Spaces:
Sleeping
Sleeping
Upload 12 files
Browse files- README.md +557 -14
- app.py +2005 -0
- combat_engine.py +97 -0
- demo_rules.py +1410 -0
- nlu_engine.py +431 -0
- requirement.md +89 -0
- requirements.txt +4 -0
- scene_assets.py +31 -0
- state_manager.py +0 -0
- story_engine.py +0 -0
- telemetry.py +81 -0
- utils.py +328 -0
README.md
CHANGED
|
@@ -1,14 +1,557 @@
|
|
| 1 |
-
---
|
| 2 |
-
title:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
-
sdk: gradio
|
| 7 |
-
sdk_version:
|
| 8 |
-
app_file: app.py
|
| 9 |
-
pinned: false
|
| 10 |
-
license: mit
|
| 11 |
-
short_description:
|
| 12 |
-
---
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: StoryWeaver
|
| 3 |
+
emoji: 📖
|
| 4 |
+
colorFrom: red
|
| 5 |
+
colorTo: purple
|
| 6 |
+
sdk: gradio
|
| 7 |
+
sdk_version: 4.44.0
|
| 8 |
+
app_file: app.py
|
| 9 |
+
pinned: false
|
| 10 |
+
license: mit
|
| 11 |
+
short_description: Interactive NLP story engine with evaluation and logging
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
# StoryWeaver
|
| 15 |
+
|
| 16 |
+
StoryWeaver is an interactive text-adventure system built for our NLP course project. The repo is structured as an engineering project first and a demo second: it contains the playable app, the state-management core, evaluation scripts, and logging utilities needed for report writing and team collaboration.
|
| 17 |
+
|
| 18 |
+
This README is written for teammates who need to:
|
| 19 |
+
|
| 20 |
+
- understand how the system is organized
|
| 21 |
+
- run the app locally
|
| 22 |
+
- know where to change prompts, rules, or UI
|
| 23 |
+
- collect evaluation results for the report
|
| 24 |
+
- debug a bad interaction without reading the whole codebase first
|
| 25 |
+
|
| 26 |
+
## What This Repository Contains
|
| 27 |
+
|
| 28 |
+
At a high level, the project has five responsibilities:
|
| 29 |
+
|
| 30 |
+
1. parse player input into structured intent
|
| 31 |
+
2. keep the world state consistent across turns
|
| 32 |
+
3. generate the next story response and options
|
| 33 |
+
4. expose the system through a Gradio UI
|
| 34 |
+
5. export logs and run reproducible evaluation
|
| 35 |
+
|
| 36 |
+
This means the repo is not only a "game demo". It is also the evidence pipeline for the course deliverables.
|
| 37 |
+
|
| 38 |
+
## Quick Start
|
| 39 |
+
|
| 40 |
+
### 1. Install dependencies
|
| 41 |
+
|
| 42 |
+
```bash
|
| 43 |
+
pip install -r requirements.txt
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
### 2. Create `.env`
|
| 47 |
+
|
| 48 |
+
Create a `.env` file in the project root:
|
| 49 |
+
|
| 50 |
+
```env
|
| 51 |
+
QWEN_API_KEY=your_api_key_here
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
Optional:
|
| 55 |
+
|
| 56 |
+
```env
|
| 57 |
+
STORYWEAVER_LOG_DIR=logs/interactions
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
### 3. Run the app
|
| 61 |
+
|
| 62 |
+
```bash
|
| 63 |
+
python app.py
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
Default local URL:
|
| 67 |
+
|
| 68 |
+
- `http://localhost:7860`
|
| 69 |
+
|
| 70 |
+
### 4. Run evaluation
|
| 71 |
+
|
| 72 |
+
```bash
|
| 73 |
+
python evaluation/run_evaluations.py --task all --repeats 3
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
Useful variants:
|
| 77 |
+
|
| 78 |
+
```bash
|
| 79 |
+
python evaluation/run_evaluations.py --task intent
|
| 80 |
+
python evaluation/run_evaluations.py --task consistency
|
| 81 |
+
python evaluation/run_evaluations.py --task latency --repeats 5
|
| 82 |
+
python evaluation/run_evaluations.py --task branch
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
## Deploy to Hugging Face Spaces (Web Upload)
|
| 86 |
+
|
| 87 |
+
If you want to deploy quickly without using git commands, use this checklist:
|
| 88 |
+
|
| 89 |
+
1. Create a new Space on Hugging Face:
|
| 90 |
+
- SDK: `Gradio`
|
| 91 |
+
- Python version: default is fine (3.10+)
|
| 92 |
+
2. Upload project files from this repository root.
|
| 93 |
+
3. Do **not** upload local-only files/directories:
|
| 94 |
+
- `venv/`, `.venv/`, `.env`, `__pycache__/`, `.gradio/`, `logs/`, `evaluation/results/`
|
| 95 |
+
4. In Space settings, add secret:
|
| 96 |
+
- `QWEN_API_KEY`
|
| 97 |
+
5. Wait for build to finish, then open the Space URL.
|
| 98 |
+
|
| 99 |
+
This repository already uses the standard Gradio entrypoint in `app.py`, so Spaces will start the app automatically.
|
| 100 |
+
|
| 101 |
+
## Recommended Reading Order
|
| 102 |
+
|
| 103 |
+
If you are new to the repo, read files in this order:
|
| 104 |
+
|
| 105 |
+
1. [state_manager.py](./state_manager.py)
|
| 106 |
+
Why: this is the single source of truth for player state, world state, quests, items, consistency checks, and state updates.
|
| 107 |
+
2. [nlu_engine.py](./nlu_engine.py)
|
| 108 |
+
Why: this shows how raw player text becomes structured intent.
|
| 109 |
+
3. [story_engine.py](./story_engine.py)
|
| 110 |
+
Why: this is the main generation pipeline and fallback logic.
|
| 111 |
+
4. [app.py](./app.py)
|
| 112 |
+
Why: this connects the UI with the engines and now also writes interaction logs.
|
| 113 |
+
5. [evaluation/run_evaluations.py](./evaluation/run_evaluations.py)
|
| 114 |
+
Why: this shows how we measure the system for the report.
|
| 115 |
+
|
| 116 |
+
If you only have 10 minutes, start with:
|
| 117 |
+
|
| 118 |
+
- `GameState.pre_validate_action`
|
| 119 |
+
- `GameState.check_consistency`
|
| 120 |
+
- `GameState.apply_changes`
|
| 121 |
+
- `NLUEngine.parse_intent`
|
| 122 |
+
- `StoryEngine.generate_story_stream`
|
| 123 |
+
- `process_user_input` in [app.py](./app.py)
|
| 124 |
+
|
| 125 |
+
## Repository Map
|
| 126 |
+
|
| 127 |
+
```text
|
| 128 |
+
StoryWeaver/
|
| 129 |
+
|-- app.py
|
| 130 |
+
|-- nlu_engine.py
|
| 131 |
+
|-- story_engine.py
|
| 132 |
+
|-- state_manager.py
|
| 133 |
+
|-- telemetry.py
|
| 134 |
+
|-- utils.py
|
| 135 |
+
|-- requirements.txt
|
| 136 |
+
|-- evaluation/
|
| 137 |
+
| |-- run_evaluations.py
|
| 138 |
+
| |-- datasets/
|
| 139 |
+
| `-- results/
|
| 140 |
+
`-- logs/
|
| 141 |
+
`-- interactions/
|
| 142 |
+
```
|
| 143 |
+
|
| 144 |
+
Core responsibilities by file:
|
| 145 |
+
|
| 146 |
+
- [app.py](./app.py)
|
| 147 |
+
Gradio app, session lifecycle, UI callbacks, per-turn logging.
|
| 148 |
+
- [state_manager.py](./state_manager.py)
|
| 149 |
+
Player/world models, item registry, NPC registry, quest registry, state validation, consistency checks, change application.
|
| 150 |
+
- [nlu_engine.py](./nlu_engine.py)
|
| 151 |
+
Intent parsing. Uses LLM parsing when available and keyword fallback when not.
|
| 152 |
+
- [story_engine.py](./story_engine.py)
|
| 153 |
+
Opening generation, main story generation, option generation, stream handling, fallback handling, telemetry tags.
|
| 154 |
+
- [telemetry.py](./telemetry.py)
|
| 155 |
+
Session metadata and JSONL interaction log export.
|
| 156 |
+
- [utils.py](./utils.py)
|
| 157 |
+
API client setup, Qwen calls, JSON extraction, retry helpers.
|
| 158 |
+
- [evaluation/run_evaluations.py](./evaluation/run_evaluations.py)
|
| 159 |
+
Reproducible experiment runner for the report.
|
| 160 |
+
|
| 161 |
+
## System Architecture
|
| 162 |
+
|
| 163 |
+
The main runtime path is:
|
| 164 |
+
|
| 165 |
+
`Player Input -> NLU -> Validation -> Story Generation -> State Update -> UI Output -> Interaction Log`
|
| 166 |
+
|
| 167 |
+
There are two ideas that matter most in this codebase:
|
| 168 |
+
|
| 169 |
+
### 1. `GameState` is the source of truth
|
| 170 |
+
|
| 171 |
+
Almost everything meaningful lives in [state_manager.py](./state_manager.py):
|
| 172 |
+
|
| 173 |
+
- player stats
|
| 174 |
+
- location
|
| 175 |
+
- time and weather
|
| 176 |
+
- inventory and equipment
|
| 177 |
+
- quests
|
| 178 |
+
- NPC states
|
| 179 |
+
- event history
|
| 180 |
+
|
| 181 |
+
When changing gameplay, try to keep state logic here instead of scattering it across prompts and UI code.
|
| 182 |
+
|
| 183 |
+
### 2. The app is a coordinator, not the game logic
|
| 184 |
+
|
| 185 |
+
[app.py](./app.py) should mostly:
|
| 186 |
+
|
| 187 |
+
- receive user input
|
| 188 |
+
- call NLU
|
| 189 |
+
- call the story engine
|
| 190 |
+
- update the chat UI
|
| 191 |
+
- write telemetry logs
|
| 192 |
+
|
| 193 |
+
If a new feature changes game rules, it probably belongs in [state_manager.py](./state_manager.py) or [story_engine.py](./story_engine.py), not in the UI layer.
|
| 194 |
+
|
| 195 |
+
## Runtime Flow
|
| 196 |
+
|
| 197 |
+
### Text input flow
|
| 198 |
+
|
| 199 |
+
For normal text input, the path is:
|
| 200 |
+
|
| 201 |
+
1. `process_user_input` receives raw text from the UI
|
| 202 |
+
2. `NLUEngine.parse_intent` converts it into a structured intent dict
|
| 203 |
+
3. `GameState.pre_validate_action` blocks clearly invalid actions early
|
| 204 |
+
4. `StoryEngine.generate_story_stream` runs the main narrative pipeline
|
| 205 |
+
5. `GameState.check_consistency` and `apply_changes` update state
|
| 206 |
+
6. UI is refreshed with story text, options, and status panel
|
| 207 |
+
7. `_record_interaction_log` writes a JSONL record to disk
|
| 208 |
+
|
| 209 |
+
### Option click flow
|
| 210 |
+
|
| 211 |
+
Button clicks do not go through full free-text parsing. Instead:
|
| 212 |
+
|
| 213 |
+
1. the selected option is converted to an intent-like dict
|
| 214 |
+
2. the story engine processes it the same way as text input
|
| 215 |
+
3. the result is rendered and logged
|
| 216 |
+
|
| 217 |
+
This is useful because option interactions and free-text interactions now share the same evaluation and observability format.
|
| 218 |
+
|
| 219 |
+
## Main Modules in More Detail
|
| 220 |
+
|
| 221 |
+
### `state_manager.py`
|
| 222 |
+
|
| 223 |
+
This file defines:
|
| 224 |
+
|
| 225 |
+
- `PlayerState`
|
| 226 |
+
- `WorldState`
|
| 227 |
+
- `GameEvent`
|
| 228 |
+
- `GameState`
|
| 229 |
+
|
| 230 |
+
Important methods:
|
| 231 |
+
|
| 232 |
+
- `pre_validate_action`
|
| 233 |
+
Rejects obviously invalid actions before calling the model.
|
| 234 |
+
- `check_consistency`
|
| 235 |
+
Detects contradictions in proposed state changes.
|
| 236 |
+
- `apply_changes`
|
| 237 |
+
Applies state changes and returns a readable change log.
|
| 238 |
+
- `validate`
|
| 239 |
+
Makes sure the resulting state is legal.
|
| 240 |
+
- `to_prompt`
|
| 241 |
+
Serializes the current game state into prompt-ready text.
|
| 242 |
+
|
| 243 |
+
When to edit this file:
|
| 244 |
+
|
| 245 |
+
- adding new items, NPCs, quests, or locations
|
| 246 |
+
- adding deterministic rules
|
| 247 |
+
- improving consistency checks
|
| 248 |
+
- changing state serialization for prompts
|
| 249 |
+
|
| 250 |
+
### `nlu_engine.py`
|
| 251 |
+
|
| 252 |
+
This file is responsible for intent recognition.
|
| 253 |
+
|
| 254 |
+
Current behavior:
|
| 255 |
+
|
| 256 |
+
- try LLM parsing first
|
| 257 |
+
- fall back to keyword rules if parsing fails
|
| 258 |
+
- return a normalized intent dict with `parser_source`
|
| 259 |
+
|
| 260 |
+
Current intent labels include:
|
| 261 |
+
|
| 262 |
+
- `ATTACK`
|
| 263 |
+
- `TALK`
|
| 264 |
+
- `MOVE`
|
| 265 |
+
- `EXPLORE`
|
| 266 |
+
- `USE_ITEM`
|
| 267 |
+
- `TRADE`
|
| 268 |
+
- `EQUIP`
|
| 269 |
+
- `REST`
|
| 270 |
+
- `QUEST`
|
| 271 |
+
- `SKILL`
|
| 272 |
+
- `PICKUP`
|
| 273 |
+
- `FLEE`
|
| 274 |
+
- `CUSTOM`
|
| 275 |
+
|
| 276 |
+
When to edit this file:
|
| 277 |
+
|
| 278 |
+
- adding a new intent type
|
| 279 |
+
- improving keyword fallback
|
| 280 |
+
- adding target extraction logic
|
| 281 |
+
- improving low-confidence handling
|
| 282 |
+
|
| 283 |
+
### `story_engine.py`
|
| 284 |
+
|
| 285 |
+
This is the main generation module.
|
| 286 |
+
|
| 287 |
+
It currently handles:
|
| 288 |
+
|
| 289 |
+
- opening generation
|
| 290 |
+
- story generation for each turn
|
| 291 |
+
- streaming and non-streaming paths
|
| 292 |
+
- default/fallback outputs
|
| 293 |
+
- consistency-aware regeneration
|
| 294 |
+
- response telemetry such as fallback reason and engine mode
|
| 295 |
+
|
| 296 |
+
Important methods:
|
| 297 |
+
|
| 298 |
+
- `generate_opening_stream`
|
| 299 |
+
- `generate_story`
|
| 300 |
+
- `generate_story_stream`
|
| 301 |
+
- `process_option_selection_stream`
|
| 302 |
+
- `_fallback_response`
|
| 303 |
+
|
| 304 |
+
When to edit this file:
|
| 305 |
+
|
| 306 |
+
- changing prompts
|
| 307 |
+
- changing multi-stage generation logic
|
| 308 |
+
- changing fallback behavior
|
| 309 |
+
- adding generation-side telemetry
|
| 310 |
+
|
| 311 |
+
### `app.py`
|
| 312 |
+
|
| 313 |
+
This file is the UI entry point and interaction orchestrator.
|
| 314 |
+
|
| 315 |
+
Important responsibilities:
|
| 316 |
+
|
| 317 |
+
- create a new game session
|
| 318 |
+
- start and restart the app session
|
| 319 |
+
- process text input
|
| 320 |
+
- process option clicks
|
| 321 |
+
- update Gradio components
|
| 322 |
+
- write structured interaction logs
|
| 323 |
+
|
| 324 |
+
When to edit this file:
|
| 325 |
+
|
| 326 |
+
- changing UI flow
|
| 327 |
+
- adding debug panels
|
| 328 |
+
- changing how logs are written
|
| 329 |
+
- changing how outputs are displayed
|
| 330 |
+
|
| 331 |
+
### `telemetry.py`
|
| 332 |
+
|
| 333 |
+
This file handles structured log export.
|
| 334 |
+
|
| 335 |
+
It is intentionally simple and file-based:
|
| 336 |
+
|
| 337 |
+
- one session gets one JSONL file
|
| 338 |
+
- one turn becomes one JSON object line
|
| 339 |
+
|
| 340 |
+
This is useful for:
|
| 341 |
+
|
| 342 |
+
- report case studies
|
| 343 |
+
- measuring fallback rate
|
| 344 |
+
- debugging weird turns
|
| 345 |
+
- collecting examples for later evaluation
|
| 346 |
+
|
| 347 |
+
## Logging and Observability
|
| 348 |
+
|
| 349 |
+
Interaction logs are written under:
|
| 350 |
+
|
| 351 |
+
- [logs/interactions](./logs/interactions)
|
| 352 |
+
|
| 353 |
+
Each turn record includes at least:
|
| 354 |
+
|
| 355 |
+
- input source
|
| 356 |
+
- user input
|
| 357 |
+
- NLU result
|
| 358 |
+
- latency
|
| 359 |
+
- fallback metadata
|
| 360 |
+
- state changes
|
| 361 |
+
- consistency issues
|
| 362 |
+
- final output text
|
| 363 |
+
- post-turn state snapshot
|
| 364 |
+
|
| 365 |
+
Example shape:
|
| 366 |
+
|
| 367 |
+
```json
|
| 368 |
+
{
|
| 369 |
+
"timestamp": "2026-03-14T18:55:00",
|
| 370 |
+
"session_id": "sw-20260314-185500-ab12cd34",
|
| 371 |
+
"turn_index": 3,
|
| 372 |
+
"input_source": "text_input",
|
| 373 |
+
"user_input": "和村长老伯谈谈最近森林里的怪事",
|
| 374 |
+
"nlu_result": {
|
| 375 |
+
"intent": "TALK",
|
| 376 |
+
"target": "村长老伯",
|
| 377 |
+
"parser_source": "llm"
|
| 378 |
+
},
|
| 379 |
+
"latency_ms": 842.13,
|
| 380 |
+
"used_fallback": false,
|
| 381 |
+
"state_changes": {},
|
| 382 |
+
"output_text": "...",
|
| 383 |
+
"post_turn_snapshot": {
|
| 384 |
+
"location": "村庄广场"
|
| 385 |
+
}
|
| 386 |
+
}
|
| 387 |
+
```
|
| 388 |
+
|
| 389 |
+
If you need to debug a bad interaction, the fastest path is:
|
| 390 |
+
|
| 391 |
+
1. check the log file
|
| 392 |
+
2. inspect `nlu_result`
|
| 393 |
+
3. inspect `telemetry.used_fallback`
|
| 394 |
+
4. inspect `state_changes`
|
| 395 |
+
5. inspect the post-turn snapshot
|
| 396 |
+
|
| 397 |
+
## Evaluation Pipeline
|
| 398 |
+
|
| 399 |
+
Evaluation entry point:
|
| 400 |
+
|
| 401 |
+
- [evaluation/run_evaluations.py](./evaluation/run_evaluations.py)
|
| 402 |
+
|
| 403 |
+
Datasets:
|
| 404 |
+
|
| 405 |
+
- [evaluation/datasets/intent_accuracy.json](./evaluation/datasets/intent_accuracy.json)
|
| 406 |
+
- [evaluation/datasets/consistency.json](./evaluation/datasets/consistency.json)
|
| 407 |
+
- [evaluation/datasets/latency.json](./evaluation/datasets/latency.json)
|
| 408 |
+
- [evaluation/datasets/branch_divergence.json](./evaluation/datasets/branch_divergence.json)
|
| 409 |
+
|
| 410 |
+
Results:
|
| 411 |
+
|
| 412 |
+
- [evaluation/results](./evaluation/results)
|
| 413 |
+
|
| 414 |
+
### What each task measures
|
| 415 |
+
|
| 416 |
+
#### Intent
|
| 417 |
+
|
| 418 |
+
- labeled input -> predicted intent
|
| 419 |
+
- optional target matching
|
| 420 |
+
- parser source breakdown
|
| 421 |
+
- per-example latency
|
| 422 |
+
|
| 423 |
+
#### Consistency
|
| 424 |
+
|
| 425 |
+
- action guard correctness via `pre_validate_action`
|
| 426 |
+
- contradiction detection via `check_consistency`
|
| 427 |
+
|
| 428 |
+
#### Latency
|
| 429 |
+
|
| 430 |
+
- NLU latency
|
| 431 |
+
- generation latency
|
| 432 |
+
- total latency
|
| 433 |
+
- fallback rate
|
| 434 |
+
|
| 435 |
+
#### Branch divergence
|
| 436 |
+
|
| 437 |
+
- same start state, different choices
|
| 438 |
+
- compare resulting story text
|
| 439 |
+
- compare option differences
|
| 440 |
+
- compare state snapshot differences
|
| 441 |
+
|
| 442 |
+
## Common Development Tasks
|
| 443 |
+
|
| 444 |
+
### Add a new intent
|
| 445 |
+
|
| 446 |
+
You will usually need to touch:
|
| 447 |
+
|
| 448 |
+
- [nlu_engine.py](./nlu_engine.py)
|
| 449 |
+
- [state_manager.py](./state_manager.py)
|
| 450 |
+
- [story_engine.py](./story_engine.py)
|
| 451 |
+
- [evaluation/datasets/intent_accuracy.json](./evaluation/datasets/intent_accuracy.json)
|
| 452 |
+
|
| 453 |
+
Suggested checklist:
|
| 454 |
+
|
| 455 |
+
1. add the label to the NLU logic
|
| 456 |
+
2. decide whether it needs pre-validation
|
| 457 |
+
3. make sure story prompts know how to handle it
|
| 458 |
+
4. add at least a few evaluation examples
|
| 459 |
+
|
| 460 |
+
### Add a new location, NPC, quest, or item
|
| 461 |
+
|
| 462 |
+
Most of the time you only need:
|
| 463 |
+
|
| 464 |
+
- [state_manager.py](./state_manager.py)
|
| 465 |
+
|
| 466 |
+
That file contains the initial world setup and registry-style data.
|
| 467 |
+
|
| 468 |
+
### Add more evaluation cases
|
| 469 |
+
|
| 470 |
+
Edit files under:
|
| 471 |
+
|
| 472 |
+
- [evaluation/datasets](./evaluation/datasets)
|
| 473 |
+
|
| 474 |
+
This is the easiest way to improve the report without changing runtime logic.
|
| 475 |
+
|
| 476 |
+
### Investigate a strange game turn
|
| 477 |
+
|
| 478 |
+
Check in this order:
|
| 479 |
+
|
| 480 |
+
1. interaction log under `logs/interactions`
|
| 481 |
+
2. `parser_source` in the NLU result
|
| 482 |
+
3. `telemetry` in the final story result
|
| 483 |
+
4. whether `pre_validate_action` rejected or allowed the turn
|
| 484 |
+
5. whether `check_consistency` flagged anything
|
| 485 |
+
|
| 486 |
+
### Change UI behavior without touching gameplay
|
| 487 |
+
|
| 488 |
+
Edit:
|
| 489 |
+
|
| 490 |
+
- [app.py](./app.py)
|
| 491 |
+
|
| 492 |
+
Try not to put game rules in the UI layer.
|
| 493 |
+
|
| 494 |
+
## Environment Notes
|
| 495 |
+
|
| 496 |
+
### If `QWEN_API_KEY` is missing
|
| 497 |
+
|
| 498 |
+
- warning logs will appear
|
| 499 |
+
- some paths will still run through fallback logic
|
| 500 |
+
- evaluation can still execute, but model-quality conclusions are not meaningful
|
| 501 |
+
|
| 502 |
+
### If `openai` is not installed
|
| 503 |
+
|
| 504 |
+
- the repo can still import in some cases because the client is lazily initialized
|
| 505 |
+
- full Qwen generation will not work
|
| 506 |
+
- evaluation scripts will mostly reflect fallback behavior
|
| 507 |
+
|
| 508 |
+
### If `gradio` is not installed
|
| 509 |
+
|
| 510 |
+
- the app cannot launch
|
| 511 |
+
- offline evaluation scripts can still be useful
|
| 512 |
+
|
| 513 |
+
## Current Known Limitations
|
| 514 |
+
|
| 515 |
+
These are the main gaps we still know about:
|
| 516 |
+
|
| 517 |
+
- some item and equipment effects are stored as metadata but not fully executed as deterministic rules
|
| 518 |
+
- combat and trade are still more prompt-driven than rule-driven
|
| 519 |
+
- branch divergence is much more meaningful with a real model than in fallback-only mode
|
| 520 |
+
- evaluation quality depends on whether the real model environment is available
|
| 521 |
+
|
| 522 |
+
## Suggested Team Workflow
|
| 523 |
+
|
| 524 |
+
If multiple teammates are working in parallel, this split is usually clean:
|
| 525 |
+
|
| 526 |
+
- gameplay/state teammate
|
| 527 |
+
Focus on [state_manager.py](./state_manager.py)
|
| 528 |
+
- prompt/generation teammate
|
| 529 |
+
Focus on [story_engine.py](./story_engine.py)
|
| 530 |
+
- NLU/evaluation teammate
|
| 531 |
+
Focus on [nlu_engine.py](./nlu_engine.py) and [evaluation](./evaluation)
|
| 532 |
+
- UI/demo teammate
|
| 533 |
+
Focus on [app.py](./app.py)
|
| 534 |
+
- report teammate
|
| 535 |
+
Focus on `evaluation/results`, `logs/interactions`, and case-study collection
|
| 536 |
+
|
| 537 |
+
## What To Use in the Final Report
|
| 538 |
+
|
| 539 |
+
For the course report, the most useful artifacts from this repo are:
|
| 540 |
+
|
| 541 |
+
- evaluation JSON outputs under `evaluation/results`
|
| 542 |
+
- interaction logs under `logs/interactions`
|
| 543 |
+
- dataset files under `evaluation/datasets`
|
| 544 |
+
- readable state transitions from `change_log`
|
| 545 |
+
- fallback metadata from `telemetry`
|
| 546 |
+
|
| 547 |
+
These can directly support:
|
| 548 |
+
|
| 549 |
+
- experiment setup
|
| 550 |
+
- metric definition
|
| 551 |
+
- result tables
|
| 552 |
+
- success cases
|
| 553 |
+
- failure case analysis
|
| 554 |
+
|
| 555 |
+
## License
|
| 556 |
+
|
| 557 |
+
MIT
|
app.py
ADDED
|
@@ -0,0 +1,2005 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app.py - StoryWeaver Gradio 交互界面
|
| 3 |
+
|
| 4 |
+
职责:
|
| 5 |
+
1. 构建游戏的 Web 前端界面 (Gradio)
|
| 6 |
+
2. 串联 NLU 引擎、叙事引擎、状态管理器
|
| 7 |
+
3. 管理用户交互流程(文本输入 + 选项点击)
|
| 8 |
+
4. 展示游戏状态(HP、背包、任务等)
|
| 9 |
+
|
| 10 |
+
数据流转:
|
| 11 |
+
用户输入 → NLU 引擎(意图识别) → 叙事引擎(两阶段生成)
|
| 12 |
+
↕ ↕
|
| 13 |
+
Gradio UI ← 状态管理器(校验 + 更新) ← 叙事引擎(文本 + 选项)
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import copy
|
| 17 |
+
from collections import Counter
|
| 18 |
+
import html
|
| 19 |
+
import json
|
| 20 |
+
import logging
|
| 21 |
+
from time import perf_counter
|
| 22 |
+
import gradio as gr
|
| 23 |
+
|
| 24 |
+
from state_manager import GameState
|
| 25 |
+
from nlu_engine import NLUEngine
|
| 26 |
+
from scene_assets import get_scene_image_path
|
| 27 |
+
from story_engine import StoryEngine
|
| 28 |
+
from telemetry import append_turn_log, create_session_metadata
|
| 29 |
+
from utils import logger
|
| 30 |
+
|
| 31 |
+
APP_UI_CSS = """
|
| 32 |
+
.story-chat {min-height: 500px;}
|
| 33 |
+
.status-panel {
|
| 34 |
+
font-family: "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
|
| 35 |
+
font-size: 0.9em;
|
| 36 |
+
line-height: 1.5;
|
| 37 |
+
background: transparent !important;
|
| 38 |
+
border: none !important;
|
| 39 |
+
border-radius: 0 !important;
|
| 40 |
+
padding: 10px 12px !important;
|
| 41 |
+
box-shadow: none !important;
|
| 42 |
+
overflow: visible !important;
|
| 43 |
+
}
|
| 44 |
+
.status-panel > div,
|
| 45 |
+
.status-panel [class*="prose"],
|
| 46 |
+
.status-panel .markdown-body,
|
| 47 |
+
.status-panel [class*="wrap"] {
|
| 48 |
+
background: transparent !important;
|
| 49 |
+
border: none !important;
|
| 50 |
+
box-shadow: none !important;
|
| 51 |
+
padding: 0 !important;
|
| 52 |
+
overflow: visible !important;
|
| 53 |
+
}
|
| 54 |
+
.status-panel * {
|
| 55 |
+
word-break: break-word;
|
| 56 |
+
overflow-wrap: anywhere;
|
| 57 |
+
}
|
| 58 |
+
.option-btn {min-height: 50px !important;}
|
| 59 |
+
.side-action-btn,
|
| 60 |
+
.side-action-btn button {min-height: 50px !important;}
|
| 61 |
+
.backpack-btn button {
|
| 62 |
+
min-height: 50px !important;
|
| 63 |
+
background: #ffffff !important;
|
| 64 |
+
color: #0f172a !important;
|
| 65 |
+
border: 1px solid #d1d5db !important;
|
| 66 |
+
}
|
| 67 |
+
.scene-sidebar {gap: 12px;}
|
| 68 |
+
.scene-card {
|
| 69 |
+
border: 1px solid #e5e7eb !important;
|
| 70 |
+
border-radius: 12px !important;
|
| 71 |
+
background: #fcfcfd !important;
|
| 72 |
+
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04) !important;
|
| 73 |
+
}
|
| 74 |
+
.scene-image {
|
| 75 |
+
min-height: 260px;
|
| 76 |
+
padding: 10px !important;
|
| 77 |
+
}
|
| 78 |
+
.scene-image > div,
|
| 79 |
+
.scene-image img,
|
| 80 |
+
.scene-image button,
|
| 81 |
+
.scene-image [class*="image"],
|
| 82 |
+
.scene-image [class*="wrap"],
|
| 83 |
+
.scene-image [class*="frame"],
|
| 84 |
+
.scene-image [class*="preview"] {
|
| 85 |
+
border: none !important;
|
| 86 |
+
box-shadow: none !important;
|
| 87 |
+
background: transparent !important;
|
| 88 |
+
}
|
| 89 |
+
.scene-image img {
|
| 90 |
+
width: 100%;
|
| 91 |
+
height: 100%;
|
| 92 |
+
object-fit: contain !important;
|
| 93 |
+
border-radius: 10px;
|
| 94 |
+
padding: 4px;
|
| 95 |
+
background: #ffffff !important;
|
| 96 |
+
}
|
| 97 |
+
"""
|
| 98 |
+
|
| 99 |
+
# ============================================================
|
| 100 |
+
# 全局游戏实例(每个会话独立)
|
| 101 |
+
# ============================================================
|
| 102 |
+
|
| 103 |
+
# 使用 Gradio State 管理每个用户的游戏状态
|
| 104 |
+
# 这里先定义工厂函数
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def create_new_game(player_name: str = "旅人") -> dict:
|
| 108 |
+
"""创建新游戏实例,返回包含所有引擎的字典"""
|
| 109 |
+
game_state = GameState(player_name=player_name)
|
| 110 |
+
nlu = NLUEngine(game_state)
|
| 111 |
+
story = StoryEngine(game_state, enable_rule_text_polish=True)
|
| 112 |
+
return {
|
| 113 |
+
"game_state": game_state,
|
| 114 |
+
"nlu": nlu,
|
| 115 |
+
"story": story,
|
| 116 |
+
"current_options": [],
|
| 117 |
+
"started": False,
|
| 118 |
+
**create_session_metadata(),
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _json_safe(value):
|
| 123 |
+
"""Convert nested values into JSON-serializable data for logs."""
|
| 124 |
+
if value is None or isinstance(value, (str, int, float, bool)):
|
| 125 |
+
return value
|
| 126 |
+
if isinstance(value, dict):
|
| 127 |
+
return {str(key): _json_safe(val) for key, val in value.items()}
|
| 128 |
+
if isinstance(value, (list, tuple, set)):
|
| 129 |
+
return [_json_safe(item) for item in value]
|
| 130 |
+
if hasattr(value, "model_dump"):
|
| 131 |
+
return _json_safe(value.model_dump())
|
| 132 |
+
return str(value)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _build_state_snapshot(gs: GameState) -> dict:
|
| 136 |
+
"""Build a compact state snapshot for reproducible evaluation logs."""
|
| 137 |
+
active_quests = []
|
| 138 |
+
effective_stats = gs.get_effective_player_stats()
|
| 139 |
+
equipment_bonuses = gs.get_equipment_stat_bonuses()
|
| 140 |
+
environment_snapshot = gs.get_environment_snapshot(limit=3)
|
| 141 |
+
for quest in gs.world.quests.values():
|
| 142 |
+
if quest.status == "active":
|
| 143 |
+
active_quests.append(
|
| 144 |
+
{
|
| 145 |
+
"quest_id": quest.quest_id,
|
| 146 |
+
"title": quest.title,
|
| 147 |
+
"status": quest.status,
|
| 148 |
+
"objectives": _json_safe(quest.objectives),
|
| 149 |
+
}
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
return {
|
| 153 |
+
"turn": gs.turn,
|
| 154 |
+
"game_mode": gs.game_mode,
|
| 155 |
+
"location": gs.player.location,
|
| 156 |
+
"scene": gs.world.current_scene,
|
| 157 |
+
"day": gs.world.day_count,
|
| 158 |
+
"time_of_day": gs.world.time_of_day,
|
| 159 |
+
"weather": gs.world.weather,
|
| 160 |
+
"light_level": gs.world.light_level,
|
| 161 |
+
"environment": _json_safe(environment_snapshot),
|
| 162 |
+
"player": {
|
| 163 |
+
"name": gs.player.name,
|
| 164 |
+
"level": gs.player.level,
|
| 165 |
+
"hp": gs.player.hp,
|
| 166 |
+
"max_hp": gs.player.max_hp,
|
| 167 |
+
"mp": gs.player.mp,
|
| 168 |
+
"max_mp": gs.player.max_mp,
|
| 169 |
+
"attack": gs.player.attack,
|
| 170 |
+
"defense": gs.player.defense,
|
| 171 |
+
"speed": gs.player.speed,
|
| 172 |
+
"luck": gs.player.luck,
|
| 173 |
+
"perception": gs.player.perception,
|
| 174 |
+
"gold": gs.player.gold,
|
| 175 |
+
"morale": gs.player.morale,
|
| 176 |
+
"sanity": gs.player.sanity,
|
| 177 |
+
"hunger": gs.player.hunger,
|
| 178 |
+
"karma": gs.player.karma,
|
| 179 |
+
"effective_stats": _json_safe(effective_stats),
|
| 180 |
+
"equipment_bonuses": _json_safe(equipment_bonuses),
|
| 181 |
+
"inventory": list(gs.player.inventory),
|
| 182 |
+
"equipment": copy.deepcopy(gs.player.equipment),
|
| 183 |
+
"skills": list(gs.player.skills),
|
| 184 |
+
"status_effects": [effect.name for effect in gs.player.status_effects],
|
| 185 |
+
},
|
| 186 |
+
"active_quests": active_quests,
|
| 187 |
+
"event_log_size": len(gs.event_log),
|
| 188 |
+
}
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _record_interaction_log(
|
| 192 |
+
game_session: dict,
|
| 193 |
+
*,
|
| 194 |
+
input_source: str,
|
| 195 |
+
user_input: str,
|
| 196 |
+
intent_result: dict | None,
|
| 197 |
+
output_text: str,
|
| 198 |
+
latency_ms: float,
|
| 199 |
+
nlu_latency_ms: float | None = None,
|
| 200 |
+
generation_latency_ms: float | None = None,
|
| 201 |
+
final_result: dict | None = None,
|
| 202 |
+
selected_option: dict | None = None,
|
| 203 |
+
):
|
| 204 |
+
"""Append a structured interaction log without affecting gameplay."""
|
| 205 |
+
if not game_session or "game_state" not in game_session:
|
| 206 |
+
return
|
| 207 |
+
|
| 208 |
+
final_result = final_result or {}
|
| 209 |
+
telemetry = _json_safe(final_result.get("telemetry", {})) or {}
|
| 210 |
+
record = {
|
| 211 |
+
"input_source": input_source,
|
| 212 |
+
"user_input": user_input,
|
| 213 |
+
"selected_option": _json_safe(selected_option),
|
| 214 |
+
"nlu_result": _json_safe(intent_result),
|
| 215 |
+
"latency_ms": round(latency_ms, 2),
|
| 216 |
+
"nlu_latency_ms": None if nlu_latency_ms is None else round(nlu_latency_ms, 2),
|
| 217 |
+
"generation_latency_ms": None if generation_latency_ms is None else round(generation_latency_ms, 2),
|
| 218 |
+
"used_fallback": bool(telemetry.get("used_fallback", False)),
|
| 219 |
+
"fallback_reason": telemetry.get("fallback_reason"),
|
| 220 |
+
"engine_mode": telemetry.get("engine_mode"),
|
| 221 |
+
"state_changes": _json_safe(final_result.get("state_changes", {})),
|
| 222 |
+
"change_log": _json_safe(final_result.get("change_log", [])),
|
| 223 |
+
"consistency_issues": _json_safe(final_result.get("consistency_issues", [])),
|
| 224 |
+
"output_text": output_text,
|
| 225 |
+
"story_text": final_result.get("story_text"),
|
| 226 |
+
"options": _json_safe(final_result.get("options", game_session.get("current_options", []))),
|
| 227 |
+
"post_turn_snapshot": _build_state_snapshot(game_session["game_state"]),
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
try:
|
| 231 |
+
append_turn_log(game_session, record)
|
| 232 |
+
except Exception as exc:
|
| 233 |
+
logger.warning(f"Failed to append interaction log: {exc}")
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def _build_option_intent(selected_option: dict) -> dict:
|
| 237 |
+
"""Represent button clicks in the same schema as free-text NLU output."""
|
| 238 |
+
option_text = selected_option.get("text", "")
|
| 239 |
+
return {
|
| 240 |
+
"intent": selected_option.get("action_type", "EXPLORE"),
|
| 241 |
+
"target": selected_option.get("target"),
|
| 242 |
+
"details": option_text,
|
| 243 |
+
"raw_input": option_text,
|
| 244 |
+
"parser_source": "option_click",
|
| 245 |
+
}
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def _get_scene_image_value(gs: GameState) -> str | None:
|
| 249 |
+
focus_npc = getattr(gs, "last_interacted_npc", None)
|
| 250 |
+
return get_scene_image_path(gs, focus_npc=focus_npc)
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def _get_scene_image_update(gs: GameState):
|
| 254 |
+
image_value = _get_scene_image_value(gs)
|
| 255 |
+
return gr.update(value=image_value, visible=bool(image_value))
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _build_map_graph_data(gs: GameState) -> dict:
|
| 259 |
+
"""基于已发现地点与连接关系构建地图拓扑数据。"""
|
| 260 |
+
world_locations = getattr(getattr(gs, "world", None), "locations", {}) or {}
|
| 261 |
+
discovered = list(getattr(getattr(gs, "world", None), "discovered_locations", []) or [])
|
| 262 |
+
history = list(getattr(gs, "location_history", []) or [])
|
| 263 |
+
|
| 264 |
+
current_location = str(getattr(gs, "current_location", None) or "").strip()
|
| 265 |
+
if not current_location:
|
| 266 |
+
current_location = str(getattr(getattr(gs, "player", None), "location", None) or "未知之地")
|
| 267 |
+
|
| 268 |
+
visible_set: set[str] = set(discovered) | set(history)
|
| 269 |
+
if current_location:
|
| 270 |
+
visible_set.add(current_location)
|
| 271 |
+
|
| 272 |
+
# 使用世界注册顺序保证地图输出稳定,便于玩家快速扫描。
|
| 273 |
+
ordered_nodes: list[str] = [name for name in world_locations.keys() if name in visible_set]
|
| 274 |
+
for name in discovered + history + [current_location]:
|
| 275 |
+
if name and name in visible_set and name not in ordered_nodes:
|
| 276 |
+
ordered_nodes.append(name)
|
| 277 |
+
|
| 278 |
+
visited_set = set(history)
|
| 279 |
+
if current_location:
|
| 280 |
+
visited_set.add(current_location)
|
| 281 |
+
|
| 282 |
+
adjacency: dict[str, list[str]] = {}
|
| 283 |
+
for node in ordered_nodes:
|
| 284 |
+
loc_info = world_locations.get(node)
|
| 285 |
+
if not loc_info:
|
| 286 |
+
adjacency[node] = []
|
| 287 |
+
continue
|
| 288 |
+
neighbors = []
|
| 289 |
+
for neighbor in list(getattr(loc_info, "connected_to", []) or []):
|
| 290 |
+
if neighbor in visible_set and neighbor != node:
|
| 291 |
+
neighbors.append(neighbor)
|
| 292 |
+
adjacency[node] = neighbors
|
| 293 |
+
|
| 294 |
+
node_state: dict[str, str] = {}
|
| 295 |
+
for node in ordered_nodes:
|
| 296 |
+
if node == current_location:
|
| 297 |
+
node_state[node] = "current"
|
| 298 |
+
elif node in visited_set:
|
| 299 |
+
node_state[node] = "visited"
|
| 300 |
+
else:
|
| 301 |
+
node_state[node] = "known"
|
| 302 |
+
|
| 303 |
+
return {
|
| 304 |
+
"current_location": current_location,
|
| 305 |
+
"nodes": ordered_nodes,
|
| 306 |
+
"adjacency": adjacency,
|
| 307 |
+
"node_state": node_state,
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def _build_location_hover_text(gs: GameState, location_name: str) -> str:
|
| 312 |
+
"""构造地点 hover 提示:展示 NPC 与怪物。"""
|
| 313 |
+
world = getattr(gs, "world", None)
|
| 314 |
+
locations = getattr(world, "locations", {}) or {}
|
| 315 |
+
npcs = getattr(world, "npcs", {}) or {}
|
| 316 |
+
loc = locations.get(location_name)
|
| 317 |
+
if not loc:
|
| 318 |
+
return f"{location_name}\nNPC: 无\n怪物: 无"
|
| 319 |
+
|
| 320 |
+
npc_names: set[str] = set(getattr(loc, "npcs_present", []) or [])
|
| 321 |
+
for npc in npcs.values():
|
| 322 |
+
if getattr(npc, "location", None) == location_name and getattr(npc, "is_alive", True):
|
| 323 |
+
npc_names.add(getattr(npc, "name", ""))
|
| 324 |
+
npc_names = {name for name in npc_names if name}
|
| 325 |
+
|
| 326 |
+
enemy_names = [str(name) for name in list(getattr(loc, "enemies", []) or []) if str(name)]
|
| 327 |
+
npc_text = "、".join(sorted(npc_names)) if npc_names else "无"
|
| 328 |
+
enemy_text = "、".join(enemy_names) if enemy_names else "无"
|
| 329 |
+
return f"{location_name}\nNPC: {npc_text}\n怪物: {enemy_text}"
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _truncate_map_label(name: str, max_len: int = 8) -> str:
|
| 333 |
+
text = str(name or "")
|
| 334 |
+
return text if len(text) <= max_len else f"{text[:max_len]}..."
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
def _build_fixed_branch_layout(nodes: list[str], adjacency: dict[str, list[str]]) -> dict[str, tuple[int, int]]:
|
| 338 |
+
"""固定起点的分层布局:保持总体顺序稳定,同时展示分支。"""
|
| 339 |
+
if not nodes:
|
| 340 |
+
return {}
|
| 341 |
+
|
| 342 |
+
node_set = set(nodes)
|
| 343 |
+
layers: dict[str, int] = {}
|
| 344 |
+
|
| 345 |
+
def _bfs(seed: str, base_layer: int) -> None:
|
| 346 |
+
if seed not in node_set or seed in layers:
|
| 347 |
+
return
|
| 348 |
+
queue: list[str] = [seed]
|
| 349 |
+
layers[seed] = base_layer
|
| 350 |
+
cursor = 0
|
| 351 |
+
while cursor < len(queue):
|
| 352 |
+
node = queue[cursor]
|
| 353 |
+
cursor += 1
|
| 354 |
+
next_layer = layers[node] + 1
|
| 355 |
+
for nxt in adjacency.get(node, []):
|
| 356 |
+
if nxt in node_set and nxt not in layers:
|
| 357 |
+
layers[nxt] = next_layer
|
| 358 |
+
queue.append(nxt)
|
| 359 |
+
|
| 360 |
+
# 第一出现地点作为固定起点,避免因当前位置变化而重排。
|
| 361 |
+
_bfs(nodes[0], 0)
|
| 362 |
+
for name in nodes:
|
| 363 |
+
if name not in layers:
|
| 364 |
+
base = (max(layers.values()) + 1) if layers else 0
|
| 365 |
+
_bfs(name, base)
|
| 366 |
+
|
| 367 |
+
level_nodes: dict[int, list[str]] = {}
|
| 368 |
+
for name in nodes:
|
| 369 |
+
level = layers.get(name, 0)
|
| 370 |
+
level_nodes.setdefault(level, []).append(name)
|
| 371 |
+
|
| 372 |
+
positions: dict[str, tuple[int, int]] = {}
|
| 373 |
+
for col_idx, level in enumerate(sorted(level_nodes.keys())):
|
| 374 |
+
for row_idx, name in enumerate(level_nodes[level]):
|
| 375 |
+
positions[name] = (col_idx, row_idx)
|
| 376 |
+
return positions
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def _render_text_map(gs: GameState | None) -> str:
|
| 380 |
+
"""拓扑地图:从左到右显示地点关系图。"""
|
| 381 |
+
if gs is None:
|
| 382 |
+
return "地图关系图\n(未开始)"
|
| 383 |
+
|
| 384 |
+
graph = _build_map_graph_data(gs)
|
| 385 |
+
nodes = graph["nodes"]
|
| 386 |
+
adjacency = graph["adjacency"]
|
| 387 |
+
node_state = graph["node_state"]
|
| 388 |
+
current_location = graph["current_location"]
|
| 389 |
+
|
| 390 |
+
if not nodes:
|
| 391 |
+
current = current_location or "未知之地"
|
| 392 |
+
return f"地图关系图\n当前位置:{current}"
|
| 393 |
+
|
| 394 |
+
positions = _build_fixed_branch_layout(nodes, adjacency)
|
| 395 |
+
if not positions:
|
| 396 |
+
return "地图关系图\n(暂无可显示节点)"
|
| 397 |
+
|
| 398 |
+
node_width = 110
|
| 399 |
+
node_height = 34
|
| 400 |
+
col_gap = 140
|
| 401 |
+
row_gap = 50
|
| 402 |
+
x_margin = 16
|
| 403 |
+
y_margin = 16
|
| 404 |
+
|
| 405 |
+
max_col = max(col for col, _ in positions.values())
|
| 406 |
+
max_row = max(row for _, row in positions.values())
|
| 407 |
+
canvas_width = x_margin * 2 + max_col * col_gap + node_width
|
| 408 |
+
canvas_height = y_margin * 2 + max_row * row_gap + node_height
|
| 409 |
+
canvas_height = max(canvas_height, 74)
|
| 410 |
+
|
| 411 |
+
node_boxes: dict[str, tuple[int, int, int, int]] = {}
|
| 412 |
+
centers: dict[str, tuple[int, int]] = {}
|
| 413 |
+
for name, (col, row) in positions.items():
|
| 414 |
+
x = x_margin + col * col_gap
|
| 415 |
+
y = y_margin + row * row_gap
|
| 416 |
+
node_boxes[name] = (x, y, node_width, node_height)
|
| 417 |
+
centers[name] = (x + node_width // 2, y + node_height // 2)
|
| 418 |
+
|
| 419 |
+
edge_pairs: set[tuple[str, str]] = set()
|
| 420 |
+
for source, neighbors in adjacency.items():
|
| 421 |
+
for target in neighbors:
|
| 422 |
+
if source in positions and target in positions and source != target:
|
| 423 |
+
edge_pairs.add(tuple(sorted((source, target))))
|
| 424 |
+
|
| 425 |
+
edge_svg: list[str] = []
|
| 426 |
+
|
| 427 |
+
def _segment_hits_box_horizontal(y: float, x_start: float, x_end: float, box: tuple[int, int, int, int]) -> bool:
|
| 428 |
+
bx, by, bw, bh = box
|
| 429 |
+
left = min(x_start, x_end)
|
| 430 |
+
right = max(x_start, x_end)
|
| 431 |
+
return (by + 1) <= y <= (by + bh - 1) and not (right <= bx + 1 or left >= bx + bw - 1)
|
| 432 |
+
|
| 433 |
+
def _segment_hits_box_vertical(x: float, y_start: float, y_end: float, box: tuple[int, int, int, int]) -> bool:
|
| 434 |
+
bx, by, bw, bh = box
|
| 435 |
+
top = min(y_start, y_end)
|
| 436 |
+
bottom = max(y_start, y_end)
|
| 437 |
+
return (bx + 1) <= x <= (bx + bw - 1) and not (bottom <= by + 1 or top >= by + bh - 1)
|
| 438 |
+
|
| 439 |
+
for source, target in sorted(edge_pairs):
|
| 440 |
+
sx, sy, sw, sh = node_boxes[source]
|
| 441 |
+
tx, ty, tw, th = node_boxes[target]
|
| 442 |
+
source_exit_x = sx + sw
|
| 443 |
+
source_exit_y = sy + sh / 2
|
| 444 |
+
target_entry_x = tx
|
| 445 |
+
target_entry_y = ty + th / 2
|
| 446 |
+
|
| 447 |
+
mid_x = (source_exit_x + target_entry_x) / 2
|
| 448 |
+
needs_detour = False
|
| 449 |
+
for name, box in node_boxes.items():
|
| 450 |
+
if name in {source, target}:
|
| 451 |
+
continue
|
| 452 |
+
if (
|
| 453 |
+
_segment_hits_box_horizontal(source_exit_y, source_exit_x, mid_x, box)
|
| 454 |
+
or _segment_hits_box_vertical(mid_x, source_exit_y, target_entry_y, box)
|
| 455 |
+
or _segment_hits_box_horizontal(target_entry_y, mid_x, target_entry_x, box)
|
| 456 |
+
):
|
| 457 |
+
needs_detour = True
|
| 458 |
+
break
|
| 459 |
+
|
| 460 |
+
if not needs_detour:
|
| 461 |
+
points = (
|
| 462 |
+
f"{source_exit_x},{source_exit_y} "
|
| 463 |
+
f"{mid_x},{source_exit_y} "
|
| 464 |
+
f"{mid_x},{target_entry_y} "
|
| 465 |
+
f"{target_entry_x},{target_entry_y}"
|
| 466 |
+
)
|
| 467 |
+
else:
|
| 468 |
+
# 局部上绕:仅在必要时走上方,减少杂乱感同时避免压到节点。
|
| 469 |
+
route_y = max(4, min(source_exit_y, target_entry_y) - node_height / 2 - 10)
|
| 470 |
+
route_left_x = source_exit_x + 6
|
| 471 |
+
route_right_x = target_entry_x - 6
|
| 472 |
+
points = (
|
| 473 |
+
f"{source_exit_x},{source_exit_y} "
|
| 474 |
+
f"{route_left_x},{source_exit_y} "
|
| 475 |
+
f"{route_left_x},{route_y} "
|
| 476 |
+
f"{route_right_x},{route_y} "
|
| 477 |
+
f"{route_right_x},{target_entry_y} "
|
| 478 |
+
f"{target_entry_x},{target_entry_y}"
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
edge_svg.append(
|
| 482 |
+
f"<polyline points='{points}' "
|
| 483 |
+
"fill='none' stroke='#94a3b8' stroke-width='1.7' "
|
| 484 |
+
"stroke-linecap='round' stroke-linejoin='round' />"
|
| 485 |
+
)
|
| 486 |
+
|
| 487 |
+
node_svg: list[str] = []
|
| 488 |
+
for name in nodes:
|
| 489 |
+
col, row = positions[name]
|
| 490 |
+
x = x_margin + col * col_gap
|
| 491 |
+
y = y_margin + row * row_gap
|
| 492 |
+
state = node_state.get(name, "known")
|
| 493 |
+
escaped_name = html.escape(_truncate_map_label(name))
|
| 494 |
+
hover_text = html.escape(_build_location_hover_text(gs, name))
|
| 495 |
+
|
| 496 |
+
if state == "current":
|
| 497 |
+
fill = "#fff7ed"
|
| 498 |
+
stroke = "#f97316"
|
| 499 |
+
text_color = "#9a3412"
|
| 500 |
+
stroke_width = 1.8
|
| 501 |
+
display_name = escaped_name
|
| 502 |
+
elif state == "visited":
|
| 503 |
+
fill = "#f1f5f9"
|
| 504 |
+
stroke = "#64748b"
|
| 505 |
+
text_color = "#334155"
|
| 506 |
+
stroke_width = 1.4
|
| 507 |
+
display_name = escaped_name
|
| 508 |
+
else:
|
| 509 |
+
fill = "#ffffff"
|
| 510 |
+
stroke = "#cbd5e1"
|
| 511 |
+
text_color = "#334155"
|
| 512 |
+
stroke_width = 1.2
|
| 513 |
+
display_name = escaped_name
|
| 514 |
+
|
| 515 |
+
rect_class_attr = " class='map-current-node'" if state == "current" else ""
|
| 516 |
+
node_svg.append(
|
| 517 |
+
"<g cursor='help'>"
|
| 518 |
+
f"<title>{hover_text}</title>"
|
| 519 |
+
f"<rect x='{x}' y='{y}' width='{node_width}' height='{node_height}' "
|
| 520 |
+
f"rx='8' ry='8' fill='{fill}' stroke='{stroke}' stroke-width='{stroke_width}'{rect_class_attr} />"
|
| 521 |
+
f"<text x='{x + node_width / 2}' y='{y + node_height / 2 + 5}' "
|
| 522 |
+
"text-anchor='middle' font-size='11.5' font-family='Microsoft YaHei UI, Noto Sans SC, sans-serif' "
|
| 523 |
+
f"fill='{text_color}'>{display_name}</text>"
|
| 524 |
+
"</g>"
|
| 525 |
+
)
|
| 526 |
+
|
| 527 |
+
svg = (
|
| 528 |
+
f"<svg width='{canvas_width}' height='{canvas_height}' viewBox='0 0 {canvas_width} {canvas_height}' "
|
| 529 |
+
"xmlns='http://www.w3.org/2000/svg'>"
|
| 530 |
+
"<style>"
|
| 531 |
+
"@keyframes mapNodePulse{"
|
| 532 |
+
"0%{fill:#fff7ed;stroke:#f97316;stroke-width:1.8;opacity:0.9;}"
|
| 533 |
+
"50%{fill:#fdba74;stroke:#c2410c;stroke-width:1.8;opacity:1;}"
|
| 534 |
+
"100%{fill:#fff7ed;stroke:#f97316;stroke-width:1.8;opacity:0.9;}"
|
| 535 |
+
"}"
|
| 536 |
+
".map-current-node{animation:mapNodePulse 1.2s ease-in-out infinite;}"
|
| 537 |
+
"</style>"
|
| 538 |
+
+ "".join(edge_svg)
|
| 539 |
+
+ "".join(node_svg)
|
| 540 |
+
+ "</svg>"
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
+
return (
|
| 544 |
+
"<div style='font-size:0.9em;'>"
|
| 545 |
+
"<details>"
|
| 546 |
+
"<summary style='cursor:pointer;font-weight:700;'>展开地图关系图</summary>"
|
| 547 |
+
"<div style='font-size:0.8em;color:#475569;margin:8px 0 6px 0;'>"
|
| 548 |
+
"鼠标悬停于地点格可查看NPC与怪物。"
|
| 549 |
+
"</div>"
|
| 550 |
+
"<div style='overflow-x:auto;padding-bottom:2px;'>"
|
| 551 |
+
+ svg
|
| 552 |
+
+ "</div>"
|
| 553 |
+
"</details>"
|
| 554 |
+
"</div>"
|
| 555 |
+
)
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
def restart_game() -> tuple:
|
| 559 |
+
"""
|
| 560 |
+
重启冒险:清空所有数据,回到初始输入名称阶段。
|
| 561 |
+
|
| 562 |
+
Returns:
|
| 563 |
+
(空聊天历史, 初始状态面板, 隐藏选项按钮×3, 空游戏会话,
|
| 564 |
+
禁用文本输入框, 重置角色名称)
|
| 565 |
+
"""
|
| 566 |
+
loading = _get_loading_button_updates()
|
| 567 |
+
return (
|
| 568 |
+
[], # 清空聊天历史
|
| 569 |
+
_format_world_info_panel(None), # 重置世界信息
|
| 570 |
+
"## 等待开始...\n\n请输入角色名称并点击「开始冒险」", # 重置状态面板
|
| 571 |
+
"地图关系图\n(未开始)", # 清空地图
|
| 572 |
+
gr.update(value=None, visible=False), # 清空场景图片
|
| 573 |
+
*loading, # 占位选项按钮
|
| 574 |
+
{}, # 清空游戏会话
|
| 575 |
+
gr.update(value="", interactive=False), # 禁用并清空文本输入
|
| 576 |
+
gr.update(value="旅人"), # 重置角色名称
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
|
| 580 |
+
# ============================================================
|
| 581 |
+
# 核心交互函数
|
| 582 |
+
# ============================================================
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
def start_game(player_name: str, game_session: dict):
|
| 586 |
+
"""
|
| 587 |
+
开始新游戏:流式生成开场叙事。
|
| 588 |
+
|
| 589 |
+
使用生成器 yield 实现流式输出,让用户看到文字逐步出现。
|
| 590 |
+
"""
|
| 591 |
+
if not player_name.strip():
|
| 592 |
+
player_name = "旅人"
|
| 593 |
+
|
| 594 |
+
# 创建新游戏
|
| 595 |
+
game_session = create_new_game(player_name)
|
| 596 |
+
game_session["started"] = True
|
| 597 |
+
|
| 598 |
+
# 初始 yield:显示加载状态,按钮保持可见但禁用
|
| 599 |
+
chat_history = [{"role": "assistant", "content": "⏳ 正在生成开场..."}]
|
| 600 |
+
world_info_text = _format_world_info_panel(game_session["game_state"])
|
| 601 |
+
status_text = _format_status_panel(game_session["game_state"])
|
| 602 |
+
loading = _get_loading_button_updates()
|
| 603 |
+
|
| 604 |
+
yield (
|
| 605 |
+
chat_history,
|
| 606 |
+
world_info_text,
|
| 607 |
+
status_text,
|
| 608 |
+
_render_text_map(game_session["game_state"]),
|
| 609 |
+
_get_scene_image_update(game_session["game_state"]),
|
| 610 |
+
*loading,
|
| 611 |
+
game_session,
|
| 612 |
+
gr.update(interactive=False),
|
| 613 |
+
)
|
| 614 |
+
|
| 615 |
+
# 流式生成开场(选项仅在流结束后从 final 事件中提取,流式期间不解析选项)
|
| 616 |
+
turn_started = perf_counter()
|
| 617 |
+
story_text = ""
|
| 618 |
+
final_result = None
|
| 619 |
+
|
| 620 |
+
for update in game_session["story"].generate_opening_stream():
|
| 621 |
+
if update["type"] == "story_chunk":
|
| 622 |
+
story_text = update["text"]
|
| 623 |
+
chat_history[-1]["content"] = story_text
|
| 624 |
+
yield (
|
| 625 |
+
chat_history,
|
| 626 |
+
world_info_text,
|
| 627 |
+
status_text,
|
| 628 |
+
_render_text_map(game_session["game_state"]),
|
| 629 |
+
_get_scene_image_update(game_session["game_state"]),
|
| 630 |
+
*loading,
|
| 631 |
+
game_session,
|
| 632 |
+
gr.update(interactive=False),
|
| 633 |
+
)
|
| 634 |
+
elif update["type"] == "final":
|
| 635 |
+
final_result = update
|
| 636 |
+
|
| 637 |
+
generation_latency_ms = (perf_counter() - turn_started) * 1000
|
| 638 |
+
|
| 639 |
+
# ★ 只在数据流完全结束后,从 final_result 中提取选项
|
| 640 |
+
if final_result:
|
| 641 |
+
story_text = final_result.get("story_text", story_text)
|
| 642 |
+
options = final_result.get("options", [])
|
| 643 |
+
else:
|
| 644 |
+
options = []
|
| 645 |
+
|
| 646 |
+
# ★ 安全兜底:强制确保恰好 3 个选项
|
| 647 |
+
options = _finalize_session_options(options)
|
| 648 |
+
|
| 649 |
+
# 最终 yield:显示完整文本 + 选项 + 启用按钮
|
| 650 |
+
game_session["current_options"] = options
|
| 651 |
+
full_message = story_text
|
| 652 |
+
if not final_result:
|
| 653 |
+
final_result = {
|
| 654 |
+
"story_text": story_text,
|
| 655 |
+
"options": options,
|
| 656 |
+
"state_changes": {},
|
| 657 |
+
"change_log": [],
|
| 658 |
+
"consistency_issues": [],
|
| 659 |
+
"telemetry": {
|
| 660 |
+
"engine_mode": "opening_app",
|
| 661 |
+
"used_fallback": True,
|
| 662 |
+
"fallback_reason": "missing_final_event",
|
| 663 |
+
},
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
chat_history[-1]["content"] = full_message
|
| 667 |
+
world_info_text = _format_world_info_panel(game_session["game_state"])
|
| 668 |
+
status_text = _format_status_panel(game_session["game_state"])
|
| 669 |
+
btn_updates = _get_button_updates(options)
|
| 670 |
+
_record_interaction_log(
|
| 671 |
+
game_session,
|
| 672 |
+
input_source="system_opening",
|
| 673 |
+
user_input="",
|
| 674 |
+
intent_result=None,
|
| 675 |
+
output_text=full_message,
|
| 676 |
+
latency_ms=generation_latency_ms,
|
| 677 |
+
generation_latency_ms=generation_latency_ms,
|
| 678 |
+
final_result=final_result,
|
| 679 |
+
)
|
| 680 |
+
|
| 681 |
+
yield (
|
| 682 |
+
chat_history,
|
| 683 |
+
world_info_text,
|
| 684 |
+
status_text,
|
| 685 |
+
_render_text_map(game_session["game_state"]),
|
| 686 |
+
_get_scene_image_update(game_session["game_state"]),
|
| 687 |
+
*btn_updates,
|
| 688 |
+
game_session,
|
| 689 |
+
gr.update(interactive=True),
|
| 690 |
+
)
|
| 691 |
+
|
| 692 |
+
|
| 693 |
+
def process_user_input(user_input: str, chat_history: list, game_session: dict):
|
| 694 |
+
"""
|
| 695 |
+
处理用户文本输入(流式版本)。
|
| 696 |
+
|
| 697 |
+
流程:
|
| 698 |
+
1. NLU 引擎解析意图
|
| 699 |
+
2. 叙事引擎流式生成故事
|
| 700 |
+
3. 逐步更新 UI
|
| 701 |
+
"""
|
| 702 |
+
if not game_session or not game_session.get("started"):
|
| 703 |
+
chat_history = chat_history or []
|
| 704 |
+
chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
|
| 705 |
+
loading = _get_loading_button_updates()
|
| 706 |
+
yield (
|
| 707 |
+
chat_history,
|
| 708 |
+
_format_world_info_panel(None),
|
| 709 |
+
"",
|
| 710 |
+
"",
|
| 711 |
+
gr.update(value=None, visible=False),
|
| 712 |
+
*loading,
|
| 713 |
+
game_session,
|
| 714 |
+
)
|
| 715 |
+
return
|
| 716 |
+
|
| 717 |
+
if not user_input.strip():
|
| 718 |
+
btn_updates = _get_button_updates(game_session.get("current_options", []))
|
| 719 |
+
yield (
|
| 720 |
+
chat_history,
|
| 721 |
+
_format_world_info_panel(game_session["game_state"]),
|
| 722 |
+
_format_status_panel(game_session["game_state"]),
|
| 723 |
+
_render_text_map(game_session["game_state"]),
|
| 724 |
+
_get_scene_image_update(game_session["game_state"]),
|
| 725 |
+
*btn_updates,
|
| 726 |
+
game_session,
|
| 727 |
+
)
|
| 728 |
+
return
|
| 729 |
+
|
| 730 |
+
gs: GameState = game_session["game_state"]
|
| 731 |
+
nlu: NLUEngine = game_session["nlu"]
|
| 732 |
+
story: StoryEngine = game_session["story"]
|
| 733 |
+
turn_started = perf_counter()
|
| 734 |
+
|
| 735 |
+
# 检查游戏是否已结束
|
| 736 |
+
if gs.is_game_over():
|
| 737 |
+
chat_history.append({"role": "user", "content": user_input})
|
| 738 |
+
chat_history.append({"role": "assistant", "content": "游戏已结束。请点击「重新开始」按钮开始新的冒险。"})
|
| 739 |
+
restart_buttons = _get_button_updates(
|
| 740 |
+
[
|
| 741 |
+
{"id": 1, "text": "重新开始", "action_type": "RESTART"},
|
| 742 |
+
]
|
| 743 |
+
)
|
| 744 |
+
yield (
|
| 745 |
+
chat_history,
|
| 746 |
+
_format_world_info_panel(gs),
|
| 747 |
+
_format_status_panel(gs),
|
| 748 |
+
_render_text_map(gs),
|
| 749 |
+
_get_scene_image_update(gs),
|
| 750 |
+
*restart_buttons,
|
| 751 |
+
game_session,
|
| 752 |
+
)
|
| 753 |
+
return
|
| 754 |
+
|
| 755 |
+
# 1. NLU 解析
|
| 756 |
+
nlu_started = perf_counter()
|
| 757 |
+
intent = nlu.parse_intent(user_input)
|
| 758 |
+
nlu_latency_ms = (perf_counter() - nlu_started) * 1000
|
| 759 |
+
|
| 760 |
+
# 1.5 预校验:立即驳回违反一致性的操作(不调用 LLM,不消耗回合)
|
| 761 |
+
is_valid, rejection_msg = gs.pre_validate_action(intent)
|
| 762 |
+
if not is_valid:
|
| 763 |
+
chat_history.append({"role": "user", "content": user_input})
|
| 764 |
+
options = game_session.get("current_options", [])
|
| 765 |
+
options = _finalize_session_options(options)
|
| 766 |
+
rejection_content = (
|
| 767 |
+
f"⚠️ **行动被驳回**:{rejection_msg}\n\n"
|
| 768 |
+
f"请重新选择行动,或输入其他指令。"
|
| 769 |
+
)
|
| 770 |
+
chat_history.append({"role": "assistant", "content": rejection_content})
|
| 771 |
+
rejection_result = {
|
| 772 |
+
"story_text": rejection_content,
|
| 773 |
+
"options": options,
|
| 774 |
+
"state_changes": {},
|
| 775 |
+
"change_log": [],
|
| 776 |
+
"consistency_issues": [],
|
| 777 |
+
"telemetry": {
|
| 778 |
+
"engine_mode": "pre_validation",
|
| 779 |
+
"used_fallback": False,
|
| 780 |
+
"fallback_reason": None,
|
| 781 |
+
},
|
| 782 |
+
}
|
| 783 |
+
_record_interaction_log(
|
| 784 |
+
game_session,
|
| 785 |
+
input_source="text_input",
|
| 786 |
+
user_input=user_input,
|
| 787 |
+
intent_result=intent,
|
| 788 |
+
output_text=rejection_content,
|
| 789 |
+
latency_ms=(perf_counter() - turn_started) * 1000,
|
| 790 |
+
nlu_latency_ms=nlu_latency_ms,
|
| 791 |
+
generation_latency_ms=0.0,
|
| 792 |
+
final_result=rejection_result,
|
| 793 |
+
)
|
| 794 |
+
btn_updates = _get_button_updates(options)
|
| 795 |
+
yield (
|
| 796 |
+
chat_history,
|
| 797 |
+
_format_world_info_panel(gs),
|
| 798 |
+
_format_status_panel(gs),
|
| 799 |
+
_render_text_map(gs),
|
| 800 |
+
_get_scene_image_update(gs),
|
| 801 |
+
*btn_updates,
|
| 802 |
+
game_session,
|
| 803 |
+
)
|
| 804 |
+
return
|
| 805 |
+
|
| 806 |
+
# 2. 添加用户消息 + 空的 assistant 消息(用于流式填充)
|
| 807 |
+
chat_history.append({"role": "user", "content": user_input})
|
| 808 |
+
chat_history.append({"role": "assistant", "content": "⏳ 正在生成..."})
|
| 809 |
+
|
| 810 |
+
# 按钮保持可见但禁用,防止流式期间点击
|
| 811 |
+
loading = _get_loading_button_updates(
|
| 812 |
+
max(len(game_session.get("current_options", [])), MIN_OPTION_BUTTONS)
|
| 813 |
+
)
|
| 814 |
+
yield (
|
| 815 |
+
chat_history,
|
| 816 |
+
_format_world_info_panel(gs),
|
| 817 |
+
_format_status_panel(gs),
|
| 818 |
+
_render_text_map(gs),
|
| 819 |
+
_get_scene_image_update(gs),
|
| 820 |
+
*loading,
|
| 821 |
+
game_session,
|
| 822 |
+
)
|
| 823 |
+
|
| 824 |
+
# 3. 流式生成故事
|
| 825 |
+
generation_started = perf_counter()
|
| 826 |
+
final_result = None
|
| 827 |
+
for update in story.generate_story_stream(intent):
|
| 828 |
+
if update["type"] == "story_chunk":
|
| 829 |
+
chat_history[-1]["content"] = update["text"]
|
| 830 |
+
yield (
|
| 831 |
+
chat_history,
|
| 832 |
+
_format_world_info_panel(gs),
|
| 833 |
+
_format_status_panel(gs),
|
| 834 |
+
_render_text_map(gs),
|
| 835 |
+
_get_scene_image_update(gs),
|
| 836 |
+
*loading,
|
| 837 |
+
game_session,
|
| 838 |
+
)
|
| 839 |
+
elif update["type"] == "final":
|
| 840 |
+
final_result = update
|
| 841 |
+
|
| 842 |
+
generation_latency_ms = (perf_counter() - generation_started) * 1000
|
| 843 |
+
|
| 844 |
+
# 4. 最终更新:完整文本 + 状态变化 + 选项 + 按钮
|
| 845 |
+
if final_result:
|
| 846 |
+
# ★ 安全兜底:强制确保恰好 3 个选项
|
| 847 |
+
options = _finalize_session_options(final_result.get("options", []))
|
| 848 |
+
game_session["current_options"] = options
|
| 849 |
+
|
| 850 |
+
change_log = final_result.get("change_log", [])
|
| 851 |
+
log_text = ""
|
| 852 |
+
if change_log:
|
| 853 |
+
log_text = "\n".join(f" {c}" for c in change_log)
|
| 854 |
+
log_text = f"\n\n**状态变化:**\n{log_text}"
|
| 855 |
+
|
| 856 |
+
issues = final_result.get("consistency_issues", [])
|
| 857 |
+
issues_text = ""
|
| 858 |
+
if issues:
|
| 859 |
+
issues_text = "\n".join(f" {i}" for i in issues)
|
| 860 |
+
issues_text = f"\n\n**一致性提示:**\n{issues_text}"
|
| 861 |
+
|
| 862 |
+
full_message = f"{final_result['story_text']}{log_text}{issues_text}"
|
| 863 |
+
chat_history[-1]["content"] = full_message
|
| 864 |
+
|
| 865 |
+
status_text = _format_status_panel(gs)
|
| 866 |
+
btn_updates = _get_button_updates(options)
|
| 867 |
+
_record_interaction_log(
|
| 868 |
+
game_session,
|
| 869 |
+
input_source="text_input",
|
| 870 |
+
user_input=user_input,
|
| 871 |
+
intent_result=intent,
|
| 872 |
+
output_text=full_message,
|
| 873 |
+
latency_ms=(perf_counter() - turn_started) * 1000,
|
| 874 |
+
nlu_latency_ms=nlu_latency_ms,
|
| 875 |
+
generation_latency_ms=generation_latency_ms,
|
| 876 |
+
final_result=final_result,
|
| 877 |
+
)
|
| 878 |
+
|
| 879 |
+
yield (
|
| 880 |
+
chat_history,
|
| 881 |
+
_format_world_info_panel(gs),
|
| 882 |
+
status_text,
|
| 883 |
+
_render_text_map(gs),
|
| 884 |
+
_get_scene_image_update(gs),
|
| 885 |
+
*btn_updates,
|
| 886 |
+
game_session,
|
| 887 |
+
)
|
| 888 |
+
else:
|
| 889 |
+
# ★ 兜底:final_result 为空,说明流式生成未产生 final 事件
|
| 890 |
+
logger.warning("流式生成未产生 final 事件,使用兜底文本")
|
| 891 |
+
fallback_text = "你环顾四周,思考着接下来该做什么..."
|
| 892 |
+
fallback_options = _finalize_session_options([])
|
| 893 |
+
game_session["current_options"] = fallback_options
|
| 894 |
+
|
| 895 |
+
full_message = fallback_text
|
| 896 |
+
fallback_result = {
|
| 897 |
+
"story_text": fallback_text,
|
| 898 |
+
"options": fallback_options,
|
| 899 |
+
"state_changes": {},
|
| 900 |
+
"change_log": [],
|
| 901 |
+
"consistency_issues": [],
|
| 902 |
+
"telemetry": {
|
| 903 |
+
"engine_mode": "app_fallback",
|
| 904 |
+
"used_fallback": True,
|
| 905 |
+
"fallback_reason": "missing_final_event",
|
| 906 |
+
},
|
| 907 |
+
}
|
| 908 |
+
chat_history[-1]["content"] = full_message
|
| 909 |
+
|
| 910 |
+
status_text = _format_status_panel(gs)
|
| 911 |
+
btn_updates = _get_button_updates(fallback_options)
|
| 912 |
+
_record_interaction_log(
|
| 913 |
+
game_session,
|
| 914 |
+
input_source="text_input",
|
| 915 |
+
user_input=user_input,
|
| 916 |
+
intent_result=intent,
|
| 917 |
+
output_text=full_message,
|
| 918 |
+
latency_ms=(perf_counter() - turn_started) * 1000,
|
| 919 |
+
nlu_latency_ms=nlu_latency_ms,
|
| 920 |
+
generation_latency_ms=generation_latency_ms,
|
| 921 |
+
final_result=fallback_result,
|
| 922 |
+
)
|
| 923 |
+
|
| 924 |
+
yield (
|
| 925 |
+
chat_history,
|
| 926 |
+
_format_world_info_panel(gs),
|
| 927 |
+
status_text,
|
| 928 |
+
_render_text_map(gs),
|
| 929 |
+
_get_scene_image_update(gs),
|
| 930 |
+
*btn_updates,
|
| 931 |
+
game_session,
|
| 932 |
+
)
|
| 933 |
+
return
|
| 934 |
+
|
| 935 |
+
|
| 936 |
+
def process_option_click(option_idx: int, chat_history: list, game_session: dict):
|
| 937 |
+
"""
|
| 938 |
+
处��玩家点击选项按钮(流式版本)。
|
| 939 |
+
|
| 940 |
+
Args:
|
| 941 |
+
option_idx: 选项索引 (0-5)
|
| 942 |
+
"""
|
| 943 |
+
if not game_session or not game_session.get("started"):
|
| 944 |
+
chat_history = chat_history or []
|
| 945 |
+
chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
|
| 946 |
+
loading = _get_loading_button_updates()
|
| 947 |
+
yield (
|
| 948 |
+
chat_history,
|
| 949 |
+
_format_world_info_panel(None),
|
| 950 |
+
"",
|
| 951 |
+
"",
|
| 952 |
+
gr.update(value=None, visible=False),
|
| 953 |
+
*loading,
|
| 954 |
+
game_session,
|
| 955 |
+
)
|
| 956 |
+
return
|
| 957 |
+
|
| 958 |
+
options = game_session.get("current_options", [])
|
| 959 |
+
if option_idx >= len(options):
|
| 960 |
+
btn_updates = _get_button_updates(options)
|
| 961 |
+
yield (
|
| 962 |
+
chat_history,
|
| 963 |
+
_format_world_info_panel(game_session["game_state"]),
|
| 964 |
+
_format_status_panel(game_session["game_state"]),
|
| 965 |
+
_render_text_map(game_session["game_state"]),
|
| 966 |
+
_get_scene_image_update(game_session["game_state"]),
|
| 967 |
+
*btn_updates,
|
| 968 |
+
game_session,
|
| 969 |
+
)
|
| 970 |
+
return
|
| 971 |
+
|
| 972 |
+
selected_option = options[option_idx]
|
| 973 |
+
gs: GameState = game_session["game_state"]
|
| 974 |
+
story: StoryEngine = game_session["story"]
|
| 975 |
+
option_intent = _build_option_intent(selected_option)
|
| 976 |
+
turn_started = perf_counter()
|
| 977 |
+
|
| 978 |
+
# 检查特殊选项:退出背包(不消耗回合)
|
| 979 |
+
if selected_option.get("action_type") == "BACKPACK_EXIT":
|
| 980 |
+
chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
|
| 981 |
+
chat_history.append({"role": "assistant", "content": "你合上背包,把注意力重新放回当前局势。"})
|
| 982 |
+
restored_options = game_session.pop("backpack_return_options", None)
|
| 983 |
+
if not isinstance(restored_options, list):
|
| 984 |
+
restored_options = _finalize_session_options([])
|
| 985 |
+
game_session["current_options"] = restored_options
|
| 986 |
+
btn_updates = _get_button_updates(restored_options)
|
| 987 |
+
yield (
|
| 988 |
+
chat_history,
|
| 989 |
+
_format_world_info_panel(gs),
|
| 990 |
+
_format_status_panel(gs),
|
| 991 |
+
_render_text_map(gs),
|
| 992 |
+
_get_scene_image_update(gs),
|
| 993 |
+
*btn_updates,
|
| 994 |
+
game_session,
|
| 995 |
+
)
|
| 996 |
+
return
|
| 997 |
+
|
| 998 |
+
from_backpack_menu = str(selected_option.get("menu", "")) == "backpack"
|
| 999 |
+
|
| 1000 |
+
# 检查特殊选项:重新开始
|
| 1001 |
+
if selected_option.get("action_type") == "RESTART":
|
| 1002 |
+
# 重新开始时使用流式开场
|
| 1003 |
+
game_session = create_new_game(gs.player.name)
|
| 1004 |
+
game_session["started"] = True
|
| 1005 |
+
|
| 1006 |
+
chat_history = [{"role": "assistant", "content": "⏳ 正在重新生成开场..."}]
|
| 1007 |
+
world_info_text = _format_world_info_panel(game_session["game_state"])
|
| 1008 |
+
status_text = _format_status_panel(game_session["game_state"])
|
| 1009 |
+
loading = _get_loading_button_updates()
|
| 1010 |
+
|
| 1011 |
+
yield (
|
| 1012 |
+
chat_history,
|
| 1013 |
+
world_info_text,
|
| 1014 |
+
status_text,
|
| 1015 |
+
_render_text_map(game_session["game_state"]),
|
| 1016 |
+
_get_scene_image_update(game_session["game_state"]),
|
| 1017 |
+
*loading,
|
| 1018 |
+
game_session,
|
| 1019 |
+
)
|
| 1020 |
+
|
| 1021 |
+
story_text = ""
|
| 1022 |
+
restart_final = None
|
| 1023 |
+
|
| 1024 |
+
for update in game_session["story"].generate_opening_stream():
|
| 1025 |
+
if update["type"] == "story_chunk":
|
| 1026 |
+
story_text = update["text"]
|
| 1027 |
+
chat_history[-1]["content"] = story_text
|
| 1028 |
+
yield (
|
| 1029 |
+
chat_history,
|
| 1030 |
+
world_info_text,
|
| 1031 |
+
status_text,
|
| 1032 |
+
_render_text_map(game_session["game_state"]),
|
| 1033 |
+
_get_scene_image_update(game_session["game_state"]),
|
| 1034 |
+
*loading,
|
| 1035 |
+
game_session,
|
| 1036 |
+
)
|
| 1037 |
+
elif update["type"] == "final":
|
| 1038 |
+
restart_final = update
|
| 1039 |
+
|
| 1040 |
+
# ★ 只在流完全结束后提取选项
|
| 1041 |
+
if restart_final:
|
| 1042 |
+
story_text = restart_final.get("story_text", story_text)
|
| 1043 |
+
restart_options = restart_final.get("options", [])
|
| 1044 |
+
else:
|
| 1045 |
+
restart_options = []
|
| 1046 |
+
|
| 1047 |
+
# ★ 安全兜底:强制确保恰好 3 个选项
|
| 1048 |
+
restart_options = _finalize_session_options(restart_options)
|
| 1049 |
+
game_session["current_options"] = restart_options
|
| 1050 |
+
full_message = story_text
|
| 1051 |
+
chat_history[-1]["content"] = full_message
|
| 1052 |
+
|
| 1053 |
+
status_text = _format_status_panel(game_session["game_state"])
|
| 1054 |
+
btn_updates = _get_button_updates(restart_options)
|
| 1055 |
+
|
| 1056 |
+
yield (
|
| 1057 |
+
chat_history,
|
| 1058 |
+
_format_world_info_panel(game_session["game_state"]),
|
| 1059 |
+
status_text,
|
| 1060 |
+
_render_text_map(game_session["game_state"]),
|
| 1061 |
+
_get_scene_image_update(game_session["game_state"]),
|
| 1062 |
+
*btn_updates,
|
| 1063 |
+
game_session,
|
| 1064 |
+
)
|
| 1065 |
+
return
|
| 1066 |
+
|
| 1067 |
+
# 检查特殊选项:退出
|
| 1068 |
+
if selected_option.get("action_type") == "QUIT":
|
| 1069 |
+
chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
|
| 1070 |
+
chat_history.append({"role": "assistant", "content": "感谢游玩 StoryWeaver!\n你的冒险到此结束,但故事永远不会真正终结...\n\n点击「开始冒险」可以重新开始。"})
|
| 1071 |
+
quit_buttons = _get_button_updates(
|
| 1072 |
+
[
|
| 1073 |
+
{"id": 1, "text": "重新开始", "action_type": "RESTART"},
|
| 1074 |
+
]
|
| 1075 |
+
)
|
| 1076 |
+
yield (
|
| 1077 |
+
chat_history,
|
| 1078 |
+
_format_world_info_panel(gs),
|
| 1079 |
+
_format_status_panel(gs),
|
| 1080 |
+
_render_text_map(gs),
|
| 1081 |
+
_get_scene_image_update(gs),
|
| 1082 |
+
*quit_buttons,
|
| 1083 |
+
game_session,
|
| 1084 |
+
)
|
| 1085 |
+
return
|
| 1086 |
+
|
| 1087 |
+
# 正常选项处理:流式生成
|
| 1088 |
+
chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
|
| 1089 |
+
chat_history.append({"role": "assistant", "content": "⏳ 正在生成..."})
|
| 1090 |
+
|
| 1091 |
+
# 按钮保持可见但禁用
|
| 1092 |
+
loading = _get_loading_button_updates(max(len(options), MIN_OPTION_BUTTONS))
|
| 1093 |
+
yield (
|
| 1094 |
+
chat_history,
|
| 1095 |
+
_format_world_info_panel(gs),
|
| 1096 |
+
_format_status_panel(gs),
|
| 1097 |
+
_render_text_map(gs),
|
| 1098 |
+
_get_scene_image_update(gs),
|
| 1099 |
+
*loading,
|
| 1100 |
+
game_session,
|
| 1101 |
+
)
|
| 1102 |
+
|
| 1103 |
+
generation_started = perf_counter()
|
| 1104 |
+
final_result = None
|
| 1105 |
+
for update in story.process_option_selection_stream(selected_option):
|
| 1106 |
+
if update["type"] == "story_chunk":
|
| 1107 |
+
chat_history[-1]["content"] = update["text"]
|
| 1108 |
+
yield (
|
| 1109 |
+
chat_history,
|
| 1110 |
+
_format_world_info_panel(gs),
|
| 1111 |
+
_format_status_panel(gs),
|
| 1112 |
+
_render_text_map(gs),
|
| 1113 |
+
_get_scene_image_update(gs),
|
| 1114 |
+
*loading,
|
| 1115 |
+
game_session,
|
| 1116 |
+
)
|
| 1117 |
+
elif update["type"] == "final":
|
| 1118 |
+
final_result = update
|
| 1119 |
+
|
| 1120 |
+
generation_latency_ms = (perf_counter() - generation_started) * 1000
|
| 1121 |
+
|
| 1122 |
+
if final_result:
|
| 1123 |
+
# 背包菜单内执行使用/装备后,继续停留在背包菜单
|
| 1124 |
+
if from_backpack_menu:
|
| 1125 |
+
options = _build_backpack_options(gs)
|
| 1126 |
+
else:
|
| 1127 |
+
options = _finalize_session_options(final_result.get("options", []))
|
| 1128 |
+
game_session["current_options"] = options
|
| 1129 |
+
|
| 1130 |
+
change_log = final_result.get("change_log", [])
|
| 1131 |
+
log_text = ""
|
| 1132 |
+
if change_log:
|
| 1133 |
+
log_text = "\n".join(f" {c}" for c in change_log)
|
| 1134 |
+
log_text = f"\n\n**状态变化:**\n{log_text}"
|
| 1135 |
+
|
| 1136 |
+
full_message = f"{final_result['story_text']}{log_text}"
|
| 1137 |
+
chat_history[-1]["content"] = full_message
|
| 1138 |
+
|
| 1139 |
+
status_text = _format_status_panel(gs)
|
| 1140 |
+
btn_updates = _get_button_updates(options)
|
| 1141 |
+
_record_interaction_log(
|
| 1142 |
+
game_session,
|
| 1143 |
+
input_source="option_click",
|
| 1144 |
+
user_input=selected_option.get("text", ""),
|
| 1145 |
+
intent_result=option_intent,
|
| 1146 |
+
output_text=full_message,
|
| 1147 |
+
latency_ms=(perf_counter() - turn_started) * 1000,
|
| 1148 |
+
generation_latency_ms=generation_latency_ms,
|
| 1149 |
+
final_result=final_result,
|
| 1150 |
+
selected_option=selected_option,
|
| 1151 |
+
)
|
| 1152 |
+
|
| 1153 |
+
yield (
|
| 1154 |
+
chat_history,
|
| 1155 |
+
_format_world_info_panel(gs),
|
| 1156 |
+
status_text,
|
| 1157 |
+
_render_text_map(gs),
|
| 1158 |
+
_get_scene_image_update(gs),
|
| 1159 |
+
*btn_updates,
|
| 1160 |
+
game_session,
|
| 1161 |
+
)
|
| 1162 |
+
else:
|
| 1163 |
+
# ★ 兜底:final_result 为空,说明流式生成未产生 final 事件
|
| 1164 |
+
logger.warning("[选项点击] 流式生成未产生 final 事件,使用兜底文本")
|
| 1165 |
+
if from_backpack_menu:
|
| 1166 |
+
fallback_text = "你整理了一下背包,却一时没想好先使用哪件物品。"
|
| 1167 |
+
fallback_options = _build_backpack_options(gs)
|
| 1168 |
+
else:
|
| 1169 |
+
fallback_text = "你环顾四周,思考着接下来该做什么..."
|
| 1170 |
+
fallback_options = _finalize_session_options([])
|
| 1171 |
+
game_session["current_options"] = fallback_options
|
| 1172 |
+
|
| 1173 |
+
full_message = fallback_text
|
| 1174 |
+
fallback_result = {
|
| 1175 |
+
"story_text": fallback_text,
|
| 1176 |
+
"options": fallback_options,
|
| 1177 |
+
"state_changes": {},
|
| 1178 |
+
"change_log": [],
|
| 1179 |
+
"consistency_issues": [],
|
| 1180 |
+
"telemetry": {
|
| 1181 |
+
"engine_mode": "app_fallback",
|
| 1182 |
+
"used_fallback": True,
|
| 1183 |
+
"fallback_reason": "missing_final_event",
|
| 1184 |
+
},
|
| 1185 |
+
}
|
| 1186 |
+
chat_history[-1]["content"] = full_message
|
| 1187 |
+
|
| 1188 |
+
status_text = _format_status_panel(gs)
|
| 1189 |
+
btn_updates = _get_button_updates(fallback_options)
|
| 1190 |
+
_record_interaction_log(
|
| 1191 |
+
game_session,
|
| 1192 |
+
input_source="option_click",
|
| 1193 |
+
user_input=selected_option.get("text", ""),
|
| 1194 |
+
intent_result=option_intent,
|
| 1195 |
+
output_text=full_message,
|
| 1196 |
+
latency_ms=(perf_counter() - turn_started) * 1000,
|
| 1197 |
+
generation_latency_ms=generation_latency_ms,
|
| 1198 |
+
final_result=fallback_result,
|
| 1199 |
+
selected_option=selected_option,
|
| 1200 |
+
)
|
| 1201 |
+
|
| 1202 |
+
yield (
|
| 1203 |
+
chat_history,
|
| 1204 |
+
_format_world_info_panel(gs),
|
| 1205 |
+
status_text,
|
| 1206 |
+
_render_text_map(gs),
|
| 1207 |
+
_get_scene_image_update(gs),
|
| 1208 |
+
*btn_updates,
|
| 1209 |
+
game_session,
|
| 1210 |
+
)
|
| 1211 |
+
return
|
| 1212 |
+
|
| 1213 |
+
|
| 1214 |
+
# ============================================================
|
| 1215 |
+
# UI 辅助函数
|
| 1216 |
+
# ============================================================
|
| 1217 |
+
|
| 1218 |
+
|
| 1219 |
+
MIN_OPTION_BUTTONS = 3
|
| 1220 |
+
MAX_OPTION_BUTTONS = 6
|
| 1221 |
+
|
| 1222 |
+
# 兜底默认选项(当解析出的选项为空时使用)
|
| 1223 |
+
_FALLBACK_BUTTON_OPTIONS = [
|
| 1224 |
+
{"id": 1, "text": "查看周围", "action_type": "EXPLORE"},
|
| 1225 |
+
{"id": 2, "text": "等待一会", "action_type": "REST"},
|
| 1226 |
+
{"id": 3, "text": "检查状态", "action_type": "EXPLORE"},
|
| 1227 |
+
]
|
| 1228 |
+
|
| 1229 |
+
|
| 1230 |
+
def _normalize_options(
|
| 1231 |
+
options: list[dict],
|
| 1232 |
+
*,
|
| 1233 |
+
minimum: int = 0,
|
| 1234 |
+
maximum: int = MAX_OPTION_BUTTONS,
|
| 1235 |
+
) -> list[dict]:
|
| 1236 |
+
"""
|
| 1237 |
+
规范化选项列表:
|
| 1238 |
+
- 至多保留 maximum 个选项
|
| 1239 |
+
- 仅当 minimum > 0 时补充兜底项
|
| 1240 |
+
- 始终重新编号
|
| 1241 |
+
"""
|
| 1242 |
+
if not isinstance(options, list):
|
| 1243 |
+
options = []
|
| 1244 |
+
|
| 1245 |
+
normalized = [opt for opt in options if isinstance(opt, dict)][:maximum]
|
| 1246 |
+
|
| 1247 |
+
for fb in _FALLBACK_BUTTON_OPTIONS:
|
| 1248 |
+
if len(normalized) >= minimum:
|
| 1249 |
+
break
|
| 1250 |
+
if not any(o.get("text") == fb["text"] for o in normalized):
|
| 1251 |
+
normalized.append(fb.copy())
|
| 1252 |
+
|
| 1253 |
+
while len(normalized) < minimum:
|
| 1254 |
+
normalized.append({
|
| 1255 |
+
"id": len(normalized) + 1,
|
| 1256 |
+
"text": "继续探索",
|
| 1257 |
+
"action_type": "EXPLORE",
|
| 1258 |
+
})
|
| 1259 |
+
|
| 1260 |
+
for i, opt in enumerate(normalized[:maximum], 1):
|
| 1261 |
+
if isinstance(opt, dict):
|
| 1262 |
+
opt["id"] = i
|
| 1263 |
+
|
| 1264 |
+
return normalized[:maximum]
|
| 1265 |
+
|
| 1266 |
+
|
| 1267 |
+
def _finalize_session_options(options: list[dict]) -> list[dict]:
|
| 1268 |
+
minimum = MIN_OPTION_BUTTONS if not options else 0
|
| 1269 |
+
return _normalize_options(options, minimum=minimum)
|
| 1270 |
+
|
| 1271 |
+
|
| 1272 |
+
def _format_options(options: list[dict]) -> str:
|
| 1273 |
+
"""将选项列表格式化为可读的文本(纯文字,绝不显示 JSON)"""
|
| 1274 |
+
if not options:
|
| 1275 |
+
return ""
|
| 1276 |
+
lines = ["---", "**你的选择:**"]
|
| 1277 |
+
for i, opt in enumerate(options):
|
| 1278 |
+
# 安全提取:兼容 dict 和异常情况
|
| 1279 |
+
if isinstance(opt, dict):
|
| 1280 |
+
idx = opt.get("id", i + 1)
|
| 1281 |
+
text = opt.get("text", "未知选项")
|
| 1282 |
+
else:
|
| 1283 |
+
idx = i + 1
|
| 1284 |
+
text = str(opt)
|
| 1285 |
+
lines.append(f" **[{idx}]** {text}")
|
| 1286 |
+
return "\n".join(lines)
|
| 1287 |
+
|
| 1288 |
+
|
| 1289 |
+
def _is_backpack_menu_active(options: list[dict]) -> bool:
|
| 1290 |
+
return any(
|
| 1291 |
+
isinstance(opt, dict)
|
| 1292 |
+
and str(opt.get("action_type", "")).upper() == "BACKPACK_EXIT"
|
| 1293 |
+
and str(opt.get("menu", "")) == "backpack"
|
| 1294 |
+
for opt in (options or [])
|
| 1295 |
+
)
|
| 1296 |
+
|
| 1297 |
+
|
| 1298 |
+
def _format_item_function(item_info) -> str:
|
| 1299 |
+
if item_info is None:
|
| 1300 |
+
return "功能未知"
|
| 1301 |
+
if item_info.use_effect:
|
| 1302 |
+
return f"效果:{item_info.use_effect}"
|
| 1303 |
+
if item_info.stat_bonus:
|
| 1304 |
+
bonus_text = ",".join(
|
| 1305 |
+
f"{stat}{'+' if int(value) >= 0 else ''}{int(value)}"
|
| 1306 |
+
for stat, value in item_info.stat_bonus.items()
|
| 1307 |
+
)
|
| 1308 |
+
return f"装备加成:{bonus_text}"
|
| 1309 |
+
if item_info.lore_text:
|
| 1310 |
+
return f"线索:{item_info.lore_text}"
|
| 1311 |
+
return "暂无可用效果"
|
| 1312 |
+
|
| 1313 |
+
|
| 1314 |
+
def _build_backpack_options(gs: GameState) -> list[dict]:
|
| 1315 |
+
inventory = list(gs.player.inventory)
|
| 1316 |
+
if not inventory:
|
| 1317 |
+
return [
|
| 1318 |
+
{"id": 1, "text": "退出背包", "action_type": "BACKPACK_EXIT", "menu": "backpack"},
|
| 1319 |
+
]
|
| 1320 |
+
|
| 1321 |
+
inventory_order = list(dict.fromkeys(inventory))
|
| 1322 |
+
equip_types = {"weapon", "armor", "accessory", "helmet", "boots"}
|
| 1323 |
+
|
| 1324 |
+
consumable_options: list[dict] = []
|
| 1325 |
+
equip_options: list[dict] = []
|
| 1326 |
+
for item_name in inventory_order:
|
| 1327 |
+
item_info = gs.world.item_registry.get(item_name)
|
| 1328 |
+
|
| 1329 |
+
if item_info and gs.is_item_consumable(item_name):
|
| 1330 |
+
consumable_options.append(
|
| 1331 |
+
{
|
| 1332 |
+
"text": f"使用{item_name}",
|
| 1333 |
+
"action_type": "USE_ITEM",
|
| 1334 |
+
"target": item_name,
|
| 1335 |
+
"menu": "backpack",
|
| 1336 |
+
}
|
| 1337 |
+
)
|
| 1338 |
+
continue
|
| 1339 |
+
|
| 1340 |
+
if item_info and item_info.item_type in equip_types:
|
| 1341 |
+
equip_options.append(
|
| 1342 |
+
{
|
| 1343 |
+
"text": f"装备{item_name}",
|
| 1344 |
+
"action_type": "EQUIP",
|
| 1345 |
+
"target": item_name,
|
| 1346 |
+
"menu": "backpack",
|
| 1347 |
+
}
|
| 1348 |
+
)
|
| 1349 |
+
|
| 1350 |
+
max_action_slots = MAX_OPTION_BUTTONS - 1
|
| 1351 |
+
merged_actions = (consumable_options + equip_options)[:max_action_slots]
|
| 1352 |
+
merged_actions.append(
|
| 1353 |
+
{"text": "退出背包", "action_type": "BACKPACK_EXIT", "menu": "backpack"}
|
| 1354 |
+
)
|
| 1355 |
+
return _normalize_options(merged_actions, minimum=0, maximum=MAX_OPTION_BUTTONS)
|
| 1356 |
+
|
| 1357 |
+
|
| 1358 |
+
def _format_backpack_story(gs: GameState) -> str:
|
| 1359 |
+
inventory = list(gs.player.inventory)
|
| 1360 |
+
if not inventory:
|
| 1361 |
+
return "你打开背包,里面空空如也。"
|
| 1362 |
+
|
| 1363 |
+
inventory_counter = Counter(inventory)
|
| 1364 |
+
inventory_order = list(dict.fromkeys(inventory))
|
| 1365 |
+
lines = ["你打开背包,快速检查随身物资:"]
|
| 1366 |
+
for item_name in inventory_order:
|
| 1367 |
+
item_info = gs.world.item_registry.get(item_name)
|
| 1368 |
+
quantity = inventory_counter.get(item_name, 1)
|
| 1369 |
+
quantity_text = f"x{quantity} " if quantity > 1 else ""
|
| 1370 |
+
description = item_info.description if item_info else "暂无描述"
|
| 1371 |
+
function_text = _format_item_function(item_info)
|
| 1372 |
+
lines.append(f"- {quantity_text}**{item_name}**:{description}({function_text})")
|
| 1373 |
+
lines.append("\n你可以直接在下方选择“使用/装备”对应物品,或退出背包。")
|
| 1374 |
+
return "\n".join(lines)
|
| 1375 |
+
|
| 1376 |
+
|
| 1377 |
+
def open_backpack(chat_history: list, game_session: dict):
|
| 1378 |
+
chat_history = chat_history or []
|
| 1379 |
+
if not game_session or not game_session.get("started"):
|
| 1380 |
+
chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
|
| 1381 |
+
loading = _get_loading_button_updates()
|
| 1382 |
+
return (
|
| 1383 |
+
chat_history,
|
| 1384 |
+
_format_world_info_panel(None),
|
| 1385 |
+
"",
|
| 1386 |
+
"",
|
| 1387 |
+
gr.update(value=None, visible=False),
|
| 1388 |
+
*loading,
|
| 1389 |
+
game_session,
|
| 1390 |
+
)
|
| 1391 |
+
|
| 1392 |
+
gs: GameState = game_session["game_state"]
|
| 1393 |
+
current_options = game_session.get("current_options", [])
|
| 1394 |
+
if not _is_backpack_menu_active(current_options):
|
| 1395 |
+
game_session["backpack_return_options"] = copy.deepcopy(current_options)
|
| 1396 |
+
|
| 1397 |
+
backpack_story = _format_backpack_story(gs)
|
| 1398 |
+
backpack_options = _build_backpack_options(gs)
|
| 1399 |
+
game_session["current_options"] = backpack_options
|
| 1400 |
+
|
| 1401 |
+
chat_history.append({"role": "user", "content": "打开背包"})
|
| 1402 |
+
chat_history.append({"role": "assistant", "content": backpack_story})
|
| 1403 |
+
|
| 1404 |
+
_record_interaction_log(
|
| 1405 |
+
game_session,
|
| 1406 |
+
input_source="backpack_button",
|
| 1407 |
+
user_input="打开背包",
|
| 1408 |
+
intent_result={"intent": "OPEN_BACKPACK", "target": None},
|
| 1409 |
+
output_text=backpack_story,
|
| 1410 |
+
latency_ms=0.0,
|
| 1411 |
+
generation_latency_ms=0.0,
|
| 1412 |
+
final_result={
|
| 1413 |
+
"story_text": backpack_story,
|
| 1414 |
+
"options": backpack_options,
|
| 1415 |
+
"state_changes": {},
|
| 1416 |
+
"change_log": [],
|
| 1417 |
+
"consistency_issues": [],
|
| 1418 |
+
"telemetry": {
|
| 1419 |
+
"engine_mode": "backpack_menu",
|
| 1420 |
+
"used_fallback": False,
|
| 1421 |
+
"fallback_reason": None,
|
| 1422 |
+
},
|
| 1423 |
+
},
|
| 1424 |
+
)
|
| 1425 |
+
|
| 1426 |
+
btn_updates = _get_button_updates(backpack_options)
|
| 1427 |
+
return (
|
| 1428 |
+
chat_history,
|
| 1429 |
+
_format_world_info_panel(gs),
|
| 1430 |
+
_format_status_panel(gs),
|
| 1431 |
+
_render_text_map(gs),
|
| 1432 |
+
_get_scene_image_update(gs),
|
| 1433 |
+
*btn_updates,
|
| 1434 |
+
game_session,
|
| 1435 |
+
)
|
| 1436 |
+
|
| 1437 |
+
|
| 1438 |
+
def _get_loading_button_updates(visible_count: int = MIN_OPTION_BUTTONS) -> list:
|
| 1439 |
+
"""返回加载中占位按钮更新,支持最多 6 个选项槽位。"""
|
| 1440 |
+
visible_count = max(0, min(int(visible_count or 0), MAX_OPTION_BUTTONS))
|
| 1441 |
+
updates = []
|
| 1442 |
+
for index in range(MAX_OPTION_BUTTONS):
|
| 1443 |
+
updates.append(
|
| 1444 |
+
gr.update(
|
| 1445 |
+
value="...",
|
| 1446 |
+
visible=index < visible_count,
|
| 1447 |
+
interactive=False,
|
| 1448 |
+
)
|
| 1449 |
+
)
|
| 1450 |
+
return updates
|
| 1451 |
+
|
| 1452 |
+
|
| 1453 |
+
def _get_button_updates(options: list[dict]) -> list:
|
| 1454 |
+
"""从选项列表生成按钮更新,始终返回 6 个槽位。"""
|
| 1455 |
+
options = _normalize_options(options, minimum=0)
|
| 1456 |
+
|
| 1457 |
+
updates = []
|
| 1458 |
+
for i in range(MAX_OPTION_BUTTONS):
|
| 1459 |
+
opt = options[i] if i < len(options) else None
|
| 1460 |
+
if isinstance(opt, dict):
|
| 1461 |
+
text = opt.get("text", "...")
|
| 1462 |
+
visible = True
|
| 1463 |
+
else:
|
| 1464 |
+
text = "..."
|
| 1465 |
+
visible = False
|
| 1466 |
+
updates.append(gr.update(value=text, visible=visible, interactive=visible))
|
| 1467 |
+
return updates
|
| 1468 |
+
|
| 1469 |
+
|
| 1470 |
+
def _format_status_panel(gs: GameState) -> str:
|
| 1471 |
+
"""格式化状态面板文本(双列 HTML 布局,减少滚动)"""
|
| 1472 |
+
p = gs.player
|
| 1473 |
+
w = gs.world
|
| 1474 |
+
effective_stats = gs.get_effective_player_stats()
|
| 1475 |
+
equipment_bonuses = gs.get_equipment_stat_bonuses()
|
| 1476 |
+
env_snapshot = gs.get_environment_snapshot(limit=3)
|
| 1477 |
+
survival_snapshot = gs.get_survival_state_snapshot()
|
| 1478 |
+
scene_summary = gs.get_scene_summary().replace("\n", "<br>")
|
| 1479 |
+
clock_display = gs.get_clock_display()
|
| 1480 |
+
|
| 1481 |
+
# 属性进度条
|
| 1482 |
+
hp_bar = _progress_bar(p.hp, p.max_hp, "HP")
|
| 1483 |
+
mp_bar = _progress_bar(p.mp, p.max_mp, "MP")
|
| 1484 |
+
stamina_bar = _progress_bar(p.stamina, p.max_stamina, "体力")
|
| 1485 |
+
hunger_bar = _progress_bar(p.hunger, 100, "饱食")
|
| 1486 |
+
sanity_bar = _progress_bar(p.sanity, 100, "理智")
|
| 1487 |
+
morale_bar = _progress_bar(p.morale, 100, "士气")
|
| 1488 |
+
|
| 1489 |
+
# 装备
|
| 1490 |
+
slot_names = {
|
| 1491 |
+
"weapon": "武器", "armor": "护甲", "accessory": "饰品",
|
| 1492 |
+
"helmet": "头盔", "boots": "靴子",
|
| 1493 |
+
}
|
| 1494 |
+
equip_lines = []
|
| 1495 |
+
for slot, item in p.equipment.items():
|
| 1496 |
+
equip_lines.append(f"{slot_names.get(slot, slot)}: {item or '无'}")
|
| 1497 |
+
equip_text = "<br>".join(equip_lines)
|
| 1498 |
+
|
| 1499 |
+
def render_stat(stat_key: str, label: str) -> str:
|
| 1500 |
+
base_value = int(getattr(p, stat_key))
|
| 1501 |
+
bonus_value = int(equipment_bonuses.get(stat_key, 0))
|
| 1502 |
+
effective_value = int(effective_stats.get(stat_key, base_value))
|
| 1503 |
+
if bonus_value > 0:
|
| 1504 |
+
return f"{label}: {effective_value} <span style='color:#4a6;'>(+{bonus_value} 装备)</span>"
|
| 1505 |
+
if bonus_value < 0:
|
| 1506 |
+
return f"{label}: {effective_value} <span style='color:#b44;'>({bonus_value} 装备)</span>"
|
| 1507 |
+
return f"{label}: {base_value}"
|
| 1508 |
+
|
| 1509 |
+
def badge(text: str, bg: str, fg: str = "#1f2937") -> str:
|
| 1510 |
+
return (
|
| 1511 |
+
f"<span style='display:inline-block;margin:0 6px 6px 0;padding:3px 10px;"
|
| 1512 |
+
f"border-radius:999px;background:{bg};color:{fg};font-size:0.8em;"
|
| 1513 |
+
f"font-weight:600;'>{text}</span>"
|
| 1514 |
+
)
|
| 1515 |
+
|
| 1516 |
+
# 状态效果
|
| 1517 |
+
if p.status_effects:
|
| 1518 |
+
effect_lines = "<br>".join(
|
| 1519 |
+
f"{e.name}({e.duration}回合)" for e in p.status_effects
|
| 1520 |
+
)
|
| 1521 |
+
else:
|
| 1522 |
+
effect_lines = "无"
|
| 1523 |
+
|
| 1524 |
+
# 背包
|
| 1525 |
+
if p.inventory:
|
| 1526 |
+
inventory_text = "<br>".join(p.inventory)
|
| 1527 |
+
else:
|
| 1528 |
+
inventory_text = "空"
|
| 1529 |
+
|
| 1530 |
+
weather_colors = {
|
| 1531 |
+
"晴朗": "#fef3c7",
|
| 1532 |
+
"多云": "#e5e7eb",
|
| 1533 |
+
"小雨": "#dbeafe",
|
| 1534 |
+
"浓雾": "#e0e7ff",
|
| 1535 |
+
"暴风雨": "#c7d2fe",
|
| 1536 |
+
"大雪": "#f3f4f6",
|
| 1537 |
+
}
|
| 1538 |
+
light_colors = {
|
| 1539 |
+
"明亮": "#fde68a",
|
| 1540 |
+
"柔和": "#fcd34d",
|
| 1541 |
+
"昏暗": "#cbd5e1",
|
| 1542 |
+
"幽暗": "#94a3b8",
|
| 1543 |
+
"漆黑": "#334155",
|
| 1544 |
+
}
|
| 1545 |
+
danger_level = int(env_snapshot.get("danger_level", 0))
|
| 1546 |
+
if danger_level >= 7:
|
| 1547 |
+
danger_badge = badge(f"危险 {danger_level}/10", "#fecaca", "#7f1d1d")
|
| 1548 |
+
elif danger_level >= 4:
|
| 1549 |
+
danger_badge = badge(f"危险 {danger_level}/10", "#fed7aa", "#9a3412")
|
| 1550 |
+
else:
|
| 1551 |
+
danger_badge = badge(f"危险 {danger_level}/10", "#dcfce7", "#166534")
|
| 1552 |
+
|
| 1553 |
+
env_badges = "".join(
|
| 1554 |
+
[
|
| 1555 |
+
badge(f"天气 {w.weather}", weather_colors.get(w.weather, "#e5e7eb")),
|
| 1556 |
+
badge(
|
| 1557 |
+
f"光照 {w.light_level}",
|
| 1558 |
+
light_colors.get(w.light_level, "#e5e7eb"),
|
| 1559 |
+
"#0f172a" if w.light_level not in {"幽暗", "漆黑"} else "#f8fafc",
|
| 1560 |
+
),
|
| 1561 |
+
danger_badge,
|
| 1562 |
+
badge(f"场景 {env_snapshot.get('location_type', 'unknown')}", "#ede9fe", "#4c1d95"),
|
| 1563 |
+
]
|
| 1564 |
+
)
|
| 1565 |
+
|
| 1566 |
+
recent_env_events = env_snapshot.get("recent_events", [])
|
| 1567 |
+
if recent_env_events:
|
| 1568 |
+
latest_event = recent_env_events[-1]
|
| 1569 |
+
latest_event_html = (
|
| 1570 |
+
f"<div style='padding:8px 10px;border-radius:10px;background:#f8fafc;"
|
| 1571 |
+
f"border:1px solid #dbeafe;margin-bottom:6px;'>"
|
| 1572 |
+
f"<b>{latest_event.get('title', '环境事件')}</b>"
|
| 1573 |
+
f"<br><span style='font-size:0.82em;color:#475569;'>{latest_event.get('description', '')}</span>"
|
| 1574 |
+
f"</div>"
|
| 1575 |
+
)
|
| 1576 |
+
recent_event_lines = "<br>".join(
|
| 1577 |
+
f"- {event.get('title', '环境事件')}"
|
| 1578 |
+
for event in reversed(recent_env_events[-3:])
|
| 1579 |
+
)
|
| 1580 |
+
else:
|
| 1581 |
+
latest_event_html = (
|
| 1582 |
+
"<div style='padding:8px 10px;border-radius:10px;background:#f8fafc;"
|
| 1583 |
+
"border:1px dashed #cbd5e1;color:#64748b;'>本回合暂无显式环境事件</div>"
|
| 1584 |
+
)
|
| 1585 |
+
recent_event_lines = "无"
|
| 1586 |
+
|
| 1587 |
+
# 活跃任务(完整展示:描述、子目标、奖励、来源)
|
| 1588 |
+
active_quests = [q for q in w.quests.values() if q.status == "active"]
|
| 1589 |
+
if active_quests:
|
| 1590 |
+
quest_blocks = []
|
| 1591 |
+
for q in active_quests:
|
| 1592 |
+
done = sum(1 for v in q.objectives.values() if v)
|
| 1593 |
+
total = len(q.objectives)
|
| 1594 |
+
tag = "主线" if q.quest_type == "main" else "支线" if q.quest_type == "side" else "🟡 " + q.quest_type
|
| 1595 |
+
# 子目标列表
|
| 1596 |
+
obj_lines = "".join(
|
| 1597 |
+
f"<br> {'✅' if v else '⬜'} {k}"
|
| 1598 |
+
for k, v in q.objectives.items()
|
| 1599 |
+
)
|
| 1600 |
+
# 奖励摘要
|
| 1601 |
+
reward_parts = []
|
| 1602 |
+
if q.rewards.gold:
|
| 1603 |
+
reward_parts.append(f"{q.rewards.gold}💰")
|
| 1604 |
+
if q.rewards.experience:
|
| 1605 |
+
reward_parts.append(f"{q.rewards.experience}经验")
|
| 1606 |
+
if q.rewards.items:
|
| 1607 |
+
reward_parts.append("、".join(q.rewards.items))
|
| 1608 |
+
if q.rewards.unlock_skill:
|
| 1609 |
+
reward_parts.append(f"技能:{q.rewards.unlock_skill}")
|
| 1610 |
+
if q.rewards.title:
|
| 1611 |
+
reward_parts.append(f"称号:{q.rewards.title}")
|
| 1612 |
+
reward_str = " | ".join(reward_parts) if reward_parts else "无"
|
| 1613 |
+
|
| 1614 |
+
block = (
|
| 1615 |
+
f"<details open><summary><b>{tag} {q.title}</b>({done}/{total})</summary>"
|
| 1616 |
+
f"<span style='font-size:0.9em;color:#666;'>来源: {q.giver_npc or '未知'}</span><br>"
|
| 1617 |
+
f"<span style='font-size:0.9em;'>{q.description}</span>"
|
| 1618 |
+
f"{obj_lines}"
|
| 1619 |
+
f"<br><span style='font-size:0.9em;color:#2f7a4a;'>奖励: {reward_str}</span>"
|
| 1620 |
+
f"</details>"
|
| 1621 |
+
)
|
| 1622 |
+
quest_blocks.append(block)
|
| 1623 |
+
quest_text = "".join(quest_blocks)
|
| 1624 |
+
else:
|
| 1625 |
+
quest_text = "无活跃任务"
|
| 1626 |
+
|
| 1627 |
+
# 使用 HTML 双列布局
|
| 1628 |
+
status = f"""<div style="font-size:0.9em;">
|
| 1629 |
+
<h3 style="margin:0 0 4px 0;text-align:center;">{p.name} — {p.title}</h3>
|
| 1630 |
+
<p style="text-align:center;margin:2px 0 6px 0;">等级 {p.level} | 经验 {p.experience}/{p.exp_to_next_level}</p>
|
| 1631 |
+
|
| 1632 |
+
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px 12px;">
|
| 1633 |
+
|
| 1634 |
+
<div>
|
| 1635 |
+
<h4 style="margin:4px 0 2px 0;">🩸 生命与状态</h4>
|
| 1636 |
+
<span style="font-size:0.85em;">
|
| 1637 |
+
{hp_bar}<br>
|
| 1638 |
+
{mp_bar}<br>
|
| 1639 |
+
{stamina_bar}<br>
|
| 1640 |
+
{hunger_bar}<br>
|
| 1641 |
+
{sanity_bar}<br>
|
| 1642 |
+
{morale_bar}
|
| 1643 |
+
</span>
|
| 1644 |
+
</div>
|
| 1645 |
+
|
| 1646 |
+
<div>
|
| 1647 |
+
<h4 style="margin:4px 0 2px 0;">🎒 背包</h4>
|
| 1648 |
+
<span style="font-size:0.85em;">
|
| 1649 |
+
{inventory_text}
|
| 1650 |
+
</span>
|
| 1651 |
+
</div>
|
| 1652 |
+
|
| 1653 |
+
<div>
|
| 1654 |
+
<h4 style="margin:4px 0 2px 0;">⚔️ 战斗属性</h4>
|
| 1655 |
+
<span style="font-size:0.85em;">
|
| 1656 |
+
{render_stat("attack", "攻击")}<br>
|
| 1657 |
+
{render_stat("defense", "防御")}<br>
|
| 1658 |
+
{render_stat("speed", "速度")}<br>
|
| 1659 |
+
{render_stat("luck", "幸运")}<br>
|
| 1660 |
+
{render_stat("perception", "感知")}
|
| 1661 |
+
</span>
|
| 1662 |
+
</div>
|
| 1663 |
+
|
| 1664 |
+
<div>
|
| 1665 |
+
<h4 style="margin:4px 0 2px 0;">🛡️ 装备</h4>
|
| 1666 |
+
<span style="font-size:0.85em;">
|
| 1667 |
+
{equip_text}
|
| 1668 |
+
</span>
|
| 1669 |
+
</div>
|
| 1670 |
+
|
| 1671 |
+
<div>
|
| 1672 |
+
<h4 style="margin:4px 0 2px 0;">💰 资源</h4>
|
| 1673 |
+
<span style="font-size:0.85em;">
|
| 1674 |
+
金币: {p.gold}<br>
|
| 1675 |
+
善恶值: {p.karma}
|
| 1676 |
+
</span>
|
| 1677 |
+
</div>
|
| 1678 |
+
|
| 1679 |
+
<div>
|
| 1680 |
+
<h4 style="margin:4px 0 2px 0;">✨ 状态效果</h4>
|
| 1681 |
+
<span style="font-size:0.85em;">
|
| 1682 |
+
{effect_lines}
|
| 1683 |
+
</span>
|
| 1684 |
+
</div>
|
| 1685 |
+
|
| 1686 |
+
<div style="grid-column: 1 / -1;">
|
| 1687 |
+
<h4 style="margin:4px 0 2px 0;">📜 任务</h4>
|
| 1688 |
+
<span style="font-size:0.9em;line-height:1.55;">
|
| 1689 |
+
{quest_text}
|
| 1690 |
+
</span>
|
| 1691 |
+
</div>
|
| 1692 |
+
|
| 1693 |
+
<div style="grid-column: 1 / -1;">
|
| 1694 |
+
<h4 style="margin:4px 0 2px 0;">🧭 当前场景信息</h4>
|
| 1695 |
+
<div style="font-size:0.85em;line-height:1.5;padding:8px 10px;border-radius:12px;background:#fff7ed;border:1px solid #fed7aa;">
|
| 1696 |
+
{env_badges}
|
| 1697 |
+
<div style="margin:6px 0 8px 0;color:#475569;">
|
| 1698 |
+
时间 {clock_display} | 场景 {w.current_scene} | 状态系数 {survival_snapshot.get('combined_multiplier', 1.0)}
|
| 1699 |
+
</div>
|
| 1700 |
+
{latest_event_html}
|
| 1701 |
+
<div style="margin-top:8px;">{scene_summary}</div>
|
| 1702 |
+
<div style="margin-top:6px;color:#475569;">最近环境事件: {recent_event_lines}</div>
|
| 1703 |
+
</div>
|
| 1704 |
+
</div>
|
| 1705 |
+
</div>
|
| 1706 |
+
</div>"""
|
| 1707 |
+
return status
|
| 1708 |
+
|
| 1709 |
+
|
| 1710 |
+
def _format_world_info_panel(gs: GameState | None) -> str:
|
| 1711 |
+
"""格式化世界信息面板(放在故事框上方)。"""
|
| 1712 |
+
if gs is None:
|
| 1713 |
+
return "🌍 **世界信息**:未开始冒险"
|
| 1714 |
+
|
| 1715 |
+
w = gs.world
|
| 1716 |
+
if hasattr(gs, "get_clock_display"):
|
| 1717 |
+
clock_display = gs.get_clock_display()
|
| 1718 |
+
else:
|
| 1719 |
+
minute_of_day = int(getattr(w, "time_progress_units", 0)) * 10 % (24 * 60)
|
| 1720 |
+
clock_display = f"{minute_of_day // 60:02d}:{minute_of_day % 60:02d}"
|
| 1721 |
+
|
| 1722 |
+
current_scene = getattr(w, "current_scene", "未知地点")
|
| 1723 |
+
day_count = getattr(w, "day_count", 1)
|
| 1724 |
+
time_of_day = getattr(w, "time_of_day", "未知时段")
|
| 1725 |
+
weather = getattr(w, "weather", "未知")
|
| 1726 |
+
light_level = getattr(w, "light_level", "未知")
|
| 1727 |
+
season = getattr(w, "season", "未知")
|
| 1728 |
+
|
| 1729 |
+
return (
|
| 1730 |
+
"🌍 **世界信息**:"
|
| 1731 |
+
f"位置 {current_scene} | "
|
| 1732 |
+
f"第{day_count}天 {time_of_day}({clock_display}) | "
|
| 1733 |
+
f"天气 {weather} | 光照 {light_level} | "
|
| 1734 |
+
f"季节 {season} | 回合 {getattr(gs, 'turn', 0)}"
|
| 1735 |
+
)
|
| 1736 |
+
|
| 1737 |
+
|
| 1738 |
+
def _progress_bar(current: int, maximum: int, label: str, length: int = 10) -> str:
|
| 1739 |
+
"""生成 HTML 进度条(数值单独一行,低于 40% 高亮红色)"""
|
| 1740 |
+
ratio = current / maximum if maximum > 0 else 0
|
| 1741 |
+
filled = int(ratio * length)
|
| 1742 |
+
empty = length - filled
|
| 1743 |
+
bar = "█" * filled + "░" * empty
|
| 1744 |
+
value_color = "#b91c1c" if ratio < 0.4 else "#0f172a"
|
| 1745 |
+
return (
|
| 1746 |
+
f"{label}: <span style='font-family:monospace;'>{bar}</span>"
|
| 1747 |
+
f"<br><span style='color:{value_color};font-weight:600;'>{current}/{maximum}</span>"
|
| 1748 |
+
)
|
| 1749 |
+
|
| 1750 |
+
|
| 1751 |
+
# ============================================================
|
| 1752 |
+
# Gradio 界面构建
|
| 1753 |
+
# ============================================================
|
| 1754 |
+
|
| 1755 |
+
|
| 1756 |
+
def build_app() -> gr.Blocks:
|
| 1757 |
+
"""构建 Gradio 界面"""
|
| 1758 |
+
|
| 1759 |
+
with gr.Blocks(
|
| 1760 |
+
title="StoryWeaver - 交互式叙事系统",
|
| 1761 |
+
) as app:
|
| 1762 |
+
app.css = APP_UI_CSS
|
| 1763 |
+
|
| 1764 |
+
gr.Markdown(
|
| 1765 |
+
"""
|
| 1766 |
+
# StoryWeaver — 交互式叙事系统
|
| 1767 |
+
*基于 AI 的动态分支剧情 RPG 体验*
|
| 1768 |
+
"""
|
| 1769 |
+
)
|
| 1770 |
+
|
| 1771 |
+
# 游戏会话状态(Gradio State)
|
| 1772 |
+
game_session = gr.State(value={})
|
| 1773 |
+
|
| 1774 |
+
with gr.Row():
|
| 1775 |
+
# ==================
|
| 1776 |
+
# 左侧:聊天区域
|
| 1777 |
+
# ==================
|
| 1778 |
+
with gr.Column(scale=10):
|
| 1779 |
+
# 玩家姓名输入 + 开始按钮
|
| 1780 |
+
with gr.Row():
|
| 1781 |
+
player_name_input = gr.Textbox(
|
| 1782 |
+
label="角色名称",
|
| 1783 |
+
placeholder="输入你的角色名称(默认: 旅人)",
|
| 1784 |
+
value="旅人",
|
| 1785 |
+
scale=3,
|
| 1786 |
+
)
|
| 1787 |
+
start_btn = gr.Button(
|
| 1788 |
+
"开始冒险",
|
| 1789 |
+
variant="primary",
|
| 1790 |
+
scale=2,
|
| 1791 |
+
)
|
| 1792 |
+
restart_btn = gr.Button(
|
| 1793 |
+
"重启冒险",
|
| 1794 |
+
variant="stop",
|
| 1795 |
+
scale=2,
|
| 1796 |
+
)
|
| 1797 |
+
|
| 1798 |
+
world_info_panel = gr.Markdown(
|
| 1799 |
+
value=_format_world_info_panel(None),
|
| 1800 |
+
)
|
| 1801 |
+
|
| 1802 |
+
# 聊天窗口
|
| 1803 |
+
chatbot = gr.Chatbot(
|
| 1804 |
+
label="故事",
|
| 1805 |
+
height=480,
|
| 1806 |
+
)
|
| 1807 |
+
|
| 1808 |
+
location_map_panel = gr.Markdown(
|
| 1809 |
+
elem_classes=["scene-card", "status-panel"],
|
| 1810 |
+
value="地图关系图\n(未开始)",
|
| 1811 |
+
label="地图",
|
| 1812 |
+
)
|
| 1813 |
+
|
| 1814 |
+
# 选项按钮(最多 6 个,分两行显示)
|
| 1815 |
+
with gr.Column():
|
| 1816 |
+
with gr.Row():
|
| 1817 |
+
option_btn_1 = gr.Button(
|
| 1818 |
+
"...",
|
| 1819 |
+
visible=True,
|
| 1820 |
+
interactive=False,
|
| 1821 |
+
elem_classes=["option-btn"],
|
| 1822 |
+
)
|
| 1823 |
+
option_btn_2 = gr.Button(
|
| 1824 |
+
"...",
|
| 1825 |
+
visible=True,
|
| 1826 |
+
interactive=False,
|
| 1827 |
+
elem_classes=["option-btn"],
|
| 1828 |
+
)
|
| 1829 |
+
option_btn_3 = gr.Button(
|
| 1830 |
+
"...",
|
| 1831 |
+
visible=True,
|
| 1832 |
+
interactive=False,
|
| 1833 |
+
elem_classes=["option-btn"],
|
| 1834 |
+
)
|
| 1835 |
+
with gr.Row():
|
| 1836 |
+
option_btn_4 = gr.Button(
|
| 1837 |
+
"...",
|
| 1838 |
+
visible=False,
|
| 1839 |
+
interactive=False,
|
| 1840 |
+
elem_classes=["option-btn"],
|
| 1841 |
+
)
|
| 1842 |
+
option_btn_5 = gr.Button(
|
| 1843 |
+
"...",
|
| 1844 |
+
visible=False,
|
| 1845 |
+
interactive=False,
|
| 1846 |
+
elem_classes=["option-btn"],
|
| 1847 |
+
)
|
| 1848 |
+
option_btn_6 = gr.Button(
|
| 1849 |
+
"...",
|
| 1850 |
+
visible=False,
|
| 1851 |
+
interactive=False,
|
| 1852 |
+
elem_classes=["option-btn"],
|
| 1853 |
+
)
|
| 1854 |
+
option_buttons = [
|
| 1855 |
+
option_btn_1,
|
| 1856 |
+
option_btn_2,
|
| 1857 |
+
option_btn_3,
|
| 1858 |
+
option_btn_4,
|
| 1859 |
+
option_btn_5,
|
| 1860 |
+
option_btn_6,
|
| 1861 |
+
]
|
| 1862 |
+
|
| 1863 |
+
# 自由输入
|
| 1864 |
+
with gr.Row():
|
| 1865 |
+
user_input = gr.Textbox(
|
| 1866 |
+
label="自由输入(也可以直接点击上方选项)",
|
| 1867 |
+
placeholder="输入你想做的事情,例如:和村长说话、攻击哥布林、搜索这个区域...",
|
| 1868 |
+
scale=5,
|
| 1869 |
+
interactive=False,
|
| 1870 |
+
)
|
| 1871 |
+
with gr.Column(scale=1):
|
| 1872 |
+
send_btn = gr.Button("发送", variant="primary", elem_classes=["side-action-btn"])
|
| 1873 |
+
open_backpack_btn = gr.Button(
|
| 1874 |
+
"打开背包",
|
| 1875 |
+
variant="secondary",
|
| 1876 |
+
elem_classes=["side-action-btn", "backpack-btn"],
|
| 1877 |
+
)
|
| 1878 |
+
|
| 1879 |
+
# ==================
|
| 1880 |
+
# 右侧:状态面板
|
| 1881 |
+
# ==================
|
| 1882 |
+
with gr.Column(scale=2, min_width=320, elem_classes=["scene-sidebar"]):
|
| 1883 |
+
scene_image = gr.Image(
|
| 1884 |
+
value=None,
|
| 1885 |
+
type="filepath",
|
| 1886 |
+
label="场景画面",
|
| 1887 |
+
show_label=False,
|
| 1888 |
+
container=False,
|
| 1889 |
+
interactive=False,
|
| 1890 |
+
height=260,
|
| 1891 |
+
buttons=[],
|
| 1892 |
+
visible=False,
|
| 1893 |
+
elem_classes=["scene-card", "scene-image"],
|
| 1894 |
+
)
|
| 1895 |
+
status_panel = gr.Markdown(
|
| 1896 |
+
elem_classes=["scene-card", "status-panel"],
|
| 1897 |
+
value="## 等待开始...\n\n请输入角色名称并点击「开始冒险」",
|
| 1898 |
+
label="角色状态",
|
| 1899 |
+
)
|
| 1900 |
+
|
| 1901 |
+
# ============================================================
|
| 1902 |
+
# 事件绑定
|
| 1903 |
+
# ============================================================
|
| 1904 |
+
|
| 1905 |
+
# 开始游戏
|
| 1906 |
+
start_btn.click(
|
| 1907 |
+
fn=start_game,
|
| 1908 |
+
inputs=[player_name_input, game_session],
|
| 1909 |
+
outputs=[
|
| 1910 |
+
chatbot, world_info_panel, status_panel, location_map_panel, scene_image,
|
| 1911 |
+
*option_buttons,
|
| 1912 |
+
game_session, user_input,
|
| 1913 |
+
],
|
| 1914 |
+
)
|
| 1915 |
+
|
| 1916 |
+
# 重启冒险
|
| 1917 |
+
restart_btn.click(
|
| 1918 |
+
fn=restart_game,
|
| 1919 |
+
inputs=[],
|
| 1920 |
+
outputs=[
|
| 1921 |
+
chatbot, world_info_panel, status_panel, location_map_panel, scene_image,
|
| 1922 |
+
*option_buttons,
|
| 1923 |
+
game_session, user_input, player_name_input,
|
| 1924 |
+
],
|
| 1925 |
+
)
|
| 1926 |
+
|
| 1927 |
+
# 文本输入发送
|
| 1928 |
+
send_btn.click(
|
| 1929 |
+
fn=process_user_input,
|
| 1930 |
+
inputs=[user_input, chatbot, game_session],
|
| 1931 |
+
outputs=[
|
| 1932 |
+
chatbot, world_info_panel, status_panel, location_map_panel, scene_image,
|
| 1933 |
+
*option_buttons,
|
| 1934 |
+
game_session,
|
| 1935 |
+
],
|
| 1936 |
+
).then(
|
| 1937 |
+
fn=lambda: "",
|
| 1938 |
+
outputs=[user_input],
|
| 1939 |
+
)
|
| 1940 |
+
|
| 1941 |
+
# 打开背包(常驻按钮)
|
| 1942 |
+
open_backpack_btn.click(
|
| 1943 |
+
fn=open_backpack,
|
| 1944 |
+
inputs=[chatbot, game_session],
|
| 1945 |
+
outputs=[
|
| 1946 |
+
chatbot, world_info_panel, status_panel, location_map_panel, scene_image,
|
| 1947 |
+
*option_buttons,
|
| 1948 |
+
game_session,
|
| 1949 |
+
],
|
| 1950 |
+
)
|
| 1951 |
+
|
| 1952 |
+
# 回车发送
|
| 1953 |
+
user_input.submit(
|
| 1954 |
+
fn=process_user_input,
|
| 1955 |
+
inputs=[user_input, chatbot, game_session],
|
| 1956 |
+
outputs=[
|
| 1957 |
+
chatbot, world_info_panel, status_panel, location_map_panel, scene_image,
|
| 1958 |
+
*option_buttons,
|
| 1959 |
+
game_session,
|
| 1960 |
+
],
|
| 1961 |
+
).then(
|
| 1962 |
+
fn=lambda: "",
|
| 1963 |
+
outputs=[user_input],
|
| 1964 |
+
)
|
| 1965 |
+
|
| 1966 |
+
# 选项按钮点击(需要使用 yield from 的生成器包装函数,
|
| 1967 |
+
# 使 Gradio 能正确识别为流式输出)
|
| 1968 |
+
def _make_option_click_handler(index: int):
|
| 1969 |
+
def _handler(ch, gs):
|
| 1970 |
+
yield from process_option_click(index, ch, gs)
|
| 1971 |
+
|
| 1972 |
+
return _handler
|
| 1973 |
+
|
| 1974 |
+
for index, option_button in enumerate(option_buttons):
|
| 1975 |
+
option_button.click(
|
| 1976 |
+
fn=_make_option_click_handler(index),
|
| 1977 |
+
inputs=[chatbot, game_session],
|
| 1978 |
+
outputs=[
|
| 1979 |
+
chatbot, world_info_panel, status_panel, location_map_panel, scene_image,
|
| 1980 |
+
*option_buttons,
|
| 1981 |
+
game_session,
|
| 1982 |
+
],
|
| 1983 |
+
)
|
| 1984 |
+
|
| 1985 |
+
return app
|
| 1986 |
+
|
| 1987 |
+
|
| 1988 |
+
# ============================================================
|
| 1989 |
+
# 启动入口
|
| 1990 |
+
# ============================================================
|
| 1991 |
+
|
| 1992 |
+
if __name__ == "__main__":
|
| 1993 |
+
logger.info("启动 StoryWeaver 交互式叙事系统...")
|
| 1994 |
+
app = build_app()
|
| 1995 |
+
app.launch(
|
| 1996 |
+
server_name="0.0.0.0",
|
| 1997 |
+
server_port=7860,
|
| 1998 |
+
share=False,
|
| 1999 |
+
show_error=True,
|
| 2000 |
+
theme=gr.themes.Soft(
|
| 2001 |
+
primary_hue="emerald",
|
| 2002 |
+
secondary_hue="blue",
|
| 2003 |
+
),
|
| 2004 |
+
css=APP_UI_CSS,
|
| 2005 |
+
)
|
combat_engine.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import random
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
MONSTER_DB: dict[str, dict[str, int]] = {
|
| 8 |
+
"哥布林": {"hp": 20, "attack": 5, "defense": 2, "difficulty": 1},
|
| 9 |
+
"森林狼": {"hp": 40, "attack": 15, "defense": 5, "difficulty": 2},
|
| 10 |
+
"远古巨龙": {"hp": 500, "attack": 100, "defense": 80, "difficulty": 10},
|
| 11 |
+
"default": {"hp": 30, "attack": 10, "defense": 5, "difficulty": 1},
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _difficulty_scale(game_state: Any | None) -> float:
|
| 16 |
+
if game_state is None:
|
| 17 |
+
return 1.0
|
| 18 |
+
diff_name = str(getattr(game_state, "difficulty", "normal")).lower()
|
| 19 |
+
return {"easy": 0.9, "normal": 1.0, "hard": 1.2}.get(diff_name, 1.0)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _current_location_danger(game_state: Any | None) -> int:
|
| 23 |
+
if game_state is None:
|
| 24 |
+
return 1
|
| 25 |
+
player = getattr(game_state, "player", None)
|
| 26 |
+
world = getattr(game_state, "world", None)
|
| 27 |
+
if player is None or world is None:
|
| 28 |
+
return 1
|
| 29 |
+
location_name = str(getattr(player, "location", ""))
|
| 30 |
+
location = getattr(world, "locations", {}).get(location_name)
|
| 31 |
+
if location is None:
|
| 32 |
+
return 1
|
| 33 |
+
try:
|
| 34 |
+
return max(1, int(getattr(location, "danger_level", 1)))
|
| 35 |
+
except Exception:
|
| 36 |
+
return 1
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def get_monster_profile(monster_name: str, game_state: Any | None = None) -> dict[str, int]:
|
| 40 |
+
normalized_name = str(monster_name or "").strip()
|
| 41 |
+
profile = MONSTER_DB.get(normalized_name)
|
| 42 |
+
if profile is not None:
|
| 43 |
+
return dict(profile)
|
| 44 |
+
|
| 45 |
+
base = dict(MONSTER_DB["default"])
|
| 46 |
+
location_danger = _current_location_danger(game_state)
|
| 47 |
+
diff_scale = _difficulty_scale(game_state)
|
| 48 |
+
|
| 49 |
+
generated_difficulty = max(1, int(round(base["difficulty"] + (location_danger - 1) * 0.6)))
|
| 50 |
+
generated_attack = max(1, int(round(base["attack"] * diff_scale + location_danger * 2)))
|
| 51 |
+
generated_defense = max(1, int(round(base["defense"] * diff_scale + location_danger)))
|
| 52 |
+
generated_hp = max(1, int(round(base["hp"] * diff_scale + location_danger * 10)))
|
| 53 |
+
|
| 54 |
+
return {
|
| 55 |
+
"hp": generated_hp,
|
| 56 |
+
"attack": generated_attack,
|
| 57 |
+
"defense": generated_defense,
|
| 58 |
+
"difficulty": generated_difficulty,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def resolve_combat(
|
| 63 |
+
player_state: Any,
|
| 64 |
+
monster_name: str,
|
| 65 |
+
*,
|
| 66 |
+
game_state: Any | None = None,
|
| 67 |
+
rng: random.Random | None = None,
|
| 68 |
+
) -> dict[str, Any]:
|
| 69 |
+
active_rng = rng or random
|
| 70 |
+
monster = get_monster_profile(monster_name, game_state=game_state)
|
| 71 |
+
|
| 72 |
+
player_level = max(1, int(getattr(player_state, "level", 1)))
|
| 73 |
+
player_attack = max(1, int(getattr(player_state, "attack_power", getattr(player_state, "attack", 1))))
|
| 74 |
+
player_defense = max(0, int(getattr(player_state, "defense_power", getattr(player_state, "defense", 0))))
|
| 75 |
+
|
| 76 |
+
player_power = player_attack + player_level * 2
|
| 77 |
+
monster_power = int(monster["defense"]) + int(monster["difficulty"]) * 3
|
| 78 |
+
outcome = "win" if player_power >= monster_power else "lose"
|
| 79 |
+
|
| 80 |
+
random_float = float(active_rng.uniform(0.0, 3.0))
|
| 81 |
+
base_hp_loss = max(1, int(round(int(monster["attack"]) - player_defense - random_float)))
|
| 82 |
+
if outcome == "lose":
|
| 83 |
+
base_hp_loss = max(base_hp_loss + int(monster["difficulty"]) * 2, int(round(base_hp_loss * 1.4)))
|
| 84 |
+
|
| 85 |
+
if outcome == "win":
|
| 86 |
+
message = f"你击败了{monster_name},但仍受了些伤。"
|
| 87 |
+
else:
|
| 88 |
+
message = f"你不敌{monster_name},被迫败退。"
|
| 89 |
+
|
| 90 |
+
return {
|
| 91 |
+
"outcome": outcome,
|
| 92 |
+
"player_hp_loss": int(base_hp_loss),
|
| 93 |
+
"monster_name": str(monster_name),
|
| 94 |
+
"message": message,
|
| 95 |
+
"player_power": int(player_power),
|
| 96 |
+
"monster_power": int(monster_power),
|
| 97 |
+
}
|
demo_rules.py
ADDED
|
@@ -0,0 +1,1410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections import deque
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
ACTION_TIME_COSTS: dict[str, int] = {
|
| 9 |
+
"MOVE": 30,
|
| 10 |
+
"ATTACK": 30,
|
| 11 |
+
"COMBAT": 30,
|
| 12 |
+
"CLAIM_REWARD": 10,
|
| 13 |
+
"REST": 30,
|
| 14 |
+
"OVERNIGHT_REST": 30,
|
| 15 |
+
"TALK": 10,
|
| 16 |
+
"SHOP_MENU": 10,
|
| 17 |
+
"SCENE_OPTIONS": 10,
|
| 18 |
+
"TRADE": 10,
|
| 19 |
+
"EQUIP": 10,
|
| 20 |
+
"USE_ITEM": 10,
|
| 21 |
+
"VIEW_MAP": 10,
|
| 22 |
+
"MAP": 10,
|
| 23 |
+
"QUEST": 10,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
MAX_OPTION_COUNT = 6
|
| 27 |
+
DEFAULT_OPTION_COUNT = 3
|
| 28 |
+
OVERNIGHT_REST_LOCATIONS = {"村庄旅店", "溪边营地"}
|
| 29 |
+
MAIN_QUEST_ID = "main_quest_01"
|
| 30 |
+
MAIN_QUEST_TROLL_ID = "main_quest_02"
|
| 31 |
+
FOREST_GOBLIN_DEFEATED_FLAG = "encounter::dark_forest_gate_goblin_defeated"
|
| 32 |
+
FOREST_TROLL_TRACKS_FOUND_FLAG = "clue::forest_troll_tracks_found"
|
| 33 |
+
FOREST_TROLL_DEFEATED_FLAG = "encounter::forest_troll_defeated"
|
| 34 |
+
FOREST_TROLL_INTRO_SEEN_FLAG = "scene::forest_troll_intro_seen"
|
| 35 |
+
FOREST_TROLL_HOARD_PENDING_FLAG = "reward::forest_troll_hoard_pending"
|
| 36 |
+
FOREST_TROLL_HOARD_CLAIMED_FLAG = "reward::forest_troll_hoard_claimed"
|
| 37 |
+
DEEP_FOREST_BARRIER_SEEN_FLAG = "clue::deep_forest_barrier_seen"
|
| 38 |
+
FOREST_CAUSE_OBJECTIVE = "调查怪物活动的原因"
|
| 39 |
+
REPORT_TO_CHIEF_OBJECTIVE = "与村长老伯对话汇报发现"
|
| 40 |
+
FOREST_TROLL_TRAVEL_OBJECTIVE = "前往森林深处"
|
| 41 |
+
FOREST_TROLL_BOSS_OBJECTIVE = "击败森林巨魔"
|
| 42 |
+
SIDE_QUEST_TRAVELER_ID = "side_quest_01"
|
| 43 |
+
SIDE_QUEST_FERRY_ID = "side_quest_02"
|
| 44 |
+
SIDE_QUEST_GUARDIAN_ID = "side_quest_03"
|
| 45 |
+
TRAVELER_RUMOR_HEARD_FLAG = "rumor::traveler_lead_heard"
|
| 46 |
+
MINE_RUMOR_HEARD_FLAG = "rumor::mine_ghost_heard"
|
| 47 |
+
FERRY_ROUTE_UNLOCKED_FLAG = "rumor::ferry_route_unlocked"
|
| 48 |
+
TRAVELER_ENCOUNTERED_FLAG = "scene::mysterious_traveler_encountered"
|
| 49 |
+
GUARDIAN_INTRO_SEEN_FLAG = "scene::elf_guardian_introduced"
|
| 50 |
+
|
| 51 |
+
LOCATION_MAP_REQUIREMENTS: dict[str, str] = {
|
| 52 |
+
"村庄广场": "村庄地图",
|
| 53 |
+
"村庄铁匠铺": "村庄地图",
|
| 54 |
+
"村庄旅店": "村庄地图",
|
| 55 |
+
"村庄杂货铺": "村庄地图",
|
| 56 |
+
"村口小路": "村庄地图",
|
| 57 |
+
# 黑暗森林入口 可以用村庄地图到达;击败哥布林后获得黑暗森林地图
|
| 58 |
+
"黑暗森林入口": "村庄地图",
|
| 59 |
+
# 溪边营地/森林深处 需要黑暗森林地图(森林入口战斗奖励)
|
| 60 |
+
"溪边营地": "黑暗森林地图",
|
| 61 |
+
"森林深处": "黑暗森林地图",
|
| 62 |
+
# 河边渡口 用村庄地图可到达;与老渔夫对话后获得山麓地图
|
| 63 |
+
"河边渡口": "村庄地图",
|
| 64 |
+
# 以下地点需要山麓地图(老渔夫给予)
|
| 65 |
+
"废弃矿洞入口": "山麓地图",
|
| 66 |
+
"山麓盗贼营": "山麓地图",
|
| 67 |
+
"精灵遗迹": "山麓地图",
|
| 68 |
+
# 古塔废墟 从村口小路可直接发现,村庄地图即可
|
| 69 |
+
"古塔废墟": "村庄地图",
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
SHOP_LOCATION_TO_MERCHANT: dict[str, str] = {
|
| 73 |
+
"村庄铁匠铺": "铁匠格林",
|
| 74 |
+
"村庄旅店": "旅店老板娘莉娜",
|
| 75 |
+
"村庄杂货铺": "杂货商人阿尔",
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
ARRIVAL_EVENT_CONFIG: dict[str, dict[str, str]] = {
|
| 79 |
+
"村庄铁匠铺": {
|
| 80 |
+
"event_key": "arrival::village_blacksmith",
|
| 81 |
+
"story_text": (
|
| 82 |
+
"一间热气腾腾的铁匠铺,炉火正旺。墙上挂满了各式武器和护甲。\n"
|
| 83 |
+
"炉火映红了铁匠粗犷的脸庞,空气中弥漫着金属和碳的气味。"
|
| 84 |
+
),
|
| 85 |
+
},
|
| 86 |
+
"村庄旅店": {
|
| 87 |
+
"event_key": "arrival::village_inn",
|
| 88 |
+
"story_text": (
|
| 89 |
+
"一家温馨的小旅店,空气中弥漫着烤肉和麦酒的香气。壁炉里的火焰跳动着。\n"
|
| 90 |
+
"壁炉噼啪作响,几位旅客正低声交谈,空气温暖而舒适。"
|
| 91 |
+
),
|
| 92 |
+
},
|
| 93 |
+
"村庄杂货铺": {
|
| 94 |
+
"event_key": "arrival::village_general_store",
|
| 95 |
+
"story_text": (
|
| 96 |
+
"一家琳琅满目的杂货铺,从草药到绳索应有尽有。老板是个精明的商人。\n"
|
| 97 |
+
"货架上摆满了稀奇古怪的商品,柜台后的商人正用算盘噼里啪啦地算账。"
|
| 98 |
+
),
|
| 99 |
+
},
|
| 100 |
+
"黑暗森林入口": {
|
| 101 |
+
"event_key": "arrival::dark_forest_gate",
|
| 102 |
+
"story_text": (
|
| 103 |
+
"黑暗森林入口的树冠压低了天色,落叶间散着新鲜爪痕和被拖行过的泥印。\n"
|
| 104 |
+
"一只拎着弯刀的哥布林正伏在断木后张望,时不时发出刺耳怪叫,像是在替林中的东西守门。"
|
| 105 |
+
),
|
| 106 |
+
},
|
| 107 |
+
"河边渡口": {
|
| 108 |
+
"event_key": "arrival::river_ferry",
|
| 109 |
+
"story_text": (
|
| 110 |
+
"破旧的渡口被河水拍得吱呀作响,湿冷水汽贴着木桩往上爬。\n"
|
| 111 |
+
"披着蓑衣的老渔夫正盯着对岸,矿洞与山麓营地的路径都从这里分开。"
|
| 112 |
+
),
|
| 113 |
+
},
|
| 114 |
+
"山麓盗贼营": {
|
| 115 |
+
"event_key": "arrival::bandit_camp",
|
| 116 |
+
"story_text": (
|
| 117 |
+
"山麓盗贼营的篝火还留着余温,翻倒的酒桶和散落的干粮说明有人刚撤离不久。\n"
|
| 118 |
+
"营帐阴影里有个盗贼斥候正贴��木桩窥探四周,显然不打算让外来者轻易通过。"
|
| 119 |
+
),
|
| 120 |
+
},
|
| 121 |
+
"古塔废墟": {
|
| 122 |
+
"event_key": "arrival::ancient_tower",
|
| 123 |
+
"story_text": (
|
| 124 |
+
"半坍塌的古塔在风里发出低鸣,残破石阶和裂墙间还能看到新鲜抓痕。\n"
|
| 125 |
+
"塔门内飘着一团幽蓝冷火,游荡幽灵在尘灰间若隐若现,像是在警告你别再向前。"
|
| 126 |
+
),
|
| 127 |
+
},
|
| 128 |
+
"废弃矿洞入口": {
|
| 129 |
+
"event_key": "arrival::mine_entrance",
|
| 130 |
+
"story_text": (
|
| 131 |
+
"矿洞入口被枯枝和碎石半堵着,腐朽的矿车轨道向黑暗深处延伸,铁锈和硫磺气味扑面而来。\n"
|
| 132 |
+
"一具骷髅兵从废弃矿车后方立起,锈迹斑斑的武器指向你——矿洞里的东西不欢迎活人。"
|
| 133 |
+
),
|
| 134 |
+
},
|
| 135 |
+
"溪边营地": {
|
| 136 |
+
"event_key": "arrival::creek_camp",
|
| 137 |
+
"story_text": (
|
| 138 |
+
"森林中一处难得的开阔地带,清澈的溪水从旁流过,树冠间的光在水面打出零碎金片。\n"
|
| 139 |
+
"篝火余烬和被压平的草丛说明有人不久前在此扎营——这里适合短暂休息和搜寻遗留物资。"
|
| 140 |
+
),
|
| 141 |
+
},
|
| 142 |
+
"精灵遗迹": {
|
| 143 |
+
"event_key": "arrival::elf_ruins",
|
| 144 |
+
"story_text": (
|
| 145 |
+
"石柱在藤蔓间若隐若现,精灵文字的刻痕随你的步伐明灭,像是感应到了来者的意图。\n"
|
| 146 |
+
"一个穿着褪色绿袍的消瘦身影从石柱阴影里转出,用警惕的眼神审视着你——遗迹有它的守护者。"
|
| 147 |
+
),
|
| 148 |
+
},
|
| 149 |
+
"森林深处": {
|
| 150 |
+
"event_key": "arrival::deep_forest",
|
| 151 |
+
"story_text": (
|
| 152 |
+
"古树盘根错节,荧光苔藓将深处映成幽蓝,腐朽与魔力的气息混杂难辨。\n"
|
| 153 |
+
"远处传来低沉咆哮,树影间有成双眼睛移动——有什么东西已经感知到了你的闯入。"
|
| 154 |
+
),
|
| 155 |
+
},
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
@dataclass(slots=True)
|
| 160 |
+
class BattleSnapshot:
|
| 161 |
+
hp: int
|
| 162 |
+
attack: int
|
| 163 |
+
defense: int
|
| 164 |
+
stamina: int
|
| 165 |
+
hit_rate: float
|
| 166 |
+
dodge_rate: float
|
| 167 |
+
live_state_multiplier: float = 1.0
|
| 168 |
+
|
| 169 |
+
@property
|
| 170 |
+
def power(self) -> float:
|
| 171 |
+
return (
|
| 172 |
+
(self.attack * 0.6 + self.defense * 0.3 + self.stamina * 0.1)
|
| 173 |
+
* self.live_state_multiplier
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
BATTLE_ENCOUNTER_CONFIG: dict[tuple[str, str], dict[str, Any]] = {
|
| 178 |
+
("黑暗森林入口", "哥布林"): {
|
| 179 |
+
"enemy_snapshot": BattleSnapshot(
|
| 180 |
+
hp=45,
|
| 181 |
+
attack=8,
|
| 182 |
+
defense=3,
|
| 183 |
+
stamina=28,
|
| 184 |
+
hit_rate=0.82,
|
| 185 |
+
dodge_rate=0.08,
|
| 186 |
+
live_state_multiplier=1.0,
|
| 187 |
+
),
|
| 188 |
+
"defeated_flag": "encounter::dark_forest_gate_goblin_defeated",
|
| 189 |
+
"reward_items": ["黑暗森林地图"],
|
| 190 |
+
"quest_objectives": ["击败森林中的怪物"],
|
| 191 |
+
},
|
| 192 |
+
("山麓盗贼营", "盗贼斥候"): {
|
| 193 |
+
"enemy_snapshot": BattleSnapshot(
|
| 194 |
+
hp=52,
|
| 195 |
+
attack=9,
|
| 196 |
+
defense=4,
|
| 197 |
+
stamina=30,
|
| 198 |
+
hit_rate=0.84,
|
| 199 |
+
dodge_rate=0.12,
|
| 200 |
+
live_state_multiplier=1.0,
|
| 201 |
+
),
|
| 202 |
+
"defeated_flag": "encounter::bandit_scout_defeated",
|
| 203 |
+
"reward_items": ["山麓地图"],
|
| 204 |
+
"quest_objectives": [],
|
| 205 |
+
},
|
| 206 |
+
("古塔废墟", "游荡幽灵"): {
|
| 207 |
+
"enemy_snapshot": BattleSnapshot(
|
| 208 |
+
hp=58,
|
| 209 |
+
attack=10,
|
| 210 |
+
defense=5,
|
| 211 |
+
stamina=32,
|
| 212 |
+
hit_rate=0.86,
|
| 213 |
+
dodge_rate=0.14,
|
| 214 |
+
live_state_multiplier=1.0,
|
| 215 |
+
),
|
| 216 |
+
"defeated_flag": "encounter::ancient_tower_wraith_defeated",
|
| 217 |
+
"reward_items": ["古塔地图"],
|
| 218 |
+
"quest_objectives": [],
|
| 219 |
+
},
|
| 220 |
+
("废弃矿洞入口", "骷髅兵"): {
|
| 221 |
+
"enemy_snapshot": BattleSnapshot(
|
| 222 |
+
hp=38,
|
| 223 |
+
attack=7,
|
| 224 |
+
defense=4,
|
| 225 |
+
stamina=22,
|
| 226 |
+
hit_rate=0.75,
|
| 227 |
+
dodge_rate=0.05,
|
| 228 |
+
live_state_multiplier=1.0,
|
| 229 |
+
),
|
| 230 |
+
"defeated_flag": "encounter::mine_skeleton_defeated",
|
| 231 |
+
"reward_items": ["骷髅碎骨"],
|
| 232 |
+
"quest_objectives": ["前往废弃矿洞调查"],
|
| 233 |
+
},
|
| 234 |
+
("黑暗森林入口", "野狼"): {
|
| 235 |
+
"enemy_snapshot": BattleSnapshot(
|
| 236 |
+
hp=58,
|
| 237 |
+
attack=11,
|
| 238 |
+
defense=5,
|
| 239 |
+
stamina=36,
|
| 240 |
+
hit_rate=0.82,
|
| 241 |
+
dodge_rate=0.14,
|
| 242 |
+
live_state_multiplier=1.0,
|
| 243 |
+
),
|
| 244 |
+
"defeated_flag": "",
|
| 245 |
+
"reward_items": [],
|
| 246 |
+
"quest_objectives": [],
|
| 247 |
+
},
|
| 248 |
+
("森林深处", "森林巨魔"): {
|
| 249 |
+
"enemy_snapshot": BattleSnapshot(
|
| 250 |
+
hp=120,
|
| 251 |
+
attack=15,
|
| 252 |
+
defense=10,
|
| 253 |
+
stamina=130,
|
| 254 |
+
hit_rate=0.88,
|
| 255 |
+
dodge_rate=0.1,
|
| 256 |
+
live_state_multiplier=1.0,
|
| 257 |
+
),
|
| 258 |
+
"defeated_flag": FOREST_TROLL_DEFEATED_FLAG,
|
| 259 |
+
"reward_items": [],
|
| 260 |
+
"quest_id": MAIN_QUEST_TROLL_ID,
|
| 261 |
+
"quest_objectives": [FOREST_TROLL_BOSS_OBJECTIVE],
|
| 262 |
+
},
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
def action_time_cost_minutes(action_type: str) -> int:
|
| 267 |
+
return ACTION_TIME_COSTS.get(str(action_type or "").upper(), 10)
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
def resolve_battle(
|
| 271 |
+
player: BattleSnapshot,
|
| 272 |
+
enemy: BattleSnapshot,
|
| 273 |
+
*,
|
| 274 |
+
player_unarmed: bool = False,
|
| 275 |
+
) -> dict[str, Any]:
|
| 276 |
+
enemy_power = max(enemy.power, 1.0)
|
| 277 |
+
player_power = player.power
|
| 278 |
+
|
| 279 |
+
if player_unarmed and player.attack <= enemy.defense:
|
| 280 |
+
player_power *= 0.5
|
| 281 |
+
|
| 282 |
+
ratio = player_power / enemy_power
|
| 283 |
+
if ratio < 0.6:
|
| 284 |
+
outcome = "forced_retreat"
|
| 285 |
+
elif ratio < 1.0:
|
| 286 |
+
outcome = "pyrrhic_win"
|
| 287 |
+
elif ratio < 1.5:
|
| 288 |
+
outcome = "normal_win"
|
| 289 |
+
else:
|
| 290 |
+
outcome = "dominant_win"
|
| 291 |
+
|
| 292 |
+
return {
|
| 293 |
+
"player_power": round(player_power, 2),
|
| 294 |
+
"enemy_power": round(enemy_power, 2),
|
| 295 |
+
"ratio": round(ratio, 3),
|
| 296 |
+
"outcome": outcome,
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def resolve_trade(
|
| 301 |
+
game_state,
|
| 302 |
+
*,
|
| 303 |
+
merchant_name: str,
|
| 304 |
+
item_name: str,
|
| 305 |
+
confirm: bool,
|
| 306 |
+
) -> dict[str, Any]:
|
| 307 |
+
npc = game_state.world.npcs.get(merchant_name)
|
| 308 |
+
if npc is None or not npc.can_trade:
|
| 309 |
+
return {"applied": False, "reason": "invalid_merchant"}
|
| 310 |
+
if npc.location != game_state.player.location:
|
| 311 |
+
return {"applied": False, "reason": "merchant_not_here"}
|
| 312 |
+
if item_name not in npc.shop_inventory:
|
| 313 |
+
return {"applied": False, "reason": "item_not_sold_here"}
|
| 314 |
+
|
| 315 |
+
item_info = game_state.world.item_registry.get(item_name)
|
| 316 |
+
if item_info is None:
|
| 317 |
+
return {"applied": False, "reason": "unknown_item"}
|
| 318 |
+
|
| 319 |
+
if not confirm:
|
| 320 |
+
return {
|
| 321 |
+
"applied": False,
|
| 322 |
+
"reason": "awaiting_confirmation",
|
| 323 |
+
"price": item_info.value,
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
if game_state.player.gold < item_info.value:
|
| 327 |
+
return {"applied": False, "reason": "insufficient_gold", "price": item_info.value}
|
| 328 |
+
|
| 329 |
+
game_state.player.gold -= item_info.value
|
| 330 |
+
game_state.player.inventory.append(item_name)
|
| 331 |
+
game_state.last_recent_gain = item_name
|
| 332 |
+
follow_up_actions = build_contextual_actions(game_state, recent_gain=item_name)
|
| 333 |
+
return {
|
| 334 |
+
"applied": True,
|
| 335 |
+
"reason": "purchased",
|
| 336 |
+
"price": item_info.value,
|
| 337 |
+
"item_name": item_name,
|
| 338 |
+
"follow_up_actions": follow_up_actions,
|
| 339 |
+
}
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def get_battle_encounter(location_name: str, enemy_name: str) -> dict[str, Any] | None:
|
| 343 |
+
return BATTLE_ENCOUNTER_CONFIG.get((str(location_name), str(enemy_name)))
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def _has_status(game_state, keyword: str) -> bool:
|
| 347 |
+
keyword = str(keyword or "")
|
| 348 |
+
return any(keyword in effect.name for effect in game_state.player.status_effects)
|
| 349 |
+
|
| 350 |
+
|
| 351 |
+
def _has_map(game_state) -> bool:
|
| 352 |
+
owned_items = set(game_state.player.inventory) | {
|
| 353 |
+
str(item)
|
| 354 |
+
for item in game_state.player.equipment.values()
|
| 355 |
+
if item
|
| 356 |
+
}
|
| 357 |
+
return any("地图" in item for item in owned_items)
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
def _has_named_map(game_state, map_name: str) -> bool:
|
| 361 |
+
owned_items = set(game_state.player.inventory) | {
|
| 362 |
+
str(item)
|
| 363 |
+
for item in game_state.player.equipment.values()
|
| 364 |
+
if item
|
| 365 |
+
}
|
| 366 |
+
return str(map_name) in owned_items
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def _is_accessible_destination(game_state, destination: str) -> bool:
|
| 370 |
+
target_location = game_state.world.locations.get(destination)
|
| 371 |
+
if target_location is None:
|
| 372 |
+
return False
|
| 373 |
+
if target_location.is_accessible:
|
| 374 |
+
return True
|
| 375 |
+
required_item = str(target_location.required_item or "")
|
| 376 |
+
owned_items = set(game_state.player.inventory) | {
|
| 377 |
+
str(item)
|
| 378 |
+
for item in game_state.player.equipment.values()
|
| 379 |
+
if item
|
| 380 |
+
}
|
| 381 |
+
return bool(required_item) and required_item in owned_items
|
| 382 |
+
|
| 383 |
+
|
| 384 |
+
def _is_route_visible(game_state, destination: str) -> bool:
|
| 385 |
+
if destination == "河边渡口":
|
| 386 |
+
return bool(game_state.world.global_flags.get(FERRY_ROUTE_UNLOCKED_FLAG))
|
| 387 |
+
return True
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def _is_forest_troll_hunt_active(game_state) -> bool:
|
| 391 |
+
quest = game_state.world.quests.get(MAIN_QUEST_TROLL_ID)
|
| 392 |
+
if quest is None or quest.status != "active":
|
| 393 |
+
return False
|
| 394 |
+
return not bool(game_state.world.global_flags.get(FOREST_TROLL_DEFEATED_FLAG))
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def _is_main_quest_report_pending(game_state) -> bool:
|
| 398 |
+
quest = game_state.world.quests.get(MAIN_QUEST_ID)
|
| 399 |
+
if quest is None or quest.status != "active":
|
| 400 |
+
return False
|
| 401 |
+
if not game_state.world.global_flags.get(FOREST_TROLL_TRACKS_FOUND_FLAG):
|
| 402 |
+
return False
|
| 403 |
+
return not bool(quest.objectives.get(REPORT_TO_CHIEF_OBJECTIVE))
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def _find_encounter_location(enemy_name: str) -> str | None:
|
| 407 |
+
enemy_name = str(enemy_name or "")
|
| 408 |
+
if not enemy_name:
|
| 409 |
+
return None
|
| 410 |
+
for (location_name, encounter_enemy), _config in BATTLE_ENCOUNTER_CONFIG.items():
|
| 411 |
+
if str(encounter_enemy) == enemy_name:
|
| 412 |
+
return str(location_name)
|
| 413 |
+
return None
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def _move_action(
|
| 417 |
+
target: str,
|
| 418 |
+
*,
|
| 419 |
+
priority: int,
|
| 420 |
+
text: str | None = None,
|
| 421 |
+
preserve_text: bool = False,
|
| 422 |
+
) -> dict[str, Any]:
|
| 423 |
+
action = _make_action(
|
| 424 |
+
action_type="MOVE",
|
| 425 |
+
target=target,
|
| 426 |
+
text=text or f"前往{target}",
|
| 427 |
+
priority=priority,
|
| 428 |
+
)
|
| 429 |
+
if preserve_text:
|
| 430 |
+
action["preserve_text"] = True
|
| 431 |
+
return action
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def _make_action(
|
| 435 |
+
*,
|
| 436 |
+
action_type: str,
|
| 437 |
+
text: str,
|
| 438 |
+
target: Any = None,
|
| 439 |
+
priority: int = 50,
|
| 440 |
+
) -> dict[str, Any]:
|
| 441 |
+
return {
|
| 442 |
+
"id": 0,
|
| 443 |
+
"text": text,
|
| 444 |
+
"action_type": action_type,
|
| 445 |
+
"target": target,
|
| 446 |
+
"priority": priority,
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
def _dedupe_actions(actions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 451 |
+
deduped: list[dict[str, Any]] = []
|
| 452 |
+
seen: set[tuple[str, str]] = set()
|
| 453 |
+
for action in sorted(
|
| 454 |
+
actions,
|
| 455 |
+
key=lambda item: int(item.get("priority", 0) or 0),
|
| 456 |
+
reverse=True,
|
| 457 |
+
):
|
| 458 |
+
normalized = dict(action)
|
| 459 |
+
normalized.setdefault("priority", 0)
|
| 460 |
+
key = (
|
| 461 |
+
str(normalized.get("action_type")),
|
| 462 |
+
str(normalized.get("target")),
|
| 463 |
+
)
|
| 464 |
+
if key in seen:
|
| 465 |
+
continue
|
| 466 |
+
seen.add(key)
|
| 467 |
+
normalized["id"] = len(deduped) + 1
|
| 468 |
+
deduped.append(normalized)
|
| 469 |
+
return deduped
|
| 470 |
+
|
| 471 |
+
|
| 472 |
+
def _first_incomplete_objective(game_state):
|
| 473 |
+
active_quests = [
|
| 474 |
+
quest
|
| 475 |
+
for quest in game_state.world.quests.values()
|
| 476 |
+
if quest.status == "active"
|
| 477 |
+
]
|
| 478 |
+
active_quests.sort(key=lambda quest: (quest.quest_type != "main", quest.quest_id))
|
| 479 |
+
for quest in active_quests:
|
| 480 |
+
for objective, completed in quest.objectives.items():
|
| 481 |
+
if not completed:
|
| 482 |
+
return quest, objective
|
| 483 |
+
return None, None
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def _extract_dialogue_target(objective: str) -> str | None:
|
| 487 |
+
suffixes = ("对话", "交谈", "确认情报", "了解情况")
|
| 488 |
+
text = str(objective or "")
|
| 489 |
+
if not text.startswith("与"):
|
| 490 |
+
return None
|
| 491 |
+
candidate = text[1:]
|
| 492 |
+
for separator in ("对话", "交谈"):
|
| 493 |
+
if separator in candidate:
|
| 494 |
+
candidate = candidate.split(separator, 1)[0]
|
| 495 |
+
break
|
| 496 |
+
else:
|
| 497 |
+
for suffix in suffixes:
|
| 498 |
+
if candidate.endswith(suffix):
|
| 499 |
+
candidate = candidate[: -len(suffix)]
|
| 500 |
+
break
|
| 501 |
+
candidate = candidate.strip()
|
| 502 |
+
return candidate or None
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
def _extract_location_target(objective: str) -> str | None:
|
| 506 |
+
text = str(objective or "")
|
| 507 |
+
for prefix in ("前往",):
|
| 508 |
+
if text.startswith(prefix):
|
| 509 |
+
candidate = text[len(prefix):]
|
| 510 |
+
for suffix in ("调查", "探索", "查看", "侦察"):
|
| 511 |
+
if candidate.endswith(suffix):
|
| 512 |
+
candidate = candidate[: -len(suffix)]
|
| 513 |
+
break
|
| 514 |
+
return candidate or None
|
| 515 |
+
return None
|
| 516 |
+
|
| 517 |
+
|
| 518 |
+
def _find_next_step(game_state, destination: str) -> str | None:
|
| 519 |
+
if destination == game_state.player.location:
|
| 520 |
+
return destination
|
| 521 |
+
visited = {game_state.player.location}
|
| 522 |
+
queue: deque[tuple[str, list[str]]] = deque([(game_state.player.location, [])])
|
| 523 |
+
while queue:
|
| 524 |
+
current, path = queue.popleft()
|
| 525 |
+
current_loc = game_state.world.locations.get(current)
|
| 526 |
+
if current_loc is None:
|
| 527 |
+
continue
|
| 528 |
+
for neighbor in current_loc.connected_to:
|
| 529 |
+
if neighbor in visited:
|
| 530 |
+
continue
|
| 531 |
+
visited.add(neighbor)
|
| 532 |
+
new_path = path + [neighbor]
|
| 533 |
+
if neighbor == destination:
|
| 534 |
+
return new_path[0]
|
| 535 |
+
queue.append((neighbor, new_path))
|
| 536 |
+
return None
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
def build_village_chief_follow_up_actions(game_state) -> list[dict[str, Any]]:
|
| 540 |
+
blacksmith_text = "前往村庄铁匠铺准备武器"
|
| 541 |
+
path_text = None
|
| 542 |
+
if _is_forest_troll_hunt_active(game_state):
|
| 543 |
+
blacksmith_text = "前往村庄铁匠铺准备武器和防具"
|
| 544 |
+
path_text = "沿村口小路赶赴森林深处"
|
| 545 |
+
return _dedupe_actions(
|
| 546 |
+
[
|
| 547 |
+
_make_action(
|
| 548 |
+
action_type="VIEW_MAP",
|
| 549 |
+
text="查看地图",
|
| 550 |
+
priority=120,
|
| 551 |
+
),
|
| 552 |
+
_move_action(
|
| 553 |
+
"森林深处" if path_text else "村口小路",
|
| 554 |
+
priority=116,
|
| 555 |
+
text=path_text,
|
| 556 |
+
preserve_text=bool(path_text),
|
| 557 |
+
),
|
| 558 |
+
_move_action(
|
| 559 |
+
"村庄杂货铺",
|
| 560 |
+
priority=112,
|
| 561 |
+
text="前往村庄杂货铺准备火把",
|
| 562 |
+
preserve_text=True,
|
| 563 |
+
),
|
| 564 |
+
_move_action(
|
| 565 |
+
"村庄铁匠铺",
|
| 566 |
+
priority=108,
|
| 567 |
+
text=blacksmith_text,
|
| 568 |
+
preserve_text=True,
|
| 569 |
+
),
|
| 570 |
+
]
|
| 571 |
+
)
|
| 572 |
+
|
| 573 |
+
|
| 574 |
+
def build_map_actions(game_state) -> list[dict[str, Any]]:
|
| 575 |
+
if not _has_map(game_state):
|
| 576 |
+
return []
|
| 577 |
+
|
| 578 |
+
current_location = game_state.world.locations.get(game_state.player.location)
|
| 579 |
+
if current_location is None:
|
| 580 |
+
return []
|
| 581 |
+
|
| 582 |
+
special_route_order = {
|
| 583 |
+
"村庄广场": ["村庄铁匠铺", "村庄旅店", "村口小路", "村庄杂货铺"],
|
| 584 |
+
"黑暗森林入口": ["村口小路", "溪边营地", "森林深处"],
|
| 585 |
+
"河边渡口": ["废弃矿洞入口", "山麓盗贼营", "村口小路"],
|
| 586 |
+
"山麓盗贼营": ["精灵遗迹", "河边渡口"],
|
| 587 |
+
"古塔废墟": ["村口小路"],
|
| 588 |
+
}
|
| 589 |
+
route_order = special_route_order.get(
|
| 590 |
+
current_location.name,
|
| 591 |
+
list(current_location.connected_to),
|
| 592 |
+
)
|
| 593 |
+
|
| 594 |
+
actions: list[dict[str, Any]] = []
|
| 595 |
+
for index, destination in enumerate(route_order):
|
| 596 |
+
# 不显示"前往当前场景"的无效选项
|
| 597 |
+
if destination == game_state.player.location:
|
| 598 |
+
continue
|
| 599 |
+
if destination not in current_location.connected_to:
|
| 600 |
+
continue
|
| 601 |
+
if not _is_accessible_destination(game_state, destination):
|
| 602 |
+
continue
|
| 603 |
+
required_map = LOCATION_MAP_REQUIREMENTS.get(destination)
|
| 604 |
+
if required_map and not _has_named_map(game_state, required_map):
|
| 605 |
+
continue
|
| 606 |
+
if not _is_route_visible(game_state, destination):
|
| 607 |
+
continue
|
| 608 |
+
if (
|
| 609 |
+
destination == "森林深处"
|
| 610 |
+
and game_state.world.global_flags.get(DEEP_FOREST_BARRIER_SEEN_FLAG)
|
| 611 |
+
and "森林之钥" not in game_state.player.inventory
|
| 612 |
+
):
|
| 613 |
+
continue
|
| 614 |
+
actions.append(
|
| 615 |
+
_move_action(
|
| 616 |
+
destination,
|
| 617 |
+
priority=120 - index * 4,
|
| 618 |
+
)
|
| 619 |
+
)
|
| 620 |
+
|
| 621 |
+
return _dedupe_actions(actions)
|
| 622 |
+
|
| 623 |
+
|
| 624 |
+
def build_shop_menu_actions(game_state, merchant_name: str) -> list[dict[str, Any]]:
|
| 625 |
+
npc = game_state.world.npcs.get(merchant_name)
|
| 626 |
+
if npc is None or not npc.can_trade:
|
| 627 |
+
return []
|
| 628 |
+
|
| 629 |
+
actions: list[dict[str, Any]] = []
|
| 630 |
+
player_gold = int(getattr(game_state.player, "gold", 0))
|
| 631 |
+
for index, item_name in enumerate(npc.shop_inventory):
|
| 632 |
+
item_info = game_state.world.item_registry.get(item_name)
|
| 633 |
+
price = int(item_info.value) if item_info else 0
|
| 634 |
+
affordable = player_gold >= price
|
| 635 |
+
if affordable:
|
| 636 |
+
label = f"【可购买】购买{item_name}({price}金币)"
|
| 637 |
+
priority = 130 - index * 4
|
| 638 |
+
else:
|
| 639 |
+
label = f"【金币不足】购买{item_name}({price}金币,当前{player_gold})"
|
| 640 |
+
priority = 90 - index * 2
|
| 641 |
+
actions.append(
|
| 642 |
+
_make_action(
|
| 643 |
+
action_type="TRADE",
|
| 644 |
+
target={"merchant": merchant_name, "item": item_name, "confirm": False},
|
| 645 |
+
text=label,
|
| 646 |
+
priority=priority,
|
| 647 |
+
)
|
| 648 |
+
)
|
| 649 |
+
|
| 650 |
+
actions.append(
|
| 651 |
+
_make_action(
|
| 652 |
+
action_type="SCENE_OPTIONS",
|
| 653 |
+
target=npc.location,
|
| 654 |
+
text="暂不购买,先离开柜台",
|
| 655 |
+
priority=60,
|
| 656 |
+
)
|
| 657 |
+
)
|
| 658 |
+
return _dedupe_actions(actions)
|
| 659 |
+
|
| 660 |
+
|
| 661 |
+
def build_scene_actions(game_state, location_name: str | None = None) -> list[dict[str, Any]]:
|
| 662 |
+
current_name = str(location_name or game_state.player.location)
|
| 663 |
+
actions: list[dict[str, Any]] = []
|
| 664 |
+
|
| 665 |
+
if current_name == "村庄广场":
|
| 666 |
+
actions.append(
|
| 667 |
+
_make_action(
|
| 668 |
+
action_type="TALK",
|
| 669 |
+
target="村长老伯",
|
| 670 |
+
text="与村长老伯对话",
|
| 671 |
+
priority=120,
|
| 672 |
+
)
|
| 673 |
+
)
|
| 674 |
+
actions.append(
|
| 675 |
+
_make_action(
|
| 676 |
+
action_type="RUMOR",
|
| 677 |
+
target={"source": "布告栏", "topic": "rumor_menu"},
|
| 678 |
+
text="查看布告栏上的异闻",
|
| 679 |
+
priority=108,
|
| 680 |
+
)
|
| 681 |
+
)
|
| 682 |
+
if _has_named_map(game_state, "村庄地图"):
|
| 683 |
+
actions.append(
|
| 684 |
+
_make_action(
|
| 685 |
+
action_type="VIEW_MAP",
|
| 686 |
+
text="查看地图",
|
| 687 |
+
priority=104,
|
| 688 |
+
)
|
| 689 |
+
)
|
| 690 |
+
actions.append(_move_action("村庄铁匠铺", priority=100))
|
| 691 |
+
actions.append(_move_action("村庄旅店", priority=96))
|
| 692 |
+
actions.append(_move_action("村口小路", priority=92))
|
| 693 |
+
actions.append(_move_action("村庄杂货铺", priority=88))
|
| 694 |
+
return _dedupe_actions(actions)
|
| 695 |
+
|
| 696 |
+
if current_name == "村口小路":
|
| 697 |
+
traveler_available = (
|
| 698 |
+
game_state.world.global_flags.get(TRAVELER_RUMOR_HEARD_FLAG)
|
| 699 |
+
and game_state.world.npcs.get("神秘旅人")
|
| 700 |
+
and game_state.world.npcs["神秘旅人"].location == "村口小路"
|
| 701 |
+
)
|
| 702 |
+
if traveler_available:
|
| 703 |
+
actions.append(
|
| 704 |
+
_make_action(
|
| 705 |
+
action_type="TALK",
|
| 706 |
+
target="神秘旅人",
|
| 707 |
+
text="与神秘旅人交谈",
|
| 708 |
+
priority=120,
|
| 709 |
+
)
|
| 710 |
+
)
|
| 711 |
+
if game_state.world.global_flags.get(FERRY_ROUTE_UNLOCKED_FLAG):
|
| 712 |
+
actions.append(_move_action("河边渡口", priority=112))
|
| 713 |
+
if _has_named_map(game_state, "村庄地图"):
|
| 714 |
+
actions.append(
|
| 715 |
+
_make_action(
|
| 716 |
+
action_type="VIEW_MAP",
|
| 717 |
+
text="查看地图",
|
| 718 |
+
priority=108,
|
| 719 |
+
)
|
| 720 |
+
)
|
| 721 |
+
if _is_main_quest_report_pending(game_state):
|
| 722 |
+
actions.append(
|
| 723 |
+
_move_action(
|
| 724 |
+
"村庄广场",
|
| 725 |
+
priority=120,
|
| 726 |
+
text="回村向村长汇报发现",
|
| 727 |
+
preserve_text=True,
|
| 728 |
+
)
|
| 729 |
+
)
|
| 730 |
+
else:
|
| 731 |
+
actions.append(_move_action("村庄广场", priority=112))
|
| 732 |
+
if _has_named_map(game_state, "村庄地图"):
|
| 733 |
+
actions.append(_move_action("黑暗森林入口", priority=104))
|
| 734 |
+
return _dedupe_actions(actions)
|
| 735 |
+
|
| 736 |
+
if current_name == "村庄铁匠铺":
|
| 737 |
+
actions.append(
|
| 738 |
+
_make_action(
|
| 739 |
+
action_type="TALK",
|
| 740 |
+
target="铁匠格林",
|
| 741 |
+
text="与铁匠格林对话",
|
| 742 |
+
priority=120,
|
| 743 |
+
)
|
| 744 |
+
)
|
| 745 |
+
if _has_named_map(game_state, "村庄地图"):
|
| 746 |
+
actions.append(
|
| 747 |
+
_make_action(
|
| 748 |
+
action_type="VIEW_MAP",
|
| 749 |
+
text="查看地图",
|
| 750 |
+
priority=104,
|
| 751 |
+
)
|
| 752 |
+
)
|
| 753 |
+
actions.append(_move_action("村庄广场", priority=96))
|
| 754 |
+
return _dedupe_actions(actions)
|
| 755 |
+
|
| 756 |
+
if current_name == "村庄旅店":
|
| 757 |
+
actions.append(
|
| 758 |
+
_make_action(
|
| 759 |
+
action_type="TALK",
|
| 760 |
+
target="旅店老板娘莉娜",
|
| 761 |
+
text="与旅店老板娘莉娜对话",
|
| 762 |
+
priority=120,
|
| 763 |
+
)
|
| 764 |
+
)
|
| 765 |
+
actions.append(
|
| 766 |
+
_make_action(
|
| 767 |
+
action_type="RUMOR",
|
| 768 |
+
target={"source": "旅店老板娘莉娜", "topic": "rumor_menu"},
|
| 769 |
+
text="向莉娜打听最近的异状",
|
| 770 |
+
priority=116,
|
| 771 |
+
)
|
| 772 |
+
)
|
| 773 |
+
actions.append(
|
| 774 |
+
_make_action(
|
| 775 |
+
action_type="REST",
|
| 776 |
+
text="在旅店休息片刻",
|
| 777 |
+
priority=110,
|
| 778 |
+
)
|
| 779 |
+
)
|
| 780 |
+
if _has_named_map(game_state, "村庄地图"):
|
| 781 |
+
actions.append(
|
| 782 |
+
_make_action(
|
| 783 |
+
action_type="VIEW_MAP",
|
| 784 |
+
text="查看地图",
|
| 785 |
+
priority=104,
|
| 786 |
+
)
|
| 787 |
+
)
|
| 788 |
+
actions.append(_move_action("村庄广场", priority=96))
|
| 789 |
+
return _dedupe_actions(actions)
|
| 790 |
+
|
| 791 |
+
if current_name == "村庄杂货铺":
|
| 792 |
+
actions.append(
|
| 793 |
+
_make_action(
|
| 794 |
+
action_type="TALK",
|
| 795 |
+
target="杂货商人阿尔",
|
| 796 |
+
text="与杂货商人阿尔对话",
|
| 797 |
+
priority=120,
|
| 798 |
+
)
|
| 799 |
+
)
|
| 800 |
+
if _has_named_map(game_state, "村庄地图"):
|
| 801 |
+
actions.append(
|
| 802 |
+
_make_action(
|
| 803 |
+
action_type="VIEW_MAP",
|
| 804 |
+
text="查看地图",
|
| 805 |
+
priority=104,
|
| 806 |
+
)
|
| 807 |
+
)
|
| 808 |
+
actions.append(_move_action("村庄广场", priority=96))
|
| 809 |
+
return _dedupe_actions(actions)
|
| 810 |
+
|
| 811 |
+
if current_name == "黑暗森林入口":
|
| 812 |
+
goblin_defeated = game_state.world.global_flags.get(FOREST_GOBLIN_DEFEATED_FLAG)
|
| 813 |
+
tracks_found = game_state.world.global_flags.get(FOREST_TROLL_TRACKS_FOUND_FLAG)
|
| 814 |
+
barrier_seen = game_state.world.global_flags.get(DEEP_FOREST_BARRIER_SEEN_FLAG)
|
| 815 |
+
troll_hunt_active = _is_forest_troll_hunt_active(game_state)
|
| 816 |
+
if not goblin_defeated:
|
| 817 |
+
actions.append(
|
| 818 |
+
_make_action(
|
| 819 |
+
action_type="ATTACK",
|
| 820 |
+
target="哥布林",
|
| 821 |
+
text="与哥布林战斗",
|
| 822 |
+
priority=120,
|
| 823 |
+
)
|
| 824 |
+
)
|
| 825 |
+
else:
|
| 826 |
+
if not tracks_found:
|
| 827 |
+
actions.append(
|
| 828 |
+
_make_action(
|
| 829 |
+
action_type="EXPLORE",
|
| 830 |
+
target="黑暗森林入口",
|
| 831 |
+
text="调查哥布林留下的痕迹",
|
| 832 |
+
priority=120,
|
| 833 |
+
)
|
| 834 |
+
)
|
| 835 |
+
elif troll_hunt_active and "森林之钥" in game_state.player.inventory:
|
| 836 |
+
actions.append(
|
| 837 |
+
_move_action(
|
| 838 |
+
"森林深处",
|
| 839 |
+
priority=120,
|
| 840 |
+
text="前往森林深处探索",
|
| 841 |
+
preserve_text=True,
|
| 842 |
+
)
|
| 843 |
+
)
|
| 844 |
+
elif not barrier_seen:
|
| 845 |
+
actions.append(_move_action("森林深处", priority=120))
|
| 846 |
+
elif _is_main_quest_report_pending(game_state):
|
| 847 |
+
actions.append(
|
| 848 |
+
_move_action(
|
| 849 |
+
"村口小路",
|
| 850 |
+
priority=120,
|
| 851 |
+
text="返回村庄向村长汇报",
|
| 852 |
+
preserve_text=True,
|
| 853 |
+
)
|
| 854 |
+
)
|
| 855 |
+
if _has_named_map(game_state, "黑暗森林地图"):
|
| 856 |
+
actions.append(
|
| 857 |
+
_make_action(
|
| 858 |
+
action_type="VIEW_MAP",
|
| 859 |
+
text="查看地图",
|
| 860 |
+
priority=112 if tracks_found else 108,
|
| 861 |
+
)
|
| 862 |
+
)
|
| 863 |
+
actions.append(_move_action("溪边营地", priority=104))
|
| 864 |
+
if tracks_found and not barrier_seen and "森林之钥" in game_state.player.inventory:
|
| 865 |
+
actions.append(
|
| 866 |
+
_move_action(
|
| 867 |
+
"森林深处",
|
| 868 |
+
priority=116,
|
| 869 |
+
text="前往森林深处探索" if troll_hunt_active else None,
|
| 870 |
+
preserve_text=troll_hunt_active,
|
| 871 |
+
)
|
| 872 |
+
)
|
| 873 |
+
if _is_main_quest_report_pending(game_state) and not barrier_seen and not troll_hunt_active:
|
| 874 |
+
actions.append(
|
| 875 |
+
_move_action(
|
| 876 |
+
"村口小路",
|
| 877 |
+
priority=114,
|
| 878 |
+
text="先返回村庄汇报情况",
|
| 879 |
+
preserve_text=True,
|
| 880 |
+
)
|
| 881 |
+
)
|
| 882 |
+
elif not barrier_seen or troll_hunt_active:
|
| 883 |
+
actions.append(_move_action("村口小路", priority=96))
|
| 884 |
+
return _dedupe_actions(actions)
|
| 885 |
+
|
| 886 |
+
if current_name == "河边渡口":
|
| 887 |
+
actions.append(
|
| 888 |
+
_make_action(
|
| 889 |
+
action_type="TALK",
|
| 890 |
+
target="渡口老渔夫",
|
| 891 |
+
text="与渡口老渔夫对话",
|
| 892 |
+
priority=120,
|
| 893 |
+
)
|
| 894 |
+
)
|
| 895 |
+
if _has_named_map(game_state, "山麓地图"):
|
| 896 |
+
actions.append(
|
| 897 |
+
_make_action(
|
| 898 |
+
action_type="VIEW_MAP",
|
| 899 |
+
text="查看地图",
|
| 900 |
+
priority=116,
|
| 901 |
+
)
|
| 902 |
+
)
|
| 903 |
+
actions.append(_move_action("废弃矿洞入口", priority=112))
|
| 904 |
+
actions.append(_move_action("山麓盗贼营", priority=108))
|
| 905 |
+
actions.append(_move_action("村口小路", priority=104))
|
| 906 |
+
return _dedupe_actions(actions)
|
| 907 |
+
|
| 908 |
+
if current_name == "山麓盗贼营":
|
| 909 |
+
if not game_state.world.global_flags.get("encounter::bandit_scout_defeated"):
|
| 910 |
+
actions.append(
|
| 911 |
+
_make_action(
|
| 912 |
+
action_type="ATTACK",
|
| 913 |
+
target="盗贼斥候",
|
| 914 |
+
text="与盗贼斥候战斗",
|
| 915 |
+
priority=120,
|
| 916 |
+
)
|
| 917 |
+
)
|
| 918 |
+
if _has_named_map(game_state, "山麓地图"):
|
| 919 |
+
actions.append(
|
| 920 |
+
_make_action(
|
| 921 |
+
action_type="VIEW_MAP",
|
| 922 |
+
text="查看地图",
|
| 923 |
+
priority=112,
|
| 924 |
+
)
|
| 925 |
+
)
|
| 926 |
+
actions.append(_move_action("河边渡口", priority=108))
|
| 927 |
+
return _dedupe_actions(actions)
|
| 928 |
+
|
| 929 |
+
if current_name == "古塔废墟":
|
| 930 |
+
if not game_state.world.global_flags.get("encounter::ancient_tower_wraith_defeated"):
|
| 931 |
+
actions.append(
|
| 932 |
+
_make_action(
|
| 933 |
+
action_type="ATTACK",
|
| 934 |
+
target="游荡幽灵",
|
| 935 |
+
text="与游荡幽灵战斗",
|
| 936 |
+
priority=120,
|
| 937 |
+
)
|
| 938 |
+
)
|
| 939 |
+
if _has_named_map(game_state, "古塔地图"):
|
| 940 |
+
actions.append(
|
| 941 |
+
_make_action(
|
| 942 |
+
action_type="VIEW_MAP",
|
| 943 |
+
text="查看地图",
|
| 944 |
+
priority=108,
|
| 945 |
+
)
|
| 946 |
+
)
|
| 947 |
+
actions.append(_move_action("村口小路", priority=104))
|
| 948 |
+
return _dedupe_actions(actions)
|
| 949 |
+
|
| 950 |
+
if current_name == "废弃矿洞入口":
|
| 951 |
+
if not game_state.world.global_flags.get("encounter::mine_skeleton_defeated"):
|
| 952 |
+
actions.append(
|
| 953 |
+
_make_action(
|
| 954 |
+
action_type="ATTACK",
|
| 955 |
+
target="骷髅兵",
|
| 956 |
+
text="与骷髅兵战斗",
|
| 957 |
+
priority=120,
|
| 958 |
+
)
|
| 959 |
+
)
|
| 960 |
+
else:
|
| 961 |
+
actions.append(
|
| 962 |
+
_make_action(
|
| 963 |
+
action_type="EXPLORE",
|
| 964 |
+
target="废弃矿洞入口",
|
| 965 |
+
text="搜查矿洞入口的遗留痕迹",
|
| 966 |
+
priority=120,
|
| 967 |
+
)
|
| 968 |
+
)
|
| 969 |
+
if _has_named_map(game_state, "山麓地图"):
|
| 970 |
+
actions.append(
|
| 971 |
+
_make_action(
|
| 972 |
+
action_type="VIEW_MAP",
|
| 973 |
+
text="查看地图",
|
| 974 |
+
priority=108,
|
| 975 |
+
)
|
| 976 |
+
)
|
| 977 |
+
actions.append(_move_action("河边渡口", priority=104))
|
| 978 |
+
return _dedupe_actions(actions)
|
| 979 |
+
|
| 980 |
+
if current_name == "溪边营地":
|
| 981 |
+
actions.append(
|
| 982 |
+
_make_action(
|
| 983 |
+
action_type="REST",
|
| 984 |
+
text="在营地休息恢复体力",
|
| 985 |
+
priority=120,
|
| 986 |
+
)
|
| 987 |
+
)
|
| 988 |
+
actions.append(
|
| 989 |
+
_make_action(
|
| 990 |
+
action_type="EXPLORE",
|
| 991 |
+
target="溪边营地",
|
| 992 |
+
text="搜寻营地遗留的物资线索",
|
| 993 |
+
priority=112,
|
| 994 |
+
)
|
| 995 |
+
)
|
| 996 |
+
if _has_named_map(game_state, "黑暗森林地图"):
|
| 997 |
+
actions.append(
|
| 998 |
+
_make_action(
|
| 999 |
+
action_type="VIEW_MAP",
|
| 1000 |
+
text="查看地图",
|
| 1001 |
+
priority=104,
|
| 1002 |
+
)
|
| 1003 |
+
)
|
| 1004 |
+
actions.append(_move_action("黑暗森林入口", priority=96))
|
| 1005 |
+
return _dedupe_actions(actions)
|
| 1006 |
+
|
| 1007 |
+
if current_name == "精灵遗迹":
|
| 1008 |
+
actions.append(
|
| 1009 |
+
_make_action(
|
| 1010 |
+
action_type="TALK",
|
| 1011 |
+
target="遗迹守护者",
|
| 1012 |
+
text="与遗迹守护者对话",
|
| 1013 |
+
priority=120,
|
| 1014 |
+
)
|
| 1015 |
+
)
|
| 1016 |
+
if _has_named_map(game_state, "山麓地图"):
|
| 1017 |
+
actions.append(
|
| 1018 |
+
_make_action(
|
| 1019 |
+
action_type="VIEW_MAP",
|
| 1020 |
+
text="查看地图",
|
| 1021 |
+
priority=108,
|
| 1022 |
+
)
|
| 1023 |
+
)
|
| 1024 |
+
actions.append(_move_action("山麓盗贼营", priority=100))
|
| 1025 |
+
return _dedupe_actions(actions)
|
| 1026 |
+
|
| 1027 |
+
if current_name == "森林深处":
|
| 1028 |
+
if game_state.world.global_flags.get(FOREST_TROLL_HOARD_PENDING_FLAG):
|
| 1029 |
+
actions.append(
|
| 1030 |
+
_make_action(
|
| 1031 |
+
action_type="CLAIM_REWARD",
|
| 1032 |
+
target={"source": "forest_troll_hoard"},
|
| 1033 |
+
text="确认拾取洞穴中的战利品",
|
| 1034 |
+
priority=124,
|
| 1035 |
+
)
|
| 1036 |
+
)
|
| 1037 |
+
elif (
|
| 1038 |
+
_is_forest_troll_hunt_active(game_state)
|
| 1039 |
+
and game_state.world.global_flags.get(FOREST_TROLL_INTRO_SEEN_FLAG)
|
| 1040 |
+
):
|
| 1041 |
+
actions.append(
|
| 1042 |
+
_make_action(
|
| 1043 |
+
action_type="ATTACK",
|
| 1044 |
+
target="森林巨魔",
|
| 1045 |
+
text="与森林巨魔战斗",
|
| 1046 |
+
priority=120,
|
| 1047 |
+
)
|
| 1048 |
+
)
|
| 1049 |
+
else:
|
| 1050 |
+
actions.append(
|
| 1051 |
+
_make_action(
|
| 1052 |
+
action_type="EXPLORE",
|
| 1053 |
+
target="森林深处",
|
| 1054 |
+
text="深入调查森林异变的根源",
|
| 1055 |
+
priority=120,
|
| 1056 |
+
)
|
| 1057 |
+
)
|
| 1058 |
+
if _has_named_map(game_state, "黑暗森林地图"):
|
| 1059 |
+
actions.append(
|
| 1060 |
+
_make_action(
|
| 1061 |
+
action_type="VIEW_MAP",
|
| 1062 |
+
text="查看地图",
|
| 1063 |
+
priority=108,
|
| 1064 |
+
)
|
| 1065 |
+
)
|
| 1066 |
+
actions.append(_move_action("黑暗森林入口", priority=100))
|
| 1067 |
+
return _dedupe_actions(actions)
|
| 1068 |
+
|
| 1069 |
+
return build_adjacent_actions(game_state)
|
| 1070 |
+
|
| 1071 |
+
|
| 1072 |
+
def build_arrival_event(game_state, location_name: str) -> dict[str, Any] | None:
|
| 1073 |
+
config = ARRIVAL_EVENT_CONFIG.get(str(location_name))
|
| 1074 |
+
if config is None:
|
| 1075 |
+
return None
|
| 1076 |
+
return {
|
| 1077 |
+
"event_key": config["event_key"],
|
| 1078 |
+
"story_text": config["story_text"],
|
| 1079 |
+
"options": build_scene_actions(game_state, location_name),
|
| 1080 |
+
}
|
| 1081 |
+
|
| 1082 |
+
|
| 1083 |
+
def build_goal_directed_actions(game_state) -> list[dict[str, Any]]:
|
| 1084 |
+
quest, objective = _first_incomplete_objective(game_state)
|
| 1085 |
+
if not quest or not objective:
|
| 1086 |
+
return []
|
| 1087 |
+
|
| 1088 |
+
actions: list[dict[str, Any]] = []
|
| 1089 |
+
inventory = set(game_state.player.inventory)
|
| 1090 |
+
current_location = game_state.world.locations.get(game_state.player.location)
|
| 1091 |
+
dialogue_target = _extract_dialogue_target(objective)
|
| 1092 |
+
if dialogue_target:
|
| 1093 |
+
npc = game_state.world.npcs.get(dialogue_target)
|
| 1094 |
+
if npc is None:
|
| 1095 |
+
npc = next(
|
| 1096 |
+
(
|
| 1097 |
+
candidate
|
| 1098 |
+
for candidate in game_state.world.npcs.values()
|
| 1099 |
+
if dialogue_target in candidate.name or candidate.name in objective
|
| 1100 |
+
),
|
| 1101 |
+
None,
|
| 1102 |
+
)
|
| 1103 |
+
if npc and npc.location == game_state.player.location:
|
| 1104 |
+
actions.append(
|
| 1105 |
+
_make_action(
|
| 1106 |
+
action_type="TALK",
|
| 1107 |
+
target=npc.name,
|
| 1108 |
+
text=f"与{npc.name}对话",
|
| 1109 |
+
priority=120,
|
| 1110 |
+
)
|
| 1111 |
+
)
|
| 1112 |
+
elif npc:
|
| 1113 |
+
next_step = _find_next_step(game_state, npc.location)
|
| 1114 |
+
if next_step:
|
| 1115 |
+
actions.append(
|
| 1116 |
+
_make_action(
|
| 1117 |
+
action_type="MOVE",
|
| 1118 |
+
target=next_step,
|
| 1119 |
+
text=f"前往{next_step}",
|
| 1120 |
+
priority=112,
|
| 1121 |
+
)
|
| 1122 |
+
)
|
| 1123 |
+
|
| 1124 |
+
location_target = _extract_location_target(objective)
|
| 1125 |
+
if location_target:
|
| 1126 |
+
if any("地图" in item for item in inventory):
|
| 1127 |
+
actions.append(
|
| 1128 |
+
_make_action(
|
| 1129 |
+
action_type="VIEW_MAP",
|
| 1130 |
+
text="查看地图",
|
| 1131 |
+
priority=110,
|
| 1132 |
+
)
|
| 1133 |
+
)
|
| 1134 |
+
if (
|
| 1135 |
+
location_target == "森林深处"
|
| 1136 |
+
and _is_forest_troll_hunt_active(game_state)
|
| 1137 |
+
and "森林之钥" in inventory
|
| 1138 |
+
and game_state.player.location != "森林深处"
|
| 1139 |
+
):
|
| 1140 |
+
actions.append(
|
| 1141 |
+
_make_action(
|
| 1142 |
+
action_type="MOVE",
|
| 1143 |
+
target="森林深处",
|
| 1144 |
+
text="赶赴森林深处",
|
| 1145 |
+
priority=105,
|
| 1146 |
+
)
|
| 1147 |
+
)
|
| 1148 |
+
else:
|
| 1149 |
+
next_step = _find_next_step(game_state, location_target)
|
| 1150 |
+
if next_step and next_step != game_state.player.location:
|
| 1151 |
+
actions.append(
|
| 1152 |
+
_make_action(
|
| 1153 |
+
action_type="MOVE",
|
| 1154 |
+
target=next_step,
|
| 1155 |
+
text=f"前往{next_step}",
|
| 1156 |
+
priority=105,
|
| 1157 |
+
)
|
| 1158 |
+
)
|
| 1159 |
+
|
| 1160 |
+
if location_target == "黑暗森林入口" and game_state.player.location == "村庄广场":
|
| 1161 |
+
if "火把" not in inventory:
|
| 1162 |
+
actions.append(
|
| 1163 |
+
_make_action(
|
| 1164 |
+
action_type="MOVE",
|
| 1165 |
+
target="村庄杂货铺",
|
| 1166 |
+
text="前往村庄杂货铺准备火把",
|
| 1167 |
+
priority=104,
|
| 1168 |
+
)
|
| 1169 |
+
)
|
| 1170 |
+
if (
|
| 1171 |
+
not game_state.player.equipment.get("weapon")
|
| 1172 |
+
and "铁剑" not in inventory
|
| 1173 |
+
and "短剑" not in inventory
|
| 1174 |
+
):
|
| 1175 |
+
actions.append(
|
| 1176 |
+
_make_action(
|
| 1177 |
+
action_type="MOVE",
|
| 1178 |
+
target="村庄铁匠铺",
|
| 1179 |
+
text="前往村庄铁匠铺准备武器",
|
| 1180 |
+
priority=103,
|
| 1181 |
+
)
|
| 1182 |
+
)
|
| 1183 |
+
|
| 1184 |
+
if "击败" in str(objective):
|
| 1185 |
+
enemy_name = str(objective).replace("击败", "").replace("森林中的", "").replace("矿洞中的", "").replace("的怪物", "").strip()
|
| 1186 |
+
encounter_location = _find_encounter_location(enemy_name)
|
| 1187 |
+
if enemy_name in {"怪物", "敌人"} and current_location:
|
| 1188 |
+
local_enemies = list(current_location.enemies or [])
|
| 1189 |
+
if local_enemies:
|
| 1190 |
+
enemy_name = local_enemies[0]
|
| 1191 |
+
encounter_location = _find_encounter_location(enemy_name)
|
| 1192 |
+
else:
|
| 1193 |
+
hunt_step = next(
|
| 1194 |
+
(
|
| 1195 |
+
neighbor
|
| 1196 |
+
for neighbor in current_location.connected_to
|
| 1197 |
+
if game_state.world.locations.get(neighbor)
|
| 1198 |
+
and game_state.world.locations[neighbor].enemies
|
| 1199 |
+
),
|
| 1200 |
+
None,
|
| 1201 |
+
)
|
| 1202 |
+
if hunt_step:
|
| 1203 |
+
actions.append(
|
| 1204 |
+
_make_action(
|
| 1205 |
+
action_type="MOVE",
|
| 1206 |
+
target=hunt_step,
|
| 1207 |
+
text=f"前往{hunt_step}搜索怪物",
|
| 1208 |
+
priority=108,
|
| 1209 |
+
)
|
| 1210 |
+
)
|
| 1211 |
+
enemy_name = ""
|
| 1212 |
+
if enemy_name and encounter_location and encounter_location != game_state.player.location:
|
| 1213 |
+
next_step = _find_next_step(game_state, encounter_location)
|
| 1214 |
+
if next_step and next_step != game_state.player.location:
|
| 1215 |
+
actions.append(
|
| 1216 |
+
_make_action(
|
| 1217 |
+
action_type="MOVE",
|
| 1218 |
+
target=next_step,
|
| 1219 |
+
text=f"前往{next_step}",
|
| 1220 |
+
priority=108,
|
| 1221 |
+
)
|
| 1222 |
+
)
|
| 1223 |
+
elif enemy_name:
|
| 1224 |
+
actions.append(
|
| 1225 |
+
_make_action(
|
| 1226 |
+
action_type="ATTACK",
|
| 1227 |
+
target=enemy_name,
|
| 1228 |
+
text=f"与{enemy_name}战斗",
|
| 1229 |
+
priority=100,
|
| 1230 |
+
)
|
| 1231 |
+
)
|
| 1232 |
+
|
| 1233 |
+
if "调查" in str(objective) or "找到" in str(objective):
|
| 1234 |
+
if (
|
| 1235 |
+
str(objective) == FOREST_CAUSE_OBJECTIVE
|
| 1236 |
+
and game_state.player.location == "黑暗森林入口"
|
| 1237 |
+
and game_state.world.global_flags.get(FOREST_GOBLIN_DEFEATED_FLAG)
|
| 1238 |
+
):
|
| 1239 |
+
actions.append(
|
| 1240 |
+
_make_action(
|
| 1241 |
+
action_type="EXPLORE",
|
| 1242 |
+
target="黑暗森林入口",
|
| 1243 |
+
text="调查哥布林留下的痕迹",
|
| 1244 |
+
priority=118,
|
| 1245 |
+
)
|
| 1246 |
+
)
|
| 1247 |
+
return _dedupe_actions(actions)
|
| 1248 |
+
actions.append(
|
| 1249 |
+
_make_action(
|
| 1250 |
+
action_type="EXPLORE",
|
| 1251 |
+
target=game_state.player.location,
|
| 1252 |
+
text=f"围绕“{objective}”继续调查",
|
| 1253 |
+
priority=92,
|
| 1254 |
+
)
|
| 1255 |
+
)
|
| 1256 |
+
|
| 1257 |
+
return _dedupe_actions(actions)
|
| 1258 |
+
|
| 1259 |
+
|
| 1260 |
+
def build_adjacent_actions(game_state) -> list[dict[str, Any]]:
|
| 1261 |
+
current_location = game_state.world.locations.get(game_state.player.location)
|
| 1262 |
+
if current_location is None:
|
| 1263 |
+
return []
|
| 1264 |
+
|
| 1265 |
+
actions: list[dict[str, Any]] = []
|
| 1266 |
+
owned_items = set(game_state.player.inventory) | {
|
| 1267 |
+
str(item)
|
| 1268 |
+
for item in game_state.player.equipment.values()
|
| 1269 |
+
if item
|
| 1270 |
+
}
|
| 1271 |
+
|
| 1272 |
+
for neighbor in current_location.connected_to:
|
| 1273 |
+
# 不显示"前往当前场景"的无效选项
|
| 1274 |
+
if neighbor == game_state.player.location:
|
| 1275 |
+
continue
|
| 1276 |
+
target_location = game_state.world.locations.get(neighbor)
|
| 1277 |
+
if target_location is None:
|
| 1278 |
+
continue
|
| 1279 |
+
if not target_location.is_accessible:
|
| 1280 |
+
required_item = str(target_location.required_item or "")
|
| 1281 |
+
if not required_item or required_item not in owned_items:
|
| 1282 |
+
continue
|
| 1283 |
+
actions.append(
|
| 1284 |
+
_make_action(
|
| 1285 |
+
action_type="MOVE",
|
| 1286 |
+
target=neighbor,
|
| 1287 |
+
text=f"前往{neighbor}",
|
| 1288 |
+
priority=78 if not target_location.is_discovered else 72,
|
| 1289 |
+
)
|
| 1290 |
+
)
|
| 1291 |
+
|
| 1292 |
+
for npc_name in current_location.npcs_present:
|
| 1293 |
+
if npc_name not in game_state.world.npcs:
|
| 1294 |
+
continue
|
| 1295 |
+
actions.append(
|
| 1296 |
+
_make_action(
|
| 1297 |
+
action_type="TALK",
|
| 1298 |
+
target=npc_name,
|
| 1299 |
+
text=f"与{npc_name}对话",
|
| 1300 |
+
priority=68,
|
| 1301 |
+
)
|
| 1302 |
+
)
|
| 1303 |
+
|
| 1304 |
+
return _dedupe_actions(actions)
|
| 1305 |
+
|
| 1306 |
+
|
| 1307 |
+
def merge_demo_options(
|
| 1308 |
+
base_options: list[dict[str, Any]],
|
| 1309 |
+
*extra_option_groups: list[dict[str, Any]],
|
| 1310 |
+
limit: int = 3,
|
| 1311 |
+
) -> list[dict[str, Any]]:
|
| 1312 |
+
merged: list[dict[str, Any]] = [option for option in base_options if isinstance(option, dict)]
|
| 1313 |
+
for group in extra_option_groups:
|
| 1314 |
+
merged.extend(group)
|
| 1315 |
+
deduped = _dedupe_actions(merged)
|
| 1316 |
+
return deduped[:limit]
|
| 1317 |
+
|
| 1318 |
+
|
| 1319 |
+
def build_contextual_actions(
|
| 1320 |
+
game_state,
|
| 1321 |
+
*,
|
| 1322 |
+
recent_gain: str | None = None,
|
| 1323 |
+
) -> list[dict[str, Any]]:
|
| 1324 |
+
actions: list[dict[str, Any]] = []
|
| 1325 |
+
inventory = set(game_state.player.inventory)
|
| 1326 |
+
light_level = str(game_state.world.light_level)
|
| 1327 |
+
location = game_state.world.locations.get(game_state.player.location)
|
| 1328 |
+
time_of_day = str(game_state.world.time_of_day)
|
| 1329 |
+
|
| 1330 |
+
if recent_gain:
|
| 1331 |
+
item_info = game_state.world.item_registry.get(recent_gain)
|
| 1332 |
+
if item_info and item_info.item_type in {"weapon", "armor", "accessory"}:
|
| 1333 |
+
slot = "weapon" if item_info.item_type == "weapon" else "armor"
|
| 1334 |
+
if game_state.player.equipment.get(slot) != recent_gain:
|
| 1335 |
+
actions.append(
|
| 1336 |
+
_make_action(
|
| 1337 |
+
action_type="EQUIP",
|
| 1338 |
+
target=recent_gain,
|
| 1339 |
+
text=f"装备{recent_gain}",
|
| 1340 |
+
priority=100,
|
| 1341 |
+
)
|
| 1342 |
+
)
|
| 1343 |
+
if "地图" in str(recent_gain):
|
| 1344 |
+
actions.append(
|
| 1345 |
+
_make_action(
|
| 1346 |
+
action_type="VIEW_MAP",
|
| 1347 |
+
text="查看地图",
|
| 1348 |
+
priority=95,
|
| 1349 |
+
)
|
| 1350 |
+
)
|
| 1351 |
+
|
| 1352 |
+
in_dark_area = (
|
| 1353 |
+
light_level in {"黑暗", "昏暗", "幽暗", "漆黑"}
|
| 1354 |
+
or (location is not None and location.location_type == "dungeon")
|
| 1355 |
+
or (
|
| 1356 |
+
location is not None
|
| 1357 |
+
and location.location_type in {"wilderness", "special"}
|
| 1358 |
+
and time_of_day in {"夜晚", "深夜"}
|
| 1359 |
+
)
|
| 1360 |
+
)
|
| 1361 |
+
if "火把" in inventory and in_dark_area and not _has_status(game_state, "火把"):
|
| 1362 |
+
actions.append(
|
| 1363 |
+
_make_action(
|
| 1364 |
+
action_type="USE_ITEM",
|
| 1365 |
+
target="火把",
|
| 1366 |
+
text="使用火把照明",
|
| 1367 |
+
priority=90,
|
| 1368 |
+
)
|
| 1369 |
+
)
|
| 1370 |
+
|
| 1371 |
+
if game_state.player.hp < max(1, game_state.player.max_hp // 2):
|
| 1372 |
+
for potion_name in ("小型治疗药水", "治疗药水"):
|
| 1373 |
+
if potion_name in inventory:
|
| 1374 |
+
actions.append(
|
| 1375 |
+
_make_action(
|
| 1376 |
+
action_type="USE_ITEM",
|
| 1377 |
+
target=potion_name,
|
| 1378 |
+
text=f"使用{potion_name}",
|
| 1379 |
+
priority=85,
|
| 1380 |
+
)
|
| 1381 |
+
)
|
| 1382 |
+
break
|
| 1383 |
+
|
| 1384 |
+
if game_state.player.hunger < 50:
|
| 1385 |
+
for food_name in ("面包", "烤肉", "麦酒", "草药包"):
|
| 1386 |
+
if food_name in inventory:
|
| 1387 |
+
actions.append(
|
| 1388 |
+
_make_action(
|
| 1389 |
+
action_type="USE_ITEM",
|
| 1390 |
+
target=food_name,
|
| 1391 |
+
text=f"食用{food_name}",
|
| 1392 |
+
priority=80,
|
| 1393 |
+
)
|
| 1394 |
+
)
|
| 1395 |
+
break
|
| 1396 |
+
|
| 1397 |
+
if (
|
| 1398 |
+
game_state.player.location in OVERNIGHT_REST_LOCATIONS
|
| 1399 |
+
and hasattr(game_state, "can_overnight_rest")
|
| 1400 |
+
and game_state.can_overnight_rest()
|
| 1401 |
+
):
|
| 1402 |
+
actions.append(
|
| 1403 |
+
_make_action(
|
| 1404 |
+
action_type="OVERNIGHT_REST",
|
| 1405 |
+
text="在此处过夜",
|
| 1406 |
+
priority=88,
|
| 1407 |
+
)
|
| 1408 |
+
)
|
| 1409 |
+
|
| 1410 |
+
return _dedupe_actions(actions)
|
nlu_engine.py
ADDED
|
@@ -0,0 +1,431 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
nlu_engine.py - StoryWeaver 自然语言理解引擎
|
| 3 |
+
|
| 4 |
+
职责:
|
| 5 |
+
1. 解析用户自然语言输入,提取结构化意图
|
| 6 |
+
2. 将玩家"乱七八糟的输入"映射到具体的动作类型
|
| 7 |
+
3. 封装意图识别的 Prompt 与 API 调用
|
| 8 |
+
|
| 9 |
+
设计思路:
|
| 10 |
+
- 使用 Qwen API 进行意图识别,利用 LLM 的语义理解能力
|
| 11 |
+
- Prompt 设计中明确列出所有可能的意图类型和示例
|
| 12 |
+
- 低温度 (0.2) 确保输出的 JSON 格式稳定可靠
|
| 13 |
+
- 提供降级机制:如果 API 调用失败,使用关键词匹配兜底
|
| 14 |
+
|
| 15 |
+
输入/输出示例(来自需求文档):
|
| 16 |
+
Input: "我想攻击那个哥布林"
|
| 17 |
+
Output: {"intent": "ATTACK", "target": "哥布林", "details": null}
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
import re
|
| 21 |
+
import logging
|
| 22 |
+
from typing import Optional
|
| 23 |
+
|
| 24 |
+
from demo_rules import build_scene_actions
|
| 25 |
+
from utils import safe_json_call, DEFAULT_MODEL
|
| 26 |
+
from state_manager import GameState
|
| 27 |
+
|
| 28 |
+
logger = logging.getLogger("StoryWeaver")
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ============================================================
|
| 32 |
+
# 意图识别 Prompt 模板
|
| 33 |
+
#
|
| 34 |
+
# 设计思路:
|
| 35 |
+
# - System Prompt 提供完整的意图类型列表和示例
|
| 36 |
+
# - 注入当前可用的行动上下文(当前场景的NPC、物品等)
|
| 37 |
+
# - 要求严格输出 JSON 格式
|
| 38 |
+
# - 低温度确保稳定性
|
| 39 |
+
# ============================================================
|
| 40 |
+
|
| 41 |
+
NLU_SYSTEM_PROMPT_TEMPLATE = """你是一个 RPG 游戏的自然语言理解模块(NLU)。你的任务是将玩家的自然语言输入解析为结构化的 JSON 意图数据。
|
| 42 |
+
|
| 43 |
+
【当前游戏上下文】
|
| 44 |
+
{context}
|
| 45 |
+
|
| 46 |
+
【支持的意图类型】
|
| 47 |
+
以下是所有合法的意图类型及其说明和示例:
|
| 48 |
+
|
| 49 |
+
| 意图 (intent) | 说明 | 示例输入 |
|
| 50 |
+
|:--|:--|:--|
|
| 51 |
+
| ATTACK | 攻击目标 | "攻击哥布林"、"打那个怪物"、"我要和它战斗" |
|
| 52 |
+
| TALK | 与NPC对话 | "和村长说话"、"找铁匠聊聊"、"我想打听消息" |
|
| 53 |
+
| MOVE | 移动到某地 | "去森林"、"回村庄"、"我要离开这里" |
|
| 54 |
+
| EXPLORE | 探索/观察环境 | "看看周围"、"仔细搜索"、"调查这个地方" |
|
| 55 |
+
| USE_ITEM | 使用物品 | "喝治疗药水"、"使用火把"、"吃面包" |
|
| 56 |
+
| TRADE | 交易(买/卖) | "买一把剑"、"卖掉这个"、"看看有什么卖的" |
|
| 57 |
+
| EQUIP | 装备物品 | "装备铁剑"、"穿上皮甲" |
|
| 58 |
+
| REST | 休息恢复 | "休息一下"、"在旅店过夜"、"睡觉" |
|
| 59 |
+
| QUEST | 接受/查看任务 | "接受任务"、"查看任务"、"任务完成了" |
|
| 60 |
+
| SKILL | 使用技能 | "施放火球术"、"使用隐身技能" |
|
| 61 |
+
| PICKUP | 拾取物品 | "捡起来"、"拿走那个东西" |
|
| 62 |
+
| FLEE | 逃跑 | "快跑"、"逃离这里"、"我要撤退" |
|
| 63 |
+
| CUSTOM | 其他自由行动 | "给NPC唱首歌"、"在墙上涂鸦" |
|
| 64 |
+
|
| 65 |
+
【当前场景中可交互的对象】
|
| 66 |
+
{interactables}
|
| 67 |
+
|
| 68 |
+
【输出格式要求】
|
| 69 |
+
请严格输出以下 JSON 格式(不要输出任何其他文字):
|
| 70 |
+
{{
|
| 71 |
+
"intent": "意图类型(从上表中选择)",
|
| 72 |
+
"target": "行动目标(NPC名称、物品名称、地点名称等,如果没有明确目标则为 null)",
|
| 73 |
+
"details": "补充细节(如 '用剑攻击'、'询问关于森林的事情' 等,如果没有额外细节则为 null)"
|
| 74 |
+
}}
|
| 75 |
+
|
| 76 |
+
【解析规则】
|
| 77 |
+
1. 如果玩家输入模糊(如"我不知道该干什么"),意图设为 EXPLORE。
|
| 78 |
+
2. 如果玩家输入包含多个动作,提取最主要的一个。
|
| 79 |
+
3. target 应尽量匹配当前场景中实际存在的对象。
|
| 80 |
+
4. 如果输入完全无法理解,设 intent 为 CUSTOM。
|
| 81 |
+
"""
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
class NLUEngine:
|
| 85 |
+
"""
|
| 86 |
+
自然语言理解引擎
|
| 87 |
+
|
| 88 |
+
核心能力:将玩家自由文本输入映射到结构化意图。
|
| 89 |
+
|
| 90 |
+
工作流程:
|
| 91 |
+
1. 收集当前场景上下文(NPC、物品、可达地点等)
|
| 92 |
+
2. 构造 Prompt 并调用 Qwen API
|
| 93 |
+
3. 解析返回的 JSON 意图
|
| 94 |
+
4. 如果 API 失败,使用关键词匹配降级
|
| 95 |
+
|
| 96 |
+
为什么用 LLM 而不是规则匹配:
|
| 97 |
+
- 玩家输入千变万化,规则难以覆盖
|
| 98 |
+
- LLM 能理解同义词、口语化表达、上下文隐含意图
|
| 99 |
+
- 例如:"我饿了" → 可能是 USE_ITEM(吃东西)或 MOVE(去旅店)
|
| 100 |
+
"""
|
| 101 |
+
|
| 102 |
+
def __init__(self, game_state: GameState, model: str = DEFAULT_MODEL):
|
| 103 |
+
self.game_state = game_state
|
| 104 |
+
self.model = model
|
| 105 |
+
|
| 106 |
+
def parse_intent(self, user_input: str) -> dict:
|
| 107 |
+
"""
|
| 108 |
+
解析用户输入,返回结构化意图。
|
| 109 |
+
|
| 110 |
+
Args:
|
| 111 |
+
user_input: 玩家的原始文本输入
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
{
|
| 115 |
+
"intent": "ATTACK",
|
| 116 |
+
"target": "哥布林",
|
| 117 |
+
"details": "用剑攻击",
|
| 118 |
+
"raw_input": "我想用剑攻击那个哥布林"
|
| 119 |
+
}
|
| 120 |
+
"""
|
| 121 |
+
if not user_input or not user_input.strip():
|
| 122 |
+
return {
|
| 123 |
+
"intent": "EXPLORE",
|
| 124 |
+
"target": None,
|
| 125 |
+
"details": "玩家沉默不语",
|
| 126 |
+
"raw_input": "",
|
| 127 |
+
"parser_source": "empty_input",
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
user_input = user_input.strip()
|
| 131 |
+
logger.info(f"NLU 解析输入: '{user_input}'")
|
| 132 |
+
|
| 133 |
+
# 尝试 LLM 解析
|
| 134 |
+
result = self._llm_parse(user_input)
|
| 135 |
+
|
| 136 |
+
# 如果 LLM 解析失败,使用关键词降级
|
| 137 |
+
if result is None:
|
| 138 |
+
logger.warning("LLM 解析失败,使用关键词降级")
|
| 139 |
+
result = self._keyword_fallback(user_input)
|
| 140 |
+
|
| 141 |
+
result = self._apply_intent_postprocessing(result, user_input)
|
| 142 |
+
|
| 143 |
+
# 附加原始输入
|
| 144 |
+
result["raw_input"] = user_input
|
| 145 |
+
|
| 146 |
+
logger.info(f"NLU 解析结果: {result}")
|
| 147 |
+
return result
|
| 148 |
+
|
| 149 |
+
def _llm_parse(self, user_input: str) -> Optional[dict]:
|
| 150 |
+
"""
|
| 151 |
+
使用 Qwen API 进行意图识别。
|
| 152 |
+
低温度 (0.2) 确保 JSON 输出稳定。
|
| 153 |
+
"""
|
| 154 |
+
context = self._build_context()
|
| 155 |
+
interactables = self._build_interactables()
|
| 156 |
+
|
| 157 |
+
system_prompt = NLU_SYSTEM_PROMPT_TEMPLATE.format(
|
| 158 |
+
context=context,
|
| 159 |
+
interactables=interactables,
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
messages = [
|
| 163 |
+
{"role": "system", "content": system_prompt},
|
| 164 |
+
{"role": "user", "content": user_input},
|
| 165 |
+
]
|
| 166 |
+
|
| 167 |
+
result = safe_json_call(
|
| 168 |
+
messages,
|
| 169 |
+
model=self.model,
|
| 170 |
+
temperature=0.2,
|
| 171 |
+
max_tokens=300,
|
| 172 |
+
max_retries=2,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
if result and isinstance(result, dict) and "intent" in result:
|
| 176 |
+
# 验证意图类型合法
|
| 177 |
+
valid_intents = {
|
| 178 |
+
"ATTACK", "TALK", "MOVE", "EXPLORE", "USE_ITEM",
|
| 179 |
+
"TRADE", "EQUIP", "REST", "QUEST", "SKILL",
|
| 180 |
+
"PICKUP", "FLEE", "CUSTOM",
|
| 181 |
+
}
|
| 182 |
+
if result["intent"] not in valid_intents:
|
| 183 |
+
result["intent"] = "CUSTOM"
|
| 184 |
+
result.setdefault("parser_source", "llm")
|
| 185 |
+
return result
|
| 186 |
+
|
| 187 |
+
return None
|
| 188 |
+
|
| 189 |
+
def _keyword_fallback(self, user_input: str) -> dict:
|
| 190 |
+
"""
|
| 191 |
+
关键词匹配降级方案。
|
| 192 |
+
|
| 193 |
+
设计思路:
|
| 194 |
+
- 当 API 不可用时的兜底策略
|
| 195 |
+
- 使用正则匹配常见中文关键词
|
| 196 |
+
- 覆盖最常见的意图类型
|
| 197 |
+
- 无法匹配时默认为 EXPLORE
|
| 198 |
+
"""
|
| 199 |
+
text = user_input.lower()
|
| 200 |
+
|
| 201 |
+
# 关键词 → 意图映射(按优先级排序)
|
| 202 |
+
keyword_rules = [
|
| 203 |
+
# 攻击相关
|
| 204 |
+
(r"攻击|打|杀|战斗|砍|刺|射|揍", "ATTACK"),
|
| 205 |
+
# 逃跑相关
|
| 206 |
+
(r"逃|跑|撤退|逃离|闪", "FLEE"),
|
| 207 |
+
# 对话相关
|
| 208 |
+
(r"说话|对话|交谈|聊|打听|询问|问", "TALK"),
|
| 209 |
+
# 移动相关
|
| 210 |
+
(r"去|前往|移动|走|回|离开|进入", "MOVE"),
|
| 211 |
+
# 物品使用
|
| 212 |
+
(r"使用|喝|吃|用|服用", "USE_ITEM"),
|
| 213 |
+
# 交易
|
| 214 |
+
(r"买|卖|交易|购买|出售|商店", "TRADE"),
|
| 215 |
+
# 装备
|
| 216 |
+
(r"装备|穿|戴|换装", "EQUIP"),
|
| 217 |
+
# 休息
|
| 218 |
+
(r"休息|睡|过夜|恢复|歇", "REST"),
|
| 219 |
+
# 任务
|
| 220 |
+
(r"任务|接受|完成|查看任务", "QUEST"),
|
| 221 |
+
# 技能
|
| 222 |
+
(r"施放|技能|魔法|法术|释放", "SKILL"),
|
| 223 |
+
# 拾取
|
| 224 |
+
(r"捡|拾|拿|拿走|拾取|收集", "PICKUP"),
|
| 225 |
+
# 探索
|
| 226 |
+
(r"看|观察|搜索|调查|探索|检查|四周", "EXPLORE"),
|
| 227 |
+
]
|
| 228 |
+
|
| 229 |
+
detected_intent = "CUSTOM"
|
| 230 |
+
for pattern, intent in keyword_rules:
|
| 231 |
+
if re.search(pattern, text):
|
| 232 |
+
detected_intent = intent
|
| 233 |
+
break
|
| 234 |
+
|
| 235 |
+
# 尝试提取目标
|
| 236 |
+
target = self._extract_target_from_text(user_input)
|
| 237 |
+
|
| 238 |
+
return {
|
| 239 |
+
"intent": detected_intent,
|
| 240 |
+
"target": target,
|
| 241 |
+
"details": None,
|
| 242 |
+
"parser_source": "keyword_fallback",
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
def _extract_target_from_text(self, text: str) -> Optional[str]:
|
| 246 |
+
"""
|
| 247 |
+
从文本中提取可能的目标对象。
|
| 248 |
+
尝试匹配当前场景中的 NPC、物品、地点名称。
|
| 249 |
+
"""
|
| 250 |
+
# 检查 NPC 名称
|
| 251 |
+
for npc_name in self.game_state.world.npcs:
|
| 252 |
+
if npc_name in text:
|
| 253 |
+
return npc_name
|
| 254 |
+
|
| 255 |
+
# 检查物品名称(背包 + 当前场景)
|
| 256 |
+
for item in self.game_state.player.inventory:
|
| 257 |
+
if item in text:
|
| 258 |
+
return item
|
| 259 |
+
|
| 260 |
+
# 检查地点名称
|
| 261 |
+
current_loc = self.game_state.world.locations.get(self.game_state.player.location)
|
| 262 |
+
if current_loc:
|
| 263 |
+
for loc_name in current_loc.connected_to:
|
| 264 |
+
if loc_name in text:
|
| 265 |
+
return loc_name
|
| 266 |
+
|
| 267 |
+
# 检查物品注册表
|
| 268 |
+
for item_name in self.game_state.world.item_registry:
|
| 269 |
+
if item_name in text:
|
| 270 |
+
return item_name
|
| 271 |
+
|
| 272 |
+
return None
|
| 273 |
+
|
| 274 |
+
def _apply_intent_postprocessing(self, result: dict, user_input: str) -> dict:
|
| 275 |
+
"""Apply narrow intent corrections for high-confidence mixed phrases."""
|
| 276 |
+
normalized = dict(result)
|
| 277 |
+
intent = str(normalized.get("intent", "")).upper()
|
| 278 |
+
if intent == "MOVE" and self._looks_like_trade_request(user_input, normalized.get("target")):
|
| 279 |
+
inferred_trade_target = self._infer_trade_target(user_input, normalized.get("target"))
|
| 280 |
+
target_text = str(normalized.get("target") or "")
|
| 281 |
+
target_location = self.game_state.world.locations.get(target_text)
|
| 282 |
+
# 目标是商店地点且玩家尚未到达时,优先保持 MOVE,避免生成“未到店先扣钱”的错误交易。
|
| 283 |
+
if (
|
| 284 |
+
target_location is not None
|
| 285 |
+
and target_location.shop_available
|
| 286 |
+
and target_text != self.game_state.player.location
|
| 287 |
+
):
|
| 288 |
+
normalized["intent_correction"] = "preserve_move_for_shop_travel"
|
| 289 |
+
elif inferred_trade_target is not None:
|
| 290 |
+
normalized["intent"] = "TRADE"
|
| 291 |
+
normalized["target"] = inferred_trade_target
|
| 292 |
+
normalized["intent_correction"] = "move_to_trade_with_structured_target"
|
| 293 |
+
if intent == "TRADE" and not isinstance(normalized.get("target"), dict):
|
| 294 |
+
target_text = str(normalized.get("target") or "")
|
| 295 |
+
target_location = self.game_state.world.locations.get(target_text)
|
| 296 |
+
if (
|
| 297 |
+
target_location is not None
|
| 298 |
+
and target_location.shop_available
|
| 299 |
+
and target_text != self.game_state.player.location
|
| 300 |
+
):
|
| 301 |
+
normalized["intent"] = "MOVE"
|
| 302 |
+
normalized["intent_correction"] = "trade_to_move_for_shop_travel"
|
| 303 |
+
return normalized
|
| 304 |
+
inferred_trade_target = self._infer_trade_target(user_input, normalized.get("target"))
|
| 305 |
+
if inferred_trade_target is not None:
|
| 306 |
+
normalized["target"] = inferred_trade_target
|
| 307 |
+
normalized["intent_correction"] = "trade_target_inferred_from_text"
|
| 308 |
+
if intent in {"ATTACK", "COMBAT"}:
|
| 309 |
+
target = normalized.get("target")
|
| 310 |
+
if not isinstance(target, str) or not target.strip() or target in {"怪物", "敌人", "它", "那个怪物"}:
|
| 311 |
+
inferred_target = self._infer_attack_target()
|
| 312 |
+
if inferred_target:
|
| 313 |
+
normalized["target"] = inferred_target
|
| 314 |
+
normalized["intent_correction"] = "attack_target_inferred_from_scene"
|
| 315 |
+
return normalized
|
| 316 |
+
|
| 317 |
+
def _looks_like_trade_request(self, user_input: str, target: Optional[str]) -> bool:
|
| 318 |
+
trade_pattern = r"买|卖|交易|购买|出售|看看有什么卖的|买点"
|
| 319 |
+
if not re.search(trade_pattern, user_input):
|
| 320 |
+
return False
|
| 321 |
+
|
| 322 |
+
target_text = str(target or "")
|
| 323 |
+
if target_text:
|
| 324 |
+
npc = self.game_state.world.npcs.get(target_text)
|
| 325 |
+
if npc and npc.can_trade:
|
| 326 |
+
return True
|
| 327 |
+
|
| 328 |
+
location = self.game_state.world.locations.get(target_text)
|
| 329 |
+
if location and location.shop_available:
|
| 330 |
+
return True
|
| 331 |
+
|
| 332 |
+
shop_hint_pattern = r"商店|杂货铺|旅店|铁匠铺"
|
| 333 |
+
return bool(re.search(shop_hint_pattern, user_input))
|
| 334 |
+
|
| 335 |
+
def _infer_attack_target(self) -> Optional[str]:
|
| 336 |
+
"""Infer a concrete ATTACK target from deterministic scene actions first."""
|
| 337 |
+
try:
|
| 338 |
+
scene_actions = build_scene_actions(self.game_state, self.game_state.player.location)
|
| 339 |
+
except Exception:
|
| 340 |
+
scene_actions = []
|
| 341 |
+
for action in scene_actions:
|
| 342 |
+
if str(action.get("action_type", "")).upper() != "ATTACK":
|
| 343 |
+
continue
|
| 344 |
+
target = action.get("target")
|
| 345 |
+
if isinstance(target, str) and target.strip():
|
| 346 |
+
return target
|
| 347 |
+
|
| 348 |
+
current_loc = self.game_state.world.locations.get(self.game_state.player.location)
|
| 349 |
+
if current_loc and current_loc.enemies:
|
| 350 |
+
return str(current_loc.enemies[0])
|
| 351 |
+
return None
|
| 352 |
+
|
| 353 |
+
def _infer_trade_target(self, user_input: str, target: object) -> Optional[dict]:
|
| 354 |
+
"""Infer structured trade target for rule-based TRADE handling."""
|
| 355 |
+
text_blob = f"{user_input} {target if isinstance(target, str) else ''}"
|
| 356 |
+
|
| 357 |
+
merchant_name: Optional[str] = None
|
| 358 |
+
for npc in self.game_state.world.npcs.values():
|
| 359 |
+
if not npc.can_trade or npc.location != self.game_state.player.location:
|
| 360 |
+
continue
|
| 361 |
+
if npc.name in text_blob or (npc.occupation and npc.occupation in text_blob):
|
| 362 |
+
merchant_name = npc.name
|
| 363 |
+
break
|
| 364 |
+
|
| 365 |
+
if merchant_name is None:
|
| 366 |
+
for npc in self.game_state.world.npcs.values():
|
| 367 |
+
if npc.can_trade and npc.location == self.game_state.player.location:
|
| 368 |
+
merchant_name = npc.name
|
| 369 |
+
break
|
| 370 |
+
if merchant_name is None:
|
| 371 |
+
return None
|
| 372 |
+
|
| 373 |
+
merchant = self.game_state.world.npcs.get(merchant_name)
|
| 374 |
+
if merchant is None:
|
| 375 |
+
return None
|
| 376 |
+
|
| 377 |
+
item_name: Optional[str] = None
|
| 378 |
+
for candidate in merchant.shop_inventory:
|
| 379 |
+
if candidate in text_blob:
|
| 380 |
+
item_name = candidate
|
| 381 |
+
break
|
| 382 |
+
if item_name is None and isinstance(target, str) and target in merchant.shop_inventory:
|
| 383 |
+
item_name = target
|
| 384 |
+
if item_name is None:
|
| 385 |
+
return None
|
| 386 |
+
|
| 387 |
+
return {"merchant": merchant_name, "item": item_name, "confirm": False}
|
| 388 |
+
|
| 389 |
+
def _build_context(self) -> str:
|
| 390 |
+
"""构建当前场景的简要上下文描述"""
|
| 391 |
+
gs = self.game_state
|
| 392 |
+
return (
|
| 393 |
+
f"场景: {gs.world.current_scene}\n"
|
| 394 |
+
f"时间: 第{gs.world.day_count}天 {gs.world.time_of_day}\n"
|
| 395 |
+
f"玩家位置: {gs.player.location}\n"
|
| 396 |
+
f"玩家 HP: {gs.player.hp}/{gs.player.max_hp}\n"
|
| 397 |
+
f"玩家背包: {', '.join(gs.player.inventory) if gs.player.inventory else '空'}"
|
| 398 |
+
)
|
| 399 |
+
|
| 400 |
+
def _build_interactables(self) -> str:
|
| 401 |
+
"""构建当前场景中可交互对象的列表"""
|
| 402 |
+
gs = self.game_state
|
| 403 |
+
lines = []
|
| 404 |
+
|
| 405 |
+
# 当前场景的 NPC
|
| 406 |
+
current_npcs = [
|
| 407 |
+
npc for npc in gs.world.npcs.values()
|
| 408 |
+
if npc.location == gs.player.location and npc.is_alive
|
| 409 |
+
]
|
| 410 |
+
if current_npcs:
|
| 411 |
+
npc_names = [f"{npc.name}({npc.occupation})" for npc in current_npcs]
|
| 412 |
+
lines.append(f"NPC: {', '.join(npc_names)}")
|
| 413 |
+
|
| 414 |
+
# 可前往的地点
|
| 415 |
+
loc = gs.world.locations.get(gs.player.location)
|
| 416 |
+
if loc and loc.connected_to:
|
| 417 |
+
lines.append(f"可前往: {', '.join(loc.connected_to)}")
|
| 418 |
+
|
| 419 |
+
# 场景中的敌人
|
| 420 |
+
if loc and loc.enemies:
|
| 421 |
+
lines.append(f"可能的敌人: {', '.join(loc.enemies)}")
|
| 422 |
+
|
| 423 |
+
# 背包物品
|
| 424 |
+
if gs.player.inventory:
|
| 425 |
+
lines.append(f"背包物品: {', '.join(gs.player.inventory)}")
|
| 426 |
+
|
| 427 |
+
# 技能
|
| 428 |
+
if gs.player.skills:
|
| 429 |
+
lines.append(f"可用技能: {', '.join(gs.player.skills)}")
|
| 430 |
+
|
| 431 |
+
return "\n".join(lines) if lines else "当前场景中没有特别的可交互对象"
|
requirement.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
项目需求规格说明书:StoryWeaver 交互式叙事系统 (API版)
|
| 2 |
+
1. 项目概况
|
| 3 |
+
项目名称:StoryWeaver
|
| 4 |
+
项目类型:基于 LLM API 的交互式叙事 Web 应用
|
| 5 |
+
核心定义:构建一个能维护世界状态、保证逻辑一致性的 AI 叙事引擎。利用大模型强大的理解能力,实现动态分支剧情和角色扮演体验。
|
| 6 |
+
|
| 7 |
+
2. 技术栈约束
|
| 8 |
+
核心语言:Python
|
| 9 |
+
LLM 服务:Qwen (通义千问) API
|
| 10 |
+
调用方式:推荐使用 OpenAI 兼容格式 或官方 dashscope 库。
|
| 11 |
+
前端界面:Gradio
|
| 12 |
+
依赖管理:使用 python-dotenv 管理环境变量,严禁在代码中硬编码 API Key。
|
| 13 |
+
3. 功能模块详解
|
| 14 |
+
请利用 Qwen API 的强大能力实现以下四个核心模块:
|
| 15 |
+
|
| 16 |
+
模块一:意图识别
|
| 17 |
+
功能描述:解析用户自然语言输入。
|
| 18 |
+
实现逻辑:构造 Prompt 指导 Qwen 输出结构化 JSON 数据。
|
| 19 |
+
输入/输出示例:
|
| 20 |
+
Input: "我想攻击那个哥布林"
|
| 21 |
+
Output: {"intent": "ATTACK", "target": "哥布林", "details": null}
|
| 22 |
+
模块二:上下文生成
|
| 23 |
+
功能描述:生成连贯、有文学色彩的剧情段落。
|
| 24 |
+
生成策略:采用 "两阶段生成策略" (Chain of Thought):
|
| 25 |
+
第一阶段:让 Qwen 生成 JSON 格式的剧情大纲(包含事件、地点变化、NPC反应)。
|
| 26 |
+
第二阶段:基于大纲生成具体的描写文本。
|
| 27 |
+
目的:便于程序解析状态变化,同时保证文本质量。
|
| 28 |
+
模块三:一致性维护
|
| 29 |
+
功能描述:核心难点。系统必须实时维护“世界模型”。
|
| 30 |
+
状态追踪:
|
| 31 |
+
在 state_manager.py 中维护字典或类对象,存储:角色位置、HP、背包物品、当前任务进度。
|
| 32 |
+
核心机制:
|
| 33 |
+
在调用 Qwen 生成前,将当前状态作为 System Prompt 注入。
|
| 34 |
+
要求 Qwen 在输出故事的同时,输出变更的状态字段(如 {"hp_change": -10})。
|
| 35 |
+
代码层面进行校验:如果 HP <= 0,则强制触发死亡结局逻辑。
|
| 36 |
+
模块四:交互与分支
|
| 37 |
+
功能描述:生成后续选项。
|
| 38 |
+
输出要求:要求 Qwen 在生成文本后,额外输出 3 个 JSON 格式的选项供用户点击。
|
| 39 |
+
4. 项目文件结构
|
| 40 |
+
请严格按照以下结构创建文件:
|
| 41 |
+
|
| 42 |
+
text
|
| 43 |
+
|
| 44 |
+
/StoryWeaver
|
| 45 |
+
├── .env # 存储 API Key (格式: QWEN_API_KEY=sk-xxxxxx)
|
| 46 |
+
├── requirement.md # (本文件) 需求定义文档
|
| 47 |
+
├── state_manager.py # 状态管理器:维护游戏全局状态 (Class 实现)
|
| 48 |
+
├── nlu_engine.py # NLU 引擎:封装意图识别的 Prompt 与 API 调用
|
| 49 |
+
├── story_engine.py # 叙事引擎:封装故事生成、分支生成的逻辑
|
| 50 |
+
├── app.py # Gradio 界面:交互逻辑
|
| 51 |
+
└── utils.py # 工具函数:API 配置加载、JSON 解析等
|
| 52 |
+
5. 核心代码规范
|
| 53 |
+
API 配置:
|
| 54 |
+
请使用 os.getenv('QWEN_API_KEY') 读取密钥。
|
| 55 |
+
初始化客户端时设置 base_url (如使用兼容接口)。
|
| 56 |
+
Prompt 设计:
|
| 57 |
+
Story Engine 的 System Prompt 必须包含当前状态的描述,例如:"当前场景:森林。玩家状态:HP 50/100。已发生事件:无。"
|
| 58 |
+
错误处理:
|
| 59 |
+
如果 API 返回格式不是标准 JSON,必须有重试或降级处理机制。
|
| 60 |
+
6. 执行步骤
|
| 61 |
+
环境搭建:生成 .env 文件模板和 requirements.txt。
|
| 62 |
+
架构规划:
|
| 63 |
+
设计 state_manager.py 的数据结构。
|
| 64 |
+
设计调用 Qwen API 的通用函数 (在 utils.py 中)。
|
| 65 |
+
模块实现:
|
| 66 |
+
先实现 story_engine.py,确保能跑通一个最简单的剧情生成。
|
| 67 |
+
再实现 nlu_engine.py 和 state_manager.py。
|
| 68 |
+
最后串联 app.py。
|
| 69 |
+
测试验证:
|
| 70 |
+
模拟输入 "攻击怪物",检查控制台打印的状态更新是否正确。
|
| 71 |
+
|
| 72 |
+
请确保 requirements.txt 包含 openai (如果用兼容库) 或 dashscope,以及 gradio, python-dotenv, pydantic。
|
| 73 |
+
Qwen 模型推荐使用 qwen-turbo 或 qwen-plus 以平衡速度和效果。
|
| 74 |
+
请在代码中详细注释 Prompt 的设计思路。
|
| 75 |
+
|
| 76 |
+
| 模块 | 具体要求 | 为什么重要 |
|
| 77 |
+
| :--- | :--- | :--- |
|
| 78 |
+
| 1. 意图识别 (NLU) | 必须能把玩家乱七八糟的输入(如“我想打怪”)映射到具体的动作类型(如 `ATTACK`)。 | 证明AI听懂了你在说什么,而不是瞎猜。 |
|
| 79 |
+
| 2. 上下文生成 (NLG) | 生成的剧情必须符合之前的设定(人物、地点、物品)。推荐使用结构化输出(先生成大纲再生成文本)。 | 保证剧情连贯,不出现“死人复活”这种低级错误。 |
|
| 80 |
+
| 3. 一致性维护 (Consistency) | 这是最高频的扣分点! 必须设计机制来检测剧情矛盾(比如时间线错误),并进行修复或拒绝。 | 证明你的系统有“记忆”和“逻辑”。 |
|
| 81 |
+
| 4. 交互与分支 | 不同的选择必须导致不同的后果。系统要有“下一回合”的选项生成能力。 | 证明这是一个游戏,而不是单向的阅读。 |
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
开发提示:
|
| 85 |
+
请利用 Planning 能力,在写代码前先构思数据流转过程。
|
| 86 |
+
state_manager.py 是项目的灵魂,请确保其健壮性。
|
| 87 |
+
生成代码时,请添加必要的注释以解释关键逻辑。
|
| 88 |
+
|
| 89 |
+
上述是我的建议,如果你对这个系统的开发有什么更好的想法,可以与我沟通。
|
requirements.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
openai>=1.0.0
|
| 2 |
+
gradio==4.44.0
|
| 3 |
+
python-dotenv>=1.0.0
|
| 4 |
+
pydantic>=2.0.0
|
scene_assets.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
IMAGE_DIR = Path(__file__).resolve().parent / "image"
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _candidate_path(name: str | None) -> Path | None:
|
| 10 |
+
if not name:
|
| 11 |
+
return None
|
| 12 |
+
candidate = IMAGE_DIR / f"{name}.png"
|
| 13 |
+
if candidate.exists():
|
| 14 |
+
return candidate
|
| 15 |
+
return None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def get_scene_image_path(game_state, focus_npc: str | None = None) -> str | None:
|
| 19 |
+
npc_candidate = _candidate_path(focus_npc)
|
| 20 |
+
if npc_candidate is not None:
|
| 21 |
+
return str(npc_candidate)
|
| 22 |
+
|
| 23 |
+
for name in (
|
| 24 |
+
getattr(game_state.world, "current_scene", None),
|
| 25 |
+
getattr(game_state.player, "location", None),
|
| 26 |
+
):
|
| 27 |
+
scene_candidate = _candidate_path(name)
|
| 28 |
+
if scene_candidate is not None:
|
| 29 |
+
return str(scene_candidate)
|
| 30 |
+
|
| 31 |
+
return None
|
state_manager.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
story_engine.py
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
telemetry.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
telemetry.py - StoryWeaver 结构化交互日志工具
|
| 3 |
+
|
| 4 |
+
职责:
|
| 5 |
+
1. 为每个游戏会话分配稳定的 session_id
|
| 6 |
+
2. 以 JSONL 形式落盘每回合交互记录
|
| 7 |
+
3. 为评估脚本和案例分析提供统一的日志格式
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import uuid
|
| 15 |
+
from datetime import datetime
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
PROJECT_ROOT = Path(__file__).resolve().parent
|
| 21 |
+
DEFAULT_LOG_DIR = PROJECT_ROOT / "logs" / "interactions"
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _resolve_log_dir() -> Path:
|
| 25 |
+
custom_dir = os.getenv("STORYWEAVER_LOG_DIR", "").strip()
|
| 26 |
+
if custom_dir:
|
| 27 |
+
return Path(custom_dir).expanduser()
|
| 28 |
+
return DEFAULT_LOG_DIR
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def create_session_metadata(session_id: str | None = None) -> dict[str, Any]:
|
| 32 |
+
"""
|
| 33 |
+
创建新的会话元数据。
|
| 34 |
+
|
| 35 |
+
每个会话对应一个单独的 JSONL 文件,便于回放和分析。
|
| 36 |
+
"""
|
| 37 |
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
| 38 |
+
new_session_id = session_id or f"sw-{timestamp}-{uuid.uuid4().hex[:8]}"
|
| 39 |
+
log_dir = _resolve_log_dir()
|
| 40 |
+
log_path = log_dir / f"{new_session_id}.jsonl"
|
| 41 |
+
return {
|
| 42 |
+
"session_id": new_session_id,
|
| 43 |
+
"turn_index": 0,
|
| 44 |
+
"interaction_log_path": str(log_path),
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def ensure_session_metadata(game_session: dict[str, Any]) -> dict[str, Any]:
|
| 49 |
+
"""确保游戏会话中带有日志所需的元数据。"""
|
| 50 |
+
if "session_id" not in game_session or "interaction_log_path" not in game_session:
|
| 51 |
+
game_session.update(create_session_metadata())
|
| 52 |
+
if "turn_index" not in game_session:
|
| 53 |
+
game_session["turn_index"] = 0
|
| 54 |
+
return game_session
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def append_turn_log(game_session: dict[str, Any], record: dict[str, Any]) -> str:
|
| 58 |
+
"""
|
| 59 |
+
追加一条结构化交互日志。
|
| 60 |
+
|
| 61 |
+
Returns:
|
| 62 |
+
日志文件路径,便于调试和脚本复用。
|
| 63 |
+
"""
|
| 64 |
+
ensure_session_metadata(game_session)
|
| 65 |
+
|
| 66 |
+
game_session["turn_index"] += 1
|
| 67 |
+
log_path = Path(game_session["interaction_log_path"])
|
| 68 |
+
log_path.parent.mkdir(parents=True, exist_ok=True)
|
| 69 |
+
|
| 70 |
+
payload = {
|
| 71 |
+
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
| 72 |
+
"session_id": game_session["session_id"],
|
| 73 |
+
"turn_index": game_session["turn_index"],
|
| 74 |
+
**record,
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
with log_path.open("a", encoding="utf-8") as fh:
|
| 78 |
+
json.dump(payload, fh, ensure_ascii=False)
|
| 79 |
+
fh.write("\n")
|
| 80 |
+
|
| 81 |
+
return str(log_path)
|
utils.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
utils.py - StoryWeaver 工具函数模块
|
| 3 |
+
|
| 4 |
+
职责:
|
| 5 |
+
1. 加载环境变量,初始化 OpenAI 兼容客户端 (Qwen API)
|
| 6 |
+
2. 提供通用的 API 调用封装函数(带重试机制)
|
| 7 |
+
3. 提供 JSON 安全解析工具(从 LLM 输出中提取结构化数据)
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import re
|
| 12 |
+
import json
|
| 13 |
+
import time
|
| 14 |
+
import logging
|
| 15 |
+
from typing import Any, Optional
|
| 16 |
+
from dotenv import load_dotenv
|
| 17 |
+
|
| 18 |
+
try:
|
| 19 |
+
from openai import OpenAI
|
| 20 |
+
_OPENAI_IMPORT_ERROR: Optional[Exception] = None
|
| 21 |
+
except ImportError as exc: # pragma: no cover - depends on local env
|
| 22 |
+
OpenAI = None # type: ignore[assignment]
|
| 23 |
+
_OPENAI_IMPORT_ERROR = exc
|
| 24 |
+
|
| 25 |
+
# ============================================================
|
| 26 |
+
# 日志配置
|
| 27 |
+
# ============================================================
|
| 28 |
+
logging.basicConfig(
|
| 29 |
+
level=logging.INFO,
|
| 30 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 31 |
+
)
|
| 32 |
+
logger = logging.getLogger("StoryWeaver")
|
| 33 |
+
|
| 34 |
+
# ============================================================
|
| 35 |
+
# 环境变量加载 & API 客户端初始化
|
| 36 |
+
# ============================================================
|
| 37 |
+
|
| 38 |
+
# 从项目根目录的 .env 文件加载环境变量
|
| 39 |
+
load_dotenv()
|
| 40 |
+
|
| 41 |
+
# 严禁硬编码 API Key —— 仅通过环境变量读取
|
| 42 |
+
QWEN_API_KEY: str = os.getenv("QWEN_API_KEY", "")
|
| 43 |
+
|
| 44 |
+
if not QWEN_API_KEY or QWEN_API_KEY == "sk-xxxxxx":
|
| 45 |
+
logger.warning(
|
| 46 |
+
"⚠️ QWEN_API_KEY 未设置或仍为模板值!"
|
| 47 |
+
"请在 .env 文件中填写有效的 API Key。"
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
# 使用 OpenAI 兼容格式连接 Qwen API
|
| 51 |
+
# base_url 指向通义千问的 OpenAI 兼容端点
|
| 52 |
+
_client: Optional[Any] = None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def get_client() -> Any:
|
| 56 |
+
"""
|
| 57 |
+
获取全局 OpenAI 客户端(懒加载单例)。
|
| 58 |
+
使用兼容格式调用 Qwen API。
|
| 59 |
+
"""
|
| 60 |
+
global _client
|
| 61 |
+
if OpenAI is None:
|
| 62 |
+
raise RuntimeError(
|
| 63 |
+
"未安装 openai 依赖,无法初始化 Qwen 客户端。"
|
| 64 |
+
"请先执行 `pip install -r requirements.txt`。"
|
| 65 |
+
) from _OPENAI_IMPORT_ERROR
|
| 66 |
+
if _client is None:
|
| 67 |
+
_client = OpenAI(
|
| 68 |
+
api_key=QWEN_API_KEY,
|
| 69 |
+
base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
|
| 70 |
+
)
|
| 71 |
+
return _client
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ============================================================
|
| 75 |
+
# 默认模型配置
|
| 76 |
+
# ============================================================
|
| 77 |
+
# 使用 qwen2.5-14b-instruct 以获得最快的响应速度
|
| 78 |
+
DEFAULT_MODEL: str = "qwen2.5-14b-instruct"
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# ============================================================
|
| 82 |
+
# 通用 API 调用封装(带重试 & 错误处理)
|
| 83 |
+
# ============================================================
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def call_qwen(
|
| 87 |
+
messages: list[dict[str, str]],
|
| 88 |
+
model: str = DEFAULT_MODEL,
|
| 89 |
+
temperature: float = 0.8,
|
| 90 |
+
max_tokens: int = 2000,
|
| 91 |
+
max_retries: int = 3,
|
| 92 |
+
retry_delay: float = 1.0,
|
| 93 |
+
) -> str:
|
| 94 |
+
"""
|
| 95 |
+
调用 Qwen API 的通用封装函数。
|
| 96 |
+
|
| 97 |
+
设计思路:
|
| 98 |
+
- 使用 OpenAI 兼容格式,方便后续切换模型
|
| 99 |
+
- 内置指数退避重试机制,应对网络波动和限流
|
| 100 |
+
- 返回纯文本内容,JSON 解析交给调用方处理
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
messages: OpenAI 格式的消息列表 [{"role": "system", "content": "..."}, ...]
|
| 104 |
+
model: 模型名称,默认 qwen-plus
|
| 105 |
+
temperature: 生成温度,越高越有创意(0.0-2.0)
|
| 106 |
+
max_tokens: 最大生成 token 数
|
| 107 |
+
max_retries: 最大重试次数
|
| 108 |
+
retry_delay: 初始重试间隔(秒),每次翻倍
|
| 109 |
+
|
| 110 |
+
Returns:
|
| 111 |
+
模型生成的文本内容
|
| 112 |
+
|
| 113 |
+
Raises:
|
| 114 |
+
Exception: 重试耗尽后抛出最后一次异常
|
| 115 |
+
"""
|
| 116 |
+
client = get_client()
|
| 117 |
+
last_exception: Optional[Exception] = None
|
| 118 |
+
|
| 119 |
+
for attempt in range(1, max_retries + 1):
|
| 120 |
+
try:
|
| 121 |
+
logger.info(f"调用 Qwen API (尝试 {attempt}/{max_retries}),模型: {model}")
|
| 122 |
+
response = client.chat.completions.create(
|
| 123 |
+
model=model,
|
| 124 |
+
messages=messages,
|
| 125 |
+
temperature=temperature,
|
| 126 |
+
max_tokens=max_tokens,
|
| 127 |
+
)
|
| 128 |
+
content = response.choices[0].message.content.strip()
|
| 129 |
+
logger.info(f"API 调用成功,响应长度: {len(content)} 字符")
|
| 130 |
+
return content
|
| 131 |
+
|
| 132 |
+
except Exception as e:
|
| 133 |
+
last_exception = e
|
| 134 |
+
logger.warning(f"API 调用失败 (尝试 {attempt}/{max_retries}): {e}")
|
| 135 |
+
if attempt < max_retries:
|
| 136 |
+
sleep_time = retry_delay * (2 ** (attempt - 1))
|
| 137 |
+
logger.info(f"等待 {sleep_time:.1f} 秒后重试...")
|
| 138 |
+
time.sleep(sleep_time)
|
| 139 |
+
|
| 140 |
+
# 重试耗尽,抛出异常
|
| 141 |
+
raise RuntimeError(
|
| 142 |
+
f"Qwen API 调用在 {max_retries} 次尝试后仍然失败: {last_exception}"
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def call_qwen_stream(
|
| 147 |
+
messages: list[dict[str, str]],
|
| 148 |
+
model: str = DEFAULT_MODEL,
|
| 149 |
+
temperature: float = 0.8,
|
| 150 |
+
max_tokens: int = 2000,
|
| 151 |
+
):
|
| 152 |
+
"""
|
| 153 |
+
调用 Qwen API 的流式版本,逐块 yield 文本内容。
|
| 154 |
+
|
| 155 |
+
使用 stream=True,让用户在 AI 生成过程中就能看到文字逐步出现,
|
| 156 |
+
大幅改善感知延迟。
|
| 157 |
+
|
| 158 |
+
Args:
|
| 159 |
+
messages: OpenAI 格式的消息列表
|
| 160 |
+
model: 模型名称
|
| 161 |
+
temperature: 生成温度
|
| 162 |
+
max_tokens: 最大生成 token 数
|
| 163 |
+
|
| 164 |
+
Yields:
|
| 165 |
+
每次生成的文本片段(str)
|
| 166 |
+
"""
|
| 167 |
+
client = get_client()
|
| 168 |
+
logger.info(f"调用 Qwen 流式 API,模型: {model}")
|
| 169 |
+
try:
|
| 170 |
+
response = client.chat.completions.create(
|
| 171 |
+
model=model,
|
| 172 |
+
messages=messages,
|
| 173 |
+
temperature=temperature,
|
| 174 |
+
max_tokens=max_tokens,
|
| 175 |
+
stream=True,
|
| 176 |
+
)
|
| 177 |
+
for chunk in response:
|
| 178 |
+
if chunk.choices and chunk.choices[0].delta.content:
|
| 179 |
+
yield chunk.choices[0].delta.content
|
| 180 |
+
except Exception as e:
|
| 181 |
+
logger.error(f"流式 API 调用失败: {e}")
|
| 182 |
+
raise
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
# ============================================================
|
| 186 |
+
# JSON 安全解析工具
|
| 187 |
+
# ============================================================
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def extract_json_from_text(text: str) -> Optional[dict | list]:
|
| 191 |
+
"""
|
| 192 |
+
从 LLM 输出的文本中提取 JSON 数据。
|
| 193 |
+
|
| 194 |
+
设计思路:
|
| 195 |
+
LLM 有时会在 JSON 前后附加说明文字,或使用 ```json 代码块包裹。
|
| 196 |
+
此函数通过多种策略尝试提取有效 JSON:
|
| 197 |
+
1. 先尝试直接解析整段文本
|
| 198 |
+
2. 再尝试提取 ```json ... ``` 代码块
|
| 199 |
+
3. 最后尝试匹配第一个 { ... } 或 [ ... ] 结构
|
| 200 |
+
|
| 201 |
+
Args:
|
| 202 |
+
text: LLM 返回的原始文本
|
| 203 |
+
|
| 204 |
+
Returns:
|
| 205 |
+
解析后的 dict/list,解析失败返回 None
|
| 206 |
+
"""
|
| 207 |
+
if not text:
|
| 208 |
+
return None
|
| 209 |
+
|
| 210 |
+
# 策略1: 直接解析(LLM 可能返回纯 JSON)
|
| 211 |
+
try:
|
| 212 |
+
return json.loads(text.strip())
|
| 213 |
+
except json.JSONDecodeError:
|
| 214 |
+
pass
|
| 215 |
+
|
| 216 |
+
# 策略2: 提取 ```json ... ``` 代码块
|
| 217 |
+
code_block_pattern = r"```(?:json)?\s*\n?(.*?)\n?\s*```"
|
| 218 |
+
matches = re.findall(code_block_pattern, text, re.DOTALL)
|
| 219 |
+
for match in matches:
|
| 220 |
+
try:
|
| 221 |
+
return json.loads(match.strip())
|
| 222 |
+
except json.JSONDecodeError:
|
| 223 |
+
continue
|
| 224 |
+
|
| 225 |
+
# 策略3: 匹配第一个完整的 JSON 对象 { ... }
|
| 226 |
+
brace_pattern = r"\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}"
|
| 227 |
+
brace_matches = re.findall(brace_pattern, text, re.DOTALL)
|
| 228 |
+
for match in brace_matches:
|
| 229 |
+
try:
|
| 230 |
+
return json.loads(match)
|
| 231 |
+
except json.JSONDecodeError:
|
| 232 |
+
continue
|
| 233 |
+
|
| 234 |
+
# 策略4: 匹配嵌套更深的 JSON(贪婪匹配从第一个 { 到最后一个 })
|
| 235 |
+
deep_match = re.search(r"\{.*\}", text, re.DOTALL)
|
| 236 |
+
if deep_match:
|
| 237 |
+
try:
|
| 238 |
+
return json.loads(deep_match.group())
|
| 239 |
+
except json.JSONDecodeError:
|
| 240 |
+
pass
|
| 241 |
+
|
| 242 |
+
# 策略5: 匹配 JSON 数组 [ ... ]
|
| 243 |
+
array_match = re.search(r"\[.*\]", text, re.DOTALL)
|
| 244 |
+
if array_match:
|
| 245 |
+
try:
|
| 246 |
+
return json.loads(array_match.group())
|
| 247 |
+
except json.JSONDecodeError:
|
| 248 |
+
pass
|
| 249 |
+
|
| 250 |
+
logger.warning(f"无法从文本中提取 JSON: {text[:200]}...")
|
| 251 |
+
return None
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def safe_json_call(
|
| 255 |
+
messages: list[dict[str, str]],
|
| 256 |
+
model: str = DEFAULT_MODEL,
|
| 257 |
+
temperature: float = 0.3,
|
| 258 |
+
max_tokens: int = 2000,
|
| 259 |
+
max_retries: int = 3,
|
| 260 |
+
) -> Optional[dict | list]:
|
| 261 |
+
"""
|
| 262 |
+
调用 Qwen API 并安全地解析返回的 JSON。
|
| 263 |
+
|
| 264 |
+
设计思路:
|
| 265 |
+
- 将 API 调用与 JSON 解析合为一步
|
| 266 |
+
- 如果第一次解析失败,会额外重试(重新调用 API)
|
| 267 |
+
- temperature 默认较低 (0.3),让 JSON 输出更稳定
|
| 268 |
+
|
| 269 |
+
Args:
|
| 270 |
+
messages: 消息列表
|
| 271 |
+
model: 模型名称
|
| 272 |
+
temperature: 生成温度(JSON 输出建议低温)
|
| 273 |
+
max_tokens: 最大 token 数
|
| 274 |
+
max_retries: JSON 解析失败时的额外重试次数
|
| 275 |
+
|
| 276 |
+
Returns:
|
| 277 |
+
解析后的 dict/list,全部失败返回 None
|
| 278 |
+
"""
|
| 279 |
+
for attempt in range(1, max_retries + 1):
|
| 280 |
+
try:
|
| 281 |
+
raw_text = call_qwen(
|
| 282 |
+
messages=messages,
|
| 283 |
+
model=model,
|
| 284 |
+
temperature=temperature,
|
| 285 |
+
max_tokens=max_tokens,
|
| 286 |
+
)
|
| 287 |
+
result = extract_json_from_text(raw_text)
|
| 288 |
+
if result is not None:
|
| 289 |
+
return result
|
| 290 |
+
logger.warning(
|
| 291 |
+
f"JSON 解析失败 (尝试 {attempt}/{max_retries}),原始文本: {raw_text[:300]}..."
|
| 292 |
+
)
|
| 293 |
+
except Exception as e:
|
| 294 |
+
logger.error(f"safe_json_call 异常 (尝试 {attempt}/{max_retries}): {e}")
|
| 295 |
+
|
| 296 |
+
logger.error(f"safe_json_call 在 {max_retries} 次尝试后仍无法获取有效 JSON")
|
| 297 |
+
return None
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
# ============================================================
|
| 301 |
+
# 辅助工具函��
|
| 302 |
+
# ============================================================
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def clamp(value: int, min_val: int, max_val: int) -> int:
|
| 306 |
+
"""将数值限制在 [min_val, max_val] 范围内"""
|
| 307 |
+
return max(min_val, min(max_val, value))
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def format_dict_for_prompt(data: dict, indent: int = 0) -> str:
|
| 311 |
+
"""
|
| 312 |
+
将字典格式化为易读的 Prompt 文本。
|
| 313 |
+
用于将状态数据注入 System Prompt。
|
| 314 |
+
"""
|
| 315 |
+
lines = []
|
| 316 |
+
prefix = " " * indent
|
| 317 |
+
for key, value in data.items():
|
| 318 |
+
if isinstance(value, dict):
|
| 319 |
+
lines.append(f"{prefix}{key}:")
|
| 320 |
+
lines.append(format_dict_for_prompt(value, indent + 1))
|
| 321 |
+
elif isinstance(value, list):
|
| 322 |
+
if value:
|
| 323 |
+
lines.append(f"{prefix}{key}: {', '.join(str(v) for v in value)}")
|
| 324 |
+
else:
|
| 325 |
+
lines.append(f"{prefix}{key}: 无")
|
| 326 |
+
else:
|
| 327 |
+
lines.append(f"{prefix}{key}: {value}")
|
| 328 |
+
return "\n".join(lines)
|