Spaces:
Build error
Port changes from duplicate space to original
Browse filesSync major feature development from duplicate HuggingFace space:
- Expand core app with enhanced UI and functionality
- Merge many separate tabs into streamlined UI (consolidation in progress)
- Add exporters package (Godot, Unreal, PlayCanvas, Ren'Py, RPGMaker,
Roblox, Twine, Ink, Flutter, GameMaker, AR, HTML, fiction exporters)
- Add modular ui_tabs package (RPG scale, config analysis, DnD GM,
LLM playtest, narrative engine, story architect, story graph, 3D study)
- Add scenario templates (beat, dnd, film, mystery, song, timeline, world)
- Add game engine components (game_configs, game_state, condition_evaluator,
config_linting, json_utils)
- Add LLM playtester and demo examples
- Port related HF spaces (dnd_game_master, narrativeengine, storygenattempt)
- Add PlayCanvas integration
- Update requirements and documentation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- .gitignore +6 -0
- TECHNICAL_DEBT_PLAN_2026-01-21.md +136 -0
- TODO_MOVINGDOTSPACE.md +91 -0
- app.py +0 -0
- beat_scenarios.py +237 -0
- bible_as_inspiration_source.py +502 -0
- condition_evaluator.py +458 -0
- config_dev_assistance_reference.md +273 -0
- config_dev_assistance_variables.md +0 -0
- config_linting.py +1256 -0
- demo_llm_loop_examples.py +935 -0
- demo_mystery_complete.json +327 -0
- demo_mystery_starter.json +81 -0
- dnd_game_master_hfport/__init__.py +34 -0
- dnd_game_master_hfport/engine/__init__.py +28 -0
- dnd_game_master_hfport/engine/checks.py +170 -0
- dnd_game_master_hfport/engine/dice.py +136 -0
- dnd_game_master_hfport/engine/modifiers.py +168 -0
- dnd_game_master_hfport/example.py +149 -0
- dnd_game_master_hfport/game_master.py +462 -0
- dnd_game_master_hfport/gradio_app.py +289 -0
- dnd_game_master_hfport/hf_integration.py +395 -0
- dnd_game_master_hfport/models/__init__.py +22 -0
- dnd_game_master_hfport/models/character.py +344 -0
- dnd_game_master_hfport/models/game_state.py +288 -0
- dnd_game_master_hfport/models/scene.py +235 -0
- dnd_game_master_hfport/prompts/__init__.py +19 -0
- dnd_game_master_hfport/prompts/gm_prompts.py +288 -0
- dnd_game_master_hfport/requirements.txt +17 -0
- dnd_scenarios.py +229 -0
- docs/hf_auth_integration_notes.md +168 -0
- exporters/__init__.py +97 -0
- exporters/ar_exporters.py +843 -0
- exporters/fiction_exporters.py +140 -0
- exporters/flutter_exporter.py +124 -0
- exporters/game_platform_exporters.py +633 -0
- exporters/gamemaker_exporter.py +103 -0
- exporters/html_exporters.py +596 -0
- exporters/ink_exporter.py +86 -0
- exporters/renpy_exporter.py +112 -0
- exporters/roblox_exporter.py +192 -0
- exporters/router.py +127 -0
- exporters/rpgmaker_exporter.py +247 -0
- exporters/terminal_exporter.py +131 -0
- exporters/twine_exporter.py +127 -0
- exporters/yarn_exporter.py +108 -0
- file_explorer_and_upload.py +166 -11
- film_scenarios.py +206 -0
- game_configs.py +0 -0
- game_state.py +377 -0
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
.DS_Store
|
| 5 |
+
*.log
|
| 6 |
+
sd_output_*.png
|
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Technical Debt & Refactoring Plan
|
| 2 |
+
**Created**: 2026-01-21
|
| 3 |
+
**Status**: Pending - UX improvements in progress first
|
| 4 |
+
**Last Updated**: 2026-01-21
|
| 5 |
+
|
| 6 |
+
---
|
| 7 |
+
|
| 8 |
+
## Executive Summary
|
| 9 |
+
|
| 10 |
+
GameConfigIdeaEditBrainstorm is a sophisticated AI-powered game design platform with ~24,343 lines of Python across 79 files. While feature-rich, it has accumulated technical debt that impacts maintainability, testability, and extensibility.
|
| 11 |
+
|
| 12 |
+
**Decision**: Focus on UX improvements first to better understand real usage patterns, then return to this restructuring plan with concrete insights.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Current Architecture Overview
|
| 17 |
+
|
| 18 |
+
```
|
| 19 |
+
├── app.py (4,104 lines) # Monolithic main file - PRIMARY CONCERN
|
| 20 |
+
├── Core Engine
|
| 21 |
+
│ ├── my_text_game_engine_attempt.py
|
| 22 |
+
│ ├── game_state.py
|
| 23 |
+
│ ├── condition_evaluator.py
|
| 24 |
+
│ └── game_configs.py
|
| 25 |
+
├── AI/ML Integration
|
| 26 |
+
│ ├── leveraging_machine_learning.py
|
| 27 |
+
│ └── llm_playtester.py
|
| 28 |
+
├── UI Tabs (ui_tabs/) # Already modularized - GOOD
|
| 29 |
+
├── Exporters (exporters/) # 14 platforms - GOOD separation
|
| 30 |
+
├── External Ports
|
| 31 |
+
│ ├── narrativeengine_hfport/
|
| 32 |
+
│ ├── storygenattempt_hfport/
|
| 33 |
+
│ └── dnd_game_master_hfport/
|
| 34 |
+
└── Scenario Templates (*_scenarios.py)
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
---
|
| 38 |
+
|
| 39 |
+
## Issues by Priority
|
| 40 |
+
|
| 41 |
+
### P0 - Critical (Blocks scaling)
|
| 42 |
+
|
| 43 |
+
| Issue | Impact | Effort | Notes |
|
| 44 |
+
|-------|--------|--------|-------|
|
| 45 |
+
| Monolithic `app.py` (4,104 lines) | Hard to maintain, test, or onboard contributors | High | Break into feature modules |
|
| 46 |
+
| No automated tests | Can't refactor safely | Medium | Add pytest suite |
|
| 47 |
+
| Tight UI-engine coupling | Can't unit test game logic | High | Extract pure engine layer |
|
| 48 |
+
|
| 49 |
+
### P1 - High (Impacts development velocity)
|
| 50 |
+
|
| 51 |
+
| Issue | Impact | Effort | Notes |
|
| 52 |
+
|-------|--------|--------|-------|
|
| 53 |
+
| Mixed state management (Player + GameState) | Confusing, potential bugs | Medium | Complete migration to GameState |
|
| 54 |
+
| 60+ hardcoded LLM list | Hard to maintain/extend | Low | Create ModelRegistry class |
|
| 55 |
+
| Sparse error handling | Silent failures confuse users | Medium | Add structured logging |
|
| 56 |
+
| Lambda consequences + declarative effects coexisting | Inconsistent, harder to validate | Medium | Migrate all to declarative |
|
| 57 |
+
|
| 58 |
+
### P2 - Medium (Quality of life)
|
| 59 |
+
|
| 60 |
+
| Issue | Impact | Effort | Notes |
|
| 61 |
+
|-------|--------|--------|-------|
|
| 62 |
+
| Code duplication in exporters | Maintenance burden | Medium | Extract base exporter class |
|
| 63 |
+
| Missing type hints | IDE support, bugs | Low | Add progressively |
|
| 64 |
+
| Inconsistent naming | Cognitive load | Low | Establish conventions |
|
| 65 |
+
| Magic strings/numbers | Bugs, hard to refactor | Low | Create enums/constants |
|
| 66 |
+
|
| 67 |
+
### P3 - Low (Nice to have)
|
| 68 |
+
|
| 69 |
+
| Issue | Impact | Effort | Notes |
|
| 70 |
+
|-------|--------|--------|-------|
|
| 71 |
+
| Exporter quality variance | Some exports may fail | Medium | Add capability metadata |
|
| 72 |
+
| No caching for LLM inferences | Repeated work | Medium | Add caching layer |
|
| 73 |
+
| Sparse docstrings | Onboarding difficulty | Low | Document as we go |
|
| 74 |
+
|
| 75 |
+
---
|
| 76 |
+
|
| 77 |
+
## Proposed Refactoring Phases
|
| 78 |
+
|
| 79 |
+
### Phase 1: Foundation (After UX work)
|
| 80 |
+
- [ ] Extract `app.py` into logical modules:
|
| 81 |
+
- `app_core.py` - Gradio app setup, shared state
|
| 82 |
+
- `app_generation.py` - Content generation handlers
|
| 83 |
+
- `app_playtest.py` - Playtest/preview handlers
|
| 84 |
+
- `app_export.py` - Export handlers
|
| 85 |
+
- `app_media.py` - Media generation handlers
|
| 86 |
+
- [ ] Add basic pytest infrastructure
|
| 87 |
+
- [ ] Create constants/enums for magic strings
|
| 88 |
+
|
| 89 |
+
### Phase 2: Engine Isolation
|
| 90 |
+
- [ ] Extract pure game engine (no Gradio dependencies)
|
| 91 |
+
- [ ] Complete Player → GameState migration
|
| 92 |
+
- [ ] Migrate lambda consequences to declarative effects
|
| 93 |
+
- [ ] Add engine unit tests
|
| 94 |
+
|
| 95 |
+
### Phase 3: ML Infrastructure
|
| 96 |
+
- [ ] Create ModelRegistry class with metadata
|
| 97 |
+
- [ ] Add structured error handling + logging
|
| 98 |
+
- [ ] Implement inference caching
|
| 99 |
+
|
| 100 |
+
### Phase 4: Polish
|
| 101 |
+
- [ ] Extract shared exporter base class
|
| 102 |
+
- [ ] Add type hints throughout
|
| 103 |
+
- [ ] Comprehensive documentation pass
|
| 104 |
+
- [ ] Add integration tests
|
| 105 |
+
|
| 106 |
+
---
|
| 107 |
+
|
| 108 |
+
## Metrics to Track
|
| 109 |
+
|
| 110 |
+
- Lines in `app.py` (target: <500)
|
| 111 |
+
- Test coverage % (target: >60%)
|
| 112 |
+
- Average function length (target: <50 lines)
|
| 113 |
+
- Number of untyped functions (target: 0)
|
| 114 |
+
|
| 115 |
+
---
|
| 116 |
+
|
| 117 |
+
## UX Insights to Gather First
|
| 118 |
+
|
| 119 |
+
Before restructuring, document insights from UX work:
|
| 120 |
+
|
| 121 |
+
- [ ] Which tabs/features are actually used most?
|
| 122 |
+
- [ ] What are common user workflows?
|
| 123 |
+
- [ ] Where do users get confused or stuck?
|
| 124 |
+
- [ ] Which exporters are production-quality vs experimental?
|
| 125 |
+
- [ ] What error messages do users encounter?
|
| 126 |
+
|
| 127 |
+
These insights will inform which modules to prioritize and how to structure the codebase for real usage patterns.
|
| 128 |
+
|
| 129 |
+
---
|
| 130 |
+
|
| 131 |
+
## Notes / Updates
|
| 132 |
+
|
| 133 |
+
*Add notes here as UX work progresses*
|
| 134 |
+
|
| 135 |
+
- 2026-01-21: Plan created. Starting UX improvements first.
|
| 136 |
+
|
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# MovingDotSpace Export - Known Issues
|
| 2 |
+
|
| 3 |
+
## Status: Deferred for later fix
|
| 4 |
+
|
| 5 |
+
Last updated: 2025-01-11
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## Issues to Fix
|
| 10 |
+
|
| 11 |
+
### 1. Stats Panel Not Updating
|
| 12 |
+
- **Money stat** doesn't update in the player stats display
|
| 13 |
+
- **Inventory section** remains empty despite items being in config
|
| 14 |
+
- **Skills section** not populating
|
| 15 |
+
|
| 16 |
+
**Debug info:** Console should log `Stats loaded: {...}` with counts. Check if:
|
| 17 |
+
- Data is in the exported config (`exportedConfig.themes['Exported Game']`)
|
| 18 |
+
- `gameState.inventory`, `gameState.skills`, `gameState.playerStats.money` are set
|
| 19 |
+
- `renderStats()` is being called and executing
|
| 20 |
+
|
| 21 |
+
**Possible causes:**
|
| 22 |
+
- Timing issue with `loadTheme()` overwriting values after we set them
|
| 23 |
+
- `renderStats()` function not finding the DOM elements
|
| 24 |
+
- Data format mismatch between config and what `renderStats()` expects
|
| 25 |
+
|
| 26 |
+
### 2. Format Verification Needed
|
| 27 |
+
Check that extracted data matches MovingDotSpace expected format:
|
| 28 |
+
|
| 29 |
+
**Inventory items:**
|
| 30 |
+
```javascript
|
| 31 |
+
{ type: "weapon", name: "Iron Sword", description: "A sturdy blade" }
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
**Skills:**
|
| 35 |
+
```javascript
|
| 36 |
+
{ branch: "Combat", name: "One-Handed", learned: false }
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
**Objectives:**
|
| 40 |
+
```javascript
|
| 41 |
+
{ id: "quest_id", name: "Quest Name", complete: false }
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
---
|
| 45 |
+
|
| 46 |
+
## What Works
|
| 47 |
+
|
| 48 |
+
- Autotravel toggle (checkbox in top-right)
|
| 49 |
+
- Location locking/unlocking system
|
| 50 |
+
- Cross-location transitions (only when location actually changes)
|
| 51 |
+
- Modal state machine navigation within locations
|
| 52 |
+
- Special locations filtered out (game_start, inventory, quest_journal)
|
| 53 |
+
|
| 54 |
+
---
|
| 55 |
+
|
| 56 |
+
## Files Involved
|
| 57 |
+
|
| 58 |
+
- `exporters/html_exporters.py` - Main MovingDotSpace exporter
|
| 59 |
+
- `ui_tabs/big_rpg_scale_tab.py` - Adds `_movingdotspace_meta` with items/skills
|
| 60 |
+
- `MovingDotSpaceExport/MovingDotSpaceStandalone.html` - Template with `renderStats()`
|
| 61 |
+
|
| 62 |
+
---
|
| 63 |
+
|
| 64 |
+
## Testing Steps
|
| 65 |
+
|
| 66 |
+
1. Load Big RPG Scale demo data
|
| 67 |
+
2. Export to Game Config
|
| 68 |
+
3. Use Platform Export > MovingDotSpace
|
| 69 |
+
4. Open browser DevTools (F12) > Console
|
| 70 |
+
5. Check for `Stats loaded: {...}` log
|
| 71 |
+
6. Inspect `gameState.inventory`, `gameState.skills`, `gameState.playerStats`
|
| 72 |
+
|
| 73 |
+
---
|
| 74 |
+
|
| 75 |
+
## Potential Fixes to Try
|
| 76 |
+
|
| 77 |
+
1. **Move stats loading after a delay:**
|
| 78 |
+
```javascript
|
| 79 |
+
setTimeout(() => {
|
| 80 |
+
gameState.inventory = themeConfig.inventory;
|
| 81 |
+
renderStats();
|
| 82 |
+
}, 500);
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
2. **Check if loadTheme overwrites:**
|
| 86 |
+
- In MovingDotSpaceStandalone.html, `loadTheme()` sets `gameState.inventory = theme.inventory || []`
|
| 87 |
+
- Our explicit setting happens after, so should work
|
| 88 |
+
|
| 89 |
+
3. **Verify DOM elements exist:**
|
| 90 |
+
- `elements.inventoryList` must exist before `renderStats()` runs
|
| 91 |
+
- Check for null element references
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Story beat data and sequence generation.
|
| 2 |
+
|
| 3 |
+
This module provides:
|
| 4 |
+
- STORY_BEATS: Dictionary of story beats organized by format and genre
|
| 5 |
+
- generate_beat_sequence: Function to generate story sequences based on format/genre
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import random
|
| 10 |
+
|
| 11 |
+
# Story beats organized by narrative function
|
| 12 |
+
STORY_BEATS = {
|
| 13 |
+
"setup": [
|
| 14 |
+
("Ordinary World", "Establish the protagonist's normal life before the adventure"),
|
| 15 |
+
("Meet the Hero", "Introduction to protagonist with defining character moment"),
|
| 16 |
+
("Establish Stakes", "Show what the protagonist stands to lose"),
|
| 17 |
+
("The Want", "Protagonist expresses a desire or goal"),
|
| 18 |
+
("The Flaw", "Hint at protagonist's weakness that must be overcome"),
|
| 19 |
+
("Supporting Cast", "Introduce key allies and their relationships"),
|
| 20 |
+
],
|
| 21 |
+
"catalyst": [
|
| 22 |
+
("Call to Adventure", "External event disrupts the ordinary world"),
|
| 23 |
+
("Inciting Incident", "Something happens that demands a response"),
|
| 24 |
+
("The Invitation", "Opportunity presents itself to the protagonist"),
|
| 25 |
+
("The Challenge", "Direct challenge forces protagonist to act"),
|
| 26 |
+
("Bad News", "Devastating information changes everything"),
|
| 27 |
+
("The Stranger", "New character brings change or information"),
|
| 28 |
+
],
|
| 29 |
+
"debate": [
|
| 30 |
+
("Refusal of Call", "Protagonist hesitates or refuses the challenge"),
|
| 31 |
+
("The Doubt", "Protagonist questions their ability"),
|
| 32 |
+
("Seeking Advice", "Protagonist consults mentor or wise figure"),
|
| 33 |
+
("Weighing Options", "Protagonist considers paths forward"),
|
| 34 |
+
("The Warning", "Someone cautions against the journey"),
|
| 35 |
+
("Internal Conflict", "Protagonist struggles with competing desires"),
|
| 36 |
+
],
|
| 37 |
+
"rising_action": [
|
| 38 |
+
("Crossing Threshold", "Protagonist commits to the journey"),
|
| 39 |
+
("New World Rules", "Protagonist learns how this new world works"),
|
| 40 |
+
("Tests and Allies", "Series of challenges, gaining companions"),
|
| 41 |
+
("Fun and Games", "Promise of the premise fulfilled"),
|
| 42 |
+
("Skill Building", "Protagonist develops abilities needed for climax"),
|
| 43 |
+
("B-Story Develops", "Secondary plot line advances"),
|
| 44 |
+
("Approaching Cave", "Preparing for the major challenge"),
|
| 45 |
+
],
|
| 46 |
+
"midpoint": [
|
| 47 |
+
("False Victory", "Apparent success that will prove hollow"),
|
| 48 |
+
("False Defeat", "Apparent failure that motivates comeback"),
|
| 49 |
+
("Major Revelation", "Game-changing information revealed"),
|
| 50 |
+
("Point of No Return", "Commitment that cannot be undone"),
|
| 51 |
+
("Raised Stakes", "The cost of failure increases dramatically"),
|
| 52 |
+
("New Goal", "Original objective shifts to something bigger"),
|
| 53 |
+
],
|
| 54 |
+
"complications": [
|
| 55 |
+
("Bad Guys Close In", "Opposition intensifies, allies scatter"),
|
| 56 |
+
("Betrayal", "Trusted ally reveals true colors"),
|
| 57 |
+
("All Is Lost", "Lowest point - everything seems hopeless"),
|
| 58 |
+
("Death Moment", "Literal or symbolic death experience"),
|
| 59 |
+
("Dark Night", "Protagonist faces their deepest fears"),
|
| 60 |
+
("Whiff of Death", "Mortality or failure becomes very real"),
|
| 61 |
+
],
|
| 62 |
+
"climax": [
|
| 63 |
+
("Gathering the Team", "Final assembly before the battle"),
|
| 64 |
+
("The Plan", "Strategy for the final confrontation revealed"),
|
| 65 |
+
("Storming the Castle", "Direct assault on the antagonist"),
|
| 66 |
+
("Final Battle", "Climactic confrontation begins"),
|
| 67 |
+
("High Tower Surprise", "Unexpected complication in the climax"),
|
| 68 |
+
("The Dig Deep", "Protagonist must use everything they've learned"),
|
| 69 |
+
("Victory/Defeat", "The outcome of the main conflict"),
|
| 70 |
+
],
|
| 71 |
+
"resolution": [
|
| 72 |
+
("New Equilibrium", "World rebalanced after the adventure"),
|
| 73 |
+
("Character Changed", "Demonstrate protagonist's transformation"),
|
| 74 |
+
("Reward", "Protagonist receives what they've earned"),
|
| 75 |
+
("Return Home", "Protagonist returns to ordinary world, changed"),
|
| 76 |
+
("Open Loop", "Hint at future adventures"),
|
| 77 |
+
("Final Image", "Mirror of opening that shows change"),
|
| 78 |
+
],
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
# Genre-specific flavor modifiers
|
| 82 |
+
GENRE_FLAVORS = {
|
| 83 |
+
"action": {
|
| 84 |
+
"prefix": "PULSE-POUNDING: ",
|
| 85 |
+
"descriptors": ["explosive", "high-octane", "adrenaline-fueled", "relentless"],
|
| 86 |
+
},
|
| 87 |
+
"drama": {
|
| 88 |
+
"prefix": "EMOTIONALLY CHARGED: ",
|
| 89 |
+
"descriptors": ["poignant", "raw", "intimate", "devastating"],
|
| 90 |
+
},
|
| 91 |
+
"comedy": {
|
| 92 |
+
"prefix": "HILARIOUS: ",
|
| 93 |
+
"descriptors": ["absurd", "witty", "chaotic", "perfectly timed"],
|
| 94 |
+
},
|
| 95 |
+
"thriller": {
|
| 96 |
+
"prefix": "HEART-STOPPING: ",
|
| 97 |
+
"descriptors": ["tense", "paranoid", "claustrophobic", "nerve-wracking"],
|
| 98 |
+
},
|
| 99 |
+
"romance": {
|
| 100 |
+
"prefix": "SWOON-WORTHY: ",
|
| 101 |
+
"descriptors": ["tender", "passionate", "yearning", "chemistry-filled"],
|
| 102 |
+
},
|
| 103 |
+
"scifi": {
|
| 104 |
+
"prefix": "MIND-BENDING: ",
|
| 105 |
+
"descriptors": ["futuristic", "technological", "alien", "conceptual"],
|
| 106 |
+
},
|
| 107 |
+
"fantasy": {
|
| 108 |
+
"prefix": "EPIC: ",
|
| 109 |
+
"descriptors": ["mystical", "legendary", "enchanted", "otherworldly"],
|
| 110 |
+
},
|
| 111 |
+
"horror": {
|
| 112 |
+
"prefix": "TERRIFYING: ",
|
| 113 |
+
"descriptors": ["dread-filled", "nightmarish", "unsettling", "visceral"],
|
| 114 |
+
},
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
# Format-specific beat counts (simplified for game flow)
|
| 118 |
+
FORMAT_BEATS = {
|
| 119 |
+
"film_90min": {
|
| 120 |
+
"name": "90-Minute Feature Film",
|
| 121 |
+
"structure": ["setup", "catalyst", "debate", "rising_action", "midpoint",
|
| 122 |
+
"complications", "climax", "resolution"],
|
| 123 |
+
"typical_count": 8,
|
| 124 |
+
},
|
| 125 |
+
"tv_30min": {
|
| 126 |
+
"name": "30-Minute TV Episode",
|
| 127 |
+
"structure": ["setup", "catalyst", "rising_action", "midpoint",
|
| 128 |
+
"complications", "climax", "resolution"],
|
| 129 |
+
"typical_count": 6,
|
| 130 |
+
},
|
| 131 |
+
"youtube_9min": {
|
| 132 |
+
"name": "9-Minute YouTube Video",
|
| 133 |
+
"structure": ["setup", "catalyst", "rising_action", "climax", "resolution"],
|
| 134 |
+
"typical_count": 5,
|
| 135 |
+
},
|
| 136 |
+
"short_3min": {
|
| 137 |
+
"name": "3-Minute Short",
|
| 138 |
+
"structure": ["setup", "catalyst", "climax", "resolution"],
|
| 139 |
+
"typical_count": 4,
|
| 140 |
+
},
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def generate_beat_sequence(format_type, genre, beat_count):
|
| 145 |
+
"""Generate a story beat sequence based on format and genre.
|
| 146 |
+
|
| 147 |
+
Args:
|
| 148 |
+
format_type: One of 'film_90min', 'tv_30min', 'youtube_9min', 'short_3min'
|
| 149 |
+
genre: One of 'action', 'drama', 'comedy', 'thriller', 'romance', 'scifi', 'fantasy', 'horror'
|
| 150 |
+
beat_count: Number of beats to generate (3-15)
|
| 151 |
+
|
| 152 |
+
Returns:
|
| 153 |
+
Tuple of (list_output, json_output, prompts_output)
|
| 154 |
+
"""
|
| 155 |
+
format_info = FORMAT_BEATS.get(format_type, FORMAT_BEATS["film_90min"])
|
| 156 |
+
genre_info = GENRE_FLAVORS.get(genre, GENRE_FLAVORS["drama"])
|
| 157 |
+
|
| 158 |
+
structure = format_info["structure"]
|
| 159 |
+
|
| 160 |
+
# Build sequence following structure
|
| 161 |
+
sequence = []
|
| 162 |
+
beats_per_section = max(1, beat_count // len(structure))
|
| 163 |
+
remaining = beat_count - (beats_per_section * len(structure))
|
| 164 |
+
|
| 165 |
+
for section in structure:
|
| 166 |
+
section_beats = STORY_BEATS.get(section, STORY_BEATS["setup"])
|
| 167 |
+
# Pick random beats from this section
|
| 168 |
+
count_for_section = beats_per_section + (1 if remaining > 0 else 0)
|
| 169 |
+
if remaining > 0:
|
| 170 |
+
remaining -= 1
|
| 171 |
+
|
| 172 |
+
available = list(section_beats)
|
| 173 |
+
random.shuffle(available)
|
| 174 |
+
for beat in available[:count_for_section]:
|
| 175 |
+
descriptor = random.choice(genre_info["descriptors"])
|
| 176 |
+
sequence.append((beat[0], beat[1], section, descriptor))
|
| 177 |
+
|
| 178 |
+
# Trim or pad to exact count
|
| 179 |
+
if len(sequence) > beat_count:
|
| 180 |
+
sequence = sequence[:beat_count]
|
| 181 |
+
|
| 182 |
+
if not sequence:
|
| 183 |
+
return "Select a valid format and genre!", "{}", ""
|
| 184 |
+
|
| 185 |
+
# Format as list
|
| 186 |
+
list_output = f"## {format_info['name']} - {genre.title()} Genre\n\n"
|
| 187 |
+
for i, (name, desc, section, descriptor) in enumerate(sequence, 1):
|
| 188 |
+
list_output += f"**{i}. {name}** [{section}]\n{descriptor.title()}: {desc}\n\n"
|
| 189 |
+
|
| 190 |
+
# Format as config JSON
|
| 191 |
+
config = {"story_location": {}}
|
| 192 |
+
for i, (name, desc, section, descriptor) in enumerate(sequence, 1):
|
| 193 |
+
state_name = name.lower().replace(" ", "_").replace("/", "_").replace("-", "_")
|
| 194 |
+
current_state_id = f"beat_{i}_{state_name}"
|
| 195 |
+
|
| 196 |
+
# Determine next state
|
| 197 |
+
if i < len(sequence):
|
| 198 |
+
next_name = sequence[i][0]
|
| 199 |
+
next_state_name = next_name.lower().replace(" ", "_").replace("/", "_").replace("-", "_")
|
| 200 |
+
next_state_id = f"beat_{i+1}_{next_state_name}"
|
| 201 |
+
else:
|
| 202 |
+
next_state_id = "story_end"
|
| 203 |
+
|
| 204 |
+
config["story_location"][current_state_id] = {
|
| 205 |
+
"description": f"[{section.upper()}] {genre_info['prefix']}{desc}",
|
| 206 |
+
"media_prompt": f"{genre.title()} {format_info['name']} scene: {name} - {descriptor} {desc}",
|
| 207 |
+
"choices": ["Continue"],
|
| 208 |
+
"transitions": {
|
| 209 |
+
"Continue": next_state_id
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
# Add ending state
|
| 214 |
+
first_beat = sequence[0][0].lower().replace(" ", "_").replace("/", "_").replace("-", "_")
|
| 215 |
+
config["story_location"]["story_end"] = {
|
| 216 |
+
"description": f"The {genre} story concludes. The journey has changed everything.",
|
| 217 |
+
"choices": ["Experience Again"],
|
| 218 |
+
"transitions": {
|
| 219 |
+
"Experience Again": f"beat_1_{first_beat}"
|
| 220 |
+
}
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
json_output = json.dumps(config, indent=2)
|
| 224 |
+
|
| 225 |
+
# Format prompts
|
| 226 |
+
prompts_output = f"## {genre.title()} Story Prompts\n\n"
|
| 227 |
+
for i, (name, desc, section, descriptor) in enumerate(sequence, 1):
|
| 228 |
+
prompts_output += f"{genre.title()} {format_info['name']} scene: {name} - {descriptor} {desc}\n"
|
| 229 |
+
|
| 230 |
+
return list_output, json_output, prompts_output
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
# Quick test
|
| 234 |
+
if __name__ == "__main__":
|
| 235 |
+
list_out, json_out, prompts_out = generate_beat_sequence("film_90min", "thriller", 5)
|
| 236 |
+
print(list_out)
|
| 237 |
+
print(json_out)
|
|
@@ -0,0 +1,502 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
|
| 3 |
+
# =============================================================================
|
| 4 |
+
# BIBLE STORY TEMPLATES - Structured Data for Game Config Generation
|
| 5 |
+
# Priority: Locations, People, Politics/Laws/Moral Rules
|
| 6 |
+
# =============================================================================
|
| 7 |
+
|
| 8 |
+
BIBLE_TEMPLATES = {
|
| 9 |
+
"matthew": {
|
| 10 |
+
"book_name": "Gospel of Matthew",
|
| 11 |
+
"youtube_link": "https://www.youtube.com/watch?v=mgUWg3ZY_C8",
|
| 12 |
+
"chapters": {
|
| 13 |
+
"1": {
|
| 14 |
+
"title": "Jesus' Lineage",
|
| 15 |
+
"summary": "Genealogy from Abraham to Jesus through David's line",
|
| 16 |
+
"locations": [
|
| 17 |
+
{"name": "Bethlehem", "type": "city", "significance": "Birthplace of Jesus, City of David"},
|
| 18 |
+
{"name": "Judea", "type": "region", "significance": "Southern kingdom, seat of Jewish religious authority"}
|
| 19 |
+
],
|
| 20 |
+
"people": [
|
| 21 |
+
{"name": "Abraham", "role": "ancestor", "faction": "Patriarchs", "description": "Father of the Jewish nation"},
|
| 22 |
+
{"name": "David", "role": "ancestor", "faction": "Kings of Israel", "description": "Greatest king, promised eternal dynasty"},
|
| 23 |
+
{"name": "Joseph", "role": "protagonist_support", "faction": "House of David", "description": "Carpenter, legal father of Jesus"},
|
| 24 |
+
{"name": "Mary", "role": "protagonist_support", "faction": "House of David", "description": "Virgin mother of Jesus"},
|
| 25 |
+
{"name": "Jesus", "role": "protagonist", "faction": "Divine", "description": "The Messiah, Son of God"}
|
| 26 |
+
],
|
| 27 |
+
"politics_laws_morals": [
|
| 28 |
+
{"type": "law", "name": "Mosaic Law", "description": "Jewish religious and civil law governing all aspects of life"},
|
| 29 |
+
{"type": "moral", "name": "Righteousness through lineage", "description": "Importance of family line and covenant promises"},
|
| 30 |
+
{"type": "political", "name": "Davidic Covenant", "description": "God's promise of eternal kingship to David's descendants"}
|
| 31 |
+
]
|
| 32 |
+
},
|
| 33 |
+
"2": {
|
| 34 |
+
"title": "King Herod's Order",
|
| 35 |
+
"summary": "Magi visit, flight to Egypt, massacre of innocents",
|
| 36 |
+
"locations": [
|
| 37 |
+
{"name": "Bethlehem", "type": "city", "significance": "Where Jesus was born, target of Herod's massacre"},
|
| 38 |
+
{"name": "Jerusalem", "type": "capital", "significance": "Herod's seat of power, religious center"},
|
| 39 |
+
{"name": "Egypt", "type": "foreign_land", "significance": "Place of refuge, echoes of Exodus"},
|
| 40 |
+
{"name": "Nazareth", "type": "town", "significance": "Final settlement of the holy family"}
|
| 41 |
+
],
|
| 42 |
+
"people": [
|
| 43 |
+
{"name": "Herod the Great", "role": "antagonist", "faction": "Roman Client Kings", "description": "Paranoid king who orders massacre of infants"},
|
| 44 |
+
{"name": "The Magi", "role": "ally", "faction": "Eastern Wise Men", "description": "Foreign astrologers who seek the newborn king"},
|
| 45 |
+
{"name": "Joseph", "role": "protagonist_support", "faction": "House of David", "description": "Protector who flees with family to Egypt"},
|
| 46 |
+
{"name": "Mary", "role": "protagonist_support", "faction": "House of David", "description": "Mother protecting her child"},
|
| 47 |
+
{"name": "Jesus", "role": "protagonist", "faction": "Divine", "description": "Infant king, target of Herod's jealousy"}
|
| 48 |
+
],
|
| 49 |
+
"politics_laws_morals": [
|
| 50 |
+
{"type": "political", "name": "Roman Occupation", "description": "Judea under Roman rule through client kings"},
|
| 51 |
+
{"type": "political", "name": "Herodian Paranoia", "description": "Herod kills anyone threatening his throne, including family"},
|
| 52 |
+
{"type": "moral", "name": "Protection of the innocent", "description": "Joseph acts on divine warning to save his family"},
|
| 53 |
+
{"type": "moral", "name": "Sacrifice of innocents", "description": "Tragedy of children killed for one man's fear"},
|
| 54 |
+
{"type": "prophecy", "name": "Out of Egypt", "description": "Fulfillment of 'Out of Egypt I called my son'"}
|
| 55 |
+
]
|
| 56 |
+
},
|
| 57 |
+
"3": {
|
| 58 |
+
"title": "John the Baptist",
|
| 59 |
+
"summary": "John prepares the way, Jesus is baptized",
|
| 60 |
+
"locations": [
|
| 61 |
+
{"name": "Judean Wilderness", "type": "wilderness", "significance": "Place of repentance and preparation"},
|
| 62 |
+
{"name": "Jordan River", "type": "river", "significance": "Site of baptism, boundary crossing symbolism"}
|
| 63 |
+
],
|
| 64 |
+
"people": [
|
| 65 |
+
{"name": "John the Baptist", "role": "herald", "faction": "Prophets", "description": "Voice crying in wilderness, prepares way for Messiah"},
|
| 66 |
+
{"name": "Jesus", "role": "protagonist", "faction": "Divine", "description": "Receives baptism to fulfill righteousness"},
|
| 67 |
+
{"name": "Pharisees", "role": "antagonist", "faction": "Religious Elite", "description": "Religious leaders challenged by John"},
|
| 68 |
+
{"name": "Sadducees", "role": "antagonist", "faction": "Religious Elite", "description": "Temple aristocracy, political collaborators"}
|
| 69 |
+
],
|
| 70 |
+
"politics_laws_morals": [
|
| 71 |
+
{"type": "moral", "name": "Repentance", "description": "Turn from sin, change of heart and direction"},
|
| 72 |
+
{"type": "ritual", "name": "Baptism", "description": "Symbolic cleansing and public commitment"},
|
| 73 |
+
{"type": "moral", "name": "Bearing fruit", "description": "True repentance shown through changed actions"},
|
| 74 |
+
{"type": "warning", "name": "Judgment coming", "description": "Axe at root of trees, winnowing fork ready"}
|
| 75 |
+
]
|
| 76 |
+
},
|
| 77 |
+
"4": {
|
| 78 |
+
"title": "Temptation and First Disciples",
|
| 79 |
+
"summary": "Jesus tempted by Satan, begins ministry, calls first disciples",
|
| 80 |
+
"locations": [
|
| 81 |
+
{"name": "Judean Wilderness", "type": "wilderness", "significance": "40 days of fasting and temptation"},
|
| 82 |
+
{"name": "Temple Pinnacle", "type": "landmark", "significance": "Site of second temptation"},
|
| 83 |
+
{"name": "High Mountain", "type": "landmark", "significance": "Where Satan shows all kingdoms"},
|
| 84 |
+
{"name": "Galilee", "type": "region", "significance": "Jesus' ministry headquarters"},
|
| 85 |
+
{"name": "Capernaum", "type": "city", "significance": "Jesus' home base for ministry"},
|
| 86 |
+
{"name": "Sea of Galilee", "type": "body_of_water", "significance": "Where fishermen disciples are called"},
|
| 87 |
+
{"name": "Syria", "type": "region", "significance": "Fame spreads, sick brought from here"}
|
| 88 |
+
],
|
| 89 |
+
"people": [
|
| 90 |
+
{"name": "Jesus", "role": "protagonist", "faction": "Divine", "description": "Overcomes temptation, begins public ministry"},
|
| 91 |
+
{"name": "Satan", "role": "antagonist", "faction": "Demonic", "description": "The tempter, offers worldly power"},
|
| 92 |
+
{"name": "Peter (Simon)", "role": "ally", "faction": "Disciples", "description": "Fisherman, first called disciple"},
|
| 93 |
+
{"name": "Andrew", "role": "ally", "faction": "Disciples", "description": "Peter's brother, also a fisherman"},
|
| 94 |
+
{"name": "James son of Zebedee", "role": "ally", "faction": "Disciples", "description": "Fisherman, one of inner circle"},
|
| 95 |
+
{"name": "John son of Zebedee", "role": "ally", "faction": "Disciples", "description": "James' brother, beloved disciple"}
|
| 96 |
+
],
|
| 97 |
+
"politics_laws_morals": [
|
| 98 |
+
{"type": "moral", "name": "Scripture as defense", "description": "Jesus counters temptation with 'It is written'"},
|
| 99 |
+
{"type": "moral", "name": "Man shall not live by bread alone", "description": "Spiritual sustenance over physical"},
|
| 100 |
+
{"type": "moral", "name": "Do not test God", "description": "Faith doesn't demand proof through stunts"},
|
| 101 |
+
{"type": "moral", "name": "Worship God alone", "description": "Reject all other powers and kingdoms"},
|
| 102 |
+
{"type": "calling", "name": "Fishers of men", "description": "New purpose for disciples - gathering people"}
|
| 103 |
+
]
|
| 104 |
+
},
|
| 105 |
+
"5": {
|
| 106 |
+
"title": "Sermon on the Mount - Part 1",
|
| 107 |
+
"summary": "Beatitudes, salt and light, fulfilling the law",
|
| 108 |
+
"locations": [
|
| 109 |
+
{"name": "Mountain in Galilee", "type": "landmark", "significance": "Site of greatest sermon, new Sinai"}
|
| 110 |
+
],
|
| 111 |
+
"people": [
|
| 112 |
+
{"name": "Jesus", "role": "protagonist", "faction": "Divine", "description": "Teacher giving new interpretation of law"},
|
| 113 |
+
{"name": "Disciples", "role": "ally", "faction": "Disciples", "description": "Inner circle receiving teaching"},
|
| 114 |
+
{"name": "Crowds", "role": "neutral", "faction": "Common People", "description": "Masses drawn to Jesus' teaching"},
|
| 115 |
+
{"name": "Pharisees", "role": "antagonist", "faction": "Religious Elite", "description": "Referenced as example of insufficient righteousness"}
|
| 116 |
+
],
|
| 117 |
+
"politics_laws_morals": [
|
| 118 |
+
{"type": "moral", "name": "Beatitudes", "description": "Blessed are the poor in spirit, mourning, meek, hungry for righteousness, merciful, pure, peacemakers, persecuted"},
|
| 119 |
+
{"type": "moral", "name": "Salt and Light", "description": "Believers must preserve and illuminate the world"},
|
| 120 |
+
{"type": "law", "name": "Law fulfilled not abolished", "description": "Jesus completes the law's true meaning"},
|
| 121 |
+
{"type": "moral", "name": "Righteousness exceeding Pharisees", "description": "External compliance insufficient, heart matters"},
|
| 122 |
+
{"type": "moral", "name": "Anger equals murder", "description": "Heart attitude as serious as the act"},
|
| 123 |
+
{"type": "moral", "name": "Lust equals adultery", "description": "Internal sin as serious as external"},
|
| 124 |
+
{"type": "moral", "name": "Cut off what causes sin", "description": "Radical measures against temptation"},
|
| 125 |
+
{"type": "law", "name": "Divorce only for unfaithfulness", "description": "Stricter standard than Moses allowed"},
|
| 126 |
+
{"type": "moral", "name": "Let yes be yes", "description": "No oaths needed for honest people"},
|
| 127 |
+
{"type": "moral", "name": "Turn the other cheek", "description": "Non-retaliation, absorb evil"},
|
| 128 |
+
{"type": "moral", "name": "Love your enemies", "description": "Radical love extending to persecutors"}
|
| 129 |
+
]
|
| 130 |
+
},
|
| 131 |
+
"6": {
|
| 132 |
+
"title": "Sermon on the Mount - Part 2",
|
| 133 |
+
"summary": "Secret righteousness, Lord's Prayer, treasures in heaven",
|
| 134 |
+
"locations": [
|
| 135 |
+
{"name": "Mountain in Galilee", "type": "landmark", "significance": "Continuation of sermon"}
|
| 136 |
+
],
|
| 137 |
+
"people": [
|
| 138 |
+
{"name": "Jesus", "role": "protagonist", "faction": "Divine", "description": "Teaching on authentic spirituality"},
|
| 139 |
+
{"name": "Disciples", "role": "ally", "faction": "Disciples", "description": "Learning to pray and trust"},
|
| 140 |
+
{"name": "Hypocrites", "role": "antagonist", "faction": "Religious Elite", "description": "Those who practice piety for show"}
|
| 141 |
+
],
|
| 142 |
+
"politics_laws_morals": [
|
| 143 |
+
{"type": "moral", "name": "Secret giving", "description": "Give to needy without announcement or recognition"},
|
| 144 |
+
{"type": "moral", "name": "Secret prayer", "description": "Pray in private, not for public display"},
|
| 145 |
+
{"type": "prayer", "name": "Lord's Prayer", "description": "Model prayer: hallowed name, kingdom come, daily bread, forgiveness, deliverance"},
|
| 146 |
+
{"type": "moral", "name": "Forgive to be forgiven", "description": "Receiving forgiveness linked to giving it"},
|
| 147 |
+
{"type": "moral", "name": "Secret fasting", "description": "Fast without showing it to others"},
|
| 148 |
+
{"type": "moral", "name": "Treasures in heaven", "description": "Store up eternal rewards, not earthly wealth"},
|
| 149 |
+
{"type": "moral", "name": "Eye as lamp", "description": "What you focus on determines your inner state"},
|
| 150 |
+
{"type": "moral", "name": "Cannot serve God and money", "description": "Divided loyalty impossible"},
|
| 151 |
+
{"type": "moral", "name": "Do not worry", "description": "Trust God for provision like birds and flowers"},
|
| 152 |
+
{"type": "moral", "name": "Seek first the kingdom", "description": "Priority of God's kingdom, other needs follow"}
|
| 153 |
+
]
|
| 154 |
+
},
|
| 155 |
+
"7": {
|
| 156 |
+
"title": "Sermon on the Mount - Part 3",
|
| 157 |
+
"summary": "Judging others, ask-seek-knock, narrow gate, wise builder",
|
| 158 |
+
"locations": [
|
| 159 |
+
{"name": "Mountain in Galilee", "type": "landmark", "significance": "Conclusion of sermon"}
|
| 160 |
+
],
|
| 161 |
+
"people": [
|
| 162 |
+
{"name": "Jesus", "role": "protagonist", "faction": "Divine", "description": "Concluding with warnings and wisdom"},
|
| 163 |
+
{"name": "Disciples", "role": "ally", "faction": "Disciples", "description": "Called to build on solid foundation"},
|
| 164 |
+
{"name": "False prophets", "role": "antagonist", "faction": "Deceivers", "description": "Wolves in sheep's clothing"},
|
| 165 |
+
{"name": "Crowds", "role": "neutral", "faction": "Common People", "description": "Amazed at teaching with authority"}
|
| 166 |
+
],
|
| 167 |
+
"politics_laws_morals": [
|
| 168 |
+
{"type": "moral", "name": "Do not judge hypocritically", "description": "Remove own plank before addressing others' speck"},
|
| 169 |
+
{"type": "moral", "name": "Pearls before swine", "description": "Discernment about sharing sacred things"},
|
| 170 |
+
{"type": "moral", "name": "Ask, seek, knock", "description": "Persistent prayer receives answer"},
|
| 171 |
+
{"type": "moral", "name": "Golden Rule", "description": "Do to others what you want done to you"},
|
| 172 |
+
{"type": "moral", "name": "Narrow gate", "description": "Few find the difficult path to life"},
|
| 173 |
+
{"type": "warning", "name": "False prophets known by fruit", "description": "Actions reveal true nature"},
|
| 174 |
+
{"type": "warning", "name": "Not everyone saying Lord Lord", "description": "Relationship over religious activity"},
|
| 175 |
+
{"type": "moral", "name": "Wise and foolish builders", "description": "Hearing and doing vs hearing only"}
|
| 176 |
+
]
|
| 177 |
+
},
|
| 178 |
+
"8": {
|
| 179 |
+
"title": "Healings and Authority",
|
| 180 |
+
"summary": "Jesus heals many, calms storm, casts out demons",
|
| 181 |
+
"locations": [
|
| 182 |
+
{"name": "Capernaum", "type": "city", "significance": "Base of ministry, centurion's servant healed"},
|
| 183 |
+
{"name": "Peter's House", "type": "building", "significance": "Peter's mother-in-law healed"},
|
| 184 |
+
{"name": "Sea of Galilee", "type": "body_of_water", "significance": "Storm calmed, disciples' faith tested"},
|
| 185 |
+
{"name": "Region of Gadarenes", "type": "region", "significance": "Two demon-possessed men healed, pigs drowned"}
|
| 186 |
+
],
|
| 187 |
+
"people": [
|
| 188 |
+
{"name": "Jesus", "role": "protagonist", "faction": "Divine", "description": "Demonstrates authority over sickness, nature, demons"},
|
| 189 |
+
{"name": "Man with leprosy", "role": "supplicant", "faction": "Outcast", "description": "Cleansed by Jesus' touch"},
|
| 190 |
+
{"name": "Roman Centurion", "role": "ally", "faction": "Roman Military", "description": "Gentile with great faith for servant's healing"},
|
| 191 |
+
{"name": "Peter's mother-in-law", "role": "recipient", "faction": "Disciples' Family", "description": "Healed of fever, immediately serves"},
|
| 192 |
+
{"name": "Disciples", "role": "ally", "faction": "Disciples", "description": "Fearful in storm, amazed at Jesus' power"},
|
| 193 |
+
{"name": "Two demon-possessed men", "role": "recipient", "faction": "Outcast", "description": "Violent men freed from Legion of demons"},
|
| 194 |
+
{"name": "Abraham, Isaac, Jacob", "role": "reference", "faction": "Patriarchs", "description": "Gentiles will feast with them in kingdom"}
|
| 195 |
+
],
|
| 196 |
+
"politics_laws_morals": [
|
| 197 |
+
{"type": "law", "name": "Show yourself to priest", "description": "Jesus respects Mosaic law for cleansing verification"},
|
| 198 |
+
{"type": "moral", "name": "Faith over nationality", "description": "Centurion's faith exceeds Israel's"},
|
| 199 |
+
{"type": "warning", "name": "Sons of kingdom thrown out", "description": "Jewish privilege not automatic salvation"},
|
| 200 |
+
{"type": "cost", "name": "Foxes have holes", "description": "Following Jesus means homelessness"},
|
| 201 |
+
{"type": "cost", "name": "Let dead bury dead", "description": "Following Jesus takes absolute priority"},
|
| 202 |
+
{"type": "authority", "name": "Even winds and waves obey", "description": "Jesus has divine authority over creation"},
|
| 203 |
+
{"type": "spiritual", "name": "Demons recognize Jesus", "description": "Evil spirits know who Jesus is"}
|
| 204 |
+
]
|
| 205 |
+
},
|
| 206 |
+
"9": {
|
| 207 |
+
"title": "More Healings and Calling of Matthew",
|
| 208 |
+
"summary": "Paralytic forgiven and healed, Matthew called, various healings",
|
| 209 |
+
"locations": [
|
| 210 |
+
{"name": "Capernaum", "type": "city", "significance": "Jesus' own town, paralytic healed"},
|
| 211 |
+
{"name": "Tax collector's booth", "type": "workplace", "significance": "Matthew's place of work"},
|
| 212 |
+
{"name": "Matthew's house", "type": "building", "significance": "Dinner with tax collectors and sinners"},
|
| 213 |
+
{"name": "Ruler's house", "type": "building", "significance": "Where dead girl is raised"},
|
| 214 |
+
{"name": "Towns and villages", "type": "region", "significance": "Jesus' teaching circuit"}
|
| 215 |
+
],
|
| 216 |
+
"people": [
|
| 217 |
+
{"name": "Jesus", "role": "protagonist", "faction": "Divine", "description": "Claims authority to forgive sins"},
|
| 218 |
+
{"name": "Paralytic man", "role": "recipient", "faction": "Common People", "description": "Lowered through roof, sins forgiven, then healed"},
|
| 219 |
+
{"name": "Matthew/Levi", "role": "ally", "faction": "Tax Collectors", "description": "Tax collector who becomes disciple and gospel writer"},
|
| 220 |
+
{"name": "Pharisees", "role": "antagonist", "faction": "Religious Elite", "description": "Question Jesus eating with sinners"},
|
| 221 |
+
{"name": "John's disciples", "role": "questioner", "faction": "John the Baptist", "description": "Ask about fasting practices"},
|
| 222 |
+
{"name": "Synagogue ruler", "role": "supplicant", "faction": "Religious Leaders", "description": "Daughter dies, raised by Jesus"},
|
| 223 |
+
{"name": "Woman with bleeding", "role": "recipient", "faction": "Outcast", "description": "12 years suffering, healed by touching cloak"},
|
| 224 |
+
{"name": "Two blind men", "role": "recipient", "faction": "Disabled", "description": "Healed according to their faith"},
|
| 225 |
+
{"name": "Mute demon-possessed man", "role": "recipient", "faction": "Possessed", "description": "Speaks after demon driven out"}
|
| 226 |
+
],
|
| 227 |
+
"politics_laws_morals": [
|
| 228 |
+
{"type": "authority", "name": "Son of Man forgives sins", "description": "Jesus claims divine prerogative"},
|
| 229 |
+
{"type": "moral", "name": "Healthy don't need doctor", "description": "Jesus comes for sinners, not self-righteous"},
|
| 230 |
+
{"type": "moral", "name": "Mercy not sacrifice", "description": "God desires compassion over ritual"},
|
| 231 |
+
{"type": "teaching", "name": "New wine in new wineskins", "description": "Jesus brings new covenant, not patching old"},
|
| 232 |
+
{"type": "faith", "name": "Your faith has healed you", "description": "Faith as conduit for healing power"},
|
| 233 |
+
{"type": "warning", "name": "Harvest plentiful, workers few", "description": "Need for more disciples to reach people"},
|
| 234 |
+
{"type": "accusation", "name": "By prince of demons", "description": "Pharisees accuse Jesus of satanic power"}
|
| 235 |
+
]
|
| 236 |
+
},
|
| 237 |
+
"10": {
|
| 238 |
+
"title": "Sending Out the Twelve",
|
| 239 |
+
"summary": "Twelve apostles commissioned with authority and warnings",
|
| 240 |
+
"locations": [
|
| 241 |
+
{"name": "Israel", "type": "nation", "significance": "Mission field limited to lost sheep of Israel"},
|
| 242 |
+
{"name": "Towns and villages", "type": "region", "significance": "Where disciples are sent"},
|
| 243 |
+
{"name": "Sodom and Gomorrah", "type": "reference", "significance": "Judgment comparison for rejecting towns"}
|
| 244 |
+
],
|
| 245 |
+
"people": [
|
| 246 |
+
{"name": "Jesus", "role": "protagonist", "faction": "Divine", "description": "Commissions and instructs the Twelve"},
|
| 247 |
+
{"name": "Peter", "role": "ally", "faction": "Twelve Apostles", "description": "Listed first among apostles"},
|
| 248 |
+
{"name": "Andrew", "role": "ally", "faction": "Twelve Apostles", "description": "Peter's brother"},
|
| 249 |
+
{"name": "James son of Zebedee", "role": "ally", "faction": "Twelve Apostles", "description": "One of inner three"},
|
| 250 |
+
{"name": "John", "role": "ally", "faction": "Twelve Apostles", "description": "Brother of James"},
|
| 251 |
+
{"name": "Philip", "role": "ally", "faction": "Twelve Apostles", "description": "From Bethsaida"},
|
| 252 |
+
{"name": "Bartholomew", "role": "ally", "faction": "Twelve Apostles", "description": "Also called Nathanael"},
|
| 253 |
+
{"name": "Thomas", "role": "ally", "faction": "Twelve Apostles", "description": "Later known as doubter"},
|
| 254 |
+
{"name": "Matthew", "role": "ally", "faction": "Twelve Apostles", "description": "The tax collector"},
|
| 255 |
+
{"name": "James son of Alphaeus", "role": "ally", "faction": "Twelve Apostles", "description": "The lesser James"},
|
| 256 |
+
{"name": "Thaddaeus", "role": "ally", "faction": "Twelve Apostles", "description": "Also called Judas son of James"},
|
| 257 |
+
{"name": "Simon the Zealot", "role": "ally", "faction": "Twelve Apostles", "description": "Former political revolutionary"},
|
| 258 |
+
{"name": "Judas Iscariot", "role": "traitor", "faction": "Twelve Apostles", "description": "The one who would betray Jesus"}
|
| 259 |
+
],
|
| 260 |
+
"politics_laws_morals": [
|
| 261 |
+
{"type": "mission", "name": "Go to lost sheep of Israel", "description": "Initial mission limited to Jews, not Gentiles or Samaritans"},
|
| 262 |
+
{"type": "provision", "name": "Freely received, freely give", "description": "No payment for healing ministry"},
|
| 263 |
+
{"type": "provision", "name": "Worker deserves his keep", "description": "Depend on hospitality, travel light"},
|
| 264 |
+
{"type": "judgment", "name": "Shake dust off feet", "description": "Symbolic rejection of rejecting towns"},
|
| 265 |
+
{"type": "warning", "name": "Sheep among wolves", "description": "Danger awaits, be shrewd as snakes"},
|
| 266 |
+
{"type": "warning", "name": "Handed over to councils", "description": "Persecution by religious and civil authorities"},
|
| 267 |
+
{"type": "promise", "name": "Spirit will speak through you", "description": "Divine help in persecution"},
|
| 268 |
+
{"type": "warning", "name": "Family betrayal", "description": "Even family members will turn against believers"},
|
| 269 |
+
{"type": "cost", "name": "Take up cross", "description": "Following Jesus means death to self"},
|
| 270 |
+
{"type": "promise", "name": "Whoever receives you receives me", "description": "Disciples represent Jesus himself"}
|
| 271 |
+
]
|
| 272 |
+
},
|
| 273 |
+
"11": {
|
| 274 |
+
"title": "Jesus and John the Baptist",
|
| 275 |
+
"summary": "John's question from prison, Jesus' testimony about John, woes on unrepentant cities",
|
| 276 |
+
"locations": [
|
| 277 |
+
{"name": "Prison", "type": "building", "significance": "Where John is held by Herod Antipas"},
|
| 278 |
+
{"name": "Chorazin", "type": "city", "significance": "Unrepentant city despite miracles"},
|
| 279 |
+
{"name": "Bethsaida", "type": "city", "significance": "Unrepentant city despite miracles"},
|
| 280 |
+
{"name": "Capernaum", "type": "city", "significance": "Jesus' base, will be brought down to Hades"},
|
| 281 |
+
{"name": "Tyre", "type": "city", "significance": "Gentile city that would have repented"},
|
| 282 |
+
{"name": "Sidon", "type": "city", "significance": "Gentile city that would have repented"},
|
| 283 |
+
{"name": "Sodom", "type": "reference", "significance": "Would have remained if it saw Jesus' miracles"}
|
| 284 |
+
],
|
| 285 |
+
"people": [
|
| 286 |
+
{"name": "Jesus", "role": "protagonist", "faction": "Divine", "description": "Praises John, condemns unrepentant cities"},
|
| 287 |
+
{"name": "John the Baptist", "role": "ally", "faction": "Prophets", "description": "In prison, sends disciples to ask if Jesus is the one"},
|
| 288 |
+
{"name": "John's disciples", "role": "messenger", "faction": "John the Baptist", "description": "Carry question to Jesus and answer back"},
|
| 289 |
+
{"name": "Elijah", "role": "reference", "faction": "Prophets", "description": "John is Elijah who was to come"}
|
| 290 |
+
],
|
| 291 |
+
"politics_laws_morals": [
|
| 292 |
+
{"type": "proof", "name": "Deeds prove identity", "description": "Blind see, lame walk, dead raised - proof Jesus is Messiah"},
|
| 293 |
+
{"type": "honor", "name": "Greatest born of women", "description": "John's unique role in salvation history"},
|
| 294 |
+
{"type": "paradox", "name": "Least in kingdom is greater", "description": "New covenant position exceeds old"},
|
| 295 |
+
{"type": "warning", "name": "Kingdom suffers violence", "description": "Forceful opposition since John's ministry"},
|
| 296 |
+
{"type": "criticism", "name": "This generation like children", "description": "Neither John's asceticism nor Jesus' fellowship satisfied critics"},
|
| 297 |
+
{"type": "judgment", "name": "Woe to unrepentant cities", "description": "Greater light means greater accountability"},
|
| 298 |
+
{"type": "revelation", "name": "Hidden from wise, revealed to children", "description": "God's reversal of expected recipients"},
|
| 299 |
+
{"type": "invitation", "name": "Come to me, all weary", "description": "Jesus offers rest from religious burden"},
|
| 300 |
+
{"type": "promise", "name": "My yoke is easy", "description": "Jesus' way lighter than Pharisaic law"}
|
| 301 |
+
]
|
| 302 |
+
}
|
| 303 |
+
}
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
|
| 307 |
+
# =============================================================================
|
| 308 |
+
# HELPER FUNCTIONS
|
| 309 |
+
# =============================================================================
|
| 310 |
+
|
| 311 |
+
def get_all_locations(book="matthew"):
|
| 312 |
+
"""Extract all unique locations from a book"""
|
| 313 |
+
locations = []
|
| 314 |
+
if book in BIBLE_TEMPLATES:
|
| 315 |
+
for chapter_num, chapter in BIBLE_TEMPLATES[book]["chapters"].items():
|
| 316 |
+
for loc in chapter.get("locations", []):
|
| 317 |
+
if loc not in locations:
|
| 318 |
+
locations.append(loc)
|
| 319 |
+
return locations
|
| 320 |
+
|
| 321 |
+
def get_all_people(book="matthew"):
|
| 322 |
+
"""Extract all unique people from a book"""
|
| 323 |
+
people = {}
|
| 324 |
+
if book in BIBLE_TEMPLATES:
|
| 325 |
+
for chapter_num, chapter in BIBLE_TEMPLATES[book]["chapters"].items():
|
| 326 |
+
for person in chapter.get("people", []):
|
| 327 |
+
if person["name"] not in people:
|
| 328 |
+
people[person["name"]] = person
|
| 329 |
+
return list(people.values())
|
| 330 |
+
|
| 331 |
+
def get_all_politics_laws_morals(book="matthew"):
|
| 332 |
+
"""Extract all unique politics/laws/morals from a book"""
|
| 333 |
+
plm = []
|
| 334 |
+
seen = set()
|
| 335 |
+
if book in BIBLE_TEMPLATES:
|
| 336 |
+
for chapter_num, chapter in BIBLE_TEMPLATES[book]["chapters"].items():
|
| 337 |
+
for item in chapter.get("politics_laws_morals", []):
|
| 338 |
+
key = (item["type"], item["name"])
|
| 339 |
+
if key not in seen:
|
| 340 |
+
seen.add(key)
|
| 341 |
+
plm.append(item)
|
| 342 |
+
return plm
|
| 343 |
+
|
| 344 |
+
def get_chapter_data(book="matthew", chapter="1"):
|
| 345 |
+
"""Get all data for a specific chapter"""
|
| 346 |
+
if book in BIBLE_TEMPLATES and chapter in BIBLE_TEMPLATES[book]["chapters"]:
|
| 347 |
+
return BIBLE_TEMPLATES[book]["chapters"][chapter]
|
| 348 |
+
return None
|
| 349 |
+
|
| 350 |
+
def format_locations_for_display(locations):
|
| 351 |
+
"""Format locations list as markdown"""
|
| 352 |
+
if not locations:
|
| 353 |
+
return "No locations defined"
|
| 354 |
+
lines = ["| Location | Type | Significance |", "|----------|------|--------------|"]
|
| 355 |
+
for loc in locations:
|
| 356 |
+
lines.append(f"| {loc['name']} | {loc['type']} | {loc['significance']} |")
|
| 357 |
+
return "\n".join(lines)
|
| 358 |
+
|
| 359 |
+
def format_people_for_display(people):
|
| 360 |
+
"""Format people list as markdown"""
|
| 361 |
+
if not people:
|
| 362 |
+
return "No people defined"
|
| 363 |
+
lines = ["| Name | Role | Faction | Description |", "|------|------|---------|-------------|"]
|
| 364 |
+
for person in people:
|
| 365 |
+
lines.append(f"| {person['name']} | {person['role']} | {person['faction']} | {person['description']} |")
|
| 366 |
+
return "\n".join(lines)
|
| 367 |
+
|
| 368 |
+
def format_politics_for_display(plm):
|
| 369 |
+
"""Format politics/laws/morals as markdown"""
|
| 370 |
+
if not plm:
|
| 371 |
+
return "No rules defined"
|
| 372 |
+
lines = ["| Type | Name | Description |", "|------|------|-------------|"]
|
| 373 |
+
for item in plm:
|
| 374 |
+
lines.append(f"| {item['type']} | {item['name']} | {item['description']} |")
|
| 375 |
+
return "\n".join(lines)
|
| 376 |
+
|
| 377 |
+
def get_chapter_summary_md(book="matthew", chapter="1"):
|
| 378 |
+
"""Get formatted markdown summary for a chapter"""
|
| 379 |
+
data = get_chapter_data(book, chapter)
|
| 380 |
+
if not data:
|
| 381 |
+
return "Chapter not found"
|
| 382 |
+
|
| 383 |
+
md = f"## Chapter {chapter}: {data['title']}\n\n"
|
| 384 |
+
md += f"**Summary:** {data['summary']}\n\n"
|
| 385 |
+
md += "### Locations\n" + format_locations_for_display(data.get("locations", [])) + "\n\n"
|
| 386 |
+
md += "### People\n" + format_people_for_display(data.get("people", [])) + "\n\n"
|
| 387 |
+
md += "### Politics, Laws & Moral Rules\n" + format_politics_for_display(data.get("politics_laws_morals", []))
|
| 388 |
+
return md
|
| 389 |
+
|
| 390 |
+
def generate_game_elements_from_chapter(book="matthew", chapter="1"):
|
| 391 |
+
"""Generate game config elements from a chapter"""
|
| 392 |
+
data = get_chapter_data(book, chapter)
|
| 393 |
+
if not data:
|
| 394 |
+
return {}
|
| 395 |
+
|
| 396 |
+
# Convert to game config format
|
| 397 |
+
config_elements = {
|
| 398 |
+
"setting": {
|
| 399 |
+
"title": data["title"],
|
| 400 |
+
"description": data["summary"],
|
| 401 |
+
"locations": {loc["name"]: {"type": loc["type"], "description": loc["significance"]}
|
| 402 |
+
for loc in data.get("locations", [])}
|
| 403 |
+
},
|
| 404 |
+
"characters": {
|
| 405 |
+
person["name"]: {
|
| 406 |
+
"role": person["role"],
|
| 407 |
+
"faction": person["faction"],
|
| 408 |
+
"description": person["description"]
|
| 409 |
+
} for person in data.get("people", [])
|
| 410 |
+
},
|
| 411 |
+
"rules": [
|
| 412 |
+
{"type": item["type"], "name": item["name"], "effect": item["description"]}
|
| 413 |
+
for item in data.get("politics_laws_morals", [])
|
| 414 |
+
]
|
| 415 |
+
}
|
| 416 |
+
return config_elements
|
| 417 |
+
|
| 418 |
+
# =============================================================================
|
| 419 |
+
# GRADIO UI
|
| 420 |
+
# =============================================================================
|
| 421 |
+
|
| 422 |
+
def bible_story_elements():
|
| 423 |
+
with gr.Tab("Using Bible as Inspiration") as story_inspiration:
|
| 424 |
+
gr.Markdown("""
|
| 425 |
+
# Bible Story Templates for Game Config Generation
|
| 426 |
+
|
| 427 |
+
Use these structured templates to generate game configs based on Biblical narratives.
|
| 428 |
+
Each chapter is broken down into **Locations**, **People**, and **Politics/Laws/Moral Rules**.
|
| 429 |
+
""")
|
| 430 |
+
|
| 431 |
+
with gr.Tab("Matthew Overview"):
|
| 432 |
+
gr.Markdown(f"**Source:** [{BIBLE_TEMPLATES['matthew']['book_name']}]({BIBLE_TEMPLATES['matthew']['youtube_link']})")
|
| 433 |
+
|
| 434 |
+
# Summary stats
|
| 435 |
+
all_locs = get_all_locations("matthew")
|
| 436 |
+
all_people = get_all_people("matthew")
|
| 437 |
+
all_plm = get_all_politics_laws_morals("matthew")
|
| 438 |
+
|
| 439 |
+
gr.Markdown(f"""
|
| 440 |
+
### Template Statistics (Chapters 1-11)
|
| 441 |
+
- **Locations:** {len(all_locs)} unique places
|
| 442 |
+
- **People:** {len(all_people)} unique characters
|
| 443 |
+
- **Rules/Morals:** {len(all_plm)} unique laws, morals, and political elements
|
| 444 |
+
""")
|
| 445 |
+
|
| 446 |
+
with gr.Accordion("All Locations", open=False):
|
| 447 |
+
gr.Markdown(format_locations_for_display(all_locs))
|
| 448 |
+
|
| 449 |
+
with gr.Accordion("All People", open=False):
|
| 450 |
+
gr.Markdown(format_people_for_display(all_people))
|
| 451 |
+
|
| 452 |
+
with gr.Accordion("All Politics/Laws/Morals", open=False):
|
| 453 |
+
gr.Markdown(format_politics_for_display(all_plm))
|
| 454 |
+
|
| 455 |
+
with gr.Tab("Chapter Browser"):
|
| 456 |
+
chapter_dropdown = gr.Dropdown(
|
| 457 |
+
choices=[(f"Chapter {k}: {v['title']}", k) for k, v in BIBLE_TEMPLATES["matthew"]["chapters"].items()],
|
| 458 |
+
label="Select Chapter",
|
| 459 |
+
value="1"
|
| 460 |
+
)
|
| 461 |
+
chapter_display = gr.Markdown(get_chapter_summary_md("matthew", "1"))
|
| 462 |
+
|
| 463 |
+
chapter_dropdown.change(
|
| 464 |
+
fn=lambda ch: get_chapter_summary_md("matthew", ch),
|
| 465 |
+
inputs=[chapter_dropdown],
|
| 466 |
+
outputs=[chapter_display]
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
with gr.Tab("Generate Game Elements"):
|
| 470 |
+
gr.Markdown("Select a chapter to generate game config elements from its narrative structure.")
|
| 471 |
+
|
| 472 |
+
gen_chapter = gr.Dropdown(
|
| 473 |
+
choices=[(f"Chapter {k}: {v['title']}", k) for k, v in BIBLE_TEMPLATES["matthew"]["chapters"].items()],
|
| 474 |
+
label="Select Chapter",
|
| 475 |
+
value="1"
|
| 476 |
+
)
|
| 477 |
+
|
| 478 |
+
def random_bible_chapter():
|
| 479 |
+
"""Select a random chapter from available chapters."""
|
| 480 |
+
import random
|
| 481 |
+
chapters = list(BIBLE_TEMPLATES["matthew"]["chapters"].keys())
|
| 482 |
+
return random.choice(chapters)
|
| 483 |
+
|
| 484 |
+
with gr.Row():
|
| 485 |
+
random_chapter_btn = gr.Button("🎲 Random Chapter", variant="secondary")
|
| 486 |
+
generate_btn = gr.Button("Generate Game Elements", variant="primary")
|
| 487 |
+
|
| 488 |
+
output_json = gr.Code(language="json", label="Generated Game Elements")
|
| 489 |
+
|
| 490 |
+
random_chapter_btn.click(
|
| 491 |
+
fn=random_bible_chapter,
|
| 492 |
+
outputs=[gen_chapter]
|
| 493 |
+
)
|
| 494 |
+
|
| 495 |
+
import json
|
| 496 |
+
generate_btn.click(
|
| 497 |
+
fn=lambda ch: json.dumps(generate_game_elements_from_chapter("matthew", ch), indent=2),
|
| 498 |
+
inputs=[gen_chapter],
|
| 499 |
+
outputs=[output_json]
|
| 500 |
+
)
|
| 501 |
+
|
| 502 |
+
return story_inspiration
|
|
@@ -0,0 +1,458 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Condition Evaluator - Logic system for game config conditions and transitions.
|
| 3 |
+
|
| 4 |
+
Provides:
|
| 5 |
+
- ConditionEvaluator: Evaluate condition expressions against GameState
|
| 6 |
+
- TransitionResolver: Resolve dynamic transitions (random, conditional)
|
| 7 |
+
- EffectApplicator: Apply declarative effects to GameState
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from typing import Any, Dict, List, Optional, Union
|
| 11 |
+
import random
|
| 12 |
+
|
| 13 |
+
from game_state import GameState
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class ConditionEvaluator:
|
| 17 |
+
"""
|
| 18 |
+
Evaluates condition expressions against GameState.
|
| 19 |
+
|
| 20 |
+
Supports:
|
| 21 |
+
- Atomic conditions: has_item, met_person, flag, visited, mission_*, money, counter, knowledge
|
| 22 |
+
- Compound conditions: and, or, not
|
| 23 |
+
- Numeric comparisons: gte, lte, gt, lt, eq
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
def __init__(self, game_state: GameState):
|
| 27 |
+
self.state = game_state
|
| 28 |
+
|
| 29 |
+
def evaluate(self, condition: Any) -> bool:
|
| 30 |
+
"""
|
| 31 |
+
Evaluate a condition expression.
|
| 32 |
+
|
| 33 |
+
Args:
|
| 34 |
+
condition: Can be:
|
| 35 |
+
- None/empty dict: Always True (no condition)
|
| 36 |
+
- str: Flag name to check
|
| 37 |
+
- dict: Condition expression
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
bool: Whether condition is satisfied
|
| 41 |
+
"""
|
| 42 |
+
# No condition = always true (backwards compatible)
|
| 43 |
+
if condition is None or condition == {}:
|
| 44 |
+
return True
|
| 45 |
+
|
| 46 |
+
# Simple string = flag check
|
| 47 |
+
if isinstance(condition, str):
|
| 48 |
+
return self.state.has_flag(condition)
|
| 49 |
+
|
| 50 |
+
if not isinstance(condition, dict):
|
| 51 |
+
return False
|
| 52 |
+
|
| 53 |
+
# Compound operators
|
| 54 |
+
if "and" in condition:
|
| 55 |
+
return all(self.evaluate(c) for c in condition["and"])
|
| 56 |
+
|
| 57 |
+
if "or" in condition:
|
| 58 |
+
return any(self.evaluate(c) for c in condition["or"])
|
| 59 |
+
|
| 60 |
+
if "not" in condition:
|
| 61 |
+
return not self.evaluate(condition["not"])
|
| 62 |
+
|
| 63 |
+
# Atomic conditions
|
| 64 |
+
return self._evaluate_atomic(condition)
|
| 65 |
+
|
| 66 |
+
def _evaluate_atomic(self, condition: Dict) -> bool:
|
| 67 |
+
"""Evaluate a single atomic condition."""
|
| 68 |
+
|
| 69 |
+
# ==================== Item Checks ====================
|
| 70 |
+
if "has_item" in condition:
|
| 71 |
+
return self.state.has_item(condition["has_item"])
|
| 72 |
+
|
| 73 |
+
if "not_has_item" in condition:
|
| 74 |
+
return not self.state.has_item(condition["not_has_item"])
|
| 75 |
+
|
| 76 |
+
# ==================== Person Checks ====================
|
| 77 |
+
if "met_person" in condition:
|
| 78 |
+
return self.state.has_met(condition["met_person"])
|
| 79 |
+
|
| 80 |
+
if "not_met_person" in condition:
|
| 81 |
+
return not self.state.has_met(condition["not_met_person"])
|
| 82 |
+
|
| 83 |
+
# ==================== Flag Checks ====================
|
| 84 |
+
if "flag" in condition:
|
| 85 |
+
return self.state.has_flag(condition["flag"])
|
| 86 |
+
|
| 87 |
+
if "not_flag" in condition:
|
| 88 |
+
return not self.state.has_flag(condition["not_flag"])
|
| 89 |
+
|
| 90 |
+
# ==================== Location Checks ====================
|
| 91 |
+
if "visited" in condition:
|
| 92 |
+
return self.state.has_visited(condition["visited"])
|
| 93 |
+
|
| 94 |
+
if "not_visited" in condition:
|
| 95 |
+
return not self.state.has_visited(condition["not_visited"])
|
| 96 |
+
|
| 97 |
+
if "discovered" in condition:
|
| 98 |
+
return self.state.has_discovered(condition["discovered"])
|
| 99 |
+
|
| 100 |
+
# ==================== Mission Checks ====================
|
| 101 |
+
if "mission_complete" in condition:
|
| 102 |
+
return self.state.is_mission_complete(condition["mission_complete"])
|
| 103 |
+
|
| 104 |
+
if "mission_active" in condition:
|
| 105 |
+
return self.state.is_mission_active(condition["mission_active"])
|
| 106 |
+
|
| 107 |
+
if "mission_failed" in condition:
|
| 108 |
+
return self.state.is_mission_failed(condition["mission_failed"])
|
| 109 |
+
|
| 110 |
+
# ==================== Money Comparison ====================
|
| 111 |
+
if "money" in condition:
|
| 112 |
+
return self._compare_numeric(self.state.money, condition["money"])
|
| 113 |
+
|
| 114 |
+
# ==================== Counter Comparison ====================
|
| 115 |
+
if "counter" in condition:
|
| 116 |
+
counter_cond = condition["counter"]
|
| 117 |
+
for counter_name, comparison in counter_cond.items():
|
| 118 |
+
value = self.state.get_counter(counter_name)
|
| 119 |
+
if not self._compare_numeric(value, comparison):
|
| 120 |
+
return False
|
| 121 |
+
return True
|
| 122 |
+
|
| 123 |
+
# ==================== Knowledge Checks ====================
|
| 124 |
+
if "knowledge" in condition:
|
| 125 |
+
return self.state.has_knowledge(condition["knowledge"])
|
| 126 |
+
|
| 127 |
+
if "knowledge_value" in condition:
|
| 128 |
+
kv = condition["knowledge_value"]
|
| 129 |
+
key = kv.get("key")
|
| 130 |
+
actual = self.state.get_knowledge(key)
|
| 131 |
+
if "eq" in kv:
|
| 132 |
+
return actual == kv["eq"]
|
| 133 |
+
if "neq" in kv:
|
| 134 |
+
return actual != kv["neq"]
|
| 135 |
+
return False
|
| 136 |
+
|
| 137 |
+
# ==================== Reputation Check ====================
|
| 138 |
+
if "reputation" in condition:
|
| 139 |
+
rep_cond = condition["reputation"]
|
| 140 |
+
npc = rep_cond.get("npc")
|
| 141 |
+
actual = self.state.get_reputation(npc)
|
| 142 |
+
return self._compare_numeric(actual, rep_cond)
|
| 143 |
+
|
| 144 |
+
# ==================== Visit Count Check ====================
|
| 145 |
+
if "visit_count" in condition:
|
| 146 |
+
vc = condition["visit_count"]
|
| 147 |
+
state_key = vc.get("state")
|
| 148 |
+
actual = self.state.get_visit_count(state_key)
|
| 149 |
+
return self._compare_numeric(actual, vc)
|
| 150 |
+
|
| 151 |
+
# Unknown condition type - return False (safe default)
|
| 152 |
+
return False
|
| 153 |
+
|
| 154 |
+
def _compare_numeric(self, actual: int, comparison: Any) -> bool:
|
| 155 |
+
"""
|
| 156 |
+
Evaluate numeric comparisons.
|
| 157 |
+
|
| 158 |
+
comparison can be:
|
| 159 |
+
- int: exact match
|
| 160 |
+
- {"gte": n}: >=
|
| 161 |
+
- {"lte": n}: <=
|
| 162 |
+
- {"gt": n}: >
|
| 163 |
+
- {"lt": n}: <
|
| 164 |
+
- {"eq": n}: ==
|
| 165 |
+
- {"neq": n}: !=
|
| 166 |
+
"""
|
| 167 |
+
if isinstance(comparison, (int, float)):
|
| 168 |
+
return actual == comparison
|
| 169 |
+
|
| 170 |
+
if isinstance(comparison, dict):
|
| 171 |
+
if "gte" in comparison:
|
| 172 |
+
return actual >= comparison["gte"]
|
| 173 |
+
if "lte" in comparison:
|
| 174 |
+
return actual <= comparison["lte"]
|
| 175 |
+
if "gt" in comparison:
|
| 176 |
+
return actual > comparison["gt"]
|
| 177 |
+
if "lt" in comparison:
|
| 178 |
+
return actual < comparison["lt"]
|
| 179 |
+
if "eq" in comparison:
|
| 180 |
+
return actual == comparison["eq"]
|
| 181 |
+
if "neq" in comparison:
|
| 182 |
+
return actual != comparison["neq"]
|
| 183 |
+
|
| 184 |
+
return False
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class TransitionResolver:
|
| 188 |
+
"""
|
| 189 |
+
Resolves transition specifications to concrete target states.
|
| 190 |
+
Handles deterministic, random, and conditional transitions.
|
| 191 |
+
"""
|
| 192 |
+
|
| 193 |
+
def __init__(self, game_state: GameState):
|
| 194 |
+
self.state = game_state
|
| 195 |
+
self.evaluator = ConditionEvaluator(game_state)
|
| 196 |
+
|
| 197 |
+
def resolve(self, transition: Any) -> str:
|
| 198 |
+
"""
|
| 199 |
+
Resolve a transition specification to a target state.
|
| 200 |
+
|
| 201 |
+
Args:
|
| 202 |
+
transition: Can be:
|
| 203 |
+
- str: Direct target (current behavior, deterministic)
|
| 204 |
+
- dict: Complex transition spec (random, conditional)
|
| 205 |
+
|
| 206 |
+
Returns:
|
| 207 |
+
str: Target state name
|
| 208 |
+
|
| 209 |
+
Raises:
|
| 210 |
+
ValueError: If transition format is invalid or no condition matches
|
| 211 |
+
"""
|
| 212 |
+
# Simple string = deterministic transition (backwards compatible)
|
| 213 |
+
if isinstance(transition, str):
|
| 214 |
+
return transition
|
| 215 |
+
|
| 216 |
+
if not isinstance(transition, dict):
|
| 217 |
+
raise ValueError(f"Invalid transition type: {type(transition)}")
|
| 218 |
+
|
| 219 |
+
# Weighted random: {"random": [["state_a", 0.7], ["state_b", 0.3]]}
|
| 220 |
+
if "random" in transition:
|
| 221 |
+
return self._resolve_weighted_random(transition["random"])
|
| 222 |
+
|
| 223 |
+
# Equal-weight pool: {"random_from": ["a", "b", "c"]}
|
| 224 |
+
if "random_from" in transition:
|
| 225 |
+
pool = transition["random_from"]
|
| 226 |
+
if not pool:
|
| 227 |
+
raise ValueError("random_from pool is empty")
|
| 228 |
+
return random.choice(pool)
|
| 229 |
+
|
| 230 |
+
# Simple conditional: {"if": condition, "then": target, "else": fallback}
|
| 231 |
+
if "if" in transition:
|
| 232 |
+
condition = transition["if"]
|
| 233 |
+
if self.evaluator.evaluate(condition):
|
| 234 |
+
then_target = transition.get("then")
|
| 235 |
+
if then_target:
|
| 236 |
+
return self.resolve(then_target)
|
| 237 |
+
else:
|
| 238 |
+
else_target = transition.get("else")
|
| 239 |
+
if else_target:
|
| 240 |
+
return self.resolve(else_target)
|
| 241 |
+
# If no matching branch, this is an error
|
| 242 |
+
raise ValueError("Conditional transition has no matching branch")
|
| 243 |
+
|
| 244 |
+
# Chained conditions: {"conditions": [{if, then}, {if, then}, {default}]}
|
| 245 |
+
if "conditions" in transition:
|
| 246 |
+
for cond_block in transition["conditions"]:
|
| 247 |
+
# Default case (no condition)
|
| 248 |
+
if "default" in cond_block:
|
| 249 |
+
return self.resolve(cond_block["default"])
|
| 250 |
+
# Conditional case
|
| 251 |
+
if "if" in cond_block and self.evaluator.evaluate(cond_block["if"]):
|
| 252 |
+
return self.resolve(cond_block["then"])
|
| 253 |
+
# No condition matched and no default
|
| 254 |
+
raise ValueError("No condition matched and no default provided")
|
| 255 |
+
|
| 256 |
+
raise ValueError(f"Unknown transition format: {transition}")
|
| 257 |
+
|
| 258 |
+
def _resolve_weighted_random(self, weights: List) -> str:
|
| 259 |
+
"""
|
| 260 |
+
Select from weighted random options.
|
| 261 |
+
|
| 262 |
+
Args:
|
| 263 |
+
weights: List of [state, probability] pairs
|
| 264 |
+
|
| 265 |
+
Returns:
|
| 266 |
+
Selected state name
|
| 267 |
+
"""
|
| 268 |
+
if not weights:
|
| 269 |
+
raise ValueError("Weighted random list is empty")
|
| 270 |
+
|
| 271 |
+
states = [w[0] for w in weights]
|
| 272 |
+
probs = [w[1] for w in weights]
|
| 273 |
+
|
| 274 |
+
# Normalize probabilities if they don't sum to 1
|
| 275 |
+
total = sum(probs)
|
| 276 |
+
if total <= 0:
|
| 277 |
+
raise ValueError("Weights must sum to positive number")
|
| 278 |
+
if abs(total - 1.0) > 0.001:
|
| 279 |
+
probs = [p / total for p in probs]
|
| 280 |
+
|
| 281 |
+
return random.choices(states, weights=probs, k=1)[0]
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
class EffectApplicator:
|
| 285 |
+
"""
|
| 286 |
+
Applies declarative effect specifications to GameState.
|
| 287 |
+
|
| 288 |
+
Supports:
|
| 289 |
+
- Items: add_item, remove_item
|
| 290 |
+
- Money: add_money, remove_money
|
| 291 |
+
- People: add_person
|
| 292 |
+
- Locations: add_location
|
| 293 |
+
- Flags: set_flag, clear_flag
|
| 294 |
+
- Counters: set_counter, increment, decrement
|
| 295 |
+
- Knowledge: set_knowledge
|
| 296 |
+
- Missions: start_mission, complete_mission, fail_mission
|
| 297 |
+
- Reputation: adjust_reputation
|
| 298 |
+
"""
|
| 299 |
+
|
| 300 |
+
def __init__(self, game_state: GameState):
|
| 301 |
+
self.state = game_state
|
| 302 |
+
|
| 303 |
+
def apply(self, effects: Dict) -> None:
|
| 304 |
+
"""
|
| 305 |
+
Apply a set of effects to the game state.
|
| 306 |
+
|
| 307 |
+
Args:
|
| 308 |
+
effects: Dict of effect specifications
|
| 309 |
+
"""
|
| 310 |
+
if not effects:
|
| 311 |
+
return
|
| 312 |
+
|
| 313 |
+
# ==================== Item Effects ====================
|
| 314 |
+
if "add_item" in effects:
|
| 315 |
+
item = effects["add_item"]
|
| 316 |
+
if isinstance(item, list):
|
| 317 |
+
self.state.add_items(item)
|
| 318 |
+
else:
|
| 319 |
+
self.state.add_item(item)
|
| 320 |
+
|
| 321 |
+
if "remove_item" in effects:
|
| 322 |
+
item = effects["remove_item"]
|
| 323 |
+
if isinstance(item, list):
|
| 324 |
+
for i in item:
|
| 325 |
+
self.state.remove_item(i)
|
| 326 |
+
else:
|
| 327 |
+
self.state.remove_item(item)
|
| 328 |
+
|
| 329 |
+
# ==================== Money Effects ====================
|
| 330 |
+
if "add_money" in effects:
|
| 331 |
+
self.state.add_money(effects["add_money"])
|
| 332 |
+
|
| 333 |
+
if "remove_money" in effects:
|
| 334 |
+
self.state.remove_money(effects["remove_money"])
|
| 335 |
+
|
| 336 |
+
if "set_money" in effects:
|
| 337 |
+
self.state.money = effects["set_money"]
|
| 338 |
+
|
| 339 |
+
# ==================== Person Effects ====================
|
| 340 |
+
if "add_person" in effects:
|
| 341 |
+
person = effects["add_person"]
|
| 342 |
+
if isinstance(person, list):
|
| 343 |
+
for p in person:
|
| 344 |
+
self.state.meet_person(p)
|
| 345 |
+
else:
|
| 346 |
+
self.state.meet_person(person)
|
| 347 |
+
|
| 348 |
+
# ==================== Location Effects ====================
|
| 349 |
+
if "add_location" in effects:
|
| 350 |
+
location = effects["add_location"]
|
| 351 |
+
if isinstance(location, list):
|
| 352 |
+
for loc in location:
|
| 353 |
+
self.state.discover_location(loc)
|
| 354 |
+
else:
|
| 355 |
+
self.state.discover_location(location)
|
| 356 |
+
|
| 357 |
+
if "visit_location" in effects:
|
| 358 |
+
location = effects["visit_location"]
|
| 359 |
+
if isinstance(location, list):
|
| 360 |
+
for loc in location:
|
| 361 |
+
self.state.visit_location(loc)
|
| 362 |
+
else:
|
| 363 |
+
self.state.visit_location(location)
|
| 364 |
+
|
| 365 |
+
# ==================== Flag Effects ====================
|
| 366 |
+
if "set_flag" in effects:
|
| 367 |
+
flag = effects["set_flag"]
|
| 368 |
+
if isinstance(flag, list):
|
| 369 |
+
for f in flag:
|
| 370 |
+
self.state.set_flag(f, True)
|
| 371 |
+
elif isinstance(flag, dict):
|
| 372 |
+
for f, v in flag.items():
|
| 373 |
+
self.state.set_flag(f, v)
|
| 374 |
+
else:
|
| 375 |
+
self.state.set_flag(flag, True)
|
| 376 |
+
|
| 377 |
+
if "clear_flag" in effects:
|
| 378 |
+
flag = effects["clear_flag"]
|
| 379 |
+
if isinstance(flag, list):
|
| 380 |
+
for f in flag:
|
| 381 |
+
self.state.clear_flag(f)
|
| 382 |
+
else:
|
| 383 |
+
self.state.clear_flag(flag)
|
| 384 |
+
|
| 385 |
+
if "toggle_flag" in effects:
|
| 386 |
+
flag = effects["toggle_flag"]
|
| 387 |
+
if isinstance(flag, list):
|
| 388 |
+
for f in flag:
|
| 389 |
+
self.state.toggle_flag(f)
|
| 390 |
+
else:
|
| 391 |
+
self.state.toggle_flag(flag)
|
| 392 |
+
|
| 393 |
+
# ==================== Counter Effects ====================
|
| 394 |
+
if "set_counter" in effects:
|
| 395 |
+
for name, value in effects["set_counter"].items():
|
| 396 |
+
self.state.set_counter(name, value)
|
| 397 |
+
|
| 398 |
+
if "increment" in effects:
|
| 399 |
+
for name, amount in effects["increment"].items():
|
| 400 |
+
self.state.increment_counter(name, amount)
|
| 401 |
+
|
| 402 |
+
if "decrement" in effects:
|
| 403 |
+
for name, amount in effects["decrement"].items():
|
| 404 |
+
self.state.decrement_counter(name, amount)
|
| 405 |
+
|
| 406 |
+
# ==================== Knowledge Effects ====================
|
| 407 |
+
if "set_knowledge" in effects:
|
| 408 |
+
for key, value in effects["set_knowledge"].items():
|
| 409 |
+
self.state.update_knowledge(key, value)
|
| 410 |
+
|
| 411 |
+
if "remove_knowledge" in effects:
|
| 412 |
+
key = effects["remove_knowledge"]
|
| 413 |
+
if isinstance(key, list):
|
| 414 |
+
for k in key:
|
| 415 |
+
self.state.remove_knowledge(k)
|
| 416 |
+
else:
|
| 417 |
+
self.state.remove_knowledge(key)
|
| 418 |
+
|
| 419 |
+
# ==================== Mission Effects ====================
|
| 420 |
+
if "start_mission" in effects:
|
| 421 |
+
mission = effects["start_mission"]
|
| 422 |
+
if isinstance(mission, str):
|
| 423 |
+
self.state.start_mission(mission)
|
| 424 |
+
elif isinstance(mission, dict):
|
| 425 |
+
for m_id, m_data in mission.items():
|
| 426 |
+
self.state.start_mission(m_id, m_data if isinstance(m_data, dict) else None)
|
| 427 |
+
elif isinstance(mission, list):
|
| 428 |
+
for m in mission:
|
| 429 |
+
self.state.start_mission(m)
|
| 430 |
+
|
| 431 |
+
if "complete_mission" in effects:
|
| 432 |
+
mission = effects["complete_mission"]
|
| 433 |
+
if isinstance(mission, list):
|
| 434 |
+
for m in mission:
|
| 435 |
+
self.state.complete_mission(m)
|
| 436 |
+
else:
|
| 437 |
+
self.state.complete_mission(mission)
|
| 438 |
+
|
| 439 |
+
if "fail_mission" in effects:
|
| 440 |
+
mission = effects["fail_mission"]
|
| 441 |
+
if isinstance(mission, list):
|
| 442 |
+
for m in mission:
|
| 443 |
+
self.state.fail_mission(m)
|
| 444 |
+
else:
|
| 445 |
+
self.state.fail_mission(mission)
|
| 446 |
+
|
| 447 |
+
if "update_mission" in effects:
|
| 448 |
+
for mission_id, updates in effects["update_mission"].items():
|
| 449 |
+
self.state.update_mission(mission_id, updates)
|
| 450 |
+
|
| 451 |
+
# ==================== Reputation Effects ====================
|
| 452 |
+
if "adjust_reputation" in effects:
|
| 453 |
+
for npc, change in effects["adjust_reputation"].items():
|
| 454 |
+
self.state.adjust_reputation(npc, change)
|
| 455 |
+
|
| 456 |
+
if "set_reputation" in effects:
|
| 457 |
+
for npc, value in effects["set_reputation"].items():
|
| 458 |
+
self.state.npc_reputation[npc] = value
|
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Config Development Assistance - Reference Guide
|
| 2 |
+
|
| 3 |
+
> End Goal for this tab is to be able to make Story based games using mechanics based games (Chess, Poker, Sudoku, Rubiks Cube, etc.) as a conduit
|
| 4 |
+
|
| 5 |
+
## Core Principles
|
| 6 |
+
|
| 7 |
+
- Sometimes a fun game includes unfairness - Elden Ring (Controls and movement restrictions), Boss fights in every game
|
| 8 |
+
- Jonas Tyroller (https://youtu.be/o5K0uqhxgsE) - This problem changes your perspective on game dev - minimise the cost of exploration so you can explore more (17:00) | dont make the same game again but worse. How can we measure worse? (Negative Reviews (Eg. Steam / Play or Apple Store / Game Journalists) / Reddit posts with complaints / Youtube Video Analysis Conclusions)
|
| 9 |
+
- Every theme in the world can be turned into a battle story as that is how resource accumulation works --> Resources are normally hard to find and therefore scatter across a map
|
| 10 |
+
- Ending Conditions --> Main Villain Antagonist Themes --> Travel, Politics and Combat needed to reach ending --> Characters
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## 1. Some Ideas About Games Fundamentals
|
| 15 |
+
|
| 16 |
+
- Is the universal rule as something done in less time is always harder and sought after
|
| 17 |
+
- Games are ruled by one rule that determines win / loss (personal objective / competition). The main rule comes from the environment of the game eg. soccer = field + players + ball + marked goals and only then main rule is when ball goes into goal then goal scored. After that extra rules try keep the game interesting and sustainable eg. offside, deadballs, cards
|
| 18 |
+
- **Tangible environments** (mainly real life)
|
| 19 |
+
- **Intangible environments** (void / in your head / fictional places)
|
| 20 |
+
- Fun = Avoiding Negatives and experiencing all content. Need a list of negative things that can happen
|
| 21 |
+
- A game is alive when the same decisions wont work over and over - this means the game is designed to have the user answer all options and then have random choices get random answers
|
| 22 |
+
- Good environments for randomness = anything with a queue like traffic or even the weather (rain / flood chance) - lead to player being overprepared for some situations and underprepared at the same time for others
|
| 23 |
+
|
| 24 |
+
---
|
| 25 |
+
|
| 26 |
+
## 2. Simple with Consequences and 'Fun' Built
|
| 27 |
+
|
| 28 |
+
- Ask for x stories reached through x decision points
|
| 29 |
+
- **Prompt 1** - Is there a way to tell 8 stories from the same opening using 2 decision points to reach them
|
| 30 |
+
- **Prompt 2** - For each verb item list things a normal person wouldn't want to happen
|
| 31 |
+
|
| 32 |
+
---
|
| 33 |
+
|
| 34 |
+
## 3. Simple Structure
|
| 35 |
+
|
| 36 |
+
> AI is good walking a tightrope but not setting up the tightrope. Structure in final format (JSON / Twee) --> current prompt: what would this config look like in Twee?
|
| 37 |
+
|
| 38 |
+
### 3.1 Generator
|
| 39 |
+
- A long game is a bunch of short games
|
| 40 |
+
- Click the button to generate a random timeline and story based on UI elements and story events. Ask an LLM to use this to write a story around
|
| 41 |
+
|
| 42 |
+
---
|
| 43 |
+
|
| 44 |
+
## 4. More Robust
|
| 45 |
+
|
| 46 |
+
- The main idea is locations + Personal Relationships + bystanders = foundation to write game mechanics into eg. random bystander = candidate for a DLC / sequel story and can be rewritten as character acquaintances / relatives
|
| 47 |
+
- Conflict Ideas - in Overengineered Tab
|
| 48 |
+
|
| 49 |
+
---
|
| 50 |
+
|
| 51 |
+
## 5. Overengineered
|
| 52 |
+
|
| 53 |
+
> Current [Timeline] Workflow = [Precreation] Mermaid Diagram to (1) Story to (2) [Creation] Initial JSON (through LLM and fix JSON by hand) to (3) [Postcreation] JSON Corrections (through LLM and fix JSON by hand) to (4) Media prompts to (5) Asset Generation to (6) JSON Media field population
|
| 54 |
+
|
| 55 |
+
### 5.1 Pre - Qualitative Considerations
|
| 56 |
+
|
| 57 |
+
- Ending Conditions --> Main Villain Antagonist Themes --> Travel, Politics and Combat needed to reach ending --> Characters
|
| 58 |
+
- Modern game commonalities = travel, 'politics' (cheatcode is related family members in high positions) (personal relationship effects on the world) and combat (physical or verbal eg. negotiation)
|
| 59 |
+
- Politics within a group origins = Easiest to understand interpersonal issues = betrayal and lack of sympathy, unfair expectations (Oblivious Decision making) or misinterpretation of actions under these categories
|
| 60 |
+
- A game is about conveying an experience to the user - this is why media is heavily used
|
| 61 |
+
- Need to learn how rules (our preferences) affect the config creation process. eg. core game loop - Write a story where the plot only moves based on - [insert core game loop]
|
| 62 |
+
|
| 63 |
+
#### 5.1.1 Ending as the Start
|
| 64 |
+
|
| 65 |
+
- The issue with a comprehensive game is that decisions with consequences can be made therefore as many endings as possible so one ending as the start is an incorrect approach
|
| 66 |
+
|
| 67 |
+
**Prompts:**
|
| 68 |
+
- "Analyse the following statement and provide 50 ideas for good starting points to develop from - A good ending is predictable in shape from the beginning of the story but the finer details hidden till the very end" (Tested with o1)
|
| 69 |
+
- "List 30 stereotypical story endings" (Tested with groq llama-3.3-70B-specdec)
|
| 70 |
+
|
| 71 |
+
**How to turn an ending into an intro:**
|
| 72 |
+
- What is the furthest away point from this that the main character can be?
|
| 73 |
+
|
| 74 |
+
#### 5.1.2 In Between the Ending and the Intro
|
| 75 |
+
|
| 76 |
+
- Travel, Politics and Combat needed to reach ending and Character development
|
| 77 |
+
|
| 78 |
+
#### 5.1.3 Review / Parody as the Start
|
| 79 |
+
|
| 80 |
+
- For a story Movies are a better source of inspiration for structures you can use (There's way more popular movies than popular games) - Screen Junkies (For Cliches Exposure) / Cinema Sins (For plotholes/inconsistencies to avoid)
|
| 81 |
+
|
| 82 |
+
#### 5.1.4 Game/Story Wiki Contents Pages
|
| 83 |
+
|
| 84 |
+
- Game/Story Wiki Contents pages are normally long lists so they help you to see how many lists you need and the length of the lists to give an idea / reference for whether you need more or less complexity
|
| 85 |
+
|
| 86 |
+
#### 5.1.5 Media as Inspiration
|
| 87 |
+
|
| 88 |
+
- Models like QVQ-72B-Preview can turn any picture into a list of potential scenes the image can represent. Need to find a way for LLMs to judge melodies and songs
|
| 89 |
+
- Some Songs I would like to be able base a game around - Tank and Various = If you Dream, Michael Buble = Home
|
| 90 |
+
|
| 91 |
+
#### 5.1.6 Main Parts of Stories to Put into Structures
|
| 92 |
+
|
| 93 |
+
**Purpose (The types of decisions you want the user to make) (Main Theme):**
|
| 94 |
+
- Unpredictable Nuances (Any movie) / Simulation (eg. Work - mixing rare industry crossover) (eg. A phrasebook into interactive game) / Time capsule / Tangible Metaphor / Song Concept as game / Advert as a game
|
| 95 |
+
- References: https://en.wikipedia.org/wiki/Philosophy, https://en.wikipedia.org/wiki/Moral_injury
|
| 96 |
+
|
| 97 |
+
**Conflicts:**
|
| 98 |
+
- Most stories are based around conflicts
|
| 99 |
+
|
| 100 |
+
**Setting:**
|
| 101 |
+
- Reference: https://en.wikipedia.org/wiki/History#Periods
|
| 102 |
+
|
| 103 |
+
**Locations:**
|
| 104 |
+
- Jungle, Sea, Desert, Snow, City, Village, Space
|
| 105 |
+
|
| 106 |
+
**Character Relations:**
|
| 107 |
+
- Friend or foe
|
| 108 |
+
|
| 109 |
+
**Character Archetypes:**
|
| 110 |
+
- (To be expanded)
|
| 111 |
+
|
| 112 |
+
**Additional Themes:**
|
| 113 |
+
- (To be expanded)
|
| 114 |
+
|
| 115 |
+
#### 5.1.7 Generalized Inspiration from Existing Games
|
| 116 |
+
|
| 117 |
+
- Random Scenario / Song to 'full game' manual or auto is end goal
|
| 118 |
+
- Components (outside Code Support for Config): Decisions (and context explanation), Nested Sections, Media (Especially to affect decisions), Replayability (GTA and Tekken type mechanics in text form), Theme integration (Modified Variables that affect UI or config order)
|
| 119 |
+
- Existing Games eg. GTA Heists - Same Map with overlapping branching narratives, Battlefront - Elites amongst Commoners, Tekken Casino (one mistake = 1/2 or 1/3 of your Resources) and Turn based: 'Tactics' type nintendo games, Chess (and any other tile based game)
|
| 120 |
+
- Existing Game Rules for text - Cyberpunk RED
|
| 121 |
+
- Community playthrough = Tally of players choices, Random item placed in a random location - first person to get it wins, Survival by location or characters met
|
| 122 |
+
- Some Kinds of game skeletons ideas - Timelines, Graph as State machine paths, Economy ecosystem
|
| 123 |
+
|
| 124 |
+
**Structural Inspirations:**
|
| 125 |
+
- GTA Heists - Replayability and stakes
|
| 126 |
+
- Tekken - 2/3 mistakes = lost round
|
| 127 |
+
- Elden Ring - Story telling by traversal of map
|
| 128 |
+
- Sports Scores
|
| 129 |
+
|
| 130 |
+
**Test Prompt for Models:**
|
| 131 |
+
> Please make 10 python lists for the types of media files and their purposes in a game and then use those lists to random generate a timeline of 20 items when the function is called. Great next suggest ways to improve this function to create better timelines
|
| 132 |
+
|
| 133 |
+
#### 5.1.8 Worldbuilding Tools
|
| 134 |
+
|
| 135 |
+
- Good worldbuilding makes new scenarios automatic
|
| 136 |
+
- In a game awareness = directly affect one of the characters - Any time fights are involved, paralympics categorisations can be inspiration (watched paralympics and wanted to learn the categories eg. s10)
|
| 137 |
+
|
| 138 |
+
**Using Law as inspiration:**
|
| 139 |
+
- SA Constitution
|
| 140 |
+
- SA Companies Act
|
| 141 |
+
|
| 142 |
+
#### 5.1.9 Concept Combination Brainstorm
|
| 143 |
+
|
| 144 |
+
- The story and the gameplay don't have to occur at the same time
|
| 145 |
+
- A timeline is the most important part of the story - once that is set you can do anything?
|
| 146 |
+
- Media Critiques (eg. Youtube Rants) as Prompts to whole games as interactive explanation
|
| 147 |
+
|
| 148 |
+
---
|
| 149 |
+
|
| 150 |
+
### 5.2 Mermaid Diagram / Structural Considerations to Story
|
| 151 |
+
|
| 152 |
+
#### 5.2.1 HF Datasets as Scenario Inspiration
|
| 153 |
+
|
| 154 |
+
- https://huggingface.co/datasets/thesven/gsm8k-reasoning
|
| 155 |
+
- https://huggingface.co/datasets/HuggingFaceFW/fineweb
|
| 156 |
+
- HuggingFaceFW/fineweb-edu
|
| 157 |
+
- https://huggingface.co/datasets/HuggingFaceFV/finevideo
|
| 158 |
+
- https://huggingface.co/spaces/HuggingFaceFV/FineVideo-Explorer
|
| 159 |
+
- Translations: https://huggingface.co/datasets/opencsg/chinese-fineweb-edu
|
| 160 |
+
|
| 161 |
+
#### 5.2.2 Structure - (Non/)Linear Storylines
|
| 162 |
+
|
| 163 |
+
> Trying to abstract the process into one workflow is beyond me so multiple paths to goal (config) is the aim now
|
| 164 |
+
|
| 165 |
+
**Branching - Decisions / Timeline Creation:**
|
| 166 |
+
- Structures for interesting timeline progression
|
| 167 |
+
- Claude Artifacts to illustrate nested structure brainstorms:
|
| 168 |
+
- https://claude.site/artifacts/4a910d81-1541-49f4-8531-4f27fe56cd1e
|
| 169 |
+
- https://claude.site/artifacts/265e9242-2093-46e1-9011-ed6ad938be90?fullscreen=false
|
| 170 |
+
- Consideration: Story from the perspective of Main character or NPC in the LLM generated story
|
| 171 |
+
|
| 172 |
+
**Linear - Existing Media eg. Songs and Screenshots:**
|
| 173 |
+
- Media position in the story part: beginning, during or end
|
| 174 |
+
- Create media first and then ask LLM to make join points in the config. For images need to add the location to every prompt or images will be in random locations
|
| 175 |
+
|
| 176 |
+
**Linear - Machine Learning Architectures as Game Maps:**
|
| 177 |
+
- Transformers, SSMs, Image and Video Generation Architectures, GANs, RNNs, etc.
|
| 178 |
+
|
| 179 |
+
**Linear - Prompt Engineering as Basis for Ideation:**
|
| 180 |
+
- Current Assisted workflow idea: Story timeline events suggestions (LLM / Premade List) | Merging events with premade mermaid structures (LLM + Story Text + Mermaid Text) | Edit mermaid till satisfied (LLM + Story Text) | Ask LLM to convert to config (LLM + JSON Text) | Edit config (LLM / User with format assistance or not) | Playtest and go back to mermaid or config if there are problems
|
| 181 |
+
- Interactive movie (UI interaction or no progress) vs Branching Paths (Maze)
|
| 182 |
+
- Things that can change the workflow: Asset First (Make Asset and make the transitions using LLM), Export First (Custom JS config, Playcanvas, Unreal Engine reverse engineered to this spaces config?) Game Mechanics First (eg. Player Stats, Inventory and NPCS not implemented yet, so traversal type games best aka graph like structures)
|
| 183 |
+
- Config writing = Remix old one, Ask LLM to make one, Endless combination testing using the prompt engineering above or writing by hand (prompt engineering on yourself)
|
| 184 |
+
- Can use song lyrics as thematic source
|
| 185 |
+
|
| 186 |
+
**Branching - Network Analysis to Game Config:**
|
| 187 |
+
- Placeholder for analysing multiple stories for their network structures and creating general rules for a structure generator based on named entity recognition and bias to locations or people - The extreme long way
|
| 188 |
+
|
| 189 |
+
**Linear - Chess PNG to Game Config:**
|
| 190 |
+
- Any Chess match can serve as end of game final battle
|
| 191 |
+
|
| 192 |
+
**Note:** Below 70B (April 2024) seem to struggle here
|
| 193 |
+
|
| 194 |
+
---
|
| 195 |
+
|
| 196 |
+
### 5.3 Config Creation
|
| 197 |
+
|
| 198 |
+
> Can use Mermaid scaffold or not
|
| 199 |
+
|
| 200 |
+
**AI Generates Scaffolds - Notes:**
|
| 201 |
+
- 8b size models make Format mistakes. For extension purposes this step specifically might have to be function call only if you explain the tool can take as many blocks as necessary
|
| 202 |
+
|
| 203 |
+
**Extension Ideas:**
|
| 204 |
+
- The basic idea is just make a new story and fight the JSON issues to make them fit and then rinse and repeat
|
| 205 |
+
- Extension = any description in original JSON as the theme using the prompt template
|
| 206 |
+
- Ask for incorporation of mermaid structure into JSON or ask for how the mermaid structure would change the JSON / story
|
| 207 |
+
|
| 208 |
+
---
|
| 209 |
+
|
| 210 |
+
### 5.4 Post-creation
|
| 211 |
+
|
| 212 |
+
> Assumed story is complete at this stage / will be continued in separate config
|
| 213 |
+
|
| 214 |
+
#### 5.4.1 Initial Media Generation
|
| 215 |
+
- Placeholder for verb extraction from description and choices - to help guide initial media population
|
| 216 |
+
- To do a full test need media - Fast and quality: https://huggingface.co/spaces/black-forest-labs/FLUX.1-schnell
|
| 217 |
+
|
| 218 |
+
#### 5.4.2 JSON Corrections
|
| 219 |
+
|
| 220 |
+
**Prompt:** "Let's critique this JSON to find areas to fix"
|
| 221 |
+
|
| 222 |
+
#### 5.4.3 JSON to Media Prompts
|
| 223 |
+
|
| 224 |
+
- This Step specifically has to be function call only
|
| 225 |
+
- Gemma-9b and Mistral 8x7b is better at this prompt than llama 3.1 8b and 70b. Can add (each media field must get an entry) and (in python list of list format for plug and play) but they affect final output
|
| 226 |
+
|
| 227 |
+
**Prompt:** "Let's make a list for the prompts we will use to make media objects in this JSON. Make one for a person to interpret and one for direct media generators that focus on keywords:"
|
| 228 |
+
|
| 229 |
+
#### 5.4.4 Media Prompts to Asset Generation to JSON Population
|
| 230 |
+
|
| 231 |
+
- This Step specifically has to be function call only
|
| 232 |
+
- This Step can be merged with the next if we can make an editor like in the semi-Auto space in test and edit tailored to just accepting the JSON and exposing only media part for editing
|
| 233 |
+
|
| 234 |
+
**Prompts:**
|
| 235 |
+
- "For each Media item described classify it by media type and comment if in a story setting it would need timing"
|
| 236 |
+
- "Here is a list of file names - assume they are in the order of the empty media sections of the JSON and rewrite the JSON"
|
| 237 |
+
|
| 238 |
+
#### 5.4.5 Quick Ways to Evaluate Current Config
|
| 239 |
+
|
| 240 |
+
**Prompt to use with SOTA LLMs:**
|
| 241 |
+
> This config is for a basic text based game engine. I don't have any structural metrics to assess the quality of the config. What JSON things can we look at to see if it may be too bland for a person testing the game?
|
| 242 |
+
|
| 243 |
+
**Original Claude 3.5 Sonnet Response snippets:**
|
| 244 |
+
|
| 245 |
+
1. **Limited state variety:** With only 13 states across 5 locations, the game might feel short and lacking in diversity. Consider adding more locations or states within existing locations.
|
| 246 |
+
2. **Low average choices:** An average of 1.92 choices per state might make the game feel linear. Increasing the number of choices in more states could improve player engagement.
|
| 247 |
+
3. **Limited consequences:** Only 3 states have consequences, which might make player choices feel less impactful. Adding more consequences could increase the sense of agency.
|
| 248 |
+
4. **Short descriptions:** The average description length of 13.15 words might not provide enough detail to immerse players. Consider expanding descriptions to create a richer narrative.
|
| 249 |
+
5. **Lack of media:** No states currently use media elements, which could make the game feel less engaging. Adding images, sound effects, or other media could enhance the player experience.
|
| 250 |
+
6. **Limited narrative branching:** While there are some loops and choices, the overall structure is relatively linear. Adding more branching paths could increase replayability and player interest.
|
| 251 |
+
|
| 252 |
+
**To make the game less bland, consider:**
|
| 253 |
+
- Adding more states and locations
|
| 254 |
+
- Increasing the number of choices in each state
|
| 255 |
+
- Implementing more consequences for player actions
|
| 256 |
+
- Expanding descriptions to create a richer narrative
|
| 257 |
+
- Incorporating media elements
|
| 258 |
+
- Creating more diverse paths through the game
|
| 259 |
+
|
| 260 |
+
#### 5.4.6 Improvement of the Default Config
|
| 261 |
+
|
| 262 |
+
- Example of how to advance a game config with LLM - end goal is to have automatic workflow that takes these considerations into account
|
| 263 |
+
- Things missing from the game engine - Economics and Basic Politics (NPC affiliation)
|
| 264 |
+
|
| 265 |
+
---
|
| 266 |
+
|
| 267 |
+
## 6. Translated/Transposed Mechanics
|
| 268 |
+
|
| 269 |
+
- Interactive Fun (one form) = Avoiding something you don't want to happen in as dramatic fashion as possible = in '1D' format that can mean avoiding deadends and if you stack stories on stories the deadends can pile up
|
| 270 |
+
- 1D games fun = ??? (Haven't figured it out yet) same as a navigation game? Reaching the end is the current end aka the game is essentially a number password and wrong input means a deadend where the full password = full story - chained stories with deadends = same missing back story in a long story = a punishment to some and a reward to others
|
| 271 |
+
- Ideas for options based on existing 2D / 3D games
|
| 272 |
+
- Most game mechanics = button press + animation == in 1D animations = text description / picture
|
| 273 |
+
- Tekken - Kick and Punch in extreme detail + complex input buttons
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -0,0 +1,1256 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Config Linting / Story Health Validation
|
| 3 |
+
|
| 4 |
+
Automated checks for game config quality:
|
| 5 |
+
- Branching validation (graph integrity)
|
| 6 |
+
- Story flags (potential issues)
|
| 7 |
+
- Mermaid diagram generation
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import json
|
| 11 |
+
from collections import deque
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def parse_config(config_input):
|
| 15 |
+
"""Parse config from string or dict, handle wrapper keys."""
|
| 16 |
+
if isinstance(config_input, str):
|
| 17 |
+
try:
|
| 18 |
+
data = json.loads(config_input)
|
| 19 |
+
except json.JSONDecodeError as e:
|
| 20 |
+
return None, f"JSON Parse Error: {e}"
|
| 21 |
+
else:
|
| 22 |
+
data = config_input
|
| 23 |
+
|
| 24 |
+
# Handle wrapper keys (masterlocation1, etc.)
|
| 25 |
+
if len(data) == 1:
|
| 26 |
+
key = list(data.keys())[0]
|
| 27 |
+
if isinstance(data[key], dict) and 'end' in data[key]:
|
| 28 |
+
return data[key], None
|
| 29 |
+
|
| 30 |
+
return data, None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def flatten_nested_config(data):
|
| 34 |
+
"""
|
| 35 |
+
Flatten a nested location->state config into flat state dict.
|
| 36 |
+
|
| 37 |
+
Detects if config is nested (location contains states with 'description')
|
| 38 |
+
vs flat (states directly have 'description').
|
| 39 |
+
|
| 40 |
+
Returns: (flattened_dict, is_nested)
|
| 41 |
+
"""
|
| 42 |
+
if not data or not isinstance(data, dict):
|
| 43 |
+
return data, False
|
| 44 |
+
|
| 45 |
+
# Check if this is a nested structure
|
| 46 |
+
# A nested structure has: location -> state -> {description, choices, transitions}
|
| 47 |
+
# A flat structure has: state -> {description, choices, transitions}
|
| 48 |
+
|
| 49 |
+
first_key = list(data.keys())[0]
|
| 50 |
+
first_value = data[first_key]
|
| 51 |
+
|
| 52 |
+
if not isinstance(first_value, dict):
|
| 53 |
+
return data, False
|
| 54 |
+
|
| 55 |
+
# If the first value has 'description', it's flat
|
| 56 |
+
if 'description' in first_value:
|
| 57 |
+
return data, False
|
| 58 |
+
|
| 59 |
+
# Check if first value contains dicts with 'description' (nested)
|
| 60 |
+
for inner_key, inner_value in first_value.items():
|
| 61 |
+
if isinstance(inner_value, dict) and 'description' in inner_value:
|
| 62 |
+
# This is nested - flatten it
|
| 63 |
+
flattened = {}
|
| 64 |
+
for location, states in data.items():
|
| 65 |
+
if isinstance(states, dict):
|
| 66 |
+
for state_name, state_data in states.items():
|
| 67 |
+
if isinstance(state_data, dict) and 'description' in state_data:
|
| 68 |
+
flat_key = f"{location}_{state_name}"
|
| 69 |
+
flattened[flat_key] = state_data
|
| 70 |
+
return flattened, True
|
| 71 |
+
|
| 72 |
+
# Not clearly nested, return as-is
|
| 73 |
+
return data, False
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def validate_branching(config_input):
|
| 77 |
+
"""
|
| 78 |
+
Validate config branching/graph integrity.
|
| 79 |
+
|
| 80 |
+
Returns dict with:
|
| 81 |
+
- errors: Critical issues (broken transitions)
|
| 82 |
+
- warnings: Potential issues (orphaned states, dead ends)
|
| 83 |
+
- stats: Metrics about the config
|
| 84 |
+
"""
|
| 85 |
+
data, error = parse_config(config_input)
|
| 86 |
+
if error:
|
| 87 |
+
return {"errors": [error], "warnings": [], "stats": {}}
|
| 88 |
+
|
| 89 |
+
# Flatten nested configs (location -> state -> data)
|
| 90 |
+
data, is_nested = flatten_nested_config(data)
|
| 91 |
+
|
| 92 |
+
errors = []
|
| 93 |
+
warnings = []
|
| 94 |
+
|
| 95 |
+
all_states = set(data.keys())
|
| 96 |
+
|
| 97 |
+
# Track which states are targeted by transitions
|
| 98 |
+
targeted_states = set()
|
| 99 |
+
|
| 100 |
+
for state_name, state_data in data.items():
|
| 101 |
+
if not isinstance(state_data, dict):
|
| 102 |
+
continue
|
| 103 |
+
|
| 104 |
+
choices = state_data.get('choices', [])
|
| 105 |
+
transitions = state_data.get('transitions', {})
|
| 106 |
+
|
| 107 |
+
# Check: All transition targets exist
|
| 108 |
+
for choice, target in transitions.items():
|
| 109 |
+
# Handle nested location_state format
|
| 110 |
+
if '_' in target and target not in all_states:
|
| 111 |
+
# Try parsing as location_state
|
| 112 |
+
parts = target.rsplit('_', 1)
|
| 113 |
+
if len(parts) == 2:
|
| 114 |
+
location, inner_state = parts
|
| 115 |
+
if location in all_states:
|
| 116 |
+
loc_data = data.get(location, {})
|
| 117 |
+
if isinstance(loc_data, dict) and inner_state in loc_data:
|
| 118 |
+
targeted_states.add(location)
|
| 119 |
+
continue
|
| 120 |
+
|
| 121 |
+
errors.append(f"'{state_name}': transition '{choice}' -> '{target}' (target doesn't exist)")
|
| 122 |
+
else:
|
| 123 |
+
if target in all_states:
|
| 124 |
+
targeted_states.add(target)
|
| 125 |
+
else:
|
| 126 |
+
errors.append(f"'{state_name}': transition '{choice}' -> '{target}' (target doesn't exist)")
|
| 127 |
+
|
| 128 |
+
# Check: Choices match transition keys
|
| 129 |
+
choice_set = set(choices) if choices else set()
|
| 130 |
+
transition_keys = set(transitions.keys())
|
| 131 |
+
|
| 132 |
+
missing_transitions = choice_set - transition_keys
|
| 133 |
+
extra_transitions = transition_keys - choice_set
|
| 134 |
+
|
| 135 |
+
if missing_transitions:
|
| 136 |
+
errors.append(f"'{state_name}': choices without transitions: {missing_transitions}")
|
| 137 |
+
if extra_transitions:
|
| 138 |
+
warnings.append(f"'{state_name}': transitions without choices: {extra_transitions}")
|
| 139 |
+
|
| 140 |
+
# Find orphaned states (not targeted by any transition, except start states)
|
| 141 |
+
# Assume first state or common names are start states
|
| 142 |
+
start_candidates = {'start', 'intro', 'beginning', 'arrival', 'location1'}
|
| 143 |
+
first_state = list(data.keys())[0] if data else None
|
| 144 |
+
|
| 145 |
+
orphaned = all_states - targeted_states - {'end'} - start_candidates
|
| 146 |
+
if first_state:
|
| 147 |
+
orphaned.discard(first_state)
|
| 148 |
+
|
| 149 |
+
if orphaned:
|
| 150 |
+
warnings.append(f"Potentially orphaned states (unreachable): {orphaned}")
|
| 151 |
+
|
| 152 |
+
# Find dead ends (no transitions out, excluding valid ending states)
|
| 153 |
+
dead_ends = []
|
| 154 |
+
valid_endings = []
|
| 155 |
+
for state_name, state_data in data.items():
|
| 156 |
+
if not isinstance(state_data, dict):
|
| 157 |
+
continue
|
| 158 |
+
transitions = state_data.get('transitions', {})
|
| 159 |
+
if not transitions:
|
| 160 |
+
# Check if this looks like an intentional ending
|
| 161 |
+
state_lower = state_name.lower()
|
| 162 |
+
is_ending = (
|
| 163 |
+
state_lower == 'end' or
|
| 164 |
+
state_lower.startswith('end_') or
|
| 165 |
+
state_lower.startswith('ending_') or
|
| 166 |
+
'_end' in state_lower or
|
| 167 |
+
'_ending' in state_lower or
|
| 168 |
+
'finale' in state_lower or
|
| 169 |
+
'conclusion' in state_lower
|
| 170 |
+
)
|
| 171 |
+
if is_ending:
|
| 172 |
+
valid_endings.append(state_name)
|
| 173 |
+
else:
|
| 174 |
+
dead_ends.append(state_name)
|
| 175 |
+
|
| 176 |
+
if dead_ends:
|
| 177 |
+
warnings.append(f"Dead-end states (no exits): {dead_ends}")
|
| 178 |
+
|
| 179 |
+
# Calculate stats
|
| 180 |
+
stats = {
|
| 181 |
+
"total_states": len(all_states),
|
| 182 |
+
"valid_endings": len(valid_endings),
|
| 183 |
+
"reachable_states": len(targeted_states) + 1, # +1 for start
|
| 184 |
+
"orphaned_count": len(orphaned),
|
| 185 |
+
"dead_end_count": len(dead_ends),
|
| 186 |
+
"total_transitions": sum(
|
| 187 |
+
len(d.get('transitions', {}))
|
| 188 |
+
for d in data.values()
|
| 189 |
+
if isinstance(d, dict)
|
| 190 |
+
),
|
| 191 |
+
"total_choices": sum(
|
| 192 |
+
len(d.get('choices', []))
|
| 193 |
+
for d in data.values()
|
| 194 |
+
if isinstance(d, dict)
|
| 195 |
+
),
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
if stats["total_states"] > 0:
|
| 199 |
+
stats["reachability_pct"] = round(
|
| 200 |
+
(stats["reachable_states"] / stats["total_states"]) * 100, 1
|
| 201 |
+
)
|
| 202 |
+
else:
|
| 203 |
+
stats["reachability_pct"] = 0
|
| 204 |
+
|
| 205 |
+
return {
|
| 206 |
+
"errors": errors,
|
| 207 |
+
"warnings": warnings,
|
| 208 |
+
"stats": stats
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def generate_story_flags(config_input):
|
| 213 |
+
"""
|
| 214 |
+
Generate story-level flags/warnings (qualitative hints).
|
| 215 |
+
|
| 216 |
+
These are softer warnings about potential narrative issues.
|
| 217 |
+
"""
|
| 218 |
+
data, error = parse_config(config_input)
|
| 219 |
+
if error:
|
| 220 |
+
return [f"Cannot analyze: {error}"]
|
| 221 |
+
|
| 222 |
+
# Flatten nested configs
|
| 223 |
+
data, is_nested = flatten_nested_config(data)
|
| 224 |
+
|
| 225 |
+
flags = []
|
| 226 |
+
|
| 227 |
+
# Collect path lengths from each state
|
| 228 |
+
state_depths = {}
|
| 229 |
+
descriptions = {}
|
| 230 |
+
|
| 231 |
+
for state_name, state_data in data.items():
|
| 232 |
+
if not isinstance(state_data, dict):
|
| 233 |
+
continue
|
| 234 |
+
|
| 235 |
+
desc = state_data.get('description', '')
|
| 236 |
+
descriptions[state_name] = desc
|
| 237 |
+
choices = state_data.get('choices', [])
|
| 238 |
+
|
| 239 |
+
# Flag: Empty or very short description
|
| 240 |
+
if len(desc) < 20:
|
| 241 |
+
flags.append({
|
| 242 |
+
"type": "sparse_content",
|
| 243 |
+
"state": state_name,
|
| 244 |
+
"message": f"Very short description ({len(desc)} chars)",
|
| 245 |
+
"severity": "info"
|
| 246 |
+
})
|
| 247 |
+
|
| 248 |
+
# Flag: Many choices (complexity)
|
| 249 |
+
if len(choices) > 5:
|
| 250 |
+
flags.append({
|
| 251 |
+
"type": "high_complexity",
|
| 252 |
+
"state": state_name,
|
| 253 |
+
"message": f"Many choices ({len(choices)}) - may overwhelm player",
|
| 254 |
+
"severity": "info"
|
| 255 |
+
})
|
| 256 |
+
|
| 257 |
+
# Flag: Description doesn't mention any choices
|
| 258 |
+
# Note: This is just informational - good narrative often sets scene
|
| 259 |
+
# without literally mentioning the choice text
|
| 260 |
+
if choices and desc:
|
| 261 |
+
desc_lower = desc.lower()
|
| 262 |
+
mentioned = any(c.lower() in desc_lower for c in choices)
|
| 263 |
+
if not mentioned:
|
| 264 |
+
flags.append({
|
| 265 |
+
"type": "disconnected_choices",
|
| 266 |
+
"state": state_name,
|
| 267 |
+
"message": "Description doesn't reference any of the available choices",
|
| 268 |
+
"severity": "info" # Changed from warning - this is just a hint
|
| 269 |
+
})
|
| 270 |
+
|
| 271 |
+
# Flag: No media in state (if others have media)
|
| 272 |
+
media = state_data.get('media', [])
|
| 273 |
+
if not media:
|
| 274 |
+
# This is just tracked, we'll flag if inconsistent
|
| 275 |
+
pass
|
| 276 |
+
|
| 277 |
+
# Flag: Inconsistent media usage
|
| 278 |
+
states_with_media = sum(
|
| 279 |
+
1 for s, d in data.items()
|
| 280 |
+
if isinstance(d, dict) and d.get('media')
|
| 281 |
+
)
|
| 282 |
+
states_without_media = len([
|
| 283 |
+
s for s, d in data.items()
|
| 284 |
+
if isinstance(d, dict) and not d.get('media') and s != 'end'
|
| 285 |
+
])
|
| 286 |
+
|
| 287 |
+
if states_with_media > 0 and states_without_media > 0:
|
| 288 |
+
ratio = states_with_media / (states_with_media + states_without_media)
|
| 289 |
+
if 0.2 < ratio < 0.8:
|
| 290 |
+
flags.append({
|
| 291 |
+
"type": "inconsistent_media",
|
| 292 |
+
"state": "global",
|
| 293 |
+
"message": f"Inconsistent media: {states_with_media} states have media, {states_without_media} don't",
|
| 294 |
+
"severity": "info"
|
| 295 |
+
})
|
| 296 |
+
|
| 297 |
+
# Flag: Very different description lengths (tone/pacing inconsistency)
|
| 298 |
+
desc_lengths = [len(d) for d in descriptions.values() if d]
|
| 299 |
+
if desc_lengths:
|
| 300 |
+
avg_len = sum(desc_lengths) / len(desc_lengths)
|
| 301 |
+
for state_name, desc in descriptions.items():
|
| 302 |
+
if desc and len(desc) > avg_len * 3:
|
| 303 |
+
flags.append({
|
| 304 |
+
"type": "length_outlier",
|
| 305 |
+
"state": state_name,
|
| 306 |
+
"message": f"Description much longer than average ({len(desc)} vs avg {int(avg_len)})",
|
| 307 |
+
"severity": "info"
|
| 308 |
+
})
|
| 309 |
+
elif desc and len(desc) < avg_len * 0.3 and len(desc) > 0:
|
| 310 |
+
flags.append({
|
| 311 |
+
"type": "length_outlier",
|
| 312 |
+
"state": state_name,
|
| 313 |
+
"message": f"Description much shorter than average ({len(desc)} vs avg {int(avg_len)})",
|
| 314 |
+
"severity": "info"
|
| 315 |
+
})
|
| 316 |
+
|
| 317 |
+
return flags
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def generate_mermaid_diagram(config_input):
|
| 321 |
+
"""
|
| 322 |
+
Generate a Mermaid flowchart diagram from config.
|
| 323 |
+
|
| 324 |
+
Returns mermaid code string.
|
| 325 |
+
"""
|
| 326 |
+
data, error = parse_config(config_input)
|
| 327 |
+
if error:
|
| 328 |
+
return f"%%Error: {error}"
|
| 329 |
+
|
| 330 |
+
# Flatten nested configs
|
| 331 |
+
data, is_nested = flatten_nested_config(data)
|
| 332 |
+
|
| 333 |
+
lines = ["flowchart TD"]
|
| 334 |
+
|
| 335 |
+
# Mermaid reserved keywords that need escaping
|
| 336 |
+
RESERVED_KEYWORDS = {'end', 'graph', 'subgraph', 'direction', 'click', 'style', 'class', 'linkStyle', 'classDef'}
|
| 337 |
+
|
| 338 |
+
def make_safe_name(name):
|
| 339 |
+
"""Convert state name to mermaid-safe identifier."""
|
| 340 |
+
safe = name.replace(' ', '_').replace('-', '_')
|
| 341 |
+
# Escape reserved keywords by prefixing with 'state_'
|
| 342 |
+
if safe.lower() in RESERVED_KEYWORDS:
|
| 343 |
+
safe = f"state_{safe}"
|
| 344 |
+
return safe
|
| 345 |
+
|
| 346 |
+
# Track states for styling
|
| 347 |
+
all_states = set(data.keys())
|
| 348 |
+
targeted_states = set()
|
| 349 |
+
dead_ends = set()
|
| 350 |
+
|
| 351 |
+
# First pass: identify targeted states
|
| 352 |
+
for state_name, state_data in data.items():
|
| 353 |
+
if not isinstance(state_data, dict):
|
| 354 |
+
continue
|
| 355 |
+
for target in state_data.get('transitions', {}).values():
|
| 356 |
+
if target in all_states:
|
| 357 |
+
targeted_states.add(target)
|
| 358 |
+
elif '_' in target:
|
| 359 |
+
parts = target.rsplit('_', 1)
|
| 360 |
+
if parts[0] in all_states:
|
| 361 |
+
targeted_states.add(parts[0])
|
| 362 |
+
|
| 363 |
+
# Generate nodes and edges
|
| 364 |
+
for state_name, state_data in data.items():
|
| 365 |
+
if not isinstance(state_data, dict):
|
| 366 |
+
continue
|
| 367 |
+
|
| 368 |
+
# Sanitize state name for mermaid
|
| 369 |
+
safe_name = make_safe_name(state_name)
|
| 370 |
+
|
| 371 |
+
# Get short description for node label
|
| 372 |
+
desc = state_data.get('description', state_name)
|
| 373 |
+
short_desc = desc[:30] + '...' if len(desc) > 30 else desc
|
| 374 |
+
short_desc = short_desc.replace('"', "'").replace('\n', ' ')
|
| 375 |
+
|
| 376 |
+
# Node shape based on type
|
| 377 |
+
if state_name == 'end':
|
| 378 |
+
lines.append(f' {safe_name}(["{short_desc}"])')
|
| 379 |
+
elif state_name in ['start', 'intro', 'beginning', 'arrival'] or state_name == list(data.keys())[0]:
|
| 380 |
+
lines.append(f' {safe_name}[/"{short_desc}"\\]')
|
| 381 |
+
else:
|
| 382 |
+
lines.append(f' {safe_name}["{short_desc}"]')
|
| 383 |
+
|
| 384 |
+
# Edges for transitions
|
| 385 |
+
transitions = state_data.get('transitions', {})
|
| 386 |
+
if not transitions:
|
| 387 |
+
dead_ends.add(state_name)
|
| 388 |
+
|
| 389 |
+
for choice, target in transitions.items():
|
| 390 |
+
safe_target = make_safe_name(target)
|
| 391 |
+
safe_choice = choice[:20].replace('"', "'")
|
| 392 |
+
lines.append(f' {safe_name} -->|"{safe_choice}"| {safe_target}')
|
| 393 |
+
|
| 394 |
+
# Add styling
|
| 395 |
+
lines.append("")
|
| 396 |
+
lines.append(" %% Styling")
|
| 397 |
+
|
| 398 |
+
# Start state styling
|
| 399 |
+
first_state = list(data.keys())[0] if data else None
|
| 400 |
+
if first_state:
|
| 401 |
+
safe_first = make_safe_name(first_state)
|
| 402 |
+
lines.append(f" style {safe_first} fill:#90EE90")
|
| 403 |
+
|
| 404 |
+
# End state styling
|
| 405 |
+
if 'end' in all_states:
|
| 406 |
+
lines.append(f" style {make_safe_name('end')} fill:#FFB6C1")
|
| 407 |
+
|
| 408 |
+
# Dead end styling
|
| 409 |
+
for de in dead_ends:
|
| 410 |
+
if de != 'end':
|
| 411 |
+
safe_de = make_safe_name(de)
|
| 412 |
+
lines.append(f" style {safe_de} fill:#FFD700")
|
| 413 |
+
|
| 414 |
+
# Orphaned state styling
|
| 415 |
+
orphaned = all_states - targeted_states - {'end'}
|
| 416 |
+
if first_state:
|
| 417 |
+
orphaned.discard(first_state)
|
| 418 |
+
for orph in orphaned:
|
| 419 |
+
safe_orph = make_safe_name(orph)
|
| 420 |
+
lines.append(f" style {safe_orph} fill:#FF6347")
|
| 421 |
+
|
| 422 |
+
return "\n".join(lines)
|
| 423 |
+
|
| 424 |
+
|
| 425 |
+
def get_config_health_summary(config_input):
|
| 426 |
+
"""
|
| 427 |
+
Get a comprehensive health summary of the config.
|
| 428 |
+
|
| 429 |
+
Returns formatted string for display.
|
| 430 |
+
"""
|
| 431 |
+
validation = validate_branching(config_input)
|
| 432 |
+
flags = generate_story_flags(config_input)
|
| 433 |
+
|
| 434 |
+
stats = validation['stats']
|
| 435 |
+
errors = validation['errors']
|
| 436 |
+
warnings = validation['warnings']
|
| 437 |
+
|
| 438 |
+
lines = []
|
| 439 |
+
|
| 440 |
+
# Overall health score (simple heuristic)
|
| 441 |
+
error_penalty = len(errors) * 20
|
| 442 |
+
warning_penalty = len(warnings) * 5
|
| 443 |
+
flag_penalty = len([f for f in flags if f.get('severity') == 'warning']) * 3
|
| 444 |
+
|
| 445 |
+
health_score = max(0, 100 - error_penalty - warning_penalty - flag_penalty)
|
| 446 |
+
|
| 447 |
+
lines.append(f"## Config Health Score: {health_score}/100")
|
| 448 |
+
lines.append("")
|
| 449 |
+
|
| 450 |
+
# Stats
|
| 451 |
+
lines.append("### Statistics")
|
| 452 |
+
lines.append(f"- Total States: {stats.get('total_states', 0)}")
|
| 453 |
+
lines.append(f"- Reachability: {stats.get('reachability_pct', 0)}%")
|
| 454 |
+
lines.append(f"- Total Transitions: {stats.get('total_transitions', 0)}")
|
| 455 |
+
lines.append(f"- Valid Endings: {stats.get('valid_endings', 0)}")
|
| 456 |
+
lines.append(f"- Dead Ends (unintentional): {stats.get('dead_end_count', 0)}")
|
| 457 |
+
lines.append(f"- Orphaned States: {stats.get('orphaned_count', 0)}")
|
| 458 |
+
lines.append("")
|
| 459 |
+
|
| 460 |
+
# Errors
|
| 461 |
+
if errors:
|
| 462 |
+
lines.append("### Errors (Must Fix)")
|
| 463 |
+
for err in errors:
|
| 464 |
+
lines.append(f"- {err}")
|
| 465 |
+
lines.append("")
|
| 466 |
+
|
| 467 |
+
# Warnings
|
| 468 |
+
if warnings:
|
| 469 |
+
lines.append("### Warnings")
|
| 470 |
+
for warn in warnings:
|
| 471 |
+
lines.append(f"- {warn}")
|
| 472 |
+
lines.append("")
|
| 473 |
+
|
| 474 |
+
# Story Flags
|
| 475 |
+
if flags:
|
| 476 |
+
lines.append("### Story Flags")
|
| 477 |
+
for flag in flags:
|
| 478 |
+
severity_icon = "" if flag.get('severity') == 'warning' else ""
|
| 479 |
+
lines.append(f"- {severity_icon} [{flag.get('state')}] {flag.get('message')}")
|
| 480 |
+
lines.append("")
|
| 481 |
+
|
| 482 |
+
if not errors and not warnings and not flags:
|
| 483 |
+
lines.append("*No issues detected*")
|
| 484 |
+
|
| 485 |
+
return "\n".join(lines)
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def get_llm_cohesion_prompts():
|
| 489 |
+
"""
|
| 490 |
+
Return preset prompts for LLM-assisted story cohesion checks.
|
| 491 |
+
"""
|
| 492 |
+
return {
|
| 493 |
+
"summarize_paths": """Analyze this game config and summarize each possible path through the story.
|
| 494 |
+
For each major branch, describe:
|
| 495 |
+
1. The path taken (which states)
|
| 496 |
+
2. Key events/decisions
|
| 497 |
+
3. The ending reached
|
| 498 |
+
|
| 499 |
+
Config:
|
| 500 |
+
{config}""",
|
| 501 |
+
|
| 502 |
+
"find_contradictions": """Review this game config for narrative contradictions or plot holes.
|
| 503 |
+
Look for:
|
| 504 |
+
1. Events that contradict each other across branches
|
| 505 |
+
2. Character actions that don't make sense
|
| 506 |
+
3. World/setting inconsistencies
|
| 507 |
+
4. Logical impossibilities
|
| 508 |
+
|
| 509 |
+
Config:
|
| 510 |
+
{config}""",
|
| 511 |
+
|
| 512 |
+
"tone_check": """Analyze the tone and mood consistency across this game config.
|
| 513 |
+
Check:
|
| 514 |
+
1. Does the writing style stay consistent?
|
| 515 |
+
2. Are there jarring tone shifts?
|
| 516 |
+
3. Does the atmosphere match the story type?
|
| 517 |
+
|
| 518 |
+
Config:
|
| 519 |
+
{config}""",
|
| 520 |
+
|
| 521 |
+
"improve_descriptions": """Review the descriptions in this game config and suggest improvements.
|
| 522 |
+
For each state, note if the description:
|
| 523 |
+
1. Sets the scene adequately
|
| 524 |
+
2. Connects to the available choices
|
| 525 |
+
3. Maintains narrative flow
|
| 526 |
+
|
| 527 |
+
Config:
|
| 528 |
+
{config}""",
|
| 529 |
+
|
| 530 |
+
"suggest_branches": """Analyze this game config and suggest additional branching opportunities.
|
| 531 |
+
Look for:
|
| 532 |
+
1. States where more choices would make sense
|
| 533 |
+
2. Missing consequences for actions
|
| 534 |
+
3. Opportunities for alternate paths
|
| 535 |
+
|
| 536 |
+
Config:
|
| 537 |
+
{config}"""
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
|
| 541 |
+
# ============================================================
|
| 542 |
+
# LOGIC GATES VALIDATION
|
| 543 |
+
# ============================================================
|
| 544 |
+
|
| 545 |
+
# Valid keys for condition expressions
|
| 546 |
+
VALID_CONDITION_KEYS = {
|
| 547 |
+
'and', 'or', 'not',
|
| 548 |
+
'has_item', 'not_has_item',
|
| 549 |
+
'met_person', 'not_met_person',
|
| 550 |
+
'flag', 'not_flag',
|
| 551 |
+
'visited', 'not_visited', 'discovered',
|
| 552 |
+
'mission_complete', 'mission_active', 'mission_failed',
|
| 553 |
+
'money', 'counter',
|
| 554 |
+
'knowledge', 'knowledge_value',
|
| 555 |
+
'reputation', 'visit_count'
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
# Valid keys for effect specifications
|
| 559 |
+
VALID_EFFECT_KEYS = {
|
| 560 |
+
'add_item', 'remove_item',
|
| 561 |
+
'add_money', 'remove_money', 'set_money',
|
| 562 |
+
'add_person',
|
| 563 |
+
'add_location', 'visit_location',
|
| 564 |
+
'set_flag', 'clear_flag', 'toggle_flag',
|
| 565 |
+
'set_counter', 'increment', 'decrement',
|
| 566 |
+
'set_knowledge', 'remove_knowledge',
|
| 567 |
+
'start_mission', 'complete_mission', 'fail_mission', 'update_mission',
|
| 568 |
+
'adjust_reputation', 'set_reputation'
|
| 569 |
+
}
|
| 570 |
+
|
| 571 |
+
# Valid keys for dynamic transitions
|
| 572 |
+
VALID_TRANSITION_KEYS = {
|
| 573 |
+
'random', 'random_from',
|
| 574 |
+
'if', 'then', 'else',
|
| 575 |
+
'conditions', 'default'
|
| 576 |
+
}
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def validate_condition(condition, path, issues):
|
| 580 |
+
"""
|
| 581 |
+
Recursively validate a condition expression.
|
| 582 |
+
|
| 583 |
+
Args:
|
| 584 |
+
condition: The condition to validate
|
| 585 |
+
path: String path for error reporting
|
| 586 |
+
issues: List to append issues to
|
| 587 |
+
"""
|
| 588 |
+
if condition is None or condition == {}:
|
| 589 |
+
return
|
| 590 |
+
|
| 591 |
+
if isinstance(condition, str):
|
| 592 |
+
return # Simple flag name - valid
|
| 593 |
+
|
| 594 |
+
if not isinstance(condition, dict):
|
| 595 |
+
issues.append({
|
| 596 |
+
"type": "invalid_condition",
|
| 597 |
+
"path": path,
|
| 598 |
+
"message": f"Condition must be dict or string, got {type(condition).__name__}"
|
| 599 |
+
})
|
| 600 |
+
return
|
| 601 |
+
|
| 602 |
+
for key in condition:
|
| 603 |
+
if key not in VALID_CONDITION_KEYS:
|
| 604 |
+
issues.append({
|
| 605 |
+
"type": "unknown_condition_key",
|
| 606 |
+
"path": path,
|
| 607 |
+
"message": f"Unknown condition key: '{key}'"
|
| 608 |
+
})
|
| 609 |
+
|
| 610 |
+
# Recurse into compound conditions
|
| 611 |
+
if key in ('and', 'or'):
|
| 612 |
+
if not isinstance(condition[key], list):
|
| 613 |
+
issues.append({
|
| 614 |
+
"type": "invalid_condition",
|
| 615 |
+
"path": path,
|
| 616 |
+
"message": f"'{key}' must be a list"
|
| 617 |
+
})
|
| 618 |
+
else:
|
| 619 |
+
for i, sub in enumerate(condition[key]):
|
| 620 |
+
validate_condition(sub, f"{path}.{key}[{i}]", issues)
|
| 621 |
+
|
| 622 |
+
if key == 'not':
|
| 623 |
+
validate_condition(condition[key], f"{path}.not", issues)
|
| 624 |
+
|
| 625 |
+
# Validate numeric comparisons
|
| 626 |
+
if key in ('money', 'counter', 'reputation', 'visit_count'):
|
| 627 |
+
val = condition[key]
|
| 628 |
+
if isinstance(val, dict):
|
| 629 |
+
valid_comparisons = {'gte', 'lte', 'gt', 'lt', 'eq', 'neq', 'npc', 'state'}
|
| 630 |
+
for cmp_key in val:
|
| 631 |
+
if cmp_key not in valid_comparisons and not isinstance(val[cmp_key], dict):
|
| 632 |
+
issues.append({
|
| 633 |
+
"type": "invalid_comparison",
|
| 634 |
+
"path": f"{path}.{key}",
|
| 635 |
+
"message": f"Unknown comparison operator: '{cmp_key}'"
|
| 636 |
+
})
|
| 637 |
+
|
| 638 |
+
|
| 639 |
+
def validate_effects(effects, path, issues):
|
| 640 |
+
"""
|
| 641 |
+
Validate effect specifications.
|
| 642 |
+
|
| 643 |
+
Args:
|
| 644 |
+
effects: The effects dict to validate
|
| 645 |
+
path: String path for error reporting
|
| 646 |
+
issues: List to append issues to
|
| 647 |
+
"""
|
| 648 |
+
if not effects or not isinstance(effects, dict):
|
| 649 |
+
return
|
| 650 |
+
|
| 651 |
+
for key in effects:
|
| 652 |
+
if key not in VALID_EFFECT_KEYS:
|
| 653 |
+
issues.append({
|
| 654 |
+
"type": "unknown_effect_key",
|
| 655 |
+
"path": path,
|
| 656 |
+
"message": f"Unknown effect key: '{key}'"
|
| 657 |
+
})
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
def validate_transition(transition, path, issues):
|
| 661 |
+
"""
|
| 662 |
+
Validate transition specification.
|
| 663 |
+
|
| 664 |
+
Args:
|
| 665 |
+
transition: The transition spec to validate
|
| 666 |
+
path: String path for error reporting
|
| 667 |
+
issues: List to append issues to
|
| 668 |
+
"""
|
| 669 |
+
if isinstance(transition, str):
|
| 670 |
+
return # Simple string target - valid (basic check only)
|
| 671 |
+
|
| 672 |
+
if not isinstance(transition, dict):
|
| 673 |
+
issues.append({
|
| 674 |
+
"type": "invalid_transition",
|
| 675 |
+
"path": path,
|
| 676 |
+
"message": f"Transition must be string or dict, got {type(transition).__name__}"
|
| 677 |
+
})
|
| 678 |
+
return
|
| 679 |
+
|
| 680 |
+
# Check for unknown keys
|
| 681 |
+
for key in transition:
|
| 682 |
+
if key not in VALID_TRANSITION_KEYS:
|
| 683 |
+
issues.append({
|
| 684 |
+
"type": "unknown_transition_key",
|
| 685 |
+
"path": path,
|
| 686 |
+
"message": f"Unknown transition key: '{key}'"
|
| 687 |
+
})
|
| 688 |
+
|
| 689 |
+
# Validate random weights
|
| 690 |
+
if 'random' in transition:
|
| 691 |
+
weights = transition['random']
|
| 692 |
+
if not isinstance(weights, list):
|
| 693 |
+
issues.append({
|
| 694 |
+
"type": "invalid_transition",
|
| 695 |
+
"path": path,
|
| 696 |
+
"message": "'random' must be a list of [state, weight] pairs"
|
| 697 |
+
})
|
| 698 |
+
else:
|
| 699 |
+
total = 0
|
| 700 |
+
for item in weights:
|
| 701 |
+
if not isinstance(item, list) or len(item) != 2:
|
| 702 |
+
issues.append({
|
| 703 |
+
"type": "invalid_transition",
|
| 704 |
+
"path": path,
|
| 705 |
+
"message": f"Random item must be [state, weight]: {item}"
|
| 706 |
+
})
|
| 707 |
+
else:
|
| 708 |
+
total += item[1] if isinstance(item[1], (int, float)) else 0
|
| 709 |
+
|
| 710 |
+
if abs(total - 1.0) > 0.01 and total > 0:
|
| 711 |
+
issues.append({
|
| 712 |
+
"type": "weight_warning",
|
| 713 |
+
"path": path,
|
| 714 |
+
"message": f"Random weights sum to {total}, not 1.0 (will be normalized)"
|
| 715 |
+
})
|
| 716 |
+
|
| 717 |
+
# Validate random_from pool
|
| 718 |
+
if 'random_from' in transition:
|
| 719 |
+
pool = transition['random_from']
|
| 720 |
+
if not isinstance(pool, list) or len(pool) == 0:
|
| 721 |
+
issues.append({
|
| 722 |
+
"type": "invalid_transition",
|
| 723 |
+
"path": path,
|
| 724 |
+
"message": "'random_from' must be a non-empty list"
|
| 725 |
+
})
|
| 726 |
+
|
| 727 |
+
# Validate conditional transition
|
| 728 |
+
if 'if' in transition:
|
| 729 |
+
validate_condition(transition['if'], f"{path}.if", issues)
|
| 730 |
+
if 'then' not in transition and 'else' not in transition:
|
| 731 |
+
issues.append({
|
| 732 |
+
"type": "invalid_transition",
|
| 733 |
+
"path": path,
|
| 734 |
+
"message": "Conditional transition needs 'then' or 'else'"
|
| 735 |
+
})
|
| 736 |
+
if 'then' in transition:
|
| 737 |
+
validate_transition(transition['then'], f"{path}.then", issues)
|
| 738 |
+
if 'else' in transition:
|
| 739 |
+
validate_transition(transition['else'], f"{path}.else", issues)
|
| 740 |
+
|
| 741 |
+
# Validate chained conditions
|
| 742 |
+
if 'conditions' in transition:
|
| 743 |
+
has_default = False
|
| 744 |
+
for i, cond_block in enumerate(transition['conditions']):
|
| 745 |
+
if 'default' in cond_block:
|
| 746 |
+
has_default = True
|
| 747 |
+
validate_transition(cond_block['default'], f"{path}.conditions[{i}].default", issues)
|
| 748 |
+
elif 'if' in cond_block:
|
| 749 |
+
validate_condition(cond_block['if'], f"{path}.conditions[{i}].if", issues)
|
| 750 |
+
if 'then' in cond_block:
|
| 751 |
+
validate_transition(cond_block['then'], f"{path}.conditions[{i}].then", issues)
|
| 752 |
+
else:
|
| 753 |
+
issues.append({
|
| 754 |
+
"type": "invalid_transition",
|
| 755 |
+
"path": f"{path}.conditions[{i}]",
|
| 756 |
+
"message": "Condition block needs 'if' or 'default'"
|
| 757 |
+
})
|
| 758 |
+
|
| 759 |
+
if not has_default:
|
| 760 |
+
issues.append({
|
| 761 |
+
"type": "missing_default",
|
| 762 |
+
"path": path,
|
| 763 |
+
"message": "Chained conditions should have a 'default' fallback"
|
| 764 |
+
})
|
| 765 |
+
|
| 766 |
+
|
| 767 |
+
def validate_logic_gates(config_input):
|
| 768 |
+
"""
|
| 769 |
+
Validate all logic gates (conditions, effects, transitions) in a config.
|
| 770 |
+
|
| 771 |
+
Returns list of validation issues.
|
| 772 |
+
"""
|
| 773 |
+
data, error = parse_config(config_input)
|
| 774 |
+
if error:
|
| 775 |
+
return [{"type": "parse_error", "path": "", "message": error}]
|
| 776 |
+
|
| 777 |
+
data, _ = flatten_nested_config(data)
|
| 778 |
+
issues = []
|
| 779 |
+
|
| 780 |
+
for state_name, state_data in data.items():
|
| 781 |
+
if not isinstance(state_data, dict):
|
| 782 |
+
continue
|
| 783 |
+
|
| 784 |
+
base_path = state_name
|
| 785 |
+
|
| 786 |
+
# Validate choice_config conditions
|
| 787 |
+
choice_config = state_data.get('choice_config', {})
|
| 788 |
+
for choice, config in choice_config.items():
|
| 789 |
+
if isinstance(config, dict) and 'condition' in config:
|
| 790 |
+
validate_condition(
|
| 791 |
+
config['condition'],
|
| 792 |
+
f"{base_path}.choice_config.{choice}.condition",
|
| 793 |
+
issues
|
| 794 |
+
)
|
| 795 |
+
|
| 796 |
+
# Validate effects
|
| 797 |
+
effects = state_data.get('effects', {})
|
| 798 |
+
for choice, effect_spec in effects.items():
|
| 799 |
+
if isinstance(effect_spec, dict):
|
| 800 |
+
validate_effects(effect_spec, f"{base_path}.effects.{choice}", issues)
|
| 801 |
+
|
| 802 |
+
# Validate on_enter effects
|
| 803 |
+
on_enter = state_data.get('on_enter')
|
| 804 |
+
if on_enter and isinstance(on_enter, dict):
|
| 805 |
+
validate_effects(on_enter, f"{base_path}.on_enter", issues)
|
| 806 |
+
|
| 807 |
+
# Validate transitions (including dynamic ones)
|
| 808 |
+
transitions = state_data.get('transitions', {})
|
| 809 |
+
for choice, trans in transitions.items():
|
| 810 |
+
validate_transition(trans, f"{base_path}.transitions.{choice}", issues)
|
| 811 |
+
|
| 812 |
+
# Validate encounter_chance
|
| 813 |
+
encounter = state_data.get('encounter_chance')
|
| 814 |
+
if encounter and isinstance(encounter, dict):
|
| 815 |
+
if 'bypass_conditions' in encounter:
|
| 816 |
+
validate_condition(
|
| 817 |
+
encounter['bypass_conditions'],
|
| 818 |
+
f"{base_path}.encounter_chance.bypass_conditions",
|
| 819 |
+
issues
|
| 820 |
+
)
|
| 821 |
+
if 'probability' in encounter:
|
| 822 |
+
prob = encounter['probability']
|
| 823 |
+
if not isinstance(prob, (int, float)) or prob < 0 or prob > 1:
|
| 824 |
+
issues.append({
|
| 825 |
+
"type": "invalid_probability",
|
| 826 |
+
"path": f"{base_path}.encounter_chance.probability",
|
| 827 |
+
"message": f"Probability must be between 0 and 1, got {prob}"
|
| 828 |
+
})
|
| 829 |
+
if 'pool' in encounter:
|
| 830 |
+
pool = encounter['pool']
|
| 831 |
+
if not isinstance(pool, list) or len(pool) == 0:
|
| 832 |
+
issues.append({
|
| 833 |
+
"type": "invalid_encounter_pool",
|
| 834 |
+
"path": f"{base_path}.encounter_chance.pool",
|
| 835 |
+
"message": "Encounter pool must be a non-empty list"
|
| 836 |
+
})
|
| 837 |
+
|
| 838 |
+
return issues
|
| 839 |
+
|
| 840 |
+
|
| 841 |
+
def get_logic_gates_summary(config_input):
|
| 842 |
+
"""
|
| 843 |
+
Get a summary of logic gates validation results.
|
| 844 |
+
|
| 845 |
+
Returns formatted string for display.
|
| 846 |
+
"""
|
| 847 |
+
issues = validate_logic_gates(config_input)
|
| 848 |
+
|
| 849 |
+
if not issues:
|
| 850 |
+
return "**Logic Gates Validation:** No issues found"
|
| 851 |
+
|
| 852 |
+
lines = ["**Logic Gates Validation Issues:**", ""]
|
| 853 |
+
|
| 854 |
+
errors = [i for i in issues if i['type'] not in ('weight_warning', 'missing_default')]
|
| 855 |
+
warnings = [i for i in issues if i['type'] in ('weight_warning', 'missing_default')]
|
| 856 |
+
|
| 857 |
+
if errors:
|
| 858 |
+
lines.append("### Errors")
|
| 859 |
+
for issue in errors:
|
| 860 |
+
lines.append(f"- [{issue['path']}] {issue['message']}")
|
| 861 |
+
lines.append("")
|
| 862 |
+
|
| 863 |
+
if warnings:
|
| 864 |
+
lines.append("### Warnings")
|
| 865 |
+
for issue in warnings:
|
| 866 |
+
lines.append(f"- [{issue['path']}] {issue['message']}")
|
| 867 |
+
lines.append("")
|
| 868 |
+
|
| 869 |
+
return "\n".join(lines)
|
| 870 |
+
|
| 871 |
+
|
| 872 |
+
# ==================== ACTIONABLE VALIDATION FUNCTIONS ====================
|
| 873 |
+
|
| 874 |
+
def get_validation_with_fixes(config_input):
|
| 875 |
+
"""
|
| 876 |
+
Enhanced validation that returns errors with suggested fixes.
|
| 877 |
+
|
| 878 |
+
Returns dict with:
|
| 879 |
+
- issues: List of issues with fix suggestions
|
| 880 |
+
- quick_fixes: Dict of auto-fixable issues
|
| 881 |
+
- stats: Config statistics
|
| 882 |
+
"""
|
| 883 |
+
data, error = parse_config(config_input)
|
| 884 |
+
if error:
|
| 885 |
+
return {
|
| 886 |
+
"issues": [{"type": "parse_error", "message": error, "fixable": False}],
|
| 887 |
+
"quick_fixes": {},
|
| 888 |
+
"stats": {}
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
data, is_nested = flatten_nested_config(data)
|
| 892 |
+
|
| 893 |
+
issues = []
|
| 894 |
+
quick_fixes = {}
|
| 895 |
+
all_states = set(data.keys())
|
| 896 |
+
|
| 897 |
+
for state_name, state_data in data.items():
|
| 898 |
+
if not isinstance(state_data, dict):
|
| 899 |
+
continue
|
| 900 |
+
|
| 901 |
+
choices = state_data.get('choices', [])
|
| 902 |
+
transitions = state_data.get('transitions', {})
|
| 903 |
+
|
| 904 |
+
# Issue: Broken transitions
|
| 905 |
+
for choice, target in transitions.items():
|
| 906 |
+
target_exists = False
|
| 907 |
+
|
| 908 |
+
if target in all_states:
|
| 909 |
+
target_exists = True
|
| 910 |
+
elif '_' in target:
|
| 911 |
+
# Check nested format
|
| 912 |
+
for split_pos in range(len(target)):
|
| 913 |
+
if target[split_pos] == '_':
|
| 914 |
+
loc = target[:split_pos]
|
| 915 |
+
st = target[split_pos+1:]
|
| 916 |
+
if loc in all_states:
|
| 917 |
+
target_exists = True
|
| 918 |
+
break
|
| 919 |
+
|
| 920 |
+
if not target_exists:
|
| 921 |
+
# Suggest similar state names
|
| 922 |
+
suggestions = find_similar_states(target, all_states)
|
| 923 |
+
issues.append({
|
| 924 |
+
"type": "broken_transition",
|
| 925 |
+
"state": state_name,
|
| 926 |
+
"choice": choice,
|
| 927 |
+
"target": target,
|
| 928 |
+
"message": f"Transition '{choice}' points to non-existent state '{target}'",
|
| 929 |
+
"suggestions": suggestions,
|
| 930 |
+
"fixable": len(suggestions) > 0,
|
| 931 |
+
"fix_type": "replace_target"
|
| 932 |
+
})
|
| 933 |
+
|
| 934 |
+
if suggestions:
|
| 935 |
+
quick_fixes[f"{state_name}|{choice}"] = {
|
| 936 |
+
"action": "replace_transition_target",
|
| 937 |
+
"old_target": target,
|
| 938 |
+
"suggested_target": suggestions[0],
|
| 939 |
+
"all_suggestions": suggestions
|
| 940 |
+
}
|
| 941 |
+
|
| 942 |
+
# Issue: Choice without transition
|
| 943 |
+
choice_set = set(choices) if choices else set()
|
| 944 |
+
transition_keys = set(transitions.keys())
|
| 945 |
+
missing_transitions = choice_set - transition_keys
|
| 946 |
+
|
| 947 |
+
for missing in missing_transitions:
|
| 948 |
+
# Suggest next logical state
|
| 949 |
+
suggestions = suggest_next_states(state_name, all_states)
|
| 950 |
+
issues.append({
|
| 951 |
+
"type": "missing_transition",
|
| 952 |
+
"state": state_name,
|
| 953 |
+
"choice": missing,
|
| 954 |
+
"message": f"Choice '{missing}' has no transition defined",
|
| 955 |
+
"suggestions": suggestions,
|
| 956 |
+
"fixable": True,
|
| 957 |
+
"fix_type": "add_transition"
|
| 958 |
+
})
|
| 959 |
+
|
| 960 |
+
quick_fixes[f"{state_name}|{missing}|add"] = {
|
| 961 |
+
"action": "add_transition",
|
| 962 |
+
"choice": missing,
|
| 963 |
+
"suggested_target": suggestions[0] if suggestions else "end"
|
| 964 |
+
}
|
| 965 |
+
|
| 966 |
+
# Issue: Dead end (not an ending state)
|
| 967 |
+
if not transitions and not is_ending_state(state_name):
|
| 968 |
+
issues.append({
|
| 969 |
+
"type": "dead_end",
|
| 970 |
+
"state": state_name,
|
| 971 |
+
"message": f"State '{state_name}' has no exits and doesn't appear to be an ending",
|
| 972 |
+
"suggestions": ["Add transitions to continue the story", "Rename to indicate it's an ending (e.g., ending_X)"],
|
| 973 |
+
"fixable": True,
|
| 974 |
+
"fix_type": "add_ending_transition"
|
| 975 |
+
})
|
| 976 |
+
|
| 977 |
+
quick_fixes[f"{state_name}|dead_end"] = {
|
| 978 |
+
"action": "convert_to_ending",
|
| 979 |
+
"add_choices": ["restart"],
|
| 980 |
+
"add_transitions": {"restart": list(all_states)[0]}
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
# Issue: Orphaned state
|
| 984 |
+
targeted_states = set()
|
| 985 |
+
for s, sd in data.items():
|
| 986 |
+
if isinstance(sd, dict):
|
| 987 |
+
for t in sd.get('transitions', {}).values():
|
| 988 |
+
targeted_states.add(t)
|
| 989 |
+
if '_' in t:
|
| 990 |
+
parts = t.split('_', 1)
|
| 991 |
+
targeted_states.add(parts[0])
|
| 992 |
+
|
| 993 |
+
first_state = list(data.keys())[0] if data else None
|
| 994 |
+
if state_name not in targeted_states and state_name != first_state and not is_ending_state(state_name):
|
| 995 |
+
# Find states that could link here
|
| 996 |
+
potential_sources = find_potential_source_states(state_name, data)
|
| 997 |
+
issues.append({
|
| 998 |
+
"type": "orphaned_state",
|
| 999 |
+
"state": state_name,
|
| 1000 |
+
"message": f"State '{state_name}' is not reachable from any other state",
|
| 1001 |
+
"suggestions": [f"Add a transition from '{s}' to '{state_name}'" for s in potential_sources[:3]],
|
| 1002 |
+
"fixable": len(potential_sources) > 0,
|
| 1003 |
+
"fix_type": "add_incoming_transition"
|
| 1004 |
+
})
|
| 1005 |
+
|
| 1006 |
+
# Calculate stats
|
| 1007 |
+
stats = {
|
| 1008 |
+
"total_states": len(all_states),
|
| 1009 |
+
"total_issues": len(issues),
|
| 1010 |
+
"fixable_issues": len([i for i in issues if i.get("fixable")]),
|
| 1011 |
+
"issue_types": {}
|
| 1012 |
+
}
|
| 1013 |
+
|
| 1014 |
+
for issue in issues:
|
| 1015 |
+
issue_type = issue["type"]
|
| 1016 |
+
stats["issue_types"][issue_type] = stats["issue_types"].get(issue_type, 0) + 1
|
| 1017 |
+
|
| 1018 |
+
return {
|
| 1019 |
+
"issues": issues,
|
| 1020 |
+
"quick_fixes": quick_fixes,
|
| 1021 |
+
"stats": stats
|
| 1022 |
+
}
|
| 1023 |
+
|
| 1024 |
+
|
| 1025 |
+
def find_similar_states(target, all_states, threshold=0.6):
|
| 1026 |
+
"""Find states with similar names to the target."""
|
| 1027 |
+
suggestions = []
|
| 1028 |
+
|
| 1029 |
+
for state in all_states:
|
| 1030 |
+
# Simple similarity: common characters ratio
|
| 1031 |
+
common = set(target.lower()) & set(state.lower())
|
| 1032 |
+
similarity = len(common) / max(len(target), len(state))
|
| 1033 |
+
|
| 1034 |
+
if similarity >= threshold:
|
| 1035 |
+
suggestions.append(state)
|
| 1036 |
+
|
| 1037 |
+
# Also check if target is a substring or vice versa
|
| 1038 |
+
if target.lower() in state.lower() or state.lower() in target.lower():
|
| 1039 |
+
if state not in suggestions:
|
| 1040 |
+
suggestions.append(state)
|
| 1041 |
+
|
| 1042 |
+
return suggestions[:5]
|
| 1043 |
+
|
| 1044 |
+
|
| 1045 |
+
def suggest_next_states(current_state, all_states):
|
| 1046 |
+
"""Suggest logical next states based on naming patterns."""
|
| 1047 |
+
suggestions = []
|
| 1048 |
+
|
| 1049 |
+
# If current state has a number, suggest the next number
|
| 1050 |
+
import re
|
| 1051 |
+
match = re.search(r'(\d+)$', current_state)
|
| 1052 |
+
if match:
|
| 1053 |
+
num = int(match.group(1))
|
| 1054 |
+
next_name = current_state[:match.start()] + str(num + 1)
|
| 1055 |
+
if next_name in all_states:
|
| 1056 |
+
suggestions.append(next_name)
|
| 1057 |
+
|
| 1058 |
+
# Suggest 'end' if it exists
|
| 1059 |
+
if 'end' in all_states:
|
| 1060 |
+
suggestions.append('end')
|
| 1061 |
+
|
| 1062 |
+
# Suggest states that start with similar prefix
|
| 1063 |
+
prefix = current_state.split('_')[0] if '_' in current_state else current_state[:3]
|
| 1064 |
+
for state in all_states:
|
| 1065 |
+
if state.startswith(prefix) and state != current_state and state not in suggestions:
|
| 1066 |
+
suggestions.append(state)
|
| 1067 |
+
|
| 1068 |
+
return suggestions[:5]
|
| 1069 |
+
|
| 1070 |
+
|
| 1071 |
+
def find_potential_source_states(target_state, data):
|
| 1072 |
+
"""Find states that could logically link to the target."""
|
| 1073 |
+
potential = []
|
| 1074 |
+
|
| 1075 |
+
# States with similar names/prefixes
|
| 1076 |
+
target_prefix = target_state.split('_')[0] if '_' in target_state else target_state[:3]
|
| 1077 |
+
|
| 1078 |
+
for state_name, state_data in data.items():
|
| 1079 |
+
if state_name == target_state:
|
| 1080 |
+
continue
|
| 1081 |
+
if not isinstance(state_data, dict):
|
| 1082 |
+
continue
|
| 1083 |
+
|
| 1084 |
+
# Check if this state has room for more choices
|
| 1085 |
+
choices = state_data.get('choices', [])
|
| 1086 |
+
if len(choices) < 4: # Can add more choices
|
| 1087 |
+
# Prefer states with similar prefix
|
| 1088 |
+
if state_name.startswith(target_prefix) or target_state.startswith(state_name.split('_')[0]):
|
| 1089 |
+
potential.insert(0, state_name)
|
| 1090 |
+
else:
|
| 1091 |
+
potential.append(state_name)
|
| 1092 |
+
|
| 1093 |
+
return potential
|
| 1094 |
+
|
| 1095 |
+
|
| 1096 |
+
def is_ending_state(state_name):
|
| 1097 |
+
"""Check if a state name indicates it's an ending."""
|
| 1098 |
+
name_lower = state_name.lower()
|
| 1099 |
+
return (
|
| 1100 |
+
name_lower == 'end' or
|
| 1101 |
+
name_lower.startswith('end_') or
|
| 1102 |
+
name_lower.startswith('ending_') or
|
| 1103 |
+
'_end' in name_lower or
|
| 1104 |
+
'_ending' in name_lower or
|
| 1105 |
+
'finale' in name_lower or
|
| 1106 |
+
'conclusion' in name_lower or
|
| 1107 |
+
'victory' in name_lower or
|
| 1108 |
+
'defeat' in name_lower or
|
| 1109 |
+
'death' in name_lower
|
| 1110 |
+
)
|
| 1111 |
+
|
| 1112 |
+
|
| 1113 |
+
def apply_quick_fix(config_input, fix_key, fix_data):
|
| 1114 |
+
"""
|
| 1115 |
+
Apply a quick fix to the config.
|
| 1116 |
+
|
| 1117 |
+
Args:
|
| 1118 |
+
config_input: JSON config string
|
| 1119 |
+
fix_key: Key identifying the fix (e.g., "state|choice")
|
| 1120 |
+
fix_data: Fix data from quick_fixes dict
|
| 1121 |
+
|
| 1122 |
+
Returns:
|
| 1123 |
+
Updated JSON config string
|
| 1124 |
+
"""
|
| 1125 |
+
data, error = parse_config(config_input)
|
| 1126 |
+
if error:
|
| 1127 |
+
return config_input
|
| 1128 |
+
|
| 1129 |
+
data, is_nested = flatten_nested_config(data)
|
| 1130 |
+
|
| 1131 |
+
action = fix_data.get("action")
|
| 1132 |
+
|
| 1133 |
+
if action == "replace_transition_target":
|
| 1134 |
+
parts = fix_key.split("|")
|
| 1135 |
+
if len(parts) >= 2:
|
| 1136 |
+
state_name, choice = parts[0], parts[1]
|
| 1137 |
+
if state_name in data and "transitions" in data[state_name]:
|
| 1138 |
+
data[state_name]["transitions"][choice] = fix_data["suggested_target"]
|
| 1139 |
+
|
| 1140 |
+
elif action == "add_transition":
|
| 1141 |
+
parts = fix_key.split("|")
|
| 1142 |
+
if len(parts) >= 2:
|
| 1143 |
+
state_name, choice = parts[0], parts[1]
|
| 1144 |
+
if state_name in data:
|
| 1145 |
+
if "transitions" not in data[state_name]:
|
| 1146 |
+
data[state_name]["transitions"] = {}
|
| 1147 |
+
data[state_name]["transitions"][choice] = fix_data["suggested_target"]
|
| 1148 |
+
|
| 1149 |
+
elif action == "convert_to_ending":
|
| 1150 |
+
parts = fix_key.split("|")
|
| 1151 |
+
state_name = parts[0]
|
| 1152 |
+
if state_name in data:
|
| 1153 |
+
data[state_name]["choices"] = fix_data.get("add_choices", [])
|
| 1154 |
+
data[state_name]["transitions"] = fix_data.get("add_transitions", {})
|
| 1155 |
+
|
| 1156 |
+
return json.dumps(data, indent=2)
|
| 1157 |
+
|
| 1158 |
+
|
| 1159 |
+
def apply_all_quick_fixes(config_input):
|
| 1160 |
+
"""
|
| 1161 |
+
Apply all auto-fixable issues to the config.
|
| 1162 |
+
|
| 1163 |
+
Returns:
|
| 1164 |
+
Tuple of (updated_config, fixes_applied_count, fixes_log)
|
| 1165 |
+
"""
|
| 1166 |
+
validation = get_validation_with_fixes(config_input)
|
| 1167 |
+
quick_fixes = validation["quick_fixes"]
|
| 1168 |
+
|
| 1169 |
+
if not quick_fixes:
|
| 1170 |
+
return config_input, 0, ["No auto-fixable issues found"]
|
| 1171 |
+
|
| 1172 |
+
current_config = config_input
|
| 1173 |
+
fixes_log = []
|
| 1174 |
+
|
| 1175 |
+
for fix_key, fix_data in quick_fixes.items():
|
| 1176 |
+
try:
|
| 1177 |
+
current_config = apply_quick_fix(current_config, fix_key, fix_data)
|
| 1178 |
+
fixes_log.append(f"Applied: {fix_data['action']} for {fix_key}")
|
| 1179 |
+
except Exception as e:
|
| 1180 |
+
fixes_log.append(f"Failed: {fix_data['action']} for {fix_key} - {str(e)}")
|
| 1181 |
+
|
| 1182 |
+
return current_config, len(quick_fixes), fixes_log
|
| 1183 |
+
|
| 1184 |
+
|
| 1185 |
+
def get_state_issues_map(config_input):
|
| 1186 |
+
"""
|
| 1187 |
+
Get a mapping of state names to their issues for inline highlighting.
|
| 1188 |
+
|
| 1189 |
+
Returns:
|
| 1190 |
+
Dict mapping state_name -> list of issues
|
| 1191 |
+
"""
|
| 1192 |
+
validation = get_validation_with_fixes(config_input)
|
| 1193 |
+
issues = validation["issues"]
|
| 1194 |
+
|
| 1195 |
+
state_issues = {}
|
| 1196 |
+
for issue in issues:
|
| 1197 |
+
state = issue.get("state", "global")
|
| 1198 |
+
if state not in state_issues:
|
| 1199 |
+
state_issues[state] = []
|
| 1200 |
+
state_issues[state].append(issue)
|
| 1201 |
+
|
| 1202 |
+
return state_issues
|
| 1203 |
+
|
| 1204 |
+
|
| 1205 |
+
def format_issues_for_display(issues):
|
| 1206 |
+
"""
|
| 1207 |
+
Format issues list as markdown for Gradio display.
|
| 1208 |
+
"""
|
| 1209 |
+
if not issues:
|
| 1210 |
+
return "No issues found!"
|
| 1211 |
+
|
| 1212 |
+
lines = ["## Validation Issues\n"]
|
| 1213 |
+
|
| 1214 |
+
# Group by type
|
| 1215 |
+
by_type = {}
|
| 1216 |
+
for issue in issues:
|
| 1217 |
+
t = issue["type"]
|
| 1218 |
+
if t not in by_type:
|
| 1219 |
+
by_type[t] = []
|
| 1220 |
+
by_type[t].append(issue)
|
| 1221 |
+
|
| 1222 |
+
type_icons = {
|
| 1223 |
+
"broken_transition": "🔴",
|
| 1224 |
+
"missing_transition": "🟠",
|
| 1225 |
+
"dead_end": "🟡",
|
| 1226 |
+
"orphaned_state": "⚪",
|
| 1227 |
+
"parse_error": "❌"
|
| 1228 |
+
}
|
| 1229 |
+
|
| 1230 |
+
type_names = {
|
| 1231 |
+
"broken_transition": "Broken Transitions",
|
| 1232 |
+
"missing_transition": "Missing Transitions",
|
| 1233 |
+
"dead_end": "Dead Ends",
|
| 1234 |
+
"orphaned_state": "Orphaned States",
|
| 1235 |
+
"parse_error": "Parse Errors"
|
| 1236 |
+
}
|
| 1237 |
+
|
| 1238 |
+
for issue_type, type_issues in by_type.items():
|
| 1239 |
+
icon = type_icons.get(issue_type, "⚠️")
|
| 1240 |
+
name = type_names.get(issue_type, issue_type)
|
| 1241 |
+
lines.append(f"### {icon} {name} ({len(type_issues)})\n")
|
| 1242 |
+
|
| 1243 |
+
for issue in type_issues:
|
| 1244 |
+
state = issue.get("state", "")
|
| 1245 |
+
message = issue.get("message", "")
|
| 1246 |
+
fixable = "✅ Auto-fixable" if issue.get("fixable") else ""
|
| 1247 |
+
|
| 1248 |
+
lines.append(f"- **{state}**: {message} {fixable}")
|
| 1249 |
+
|
| 1250 |
+
suggestions = issue.get("suggestions", [])
|
| 1251 |
+
if suggestions:
|
| 1252 |
+
lines.append(f" - Suggestions: {', '.join(suggestions[:3])}")
|
| 1253 |
+
|
| 1254 |
+
lines.append("")
|
| 1255 |
+
|
| 1256 |
+
return "\n".join(lines)
|
|
@@ -0,0 +1,935 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Demo examples for the LLM Improvement Loop workflow.
|
| 2 |
+
|
| 3 |
+
This module provides example configs showing the progression through
|
| 4 |
+
the Mystery Deepening workflow:
|
| 5 |
+
- Round 1: Environment (plant clues in descriptions)
|
| 6 |
+
- Round 2: Characters (hidden motivations)
|
| 7 |
+
- Round 3: Choices (investigation options)
|
| 8 |
+
- Round 4: Tension (red herrings and reveals)
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
# Original starter config (7 states, basic mystery)
|
| 12 |
+
DEMO_MYSTERY_STARTER = '''{
|
| 13 |
+
"investigation": {
|
| 14 |
+
"crime_scene": {
|
| 15 |
+
"description": "A dimly lit office. Papers scattered everywhere. A broken window.",
|
| 16 |
+
"choices": ["Examine the desk", "Check the window", "Leave"],
|
| 17 |
+
"transitions": {
|
| 18 |
+
"Examine the desk": "desk_clue",
|
| 19 |
+
"Check the window": "window_clue",
|
| 20 |
+
"Leave": "hallway"
|
| 21 |
+
}
|
| 22 |
+
},
|
| 23 |
+
"desk_clue": {
|
| 24 |
+
"description": "The desk has a half-written letter and an empty coffee cup.",
|
| 25 |
+
"choices": ["Read the letter", "Back to scene"],
|
| 26 |
+
"transitions": {
|
| 27 |
+
"Read the letter": "letter_reveal",
|
| 28 |
+
"Back to scene": "crime_scene"
|
| 29 |
+
}
|
| 30 |
+
},
|
| 31 |
+
"window_clue": {
|
| 32 |
+
"description": "Glass shards on the inside. Someone broke in from outside.",
|
| 33 |
+
"choices": ["Look outside", "Back to scene"],
|
| 34 |
+
"transitions": {
|
| 35 |
+
"Look outside": "outside_view",
|
| 36 |
+
"Back to scene": "crime_scene"
|
| 37 |
+
}
|
| 38 |
+
},
|
| 39 |
+
"letter_reveal": {
|
| 40 |
+
"description": "The letter mentions a meeting tonight. It's unfinished.",
|
| 41 |
+
"choices": ["Continue investigating"],
|
| 42 |
+
"transitions": {
|
| 43 |
+
"Continue investigating": "crime_scene"
|
| 44 |
+
}
|
| 45 |
+
},
|
| 46 |
+
"outside_view": {
|
| 47 |
+
"description": "Footprints in the mud lead toward the parking lot.",
|
| 48 |
+
"choices": ["Follow the footprints", "Back inside"],
|
| 49 |
+
"transitions": {
|
| 50 |
+
"Follow the footprints": "hallway",
|
| 51 |
+
"Back inside": "crime_scene"
|
| 52 |
+
}
|
| 53 |
+
},
|
| 54 |
+
"hallway": {
|
| 55 |
+
"description": "The hallway is quiet. A security guard approaches.",
|
| 56 |
+
"choices": ["Talk to guard", "Return to office"],
|
| 57 |
+
"transitions": {
|
| 58 |
+
"Talk to guard": "guard_talk",
|
| 59 |
+
"Return to office": "crime_scene"
|
| 60 |
+
}
|
| 61 |
+
},
|
| 62 |
+
"guard_talk": {
|
| 63 |
+
"description": "The guard says he heard nothing unusual tonight.",
|
| 64 |
+
"choices": ["Press for details", "Thank him and leave"],
|
| 65 |
+
"transitions": {
|
| 66 |
+
"Press for details": "guard_talk",
|
| 67 |
+
"Thank him and leave": "hallway"
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
}'''
|
| 72 |
+
|
| 73 |
+
# After Round 1: Environment focus - plant clues in descriptions
|
| 74 |
+
DEMO_AFTER_ROUND_1 = '''{
|
| 75 |
+
"investigation": {
|
| 76 |
+
"crime_scene": {
|
| 77 |
+
"description": "A dimly lit corner office on the 14th floor. Papers are scattered across the mahogany desk and floor - financial reports, all dated from last month. A broken window lets in cold night air, and you notice the glass shards fell inward, onto an expensive Persian rug now stained with what looks like coffee. A framed photo lies face-down near the overturned chair.",
|
| 78 |
+
"choices": ["Examine the desk", "Check the window", "Look at the photo", "Leave"],
|
| 79 |
+
"transitions": {
|
| 80 |
+
"Examine the desk": "desk_clue",
|
| 81 |
+
"Check the window": "window_clue",
|
| 82 |
+
"Look at the photo": "photo_clue",
|
| 83 |
+
"Leave": "hallway"
|
| 84 |
+
}
|
| 85 |
+
},
|
| 86 |
+
"desk_clue": {
|
| 87 |
+
"description": "The desk has a half-written letter addressed to 'M' - the handwriting grows increasingly shaky toward the end. An empty coffee cup sits beside it, a faint lipstick mark on the rim despite this being a man's office. The desk drawer is slightly ajar, revealing a corner of what looks like a passport.",
|
| 88 |
+
"choices": ["Read the letter", "Check the drawer", "Examine the coffee cup", "Back to scene"],
|
| 89 |
+
"transitions": {
|
| 90 |
+
"Read the letter": "letter_reveal",
|
| 91 |
+
"Check the drawer": "drawer_contents",
|
| 92 |
+
"Examine the coffee cup": "coffee_clue",
|
| 93 |
+
"Back to scene": "crime_scene"
|
| 94 |
+
}
|
| 95 |
+
},
|
| 96 |
+
"window_clue": {
|
| 97 |
+
"description": "Glass shards cover the inside of the windowsill and floor - definitely broken from outside. But something's wrong: the window latch is still locked from the inside. On the exterior ledge, you spot a small piece of torn fabric caught on a nail, dark blue like a uniform.",
|
| 98 |
+
"choices": ["Look outside", "Examine the fabric", "Back to scene"],
|
| 99 |
+
"transitions": {
|
| 100 |
+
"Look outside": "outside_view",
|
| 101 |
+
"Examine the fabric": "fabric_clue",
|
| 102 |
+
"Back to scene": "crime_scene"
|
| 103 |
+
}
|
| 104 |
+
},
|
| 105 |
+
"photo_clue": {
|
| 106 |
+
"description": "The frame's glass is cracked. The photo shows two men shaking hands at what looks like a company event - one is clearly the office's occupant, the other wears a security uniform. On the back, someone has written 'Partners - Nov 2019' in neat handwriting.",
|
| 107 |
+
"choices": ["Keep the photo", "Put it back", "Back to scene"],
|
| 108 |
+
"transitions": {
|
| 109 |
+
"Keep the photo": "crime_scene",
|
| 110 |
+
"Put it back": "crime_scene",
|
| 111 |
+
"Back to scene": "crime_scene"
|
| 112 |
+
}
|
| 113 |
+
},
|
| 114 |
+
"letter_reveal": {
|
| 115 |
+
"description": "The letter reads: 'Dear M, I can't keep doing this. They're going to find out about the transfers, and when they do...' The writing stops abruptly. A single drop of dried coffee stains the page, as if the cup was knocked over mid-sentence.",
|
| 116 |
+
"choices": ["Continue investigating"],
|
| 117 |
+
"transitions": {
|
| 118 |
+
"Continue investigating": "crime_scene"
|
| 119 |
+
}
|
| 120 |
+
},
|
| 121 |
+
"drawer_contents": {
|
| 122 |
+
"description": "Inside the drawer: a passport with several recent stamps to the Cayman Islands, a burner phone with a cracked screen, and a sticky note with a phone number and the words 'CALL BEFORE FRIDAY' underlined three times.",
|
| 123 |
+
"choices": ["Take the phone", "Note the number", "Back to desk"],
|
| 124 |
+
"transitions": {
|
| 125 |
+
"Take the phone": "desk_clue",
|
| 126 |
+
"Note the number": "desk_clue",
|
| 127 |
+
"Back to desk": "desk_clue"
|
| 128 |
+
}
|
| 129 |
+
},
|
| 130 |
+
"coffee_clue": {
|
| 131 |
+
"description": "The lipstick shade is a distinctive coral pink. The cup still has dregs at the bottom - the coffee smells slightly bitter, different from regular office coffee. There's a faint residue around the rim that doesn't look like dried cream.",
|
| 132 |
+
"choices": ["Back to desk"],
|
| 133 |
+
"transitions": {
|
| 134 |
+
"Back to desk": "desk_clue"
|
| 135 |
+
}
|
| 136 |
+
},
|
| 137 |
+
"fabric_clue": {
|
| 138 |
+
"description": "The fabric is from a security uniform - you can see part of a stitched logo. It's torn at an angle suggesting someone climbed up, not down. But why would security break in through a window?",
|
| 139 |
+
"choices": ["Back to window"],
|
| 140 |
+
"transitions": {
|
| 141 |
+
"Back to window": "window_clue"
|
| 142 |
+
}
|
| 143 |
+
},
|
| 144 |
+
"outside_view": {
|
| 145 |
+
"description": "Footprints in the mud lead toward the parking lot - two sets, one coming and one going. The incoming prints are deep, as if carrying something heavy. The outgoing prints are lighter, faster, heading toward a side exit that bypasses the main security desk.",
|
| 146 |
+
"choices": ["Follow the footprints", "Back inside"],
|
| 147 |
+
"transitions": {
|
| 148 |
+
"Follow the footprints": "hallway",
|
| 149 |
+
"Back inside": "crime_scene"
|
| 150 |
+
}
|
| 151 |
+
},
|
| 152 |
+
"hallway": {
|
| 153 |
+
"description": "The hallway is quiet, lit by flickering fluorescent lights. A security camera in the corner has a small red light - but you notice it's pointing at the wall, not the office door. A security guard approaches, his boots leaving muddy prints on the polished floor.",
|
| 154 |
+
"choices": ["Talk to guard", "Check the camera", "Return to office"],
|
| 155 |
+
"transitions": {
|
| 156 |
+
"Talk to guard": "guard_talk",
|
| 157 |
+
"Check the camera": "camera_check",
|
| 158 |
+
"Return to office": "crime_scene"
|
| 159 |
+
}
|
| 160 |
+
},
|
| 161 |
+
"camera_check": {
|
| 162 |
+
"description": "The camera has been manually rotated away from the office door. The adjustment screw is loose, recently turned. Whoever did this knew exactly which camera to move.",
|
| 163 |
+
"choices": ["Back to hallway"],
|
| 164 |
+
"transitions": {
|
| 165 |
+
"Back to hallway": "hallway"
|
| 166 |
+
}
|
| 167 |
+
},
|
| 168 |
+
"guard_talk": {
|
| 169 |
+
"description": "The guard - his name tag reads 'Martinez' - says he heard nothing unusual tonight. His uniform is slightly disheveled, and there's a small tear near his sleeve. He keeps glancing at his watch.",
|
| 170 |
+
"choices": ["Press for details", "Thank him and leave"],
|
| 171 |
+
"transitions": {
|
| 172 |
+
"Press for details": "guard_pressed",
|
| 173 |
+
"Thank him and leave": "hallway"
|
| 174 |
+
}
|
| 175 |
+
},
|
| 176 |
+
"guard_pressed": {
|
| 177 |
+
"description": "'Look, I was on my rounds, okay? The 14th floor wasn't scheduled until midnight.' He shifts his weight nervously. 'Ask Jenkins at the front desk - he'll confirm my times.' But you notice his boots match the muddy prints in the hallway.",
|
| 178 |
+
"choices": ["Confront about the boots", "Let it go", "Ask about the photo"],
|
| 179 |
+
"transitions": {
|
| 180 |
+
"Confront about the boots": "confrontation",
|
| 181 |
+
"Let it go": "hallway",
|
| 182 |
+
"Ask about the photo": "photo_question"
|
| 183 |
+
}
|
| 184 |
+
},
|
| 185 |
+
"confrontation": {
|
| 186 |
+
"description": "Martinez's face pales. 'Those... those aren't... look, I can explain.' His hand moves toward his radio, then stops. 'Not here. Meet me in the parking garage in ten minutes. There are things you need to know about what really happened in that office.'",
|
| 187 |
+
"choices": ["Agree to meet", "Detain him now"],
|
| 188 |
+
"transitions": {
|
| 189 |
+
"Agree to meet": "hallway",
|
| 190 |
+
"Detain him now": "hallway"
|
| 191 |
+
}
|
| 192 |
+
},
|
| 193 |
+
"photo_question": {
|
| 194 |
+
"description": "At the mention of the photo, Martinez goes very still. 'Where did you find that? That was supposed to be... nevermind.' He takes a deep breath. 'Richardson and I go way back. But that was before I knew what he was really doing with those accounts.'",
|
| 195 |
+
"choices": ["Ask about the accounts", "Back to hallway"],
|
| 196 |
+
"transitions": {
|
| 197 |
+
"Ask about the accounts": "accounts_info",
|
| 198 |
+
"Back to hallway": "hallway"
|
| 199 |
+
}
|
| 200 |
+
},
|
| 201 |
+
"accounts_info": {
|
| 202 |
+
"description": "'He was moving money - lots of it. Company funds to offshore accounts. I found out six months ago and... I should have reported it. But he promised me a cut.' Martinez looks defeated. 'Tonight, someone else found out too. Someone who doesn't negotiate.'",
|
| 203 |
+
"choices": ["Who else knew?", "Back to hallway"],
|
| 204 |
+
"transitions": {
|
| 205 |
+
"Who else knew?": "hallway",
|
| 206 |
+
"Back to hallway": "hallway"
|
| 207 |
+
}
|
| 208 |
+
}
|
| 209 |
+
}
|
| 210 |
+
}'''
|
| 211 |
+
|
| 212 |
+
# After Round 2: Characters focus - hidden motivations, personality through dialogue
|
| 213 |
+
DEMO_AFTER_ROUND_2 = '''{
|
| 214 |
+
"investigation": {
|
| 215 |
+
"crime_scene": {
|
| 216 |
+
"description": "A dimly lit corner office on the 14th floor. Papers are scattered across the mahogany desk and floor - financial reports, all dated from last month. A broken window lets in cold night air, and you notice the glass shards fell inward, onto an expensive Persian rug now stained with what looks like coffee. A framed photo lies face-down near the overturned chair. On the desk, a half-eaten sandwich suggests Richardson left in a hurry - or was interrupted mid-meal.",
|
| 217 |
+
"choices": ["Examine the desk", "Check the window", "Look at the photo", "Leave"],
|
| 218 |
+
"transitions": {
|
| 219 |
+
"Examine the desk": "desk_clue",
|
| 220 |
+
"Check the window": "window_clue",
|
| 221 |
+
"Look at the photo": "photo_clue",
|
| 222 |
+
"Leave": "hallway"
|
| 223 |
+
}
|
| 224 |
+
},
|
| 225 |
+
"desk_clue": {
|
| 226 |
+
"description": "The desk has a half-written letter addressed to 'M' - the handwriting grows increasingly shaky toward the end, betraying Richardson's fear. An empty coffee cup sits beside it, a faint coral lipstick mark on the rim - Richardson's wife wears red, not coral. The desk drawer is slightly ajar, revealing a corner of what looks like a passport. A sticky note on the monitor reads 'MIRANDA CALLED - URGENT' in what looks like a secretary's handwriting.",
|
| 227 |
+
"choices": ["Read the letter", "Check the drawer", "Examine the coffee cup", "Back to scene"],
|
| 228 |
+
"transitions": {
|
| 229 |
+
"Read the letter": "letter_reveal",
|
| 230 |
+
"Check the drawer": "drawer_contents",
|
| 231 |
+
"Examine the coffee cup": "coffee_clue",
|
| 232 |
+
"Back to scene": "crime_scene"
|
| 233 |
+
}
|
| 234 |
+
},
|
| 235 |
+
"window_clue": {
|
| 236 |
+
"description": "Glass shards cover the inside of the windowsill and floor - definitely broken from outside. But something's wrong: the window latch is still locked from the inside. On the exterior ledge, you spot a small piece of torn fabric caught on a nail, dark blue like a security uniform. The tear pattern suggests someone climbed up, not down - but Martinez is the only guard on duty tonight.",
|
| 237 |
+
"choices": ["Look outside", "Examine the fabric", "Back to scene"],
|
| 238 |
+
"transitions": {
|
| 239 |
+
"Look outside": "outside_view",
|
| 240 |
+
"Examine the fabric": "fabric_clue",
|
| 241 |
+
"Back to scene": "crime_scene"
|
| 242 |
+
}
|
| 243 |
+
},
|
| 244 |
+
"photo_clue": {
|
| 245 |
+
"description": "The frame's glass is cracked. The photo shows two men shaking hands at the company Christmas party 2019 - Richardson in an expensive suit, Martinez in his security uniform, both grinning like old friends. On the back, someone has written 'Partners in crime - Nov 2019' in feminine handwriting, with a small heart. The same coral lipstick has left a faint kiss mark in the corner.",
|
| 246 |
+
"choices": ["Keep the photo", "Put it back", "Back to scene"],
|
| 247 |
+
"transitions": {
|
| 248 |
+
"Keep the photo": "crime_scene",
|
| 249 |
+
"Put it back": "crime_scene",
|
| 250 |
+
"Back to scene": "crime_scene"
|
| 251 |
+
}
|
| 252 |
+
},
|
| 253 |
+
"letter_reveal": {
|
| 254 |
+
"description": "The letter reads: 'Dear M, I can't keep doing this. They're going to find out about the transfers, and when they do, neither of us will be safe. Elena is getting suspicious - she found the Cayman statements. I need to disappear, but I can't leave you behind. Meet me at the usual place at midnight. If I'm not there by 12:30, assume the worst and run. Don't trust Martinez anymore - he's been asking questions. All my love, R.' The writing stops mid-sentence, a single drop of dried coffee staining the page.",
|
| 255 |
+
"choices": ["Continue investigating"],
|
| 256 |
+
"transitions": {
|
| 257 |
+
"Continue investigating": "crime_scene"
|
| 258 |
+
}
|
| 259 |
+
},
|
| 260 |
+
"drawer_contents": {
|
| 261 |
+
"description": "Inside the drawer: a passport with several recent stamps to the Cayman Islands - the last trip was three days ago. A burner phone with a cracked screen shows the last call was to 'M' at 11:47 PM. A sticky note reads 'CALL BEFORE FRIDAY - E knows' with 'E' underlined three times. Elena, Richardson's wife, is known for her jealous streak and family money.",
|
| 262 |
+
"choices": ["Take the phone", "Note the number", "Back to desk"],
|
| 263 |
+
"transitions": {
|
| 264 |
+
"Take the phone": "desk_clue",
|
| 265 |
+
"Note the number": "desk_clue",
|
| 266 |
+
"Back to desk": "desk_clue"
|
| 267 |
+
}
|
| 268 |
+
},
|
| 269 |
+
"coffee_clue": {
|
| 270 |
+
"description": "The coral lipstick shade is distinctive - 'Sunset Kiss' by Luxe, the kind sold at high-end department stores. Richardson's secretary Miranda wears this exact shade. The cup still has dregs at the bottom - the coffee smells bitter, with an underlying chemical note. There's a faint crystalline residue around the rim. Either Miranda has unusual taste in coffee additives, or someone wanted to make sure Richardson didn't finish his drink.",
|
| 271 |
+
"choices": ["Back to desk"],
|
| 272 |
+
"transitions": {
|
| 273 |
+
"Back to desk": "desk_clue"
|
| 274 |
+
}
|
| 275 |
+
},
|
| 276 |
+
"fabric_clue": {
|
| 277 |
+
"description": "The fabric is from a security uniform - you can see part of a stitched company logo. It's torn at an angle suggesting someone climbed up, not down. The thread count matches Martinez's uniform exactly, but the tear on his sleeve is on the right arm. This fabric came from a left sleeve. Either Martinez changed uniforms, or there's another security guard involved.",
|
| 278 |
+
"choices": ["Back to window"],
|
| 279 |
+
"transitions": {
|
| 280 |
+
"Back to window": "window_clue"
|
| 281 |
+
}
|
| 282 |
+
},
|
| 283 |
+
"outside_view": {
|
| 284 |
+
"description": "Footprints in the mud lead toward the parking lot - two sets, one coming and one going. The incoming prints are deep, size 11 work boots, carrying something heavy. The outgoing prints are lighter, faster, size 7 women's heels, heading toward a side exit. A cigarette butt near the window smells fresh - Virginia Slims, a women's brand. Miranda smokes Virginia Slims.",
|
| 285 |
+
"choices": ["Follow the footprints", "Back inside"],
|
| 286 |
+
"transitions": {
|
| 287 |
+
"Follow the footprints": "hallway",
|
| 288 |
+
"Back inside": "crime_scene"
|
| 289 |
+
}
|
| 290 |
+
},
|
| 291 |
+
"hallway": {
|
| 292 |
+
"description": "The hallway is quiet, lit by flickering fluorescent lights. A security camera in the corner has a small red light - but you notice it's pointing at the wall, not the office door. Martinez approaches, his boots leaving muddy prints on the polished floor. His jaw is tight, eyes darting. 'Detective. Didn't expect you so soon.' His hand rests near his radio, as if ready to call for backup - or warn someone.",
|
| 293 |
+
"choices": ["Talk to guard", "Check the camera", "Return to office"],
|
| 294 |
+
"transitions": {
|
| 295 |
+
"Talk to guard": "guard_talk",
|
| 296 |
+
"Check the camera": "camera_check",
|
| 297 |
+
"Return to office": "crime_scene"
|
| 298 |
+
}
|
| 299 |
+
},
|
| 300 |
+
"camera_check": {
|
| 301 |
+
"description": "The camera has been manually rotated away from the office door. The adjustment screw is loose, recently turned - you can still see fresh scratches on the metal. Whoever did this knew exactly which camera to move. The timestamp log shows the adjustment happened at 11:42 PM, five minutes before the estimated time of the incident. Martinez clocked in at 11:30 PM.",
|
| 302 |
+
"choices": ["Back to hallway"],
|
| 303 |
+
"transitions": {
|
| 304 |
+
"Back to hallway": "hallway"
|
| 305 |
+
}
|
| 306 |
+
},
|
| 307 |
+
"guard_talk": {
|
| 308 |
+
"description": "'Evening, Detective.' Martinez's voice is steady but his hands aren't - they're shaking slightly. His name tag reads 'Martinez, J.' - the same J.M. initialed on the back of the photo in Richardson's office. His uniform is disheveled, with a small tear near his left sleeve. 'I heard nothing unusual tonight. Just doing my rounds.' He keeps glancing at his watch. It's 12:15 AM.",
|
| 309 |
+
"choices": ["Press for details", "Thank him and leave"],
|
| 310 |
+
"transitions": {
|
| 311 |
+
"Press for details": "guard_pressed",
|
| 312 |
+
"Thank him and leave": "hallway"
|
| 313 |
+
}
|
| 314 |
+
},
|
| 315 |
+
"guard_pressed": {
|
| 316 |
+
"description": "'Look, I was on my rounds, okay?' Martinez's voice rises, defensive. 'The 14th floor wasn't scheduled until midnight.' He shifts his weight, mud flaking from his boots. 'Ask Jenkins at the front desk - he'll confirm my times.' His phone buzzes. He glances at it and his face goes pale. 'I need to... excuse me.' He starts to turn away. His boots are definitely size 11.",
|
| 317 |
+
"choices": ["Confront about the boots", "Let it go", "Ask about the photo"],
|
| 318 |
+
"transitions": {
|
| 319 |
+
"Confront about the boots": "confrontation",
|
| 320 |
+
"Let it go": "hallway",
|
| 321 |
+
"Ask about the photo": "photo_question"
|
| 322 |
+
}
|
| 323 |
+
},
|
| 324 |
+
"confrontation": {
|
| 325 |
+
"description": "Martinez's face drains of color. 'Those... those aren't...' He takes a shaky breath. 'Fine. Yes, I was up there tonight. But not to hurt him - to WARN him.' His voice cracks. 'Richardson and I... we were partners. In more ways than one. Someone found out about the money, about us. They called Richardson at 11:30, threatened to tell his wife everything unless he paid. I went up to help him figure out what to do. But when I got there...' He stops, unable to continue.",
|
| 326 |
+
"choices": ["Agree to meet", "Detain him now"],
|
| 327 |
+
"transitions": {
|
| 328 |
+
"Agree to meet": "hallway",
|
| 329 |
+
"Detain him now": "hallway"
|
| 330 |
+
}
|
| 331 |
+
},
|
| 332 |
+
"photo_question": {
|
| 333 |
+
"description": "At the mention of the photo, Martinez goes rigid. 'Where did you find that? That was supposed to be... Miranda was supposed to get rid of it.' He realizes what he's said too late. 'Look, Richardson and I were close, okay? Closer than his wife knew. Miranda found out - she's been covering for us, but lately she's been acting strange. Jealous, maybe. She kept saying if she couldn't have him, no one would.'",
|
| 334 |
+
"choices": ["Ask about the accounts", "Back to hallway"],
|
| 335 |
+
"transitions": {
|
| 336 |
+
"Ask about the accounts": "accounts_info",
|
| 337 |
+
"Back to hallway": "hallway"
|
| 338 |
+
}
|
| 339 |
+
},
|
| 340 |
+
"accounts_info": {
|
| 341 |
+
"description": "'He was moving money - lots of it. Company funds to offshore accounts. We were going to leave together, start fresh in Costa Rica.' Martinez's voice is hollow. 'I found out six months ago what he was doing. Should have reported it. But he promised we'd run away, disappear. Tonight was supposed to be the night.' He looks at his watch again - 12:28 AM. 'Someone else found out. Someone who doesn't make deals. Elena Richardson has family in organized crime. She called Richardson at 11:30 PM. I heard her voice through the door.'",
|
| 342 |
+
"choices": ["Who else knew?", "Back to hallway"],
|
| 343 |
+
"transitions": {
|
| 344 |
+
"Who else knew?": "hallway",
|
| 345 |
+
"Back to hallway": "hallway"
|
| 346 |
+
}
|
| 347 |
+
},
|
| 348 |
+
"miranda_lead": {
|
| 349 |
+
"description": "You find Miranda Chen at her desk on the 12th floor, still typing despite the late hour. Her coral lipstick is freshly applied, and she startles when she sees you. 'Detective! I didn't hear about... is Mr. Richardson alright?' Her voice is too high, too practiced. On her desk sits an empty coffee cup - the same brand as the one in Richardson's office. A Virginia Slims pack peeks from her purse.",
|
| 350 |
+
"choices": ["Ask about Richardson", "Ask about the coffee", "Leave"],
|
| 351 |
+
"transitions": {
|
| 352 |
+
"Ask about Richardson": "miranda_confession",
|
| 353 |
+
"Ask about the coffee": "miranda_coffee",
|
| 354 |
+
"Leave": "hallway"
|
| 355 |
+
}
|
| 356 |
+
},
|
| 357 |
+
"miranda_confession": {
|
| 358 |
+
"description": "'He was going to leave.' Miranda's composure cracks. 'After everything I did for him - covering up the affair with Martinez, hiding the money transfers, lying to Elena when she called. And he was going to take HIM instead of me.' Her hands tremble. 'I brought him coffee at 11:40. We talked. I told him I knew about the midnight flight. He laughed. Said I wasn't his type.' She looks at you with hollow eyes. 'I didn't mean for... the pills were just supposed to make him sleepy. Elena's man must have arrived while he was drowsy.'",
|
| 359 |
+
"choices": ["Continue investigating"],
|
| 360 |
+
"transitions": {
|
| 361 |
+
"Continue investigating": "hallway"
|
| 362 |
+
}
|
| 363 |
+
},
|
| 364 |
+
"miranda_coffee": {
|
| 365 |
+
"description": "'I always bring Mr. Richardson his evening coffee. It's part of my job.' Miranda's smile is too bright. 'Tonight was no different.' But her eyes flick to her purse. Inside, you can see an empty prescription bottle - the label reads 'Alprazolam.' Enough dissolved in coffee would make anyone confused, disoriented. Easy prey.",
|
| 366 |
+
"choices": ["Confront about pills", "Back away"],
|
| 367 |
+
"transitions": {
|
| 368 |
+
"Confront about pills": "miranda_confession",
|
| 369 |
+
"Back away": "hallway"
|
| 370 |
+
}
|
| 371 |
+
}
|
| 372 |
+
}
|
| 373 |
+
}'''
|
| 374 |
+
|
| 375 |
+
# After Round 3: Choices focus - investigation options, multiple approaches
|
| 376 |
+
DEMO_AFTER_ROUND_3 = '''{
|
| 377 |
+
"investigation": {
|
| 378 |
+
"crime_scene": {
|
| 379 |
+
"description": "A dimly lit corner office on the 14th floor. Papers are scattered across the mahogany desk and floor - financial reports, all dated from last month. A broken window lets in cold night air, and you notice the glass shards fell inward, onto an expensive Persian rug now stained with what looks like coffee. A framed photo lies face-down near the overturned chair. On the desk, a half-eaten sandwich suggests Richardson left in a hurry - or was interrupted mid-meal.",
|
| 380 |
+
"choices": ["Examine the desk carefully", "Check the broken window", "Look at the fallen photo", "Search the floor for evidence", "Leave and check the hallway"],
|
| 381 |
+
"transitions": {
|
| 382 |
+
"Examine the desk carefully": "desk_clue",
|
| 383 |
+
"Check the broken window": "window_clue",
|
| 384 |
+
"Look at the fallen photo": "photo_clue",
|
| 385 |
+
"Search the floor for evidence": "floor_search",
|
| 386 |
+
"Leave and check the hallway": "hallway"
|
| 387 |
+
}
|
| 388 |
+
},
|
| 389 |
+
"desk_clue": {
|
| 390 |
+
"description": "The desk has a half-written letter addressed to 'M' - the handwriting grows increasingly shaky toward the end, betraying Richardson's fear. An empty coffee cup sits beside it, a faint coral lipstick mark on the rim - Richardson's wife wears red, not coral. The desk drawer is slightly ajar, revealing a corner of what looks like a passport. A sticky note on the monitor reads 'MIRANDA CALLED - URGENT' in what looks like a secretary's handwriting.",
|
| 391 |
+
"choices": ["Read the letter", "Check the drawer", "Examine the coffee cup", "Look at the sticky note closely", "Back to scene"],
|
| 392 |
+
"transitions": {
|
| 393 |
+
"Read the letter": "letter_reveal",
|
| 394 |
+
"Check the drawer": "drawer_contents",
|
| 395 |
+
"Examine the coffee cup": "coffee_clue",
|
| 396 |
+
"Look at the sticky note closely": "sticky_note_clue",
|
| 397 |
+
"Back to scene": "crime_scene"
|
| 398 |
+
}
|
| 399 |
+
},
|
| 400 |
+
"window_clue": {
|
| 401 |
+
"description": "Glass shards cover the inside of the windowsill and floor - definitely broken from outside. But something's wrong: the window latch is still locked from the inside. On the exterior ledge, you spot a small piece of torn fabric caught on a nail, dark blue like a security uniform. The tear pattern suggests someone climbed up, not down - but Martinez is the only guard on duty tonight.",
|
| 402 |
+
"choices": ["Look outside carefully", "Examine the fabric closely", "Test the lock mechanism", "Check for fingerprints", "Back to scene"],
|
| 403 |
+
"transitions": {
|
| 404 |
+
"Look outside carefully": "outside_view",
|
| 405 |
+
"Examine the fabric closely": "fabric_clue",
|
| 406 |
+
"Test the lock mechanism": "lock_test",
|
| 407 |
+
"Check for fingerprints": "fingerprint_search",
|
| 408 |
+
"Back to scene": "crime_scene"
|
| 409 |
+
}
|
| 410 |
+
},
|
| 411 |
+
"photo_clue": {
|
| 412 |
+
"description": "The frame's glass is cracked. The photo shows two men shaking hands at the company Christmas party 2019 - Richardson in an expensive suit, Martinez in his security uniform, both grinning like old friends. On the back, someone has written 'Partners in crime - Nov 2019' in feminine handwriting, with a small heart. The same coral lipstick has left a faint kiss mark in the corner.",
|
| 413 |
+
"choices": ["Keep the photo as evidence", "Study the handwriting", "Compare the lipstick shade", "Put it back for now", "Back to scene"],
|
| 414 |
+
"transitions": {
|
| 415 |
+
"Keep the photo as evidence": "evidence_collected",
|
| 416 |
+
"Study the handwriting": "handwriting_analysis",
|
| 417 |
+
"Compare the lipstick shade": "lipstick_comparison",
|
| 418 |
+
"Put it back for now": "crime_scene",
|
| 419 |
+
"Back to scene": "crime_scene"
|
| 420 |
+
}
|
| 421 |
+
},
|
| 422 |
+
"floor_search": {
|
| 423 |
+
"description": "Crouching low, you scan the Persian rug and hardwood floor. Beneath the overturned chair, you find a crumpled receipt from a pharmacy dated today - Alprazolam prescription filled at 6:15 PM, patient name obscured by a coffee stain. Near the window, a single pearl earring catches the light. Elena Richardson was photographed wearing pearls at last month's gala.",
|
| 424 |
+
"choices": ["Pocket the receipt", "Examine the earring", "Check under the desk", "Back to scene"],
|
| 425 |
+
"transitions": {
|
| 426 |
+
"Pocket the receipt": "receipt_clue",
|
| 427 |
+
"Examine the earring": "earring_clue",
|
| 428 |
+
"Check under the desk": "under_desk",
|
| 429 |
+
"Back to scene": "crime_scene"
|
| 430 |
+
}
|
| 431 |
+
},
|
| 432 |
+
"letter_reveal": {
|
| 433 |
+
"description": "The letter reads: 'Dear M, I can't keep doing this. They're going to find out about the transfers, and when they do, neither of us will be safe. Elena is getting suspicious - she found the Cayman statements. I need to disappear, but I can't leave you behind. Meet me at the usual place at midnight. If I'm not there by 12:30, assume the worst and run. Don't trust Martinez anymore - he's been asking questions. All my love, R.' The writing stops mid-sentence, a single drop of dried coffee staining the page.",
|
| 434 |
+
"choices": ["Who is 'M'?", "What transfers?", "Why not trust Martinez?", "Continue investigating"],
|
| 435 |
+
"transitions": {
|
| 436 |
+
"Who is 'M'?": "m_mystery",
|
| 437 |
+
"What transfers?": "transfer_investigation",
|
| 438 |
+
"Why not trust Martinez?": "martinez_suspicion",
|
| 439 |
+
"Continue investigating": "crime_scene"
|
| 440 |
+
}
|
| 441 |
+
},
|
| 442 |
+
"drawer_contents": {
|
| 443 |
+
"description": "Inside the drawer: a passport with several recent stamps to the Cayman Islands - the last trip was three days ago. A burner phone with a cracked screen shows the last call was to 'M' at 11:47 PM. A sticky note reads 'CALL BEFORE FRIDAY - E knows' with 'E' underlined three times. Elena, Richardson's wife, is known for her jealous streak and family money.",
|
| 444 |
+
"choices": ["Take the phone", "Check the passport dates", "Examine the sticky note", "Look for hidden compartments", "Back to desk"],
|
| 445 |
+
"transitions": {
|
| 446 |
+
"Take the phone": "phone_evidence",
|
| 447 |
+
"Check the passport dates": "passport_clue",
|
| 448 |
+
"Examine the sticky note": "friday_deadline",
|
| 449 |
+
"Look for hidden compartments": "secret_compartment",
|
| 450 |
+
"Back to desk": "desk_clue"
|
| 451 |
+
}
|
| 452 |
+
},
|
| 453 |
+
"coffee_clue": {
|
| 454 |
+
"description": "The coral lipstick shade is distinctive - 'Sunset Kiss' by Luxe, the kind sold at high-end department stores. Richardson's secretary Miranda wears this exact shade. The cup still has dregs at the bottom - the coffee smells bitter, with an underlying chemical note. There's a faint crystalline residue around the rim. Either Miranda has unusual taste in coffee additives, or someone wanted to make sure Richardson didn't finish his drink.",
|
| 455 |
+
"choices": ["Smell the coffee", "Scrape the residue for analysis", "Photograph the lipstick mark", "Back to desk"],
|
| 456 |
+
"transitions": {
|
| 457 |
+
"Smell the coffee": "poison_hint",
|
| 458 |
+
"Scrape the residue for analysis": "residue_analysis",
|
| 459 |
+
"Photograph the lipstick mark": "lipstick_evidence",
|
| 460 |
+
"Back to desk": "desk_clue"
|
| 461 |
+
}
|
| 462 |
+
},
|
| 463 |
+
"sticky_note_clue": {
|
| 464 |
+
"description": "The handwriting on 'MIRANDA CALLED - URGENT' is hurried, slanted - written by someone right-handed in a rush. But Richardson is left-handed. Someone else wrote this. The time stamp in the corner reads 11:35 PM. Miranda Chen is Richardson's secretary. Her desk is on the 12th floor.",
|
| 465 |
+
"choices": ["Go find Miranda", "Call Miranda's extension", "Back to desk"],
|
| 466 |
+
"transitions": {
|
| 467 |
+
"Go find Miranda": "miranda_lead",
|
| 468 |
+
"Call Miranda's extension": "miranda_call",
|
| 469 |
+
"Back to desk": "desk_clue"
|
| 470 |
+
}
|
| 471 |
+
},
|
| 472 |
+
"fabric_clue": {
|
| 473 |
+
"description": "The fabric is from a security uniform - you can see part of a stitched company logo. It's torn at an angle suggesting someone climbed up, not down. The thread count matches Martinez's uniform exactly, but the tear on his sleeve is on the right arm. This fabric came from a left sleeve. Either Martinez changed uniforms, or there's another security guard involved.",
|
| 474 |
+
"choices": ["Check the security roster", "Confront Martinez about this", "Examine more closely", "Back to window"],
|
| 475 |
+
"transitions": {
|
| 476 |
+
"Check the security roster": "security_roster",
|
| 477 |
+
"Confront Martinez about this": "confrontation",
|
| 478 |
+
"Examine more closely": "fabric_detail",
|
| 479 |
+
"Back to window": "window_clue"
|
| 480 |
+
}
|
| 481 |
+
},
|
| 482 |
+
"outside_view": {
|
| 483 |
+
"description": "Footprints in the mud lead toward the parking lot - two sets, one coming and one going. The incoming prints are deep, size 11 work boots, carrying something heavy. The outgoing prints are lighter, faster, size 7 women's heels, heading toward a side exit. A cigarette butt near the window smells fresh - Virginia Slims, a women's brand. Miranda smokes Virginia Slims.",
|
| 484 |
+
"choices": ["Follow the boot prints", "Follow the heel prints", "Collect the cigarette butt", "Photograph the prints", "Back inside"],
|
| 485 |
+
"transitions": {
|
| 486 |
+
"Follow the boot prints": "parking_lot",
|
| 487 |
+
"Follow the heel prints": "side_exit",
|
| 488 |
+
"Collect the cigarette butt": "cigarette_evidence",
|
| 489 |
+
"Photograph the prints": "print_documentation",
|
| 490 |
+
"Back inside": "crime_scene"
|
| 491 |
+
}
|
| 492 |
+
},
|
| 493 |
+
"hallway": {
|
| 494 |
+
"description": "The hallway is quiet, lit by flickering fluorescent lights. A security camera in the corner has a small red light - but you notice it's pointing at the wall, not the office door. Martinez approaches, his boots leaving muddy prints on the polished floor. His jaw is tight, eyes darting. 'Detective. Didn't expect you so soon.' His hand rests near his radio, as if ready to call for backup - or warn someone.",
|
| 495 |
+
"choices": ["Talk to Martinez directly", "Check the security camera first", "Examine his muddy boots", "Ask about other staff on duty", "Return to office"],
|
| 496 |
+
"transitions": {
|
| 497 |
+
"Talk to Martinez directly": "guard_talk",
|
| 498 |
+
"Check the security camera first": "camera_check",
|
| 499 |
+
"Examine his muddy boots": "boot_examination",
|
| 500 |
+
"Ask about other staff on duty": "staff_inquiry",
|
| 501 |
+
"Return to office": "crime_scene"
|
| 502 |
+
}
|
| 503 |
+
},
|
| 504 |
+
"camera_check": {
|
| 505 |
+
"description": "The camera has been manually rotated away from the office door. The adjustment screw is loose, recently turned - you can still see fresh scratches on the metal. Whoever did this knew exactly which camera to move. The timestamp log shows the adjustment happened at 11:42 PM, five minutes before the estimated time of the incident. Martinez clocked in at 11:30 PM.",
|
| 506 |
+
"choices": ["Check the camera footage", "Dust for prints on the camera", "Confront Martinez about timing", "Back to hallway"],
|
| 507 |
+
"transitions": {
|
| 508 |
+
"Check the camera footage": "footage_review",
|
| 509 |
+
"Dust for prints on the camera": "camera_prints",
|
| 510 |
+
"Confront Martinez about timing": "timing_confrontation",
|
| 511 |
+
"Back to hallway": "hallway"
|
| 512 |
+
}
|
| 513 |
+
},
|
| 514 |
+
"guard_talk": {
|
| 515 |
+
"description": "'Evening, Detective.' Martinez's voice is steady but his hands aren't - they're shaking slightly. His name tag reads 'Martinez, J.' - the same J.M. initialed on the back of the photo in Richardson's office. His uniform is disheveled, with a small tear near his left sleeve. 'I heard nothing unusual tonight. Just doing my rounds.' He keeps glancing at his watch. It's 12:15 AM.",
|
| 516 |
+
"choices": ["Press for details about tonight", "Ask about his relationship with Richardson", "Point out the tear in his uniform", "Thank him and leave"],
|
| 517 |
+
"transitions": {
|
| 518 |
+
"Press for details about tonight": "guard_pressed",
|
| 519 |
+
"Ask about his relationship with Richardson": "relationship_question",
|
| 520 |
+
"Point out the tear in his uniform": "uniform_confrontation",
|
| 521 |
+
"Thank him and leave": "hallway"
|
| 522 |
+
}
|
| 523 |
+
},
|
| 524 |
+
"guard_pressed": {
|
| 525 |
+
"description": "'Look, I was on my rounds, okay?' Martinez's voice rises, defensive. 'The 14th floor wasn't scheduled until midnight.' He shifts his weight, mud flaking from his boots. 'Ask Jenkins at the front desk - he'll confirm my times.' His phone buzzes. He glances at it and his face goes pale. 'I need to... excuse me.' He starts to turn away. His boots are definitely size 11.",
|
| 526 |
+
"choices": ["Confront about the muddy boots", "Demand to see his phone", "Let him go and follow", "Ask about the photo"],
|
| 527 |
+
"transitions": {
|
| 528 |
+
"Confront about the muddy boots": "confrontation",
|
| 529 |
+
"Demand to see his phone": "phone_demand",
|
| 530 |
+
"Let him go and follow": "follow_martinez",
|
| 531 |
+
"Ask about the photo": "photo_question"
|
| 532 |
+
}
|
| 533 |
+
},
|
| 534 |
+
"confrontation": {
|
| 535 |
+
"description": "Martinez's face drains of color. 'Those... those aren't...' He takes a shaky breath. 'Fine. Yes, I was up there tonight. But not to hurt him - to WARN him.' His voice cracks. 'Richardson and I... we were partners. In more ways than one. Someone found out about the money, about us. They called Richardson at 11:30, threatened to tell his wife everything unless he paid. I went up to help him figure out what to do. But when I got there...' He stops, unable to continue.",
|
| 536 |
+
"choices": ["What did you find when you arrived?", "Who called Richardson?", "Tell me about Elena Richardson", "You're under arrest"],
|
| 537 |
+
"transitions": {
|
| 538 |
+
"What did you find when you arrived?": "martinez_discovery",
|
| 539 |
+
"Who called Richardson?": "caller_identity",
|
| 540 |
+
"Tell me about Elena Richardson": "elena_info",
|
| 541 |
+
"You're under arrest": "arrest_martinez"
|
| 542 |
+
}
|
| 543 |
+
},
|
| 544 |
+
"photo_question": {
|
| 545 |
+
"description": "At the mention of the photo, Martinez goes rigid. 'Where did you find that? That was supposed to be... Miranda was supposed to get rid of it.' He realizes what he's said too late. 'Look, Richardson and I were close, okay? Closer than his wife knew. Miranda found out - she's been covering for us, but lately she's been acting strange. Jealous, maybe. She kept saying if she couldn't have him, no one would.'",
|
| 546 |
+
"choices": ["Tell me more about Miranda", "Ask about the accounts", "Was Miranda here tonight?", "Back to hallway"],
|
| 547 |
+
"transitions": {
|
| 548 |
+
"Tell me more about Miranda": "miranda_background",
|
| 549 |
+
"Ask about the accounts": "accounts_info",
|
| 550 |
+
"Was Miranda here tonight?": "miranda_whereabouts",
|
| 551 |
+
"Back to hallway": "hallway"
|
| 552 |
+
}
|
| 553 |
+
},
|
| 554 |
+
"accounts_info": {
|
| 555 |
+
"description": "'He was moving money - lots of it. Company funds to offshore accounts. We were going to leave together, start fresh in Costa Rica.' Martinez's voice is hollow. 'I found out six months ago what he was doing. Should have reported it. But he promised we'd run away, disappear. Tonight was supposed to be the night.' He looks at his watch again - 12:28 AM. 'Someone else found out. Someone who doesn't make deals. Elena Richardson has family in organized crime. She called Richardson at 11:30 PM. I heard her voice through the door.'",
|
| 556 |
+
"choices": ["Tell me about Elena's family", "Who else knew about the money?", "What happened at 11:30?", "Back to hallway"],
|
| 557 |
+
"transitions": {
|
| 558 |
+
"Tell me about Elena's family": "elena_family",
|
| 559 |
+
"Who else knew about the money?": "money_knowledge",
|
| 560 |
+
"What happened at 11:30?": "eleven_thirty",
|
| 561 |
+
"Back to hallway": "hallway"
|
| 562 |
+
}
|
| 563 |
+
},
|
| 564 |
+
"miranda_lead": {
|
| 565 |
+
"description": "You find Miranda Chen at her desk on the 12th floor, still typing despite the late hour. Her coral lipstick is freshly applied, and she startles when she sees you. 'Detective! I didn't hear about... is Mr. Richardson alright?' Her voice is too high, too practiced. On her desk sits an empty coffee cup - the same brand as the one in Richardson's office. A Virginia Slims pack peeks from her purse.",
|
| 566 |
+
"choices": ["Ask about Richardson directly", "Ask about the coffee", "Mention the cigarettes outside", "Ask why she's working late", "Leave quietly"],
|
| 567 |
+
"transitions": {
|
| 568 |
+
"Ask about Richardson directly": "miranda_richardson",
|
| 569 |
+
"Ask about the coffee": "miranda_coffee",
|
| 570 |
+
"Mention the cigarettes outside": "miranda_cigarettes",
|
| 571 |
+
"Ask why she's working late": "miranda_late",
|
| 572 |
+
"Leave quietly": "hallway"
|
| 573 |
+
}
|
| 574 |
+
},
|
| 575 |
+
"miranda_confession": {
|
| 576 |
+
"description": "'He was going to leave.' Miranda's composure cracks. 'After everything I did for him - covering up the affair with Martinez, hiding the money transfers, lying to Elena when she called. And he was going to take HIM instead of me.' Her hands tremble. 'I brought him coffee at 11:40. We talked. I told him I knew about the midnight flight. He laughed. Said I wasn't his type.' She looks at you with hollow eyes. 'I didn't mean for... the pills were just supposed to make him sleepy. Elena's man must have arrived while he was drowsy.'",
|
| 577 |
+
"choices": ["What pills did you use?", "Who is Elena's man?", "You're under arrest", "Continue investigating"],
|
| 578 |
+
"transitions": {
|
| 579 |
+
"What pills did you use?": "pills_confession",
|
| 580 |
+
"Who is Elena's man?": "elenas_enforcer",
|
| 581 |
+
"You're under arrest": "miranda_arrest",
|
| 582 |
+
"Continue investigating": "hallway"
|
| 583 |
+
}
|
| 584 |
+
},
|
| 585 |
+
"miranda_coffee": {
|
| 586 |
+
"description": "'I always bring Mr. Richardson his evening coffee. It's part of my job.' Miranda's smile is too bright. 'Tonight was no different.' But her eyes flick to her purse. Inside, you can see an empty prescription bottle - the label reads 'Alprazolam.' Enough dissolved in coffee would make anyone confused, disoriented. Easy prey.",
|
| 587 |
+
"choices": ["Confront about the prescription", "Ask who else visited tonight", "Mention the lipstick on the cup", "Back away slowly"],
|
| 588 |
+
"transitions": {
|
| 589 |
+
"Confront about the prescription": "miranda_confession",
|
| 590 |
+
"Ask who else visited tonight": "visitor_info",
|
| 591 |
+
"Mention the lipstick on the cup": "lipstick_confrontation",
|
| 592 |
+
"Back away slowly": "hallway"
|
| 593 |
+
}
|
| 594 |
+
},
|
| 595 |
+
"lock_test": {
|
| 596 |
+
"description": "You examine the window lock mechanism carefully. It's a standard sash lock, latched from the inside. But there are faint scratches around the latch - someone jimmied it closed AFTER the glass was broken. This wasn't a break-in. It was staged to look like one. The real entrance was probably the office door.",
|
| 597 |
+
"choices": ["Check the office door lock", "Re-examine the glass pattern", "Back to scene"],
|
| 598 |
+
"transitions": {
|
| 599 |
+
"Check the office door lock": "door_lock",
|
| 600 |
+
"Re-examine the glass pattern": "glass_analysis",
|
| 601 |
+
"Back to scene": "crime_scene"
|
| 602 |
+
}
|
| 603 |
+
},
|
| 604 |
+
"fingerprint_search": {
|
| 605 |
+
"description": "Dusting the window frame reveals multiple prints. Most are smudged, but you get two clean sets: one matches the security database (Martinez), and another belongs to a woman - long, manicured nails. The woman's prints are on the INSIDE of the window latch. Someone closed it from inside after the glass was broken.",
|
| 606 |
+
"choices": ["Run the unknown prints", "Confront Martinez", "Back to scene"],
|
| 607 |
+
"transitions": {
|
| 608 |
+
"Run the unknown prints": "print_results",
|
| 609 |
+
"Confront Martinez": "confrontation",
|
| 610 |
+
"Back to scene": "crime_scene"
|
| 611 |
+
}
|
| 612 |
+
},
|
| 613 |
+
"evidence_collected": {
|
| 614 |
+
"description": "You slip the photo into an evidence bag. The connection between Martinez and Richardson goes deeper than a professional relationship. The feminine handwriting and lipstick mark suggest a third party - someone close enough to Richardson to know about his affairs, someone who might feel betrayed.",
|
| 615 |
+
"choices": ["Continue investigating"],
|
| 616 |
+
"transitions": {
|
| 617 |
+
"Continue investigating": "crime_scene"
|
| 618 |
+
}
|
| 619 |
+
},
|
| 620 |
+
"handwriting_analysis": {
|
| 621 |
+
"description": "The handwriting on the photo back is distinctive - elegant, educated, with a slight leftward slant. Comparing it to the sticky note on Richardson's monitor, you notice similarities. Both were written by the same person. Miranda Chen is left-handed.",
|
| 622 |
+
"choices": ["Go find Miranda", "Back to scene"],
|
| 623 |
+
"transitions": {
|
| 624 |
+
"Go find Miranda": "miranda_lead",
|
| 625 |
+
"Back to scene": "crime_scene"
|
| 626 |
+
}
|
| 627 |
+
},
|
| 628 |
+
"m_mystery": {
|
| 629 |
+
"description": "Who is 'M'? The letter suggests an intimate relationship. Martinez's first name is Jorge - that doesn't match. But Miranda Chen's last name starts with M. Or could 'M' refer to someone else entirely? Richardson's wife Elena was born Elena Morrison. Three suspects, one initial.",
|
| 630 |
+
"choices": ["Focus on Martinez", "Investigate Miranda", "Look into Elena", "Continue investigating"],
|
| 631 |
+
"transitions": {
|
| 632 |
+
"Focus on Martinez": "guard_talk",
|
| 633 |
+
"Investigate Miranda": "miranda_lead",
|
| 634 |
+
"Look into Elena": "elena_investigation",
|
| 635 |
+
"Continue investigating": "crime_scene"
|
| 636 |
+
}
|
| 637 |
+
}
|
| 638 |
+
}
|
| 639 |
+
}'''
|
| 640 |
+
|
| 641 |
+
# After Round 4: Tension focus - time pressure, red herrings, dramatic reveals
|
| 642 |
+
DEMO_AFTER_ROUND_4 = '''{
|
| 643 |
+
"investigation": {
|
| 644 |
+
"crime_scene": {
|
| 645 |
+
"description": "A dimly lit corner office on the 14th floor. Papers are scattered across the mahogany desk and floor - financial reports, all dated from last month. A broken window lets in cold night air, and you notice the glass shards fell inward, onto an expensive Persian rug now stained with what looks like coffee. A framed photo lies face-down near the overturned chair. On the desk, a half-eaten sandwich suggests Richardson left in a hurry - or was interrupted mid-meal. Your phone buzzes: 'URGENT - Richardson's wife just landed at the airport. ETA 45 minutes.' The clock is ticking.",
|
| 646 |
+
"choices": ["Examine the desk carefully", "Check the broken window", "Look at the fallen photo", "Search the floor for evidence", "Leave and check the hallway"],
|
| 647 |
+
"transitions": {
|
| 648 |
+
"Examine the desk carefully": "desk_clue",
|
| 649 |
+
"Check the broken window": "window_clue",
|
| 650 |
+
"Look at the fallen photo": "photo_clue",
|
| 651 |
+
"Search the floor for evidence": "floor_search",
|
| 652 |
+
"Leave and check the hallway": "hallway"
|
| 653 |
+
}
|
| 654 |
+
},
|
| 655 |
+
"desk_clue": {
|
| 656 |
+
"description": "The desk has a half-written letter addressed to 'M' - the handwriting grows increasingly shaky toward the end, betraying Richardson's fear. An empty coffee cup sits beside it, a faint coral lipstick mark on the rim - Richardson's wife wears red, not coral. The desk drawer is slightly ajar, revealing a corner of what looks like a passport. A sticky note on the monitor reads 'MIRANDA CALLED - URGENT' in what looks like a secretary's handwriting. Wait - there's another note underneath: 'Elena knows. Tonight. Midnight.' Someone warned him, but he didn't run.",
|
| 657 |
+
"choices": ["Read the letter", "Check the drawer", "Examine the coffee cup", "Look at both sticky notes", "Back to scene"],
|
| 658 |
+
"transitions": {
|
| 659 |
+
"Read the letter": "letter_reveal",
|
| 660 |
+
"Check the drawer": "drawer_contents",
|
| 661 |
+
"Examine the coffee cup": "coffee_clue",
|
| 662 |
+
"Look at both sticky notes": "sticky_notes_comparison",
|
| 663 |
+
"Back to scene": "crime_scene"
|
| 664 |
+
}
|
| 665 |
+
},
|
| 666 |
+
"window_clue": {
|
| 667 |
+
"description": "Glass shards cover the inside of the windowsill and floor - definitely broken from outside. But something's wrong: the window latch is still locked from the inside. RED HERRING ALERT: Your gut says this is staged. On the exterior ledge, you spot a small piece of torn fabric caught on a nail, dark blue like a security uniform. But wait - security uniforms were switched to BLACK last month. This fabric is from an OLD uniform. Someone planted this.",
|
| 668 |
+
"choices": ["Look outside carefully", "Examine the planted fabric", "Test the lock mechanism", "Check for fingerprints", "Back to scene"],
|
| 669 |
+
"transitions": {
|
| 670 |
+
"Look outside carefully": "outside_view",
|
| 671 |
+
"Examine the planted fabric": "fabric_clue",
|
| 672 |
+
"Test the lock mechanism": "lock_test",
|
| 673 |
+
"Check for fingerprints": "fingerprint_search",
|
| 674 |
+
"Back to scene": "crime_scene"
|
| 675 |
+
}
|
| 676 |
+
},
|
| 677 |
+
"photo_clue": {
|
| 678 |
+
"description": "The frame's glass is cracked. The photo shows two men shaking hands at the company Christmas party 2019 - Richardson in an expensive suit, Martinez in his security uniform, both grinning like old friends. On the back, someone has written 'Partners in crime - Nov 2019' in feminine handwriting, with a small heart. The same coral lipstick has left a faint kiss mark in the corner. But here's what catches your eye: the photo has been recently handled - fresh fingerprints over old dust. Someone looked at this TONIGHT.",
|
| 679 |
+
"choices": ["Keep the photo as evidence", "Study the fresh fingerprints", "Compare the lipstick shade", "Check for hidden messages", "Back to scene"],
|
| 680 |
+
"transitions": {
|
| 681 |
+
"Keep the photo as evidence": "evidence_collected",
|
| 682 |
+
"Study the fresh fingerprints": "fresh_prints",
|
| 683 |
+
"Compare the lipstick shade": "lipstick_comparison",
|
| 684 |
+
"Check for hidden messages": "hidden_message",
|
| 685 |
+
"Back to scene": "crime_scene"
|
| 686 |
+
}
|
| 687 |
+
},
|
| 688 |
+
"floor_search": {
|
| 689 |
+
"description": "Crouching low, you scan the Persian rug and hardwood floor. Beneath the overturned chair, you find a crumpled receipt from a pharmacy dated today - Alprazolam prescription filled at 6:15 PM, patient name obscured by a coffee stain. Near the window, a single pearl earring catches the light. Elena Richardson was photographed wearing pearls at last month's gala. But pearls are common - is this really hers, or another planted clue?",
|
| 690 |
+
"choices": ["Pocket the receipt - crucial evidence", "Examine the earring skeptically", "Check under the desk", "Look for more planted evidence", "Back to scene"],
|
| 691 |
+
"transitions": {
|
| 692 |
+
"Pocket the receipt - crucial evidence": "receipt_clue",
|
| 693 |
+
"Examine the earring skeptically": "earring_analysis",
|
| 694 |
+
"Check under the desk": "under_desk",
|
| 695 |
+
"Look for more planted evidence": "planted_evidence_search",
|
| 696 |
+
"Back to scene": "crime_scene"
|
| 697 |
+
}
|
| 698 |
+
},
|
| 699 |
+
"letter_reveal": {
|
| 700 |
+
"description": "The letter reads: 'Dear M, I can't keep doing this. They're going to find out about the transfers, and when they do, neither of us will be safe. Elena is getting suspicious - she found the Cayman statements. I need to disappear, but I can't leave you behind. Meet me at the usual place at midnight. If I'm not there by 12:30, assume the worst and run. Don't trust Martinez anymore - he's been asking questions. All my love, R.' The writing stops mid-sentence. WAIT - the ink at the end is different. Someone ADDED that last line about Martinez. The original letter ended at 'run.'",
|
| 701 |
+
"choices": ["Examine the different inks", "Who is 'M'?", "Why was Martinez's name added?", "Continue investigating"],
|
| 702 |
+
"transitions": {
|
| 703 |
+
"Examine the different inks": "ink_analysis",
|
| 704 |
+
"Who is 'M'?": "m_mystery",
|
| 705 |
+
"Why was Martinez's name added?": "martinez_framed",
|
| 706 |
+
"Continue investigating": "crime_scene"
|
| 707 |
+
}
|
| 708 |
+
},
|
| 709 |
+
"drawer_contents": {
|
| 710 |
+
"description": "Inside the drawer: a passport with several recent stamps to the Cayman Islands - the last trip was three days ago. A burner phone with a cracked screen shows the last call was to 'M' at 11:47 PM - just fifteen minutes before the estimated incident. A sticky note reads 'CALL BEFORE FRIDAY - E knows' with 'E' underlined three times. Your phone buzzes again: 'Elena Richardson's car just passed the highway exit. 30 minutes.' You need to solve this before she arrives and lawyers up.",
|
| 711 |
+
"choices": ["Grab the phone - check call history", "Check the passport dates", "Examine the 'E knows' note", "Look for hidden compartments", "Back to desk"],
|
| 712 |
+
"transitions": {
|
| 713 |
+
"Grab the phone - check call history": "phone_evidence",
|
| 714 |
+
"Check the passport dates": "passport_clue",
|
| 715 |
+
"Examine the 'E knows' note": "friday_deadline",
|
| 716 |
+
"Look for hidden compartments": "secret_compartment",
|
| 717 |
+
"Back to desk": "desk_clue"
|
| 718 |
+
}
|
| 719 |
+
},
|
| 720 |
+
"coffee_clue": {
|
| 721 |
+
"description": "The coral lipstick shade is distinctive - 'Sunset Kiss' by Luxe, sold exclusively at Nordstrom. Richardson's secretary Miranda wears this exact shade - you've seen her wear it. But so does ELENA RICHARDSON in her Instagram photos. The cup still has dregs at the bottom - the coffee smells bitter, with an underlying chemical note. There's a faint crystalline residue around the rim. TWIST: The residue isn't a sedative. Field test shows it's artificial sweetener - the expensive kind Elena uses for her diet. Miranda takes her coffee black.",
|
| 722 |
+
"choices": ["This changes everything", "Test the residue again", "Photograph the evidence", "Back to desk"],
|
| 723 |
+
"transitions": {
|
| 724 |
+
"This changes everything": "elena_revelation",
|
| 725 |
+
"Test the residue again": "residue_confirmation",
|
| 726 |
+
"Photograph the evidence": "evidence_photo",
|
| 727 |
+
"Back to desk": "desk_clue"
|
| 728 |
+
}
|
| 729 |
+
},
|
| 730 |
+
"hallway": {
|
| 731 |
+
"description": "The hallway is quiet, lit by flickering fluorescent lights. A security camera in the corner has a small red light - but you notice it's pointing at the wall, not the office door. Martinez approaches, his boots leaving muddy prints on the polished floor. His jaw is tight, eyes darting. 'Detective. Didn't expect you so soon.' His hand rests near his radio. Then he says something strange: 'Whatever they told you about me and Richardson - it's not the whole story. Elena's people got here first. I tried to warn him.' He looks genuinely scared.",
|
| 732 |
+
"choices": ["What do you mean, Elena's people?", "Check the security camera first", "Examine his muddy boots", "Ask about the warning", "Return to office"],
|
| 733 |
+
"transitions": {
|
| 734 |
+
"What do you mean, Elena's people?": "elena_people",
|
| 735 |
+
"Check the security camera first": "camera_check",
|
| 736 |
+
"Examine his muddy boots": "boot_examination",
|
| 737 |
+
"Ask about the warning": "warning_details",
|
| 738 |
+
"Return to office": "crime_scene"
|
| 739 |
+
}
|
| 740 |
+
},
|
| 741 |
+
"camera_check": {
|
| 742 |
+
"description": "The camera has been manually rotated away from the office door. The adjustment screw is loose, recently turned - you can still see fresh scratches on the metal. Whoever did this knew exactly which camera to move. The timestamp log shows the adjustment happened at 11:42 PM. But here's the CRUCIAL DETAIL: there are TWO sets of scratches. Someone adjusted this camera TWICE. First at 11:42 PM, then again at 11:58 PM - after the incident. Someone came back to make sure it was still pointed away.",
|
| 743 |
+
"choices": ["Check footage from other cameras", "Who came back at 11:58?", "Confront Martinez about timing", "Back to hallway"],
|
| 744 |
+
"transitions": {
|
| 745 |
+
"Check footage from other cameras": "other_cameras",
|
| 746 |
+
"Who came back at 11:58?": "second_adjustment",
|
| 747 |
+
"Confront Martinez about timing": "timing_confrontation",
|
| 748 |
+
"Back to hallway": "hallway"
|
| 749 |
+
}
|
| 750 |
+
},
|
| 751 |
+
"guard_talk": {
|
| 752 |
+
"description": "'Evening, Detective.' Martinez's voice is steady but his hands aren't - they're shaking. His name tag reads 'Martinez, J.' His uniform is disheveled, with a small tear near his left sleeve. 'I heard nothing unusual tonight. Just doing my rounds.' He keeps glancing at his watch. It's 12:15 AM. Then he leans closer and whispers: 'The woman who came up at 11:35 - it wasn't Miranda. It was Elena. She was wearing Miranda's coat, but I recognized her perfume. Chanel No. 5. I didn't say anything because... her family...' He draws a finger across his throat.",
|
| 753 |
+
"choices": ["Elena was HERE?", "Tell me about her family", "Why didn't you stop her?", "You're lying to protect yourself"],
|
| 754 |
+
"transitions": {
|
| 755 |
+
"Elena was HERE?": "elena_presence",
|
| 756 |
+
"Tell me about her family": "family_connections",
|
| 757 |
+
"Why didn't you stop her?": "martinez_fear",
|
| 758 |
+
"You're lying to protect yourself": "martinez_defense"
|
| 759 |
+
}
|
| 760 |
+
},
|
| 761 |
+
"guard_pressed": {
|
| 762 |
+
"description": "'Look, I was on my rounds, okay?' Martinez's voice rises, then drops to a whisper. 'The 14th floor wasn't scheduled until midnight. But I went up early because Richardson texted me at 11:25 - said someone was coming, someone dangerous. When I got there at 11:40, I heard voices inside. A woman, screaming. Then silence. I... I hid. I'm not proud of it.' He shows you his phone - the text from Richardson reads: 'SHE FOUND OUT. Coming to office. Help me. Please.' Sent at 11:25 PM.",
|
| 763 |
+
"choices": ["Who is 'she'?", "Why did you hide?", "Show me the rest of the texts", "What happened after the silence?"],
|
| 764 |
+
"transitions": {
|
| 765 |
+
"Who is 'she'?": "she_identity",
|
| 766 |
+
"Why did you hide?": "hiding_reason",
|
| 767 |
+
"Show me the rest of the texts": "full_texts",
|
| 768 |
+
"What happened after the silence?": "aftermath"
|
| 769 |
+
}
|
| 770 |
+
},
|
| 771 |
+
"confrontation": {
|
| 772 |
+
"description": "Martinez breaks down completely. 'Fine! Yes, Richardson and I were together. For two years. But I didn't hurt him - I LOVED him.' Tears stream down his face. 'When I got to the office, he was already on the floor. The coffee cup was knocked over. A woman in Miranda's coat was climbing out the window - that's why there's fabric on the ledge. But the way she moved... it wasn't Miranda. It was Elena, I swear to God. She set this whole thing up to frame Miranda, frame me. She's been planning this for months.'",
|
| 773 |
+
"choices": ["How do you know it was Elena?", "Where is Richardson now?", "What do you mean, planning for months?", "You're under arrest"],
|
| 774 |
+
"transitions": {
|
| 775 |
+
"How do you know it was Elena?": "elena_identification",
|
| 776 |
+
"Where is Richardson now?": "richardson_fate",
|
| 777 |
+
"What do you mean, planning for months?": "long_con",
|
| 778 |
+
"You're under arrest": "arrest_martinez"
|
| 779 |
+
}
|
| 780 |
+
},
|
| 781 |
+
"photo_question": {
|
| 782 |
+
"description": "At the mention of the photo, Martinez's face changes from fear to realization. 'That photo... Elena found it last month. She came to me, showed it to me. Said she'd destroy both our careers unless I helped her.' He pulls out his phone, shows you a text thread. Elena Richardson: 'I know about you and my husband. Friday night. You will let me into the building after hours. You will turn off the camera. Or these photos go to HR, the board, and every news outlet in the city.' Dated three days ago.",
|
| 783 |
+
"choices": ["She blackmailed you into helping", "Did you know what she planned?", "Why didn't you warn Richardson?", "Show this to my captain"],
|
| 784 |
+
"transitions": {
|
| 785 |
+
"She blackmailed you into helping": "blackmail_reveal",
|
| 786 |
+
"Did you know what she planned?": "plan_knowledge",
|
| 787 |
+
"Why didn't you warn Richardson?": "warning_failure",
|
| 788 |
+
"Show this to my captain": "evidence_secured"
|
| 789 |
+
}
|
| 790 |
+
},
|
| 791 |
+
"accounts_info": {
|
| 792 |
+
"description": "'He was moving money - lots of it. Company funds to offshore accounts.' Martinez wipes his eyes. 'We were going to leave together, start fresh. But Elena found out about everything - the money AND us. She didn't just want a divorce. She wanted revenge.' He checks his watch nervously. 'Her family has connections to people who make problems disappear. The coffee, the staged break-in - this was a professional hit made to look like a crime of passion. And I...' His voice breaks. 'I let her in. God help me, I let her in.'",
|
| 793 |
+
"choices": ["Where is Elena now?", "The coffee was poisoned?", "Who else helped her?", "We need to find Richardson"],
|
| 794 |
+
"transitions": {
|
| 795 |
+
"Where is Elena now?": "elena_location",
|
| 796 |
+
"The coffee was poisoned?": "poison_confirmation",
|
| 797 |
+
"Who else helped her?": "accomplices",
|
| 798 |
+
"We need to find Richardson": "richardson_search"
|
| 799 |
+
}
|
| 800 |
+
},
|
| 801 |
+
"miranda_lead": {
|
| 802 |
+
"description": "You find Miranda Chen at her desk on the 12th floor, furiously packing a box. She freezes when she sees you. 'Detective! I was just... organizing.' Her coral lipstick is smeared, mascara running. 'I heard sirens. Is Mr. Richardson okay?' She's lying - her coat is hanging on the back of her chair, dry and clean. But someone wearing her coat was at the scene. On her desk, a resignation letter dated TODAY. She was planning to leave before any of this happened.",
|
| 803 |
+
"choices": ["Where's your other coat, Miranda?", "Who borrowed your coat tonight?", "Why are you resigning?", "Did you bring Richardson coffee tonight?"],
|
| 804 |
+
"transitions": {
|
| 805 |
+
"Where's your other coat, Miranda?": "coat_question",
|
| 806 |
+
"Who borrowed your coat tonight?": "coat_borrower",
|
| 807 |
+
"Why are you resigning?": "resignation_reason",
|
| 808 |
+
"Did you bring Richardson coffee tonight?": "coffee_denial"
|
| 809 |
+
}
|
| 810 |
+
},
|
| 811 |
+
"miranda_confession": {
|
| 812 |
+
"description": "'I didn't hurt him!' Miranda sobs. 'Yes, I brought him coffee at 11:40. Yes, I've been in love with him for three years. But when I got to his office, Elena was already there. She was wearing MY coat - must have taken it from my desk earlier. She told me to leave, that this was between her and her husband. I... I ran. I was scared.' She pulls out her phone. 'Look - I called 911 at 11:52 PM. Anonymously. That's how you got here so fast. I tried to help him, I swear.'",
|
| 813 |
+
"choices": ["The 911 call - that was you?", "What did you see Elena doing?", "Why was she wearing your coat?", "You're coming with me"],
|
| 814 |
+
"transitions": {
|
| 815 |
+
"The 911 call - that was you?": "911_confirmation",
|
| 816 |
+
"What did you see Elena doing?": "elena_actions",
|
| 817 |
+
"Why was she wearing your coat?": "coat_explanation",
|
| 818 |
+
"You're coming with me": "miranda_detained"
|
| 819 |
+
}
|
| 820 |
+
},
|
| 821 |
+
"miranda_coffee": {
|
| 822 |
+
"description": "'I always bring Mr. Richardson his evening coffee.' Miranda's hands shake as she grabs the edge of her desk. 'But tonight - tonight Elena intercepted me in the hallway. Took the cup right out of my hands. Said she wanted to make up with her husband, bring him coffee as a peace offering. I thought it was sweet.' Her face crumbles. 'Oh God. She put something in it, didn't she? She used MY cup, MY lipstick shade - she was setting me up from the start.'",
|
| 823 |
+
"choices": ["Elena took the coffee from you?", "She planned to frame you", "What time was this?", "I need you to come with me"],
|
| 824 |
+
"transitions": {
|
| 825 |
+
"Elena took the coffee from you?": "coffee_interception",
|
| 826 |
+
"She planned to frame you": "frame_job",
|
| 827 |
+
"What time was this?": "timeline_clarification",
|
| 828 |
+
"I need you to come with me": "miranda_witness"
|
| 829 |
+
}
|
| 830 |
+
},
|
| 831 |
+
"ink_analysis": {
|
| 832 |
+
"description": "Examining the letter under your UV light, the truth becomes clear. The original letter was written with Richardson's usual fountain pen - blue-black Montblanc ink. But the last line about Martinez was added with a cheap ballpoint, common office supply. Someone added that line to redirect suspicion toward Martinez and away from the real killer. The handwriting is close to Richardson's but not exact - slightly more angular. Elena Richardson is known for her perfect forgery skills; she handles all the household documents.",
|
| 833 |
+
"choices": ["Elena forged this line", "Test the ballpoint ink", "Check security footage for document tampering", "Continue investigating"],
|
| 834 |
+
"transitions": {
|
| 835 |
+
"Elena forged this line": "forgery_confirmed",
|
| 836 |
+
"Test the ballpoint ink": "ink_test",
|
| 837 |
+
"Check security footage for document tampering": "document_footage",
|
| 838 |
+
"Continue investigating": "crime_scene"
|
| 839 |
+
}
|
| 840 |
+
},
|
| 841 |
+
"final_confrontation": {
|
| 842 |
+
"description": "Your phone buzzes one final time: 'Elena Richardson just pulled into the parking garage. She's heading for the elevator.' You have minutes to make a decision. The evidence points to a calculated murder disguised as a crime of passion. Elena poisoned her husband's coffee, staged the break-in, framed Miranda and Martinez, and is about to walk into this building pretending to be the grieving widow. Martinez is willing to testify. Miranda is a witness. The forged letter and planted evidence tell the story. But Elena's family connections mean this case needs to be airtight.",
|
| 843 |
+
"choices": ["Arrest Elena in the lobby", "Gather more evidence first", "Confront her in the office", "Call for backup and wait"],
|
| 844 |
+
"transitions": {
|
| 845 |
+
"Arrest Elena in the lobby": "lobby_arrest",
|
| 846 |
+
"Gather more evidence first": "more_evidence",
|
| 847 |
+
"Confront her in the office": "office_confrontation",
|
| 848 |
+
"Call for backup and wait": "backup_ending"
|
| 849 |
+
}
|
| 850 |
+
},
|
| 851 |
+
"true_ending": {
|
| 852 |
+
"description": "As Elena Richardson steps off the elevator, her face a perfect mask of concern, you step forward with your badge. 'Mrs. Richardson. We have witnesses placing you at your husband's office at 11:35 PM, wearing Miranda Chen's coat. We have the forged addition to your husband's letter. We have your artificial sweetener in the poisoned coffee. And we have your blackmail texts to Jorge Martinez.' Her mask slips, just for a moment, revealing cold fury. 'You can't prove anything. My family will—' 'Your family will read about this in tomorrow's paper,' you interrupt. 'Elena Richardson, you're under arrest for the murder of David Richardson.' As the cuffs click, Martinez finally stops shaking. Miranda stops crying. Sometimes, the truth finds a way through the lies.",
|
| 853 |
+
"choices": ["Case closed"],
|
| 854 |
+
"transitions": {
|
| 855 |
+
"Case closed": "crime_scene"
|
| 856 |
+
}
|
| 857 |
+
}
|
| 858 |
+
}
|
| 859 |
+
}'''
|
| 860 |
+
|
| 861 |
+
# Demo workflow guide
|
| 862 |
+
DEMO_WORKFLOW_GUIDE = """
|
| 863 |
+
## LLM Improvement Loop Demo: Mystery Deepening Workflow
|
| 864 |
+
|
| 865 |
+
This demo shows how to iteratively improve a game config using the Mystery Deepening workflow.
|
| 866 |
+
|
| 867 |
+
### Starting Config
|
| 868 |
+
- 7 states
|
| 869 |
+
- Basic mystery structure
|
| 870 |
+
- Short descriptions (8-15 words each)
|
| 871 |
+
- 2 choices per state on average
|
| 872 |
+
|
| 873 |
+
### Workflow Steps
|
| 874 |
+
|
| 875 |
+
**Round 1: Environment Focus**
|
| 876 |
+
- Click "1: environment" in Mystery Deepening column
|
| 877 |
+
- Generate prompt → Copy to ChatGPT/Claude
|
| 878 |
+
- LLM adds: environmental clues, sensory details, hidden objects
|
| 879 |
+
- Result: 18 states, rich atmospheric descriptions
|
| 880 |
+
|
| 881 |
+
**Round 2: Characters Focus**
|
| 882 |
+
- Click "2: characters"
|
| 883 |
+
- Generate prompt with Round 1 config
|
| 884 |
+
- LLM adds: NPC motivations, dialogue revealing personality, relationships
|
| 885 |
+
- Result: Character depth, suspicious behaviors, backstory hints
|
| 886 |
+
|
| 887 |
+
**Round 3: Choices Focus**
|
| 888 |
+
- Click "3: choices"
|
| 889 |
+
- Generate prompt with Round 2 config
|
| 890 |
+
- LLM adds: investigation options, different approaches, hidden paths
|
| 891 |
+
- Result: 3-4 choices per state, multiple investigation strategies
|
| 892 |
+
|
| 893 |
+
**Round 4: Tension Focus**
|
| 894 |
+
- Click "4: tension"
|
| 895 |
+
- Generate prompt with Round 3 config
|
| 896 |
+
- LLM adds: time pressure, red herrings, dramatic reveals
|
| 897 |
+
- Result: Heightened stakes, misleading clues, climactic moments
|
| 898 |
+
|
| 899 |
+
### Metrics Progression (Example)
|
| 900 |
+
| Round | States | Avg Choices | Avg Desc Length |
|
| 901 |
+
|-------|--------|-------------|-----------------|
|
| 902 |
+
| Start | 7 | 2.0 | 12 words |
|
| 903 |
+
| R1 | 18 | 2.8 | 45 words |
|
| 904 |
+
| R2 | 22 | 3.1 | 52 words |
|
| 905 |
+
| R3 | 25 | 3.6 | 55 words |
|
| 906 |
+
| R4 | 28 | 3.8 | 62 words |
|
| 907 |
+
|
| 908 |
+
### Tips
|
| 909 |
+
1. Each round builds on previous improvements
|
| 910 |
+
2. The prompt includes round context for continuity
|
| 911 |
+
3. Use "Revert" if a round's changes don't work
|
| 912 |
+
4. Export history to save your improvement journey
|
| 913 |
+
"""
|
| 914 |
+
|
| 915 |
+
if __name__ == "__main__":
|
| 916 |
+
import json
|
| 917 |
+
|
| 918 |
+
# Validate all rounds
|
| 919 |
+
rounds = [
|
| 920 |
+
("Starter (R0)", DEMO_MYSTERY_STARTER),
|
| 921 |
+
("Round 1 - Environment", DEMO_AFTER_ROUND_1),
|
| 922 |
+
("Round 2 - Characters", DEMO_AFTER_ROUND_2),
|
| 923 |
+
("Round 3 - Choices", DEMO_AFTER_ROUND_3),
|
| 924 |
+
("Round 4 - Tension", DEMO_AFTER_ROUND_4),
|
| 925 |
+
]
|
| 926 |
+
|
| 927 |
+
print("=== Demo Config Validation ===\n")
|
| 928 |
+
for name, config_str in rounds:
|
| 929 |
+
config = json.loads(config_str)
|
| 930 |
+
states = config.get('investigation', {})
|
| 931 |
+
total_choices = sum(len(s.get('choices', [])) for s in states.values())
|
| 932 |
+
avg_choices = total_choices / len(states) if states else 0
|
| 933 |
+
print(f"{name}: {len(states)} states, {avg_choices:.1f} avg choices")
|
| 934 |
+
|
| 935 |
+
print("\n" + DEMO_WORKFLOW_GUIDE)
|
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"investigation": {
|
| 3 |
+
"crime_scene": {
|
| 4 |
+
"description": "A dimly lit corner office on the 14th floor. Papers are scattered across the mahogany desk and floor - financial reports, all dated from last month. A broken window lets in cold night air, and you notice the glass shards fell inward, onto an expensive Persian rug now stained with what looks like coffee. A framed photo lies face-down near the overturned chair. On the desk, a half-eaten sandwich suggests Richardson left in a hurry - or was interrupted mid-meal. Your phone buzzes: 'URGENT - Richardson's wife just landed at the airport. ETA 45 minutes.' The clock is ticking.",
|
| 5 |
+
"choices": [
|
| 6 |
+
"Examine the desk carefully",
|
| 7 |
+
"Check the broken window",
|
| 8 |
+
"Look at the fallen photo",
|
| 9 |
+
"Search the floor for evidence",
|
| 10 |
+
"Leave and check the hallway"
|
| 11 |
+
],
|
| 12 |
+
"transitions": {
|
| 13 |
+
"Examine the desk carefully": "desk_clue",
|
| 14 |
+
"Check the broken window": "window_clue",
|
| 15 |
+
"Look at the fallen photo": "photo_clue",
|
| 16 |
+
"Search the floor for evidence": "floor_search",
|
| 17 |
+
"Leave and check the hallway": "hallway"
|
| 18 |
+
}
|
| 19 |
+
},
|
| 20 |
+
"desk_clue": {
|
| 21 |
+
"description": "The desk has a half-written letter addressed to 'M' - the handwriting grows increasingly shaky toward the end, betraying Richardson's fear. An empty coffee cup sits beside it, a faint coral lipstick mark on the rim - Richardson's wife wears red, not coral. The desk drawer is slightly ajar, revealing a corner of what looks like a passport. A sticky note on the monitor reads 'MIRANDA CALLED - URGENT' in what looks like a secretary's handwriting. Wait - there's another note underneath: 'Elena knows. Tonight. Midnight.' Someone warned him, but he didn't run.",
|
| 22 |
+
"choices": [
|
| 23 |
+
"Read the letter",
|
| 24 |
+
"Check the drawer",
|
| 25 |
+
"Examine the coffee cup",
|
| 26 |
+
"Look at both sticky notes",
|
| 27 |
+
"Back to scene"
|
| 28 |
+
],
|
| 29 |
+
"transitions": {
|
| 30 |
+
"Read the letter": "letter_reveal",
|
| 31 |
+
"Check the drawer": "drawer_contents",
|
| 32 |
+
"Examine the coffee cup": "coffee_clue",
|
| 33 |
+
"Look at both sticky notes": "sticky_notes_comparison",
|
| 34 |
+
"Back to scene": "crime_scene"
|
| 35 |
+
}
|
| 36 |
+
},
|
| 37 |
+
"window_clue": {
|
| 38 |
+
"description": "Glass shards cover the inside of the windowsill and floor - definitely broken from outside. But something's wrong: the window latch is still locked from the inside. RED HERRING ALERT: Your gut says this is staged. On the exterior ledge, you spot a small piece of torn fabric caught on a nail, dark blue like a security uniform. But wait - security uniforms were switched to BLACK last month. This fabric is from an OLD uniform. Someone planted this.",
|
| 39 |
+
"choices": [
|
| 40 |
+
"Look outside carefully",
|
| 41 |
+
"Examine the planted fabric",
|
| 42 |
+
"Test the lock mechanism",
|
| 43 |
+
"Check for fingerprints",
|
| 44 |
+
"Back to scene"
|
| 45 |
+
],
|
| 46 |
+
"transitions": {
|
| 47 |
+
"Look outside carefully": "outside_view",
|
| 48 |
+
"Examine the planted fabric": "fabric_clue",
|
| 49 |
+
"Test the lock mechanism": "lock_test",
|
| 50 |
+
"Check for fingerprints": "fingerprint_search",
|
| 51 |
+
"Back to scene": "crime_scene"
|
| 52 |
+
}
|
| 53 |
+
},
|
| 54 |
+
"photo_clue": {
|
| 55 |
+
"description": "The frame's glass is cracked. The photo shows two men shaking hands at the company Christmas party 2019 - Richardson in an expensive suit, Martinez in his security uniform, both grinning like old friends. On the back, someone has written 'Partners in crime - Nov 2019' in feminine handwriting, with a small heart. The same coral lipstick has left a faint kiss mark in the corner. But here's what catches your eye: the photo has been recently handled - fresh fingerprints over old dust. Someone looked at this TONIGHT.",
|
| 56 |
+
"choices": [
|
| 57 |
+
"Keep the photo as evidence",
|
| 58 |
+
"Study the fresh fingerprints",
|
| 59 |
+
"Compare the lipstick shade",
|
| 60 |
+
"Check for hidden messages",
|
| 61 |
+
"Back to scene"
|
| 62 |
+
],
|
| 63 |
+
"transitions": {
|
| 64 |
+
"Keep the photo as evidence": "evidence_collected",
|
| 65 |
+
"Study the fresh fingerprints": "fresh_prints",
|
| 66 |
+
"Compare the lipstick shade": "lipstick_comparison",
|
| 67 |
+
"Check for hidden messages": "hidden_message",
|
| 68 |
+
"Back to scene": "crime_scene"
|
| 69 |
+
}
|
| 70 |
+
},
|
| 71 |
+
"floor_search": {
|
| 72 |
+
"description": "Crouching low, you scan the Persian rug and hardwood floor. Beneath the overturned chair, you find a crumpled receipt from a pharmacy dated today - Alprazolam prescription filled at 6:15 PM, patient name obscured by a coffee stain. Near the window, a single pearl earring catches the light. Elena Richardson was photographed wearing pearls at last month's gala. But pearls are common - is this really hers, or another planted clue?",
|
| 73 |
+
"choices": [
|
| 74 |
+
"Pocket the receipt - crucial evidence",
|
| 75 |
+
"Examine the earring skeptically",
|
| 76 |
+
"Check under the desk",
|
| 77 |
+
"Look for more planted evidence",
|
| 78 |
+
"Back to scene"
|
| 79 |
+
],
|
| 80 |
+
"transitions": {
|
| 81 |
+
"Pocket the receipt - crucial evidence": "receipt_clue",
|
| 82 |
+
"Examine the earring skeptically": "earring_analysis",
|
| 83 |
+
"Check under the desk": "under_desk",
|
| 84 |
+
"Look for more planted evidence": "planted_evidence_search",
|
| 85 |
+
"Back to scene": "crime_scene"
|
| 86 |
+
}
|
| 87 |
+
},
|
| 88 |
+
"letter_reveal": {
|
| 89 |
+
"description": "The letter reads: 'Dear M, I can't keep doing this. They're going to find out about the transfers, and when they do, neither of us will be safe. Elena is getting suspicious - she found the Cayman statements. I need to disappear, but I can't leave you behind. Meet me at the usual place at midnight. If I'm not there by 12:30, assume the worst and run. Don't trust Martinez anymore - he's been asking questions. All my love, R.' The writing stops mid-sentence. WAIT - the ink at the end is different. Someone ADDED that last line about Martinez. The original letter ended at 'run.'",
|
| 90 |
+
"choices": [
|
| 91 |
+
"Examine the different inks",
|
| 92 |
+
"Who is 'M'?",
|
| 93 |
+
"Why was Martinez's name added?",
|
| 94 |
+
"Continue investigating"
|
| 95 |
+
],
|
| 96 |
+
"transitions": {
|
| 97 |
+
"Examine the different inks": "ink_analysis",
|
| 98 |
+
"Who is 'M'?": "m_mystery",
|
| 99 |
+
"Why was Martinez's name added?": "martinez_framed",
|
| 100 |
+
"Continue investigating": "crime_scene"
|
| 101 |
+
}
|
| 102 |
+
},
|
| 103 |
+
"drawer_contents": {
|
| 104 |
+
"description": "Inside the drawer: a passport with several recent stamps to the Cayman Islands - the last trip was three days ago. A burner phone with a cracked screen shows the last call was to 'M' at 11:47 PM - just fifteen minutes before the estimated incident. A sticky note reads 'CALL BEFORE FRIDAY - E knows' with 'E' underlined three times. Your phone buzzes again: 'Elena Richardson's car just passed the highway exit. 30 minutes.' You need to solve this before she arrives and lawyers up.",
|
| 105 |
+
"choices": [
|
| 106 |
+
"Grab the phone - check call history",
|
| 107 |
+
"Check the passport dates",
|
| 108 |
+
"Examine the 'E knows' note",
|
| 109 |
+
"Look for hidden compartments",
|
| 110 |
+
"Back to desk"
|
| 111 |
+
],
|
| 112 |
+
"transitions": {
|
| 113 |
+
"Grab the phone - check call history": "phone_evidence",
|
| 114 |
+
"Check the passport dates": "passport_clue",
|
| 115 |
+
"Examine the 'E knows' note": "friday_deadline",
|
| 116 |
+
"Look for hidden compartments": "secret_compartment",
|
| 117 |
+
"Back to desk": "desk_clue"
|
| 118 |
+
}
|
| 119 |
+
},
|
| 120 |
+
"coffee_clue": {
|
| 121 |
+
"description": "The coral lipstick shade is distinctive - 'Sunset Kiss' by Luxe, sold exclusively at Nordstrom. Richardson's secretary Miranda wears this exact shade - you've seen her wear it. But so does ELENA RICHARDSON in her Instagram photos. The cup still has dregs at the bottom - the coffee smells bitter, with an underlying chemical note. There's a faint crystalline residue around the rim. TWIST: The residue isn't a sedative. Field test shows it's artificial sweetener - the expensive kind Elena uses for her diet. Miranda takes her coffee black.",
|
| 122 |
+
"choices": [
|
| 123 |
+
"This changes everything",
|
| 124 |
+
"Test the residue again",
|
| 125 |
+
"Photograph the evidence",
|
| 126 |
+
"Back to desk"
|
| 127 |
+
],
|
| 128 |
+
"transitions": {
|
| 129 |
+
"This changes everything": "elena_revelation",
|
| 130 |
+
"Test the residue again": "residue_confirmation",
|
| 131 |
+
"Photograph the evidence": "evidence_photo",
|
| 132 |
+
"Back to desk": "desk_clue"
|
| 133 |
+
}
|
| 134 |
+
},
|
| 135 |
+
"hallway": {
|
| 136 |
+
"description": "The hallway is quiet, lit by flickering fluorescent lights. A security camera in the corner has a small red light - but you notice it's pointing at the wall, not the office door. Martinez approaches, his boots leaving muddy prints on the polished floor. His jaw is tight, eyes darting. 'Detective. Didn't expect you so soon.' His hand rests near his radio. Then he says something strange: 'Whatever they told you about me and Richardson - it's not the whole story. Elena's people got here first. I tried to warn him.' He looks genuinely scared.",
|
| 137 |
+
"choices": [
|
| 138 |
+
"What do you mean, Elena's people?",
|
| 139 |
+
"Check the security camera first",
|
| 140 |
+
"Examine his muddy boots",
|
| 141 |
+
"Ask about the warning",
|
| 142 |
+
"Return to office"
|
| 143 |
+
],
|
| 144 |
+
"transitions": {
|
| 145 |
+
"What do you mean, Elena's people?": "elena_people",
|
| 146 |
+
"Check the security camera first": "camera_check",
|
| 147 |
+
"Examine his muddy boots": "boot_examination",
|
| 148 |
+
"Ask about the warning": "warning_details",
|
| 149 |
+
"Return to office": "crime_scene"
|
| 150 |
+
}
|
| 151 |
+
},
|
| 152 |
+
"camera_check": {
|
| 153 |
+
"description": "The camera has been manually rotated away from the office door. The adjustment screw is loose, recently turned - you can still see fresh scratches on the metal. Whoever did this knew exactly which camera to move. The timestamp log shows the adjustment happened at 11:42 PM. But here's the CRUCIAL DETAIL: there are TWO sets of scratches. Someone adjusted this camera TWICE. First at 11:42 PM, then again at 11:58 PM - after the incident. Someone came back to make sure it was still pointed away.",
|
| 154 |
+
"choices": [
|
| 155 |
+
"Check footage from other cameras",
|
| 156 |
+
"Who came back at 11:58?",
|
| 157 |
+
"Confront Martinez about timing",
|
| 158 |
+
"Back to hallway"
|
| 159 |
+
],
|
| 160 |
+
"transitions": {
|
| 161 |
+
"Check footage from other cameras": "other_cameras",
|
| 162 |
+
"Who came back at 11:58?": "second_adjustment",
|
| 163 |
+
"Confront Martinez about timing": "timing_confrontation",
|
| 164 |
+
"Back to hallway": "hallway"
|
| 165 |
+
}
|
| 166 |
+
},
|
| 167 |
+
"guard_talk": {
|
| 168 |
+
"description": "'Evening, Detective.' Martinez's voice is steady but his hands aren't - they're shaking. His name tag reads 'Martinez, J.' His uniform is disheveled, with a small tear near his left sleeve. 'I heard nothing unusual tonight. Just doing my rounds.' He keeps glancing at his watch. It's 12:15 AM. Then he leans closer and whispers: 'The woman who came up at 11:35 - it wasn't Miranda. It was Elena. She was wearing Miranda's coat, but I recognized her perfume. Chanel No. 5. I didn't say anything because... her family...' He draws a finger across his throat.",
|
| 169 |
+
"choices": [
|
| 170 |
+
"Elena was HERE?",
|
| 171 |
+
"Tell me about her family",
|
| 172 |
+
"Why didn't you stop her?",
|
| 173 |
+
"You're lying to protect yourself"
|
| 174 |
+
],
|
| 175 |
+
"transitions": {
|
| 176 |
+
"Elena was HERE?": "elena_presence",
|
| 177 |
+
"Tell me about her family": "family_connections",
|
| 178 |
+
"Why didn't you stop her?": "martinez_fear",
|
| 179 |
+
"You're lying to protect yourself": "martinez_defense"
|
| 180 |
+
}
|
| 181 |
+
},
|
| 182 |
+
"guard_pressed": {
|
| 183 |
+
"description": "'Look, I was on my rounds, okay?' Martinez's voice rises, then drops to a whisper. 'The 14th floor wasn't scheduled until midnight. But I went up early because Richardson texted me at 11:25 - said someone was coming, someone dangerous. When I got there at 11:40, I heard voices inside. A woman, screaming. Then silence. I... I hid. I'm not proud of it.' He shows you his phone - the text from Richardson reads: 'SHE FOUND OUT. Coming to office. Help me. Please.' Sent at 11:25 PM.",
|
| 184 |
+
"choices": [
|
| 185 |
+
"Who is 'she'?",
|
| 186 |
+
"Why did you hide?",
|
| 187 |
+
"Show me the rest of the texts",
|
| 188 |
+
"What happened after the silence?"
|
| 189 |
+
],
|
| 190 |
+
"transitions": {
|
| 191 |
+
"Who is 'she'?": "she_identity",
|
| 192 |
+
"Why did you hide?": "hiding_reason",
|
| 193 |
+
"Show me the rest of the texts": "full_texts",
|
| 194 |
+
"What happened after the silence?": "aftermath"
|
| 195 |
+
}
|
| 196 |
+
},
|
| 197 |
+
"confrontation": {
|
| 198 |
+
"description": "Martinez breaks down completely. 'Fine! Yes, Richardson and I were together. For two years. But I didn't hurt him - I LOVED him.' Tears stream down his face. 'When I got to the office, he was already on the floor. The coffee cup was knocked over. A woman in Miranda's coat was climbing out the window - that's why there's fabric on the ledge. But the way she moved... it wasn't Miranda. It was Elena, I swear to God. She set this whole thing up to frame Miranda, frame me. She's been planning this for months.'",
|
| 199 |
+
"choices": [
|
| 200 |
+
"How do you know it was Elena?",
|
| 201 |
+
"Where is Richardson now?",
|
| 202 |
+
"What do you mean, planning for months?",
|
| 203 |
+
"You're under arrest"
|
| 204 |
+
],
|
| 205 |
+
"transitions": {
|
| 206 |
+
"How do you know it was Elena?": "elena_identification",
|
| 207 |
+
"Where is Richardson now?": "richardson_fate",
|
| 208 |
+
"What do you mean, planning for months?": "long_con",
|
| 209 |
+
"You're under arrest": "arrest_martinez"
|
| 210 |
+
}
|
| 211 |
+
},
|
| 212 |
+
"photo_question": {
|
| 213 |
+
"description": "At the mention of the photo, Martinez's face changes from fear to realization. 'That photo... Elena found it last month. She came to me, showed it to me. Said she'd destroy both our careers unless I helped her.' He pulls out his phone, shows you a text thread. Elena Richardson: 'I know about you and my husband. Friday night. You will let me into the building after hours. You will turn off the camera. Or these photos go to HR, the board, and every news outlet in the city.' Dated three days ago.",
|
| 214 |
+
"choices": [
|
| 215 |
+
"She blackmailed you into helping",
|
| 216 |
+
"Did you know what she planned?",
|
| 217 |
+
"Why didn't you warn Richardson?",
|
| 218 |
+
"Show this to my captain"
|
| 219 |
+
],
|
| 220 |
+
"transitions": {
|
| 221 |
+
"She blackmailed you into helping": "blackmail_reveal",
|
| 222 |
+
"Did you know what she planned?": "plan_knowledge",
|
| 223 |
+
"Why didn't you warn Richardson?": "warning_failure",
|
| 224 |
+
"Show this to my captain": "evidence_secured"
|
| 225 |
+
}
|
| 226 |
+
},
|
| 227 |
+
"accounts_info": {
|
| 228 |
+
"description": "'He was moving money - lots of it. Company funds to offshore accounts.' Martinez wipes his eyes. 'We were going to leave together, start fresh. But Elena found out about everything - the money AND us. She didn't just want a divorce. She wanted revenge.' He checks his watch nervously. 'Her family has connections to people who make problems disappear. The coffee, the staged break-in - this was a professional hit made to look like a crime of passion. And I...' His voice breaks. 'I let her in. God help me, I let her in.'",
|
| 229 |
+
"choices": [
|
| 230 |
+
"Where is Elena now?",
|
| 231 |
+
"The coffee was poisoned?",
|
| 232 |
+
"Who else helped her?",
|
| 233 |
+
"We need to find Richardson"
|
| 234 |
+
],
|
| 235 |
+
"transitions": {
|
| 236 |
+
"Where is Elena now?": "elena_location",
|
| 237 |
+
"The coffee was poisoned?": "poison_confirmation",
|
| 238 |
+
"Who else helped her?": "accomplices",
|
| 239 |
+
"We need to find Richardson": "richardson_search"
|
| 240 |
+
}
|
| 241 |
+
},
|
| 242 |
+
"miranda_lead": {
|
| 243 |
+
"description": "You find Miranda Chen at her desk on the 12th floor, furiously packing a box. She freezes when she sees you. 'Detective! I was just... organizing.' Her coral lipstick is smeared, mascara running. 'I heard sirens. Is Mr. Richardson okay?' She's lying - her coat is hanging on the back of her chair, dry and clean. But someone wearing her coat was at the scene. On her desk, a resignation letter dated TODAY. She was planning to leave before any of this happened.",
|
| 244 |
+
"choices": [
|
| 245 |
+
"Where's your other coat, Miranda?",
|
| 246 |
+
"Who borrowed your coat tonight?",
|
| 247 |
+
"Why are you resigning?",
|
| 248 |
+
"Did you bring Richardson coffee tonight?"
|
| 249 |
+
],
|
| 250 |
+
"transitions": {
|
| 251 |
+
"Where's your other coat, Miranda?": "coat_question",
|
| 252 |
+
"Who borrowed your coat tonight?": "coat_borrower",
|
| 253 |
+
"Why are you resigning?": "resignation_reason",
|
| 254 |
+
"Did you bring Richardson coffee tonight?": "coffee_denial"
|
| 255 |
+
}
|
| 256 |
+
},
|
| 257 |
+
"miranda_confession": {
|
| 258 |
+
"description": "'I didn't hurt him!' Miranda sobs. 'Yes, I brought him coffee at 11:40. Yes, I've been in love with him for three years. But when I got to his office, Elena was already there. She was wearing MY coat - must have taken it from my desk earlier. She told me to leave, that this was between her and her husband. I... I ran. I was scared.' She pulls out her phone. 'Look - I called 911 at 11:52 PM. Anonymously. That's how you got here so fast. I tried to help him, I swear.'",
|
| 259 |
+
"choices": [
|
| 260 |
+
"The 911 call - that was you?",
|
| 261 |
+
"What did you see Elena doing?",
|
| 262 |
+
"Why was she wearing your coat?",
|
| 263 |
+
"You're coming with me"
|
| 264 |
+
],
|
| 265 |
+
"transitions": {
|
| 266 |
+
"The 911 call - that was you?": "911_confirmation",
|
| 267 |
+
"What did you see Elena doing?": "elena_actions",
|
| 268 |
+
"Why was she wearing your coat?": "coat_explanation",
|
| 269 |
+
"You're coming with me": "miranda_detained"
|
| 270 |
+
}
|
| 271 |
+
},
|
| 272 |
+
"miranda_coffee": {
|
| 273 |
+
"description": "'I always bring Mr. Richardson his evening coffee.' Miranda's hands shake as she grabs the edge of her desk. 'But tonight - tonight Elena intercepted me in the hallway. Took the cup right out of my hands. Said she wanted to make up with her husband, bring him coffee as a peace offering. I thought it was sweet.' Her face crumbles. 'Oh God. She put something in it, didn't she? She used MY cup, MY lipstick shade - she was setting me up from the start.'",
|
| 274 |
+
"choices": [
|
| 275 |
+
"Elena took the coffee from you?",
|
| 276 |
+
"She planned to frame you",
|
| 277 |
+
"What time was this?",
|
| 278 |
+
"I need you to come with me"
|
| 279 |
+
],
|
| 280 |
+
"transitions": {
|
| 281 |
+
"Elena took the coffee from you?": "coffee_interception",
|
| 282 |
+
"She planned to frame you": "frame_job",
|
| 283 |
+
"What time was this?": "timeline_clarification",
|
| 284 |
+
"I need you to come with me": "miranda_witness"
|
| 285 |
+
}
|
| 286 |
+
},
|
| 287 |
+
"ink_analysis": {
|
| 288 |
+
"description": "Examining the letter under your UV light, the truth becomes clear. The original letter was written with Richardson's usual fountain pen - blue-black Montblanc ink. But the last line about Martinez was added with a cheap ballpoint, common office supply. Someone added that line to redirect suspicion toward Martinez and away from the real killer. The handwriting is close to Richardson's but not exact - slightly more angular. Elena Richardson is known for her perfect forgery skills; she handles all the household documents.",
|
| 289 |
+
"choices": [
|
| 290 |
+
"Elena forged this line",
|
| 291 |
+
"Test the ballpoint ink",
|
| 292 |
+
"Check security footage for document tampering",
|
| 293 |
+
"Continue investigating"
|
| 294 |
+
],
|
| 295 |
+
"transitions": {
|
| 296 |
+
"Elena forged this line": "forgery_confirmed",
|
| 297 |
+
"Test the ballpoint ink": "ink_test",
|
| 298 |
+
"Check security footage for document tampering": "document_footage",
|
| 299 |
+
"Continue investigating": "crime_scene"
|
| 300 |
+
}
|
| 301 |
+
},
|
| 302 |
+
"final_confrontation": {
|
| 303 |
+
"description": "Your phone buzzes one final time: 'Elena Richardson just pulled into the parking garage. She's heading for the elevator.' You have minutes to make a decision. The evidence points to a calculated murder disguised as a crime of passion. Elena poisoned her husband's coffee, staged the break-in, framed Miranda and Martinez, and is about to walk into this building pretending to be the grieving widow. Martinez is willing to testify. Miranda is a witness. The forged letter and planted evidence tell the story. But Elena's family connections mean this case needs to be airtight.",
|
| 304 |
+
"choices": [
|
| 305 |
+
"Arrest Elena in the lobby",
|
| 306 |
+
"Gather more evidence first",
|
| 307 |
+
"Confront her in the office",
|
| 308 |
+
"Call for backup and wait"
|
| 309 |
+
],
|
| 310 |
+
"transitions": {
|
| 311 |
+
"Arrest Elena in the lobby": "lobby_arrest",
|
| 312 |
+
"Gather more evidence first": "more_evidence",
|
| 313 |
+
"Confront her in the office": "office_confrontation",
|
| 314 |
+
"Call for backup and wait": "backup_ending"
|
| 315 |
+
}
|
| 316 |
+
},
|
| 317 |
+
"true_ending": {
|
| 318 |
+
"description": "As Elena Richardson steps off the elevator, her face a perfect mask of concern, you step forward with your badge. 'Mrs. Richardson. We have witnesses placing you at your husband's office at 11:35 PM, wearing Miranda Chen's coat. We have the forged addition to your husband's letter. We have your artificial sweetener in the poisoned coffee. And we have your blackmail texts to Jorge Martinez.' Her mask slips, just for a moment, revealing cold fury. 'You can't prove anything. My family will\u2014' 'Your family will read about this in tomorrow's paper,' you interrupt. 'Elena Richardson, you're under arrest for the murder of David Richardson.' As the cuffs click, Martinez finally stops shaking. Miranda stops crying. Sometimes, the truth finds a way through the lies.",
|
| 319 |
+
"choices": [
|
| 320 |
+
"Case closed"
|
| 321 |
+
],
|
| 322 |
+
"transitions": {
|
| 323 |
+
"Case closed": "crime_scene"
|
| 324 |
+
}
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"investigation": {
|
| 3 |
+
"crime_scene": {
|
| 4 |
+
"description": "A dimly lit office. Papers scattered everywhere. A broken window.",
|
| 5 |
+
"choices": [
|
| 6 |
+
"Examine the desk",
|
| 7 |
+
"Check the window",
|
| 8 |
+
"Leave"
|
| 9 |
+
],
|
| 10 |
+
"transitions": {
|
| 11 |
+
"Examine the desk": "desk_clue",
|
| 12 |
+
"Check the window": "window_clue",
|
| 13 |
+
"Leave": "hallway"
|
| 14 |
+
}
|
| 15 |
+
},
|
| 16 |
+
"desk_clue": {
|
| 17 |
+
"description": "The desk has a half-written letter and an empty coffee cup.",
|
| 18 |
+
"choices": [
|
| 19 |
+
"Read the letter",
|
| 20 |
+
"Back to scene"
|
| 21 |
+
],
|
| 22 |
+
"transitions": {
|
| 23 |
+
"Read the letter": "letter_reveal",
|
| 24 |
+
"Back to scene": "crime_scene"
|
| 25 |
+
}
|
| 26 |
+
},
|
| 27 |
+
"window_clue": {
|
| 28 |
+
"description": "Glass shards on the inside. Someone broke in from outside.",
|
| 29 |
+
"choices": [
|
| 30 |
+
"Look outside",
|
| 31 |
+
"Back to scene"
|
| 32 |
+
],
|
| 33 |
+
"transitions": {
|
| 34 |
+
"Look outside": "outside_view",
|
| 35 |
+
"Back to scene": "crime_scene"
|
| 36 |
+
}
|
| 37 |
+
},
|
| 38 |
+
"letter_reveal": {
|
| 39 |
+
"description": "The letter mentions a meeting tonight. It's unfinished.",
|
| 40 |
+
"choices": [
|
| 41 |
+
"Continue investigating"
|
| 42 |
+
],
|
| 43 |
+
"transitions": {
|
| 44 |
+
"Continue investigating": "crime_scene"
|
| 45 |
+
}
|
| 46 |
+
},
|
| 47 |
+
"outside_view": {
|
| 48 |
+
"description": "Footprints in the mud lead toward the parking lot.",
|
| 49 |
+
"choices": [
|
| 50 |
+
"Follow the footprints",
|
| 51 |
+
"Back inside"
|
| 52 |
+
],
|
| 53 |
+
"transitions": {
|
| 54 |
+
"Follow the footprints": "hallway",
|
| 55 |
+
"Back inside": "crime_scene"
|
| 56 |
+
}
|
| 57 |
+
},
|
| 58 |
+
"hallway": {
|
| 59 |
+
"description": "The hallway is quiet. A security guard approaches.",
|
| 60 |
+
"choices": [
|
| 61 |
+
"Talk to guard",
|
| 62 |
+
"Return to office"
|
| 63 |
+
],
|
| 64 |
+
"transitions": {
|
| 65 |
+
"Talk to guard": "guard_talk",
|
| 66 |
+
"Return to office": "crime_scene"
|
| 67 |
+
}
|
| 68 |
+
},
|
| 69 |
+
"guard_talk": {
|
| 70 |
+
"description": "The guard says he heard nothing unusual tonight.",
|
| 71 |
+
"choices": [
|
| 72 |
+
"Press for details",
|
| 73 |
+
"Thank him and leave"
|
| 74 |
+
],
|
| 75 |
+
"transitions": {
|
| 76 |
+
"Press for details": "guard_talk",
|
| 77 |
+
"Thank him and leave": "hallway"
|
| 78 |
+
}
|
| 79 |
+
}
|
| 80 |
+
}
|
| 81 |
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
D&D AI Game Master - Python Module for Hugging Face Integration
|
| 3 |
+
|
| 4 |
+
A complete D&D 5e game master powered by Hugging Face language models.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from .game_master import DnDGameMaster
|
| 8 |
+
from .hf_integration import HuggingFaceGM, create_gm
|
| 9 |
+
from .models.character import Character, create_blank_character, create_test_character
|
| 10 |
+
from .models.scene import Scene, NPC, Lighting, TimeOfDay, NPCAttitude
|
| 11 |
+
from .models.game_state import GameState, GamePhase, NarrativeEntry
|
| 12 |
+
|
| 13 |
+
__version__ = "0.1.0"
|
| 14 |
+
|
| 15 |
+
__all__ = [
|
| 16 |
+
# Main classes
|
| 17 |
+
"DnDGameMaster",
|
| 18 |
+
"HuggingFaceGM",
|
| 19 |
+
"create_gm",
|
| 20 |
+
# Character
|
| 21 |
+
"Character",
|
| 22 |
+
"create_blank_character",
|
| 23 |
+
"create_test_character",
|
| 24 |
+
# Scene
|
| 25 |
+
"Scene",
|
| 26 |
+
"NPC",
|
| 27 |
+
"Lighting",
|
| 28 |
+
"TimeOfDay",
|
| 29 |
+
"NPCAttitude",
|
| 30 |
+
# Game State
|
| 31 |
+
"GameState",
|
| 32 |
+
"GamePhase",
|
| 33 |
+
"NarrativeEntry",
|
| 34 |
+
]
|
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""D&D 5e game engine - dice, checks, and modifiers."""
|
| 2 |
+
|
| 3 |
+
from .dice import roll_dice, roll_d20, parse_dice_notation, DiceResult, RollMode
|
| 4 |
+
from .checks import skill_check, attack_roll, CheckResult
|
| 5 |
+
from .modifiers import (
|
| 6 |
+
get_ability_modifier,
|
| 7 |
+
get_proficiency_bonus,
|
| 8 |
+
get_skill_modifier,
|
| 9 |
+
SKILL_ABILITIES,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
# Dice
|
| 14 |
+
"roll_dice",
|
| 15 |
+
"roll_d20",
|
| 16 |
+
"parse_dice_notation",
|
| 17 |
+
"DiceResult",
|
| 18 |
+
"RollMode",
|
| 19 |
+
# Checks
|
| 20 |
+
"skill_check",
|
| 21 |
+
"attack_roll",
|
| 22 |
+
"CheckResult",
|
| 23 |
+
# Modifiers
|
| 24 |
+
"get_ability_modifier",
|
| 25 |
+
"get_proficiency_bonus",
|
| 26 |
+
"get_skill_modifier",
|
| 27 |
+
"SKILL_ABILITIES",
|
| 28 |
+
]
|
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""D&D 5e skill checks and attack rolls."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from .dice import roll_d20, DiceResult, RollMode
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class CheckResult:
|
| 9 |
+
"""Result of a skill check or attack roll."""
|
| 10 |
+
roll: DiceResult
|
| 11 |
+
dc: int
|
| 12 |
+
success: bool
|
| 13 |
+
critical_success: bool
|
| 14 |
+
critical_failure: bool
|
| 15 |
+
margin: int # How much above/below DC
|
| 16 |
+
|
| 17 |
+
def __str__(self) -> str:
|
| 18 |
+
result = "SUCCESS" if self.success else "FAILURE"
|
| 19 |
+
if self.critical_success:
|
| 20 |
+
result = "CRITICAL SUCCESS"
|
| 21 |
+
elif self.critical_failure:
|
| 22 |
+
result = "CRITICAL FAILURE"
|
| 23 |
+
return f"{self.roll} vs DC {self.dc}: {result} (margin: {self.margin:+d})"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def skill_check(
|
| 27 |
+
modifier: int,
|
| 28 |
+
dc: int,
|
| 29 |
+
mode: RollMode = RollMode.NORMAL
|
| 30 |
+
) -> CheckResult:
|
| 31 |
+
"""
|
| 32 |
+
Perform a skill check against a DC.
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
modifier: Total skill modifier
|
| 36 |
+
dc: Difficulty Class to beat
|
| 37 |
+
mode: RollMode for advantage/disadvantage
|
| 38 |
+
|
| 39 |
+
Returns:
|
| 40 |
+
CheckResult with roll details and success/failure
|
| 41 |
+
"""
|
| 42 |
+
roll = roll_d20(modifier, mode)
|
| 43 |
+
|
| 44 |
+
is_nat_20 = roll.natural_roll == 20
|
| 45 |
+
is_nat_1 = roll.natural_roll == 1
|
| 46 |
+
|
| 47 |
+
# In 5e, natural 20/1 on skill checks aren't auto-success/fail RAW,
|
| 48 |
+
# but many tables play that way. We'll use that common house rule.
|
| 49 |
+
success = roll.total >= dc or is_nat_20
|
| 50 |
+
if is_nat_1:
|
| 51 |
+
success = False
|
| 52 |
+
|
| 53 |
+
return CheckResult(
|
| 54 |
+
roll=roll,
|
| 55 |
+
dc=dc,
|
| 56 |
+
success=success,
|
| 57 |
+
critical_success=is_nat_20,
|
| 58 |
+
critical_failure=is_nat_1,
|
| 59 |
+
margin=roll.total - dc
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def attack_roll(
|
| 64 |
+
attack_bonus: int,
|
| 65 |
+
target_ac: int,
|
| 66 |
+
mode: RollMode = RollMode.NORMAL
|
| 67 |
+
) -> CheckResult:
|
| 68 |
+
"""
|
| 69 |
+
Perform an attack roll against AC.
|
| 70 |
+
|
| 71 |
+
Args:
|
| 72 |
+
attack_bonus: Total attack bonus
|
| 73 |
+
target_ac: Target's Armor Class
|
| 74 |
+
mode: RollMode for advantage/disadvantage
|
| 75 |
+
|
| 76 |
+
Returns:
|
| 77 |
+
CheckResult with roll details and hit/miss
|
| 78 |
+
"""
|
| 79 |
+
roll = roll_d20(attack_bonus, mode)
|
| 80 |
+
|
| 81 |
+
is_nat_20 = roll.natural_roll == 20
|
| 82 |
+
is_nat_1 = roll.natural_roll == 1
|
| 83 |
+
|
| 84 |
+
# Natural 20 always hits, natural 1 always misses (RAW for attacks)
|
| 85 |
+
if is_nat_20:
|
| 86 |
+
success = True
|
| 87 |
+
elif is_nat_1:
|
| 88 |
+
success = False
|
| 89 |
+
else:
|
| 90 |
+
success = roll.total >= target_ac
|
| 91 |
+
|
| 92 |
+
return CheckResult(
|
| 93 |
+
roll=roll,
|
| 94 |
+
dc=target_ac,
|
| 95 |
+
success=success,
|
| 96 |
+
critical_success=is_nat_20,
|
| 97 |
+
critical_failure=is_nat_1,
|
| 98 |
+
margin=roll.total - target_ac
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def saving_throw(
|
| 103 |
+
modifier: int,
|
| 104 |
+
dc: int,
|
| 105 |
+
mode: RollMode = RollMode.NORMAL
|
| 106 |
+
) -> CheckResult:
|
| 107 |
+
"""
|
| 108 |
+
Perform a saving throw against a DC.
|
| 109 |
+
|
| 110 |
+
Args:
|
| 111 |
+
modifier: Total saving throw modifier
|
| 112 |
+
dc: Difficulty Class to beat
|
| 113 |
+
mode: RollMode for advantage/disadvantage
|
| 114 |
+
|
| 115 |
+
Returns:
|
| 116 |
+
CheckResult with roll details and success/failure
|
| 117 |
+
"""
|
| 118 |
+
# Saving throws work like skill checks
|
| 119 |
+
return skill_check(modifier, dc, mode)
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def contested_check(
|
| 123 |
+
modifier1: int,
|
| 124 |
+
modifier2: int,
|
| 125 |
+
mode1: RollMode = RollMode.NORMAL,
|
| 126 |
+
mode2: RollMode = RollMode.NORMAL
|
| 127 |
+
) -> tuple[CheckResult, CheckResult, int]:
|
| 128 |
+
"""
|
| 129 |
+
Perform a contested check between two parties.
|
| 130 |
+
|
| 131 |
+
Args:
|
| 132 |
+
modifier1: First party's modifier
|
| 133 |
+
modifier2: Second party's modifier
|
| 134 |
+
mode1: First party's roll mode
|
| 135 |
+
mode2: Second party's roll mode
|
| 136 |
+
|
| 137 |
+
Returns:
|
| 138 |
+
Tuple of (party1_result, party2_result, winner)
|
| 139 |
+
winner: 1 if party1 wins, 2 if party2 wins, 0 if tie
|
| 140 |
+
"""
|
| 141 |
+
roll1 = roll_d20(modifier1, mode1)
|
| 142 |
+
roll2 = roll_d20(modifier2, mode2)
|
| 143 |
+
|
| 144 |
+
# Use each other's total as the DC
|
| 145 |
+
result1 = CheckResult(
|
| 146 |
+
roll=roll1,
|
| 147 |
+
dc=roll2.total,
|
| 148 |
+
success=roll1.total >= roll2.total,
|
| 149 |
+
critical_success=roll1.natural_roll == 20,
|
| 150 |
+
critical_failure=roll1.natural_roll == 1,
|
| 151 |
+
margin=roll1.total - roll2.total
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
result2 = CheckResult(
|
| 155 |
+
roll=roll2,
|
| 156 |
+
dc=roll1.total,
|
| 157 |
+
success=roll2.total > roll1.total, # Tie goes to party1
|
| 158 |
+
critical_success=roll2.natural_roll == 20,
|
| 159 |
+
critical_failure=roll2.natural_roll == 1,
|
| 160 |
+
margin=roll2.total - roll1.total
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
if roll1.total > roll2.total:
|
| 164 |
+
winner = 1
|
| 165 |
+
elif roll2.total > roll1.total:
|
| 166 |
+
winner = 2
|
| 167 |
+
else:
|
| 168 |
+
winner = 0 # Tie
|
| 169 |
+
|
| 170 |
+
return result1, result2, winner
|
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dice rolling system for D&D 5e."""
|
| 2 |
+
|
| 3 |
+
import random
|
| 4 |
+
import re
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from typing import List, Tuple
|
| 7 |
+
from enum import Enum
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class RollMode(Enum):
|
| 11 |
+
"""Roll modes for advantage/disadvantage."""
|
| 12 |
+
NORMAL = "normal"
|
| 13 |
+
ADVANTAGE = "advantage"
|
| 14 |
+
DISADVANTAGE = "disadvantage"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
@dataclass
|
| 18 |
+
class DiceResult:
|
| 19 |
+
"""Result of a dice roll."""
|
| 20 |
+
notation: str
|
| 21 |
+
rolls: List[int]
|
| 22 |
+
modifier: int
|
| 23 |
+
total: int
|
| 24 |
+
natural_roll: int # For d20s, the die result before modifiers
|
| 25 |
+
|
| 26 |
+
def __str__(self) -> str:
|
| 27 |
+
rolls_str = ", ".join(str(r) for r in self.rolls)
|
| 28 |
+
if self.modifier >= 0:
|
| 29 |
+
return f"{self.notation}: [{rolls_str}] + {self.modifier} = {self.total}"
|
| 30 |
+
else:
|
| 31 |
+
return f"{self.notation}: [{rolls_str}] - {abs(self.modifier)} = {self.total}"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def parse_dice_notation(notation: str) -> Tuple[int, int, int]:
|
| 35 |
+
"""
|
| 36 |
+
Parse dice notation like '2d6+3' into (count, sides, modifier).
|
| 37 |
+
|
| 38 |
+
Args:
|
| 39 |
+
notation: Dice notation string (e.g., "2d6+3", "d20", "4d8-2")
|
| 40 |
+
|
| 41 |
+
Returns:
|
| 42 |
+
Tuple of (count, sides, modifier)
|
| 43 |
+
|
| 44 |
+
Raises:
|
| 45 |
+
ValueError: If notation is invalid
|
| 46 |
+
"""
|
| 47 |
+
pattern = r'^(\d*)d(\d+)([+-]\d+)?$'
|
| 48 |
+
match = re.match(pattern, notation.lower().strip())
|
| 49 |
+
|
| 50 |
+
if not match:
|
| 51 |
+
raise ValueError(f"Invalid dice notation: {notation}")
|
| 52 |
+
|
| 53 |
+
count = int(match.group(1)) if match.group(1) else 1
|
| 54 |
+
sides = int(match.group(2))
|
| 55 |
+
modifier = int(match.group(3)) if match.group(3) else 0
|
| 56 |
+
|
| 57 |
+
return count, sides, modifier
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def roll_dice(notation: str, mode: RollMode = RollMode.NORMAL) -> DiceResult:
|
| 61 |
+
"""
|
| 62 |
+
Roll dice with optional advantage/disadvantage.
|
| 63 |
+
|
| 64 |
+
Args:
|
| 65 |
+
notation: Dice notation string (e.g., "2d6+3")
|
| 66 |
+
mode: RollMode for advantage/disadvantage
|
| 67 |
+
|
| 68 |
+
Returns:
|
| 69 |
+
DiceResult with roll details
|
| 70 |
+
"""
|
| 71 |
+
count, sides, modifier = parse_dice_notation(notation)
|
| 72 |
+
|
| 73 |
+
if mode == RollMode.NORMAL:
|
| 74 |
+
rolls = [random.randint(1, sides) for _ in range(count)]
|
| 75 |
+
natural = sum(rolls)
|
| 76 |
+
elif mode == RollMode.ADVANTAGE:
|
| 77 |
+
roll1 = [random.randint(1, sides) for _ in range(count)]
|
| 78 |
+
roll2 = [random.randint(1, sides) for _ in range(count)]
|
| 79 |
+
if sum(roll1) >= sum(roll2):
|
| 80 |
+
rolls = roll1
|
| 81 |
+
else:
|
| 82 |
+
rolls = roll2
|
| 83 |
+
natural = sum(rolls)
|
| 84 |
+
else: # DISADVANTAGE
|
| 85 |
+
roll1 = [random.randint(1, sides) for _ in range(count)]
|
| 86 |
+
roll2 = [random.randint(1, sides) for _ in range(count)]
|
| 87 |
+
if sum(roll1) <= sum(roll2):
|
| 88 |
+
rolls = roll1
|
| 89 |
+
else:
|
| 90 |
+
rolls = roll2
|
| 91 |
+
natural = sum(rolls)
|
| 92 |
+
|
| 93 |
+
return DiceResult(
|
| 94 |
+
notation=notation,
|
| 95 |
+
rolls=rolls,
|
| 96 |
+
modifier=modifier,
|
| 97 |
+
total=sum(rolls) + modifier,
|
| 98 |
+
natural_roll=natural
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def roll_d20(modifier: int = 0, mode: RollMode = RollMode.NORMAL) -> DiceResult:
|
| 103 |
+
"""
|
| 104 |
+
Convenience function for d20 rolls.
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
modifier: Modifier to add to the roll
|
| 108 |
+
mode: RollMode for advantage/disadvantage
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
DiceResult with roll details
|
| 112 |
+
"""
|
| 113 |
+
result = roll_dice("1d20", mode)
|
| 114 |
+
result.modifier = modifier
|
| 115 |
+
result.total = result.natural_roll + modifier
|
| 116 |
+
return result
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def roll_damage(notation: str, critical: bool = False) -> DiceResult:
|
| 120 |
+
"""
|
| 121 |
+
Roll damage dice, doubling on critical hits.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
notation: Damage dice notation (e.g., "2d6+3")
|
| 125 |
+
critical: If True, double the dice (not modifier)
|
| 126 |
+
|
| 127 |
+
Returns:
|
| 128 |
+
DiceResult with damage details
|
| 129 |
+
"""
|
| 130 |
+
count, sides, modifier = parse_dice_notation(notation)
|
| 131 |
+
|
| 132 |
+
if critical:
|
| 133 |
+
count *= 2
|
| 134 |
+
notation = f"{count}d{sides}{'+' if modifier >= 0 else ''}{modifier}"
|
| 135 |
+
|
| 136 |
+
return roll_dice(notation)
|
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""D&D 5e ability modifiers and proficiency calculations."""
|
| 2 |
+
|
| 3 |
+
from typing import Dict
|
| 4 |
+
|
| 5 |
+
# Skill to ability mapping (D&D 5e)
|
| 6 |
+
SKILL_ABILITIES: Dict[str, str] = {
|
| 7 |
+
"acrobatics": "dexterity",
|
| 8 |
+
"animal_handling": "wisdom",
|
| 9 |
+
"arcana": "intelligence",
|
| 10 |
+
"athletics": "strength",
|
| 11 |
+
"deception": "charisma",
|
| 12 |
+
"history": "intelligence",
|
| 13 |
+
"insight": "wisdom",
|
| 14 |
+
"intimidation": "charisma",
|
| 15 |
+
"investigation": "intelligence",
|
| 16 |
+
"medicine": "wisdom",
|
| 17 |
+
"nature": "intelligence",
|
| 18 |
+
"perception": "wisdom",
|
| 19 |
+
"performance": "charisma",
|
| 20 |
+
"persuasion": "charisma",
|
| 21 |
+
"religion": "intelligence",
|
| 22 |
+
"sleight_of_hand": "dexterity",
|
| 23 |
+
"stealth": "dexterity",
|
| 24 |
+
"survival": "wisdom",
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
# Alternate names mapping
|
| 28 |
+
SKILL_ALIASES: Dict[str, str] = {
|
| 29 |
+
"animal handling": "animal_handling",
|
| 30 |
+
"sleight of hand": "sleight_of_hand",
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def normalize_skill_name(skill: str) -> str:
|
| 35 |
+
"""Normalize skill name to standard format."""
|
| 36 |
+
normalized = skill.lower().strip()
|
| 37 |
+
return SKILL_ALIASES.get(normalized, normalized.replace(" ", "_"))
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def get_ability_modifier(score: int) -> int:
|
| 41 |
+
"""
|
| 42 |
+
Calculate ability modifier from ability score.
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
score: Ability score (typically 1-30)
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
Ability modifier (score - 10) // 2
|
| 49 |
+
"""
|
| 50 |
+
return (score - 10) // 2
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def get_proficiency_bonus(level: int) -> int:
|
| 54 |
+
"""
|
| 55 |
+
Get proficiency bonus based on character level.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
level: Character level (1-20)
|
| 59 |
+
|
| 60 |
+
Returns:
|
| 61 |
+
Proficiency bonus (2-6)
|
| 62 |
+
"""
|
| 63 |
+
if level < 1:
|
| 64 |
+
return 2
|
| 65 |
+
return 2 + (level - 1) // 4
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def get_skill_modifier(
|
| 69 |
+
ability_scores: Dict[str, int],
|
| 70 |
+
skill: str,
|
| 71 |
+
level: int,
|
| 72 |
+
is_proficient: bool = False,
|
| 73 |
+
has_expertise: bool = False
|
| 74 |
+
) -> int:
|
| 75 |
+
"""
|
| 76 |
+
Calculate total skill check modifier.
|
| 77 |
+
|
| 78 |
+
Args:
|
| 79 |
+
ability_scores: Dict mapping ability names to scores
|
| 80 |
+
skill: Skill name (e.g., "stealth", "perception")
|
| 81 |
+
level: Character level
|
| 82 |
+
is_proficient: Whether character is proficient in this skill
|
| 83 |
+
has_expertise: Whether character has expertise (double proficiency)
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
Total modifier for skill checks
|
| 87 |
+
"""
|
| 88 |
+
normalized_skill = normalize_skill_name(skill)
|
| 89 |
+
ability = SKILL_ABILITIES.get(normalized_skill, "strength")
|
| 90 |
+
base_mod = get_ability_modifier(ability_scores.get(ability, 10))
|
| 91 |
+
|
| 92 |
+
if has_expertise:
|
| 93 |
+
return base_mod + (get_proficiency_bonus(level) * 2)
|
| 94 |
+
elif is_proficient:
|
| 95 |
+
return base_mod + get_proficiency_bonus(level)
|
| 96 |
+
return base_mod
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def get_saving_throw_modifier(
|
| 100 |
+
ability_scores: Dict[str, int],
|
| 101 |
+
ability: str,
|
| 102 |
+
level: int,
|
| 103 |
+
is_proficient: bool = False
|
| 104 |
+
) -> int:
|
| 105 |
+
"""
|
| 106 |
+
Calculate saving throw modifier.
|
| 107 |
+
|
| 108 |
+
Args:
|
| 109 |
+
ability_scores: Dict mapping ability names to scores
|
| 110 |
+
ability: Ability name (e.g., "dexterity", "wisdom")
|
| 111 |
+
level: Character level
|
| 112 |
+
is_proficient: Whether character is proficient in this save
|
| 113 |
+
|
| 114 |
+
Returns:
|
| 115 |
+
Total modifier for saving throws
|
| 116 |
+
"""
|
| 117 |
+
base_mod = get_ability_modifier(ability_scores.get(ability.lower(), 10))
|
| 118 |
+
|
| 119 |
+
if is_proficient:
|
| 120 |
+
return base_mod + get_proficiency_bonus(level)
|
| 121 |
+
return base_mod
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def get_attack_bonus(
|
| 125 |
+
ability_scores: Dict[str, int],
|
| 126 |
+
ability: str,
|
| 127 |
+
level: int,
|
| 128 |
+
is_proficient: bool = True
|
| 129 |
+
) -> int:
|
| 130 |
+
"""
|
| 131 |
+
Calculate attack bonus.
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
ability_scores: Dict mapping ability names to scores
|
| 135 |
+
ability: Ability used for attack (usually "strength" or "dexterity")
|
| 136 |
+
level: Character level
|
| 137 |
+
is_proficient: Whether character is proficient with the weapon
|
| 138 |
+
|
| 139 |
+
Returns:
|
| 140 |
+
Attack bonus
|
| 141 |
+
"""
|
| 142 |
+
base_mod = get_ability_modifier(ability_scores.get(ability.lower(), 10))
|
| 143 |
+
|
| 144 |
+
if is_proficient:
|
| 145 |
+
return base_mod + get_proficiency_bonus(level)
|
| 146 |
+
return base_mod
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def get_spell_save_dc(
|
| 150 |
+
ability_scores: Dict[str, int],
|
| 151 |
+
spellcasting_ability: str,
|
| 152 |
+
level: int
|
| 153 |
+
) -> int:
|
| 154 |
+
"""
|
| 155 |
+
Calculate spell save DC.
|
| 156 |
+
|
| 157 |
+
Args:
|
| 158 |
+
ability_scores: Dict mapping ability names to scores
|
| 159 |
+
spellcasting_ability: The spellcasting ability (e.g., "intelligence")
|
| 160 |
+
level: Character level
|
| 161 |
+
|
| 162 |
+
Returns:
|
| 163 |
+
Spell save DC (8 + proficiency + ability modifier)
|
| 164 |
+
"""
|
| 165 |
+
ability_mod = get_ability_modifier(
|
| 166 |
+
ability_scores.get(spellcasting_ability.lower(), 10)
|
| 167 |
+
)
|
| 168 |
+
return 8 + get_proficiency_bonus(level) + ability_mod
|
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Example usage of the D&D AI Game Master module.
|
| 4 |
+
|
| 5 |
+
Run with:
|
| 6 |
+
python -m dnd_game_master.example
|
| 7 |
+
|
| 8 |
+
Or:
|
| 9 |
+
python example.py
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import os
|
| 13 |
+
from dnd_game_master import (
|
| 14 |
+
DnDGameMaster,
|
| 15 |
+
Character,
|
| 16 |
+
Scene,
|
| 17 |
+
create_test_character,
|
| 18 |
+
)
|
| 19 |
+
from dnd_game_master.models.scene import (
|
| 20 |
+
Lighting,
|
| 21 |
+
TimeOfDay,
|
| 22 |
+
NPC,
|
| 23 |
+
NPCAttitude,
|
| 24 |
+
create_tavern_scene,
|
| 25 |
+
)
|
| 26 |
+
from dnd_game_master.game_master import run_game_loop
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def main():
|
| 30 |
+
"""Run the D&D Game Master example."""
|
| 31 |
+
|
| 32 |
+
# Get API token from environment or prompt
|
| 33 |
+
api_token = os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN")
|
| 34 |
+
|
| 35 |
+
if not api_token:
|
| 36 |
+
print("=" * 60)
|
| 37 |
+
print("D&D AI Game Master - Setup")
|
| 38 |
+
print("=" * 60)
|
| 39 |
+
print("\nNo HF_TOKEN environment variable found.")
|
| 40 |
+
print("Get your free token at: https://huggingface.co/settings/tokens")
|
| 41 |
+
api_token = input("\nEnter your Hugging Face API token: ").strip()
|
| 42 |
+
|
| 43 |
+
if not api_token:
|
| 44 |
+
print("\nNo token provided. Exiting.")
|
| 45 |
+
return
|
| 46 |
+
|
| 47 |
+
print("\nInitializing Game Master...")
|
| 48 |
+
|
| 49 |
+
# Create the Game Master with Hugging Face
|
| 50 |
+
gm = DnDGameMaster(
|
| 51 |
+
model_id="mistralai/Mistral-7B-Instruct-v0.3",
|
| 52 |
+
api_token=api_token,
|
| 53 |
+
use_local_model=False, # Set True to load model locally
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
# Use test character or create custom one
|
| 57 |
+
print("\nWould you like to:")
|
| 58 |
+
print("1. Use the pre-made character (Kira the Rogue)")
|
| 59 |
+
print("2. Create a quick custom character")
|
| 60 |
+
|
| 61 |
+
choice = input("\nChoice (1/2): ").strip()
|
| 62 |
+
|
| 63 |
+
if choice == "2":
|
| 64 |
+
name = input("Character name: ").strip() or "Adventurer"
|
| 65 |
+
print("\nClasses: Fighter, Rogue, Wizard, Cleric, Ranger, Paladin")
|
| 66 |
+
char_class = input("Class: ").strip() or "Fighter"
|
| 67 |
+
print("\nRaces: Human, Elf, Dwarf, Halfling, Half-Elf, Tiefling")
|
| 68 |
+
race = input("Race: ").strip() or "Human"
|
| 69 |
+
|
| 70 |
+
character = Character(
|
| 71 |
+
name=name,
|
| 72 |
+
race=race,
|
| 73 |
+
character_class=char_class,
|
| 74 |
+
level=3,
|
| 75 |
+
strength=14,
|
| 76 |
+
dexterity=14,
|
| 77 |
+
constitution=14,
|
| 78 |
+
intelligence=12,
|
| 79 |
+
wisdom=12,
|
| 80 |
+
charisma=12,
|
| 81 |
+
max_hp=25,
|
| 82 |
+
current_hp=25,
|
| 83 |
+
armor_class=15,
|
| 84 |
+
proficient_skills=["perception", "athletics", "intimidation"],
|
| 85 |
+
)
|
| 86 |
+
else:
|
| 87 |
+
character = create_test_character()
|
| 88 |
+
|
| 89 |
+
# Create starting scene
|
| 90 |
+
scene = create_tavern_scene()
|
| 91 |
+
|
| 92 |
+
# Run the game loop
|
| 93 |
+
run_game_loop(
|
| 94 |
+
gm=gm,
|
| 95 |
+
character=character,
|
| 96 |
+
scene=scene,
|
| 97 |
+
adventure_name="The Dusty Tankard Mystery",
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def quick_demo():
|
| 102 |
+
"""Run a quick non-interactive demo."""
|
| 103 |
+
|
| 104 |
+
api_token = os.environ.get("HF_TOKEN")
|
| 105 |
+
if not api_token:
|
| 106 |
+
print("Set HF_TOKEN environment variable to run demo")
|
| 107 |
+
return
|
| 108 |
+
|
| 109 |
+
print("Initializing Game Master...")
|
| 110 |
+
gm = DnDGameMaster(api_token=api_token)
|
| 111 |
+
|
| 112 |
+
character = create_test_character()
|
| 113 |
+
scene = create_tavern_scene()
|
| 114 |
+
|
| 115 |
+
gm.start_adventure(character, scene, "Demo Adventure")
|
| 116 |
+
|
| 117 |
+
# Simulate a few actions
|
| 118 |
+
actions = [
|
| 119 |
+
"I look around the tavern, trying to spot anything unusual.",
|
| 120 |
+
"I approach the hooded stranger in the corner.",
|
| 121 |
+
"I try to pickpocket the stranger.",
|
| 122 |
+
]
|
| 123 |
+
|
| 124 |
+
for action in actions:
|
| 125 |
+
print(f"\n> {action}")
|
| 126 |
+
result = gm.process_player_action(action)
|
| 127 |
+
|
| 128 |
+
if result["type"] == "needs_roll":
|
| 129 |
+
print(f"\n{result['narration']}")
|
| 130 |
+
print(f"[Rolling {result['skill']} DC {result['dc']}...]")
|
| 131 |
+
|
| 132 |
+
roll_result = gm.execute_roll()
|
| 133 |
+
roll = roll_result["roll"]
|
| 134 |
+
print(f"Rolled: {roll['natural']} + {roll['modifier']} = {roll['total']}")
|
| 135 |
+
print(f"Result: {'Success!' if roll_result['success'] else 'Failure...'}")
|
| 136 |
+
print(f"\n{roll_result['narration']}")
|
| 137 |
+
else:
|
| 138 |
+
print(f"\n{result['narration']}")
|
| 139 |
+
|
| 140 |
+
print("-" * 40)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
if __name__ == "__main__":
|
| 144 |
+
import sys
|
| 145 |
+
|
| 146 |
+
if "--demo" in sys.argv:
|
| 147 |
+
quick_demo()
|
| 148 |
+
else:
|
| 149 |
+
main()
|
|
@@ -0,0 +1,462 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Main D&D Game Master orchestrator."""
|
| 2 |
+
|
| 3 |
+
from typing import Optional, Dict, Any, List
|
| 4 |
+
from .hf_integration import HuggingFaceGM, create_gm
|
| 5 |
+
from .models.character import Character, create_blank_character
|
| 6 |
+
from .models.scene import Scene, Lighting, TimeOfDay
|
| 7 |
+
from .models.game_state import GameState, GamePhase
|
| 8 |
+
from .engine.dice import roll_d20, roll_dice, RollMode
|
| 9 |
+
from .engine.checks import skill_check, attack_roll
|
| 10 |
+
from .engine.modifiers import get_skill_modifier, get_ability_modifier
|
| 11 |
+
from .prompts.gm_prompts import (
|
| 12 |
+
build_gm_system_prompt,
|
| 13 |
+
build_scene_context,
|
| 14 |
+
build_roll_outcome_prompt,
|
| 15 |
+
build_scene_extraction_prompt,
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class DnDGameMaster:
|
| 20 |
+
"""
|
| 21 |
+
Main Game Master class that orchestrates the D&D experience.
|
| 22 |
+
|
| 23 |
+
Integrates Hugging Face models for narrative generation with
|
| 24 |
+
D&D 5e game mechanics.
|
| 25 |
+
|
| 26 |
+
Example:
|
| 27 |
+
>>> gm = DnDGameMaster(api_token="hf_...")
|
| 28 |
+
>>> gm.start_adventure(character, scene)
|
| 29 |
+
>>> result = gm.process_player_action("I search the room")
|
| 30 |
+
>>> if result["type"] == "needs_roll":
|
| 31 |
+
... roll_result = gm.execute_roll()
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
def __init__(
|
| 35 |
+
self,
|
| 36 |
+
model_id: str = "mistralai/Mistral-7B-Instruct-v0.3",
|
| 37 |
+
api_token: Optional[str] = None,
|
| 38 |
+
use_local_model: bool = False,
|
| 39 |
+
device: str = "auto",
|
| 40 |
+
):
|
| 41 |
+
"""
|
| 42 |
+
Initialize the D&D Game Master.
|
| 43 |
+
|
| 44 |
+
Args:
|
| 45 |
+
model_id: Hugging Face model to use for narrative generation
|
| 46 |
+
api_token: HF API token (or set HF_TOKEN environment variable)
|
| 47 |
+
use_local_model: If True, load model locally instead of using API
|
| 48 |
+
device: Device for local inference ("auto", "cuda", "cpu", "mps")
|
| 49 |
+
"""
|
| 50 |
+
self.gm = create_gm(
|
| 51 |
+
model_id=model_id,
|
| 52 |
+
api_token=api_token,
|
| 53 |
+
local=use_local_model,
|
| 54 |
+
device=device,
|
| 55 |
+
)
|
| 56 |
+
self.state = GameState()
|
| 57 |
+
self.system_prompt = build_gm_system_prompt()
|
| 58 |
+
self.pending_roll: Optional[Dict[str, Any]] = None
|
| 59 |
+
|
| 60 |
+
def set_character(self, character: Character):
|
| 61 |
+
"""Set the active player character."""
|
| 62 |
+
self.state.character = character
|
| 63 |
+
self.state.phase = GamePhase.PLAYING
|
| 64 |
+
|
| 65 |
+
def set_scene(self, scene: Scene):
|
| 66 |
+
"""Set the current scene."""
|
| 67 |
+
self.state.scene = scene
|
| 68 |
+
self.state.add_narrative("scene", scene.description)
|
| 69 |
+
|
| 70 |
+
def start_adventure(
|
| 71 |
+
self,
|
| 72 |
+
character: Character,
|
| 73 |
+
scene: Scene,
|
| 74 |
+
adventure_name: str = "",
|
| 75 |
+
):
|
| 76 |
+
"""
|
| 77 |
+
Begin a new adventure.
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
character: The player character
|
| 81 |
+
scene: The starting scene
|
| 82 |
+
adventure_name: Optional name for the adventure
|
| 83 |
+
"""
|
| 84 |
+
self.state.start_adventure(character, scene, adventure_name)
|
| 85 |
+
self.pending_roll = None
|
| 86 |
+
print(f"\n{'='*60}")
|
| 87 |
+
print(f"Adventure: {adventure_name or 'A New Journey'}")
|
| 88 |
+
print(f"Character: {character.name}, {character.race} {character.character_class}")
|
| 89 |
+
print(f"{'='*60}")
|
| 90 |
+
print(f"\n{scene.description}\n")
|
| 91 |
+
|
| 92 |
+
def process_player_action(self, action: str) -> Dict[str, Any]:
|
| 93 |
+
"""
|
| 94 |
+
Process a player's described action.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
action: What the player wants to do (natural language)
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
Dict with:
|
| 101 |
+
- type: "impossible" | "auto_success" | "auto_fail" | "needs_roll"
|
| 102 |
+
- narration: The narrative response
|
| 103 |
+
- skill: (if needs_roll) Skill to check
|
| 104 |
+
- dc: (if needs_roll) Difficulty class
|
| 105 |
+
- advantage/disadvantage: (if needs_roll) Roll mode
|
| 106 |
+
- suggestions: (if impossible) Alternative actions
|
| 107 |
+
"""
|
| 108 |
+
if not self.state.character or not self.state.scene:
|
| 109 |
+
return {
|
| 110 |
+
"type": "error",
|
| 111 |
+
"narration": "No active character or scene. Use start_adventure() first.",
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
# Build context for the LLM
|
| 115 |
+
context = build_scene_context(self.state.character, self.state.scene)
|
| 116 |
+
|
| 117 |
+
# Get GM decision
|
| 118 |
+
decision = self.gm.get_gm_decision(
|
| 119 |
+
player_action=action,
|
| 120 |
+
system_prompt=self.system_prompt,
|
| 121 |
+
context=context,
|
| 122 |
+
conversation_history=self.state.get_recent_messages(),
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
# Log the interaction
|
| 126 |
+
self.state.add_message("user", action)
|
| 127 |
+
self.state.add_narrative("player", action)
|
| 128 |
+
|
| 129 |
+
# Handle based on decision type
|
| 130 |
+
decision_type = decision.get("decision", "auto_success")
|
| 131 |
+
narration = decision.get("narration", "")
|
| 132 |
+
|
| 133 |
+
if decision_type == "needs_roll":
|
| 134 |
+
# Store pending roll info
|
| 135 |
+
self.pending_roll = {
|
| 136 |
+
"action": action,
|
| 137 |
+
"skill": decision.get("skill", "perception"),
|
| 138 |
+
"dc": decision.get("dc", 15),
|
| 139 |
+
"advantage": decision.get("advantage", False),
|
| 140 |
+
"disadvantage": decision.get("disadvantage", False),
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
self.state.add_narrative(
|
| 144 |
+
"roll_request",
|
| 145 |
+
f"Roll {self.pending_roll['skill'].title()} (DC {self.pending_roll['dc']})",
|
| 146 |
+
skill=self.pending_roll["skill"],
|
| 147 |
+
dc=self.pending_roll["dc"],
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
return {
|
| 151 |
+
"type": "needs_roll",
|
| 152 |
+
"skill": self.pending_roll["skill"],
|
| 153 |
+
"dc": self.pending_roll["dc"],
|
| 154 |
+
"advantage": self.pending_roll["advantage"],
|
| 155 |
+
"disadvantage": self.pending_roll["disadvantage"],
|
| 156 |
+
"narration": narration,
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
# Auto-resolved (success, fail, or impossible)
|
| 160 |
+
self.state.add_message("assistant", narration)
|
| 161 |
+
self.state.add_narrative("gm_response", narration)
|
| 162 |
+
|
| 163 |
+
result = {
|
| 164 |
+
"type": decision_type,
|
| 165 |
+
"narration": narration,
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
if decision_type == "impossible":
|
| 169 |
+
result["suggestions"] = decision.get("suggestions", [])
|
| 170 |
+
result["reason"] = decision.get("reason", "")
|
| 171 |
+
|
| 172 |
+
return result
|
| 173 |
+
|
| 174 |
+
def execute_roll(self, manual_roll: Optional[int] = None) -> Dict[str, Any]:
|
| 175 |
+
"""
|
| 176 |
+
Execute a pending skill check roll.
|
| 177 |
+
|
| 178 |
+
Args:
|
| 179 |
+
manual_roll: Optional manual d20 result (for physical dice)
|
| 180 |
+
|
| 181 |
+
Returns:
|
| 182 |
+
Dict with:
|
| 183 |
+
- roll: Roll details (natural, modifier, total, dc)
|
| 184 |
+
- success: Whether the check succeeded
|
| 185 |
+
- critical_success/critical_failure: If natural 20/1
|
| 186 |
+
- narration: Outcome narration
|
| 187 |
+
"""
|
| 188 |
+
if not self.pending_roll:
|
| 189 |
+
return {"error": "No pending roll to execute"}
|
| 190 |
+
|
| 191 |
+
roll_info = self.pending_roll
|
| 192 |
+
character = self.state.character
|
| 193 |
+
|
| 194 |
+
# Calculate modifier
|
| 195 |
+
modifier = get_skill_modifier(
|
| 196 |
+
ability_scores=character.get_ability_scores(),
|
| 197 |
+
skill=roll_info["skill"],
|
| 198 |
+
level=character.level,
|
| 199 |
+
is_proficient=character.is_proficient(roll_info["skill"]),
|
| 200 |
+
has_expertise=character.has_expertise(roll_info["skill"]),
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
# Determine roll mode
|
| 204 |
+
mode = RollMode.NORMAL
|
| 205 |
+
if roll_info["advantage"]:
|
| 206 |
+
mode = RollMode.ADVANTAGE
|
| 207 |
+
elif roll_info["disadvantage"]:
|
| 208 |
+
mode = RollMode.DISADVANTAGE
|
| 209 |
+
|
| 210 |
+
# Roll the dice
|
| 211 |
+
if manual_roll is not None:
|
| 212 |
+
# Use manual roll
|
| 213 |
+
natural = manual_roll
|
| 214 |
+
total = natural + modifier
|
| 215 |
+
success = total >= roll_info["dc"] or natural == 20
|
| 216 |
+
if natural == 1:
|
| 217 |
+
success = False
|
| 218 |
+
critical_success = natural == 20
|
| 219 |
+
critical_failure = natural == 1
|
| 220 |
+
else:
|
| 221 |
+
# Automatic roll
|
| 222 |
+
result = skill_check(modifier, roll_info["dc"], mode)
|
| 223 |
+
natural = result.roll.natural_roll
|
| 224 |
+
total = result.roll.total
|
| 225 |
+
success = result.success
|
| 226 |
+
critical_success = result.critical_success
|
| 227 |
+
critical_failure = result.critical_failure
|
| 228 |
+
|
| 229 |
+
# Build outcome prompt
|
| 230 |
+
outcome_prompt = build_roll_outcome_prompt(
|
| 231 |
+
action=roll_info["action"],
|
| 232 |
+
skill=roll_info["skill"],
|
| 233 |
+
roll_total=total,
|
| 234 |
+
dc=roll_info["dc"],
|
| 235 |
+
success=success,
|
| 236 |
+
critical=critical_success or critical_failure,
|
| 237 |
+
natural_roll=natural,
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
# Get outcome narration from LLM
|
| 241 |
+
outcome = self.gm.narrate_outcome(outcome_prompt, self.system_prompt)
|
| 242 |
+
narration = outcome.get("narration", "The action resolves...")
|
| 243 |
+
|
| 244 |
+
# Log the roll and outcome
|
| 245 |
+
roll_summary = (
|
| 246 |
+
f"{roll_info['skill'].title()}: {natural} + {modifier} = {total} "
|
| 247 |
+
f"vs DC {roll_info['dc']} - {'SUCCESS' if success else 'FAILURE'}"
|
| 248 |
+
)
|
| 249 |
+
self.state.add_narrative("roll", roll_summary)
|
| 250 |
+
self.state.add_narrative("gm_response", narration)
|
| 251 |
+
self.state.add_message("assistant", narration)
|
| 252 |
+
|
| 253 |
+
# Check for location change
|
| 254 |
+
if outcome.get("location_changed"):
|
| 255 |
+
new_location = outcome.get("new_location", "Unknown")
|
| 256 |
+
new_description = outcome.get("new_description", "")
|
| 257 |
+
self.state.scene.location = new_location
|
| 258 |
+
self.state.scene.description = new_description
|
| 259 |
+
|
| 260 |
+
# Clear pending roll
|
| 261 |
+
self.pending_roll = None
|
| 262 |
+
|
| 263 |
+
return {
|
| 264 |
+
"roll": {
|
| 265 |
+
"natural": natural,
|
| 266 |
+
"modifier": modifier,
|
| 267 |
+
"total": total,
|
| 268 |
+
"dc": roll_info["dc"],
|
| 269 |
+
"skill": roll_info["skill"],
|
| 270 |
+
},
|
| 271 |
+
"success": success,
|
| 272 |
+
"critical_success": critical_success,
|
| 273 |
+
"critical_failure": critical_failure,
|
| 274 |
+
"margin": total - roll_info["dc"],
|
| 275 |
+
"narration": narration,
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
def quick_roll(self, skill: str, dc: int) -> Dict[str, Any]:
|
| 279 |
+
"""
|
| 280 |
+
Perform a quick skill check without LLM narration.
|
| 281 |
+
|
| 282 |
+
Args:
|
| 283 |
+
skill: Skill name
|
| 284 |
+
dc: Difficulty class
|
| 285 |
+
|
| 286 |
+
Returns:
|
| 287 |
+
Roll result dictionary
|
| 288 |
+
"""
|
| 289 |
+
if not self.state.character:
|
| 290 |
+
return {"error": "No active character"}
|
| 291 |
+
|
| 292 |
+
modifier = get_skill_modifier(
|
| 293 |
+
ability_scores=self.state.character.get_ability_scores(),
|
| 294 |
+
skill=skill,
|
| 295 |
+
level=self.state.character.level,
|
| 296 |
+
is_proficient=self.state.character.is_proficient(skill),
|
| 297 |
+
has_expertise=self.state.character.has_expertise(skill),
|
| 298 |
+
)
|
| 299 |
+
|
| 300 |
+
result = skill_check(modifier, dc)
|
| 301 |
+
|
| 302 |
+
return {
|
| 303 |
+
"roll": {
|
| 304 |
+
"natural": result.roll.natural_roll,
|
| 305 |
+
"modifier": modifier,
|
| 306 |
+
"total": result.roll.total,
|
| 307 |
+
"dc": dc,
|
| 308 |
+
"skill": skill,
|
| 309 |
+
},
|
| 310 |
+
"success": result.success,
|
| 311 |
+
"critical_success": result.critical_success,
|
| 312 |
+
"critical_failure": result.critical_failure,
|
| 313 |
+
"margin": result.margin,
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
def roll_initiative(self) -> Dict[str, Any]:
|
| 317 |
+
"""Roll initiative for the current character."""
|
| 318 |
+
if not self.state.character:
|
| 319 |
+
return {"error": "No active character"}
|
| 320 |
+
|
| 321 |
+
dex_mod = get_ability_modifier(self.state.character.dexterity)
|
| 322 |
+
init_bonus = dex_mod + self.state.character.initiative_bonus
|
| 323 |
+
|
| 324 |
+
roll = roll_d20(init_bonus)
|
| 325 |
+
|
| 326 |
+
return {
|
| 327 |
+
"natural": roll.natural_roll,
|
| 328 |
+
"modifier": init_bonus,
|
| 329 |
+
"total": roll.total,
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
def roll_damage(self, notation: str, critical: bool = False) -> Dict[str, Any]:
|
| 333 |
+
"""
|
| 334 |
+
Roll damage dice.
|
| 335 |
+
|
| 336 |
+
Args:
|
| 337 |
+
notation: Dice notation (e.g., "2d6+3")
|
| 338 |
+
critical: If True, double the dice
|
| 339 |
+
|
| 340 |
+
Returns:
|
| 341 |
+
Damage roll result
|
| 342 |
+
"""
|
| 343 |
+
from .engine.dice import roll_damage
|
| 344 |
+
result = roll_damage(notation, critical)
|
| 345 |
+
|
| 346 |
+
return {
|
| 347 |
+
"notation": notation,
|
| 348 |
+
"rolls": result.rolls,
|
| 349 |
+
"modifier": result.modifier,
|
| 350 |
+
"total": result.total,
|
| 351 |
+
"critical": critical,
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
def get_narrative_history(self, max_entries: int = 50) -> str:
|
| 355 |
+
"""Get formatted narrative history."""
|
| 356 |
+
return self.state.get_recent_context(max_entries)
|
| 357 |
+
|
| 358 |
+
def get_character_summary(self) -> str:
|
| 359 |
+
"""Get current character summary."""
|
| 360 |
+
if not self.state.character:
|
| 361 |
+
return "No active character"
|
| 362 |
+
return self.state.character.summary()
|
| 363 |
+
|
| 364 |
+
def get_scene_summary(self) -> str:
|
| 365 |
+
"""Get current scene summary."""
|
| 366 |
+
if not self.state.scene:
|
| 367 |
+
return "No active scene"
|
| 368 |
+
return self.state.scene.to_context_string()
|
| 369 |
+
|
| 370 |
+
def save_game(self, filepath: str):
|
| 371 |
+
"""Save current game state to file."""
|
| 372 |
+
self.state.save_to_file(filepath)
|
| 373 |
+
print(f"Game saved to {filepath}")
|
| 374 |
+
|
| 375 |
+
def load_game(self, filepath: str):
|
| 376 |
+
"""Load game state from file."""
|
| 377 |
+
self.state = GameState.load_from_file(filepath)
|
| 378 |
+
print(f"Game loaded from {filepath}")
|
| 379 |
+
print(f"Character: {self.state.character.name if self.state.character else 'None'}")
|
| 380 |
+
print(f"Location: {self.state.scene.location if self.state.scene else 'None'}")
|
| 381 |
+
|
| 382 |
+
|
| 383 |
+
def run_game_loop(
|
| 384 |
+
gm: DnDGameMaster,
|
| 385 |
+
character: Character,
|
| 386 |
+
scene: Scene,
|
| 387 |
+
adventure_name: str = "Adventure",
|
| 388 |
+
):
|
| 389 |
+
"""
|
| 390 |
+
Run an interactive game loop in the terminal.
|
| 391 |
+
|
| 392 |
+
Args:
|
| 393 |
+
gm: Initialized DnDGameMaster
|
| 394 |
+
character: Player character
|
| 395 |
+
scene: Starting scene
|
| 396 |
+
adventure_name: Name of the adventure
|
| 397 |
+
"""
|
| 398 |
+
gm.start_adventure(character, scene, adventure_name)
|
| 399 |
+
|
| 400 |
+
print("\nCommands: 'quit' to exit, 'stats' for character, 'look' for scene")
|
| 401 |
+
print("-" * 60)
|
| 402 |
+
|
| 403 |
+
while True:
|
| 404 |
+
try:
|
| 405 |
+
action = input("\n> What do you do? ").strip()
|
| 406 |
+
except (KeyboardInterrupt, EOFError):
|
| 407 |
+
print("\n\nFarewell, adventurer!")
|
| 408 |
+
break
|
| 409 |
+
|
| 410 |
+
if not action:
|
| 411 |
+
continue
|
| 412 |
+
|
| 413 |
+
if action.lower() in ['quit', 'exit', 'q']:
|
| 414 |
+
print("\nFarewell, adventurer!")
|
| 415 |
+
break
|
| 416 |
+
|
| 417 |
+
if action.lower() == 'stats':
|
| 418 |
+
print(f"\n{gm.get_character_summary()}")
|
| 419 |
+
continue
|
| 420 |
+
|
| 421 |
+
if action.lower() in ['look', 'scene']:
|
| 422 |
+
print(f"\n{gm.get_scene_summary()}")
|
| 423 |
+
continue
|
| 424 |
+
|
| 425 |
+
if action.lower() == 'save':
|
| 426 |
+
gm.save_game("savegame.json")
|
| 427 |
+
continue
|
| 428 |
+
|
| 429 |
+
# Process the action
|
| 430 |
+
result = gm.process_player_action(action)
|
| 431 |
+
|
| 432 |
+
if result.get("type") == "needs_roll":
|
| 433 |
+
print(f"\n{result.get('narration', '')}")
|
| 434 |
+
print(f"\n>>> Roll {result['skill'].title()} (DC {result['dc']})")
|
| 435 |
+
|
| 436 |
+
input("Press Enter to roll...")
|
| 437 |
+
|
| 438 |
+
roll_result = gm.execute_roll()
|
| 439 |
+
roll = roll_result["roll"]
|
| 440 |
+
|
| 441 |
+
print(f"\n[{roll['natural']}] + {roll['modifier']} = {roll['total']} vs DC {roll['dc']}")
|
| 442 |
+
|
| 443 |
+
if roll_result["critical_success"]:
|
| 444 |
+
print("*** CRITICAL SUCCESS! ***")
|
| 445 |
+
elif roll_result["critical_failure"]:
|
| 446 |
+
print("*** CRITICAL FAILURE! ***")
|
| 447 |
+
elif roll_result["success"]:
|
| 448 |
+
print("Success!")
|
| 449 |
+
else:
|
| 450 |
+
print("Failure...")
|
| 451 |
+
|
| 452 |
+
print(f"\n{roll_result['narration']}")
|
| 453 |
+
|
| 454 |
+
elif result.get("type") == "impossible":
|
| 455 |
+
print(f"\n{result.get('narration', 'That is not possible.')}")
|
| 456 |
+
if result.get("suggestions"):
|
| 457 |
+
print("\nYou might try:")
|
| 458 |
+
for s in result["suggestions"]:
|
| 459 |
+
print(f" - {s}")
|
| 460 |
+
|
| 461 |
+
else:
|
| 462 |
+
print(f"\n{result.get('narration', '')}")
|
|
@@ -0,0 +1,289 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Gradio web interface for D&D AI Game Master.
|
| 4 |
+
|
| 5 |
+
For Hugging Face Spaces deployment or local web interface.
|
| 6 |
+
|
| 7 |
+
Run with:
|
| 8 |
+
python -m dnd_game_master.gradio_app
|
| 9 |
+
|
| 10 |
+
Or deploy to Hugging Face Spaces.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import os
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
import gradio as gr
|
| 17 |
+
HAS_GRADIO = True
|
| 18 |
+
except ImportError:
|
| 19 |
+
HAS_GRADIO = False
|
| 20 |
+
print("Gradio not installed. Install with: pip install gradio")
|
| 21 |
+
|
| 22 |
+
from dnd_game_master import DnDGameMaster, Character, Scene
|
| 23 |
+
from dnd_game_master.models.scene import Lighting, TimeOfDay, NPC, NPCAttitude
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
# Global game instance
|
| 27 |
+
game_instance = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def initialize_game(
|
| 31 |
+
character_name: str,
|
| 32 |
+
character_class: str,
|
| 33 |
+
character_race: str,
|
| 34 |
+
api_token: str,
|
| 35 |
+
) -> str:
|
| 36 |
+
"""Initialize a new game session."""
|
| 37 |
+
global game_instance
|
| 38 |
+
|
| 39 |
+
if not api_token:
|
| 40 |
+
return "Please provide a Hugging Face API token."
|
| 41 |
+
|
| 42 |
+
try:
|
| 43 |
+
# Create character
|
| 44 |
+
character = Character(
|
| 45 |
+
name=character_name or "Adventurer",
|
| 46 |
+
race=character_race or "Human",
|
| 47 |
+
character_class=character_class or "Fighter",
|
| 48 |
+
level=1,
|
| 49 |
+
strength=14,
|
| 50 |
+
dexterity=14,
|
| 51 |
+
constitution=14,
|
| 52 |
+
intelligence=12,
|
| 53 |
+
wisdom=12,
|
| 54 |
+
charisma=12,
|
| 55 |
+
max_hp=12,
|
| 56 |
+
current_hp=12,
|
| 57 |
+
armor_class=14,
|
| 58 |
+
proficient_skills=["perception", "athletics"],
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# Create starting scene
|
| 62 |
+
scene = Scene(
|
| 63 |
+
location="The Dusty Tankard Tavern",
|
| 64 |
+
description=(
|
| 65 |
+
"You push open the weathered oak door and step into the warmth of "
|
| 66 |
+
"The Dusty Tankard. The tavern is dimly lit by flickering candles and "
|
| 67 |
+
"a crackling fireplace. The smell of ale and roasted meat fills the air. "
|
| 68 |
+
"A few patrons sit at scattered tables, speaking in hushed tones. "
|
| 69 |
+
"Behind the bar, a stern-faced woman polishes a mug, her eyes following you."
|
| 70 |
+
),
|
| 71 |
+
lighting=Lighting.DIM,
|
| 72 |
+
time_of_day=TimeOfDay.EVENING,
|
| 73 |
+
npcs=[
|
| 74 |
+
NPC("Mira the Bartender", "A stern woman with knowing eyes", NPCAttitude.NEUTRAL),
|
| 75 |
+
NPC("Hooded Stranger", "Sits alone in the corner, face hidden", NPCAttitude.UNFRIENDLY),
|
| 76 |
+
],
|
| 77 |
+
objects=["Notice board", "Crackling fireplace", "Locked cellar door"],
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Initialize game master
|
| 81 |
+
game_instance = DnDGameMaster(api_token=api_token)
|
| 82 |
+
game_instance.start_adventure(character, scene, "The Dusty Tankard Mystery")
|
| 83 |
+
|
| 84 |
+
return f"""Adventure Started!
|
| 85 |
+
|
| 86 |
+
You are {character.name}, a {character.race} {character.character_class}.
|
| 87 |
+
|
| 88 |
+
---
|
| 89 |
+
|
| 90 |
+
{scene.description}
|
| 91 |
+
|
| 92 |
+
---
|
| 93 |
+
|
| 94 |
+
What do you do?"""
|
| 95 |
+
|
| 96 |
+
except Exception as e:
|
| 97 |
+
return f"Error initializing game: {str(e)}"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def process_action(action: str, history: list) -> tuple:
|
| 101 |
+
"""Process a player action and return the response."""
|
| 102 |
+
global game_instance
|
| 103 |
+
|
| 104 |
+
if not game_instance:
|
| 105 |
+
return history + [[action, "Please initialize the game first!"]], ""
|
| 106 |
+
|
| 107 |
+
if not action.strip():
|
| 108 |
+
return history, ""
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
result = game_instance.process_player_action(action)
|
| 112 |
+
|
| 113 |
+
if result.get("type") == "needs_roll":
|
| 114 |
+
# Auto-execute the roll
|
| 115 |
+
narration = result.get("narration", "")
|
| 116 |
+
roll_result = game_instance.execute_roll()
|
| 117 |
+
|
| 118 |
+
roll = roll_result["roll"]
|
| 119 |
+
roll_text = f"[{roll['natural']}] + {roll['modifier']} = {roll['total']} vs DC {roll['dc']}"
|
| 120 |
+
|
| 121 |
+
if roll_result["critical_success"]:
|
| 122 |
+
roll_text += " - CRITICAL SUCCESS!"
|
| 123 |
+
elif roll_result["critical_failure"]:
|
| 124 |
+
roll_text += " - CRITICAL FAILURE!"
|
| 125 |
+
elif roll_result["success"]:
|
| 126 |
+
roll_text += " - Success!"
|
| 127 |
+
else:
|
| 128 |
+
roll_text += " - Failure..."
|
| 129 |
+
|
| 130 |
+
response = f"{narration}\n\n**Roll {result['skill'].title()}:** {roll_text}\n\n{roll_result['narration']}"
|
| 131 |
+
|
| 132 |
+
elif result.get("type") == "impossible":
|
| 133 |
+
response = result.get("narration", "That is not possible.")
|
| 134 |
+
if result.get("suggestions"):
|
| 135 |
+
response += "\n\n**You might try:**\n"
|
| 136 |
+
for s in result["suggestions"]:
|
| 137 |
+
response += f"- {s}\n"
|
| 138 |
+
else:
|
| 139 |
+
response = result.get("narration", "...")
|
| 140 |
+
|
| 141 |
+
return history + [[action, response]], ""
|
| 142 |
+
|
| 143 |
+
except Exception as e:
|
| 144 |
+
return history + [[action, f"Error: {str(e)}"]], ""
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def get_character_info() -> str:
|
| 148 |
+
"""Get current character information."""
|
| 149 |
+
global game_instance
|
| 150 |
+
if not game_instance or not game_instance.state.character:
|
| 151 |
+
return "No active character"
|
| 152 |
+
return game_instance.get_character_summary()
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def get_scene_info() -> str:
|
| 156 |
+
"""Get current scene information."""
|
| 157 |
+
global game_instance
|
| 158 |
+
if not game_instance or not game_instance.state.scene:
|
| 159 |
+
return "No active scene"
|
| 160 |
+
return game_instance.get_scene_summary()
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def create_interface():
|
| 164 |
+
"""Create the Gradio interface."""
|
| 165 |
+
|
| 166 |
+
with gr.Blocks(title="D&D AI Game Master", theme=gr.themes.Soft()) as demo:
|
| 167 |
+
gr.Markdown("""
|
| 168 |
+
# D&D AI Game Master
|
| 169 |
+
|
| 170 |
+
An AI-powered Dungeons & Dragons Game Master using Hugging Face models.
|
| 171 |
+
|
| 172 |
+
---
|
| 173 |
+
""")
|
| 174 |
+
|
| 175 |
+
with gr.Tab("Setup"):
|
| 176 |
+
gr.Markdown("### Character Creation")
|
| 177 |
+
|
| 178 |
+
with gr.Row():
|
| 179 |
+
name_input = gr.Textbox(
|
| 180 |
+
label="Character Name",
|
| 181 |
+
placeholder="Enter your character's name",
|
| 182 |
+
value="Adventurer"
|
| 183 |
+
)
|
| 184 |
+
class_input = gr.Dropdown(
|
| 185 |
+
choices=["Fighter", "Rogue", "Wizard", "Cleric", "Ranger", "Paladin", "Bard"],
|
| 186 |
+
label="Class",
|
| 187 |
+
value="Fighter"
|
| 188 |
+
)
|
| 189 |
+
race_input = gr.Dropdown(
|
| 190 |
+
choices=["Human", "Elf", "Dwarf", "Halfling", "Half-Elf", "Tiefling", "Dragonborn"],
|
| 191 |
+
label="Race",
|
| 192 |
+
value="Human"
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
token_input = gr.Textbox(
|
| 196 |
+
label="Hugging Face API Token",
|
| 197 |
+
placeholder="hf_...",
|
| 198 |
+
type="password",
|
| 199 |
+
info="Get your free token at huggingface.co/settings/tokens"
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
start_btn = gr.Button("Start Adventure", variant="primary")
|
| 203 |
+
setup_output = gr.Markdown()
|
| 204 |
+
|
| 205 |
+
start_btn.click(
|
| 206 |
+
initialize_game,
|
| 207 |
+
inputs=[name_input, class_input, race_input, token_input],
|
| 208 |
+
outputs=setup_output
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
with gr.Tab("Play"):
|
| 212 |
+
chatbot = gr.Chatbot(
|
| 213 |
+
label="Adventure",
|
| 214 |
+
height=400,
|
| 215 |
+
show_copy_button=True,
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
with gr.Row():
|
| 219 |
+
action_input = gr.Textbox(
|
| 220 |
+
label="What do you do?",
|
| 221 |
+
placeholder="Describe your action...",
|
| 222 |
+
scale=4
|
| 223 |
+
)
|
| 224 |
+
submit_btn = gr.Button("Submit", variant="primary", scale=1)
|
| 225 |
+
|
| 226 |
+
submit_btn.click(
|
| 227 |
+
process_action,
|
| 228 |
+
inputs=[action_input, chatbot],
|
| 229 |
+
outputs=[chatbot, action_input]
|
| 230 |
+
)
|
| 231 |
+
action_input.submit(
|
| 232 |
+
process_action,
|
| 233 |
+
inputs=[action_input, chatbot],
|
| 234 |
+
outputs=[chatbot, action_input]
|
| 235 |
+
)
|
| 236 |
+
|
| 237 |
+
with gr.Tab("Status"):
|
| 238 |
+
with gr.Row():
|
| 239 |
+
with gr.Column():
|
| 240 |
+
gr.Markdown("### Character")
|
| 241 |
+
char_info = gr.Textbox(
|
| 242 |
+
label="Character Stats",
|
| 243 |
+
lines=6,
|
| 244 |
+
interactive=False
|
| 245 |
+
)
|
| 246 |
+
refresh_char_btn = gr.Button("Refresh")
|
| 247 |
+
refresh_char_btn.click(get_character_info, outputs=char_info)
|
| 248 |
+
|
| 249 |
+
with gr.Column():
|
| 250 |
+
gr.Markdown("### Current Scene")
|
| 251 |
+
scene_info = gr.Textbox(
|
| 252 |
+
label="Scene Details",
|
| 253 |
+
lines=6,
|
| 254 |
+
interactive=False
|
| 255 |
+
)
|
| 256 |
+
refresh_scene_btn = gr.Button("Refresh")
|
| 257 |
+
refresh_scene_btn.click(get_scene_info, outputs=scene_info)
|
| 258 |
+
|
| 259 |
+
gr.Markdown("""
|
| 260 |
+
---
|
| 261 |
+
**Tips:**
|
| 262 |
+
- Describe your actions naturally: "I search the room for hidden doors"
|
| 263 |
+
- The GM will decide if you need to roll dice
|
| 264 |
+
- Use the Status tab to check your character and surroundings
|
| 265 |
+
""")
|
| 266 |
+
|
| 267 |
+
return demo
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def main():
|
| 271 |
+
"""Launch the Gradio app."""
|
| 272 |
+
if not HAS_GRADIO:
|
| 273 |
+
print("Please install gradio: pip install gradio")
|
| 274 |
+
return
|
| 275 |
+
|
| 276 |
+
demo = create_interface()
|
| 277 |
+
|
| 278 |
+
# Get port from environment (for Spaces) or use default
|
| 279 |
+
port = int(os.environ.get("PORT", 7860))
|
| 280 |
+
|
| 281 |
+
demo.launch(
|
| 282 |
+
server_name="0.0.0.0",
|
| 283 |
+
server_port=port,
|
| 284 |
+
share=False, # Set True for public link
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
if __name__ == "__main__":
|
| 289 |
+
main()
|
|
@@ -0,0 +1,395 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hugging Face integration for the D&D Game Master."""
|
| 2 |
+
|
| 3 |
+
from typing import List, Dict, Optional, Any, Union
|
| 4 |
+
import json
|
| 5 |
+
import re
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
# Support multiple import methods
|
| 9 |
+
try:
|
| 10 |
+
from transformers import pipeline, AutoModelForCausalLM, AutoTokenizer
|
| 11 |
+
HAS_TRANSFORMERS = True
|
| 12 |
+
except ImportError:
|
| 13 |
+
HAS_TRANSFORMERS = False
|
| 14 |
+
|
| 15 |
+
try:
|
| 16 |
+
from huggingface_hub import InferenceClient
|
| 17 |
+
HAS_HF_HUB = True
|
| 18 |
+
except ImportError:
|
| 19 |
+
HAS_HF_HUB = False
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class HuggingFaceGM:
|
| 23 |
+
"""Hugging Face-powered Game Master for D&D."""
|
| 24 |
+
|
| 25 |
+
# Recommended models for narrative generation
|
| 26 |
+
RECOMMENDED_MODELS = {
|
| 27 |
+
"mistralai/Mistral-7B-Instruct-v0.3": {
|
| 28 |
+
"name": "Mistral 7B Instruct",
|
| 29 |
+
"size": "7B",
|
| 30 |
+
"description": "Good balance of speed and quality",
|
| 31 |
+
"speed": "fast",
|
| 32 |
+
},
|
| 33 |
+
"meta-llama/Llama-3.2-3B-Instruct": {
|
| 34 |
+
"name": "Llama 3.2 3B",
|
| 35 |
+
"size": "3B",
|
| 36 |
+
"description": "Fast responses, good for quick gameplay",
|
| 37 |
+
"speed": "very_fast",
|
| 38 |
+
},
|
| 39 |
+
"google/gemma-2-9b-it": {
|
| 40 |
+
"name": "Gemma 2 9B",
|
| 41 |
+
"size": "9B",
|
| 42 |
+
"description": "High quality narrative generation",
|
| 43 |
+
"speed": "medium",
|
| 44 |
+
},
|
| 45 |
+
"HuggingFaceH4/zephyr-7b-beta": {
|
| 46 |
+
"name": "Zephyr 7B",
|
| 47 |
+
"size": "7B",
|
| 48 |
+
"description": "Strong instruction following",
|
| 49 |
+
"speed": "fast",
|
| 50 |
+
},
|
| 51 |
+
"microsoft/Phi-3-mini-4k-instruct": {
|
| 52 |
+
"name": "Phi-3 Mini",
|
| 53 |
+
"size": "3.8B",
|
| 54 |
+
"description": "Compact but capable",
|
| 55 |
+
"speed": "very_fast",
|
| 56 |
+
},
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
def __init__(
|
| 60 |
+
self,
|
| 61 |
+
model_id: str = "mistralai/Mistral-7B-Instruct-v0.3",
|
| 62 |
+
use_api: bool = True,
|
| 63 |
+
api_token: Optional[str] = None,
|
| 64 |
+
device: str = "auto",
|
| 65 |
+
):
|
| 66 |
+
"""
|
| 67 |
+
Initialize the Hugging Face Game Master.
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
model_id: Hugging Face model ID
|
| 71 |
+
use_api: If True, use Inference API. If False, load model locally.
|
| 72 |
+
api_token: Hugging Face API token (required for API, optional for local)
|
| 73 |
+
device: Device for local inference ("auto", "cuda", "cpu", "mps")
|
| 74 |
+
"""
|
| 75 |
+
self.model_id = model_id
|
| 76 |
+
self.use_api = use_api
|
| 77 |
+
self.api_token = api_token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN")
|
| 78 |
+
self.device = device
|
| 79 |
+
|
| 80 |
+
# Model and tokenizer for local inference
|
| 81 |
+
self.model = None
|
| 82 |
+
self.tokenizer = None
|
| 83 |
+
self.generator = None
|
| 84 |
+
self.client = None
|
| 85 |
+
|
| 86 |
+
if use_api:
|
| 87 |
+
self._init_api_client()
|
| 88 |
+
else:
|
| 89 |
+
self._init_local_model()
|
| 90 |
+
|
| 91 |
+
def _init_api_client(self):
|
| 92 |
+
"""Initialize the Hugging Face Inference API client."""
|
| 93 |
+
if not HAS_HF_HUB:
|
| 94 |
+
raise ImportError(
|
| 95 |
+
"huggingface_hub is required for API inference. "
|
| 96 |
+
"Install with: pip install huggingface_hub"
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
self.client = InferenceClient(
|
| 100 |
+
model=self.model_id,
|
| 101 |
+
token=self.api_token,
|
| 102 |
+
)
|
| 103 |
+
print(f"Initialized HF Inference API with model: {self.model_id}")
|
| 104 |
+
|
| 105 |
+
def _init_local_model(self):
|
| 106 |
+
"""Load model locally for inference."""
|
| 107 |
+
if not HAS_TRANSFORMERS:
|
| 108 |
+
raise ImportError(
|
| 109 |
+
"transformers and torch are required for local inference. "
|
| 110 |
+
"Install with: pip install transformers torch"
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
print(f"Loading model {self.model_id}... This may take a few minutes.")
|
| 114 |
+
|
| 115 |
+
self.tokenizer = AutoTokenizer.from_pretrained(
|
| 116 |
+
self.model_id,
|
| 117 |
+
token=self.api_token,
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
self.model = AutoModelForCausalLM.from_pretrained(
|
| 121 |
+
self.model_id,
|
| 122 |
+
device_map=self.device,
|
| 123 |
+
torch_dtype="auto",
|
| 124 |
+
token=self.api_token,
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
self.generator = pipeline(
|
| 128 |
+
"text-generation",
|
| 129 |
+
model=self.model,
|
| 130 |
+
tokenizer=self.tokenizer,
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
print(f"Model loaded successfully on device: {self.device}")
|
| 134 |
+
|
| 135 |
+
def chat(
|
| 136 |
+
self,
|
| 137 |
+
messages: List[Dict[str, str]],
|
| 138 |
+
max_tokens: int = 1024,
|
| 139 |
+
temperature: float = 0.8,
|
| 140 |
+
top_p: float = 0.95,
|
| 141 |
+
) -> str:
|
| 142 |
+
"""
|
| 143 |
+
Send a chat request to the model.
|
| 144 |
+
|
| 145 |
+
Args:
|
| 146 |
+
messages: List of {"role": "system/user/assistant", "content": "..."}
|
| 147 |
+
max_tokens: Maximum tokens to generate
|
| 148 |
+
temperature: Sampling temperature (higher = more creative)
|
| 149 |
+
top_p: Nucleus sampling parameter
|
| 150 |
+
|
| 151 |
+
Returns:
|
| 152 |
+
Generated text response
|
| 153 |
+
"""
|
| 154 |
+
if self.use_api:
|
| 155 |
+
return self._chat_api(messages, max_tokens, temperature, top_p)
|
| 156 |
+
else:
|
| 157 |
+
return self._chat_local(messages, max_tokens, temperature, top_p)
|
| 158 |
+
|
| 159 |
+
def _chat_api(
|
| 160 |
+
self,
|
| 161 |
+
messages: List[Dict[str, str]],
|
| 162 |
+
max_tokens: int,
|
| 163 |
+
temperature: float,
|
| 164 |
+
top_p: float,
|
| 165 |
+
) -> str:
|
| 166 |
+
"""Use Hugging Face Inference API."""
|
| 167 |
+
try:
|
| 168 |
+
response = self.client.chat_completion(
|
| 169 |
+
messages=messages,
|
| 170 |
+
max_tokens=max_tokens,
|
| 171 |
+
temperature=temperature,
|
| 172 |
+
top_p=top_p,
|
| 173 |
+
)
|
| 174 |
+
return response.choices[0].message.content
|
| 175 |
+
except Exception as e:
|
| 176 |
+
print(f"API Error: {e}")
|
| 177 |
+
raise RuntimeError(f"Failed to get response from HF API: {e}")
|
| 178 |
+
|
| 179 |
+
def _chat_local(
|
| 180 |
+
self,
|
| 181 |
+
messages: List[Dict[str, str]],
|
| 182 |
+
max_tokens: int,
|
| 183 |
+
temperature: float,
|
| 184 |
+
top_p: float,
|
| 185 |
+
) -> str:
|
| 186 |
+
"""Use local model inference."""
|
| 187 |
+
# Format messages using the tokenizer's chat template if available
|
| 188 |
+
if hasattr(self.tokenizer, 'apply_chat_template'):
|
| 189 |
+
prompt = self.tokenizer.apply_chat_template(
|
| 190 |
+
messages,
|
| 191 |
+
tokenize=False,
|
| 192 |
+
add_generation_prompt=True
|
| 193 |
+
)
|
| 194 |
+
else:
|
| 195 |
+
prompt = self._format_messages_fallback(messages)
|
| 196 |
+
|
| 197 |
+
outputs = self.generator(
|
| 198 |
+
prompt,
|
| 199 |
+
max_new_tokens=max_tokens,
|
| 200 |
+
temperature=temperature,
|
| 201 |
+
top_p=top_p,
|
| 202 |
+
do_sample=True,
|
| 203 |
+
pad_token_id=self.tokenizer.eos_token_id,
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
generated = outputs[0]["generated_text"]
|
| 207 |
+
|
| 208 |
+
# Extract only the new generation (after the prompt)
|
| 209 |
+
if generated.startswith(prompt):
|
| 210 |
+
response = generated[len(prompt):].strip()
|
| 211 |
+
else:
|
| 212 |
+
# Try to find where the response starts
|
| 213 |
+
response = generated.strip()
|
| 214 |
+
|
| 215 |
+
return response
|
| 216 |
+
|
| 217 |
+
def _format_messages_fallback(self, messages: List[Dict[str, str]]) -> str:
|
| 218 |
+
"""Fallback message formatting for models without chat templates."""
|
| 219 |
+
parts = []
|
| 220 |
+
for msg in messages:
|
| 221 |
+
role = msg["role"].upper()
|
| 222 |
+
content = msg["content"]
|
| 223 |
+
if role == "SYSTEM":
|
| 224 |
+
parts.append(f"### System:\n{content}")
|
| 225 |
+
elif role == "USER":
|
| 226 |
+
parts.append(f"### User:\n{content}")
|
| 227 |
+
elif role == "ASSISTANT":
|
| 228 |
+
parts.append(f"### Assistant:\n{content}")
|
| 229 |
+
parts.append("### Assistant:\n")
|
| 230 |
+
return "\n\n".join(parts)
|
| 231 |
+
|
| 232 |
+
def parse_json_response(self, response: str) -> Dict[str, Any]:
|
| 233 |
+
"""
|
| 234 |
+
Parse JSON from the model response.
|
| 235 |
+
|
| 236 |
+
Handles various formats including markdown code blocks.
|
| 237 |
+
|
| 238 |
+
Args:
|
| 239 |
+
response: Raw model response
|
| 240 |
+
|
| 241 |
+
Returns:
|
| 242 |
+
Parsed JSON dictionary
|
| 243 |
+
"""
|
| 244 |
+
# Remove markdown code blocks if present
|
| 245 |
+
cleaned = response.strip()
|
| 246 |
+
|
| 247 |
+
# Try to extract JSON from markdown code block
|
| 248 |
+
json_block_match = re.search(r'```(?:json)?\s*([\s\S]*?)```', cleaned)
|
| 249 |
+
if json_block_match:
|
| 250 |
+
cleaned = json_block_match.group(1).strip()
|
| 251 |
+
|
| 252 |
+
# Try to find JSON object boundaries
|
| 253 |
+
json_match = re.search(r'\{[\s\S]*\}', cleaned)
|
| 254 |
+
if json_match:
|
| 255 |
+
try:
|
| 256 |
+
return json.loads(json_match.group())
|
| 257 |
+
except json.JSONDecodeError:
|
| 258 |
+
pass
|
| 259 |
+
|
| 260 |
+
# Try parsing the whole thing
|
| 261 |
+
try:
|
| 262 |
+
return json.loads(cleaned)
|
| 263 |
+
except json.JSONDecodeError:
|
| 264 |
+
pass
|
| 265 |
+
|
| 266 |
+
# Fallback: treat as pure narration
|
| 267 |
+
return {
|
| 268 |
+
"decision": "auto_success",
|
| 269 |
+
"narration": response.strip(),
|
| 270 |
+
"parse_error": True,
|
| 271 |
+
"raw_response": response,
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
def get_gm_decision(
|
| 275 |
+
self,
|
| 276 |
+
player_action: str,
|
| 277 |
+
system_prompt: str,
|
| 278 |
+
context: str,
|
| 279 |
+
conversation_history: Optional[List[Dict[str, str]]] = None,
|
| 280 |
+
) -> Dict[str, Any]:
|
| 281 |
+
"""
|
| 282 |
+
Get the GM's decision for a player action.
|
| 283 |
+
|
| 284 |
+
Args:
|
| 285 |
+
player_action: What the player wants to do
|
| 286 |
+
system_prompt: The GM system prompt
|
| 287 |
+
context: Current scene/character context
|
| 288 |
+
conversation_history: Previous conversation messages
|
| 289 |
+
|
| 290 |
+
Returns:
|
| 291 |
+
Parsed GM decision dictionary with keys like:
|
| 292 |
+
- decision: "impossible" | "auto_success" | "auto_fail" | "needs_roll"
|
| 293 |
+
- narration: The narrative text
|
| 294 |
+
- skill: (if needs_roll) The skill to check
|
| 295 |
+
- dc: (if needs_roll) The difficulty class
|
| 296 |
+
"""
|
| 297 |
+
messages = [{"role": "system", "content": system_prompt}]
|
| 298 |
+
|
| 299 |
+
if conversation_history:
|
| 300 |
+
messages.extend(conversation_history[-10:]) # Limit context
|
| 301 |
+
|
| 302 |
+
user_message = f"{context}\n\n---\n\nPlayer action: {player_action}"
|
| 303 |
+
messages.append({"role": "user", "content": user_message})
|
| 304 |
+
|
| 305 |
+
response = self.chat(messages)
|
| 306 |
+
return self.parse_json_response(response)
|
| 307 |
+
|
| 308 |
+
def narrate_outcome(
|
| 309 |
+
self,
|
| 310 |
+
outcome_prompt: str,
|
| 311 |
+
system_prompt: str,
|
| 312 |
+
) -> Dict[str, Any]:
|
| 313 |
+
"""
|
| 314 |
+
Get narration for a roll outcome.
|
| 315 |
+
|
| 316 |
+
Args:
|
| 317 |
+
outcome_prompt: The outcome prompt with roll results
|
| 318 |
+
system_prompt: The GM system prompt
|
| 319 |
+
|
| 320 |
+
Returns:
|
| 321 |
+
Parsed response with narration and any scene updates
|
| 322 |
+
"""
|
| 323 |
+
messages = [
|
| 324 |
+
{"role": "system", "content": system_prompt},
|
| 325 |
+
{"role": "user", "content": outcome_prompt},
|
| 326 |
+
]
|
| 327 |
+
|
| 328 |
+
response = self.chat(messages)
|
| 329 |
+
return self.parse_json_response(response)
|
| 330 |
+
|
| 331 |
+
def extract_scene(
|
| 332 |
+
self,
|
| 333 |
+
extraction_prompt: str,
|
| 334 |
+
) -> Dict[str, Any]:
|
| 335 |
+
"""
|
| 336 |
+
Extract scene details from narrative.
|
| 337 |
+
|
| 338 |
+
Args:
|
| 339 |
+
extraction_prompt: The scene extraction prompt
|
| 340 |
+
|
| 341 |
+
Returns:
|
| 342 |
+
Parsed scene data
|
| 343 |
+
"""
|
| 344 |
+
messages = [{"role": "user", "content": extraction_prompt}]
|
| 345 |
+
response = self.chat(messages, max_tokens=512)
|
| 346 |
+
return self.parse_json_response(response)
|
| 347 |
+
|
| 348 |
+
@classmethod
|
| 349 |
+
def list_recommended_models(cls) -> List[Dict[str, str]]:
|
| 350 |
+
"""Get list of recommended models with descriptions."""
|
| 351 |
+
return [
|
| 352 |
+
{"id": model_id, **info}
|
| 353 |
+
for model_id, info in cls.RECOMMENDED_MODELS.items()
|
| 354 |
+
]
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def create_gm(
|
| 358 |
+
model_id: str = "mistralai/Mistral-7B-Instruct-v0.3",
|
| 359 |
+
api_token: Optional[str] = None,
|
| 360 |
+
local: bool = False,
|
| 361 |
+
device: str = "auto",
|
| 362 |
+
) -> HuggingFaceGM:
|
| 363 |
+
"""
|
| 364 |
+
Create a Hugging Face Game Master instance.
|
| 365 |
+
|
| 366 |
+
This is a convenience function for quick setup.
|
| 367 |
+
|
| 368 |
+
Args:
|
| 369 |
+
model_id: Hugging Face model ID
|
| 370 |
+
api_token: Your HF API token (or set HF_TOKEN env var)
|
| 371 |
+
local: If True, load model locally instead of using API
|
| 372 |
+
device: Device for local inference ("auto", "cuda", "cpu", "mps")
|
| 373 |
+
|
| 374 |
+
Returns:
|
| 375 |
+
HuggingFaceGM instance ready for use
|
| 376 |
+
|
| 377 |
+
Example:
|
| 378 |
+
>>> gm = create_gm(api_token="hf_...")
|
| 379 |
+
>>> response = gm.chat([{"role": "user", "content": "Hello!"}])
|
| 380 |
+
"""
|
| 381 |
+
return HuggingFaceGM(
|
| 382 |
+
model_id=model_id,
|
| 383 |
+
use_api=not local,
|
| 384 |
+
api_token=api_token,
|
| 385 |
+
device=device,
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
|
| 389 |
+
# For backwards compatibility and simpler imports
|
| 390 |
+
__all__ = [
|
| 391 |
+
"HuggingFaceGM",
|
| 392 |
+
"create_gm",
|
| 393 |
+
"HAS_TRANSFORMERS",
|
| 394 |
+
"HAS_HF_HUB",
|
| 395 |
+
]
|
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data models for D&D game state."""
|
| 2 |
+
|
| 3 |
+
from .character import Character, create_blank_character, create_test_character
|
| 4 |
+
from .scene import Scene, NPC, Lighting, TimeOfDay, NPCAttitude
|
| 5 |
+
from .game_state import GameState, GamePhase, NarrativeEntry
|
| 6 |
+
|
| 7 |
+
__all__ = [
|
| 8 |
+
# Character
|
| 9 |
+
"Character",
|
| 10 |
+
"create_blank_character",
|
| 11 |
+
"create_test_character",
|
| 12 |
+
# Scene
|
| 13 |
+
"Scene",
|
| 14 |
+
"NPC",
|
| 15 |
+
"Lighting",
|
| 16 |
+
"TimeOfDay",
|
| 17 |
+
"NPCAttitude",
|
| 18 |
+
# Game State
|
| 19 |
+
"GameState",
|
| 20 |
+
"GamePhase",
|
| 21 |
+
"NarrativeEntry",
|
| 22 |
+
]
|
|
@@ -0,0 +1,344 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Character data model for D&D 5e."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from typing import Dict, List, Optional
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
@dataclass
|
| 8 |
+
class Character:
|
| 9 |
+
"""A D&D 5e player character."""
|
| 10 |
+
|
| 11 |
+
# Basic info
|
| 12 |
+
name: str
|
| 13 |
+
race: str
|
| 14 |
+
character_class: str
|
| 15 |
+
level: int = 1
|
| 16 |
+
background: str = ""
|
| 17 |
+
alignment: str = "Neutral"
|
| 18 |
+
|
| 19 |
+
# Ability scores
|
| 20 |
+
strength: int = 10
|
| 21 |
+
dexterity: int = 10
|
| 22 |
+
constitution: int = 10
|
| 23 |
+
intelligence: int = 10
|
| 24 |
+
wisdom: int = 10
|
| 25 |
+
charisma: int = 10
|
| 26 |
+
|
| 27 |
+
# Combat stats
|
| 28 |
+
max_hp: int = 10
|
| 29 |
+
current_hp: int = 10
|
| 30 |
+
temp_hp: int = 0
|
| 31 |
+
armor_class: int = 10
|
| 32 |
+
speed: int = 30
|
| 33 |
+
initiative_bonus: int = 0
|
| 34 |
+
|
| 35 |
+
# Proficiencies
|
| 36 |
+
proficient_skills: List[str] = field(default_factory=list)
|
| 37 |
+
expertise_skills: List[str] = field(default_factory=list)
|
| 38 |
+
proficient_saves: List[str] = field(default_factory=list)
|
| 39 |
+
|
| 40 |
+
# Equipment and features
|
| 41 |
+
equipment: List[str] = field(default_factory=list)
|
| 42 |
+
features: List[str] = field(default_factory=list)
|
| 43 |
+
|
| 44 |
+
# Status
|
| 45 |
+
conditions: List[str] = field(default_factory=list)
|
| 46 |
+
death_saves_success: int = 0
|
| 47 |
+
death_saves_failure: int = 0
|
| 48 |
+
|
| 49 |
+
def get_ability_scores(self) -> Dict[str, int]:
|
| 50 |
+
"""Get all ability scores as a dictionary."""
|
| 51 |
+
return {
|
| 52 |
+
"strength": self.strength,
|
| 53 |
+
"dexterity": self.dexterity,
|
| 54 |
+
"constitution": self.constitution,
|
| 55 |
+
"intelligence": self.intelligence,
|
| 56 |
+
"wisdom": self.wisdom,
|
| 57 |
+
"charisma": self.charisma,
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
def is_proficient(self, skill: str) -> bool:
|
| 61 |
+
"""Check if character is proficient in a skill."""
|
| 62 |
+
normalized = skill.lower().replace(" ", "_")
|
| 63 |
+
return normalized in [
|
| 64 |
+
s.lower().replace(" ", "_") for s in self.proficient_skills
|
| 65 |
+
]
|
| 66 |
+
|
| 67 |
+
def has_expertise(self, skill: str) -> bool:
|
| 68 |
+
"""Check if character has expertise in a skill."""
|
| 69 |
+
normalized = skill.lower().replace(" ", "_")
|
| 70 |
+
return normalized in [
|
| 71 |
+
s.lower().replace(" ", "_") for s in self.expertise_skills
|
| 72 |
+
]
|
| 73 |
+
|
| 74 |
+
def is_save_proficient(self, ability: str) -> bool:
|
| 75 |
+
"""Check if character is proficient in a saving throw."""
|
| 76 |
+
return ability.lower() in [s.lower() for s in self.proficient_saves]
|
| 77 |
+
|
| 78 |
+
def take_damage(self, amount: int) -> int:
|
| 79 |
+
"""
|
| 80 |
+
Apply damage to the character.
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
amount: Damage amount
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
Actual damage taken after temp HP
|
| 87 |
+
"""
|
| 88 |
+
# Temp HP absorbs damage first
|
| 89 |
+
if self.temp_hp > 0:
|
| 90 |
+
if amount <= self.temp_hp:
|
| 91 |
+
self.temp_hp -= amount
|
| 92 |
+
return 0
|
| 93 |
+
else:
|
| 94 |
+
amount -= self.temp_hp
|
| 95 |
+
self.temp_hp = 0
|
| 96 |
+
|
| 97 |
+
self.current_hp = max(0, self.current_hp - amount)
|
| 98 |
+
return amount
|
| 99 |
+
|
| 100 |
+
def heal(self, amount: int) -> int:
|
| 101 |
+
"""
|
| 102 |
+
Heal the character.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
amount: Healing amount
|
| 106 |
+
|
| 107 |
+
Returns:
|
| 108 |
+
Actual HP restored
|
| 109 |
+
"""
|
| 110 |
+
old_hp = self.current_hp
|
| 111 |
+
self.current_hp = min(self.max_hp, self.current_hp + amount)
|
| 112 |
+
return self.current_hp - old_hp
|
| 113 |
+
|
| 114 |
+
def is_unconscious(self) -> bool:
|
| 115 |
+
"""Check if character is at 0 HP."""
|
| 116 |
+
return self.current_hp <= 0
|
| 117 |
+
|
| 118 |
+
def is_dead(self) -> bool:
|
| 119 |
+
"""Check if character has failed 3 death saves."""
|
| 120 |
+
return self.death_saves_failure >= 3
|
| 121 |
+
|
| 122 |
+
def is_stable(self) -> bool:
|
| 123 |
+
"""Check if character has succeeded 3 death saves."""
|
| 124 |
+
return self.death_saves_success >= 3
|
| 125 |
+
|
| 126 |
+
def add_condition(self, condition: str):
|
| 127 |
+
"""Add a condition to the character."""
|
| 128 |
+
if condition.lower() not in [c.lower() for c in self.conditions]:
|
| 129 |
+
self.conditions.append(condition)
|
| 130 |
+
|
| 131 |
+
def remove_condition(self, condition: str):
|
| 132 |
+
"""Remove a condition from the character."""
|
| 133 |
+
self.conditions = [
|
| 134 |
+
c for c in self.conditions if c.lower() != condition.lower()
|
| 135 |
+
]
|
| 136 |
+
|
| 137 |
+
def has_condition(self, condition: str) -> bool:
|
| 138 |
+
"""Check if character has a condition."""
|
| 139 |
+
return condition.lower() in [c.lower() for c in self.conditions]
|
| 140 |
+
|
| 141 |
+
def to_dict(self) -> Dict:
|
| 142 |
+
"""Convert character to dictionary for serialization."""
|
| 143 |
+
return {
|
| 144 |
+
"name": self.name,
|
| 145 |
+
"race": self.race,
|
| 146 |
+
"character_class": self.character_class,
|
| 147 |
+
"level": self.level,
|
| 148 |
+
"background": self.background,
|
| 149 |
+
"alignment": self.alignment,
|
| 150 |
+
"ability_scores": self.get_ability_scores(),
|
| 151 |
+
"max_hp": self.max_hp,
|
| 152 |
+
"current_hp": self.current_hp,
|
| 153 |
+
"temp_hp": self.temp_hp,
|
| 154 |
+
"armor_class": self.armor_class,
|
| 155 |
+
"speed": self.speed,
|
| 156 |
+
"proficient_skills": self.proficient_skills,
|
| 157 |
+
"expertise_skills": self.expertise_skills,
|
| 158 |
+
"proficient_saves": self.proficient_saves,
|
| 159 |
+
"equipment": self.equipment,
|
| 160 |
+
"features": self.features,
|
| 161 |
+
"conditions": self.conditions,
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
def summary(self) -> str:
|
| 165 |
+
"""Get a brief character summary."""
|
| 166 |
+
return (
|
| 167 |
+
f"{self.name}, Level {self.level} {self.race} {self.character_class}\n"
|
| 168 |
+
f"HP: {self.current_hp}/{self.max_hp} | AC: {self.armor_class}\n"
|
| 169 |
+
f"STR: {self.strength} DEX: {self.dexterity} CON: {self.constitution}\n"
|
| 170 |
+
f"INT: {self.intelligence} WIS: {self.wisdom} CHA: {self.charisma}"
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def create_blank_character(name: str = "Adventurer") -> Character:
|
| 175 |
+
"""Create a blank character with default stats."""
|
| 176 |
+
return Character(
|
| 177 |
+
name=name,
|
| 178 |
+
race="Human",
|
| 179 |
+
character_class="Fighter",
|
| 180 |
+
level=1,
|
| 181 |
+
)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def create_test_character() -> Character:
|
| 185 |
+
"""Create a pre-built test character (Kira the Rogue)."""
|
| 186 |
+
return Character(
|
| 187 |
+
name="Kira",
|
| 188 |
+
race="Half-Elf",
|
| 189 |
+
character_class="Rogue",
|
| 190 |
+
level=3,
|
| 191 |
+
background="Criminal",
|
| 192 |
+
alignment="Chaotic Good",
|
| 193 |
+
strength=10,
|
| 194 |
+
dexterity=16,
|
| 195 |
+
constitution=12,
|
| 196 |
+
intelligence=14,
|
| 197 |
+
wisdom=10,
|
| 198 |
+
charisma=14,
|
| 199 |
+
max_hp=21,
|
| 200 |
+
current_hp=21,
|
| 201 |
+
armor_class=14,
|
| 202 |
+
speed=30,
|
| 203 |
+
proficient_skills=[
|
| 204 |
+
"stealth", "perception", "deception",
|
| 205 |
+
"sleight_of_hand", "acrobatics", "investigation"
|
| 206 |
+
],
|
| 207 |
+
expertise_skills=["stealth", "sleight_of_hand"],
|
| 208 |
+
proficient_saves=["dexterity", "intelligence"],
|
| 209 |
+
equipment=[
|
| 210 |
+
"Shortsword", "Shortbow", "Leather Armor",
|
| 211 |
+
"Thieves' Tools", "Burglar's Pack"
|
| 212 |
+
],
|
| 213 |
+
features=["Sneak Attack (2d6)", "Cunning Action", "Expertise"],
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def create_fighter_character() -> Character:
|
| 218 |
+
"""Create a pre-built Fighter character (Thorin the Dwarf)."""
|
| 219 |
+
return Character(
|
| 220 |
+
name="Thorin",
|
| 221 |
+
race="Dwarf",
|
| 222 |
+
character_class="Fighter",
|
| 223 |
+
level=3,
|
| 224 |
+
background="Soldier",
|
| 225 |
+
alignment="Lawful Good",
|
| 226 |
+
strength=16,
|
| 227 |
+
dexterity=12,
|
| 228 |
+
constitution=16,
|
| 229 |
+
intelligence=10,
|
| 230 |
+
wisdom=12,
|
| 231 |
+
charisma=8,
|
| 232 |
+
max_hp=31,
|
| 233 |
+
current_hp=31,
|
| 234 |
+
armor_class=18,
|
| 235 |
+
speed=25,
|
| 236 |
+
proficient_skills=["athletics", "intimidation", "perception", "survival"],
|
| 237 |
+
expertise_skills=[],
|
| 238 |
+
proficient_saves=["strength", "constitution"],
|
| 239 |
+
equipment=[
|
| 240 |
+
"Longsword", "Shield", "Chain Mail",
|
| 241 |
+
"Handaxe (2)", "Explorer's Pack"
|
| 242 |
+
],
|
| 243 |
+
features=["Fighting Style (Defense)", "Second Wind", "Action Surge"],
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def create_wizard_character() -> Character:
|
| 248 |
+
"""Create a pre-built Wizard character (Elara the Elf)."""
|
| 249 |
+
return Character(
|
| 250 |
+
name="Elara",
|
| 251 |
+
race="High Elf",
|
| 252 |
+
character_class="Wizard",
|
| 253 |
+
level=3,
|
| 254 |
+
background="Sage",
|
| 255 |
+
alignment="Neutral Good",
|
| 256 |
+
strength=8,
|
| 257 |
+
dexterity=14,
|
| 258 |
+
constitution=12,
|
| 259 |
+
intelligence=17,
|
| 260 |
+
wisdom=13,
|
| 261 |
+
charisma=10,
|
| 262 |
+
max_hp=17,
|
| 263 |
+
current_hp=17,
|
| 264 |
+
armor_class=12,
|
| 265 |
+
speed=30,
|
| 266 |
+
proficient_skills=["arcana", "history", "investigation", "insight"],
|
| 267 |
+
expertise_skills=[],
|
| 268 |
+
proficient_saves=["intelligence", "wisdom"],
|
| 269 |
+
equipment=[
|
| 270 |
+
"Quarterstaff", "Spellbook", "Component Pouch",
|
| 271 |
+
"Scholar's Pack", "Dagger"
|
| 272 |
+
],
|
| 273 |
+
features=["Arcane Recovery", "Evocation Savant", "Sculpt Spells"],
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
|
| 277 |
+
def create_cleric_character() -> Character:
|
| 278 |
+
"""Create a pre-built Cleric character (Brother Marcus)."""
|
| 279 |
+
return Character(
|
| 280 |
+
name="Brother Marcus",
|
| 281 |
+
race="Human",
|
| 282 |
+
character_class="Cleric",
|
| 283 |
+
level=3,
|
| 284 |
+
background="Acolyte",
|
| 285 |
+
alignment="Lawful Good",
|
| 286 |
+
strength=14,
|
| 287 |
+
dexterity=10,
|
| 288 |
+
constitution=14,
|
| 289 |
+
intelligence=10,
|
| 290 |
+
wisdom=16,
|
| 291 |
+
charisma=12,
|
| 292 |
+
max_hp=24,
|
| 293 |
+
current_hp=24,
|
| 294 |
+
armor_class=18,
|
| 295 |
+
speed=30,
|
| 296 |
+
proficient_skills=["medicine", "religion", "insight", "persuasion"],
|
| 297 |
+
expertise_skills=[],
|
| 298 |
+
proficient_saves=["wisdom", "charisma"],
|
| 299 |
+
equipment=[
|
| 300 |
+
"Mace", "Shield", "Chain Mail",
|
| 301 |
+
"Holy Symbol", "Priest's Pack"
|
| 302 |
+
],
|
| 303 |
+
features=["Spellcasting", "Channel Divinity", "Turn Undead"],
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
def create_ranger_character() -> Character:
|
| 308 |
+
"""Create a pre-built Ranger character (Sylvan the Wood Elf)."""
|
| 309 |
+
return Character(
|
| 310 |
+
name="Sylvan",
|
| 311 |
+
race="Wood Elf",
|
| 312 |
+
character_class="Ranger",
|
| 313 |
+
level=3,
|
| 314 |
+
background="Outlander",
|
| 315 |
+
alignment="Neutral Good",
|
| 316 |
+
strength=12,
|
| 317 |
+
dexterity=16,
|
| 318 |
+
constitution=14,
|
| 319 |
+
intelligence=10,
|
| 320 |
+
wisdom=14,
|
| 321 |
+
charisma=10,
|
| 322 |
+
max_hp=28,
|
| 323 |
+
current_hp=28,
|
| 324 |
+
armor_class=15,
|
| 325 |
+
speed=35,
|
| 326 |
+
proficient_skills=["nature", "survival", "stealth", "perception", "animal_handling"],
|
| 327 |
+
expertise_skills=[],
|
| 328 |
+
proficient_saves=["strength", "dexterity"],
|
| 329 |
+
equipment=[
|
| 330 |
+
"Longbow", "Shortsword (2)", "Studded Leather",
|
| 331 |
+
"Explorer's Pack", "Quiver (20 arrows)"
|
| 332 |
+
],
|
| 333 |
+
features=["Favored Enemy (Beasts)", "Natural Explorer (Forest)", "Fighting Style (Archery)"],
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
# Dictionary of all preset characters
|
| 338 |
+
PRESET_CHARACTERS = {
|
| 339 |
+
"Kira (Rogue)": create_test_character,
|
| 340 |
+
"Thorin (Fighter)": create_fighter_character,
|
| 341 |
+
"Elara (Wizard)": create_wizard_character,
|
| 342 |
+
"Brother Marcus (Cleric)": create_cleric_character,
|
| 343 |
+
"Sylvan (Ranger)": create_ranger_character,
|
| 344 |
+
}
|
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Game state management for D&D sessions."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from typing import List, Optional, Dict, Any
|
| 5 |
+
from enum import Enum
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from .character import Character
|
| 8 |
+
from .scene import Scene
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class GamePhase(Enum):
|
| 12 |
+
"""Current phase of the game."""
|
| 13 |
+
SETUP = "setup"
|
| 14 |
+
ADVENTURE_SELECTION = "adventure_selection"
|
| 15 |
+
CHARACTER_CREATION = "character_creation"
|
| 16 |
+
PLAYING = "playing"
|
| 17 |
+
COMBAT = "combat"
|
| 18 |
+
PAUSED = "paused"
|
| 19 |
+
ENDED = "ended"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class NarrativeType(Enum):
|
| 23 |
+
"""Types of narrative entries."""
|
| 24 |
+
SCENE = "scene"
|
| 25 |
+
PLAYER = "player"
|
| 26 |
+
GM_RESPONSE = "gm_response"
|
| 27 |
+
NPC = "npc"
|
| 28 |
+
ROLL = "roll"
|
| 29 |
+
ROLL_REQUEST = "roll_request"
|
| 30 |
+
ROLL_OUTCOME = "roll_outcome"
|
| 31 |
+
DAMAGE = "damage"
|
| 32 |
+
SYSTEM = "system"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class NarrativeEntry:
|
| 37 |
+
"""A single entry in the narrative log."""
|
| 38 |
+
entry_type: str
|
| 39 |
+
content: str
|
| 40 |
+
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
|
| 41 |
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
| 42 |
+
|
| 43 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 44 |
+
"""Convert to dictionary."""
|
| 45 |
+
return {
|
| 46 |
+
"entry_type": self.entry_type,
|
| 47 |
+
"content": self.content,
|
| 48 |
+
"timestamp": self.timestamp,
|
| 49 |
+
"metadata": self.metadata,
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
@classmethod
|
| 53 |
+
def from_dict(cls, data: Dict[str, Any]) -> "NarrativeEntry":
|
| 54 |
+
"""Create from dictionary."""
|
| 55 |
+
return cls(
|
| 56 |
+
entry_type=data.get("entry_type", "system"),
|
| 57 |
+
content=data.get("content", ""),
|
| 58 |
+
timestamp=data.get("timestamp", datetime.now().isoformat()),
|
| 59 |
+
metadata=data.get("metadata", {}),
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@dataclass
|
| 64 |
+
class CombatState:
|
| 65 |
+
"""State for combat encounters."""
|
| 66 |
+
is_active: bool = False
|
| 67 |
+
round_number: int = 0
|
| 68 |
+
turn_order: List[str] = field(default_factory=list)
|
| 69 |
+
current_turn_index: int = 0
|
| 70 |
+
combatants: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
| 71 |
+
|
| 72 |
+
def start_combat(self, initiative_order: List[tuple]):
|
| 73 |
+
"""
|
| 74 |
+
Start combat with initiative order.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
initiative_order: List of (name, initiative_roll) tuples
|
| 78 |
+
"""
|
| 79 |
+
self.is_active = True
|
| 80 |
+
self.round_number = 1
|
| 81 |
+
self.current_turn_index = 0
|
| 82 |
+
|
| 83 |
+
# Sort by initiative (descending)
|
| 84 |
+
sorted_order = sorted(initiative_order, key=lambda x: x[1], reverse=True)
|
| 85 |
+
self.turn_order = [name for name, _ in sorted_order]
|
| 86 |
+
|
| 87 |
+
# Initialize combatant tracking
|
| 88 |
+
for name, initiative in sorted_order:
|
| 89 |
+
self.combatants[name] = {
|
| 90 |
+
"initiative": initiative,
|
| 91 |
+
"conditions": [],
|
| 92 |
+
"has_acted": False,
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
def next_turn(self) -> Optional[str]:
|
| 96 |
+
"""Advance to next combatant's turn."""
|
| 97 |
+
if not self.is_active:
|
| 98 |
+
return None
|
| 99 |
+
|
| 100 |
+
# Mark current combatant as having acted
|
| 101 |
+
current = self.get_current_combatant()
|
| 102 |
+
if current and current in self.combatants:
|
| 103 |
+
self.combatants[current]["has_acted"] = True
|
| 104 |
+
|
| 105 |
+
# Move to next
|
| 106 |
+
self.current_turn_index += 1
|
| 107 |
+
|
| 108 |
+
# Check for new round
|
| 109 |
+
if self.current_turn_index >= len(self.turn_order):
|
| 110 |
+
self.current_turn_index = 0
|
| 111 |
+
self.round_number += 1
|
| 112 |
+
# Reset has_acted for all
|
| 113 |
+
for combatant in self.combatants.values():
|
| 114 |
+
combatant["has_acted"] = False
|
| 115 |
+
|
| 116 |
+
return self.get_current_combatant()
|
| 117 |
+
|
| 118 |
+
def get_current_combatant(self) -> Optional[str]:
|
| 119 |
+
"""Get the name of the current combatant."""
|
| 120 |
+
if not self.turn_order or self.current_turn_index >= len(self.turn_order):
|
| 121 |
+
return None
|
| 122 |
+
return self.turn_order[self.current_turn_index]
|
| 123 |
+
|
| 124 |
+
def remove_combatant(self, name: str):
|
| 125 |
+
"""Remove a combatant (defeated, fled, etc.)."""
|
| 126 |
+
if name in self.turn_order:
|
| 127 |
+
idx = self.turn_order.index(name)
|
| 128 |
+
self.turn_order.remove(name)
|
| 129 |
+
if idx < self.current_turn_index:
|
| 130 |
+
self.current_turn_index -= 1
|
| 131 |
+
if name in self.combatants:
|
| 132 |
+
del self.combatants[name]
|
| 133 |
+
|
| 134 |
+
def end_combat(self):
|
| 135 |
+
"""End the combat encounter."""
|
| 136 |
+
self.is_active = False
|
| 137 |
+
self.round_number = 0
|
| 138 |
+
self.turn_order = []
|
| 139 |
+
self.current_turn_index = 0
|
| 140 |
+
self.combatants = {}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@dataclass
|
| 144 |
+
class GameState:
|
| 145 |
+
"""Complete game state for a D&D session."""
|
| 146 |
+
# Session info
|
| 147 |
+
session_id: str = ""
|
| 148 |
+
session_name: str = "New Adventure"
|
| 149 |
+
created_at: str = field(default_factory=lambda: datetime.now().isoformat())
|
| 150 |
+
|
| 151 |
+
# Game phase
|
| 152 |
+
phase: GamePhase = GamePhase.SETUP
|
| 153 |
+
|
| 154 |
+
# Character and scene
|
| 155 |
+
character: Optional[Character] = None
|
| 156 |
+
scene: Optional[Scene] = None
|
| 157 |
+
|
| 158 |
+
# Combat
|
| 159 |
+
combat: CombatState = field(default_factory=CombatState)
|
| 160 |
+
|
| 161 |
+
# Narrative history
|
| 162 |
+
narrative_history: List[NarrativeEntry] = field(default_factory=list)
|
| 163 |
+
|
| 164 |
+
# LLM conversation history (for context)
|
| 165 |
+
conversation_history: List[Dict[str, str]] = field(default_factory=list)
|
| 166 |
+
|
| 167 |
+
# Adventure metadata
|
| 168 |
+
adventure_name: str = ""
|
| 169 |
+
adventure_description: str = ""
|
| 170 |
+
|
| 171 |
+
def add_narrative(
|
| 172 |
+
self,
|
| 173 |
+
entry_type: str,
|
| 174 |
+
content: str,
|
| 175 |
+
**metadata
|
| 176 |
+
) -> NarrativeEntry:
|
| 177 |
+
"""Add a narrative entry to the history."""
|
| 178 |
+
entry = NarrativeEntry(
|
| 179 |
+
entry_type=entry_type,
|
| 180 |
+
content=content,
|
| 181 |
+
metadata=metadata
|
| 182 |
+
)
|
| 183 |
+
self.narrative_history.append(entry)
|
| 184 |
+
return entry
|
| 185 |
+
|
| 186 |
+
def add_message(self, role: str, content: str):
|
| 187 |
+
"""Add a message to the LLM conversation history."""
|
| 188 |
+
self.conversation_history.append({
|
| 189 |
+
"role": role,
|
| 190 |
+
"content": content
|
| 191 |
+
})
|
| 192 |
+
|
| 193 |
+
def get_recent_context(self, max_entries: int = 10) -> str:
|
| 194 |
+
"""Get recent narrative for context building."""
|
| 195 |
+
recent = self.narrative_history[-max_entries:]
|
| 196 |
+
return "\n".join([
|
| 197 |
+
f"[{e.entry_type.upper()}] {e.content}"
|
| 198 |
+
for e in recent
|
| 199 |
+
])
|
| 200 |
+
|
| 201 |
+
def get_recent_messages(self, max_messages: int = 20) -> List[Dict[str, str]]:
|
| 202 |
+
"""Get recent conversation messages for LLM context."""
|
| 203 |
+
return self.conversation_history[-max_messages:]
|
| 204 |
+
|
| 205 |
+
def clear_conversation(self):
|
| 206 |
+
"""Clear conversation history (but keep narrative)."""
|
| 207 |
+
self.conversation_history = []
|
| 208 |
+
|
| 209 |
+
def start_adventure(
|
| 210 |
+
self,
|
| 211 |
+
character: Character,
|
| 212 |
+
scene: Scene,
|
| 213 |
+
adventure_name: str = "",
|
| 214 |
+
):
|
| 215 |
+
"""Initialize a new adventure."""
|
| 216 |
+
self.character = character
|
| 217 |
+
self.scene = scene
|
| 218 |
+
self.adventure_name = adventure_name
|
| 219 |
+
self.phase = GamePhase.PLAYING
|
| 220 |
+
self.narrative_history = []
|
| 221 |
+
self.conversation_history = []
|
| 222 |
+
|
| 223 |
+
# Add initial scene to narrative
|
| 224 |
+
self.add_narrative("scene", scene.description)
|
| 225 |
+
|
| 226 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 227 |
+
"""Serialize game state to dictionary."""
|
| 228 |
+
return {
|
| 229 |
+
"session_id": self.session_id,
|
| 230 |
+
"session_name": self.session_name,
|
| 231 |
+
"created_at": self.created_at,
|
| 232 |
+
"phase": self.phase.value,
|
| 233 |
+
"character": self.character.to_dict() if self.character else None,
|
| 234 |
+
"scene": self.scene.to_dict() if self.scene else None,
|
| 235 |
+
"narrative_history": [e.to_dict() for e in self.narrative_history],
|
| 236 |
+
"adventure_name": self.adventure_name,
|
| 237 |
+
"adventure_description": self.adventure_description,
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
@classmethod
|
| 241 |
+
def from_dict(cls, data: Dict[str, Any]) -> "GameState":
|
| 242 |
+
"""Deserialize game state from dictionary."""
|
| 243 |
+
state = cls(
|
| 244 |
+
session_id=data.get("session_id", ""),
|
| 245 |
+
session_name=data.get("session_name", "New Adventure"),
|
| 246 |
+
created_at=data.get("created_at", datetime.now().isoformat()),
|
| 247 |
+
phase=GamePhase(data.get("phase", "setup")),
|
| 248 |
+
adventure_name=data.get("adventure_name", ""),
|
| 249 |
+
adventure_description=data.get("adventure_description", ""),
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
# Load character if present
|
| 253 |
+
if data.get("character"):
|
| 254 |
+
from .character import Character
|
| 255 |
+
char_data = data["character"]
|
| 256 |
+
state.character = Character(
|
| 257 |
+
name=char_data.get("name", "Adventurer"),
|
| 258 |
+
race=char_data.get("race", "Human"),
|
| 259 |
+
character_class=char_data.get("character_class", "Fighter"),
|
| 260 |
+
level=char_data.get("level", 1),
|
| 261 |
+
# Add other fields as needed
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
# Load scene if present
|
| 265 |
+
if data.get("scene"):
|
| 266 |
+
state.scene = Scene.from_dict(data["scene"])
|
| 267 |
+
|
| 268 |
+
# Load narrative history
|
| 269 |
+
state.narrative_history = [
|
| 270 |
+
NarrativeEntry.from_dict(e)
|
| 271 |
+
for e in data.get("narrative_history", [])
|
| 272 |
+
]
|
| 273 |
+
|
| 274 |
+
return state
|
| 275 |
+
|
| 276 |
+
def save_to_file(self, filepath: str):
|
| 277 |
+
"""Save game state to a JSON file."""
|
| 278 |
+
import json
|
| 279 |
+
with open(filepath, 'w') as f:
|
| 280 |
+
json.dump(self.to_dict(), f, indent=2)
|
| 281 |
+
|
| 282 |
+
@classmethod
|
| 283 |
+
def load_from_file(cls, filepath: str) -> "GameState":
|
| 284 |
+
"""Load game state from a JSON file."""
|
| 285 |
+
import json
|
| 286 |
+
with open(filepath, 'r') as f:
|
| 287 |
+
data = json.load(f)
|
| 288 |
+
return cls.from_dict(data)
|
|
@@ -0,0 +1,235 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scene and environment data models for D&D."""
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from typing import List, Optional, Dict, Any
|
| 5 |
+
from enum import Enum
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class TimeOfDay(Enum):
|
| 9 |
+
"""Time of day for scene atmosphere."""
|
| 10 |
+
DAWN = "dawn"
|
| 11 |
+
MORNING = "morning"
|
| 12 |
+
MIDDAY = "midday"
|
| 13 |
+
AFTERNOON = "afternoon"
|
| 14 |
+
DUSK = "dusk"
|
| 15 |
+
EVENING = "evening"
|
| 16 |
+
NIGHT = "night"
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class Lighting(Enum):
|
| 20 |
+
"""Lighting conditions affecting visibility."""
|
| 21 |
+
BRIGHT = "bright"
|
| 22 |
+
DIM = "dim"
|
| 23 |
+
DARK = "dark"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class NPCAttitude(Enum):
|
| 27 |
+
"""NPC disposition toward the party."""
|
| 28 |
+
HOSTILE = "hostile"
|
| 29 |
+
UNFRIENDLY = "unfriendly"
|
| 30 |
+
NEUTRAL = "neutral"
|
| 31 |
+
FRIENDLY = "friendly"
|
| 32 |
+
HELPFUL = "helpful"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass
|
| 36 |
+
class NPC:
|
| 37 |
+
"""A non-player character in the scene."""
|
| 38 |
+
name: str
|
| 39 |
+
description: str = ""
|
| 40 |
+
attitude: NPCAttitude = NPCAttitude.NEUTRAL
|
| 41 |
+
notes: str = ""
|
| 42 |
+
|
| 43 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 44 |
+
"""Convert to dictionary."""
|
| 45 |
+
return {
|
| 46 |
+
"name": self.name,
|
| 47 |
+
"description": self.description,
|
| 48 |
+
"attitude": self.attitude.value,
|
| 49 |
+
"notes": self.notes,
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
@classmethod
|
| 53 |
+
def from_dict(cls, data: Dict[str, Any]) -> "NPC":
|
| 54 |
+
"""Create NPC from dictionary."""
|
| 55 |
+
return cls(
|
| 56 |
+
name=data.get("name", "Unknown"),
|
| 57 |
+
description=data.get("description", ""),
|
| 58 |
+
attitude=NPCAttitude(data.get("attitude", "neutral")),
|
| 59 |
+
notes=data.get("notes", ""),
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@dataclass
|
| 64 |
+
class Scene:
|
| 65 |
+
"""Current scene/location in the game world."""
|
| 66 |
+
location: str
|
| 67 |
+
description: str
|
| 68 |
+
lighting: Lighting = Lighting.BRIGHT
|
| 69 |
+
time_of_day: TimeOfDay = TimeOfDay.MIDDAY
|
| 70 |
+
npcs: List[NPC] = field(default_factory=list)
|
| 71 |
+
objects: List[str] = field(default_factory=list)
|
| 72 |
+
exits: List[str] = field(default_factory=list)
|
| 73 |
+
notes: str = ""
|
| 74 |
+
|
| 75 |
+
def to_context_string(self) -> str:
|
| 76 |
+
"""Format scene as context for the LLM."""
|
| 77 |
+
parts = [
|
| 78 |
+
f"Location: {self.location}",
|
| 79 |
+
f"Time: {self.time_of_day.value.title()}, {self.lighting.value} lighting",
|
| 80 |
+
f"Description: {self.description}",
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
if self.npcs:
|
| 84 |
+
npc_list = ", ".join([
|
| 85 |
+
f"{n.name} ({n.attitude.value})" for n in self.npcs
|
| 86 |
+
])
|
| 87 |
+
parts.append(f"NPCs present: {npc_list}")
|
| 88 |
+
|
| 89 |
+
if self.objects:
|
| 90 |
+
parts.append(f"Notable objects: {', '.join(self.objects)}")
|
| 91 |
+
|
| 92 |
+
if self.exits:
|
| 93 |
+
parts.append(f"Exits: {', '.join(self.exits)}")
|
| 94 |
+
|
| 95 |
+
return "\n".join(parts)
|
| 96 |
+
|
| 97 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 98 |
+
"""Convert to dictionary for serialization."""
|
| 99 |
+
return {
|
| 100 |
+
"location": self.location,
|
| 101 |
+
"description": self.description,
|
| 102 |
+
"lighting": self.lighting.value,
|
| 103 |
+
"time_of_day": self.time_of_day.value,
|
| 104 |
+
"npcs": [npc.to_dict() for npc in self.npcs],
|
| 105 |
+
"objects": self.objects,
|
| 106 |
+
"exits": self.exits,
|
| 107 |
+
"notes": self.notes,
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
@classmethod
|
| 111 |
+
def from_dict(cls, data: Dict[str, Any]) -> "Scene":
|
| 112 |
+
"""Create Scene from dictionary."""
|
| 113 |
+
npcs = [NPC.from_dict(n) for n in data.get("npcs", [])]
|
| 114 |
+
return cls(
|
| 115 |
+
location=data.get("location", "Unknown"),
|
| 116 |
+
description=data.get("description", ""),
|
| 117 |
+
lighting=Lighting(data.get("lighting", "bright")),
|
| 118 |
+
time_of_day=TimeOfDay(data.get("time_of_day", "midday")),
|
| 119 |
+
npcs=npcs,
|
| 120 |
+
objects=data.get("objects", []),
|
| 121 |
+
exits=data.get("exits", []),
|
| 122 |
+
notes=data.get("notes", ""),
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
def add_npc(self, npc: NPC):
|
| 126 |
+
"""Add an NPC to the scene."""
|
| 127 |
+
self.npcs.append(npc)
|
| 128 |
+
|
| 129 |
+
def remove_npc(self, name: str):
|
| 130 |
+
"""Remove an NPC by name."""
|
| 131 |
+
self.npcs = [n for n in self.npcs if n.name.lower() != name.lower()]
|
| 132 |
+
|
| 133 |
+
def get_npc(self, name: str) -> Optional[NPC]:
|
| 134 |
+
"""Get an NPC by name."""
|
| 135 |
+
for npc in self.npcs:
|
| 136 |
+
if npc.name.lower() == name.lower():
|
| 137 |
+
return npc
|
| 138 |
+
return None
|
| 139 |
+
|
| 140 |
+
def add_object(self, obj: str):
|
| 141 |
+
"""Add a notable object to the scene."""
|
| 142 |
+
if obj not in self.objects:
|
| 143 |
+
self.objects.append(obj)
|
| 144 |
+
|
| 145 |
+
def remove_object(self, obj: str):
|
| 146 |
+
"""Remove an object from the scene."""
|
| 147 |
+
self.objects = [o for o in self.objects if o.lower() != obj.lower()]
|
| 148 |
+
|
| 149 |
+
def update_lighting(self, lighting: Lighting):
|
| 150 |
+
"""Update scene lighting."""
|
| 151 |
+
self.lighting = lighting
|
| 152 |
+
|
| 153 |
+
def advance_time(self):
|
| 154 |
+
"""Advance to the next time of day."""
|
| 155 |
+
times = list(TimeOfDay)
|
| 156 |
+
current_idx = times.index(self.time_of_day)
|
| 157 |
+
next_idx = (current_idx + 1) % len(times)
|
| 158 |
+
self.time_of_day = times[next_idx]
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
# Premade adventure scenes
|
| 162 |
+
def create_tavern_scene() -> Scene:
|
| 163 |
+
"""Create a classic tavern starting scene."""
|
| 164 |
+
return Scene(
|
| 165 |
+
location="The Dusty Tankard Tavern",
|
| 166 |
+
description=(
|
| 167 |
+
"A dimly lit tavern filled with the murmur of hushed conversations. "
|
| 168 |
+
"Wooden beams creak overhead, and the smell of ale and roasted meat "
|
| 169 |
+
"hangs thick in the air. A crackling fireplace provides warmth against "
|
| 170 |
+
"the chill outside."
|
| 171 |
+
),
|
| 172 |
+
lighting=Lighting.DIM,
|
| 173 |
+
time_of_day=TimeOfDay.EVENING,
|
| 174 |
+
npcs=[
|
| 175 |
+
NPC(
|
| 176 |
+
"Mira the Bartender",
|
| 177 |
+
"A stern woman with knowing eyes and calloused hands",
|
| 178 |
+
NPCAttitude.NEUTRAL
|
| 179 |
+
),
|
| 180 |
+
NPC(
|
| 181 |
+
"Hooded Stranger",
|
| 182 |
+
"Sits alone in the corner, nursing a drink",
|
| 183 |
+
NPCAttitude.UNFRIENDLY
|
| 184 |
+
),
|
| 185 |
+
],
|
| 186 |
+
objects=["Notice board", "Crackling fireplace", "Locked cellar door"],
|
| 187 |
+
exits=["Front door to the street", "Back door to the alley", "Stairs to rooms above"],
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def create_dungeon_entrance_scene() -> Scene:
|
| 192 |
+
"""Create a dungeon entrance scene."""
|
| 193 |
+
return Scene(
|
| 194 |
+
location="Ancient Crypt Entrance",
|
| 195 |
+
description=(
|
| 196 |
+
"Crumbling stone steps descend into darkness. The air grows cold and "
|
| 197 |
+
"damp as you approach the weathered entrance. Faded runes are carved "
|
| 198 |
+
"into the archway, their meaning long forgotten. A foul smell drifts "
|
| 199 |
+
"up from below."
|
| 200 |
+
),
|
| 201 |
+
lighting=Lighting.DIM,
|
| 202 |
+
time_of_day=TimeOfDay.AFTERNOON,
|
| 203 |
+
npcs=[],
|
| 204 |
+
objects=[
|
| 205 |
+
"Faded warning runes", "Scattered bones", "Rusty iron gate",
|
| 206 |
+
"Extinguished torch sconces"
|
| 207 |
+
],
|
| 208 |
+
exits=["Stone steps descending into darkness", "Forest path back to town"],
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def create_forest_clearing_scene() -> Scene:
|
| 213 |
+
"""Create a forest clearing scene."""
|
| 214 |
+
return Scene(
|
| 215 |
+
location="Moonlit Forest Clearing",
|
| 216 |
+
description=(
|
| 217 |
+
"Silver moonlight filters through the canopy, illuminating a small "
|
| 218 |
+
"clearing in the ancient forest. Fireflies dance among the ferns, "
|
| 219 |
+
"and the distant hoot of an owl echoes through the trees. A moss-covered "
|
| 220 |
+
"stone circle stands at the center."
|
| 221 |
+
),
|
| 222 |
+
lighting=Lighting.DIM,
|
| 223 |
+
time_of_day=TimeOfDay.NIGHT,
|
| 224 |
+
npcs=[],
|
| 225 |
+
objects=["Ancient stone circle", "Fairy ring of mushrooms", "Clear spring"],
|
| 226 |
+
exits=["Overgrown path north", "Game trail east", "Dense thicket west"],
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
# Dictionary of all preset scenes
|
| 231 |
+
PRESET_SCENES = {
|
| 232 |
+
"The Dusty Tankard (Tavern)": create_tavern_scene,
|
| 233 |
+
"Ancient Crypt Entrance (Dungeon)": create_dungeon_entrance_scene,
|
| 234 |
+
"Moonlit Forest Clearing": create_forest_clearing_scene,
|
| 235 |
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""System prompts for the D&D Game Master LLM."""
|
| 2 |
+
|
| 3 |
+
from .gm_prompts import (
|
| 4 |
+
build_gm_system_prompt,
|
| 5 |
+
build_scene_context,
|
| 6 |
+
build_roll_outcome_prompt,
|
| 7 |
+
build_scene_extraction_prompt,
|
| 8 |
+
build_combat_prompt,
|
| 9 |
+
GM_PERSONALITY,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
__all__ = [
|
| 13 |
+
"build_gm_system_prompt",
|
| 14 |
+
"build_scene_context",
|
| 15 |
+
"build_roll_outcome_prompt",
|
| 16 |
+
"build_scene_extraction_prompt",
|
| 17 |
+
"build_combat_prompt",
|
| 18 |
+
"GM_PERSONALITY",
|
| 19 |
+
]
|
|
@@ -0,0 +1,288 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prompt templates for the D&D Game Master LLM."""
|
| 2 |
+
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from ..models.character import Character
|
| 5 |
+
from ..models.scene import Scene
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
# Base GM personality and rules
|
| 9 |
+
GM_PERSONALITY = """You are an expert Dungeons & Dragons 5th Edition Game Master with decades of experience running immersive campaigns. Your storytelling is vivid and engaging, bringing scenes to life with sensory details. You are fair but challenging, creating tension while respecting player agency.
|
| 10 |
+
|
| 11 |
+
Your core principles:
|
| 12 |
+
- Describe environments with rich sensory details (sights, sounds, smells)
|
| 13 |
+
- Give NPCs distinct personalities and motivations
|
| 14 |
+
- Maintain internal world consistency
|
| 15 |
+
- Reward creative problem-solving
|
| 16 |
+
- Apply D&D 5e rules accurately
|
| 17 |
+
- Keep the narrative moving forward
|
| 18 |
+
- Balance challenge with fun"""
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def build_gm_system_prompt() -> str:
|
| 22 |
+
"""Build the main GM system prompt for action adjudication."""
|
| 23 |
+
return f"""{GM_PERSONALITY}
|
| 24 |
+
|
| 25 |
+
## Your Role
|
| 26 |
+
When a player describes an action, you must decide how to resolve it and respond with a JSON object.
|
| 27 |
+
|
| 28 |
+
## Decision Types
|
| 29 |
+
- "impossible": The action cannot physically happen or breaks world logic
|
| 30 |
+
- "auto_success": Trivial action that any adventurer could accomplish
|
| 31 |
+
- "auto_fail": Action that would certainly fail regardless of luck
|
| 32 |
+
- "needs_roll": Outcome is uncertain and requires a skill check
|
| 33 |
+
|
| 34 |
+
## Response Format
|
| 35 |
+
You MUST respond with valid JSON only, in this exact structure:
|
| 36 |
+
|
| 37 |
+
For needs_roll:
|
| 38 |
+
{{
|
| 39 |
+
"decision": "needs_roll",
|
| 40 |
+
"skill": "stealth",
|
| 41 |
+
"dc": 15,
|
| 42 |
+
"advantage": false,
|
| 43 |
+
"disadvantage": false,
|
| 44 |
+
"narration": "Brief setup for what happens as you attempt this...",
|
| 45 |
+
"reason": "Why this requires a roll"
|
| 46 |
+
}}
|
| 47 |
+
|
| 48 |
+
For auto_success:
|
| 49 |
+
{{
|
| 50 |
+
"decision": "auto_success",
|
| 51 |
+
"narration": "Vivid description of what happens as you succeed...",
|
| 52 |
+
"reason": "Why this automatically succeeds"
|
| 53 |
+
}}
|
| 54 |
+
|
| 55 |
+
For auto_fail:
|
| 56 |
+
{{
|
| 57 |
+
"decision": "auto_fail",
|
| 58 |
+
"narration": "Description of why this fails...",
|
| 59 |
+
"reason": "Why this cannot succeed"
|
| 60 |
+
}}
|
| 61 |
+
|
| 62 |
+
For impossible:
|
| 63 |
+
{{
|
| 64 |
+
"decision": "impossible",
|
| 65 |
+
"narration": "Explanation of why this cannot be attempted...",
|
| 66 |
+
"reason": "Why this is impossible",
|
| 67 |
+
"suggestions": ["alternative action 1", "alternative action 2"]
|
| 68 |
+
}}
|
| 69 |
+
|
| 70 |
+
## Skill Check Guidelines
|
| 71 |
+
Common DCs:
|
| 72 |
+
- DC 5: Very easy (climb a knotted rope)
|
| 73 |
+
- DC 10: Easy (hear an approaching guard)
|
| 74 |
+
- DC 15: Medium (pick a typical lock)
|
| 75 |
+
- DC 20: Hard (leap across a 20-foot chasm)
|
| 76 |
+
- DC 25: Very hard (pick an amazing lock)
|
| 77 |
+
- DC 30: Nearly impossible (leap across a 30-foot chasm)
|
| 78 |
+
|
| 79 |
+
Match skills to actions:
|
| 80 |
+
- Stealth: Moving quietly, hiding
|
| 81 |
+
- Perception: Noticing things, searching
|
| 82 |
+
- Investigation: Deducing, examining closely
|
| 83 |
+
- Persuasion: Convincing through charm
|
| 84 |
+
- Deception: Lying convincingly
|
| 85 |
+
- Intimidation: Threatening, coercing
|
| 86 |
+
- Athletics: Climbing, jumping, swimming
|
| 87 |
+
- Acrobatics: Balance, tumbling, dodging
|
| 88 |
+
- Sleight of Hand: Pickpocketing, hiding objects
|
| 89 |
+
- Insight: Reading intentions, detecting lies
|
| 90 |
+
|
| 91 |
+
IMPORTANT: Respond with JSON only. No markdown, no explanation outside the JSON."""
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def build_scene_context(character: Character, scene: Scene) -> str:
|
| 95 |
+
"""Build context string with current scene and character info."""
|
| 96 |
+
context_parts = [
|
| 97 |
+
"=== CURRENT SCENE ===",
|
| 98 |
+
scene.to_context_string(),
|
| 99 |
+
"",
|
| 100 |
+
"=== ACTIVE CHARACTER ===",
|
| 101 |
+
f"Name: {character.name}",
|
| 102 |
+
f"Race/Class: {character.race} {character.character_class} (Level {character.level})",
|
| 103 |
+
f"HP: {character.current_hp}/{character.max_hp} | AC: {character.armor_class}",
|
| 104 |
+
]
|
| 105 |
+
|
| 106 |
+
if character.conditions:
|
| 107 |
+
context_parts.append(f"Conditions: {', '.join(character.conditions)}")
|
| 108 |
+
|
| 109 |
+
return "\n".join(context_parts)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def build_roll_outcome_prompt(
|
| 113 |
+
action: str,
|
| 114 |
+
skill: str,
|
| 115 |
+
roll_total: int,
|
| 116 |
+
dc: int,
|
| 117 |
+
success: bool,
|
| 118 |
+
critical: bool = False,
|
| 119 |
+
natural_roll: int = 0,
|
| 120 |
+
) -> str:
|
| 121 |
+
"""Build prompt for narrating roll outcomes."""
|
| 122 |
+
if critical and success:
|
| 123 |
+
result = "CRITICAL SUCCESS (Natural 20)"
|
| 124 |
+
elif critical and not success:
|
| 125 |
+
result = "CRITICAL FAILURE (Natural 1)"
|
| 126 |
+
elif success:
|
| 127 |
+
result = "SUCCESS"
|
| 128 |
+
else:
|
| 129 |
+
result = "FAILURE"
|
| 130 |
+
|
| 131 |
+
margin = roll_total - dc
|
| 132 |
+
margin_str = f"+{margin}" if margin >= 0 else str(margin)
|
| 133 |
+
|
| 134 |
+
return f"""The player attempted: "{action}"
|
| 135 |
+
|
| 136 |
+
Skill check: {skill.title()}
|
| 137 |
+
Roll: {natural_roll} + modifier = {roll_total} vs DC {dc} (margin: {margin_str})
|
| 138 |
+
Result: {result}
|
| 139 |
+
|
| 140 |
+
Narrate the outcome vividly, describing what happens based on the result. Be specific and cinematic.
|
| 141 |
+
{"The critical success should feel especially rewarding and impactful." if critical and success else ""}
|
| 142 |
+
{"The critical failure should have dramatic (but not campaign-ending) consequences." if critical and not success else ""}
|
| 143 |
+
|
| 144 |
+
After narrating, if the scene has changed significantly, note the new scene state.
|
| 145 |
+
|
| 146 |
+
Respond with JSON:
|
| 147 |
+
{{
|
| 148 |
+
"narration": "Your vivid narration of what happens...",
|
| 149 |
+
"location_changed": false,
|
| 150 |
+
"new_location": null,
|
| 151 |
+
"new_description": null,
|
| 152 |
+
"npcs_changed": [],
|
| 153 |
+
"objects_changed": []
|
| 154 |
+
}}"""
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def build_scene_extraction_prompt(narrative: str) -> str:
|
| 158 |
+
"""Build prompt for extracting scene details from narrative."""
|
| 159 |
+
return f"""Analyze this narrative and extract the current scene details:
|
| 160 |
+
|
| 161 |
+
---
|
| 162 |
+
{narrative}
|
| 163 |
+
---
|
| 164 |
+
|
| 165 |
+
Extract and respond with JSON:
|
| 166 |
+
{{
|
| 167 |
+
"location": "Name of the current location",
|
| 168 |
+
"description": "Detailed description of the scene (2-3 sentences)",
|
| 169 |
+
"lighting": "bright" | "dim" | "dark",
|
| 170 |
+
"time_of_day": "dawn" | "morning" | "midday" | "afternoon" | "dusk" | "evening" | "night",
|
| 171 |
+
"npcs": [
|
| 172 |
+
{{"name": "NPC name", "description": "Brief description", "attitude": "hostile" | "unfriendly" | "neutral" | "friendly" | "helpful"}}
|
| 173 |
+
],
|
| 174 |
+
"objects": ["notable object 1", "notable object 2"],
|
| 175 |
+
"exits": ["exit 1", "exit 2"]
|
| 176 |
+
}}
|
| 177 |
+
|
| 178 |
+
Only include NPCs and objects that are clearly present in the narrative. If unsure, omit them."""
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def build_combat_prompt(
|
| 182 |
+
combatants: list,
|
| 183 |
+
current_turn: str,
|
| 184 |
+
round_number: int,
|
| 185 |
+
scene_context: str,
|
| 186 |
+
) -> str:
|
| 187 |
+
"""Build prompt for combat encounters."""
|
| 188 |
+
combatant_list = "\n".join([
|
| 189 |
+
f"- {c['name']}: HP {c.get('hp', '?')}, AC {c.get('ac', '?')}"
|
| 190 |
+
for c in combatants
|
| 191 |
+
])
|
| 192 |
+
|
| 193 |
+
return f"""{GM_PERSONALITY}
|
| 194 |
+
|
| 195 |
+
## COMBAT ENCOUNTER
|
| 196 |
+
Round: {round_number}
|
| 197 |
+
Current Turn: {current_turn}
|
| 198 |
+
|
| 199 |
+
### Combatants:
|
| 200 |
+
{combatant_list}
|
| 201 |
+
|
| 202 |
+
### Scene:
|
| 203 |
+
{scene_context}
|
| 204 |
+
|
| 205 |
+
When describing combat actions:
|
| 206 |
+
1. Be visceral and exciting
|
| 207 |
+
2. Describe the physical action vividly
|
| 208 |
+
3. React to hits and misses dramatically
|
| 209 |
+
4. Track positioning and tactics
|
| 210 |
+
5. Give enemies personality in their fighting style
|
| 211 |
+
|
| 212 |
+
For enemy turns, describe their action and respond with:
|
| 213 |
+
{{
|
| 214 |
+
"action": "attack" | "spell" | "ability" | "movement" | "other",
|
| 215 |
+
"target": "target name",
|
| 216 |
+
"narration": "Description of what they do",
|
| 217 |
+
"attack_roll": null, // For attacks: the roll to make
|
| 218 |
+
"damage_roll": null, // For damage: the dice to roll
|
| 219 |
+
"save_dc": null, // For saves: the DC
|
| 220 |
+
"save_type": null // For saves: which save
|
| 221 |
+
}}"""
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
def build_npc_dialogue_prompt(
|
| 225 |
+
npc_name: str,
|
| 226 |
+
npc_description: str,
|
| 227 |
+
npc_attitude: str,
|
| 228 |
+
player_said: str,
|
| 229 |
+
context: str,
|
| 230 |
+
) -> str:
|
| 231 |
+
"""Build prompt for NPC dialogue responses."""
|
| 232 |
+
return f"""You are roleplaying as {npc_name}.
|
| 233 |
+
|
| 234 |
+
Description: {npc_description}
|
| 235 |
+
Attitude toward party: {npc_attitude}
|
| 236 |
+
|
| 237 |
+
Context:
|
| 238 |
+
{context}
|
| 239 |
+
|
| 240 |
+
The player says: "{player_said}"
|
| 241 |
+
|
| 242 |
+
Respond in character as {npc_name}. Stay true to their personality and attitude.
|
| 243 |
+
Consider:
|
| 244 |
+
- Their current mood and disposition
|
| 245 |
+
- What they know vs. don't know
|
| 246 |
+
- Their goals and motivations
|
| 247 |
+
- Their speech patterns and mannerisms
|
| 248 |
+
|
| 249 |
+
Respond with JSON:
|
| 250 |
+
{{
|
| 251 |
+
"dialogue": "What {npc_name} says in response",
|
| 252 |
+
"action": "Optional physical action or expression",
|
| 253 |
+
"attitude_change": null // or "more_friendly" / "more_hostile" if attitude shifts
|
| 254 |
+
}}"""
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def build_exploration_prompt(
|
| 258 |
+
action: str,
|
| 259 |
+
scene: Scene,
|
| 260 |
+
character: Character,
|
| 261 |
+
) -> str:
|
| 262 |
+
"""Build prompt for exploration and investigation actions."""
|
| 263 |
+
return f"""{GM_PERSONALITY}
|
| 264 |
+
|
| 265 |
+
The player is exploring and wants to: {action}
|
| 266 |
+
|
| 267 |
+
Current Scene:
|
| 268 |
+
{scene.to_context_string()}
|
| 269 |
+
|
| 270 |
+
Character:
|
| 271 |
+
{character.name} - {character.race} {character.character_class} (Level {character.level})
|
| 272 |
+
|
| 273 |
+
Describe what they discover or experience. If they're searching or investigating:
|
| 274 |
+
- Reward thoroughness with details
|
| 275 |
+
- Hide secrets behind appropriate skill checks
|
| 276 |
+
- Connect discoveries to the larger world/story
|
| 277 |
+
- Foreshadow dangers or opportunities
|
| 278 |
+
|
| 279 |
+
If no roll is needed, describe what they find directly. If a roll would reveal more, ask for one.
|
| 280 |
+
|
| 281 |
+
Respond with JSON:
|
| 282 |
+
{{
|
| 283 |
+
"decision": "auto_success" | "needs_roll",
|
| 284 |
+
"skill": "perception", // if needs_roll
|
| 285 |
+
"dc": 12, // if needs_roll
|
| 286 |
+
"narration": "What they discover or experience...",
|
| 287 |
+
"hidden_info": "Additional info revealed on success" // if needs_roll
|
| 288 |
+
}}"""
|
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# D&D AI Game Master - Python Dependencies
|
| 2 |
+
|
| 3 |
+
# Required for Hugging Face Inference API
|
| 4 |
+
huggingface_hub>=0.20.0
|
| 5 |
+
|
| 6 |
+
# Required for local model inference (optional)
|
| 7 |
+
transformers>=4.40.0
|
| 8 |
+
torch>=2.0.0
|
| 9 |
+
|
| 10 |
+
# Optional: for faster inference on NVIDIA GPUs
|
| 11 |
+
# accelerate>=0.27.0
|
| 12 |
+
|
| 13 |
+
# Optional: for quantized models (reduced memory)
|
| 14 |
+
# bitsandbytes>=0.42.0
|
| 15 |
+
|
| 16 |
+
# Optional: for Gradio web interface
|
| 17 |
+
# gradio>=4.0.0
|
|
@@ -0,0 +1,229 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""D&D Adventure scenario data and sequence generation.
|
| 2 |
+
|
| 3 |
+
This module provides:
|
| 4 |
+
- DND_ENCOUNTERS: Dictionary of encounter templates by type
|
| 5 |
+
- generate_dnd_sequence: Function to generate adventure sequences
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import random
|
| 10 |
+
|
| 11 |
+
# Encounter templates organized by type
|
| 12 |
+
DND_ENCOUNTERS = {
|
| 13 |
+
"combat": [
|
| 14 |
+
("Goblin Ambush", "A band of goblin raiders springs from hiding, blocking the path ahead", ["Goblin", "Goblin Boss"]),
|
| 15 |
+
("Wolf Pack Hunt", "Hungry wolves circle the party, eyes gleaming in the darkness", ["Wolf", "Dire Wolf"]),
|
| 16 |
+
("Orc War Party", "War drums echo as an orc raiding party charges forward", ["Orc", "Orc War Chief"]),
|
| 17 |
+
("Undead Rising", "The ground trembles as skeletal warriors claw their way to the surface", ["Skeleton", "Zombie"]),
|
| 18 |
+
("Bandit Roadblock", "Rough-looking bandits demand toll or blood", ["Bandit", "Bandit Captain"]),
|
| 19 |
+
("Giant Spider Lair", "Webs glisten in the torchlight as massive spiders descend", ["Giant Spider", "Phase Spider"]),
|
| 20 |
+
("Troll Bridge", "A massive troll guards the only crossing, demanding tribute", ["Troll"]),
|
| 21 |
+
("Dragon Wyrmling", "A young dragon defends its growing hoard with fierce determination", ["Dragon Wyrmling"]),
|
| 22 |
+
("Cultist Ritual", "Robed figures chant around a dark altar, summoning something terrible", ["Cultist", "Cult Fanatic"]),
|
| 23 |
+
("Elemental Fury", "An elemental creature of pure destructive force bars the way", ["Fire Elemental", "Earth Elemental"]),
|
| 24 |
+
],
|
| 25 |
+
"social": [
|
| 26 |
+
("Tavern Rumors", "Locals share whispered tales over ale in a dimly lit tavern", ["Commoner", "Noble"]),
|
| 27 |
+
("Noble's Request", "A wealthy patron offers gold for a dangerous task", ["Noble", "Knight"]),
|
| 28 |
+
("Merchant Dispute", "Two traders argue over goods, seeking an arbiter", ["Merchant", "Guard"]),
|
| 29 |
+
("Temple Audience", "A high priest offers guidance... for a price", ["Priest", "Acolyte"]),
|
| 30 |
+
("Guild Meeting", "The local guild has work, but demands loyalty first", ["Guild Master", "Apprentice"]),
|
| 31 |
+
("Royal Court", "The party gains audience with local nobility", ["King", "Advisor"]),
|
| 32 |
+
("Mysterious Stranger", "A hooded figure offers cryptic warnings and hidden knowledge", ["Spy", "Assassin"]),
|
| 33 |
+
("Festival Celebration", "The town celebrates, but something lurks beneath the merriment", ["Commoner", "Entertainer"]),
|
| 34 |
+
],
|
| 35 |
+
"exploration": [
|
| 36 |
+
("Ancient Ruins", "Crumbling stone walls hint at a civilization long forgotten", ["trap", "puzzle"]),
|
| 37 |
+
("Dark Cave", "The cave mouth yawns before you, promising secrets in the darkness", ["hazard", "treasure"]),
|
| 38 |
+
("Haunted Manor", "The abandoned estate creaks and groans with supernatural presence", ["ghost", "trap"]),
|
| 39 |
+
("Underground River", "A subterranean waterway offers passage deeper into the earth", ["hazard", "discovery"]),
|
| 40 |
+
("Forest Clearing", "Fey magic permeates this impossibly perfect glade", ["fey", "mystery"]),
|
| 41 |
+
("Mountain Pass", "Treacherous cliffs and howling winds test every step", ["hazard", "environmental"]),
|
| 42 |
+
("Sunken Temple", "Flooded chambers hide ancient treasures and guardians", ["trap", "treasure"]),
|
| 43 |
+
("Wizard's Tower", "Arcane energy crackles through abandoned magical laboratories", ["magic", "puzzle"]),
|
| 44 |
+
],
|
| 45 |
+
"puzzle": [
|
| 46 |
+
("The Riddle Door", "Ancient text poses a riddle - only the wise may pass", ["intelligence", "lore"]),
|
| 47 |
+
("Pressure Plates", "The floor is a grid of stone plates, some safe, some deadly", ["dexterity", "perception"]),
|
| 48 |
+
("Mirror Maze", "Reflections shift and change, hiding the true path", ["wisdom", "investigation"]),
|
| 49 |
+
("Elemental Locks", "Four elements must be combined in the correct order", ["arcana", "elements"]),
|
| 50 |
+
("Ancestor's Trial", "Spirits of the past judge worth through tests of virtue", ["charisma", "history"]),
|
| 51 |
+
("Clockwork Mechanism", "Gears and levers control something vital within", ["intelligence", "mechanics"]),
|
| 52 |
+
("Symbol Sequence", "Ancient symbols must be activated in the prophesied order", ["religion", "arcana"]),
|
| 53 |
+
("Weight Balance", "Treasures and counterweights must be perfectly balanced", ["wisdom", "mathematics"]),
|
| 54 |
+
],
|
| 55 |
+
"boss": [
|
| 56 |
+
("Dragon's Lair", "The great wyrm awakens, fire building in its throat", ["Adult Dragon"]),
|
| 57 |
+
("Lich's Sanctum", "The undead sorcerer rises from its throne of bones", ["Lich"]),
|
| 58 |
+
("Beholder's Den", "Multiple eyes swivel toward you, each crackling with deadly magic", ["Beholder"]),
|
| 59 |
+
("Giant King", "The massive ruler of giants hefts a weapon the size of a tree", ["Giant King"]),
|
| 60 |
+
("Vampire Lord", "The ancient vampire regards you with cold, eternal hunger", ["Vampire"]),
|
| 61 |
+
("Mind Flayer Colony", "Alien intellects probe your mind as tentacled horrors advance", ["Mind Flayer"]),
|
| 62 |
+
("Demon Portal", "A rift to the Abyss spews forth a demon of terrible power", ["Demon Lord"]),
|
| 63 |
+
("Corrupted Paladin", "Once a hero, now fallen to darkness and seeking to spread it", ["Death Knight"]),
|
| 64 |
+
],
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
# Difficulty modifiers
|
| 68 |
+
DIFFICULTY_LEVELS = {
|
| 69 |
+
"easy": {
|
| 70 |
+
"adjective": "manageable",
|
| 71 |
+
"enemies": "few",
|
| 72 |
+
"treasure": "modest",
|
| 73 |
+
},
|
| 74 |
+
"medium": {
|
| 75 |
+
"adjective": "challenging",
|
| 76 |
+
"enemies": "several",
|
| 77 |
+
"treasure": "valuable",
|
| 78 |
+
},
|
| 79 |
+
"hard": {
|
| 80 |
+
"adjective": "dangerous",
|
| 81 |
+
"enemies": "many",
|
| 82 |
+
"treasure": "significant",
|
| 83 |
+
},
|
| 84 |
+
"deadly": {
|
| 85 |
+
"adjective": "deadly",
|
| 86 |
+
"enemies": "overwhelming",
|
| 87 |
+
"treasure": "legendary",
|
| 88 |
+
},
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
# Location flavors
|
| 92 |
+
LOCATIONS = [
|
| 93 |
+
"dungeon depths", "ancient forest", "mountain fortress", "coastal caves",
|
| 94 |
+
"desert tomb", "frozen tundra", "volcanic lair", "feywild crossing",
|
| 95 |
+
"shadowfell edge", "planar rift", "underwater ruins", "floating citadel",
|
| 96 |
+
]
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def generate_dnd_sequence(encounter_count, difficulty, include_combat, include_social,
|
| 100 |
+
include_exploration, include_puzzle, include_boss,
|
| 101 |
+
force_boss_end=True):
|
| 102 |
+
"""Generate a D&D adventure sequence.
|
| 103 |
+
|
| 104 |
+
Args:
|
| 105 |
+
encounter_count: Number of encounters (3-10)
|
| 106 |
+
difficulty: One of 'easy', 'medium', 'hard', 'deadly'
|
| 107 |
+
include_combat: Include combat encounters
|
| 108 |
+
include_social: Include social encounters
|
| 109 |
+
include_exploration: Include exploration encounters
|
| 110 |
+
include_puzzle: Include puzzle encounters
|
| 111 |
+
include_boss: Include boss encounters
|
| 112 |
+
force_boss_end: Force sequence to end with a boss fight
|
| 113 |
+
|
| 114 |
+
Returns:
|
| 115 |
+
Tuple of (list_output, json_output, prompts_output)
|
| 116 |
+
"""
|
| 117 |
+
difficulty_info = DIFFICULTY_LEVELS.get(difficulty, DIFFICULTY_LEVELS["medium"])
|
| 118 |
+
location = random.choice(LOCATIONS)
|
| 119 |
+
|
| 120 |
+
# Build available encounters pool
|
| 121 |
+
available = []
|
| 122 |
+
if include_combat:
|
| 123 |
+
available.extend([(e[0], e[1], "combat", e[2]) for e in DND_ENCOUNTERS["combat"]])
|
| 124 |
+
if include_social:
|
| 125 |
+
available.extend([(e[0], e[1], "social", e[2]) for e in DND_ENCOUNTERS["social"]])
|
| 126 |
+
if include_exploration:
|
| 127 |
+
available.extend([(e[0], e[1], "exploration", e[2]) for e in DND_ENCOUNTERS["exploration"]])
|
| 128 |
+
if include_puzzle:
|
| 129 |
+
available.extend([(e[0], e[1], "puzzle", e[2]) for e in DND_ENCOUNTERS["puzzle"]])
|
| 130 |
+
|
| 131 |
+
if not available and not include_boss:
|
| 132 |
+
return "Select at least one encounter type!", "{}", ""
|
| 133 |
+
|
| 134 |
+
sequence = []
|
| 135 |
+
|
| 136 |
+
# Reserve boss for end if requested
|
| 137 |
+
boss_encounter = None
|
| 138 |
+
if force_boss_end and include_boss:
|
| 139 |
+
boss_encounter = random.choice(DND_ENCOUNTERS["boss"])
|
| 140 |
+
encounter_count -= 1
|
| 141 |
+
|
| 142 |
+
# Fill with random encounters
|
| 143 |
+
random.shuffle(available)
|
| 144 |
+
for enc in available[:encounter_count]:
|
| 145 |
+
sequence.append(enc)
|
| 146 |
+
|
| 147 |
+
# Add boss at end
|
| 148 |
+
if boss_encounter:
|
| 149 |
+
sequence.append((boss_encounter[0], boss_encounter[1], "boss", boss_encounter[2]))
|
| 150 |
+
|
| 151 |
+
if not sequence:
|
| 152 |
+
return "No encounters available!", "{}", ""
|
| 153 |
+
|
| 154 |
+
# Format as list
|
| 155 |
+
list_output = f"## D&D Adventure in the {location.title()}\n"
|
| 156 |
+
list_output += f"**Difficulty:** {difficulty.title()} ({difficulty_info['adjective']})\n\n"
|
| 157 |
+
|
| 158 |
+
for i, (name, desc, enc_type, extras) in enumerate(sequence, 1):
|
| 159 |
+
extras_str = ", ".join(extras) if isinstance(extras, list) else str(extras)
|
| 160 |
+
list_output += f"**{i}. {name}** [{enc_type}]\n{desc}\n*Features: {extras_str}*\n\n"
|
| 161 |
+
|
| 162 |
+
# Format as config JSON
|
| 163 |
+
config = {"adventure": {}}
|
| 164 |
+
for i, (name, desc, enc_type, extras) in enumerate(sequence, 1):
|
| 165 |
+
state_name = name.lower().replace(" ", "_").replace("'", "").replace("-", "_")
|
| 166 |
+
current_state_id = f"encounter_{i}_{state_name}"
|
| 167 |
+
|
| 168 |
+
extras_str = ", ".join(extras) if isinstance(extras, list) else str(extras)
|
| 169 |
+
|
| 170 |
+
# Determine next state
|
| 171 |
+
if i < len(sequence):
|
| 172 |
+
next_name = sequence[i][0]
|
| 173 |
+
next_state_name = next_name.lower().replace(" ", "_").replace("'", "").replace("-", "_")
|
| 174 |
+
next_state_id = f"encounter_{i+1}_{next_state_name}"
|
| 175 |
+
else:
|
| 176 |
+
next_state_id = "adventure_complete"
|
| 177 |
+
|
| 178 |
+
# Create choices based on encounter type
|
| 179 |
+
if enc_type == "combat":
|
| 180 |
+
choices = ["Fight", "Attempt Diplomacy", "Try to Sneak Past"]
|
| 181 |
+
elif enc_type == "social":
|
| 182 |
+
choices = ["Engage in Conversation", "Observe Silently", "Make an Offer"]
|
| 183 |
+
elif enc_type == "exploration":
|
| 184 |
+
choices = ["Investigate Carefully", "Rush Forward", "Search for Traps"]
|
| 185 |
+
elif enc_type == "puzzle":
|
| 186 |
+
choices = ["Study the Puzzle", "Try a Solution", "Look for Clues"]
|
| 187 |
+
elif enc_type == "boss":
|
| 188 |
+
choices = ["Prepare for Battle", "Attempt Negotiation", "Launch Surprise Attack"]
|
| 189 |
+
else:
|
| 190 |
+
choices = ["Continue"]
|
| 191 |
+
|
| 192 |
+
# All choices lead to same next state for simplicity
|
| 193 |
+
transitions = {choice: next_state_id for choice in choices}
|
| 194 |
+
|
| 195 |
+
config["adventure"][current_state_id] = {
|
| 196 |
+
"description": f"[{enc_type.upper()} - {difficulty.upper()}] {desc}\n\nYou notice: {extras_str}",
|
| 197 |
+
"media_prompt": f"Fantasy RPG scene in {location}: {name} - {desc}, {difficulty_info['adjective']} atmosphere, featuring {extras_str}",
|
| 198 |
+
"choices": choices,
|
| 199 |
+
"transitions": transitions
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
# Add completion state
|
| 203 |
+
first_enc = sequence[0][0].lower().replace(" ", "_").replace("'", "").replace("-", "_")
|
| 204 |
+
config["adventure"]["adventure_complete"] = {
|
| 205 |
+
"description": f"Victory! The {location} has been conquered. {difficulty_info['treasure'].title()} treasures await the brave adventurers.",
|
| 206 |
+
"choices": ["Embark on New Adventure"],
|
| 207 |
+
"transitions": {
|
| 208 |
+
"Embark on New Adventure": f"encounter_1_{first_enc}"
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
json_output = json.dumps(config, indent=2)
|
| 213 |
+
|
| 214 |
+
# Format prompts
|
| 215 |
+
prompts_output = f"## D&D Adventure Prompts ({location.title()})\n\n"
|
| 216 |
+
for i, (name, desc, enc_type, extras) in enumerate(sequence, 1):
|
| 217 |
+
extras_str = ", ".join(extras) if isinstance(extras, list) else str(extras)
|
| 218 |
+
prompts_output += f"Fantasy RPG scene: {name} - {desc}, featuring {extras_str}\n"
|
| 219 |
+
|
| 220 |
+
return list_output, json_output, prompts_output
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
# Quick test
|
| 224 |
+
if __name__ == "__main__":
|
| 225 |
+
list_out, json_out, prompts_out = generate_dnd_sequence(
|
| 226 |
+
5, "medium", True, True, True, False, True
|
| 227 |
+
)
|
| 228 |
+
print(list_out)
|
| 229 |
+
print(json_out)
|
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# HuggingFace Authentication Integration Notes
|
| 2 |
+
|
| 3 |
+
## Purpose
|
| 4 |
+
Allow users to save their Big RPG Scale entity databases to their own HuggingFace datasets (avoiding data collection concerns).
|
| 5 |
+
|
| 6 |
+
## Requirements
|
| 7 |
+
- Space-level OAuth configuration
|
| 8 |
+
- User authentication via `gr.LoginButton()`
|
| 9 |
+
- Save/load to user's own HF datasets
|
| 10 |
+
|
| 11 |
+
## README.md Changes Required
|
| 12 |
+
```yaml
|
| 13 |
+
---
|
| 14 |
+
title: GameConfigIdeaEditBrainstorm
|
| 15 |
+
hf_oauth: true
|
| 16 |
+
hf_oauth_scopes:
|
| 17 |
+
- read-repos
|
| 18 |
+
- write-repos
|
| 19 |
+
---
|
| 20 |
+
```
|
| 21 |
+
|
| 22 |
+
## Gradio OAuth Integration
|
| 23 |
+
|
| 24 |
+
### Login Button
|
| 25 |
+
```python
|
| 26 |
+
import gradio as gr
|
| 27 |
+
|
| 28 |
+
with gr.Blocks() as demo:
|
| 29 |
+
gr.LoginButton(variant="huggingface", size="sm")
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
### Access User Profile
|
| 33 |
+
```python
|
| 34 |
+
def my_function(profile: gr.OAuthProfile | None):
|
| 35 |
+
if profile:
|
| 36 |
+
username = profile.username
|
| 37 |
+
# Can now save to user's repos
|
| 38 |
+
else:
|
| 39 |
+
return "Please log in first"
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
### Access User Token
|
| 43 |
+
```python
|
| 44 |
+
def save_to_user_dataset(
|
| 45 |
+
profile: gr.OAuthProfile | None,
|
| 46 |
+
token: gr.OAuthToken | None,
|
| 47 |
+
data: str
|
| 48 |
+
):
|
| 49 |
+
if not profile or not token:
|
| 50 |
+
return "Login required"
|
| 51 |
+
|
| 52 |
+
# Use token.token for API calls
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
## Implementation Functions
|
| 56 |
+
|
| 57 |
+
### Save Database
|
| 58 |
+
```python
|
| 59 |
+
from huggingface_hub import HfApi, create_repo
|
| 60 |
+
|
| 61 |
+
def save_database_to_hf(
|
| 62 |
+
db: EntityDatabase,
|
| 63 |
+
profile: gr.OAuthProfile,
|
| 64 |
+
token: gr.OAuthToken,
|
| 65 |
+
dataset_name: str = "rpg-entity-database"
|
| 66 |
+
) -> str:
|
| 67 |
+
username = profile.username
|
| 68 |
+
repo_id = f"{username}/{dataset_name}"
|
| 69 |
+
|
| 70 |
+
api = HfApi(token=token.token)
|
| 71 |
+
|
| 72 |
+
# Create repo if needed
|
| 73 |
+
create_repo(repo_id, repo_type="dataset", exist_ok=True, private=True)
|
| 74 |
+
|
| 75 |
+
# Export and upload
|
| 76 |
+
db_json = db.to_json()
|
| 77 |
+
api.upload_file(
|
| 78 |
+
path_or_fileobj=db_json.encode('utf-8'),
|
| 79 |
+
path_in_repo="entity_database.json",
|
| 80 |
+
repo_id=repo_id,
|
| 81 |
+
repo_type="dataset"
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
return f"Saved to: https://huggingface.co/datasets/{repo_id}"
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
### Load Database
|
| 88 |
+
```python
|
| 89 |
+
from huggingface_hub import hf_hub_download
|
| 90 |
+
|
| 91 |
+
def load_database_from_hf(
|
| 92 |
+
db: EntityDatabase,
|
| 93 |
+
profile: gr.OAuthProfile,
|
| 94 |
+
token: gr.OAuthToken,
|
| 95 |
+
dataset_name: str = "rpg-entity-database"
|
| 96 |
+
) -> str:
|
| 97 |
+
username = profile.username
|
| 98 |
+
repo_id = f"{username}/{dataset_name}"
|
| 99 |
+
|
| 100 |
+
file_path = hf_hub_download(
|
| 101 |
+
repo_id=repo_id,
|
| 102 |
+
filename="entity_database.json",
|
| 103 |
+
repo_type="dataset",
|
| 104 |
+
token=token.token
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
with open(file_path, 'r') as f:
|
| 108 |
+
json_str = f.read()
|
| 109 |
+
|
| 110 |
+
success, message = db.from_json(json_str)
|
| 111 |
+
return message
|
| 112 |
+
```
|
| 113 |
+
|
| 114 |
+
## UI Integration
|
| 115 |
+
|
| 116 |
+
### Entity Database Tab Addition
|
| 117 |
+
```python
|
| 118 |
+
with gr.Row():
|
| 119 |
+
login_btn = gr.LoginButton(variant="huggingface", size="sm")
|
| 120 |
+
login_status = gr.Markdown("*Not logged in*")
|
| 121 |
+
|
| 122 |
+
with gr.Accordion("Cloud Save/Load (HuggingFace)", open=False):
|
| 123 |
+
gr.Markdown("Save your entity database to your HuggingFace account.")
|
| 124 |
+
|
| 125 |
+
hf_dataset_name = gr.Textbox(
|
| 126 |
+
label="Dataset Name",
|
| 127 |
+
value="rpg-entity-database"
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
with gr.Row():
|
| 131 |
+
save_to_hf_btn = gr.Button("Save to HuggingFace", variant="primary")
|
| 132 |
+
load_from_hf_btn = gr.Button("Load from HuggingFace")
|
| 133 |
+
|
| 134 |
+
hf_status = gr.Markdown("")
|
| 135 |
+
```
|
| 136 |
+
|
| 137 |
+
## Graceful Degradation
|
| 138 |
+
- All local features work without login
|
| 139 |
+
- Show `gr.Info("Please log in...")` when save/load attempted without auth
|
| 140 |
+
- Display current login status prominently
|
| 141 |
+
|
| 142 |
+
## Alternative: CommitScheduler (Auto-Save)
|
| 143 |
+
For session logging (like D&D GM event logs):
|
| 144 |
+
```python
|
| 145 |
+
from huggingface_hub import CommitScheduler
|
| 146 |
+
|
| 147 |
+
scheduler = CommitScheduler(
|
| 148 |
+
folder_path="./user_data",
|
| 149 |
+
repo_id=f"{username}/game-sessions",
|
| 150 |
+
token=token.token,
|
| 151 |
+
private=True
|
| 152 |
+
)
|
| 153 |
+
```
|
| 154 |
+
- Runs background thread, pushes every ~1 minute
|
| 155 |
+
- Good for append-only data (logs, feedback)
|
| 156 |
+
- Not ideal for frequently-updated files
|
| 157 |
+
|
| 158 |
+
## Resources
|
| 159 |
+
- [Gradio OAuth Docs](https://huggingface.co/docs/hub/spaces-oauth)
|
| 160 |
+
- [Gradio OAuth Example Space](https://huggingface.co/spaces/gradio/oauth-example)
|
| 161 |
+
- [HuggingFace Hub Upload Guide](https://huggingface.co/docs/huggingface_hub/guides/upload)
|
| 162 |
+
- [Spaces Persistent Storage](https://huggingface.co/docs/hub/spaces-storage)
|
| 163 |
+
|
| 164 |
+
## TODO
|
| 165 |
+
- [ ] Test OAuth flow locally with `huggingface-cli login`
|
| 166 |
+
- [ ] Decide on dataset structure (single JSON vs multiple files)
|
| 167 |
+
- [ ] Consider versioning/backup strategy
|
| 168 |
+
- [ ] Investigate Space-level persistent storage options
|
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Game Config IDE - Platform Exporters Package.
|
| 2 |
+
|
| 3 |
+
This package provides exporters for converting game configs to various
|
| 4 |
+
platforms and engines.
|
| 5 |
+
|
| 6 |
+
Supported platforms:
|
| 7 |
+
- Game Engines: GameMaker, Flutter
|
| 8 |
+
- Narrative Engines: Twine, Ren'Py, Ink, Yarn Spinner
|
| 9 |
+
- Game Platforms: Roblox, RPG Maker MZ, Minecraft, Papyrus (Skyrim/Fallout), VRChat
|
| 10 |
+
- AR/XR Platforms: Snap Lens Studio, Meta Spark, 8th Wall, TikTok Effect House, Apple Reality
|
| 11 |
+
- Interactive Fiction: Inform 7, Godot Dialogue Manager
|
| 12 |
+
- Playable HTML: 2D Map, MovingDotSpace
|
| 13 |
+
- Terminal: Python Terminal Game
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
# Individual exporters
|
| 17 |
+
from .gamemaker_exporter import export_to_gamemaker
|
| 18 |
+
from .flutter_exporter import export_to_flutter
|
| 19 |
+
from .twine_exporter import export_to_twine
|
| 20 |
+
from .renpy_exporter import export_to_renpy
|
| 21 |
+
from .ink_exporter import export_to_ink
|
| 22 |
+
from .yarn_exporter import export_to_yarn
|
| 23 |
+
from .roblox_exporter import export_to_roblox
|
| 24 |
+
from .rpgmaker_exporter import export_to_rpgmaker
|
| 25 |
+
from .terminal_exporter import export_to_terminal
|
| 26 |
+
|
| 27 |
+
# AR/XR exporters
|
| 28 |
+
from .ar_exporters import (
|
| 29 |
+
export_to_lensstudio,
|
| 30 |
+
export_to_metaspark,
|
| 31 |
+
export_to_8thwall,
|
| 32 |
+
export_to_tiktok,
|
| 33 |
+
export_to_reality
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# Game platform exporters
|
| 37 |
+
from .game_platform_exporters import (
|
| 38 |
+
export_to_minecraft,
|
| 39 |
+
export_to_papyrus,
|
| 40 |
+
export_to_vrchat
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# Interactive fiction exporters
|
| 44 |
+
from .fiction_exporters import (
|
| 45 |
+
export_to_inform7,
|
| 46 |
+
export_to_godot_dialogue
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
# HTML exporters
|
| 50 |
+
from .html_exporters import (
|
| 51 |
+
export_to_2d_map,
|
| 52 |
+
export_to_movingdotspace
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
# PlayCanvas exporter (from root level)
|
| 56 |
+
try:
|
| 57 |
+
from playcanvas_exporter import export_to_playcanvas, export_to_playcanvas_html
|
| 58 |
+
except ImportError:
|
| 59 |
+
export_to_playcanvas = None
|
| 60 |
+
export_to_playcanvas_html = None
|
| 61 |
+
|
| 62 |
+
# Router
|
| 63 |
+
from .router import ConfigConversionforExporttoPlatform
|
| 64 |
+
|
| 65 |
+
__all__ = [
|
| 66 |
+
# Individual exporters
|
| 67 |
+
'export_to_gamemaker',
|
| 68 |
+
'export_to_flutter',
|
| 69 |
+
'export_to_twine',
|
| 70 |
+
'export_to_renpy',
|
| 71 |
+
'export_to_ink',
|
| 72 |
+
'export_to_yarn',
|
| 73 |
+
'export_to_roblox',
|
| 74 |
+
'export_to_rpgmaker',
|
| 75 |
+
'export_to_terminal',
|
| 76 |
+
# AR/XR
|
| 77 |
+
'export_to_lensstudio',
|
| 78 |
+
'export_to_metaspark',
|
| 79 |
+
'export_to_8thwall',
|
| 80 |
+
'export_to_tiktok',
|
| 81 |
+
'export_to_reality',
|
| 82 |
+
# Game platforms
|
| 83 |
+
'export_to_minecraft',
|
| 84 |
+
'export_to_papyrus',
|
| 85 |
+
'export_to_vrchat',
|
| 86 |
+
# Fiction
|
| 87 |
+
'export_to_inform7',
|
| 88 |
+
'export_to_godot_dialogue',
|
| 89 |
+
# HTML
|
| 90 |
+
'export_to_2d_map',
|
| 91 |
+
'export_to_movingdotspace',
|
| 92 |
+
# PlayCanvas
|
| 93 |
+
'export_to_playcanvas',
|
| 94 |
+
'export_to_playcanvas_html',
|
| 95 |
+
# Router
|
| 96 |
+
'ConfigConversionforExporttoPlatform',
|
| 97 |
+
]
|
|
@@ -0,0 +1,843 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AR/XR platform exporters for game configs.
|
| 2 |
+
|
| 3 |
+
Includes exporters for:
|
| 4 |
+
- Snap Lens Studio
|
| 5 |
+
- Meta Spark AR (Instagram/Facebook)
|
| 6 |
+
- 8th Wall WebAR
|
| 7 |
+
- TikTok Effect House
|
| 8 |
+
- Apple Reality Composer / visionOS
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def export_to_lensstudio(config_json):
|
| 15 |
+
"""Export game config to Snap Lens Studio JavaScript format."""
|
| 16 |
+
try:
|
| 17 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 18 |
+
except json.JSONDecodeError as e:
|
| 19 |
+
return f"JSON Error: {str(e)}", ""
|
| 20 |
+
|
| 21 |
+
js_code = '''// Snap Lens Studio - Interactive Story Experience
|
| 22 |
+
// Generated from Game Config IDE
|
| 23 |
+
//
|
| 24 |
+
// Setup in Lens Studio:
|
| 25 |
+
// 1. Create a Script component
|
| 26 |
+
// 2. Add @input bindings for UI elements
|
| 27 |
+
// 3. Paste this code
|
| 28 |
+
|
| 29 |
+
// @input Component.Text descriptionText {"label":"Description Text"}
|
| 30 |
+
// @input Component.ScriptComponent[] choiceButtons {"label":"Choice Buttons"}
|
| 31 |
+
// @input Asset.AudioTrackAsset[] audioTracks {"label":"Audio Tracks"}
|
| 32 |
+
|
| 33 |
+
// Story States Configuration
|
| 34 |
+
var STORY_STATES = {
|
| 35 |
+
'''
|
| 36 |
+
|
| 37 |
+
first_state = None
|
| 38 |
+
|
| 39 |
+
# Generate states
|
| 40 |
+
for location_key, location_data in config.items():
|
| 41 |
+
if isinstance(location_data, dict):
|
| 42 |
+
for state_key, state_data in location_data.items():
|
| 43 |
+
if isinstance(state_data, dict):
|
| 44 |
+
state_name = f"{location_key}_{state_key}"
|
| 45 |
+
if first_state is None:
|
| 46 |
+
first_state = state_name
|
| 47 |
+
|
| 48 |
+
description = state_data.get('description', 'No description').replace('"', '\\"').replace('\n', '\\n')
|
| 49 |
+
choices = state_data.get('choices', [])
|
| 50 |
+
transitions = state_data.get('transitions', {})
|
| 51 |
+
media = state_data.get('media', {})
|
| 52 |
+
|
| 53 |
+
js_code += f' "{state_name}": {{\n'
|
| 54 |
+
js_code += f' description: "{description}",\n'
|
| 55 |
+
js_code += f' location: "{location_key}",\n'
|
| 56 |
+
|
| 57 |
+
# Choices
|
| 58 |
+
js_code += ' choices: ['
|
| 59 |
+
if choices:
|
| 60 |
+
js_code += ', '.join([f'"{c}"' for c in choices])
|
| 61 |
+
js_code += '],\n'
|
| 62 |
+
|
| 63 |
+
# Transitions
|
| 64 |
+
js_code += ' transitions: {\n'
|
| 65 |
+
if isinstance(transitions, dict):
|
| 66 |
+
for choice, next_state in transitions.items():
|
| 67 |
+
if next_state:
|
| 68 |
+
if '_' not in next_state:
|
| 69 |
+
next_state = f"{location_key}_{next_state}"
|
| 70 |
+
js_code += f' "{choice}": "{next_state}",\n'
|
| 71 |
+
js_code += ' },\n'
|
| 72 |
+
|
| 73 |
+
# Media hints
|
| 74 |
+
if media.get('image') or media.get('audio'):
|
| 75 |
+
js_code += ' media: {\n'
|
| 76 |
+
if media.get('image'):
|
| 77 |
+
js_code += f' image: "{media["image"]}",\n'
|
| 78 |
+
if media.get('audio'):
|
| 79 |
+
js_code += f' audio: "{media["audio"]}",\n'
|
| 80 |
+
js_code += ' },\n'
|
| 81 |
+
|
| 82 |
+
js_code += ' },\n'
|
| 83 |
+
|
| 84 |
+
js_code += f'''}};
|
| 85 |
+
|
| 86 |
+
var currentState = "{first_state or "start"}";
|
| 87 |
+
|
| 88 |
+
// Initialize the story
|
| 89 |
+
function initialize() {{
|
| 90 |
+
showState(currentState);
|
| 91 |
+
}}
|
| 92 |
+
|
| 93 |
+
// Display current state
|
| 94 |
+
function showState(stateName) {{
|
| 95 |
+
var state = STORY_STATES[stateName];
|
| 96 |
+
if (!state) {{
|
| 97 |
+
print("State not found: " + stateName);
|
| 98 |
+
return;
|
| 99 |
+
}}
|
| 100 |
+
|
| 101 |
+
// Update description text
|
| 102 |
+
if (script.descriptionText) {{
|
| 103 |
+
script.descriptionText.text = state.description;
|
| 104 |
+
}}
|
| 105 |
+
|
| 106 |
+
// Update choice buttons
|
| 107 |
+
for (var i = 0; i < script.choiceButtons.length; i++) {{
|
| 108 |
+
var btn = script.choiceButtons[i];
|
| 109 |
+
if (i < state.choices.length) {{
|
| 110 |
+
btn.getSceneObject().enabled = true;
|
| 111 |
+
// Assuming button has a text child
|
| 112 |
+
var textComponent = btn.getSceneObject().getComponent("Component.Text");
|
| 113 |
+
if (textComponent) {{
|
| 114 |
+
textComponent.text = state.choices[i];
|
| 115 |
+
}}
|
| 116 |
+
}} else {{
|
| 117 |
+
btn.getSceneObject().enabled = false;
|
| 118 |
+
}}
|
| 119 |
+
}}
|
| 120 |
+
|
| 121 |
+
currentState = stateName;
|
| 122 |
+
print("Now at state: " + stateName);
|
| 123 |
+
}}
|
| 124 |
+
|
| 125 |
+
// Handle choice selection (call this from button tap events)
|
| 126 |
+
function makeChoice(choiceIndex) {{
|
| 127 |
+
var state = STORY_STATES[currentState];
|
| 128 |
+
if (!state || choiceIndex >= state.choices.length) return;
|
| 129 |
+
|
| 130 |
+
var choiceText = state.choices[choiceIndex];
|
| 131 |
+
var nextState = state.transitions[choiceText];
|
| 132 |
+
|
| 133 |
+
if (nextState) {{
|
| 134 |
+
showState(nextState);
|
| 135 |
+
}} else {{
|
| 136 |
+
print("No transition for choice: " + choiceText);
|
| 137 |
+
}}
|
| 138 |
+
}}
|
| 139 |
+
|
| 140 |
+
// Reset to beginning
|
| 141 |
+
function resetStory() {{
|
| 142 |
+
showState("{first_state or "start"}");
|
| 143 |
+
}}
|
| 144 |
+
|
| 145 |
+
// Export functions for external use
|
| 146 |
+
script.api.makeChoice = makeChoice;
|
| 147 |
+
script.api.resetStory = resetStory;
|
| 148 |
+
script.api.getCurrentState = function() {{ return currentState; }};
|
| 149 |
+
|
| 150 |
+
// Auto-initialize on start
|
| 151 |
+
var onStartEvent = script.createEvent("OnStartEvent");
|
| 152 |
+
onStartEvent.bind(initialize);
|
| 153 |
+
'''
|
| 154 |
+
|
| 155 |
+
explanation = """Snap Lens Studio Export Generated!
|
| 156 |
+
|
| 157 |
+
Setup Instructions:
|
| 158 |
+
1. Download Lens Studio: https://lensstudio.snapchat.com/
|
| 159 |
+
2. Create new project (World or Face lens)
|
| 160 |
+
3. Add Script component to scene
|
| 161 |
+
4. Paste this code
|
| 162 |
+
5. Set up @input bindings in Inspector
|
| 163 |
+
|
| 164 |
+
Required Scene Setup:
|
| 165 |
+
- Text component for description (link to descriptionText)
|
| 166 |
+
- Button objects with tap handlers (link to choiceButtons array)
|
| 167 |
+
- Call script.api.makeChoice(0), makeChoice(1), etc. from buttons
|
| 168 |
+
|
| 169 |
+
Button Tap Setup:
|
| 170 |
+
1. Select button object
|
| 171 |
+
2. Add "Interaction Component"
|
| 172 |
+
3. On Tap event -> call Script.makeChoice(index)
|
| 173 |
+
|
| 174 |
+
Testing:
|
| 175 |
+
- Use Preview panel in Lens Studio
|
| 176 |
+
- Send to Snapchat for device testing
|
| 177 |
+
- Use Lens Studio's built-in simulator
|
| 178 |
+
|
| 179 |
+
Publishing:
|
| 180 |
+
- Submit via My Lenses portal
|
| 181 |
+
- Review typically takes 1-3 days
|
| 182 |
+
- 300M+ daily Snapchat AR users
|
| 183 |
+
|
| 184 |
+
Tips:
|
| 185 |
+
- Use World Tracking for immersive stories
|
| 186 |
+
- Add 3D objects that appear with state changes
|
| 187 |
+
- Use audio for atmosphere
|
| 188 |
+
"""
|
| 189 |
+
|
| 190 |
+
return explanation, js_code
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def export_to_metaspark(config_json):
|
| 194 |
+
"""Export game config to Meta Spark AR (Instagram/Facebook) format."""
|
| 195 |
+
try:
|
| 196 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 197 |
+
except json.JSONDecodeError as e:
|
| 198 |
+
return f"JSON Error: {str(e)}", ""
|
| 199 |
+
|
| 200 |
+
js_code = '''// Meta Spark AR - Interactive Story Effect
|
| 201 |
+
// Generated from Game Config IDE
|
| 202 |
+
//
|
| 203 |
+
// Setup in Meta Spark Studio:
|
| 204 |
+
// 1. Create new effect project
|
| 205 |
+
// 2. Add Script to Assets
|
| 206 |
+
// 3. Paste this code and connect to scene objects
|
| 207 |
+
|
| 208 |
+
const Scene = require('Scene');
|
| 209 |
+
const Diagnostics = require('Diagnostics');
|
| 210 |
+
const Patches = require('Patches');
|
| 211 |
+
const Time = require('Time');
|
| 212 |
+
const Reactive = require('Reactive');
|
| 213 |
+
|
| 214 |
+
// Story States Configuration
|
| 215 |
+
const STORY_STATES = {
|
| 216 |
+
'''
|
| 217 |
+
|
| 218 |
+
first_state = None
|
| 219 |
+
|
| 220 |
+
# Generate states
|
| 221 |
+
for location_key, location_data in config.items():
|
| 222 |
+
if isinstance(location_data, dict):
|
| 223 |
+
for state_key, state_data in location_data.items():
|
| 224 |
+
if isinstance(state_data, dict):
|
| 225 |
+
state_name = f"{location_key}_{state_key}"
|
| 226 |
+
if first_state is None:
|
| 227 |
+
first_state = state_name
|
| 228 |
+
|
| 229 |
+
description = state_data.get('description', 'No description').replace('"', '\\"').replace('\n', '\\n')
|
| 230 |
+
choices = state_data.get('choices', [])
|
| 231 |
+
transitions = state_data.get('transitions', {})
|
| 232 |
+
|
| 233 |
+
js_code += f' "{state_name}": {{\n'
|
| 234 |
+
js_code += f' description: "{description}",\n'
|
| 235 |
+
js_code += f' location: "{location_key}",\n'
|
| 236 |
+
js_code += ' choices: ['
|
| 237 |
+
if choices:
|
| 238 |
+
js_code += ', '.join([f'"{c}"' for c in choices])
|
| 239 |
+
js_code += '],\n'
|
| 240 |
+
js_code += ' transitions: {\n'
|
| 241 |
+
if isinstance(transitions, dict):
|
| 242 |
+
for choice, next_state in transitions.items():
|
| 243 |
+
if next_state:
|
| 244 |
+
if '_' not in next_state:
|
| 245 |
+
next_state = f"{location_key}_{next_state}"
|
| 246 |
+
js_code += f' "{choice}": "{next_state}",\n'
|
| 247 |
+
js_code += ' }\n'
|
| 248 |
+
js_code += ' },\n'
|
| 249 |
+
|
| 250 |
+
js_code += f'''}};
|
| 251 |
+
|
| 252 |
+
let currentState = "{first_state or "start"}";
|
| 253 |
+
|
| 254 |
+
(async function() {{
|
| 255 |
+
// Find scene objects (adjust names to match your scene)
|
| 256 |
+
const [descriptionText, choice1Btn, choice2Btn, choice3Btn] = await Promise.all([
|
| 257 |
+
Scene.root.findFirst('descriptionText'),
|
| 258 |
+
Scene.root.findFirst('choice1Button'),
|
| 259 |
+
Scene.root.findFirst('choice2Button'),
|
| 260 |
+
Scene.root.findFirst('choice3Button')
|
| 261 |
+
]);
|
| 262 |
+
|
| 263 |
+
const choiceButtons = [choice1Btn, choice2Btn, choice3Btn].filter(b => b);
|
| 264 |
+
|
| 265 |
+
// Update UI with current state
|
| 266 |
+
function showState(stateName) {{
|
| 267 |
+
const state = STORY_STATES[stateName];
|
| 268 |
+
if (!state) {{
|
| 269 |
+
Diagnostics.log("State not found: " + stateName);
|
| 270 |
+
return;
|
| 271 |
+
}}
|
| 272 |
+
|
| 273 |
+
// Update description
|
| 274 |
+
if (descriptionText) {{
|
| 275 |
+
descriptionText.text = state.description;
|
| 276 |
+
}}
|
| 277 |
+
|
| 278 |
+
// Update choice buttons
|
| 279 |
+
choiceButtons.forEach((btn, i) => {{
|
| 280 |
+
if (btn) {{
|
| 281 |
+
const hasChoice = i < state.choices.length;
|
| 282 |
+
btn.hidden = Reactive.val(!hasChoice);
|
| 283 |
+
if (hasChoice && btn.findFirst) {{
|
| 284 |
+
btn.findFirst('text').then(t => {{
|
| 285 |
+
if (t) t.text = state.choices[i];
|
| 286 |
+
}});
|
| 287 |
+
}}
|
| 288 |
+
}}
|
| 289 |
+
}});
|
| 290 |
+
|
| 291 |
+
currentState = stateName;
|
| 292 |
+
Diagnostics.log("State: " + stateName);
|
| 293 |
+
|
| 294 |
+
// Send to Patch Editor
|
| 295 |
+
Patches.inputs.setString('currentState', stateName);
|
| 296 |
+
Patches.inputs.setString('description', state.description);
|
| 297 |
+
}}
|
| 298 |
+
|
| 299 |
+
// Handle choice (connect via Patch Editor pulse inputs)
|
| 300 |
+
async function setupChoiceHandlers() {{
|
| 301 |
+
try {{
|
| 302 |
+
const choice1Pulse = await Patches.outputs.getPulse('choice1Selected');
|
| 303 |
+
const choice2Pulse = await Patches.outputs.getPulse('choice2Selected');
|
| 304 |
+
const choice3Pulse = await Patches.outputs.getPulse('choice3Selected');
|
| 305 |
+
|
| 306 |
+
choice1Pulse.subscribe(() => makeChoice(0));
|
| 307 |
+
choice2Pulse.subscribe(() => makeChoice(1));
|
| 308 |
+
choice3Pulse.subscribe(() => makeChoice(2));
|
| 309 |
+
}} catch(e) {{
|
| 310 |
+
Diagnostics.log("Patch outputs not connected: " + e);
|
| 311 |
+
}}
|
| 312 |
+
}}
|
| 313 |
+
|
| 314 |
+
function makeChoice(index) {{
|
| 315 |
+
const state = STORY_STATES[currentState];
|
| 316 |
+
if (!state || index >= state.choices.length) return;
|
| 317 |
+
|
| 318 |
+
const choiceText = state.choices[index];
|
| 319 |
+
const nextState = state.transitions[choiceText];
|
| 320 |
+
|
| 321 |
+
if (nextState) {{
|
| 322 |
+
showState(nextState);
|
| 323 |
+
}}
|
| 324 |
+
}}
|
| 325 |
+
|
| 326 |
+
// Initialize
|
| 327 |
+
showState(currentState);
|
| 328 |
+
setupChoiceHandlers();
|
| 329 |
+
|
| 330 |
+
}})();
|
| 331 |
+
'''
|
| 332 |
+
|
| 333 |
+
explanation = """Meta Spark AR Export Generated!
|
| 334 |
+
|
| 335 |
+
Setup Instructions:
|
| 336 |
+
1. Download Meta Spark Studio: https://spark.meta.com/
|
| 337 |
+
2. Create new project (Target Tracker, World, or Face effect)
|
| 338 |
+
3. Add Script asset and paste this code
|
| 339 |
+
4. Create UI elements and name them to match script
|
| 340 |
+
|
| 341 |
+
Scene Object Names:
|
| 342 |
+
- descriptionText: Text object for story description
|
| 343 |
+
- choice1Button, choice2Button, choice3Button: Button objects
|
| 344 |
+
|
| 345 |
+
Patch Editor Integration:
|
| 346 |
+
1. Open Patch Editor
|
| 347 |
+
2. Create pulse outputs: choice1Selected, choice2Selected, choice3Selected
|
| 348 |
+
3. Connect tap events to these pulses
|
| 349 |
+
4. Script outputs: currentState, description (string)
|
| 350 |
+
|
| 351 |
+
Testing:
|
| 352 |
+
- Use device preview in Meta Spark
|
| 353 |
+
- Send to Instagram app for testing
|
| 354 |
+
- Test on both iOS and Android
|
| 355 |
+
|
| 356 |
+
Publishing:
|
| 357 |
+
- Submit via Meta Spark Hub
|
| 358 |
+
- Publish to Instagram and/or Facebook
|
| 359 |
+
- 600M+ Instagram AR filter users
|
| 360 |
+
|
| 361 |
+
Tips:
|
| 362 |
+
- Use Face Tracker for selfie-style stories
|
| 363 |
+
- Use World Effects for immersive AR
|
| 364 |
+
- Add 3D assets that change with states
|
| 365 |
+
"""
|
| 366 |
+
|
| 367 |
+
return explanation, js_code
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def export_to_8thwall(config_json):
|
| 371 |
+
"""Export game config to 8th Wall WebAR (A-Frame) format."""
|
| 372 |
+
try:
|
| 373 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 374 |
+
except json.JSONDecodeError as e:
|
| 375 |
+
return f"JSON Error: {str(e)}", ""
|
| 376 |
+
|
| 377 |
+
html_code = '''<!DOCTYPE html>
|
| 378 |
+
<html>
|
| 379 |
+
<head>
|
| 380 |
+
<meta charset="utf-8">
|
| 381 |
+
<title>AR Story Experience</title>
|
| 382 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 383 |
+
|
| 384 |
+
<!-- 8th Wall Web SDK (replace YOUR_APP_KEY) -->
|
| 385 |
+
<script src="https://apps.8thwall.com/xrweb?appKey=YOUR_APP_KEY"></script>
|
| 386 |
+
|
| 387 |
+
<!-- A-Frame -->
|
| 388 |
+
<script src="https://aframe.io/releases/1.4.0/aframe.min.js"></script>
|
| 389 |
+
<script src="https://cdn.8thwall.com/web/aframe/8frame-1.4.0.min.js"></script>
|
| 390 |
+
|
| 391 |
+
<style>
|
| 392 |
+
#story-ui {
|
| 393 |
+
position: fixed;
|
| 394 |
+
bottom: 20px;
|
| 395 |
+
left: 50%;
|
| 396 |
+
transform: translateX(-50%);
|
| 397 |
+
width: 90%;
|
| 398 |
+
max-width: 400px;
|
| 399 |
+
background: rgba(0,0,0,0.8);
|
| 400 |
+
border-radius: 15px;
|
| 401 |
+
padding: 20px;
|
| 402 |
+
color: white;
|
| 403 |
+
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
|
| 404 |
+
z-index: 1000;
|
| 405 |
+
}
|
| 406 |
+
#story-description {
|
| 407 |
+
font-size: 16px;
|
| 408 |
+
margin-bottom: 15px;
|
| 409 |
+
line-height: 1.4;
|
| 410 |
+
}
|
| 411 |
+
.choice-btn {
|
| 412 |
+
display: block;
|
| 413 |
+
width: 100%;
|
| 414 |
+
padding: 12px;
|
| 415 |
+
margin: 8px 0;
|
| 416 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 417 |
+
border: none;
|
| 418 |
+
border-radius: 10px;
|
| 419 |
+
color: white;
|
| 420 |
+
font-size: 14px;
|
| 421 |
+
cursor: pointer;
|
| 422 |
+
transition: transform 0.2s;
|
| 423 |
+
}
|
| 424 |
+
.choice-btn:hover {
|
| 425 |
+
transform: scale(1.02);
|
| 426 |
+
}
|
| 427 |
+
.choice-btn:active {
|
| 428 |
+
transform: scale(0.98);
|
| 429 |
+
}
|
| 430 |
+
.hidden { display: none; }
|
| 431 |
+
</style>
|
| 432 |
+
</head>
|
| 433 |
+
<body>
|
| 434 |
+
<!-- A-Frame Scene -->
|
| 435 |
+
<a-scene
|
| 436 |
+
xrextras-gesture-detector
|
| 437 |
+
xrextras-almost-there
|
| 438 |
+
xrextras-loading
|
| 439 |
+
xrextras-runtime-error
|
| 440 |
+
renderer="colorManagement: true"
|
| 441 |
+
xrweb="disableWorldTracking: false">
|
| 442 |
+
|
| 443 |
+
<!-- Camera -->
|
| 444 |
+
<a-camera id="camera" position="0 1.6 0"></a-camera>
|
| 445 |
+
|
| 446 |
+
<!-- Story 3D Elements (add your own) -->
|
| 447 |
+
<a-entity id="story-3d-content" position="0 0 -3">
|
| 448 |
+
<a-text id="ar-title" value="AR Story" position="0 2 0" align="center" color="#FFF" width="4"></a-text>
|
| 449 |
+
</a-entity>
|
| 450 |
+
|
| 451 |
+
</a-scene>
|
| 452 |
+
|
| 453 |
+
<!-- Story UI Overlay -->
|
| 454 |
+
<div id="story-ui">
|
| 455 |
+
<div id="story-description">Loading story...</div>
|
| 456 |
+
<div id="choices-container"></div>
|
| 457 |
+
</div>
|
| 458 |
+
|
| 459 |
+
<script>
|
| 460 |
+
// Story States Configuration
|
| 461 |
+
const STORY_STATES = {
|
| 462 |
+
'''
|
| 463 |
+
|
| 464 |
+
first_state = None
|
| 465 |
+
|
| 466 |
+
# Generate states
|
| 467 |
+
for location_key, location_data in config.items():
|
| 468 |
+
if isinstance(location_data, dict):
|
| 469 |
+
for state_key, state_data in location_data.items():
|
| 470 |
+
if isinstance(state_data, dict):
|
| 471 |
+
state_name = f"{location_key}_{state_key}"
|
| 472 |
+
if first_state is None:
|
| 473 |
+
first_state = state_name
|
| 474 |
+
|
| 475 |
+
description = state_data.get('description', 'No description').replace('"', '\\"').replace('\n', '\\n')
|
| 476 |
+
choices = state_data.get('choices', [])
|
| 477 |
+
transitions = state_data.get('transitions', {})
|
| 478 |
+
|
| 479 |
+
html_code += f' "{state_name}": {{\n'
|
| 480 |
+
html_code += f' description: "{description}",\n'
|
| 481 |
+
html_code += f' location: "{location_key}",\n'
|
| 482 |
+
html_code += ' choices: ['
|
| 483 |
+
if choices:
|
| 484 |
+
html_code += ', '.join([f'"{c}"' for c in choices])
|
| 485 |
+
html_code += '],\n'
|
| 486 |
+
html_code += ' transitions: {\n'
|
| 487 |
+
if isinstance(transitions, dict):
|
| 488 |
+
for choice, next_state in transitions.items():
|
| 489 |
+
if next_state:
|
| 490 |
+
if '_' not in next_state:
|
| 491 |
+
next_state = f"{location_key}_{next_state}"
|
| 492 |
+
html_code += f' "{choice}": "{next_state}",\n'
|
| 493 |
+
html_code += ' }\n'
|
| 494 |
+
html_code += ' },\n'
|
| 495 |
+
|
| 496 |
+
html_code += f''' }};
|
| 497 |
+
|
| 498 |
+
let currentState = "{first_state or "start"}";
|
| 499 |
+
|
| 500 |
+
// DOM Elements
|
| 501 |
+
const descriptionEl = document.getElementById('story-description');
|
| 502 |
+
const choicesContainer = document.getElementById('choices-container');
|
| 503 |
+
const arTitle = document.getElementById('ar-title');
|
| 504 |
+
|
| 505 |
+
// Show state
|
| 506 |
+
function showState(stateName) {{
|
| 507 |
+
const state = STORY_STATES[stateName];
|
| 508 |
+
if (!state) {{
|
| 509 |
+
console.error("State not found:", stateName);
|
| 510 |
+
return;
|
| 511 |
+
}}
|
| 512 |
+
|
| 513 |
+
// Update UI
|
| 514 |
+
descriptionEl.textContent = state.description;
|
| 515 |
+
|
| 516 |
+
// Clear and create choice buttons
|
| 517 |
+
choicesContainer.innerHTML = '';
|
| 518 |
+
state.choices.forEach((choice, index) => {{
|
| 519 |
+
const btn = document.createElement('button');
|
| 520 |
+
btn.className = 'choice-btn';
|
| 521 |
+
btn.textContent = choice;
|
| 522 |
+
btn.onclick = () => makeChoice(index);
|
| 523 |
+
choicesContainer.appendChild(btn);
|
| 524 |
+
}});
|
| 525 |
+
|
| 526 |
+
// Update AR content (customize this!)
|
| 527 |
+
if (arTitle) {{
|
| 528 |
+
arTitle.setAttribute('value', state.location);
|
| 529 |
+
}}
|
| 530 |
+
|
| 531 |
+
currentState = stateName;
|
| 532 |
+
console.log("State:", stateName);
|
| 533 |
+
}}
|
| 534 |
+
|
| 535 |
+
// Handle choice
|
| 536 |
+
function makeChoice(index) {{
|
| 537 |
+
const state = STORY_STATES[currentState];
|
| 538 |
+
if (!state || index >= state.choices.length) return;
|
| 539 |
+
|
| 540 |
+
const choiceText = state.choices[index];
|
| 541 |
+
const nextState = state.transitions[choiceText];
|
| 542 |
+
|
| 543 |
+
if (nextState) {{
|
| 544 |
+
showState(nextState);
|
| 545 |
+
}}
|
| 546 |
+
}}
|
| 547 |
+
|
| 548 |
+
// Initialize when scene loads
|
| 549 |
+
document.querySelector('a-scene').addEventListener('loaded', () => {{
|
| 550 |
+
showState(currentState);
|
| 551 |
+
}});
|
| 552 |
+
</script>
|
| 553 |
+
</body>
|
| 554 |
+
</html>
|
| 555 |
+
'''
|
| 556 |
+
|
| 557 |
+
explanation = """8th Wall WebAR Export Generated!
|
| 558 |
+
|
| 559 |
+
Setup Instructions:
|
| 560 |
+
1. Create account at https://www.8thwall.com/
|
| 561 |
+
2. Create new Web project
|
| 562 |
+
3. Get your App Key from dashboard
|
| 563 |
+
4. Replace YOUR_APP_KEY in the HTML
|
| 564 |
+
5. Upload to 8th Wall hosting or your own server
|
| 565 |
+
|
| 566 |
+
Key Features:
|
| 567 |
+
- Works in mobile browser (no app needed!)
|
| 568 |
+
- A-Frame for 3D content
|
| 569 |
+
- Responsive story UI overlay
|
| 570 |
+
- World tracking enabled
|
| 571 |
+
|
| 572 |
+
Customization:
|
| 573 |
+
- Add 3D models in <a-entity> tags
|
| 574 |
+
- Modify CSS for different UI styles
|
| 575 |
+
- Add sounds with <a-sound> elements
|
| 576 |
+
- Use xrextras components for AR features
|
| 577 |
+
|
| 578 |
+
Testing:
|
| 579 |
+
- Use 8th Wall's built-in hosting
|
| 580 |
+
- Scan QR code on mobile device
|
| 581 |
+
- Test on iOS Safari and Android Chrome
|
| 582 |
+
|
| 583 |
+
Hosting Options:
|
| 584 |
+
1. 8th Wall hosting (included with subscription)
|
| 585 |
+
2. Your own HTTPS server
|
| 586 |
+
3. GitHub Pages (free, needs HTTPS)
|
| 587 |
+
|
| 588 |
+
Requirements:
|
| 589 |
+
- 8th Wall subscription for App Key
|
| 590 |
+
- HTTPS hosting (required for camera access)
|
| 591 |
+
- Mobile device with camera
|
| 592 |
+
|
| 593 |
+
Alternative (Free Testing):
|
| 594 |
+
- Remove 8th Wall script
|
| 595 |
+
- Use basic A-Frame only
|
| 596 |
+
- Won't have advanced AR features
|
| 597 |
+
|
| 598 |
+
Reach: 1B+ WebAR experiences served
|
| 599 |
+
"""
|
| 600 |
+
|
| 601 |
+
return explanation, html_code
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
def export_to_tiktok(config_json):
|
| 605 |
+
"""Export game config to TikTok Effect House JavaScript format."""
|
| 606 |
+
try:
|
| 607 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 608 |
+
except json.JSONDecodeError as e:
|
| 609 |
+
return f"JSON Error: {str(e)}", ""
|
| 610 |
+
|
| 611 |
+
js_code = '''// TikTok Effect House - Interactive Story Effect
|
| 612 |
+
// Generated from Game Config IDE
|
| 613 |
+
//
|
| 614 |
+
// Setup in Effect House:
|
| 615 |
+
// 1. Create new project
|
| 616 |
+
// 2. Add Visual Scripting or Script component
|
| 617 |
+
// 3. Paste this code
|
| 618 |
+
|
| 619 |
+
const Scene = require('Scene');
|
| 620 |
+
const Time = require('Time');
|
| 621 |
+
const Patches = require('Patches');
|
| 622 |
+
const Diagnostics = require('Diagnostics');
|
| 623 |
+
const TouchGestures = require('TouchGestures');
|
| 624 |
+
|
| 625 |
+
const STORY_STATES = {
|
| 626 |
+
'''
|
| 627 |
+
|
| 628 |
+
first_state = None
|
| 629 |
+
|
| 630 |
+
for location_key, location_data in config.items():
|
| 631 |
+
if isinstance(location_data, dict):
|
| 632 |
+
for state_key, state_data in location_data.items():
|
| 633 |
+
if isinstance(state_data, dict):
|
| 634 |
+
state_name = f"{location_key}_{state_key}"
|
| 635 |
+
if first_state is None:
|
| 636 |
+
first_state = state_name
|
| 637 |
+
|
| 638 |
+
description = state_data.get('description', '').replace('"', '\\"').replace('\n', '\\n')
|
| 639 |
+
choices = state_data.get('choices', [])
|
| 640 |
+
transitions = state_data.get('transitions', {})
|
| 641 |
+
|
| 642 |
+
js_code += f' "{state_name}": {{\n'
|
| 643 |
+
js_code += f' description: "{description}",\n'
|
| 644 |
+
js_code += ' choices: ['
|
| 645 |
+
if choices:
|
| 646 |
+
js_code += ', '.join([f'"{c}"' for c in choices])
|
| 647 |
+
js_code += '],\n'
|
| 648 |
+
js_code += ' transitions: {'
|
| 649 |
+
if isinstance(transitions, dict):
|
| 650 |
+
trans_items = []
|
| 651 |
+
for choice, next_state in transitions.items():
|
| 652 |
+
if next_state:
|
| 653 |
+
if '_' not in next_state:
|
| 654 |
+
next_state = f"{location_key}_{next_state}"
|
| 655 |
+
trans_items.append(f'"{choice}": "{next_state}"')
|
| 656 |
+
js_code += ', '.join(trans_items)
|
| 657 |
+
js_code += '}\n'
|
| 658 |
+
js_code += ' },\n'
|
| 659 |
+
|
| 660 |
+
js_code += f'''}};
|
| 661 |
+
|
| 662 |
+
let currentState = "{first_state or "start"}";
|
| 663 |
+
let choiceIndex = 0;
|
| 664 |
+
|
| 665 |
+
(async function() {{
|
| 666 |
+
const storyText = await Scene.root.findFirst('storyText');
|
| 667 |
+
const choiceTexts = await Promise.all([
|
| 668 |
+
Scene.root.findFirst('choice1'),
|
| 669 |
+
Scene.root.findFirst('choice2'),
|
| 670 |
+
Scene.root.findFirst('choice3')
|
| 671 |
+
]);
|
| 672 |
+
|
| 673 |
+
function showState(stateName) {{
|
| 674 |
+
const state = STORY_STATES[stateName];
|
| 675 |
+
if (!state) return;
|
| 676 |
+
if (storyText) storyText.text = state.description;
|
| 677 |
+
choiceTexts.forEach((t, i) => {{
|
| 678 |
+
if (t) t.text = i < state.choices.length ? state.choices[i] : '';
|
| 679 |
+
}});
|
| 680 |
+
currentState = stateName;
|
| 681 |
+
choiceIndex = 0;
|
| 682 |
+
Diagnostics.log('State: ' + stateName);
|
| 683 |
+
}}
|
| 684 |
+
|
| 685 |
+
let lastTap = 0;
|
| 686 |
+
TouchGestures.onTap().subscribe(() => {{
|
| 687 |
+
const now = Date.now();
|
| 688 |
+
const state = STORY_STATES[currentState];
|
| 689 |
+
if (now - lastTap < 300 && state.choices.length > 0) {{
|
| 690 |
+
const next = state.transitions[state.choices[choiceIndex]];
|
| 691 |
+
if (next) showState(next);
|
| 692 |
+
}} else if (state.choices.length > 0) {{
|
| 693 |
+
choiceIndex = (choiceIndex + 1) % state.choices.length;
|
| 694 |
+
Patches.inputs.setScalar('currentChoice', choiceIndex);
|
| 695 |
+
}}
|
| 696 |
+
lastTap = now;
|
| 697 |
+
}});
|
| 698 |
+
|
| 699 |
+
showState(currentState);
|
| 700 |
+
}})();
|
| 701 |
+
'''
|
| 702 |
+
|
| 703 |
+
explanation = """TikTok Effect House Export Generated!
|
| 704 |
+
|
| 705 |
+
Setup:
|
| 706 |
+
1. Download Effect House: https://effecthouse.tiktok.com/
|
| 707 |
+
2. Create new project
|
| 708 |
+
3. Add Script component
|
| 709 |
+
4. Paste this code
|
| 710 |
+
|
| 711 |
+
Interaction:
|
| 712 |
+
- Single tap: Cycle through choices
|
| 713 |
+
- Double tap: Select current choice
|
| 714 |
+
|
| 715 |
+
Scene Objects:
|
| 716 |
+
- storyText: 2D Text for description
|
| 717 |
+
- choice1, choice2, choice3: Choice text objects
|
| 718 |
+
|
| 719 |
+
Testing:
|
| 720 |
+
- Preview in Effect House
|
| 721 |
+
- Send to TikTok app
|
| 722 |
+
|
| 723 |
+
Publishing:
|
| 724 |
+
- Submit via Effect House
|
| 725 |
+
- 1B+ TikTok users reach
|
| 726 |
+
"""
|
| 727 |
+
|
| 728 |
+
return explanation, js_code
|
| 729 |
+
|
| 730 |
+
|
| 731 |
+
def export_to_reality(config_json):
|
| 732 |
+
"""Export game config to Apple Reality Composer Pro / RealityKit Swift format."""
|
| 733 |
+
try:
|
| 734 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 735 |
+
except json.JSONDecodeError as e:
|
| 736 |
+
return f"JSON Error: {str(e)}", ""
|
| 737 |
+
|
| 738 |
+
swift_code = '''import SwiftUI
|
| 739 |
+
import RealityKit
|
| 740 |
+
|
| 741 |
+
// Story Adventure for visionOS / iOS AR
|
| 742 |
+
// Generated from Game Config IDE
|
| 743 |
+
|
| 744 |
+
struct StoryState: Identifiable {
|
| 745 |
+
let id: String
|
| 746 |
+
let description: String
|
| 747 |
+
let choices: [String]
|
| 748 |
+
let transitions: [String: String]
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
class StoryManager: ObservableObject {
|
| 752 |
+
@Published var currentStateId: String
|
| 753 |
+
let states: [String: StoryState]
|
| 754 |
+
|
| 755 |
+
init() {
|
| 756 |
+
var stateDict: [String: StoryState] = [:]
|
| 757 |
+
'''
|
| 758 |
+
|
| 759 |
+
first_state = None
|
| 760 |
+
|
| 761 |
+
for location_key, location_data in config.items():
|
| 762 |
+
if isinstance(location_data, dict):
|
| 763 |
+
for state_key, state_data in location_data.items():
|
| 764 |
+
if isinstance(state_data, dict):
|
| 765 |
+
state_name = f"{location_key}_{state_key}"
|
| 766 |
+
if first_state is None:
|
| 767 |
+
first_state = state_name
|
| 768 |
+
|
| 769 |
+
description = state_data.get('description', '').replace('"', '\\"')
|
| 770 |
+
choices = state_data.get('choices', [])
|
| 771 |
+
transitions = state_data.get('transitions', {})
|
| 772 |
+
|
| 773 |
+
choices_str = ', '.join([f'"{c}"' for c in choices])
|
| 774 |
+
trans_items = []
|
| 775 |
+
if isinstance(transitions, dict):
|
| 776 |
+
for choice, next_state in transitions.items():
|
| 777 |
+
if next_state:
|
| 778 |
+
if '_' not in next_state:
|
| 779 |
+
next_state = f"{location_key}_{next_state}"
|
| 780 |
+
trans_items.append(f'"{choice}": "{next_state}"')
|
| 781 |
+
|
| 782 |
+
swift_code += f'\n stateDict["{state_name}"] = StoryState(id: "{state_name}", description: "{description}", choices: [{choices_str}], transitions: [{", ".join(trans_items)}])\n'
|
| 783 |
+
|
| 784 |
+
swift_code += f'''
|
| 785 |
+
self.states = stateDict
|
| 786 |
+
self.currentStateId = "{first_state or "start"}"
|
| 787 |
+
}}
|
| 788 |
+
|
| 789 |
+
var currentState: StoryState? {{ states[currentStateId] }}
|
| 790 |
+
|
| 791 |
+
func makeChoice(_ choice: String) {{
|
| 792 |
+
guard let next = currentState?.transitions[choice] else {{ return }}
|
| 793 |
+
currentStateId = next
|
| 794 |
+
}}
|
| 795 |
+
}}
|
| 796 |
+
|
| 797 |
+
struct StoryView: View {{
|
| 798 |
+
@StateObject private var manager = StoryManager()
|
| 799 |
+
|
| 800 |
+
var body: some View {{
|
| 801 |
+
VStack(spacing: 20) {{
|
| 802 |
+
if let state = manager.currentState {{
|
| 803 |
+
Text(state.description)
|
| 804 |
+
.font(.title3)
|
| 805 |
+
.padding()
|
| 806 |
+
ForEach(state.choices, id: \\.self) {{ choice in
|
| 807 |
+
Button(choice) {{ manager.makeChoice(choice) }}
|
| 808 |
+
.buttonStyle(.borderedProminent)
|
| 809 |
+
}}
|
| 810 |
+
}}
|
| 811 |
+
}}
|
| 812 |
+
.padding()
|
| 813 |
+
}}
|
| 814 |
+
}}
|
| 815 |
+
|
| 816 |
+
#Preview {{ StoryView() }}
|
| 817 |
+
'''
|
| 818 |
+
|
| 819 |
+
explanation = """Apple Reality / visionOS Export Generated!
|
| 820 |
+
|
| 821 |
+
Setup:
|
| 822 |
+
1. Create visionOS or iOS project in Xcode 15+
|
| 823 |
+
2. Add this Swift file
|
| 824 |
+
3. Use StoryView in your app
|
| 825 |
+
|
| 826 |
+
Requirements:
|
| 827 |
+
- Xcode 15+
|
| 828 |
+
- iOS 17+ / visionOS 1.0+
|
| 829 |
+
- Swift 5.9+
|
| 830 |
+
|
| 831 |
+
For Vision Pro:
|
| 832 |
+
- Wrap in RealityView for spatial
|
| 833 |
+
- Add 3D content per state
|
| 834 |
+
|
| 835 |
+
Testing:
|
| 836 |
+
- visionOS Simulator
|
| 837 |
+
- iOS device with AR
|
| 838 |
+
|
| 839 |
+
Distribution:
|
| 840 |
+
- App Store
|
| 841 |
+
"""
|
| 842 |
+
|
| 843 |
+
return explanation, swift_code
|
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Interactive fiction exporters for game configs.
|
| 2 |
+
|
| 3 |
+
Includes exporters for:
|
| 4 |
+
- Inform 7 natural language format
|
| 5 |
+
- Godot Dialogue Manager format
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def export_to_inform7(config_json):
|
| 12 |
+
"""Export game config to Inform 7 natural language format."""
|
| 13 |
+
try:
|
| 14 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 15 |
+
except json.JSONDecodeError as e:
|
| 16 |
+
return f"JSON Error: {str(e)}", ""
|
| 17 |
+
|
| 18 |
+
i7_code = '''"Story Adventure" by "Game Config IDE"
|
| 19 |
+
|
| 20 |
+
'''
|
| 21 |
+
|
| 22 |
+
locations = {}
|
| 23 |
+
first_room = None
|
| 24 |
+
|
| 25 |
+
for location_key, location_data in config.items():
|
| 26 |
+
if isinstance(location_data, dict):
|
| 27 |
+
room_name = location_key.replace('_', ' ').title()
|
| 28 |
+
if first_room is None:
|
| 29 |
+
first_room = room_name
|
| 30 |
+
for state_key, state_data in location_data.items():
|
| 31 |
+
if isinstance(state_data, dict):
|
| 32 |
+
desc = state_data.get('description', f'You are in {room_name}.').replace('"', "'")
|
| 33 |
+
locations[room_name] = desc
|
| 34 |
+
break
|
| 35 |
+
|
| 36 |
+
for room_name, desc in locations.items():
|
| 37 |
+
i7_code += f'{room_name} is a room. "{desc}"\n\n'
|
| 38 |
+
|
| 39 |
+
if first_room:
|
| 40 |
+
i7_code += f"The player is in {first_room}.\n\n"
|
| 41 |
+
|
| 42 |
+
# Generate actions for choices
|
| 43 |
+
for location_key, location_data in config.items():
|
| 44 |
+
if isinstance(location_data, dict):
|
| 45 |
+
for state_key, state_data in location_data.items():
|
| 46 |
+
if isinstance(state_data, dict):
|
| 47 |
+
choices = state_data.get('choices', [])
|
| 48 |
+
transitions = state_data.get('transitions', {})
|
| 49 |
+
|
| 50 |
+
for choice in choices:
|
| 51 |
+
action_name = choice.lower().replace(' ', '-')[:20]
|
| 52 |
+
next_state = transitions.get(choice, '') if isinstance(transitions, dict) else ''
|
| 53 |
+
|
| 54 |
+
i7_code += f'{action_name.replace("-", " ")}ing is an action applying to nothing.\n'
|
| 55 |
+
i7_code += f'Understand "{choice.lower()}" as {action_name}ing.\n'
|
| 56 |
+
i7_code += f'Carry out {action_name}ing: say "You chose: {choice}."\n\n'
|
| 57 |
+
|
| 58 |
+
explanation = """Inform 7 Interactive Fiction Export Generated!
|
| 59 |
+
|
| 60 |
+
Setup:
|
| 61 |
+
1. Download Inform 7: http://inform7.com/
|
| 62 |
+
2. Create new project
|
| 63 |
+
3. Paste code in Source panel
|
| 64 |
+
4. Click "Go!" to compile
|
| 65 |
+
|
| 66 |
+
Online Testing:
|
| 67 |
+
- Borogove: https://borogove.app/
|
| 68 |
+
|
| 69 |
+
Distribution:
|
| 70 |
+
- Compile to .z8 or .gblorb
|
| 71 |
+
- Upload to IFDB
|
| 72 |
+
"""
|
| 73 |
+
|
| 74 |
+
return explanation, i7_code
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def export_to_godot_dialogue(config_json):
|
| 78 |
+
"""Export game config to Godot Dialogue Manager format."""
|
| 79 |
+
try:
|
| 80 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 81 |
+
except json.JSONDecodeError as e:
|
| 82 |
+
return f"JSON Error: {str(e)}", ""
|
| 83 |
+
|
| 84 |
+
dialogue_code = '# Godot Dialogue Manager - story.dialogue\n\n'
|
| 85 |
+
|
| 86 |
+
first_title = None
|
| 87 |
+
|
| 88 |
+
for location_key, location_data in config.items():
|
| 89 |
+
if isinstance(location_data, dict):
|
| 90 |
+
for state_key, state_data in location_data.items():
|
| 91 |
+
if isinstance(state_data, dict):
|
| 92 |
+
title = f"{location_key}_{state_key}"
|
| 93 |
+
if first_title is None:
|
| 94 |
+
first_title = title
|
| 95 |
+
|
| 96 |
+
description = state_data.get('description', '')
|
| 97 |
+
choices = state_data.get('choices', [])
|
| 98 |
+
transitions = state_data.get('transitions', {})
|
| 99 |
+
|
| 100 |
+
dialogue_code += f"~ {title}\n"
|
| 101 |
+
dialogue_code += f"{description}\n"
|
| 102 |
+
|
| 103 |
+
if choices:
|
| 104 |
+
for choice in choices:
|
| 105 |
+
next_state = transitions.get(choice, '') if isinstance(transitions, dict) else ''
|
| 106 |
+
if next_state:
|
| 107 |
+
if '_' not in next_state:
|
| 108 |
+
next_state = f"{location_key}_{next_state}"
|
| 109 |
+
dialogue_code += f"- {choice} => {next_state}\n"
|
| 110 |
+
else:
|
| 111 |
+
dialogue_code += f"- {choice} => END\n"
|
| 112 |
+
else:
|
| 113 |
+
dialogue_code += "=> END\n"
|
| 114 |
+
|
| 115 |
+
dialogue_code += "\n"
|
| 116 |
+
|
| 117 |
+
explanation = f"""Godot Dialogue Manager Export Generated!
|
| 118 |
+
|
| 119 |
+
Setup:
|
| 120 |
+
1. Install "Dialogue Manager" addon from AssetLib
|
| 121 |
+
2. Save as story.dialogue
|
| 122 |
+
3. Use in GDScript:
|
| 123 |
+
|
| 124 |
+
DialogueManager.show_dialogue_balloon(
|
| 125 |
+
preload("res://story.dialogue"),
|
| 126 |
+
"{first_title or "start"}"
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
Syntax:
|
| 130 |
+
- ~ title : Node start
|
| 131 |
+
- Text : Dialogue line
|
| 132 |
+
- - Choice => target : Player choice
|
| 133 |
+
- => END : End dialogue
|
| 134 |
+
|
| 135 |
+
Requirements:
|
| 136 |
+
- Godot 4.0+
|
| 137 |
+
- Dialogue Manager addon
|
| 138 |
+
"""
|
| 139 |
+
|
| 140 |
+
return explanation, dialogue_code
|
|
@@ -0,0 +1,124 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Flutter/Dart exporter for game configs."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def export_to_flutter(config_json):
|
| 7 |
+
"""Export config to Flutter/Dart code."""
|
| 8 |
+
try:
|
| 9 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 10 |
+
except:
|
| 11 |
+
return "Error: Invalid JSON", ""
|
| 12 |
+
|
| 13 |
+
dart = '''// Flutter Text Adventure Game
|
| 14 |
+
// Add to your lib/main.dart or create lib/game_state.dart
|
| 15 |
+
|
| 16 |
+
import 'package:flutter/material.dart';
|
| 17 |
+
|
| 18 |
+
// ========== GAME CONFIG ==========
|
| 19 |
+
final Map<String, Map<String, dynamic>> gameConfig = ''' + json.dumps(config, indent=2).replace('true', 'true').replace('false', 'false').replace('null', 'null') + ''';
|
| 20 |
+
|
| 21 |
+
// ========== GAME STATE ==========
|
| 22 |
+
class GameState extends ChangeNotifier {
|
| 23 |
+
String currentLocation = "''' + list(config.keys())[0] + '''";
|
| 24 |
+
String currentState = "''' + list(config[list(config.keys())[0]].keys())[0] + '''";
|
| 25 |
+
List<String> inventory = [];
|
| 26 |
+
int money = 0;
|
| 27 |
+
Map<String, bool> flags = {};
|
| 28 |
+
|
| 29 |
+
Map<String, dynamic>? get currentStateData {
|
| 30 |
+
return gameConfig[currentLocation]?[currentState];
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
String get description => currentStateData?['description'] ?? 'No description';
|
| 34 |
+
|
| 35 |
+
List<String> get choices =>
|
| 36 |
+
List<String>.from(currentStateData?['choices'] ?? []);
|
| 37 |
+
|
| 38 |
+
void makeChoice(int index) {
|
| 39 |
+
if (index < 0 || index >= choices.length) return;
|
| 40 |
+
|
| 41 |
+
String choice = choices[index];
|
| 42 |
+
var transitions = currentStateData?['transitions'] as Map<String, dynamic>?;
|
| 43 |
+
|
| 44 |
+
if (transitions != null && transitions.containsKey(choice)) {
|
| 45 |
+
String nextState = transitions[choice].toString();
|
| 46 |
+
if (nextState.contains('/')) {
|
| 47 |
+
var parts = nextState.split('/');
|
| 48 |
+
currentLocation = parts[0];
|
| 49 |
+
currentState = parts[1];
|
| 50 |
+
} else {
|
| 51 |
+
currentState = nextState;
|
| 52 |
+
}
|
| 53 |
+
notifyListeners();
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
// ========== GAME SCREEN WIDGET ==========
|
| 59 |
+
class GameScreen extends StatelessWidget {
|
| 60 |
+
final GameState gameState;
|
| 61 |
+
|
| 62 |
+
const GameScreen({Key? key, required this.gameState}) : super(key: key);
|
| 63 |
+
|
| 64 |
+
@override
|
| 65 |
+
Widget build(BuildContext context) {
|
| 66 |
+
return ListenableBuilder(
|
| 67 |
+
listenable: gameState,
|
| 68 |
+
builder: (context, child) {
|
| 69 |
+
return Scaffold(
|
| 70 |
+
appBar: AppBar(title: Text(gameState.currentLocation)),
|
| 71 |
+
body: Padding(
|
| 72 |
+
padding: const EdgeInsets.all(16.0),
|
| 73 |
+
child: Column(
|
| 74 |
+
crossAxisAlignment: CrossAxisAlignment.start,
|
| 75 |
+
children: [
|
| 76 |
+
Text(
|
| 77 |
+
gameState.description,
|
| 78 |
+
style: Theme.of(context).textTheme.bodyLarge,
|
| 79 |
+
),
|
| 80 |
+
const SizedBox(height: 24),
|
| 81 |
+
const Text('What do you do?',
|
| 82 |
+
style: TextStyle(fontWeight: FontWeight.bold)),
|
| 83 |
+
const SizedBox(height: 8),
|
| 84 |
+
...gameState.choices.asMap().entries.map((entry) {
|
| 85 |
+
return Padding(
|
| 86 |
+
padding: const EdgeInsets.symmetric(vertical: 4),
|
| 87 |
+
child: ElevatedButton(
|
| 88 |
+
onPressed: () => gameState.makeChoice(entry.key),
|
| 89 |
+
child: Text(entry.value),
|
| 90 |
+
),
|
| 91 |
+
);
|
| 92 |
+
}).toList(),
|
| 93 |
+
],
|
| 94 |
+
),
|
| 95 |
+
),
|
| 96 |
+
);
|
| 97 |
+
},
|
| 98 |
+
);
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
// ========== MAIN APP ==========
|
| 103 |
+
void main() {
|
| 104 |
+
runApp(MaterialApp(
|
| 105 |
+
home: GameScreen(gameState: GameState()),
|
| 106 |
+
theme: ThemeData.dark(),
|
| 107 |
+
));
|
| 108 |
+
}
|
| 109 |
+
'''
|
| 110 |
+
|
| 111 |
+
explanation = """Flutter/Dart Export:
|
| 112 |
+
|
| 113 |
+
1. Create new Flutter project: flutter create text_adventure
|
| 114 |
+
2. Replace lib/main.dart with this code
|
| 115 |
+
3. Run: flutter run
|
| 116 |
+
|
| 117 |
+
Features:
|
| 118 |
+
- Uses ChangeNotifier for state management
|
| 119 |
+
- Responsive button-based choices
|
| 120 |
+
- Dark theme by default
|
| 121 |
+
|
| 122 |
+
For mobile: flutter build apk (Android) or flutter build ios"""
|
| 123 |
+
|
| 124 |
+
return explanation, dart
|
|
@@ -0,0 +1,633 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Game platform exporters for game configs.
|
| 2 |
+
|
| 3 |
+
Includes exporters for:
|
| 4 |
+
- Minecraft Datapack
|
| 5 |
+
- Skyrim/Fallout 4 Papyrus
|
| 6 |
+
- VRChat UdonSharp
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import json
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def export_to_minecraft(config_json):
|
| 13 |
+
"""Export game config to Minecraft Datapack format (mcfunction + JSON)."""
|
| 14 |
+
try:
|
| 15 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 16 |
+
except json.JSONDecodeError as e:
|
| 17 |
+
return f"JSON Error: {str(e)}", ""
|
| 18 |
+
|
| 19 |
+
# Generate datapack structure as a combined output
|
| 20 |
+
output = '''# ========================================
|
| 21 |
+
# MINECRAFT DATAPACK - Story Adventure
|
| 22 |
+
# Generated from Game Config IDE
|
| 23 |
+
# ========================================
|
| 24 |
+
#
|
| 25 |
+
# FOLDER STRUCTURE:
|
| 26 |
+
# story_adventure/
|
| 27 |
+
# ├── pack.mcmeta
|
| 28 |
+
# ├── data/
|
| 29 |
+
# │ └── story/
|
| 30 |
+
# │ ├── functions/
|
| 31 |
+
# │ │ ├── init.mcfunction
|
| 32 |
+
# │ │ ├── tick.mcfunction
|
| 33 |
+
# │ │ └── states/
|
| 34 |
+
# │ │ └── [state].mcfunction
|
| 35 |
+
# │ └── advancements/
|
| 36 |
+
# │ └── root.json
|
| 37 |
+
#
|
| 38 |
+
# INSTALLATION:
|
| 39 |
+
# 1. Create folder: .minecraft/saves/[world]/datapacks/story_adventure/
|
| 40 |
+
# 2. Create the folder structure above
|
| 41 |
+
# 3. Copy each file section below to the corresponding file
|
| 42 |
+
# ========================================
|
| 43 |
+
|
| 44 |
+
### FILE: pack.mcmeta ###
|
| 45 |
+
{
|
| 46 |
+
"pack": {
|
| 47 |
+
"pack_format": 26,
|
| 48 |
+
"description": "Story Adventure - Interactive narrative datapack"
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
### FILE: data/story/functions/init.mcfunction ###
|
| 53 |
+
# Initialize story system
|
| 54 |
+
# Run once: /function story:init
|
| 55 |
+
|
| 56 |
+
# Create scoreboard objectives
|
| 57 |
+
scoreboard objectives add story_state dummy "Current State"
|
| 58 |
+
scoreboard objectives add story_choice dummy "Player Choice"
|
| 59 |
+
|
| 60 |
+
# Set initial state (0 = first state)
|
| 61 |
+
scoreboard players set @a story_state 0
|
| 62 |
+
scoreboard players set @a story_choice 0
|
| 63 |
+
|
| 64 |
+
# Notify players
|
| 65 |
+
tellraw @a {"text":"[Story] Adventure initialized! Use /trigger story_choice to make choices.","color":"gold"}
|
| 66 |
+
|
| 67 |
+
# Enable trigger for choices
|
| 68 |
+
scoreboard objectives add story_trigger trigger "Make Choice"
|
| 69 |
+
scoreboard players enable @a story_trigger
|
| 70 |
+
|
| 71 |
+
### FILE: data/story/functions/tick.mcfunction ###
|
| 72 |
+
# Main tick function - checks for player choices
|
| 73 |
+
# Add to tick.json or run periodically
|
| 74 |
+
|
| 75 |
+
# Check if any player made a choice
|
| 76 |
+
execute as @a[scores={story_trigger=1..}] run function story:process_choice
|
| 77 |
+
scoreboard players set @a story_trigger 0
|
| 78 |
+
scoreboard players enable @a story_trigger
|
| 79 |
+
|
| 80 |
+
### FILE: data/story/functions/process_choice.mcfunction ###
|
| 81 |
+
# Process player choice based on current state
|
| 82 |
+
# Called when player uses /trigger story_trigger set [choice_number]
|
| 83 |
+
|
| 84 |
+
'''
|
| 85 |
+
|
| 86 |
+
# Generate state functions
|
| 87 |
+
state_index = 0
|
| 88 |
+
state_map = {} # Map state names to indices
|
| 89 |
+
|
| 90 |
+
# First pass: assign indices to states
|
| 91 |
+
for location_key, location_data in config.items():
|
| 92 |
+
if isinstance(location_data, dict):
|
| 93 |
+
for state_key, state_data in location_data.items():
|
| 94 |
+
if isinstance(state_data, dict):
|
| 95 |
+
state_name = f"{location_key}_{state_key}"
|
| 96 |
+
state_map[state_name] = state_index
|
| 97 |
+
state_index += 1
|
| 98 |
+
|
| 99 |
+
# Second pass: generate state functions
|
| 100 |
+
for location_key, location_data in config.items():
|
| 101 |
+
if isinstance(location_data, dict):
|
| 102 |
+
for state_key, state_data in location_data.items():
|
| 103 |
+
if isinstance(state_data, dict):
|
| 104 |
+
state_name = f"{location_key}_{state_key}"
|
| 105 |
+
state_idx = state_map[state_name]
|
| 106 |
+
description = state_data.get('description', 'No description').replace('"', '\\"')
|
| 107 |
+
choices = state_data.get('choices', [])
|
| 108 |
+
transitions = state_data.get('transitions', {})
|
| 109 |
+
|
| 110 |
+
output += f"\n### FILE: data/story/functions/states/{state_name}.mcfunction ###\n"
|
| 111 |
+
output += f"# State: {state_name} (index {state_idx})\n\n"
|
| 112 |
+
|
| 113 |
+
# Display description
|
| 114 |
+
output += f'tellraw @s {{"text":"\\n=== {location_key.upper()} ===\\n","color":"yellow","bold":true}}\n'
|
| 115 |
+
output += f'tellraw @s {{"text":"{description}\\n","color":"white"}}\n'
|
| 116 |
+
|
| 117 |
+
# Display choices
|
| 118 |
+
if choices:
|
| 119 |
+
output += f'tellraw @s {{"text":"\\nChoices:","color":"green"}}\n'
|
| 120 |
+
for i, choice in enumerate(choices):
|
| 121 |
+
choice_escaped = choice.replace('"', '\\"')
|
| 122 |
+
output += f'tellraw @s {{"text":"[{i+1}] {choice_escaped}","color":"aqua","clickEvent":{{"action":"run_command","value":"/trigger story_trigger set {i+1}"}}}}\n'
|
| 123 |
+
|
| 124 |
+
output += "\n"
|
| 125 |
+
|
| 126 |
+
# Generate process_choice logic
|
| 127 |
+
output += "\n### FILE: data/story/functions/process_choice.mcfunction (continued) ###\n"
|
| 128 |
+
output += "# Add state transition logic here\n\n"
|
| 129 |
+
|
| 130 |
+
for location_key, location_data in config.items():
|
| 131 |
+
if isinstance(location_data, dict):
|
| 132 |
+
for state_key, state_data in location_data.items():
|
| 133 |
+
if isinstance(state_data, dict):
|
| 134 |
+
state_name = f"{location_key}_{state_key}"
|
| 135 |
+
state_idx = state_map[state_name]
|
| 136 |
+
choices = state_data.get('choices', [])
|
| 137 |
+
transitions = state_data.get('transitions', {})
|
| 138 |
+
|
| 139 |
+
output += f"# From state: {state_name} ({state_idx})\n"
|
| 140 |
+
for i, choice in enumerate(choices):
|
| 141 |
+
next_state = transitions.get(choice, '') if isinstance(transitions, dict) else ''
|
| 142 |
+
if next_state:
|
| 143 |
+
if '_' not in next_state:
|
| 144 |
+
next_state = f"{location_key}_{next_state}"
|
| 145 |
+
if next_state in state_map:
|
| 146 |
+
next_idx = state_map[next_state]
|
| 147 |
+
output += f"execute as @s[scores={{story_state={state_idx},story_trigger={i+1}}}] run scoreboard players set @s story_state {next_idx}\n"
|
| 148 |
+
output += f"execute as @s[scores={{story_state={next_idx}}}] run function story:states/{next_state}\n"
|
| 149 |
+
|
| 150 |
+
# Add start command
|
| 151 |
+
output += '''
|
| 152 |
+
|
| 153 |
+
### FILE: data/story/functions/start.mcfunction ###
|
| 154 |
+
# Start the story - run with /function story:start
|
| 155 |
+
function story:init
|
| 156 |
+
'''
|
| 157 |
+
|
| 158 |
+
first_state = list(state_map.keys())[0] if state_map else "start"
|
| 159 |
+
output += f"function story:states/{first_state}\n"
|
| 160 |
+
|
| 161 |
+
explanation = """Minecraft Datapack Export Generated!
|
| 162 |
+
|
| 163 |
+
Installation:
|
| 164 |
+
1. Navigate to: .minecraft/saves/[world]/datapacks/
|
| 165 |
+
2. Create folder: story_adventure
|
| 166 |
+
3. Create the folder structure shown above
|
| 167 |
+
4. Copy each ### FILE: ### section to its file
|
| 168 |
+
5. Run: /reload in-game
|
| 169 |
+
|
| 170 |
+
Commands:
|
| 171 |
+
- /function story:start - Begin the adventure
|
| 172 |
+
- /function story:init - Reset/initialize
|
| 173 |
+
- Click choices in chat or use /trigger story_trigger set [number]
|
| 174 |
+
|
| 175 |
+
Requirements:
|
| 176 |
+
- Minecraft Java Edition 1.20.4+
|
| 177 |
+
- pack_format 26 (adjust for your version)
|
| 178 |
+
- Command blocks enabled (for automation)
|
| 179 |
+
|
| 180 |
+
Testing:
|
| 181 |
+
- Use /reload after changes
|
| 182 |
+
- Check /datapack list to verify loaded
|
| 183 |
+
- Use F3+T to reload resources
|
| 184 |
+
|
| 185 |
+
Multiplayer:
|
| 186 |
+
- Each player has independent state
|
| 187 |
+
- Uses scoreboards for tracking
|
| 188 |
+
- Works on servers with datapacks enabled
|
| 189 |
+
|
| 190 |
+
Customization:
|
| 191 |
+
- Add sounds with /playsound
|
| 192 |
+
- Add particles with /particle
|
| 193 |
+
- Spawn entities for NPCs
|
| 194 |
+
- Use /title for dramatic text
|
| 195 |
+
"""
|
| 196 |
+
|
| 197 |
+
return explanation, output
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def export_to_papyrus(config_json):
|
| 201 |
+
"""Export game config to Skyrim/Fallout 4 Papyrus script format."""
|
| 202 |
+
try:
|
| 203 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 204 |
+
except json.JSONDecodeError as e:
|
| 205 |
+
return f"JSON Error: {str(e)}", ""
|
| 206 |
+
|
| 207 |
+
psc_code = '''Scriptname StoryAdventure extends Quest
|
| 208 |
+
{Story Adventure System - Generated from Game Config IDE}
|
| 209 |
+
|
| 210 |
+
; ========================================
|
| 211 |
+
; PROPERTIES - Set these in Creation Kit
|
| 212 |
+
; ========================================
|
| 213 |
+
Message Property StartMessage Auto
|
| 214 |
+
{Initial message to show player}
|
| 215 |
+
|
| 216 |
+
Message[] Property StateMessages Auto
|
| 217 |
+
{Array of messages for each state - create in CK}
|
| 218 |
+
|
| 219 |
+
GlobalVariable Property CurrentStateIndex Auto
|
| 220 |
+
{Tracks current state - create GlobalVariable in CK}
|
| 221 |
+
|
| 222 |
+
; ========================================
|
| 223 |
+
; STATE DATA
|
| 224 |
+
; ========================================
|
| 225 |
+
|
| 226 |
+
'''
|
| 227 |
+
|
| 228 |
+
# Generate state data
|
| 229 |
+
states = []
|
| 230 |
+
state_index = 0
|
| 231 |
+
state_map = {}
|
| 232 |
+
|
| 233 |
+
for location_key, location_data in config.items():
|
| 234 |
+
if isinstance(location_data, dict):
|
| 235 |
+
for state_key, state_data in location_data.items():
|
| 236 |
+
if isinstance(state_data, dict):
|
| 237 |
+
state_name = f"{location_key}_{state_key}"
|
| 238 |
+
state_map[state_name] = state_index
|
| 239 |
+
states.append({
|
| 240 |
+
'name': state_name,
|
| 241 |
+
'index': state_index,
|
| 242 |
+
'description': state_data.get('description', ''),
|
| 243 |
+
'choices': state_data.get('choices', []),
|
| 244 |
+
'transitions': state_data.get('transitions', {}),
|
| 245 |
+
'location': location_key
|
| 246 |
+
})
|
| 247 |
+
state_index += 1
|
| 248 |
+
|
| 249 |
+
# Add state constants
|
| 250 |
+
for state in states:
|
| 251 |
+
psc_code += f"int Property STATE_{state['name'].upper()} = {state['index']} AutoReadOnly\n"
|
| 252 |
+
|
| 253 |
+
psc_code += f'''
|
| 254 |
+
int Property TOTAL_STATES = {len(states)} AutoReadOnly
|
| 255 |
+
|
| 256 |
+
; ========================================
|
| 257 |
+
; FUNCTIONS
|
| 258 |
+
; ========================================
|
| 259 |
+
|
| 260 |
+
Function StartStory()
|
| 261 |
+
{{Begin the story from the first state}}
|
| 262 |
+
CurrentStateIndex.SetValue(0)
|
| 263 |
+
ShowCurrentState()
|
| 264 |
+
EndFunction
|
| 265 |
+
|
| 266 |
+
Function ShowCurrentState()
|
| 267 |
+
{{Display the current state's message and choices}}
|
| 268 |
+
int stateIdx = CurrentStateIndex.GetValueInt()
|
| 269 |
+
|
| 270 |
+
If stateIdx >= 0 && stateIdx < StateMessages.Length
|
| 271 |
+
int result = StateMessages[stateIdx].Show()
|
| 272 |
+
ProcessChoice(stateIdx, result)
|
| 273 |
+
EndIf
|
| 274 |
+
EndFunction
|
| 275 |
+
|
| 276 |
+
Function ProcessChoice(int fromState, int choiceIndex)
|
| 277 |
+
{{Handle the player's choice and transition to next state}}
|
| 278 |
+
|
| 279 |
+
int nextState = -1
|
| 280 |
+
|
| 281 |
+
'''
|
| 282 |
+
|
| 283 |
+
# Generate choice processing
|
| 284 |
+
for state in states:
|
| 285 |
+
psc_code += f" ; From {state['name']}\n"
|
| 286 |
+
psc_code += f" If fromState == {state['index']}\n"
|
| 287 |
+
|
| 288 |
+
transitions = state['transitions']
|
| 289 |
+
choices = state['choices']
|
| 290 |
+
|
| 291 |
+
for i, choice in enumerate(choices):
|
| 292 |
+
next_state = transitions.get(choice, '') if isinstance(transitions, dict) else ''
|
| 293 |
+
if next_state:
|
| 294 |
+
if '_' not in next_state:
|
| 295 |
+
next_state = f"{state['location']}_{next_state}"
|
| 296 |
+
if next_state in state_map:
|
| 297 |
+
psc_code += f" If choiceIndex == {i}\n"
|
| 298 |
+
psc_code += f" nextState = {state_map[next_state]} ; -> {next_state}\n"
|
| 299 |
+
psc_code += f" EndIf\n"
|
| 300 |
+
|
| 301 |
+
psc_code += f" EndIf\n\n"
|
| 302 |
+
|
| 303 |
+
psc_code += '''
|
| 304 |
+
; Transition to next state
|
| 305 |
+
If nextState >= 0
|
| 306 |
+
CurrentStateIndex.SetValue(nextState)
|
| 307 |
+
ShowCurrentState()
|
| 308 |
+
Else
|
| 309 |
+
Debug.Notification("Story: No valid transition")
|
| 310 |
+
EndIf
|
| 311 |
+
EndFunction
|
| 312 |
+
|
| 313 |
+
Function ResetStory()
|
| 314 |
+
{Reset to beginning}
|
| 315 |
+
CurrentStateIndex.SetValue(0)
|
| 316 |
+
EndFunction
|
| 317 |
+
|
| 318 |
+
; ========================================
|
| 319 |
+
; CREATION KIT MESSAGE TEMPLATES
|
| 320 |
+
; ========================================
|
| 321 |
+
; Create these Message forms in Creation Kit:
|
| 322 |
+
;
|
| 323 |
+
'''
|
| 324 |
+
|
| 325 |
+
# Add message templates as comments
|
| 326 |
+
for state in states:
|
| 327 |
+
desc = state['description'][:100].replace('\n', ' ')
|
| 328 |
+
psc_code += f"; Message: {state['name']}_MSG\n"
|
| 329 |
+
psc_code += f"; Message Text: \"{desc}...\"\n"
|
| 330 |
+
psc_code += f"; Buttons:\n"
|
| 331 |
+
for i, choice in enumerate(state['choices']):
|
| 332 |
+
psc_code += f"; [{i}] {choice}\n"
|
| 333 |
+
psc_code += ";\n"
|
| 334 |
+
|
| 335 |
+
explanation = """Skyrim/Fallout 4 Papyrus Export Generated!
|
| 336 |
+
|
| 337 |
+
Setup in Creation Kit:
|
| 338 |
+
1. Create new Quest (or use existing)
|
| 339 |
+
2. Attach this script to the Quest
|
| 340 |
+
3. Create Message forms for each state (see bottom of script)
|
| 341 |
+
4. Create GlobalVariable for CurrentStateIndex
|
| 342 |
+
5. Link properties in Quest script properties
|
| 343 |
+
|
| 344 |
+
Message Form Setup:
|
| 345 |
+
- Create one Message per state
|
| 346 |
+
- Add buttons matching the choices
|
| 347 |
+
- Link to StateMessages array property
|
| 348 |
+
|
| 349 |
+
Triggering the Story:
|
| 350 |
+
- Call StartStory() from dialogue, trigger, or spell
|
| 351 |
+
- Example: StoryQuest.StartStory()
|
| 352 |
+
|
| 353 |
+
Testing:
|
| 354 |
+
- Use console: cqf StoryQuest StartStory
|
| 355 |
+
- Or create test spell/item
|
| 356 |
+
|
| 357 |
+
Requirements:
|
| 358 |
+
- Creation Kit (free with game)
|
| 359 |
+
- Papyrus compiler
|
| 360 |
+
- Basic CK knowledge
|
| 361 |
+
|
| 362 |
+
Mod Distribution:
|
| 363 |
+
- Package as .esp + .psc/.pex files
|
| 364 |
+
- Upload to Nexus Mods
|
| 365 |
+
- 10M+ active Skyrim modders
|
| 366 |
+
"""
|
| 367 |
+
|
| 368 |
+
return explanation, psc_code
|
| 369 |
+
|
| 370 |
+
|
| 371 |
+
def export_to_vrchat(config_json):
|
| 372 |
+
"""Export game config to VRChat UdonSharp format."""
|
| 373 |
+
try:
|
| 374 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 375 |
+
except json.JSONDecodeError as e:
|
| 376 |
+
return f"JSON Error: {str(e)}", ""
|
| 377 |
+
|
| 378 |
+
cs_code = '''using UdonSharp;
|
| 379 |
+
using UnityEngine;
|
| 380 |
+
using UnityEngine.UI;
|
| 381 |
+
using VRC.SDKBase;
|
| 382 |
+
using VRC.Udon;
|
| 383 |
+
using TMPro;
|
| 384 |
+
|
| 385 |
+
/// <summary>
|
| 386 |
+
/// VRChat Story Adventure System
|
| 387 |
+
/// Generated from Game Config IDE
|
| 388 |
+
///
|
| 389 |
+
/// Setup:
|
| 390 |
+
/// 1. Create UdonSharpProgramAsset
|
| 391 |
+
/// 2. Attach to GameObject in world
|
| 392 |
+
/// 3. Link UI elements in Inspector
|
| 393 |
+
/// </summary>
|
| 394 |
+
public class StoryAdventure : UdonSharpBehaviour
|
| 395 |
+
{
|
| 396 |
+
[Header("UI References")]
|
| 397 |
+
[SerializeField] private TextMeshProUGUI descriptionText;
|
| 398 |
+
[SerializeField] private Button[] choiceButtons;
|
| 399 |
+
[SerializeField] private TextMeshProUGUI[] choiceButtonTexts;
|
| 400 |
+
|
| 401 |
+
[Header("Audio (Optional)")]
|
| 402 |
+
[SerializeField] private AudioSource audioSource;
|
| 403 |
+
[SerializeField] private AudioClip[] stateAudioClips;
|
| 404 |
+
|
| 405 |
+
[Header("Settings")]
|
| 406 |
+
[SerializeField] private bool syncAcrossPlayers = false;
|
| 407 |
+
|
| 408 |
+
// State tracking
|
| 409 |
+
[UdonSynced] private int currentStateIndex = 0;
|
| 410 |
+
private string currentStateName = "";
|
| 411 |
+
|
| 412 |
+
// ========================================
|
| 413 |
+
// STATE DATA
|
| 414 |
+
// ========================================
|
| 415 |
+
|
| 416 |
+
'''
|
| 417 |
+
|
| 418 |
+
# Generate state data
|
| 419 |
+
states = []
|
| 420 |
+
state_index = 0
|
| 421 |
+
state_map = {}
|
| 422 |
+
|
| 423 |
+
for location_key, location_data in config.items():
|
| 424 |
+
if isinstance(location_data, dict):
|
| 425 |
+
for state_key, state_data in location_data.items():
|
| 426 |
+
if isinstance(state_data, dict):
|
| 427 |
+
state_name = f"{location_key}_{state_key}"
|
| 428 |
+
state_map[state_name] = state_index
|
| 429 |
+
states.append({
|
| 430 |
+
'name': state_name,
|
| 431 |
+
'index': state_index,
|
| 432 |
+
'description': state_data.get('description', '').replace('"', '\\"').replace('\n', '\\n'),
|
| 433 |
+
'choices': state_data.get('choices', []),
|
| 434 |
+
'transitions': state_data.get('transitions', {}),
|
| 435 |
+
'location': location_key
|
| 436 |
+
})
|
| 437 |
+
state_index += 1
|
| 438 |
+
|
| 439 |
+
# State name constants
|
| 440 |
+
for state in states:
|
| 441 |
+
cs_code += f' private const int STATE_{state["name"].upper()} = {state["index"]};\n'
|
| 442 |
+
|
| 443 |
+
cs_code += f'''
|
| 444 |
+
private const int TOTAL_STATES = {len(states)};
|
| 445 |
+
|
| 446 |
+
// State descriptions
|
| 447 |
+
private string[] stateDescriptions = new string[]
|
| 448 |
+
{{
|
| 449 |
+
'''
|
| 450 |
+
|
| 451 |
+
for state in states:
|
| 452 |
+
cs_code += f' "{state["description"]}",\n'
|
| 453 |
+
|
| 454 |
+
cs_code += ''' };
|
| 455 |
+
|
| 456 |
+
// State choices (jagged array workaround for Udon)
|
| 457 |
+
private string[][] stateChoices;
|
| 458 |
+
|
| 459 |
+
// ========================================
|
| 460 |
+
// INITIALIZATION
|
| 461 |
+
// ========================================
|
| 462 |
+
|
| 463 |
+
void Start()
|
| 464 |
+
{
|
| 465 |
+
InitializeChoices();
|
| 466 |
+
ShowState(currentStateIndex);
|
| 467 |
+
}
|
| 468 |
+
|
| 469 |
+
private void InitializeChoices()
|
| 470 |
+
{
|
| 471 |
+
stateChoices = new string[TOTAL_STATES][];
|
| 472 |
+
|
| 473 |
+
'''
|
| 474 |
+
|
| 475 |
+
for state in states:
|
| 476 |
+
choices_str = ', '.join([f'"{c}"' for c in state['choices']])
|
| 477 |
+
cs_code += f' stateChoices[{state["index"]}] = new string[] {{ {choices_str} }};\n'
|
| 478 |
+
|
| 479 |
+
cs_code += ''' }
|
| 480 |
+
|
| 481 |
+
// ========================================
|
| 482 |
+
// STATE DISPLAY
|
| 483 |
+
// ========================================
|
| 484 |
+
|
| 485 |
+
private void ShowState(int stateIndex)
|
| 486 |
+
{
|
| 487 |
+
if (stateIndex < 0 || stateIndex >= TOTAL_STATES) return;
|
| 488 |
+
|
| 489 |
+
currentStateIndex = stateIndex;
|
| 490 |
+
|
| 491 |
+
// Update description
|
| 492 |
+
if (descriptionText != null)
|
| 493 |
+
{
|
| 494 |
+
descriptionText.text = stateDescriptions[stateIndex];
|
| 495 |
+
}
|
| 496 |
+
|
| 497 |
+
// Update choice buttons
|
| 498 |
+
string[] choices = stateChoices[stateIndex];
|
| 499 |
+
for (int i = 0; i < choiceButtons.Length; i++)
|
| 500 |
+
{
|
| 501 |
+
if (i < choices.Length)
|
| 502 |
+
{
|
| 503 |
+
choiceButtons[i].gameObject.SetActive(true);
|
| 504 |
+
if (choiceButtonTexts[i] != null)
|
| 505 |
+
{
|
| 506 |
+
choiceButtonTexts[i].text = choices[i];
|
| 507 |
+
}
|
| 508 |
+
}
|
| 509 |
+
else
|
| 510 |
+
{
|
| 511 |
+
choiceButtons[i].gameObject.SetActive(false);
|
| 512 |
+
}
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
// Play audio if available
|
| 516 |
+
if (audioSource != null && stateIndex < stateAudioClips.Length && stateAudioClips[stateIndex] != null)
|
| 517 |
+
{
|
| 518 |
+
audioSource.PlayOneShot(stateAudioClips[stateIndex]);
|
| 519 |
+
}
|
| 520 |
+
|
| 521 |
+
// Sync if enabled
|
| 522 |
+
if (syncAcrossPlayers)
|
| 523 |
+
{
|
| 524 |
+
RequestSerialization();
|
| 525 |
+
}
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
// ========================================
|
| 529 |
+
// CHOICE HANDLING
|
| 530 |
+
// ========================================
|
| 531 |
+
|
| 532 |
+
// Call these from button OnClick events
|
| 533 |
+
public void OnChoice0() { ProcessChoice(0); }
|
| 534 |
+
public void OnChoice1() { ProcessChoice(1); }
|
| 535 |
+
public void OnChoice2() { ProcessChoice(2); }
|
| 536 |
+
public void OnChoice3() { ProcessChoice(3); }
|
| 537 |
+
|
| 538 |
+
private void ProcessChoice(int choiceIndex)
|
| 539 |
+
{
|
| 540 |
+
int nextState = GetNextState(currentStateIndex, choiceIndex);
|
| 541 |
+
if (nextState >= 0)
|
| 542 |
+
{
|
| 543 |
+
ShowState(nextState);
|
| 544 |
+
}
|
| 545 |
+
}
|
| 546 |
+
|
| 547 |
+
private int GetNextState(int fromState, int choiceIndex)
|
| 548 |
+
{
|
| 549 |
+
'''
|
| 550 |
+
|
| 551 |
+
# Generate transition logic
|
| 552 |
+
for state in states:
|
| 553 |
+
cs_code += f" // From: {state['name']}\n"
|
| 554 |
+
cs_code += f" if (fromState == {state['index']})\n"
|
| 555 |
+
cs_code += " {\n"
|
| 556 |
+
|
| 557 |
+
transitions = state['transitions']
|
| 558 |
+
choices = state['choices']
|
| 559 |
+
|
| 560 |
+
for i, choice in enumerate(choices):
|
| 561 |
+
next_state = transitions.get(choice, '') if isinstance(transitions, dict) else ''
|
| 562 |
+
if next_state:
|
| 563 |
+
if '_' not in next_state:
|
| 564 |
+
next_state = f"{state['location']}_{next_state}"
|
| 565 |
+
if next_state in state_map:
|
| 566 |
+
cs_code += f" if (choiceIndex == {i}) return {state_map[next_state]}; // -> {next_state}\n"
|
| 567 |
+
|
| 568 |
+
cs_code += " }\n\n"
|
| 569 |
+
|
| 570 |
+
cs_code += '''
|
| 571 |
+
return -1; // No valid transition
|
| 572 |
+
}
|
| 573 |
+
|
| 574 |
+
// ========================================
|
| 575 |
+
// PUBLIC API
|
| 576 |
+
// ========================================
|
| 577 |
+
|
| 578 |
+
public void ResetStory()
|
| 579 |
+
{
|
| 580 |
+
ShowState(0);
|
| 581 |
+
}
|
| 582 |
+
|
| 583 |
+
public override void OnDeserialization()
|
| 584 |
+
{
|
| 585 |
+
// Sync state when variable is updated
|
| 586 |
+
if (syncAcrossPlayers)
|
| 587 |
+
{
|
| 588 |
+
ShowState(currentStateIndex);
|
| 589 |
+
}
|
| 590 |
+
}
|
| 591 |
+
}
|
| 592 |
+
'''
|
| 593 |
+
|
| 594 |
+
explanation = """VRChat UdonSharp Export Generated!
|
| 595 |
+
|
| 596 |
+
Setup in Unity:
|
| 597 |
+
1. Create new UdonSharpProgramAsset
|
| 598 |
+
2. Copy this code to the .cs file
|
| 599 |
+
3. Create GameObject in scene
|
| 600 |
+
4. Add UdonBehaviour component
|
| 601 |
+
5. Link the UdonSharpProgramAsset
|
| 602 |
+
6. Set up UI in Inspector
|
| 603 |
+
|
| 604 |
+
UI Setup:
|
| 605 |
+
- Canvas with TextMeshPro for description
|
| 606 |
+
- Button array for choices (4 recommended)
|
| 607 |
+
- Link to descriptionText, choiceButtons, choiceButtonTexts
|
| 608 |
+
|
| 609 |
+
Button Events:
|
| 610 |
+
- Button 0: OnClick -> UdonBehaviour.SendCustomEvent("OnChoice0")
|
| 611 |
+
- Button 1: OnClick -> UdonBehaviour.SendCustomEvent("OnChoice1")
|
| 612 |
+
- etc.
|
| 613 |
+
|
| 614 |
+
Multiplayer Sync:
|
| 615 |
+
- Enable syncAcrossPlayers for shared experience
|
| 616 |
+
- Uses UdonSynced for state synchronization
|
| 617 |
+
|
| 618 |
+
Requirements:
|
| 619 |
+
- Unity 2019.4.31f1 (VRChat current)
|
| 620 |
+
- VRChat SDK3 - Worlds
|
| 621 |
+
- UdonSharp package
|
| 622 |
+
|
| 623 |
+
Testing:
|
| 624 |
+
- Use VRChat SDK control panel
|
| 625 |
+
- Build & Test locally first
|
| 626 |
+
- Check Udon console for errors
|
| 627 |
+
|
| 628 |
+
Publishing:
|
| 629 |
+
- Build and publish via VRChat SDK
|
| 630 |
+
- 30K+ concurrent VR users
|
| 631 |
+
"""
|
| 632 |
+
|
| 633 |
+
return explanation, cs_code
|
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GameMaker Studio 2 exporter for game configs."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def export_to_gamemaker(config_json):
|
| 7 |
+
"""Export config to GameMaker GML script."""
|
| 8 |
+
try:
|
| 9 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 10 |
+
except:
|
| 11 |
+
return "Error: Invalid JSON", ""
|
| 12 |
+
|
| 13 |
+
gml = '''// GameMaker Studio 2 - Text Adventure Game
|
| 14 |
+
// Create this in a persistent controller object
|
| 15 |
+
|
| 16 |
+
// ========== GAME CONFIG ==========
|
| 17 |
+
global.game_config = ds_map_create();
|
| 18 |
+
'''
|
| 19 |
+
|
| 20 |
+
# Add states to config
|
| 21 |
+
for location, states in config.items():
|
| 22 |
+
for state_name, state_data in states.items():
|
| 23 |
+
state_key = f"{location}_{state_name}"
|
| 24 |
+
gml += f'\n// State: {state_key}\n'
|
| 25 |
+
gml += f'var state_{state_key} = ds_map_create();\n'
|
| 26 |
+
gml += f'ds_map_add(state_{state_key}, "description", "{state_data.get("description", "")[:100]}");\n'
|
| 27 |
+
|
| 28 |
+
choices = state_data.get("choices", [])
|
| 29 |
+
gml += f'var choices_{state_key} = ds_list_create();\n'
|
| 30 |
+
for choice in choices:
|
| 31 |
+
choice_escaped = str(choice).replace('"', '\\"')
|
| 32 |
+
gml += f'ds_list_add(choices_{state_key}, "{choice_escaped}");\n'
|
| 33 |
+
gml += f'ds_map_add_list(state_{state_key}, "choices", choices_{state_key});\n'
|
| 34 |
+
gml += f'ds_map_add_map(global.game_config, "{state_key}", state_{state_key});\n'
|
| 35 |
+
|
| 36 |
+
gml += '''
|
| 37 |
+
// ========== GAME STATE ==========
|
| 38 |
+
global.current_location = "''' + list(config.keys())[0] + '''";
|
| 39 |
+
global.current_state = "''' + list(config[list(config.keys())[0]].keys())[0] + '''";
|
| 40 |
+
global.inventory = ds_list_create();
|
| 41 |
+
global.money = 0;
|
| 42 |
+
|
| 43 |
+
// ========== GAME FUNCTIONS ==========
|
| 44 |
+
function get_current_state() {
|
| 45 |
+
var state_key = global.current_location + "_" + global.current_state;
|
| 46 |
+
return ds_map_find_value(global.game_config, state_key);
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
function get_description() {
|
| 50 |
+
var state = get_current_state();
|
| 51 |
+
if (state != undefined) {
|
| 52 |
+
return ds_map_find_value(state, "description");
|
| 53 |
+
}
|
| 54 |
+
return "No description";
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
function get_choices() {
|
| 58 |
+
var state = get_current_state();
|
| 59 |
+
if (state != undefined) {
|
| 60 |
+
return ds_map_find_value(state, "choices");
|
| 61 |
+
}
|
| 62 |
+
return undefined;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
function make_choice(choice_index) {
|
| 66 |
+
var choices = get_choices();
|
| 67 |
+
if (choices != undefined && choice_index < ds_list_size(choices)) {
|
| 68 |
+
// Handle transition logic here
|
| 69 |
+
show_debug_message("Choice made: " + ds_list_find_value(choices, choice_index));
|
| 70 |
+
}
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
// ========== DRAW EVENT (for obj_game_controller) ==========
|
| 74 |
+
// In Draw GUI event:
|
| 75 |
+
/*
|
| 76 |
+
draw_set_font(fnt_game);
|
| 77 |
+
draw_set_color(c_white);
|
| 78 |
+
|
| 79 |
+
// Draw description
|
| 80 |
+
draw_text_ext(50, 50, get_description(), 20, 700);
|
| 81 |
+
|
| 82 |
+
// Draw choices
|
| 83 |
+
var choices = get_choices();
|
| 84 |
+
if (choices != undefined) {
|
| 85 |
+
for (var i = 0; i < ds_list_size(choices); i++) {
|
| 86 |
+
var choice_text = string(i + 1) + ". " + ds_list_find_value(choices, i);
|
| 87 |
+
draw_text(50, 200 + (i * 30), choice_text);
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
*/
|
| 91 |
+
'''
|
| 92 |
+
|
| 93 |
+
explanation = """GameMaker Studio 2 Export:
|
| 94 |
+
|
| 95 |
+
1. Create a new object: obj_game_controller
|
| 96 |
+
2. Mark it as Persistent
|
| 97 |
+
3. In Create event: paste the config and state code
|
| 98 |
+
4. In Draw GUI event: use the drawing code
|
| 99 |
+
5. In keyboard events: call make_choice() with player input
|
| 100 |
+
|
| 101 |
+
The code uses ds_maps and ds_lists for state management."""
|
| 102 |
+
|
| 103 |
+
return explanation, gml
|
|
@@ -0,0 +1,596 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HTML-based exporters for game configs.
|
| 2 |
+
|
| 3 |
+
Includes exporters for:
|
| 4 |
+
- 2D Map SVG visualization
|
| 5 |
+
- MovingDotSpace interactive HTML game
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
import math
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def export_to_2d_map(config_json):
|
| 14 |
+
"""Export config to 2D map layout visualization."""
|
| 15 |
+
try:
|
| 16 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 17 |
+
except:
|
| 18 |
+
return "Error: Invalid JSON", ""
|
| 19 |
+
|
| 20 |
+
# Generate SVG map
|
| 21 |
+
locations = list(config.keys())
|
| 22 |
+
num_locations = len(locations)
|
| 23 |
+
|
| 24 |
+
svg_width = 800
|
| 25 |
+
svg_height = 600
|
| 26 |
+
|
| 27 |
+
svg = f'''<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {svg_width} {svg_height}">
|
| 28 |
+
<style>
|
| 29 |
+
.location {{ fill: #4a90d9; stroke: #2c5aa0; stroke-width: 2; }}
|
| 30 |
+
.location:hover {{ fill: #6ba3e0; }}
|
| 31 |
+
.label {{ font-family: Arial; font-size: 12px; fill: white; text-anchor: middle; }}
|
| 32 |
+
.connection {{ stroke: #666; stroke-width: 2; fill: none; }}
|
| 33 |
+
</style>
|
| 34 |
+
<rect width="100%" height="100%" fill="#1a1a2e"/>
|
| 35 |
+
'''
|
| 36 |
+
|
| 37 |
+
# Calculate positions in a circle
|
| 38 |
+
center_x, center_y = svg_width // 2, svg_height // 2
|
| 39 |
+
radius = min(svg_width, svg_height) // 3
|
| 40 |
+
|
| 41 |
+
positions = {}
|
| 42 |
+
for i, location in enumerate(locations):
|
| 43 |
+
angle = (2 * math.pi * i) / num_locations - math.pi / 2
|
| 44 |
+
x = center_x + radius * math.cos(angle)
|
| 45 |
+
y = center_y + radius * math.sin(angle)
|
| 46 |
+
positions[location] = (x, y)
|
| 47 |
+
|
| 48 |
+
# Draw connections based on transitions
|
| 49 |
+
connections_drawn = set()
|
| 50 |
+
for location, states in config.items():
|
| 51 |
+
for state_name, state_data in states.items():
|
| 52 |
+
transitions = state_data.get("transitions", {})
|
| 53 |
+
for choice, target in transitions.items():
|
| 54 |
+
if isinstance(target, str) and "/" in target:
|
| 55 |
+
target_loc = target.split("/")[0]
|
| 56 |
+
if target_loc in positions:
|
| 57 |
+
conn_key = tuple(sorted([location, target_loc]))
|
| 58 |
+
if conn_key not in connections_drawn:
|
| 59 |
+
x1, y1 = positions[location]
|
| 60 |
+
x2, y2 = positions[target_loc]
|
| 61 |
+
svg += f' <line class="connection" x1="{x1}" y1="{y1}" x2="{x2}" y2="{y2}"/>\n'
|
| 62 |
+
connections_drawn.add(conn_key)
|
| 63 |
+
|
| 64 |
+
# Draw location nodes
|
| 65 |
+
for location, (x, y) in positions.items():
|
| 66 |
+
num_states = len(config[location])
|
| 67 |
+
node_size = 30 + num_states * 5
|
| 68 |
+
svg += f' <circle class="location" cx="{x}" cy="{y}" r="{node_size}"/>\n'
|
| 69 |
+
svg += f' <text class="label" x="{x}" y="{y + 4}">{location}</text>\n'
|
| 70 |
+
|
| 71 |
+
svg += '</svg>'
|
| 72 |
+
|
| 73 |
+
# Also generate a text-based map
|
| 74 |
+
text_map = "=== 2D MAP LAYOUT ===\n\n"
|
| 75 |
+
text_map += "Locations and their states:\n"
|
| 76 |
+
for location, states in config.items():
|
| 77 |
+
text_map += f"\n[{location}]\n"
|
| 78 |
+
for state_name in states.keys():
|
| 79 |
+
text_map += f" - {state_name}\n"
|
| 80 |
+
|
| 81 |
+
text_map += "\nConnections:\n"
|
| 82 |
+
for conn in connections_drawn:
|
| 83 |
+
text_map += f" {conn[0]} <---> {conn[1]}\n"
|
| 84 |
+
|
| 85 |
+
explanation = """2D Map Export:
|
| 86 |
+
|
| 87 |
+
The SVG shows your game's location layout:
|
| 88 |
+
- Each circle is a location
|
| 89 |
+
- Circle size = number of states in that location
|
| 90 |
+
- Lines show connections between locations
|
| 91 |
+
|
| 92 |
+
You can:
|
| 93 |
+
1. Copy the SVG and open in a browser
|
| 94 |
+
2. Use as a reference for level design
|
| 95 |
+
3. Import into vector editors (Figma, Inkscape)"""
|
| 96 |
+
|
| 97 |
+
return explanation, svg
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def export_to_movingdotspace(config_json, autotravel=True):
|
| 101 |
+
"""Export game config to a playable MovingDotSpace HTML file.
|
| 102 |
+
|
| 103 |
+
Converts the game config into an interactive 2D exploration game
|
| 104 |
+
where players use arrow keys to move and collide with targets to
|
| 105 |
+
trigger state-machine modals. Locations are locked until the player
|
| 106 |
+
transitions to them via game choices.
|
| 107 |
+
|
| 108 |
+
Args:
|
| 109 |
+
config_json: The game config JSON string or dict
|
| 110 |
+
autotravel: If True, teleport player to new locations on transition.
|
| 111 |
+
If False, just unlock the location and player must walk there.
|
| 112 |
+
"""
|
| 113 |
+
try:
|
| 114 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 115 |
+
except json.JSONDecodeError as e:
|
| 116 |
+
return f"JSON Error: {str(e)}", ""
|
| 117 |
+
|
| 118 |
+
# === EXTRACT STATS DATA FROM CONFIG ===
|
| 119 |
+
extracted_objectives = []
|
| 120 |
+
extracted_inventory = []
|
| 121 |
+
extracted_skills = []
|
| 122 |
+
starting_money = 1000 # Default
|
| 123 |
+
|
| 124 |
+
# Check for MovingDotSpace metadata (from Big RPG Scale export)
|
| 125 |
+
mds_meta = config.get("_movingdotspace_meta", {})
|
| 126 |
+
if mds_meta:
|
| 127 |
+
# Use metadata if available
|
| 128 |
+
extracted_inventory = mds_meta.get("inventory", [])
|
| 129 |
+
extracted_skills = mds_meta.get("skills", [])
|
| 130 |
+
extracted_objectives = mds_meta.get("objectives", [])
|
| 131 |
+
starting_money = mds_meta.get("startingMoney", 100)
|
| 132 |
+
|
| 133 |
+
# Also parse through config to find additional quests, items, skills, and money from on_enter effects
|
| 134 |
+
for location_key, location_data in config.items():
|
| 135 |
+
if location_key.startswith("_"): # Skip metadata keys
|
| 136 |
+
continue
|
| 137 |
+
if not isinstance(location_data, dict):
|
| 138 |
+
continue
|
| 139 |
+
|
| 140 |
+
for state_key, state_data in location_data.items():
|
| 141 |
+
if not isinstance(state_data, dict):
|
| 142 |
+
continue
|
| 143 |
+
|
| 144 |
+
# Check on_enter effects for money and quests
|
| 145 |
+
on_enter = state_data.get("on_enter", {})
|
| 146 |
+
if isinstance(on_enter, dict):
|
| 147 |
+
# Extract starting money (override if found)
|
| 148 |
+
if "set_money" in on_enter:
|
| 149 |
+
starting_money = on_enter["set_money"]
|
| 150 |
+
|
| 151 |
+
# Extract quests as objectives (add to existing)
|
| 152 |
+
if "start_mission" in on_enter:
|
| 153 |
+
missions = on_enter["start_mission"]
|
| 154 |
+
if isinstance(missions, list):
|
| 155 |
+
for mission in missions:
|
| 156 |
+
if not any(obj["id"] == mission for obj in extracted_objectives):
|
| 157 |
+
extracted_objectives.append({
|
| 158 |
+
"id": mission.replace("_", " ").title(),
|
| 159 |
+
"name": f"Complete: {mission.replace('_', ' ').title()}",
|
| 160 |
+
"complete": False
|
| 161 |
+
})
|
| 162 |
+
elif isinstance(missions, str):
|
| 163 |
+
if not any(obj["id"] == missions for obj in extracted_objectives):
|
| 164 |
+
extracted_objectives.append({
|
| 165 |
+
"id": missions.replace("_", " ").title(),
|
| 166 |
+
"name": f"Complete: {missions.replace('_', ' ').title()}",
|
| 167 |
+
"complete": False
|
| 168 |
+
})
|
| 169 |
+
|
| 170 |
+
# Extract items added to inventory (add to existing)
|
| 171 |
+
if "add_item" in on_enter:
|
| 172 |
+
items = on_enter["add_item"]
|
| 173 |
+
if isinstance(items, list):
|
| 174 |
+
for item in items:
|
| 175 |
+
if isinstance(item, dict):
|
| 176 |
+
extracted_inventory.append(item)
|
| 177 |
+
elif isinstance(item, str):
|
| 178 |
+
extracted_inventory.append({
|
| 179 |
+
"type": "item",
|
| 180 |
+
"name": item.replace("_", " ").title(),
|
| 181 |
+
"description": f"A {item.replace('_', ' ')}"
|
| 182 |
+
})
|
| 183 |
+
elif isinstance(items, str):
|
| 184 |
+
extracted_inventory.append({
|
| 185 |
+
"type": "item",
|
| 186 |
+
"name": items.replace("_", " ").title(),
|
| 187 |
+
"description": f"A {items.replace('_', ' ')}"
|
| 188 |
+
})
|
| 189 |
+
|
| 190 |
+
# If no objectives found, add default exploration objective
|
| 191 |
+
if not extracted_objectives:
|
| 192 |
+
extracted_objectives.append({
|
| 193 |
+
"id": "Main",
|
| 194 |
+
"name": "Explore all locations",
|
| 195 |
+
"complete": False
|
| 196 |
+
})
|
| 197 |
+
|
| 198 |
+
# Generate targets from game config locations (exclude metadata and special UI locations)
|
| 199 |
+
# Special locations that are UI/menu states, not map targets:
|
| 200 |
+
# - game_start: intro state
|
| 201 |
+
# - inventory: player inventory UI
|
| 202 |
+
# - quest_journal: quest tracking UI
|
| 203 |
+
special_locations = {"_movingdotspace_meta", "game_start", "inventory", "quest_journal"}
|
| 204 |
+
targets = []
|
| 205 |
+
locations = [k for k in config.keys() if k not in special_locations and not k.startswith("_")]
|
| 206 |
+
first_location = locations[0] if locations else None
|
| 207 |
+
|
| 208 |
+
# Build a map of location keys to their display names for JS
|
| 209 |
+
location_map = {}
|
| 210 |
+
|
| 211 |
+
for i, location_key in enumerate(locations):
|
| 212 |
+
location_data = config[location_key]
|
| 213 |
+
display_name = location_key.replace("_", " ").title()
|
| 214 |
+
location_map[location_key] = display_name
|
| 215 |
+
|
| 216 |
+
# Calculate grid position (4 columns)
|
| 217 |
+
x = 150 + (i % 4) * 350
|
| 218 |
+
y = 150 + (i // 4) * 250
|
| 219 |
+
|
| 220 |
+
# Build modalStates from game config states
|
| 221 |
+
modal_states = {}
|
| 222 |
+
first_state = None
|
| 223 |
+
|
| 224 |
+
for state_key, state_data in location_data.items():
|
| 225 |
+
if not isinstance(state_data, dict):
|
| 226 |
+
continue
|
| 227 |
+
|
| 228 |
+
if first_state is None:
|
| 229 |
+
first_state = state_key
|
| 230 |
+
|
| 231 |
+
# Convert choices to items with actions
|
| 232 |
+
items = []
|
| 233 |
+
transitions = state_data.get("transitions", {})
|
| 234 |
+
for choice in state_data.get("choices", []):
|
| 235 |
+
# Get transition target for this choice
|
| 236 |
+
target_ref = transitions.get(choice, "close")
|
| 237 |
+
|
| 238 |
+
# Parse target reference - check if it's a cross-location transition
|
| 239 |
+
if isinstance(target_ref, str):
|
| 240 |
+
if "/" in target_ref:
|
| 241 |
+
# Format: "location/state" - check if location is different
|
| 242 |
+
parts = target_ref.split("/", 1)
|
| 243 |
+
target_location = parts[0]
|
| 244 |
+
target_state = parts[1] if len(parts) > 1 else "arrive"
|
| 245 |
+
|
| 246 |
+
if target_location != location_key:
|
| 247 |
+
# Cross-location transition - mark with crossloc prefix
|
| 248 |
+
action = f"crossloc:{target_ref}"
|
| 249 |
+
else:
|
| 250 |
+
# Same location - just use the state name
|
| 251 |
+
action = target_state
|
| 252 |
+
else:
|
| 253 |
+
action = target_ref
|
| 254 |
+
else:
|
| 255 |
+
action = "close"
|
| 256 |
+
|
| 257 |
+
items.append({
|
| 258 |
+
"label": choice,
|
| 259 |
+
"action": action
|
| 260 |
+
})
|
| 261 |
+
|
| 262 |
+
# Get image from media
|
| 263 |
+
media = state_data.get("media", {})
|
| 264 |
+
modal_img = media.get("image", "") if isinstance(media, dict) else ""
|
| 265 |
+
|
| 266 |
+
modal_states[state_key] = {
|
| 267 |
+
"title": state_key.replace("_", " ").title(),
|
| 268 |
+
"content": state_data.get("description", ""),
|
| 269 |
+
"items": items,
|
| 270 |
+
"consequences": [],
|
| 271 |
+
"modalimg": modal_img
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
# Create target with smmodal collision type
|
| 275 |
+
# Include locationKey for lock/unlock tracking
|
| 276 |
+
target = {
|
| 277 |
+
"name": display_name,
|
| 278 |
+
"locationKey": location_key,
|
| 279 |
+
"x": x,
|
| 280 |
+
"y": y,
|
| 281 |
+
"collisionType": "smmodal",
|
| 282 |
+
"modalStates": modal_states,
|
| 283 |
+
"locked": location_key != first_location # Only first location unlocked
|
| 284 |
+
}
|
| 285 |
+
targets.append(target)
|
| 286 |
+
|
| 287 |
+
# Build the MovingDotSpace config structure with extracted stats
|
| 288 |
+
movingdot_config = {
|
| 289 |
+
"themes": {
|
| 290 |
+
"Exported Game": {
|
| 291 |
+
"background": "#2d3436",
|
| 292 |
+
"backgroundColor": "#2d3436",
|
| 293 |
+
"inventory": extracted_inventory,
|
| 294 |
+
"skills": extracted_skills,
|
| 295 |
+
"startingMoney": starting_money,
|
| 296 |
+
"autotravel": autotravel,
|
| 297 |
+
"persistentTargets": [],
|
| 298 |
+
"story": [{
|
| 299 |
+
"part": 0,
|
| 300 |
+
"objectives": extracted_objectives,
|
| 301 |
+
"targets": targets
|
| 302 |
+
}],
|
| 303 |
+
"actions": {}
|
| 304 |
+
}
|
| 305 |
+
}
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
# Read the HTML template
|
| 309 |
+
template_path = os.path.join(os.path.dirname(__file__), "..", "MovingDotSpaceExport", "MovingDotSpaceStandalone.html")
|
| 310 |
+
|
| 311 |
+
try:
|
| 312 |
+
with open(template_path, 'r', encoding='utf-8') as f:
|
| 313 |
+
html_template = f.read()
|
| 314 |
+
except FileNotFoundError:
|
| 315 |
+
return "Error: MovingDotSpaceStandalone.html template not found", ""
|
| 316 |
+
|
| 317 |
+
# Convert config to JSON and create the initialization code
|
| 318 |
+
config_json_str = json.dumps(movingdot_config, indent=2)
|
| 319 |
+
location_map_json = json.dumps(location_map)
|
| 320 |
+
|
| 321 |
+
# Custom JS to handle location locking and cross-location transitions
|
| 322 |
+
autotravel_js = "true" if autotravel else "false"
|
| 323 |
+
init_code = f"""
|
| 324 |
+
// Auto-generated config from Game Config IDE
|
| 325 |
+
// This overrides the default themes after initial load
|
| 326 |
+
(function() {{
|
| 327 |
+
const exportedConfig = {config_json_str};
|
| 328 |
+
const locationMap = {location_map_json};
|
| 329 |
+
|
| 330 |
+
// Track unlocked locations
|
| 331 |
+
const unlockedLocations = new Set(['{first_location}']);
|
| 332 |
+
|
| 333 |
+
// Autotravel setting - if true, teleport to new locations; if false, just unlock them
|
| 334 |
+
let autotravelEnabled = {autotravel_js};
|
| 335 |
+
|
| 336 |
+
// Use setTimeout to ensure this runs after the default init()
|
| 337 |
+
setTimeout(function() {{
|
| 338 |
+
initMovingDotSpace(exportedConfig);
|
| 339 |
+
|
| 340 |
+
// Explicitly set stats from config after theme is loaded
|
| 341 |
+
const themeConfig = exportedConfig.themes['Exported Game'];
|
| 342 |
+
if (themeConfig) {{
|
| 343 |
+
// Set starting money
|
| 344 |
+
if (themeConfig.startingMoney !== undefined) {{
|
| 345 |
+
gameState.playerStats.money = themeConfig.startingMoney;
|
| 346 |
+
}}
|
| 347 |
+
// Ensure inventory is loaded
|
| 348 |
+
if (themeConfig.inventory && themeConfig.inventory.length > 0) {{
|
| 349 |
+
gameState.inventory = themeConfig.inventory;
|
| 350 |
+
}}
|
| 351 |
+
// Ensure skills are loaded
|
| 352 |
+
if (themeConfig.skills && themeConfig.skills.length > 0) {{
|
| 353 |
+
gameState.skills = themeConfig.skills;
|
| 354 |
+
}}
|
| 355 |
+
// Load objectives from story part 0 (they should already be there, but ensure)
|
| 356 |
+
const storyPart = themeConfig.story && themeConfig.story[0];
|
| 357 |
+
if (storyPart && storyPart.objectives) {{
|
| 358 |
+
gameState.objectives = storyPart.objectives;
|
| 359 |
+
}}
|
| 360 |
+
// Re-render stats to show updated values
|
| 361 |
+
renderStats();
|
| 362 |
+
console.log('Stats loaded:', {{
|
| 363 |
+
money: gameState.playerStats.money,
|
| 364 |
+
inventory: gameState.inventory.length,
|
| 365 |
+
skills: gameState.skills.length,
|
| 366 |
+
objectives: gameState.objectives.length
|
| 367 |
+
}});
|
| 368 |
+
}}
|
| 369 |
+
|
| 370 |
+
// Create autotravel toggle button
|
| 371 |
+
const controlsDiv = document.createElement('div');
|
| 372 |
+
controlsDiv.id = 'autotravel-controls';
|
| 373 |
+
controlsDiv.style.cssText = 'position: absolute; top: 10px; right: 10px; z-index: 1000; background: rgba(0,0,0,0.7); padding: 8px 12px; border-radius: 5px; color: white; font-size: 12px;';
|
| 374 |
+
controlsDiv.innerHTML = `
|
| 375 |
+
<label style="cursor: pointer; display: flex; align-items: center; gap: 6px;">
|
| 376 |
+
<input type="checkbox" id="autotravel-toggle" ${{autotravelEnabled ? 'checked' : ''}} style="cursor: pointer;">
|
| 377 |
+
<span>Autotravel</span>
|
| 378 |
+
</label>
|
| 379 |
+
`;
|
| 380 |
+
document.getElementById('game-container').appendChild(controlsDiv);
|
| 381 |
+
|
| 382 |
+
document.getElementById('autotravel-toggle').addEventListener('change', function(e) {{
|
| 383 |
+
autotravelEnabled = e.target.checked;
|
| 384 |
+
const status = autotravelEnabled ? 'ON - Will teleport to new locations' : 'OFF - Walk to unlocked locations manually';
|
| 385 |
+
elements.overlayText.textContent = 'Autotravel: ' + status;
|
| 386 |
+
}});
|
| 387 |
+
|
| 388 |
+
// Override the handleSMModalAction to support cross-location transitions
|
| 389 |
+
const originalHandleSMModalAction = window.handleSMModalAction || function() {{}};
|
| 390 |
+
|
| 391 |
+
window.handleSMModalActionCustom = function(item) {{
|
| 392 |
+
const action = item.action;
|
| 393 |
+
|
| 394 |
+
// Check for cross-location transition
|
| 395 |
+
if (action.startsWith('crossloc:')) {{
|
| 396 |
+
const fullRef = action.substring(9); // Remove "crossloc:"
|
| 397 |
+
const parts = fullRef.split('/');
|
| 398 |
+
const targetLocation = parts[0];
|
| 399 |
+
const targetState = parts[1];
|
| 400 |
+
|
| 401 |
+
// Unlock the target location
|
| 402 |
+
unlockedLocations.add(targetLocation);
|
| 403 |
+
|
| 404 |
+
// Update target visibility
|
| 405 |
+
updateTargetVisibility();
|
| 406 |
+
|
| 407 |
+
// Find the target element for that location
|
| 408 |
+
const targetData = gameState.targets.find(t => t.locationKey === targetLocation);
|
| 409 |
+
if (targetData) {{
|
| 410 |
+
// Close current modal first
|
| 411 |
+
closeSMModal();
|
| 412 |
+
|
| 413 |
+
if (autotravelEnabled) {{
|
| 414 |
+
// Autotravel ON: Teleport player near the target
|
| 415 |
+
gameState.dotPosition.x = targetData.x + 50;
|
| 416 |
+
gameState.dotPosition.y = targetData.y;
|
| 417 |
+
renderDot();
|
| 418 |
+
|
| 419 |
+
// After a brief delay, open the new location's modal at the target state
|
| 420 |
+
setTimeout(function() {{
|
| 421 |
+
if (targetData.modalStates && targetData.modalStates[targetState]) {{
|
| 422 |
+
gameState.currentSMModalStates = targetData.modalStates;
|
| 423 |
+
gameState.currentSMModalState = targetState;
|
| 424 |
+
gameState.isSMModalOpen = true;
|
| 425 |
+
renderSMModalState();
|
| 426 |
+
elements.smModal.classList.add('open');
|
| 427 |
+
}}
|
| 428 |
+
}}, 300);
|
| 429 |
+
}} else {{
|
| 430 |
+
// Autotravel OFF: Just show message, player must walk there
|
| 431 |
+
const locationName = locationMap[targetLocation] || targetLocation;
|
| 432 |
+
elements.overlayText.textContent = `Unlocked: ${{locationName}}! Walk there using arrow keys.`;
|
| 433 |
+
}}
|
| 434 |
+
}}
|
| 435 |
+
return;
|
| 436 |
+
}}
|
| 437 |
+
|
| 438 |
+
// Check if action is a state transition within current modal
|
| 439 |
+
if (gameState.currentSMModalStates && gameState.currentSMModalStates[action]) {{
|
| 440 |
+
gameState.currentSMModalState = action;
|
| 441 |
+
renderSMModalState();
|
| 442 |
+
}} else if (action === 'close' || !gameState.currentSMModalStates) {{
|
| 443 |
+
// Explicit close or no modal states - just close
|
| 444 |
+
closeSMModal();
|
| 445 |
+
}} else {{
|
| 446 |
+
// Execute as action and close
|
| 447 |
+
executeAction(action);
|
| 448 |
+
closeSMModal();
|
| 449 |
+
}}
|
| 450 |
+
}};
|
| 451 |
+
|
| 452 |
+
// Override renderSMModalState to use our custom handler
|
| 453 |
+
const originalRenderSMModalState = window.renderSMModalState;
|
| 454 |
+
window.renderSMModalState = function() {{
|
| 455 |
+
const states = gameState.currentSMModalStates;
|
| 456 |
+
if (!states) {{
|
| 457 |
+
console.error("No modal states available");
|
| 458 |
+
return;
|
| 459 |
+
}}
|
| 460 |
+
const currentState = states[gameState.currentSMModalState];
|
| 461 |
+
|
| 462 |
+
if (!currentState) {{
|
| 463 |
+
console.error("State not found:", gameState.currentSMModalState);
|
| 464 |
+
return;
|
| 465 |
+
}}
|
| 466 |
+
|
| 467 |
+
elements.smModalTitle.textContent = currentState.title || '';
|
| 468 |
+
elements.smModalContent.textContent = currentState.content || '';
|
| 469 |
+
|
| 470 |
+
// Render actions with custom handler
|
| 471 |
+
elements.smModalActions.innerHTML = '';
|
| 472 |
+
(currentState.items || []).forEach(item => {{
|
| 473 |
+
const btn = document.createElement('button');
|
| 474 |
+
btn.textContent = item.label;
|
| 475 |
+
btn.addEventListener('click', () => handleSMModalActionCustom(item));
|
| 476 |
+
elements.smModalActions.appendChild(btn);
|
| 477 |
+
}});
|
| 478 |
+
|
| 479 |
+
// Handle image
|
| 480 |
+
if (currentState.modalimg) {{
|
| 481 |
+
elements.smModalImage.src = currentState.modalimg;
|
| 482 |
+
elements.smModalImage.style.display = 'block';
|
| 483 |
+
}} else {{
|
| 484 |
+
elements.smModalImage.style.display = 'none';
|
| 485 |
+
}}
|
| 486 |
+
|
| 487 |
+
// Execute consequences
|
| 488 |
+
(currentState.consequences || []).forEach(consequence => {{
|
| 489 |
+
executeAction(consequence);
|
| 490 |
+
}});
|
| 491 |
+
}};
|
| 492 |
+
|
| 493 |
+
// Function to update target visibility based on unlocked locations
|
| 494 |
+
function updateTargetVisibility() {{
|
| 495 |
+
const targetsContainer = document.getElementById('targets-container');
|
| 496 |
+
const targetElements = targetsContainer.querySelectorAll('.target');
|
| 497 |
+
const labelElements = targetsContainer.querySelectorAll('.target-label');
|
| 498 |
+
|
| 499 |
+
gameState.targets.forEach((target, index) => {{
|
| 500 |
+
const isUnlocked = unlockedLocations.has(target.locationKey);
|
| 501 |
+
target.locked = !isUnlocked;
|
| 502 |
+
|
| 503 |
+
// Find corresponding DOM elements
|
| 504 |
+
const targetEl = targetElements[index];
|
| 505 |
+
const labelEl = labelElements[index];
|
| 506 |
+
|
| 507 |
+
if (targetEl) {{
|
| 508 |
+
if (isUnlocked) {{
|
| 509 |
+
targetEl.style.backgroundColor = 'red';
|
| 510 |
+
targetEl.style.opacity = '1';
|
| 511 |
+
targetEl.style.pointerEvents = 'auto';
|
| 512 |
+
}} else {{
|
| 513 |
+
targetEl.style.backgroundColor = '#555';
|
| 514 |
+
targetEl.style.opacity = '0.4';
|
| 515 |
+
}}
|
| 516 |
+
}}
|
| 517 |
+
if (labelEl) {{
|
| 518 |
+
labelEl.style.opacity = isUnlocked ? '1' : '0.4';
|
| 519 |
+
if (!isUnlocked) {{
|
| 520 |
+
labelEl.textContent = target.name + ' (Locked)';
|
| 521 |
+
}} else {{
|
| 522 |
+
labelEl.textContent = target.name;
|
| 523 |
+
}}
|
| 524 |
+
}}
|
| 525 |
+
}});
|
| 526 |
+
}}
|
| 527 |
+
|
| 528 |
+
// Override handleCollision to check if location is unlocked
|
| 529 |
+
const originalHandleCollision = window.handleCollision;
|
| 530 |
+
window.handleCollision = function(target) {{
|
| 531 |
+
// Check if target is locked
|
| 532 |
+
if (target.locked) {{
|
| 533 |
+
elements.overlayText.textContent = 'This location is locked. Find a way to unlock it first!';
|
| 534 |
+
return;
|
| 535 |
+
}}
|
| 536 |
+
// Call original handler
|
| 537 |
+
originalHandleCollision(target);
|
| 538 |
+
}};
|
| 539 |
+
|
| 540 |
+
// Initial visibility update
|
| 541 |
+
setTimeout(updateTargetVisibility, 100);
|
| 542 |
+
|
| 543 |
+
}}, 50);
|
| 544 |
+
}})();
|
| 545 |
+
"""
|
| 546 |
+
|
| 547 |
+
html_output = html_template.replace("/* CONFIG_PLACEHOLDER */", init_code)
|
| 548 |
+
|
| 549 |
+
# Build stats summary for explanation
|
| 550 |
+
stats_summary = []
|
| 551 |
+
if extracted_objectives:
|
| 552 |
+
stats_summary.append(f"{len(extracted_objectives)} objectives")
|
| 553 |
+
if extracted_inventory:
|
| 554 |
+
stats_summary.append(f"{len(extracted_inventory)} starting items")
|
| 555 |
+
stats_summary.append(f"{starting_money} starting gold")
|
| 556 |
+
|
| 557 |
+
autotravel_status = "ON (teleport to locations)" if autotravel else "OFF (walk manually)"
|
| 558 |
+
|
| 559 |
+
explanation = f"""MovingDotSpace Export:
|
| 560 |
+
|
| 561 |
+
Created a playable 2D exploration game with {len(targets)} location targets.
|
| 562 |
+
|
| 563 |
+
**Location Locking System:**
|
| 564 |
+
- Only the first location ("{first_location}") is unlocked at start
|
| 565 |
+
- Other locations appear grayed out with "(Locked)" label
|
| 566 |
+
- When you select a choice that transitions to another location, it unlocks
|
| 567 |
+
|
| 568 |
+
**Autotravel:** {autotravel_status}
|
| 569 |
+
- Toggle checkbox in top-right corner to switch modes
|
| 570 |
+
- ON: Automatically teleport to new locations when unlocked
|
| 571 |
+
- OFF: Location unlocks but you must walk there with arrow keys
|
| 572 |
+
|
| 573 |
+
**Stats Panel (bottom):**
|
| 574 |
+
- {', '.join(stats_summary)}
|
| 575 |
+
- Objectives extracted from quests/missions in your config
|
| 576 |
+
- Inventory populated from on_enter add_item effects
|
| 577 |
+
- Player stats with starting money from config
|
| 578 |
+
|
| 579 |
+
**How to Play:**
|
| 580 |
+
1. Save the HTML file and open in a browser
|
| 581 |
+
2. Click the game area to focus it
|
| 582 |
+
3. Use arrow keys to move the green dot
|
| 583 |
+
4. Collide with red targets to trigger interactions
|
| 584 |
+
5. Choose options that lead to new locations to unlock them
|
| 585 |
+
6. Toggle autotravel checkbox to control movement style
|
| 586 |
+
|
| 587 |
+
**Features:**
|
| 588 |
+
- {len(targets)} interactive locations from your config
|
| 589 |
+
- Progressive unlocking based on game choices
|
| 590 |
+
- Cross-location transitions (e.g., "village/hub") handled automatically
|
| 591 |
+
- State machine modals with your choices/transitions
|
| 592 |
+
- Live stats panel with objectives, inventory, and player stats
|
| 593 |
+
|
| 594 |
+
The HTML file is self-contained and can be shared directly."""
|
| 595 |
+
|
| 596 |
+
return explanation, html_output
|
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ink interactive fiction exporter for game configs."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def export_to_ink(config_json):
|
| 7 |
+
"""Export game config to Ink interactive fiction format (.ink)."""
|
| 8 |
+
try:
|
| 9 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 10 |
+
except json.JSONDecodeError as e:
|
| 11 |
+
return f"JSON Error: {str(e)}", ""
|
| 12 |
+
|
| 13 |
+
ink_code = '''// Ink Interactive Story
|
| 14 |
+
// Generated from Game Config IDE
|
| 15 |
+
// Test at: https://www.inklestudios.com/ink/
|
| 16 |
+
|
| 17 |
+
VAR current_location = ""
|
| 18 |
+
|
| 19 |
+
'''
|
| 20 |
+
|
| 21 |
+
first_knot = None
|
| 22 |
+
|
| 23 |
+
# Generate knots (Ink's term for passages/labels)
|
| 24 |
+
for location_key, location_data in config.items():
|
| 25 |
+
if isinstance(location_data, dict):
|
| 26 |
+
for state_key, state_data in location_data.items():
|
| 27 |
+
if isinstance(state_data, dict):
|
| 28 |
+
knot_name = f"{location_key}_{state_key}"
|
| 29 |
+
if first_knot is None:
|
| 30 |
+
first_knot = knot_name
|
| 31 |
+
|
| 32 |
+
description = state_data.get('description', 'No description')
|
| 33 |
+
choices = state_data.get('choices', [])
|
| 34 |
+
transitions = state_data.get('transitions', {})
|
| 35 |
+
|
| 36 |
+
ink_code += f"=== {knot_name} ===\n"
|
| 37 |
+
ink_code += f"~ current_location = \"{location_key}\"\n"
|
| 38 |
+
ink_code += f"{description}\n\n"
|
| 39 |
+
|
| 40 |
+
if choices:
|
| 41 |
+
for choice in choices:
|
| 42 |
+
next_state = transitions.get(choice, '') if isinstance(transitions, dict) else ''
|
| 43 |
+
|
| 44 |
+
if next_state:
|
| 45 |
+
if '_' not in next_state:
|
| 46 |
+
next_state = f"{location_key}_{next_state}"
|
| 47 |
+
ink_code += f"* [{choice}] -> {next_state}\n"
|
| 48 |
+
else:
|
| 49 |
+
ink_code += f"* [{choice}]\n"
|
| 50 |
+
ink_code += f" You chose: {choice}\n"
|
| 51 |
+
ink_code += f" -> END\n"
|
| 52 |
+
else:
|
| 53 |
+
ink_code += "-> END\n"
|
| 54 |
+
|
| 55 |
+
ink_code += "\n"
|
| 56 |
+
|
| 57 |
+
# Add start redirect at the top
|
| 58 |
+
if first_knot:
|
| 59 |
+
ink_code = f"-> {first_knot}\n\n" + ink_code
|
| 60 |
+
|
| 61 |
+
explanation = """Ink Interactive Fiction Export Generated!
|
| 62 |
+
|
| 63 |
+
Setup Instructions:
|
| 64 |
+
1. Install Inky editor: https://github.com/inkle/inky/releases
|
| 65 |
+
2. Paste this code into Inky
|
| 66 |
+
3. Click Play to test
|
| 67 |
+
4. Export to JSON for game integration
|
| 68 |
+
|
| 69 |
+
Online Testing:
|
| 70 |
+
- Ink Playground: https://www.inklestudios.com/ink/
|
| 71 |
+
- Paste code and click "Play"
|
| 72 |
+
|
| 73 |
+
Integration Options:
|
| 74 |
+
- Unity: Use ink-unity-integration package
|
| 75 |
+
- Web: Use inkjs library
|
| 76 |
+
- Godot: Use inkgd plugin
|
| 77 |
+
|
| 78 |
+
Ink Syntax Reference:
|
| 79 |
+
- === knot === : Named passage
|
| 80 |
+
- * [choice] : Player choice
|
| 81 |
+
- -> target : Redirect/jump
|
| 82 |
+
- ~ var = value : Set variable
|
| 83 |
+
- { condition: text } : Conditional text
|
| 84 |
+
"""
|
| 85 |
+
|
| 86 |
+
return explanation, ink_code
|
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ren'Py visual novel exporter for game configs."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def export_to_renpy(config_json):
|
| 7 |
+
"""Export game config to Ren'Py visual novel format (.rpy)."""
|
| 8 |
+
try:
|
| 9 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 10 |
+
except json.JSONDecodeError as e:
|
| 11 |
+
return f"JSON Error: {str(e)}", ""
|
| 12 |
+
|
| 13 |
+
rpy_code = '''# Ren'Py Visual Novel Export
|
| 14 |
+
# Generated from Game Config IDE
|
| 15 |
+
|
| 16 |
+
init python:
|
| 17 |
+
# Game state tracking
|
| 18 |
+
game_flags = {}
|
| 19 |
+
|
| 20 |
+
'''
|
| 21 |
+
|
| 22 |
+
# Track all labels for jump validation
|
| 23 |
+
all_labels = []
|
| 24 |
+
first_label = None
|
| 25 |
+
|
| 26 |
+
# First pass: collect all state names
|
| 27 |
+
for location_key, location_data in config.items():
|
| 28 |
+
if isinstance(location_data, dict):
|
| 29 |
+
for state_key, state_data in location_data.items():
|
| 30 |
+
if isinstance(state_data, dict):
|
| 31 |
+
label_name = f"{location_key}_{state_key}"
|
| 32 |
+
all_labels.append(label_name)
|
| 33 |
+
if first_label is None:
|
| 34 |
+
first_label = label_name
|
| 35 |
+
|
| 36 |
+
# Add start label
|
| 37 |
+
rpy_code += f'''label start:
|
| 38 |
+
jump {first_label or "game_start"}
|
| 39 |
+
|
| 40 |
+
'''
|
| 41 |
+
|
| 42 |
+
# Second pass: generate labels
|
| 43 |
+
for location_key, location_data in config.items():
|
| 44 |
+
if isinstance(location_data, dict):
|
| 45 |
+
for state_key, state_data in location_data.items():
|
| 46 |
+
if isinstance(state_data, dict):
|
| 47 |
+
label_name = f"{location_key}_{state_key}"
|
| 48 |
+
description = state_data.get('description', 'No description').replace('"', '\\"')
|
| 49 |
+
choices = state_data.get('choices', [])
|
| 50 |
+
transitions = state_data.get('transitions', {})
|
| 51 |
+
media = state_data.get('media', {})
|
| 52 |
+
|
| 53 |
+
rpy_code += f"label {label_name}:\n"
|
| 54 |
+
|
| 55 |
+
# Add scene/background if media exists
|
| 56 |
+
if media.get('image'):
|
| 57 |
+
img_name = media['image'].replace('saved_media/', '').replace('.', '_')
|
| 58 |
+
rpy_code += f' scene bg {img_name}\n'
|
| 59 |
+
|
| 60 |
+
# Add description as narration
|
| 61 |
+
rpy_code += f' "{description}"\n\n'
|
| 62 |
+
|
| 63 |
+
# Add choices menu
|
| 64 |
+
if choices:
|
| 65 |
+
rpy_code += " menu:\n"
|
| 66 |
+
for choice in choices:
|
| 67 |
+
choice_escaped = choice.replace('"', '\\"')
|
| 68 |
+
next_state = transitions.get(choice, '') if isinstance(transitions, dict) else ''
|
| 69 |
+
|
| 70 |
+
rpy_code += f' "{choice_escaped}":\n'
|
| 71 |
+
|
| 72 |
+
if next_state:
|
| 73 |
+
# Handle state references
|
| 74 |
+
if '_' not in next_state:
|
| 75 |
+
next_state = f"{location_key}_{next_state}"
|
| 76 |
+
if next_state in all_labels:
|
| 77 |
+
rpy_code += f' jump {next_state}\n'
|
| 78 |
+
else:
|
| 79 |
+
rpy_code += f' # Target not found: {next_state}\n'
|
| 80 |
+
rpy_code += f' "This path is not yet implemented."\n'
|
| 81 |
+
else:
|
| 82 |
+
rpy_code += f' "You chose: {choice_escaped}"\n'
|
| 83 |
+
else:
|
| 84 |
+
rpy_code += " return\n"
|
| 85 |
+
|
| 86 |
+
rpy_code += "\n"
|
| 87 |
+
|
| 88 |
+
explanation = """Ren'Py Visual Novel Export Generated!
|
| 89 |
+
|
| 90 |
+
Setup Instructions:
|
| 91 |
+
1. Download Ren'Py from https://www.renpy.org/
|
| 92 |
+
2. Create a new project
|
| 93 |
+
3. Replace game/script.rpy with this code
|
| 94 |
+
4. Add images to game/images/ folder
|
| 95 |
+
5. Run the project
|
| 96 |
+
|
| 97 |
+
Image Setup:
|
| 98 |
+
- Place background images in game/images/
|
| 99 |
+
- Name them to match the scene declarations
|
| 100 |
+
- Supported formats: .png, .jpg, .webp
|
| 101 |
+
|
| 102 |
+
Testing:
|
| 103 |
+
- Use Ren'Py launcher to test
|
| 104 |
+
- Press Shift+R to reload scripts
|
| 105 |
+
- Use developer console for debugging
|
| 106 |
+
|
| 107 |
+
Web Export:
|
| 108 |
+
- Ren'Py can export to web (HTML5)
|
| 109 |
+
- Use "Build Distributions" > "Web"
|
| 110 |
+
"""
|
| 111 |
+
|
| 112 |
+
return explanation, rpy_code
|
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Roblox Lua exporter for game configs."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def export_to_roblox(config_json):
|
| 7 |
+
"""Export game config to Roblox Lua format (ModuleScript + LocalScript)."""
|
| 8 |
+
try:
|
| 9 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 10 |
+
except json.JSONDecodeError as e:
|
| 11 |
+
return f"JSON Error: {str(e)}", ""
|
| 12 |
+
|
| 13 |
+
lua_code = '''--[[
|
| 14 |
+
Roblox Story System Export
|
| 15 |
+
Generated from Game Config IDE
|
| 16 |
+
|
| 17 |
+
Setup:
|
| 18 |
+
1. Create a ModuleScript in ReplicatedStorage named "StoryModule"
|
| 19 |
+
2. Paste this code
|
| 20 |
+
3. Create UI elements and connect to the module
|
| 21 |
+
--]]
|
| 22 |
+
|
| 23 |
+
local StoryModule = {}
|
| 24 |
+
|
| 25 |
+
-- Story States Configuration
|
| 26 |
+
StoryModule.States = {
|
| 27 |
+
'''
|
| 28 |
+
|
| 29 |
+
first_state = None
|
| 30 |
+
|
| 31 |
+
# Generate states table
|
| 32 |
+
for location_key, location_data in config.items():
|
| 33 |
+
if isinstance(location_data, dict):
|
| 34 |
+
for state_key, state_data in location_data.items():
|
| 35 |
+
if isinstance(state_data, dict):
|
| 36 |
+
state_name = f"{location_key}_{state_key}"
|
| 37 |
+
if first_state is None:
|
| 38 |
+
first_state = state_name
|
| 39 |
+
|
| 40 |
+
description = state_data.get('description', 'No description').replace('"', '\\"').replace('\n', '\\n')
|
| 41 |
+
choices = state_data.get('choices', [])
|
| 42 |
+
transitions = state_data.get('transitions', {})
|
| 43 |
+
media = state_data.get('media', {})
|
| 44 |
+
|
| 45 |
+
lua_code += f' ["{state_name}"] = {{\n'
|
| 46 |
+
lua_code += f' Description = "{description}",\n'
|
| 47 |
+
lua_code += f' Location = "{location_key}",\n'
|
| 48 |
+
|
| 49 |
+
# Choices array
|
| 50 |
+
lua_code += ' Choices = {'
|
| 51 |
+
if choices:
|
| 52 |
+
choice_strs = [f'"{c}"' for c in choices]
|
| 53 |
+
lua_code += ', '.join(choice_strs)
|
| 54 |
+
lua_code += '},\n'
|
| 55 |
+
|
| 56 |
+
# Transitions table
|
| 57 |
+
lua_code += ' Transitions = {\n'
|
| 58 |
+
if isinstance(transitions, dict):
|
| 59 |
+
for choice, next_state in transitions.items():
|
| 60 |
+
if next_state:
|
| 61 |
+
if '_' not in next_state:
|
| 62 |
+
next_state = f"{location_key}_{next_state}"
|
| 63 |
+
lua_code += f' ["{choice}"] = "{next_state}",\n'
|
| 64 |
+
lua_code += ' },\n'
|
| 65 |
+
|
| 66 |
+
# Media table
|
| 67 |
+
lua_code += ' Media = {\n'
|
| 68 |
+
if media.get('image'):
|
| 69 |
+
lua_code += f' Image = "{media["image"]}",\n'
|
| 70 |
+
if media.get('audio'):
|
| 71 |
+
lua_code += f' Audio = "{media["audio"]}",\n'
|
| 72 |
+
lua_code += ' },\n'
|
| 73 |
+
|
| 74 |
+
lua_code += ' },\n'
|
| 75 |
+
|
| 76 |
+
lua_code += f'''}}
|
| 77 |
+
|
| 78 |
+
-- Starting state
|
| 79 |
+
StoryModule.StartState = "{first_state or "start"}"
|
| 80 |
+
|
| 81 |
+
-- Current state tracker
|
| 82 |
+
local currentState = StoryModule.StartState
|
| 83 |
+
|
| 84 |
+
-- Get current state data
|
| 85 |
+
function StoryModule.GetCurrentState()
|
| 86 |
+
return StoryModule.States[currentState]
|
| 87 |
+
end
|
| 88 |
+
|
| 89 |
+
-- Get state by name
|
| 90 |
+
function StoryModule.GetState(stateName)
|
| 91 |
+
return StoryModule.States[stateName]
|
| 92 |
+
end
|
| 93 |
+
|
| 94 |
+
-- Make a choice and transition to next state
|
| 95 |
+
function StoryModule.MakeChoice(choiceText)
|
| 96 |
+
local state = StoryModule.States[currentState]
|
| 97 |
+
if state and state.Transitions[choiceText] then
|
| 98 |
+
currentState = state.Transitions[choiceText]
|
| 99 |
+
return true, StoryModule.States[currentState]
|
| 100 |
+
end
|
| 101 |
+
return false, nil
|
| 102 |
+
end
|
| 103 |
+
|
| 104 |
+
-- Reset to start
|
| 105 |
+
function StoryModule.Reset()
|
| 106 |
+
currentState = StoryModule.StartState
|
| 107 |
+
end
|
| 108 |
+
|
| 109 |
+
-- Get all available choices for current state
|
| 110 |
+
function StoryModule.GetChoices()
|
| 111 |
+
local state = StoryModule.States[currentState]
|
| 112 |
+
return state and state.Choices or {{}}
|
| 113 |
+
end
|
| 114 |
+
|
| 115 |
+
return StoryModule
|
| 116 |
+
|
| 117 |
+
--[[
|
| 118 |
+
EXAMPLE LOCALSCRIPT (put in StarterPlayerScripts):
|
| 119 |
+
|
| 120 |
+
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
| 121 |
+
local StoryModule = require(ReplicatedStorage:WaitForChild("StoryModule"))
|
| 122 |
+
local Players = game:GetService("Players")
|
| 123 |
+
local player = Players.LocalPlayer
|
| 124 |
+
local playerGui = player:WaitForChild("PlayerGui")
|
| 125 |
+
|
| 126 |
+
-- Create simple UI
|
| 127 |
+
local screenGui = Instance.new("ScreenGui")
|
| 128 |
+
screenGui.Parent = playerGui
|
| 129 |
+
|
| 130 |
+
local descLabel = Instance.new("TextLabel")
|
| 131 |
+
descLabel.Size = UDim2.new(0.8, 0, 0.3, 0)
|
| 132 |
+
descLabel.Position = UDim2.new(0.1, 0, 0.1, 0)
|
| 133 |
+
descLabel.TextWrapped = true
|
| 134 |
+
descLabel.Parent = screenGui
|
| 135 |
+
|
| 136 |
+
local function updateUI()
|
| 137 |
+
local state = StoryModule.GetCurrentState()
|
| 138 |
+
descLabel.Text = state.Description
|
| 139 |
+
|
| 140 |
+
-- Clear old buttons
|
| 141 |
+
for _, child in pairs(screenGui:GetChildren()) do
|
| 142 |
+
if child:IsA("TextButton") then
|
| 143 |
+
child:Destroy()
|
| 144 |
+
end
|
| 145 |
+
end
|
| 146 |
+
|
| 147 |
+
-- Create choice buttons
|
| 148 |
+
for i, choice in ipairs(state.Choices) do
|
| 149 |
+
local btn = Instance.new("TextButton")
|
| 150 |
+
btn.Size = UDim2.new(0.3, 0, 0.08, 0)
|
| 151 |
+
btn.Position = UDim2.new(0.35, 0, 0.5 + (i-1) * 0.1, 0)
|
| 152 |
+
btn.Text = choice
|
| 153 |
+
btn.Parent = screenGui
|
| 154 |
+
|
| 155 |
+
btn.MouseButton1Click:Connect(function()
|
| 156 |
+
StoryModule.MakeChoice(choice)
|
| 157 |
+
updateUI()
|
| 158 |
+
end)
|
| 159 |
+
end
|
| 160 |
+
end
|
| 161 |
+
|
| 162 |
+
updateUI()
|
| 163 |
+
--]]
|
| 164 |
+
'''
|
| 165 |
+
|
| 166 |
+
explanation = """Roblox Lua Export Generated!
|
| 167 |
+
|
| 168 |
+
Setup Instructions:
|
| 169 |
+
1. Open Roblox Studio
|
| 170 |
+
2. Create a ModuleScript in ReplicatedStorage named "StoryModule"
|
| 171 |
+
3. Paste this code into the ModuleScript
|
| 172 |
+
4. Create a LocalScript in StarterPlayerScripts for UI
|
| 173 |
+
5. Use the example code at the bottom as a starting point
|
| 174 |
+
|
| 175 |
+
Testing:
|
| 176 |
+
- Use Play Solo (F5) to test
|
| 177 |
+
- The example creates basic text UI
|
| 178 |
+
- Customize with proper GUI elements
|
| 179 |
+
|
| 180 |
+
API Reference:
|
| 181 |
+
- StoryModule.GetCurrentState() : Get current state data
|
| 182 |
+
- StoryModule.MakeChoice(choice) : Make a choice, returns success + new state
|
| 183 |
+
- StoryModule.GetChoices() : Get available choices array
|
| 184 |
+
- StoryModule.Reset() : Reset to start state
|
| 185 |
+
|
| 186 |
+
Publishing:
|
| 187 |
+
- Test in Studio first
|
| 188 |
+
- Publish as Experience
|
| 189 |
+
- Share via Roblox platform
|
| 190 |
+
"""
|
| 191 |
+
|
| 192 |
+
return explanation, lua_code
|
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Router for config-to-platform exports.
|
| 2 |
+
|
| 3 |
+
This module provides the main routing function that dispatches
|
| 4 |
+
to the appropriate platform-specific exporter based on the
|
| 5 |
+
platform identifier.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
|
| 10 |
+
# Import all exporters
|
| 11 |
+
from .gamemaker_exporter import export_to_gamemaker
|
| 12 |
+
from .flutter_exporter import export_to_flutter
|
| 13 |
+
from .twine_exporter import export_to_twine
|
| 14 |
+
from .renpy_exporter import export_to_renpy
|
| 15 |
+
from .ink_exporter import export_to_ink
|
| 16 |
+
from .yarn_exporter import export_to_yarn
|
| 17 |
+
from .roblox_exporter import export_to_roblox
|
| 18 |
+
from .rpgmaker_exporter import export_to_rpgmaker
|
| 19 |
+
from .ar_exporters import (
|
| 20 |
+
export_to_lensstudio,
|
| 21 |
+
export_to_metaspark,
|
| 22 |
+
export_to_8thwall,
|
| 23 |
+
export_to_tiktok,
|
| 24 |
+
export_to_reality
|
| 25 |
+
)
|
| 26 |
+
from .game_platform_exporters import (
|
| 27 |
+
export_to_minecraft,
|
| 28 |
+
export_to_papyrus,
|
| 29 |
+
export_to_vrchat
|
| 30 |
+
)
|
| 31 |
+
from .fiction_exporters import (
|
| 32 |
+
export_to_inform7,
|
| 33 |
+
export_to_godot_dialogue
|
| 34 |
+
)
|
| 35 |
+
from .html_exporters import (
|
| 36 |
+
export_to_2d_map,
|
| 37 |
+
export_to_movingdotspace
|
| 38 |
+
)
|
| 39 |
+
from .terminal_exporter import export_to_terminal
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def ConfigConversionforExporttoPlatform(platform, config):
|
| 43 |
+
"""Route config export to the appropriate platform-specific exporter.
|
| 44 |
+
|
| 45 |
+
Args:
|
| 46 |
+
platform: Platform identifier string (e.g., 'gamemaker', 'flutter', 'twine')
|
| 47 |
+
config: Game config JSON string or dict
|
| 48 |
+
|
| 49 |
+
Returns:
|
| 50 |
+
Tuple of (explanation, output_code)
|
| 51 |
+
"""
|
| 52 |
+
FinalOutput = ""
|
| 53 |
+
FinalOutputExplanation = ""
|
| 54 |
+
|
| 55 |
+
# Import engine exporters from main module (these live in my_text_game_engine_attempt.py)
|
| 56 |
+
try:
|
| 57 |
+
from my_text_game_engine_attempt import (
|
| 58 |
+
export_to_playcanvas,
|
| 59 |
+
export_to_godot,
|
| 60 |
+
export_to_unreal
|
| 61 |
+
)
|
| 62 |
+
has_engine_exporters = True
|
| 63 |
+
except ImportError:
|
| 64 |
+
has_engine_exporters = False
|
| 65 |
+
|
| 66 |
+
if platform == "playcanvas" and has_engine_exporters:
|
| 67 |
+
FinalOutputExplanation, FinalOutput = export_to_playcanvas(config)
|
| 68 |
+
elif platform == "godot" and has_engine_exporters:
|
| 69 |
+
FinalOutputExplanation, FinalOutput = export_to_godot(config)
|
| 70 |
+
elif platform == "unreal" and has_engine_exporters:
|
| 71 |
+
FinalOutputExplanation, FinalOutput = export_to_unreal(config)
|
| 72 |
+
elif platform == "gamemaker":
|
| 73 |
+
FinalOutputExplanation, FinalOutput = export_to_gamemaker(config)
|
| 74 |
+
elif platform == "flutter":
|
| 75 |
+
FinalOutputExplanation, FinalOutput = export_to_flutter(config)
|
| 76 |
+
elif platform == "twine":
|
| 77 |
+
FinalOutputExplanation, FinalOutput = export_to_twine(config)
|
| 78 |
+
elif platform == "renpy":
|
| 79 |
+
FinalOutputExplanation, FinalOutput = export_to_renpy(config)
|
| 80 |
+
elif platform == "ink":
|
| 81 |
+
FinalOutputExplanation, FinalOutput = export_to_ink(config)
|
| 82 |
+
elif platform == "yarn":
|
| 83 |
+
FinalOutputExplanation, FinalOutput = export_to_yarn(config)
|
| 84 |
+
elif platform == "roblox":
|
| 85 |
+
FinalOutputExplanation, FinalOutput = export_to_roblox(config)
|
| 86 |
+
elif platform == "rpgmaker":
|
| 87 |
+
FinalOutputExplanation, FinalOutput = export_to_rpgmaker(config)
|
| 88 |
+
elif platform == "lensstudio":
|
| 89 |
+
FinalOutputExplanation, FinalOutput = export_to_lensstudio(config)
|
| 90 |
+
elif platform == "metaspark":
|
| 91 |
+
FinalOutputExplanation, FinalOutput = export_to_metaspark(config)
|
| 92 |
+
elif platform == "8thwall":
|
| 93 |
+
FinalOutputExplanation, FinalOutput = export_to_8thwall(config)
|
| 94 |
+
elif platform == "minecraft":
|
| 95 |
+
FinalOutputExplanation, FinalOutput = export_to_minecraft(config)
|
| 96 |
+
elif platform == "papyrus":
|
| 97 |
+
FinalOutputExplanation, FinalOutput = export_to_papyrus(config)
|
| 98 |
+
elif platform == "vrchat":
|
| 99 |
+
FinalOutputExplanation, FinalOutput = export_to_vrchat(config)
|
| 100 |
+
elif platform == "tiktok":
|
| 101 |
+
FinalOutputExplanation, FinalOutput = export_to_tiktok(config)
|
| 102 |
+
elif platform == "reality":
|
| 103 |
+
FinalOutputExplanation, FinalOutput = export_to_reality(config)
|
| 104 |
+
elif platform == "inform7":
|
| 105 |
+
FinalOutputExplanation, FinalOutput = export_to_inform7(config)
|
| 106 |
+
elif platform == "godot_dialogue":
|
| 107 |
+
FinalOutputExplanation, FinalOutput = export_to_godot_dialogue(config)
|
| 108 |
+
elif platform == "movingdotspace" or platform == "2d map related space":
|
| 109 |
+
FinalOutputExplanation, FinalOutput = export_to_movingdotspace(config)
|
| 110 |
+
elif platform == "terminal":
|
| 111 |
+
FinalOutputExplanation, FinalOutput = export_to_terminal(config)
|
| 112 |
+
elif platform == "existing game":
|
| 113 |
+
FinalOutput = """// Config for importing into existing game mod systems
|
| 114 |
+
// Copy this JSON and use your game's import feature
|
| 115 |
+
|
| 116 |
+
""" + (config if isinstance(config, str) else json.dumps(json.loads(config) if isinstance(config, str) else config, indent=2))
|
| 117 |
+
FinalOutputExplanation = """Existing Game Integration:
|
| 118 |
+
|
| 119 |
+
Popular games with mod/custom content support:
|
| 120 |
+
- Skyrim: Use Creation Kit, save as quest mod
|
| 121 |
+
- GTA V: Use Script Hook V, convert to mission script
|
| 122 |
+
- Sims 4: Use Mod Constructor, create story mod
|
| 123 |
+
- Roblox: Use Roblox Studio, create experience
|
| 124 |
+
|
| 125 |
+
The config JSON is provided above - convert it to your game's format."""
|
| 126 |
+
|
| 127 |
+
return FinalOutputExplanation, FinalOutput
|
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""RPG Maker MZ JavaScript plugin exporter for game configs."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def export_to_rpgmaker(config_json):
|
| 7 |
+
"""Export game config to RPG Maker MZ JavaScript plugin format."""
|
| 8 |
+
try:
|
| 9 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 10 |
+
except json.JSONDecodeError as e:
|
| 11 |
+
return f"JSON Error: {str(e)}", ""
|
| 12 |
+
|
| 13 |
+
js_code = '''//=============================================================================
|
| 14 |
+
// StoryAdventure.js - RPG Maker MZ Plugin
|
| 15 |
+
// Generated from Game Config IDE
|
| 16 |
+
//=============================================================================
|
| 17 |
+
|
| 18 |
+
/*:
|
| 19 |
+
* @target MZ
|
| 20 |
+
* @plugindesc Story Adventure System - Branching narrative plugin
|
| 21 |
+
* @author Game Config IDE Export
|
| 22 |
+
*
|
| 23 |
+
* @command StartStory
|
| 24 |
+
* @text Start Story
|
| 25 |
+
* @desc Begin the story from the starting state
|
| 26 |
+
*
|
| 27 |
+
* @command ShowCurrentState
|
| 28 |
+
* @text Show Current State
|
| 29 |
+
* @desc Display the current state with choices
|
| 30 |
+
*
|
| 31 |
+
* @help
|
| 32 |
+
* This plugin provides a branching narrative system.
|
| 33 |
+
*
|
| 34 |
+
* Plugin Commands:
|
| 35 |
+
* - StartStory: Initialize and show first state
|
| 36 |
+
* - ShowCurrentState: Redisplay current state
|
| 37 |
+
*
|
| 38 |
+
* Script Calls:
|
| 39 |
+
* - $gameStory.setState("state_name")
|
| 40 |
+
* - $gameStory.makeChoice(index)
|
| 41 |
+
* - $gameStory.getCurrentState()
|
| 42 |
+
*/
|
| 43 |
+
|
| 44 |
+
(() => {
|
| 45 |
+
const pluginName = "StoryAdventure";
|
| 46 |
+
|
| 47 |
+
// Story States Data
|
| 48 |
+
const STORY_STATES = {
|
| 49 |
+
'''
|
| 50 |
+
|
| 51 |
+
first_state = None
|
| 52 |
+
|
| 53 |
+
# Generate states
|
| 54 |
+
for location_key, location_data in config.items():
|
| 55 |
+
if isinstance(location_data, dict):
|
| 56 |
+
for state_key, state_data in location_data.items():
|
| 57 |
+
if isinstance(state_data, dict):
|
| 58 |
+
state_name = f"{location_key}_{state_key}"
|
| 59 |
+
if first_state is None:
|
| 60 |
+
first_state = state_name
|
| 61 |
+
|
| 62 |
+
description = state_data.get('description', 'No description').replace('"', '\\"').replace('\n', '\\n')
|
| 63 |
+
choices = state_data.get('choices', [])
|
| 64 |
+
transitions = state_data.get('transitions', {})
|
| 65 |
+
|
| 66 |
+
js_code += f' "{state_name}": {{\n'
|
| 67 |
+
js_code += f' description: "{description}",\n'
|
| 68 |
+
js_code += f' location: "{location_key}",\n'
|
| 69 |
+
|
| 70 |
+
# Choices array
|
| 71 |
+
js_code += ' choices: ['
|
| 72 |
+
if choices:
|
| 73 |
+
choice_strs = [f'"{c}"' for c in choices]
|
| 74 |
+
js_code += ', '.join(choice_strs)
|
| 75 |
+
js_code += '],\n'
|
| 76 |
+
|
| 77 |
+
# Transitions object
|
| 78 |
+
js_code += ' transitions: {\n'
|
| 79 |
+
if isinstance(transitions, dict):
|
| 80 |
+
for choice, next_state in transitions.items():
|
| 81 |
+
if next_state:
|
| 82 |
+
if '_' not in next_state:
|
| 83 |
+
next_state = f"{location_key}_{next_state}"
|
| 84 |
+
choice_escaped = choice.replace('"', '\\"')
|
| 85 |
+
js_code += f' "{choice_escaped}": "{next_state}",\n'
|
| 86 |
+
js_code += ' }\n'
|
| 87 |
+
js_code += ' },\n'
|
| 88 |
+
|
| 89 |
+
js_code += f''' }};
|
| 90 |
+
|
| 91 |
+
const START_STATE = "{first_state or "start"}";
|
| 92 |
+
|
| 93 |
+
// Game Story Manager
|
| 94 |
+
class Game_Story {{
|
| 95 |
+
constructor() {{
|
| 96 |
+
this.initialize();
|
| 97 |
+
}}
|
| 98 |
+
|
| 99 |
+
initialize() {{
|
| 100 |
+
this._currentState = START_STATE;
|
| 101 |
+
this._visitedStates = [];
|
| 102 |
+
}}
|
| 103 |
+
|
| 104 |
+
getCurrentState() {{
|
| 105 |
+
return STORY_STATES[this._currentState];
|
| 106 |
+
}}
|
| 107 |
+
|
| 108 |
+
getStateName() {{
|
| 109 |
+
return this._currentState;
|
| 110 |
+
}}
|
| 111 |
+
|
| 112 |
+
setState(stateName) {{
|
| 113 |
+
if (STORY_STATES[stateName]) {{
|
| 114 |
+
this._currentState = stateName;
|
| 115 |
+
this._visitedStates.push(stateName);
|
| 116 |
+
return true;
|
| 117 |
+
}}
|
| 118 |
+
return false;
|
| 119 |
+
}}
|
| 120 |
+
|
| 121 |
+
makeChoice(choiceIndex) {{
|
| 122 |
+
const state = this.getCurrentState();
|
| 123 |
+
if (state && state.choices[choiceIndex]) {{
|
| 124 |
+
const choiceText = state.choices[choiceIndex];
|
| 125 |
+
const nextState = state.transitions[choiceText];
|
| 126 |
+
if (nextState) {{
|
| 127 |
+
return this.setState(nextState);
|
| 128 |
+
}}
|
| 129 |
+
}}
|
| 130 |
+
return false;
|
| 131 |
+
}}
|
| 132 |
+
|
| 133 |
+
getChoices() {{
|
| 134 |
+
const state = this.getCurrentState();
|
| 135 |
+
return state ? state.choices : [];
|
| 136 |
+
}}
|
| 137 |
+
|
| 138 |
+
hasVisited(stateName) {{
|
| 139 |
+
return this._visitedStates.includes(stateName);
|
| 140 |
+
}}
|
| 141 |
+
|
| 142 |
+
reset() {{
|
| 143 |
+
this.initialize();
|
| 144 |
+
}}
|
| 145 |
+
}}
|
| 146 |
+
|
| 147 |
+
// Create global instance
|
| 148 |
+
window.$gameStory = null;
|
| 149 |
+
|
| 150 |
+
// Initialize on new game
|
| 151 |
+
const _DataManager_createGameObjects = DataManager.createGameObjects;
|
| 152 |
+
DataManager.createGameObjects = function() {{
|
| 153 |
+
_DataManager_createGameObjects.call(this);
|
| 154 |
+
$gameStory = new Game_Story();
|
| 155 |
+
}};
|
| 156 |
+
|
| 157 |
+
// Save story state
|
| 158 |
+
const _DataManager_makeSaveContents = DataManager.makeSaveContents;
|
| 159 |
+
DataManager.makeSaveContents = function() {{
|
| 160 |
+
const contents = _DataManager_makeSaveContents.call(this);
|
| 161 |
+
contents.story = {{
|
| 162 |
+
currentState: $gameStory._currentState,
|
| 163 |
+
visitedStates: $gameStory._visitedStates
|
| 164 |
+
}};
|
| 165 |
+
return contents;
|
| 166 |
+
}};
|
| 167 |
+
|
| 168 |
+
// Load story state
|
| 169 |
+
const _DataManager_extractSaveContents = DataManager.extractSaveContents;
|
| 170 |
+
DataManager.extractSaveContents = function(contents) {{
|
| 171 |
+
_DataManager_extractSaveContents.call(this, contents);
|
| 172 |
+
if (contents.story) {{
|
| 173 |
+
$gameStory._currentState = contents.story.currentState;
|
| 174 |
+
$gameStory._visitedStates = contents.story.visitedStates;
|
| 175 |
+
}}
|
| 176 |
+
}};
|
| 177 |
+
|
| 178 |
+
// Show story state as message with choices
|
| 179 |
+
function showStoryState() {{
|
| 180 |
+
const state = $gameStory.getCurrentState();
|
| 181 |
+
if (!state) return;
|
| 182 |
+
|
| 183 |
+
// Show description
|
| 184 |
+
$gameMessage.add(state.description);
|
| 185 |
+
|
| 186 |
+
// Set up choices if available
|
| 187 |
+
if (state.choices.length > 0) {{
|
| 188 |
+
$gameMessage.setChoices(state.choices, 0, -1);
|
| 189 |
+
$gameMessage.setChoiceCallback(n => {{
|
| 190 |
+
if (n >= 0) {{
|
| 191 |
+
$gameStory.makeChoice(n);
|
| 192 |
+
// Optionally auto-continue
|
| 193 |
+
// showStoryState();
|
| 194 |
+
}}
|
| 195 |
+
}});
|
| 196 |
+
}}
|
| 197 |
+
}}
|
| 198 |
+
|
| 199 |
+
// Plugin Commands
|
| 200 |
+
PluginManager.registerCommand(pluginName, "StartStory", args => {{
|
| 201 |
+
$gameStory.reset();
|
| 202 |
+
showStoryState();
|
| 203 |
+
}});
|
| 204 |
+
|
| 205 |
+
PluginManager.registerCommand(pluginName, "ShowCurrentState", args => {{
|
| 206 |
+
showStoryState();
|
| 207 |
+
}});
|
| 208 |
+
|
| 209 |
+
// Export for script calls
|
| 210 |
+
window.Game_Story = Game_Story;
|
| 211 |
+
window.showStoryState = showStoryState;
|
| 212 |
+
|
| 213 |
+
}})();
|
| 214 |
+
'''
|
| 215 |
+
|
| 216 |
+
explanation = """RPG Maker MZ Plugin Export Generated!
|
| 217 |
+
|
| 218 |
+
Setup Instructions:
|
| 219 |
+
1. Save this as js/plugins/StoryAdventure.js
|
| 220 |
+
2. Open RPG Maker MZ project
|
| 221 |
+
3. Go to Plugin Manager (gear icon)
|
| 222 |
+
4. Add "StoryAdventure" plugin
|
| 223 |
+
5. Use Plugin Commands in events
|
| 224 |
+
|
| 225 |
+
Plugin Commands:
|
| 226 |
+
- StartStory: Begin from first state
|
| 227 |
+
- ShowCurrentState: Show current state again
|
| 228 |
+
|
| 229 |
+
Script Calls (in Event > Script):
|
| 230 |
+
- $gameStory.setState("state_name")
|
| 231 |
+
- $gameStory.makeChoice(0) // choice index
|
| 232 |
+
- $gameStory.getCurrentState()
|
| 233 |
+
- $gameStory.getChoices()
|
| 234 |
+
- $gameStory.hasVisited("state_name")
|
| 235 |
+
- showStoryState() // Show with message window
|
| 236 |
+
|
| 237 |
+
Event Integration:
|
| 238 |
+
- Use Conditional Branch to check states
|
| 239 |
+
- Script: $gameStory.getStateName() === "village_start"
|
| 240 |
+
- Combine with map transfers for location changes
|
| 241 |
+
|
| 242 |
+
Saving:
|
| 243 |
+
- Story progress auto-saves with game
|
| 244 |
+
- Visited states tracked for conditionals
|
| 245 |
+
"""
|
| 246 |
+
|
| 247 |
+
return explanation, js_code
|
|
@@ -0,0 +1,131 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Python terminal game exporter for game configs."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def export_to_terminal(config_json):
|
| 7 |
+
"""Export game config to standalone Python terminal game."""
|
| 8 |
+
try:
|
| 9 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 10 |
+
except json.JSONDecodeError as e:
|
| 11 |
+
return f"JSON Error: {str(e)}", ""
|
| 12 |
+
|
| 13 |
+
code = '''#!/usr/bin/env python3
|
| 14 |
+
"""
|
| 15 |
+
Auto-generated Terminal Text Adventure Game
|
| 16 |
+
Run with: python game.py
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
|
| 21 |
+
# Game configuration
|
| 22 |
+
CONFIG = ''' + json.dumps(config, indent=2) + '''
|
| 23 |
+
|
| 24 |
+
def get_state(location, state_name):
|
| 25 |
+
"""Get state data from config."""
|
| 26 |
+
if location in CONFIG and state_name in CONFIG[location]:
|
| 27 |
+
return CONFIG[location][state_name]
|
| 28 |
+
return None
|
| 29 |
+
|
| 30 |
+
def play_game():
|
| 31 |
+
"""Main game loop."""
|
| 32 |
+
# Find starting location and state
|
| 33 |
+
current_location = list(CONFIG.keys())[0]
|
| 34 |
+
current_state = list(CONFIG[current_location].keys())[0]
|
| 35 |
+
|
| 36 |
+
inventory = []
|
| 37 |
+
flags = {}
|
| 38 |
+
money = 0
|
| 39 |
+
|
| 40 |
+
print("=" * 50)
|
| 41 |
+
print("TERMINAL TEXT ADVENTURE")
|
| 42 |
+
print("=" * 50)
|
| 43 |
+
print()
|
| 44 |
+
|
| 45 |
+
while True:
|
| 46 |
+
state = get_state(current_location, current_state)
|
| 47 |
+
if not state:
|
| 48 |
+
print(f"\\nGame Over - No state found: {current_location}/{current_state}")
|
| 49 |
+
break
|
| 50 |
+
|
| 51 |
+
# Display description
|
| 52 |
+
description = state.get("description", "No description")
|
| 53 |
+
print(f"\\n[{current_location.upper()} - {current_state}]")
|
| 54 |
+
print("-" * 40)
|
| 55 |
+
print(description)
|
| 56 |
+
print()
|
| 57 |
+
|
| 58 |
+
# Get choices
|
| 59 |
+
choices = state.get("choices", [])
|
| 60 |
+
if not choices:
|
| 61 |
+
print("\\n*** THE END ***")
|
| 62 |
+
break
|
| 63 |
+
|
| 64 |
+
# Display choices
|
| 65 |
+
print("What do you do?")
|
| 66 |
+
for i, choice in enumerate(choices, 1):
|
| 67 |
+
print(f" {i}. {choice}")
|
| 68 |
+
print(f" 0. Quit game")
|
| 69 |
+
|
| 70 |
+
# Get player input
|
| 71 |
+
try:
|
| 72 |
+
choice_input = input("\\nEnter choice: ").strip()
|
| 73 |
+
if choice_input == "0":
|
| 74 |
+
print("\\nThanks for playing!")
|
| 75 |
+
break
|
| 76 |
+
choice_idx = int(choice_input) - 1
|
| 77 |
+
if 0 <= choice_idx < len(choices):
|
| 78 |
+
chosen = choices[choice_idx]
|
| 79 |
+
print(f"\\n> {chosen}")
|
| 80 |
+
|
| 81 |
+
# Handle transitions
|
| 82 |
+
transitions = state.get("transitions", {})
|
| 83 |
+
if chosen in transitions:
|
| 84 |
+
next_state = transitions[chosen]
|
| 85 |
+
if "/" in str(next_state):
|
| 86 |
+
parts = next_state.split("/")
|
| 87 |
+
current_location = parts[0]
|
| 88 |
+
current_state = parts[1]
|
| 89 |
+
else:
|
| 90 |
+
current_state = next_state
|
| 91 |
+
else:
|
| 92 |
+
print("Invalid choice, try again.")
|
| 93 |
+
except ValueError:
|
| 94 |
+
print("Please enter a number.")
|
| 95 |
+
except KeyboardInterrupt:
|
| 96 |
+
print("\\n\\nGame interrupted. Goodbye!")
|
| 97 |
+
break
|
| 98 |
+
|
| 99 |
+
if __name__ == "__main__":
|
| 100 |
+
play_game()
|
| 101 |
+
'''
|
| 102 |
+
|
| 103 |
+
explanation = """Python Terminal Game Export Generated!
|
| 104 |
+
|
| 105 |
+
How to Run:
|
| 106 |
+
1. Save the code as 'game.py'
|
| 107 |
+
2. Open terminal/command prompt
|
| 108 |
+
3. Run: python game.py
|
| 109 |
+
|
| 110 |
+
Requirements:
|
| 111 |
+
- Python 3.6+
|
| 112 |
+
- No external dependencies
|
| 113 |
+
|
| 114 |
+
Features:
|
| 115 |
+
- Text-based adventure gameplay
|
| 116 |
+
- Choice-based navigation
|
| 117 |
+
- State transitions from your config
|
| 118 |
+
- Quit option (enter 0)
|
| 119 |
+
|
| 120 |
+
Mobile Options:
|
| 121 |
+
- Pydroid 3 (Android) - Run Python on phone
|
| 122 |
+
- Pythonista (iOS) - Python IDE for iPhone/iPad
|
| 123 |
+
- Replit Mobile - Cloud-based Python
|
| 124 |
+
|
| 125 |
+
Web Hosting:
|
| 126 |
+
- Replit.com - Host and share Python apps
|
| 127 |
+
- PythonAnywhere - Free Python hosting
|
| 128 |
+
- Google Colab - Run in browser (add input handling)
|
| 129 |
+
"""
|
| 130 |
+
|
| 131 |
+
return explanation, code
|
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Twine/SugarCube exporter for game configs."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def export_to_twine(config_json):
|
| 7 |
+
"""Export game config to Twine/SugarCube HTML format."""
|
| 8 |
+
try:
|
| 9 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 10 |
+
except json.JSONDecodeError as e:
|
| 11 |
+
return f"JSON Error: {str(e)}", ""
|
| 12 |
+
|
| 13 |
+
# Build Twine passages
|
| 14 |
+
passages = []
|
| 15 |
+
first_passage = None
|
| 16 |
+
|
| 17 |
+
for location_key, location_data in config.items():
|
| 18 |
+
if isinstance(location_data, dict):
|
| 19 |
+
for state_key, state_data in location_data.items():
|
| 20 |
+
if isinstance(state_data, dict):
|
| 21 |
+
passage_name = f"{location_key}_{state_key}"
|
| 22 |
+
if first_passage is None:
|
| 23 |
+
first_passage = passage_name
|
| 24 |
+
|
| 25 |
+
description = state_data.get('description', 'No description')
|
| 26 |
+
choices = state_data.get('choices', [])
|
| 27 |
+
transitions = state_data.get('transitions', {})
|
| 28 |
+
|
| 29 |
+
# Build passage content
|
| 30 |
+
content = f"{description}\n\n"
|
| 31 |
+
|
| 32 |
+
# Add choices as links
|
| 33 |
+
for choice in choices:
|
| 34 |
+
if isinstance(transitions, dict):
|
| 35 |
+
next_state = transitions.get(choice, '')
|
| 36 |
+
if next_state:
|
| 37 |
+
# Handle slash notation (location/state) -> convert to underscore
|
| 38 |
+
if '/' in next_state:
|
| 39 |
+
next_state = next_state.replace('/', '_')
|
| 40 |
+
# Handle state-only references (add current location prefix)
|
| 41 |
+
elif '_' not in next_state or not any(next_state.startswith(loc) for loc in config.keys()):
|
| 42 |
+
# Check if it's just a state name within current location
|
| 43 |
+
if next_state in location_data:
|
| 44 |
+
next_state = f"{location_key}_{next_state}"
|
| 45 |
+
content += f"[[{choice}|{next_state}]]\n"
|
| 46 |
+
else:
|
| 47 |
+
content += f"[[{choice}]]\n"
|
| 48 |
+
else:
|
| 49 |
+
content += f"[[{choice}]]\n"
|
| 50 |
+
|
| 51 |
+
passages.append((passage_name, content.strip()))
|
| 52 |
+
|
| 53 |
+
# Generate Twine HTML
|
| 54 |
+
twine_html = f'''<!DOCTYPE html>
|
| 55 |
+
<html>
|
| 56 |
+
<head>
|
| 57 |
+
<meta charset="UTF-8">
|
| 58 |
+
<title>Exported Story</title>
|
| 59 |
+
<style>
|
| 60 |
+
body {{ font-family: Georgia, serif; max-width: 600px; margin: 50px auto; padding: 20px; }}
|
| 61 |
+
.passage {{ margin-bottom: 20px; }}
|
| 62 |
+
a {{ color: #0066cc; text-decoration: none; display: block; margin: 10px 0; padding: 10px; background: #f0f0f0; border-radius: 5px; }}
|
| 63 |
+
a:hover {{ background: #e0e0e0; }}
|
| 64 |
+
</style>
|
| 65 |
+
</head>
|
| 66 |
+
<body>
|
| 67 |
+
<div id="story">
|
| 68 |
+
<div id="current-passage"></div>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<script>
|
| 72 |
+
const passages = {{
|
| 73 |
+
'''
|
| 74 |
+
|
| 75 |
+
# Add passages as JavaScript object
|
| 76 |
+
for name, content in passages:
|
| 77 |
+
# Escape for JavaScript
|
| 78 |
+
escaped_content = content.replace('\\', '\\\\').replace("'", "\\'").replace('\n', '\\n')
|
| 79 |
+
twine_html += f" '{name}': '{escaped_content}',\n"
|
| 80 |
+
|
| 81 |
+
twine_html += f'''}};
|
| 82 |
+
|
| 83 |
+
let currentPassage = '{first_passage or "start"}';
|
| 84 |
+
|
| 85 |
+
function showPassage(name) {{
|
| 86 |
+
const content = passages[name] || 'Passage not found: ' + name;
|
| 87 |
+
const container = document.getElementById('current-passage');
|
| 88 |
+
|
| 89 |
+
// Parse links [[text|target]] or [[text]]
|
| 90 |
+
let html = content.replace(/\\[\\[([^|\\]]+)\\|([^\\]]+)\\]\\]/g, '<a href="#" onclick="showPassage(\\'$2\\'); return false;">$1</a>');
|
| 91 |
+
html = html.replace(/\\[\\[([^\\]]+)\\]\\]/g, '<a href="#" onclick="showPassage(\\'$1\\'); return false;">$1</a>');
|
| 92 |
+
html = html.replace(/\\n/g, '<br>');
|
| 93 |
+
|
| 94 |
+
container.innerHTML = '<div class="passage">' + html + '</div>';
|
| 95 |
+
currentPassage = name;
|
| 96 |
+
}}
|
| 97 |
+
|
| 98 |
+
// Start the story
|
| 99 |
+
showPassage(currentPassage);
|
| 100 |
+
</script>
|
| 101 |
+
</body>
|
| 102 |
+
</html>'''
|
| 103 |
+
|
| 104 |
+
explanation = """Twine/Interactive Fiction Export Generated!
|
| 105 |
+
|
| 106 |
+
Instructions:
|
| 107 |
+
1. Save this as an .html file
|
| 108 |
+
2. Open in any web browser to play
|
| 109 |
+
3. Or import into Twine (twinery.org):
|
| 110 |
+
- Open Twine
|
| 111 |
+
- Import > From File
|
| 112 |
+
- Select the HTML file
|
| 113 |
+
|
| 114 |
+
Alternative - Use Twinery.org online:
|
| 115 |
+
1. Go to https://twinery.org/
|
| 116 |
+
2. Click "Use it online"
|
| 117 |
+
3. Create new story
|
| 118 |
+
4. Copy passages manually or import HTML
|
| 119 |
+
|
| 120 |
+
Features:
|
| 121 |
+
- Self-contained HTML file
|
| 122 |
+
- No dependencies required
|
| 123 |
+
- Works offline
|
| 124 |
+
- Easy to customize CSS
|
| 125 |
+
"""
|
| 126 |
+
|
| 127 |
+
return explanation, twine_html
|
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Yarn Spinner dialogue exporter for game configs."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def export_to_yarn(config_json):
|
| 7 |
+
"""Export game config to Yarn Spinner dialogue format (.yarn)."""
|
| 8 |
+
try:
|
| 9 |
+
config = json.loads(config_json) if isinstance(config_json, str) else config_json
|
| 10 |
+
except json.JSONDecodeError as e:
|
| 11 |
+
return f"JSON Error: {str(e)}", ""
|
| 12 |
+
|
| 13 |
+
yarn_code = '''title: Start
|
| 14 |
+
tags:
|
| 15 |
+
---
|
| 16 |
+
<<jump {first_node}>>
|
| 17 |
+
===
|
| 18 |
+
|
| 19 |
+
'''
|
| 20 |
+
|
| 21 |
+
first_node = None
|
| 22 |
+
nodes = []
|
| 23 |
+
|
| 24 |
+
# Generate nodes
|
| 25 |
+
for location_key, location_data in config.items():
|
| 26 |
+
if isinstance(location_data, dict):
|
| 27 |
+
for state_key, state_data in location_data.items():
|
| 28 |
+
if isinstance(state_data, dict):
|
| 29 |
+
node_name = f"{location_key}_{state_key}"
|
| 30 |
+
if first_node is None:
|
| 31 |
+
first_node = node_name
|
| 32 |
+
|
| 33 |
+
description = state_data.get('description', 'No description')
|
| 34 |
+
choices = state_data.get('choices', [])
|
| 35 |
+
transitions = state_data.get('transitions', {})
|
| 36 |
+
media = state_data.get('media', {})
|
| 37 |
+
|
| 38 |
+
node_code = f"title: {node_name}\n"
|
| 39 |
+
node_code += f"tags: location:{location_key}\n"
|
| 40 |
+
node_code += "---\n"
|
| 41 |
+
|
| 42 |
+
# Add media commands if present
|
| 43 |
+
if media.get('image'):
|
| 44 |
+
node_code += f"<<set_background \"{media['image']}\">>\n"
|
| 45 |
+
if media.get('audio'):
|
| 46 |
+
node_code += f"<<play_audio \"{media['audio']}\">>\n"
|
| 47 |
+
|
| 48 |
+
# Add description
|
| 49 |
+
node_code += f"{description}\n\n"
|
| 50 |
+
|
| 51 |
+
# Add choices
|
| 52 |
+
if choices:
|
| 53 |
+
for choice in choices:
|
| 54 |
+
next_state = transitions.get(choice, '') if isinstance(transitions, dict) else ''
|
| 55 |
+
|
| 56 |
+
if next_state:
|
| 57 |
+
if '_' not in next_state:
|
| 58 |
+
next_state = f"{location_key}_{next_state}"
|
| 59 |
+
node_code += f"-> {choice}\n"
|
| 60 |
+
node_code += f" <<jump {next_state}>>\n"
|
| 61 |
+
else:
|
| 62 |
+
node_code += f"-> {choice}\n"
|
| 63 |
+
node_code += f" You selected: {choice}\n"
|
| 64 |
+
node_code += f" <<stop>>\n"
|
| 65 |
+
else:
|
| 66 |
+
node_code += "<<stop>>\n"
|
| 67 |
+
|
| 68 |
+
node_code += "===\n\n"
|
| 69 |
+
nodes.append(node_code)
|
| 70 |
+
|
| 71 |
+
# Replace placeholder in start node
|
| 72 |
+
yarn_code = yarn_code.replace("{first_node}", first_node or "Start")
|
| 73 |
+
|
| 74 |
+
# Add all nodes
|
| 75 |
+
yarn_code += "\n".join(nodes)
|
| 76 |
+
|
| 77 |
+
explanation = """Yarn Spinner Dialogue Export Generated!
|
| 78 |
+
|
| 79 |
+
Setup Instructions:
|
| 80 |
+
1. Install Yarn Spinner for Unity: https://yarnspinner.dev/
|
| 81 |
+
2. Create a new .yarn file in your project
|
| 82 |
+
3. Paste this code
|
| 83 |
+
4. Add a Dialogue Runner component to your scene
|
| 84 |
+
|
| 85 |
+
Online Testing:
|
| 86 |
+
- Try Yarn Spinner: https://try.yarnspinner.dev/
|
| 87 |
+
- Paste code and click "Run"
|
| 88 |
+
|
| 89 |
+
Unity Integration:
|
| 90 |
+
1. Import Yarn Spinner package from Unity Asset Store (free)
|
| 91 |
+
2. Create YarnProject asset
|
| 92 |
+
3. Add .yarn files to project
|
| 93 |
+
4. Set up DialogueRunner with your UI
|
| 94 |
+
|
| 95 |
+
Custom Commands (implement in C#):
|
| 96 |
+
- <<set_background "image">> : Change background
|
| 97 |
+
- <<play_audio "file">> : Play sound effect
|
| 98 |
+
- <<stop>> : End dialogue
|
| 99 |
+
|
| 100 |
+
Yarn Syntax Reference:
|
| 101 |
+
- title: NodeName : Start of node
|
| 102 |
+
- -> Choice text : Player option
|
| 103 |
+
- <<jump NodeName>> : Go to node
|
| 104 |
+
- <<set $var = value>> : Set variable
|
| 105 |
+
- <<if $var>> ... <<endif>> : Conditional
|
| 106 |
+
"""
|
| 107 |
+
|
| 108 |
+
return explanation, yarn_code
|
|
@@ -16,7 +16,8 @@ os.makedirs(SAVE_DIR, exist_ok=True)
|
|
| 16 |
SUPPORTED_EXTENSIONS = {
|
| 17 |
"image": [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"],
|
| 18 |
"audio": [".mp3", ".wav", ".ogg"],
|
| 19 |
-
"video": [".mp4", ".avi", ".mov", ".webm"]
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
def save_file(file):
|
|
@@ -50,24 +51,61 @@ def save_file(file):
|
|
| 50 |
return f"Error saving file: {str(e)}", gr.update(value=SAVE_DIR), gr.update()
|
| 51 |
|
| 52 |
def view_file(file_path):
|
|
|
|
|
|
|
|
|
|
| 53 |
if not file_path:
|
| 54 |
-
return None, None, None, "No file selected."
|
| 55 |
|
| 56 |
try:
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
_, extension = os.path.splitext(full_path)
|
| 59 |
extension = extension.lower()
|
| 60 |
|
| 61 |
if extension in SUPPORTED_EXTENSIONS["image"]:
|
| 62 |
-
return Image.open(full_path), None, None, None
|
| 63 |
elif extension in SUPPORTED_EXTENSIONS["audio"]:
|
| 64 |
-
return None, full_path, None, None
|
| 65 |
elif extension in SUPPORTED_EXTENSIONS["video"]:
|
| 66 |
-
return None, None, full_path, None
|
|
|
|
|
|
|
| 67 |
else:
|
| 68 |
-
return None, None, None, f"Unsupported file type: {extension}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
except Exception as e:
|
| 70 |
-
|
|
|
|
| 71 |
|
| 72 |
def refresh_file_explorer():
|
| 73 |
files = os.listdir(SAVE_DIR)
|
|
@@ -81,6 +119,117 @@ def delete_file(file_path):
|
|
| 81 |
else:
|
| 82 |
return f"{file_path} does not exist."
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
def import_config_with_media(zip_path):
|
| 85 |
global SAVE_DIR
|
| 86 |
target_folder = SAVE_DIR
|
|
@@ -100,21 +249,27 @@ def import_config_with_media(zip_path):
|
|
| 100 |
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
| 101 |
zip_ref.extractall(temp_dir)
|
| 102 |
|
| 103 |
-
# Load the config
|
| 104 |
config_path = os.path.join(temp_dir, 'config.json')
|
|
|
|
|
|
|
| 105 |
if os.path.exists(config_path):
|
| 106 |
with open(config_path, 'r') as f:
|
| 107 |
config = json.load(f)
|
|
|
|
|
|
|
|
|
|
| 108 |
else:
|
| 109 |
-
raise FileNotFoundError("config.json not found in the zip file")
|
| 110 |
|
| 111 |
# Create the target folder if it doesn't exist
|
| 112 |
os.makedirs(target_folder, exist_ok=True)
|
| 113 |
|
| 114 |
# Copy media files that don't already exist in the target folder
|
|
|
|
| 115 |
for root, _, files in os.walk(temp_dir):
|
| 116 |
for file in files:
|
| 117 |
-
if file
|
| 118 |
src_path = os.path.join(root, file)
|
| 119 |
dst_path = os.path.join(target_folder, file)
|
| 120 |
if not os.path.exists(dst_path):
|
|
|
|
| 16 |
SUPPORTED_EXTENSIONS = {
|
| 17 |
"image": [".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp"],
|
| 18 |
"audio": [".mp3", ".wav", ".ogg"],
|
| 19 |
+
"video": [".mp4", ".avi", ".mov", ".webm"],
|
| 20 |
+
"model3d": [".glb", ".gltf", ".obj"]
|
| 21 |
}
|
| 22 |
|
| 23 |
def save_file(file):
|
|
|
|
| 51 |
return f"Error saving file: {str(e)}", gr.update(value=SAVE_DIR), gr.update()
|
| 52 |
|
| 53 |
def view_file(file_path):
|
| 54 |
+
"""View a file and return appropriate outputs for each media type.
|
| 55 |
+
Returns: (image, audio, video, message, model3d)
|
| 56 |
+
"""
|
| 57 |
if not file_path:
|
| 58 |
+
return None, None, None, "No file selected.", None
|
| 59 |
|
| 60 |
try:
|
| 61 |
+
# FileExplorer returns relative path from root_dir, or could be full path
|
| 62 |
+
# Handle both cases
|
| 63 |
+
if os.path.isabs(file_path):
|
| 64 |
+
full_path = file_path
|
| 65 |
+
elif file_path.startswith(SAVE_DIR):
|
| 66 |
+
full_path = file_path
|
| 67 |
+
else:
|
| 68 |
+
full_path = os.path.join(SAVE_DIR, file_path)
|
| 69 |
+
|
| 70 |
+
# Normalize the path
|
| 71 |
+
full_path = os.path.normpath(full_path)
|
| 72 |
+
|
| 73 |
+
if not os.path.exists(full_path):
|
| 74 |
+
return None, None, None, f"File not found: {full_path}", None
|
| 75 |
+
|
| 76 |
_, extension = os.path.splitext(full_path)
|
| 77 |
extension = extension.lower()
|
| 78 |
|
| 79 |
if extension in SUPPORTED_EXTENSIONS["image"]:
|
| 80 |
+
return Image.open(full_path), None, None, f"Viewing: {os.path.basename(full_path)}", None
|
| 81 |
elif extension in SUPPORTED_EXTENSIONS["audio"]:
|
| 82 |
+
return None, full_path, None, f"Viewing: {os.path.basename(full_path)}", None
|
| 83 |
elif extension in SUPPORTED_EXTENSIONS["video"]:
|
| 84 |
+
return None, None, full_path, f"Viewing: {os.path.basename(full_path)}", None
|
| 85 |
+
elif extension in SUPPORTED_EXTENSIONS["model3d"]:
|
| 86 |
+
return None, None, None, f"Viewing 3D: {os.path.basename(full_path)}", full_path
|
| 87 |
else:
|
| 88 |
+
return None, None, None, f"Unsupported file type: {extension}", None
|
| 89 |
+
except Exception as e:
|
| 90 |
+
return None, None, None, f"Error viewing file: {str(e)}", None
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def get_all_media_files():
|
| 94 |
+
"""Get all media files from saved_media folder for use in dropdowns"""
|
| 95 |
+
try:
|
| 96 |
+
if not os.path.exists(SAVE_DIR):
|
| 97 |
+
return []
|
| 98 |
+
files = os.listdir(SAVE_DIR)
|
| 99 |
+
# Filter to only supported media files
|
| 100 |
+
media_files = []
|
| 101 |
+
for f in files:
|
| 102 |
+
ext = os.path.splitext(f)[1].lower()
|
| 103 |
+
if any(ext in exts for exts in SUPPORTED_EXTENSIONS.values()):
|
| 104 |
+
media_files.append(f)
|
| 105 |
+
return sorted(media_files)
|
| 106 |
except Exception as e:
|
| 107 |
+
print(f"Error listing media files: {e}")
|
| 108 |
+
return []
|
| 109 |
|
| 110 |
def refresh_file_explorer():
|
| 111 |
files = os.listdir(SAVE_DIR)
|
|
|
|
| 119 |
else:
|
| 120 |
return f"{file_path} does not exist."
|
| 121 |
|
| 122 |
+
|
| 123 |
+
def delete_file_and_refresh(filename):
|
| 124 |
+
"""Delete file and return updated dropdown choices"""
|
| 125 |
+
if not filename:
|
| 126 |
+
return gr.update(choices=get_all_media_files()), "No file selected"
|
| 127 |
+
|
| 128 |
+
full_path = os.path.join(SAVE_DIR, filename)
|
| 129 |
+
if os.path.exists(full_path):
|
| 130 |
+
os.remove(full_path)
|
| 131 |
+
return gr.update(choices=get_all_media_files(), value=None), f"Deleted: {filename}"
|
| 132 |
+
return gr.update(choices=get_all_media_files()), f"File not found: {filename}"
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def save_file_and_refresh(file):
|
| 136 |
+
"""Save file and return updated dropdown choices for Browse tab"""
|
| 137 |
+
if file is None:
|
| 138 |
+
return "No file uploaded.", gr.update(), gr.update(value=None)
|
| 139 |
+
|
| 140 |
+
try:
|
| 141 |
+
original_filename = os.path.basename(file.name)
|
| 142 |
+
_, extension = os.path.splitext(original_filename)
|
| 143 |
+
|
| 144 |
+
if not any(extension.lower() in exts for exts in SUPPORTED_EXTENSIONS.values()):
|
| 145 |
+
return f"Unsupported file type: {extension}", gr.update(), gr.update()
|
| 146 |
+
|
| 147 |
+
base_name = os.path.splitext(original_filename)[0]
|
| 148 |
+
counter = 1
|
| 149 |
+
new_filename = f"{base_name}{extension}"
|
| 150 |
+
while os.path.exists(os.path.join(SAVE_DIR, new_filename)):
|
| 151 |
+
new_filename = f"{base_name}_{counter}{extension}"
|
| 152 |
+
counter += 1
|
| 153 |
+
|
| 154 |
+
dest_path = os.path.join(SAVE_DIR, new_filename)
|
| 155 |
+
shutil.copy2(file.name, dest_path)
|
| 156 |
+
|
| 157 |
+
# Return updated dropdown choices
|
| 158 |
+
return f"File saved: {new_filename}", gr.update(choices=get_all_media_files()), gr.update(value=None)
|
| 159 |
+
except Exception as e:
|
| 160 |
+
return f"Error saving file: {str(e)}", gr.update(), gr.update()
|
| 161 |
+
|
| 162 |
+
def upload_file_or_bundle(file):
|
| 163 |
+
"""
|
| 164 |
+
Unified upload handler that handles both media files and zip bundles.
|
| 165 |
+
Returns: (status, dropdown_update, file_input_clear, config_json, extracted_files)
|
| 166 |
+
"""
|
| 167 |
+
if file is None:
|
| 168 |
+
return "No file uploaded.", gr.update(), gr.update(value=None), None, ""
|
| 169 |
+
|
| 170 |
+
try:
|
| 171 |
+
original_filename = os.path.basename(file.name)
|
| 172 |
+
_, extension = os.path.splitext(original_filename)
|
| 173 |
+
ext_lower = extension.lower()
|
| 174 |
+
|
| 175 |
+
# Check if it's a zip bundle
|
| 176 |
+
if ext_lower == ".zip":
|
| 177 |
+
try:
|
| 178 |
+
config, extracted_files = import_config_with_media(file.name)
|
| 179 |
+
config_json = json.dumps(config, indent=2)
|
| 180 |
+
files_str = "\n".join(extracted_files) if extracted_files else "No new files extracted (all already existed)"
|
| 181 |
+
status = f"Bundle imported! Config loaded, {len(extracted_files)} new media files extracted."
|
| 182 |
+
return (
|
| 183 |
+
status,
|
| 184 |
+
gr.update(choices=get_all_media_files()),
|
| 185 |
+
gr.update(value=None),
|
| 186 |
+
config_json,
|
| 187 |
+
files_str
|
| 188 |
+
)
|
| 189 |
+
except FileNotFoundError as e:
|
| 190 |
+
return f"Error: {str(e)}", gr.update(), gr.update(), None, ""
|
| 191 |
+
except Exception as e:
|
| 192 |
+
return f"Error importing bundle: {str(e)}", gr.update(), gr.update(), None, ""
|
| 193 |
+
|
| 194 |
+
# Handle regular media files
|
| 195 |
+
if not any(ext_lower in exts for exts in SUPPORTED_EXTENSIONS.values()):
|
| 196 |
+
return f"Unsupported file type: {extension}", gr.update(), gr.update(), None, ""
|
| 197 |
+
|
| 198 |
+
base_name = os.path.splitext(original_filename)[0]
|
| 199 |
+
counter = 1
|
| 200 |
+
new_filename = f"{base_name}{extension}"
|
| 201 |
+
while os.path.exists(os.path.join(SAVE_DIR, new_filename)):
|
| 202 |
+
new_filename = f"{base_name}_{counter}{extension}"
|
| 203 |
+
counter += 1
|
| 204 |
+
|
| 205 |
+
dest_path = os.path.join(SAVE_DIR, new_filename)
|
| 206 |
+
shutil.copy2(file.name, dest_path)
|
| 207 |
+
|
| 208 |
+
return (
|
| 209 |
+
f"File saved: {new_filename}",
|
| 210 |
+
gr.update(choices=get_all_media_files()),
|
| 211 |
+
gr.update(value=None),
|
| 212 |
+
None,
|
| 213 |
+
""
|
| 214 |
+
)
|
| 215 |
+
except Exception as e:
|
| 216 |
+
return f"Error: {str(e)}", gr.update(), gr.update(), None, ""
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def export_config_with_media_wrapper(config_json):
|
| 220 |
+
"""Wrapper for export that provides status message along with the file"""
|
| 221 |
+
from my_text_game_engine_attempt import export_config_with_media
|
| 222 |
+
if not config_json or not config_json.strip():
|
| 223 |
+
return None, "Please paste a config JSON to export"
|
| 224 |
+
try:
|
| 225 |
+
zip_path = export_config_with_media(config_json)
|
| 226 |
+
return zip_path, f"Export successful! Bundle ready for download."
|
| 227 |
+
except json.JSONDecodeError as e:
|
| 228 |
+
return None, f"Invalid JSON: {str(e)}"
|
| 229 |
+
except Exception as e:
|
| 230 |
+
return None, f"Export failed: {str(e)}"
|
| 231 |
+
|
| 232 |
+
|
| 233 |
def import_config_with_media(zip_path):
|
| 234 |
global SAVE_DIR
|
| 235 |
target_folder = SAVE_DIR
|
|
|
|
| 249 |
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
|
| 250 |
zip_ref.extractall(temp_dir)
|
| 251 |
|
| 252 |
+
# Load the config (check both config.json and game_config.json for compatibility)
|
| 253 |
config_path = os.path.join(temp_dir, 'config.json')
|
| 254 |
+
alt_config_path = os.path.join(temp_dir, 'game_config.json')
|
| 255 |
+
|
| 256 |
if os.path.exists(config_path):
|
| 257 |
with open(config_path, 'r') as f:
|
| 258 |
config = json.load(f)
|
| 259 |
+
elif os.path.exists(alt_config_path):
|
| 260 |
+
with open(alt_config_path, 'r') as f:
|
| 261 |
+
config = json.load(f)
|
| 262 |
else:
|
| 263 |
+
raise FileNotFoundError("config.json (or game_config.json) not found in the zip file")
|
| 264 |
|
| 265 |
# Create the target folder if it doesn't exist
|
| 266 |
os.makedirs(target_folder, exist_ok=True)
|
| 267 |
|
| 268 |
# Copy media files that don't already exist in the target folder
|
| 269 |
+
config_files = {'config.json', 'game_config.json'}
|
| 270 |
for root, _, files in os.walk(temp_dir):
|
| 271 |
for file in files:
|
| 272 |
+
if file not in config_files:
|
| 273 |
src_path = os.path.join(root, file)
|
| 274 |
dst_path = os.path.join(target_folder, file)
|
| 275 |
if not os.path.exists(dst_path):
|
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Film scenario data and sequence generation.
|
| 2 |
+
|
| 3 |
+
This module provides:
|
| 4 |
+
- FILM_SCENARIOS: Dictionary of categorized film scenario templates
|
| 5 |
+
- generate_scenario_sequence: Function to generate random sequences of scenarios
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import json
|
| 9 |
+
import random
|
| 10 |
+
|
| 11 |
+
FILM_SCENARIOS = {
|
| 12 |
+
"openings": [
|
| 13 |
+
("Dawn Patrol", "Character wakes up, morning routine reveals personality/situation"),
|
| 14 |
+
("The Arrival", "Protagonist enters new location for first time"),
|
| 15 |
+
("In Medias Res", "Opens mid-action, audience catches up"),
|
| 16 |
+
("The Funeral", "Character death sets story in motion, mourners gathered"),
|
| 17 |
+
("Voiceover Reflection", "Older character narrates past events"),
|
| 18 |
+
],
|
| 19 |
+
"tension": [
|
| 20 |
+
("The Ticking Clock", "Countdown visible/mentioned, deadline approaching"),
|
| 21 |
+
("Hiding in Plain Sight", "Character conceals identity among enemies"),
|
| 22 |
+
("The Stakeout", "Characters wait and watch from vehicle/building"),
|
| 23 |
+
("Parallel Editing Chase", "Cut between pursuer and pursued"),
|
| 24 |
+
("The Interrogation", "Character questioned under pressure"),
|
| 25 |
+
("Eavesdropping", "Character overhears crucial conversation"),
|
| 26 |
+
("The Setup/Double-Cross", "Trusted ally reveals betrayal"),
|
| 27 |
+
("Trapped in Enclosed Space", "Elevator, room, vehicle - no escape"),
|
| 28 |
+
("The Hostage Situation", "Loved one held, demands made"),
|
| 29 |
+
("Walking Into a Trap", "Audience knows danger character doesn't"),
|
| 30 |
+
],
|
| 31 |
+
"action": [
|
| 32 |
+
("The Standoff", "Multiple parties aim weapons, no one shoots first"),
|
| 33 |
+
("The Bar Fight", "Violence erupts in drinking establishment"),
|
| 34 |
+
("Car Chase Through City", "Vehicles weave through traffic, obstacles"),
|
| 35 |
+
("Rooftop Confrontation", "Final battle on building top, city below"),
|
| 36 |
+
("The Heist Execution", "Plan unfolds step by step with complications"),
|
| 37 |
+
("Training Montage", "Character improves skills over compressed time"),
|
| 38 |
+
("The Last Stand", "Outnumbered defenders hold position"),
|
| 39 |
+
("One vs Many", "Single fighter takes on multiple opponents"),
|
| 40 |
+
("The Duel", "Two opponents face off, formal or informal"),
|
| 41 |
+
("Escape Sequence", "Character flees captivity/danger"),
|
| 42 |
+
],
|
| 43 |
+
"emotional": [
|
| 44 |
+
("The Confession", "Character admits truth - love, crime, secret"),
|
| 45 |
+
("Deathbed Scene", "Dying character's final words/wishes"),
|
| 46 |
+
("The Reunion", "Long-separated characters meet again"),
|
| 47 |
+
("Breaking the News", "Character learns devastating information"),
|
| 48 |
+
("The Sacrifice", "Character gives up something precious for others"),
|
| 49 |
+
("Saying Goodbye", "Characters part ways, possibly forever"),
|
| 50 |
+
("The Breakdown", "Character's composure finally cracks"),
|
| 51 |
+
("Reconciliation", "Estranged characters make peace"),
|
| 52 |
+
("The Proposal", "Marriage or significant commitment offered"),
|
| 53 |
+
("Visiting the Grave", "Character speaks to deceased at burial site"),
|
| 54 |
+
],
|
| 55 |
+
"discovery": [
|
| 56 |
+
("Finding the Body", "Character discovers corpse"),
|
| 57 |
+
("The Evidence Room", "Character finds proof of conspiracy/truth"),
|
| 58 |
+
("Flashback Reveal", "Past event recontextualizes everything"),
|
| 59 |
+
("The Twist Revealed", "Major plot revelation changes understanding"),
|
| 60 |
+
("Reading the Letter/Document", "Written words deliver crucial info"),
|
| 61 |
+
("Surveillance Footage", "Character watches recording of key event"),
|
| 62 |
+
("The Photograph", "Image reveals connection or identity"),
|
| 63 |
+
("Decoding the Message", "Cipher/puzzle solved, meaning clear"),
|
| 64 |
+
],
|
| 65 |
+
"social": [
|
| 66 |
+
("The Dinner Party", "Tension beneath polite social gathering"),
|
| 67 |
+
("The Job Interview", "Character proves worth or fails to"),
|
| 68 |
+
("Meeting the Parents", "Romantic partner meets family"),
|
| 69 |
+
("The Courtroom", "Legal proceedings, testimony, verdict"),
|
| 70 |
+
("Press Conference", "Public statement, reporters' questions"),
|
| 71 |
+
("The Gala/Ball", "Formal event, everyone dressed up, intrigue"),
|
| 72 |
+
("The Wake/Reception", "Social gathering after significant event"),
|
| 73 |
+
],
|
| 74 |
+
"endings": [
|
| 75 |
+
("Walking Into the Sunset", "Hero departs, back to camera, horizon ahead"),
|
| 76 |
+
("The Circular Return", "Final scene mirrors opening, showing change"),
|
| 77 |
+
("Freeze Frame", "Action stops, often with voiceover or text"),
|
| 78 |
+
("The Bittersweet Victory", "Won but at great cost, hollow celebration"),
|
| 79 |
+
("New Dawn", "Sun rises on changed world/character, hope restored"),
|
| 80 |
+
("The Cliffhanger", "Unresolved tension, question left hanging"),
|
| 81 |
+
("Where Are They Now", "Text/montage showing characters' futures"),
|
| 82 |
+
("The Twist Ending", "Final revelation reframes entire story"),
|
| 83 |
+
("Full Circle Reunion", "Characters gather one last time"),
|
| 84 |
+
("The Long Walk Away", "Character leaves location, camera lingers"),
|
| 85 |
+
("Passing the Torch", "Legacy/knowledge transferred to next generation"),
|
| 86 |
+
("The Final Confrontation", "Last face-to-face with antagonist"),
|
| 87 |
+
("Quiet Moment After Storm", "Calm after climax, processing events"),
|
| 88 |
+
("The Sacrifice Payoff", "Earlier sacrifice proven worthwhile"),
|
| 89 |
+
("Open Road/New Beginning", "Character sets off on next journey"),
|
| 90 |
+
],
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def generate_scenario_sequence(count, include_opening, include_tension, include_action,
|
| 95 |
+
include_emotional, include_discovery, include_social, include_ending,
|
| 96 |
+
force_opening, force_ending):
|
| 97 |
+
"""Generate a random sequence of film scenarios.
|
| 98 |
+
|
| 99 |
+
Args:
|
| 100 |
+
count: Number of scenarios to generate
|
| 101 |
+
include_opening: Include opening scenarios
|
| 102 |
+
include_tension: Include tension scenarios
|
| 103 |
+
include_action: Include action scenarios
|
| 104 |
+
include_emotional: Include emotional scenarios
|
| 105 |
+
include_discovery: Include discovery scenarios
|
| 106 |
+
include_social: Include social scenarios
|
| 107 |
+
include_ending: Include ending scenarios
|
| 108 |
+
force_opening: Force sequence to start with an opening
|
| 109 |
+
force_ending: Force sequence to end with an ending
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
Tuple of (list_output, json_output, prompts_output)
|
| 113 |
+
"""
|
| 114 |
+
available = []
|
| 115 |
+
if include_opening:
|
| 116 |
+
available.extend([(s[0], s[1], "opening") for s in FILM_SCENARIOS["openings"]])
|
| 117 |
+
if include_tension:
|
| 118 |
+
available.extend([(s[0], s[1], "tension") for s in FILM_SCENARIOS["tension"]])
|
| 119 |
+
if include_action:
|
| 120 |
+
available.extend([(s[0], s[1], "action") for s in FILM_SCENARIOS["action"]])
|
| 121 |
+
if include_emotional:
|
| 122 |
+
available.extend([(s[0], s[1], "emotional") for s in FILM_SCENARIOS["emotional"]])
|
| 123 |
+
if include_discovery:
|
| 124 |
+
available.extend([(s[0], s[1], "discovery") for s in FILM_SCENARIOS["discovery"]])
|
| 125 |
+
if include_social:
|
| 126 |
+
available.extend([(s[0], s[1], "social") for s in FILM_SCENARIOS["social"]])
|
| 127 |
+
if include_ending:
|
| 128 |
+
available.extend([(s[0], s[1], "ending") for s in FILM_SCENARIOS["endings"]])
|
| 129 |
+
|
| 130 |
+
if not available:
|
| 131 |
+
return "Select at least one category!", "{}", ""
|
| 132 |
+
|
| 133 |
+
sequence = []
|
| 134 |
+
|
| 135 |
+
# Force opening if requested
|
| 136 |
+
if force_opening and include_opening:
|
| 137 |
+
opening = random.choice(FILM_SCENARIOS["openings"])
|
| 138 |
+
sequence.append((opening[0], opening[1], "opening"))
|
| 139 |
+
count -= 1
|
| 140 |
+
|
| 141 |
+
# Force ending if requested
|
| 142 |
+
ending_scene = None
|
| 143 |
+
if force_ending and include_ending:
|
| 144 |
+
ending_scene = random.choice(FILM_SCENARIOS["endings"])
|
| 145 |
+
count -= 1
|
| 146 |
+
|
| 147 |
+
# Fill middle with random selections
|
| 148 |
+
middle_available = [s for s in available if s[2] not in (["opening"] if force_opening else [])
|
| 149 |
+
and s[2] not in (["ending"] if force_ending else [])]
|
| 150 |
+
if not middle_available:
|
| 151 |
+
middle_available = available
|
| 152 |
+
|
| 153 |
+
random.shuffle(middle_available)
|
| 154 |
+
sequence.extend(middle_available[:max(0, count)])
|
| 155 |
+
|
| 156 |
+
# Add ending at the end
|
| 157 |
+
if ending_scene:
|
| 158 |
+
sequence.append((ending_scene[0], ending_scene[1], "ending"))
|
| 159 |
+
|
| 160 |
+
# Format as list
|
| 161 |
+
list_output = "## Generated Sequence\n\n"
|
| 162 |
+
for i, (name, desc, cat) in enumerate(sequence, 1):
|
| 163 |
+
list_output += f"**{i}. {name}** [{cat}]\n{desc}\n\n"
|
| 164 |
+
|
| 165 |
+
# Format as config JSON (using correct engine format)
|
| 166 |
+
config = {"story_location": {}}
|
| 167 |
+
for i, (name, desc, cat) in enumerate(sequence, 1):
|
| 168 |
+
state_name = name.lower().replace(" ", "_").replace("/", "_").replace("-", "_")
|
| 169 |
+
current_state_id = f"scene_{i}_{state_name}"
|
| 170 |
+
|
| 171 |
+
# Determine next state
|
| 172 |
+
if i < len(sequence):
|
| 173 |
+
next_name = sequence[i][0] # Get next scene's name
|
| 174 |
+
next_state_name = next_name.lower().replace(" ", "_").replace("/", "_").replace("-", "_")
|
| 175 |
+
next_state_id = f"scene_{i+1}_{next_state_name}"
|
| 176 |
+
else:
|
| 177 |
+
next_state_id = "ending"
|
| 178 |
+
|
| 179 |
+
# Use proper format: choices as list of strings, transitions as dict
|
| 180 |
+
config["story_location"][current_state_id] = {
|
| 181 |
+
"description": f"[{cat.upper()}] {desc}",
|
| 182 |
+
"media_prompt": f"Cinematic shot: {name} - {desc}",
|
| 183 |
+
"choices": ["Continue"],
|
| 184 |
+
"transitions": {
|
| 185 |
+
"Continue": next_state_id
|
| 186 |
+
}
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
# Add ending state
|
| 190 |
+
first_state_name = sequence[0][0].lower().replace(" ", "_").replace("/", "_").replace("-", "_")
|
| 191 |
+
config["story_location"]["ending"] = {
|
| 192 |
+
"description": "The story concludes here.",
|
| 193 |
+
"choices": ["Start Over"],
|
| 194 |
+
"transitions": {
|
| 195 |
+
"Start Over": f"scene_1_{first_state_name}"
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
json_output = json.dumps(config, indent=2)
|
| 200 |
+
|
| 201 |
+
# Format prompts only (for queue)
|
| 202 |
+
prompts_output = "## Video Prompts (copy to queue)\n\n"
|
| 203 |
+
for i, (name, desc, cat) in enumerate(sequence, 1):
|
| 204 |
+
prompts_output += f"Cinematic shot: {name} - {desc}\n"
|
| 205 |
+
|
| 206 |
+
return list_output, json_output, prompts_output
|
|
The diff for this file is too large to render.
See raw diff
|
|
|
|
@@ -0,0 +1,377 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
GameState - Rich state tracking for game configs with logic gates.
|
| 3 |
+
|
| 4 |
+
Provides comprehensive tracking beyond the basic Player class:
|
| 5 |
+
- Inventory, money, knowledge (existing)
|
| 6 |
+
- People met, locations discovered
|
| 7 |
+
- Missions/quests (active, completed, failed)
|
| 8 |
+
- Boolean flags and numeric counters
|
| 9 |
+
- Choice history for analytics
|
| 10 |
+
|
| 11 |
+
JSON-serializable for save/load functionality.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from typing import Dict, List, Set, Any, Optional
|
| 15 |
+
from dataclasses import dataclass, field
|
| 16 |
+
import json
|
| 17 |
+
import time
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class GameState:
|
| 22 |
+
"""
|
| 23 |
+
Comprehensive game state tracking.
|
| 24 |
+
All fields are JSON-serializable for save/load.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
# ==================== Character Resources (from existing Player) ====================
|
| 28 |
+
inventory: List[str] = field(default_factory=list)
|
| 29 |
+
money: int = 0
|
| 30 |
+
knowledge: Dict[str, Any] = field(default_factory=dict)
|
| 31 |
+
|
| 32 |
+
# ==================== People/NPCs ====================
|
| 33 |
+
people_met: Set[str] = field(default_factory=set)
|
| 34 |
+
npc_reputation: Dict[str, int] = field(default_factory=dict) # NPC -> affinity score
|
| 35 |
+
|
| 36 |
+
# ==================== Locations ====================
|
| 37 |
+
locations_visited: Set[str] = field(default_factory=set)
|
| 38 |
+
locations_discovered: Set[str] = field(default_factory=set) # Known but not visited
|
| 39 |
+
current_location: str = ""
|
| 40 |
+
current_state: str = ""
|
| 41 |
+
|
| 42 |
+
# ==================== Missions/Quests ====================
|
| 43 |
+
missions_active: Dict[str, Dict] = field(default_factory=dict) # id -> {status, progress, data}
|
| 44 |
+
missions_completed: Set[str] = field(default_factory=set)
|
| 45 |
+
missions_failed: Set[str] = field(default_factory=set)
|
| 46 |
+
|
| 47 |
+
# ==================== Flags (boolean switches) ====================
|
| 48 |
+
flags: Dict[str, bool] = field(default_factory=dict)
|
| 49 |
+
|
| 50 |
+
# ==================== Counters (numeric trackers) ====================
|
| 51 |
+
counters: Dict[str, int] = field(default_factory=dict)
|
| 52 |
+
|
| 53 |
+
# ==================== History Tracking ====================
|
| 54 |
+
choice_history: List[Dict] = field(default_factory=list) # [{state, choice, timestamp}]
|
| 55 |
+
state_visit_counts: Dict[str, int] = field(default_factory=dict)
|
| 56 |
+
|
| 57 |
+
# ==================== Metadata ====================
|
| 58 |
+
game_start_time: float = field(default_factory=time.time)
|
| 59 |
+
total_choices_made: int = 0
|
| 60 |
+
|
| 61 |
+
# ==================== Item Methods ====================
|
| 62 |
+
|
| 63 |
+
def add_item(self, item: str) -> None:
|
| 64 |
+
"""Add an item to inventory (no duplicates)."""
|
| 65 |
+
if item not in self.inventory:
|
| 66 |
+
self.inventory.append(item)
|
| 67 |
+
|
| 68 |
+
def add_items(self, items: List[str]) -> None:
|
| 69 |
+
"""Add multiple items to inventory."""
|
| 70 |
+
for item in items:
|
| 71 |
+
self.add_item(item)
|
| 72 |
+
|
| 73 |
+
def remove_item(self, item: str) -> bool:
|
| 74 |
+
"""Remove an item from inventory. Returns True if successful."""
|
| 75 |
+
if item in self.inventory:
|
| 76 |
+
self.inventory.remove(item)
|
| 77 |
+
return True
|
| 78 |
+
return False
|
| 79 |
+
|
| 80 |
+
def has_item(self, item: str) -> bool:
|
| 81 |
+
"""Check if player has an item."""
|
| 82 |
+
return item in self.inventory
|
| 83 |
+
|
| 84 |
+
def item_count(self, item: str) -> int:
|
| 85 |
+
"""Count occurrences of an item (for stackable items)."""
|
| 86 |
+
return self.inventory.count(item)
|
| 87 |
+
|
| 88 |
+
# ==================== Money Methods ====================
|
| 89 |
+
|
| 90 |
+
def add_money(self, amount: int) -> None:
|
| 91 |
+
"""Add money to player's balance."""
|
| 92 |
+
self.money += amount
|
| 93 |
+
|
| 94 |
+
def remove_money(self, amount: int) -> bool:
|
| 95 |
+
"""Remove money. Returns True if player had enough."""
|
| 96 |
+
if self.money >= amount:
|
| 97 |
+
self.money -= amount
|
| 98 |
+
return True
|
| 99 |
+
return False
|
| 100 |
+
|
| 101 |
+
def has_money(self, amount: int) -> bool:
|
| 102 |
+
"""Check if player has at least this much money."""
|
| 103 |
+
return self.money >= amount
|
| 104 |
+
|
| 105 |
+
# ==================== People Methods ====================
|
| 106 |
+
|
| 107 |
+
def meet_person(self, person: str) -> None:
|
| 108 |
+
"""Mark an NPC as met."""
|
| 109 |
+
self.people_met.add(person)
|
| 110 |
+
|
| 111 |
+
def has_met(self, person: str) -> bool:
|
| 112 |
+
"""Check if player has met an NPC."""
|
| 113 |
+
return person in self.people_met
|
| 114 |
+
|
| 115 |
+
def adjust_reputation(self, npc: str, change: int) -> None:
|
| 116 |
+
"""Adjust reputation with an NPC."""
|
| 117 |
+
self.npc_reputation[npc] = self.npc_reputation.get(npc, 0) + change
|
| 118 |
+
|
| 119 |
+
def get_reputation(self, npc: str) -> int:
|
| 120 |
+
"""Get reputation with an NPC (default 0)."""
|
| 121 |
+
return self.npc_reputation.get(npc, 0)
|
| 122 |
+
|
| 123 |
+
# ==================== Location Methods ====================
|
| 124 |
+
|
| 125 |
+
def visit_location(self, location: str) -> None:
|
| 126 |
+
"""Mark a location as visited."""
|
| 127 |
+
self.locations_visited.add(location)
|
| 128 |
+
self.locations_discovered.discard(location) # No longer just discovered
|
| 129 |
+
|
| 130 |
+
def discover_location(self, location: str) -> None:
|
| 131 |
+
"""Mark a location as discovered (but not visited)."""
|
| 132 |
+
if location not in self.locations_visited:
|
| 133 |
+
self.locations_discovered.add(location)
|
| 134 |
+
|
| 135 |
+
def has_visited(self, location: str) -> bool:
|
| 136 |
+
"""Check if player has visited a location."""
|
| 137 |
+
return location in self.locations_visited
|
| 138 |
+
|
| 139 |
+
def has_discovered(self, location: str) -> bool:
|
| 140 |
+
"""Check if player knows about a location (visited or discovered)."""
|
| 141 |
+
return location in self.locations_visited or location in self.locations_discovered
|
| 142 |
+
|
| 143 |
+
# ==================== Mission Methods ====================
|
| 144 |
+
|
| 145 |
+
def start_mission(self, mission_id: str, data: Dict = None) -> None:
|
| 146 |
+
"""Start a new mission/quest."""
|
| 147 |
+
self.missions_active[mission_id] = data or {"status": "active", "progress": 0}
|
| 148 |
+
|
| 149 |
+
def update_mission(self, mission_id: str, updates: Dict) -> None:
|
| 150 |
+
"""Update mission data."""
|
| 151 |
+
if mission_id in self.missions_active:
|
| 152 |
+
self.missions_active[mission_id].update(updates)
|
| 153 |
+
|
| 154 |
+
def complete_mission(self, mission_id: str) -> None:
|
| 155 |
+
"""Mark a mission as completed."""
|
| 156 |
+
if mission_id in self.missions_active:
|
| 157 |
+
del self.missions_active[mission_id]
|
| 158 |
+
self.missions_completed.add(mission_id)
|
| 159 |
+
|
| 160 |
+
def fail_mission(self, mission_id: str) -> None:
|
| 161 |
+
"""Mark a mission as failed."""
|
| 162 |
+
if mission_id in self.missions_active:
|
| 163 |
+
del self.missions_active[mission_id]
|
| 164 |
+
self.missions_failed.add(mission_id)
|
| 165 |
+
|
| 166 |
+
def is_mission_complete(self, mission_id: str) -> bool:
|
| 167 |
+
"""Check if a mission is completed."""
|
| 168 |
+
return mission_id in self.missions_completed
|
| 169 |
+
|
| 170 |
+
def is_mission_active(self, mission_id: str) -> bool:
|
| 171 |
+
"""Check if a mission is currently active."""
|
| 172 |
+
return mission_id in self.missions_active
|
| 173 |
+
|
| 174 |
+
def is_mission_failed(self, mission_id: str) -> bool:
|
| 175 |
+
"""Check if a mission has failed."""
|
| 176 |
+
return mission_id in self.missions_failed
|
| 177 |
+
|
| 178 |
+
def get_mission_data(self, mission_id: str) -> Optional[Dict]:
|
| 179 |
+
"""Get data for an active mission."""
|
| 180 |
+
return self.missions_active.get(mission_id)
|
| 181 |
+
|
| 182 |
+
# ==================== Flag Methods ====================
|
| 183 |
+
|
| 184 |
+
def set_flag(self, flag: str, value: bool = True) -> None:
|
| 185 |
+
"""Set a boolean flag."""
|
| 186 |
+
self.flags[flag] = value
|
| 187 |
+
|
| 188 |
+
def clear_flag(self, flag: str) -> None:
|
| 189 |
+
"""Clear (set to False) a flag."""
|
| 190 |
+
self.flags[flag] = False
|
| 191 |
+
|
| 192 |
+
def has_flag(self, flag: str) -> bool:
|
| 193 |
+
"""Check if a flag is set (True)."""
|
| 194 |
+
return self.flags.get(flag, False)
|
| 195 |
+
|
| 196 |
+
def toggle_flag(self, flag: str) -> bool:
|
| 197 |
+
"""Toggle a flag and return new value."""
|
| 198 |
+
new_value = not self.flags.get(flag, False)
|
| 199 |
+
self.flags[flag] = new_value
|
| 200 |
+
return new_value
|
| 201 |
+
|
| 202 |
+
# ==================== Counter Methods ====================
|
| 203 |
+
|
| 204 |
+
def set_counter(self, name: str, value: int) -> None:
|
| 205 |
+
"""Set a counter to a specific value."""
|
| 206 |
+
self.counters[name] = value
|
| 207 |
+
|
| 208 |
+
def increment_counter(self, name: str, amount: int = 1) -> int:
|
| 209 |
+
"""Increment a counter and return new value."""
|
| 210 |
+
self.counters[name] = self.counters.get(name, 0) + amount
|
| 211 |
+
return self.counters[name]
|
| 212 |
+
|
| 213 |
+
def decrement_counter(self, name: str, amount: int = 1) -> int:
|
| 214 |
+
"""Decrement a counter and return new value."""
|
| 215 |
+
return self.increment_counter(name, -amount)
|
| 216 |
+
|
| 217 |
+
def get_counter(self, name: str, default: int = 0) -> int:
|
| 218 |
+
"""Get a counter value."""
|
| 219 |
+
return self.counters.get(name, default)
|
| 220 |
+
|
| 221 |
+
# ==================== Knowledge Methods ====================
|
| 222 |
+
|
| 223 |
+
def update_knowledge(self, key: str, value: Any = True) -> None:
|
| 224 |
+
"""Add or update knowledge."""
|
| 225 |
+
self.knowledge[key] = value
|
| 226 |
+
|
| 227 |
+
def has_knowledge(self, key: str) -> bool:
|
| 228 |
+
"""Check if player has a knowledge entry."""
|
| 229 |
+
return key in self.knowledge
|
| 230 |
+
|
| 231 |
+
def get_knowledge(self, key: str, default: Any = None) -> Any:
|
| 232 |
+
"""Get a knowledge value."""
|
| 233 |
+
return self.knowledge.get(key, default)
|
| 234 |
+
|
| 235 |
+
def remove_knowledge(self, key: str) -> None:
|
| 236 |
+
"""Remove a knowledge entry."""
|
| 237 |
+
self.knowledge.pop(key, None)
|
| 238 |
+
|
| 239 |
+
# ==================== History Methods ====================
|
| 240 |
+
|
| 241 |
+
def record_choice(self, state: str, choice: str) -> None:
|
| 242 |
+
"""Record a player choice for history tracking."""
|
| 243 |
+
self.choice_history.append({
|
| 244 |
+
"state": state,
|
| 245 |
+
"choice": choice,
|
| 246 |
+
"timestamp": time.time()
|
| 247 |
+
})
|
| 248 |
+
self.total_choices_made += 1
|
| 249 |
+
self.state_visit_counts[state] = self.state_visit_counts.get(state, 0) + 1
|
| 250 |
+
|
| 251 |
+
def get_visit_count(self, state: str) -> int:
|
| 252 |
+
"""Get number of times a state was visited."""
|
| 253 |
+
return self.state_visit_counts.get(state, 0)
|
| 254 |
+
|
| 255 |
+
def get_last_choices(self, n: int = 5) -> List[Dict]:
|
| 256 |
+
"""Get the last N choices made."""
|
| 257 |
+
return self.choice_history[-n:] if self.choice_history else []
|
| 258 |
+
|
| 259 |
+
# ==================== Serialization ====================
|
| 260 |
+
|
| 261 |
+
def to_dict(self) -> Dict:
|
| 262 |
+
"""Convert to JSON-serializable dict for saving."""
|
| 263 |
+
return {
|
| 264 |
+
"inventory": self.inventory,
|
| 265 |
+
"money": self.money,
|
| 266 |
+
"knowledge": self.knowledge,
|
| 267 |
+
"people_met": list(self.people_met),
|
| 268 |
+
"npc_reputation": self.npc_reputation,
|
| 269 |
+
"locations_visited": list(self.locations_visited),
|
| 270 |
+
"locations_discovered": list(self.locations_discovered),
|
| 271 |
+
"current_location": self.current_location,
|
| 272 |
+
"current_state": self.current_state,
|
| 273 |
+
"missions_active": self.missions_active,
|
| 274 |
+
"missions_completed": list(self.missions_completed),
|
| 275 |
+
"missions_failed": list(self.missions_failed),
|
| 276 |
+
"flags": self.flags,
|
| 277 |
+
"counters": self.counters,
|
| 278 |
+
"choice_history": self.choice_history,
|
| 279 |
+
"state_visit_counts": self.state_visit_counts,
|
| 280 |
+
"game_start_time": self.game_start_time,
|
| 281 |
+
"total_choices_made": self.total_choices_made
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
@classmethod
|
| 285 |
+
def from_dict(cls, data: Dict) -> 'GameState':
|
| 286 |
+
"""Reconstruct GameState from dict (for loading saves)."""
|
| 287 |
+
state = cls()
|
| 288 |
+
state.inventory = data.get("inventory", [])
|
| 289 |
+
state.money = data.get("money", 0)
|
| 290 |
+
state.knowledge = data.get("knowledge", {})
|
| 291 |
+
state.people_met = set(data.get("people_met", []))
|
| 292 |
+
state.npc_reputation = data.get("npc_reputation", {})
|
| 293 |
+
state.locations_visited = set(data.get("locations_visited", []))
|
| 294 |
+
state.locations_discovered = set(data.get("locations_discovered", []))
|
| 295 |
+
state.current_location = data.get("current_location", "")
|
| 296 |
+
state.current_state = data.get("current_state", "")
|
| 297 |
+
state.missions_active = data.get("missions_active", {})
|
| 298 |
+
state.missions_completed = set(data.get("missions_completed", []))
|
| 299 |
+
state.missions_failed = set(data.get("missions_failed", []))
|
| 300 |
+
state.flags = data.get("flags", {})
|
| 301 |
+
state.counters = data.get("counters", {})
|
| 302 |
+
state.choice_history = data.get("choice_history", [])
|
| 303 |
+
state.state_visit_counts = data.get("state_visit_counts", {})
|
| 304 |
+
state.game_start_time = data.get("game_start_time", time.time())
|
| 305 |
+
state.total_choices_made = data.get("total_choices_made", 0)
|
| 306 |
+
return state
|
| 307 |
+
|
| 308 |
+
def to_json(self) -> str:
|
| 309 |
+
"""Serialize to JSON string."""
|
| 310 |
+
return json.dumps(self.to_dict(), indent=2)
|
| 311 |
+
|
| 312 |
+
@classmethod
|
| 313 |
+
def from_json(cls, json_str: str) -> 'GameState':
|
| 314 |
+
"""Deserialize from JSON string."""
|
| 315 |
+
return cls.from_dict(json.loads(json_str))
|
| 316 |
+
|
| 317 |
+
def reset(self) -> None:
|
| 318 |
+
"""Reset state to initial values (for new game)."""
|
| 319 |
+
self.inventory.clear()
|
| 320 |
+
self.money = 0
|
| 321 |
+
self.knowledge.clear()
|
| 322 |
+
self.people_met.clear()
|
| 323 |
+
self.npc_reputation.clear()
|
| 324 |
+
self.locations_visited.clear()
|
| 325 |
+
self.locations_discovered.clear()
|
| 326 |
+
self.current_location = ""
|
| 327 |
+
self.current_state = ""
|
| 328 |
+
self.missions_active.clear()
|
| 329 |
+
self.missions_completed.clear()
|
| 330 |
+
self.missions_failed.clear()
|
| 331 |
+
self.flags.clear()
|
| 332 |
+
self.counters.clear()
|
| 333 |
+
self.choice_history.clear()
|
| 334 |
+
self.state_visit_counts.clear()
|
| 335 |
+
self.game_start_time = time.time()
|
| 336 |
+
self.total_choices_made = 0
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
class Player:
|
| 340 |
+
"""
|
| 341 |
+
Backwards-compatible Player class that wraps GameState.
|
| 342 |
+
Existing configs using lambda consequences will continue to work.
|
| 343 |
+
"""
|
| 344 |
+
|
| 345 |
+
def __init__(self, game_state: GameState = None):
|
| 346 |
+
self._state = game_state or GameState()
|
| 347 |
+
|
| 348 |
+
# Existing API (unchanged for backwards compatibility)
|
| 349 |
+
@property
|
| 350 |
+
def inventory(self) -> List[str]:
|
| 351 |
+
return self._state.inventory
|
| 352 |
+
|
| 353 |
+
@property
|
| 354 |
+
def money(self) -> int:
|
| 355 |
+
return self._state.money
|
| 356 |
+
|
| 357 |
+
@money.setter
|
| 358 |
+
def money(self, value: int):
|
| 359 |
+
self._state.money = value
|
| 360 |
+
|
| 361 |
+
@property
|
| 362 |
+
def knowledge(self) -> Dict[str, Any]:
|
| 363 |
+
return self._state.knowledge
|
| 364 |
+
|
| 365 |
+
def add_item(self, item: str) -> None:
|
| 366 |
+
self._state.add_item(item)
|
| 367 |
+
|
| 368 |
+
def has_item(self, item: str) -> bool:
|
| 369 |
+
return self._state.has_item(item)
|
| 370 |
+
|
| 371 |
+
def update_knowledge(self, topic: str) -> None:
|
| 372 |
+
self._state.update_knowledge(topic, True)
|
| 373 |
+
|
| 374 |
+
# NEW: Expose full state for advanced usage
|
| 375 |
+
@property
|
| 376 |
+
def game_state(self) -> GameState:
|
| 377 |
+
return self._state
|