kwabs22 Claude Opus 4.5 commited on
Commit
9328e91
·
1 Parent(s): 68f5a46

Port changes from duplicate space to original

Browse files

Sync 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>

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +6 -0
  2. TECHNICAL_DEBT_PLAN_2026-01-21.md +136 -0
  3. TODO_MOVINGDOTSPACE.md +91 -0
  4. app.py +0 -0
  5. beat_scenarios.py +237 -0
  6. bible_as_inspiration_source.py +502 -0
  7. condition_evaluator.py +458 -0
  8. config_dev_assistance_reference.md +273 -0
  9. config_dev_assistance_variables.md +0 -0
  10. config_linting.py +1256 -0
  11. demo_llm_loop_examples.py +935 -0
  12. demo_mystery_complete.json +327 -0
  13. demo_mystery_starter.json +81 -0
  14. dnd_game_master_hfport/__init__.py +34 -0
  15. dnd_game_master_hfport/engine/__init__.py +28 -0
  16. dnd_game_master_hfport/engine/checks.py +170 -0
  17. dnd_game_master_hfport/engine/dice.py +136 -0
  18. dnd_game_master_hfport/engine/modifiers.py +168 -0
  19. dnd_game_master_hfport/example.py +149 -0
  20. dnd_game_master_hfport/game_master.py +462 -0
  21. dnd_game_master_hfport/gradio_app.py +289 -0
  22. dnd_game_master_hfport/hf_integration.py +395 -0
  23. dnd_game_master_hfport/models/__init__.py +22 -0
  24. dnd_game_master_hfport/models/character.py +344 -0
  25. dnd_game_master_hfport/models/game_state.py +288 -0
  26. dnd_game_master_hfport/models/scene.py +235 -0
  27. dnd_game_master_hfport/prompts/__init__.py +19 -0
  28. dnd_game_master_hfport/prompts/gm_prompts.py +288 -0
  29. dnd_game_master_hfport/requirements.txt +17 -0
  30. dnd_scenarios.py +229 -0
  31. docs/hf_auth_integration_notes.md +168 -0
  32. exporters/__init__.py +97 -0
  33. exporters/ar_exporters.py +843 -0
  34. exporters/fiction_exporters.py +140 -0
  35. exporters/flutter_exporter.py +124 -0
  36. exporters/game_platform_exporters.py +633 -0
  37. exporters/gamemaker_exporter.py +103 -0
  38. exporters/html_exporters.py +596 -0
  39. exporters/ink_exporter.py +86 -0
  40. exporters/renpy_exporter.py +112 -0
  41. exporters/roblox_exporter.py +192 -0
  42. exporters/router.py +127 -0
  43. exporters/rpgmaker_exporter.py +247 -0
  44. exporters/terminal_exporter.py +131 -0
  45. exporters/twine_exporter.py +127 -0
  46. exporters/yarn_exporter.py +108 -0
  47. file_explorer_and_upload.py +166 -11
  48. film_scenarios.py +206 -0
  49. game_configs.py +0 -0
  50. game_state.py +377 -0
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .DS_Store
5
+ *.log
6
+ sd_output_*.png
TECHNICAL_DEBT_PLAN_2026-01-21.md ADDED
@@ -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
+
TODO_MOVINGDOTSPACE.md ADDED
@@ -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
app.py CHANGED
The diff for this file is too large to render. See raw diff
 
beat_scenarios.py ADDED
@@ -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)
bible_as_inspiration_source.py ADDED
@@ -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
condition_evaluator.py ADDED
@@ -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
config_dev_assistance_reference.md ADDED
@@ -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
config_dev_assistance_variables.md ADDED
The diff for this file is too large to render. See raw diff
 
config_linting.py ADDED
@@ -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)
demo_llm_loop_examples.py ADDED
@@ -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)
demo_mystery_complete.json ADDED
@@ -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
+ }
demo_mystery_starter.json ADDED
@@ -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
+ }
dnd_game_master_hfport/__init__.py ADDED
@@ -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
+ ]
dnd_game_master_hfport/engine/__init__.py ADDED
@@ -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
+ ]
dnd_game_master_hfport/engine/checks.py ADDED
@@ -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
dnd_game_master_hfport/engine/dice.py ADDED
@@ -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)
dnd_game_master_hfport/engine/modifiers.py ADDED
@@ -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
dnd_game_master_hfport/example.py ADDED
@@ -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()
dnd_game_master_hfport/game_master.py ADDED
@@ -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', '')}")
dnd_game_master_hfport/gradio_app.py ADDED
@@ -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()
dnd_game_master_hfport/hf_integration.py ADDED
@@ -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
+ ]
dnd_game_master_hfport/models/__init__.py ADDED
@@ -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
+ ]
dnd_game_master_hfport/models/character.py ADDED
@@ -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
+ }
dnd_game_master_hfport/models/game_state.py ADDED
@@ -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)
dnd_game_master_hfport/models/scene.py ADDED
@@ -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
+ }
dnd_game_master_hfport/prompts/__init__.py ADDED
@@ -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
+ ]
dnd_game_master_hfport/prompts/gm_prompts.py ADDED
@@ -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
+ }}"""
dnd_game_master_hfport/requirements.txt ADDED
@@ -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
dnd_scenarios.py ADDED
@@ -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)
docs/hf_auth_integration_notes.md ADDED
@@ -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
exporters/__init__.py ADDED
@@ -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
+ ]
exporters/ar_exporters.py ADDED
@@ -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
exporters/fiction_exporters.py ADDED
@@ -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
exporters/flutter_exporter.py ADDED
@@ -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
exporters/game_platform_exporters.py ADDED
@@ -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
exporters/gamemaker_exporter.py ADDED
@@ -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
exporters/html_exporters.py ADDED
@@ -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
exporters/ink_exporter.py ADDED
@@ -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
exporters/renpy_exporter.py ADDED
@@ -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
exporters/roblox_exporter.py ADDED
@@ -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
exporters/router.py ADDED
@@ -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
exporters/rpgmaker_exporter.py ADDED
@@ -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
exporters/terminal_exporter.py ADDED
@@ -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
exporters/twine_exporter.py ADDED
@@ -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
exporters/yarn_exporter.py ADDED
@@ -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
file_explorer_and_upload.py CHANGED
@@ -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
- full_path = os.path.join(SAVE_DIR, file_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- return None, None, None, f"Error viewing file: {str(e)}"
 
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 != 'config.json':
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):
film_scenarios.py ADDED
@@ -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
game_configs.py ADDED
The diff for this file is too large to render. See raw diff
 
game_state.py ADDED
@@ -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