wzh0617 commited on
Commit
8bdaafd
·
verified ·
1 Parent(s): 837ce6a

Upload 12 files

Browse files
Files changed (12) hide show
  1. README.md +557 -14
  2. app.py +2005 -0
  3. combat_engine.py +97 -0
  4. demo_rules.py +1410 -0
  5. nlu_engine.py +431 -0
  6. requirement.md +89 -0
  7. requirements.txt +4 -0
  8. scene_assets.py +31 -0
  9. state_manager.py +0 -0
  10. story_engine.py +0 -0
  11. telemetry.py +81 -0
  12. utils.py +328 -0
README.md CHANGED
@@ -1,14 +1,557 @@
1
- ---
2
- title: Story Weaver
3
- emoji: 📉
4
- colorFrom: pink
5
- colorTo: green
6
- sdk: gradio
7
- sdk_version: 6.10.0
8
- app_file: app.py
9
- pinned: false
10
- license: mit
11
- short_description: 'Story_Weaver '
12
- ---
13
-
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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>&nbsp;&nbsp;{'✅' 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)