Spaces:
Runtime error
Runtime error
PPP commited on
Commit ·
1b980f7
1
Parent(s): 82f3f72
上传完整代码
Browse files- .gitattributes +0 -21
- app.py +495 -381
- demo_rules.py +1402 -0
- scene_assets.py +31 -0
- state_manager.py +0 -0
- story_engine.py +0 -0
.gitattributes
CHANGED
|
@@ -33,24 +33,3 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
-
image/村口小路.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
-
image/村长老伯.png filter=lfs diff=lfs merge=lfs -text
|
| 38 |
-
image/村庄广场.png filter=lfs diff=lfs merge=lfs -text
|
| 39 |
-
image/村庄旅店.png filter=lfs diff=lfs merge=lfs -text
|
| 40 |
-
image/村庄铁匠铺.png filter=lfs diff=lfs merge=lfs -text
|
| 41 |
-
image/村庄杂货铺.png filter=lfs diff=lfs merge=lfs -text
|
| 42 |
-
image/渡口老渔夫.png filter=lfs diff=lfs merge=lfs -text
|
| 43 |
-
image/废弃矿洞入口.png filter=lfs diff=lfs merge=lfs -text
|
| 44 |
-
image/古塔废墟.png filter=lfs diff=lfs merge=lfs -text
|
| 45 |
-
image/河边渡口.png filter=lfs diff=lfs merge=lfs -text
|
| 46 |
-
image/黑暗森林入口.png filter=lfs diff=lfs merge=lfs -text
|
| 47 |
-
image/精灵遗迹.png filter=lfs diff=lfs merge=lfs -text
|
| 48 |
-
image/矿洞深层.png filter=lfs diff=lfs merge=lfs -text
|
| 49 |
-
image/旅店老板娘莉娜.png filter=lfs diff=lfs merge=lfs -text
|
| 50 |
-
image/森林深处.png filter=lfs diff=lfs merge=lfs -text
|
| 51 |
-
image/山麓盗贼营.png filter=lfs diff=lfs merge=lfs -text
|
| 52 |
-
image/神秘旅人.png filter=lfs diff=lfs merge=lfs -text
|
| 53 |
-
image/铁匠格林.png filter=lfs diff=lfs merge=lfs -text
|
| 54 |
-
image/溪边营地.png filter=lfs diff=lfs merge=lfs -text
|
| 55 |
-
image/遗迹守护者.png filter=lfs diff=lfs merge=lfs -text
|
| 56 |
-
image/杂货商人阿尔.png filter=lfs diff=lfs merge=lfs -text
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
CHANGED
|
@@ -21,9 +21,54 @@ import gradio as gr
|
|
| 21 |
|
| 22 |
from state_manager import GameState
|
| 23 |
from nlu_engine import NLUEngine
|
|
|
|
| 24 |
from story_engine import StoryEngine
|
| 25 |
from telemetry import append_turn_log, create_session_metadata
|
| 26 |
from utils import logger
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
# ============================================================
|
| 29 |
# 全局游戏实例(每个会话独立)
|
|
@@ -37,8 +82,8 @@ def create_new_game(player_name: str = "旅人") -> dict:
|
|
| 37 |
"""创建新游戏实例,返回包含所有引擎的字典"""
|
| 38 |
game_state = GameState(player_name=player_name)
|
| 39 |
nlu = NLUEngine(game_state)
|
| 40 |
-
story = StoryEngine(game_state)
|
| 41 |
-
return {
|
| 42 |
"game_state": game_state,
|
| 43 |
"nlu": nlu,
|
| 44 |
"story": story,
|
|
@@ -167,14 +212,24 @@ def _build_option_intent(selected_option: dict) -> dict:
|
|
| 167 |
option_text = selected_option.get("text", "")
|
| 168 |
return {
|
| 169 |
"intent": selected_option.get("action_type", "EXPLORE"),
|
| 170 |
-
"target":
|
| 171 |
"details": option_text,
|
| 172 |
"raw_input": option_text,
|
| 173 |
"parser_source": "option_click",
|
| 174 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
|
| 176 |
|
| 177 |
-
def restart_game() -> tuple:
|
| 178 |
"""
|
| 179 |
重启冒险:清空所有数据,回到初始输入名称阶段。
|
| 180 |
|
|
@@ -183,16 +238,15 @@ def restart_game() -> tuple:
|
|
| 183 |
禁用文本输入框, 重置角色名称)
|
| 184 |
"""
|
| 185 |
loading = _get_loading_button_updates()
|
| 186 |
-
return (
|
| 187 |
-
[], # 清空聊天历史
|
| 188 |
-
"## 等待开始...\n\n请输入角色名称并点击「开始冒险」", # 重置状态面板
|
| 189 |
-
|
| 190 |
-
loading
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
gr.update(value=""
|
| 194 |
-
|
| 195 |
-
)
|
| 196 |
|
| 197 |
|
| 198 |
# ============================================================
|
|
@@ -218,12 +272,12 @@ def start_game(player_name: str, game_session: dict):
|
|
| 218 |
status_text = _format_status_panel(game_session["game_state"])
|
| 219 |
loading = _get_loading_button_updates()
|
| 220 |
|
| 221 |
-
yield (
|
| 222 |
-
chat_history, status_text,
|
| 223 |
-
loading
|
| 224 |
-
game_session,
|
| 225 |
-
gr.update(interactive=False),
|
| 226 |
-
)
|
| 227 |
|
| 228 |
# 流式生成开场(选项仅在流结束后从 final 事件中提取,流式期间不解析选项)
|
| 229 |
turn_started = perf_counter()
|
|
@@ -234,12 +288,12 @@ def start_game(player_name: str, game_session: dict):
|
|
| 234 |
if update["type"] == "story_chunk":
|
| 235 |
story_text = update["text"]
|
| 236 |
chat_history[-1]["content"] = story_text
|
| 237 |
-
yield (
|
| 238 |
-
chat_history, status_text,
|
| 239 |
-
loading
|
| 240 |
-
game_session,
|
| 241 |
-
gr.update(interactive=False),
|
| 242 |
-
)
|
| 243 |
elif update["type"] == "final":
|
| 244 |
final_result = update
|
| 245 |
|
|
@@ -253,7 +307,7 @@ def start_game(player_name: str, game_session: dict):
|
|
| 253 |
options = []
|
| 254 |
|
| 255 |
# ★ 安全兜底:强制确保恰好 3 个选项
|
| 256 |
-
options =
|
| 257 |
|
| 258 |
# 最终 yield:显示完整文本 + 选项 + 启用按钮
|
| 259 |
game_session["current_options"] = options
|
|
@@ -287,12 +341,12 @@ def start_game(player_name: str, game_session: dict):
|
|
| 287 |
final_result=final_result,
|
| 288 |
)
|
| 289 |
|
| 290 |
-
yield (
|
| 291 |
-
chat_history, status_text,
|
| 292 |
-
btn_updates
|
| 293 |
-
game_session,
|
| 294 |
-
gr.update(interactive=True),
|
| 295 |
-
)
|
| 296 |
|
| 297 |
|
| 298 |
def process_user_input(user_input: str, chat_history: list, game_session: dict):
|
|
@@ -304,24 +358,25 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
|
|
| 304 |
2. 叙事引擎流式生成故事
|
| 305 |
3. 逐步更新 UI
|
| 306 |
"""
|
| 307 |
-
if not game_session or not game_session.get("started"):
|
| 308 |
-
chat_history = chat_history or []
|
| 309 |
-
chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
|
| 310 |
-
loading = _get_loading_button_updates()
|
| 311 |
-
yield (
|
| 312 |
-
chat_history, "",
|
| 313 |
-
loading
|
| 314 |
-
game_session,
|
| 315 |
-
)
|
| 316 |
-
return
|
| 317 |
-
|
| 318 |
-
if not user_input.strip():
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
|
|
|
| 325 |
|
| 326 |
gs: GameState = game_session["game_state"]
|
| 327 |
nlu: NLUEngine = game_session["nlu"]
|
|
@@ -329,18 +384,22 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
|
|
| 329 |
turn_started = perf_counter()
|
| 330 |
|
| 331 |
# 检查游戏是否已结束
|
| 332 |
-
if gs.is_game_over():
|
| 333 |
-
chat_history.append({"role": "user", "content": user_input})
|
| 334 |
-
chat_history.append({"role": "assistant", "content": "游戏已结束。请点击「重新开始」按钮开始新的冒险。"})
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
|
| 345 |
# 1. NLU 解析
|
| 346 |
nlu_started = perf_counter()
|
|
@@ -350,10 +409,10 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
|
|
| 350 |
# 1.5 预校验:立即驳回违反一致性的操作(不调用 LLM,不消耗回合)
|
| 351 |
is_valid, rejection_msg = gs.pre_validate_action(intent)
|
| 352 |
if not is_valid:
|
| 353 |
-
chat_history.append({"role": "user", "content": user_input})
|
| 354 |
-
options = game_session.get("current_options", [])
|
| 355 |
-
options =
|
| 356 |
-
options_text = _format_options(options)
|
| 357 |
rejection_content = (
|
| 358 |
f"⚠️ **行动被驳回**:{rejection_msg}\n\n"
|
| 359 |
f"请重新选择行动,或输入其他指令。\n\n{options_text}"
|
|
@@ -383,26 +442,30 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
|
|
| 383 |
final_result=rejection_result,
|
| 384 |
)
|
| 385 |
btn_updates = _get_button_updates(options)
|
| 386 |
-
yield (
|
| 387 |
-
chat_history,
|
| 388 |
-
_format_status_panel(gs),
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
|
|
|
| 393 |
|
| 394 |
# 2. 添加用户消息 + 空的 assistant 消息(用于流式填充)
|
| 395 |
chat_history.append({"role": "user", "content": user_input})
|
| 396 |
chat_history.append({"role": "assistant", "content": "⏳ 正在生成..."})
|
| 397 |
|
| 398 |
# 按钮保持可见但禁用,防止流式期间点击
|
| 399 |
-
loading = _get_loading_button_updates(
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
|
|
|
|
|
|
|
|
|
| 406 |
|
| 407 |
# 3. 流式生成故事
|
| 408 |
generation_started = perf_counter()
|
|
@@ -412,10 +475,11 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
|
|
| 412 |
chat_history[-1]["content"] = update["text"]
|
| 413 |
yield (
|
| 414 |
chat_history,
|
| 415 |
-
_format_status_panel(gs),
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
|
|
|
| 419 |
elif update["type"] == "final":
|
| 420 |
final_result = update
|
| 421 |
|
|
@@ -424,8 +488,8 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
|
|
| 424 |
# 4. 最终更新:完整文本 + 状态变化 + 选项 + 按钮
|
| 425 |
if final_result:
|
| 426 |
# ★ 安全兜底:强制确保恰好 3 个选项
|
| 427 |
-
options =
|
| 428 |
-
game_session["current_options"] = options
|
| 429 |
|
| 430 |
change_log = final_result.get("change_log", [])
|
| 431 |
log_text = ""
|
|
@@ -459,15 +523,16 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
|
|
| 459 |
|
| 460 |
yield (
|
| 461 |
chat_history,
|
| 462 |
-
status_text,
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
|
|
|
| 466 |
else:
|
| 467 |
# ★ 兜底:final_result 为空,说明流式生成未产生 final 事件
|
| 468 |
logger.warning("流式生成未产生 final 事件,使用兜底文本")
|
| 469 |
fallback_text = "你环顾四周,思考着接下来该做什么..."
|
| 470 |
-
fallback_options =
|
| 471 |
game_session["current_options"] = fallback_options
|
| 472 |
|
| 473 |
options_text = _format_options(fallback_options)
|
|
@@ -500,42 +565,45 @@ def process_user_input(user_input: str, chat_history: list, game_session: dict):
|
|
| 500 |
final_result=fallback_result,
|
| 501 |
)
|
| 502 |
|
| 503 |
-
yield (
|
| 504 |
-
chat_history,
|
| 505 |
-
status_text,
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
|
| 512 |
-
def process_option_click(option_idx: int, chat_history: list, game_session: dict):
|
| 513 |
-
"""
|
| 514 |
-
处理玩家点击选项按钮(流式版本)。
|
| 515 |
|
| 516 |
-
|
| 517 |
-
option_idx: 选项索引 (0, 1, 2)
|
| 518 |
"""
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
)
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
)
|
| 538 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 539 |
|
| 540 |
selected_option = options[option_idx]
|
| 541 |
gs: GameState = game_session["game_state"]
|
|
@@ -549,15 +617,15 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
|
|
| 549 |
game_session = create_new_game(gs.player.name)
|
| 550 |
game_session["started"] = True
|
| 551 |
|
| 552 |
-
chat_history = [{"role": "assistant", "content": "⏳ 正在重新生成开场..."}]
|
| 553 |
-
status_text = _format_status_panel(game_session["game_state"])
|
| 554 |
-
loading = _get_loading_button_updates()
|
| 555 |
-
|
| 556 |
-
yield (
|
| 557 |
-
chat_history, status_text,
|
| 558 |
-
loading
|
| 559 |
-
game_session,
|
| 560 |
-
)
|
| 561 |
|
| 562 |
story_text = ""
|
| 563 |
restart_final = None
|
|
@@ -566,11 +634,11 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
|
|
| 566 |
if update["type"] == "story_chunk":
|
| 567 |
story_text = update["text"]
|
| 568 |
chat_history[-1]["content"] = story_text
|
| 569 |
-
yield (
|
| 570 |
-
chat_history, status_text,
|
| 571 |
-
loading
|
| 572 |
-
game_session,
|
| 573 |
-
)
|
| 574 |
elif update["type"] == "final":
|
| 575 |
restart_final = update
|
| 576 |
|
|
@@ -582,8 +650,8 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
|
|
| 582 |
restart_options = []
|
| 583 |
|
| 584 |
# ★ 安全兜底:强制确保恰好 3 个选项
|
| 585 |
-
restart_options =
|
| 586 |
-
game_session["current_options"] = restart_options
|
| 587 |
options_text = _format_options(restart_options)
|
| 588 |
full_message = f"{story_text}\n\n{options_text}"
|
| 589 |
chat_history[-1]["content"] = full_message
|
|
@@ -591,39 +659,44 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
|
|
| 591 |
status_text = _format_status_panel(game_session["game_state"])
|
| 592 |
btn_updates = _get_button_updates(restart_options)
|
| 593 |
|
| 594 |
-
yield (
|
| 595 |
-
chat_history, status_text,
|
| 596 |
-
btn_updates
|
| 597 |
-
game_session,
|
| 598 |
-
)
|
| 599 |
-
return
|
| 600 |
|
| 601 |
# 检查特殊选项:退出
|
| 602 |
if selected_option.get("action_type") == "QUIT":
|
| 603 |
chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
|
| 604 |
-
chat_history.append({"role": "assistant", "content": "感谢游玩 StoryWeaver!\n你的冒险到此结束,但故事永远不会真正终结...\n\n点击「开始冒险」可以重新开始。"})
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
|
| 615 |
# 正常选项处理:流式生成
|
| 616 |
chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
|
| 617 |
chat_history.append({"role": "assistant", "content": "⏳ 正在生成..."})
|
| 618 |
|
| 619 |
# 按钮保持可见但禁用
|
| 620 |
-
loading = _get_loading_button_updates()
|
| 621 |
-
yield (
|
| 622 |
-
chat_history,
|
| 623 |
-
_format_status_panel(gs),
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
|
|
|
| 627 |
|
| 628 |
generation_started = perf_counter()
|
| 629 |
final_result = None
|
|
@@ -632,10 +705,11 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
|
|
| 632 |
chat_history[-1]["content"] = update["text"]
|
| 633 |
yield (
|
| 634 |
chat_history,
|
| 635 |
-
_format_status_panel(gs),
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
|
|
|
| 639 |
elif update["type"] == "final":
|
| 640 |
final_result = update
|
| 641 |
|
|
@@ -643,8 +717,8 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
|
|
| 643 |
|
| 644 |
if final_result:
|
| 645 |
# ★ 安全兜底:强制确保恰好 3 个选项
|
| 646 |
-
options =
|
| 647 |
-
game_session["current_options"] = options
|
| 648 |
|
| 649 |
change_log = final_result.get("change_log", [])
|
| 650 |
log_text = ""
|
|
@@ -671,15 +745,15 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
|
|
| 671 |
)
|
| 672 |
|
| 673 |
yield (
|
| 674 |
-
chat_history, status_text,
|
| 675 |
-
btn_updates
|
| 676 |
-
game_session,
|
| 677 |
-
)
|
| 678 |
-
else:
|
| 679 |
# ★ 兜底:final_result 为空,说明流式生成未产生 final 事件
|
| 680 |
logger.warning("[选项点击] 流式生成未产生 final 事件,使用兜底文本")
|
| 681 |
fallback_text = "你环顾四周,思考着接下来该做什么..."
|
| 682 |
-
fallback_options =
|
| 683 |
game_session["current_options"] = fallback_options
|
| 684 |
|
| 685 |
options_text = _format_options(fallback_options)
|
|
@@ -712,59 +786,70 @@ def process_option_click(option_idx: int, chat_history: list, game_session: dict
|
|
| 712 |
selected_option=selected_option,
|
| 713 |
)
|
| 714 |
|
| 715 |
-
yield (
|
| 716 |
-
chat_history, status_text,
|
| 717 |
-
btn_updates
|
| 718 |
-
game_session,
|
| 719 |
-
)
|
| 720 |
return
|
| 721 |
|
| 722 |
|
| 723 |
-
# ============================================================
|
| 724 |
-
# UI 辅助函数
|
| 725 |
-
# ============================================================
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
|
| 729 |
-
|
| 730 |
-
|
| 731 |
-
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 768 |
|
| 769 |
|
| 770 |
def _format_options(options: list[dict]) -> str:
|
|
@@ -784,33 +869,36 @@ def _format_options(options: list[dict]) -> str:
|
|
| 784 |
return "\n".join(lines)
|
| 785 |
|
| 786 |
|
| 787 |
-
def _get_loading_button_updates() -> list:
|
| 788 |
-
"""返回
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
|
| 799 |
-
|
| 800 |
-
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
| 807 |
-
|
| 808 |
-
if
|
| 809 |
-
|
| 810 |
-
|
| 811 |
-
|
| 812 |
-
|
| 813 |
-
|
|
|
|
|
|
|
|
|
|
| 814 |
|
| 815 |
|
| 816 |
def _format_status_panel(gs: GameState) -> str:
|
|
@@ -820,14 +908,17 @@ def _format_status_panel(gs: GameState) -> str:
|
|
| 820 |
effective_stats = gs.get_effective_player_stats()
|
| 821 |
equipment_bonuses = gs.get_equipment_stat_bonuses()
|
| 822 |
env_snapshot = gs.get_environment_snapshot(limit=3)
|
|
|
|
| 823 |
scene_summary = gs.get_scene_summary().replace("\n", "<br>")
|
|
|
|
| 824 |
|
| 825 |
# 属性进度条
|
| 826 |
hp_bar = _progress_bar(p.hp, p.max_hp, "HP")
|
| 827 |
-
mp_bar = _progress_bar(p.mp, p.max_mp, "MP")
|
| 828 |
-
|
| 829 |
-
|
| 830 |
-
|
|
|
|
| 831 |
|
| 832 |
# 装备
|
| 833 |
slot_names = {
|
|
@@ -977,13 +1068,14 @@ def _format_status_panel(gs: GameState) -> str:
|
|
| 977 |
<div>
|
| 978 |
<h4 style="margin:4px 0 2px 0;">🩸 生命与状态</h4>
|
| 979 |
<span style="font-size:0.85em;">
|
| 980 |
-
{hp_bar}<br>
|
| 981 |
-
{mp_bar}<br>
|
| 982 |
-
{
|
| 983 |
-
{
|
| 984 |
-
{
|
| 985 |
-
|
| 986 |
-
</
|
|
|
|
| 987 |
|
| 988 |
<div>
|
| 989 |
<h4 style="margin:4px 0 2px 0;">⚔️ 战斗属性</h4>
|
|
@@ -1026,18 +1118,15 @@ def _format_status_panel(gs: GameState) -> str:
|
|
| 1026 |
</div>
|
| 1027 |
|
| 1028 |
<div style="grid-column: 1 / -1;">
|
| 1029 |
-
<h4 style="margin:4px 0 2px 0;">
|
| 1030 |
-
<div style="font-size:0.85em;">
|
| 1031 |
{env_badges}
|
| 1032 |
-
|
| 1033 |
-
|
| 1034 |
-
</div>
|
| 1035 |
</div>
|
| 1036 |
-
|
| 1037 |
-
<div style="
|
| 1038 |
-
<
|
| 1039 |
-
<div style="font-size:0.85em;line-height:1.5;padding:8px 10px;border-radius:10px;background:#fff7ed;border:1px solid #fed7aa;">
|
| 1040 |
-
{scene_summary}
|
| 1041 |
</div>
|
| 1042 |
</div>
|
| 1043 |
|
|
@@ -1051,8 +1140,8 @@ def _format_status_panel(gs: GameState) -> str:
|
|
| 1051 |
<div>
|
| 1052 |
<h4 style="margin:4px 0 2px 0;">🌍 世界信息</h4>
|
| 1053 |
<span style="font-size:0.85em;">
|
| 1054 |
-
位置: {w.current_scene}<br>
|
| 1055 |
-
时间: 第{w.day_count}天 {w.time_of_day}<br>
|
| 1056 |
天气: {w.weather}<br>
|
| 1057 |
光照: {w.light_level}<br>
|
| 1058 |
季节: {w.season}<br>
|
|
@@ -1079,12 +1168,13 @@ def _progress_bar(current: int, maximum: int, label: str, length: int = 10) -> s
|
|
| 1079 |
# ============================================================
|
| 1080 |
|
| 1081 |
|
| 1082 |
-
def build_app() -> gr.Blocks:
|
| 1083 |
-
"""构建 Gradio 界面"""
|
| 1084 |
-
|
| 1085 |
-
with gr.Blocks(
|
| 1086 |
-
title="StoryWeaver - 交互式叙事系统",
|
| 1087 |
-
) as app:
|
|
|
|
| 1088 |
|
| 1089 |
gr.Markdown(
|
| 1090 |
"""
|
|
@@ -1126,17 +1216,54 @@ def build_app() -> gr.Blocks:
|
|
| 1126 |
height=480,
|
| 1127 |
)
|
| 1128 |
|
| 1129 |
-
# 选项按钮(
|
| 1130 |
-
with gr.
|
| 1131 |
-
|
| 1132 |
-
|
| 1133 |
-
|
| 1134 |
-
|
| 1135 |
-
|
| 1136 |
-
|
| 1137 |
-
|
| 1138 |
-
|
| 1139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1140 |
|
| 1141 |
# 自由输入
|
| 1142 |
with gr.Row():
|
|
@@ -1148,109 +1275,100 @@ def build_app() -> gr.Blocks:
|
|
| 1148 |
)
|
| 1149 |
send_btn = gr.Button("发送", variant="primary", scale=1)
|
| 1150 |
|
| 1151 |
-
# ==================
|
| 1152 |
-
# 右侧:状态面板
|
| 1153 |
-
# ==================
|
| 1154 |
-
with gr.Column(scale=2, min_width=250):
|
| 1155 |
-
|
| 1156 |
-
value=
|
| 1157 |
-
|
| 1158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1159 |
|
| 1160 |
# ============================================================
|
| 1161 |
# 事件绑定
|
| 1162 |
# ============================================================
|
| 1163 |
|
| 1164 |
# 开始游戏
|
| 1165 |
-
start_btn.click(
|
| 1166 |
-
fn=start_game,
|
| 1167 |
-
inputs=[player_name_input, game_session],
|
| 1168 |
-
outputs=[
|
| 1169 |
-
chatbot, status_panel,
|
| 1170 |
-
|
| 1171 |
-
game_session, user_input,
|
| 1172 |
-
],
|
| 1173 |
-
)
|
| 1174 |
|
| 1175 |
# 重启冒险
|
| 1176 |
-
restart_btn.click(
|
| 1177 |
-
fn=restart_game,
|
| 1178 |
-
inputs=[],
|
| 1179 |
-
outputs=[
|
| 1180 |
-
chatbot, status_panel,
|
| 1181 |
-
|
| 1182 |
-
game_session, user_input, player_name_input,
|
| 1183 |
-
],
|
| 1184 |
-
)
|
| 1185 |
|
| 1186 |
# 文本输入发送
|
| 1187 |
-
send_btn.click(
|
| 1188 |
-
fn=process_user_input,
|
| 1189 |
-
inputs=[user_input, chatbot, game_session],
|
| 1190 |
-
outputs=[
|
| 1191 |
-
chatbot, status_panel,
|
| 1192 |
-
|
| 1193 |
-
game_session,
|
| 1194 |
-
],
|
| 1195 |
-
).then(
|
| 1196 |
fn=lambda: "",
|
| 1197 |
outputs=[user_input],
|
| 1198 |
)
|
| 1199 |
|
| 1200 |
# 回车发送
|
| 1201 |
-
user_input.submit(
|
| 1202 |
-
fn=process_user_input,
|
| 1203 |
-
inputs=[user_input, chatbot, game_session],
|
| 1204 |
-
outputs=[
|
| 1205 |
-
chatbot, status_panel,
|
| 1206 |
-
|
| 1207 |
-
game_session,
|
| 1208 |
-
],
|
| 1209 |
-
).then(
|
| 1210 |
fn=lambda: "",
|
| 1211 |
outputs=[user_input],
|
| 1212 |
)
|
| 1213 |
|
| 1214 |
# 选项按钮点击(需要使用 yield from 的生成器包装函数,
|
| 1215 |
# 使 Gradio 能正确识别为流式输出)
|
| 1216 |
-
def
|
| 1217 |
-
|
| 1218 |
-
|
| 1219 |
-
|
| 1220 |
-
|
| 1221 |
-
|
| 1222 |
-
|
| 1223 |
-
|
| 1224 |
-
|
| 1225 |
-
|
| 1226 |
-
|
| 1227 |
-
|
| 1228 |
-
|
| 1229 |
-
|
| 1230 |
-
|
| 1231 |
-
|
| 1232 |
-
],
|
| 1233 |
-
)
|
| 1234 |
-
|
| 1235 |
-
option_btn_2.click(
|
| 1236 |
-
fn=_option_click_1,
|
| 1237 |
-
inputs=[chatbot, game_session],
|
| 1238 |
-
outputs=[
|
| 1239 |
-
chatbot, status_panel,
|
| 1240 |
-
option_btn_1, option_btn_2, option_btn_3,
|
| 1241 |
-
game_session,
|
| 1242 |
-
],
|
| 1243 |
-
)
|
| 1244 |
-
|
| 1245 |
-
option_btn_3.click(
|
| 1246 |
-
fn=_option_click_2,
|
| 1247 |
-
inputs=[chatbot, game_session],
|
| 1248 |
-
outputs=[
|
| 1249 |
-
chatbot, status_panel,
|
| 1250 |
-
option_btn_1, option_btn_2, option_btn_3,
|
| 1251 |
-
game_session,
|
| 1252 |
-
],
|
| 1253 |
-
)
|
| 1254 |
|
| 1255 |
return app
|
| 1256 |
|
|
@@ -1271,9 +1389,5 @@ if __name__ == "__main__":
|
|
| 1271 |
primary_hue="emerald",
|
| 1272 |
secondary_hue="blue",
|
| 1273 |
),
|
| 1274 |
-
css=
|
| 1275 |
-
|
| 1276 |
-
.status-panel {font-family: monospace; font-size: 0.85em;}
|
| 1277 |
-
.option-btn {min-height: 50px !important;}
|
| 1278 |
-
""",
|
| 1279 |
-
)
|
|
|
|
| 21 |
|
| 22 |
from state_manager import GameState
|
| 23 |
from nlu_engine import NLUEngine
|
| 24 |
+
from scene_assets import get_scene_image_path
|
| 25 |
from story_engine import StoryEngine
|
| 26 |
from telemetry import append_turn_log, create_session_metadata
|
| 27 |
from utils import logger
|
| 28 |
+
|
| 29 |
+
APP_UI_CSS = """
|
| 30 |
+
.story-chat {min-height: 500px;}
|
| 31 |
+
.status-panel {
|
| 32 |
+
font-family: "Microsoft YaHei UI", "Noto Sans SC", sans-serif;
|
| 33 |
+
font-size: 0.88em;
|
| 34 |
+
line-height: 1.6;
|
| 35 |
+
}
|
| 36 |
+
.option-btn {min-height: 50px !important;}
|
| 37 |
+
.scene-sidebar {gap: 14px;}
|
| 38 |
+
.scene-card {
|
| 39 |
+
border: 1px solid rgba(161, 125, 83, 0.14) !important;
|
| 40 |
+
border-radius: 18px !important;
|
| 41 |
+
background:
|
| 42 |
+
linear-gradient(180deg, rgba(255, 249, 241, 0.98) 0%,
|
| 43 |
+
rgba(246, 238, 226, 0.96) 100%) !important;
|
| 44 |
+
box-shadow: 0 14px 28px rgba(110, 81, 49, 0.08) !important;
|
| 45 |
+
}
|
| 46 |
+
.scene-image {
|
| 47 |
+
min-height: 280px;
|
| 48 |
+
padding: 14px !important;
|
| 49 |
+
}
|
| 50 |
+
.scene-image > div,
|
| 51 |
+
.scene-image img,
|
| 52 |
+
.scene-image button,
|
| 53 |
+
.scene-image [class*="image"],
|
| 54 |
+
.scene-image [class*="wrap"],
|
| 55 |
+
.scene-image [class*="frame"],
|
| 56 |
+
.scene-image [class*="preview"] {
|
| 57 |
+
border: none !important;
|
| 58 |
+
box-shadow: none !important;
|
| 59 |
+
background: transparent !important;
|
| 60 |
+
}
|
| 61 |
+
.scene-image img {
|
| 62 |
+
width: 100%;
|
| 63 |
+
height: 100%;
|
| 64 |
+
object-fit: contain !important;
|
| 65 |
+
border-radius: 14px;
|
| 66 |
+
padding: 8px;
|
| 67 |
+
background:
|
| 68 |
+
radial-gradient(circle at top, rgba(255, 255, 255, 0.95) 0%,
|
| 69 |
+
rgba(247, 239, 227, 0.92) 100%) !important;
|
| 70 |
+
}
|
| 71 |
+
"""
|
| 72 |
|
| 73 |
# ============================================================
|
| 74 |
# 全局游戏实例(每个会话独立)
|
|
|
|
| 82 |
"""创建新游戏实例,返回包含所有引擎的字典"""
|
| 83 |
game_state = GameState(player_name=player_name)
|
| 84 |
nlu = NLUEngine(game_state)
|
| 85 |
+
story = StoryEngine(game_state, enable_rule_text_polish=True)
|
| 86 |
+
return {
|
| 87 |
"game_state": game_state,
|
| 88 |
"nlu": nlu,
|
| 89 |
"story": story,
|
|
|
|
| 212 |
option_text = selected_option.get("text", "")
|
| 213 |
return {
|
| 214 |
"intent": selected_option.get("action_type", "EXPLORE"),
|
| 215 |
+
"target": selected_option.get("target"),
|
| 216 |
"details": option_text,
|
| 217 |
"raw_input": option_text,
|
| 218 |
"parser_source": "option_click",
|
| 219 |
}
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def _get_scene_image_value(gs: GameState) -> str | None:
|
| 223 |
+
focus_npc = getattr(gs, "last_interacted_npc", None)
|
| 224 |
+
return get_scene_image_path(gs, focus_npc=focus_npc)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _get_scene_image_update(gs: GameState):
|
| 228 |
+
image_value = _get_scene_image_value(gs)
|
| 229 |
+
return gr.update(value=image_value, visible=bool(image_value))
|
| 230 |
|
| 231 |
|
| 232 |
+
def restart_game() -> tuple:
|
| 233 |
"""
|
| 234 |
重启冒险:清空所有数据,回到初始输入名称阶段。
|
| 235 |
|
|
|
|
| 238 |
禁用文本输入框, 重置角色名称)
|
| 239 |
"""
|
| 240 |
loading = _get_loading_button_updates()
|
| 241 |
+
return (
|
| 242 |
+
[], # 清空聊天历史
|
| 243 |
+
"## 等待开始...\n\n请输入角色名称并点击「开始冒险」", # 重置状态面板
|
| 244 |
+
gr.update(value=None, visible=False), # 清空场景图片
|
| 245 |
+
*loading, # 占位选项按钮
|
| 246 |
+
{}, # 清空游戏会话
|
| 247 |
+
gr.update(value="", interactive=False), # 禁用并清空文本输入
|
| 248 |
+
gr.update(value="旅人"), # 重置角色名称
|
| 249 |
+
)
|
|
|
|
| 250 |
|
| 251 |
|
| 252 |
# ============================================================
|
|
|
|
| 272 |
status_text = _format_status_panel(game_session["game_state"])
|
| 273 |
loading = _get_loading_button_updates()
|
| 274 |
|
| 275 |
+
yield (
|
| 276 |
+
chat_history, status_text, _get_scene_image_update(game_session["game_state"]),
|
| 277 |
+
*loading,
|
| 278 |
+
game_session,
|
| 279 |
+
gr.update(interactive=False),
|
| 280 |
+
)
|
| 281 |
|
| 282 |
# 流式生成开场(选项仅在流结束后从 final 事件中提取,流式期间不解析选项)
|
| 283 |
turn_started = perf_counter()
|
|
|
|
| 288 |
if update["type"] == "story_chunk":
|
| 289 |
story_text = update["text"]
|
| 290 |
chat_history[-1]["content"] = story_text
|
| 291 |
+
yield (
|
| 292 |
+
chat_history, status_text, _get_scene_image_update(game_session["game_state"]),
|
| 293 |
+
*loading,
|
| 294 |
+
game_session,
|
| 295 |
+
gr.update(interactive=False),
|
| 296 |
+
)
|
| 297 |
elif update["type"] == "final":
|
| 298 |
final_result = update
|
| 299 |
|
|
|
|
| 307 |
options = []
|
| 308 |
|
| 309 |
# ★ 安全兜底:强制确保恰好 3 个选项
|
| 310 |
+
options = _finalize_session_options(options)
|
| 311 |
|
| 312 |
# 最终 yield:显示完整文本 + 选项 + 启用按钮
|
| 313 |
game_session["current_options"] = options
|
|
|
|
| 341 |
final_result=final_result,
|
| 342 |
)
|
| 343 |
|
| 344 |
+
yield (
|
| 345 |
+
chat_history, status_text, _get_scene_image_update(game_session["game_state"]),
|
| 346 |
+
*btn_updates,
|
| 347 |
+
game_session,
|
| 348 |
+
gr.update(interactive=True),
|
| 349 |
+
)
|
| 350 |
|
| 351 |
|
| 352 |
def process_user_input(user_input: str, chat_history: list, game_session: dict):
|
|
|
|
| 358 |
2. 叙事引擎流式生成故事
|
| 359 |
3. 逐步更新 UI
|
| 360 |
"""
|
| 361 |
+
if not game_session or not game_session.get("started"):
|
| 362 |
+
chat_history = chat_history or []
|
| 363 |
+
chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
|
| 364 |
+
loading = _get_loading_button_updates()
|
| 365 |
+
yield (
|
| 366 |
+
chat_history, "", gr.update(value=None, visible=False),
|
| 367 |
+
*loading,
|
| 368 |
+
game_session,
|
| 369 |
+
)
|
| 370 |
+
return
|
| 371 |
+
|
| 372 |
+
if not user_input.strip():
|
| 373 |
+
btn_updates = _get_button_updates(game_session.get("current_options", []))
|
| 374 |
+
yield (
|
| 375 |
+
chat_history, _format_status_panel(game_session["game_state"]), _get_scene_image_update(game_session["game_state"]),
|
| 376 |
+
*btn_updates,
|
| 377 |
+
game_session,
|
| 378 |
+
)
|
| 379 |
+
return
|
| 380 |
|
| 381 |
gs: GameState = game_session["game_state"]
|
| 382 |
nlu: NLUEngine = game_session["nlu"]
|
|
|
|
| 384 |
turn_started = perf_counter()
|
| 385 |
|
| 386 |
# 检查游戏是否已结束
|
| 387 |
+
if gs.is_game_over():
|
| 388 |
+
chat_history.append({"role": "user", "content": user_input})
|
| 389 |
+
chat_history.append({"role": "assistant", "content": "游戏已结束。请点击「重新开始」按钮开始新的冒险。"})
|
| 390 |
+
restart_buttons = _get_button_updates(
|
| 391 |
+
[
|
| 392 |
+
{"id": 1, "text": "重新开始", "action_type": "RESTART"},
|
| 393 |
+
]
|
| 394 |
+
)
|
| 395 |
+
yield (
|
| 396 |
+
chat_history,
|
| 397 |
+
_format_status_panel(gs),
|
| 398 |
+
_get_scene_image_update(gs),
|
| 399 |
+
*restart_buttons,
|
| 400 |
+
game_session,
|
| 401 |
+
)
|
| 402 |
+
return
|
| 403 |
|
| 404 |
# 1. NLU 解析
|
| 405 |
nlu_started = perf_counter()
|
|
|
|
| 409 |
# 1.5 预校验:立即驳回违反一致性的操作(不调用 LLM,不消耗回合)
|
| 410 |
is_valid, rejection_msg = gs.pre_validate_action(intent)
|
| 411 |
if not is_valid:
|
| 412 |
+
chat_history.append({"role": "user", "content": user_input})
|
| 413 |
+
options = game_session.get("current_options", [])
|
| 414 |
+
options = _finalize_session_options(options)
|
| 415 |
+
options_text = _format_options(options)
|
| 416 |
rejection_content = (
|
| 417 |
f"⚠️ **行动被驳回**:{rejection_msg}\n\n"
|
| 418 |
f"请重新选择行动,或输入其他指令。\n\n{options_text}"
|
|
|
|
| 442 |
final_result=rejection_result,
|
| 443 |
)
|
| 444 |
btn_updates = _get_button_updates(options)
|
| 445 |
+
yield (
|
| 446 |
+
chat_history,
|
| 447 |
+
_format_status_panel(gs),
|
| 448 |
+
_get_scene_image_update(gs),
|
| 449 |
+
*btn_updates,
|
| 450 |
+
game_session,
|
| 451 |
+
)
|
| 452 |
+
return
|
| 453 |
|
| 454 |
# 2. 添加用户消息 + 空的 assistant 消息(用于流式填充)
|
| 455 |
chat_history.append({"role": "user", "content": user_input})
|
| 456 |
chat_history.append({"role": "assistant", "content": "⏳ 正在生成..."})
|
| 457 |
|
| 458 |
# 按钮保持可见但禁用,防止流式期间点击
|
| 459 |
+
loading = _get_loading_button_updates(
|
| 460 |
+
max(len(game_session.get("current_options", [])), MIN_OPTION_BUTTONS)
|
| 461 |
+
)
|
| 462 |
+
yield (
|
| 463 |
+
chat_history,
|
| 464 |
+
_format_status_panel(gs),
|
| 465 |
+
_get_scene_image_update(gs),
|
| 466 |
+
*loading,
|
| 467 |
+
game_session,
|
| 468 |
+
)
|
| 469 |
|
| 470 |
# 3. 流式生成故事
|
| 471 |
generation_started = perf_counter()
|
|
|
|
| 475 |
chat_history[-1]["content"] = update["text"]
|
| 476 |
yield (
|
| 477 |
chat_history,
|
| 478 |
+
_format_status_panel(gs),
|
| 479 |
+
_get_scene_image_update(gs),
|
| 480 |
+
*loading,
|
| 481 |
+
game_session,
|
| 482 |
+
)
|
| 483 |
elif update["type"] == "final":
|
| 484 |
final_result = update
|
| 485 |
|
|
|
|
| 488 |
# 4. 最终更新:完整文本 + 状态变化 + 选项 + 按钮
|
| 489 |
if final_result:
|
| 490 |
# ★ 安全兜底:强制确保恰好 3 个选项
|
| 491 |
+
options = _finalize_session_options(final_result.get("options", []))
|
| 492 |
+
game_session["current_options"] = options
|
| 493 |
|
| 494 |
change_log = final_result.get("change_log", [])
|
| 495 |
log_text = ""
|
|
|
|
| 523 |
|
| 524 |
yield (
|
| 525 |
chat_history,
|
| 526 |
+
status_text,
|
| 527 |
+
_get_scene_image_update(gs),
|
| 528 |
+
*btn_updates,
|
| 529 |
+
game_session,
|
| 530 |
+
)
|
| 531 |
else:
|
| 532 |
# ★ 兜底:final_result 为空,说明流式生成未产生 final 事件
|
| 533 |
logger.warning("流式生成未产生 final 事件,使用兜底文本")
|
| 534 |
fallback_text = "你环顾四周,思考着接下来该做什么..."
|
| 535 |
+
fallback_options = _finalize_session_options([])
|
| 536 |
game_session["current_options"] = fallback_options
|
| 537 |
|
| 538 |
options_text = _format_options(fallback_options)
|
|
|
|
| 565 |
final_result=fallback_result,
|
| 566 |
)
|
| 567 |
|
| 568 |
+
yield (
|
| 569 |
+
chat_history,
|
| 570 |
+
status_text,
|
| 571 |
+
_get_scene_image_update(gs),
|
| 572 |
+
*btn_updates,
|
| 573 |
+
game_session,
|
| 574 |
+
)
|
| 575 |
+
return
|
| 576 |
|
|
|
|
|
|
|
|
|
|
| 577 |
|
| 578 |
+
def process_option_click(option_idx: int, chat_history: list, game_session: dict):
|
|
|
|
| 579 |
"""
|
| 580 |
+
处理玩家点击选项按钮(流式版本)。
|
| 581 |
+
|
| 582 |
+
Args:
|
| 583 |
+
option_idx: 选项索引 (0-5)
|
| 584 |
+
"""
|
| 585 |
+
if not game_session or not game_session.get("started"):
|
| 586 |
+
chat_history = chat_history or []
|
| 587 |
+
chat_history.append({"role": "assistant", "content": "请先点击「开始冒险」按钮!"})
|
| 588 |
+
loading = _get_loading_button_updates()
|
| 589 |
+
yield (
|
| 590 |
+
chat_history, "", gr.update(value=None, visible=False),
|
| 591 |
+
*loading,
|
| 592 |
+
game_session,
|
| 593 |
+
)
|
| 594 |
+
return
|
| 595 |
+
|
| 596 |
+
options = game_session.get("current_options", [])
|
| 597 |
+
if option_idx >= len(options):
|
| 598 |
+
btn_updates = _get_button_updates(options)
|
| 599 |
+
yield (
|
| 600 |
+
chat_history,
|
| 601 |
+
_format_status_panel(game_session["game_state"]),
|
| 602 |
+
_get_scene_image_update(game_session["game_state"]),
|
| 603 |
+
*btn_updates,
|
| 604 |
+
game_session,
|
| 605 |
+
)
|
| 606 |
+
return
|
| 607 |
|
| 608 |
selected_option = options[option_idx]
|
| 609 |
gs: GameState = game_session["game_state"]
|
|
|
|
| 617 |
game_session = create_new_game(gs.player.name)
|
| 618 |
game_session["started"] = True
|
| 619 |
|
| 620 |
+
chat_history = [{"role": "assistant", "content": "⏳ 正在重新生成开场..."}]
|
| 621 |
+
status_text = _format_status_panel(game_session["game_state"])
|
| 622 |
+
loading = _get_loading_button_updates()
|
| 623 |
+
|
| 624 |
+
yield (
|
| 625 |
+
chat_history, status_text, _get_scene_image_update(game_session["game_state"]),
|
| 626 |
+
*loading,
|
| 627 |
+
game_session,
|
| 628 |
+
)
|
| 629 |
|
| 630 |
story_text = ""
|
| 631 |
restart_final = None
|
|
|
|
| 634 |
if update["type"] == "story_chunk":
|
| 635 |
story_text = update["text"]
|
| 636 |
chat_history[-1]["content"] = story_text
|
| 637 |
+
yield (
|
| 638 |
+
chat_history, status_text, _get_scene_image_update(game_session["game_state"]),
|
| 639 |
+
*loading,
|
| 640 |
+
game_session,
|
| 641 |
+
)
|
| 642 |
elif update["type"] == "final":
|
| 643 |
restart_final = update
|
| 644 |
|
|
|
|
| 650 |
restart_options = []
|
| 651 |
|
| 652 |
# ★ 安全兜底:强制确保恰好 3 个选项
|
| 653 |
+
restart_options = _finalize_session_options(restart_options)
|
| 654 |
+
game_session["current_options"] = restart_options
|
| 655 |
options_text = _format_options(restart_options)
|
| 656 |
full_message = f"{story_text}\n\n{options_text}"
|
| 657 |
chat_history[-1]["content"] = full_message
|
|
|
|
| 659 |
status_text = _format_status_panel(game_session["game_state"])
|
| 660 |
btn_updates = _get_button_updates(restart_options)
|
| 661 |
|
| 662 |
+
yield (
|
| 663 |
+
chat_history, status_text, _get_scene_image_update(game_session["game_state"]),
|
| 664 |
+
*btn_updates,
|
| 665 |
+
game_session,
|
| 666 |
+
)
|
| 667 |
+
return
|
| 668 |
|
| 669 |
# 检查特殊选项:退出
|
| 670 |
if selected_option.get("action_type") == "QUIT":
|
| 671 |
chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
|
| 672 |
+
chat_history.append({"role": "assistant", "content": "感谢游玩 StoryWeaver!\n你的冒险到此结束,但故事永远不会真正终结...\n\n点击「开始冒险」可以重新开始。"})
|
| 673 |
+
quit_buttons = _get_button_updates(
|
| 674 |
+
[
|
| 675 |
+
{"id": 1, "text": "重新开始", "action_type": "RESTART"},
|
| 676 |
+
]
|
| 677 |
+
)
|
| 678 |
+
yield (
|
| 679 |
+
chat_history,
|
| 680 |
+
_format_status_panel(gs),
|
| 681 |
+
_get_scene_image_update(gs),
|
| 682 |
+
*quit_buttons,
|
| 683 |
+
game_session,
|
| 684 |
+
)
|
| 685 |
+
return
|
| 686 |
|
| 687 |
# 正常选项处理:流式生成
|
| 688 |
chat_history.append({"role": "user", "content": f"选择: {selected_option['text']}"})
|
| 689 |
chat_history.append({"role": "assistant", "content": "⏳ 正在生成..."})
|
| 690 |
|
| 691 |
# 按钮保持可见但禁用
|
| 692 |
+
loading = _get_loading_button_updates(max(len(options), MIN_OPTION_BUTTONS))
|
| 693 |
+
yield (
|
| 694 |
+
chat_history,
|
| 695 |
+
_format_status_panel(gs),
|
| 696 |
+
_get_scene_image_update(gs),
|
| 697 |
+
*loading,
|
| 698 |
+
game_session,
|
| 699 |
+
)
|
| 700 |
|
| 701 |
generation_started = perf_counter()
|
| 702 |
final_result = None
|
|
|
|
| 705 |
chat_history[-1]["content"] = update["text"]
|
| 706 |
yield (
|
| 707 |
chat_history,
|
| 708 |
+
_format_status_panel(gs),
|
| 709 |
+
_get_scene_image_update(gs),
|
| 710 |
+
*loading,
|
| 711 |
+
game_session,
|
| 712 |
+
)
|
| 713 |
elif update["type"] == "final":
|
| 714 |
final_result = update
|
| 715 |
|
|
|
|
| 717 |
|
| 718 |
if final_result:
|
| 719 |
# ★ 安全兜底:强制确保恰好 3 个选项
|
| 720 |
+
options = _finalize_session_options(final_result.get("options", []))
|
| 721 |
+
game_session["current_options"] = options
|
| 722 |
|
| 723 |
change_log = final_result.get("change_log", [])
|
| 724 |
log_text = ""
|
|
|
|
| 745 |
)
|
| 746 |
|
| 747 |
yield (
|
| 748 |
+
chat_history, status_text, _get_scene_image_update(gs),
|
| 749 |
+
*btn_updates,
|
| 750 |
+
game_session,
|
| 751 |
+
)
|
| 752 |
+
else:
|
| 753 |
# ★ 兜底:final_result 为空,说明流式生成未产生 final 事件
|
| 754 |
logger.warning("[选项点击] 流式生成未产生 final 事件,使用兜底文本")
|
| 755 |
fallback_text = "你环顾四周,思考着接下来该做什么..."
|
| 756 |
+
fallback_options = _finalize_session_options([])
|
| 757 |
game_session["current_options"] = fallback_options
|
| 758 |
|
| 759 |
options_text = _format_options(fallback_options)
|
|
|
|
| 786 |
selected_option=selected_option,
|
| 787 |
)
|
| 788 |
|
| 789 |
+
yield (
|
| 790 |
+
chat_history, status_text, _get_scene_image_update(gs),
|
| 791 |
+
*btn_updates,
|
| 792 |
+
game_session,
|
| 793 |
+
)
|
| 794 |
return
|
| 795 |
|
| 796 |
|
| 797 |
+
# ============================================================
|
| 798 |
+
# UI 辅助函数
|
| 799 |
+
# ============================================================
|
| 800 |
+
|
| 801 |
+
|
| 802 |
+
MIN_OPTION_BUTTONS = 3
|
| 803 |
+
MAX_OPTION_BUTTONS = 6
|
| 804 |
+
|
| 805 |
+
# 兜底默认选项(当解析出的选项为空时使用)
|
| 806 |
+
_FALLBACK_BUTTON_OPTIONS = [
|
| 807 |
+
{"id": 1, "text": "查看周围", "action_type": "EXPLORE"},
|
| 808 |
+
{"id": 2, "text": "等待一会", "action_type": "REST"},
|
| 809 |
+
{"id": 3, "text": "检查状态", "action_type": "EXPLORE"},
|
| 810 |
+
]
|
| 811 |
+
|
| 812 |
+
|
| 813 |
+
def _normalize_options(
|
| 814 |
+
options: list[dict],
|
| 815 |
+
*,
|
| 816 |
+
minimum: int = 0,
|
| 817 |
+
maximum: int = MAX_OPTION_BUTTONS,
|
| 818 |
+
) -> list[dict]:
|
| 819 |
+
"""
|
| 820 |
+
规范化选项列表:
|
| 821 |
+
- 至多保留 maximum 个选项
|
| 822 |
+
- 仅当 minimum > 0 时补充兜底项
|
| 823 |
+
- 始终重新编号
|
| 824 |
+
"""
|
| 825 |
+
if not isinstance(options, list):
|
| 826 |
+
options = []
|
| 827 |
+
|
| 828 |
+
normalized = [opt for opt in options if isinstance(opt, dict)][:maximum]
|
| 829 |
+
|
| 830 |
+
for fb in _FALLBACK_BUTTON_OPTIONS:
|
| 831 |
+
if len(normalized) >= minimum:
|
| 832 |
+
break
|
| 833 |
+
if not any(o.get("text") == fb["text"] for o in normalized):
|
| 834 |
+
normalized.append(fb.copy())
|
| 835 |
+
|
| 836 |
+
while len(normalized) < minimum:
|
| 837 |
+
normalized.append({
|
| 838 |
+
"id": len(normalized) + 1,
|
| 839 |
+
"text": "继续探索",
|
| 840 |
+
"action_type": "EXPLORE",
|
| 841 |
+
})
|
| 842 |
+
|
| 843 |
+
for i, opt in enumerate(normalized[:maximum], 1):
|
| 844 |
+
if isinstance(opt, dict):
|
| 845 |
+
opt["id"] = i
|
| 846 |
+
|
| 847 |
+
return normalized[:maximum]
|
| 848 |
+
|
| 849 |
+
|
| 850 |
+
def _finalize_session_options(options: list[dict]) -> list[dict]:
|
| 851 |
+
minimum = MIN_OPTION_BUTTONS if not options else 0
|
| 852 |
+
return _normalize_options(options, minimum=minimum)
|
| 853 |
|
| 854 |
|
| 855 |
def _format_options(options: list[dict]) -> str:
|
|
|
|
| 869 |
return "\n".join(lines)
|
| 870 |
|
| 871 |
|
| 872 |
+
def _get_loading_button_updates(visible_count: int = MIN_OPTION_BUTTONS) -> list:
|
| 873 |
+
"""返回加载中占位按钮更新,支持最多 6 个选项槽位。"""
|
| 874 |
+
visible_count = max(0, min(int(visible_count or 0), MAX_OPTION_BUTTONS))
|
| 875 |
+
updates = []
|
| 876 |
+
for index in range(MAX_OPTION_BUTTONS):
|
| 877 |
+
updates.append(
|
| 878 |
+
gr.update(
|
| 879 |
+
value="...",
|
| 880 |
+
visible=index < visible_count,
|
| 881 |
+
interactive=False,
|
| 882 |
+
)
|
| 883 |
+
)
|
| 884 |
+
return updates
|
| 885 |
+
|
| 886 |
+
|
| 887 |
+
def _get_button_updates(options: list[dict]) -> list:
|
| 888 |
+
"""从选项列表生成按钮更新,始终返回 6 个槽位。"""
|
| 889 |
+
options = _normalize_options(options, minimum=0)
|
| 890 |
+
|
| 891 |
+
updates = []
|
| 892 |
+
for i in range(MAX_OPTION_BUTTONS):
|
| 893 |
+
opt = options[i] if i < len(options) else None
|
| 894 |
+
if isinstance(opt, dict):
|
| 895 |
+
text = opt.get("text", "...")
|
| 896 |
+
visible = True
|
| 897 |
+
else:
|
| 898 |
+
text = "..."
|
| 899 |
+
visible = False
|
| 900 |
+
updates.append(gr.update(value=text, visible=visible, interactive=visible))
|
| 901 |
+
return updates
|
| 902 |
|
| 903 |
|
| 904 |
def _format_status_panel(gs: GameState) -> str:
|
|
|
|
| 908 |
effective_stats = gs.get_effective_player_stats()
|
| 909 |
equipment_bonuses = gs.get_equipment_stat_bonuses()
|
| 910 |
env_snapshot = gs.get_environment_snapshot(limit=3)
|
| 911 |
+
survival_snapshot = gs.get_survival_state_snapshot()
|
| 912 |
scene_summary = gs.get_scene_summary().replace("\n", "<br>")
|
| 913 |
+
clock_display = gs.get_clock_display()
|
| 914 |
|
| 915 |
# 属性进度条
|
| 916 |
hp_bar = _progress_bar(p.hp, p.max_hp, "HP")
|
| 917 |
+
mp_bar = _progress_bar(p.mp, p.max_mp, "MP")
|
| 918 |
+
stamina_bar = _progress_bar(p.stamina, p.max_stamina, "体力")
|
| 919 |
+
hunger_bar = _progress_bar(p.hunger, 100, "饱食")
|
| 920 |
+
sanity_bar = _progress_bar(p.sanity, 100, "理智")
|
| 921 |
+
morale_bar = _progress_bar(p.morale, 100, "士气")
|
| 922 |
|
| 923 |
# 装备
|
| 924 |
slot_names = {
|
|
|
|
| 1068 |
<div>
|
| 1069 |
<h4 style="margin:4px 0 2px 0;">🩸 生命与状态</h4>
|
| 1070 |
<span style="font-size:0.85em;">
|
| 1071 |
+
{hp_bar}<br>
|
| 1072 |
+
{mp_bar}<br>
|
| 1073 |
+
{stamina_bar}<br>
|
| 1074 |
+
{hunger_bar}<br>
|
| 1075 |
+
{sanity_bar}<br>
|
| 1076 |
+
{morale_bar}
|
| 1077 |
+
</span>
|
| 1078 |
+
</div>
|
| 1079 |
|
| 1080 |
<div>
|
| 1081 |
<h4 style="margin:4px 0 2px 0;">⚔️ 战斗属性</h4>
|
|
|
|
| 1118 |
</div>
|
| 1119 |
|
| 1120 |
<div style="grid-column: 1 / -1;">
|
| 1121 |
+
<h4 style="margin:4px 0 2px 0;">🧭 当前场景信息</h4>
|
| 1122 |
+
<div style="font-size:0.85em;line-height:1.5;padding:8px 10px;border-radius:12px;background:#fff7ed;border:1px solid #fed7aa;">
|
| 1123 |
{env_badges}
|
| 1124 |
+
<div style="margin:6px 0 8px 0;color:#475569;">
|
| 1125 |
+
时间 {clock_display} | 场景 {w.current_scene} | 状态系数 {survival_snapshot.get('combined_multiplier', 1.0)}
|
|
|
|
| 1126 |
</div>
|
| 1127 |
+
{latest_event_html}
|
| 1128 |
+
<div style="margin-top:8px;">{scene_summary}</div>
|
| 1129 |
+
<div style="margin-top:6px;color:#475569;">最近环境事件: {recent_event_lines}</div>
|
|
|
|
|
|
|
| 1130 |
</div>
|
| 1131 |
</div>
|
| 1132 |
|
|
|
|
| 1140 |
<div>
|
| 1141 |
<h4 style="margin:4px 0 2px 0;">🌍 世界信息</h4>
|
| 1142 |
<span style="font-size:0.85em;">
|
| 1143 |
+
位置: {w.current_scene}<br>
|
| 1144 |
+
时间: 第{w.day_count}天 {w.time_of_day} ({clock_display})<br>
|
| 1145 |
天气: {w.weather}<br>
|
| 1146 |
光照: {w.light_level}<br>
|
| 1147 |
季节: {w.season}<br>
|
|
|
|
| 1168 |
# ============================================================
|
| 1169 |
|
| 1170 |
|
| 1171 |
+
def build_app() -> gr.Blocks:
|
| 1172 |
+
"""构建 Gradio 界面"""
|
| 1173 |
+
|
| 1174 |
+
with gr.Blocks(
|
| 1175 |
+
title="StoryWeaver - 交互式叙事系统",
|
| 1176 |
+
) as app:
|
| 1177 |
+
app.css = APP_UI_CSS
|
| 1178 |
|
| 1179 |
gr.Markdown(
|
| 1180 |
"""
|
|
|
|
| 1216 |
height=480,
|
| 1217 |
)
|
| 1218 |
|
| 1219 |
+
# 选项按钮(最多 6 个,分两行显示)
|
| 1220 |
+
with gr.Column():
|
| 1221 |
+
with gr.Row():
|
| 1222 |
+
option_btn_1 = gr.Button(
|
| 1223 |
+
"...",
|
| 1224 |
+
visible=True,
|
| 1225 |
+
interactive=False,
|
| 1226 |
+
elem_classes=["option-btn"],
|
| 1227 |
+
)
|
| 1228 |
+
option_btn_2 = gr.Button(
|
| 1229 |
+
"...",
|
| 1230 |
+
visible=True,
|
| 1231 |
+
interactive=False,
|
| 1232 |
+
elem_classes=["option-btn"],
|
| 1233 |
+
)
|
| 1234 |
+
option_btn_3 = gr.Button(
|
| 1235 |
+
"...",
|
| 1236 |
+
visible=True,
|
| 1237 |
+
interactive=False,
|
| 1238 |
+
elem_classes=["option-btn"],
|
| 1239 |
+
)
|
| 1240 |
+
with gr.Row():
|
| 1241 |
+
option_btn_4 = gr.Button(
|
| 1242 |
+
"...",
|
| 1243 |
+
visible=False,
|
| 1244 |
+
interactive=False,
|
| 1245 |
+
elem_classes=["option-btn"],
|
| 1246 |
+
)
|
| 1247 |
+
option_btn_5 = gr.Button(
|
| 1248 |
+
"...",
|
| 1249 |
+
visible=False,
|
| 1250 |
+
interactive=False,
|
| 1251 |
+
elem_classes=["option-btn"],
|
| 1252 |
+
)
|
| 1253 |
+
option_btn_6 = gr.Button(
|
| 1254 |
+
"...",
|
| 1255 |
+
visible=False,
|
| 1256 |
+
interactive=False,
|
| 1257 |
+
elem_classes=["option-btn"],
|
| 1258 |
+
)
|
| 1259 |
+
option_buttons = [
|
| 1260 |
+
option_btn_1,
|
| 1261 |
+
option_btn_2,
|
| 1262 |
+
option_btn_3,
|
| 1263 |
+
option_btn_4,
|
| 1264 |
+
option_btn_5,
|
| 1265 |
+
option_btn_6,
|
| 1266 |
+
]
|
| 1267 |
|
| 1268 |
# 自由输入
|
| 1269 |
with gr.Row():
|
|
|
|
| 1275 |
)
|
| 1276 |
send_btn = gr.Button("发送", variant="primary", scale=1)
|
| 1277 |
|
| 1278 |
+
# ==================
|
| 1279 |
+
# 右侧:状态面板
|
| 1280 |
+
# ==================
|
| 1281 |
+
with gr.Column(scale=2, min_width=250, elem_classes=["scene-sidebar"]):
|
| 1282 |
+
scene_image = gr.Image(
|
| 1283 |
+
value=None,
|
| 1284 |
+
type="filepath",
|
| 1285 |
+
label="场景画面",
|
| 1286 |
+
show_label=False,
|
| 1287 |
+
container=False,
|
| 1288 |
+
interactive=False,
|
| 1289 |
+
height=260,
|
| 1290 |
+
buttons=[],
|
| 1291 |
+
visible=False,
|
| 1292 |
+
elem_classes=["scene-card", "scene-image"],
|
| 1293 |
+
)
|
| 1294 |
+
status_panel = gr.Markdown(
|
| 1295 |
+
elem_classes=["scene-card", "status-panel"],
|
| 1296 |
+
value="## 等待开始...\n\n请输入角色名称并点击「开始冒险」",
|
| 1297 |
+
label="角色状态",
|
| 1298 |
+
)
|
| 1299 |
|
| 1300 |
# ============================================================
|
| 1301 |
# 事件绑定
|
| 1302 |
# ============================================================
|
| 1303 |
|
| 1304 |
# 开始游戏
|
| 1305 |
+
start_btn.click(
|
| 1306 |
+
fn=start_game,
|
| 1307 |
+
inputs=[player_name_input, game_session],
|
| 1308 |
+
outputs=[
|
| 1309 |
+
chatbot, status_panel, scene_image,
|
| 1310 |
+
*option_buttons,
|
| 1311 |
+
game_session, user_input,
|
| 1312 |
+
],
|
| 1313 |
+
)
|
| 1314 |
|
| 1315 |
# 重启冒险
|
| 1316 |
+
restart_btn.click(
|
| 1317 |
+
fn=restart_game,
|
| 1318 |
+
inputs=[],
|
| 1319 |
+
outputs=[
|
| 1320 |
+
chatbot, status_panel, scene_image,
|
| 1321 |
+
*option_buttons,
|
| 1322 |
+
game_session, user_input, player_name_input,
|
| 1323 |
+
],
|
| 1324 |
+
)
|
| 1325 |
|
| 1326 |
# 文本输入发送
|
| 1327 |
+
send_btn.click(
|
| 1328 |
+
fn=process_user_input,
|
| 1329 |
+
inputs=[user_input, chatbot, game_session],
|
| 1330 |
+
outputs=[
|
| 1331 |
+
chatbot, status_panel, scene_image,
|
| 1332 |
+
*option_buttons,
|
| 1333 |
+
game_session,
|
| 1334 |
+
],
|
| 1335 |
+
).then(
|
| 1336 |
fn=lambda: "",
|
| 1337 |
outputs=[user_input],
|
| 1338 |
)
|
| 1339 |
|
| 1340 |
# 回车发送
|
| 1341 |
+
user_input.submit(
|
| 1342 |
+
fn=process_user_input,
|
| 1343 |
+
inputs=[user_input, chatbot, game_session],
|
| 1344 |
+
outputs=[
|
| 1345 |
+
chatbot, status_panel, scene_image,
|
| 1346 |
+
*option_buttons,
|
| 1347 |
+
game_session,
|
| 1348 |
+
],
|
| 1349 |
+
).then(
|
| 1350 |
fn=lambda: "",
|
| 1351 |
outputs=[user_input],
|
| 1352 |
)
|
| 1353 |
|
| 1354 |
# 选项按钮点击(需要使用 yield from 的生成器包装函数,
|
| 1355 |
# 使 Gradio 能正确识别为流式输出)
|
| 1356 |
+
def _make_option_click_handler(index: int):
|
| 1357 |
+
def _handler(ch, gs):
|
| 1358 |
+
yield from process_option_click(index, ch, gs)
|
| 1359 |
+
|
| 1360 |
+
return _handler
|
| 1361 |
+
|
| 1362 |
+
for index, option_button in enumerate(option_buttons):
|
| 1363 |
+
option_button.click(
|
| 1364 |
+
fn=_make_option_click_handler(index),
|
| 1365 |
+
inputs=[chatbot, game_session],
|
| 1366 |
+
outputs=[
|
| 1367 |
+
chatbot, status_panel, scene_image,
|
| 1368 |
+
*option_buttons,
|
| 1369 |
+
game_session,
|
| 1370 |
+
],
|
| 1371 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1372 |
|
| 1373 |
return app
|
| 1374 |
|
|
|
|
| 1389 |
primary_hue="emerald",
|
| 1390 |
secondary_hue="blue",
|
| 1391 |
),
|
| 1392 |
+
css=APP_UI_CSS,
|
| 1393 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
demo_rules.py
ADDED
|
@@ -0,0 +1,1402 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
for index, item_name in enumerate(npc.shop_inventory):
|
| 631 |
+
item_info = game_state.world.item_registry.get(item_name)
|
| 632 |
+
price = int(item_info.value) if item_info else 0
|
| 633 |
+
actions.append(
|
| 634 |
+
_make_action(
|
| 635 |
+
action_type="TRADE",
|
| 636 |
+
target={"merchant": merchant_name, "item": item_name, "confirm": False},
|
| 637 |
+
text=f"购买{item_name}({price}金币)",
|
| 638 |
+
priority=120 - index * 4,
|
| 639 |
+
)
|
| 640 |
+
)
|
| 641 |
+
|
| 642 |
+
actions.append(
|
| 643 |
+
_make_action(
|
| 644 |
+
action_type="SCENE_OPTIONS",
|
| 645 |
+
target=npc.location,
|
| 646 |
+
text="暂不购买,先离开柜台",
|
| 647 |
+
priority=60,
|
| 648 |
+
)
|
| 649 |
+
)
|
| 650 |
+
return _dedupe_actions(actions)
|
| 651 |
+
|
| 652 |
+
|
| 653 |
+
def build_scene_actions(game_state, location_name: str | None = None) -> list[dict[str, Any]]:
|
| 654 |
+
current_name = str(location_name or game_state.player.location)
|
| 655 |
+
actions: list[dict[str, Any]] = []
|
| 656 |
+
|
| 657 |
+
if current_name == "村庄广场":
|
| 658 |
+
actions.append(
|
| 659 |
+
_make_action(
|
| 660 |
+
action_type="TALK",
|
| 661 |
+
target="村长老伯",
|
| 662 |
+
text="与村长老伯对话",
|
| 663 |
+
priority=120,
|
| 664 |
+
)
|
| 665 |
+
)
|
| 666 |
+
actions.append(
|
| 667 |
+
_make_action(
|
| 668 |
+
action_type="RUMOR",
|
| 669 |
+
target={"source": "布告栏", "topic": "rumor_menu"},
|
| 670 |
+
text="查看布告栏上的异闻",
|
| 671 |
+
priority=108,
|
| 672 |
+
)
|
| 673 |
+
)
|
| 674 |
+
if _has_named_map(game_state, "村庄地图"):
|
| 675 |
+
actions.append(
|
| 676 |
+
_make_action(
|
| 677 |
+
action_type="VIEW_MAP",
|
| 678 |
+
text="查看地图",
|
| 679 |
+
priority=104,
|
| 680 |
+
)
|
| 681 |
+
)
|
| 682 |
+
actions.append(_move_action("村庄铁匠铺", priority=100))
|
| 683 |
+
actions.append(_move_action("村庄旅店", priority=96))
|
| 684 |
+
actions.append(_move_action("村口小路", priority=92))
|
| 685 |
+
actions.append(_move_action("村庄杂货铺", priority=88))
|
| 686 |
+
return _dedupe_actions(actions)
|
| 687 |
+
|
| 688 |
+
if current_name == "村口小路":
|
| 689 |
+
traveler_available = (
|
| 690 |
+
game_state.world.global_flags.get(TRAVELER_RUMOR_HEARD_FLAG)
|
| 691 |
+
and game_state.world.npcs.get("神秘旅人")
|
| 692 |
+
and game_state.world.npcs["神秘旅人"].location == "村口小路"
|
| 693 |
+
)
|
| 694 |
+
if traveler_available:
|
| 695 |
+
actions.append(
|
| 696 |
+
_make_action(
|
| 697 |
+
action_type="TALK",
|
| 698 |
+
target="神秘旅人",
|
| 699 |
+
text="与神秘旅人交谈",
|
| 700 |
+
priority=120,
|
| 701 |
+
)
|
| 702 |
+
)
|
| 703 |
+
if game_state.world.global_flags.get(FERRY_ROUTE_UNLOCKED_FLAG):
|
| 704 |
+
actions.append(_move_action("河边渡口", priority=112))
|
| 705 |
+
if _has_named_map(game_state, "村庄地图"):
|
| 706 |
+
actions.append(
|
| 707 |
+
_make_action(
|
| 708 |
+
action_type="VIEW_MAP",
|
| 709 |
+
text="查看地图",
|
| 710 |
+
priority=108,
|
| 711 |
+
)
|
| 712 |
+
)
|
| 713 |
+
if _is_main_quest_report_pending(game_state):
|
| 714 |
+
actions.append(
|
| 715 |
+
_move_action(
|
| 716 |
+
"村庄广场",
|
| 717 |
+
priority=120,
|
| 718 |
+
text="回村向村长汇报发现",
|
| 719 |
+
preserve_text=True,
|
| 720 |
+
)
|
| 721 |
+
)
|
| 722 |
+
else:
|
| 723 |
+
actions.append(_move_action("村庄广场", priority=112))
|
| 724 |
+
if _has_named_map(game_state, "村庄地图"):
|
| 725 |
+
actions.append(_move_action("黑暗森林入口", priority=104))
|
| 726 |
+
return _dedupe_actions(actions)
|
| 727 |
+
|
| 728 |
+
if current_name == "村庄铁匠铺":
|
| 729 |
+
actions.append(
|
| 730 |
+
_make_action(
|
| 731 |
+
action_type="TALK",
|
| 732 |
+
target="铁匠格林",
|
| 733 |
+
text="与铁匠格林对话",
|
| 734 |
+
priority=120,
|
| 735 |
+
)
|
| 736 |
+
)
|
| 737 |
+
if _has_named_map(game_state, "村庄地图"):
|
| 738 |
+
actions.append(
|
| 739 |
+
_make_action(
|
| 740 |
+
action_type="VIEW_MAP",
|
| 741 |
+
text="查看地图",
|
| 742 |
+
priority=104,
|
| 743 |
+
)
|
| 744 |
+
)
|
| 745 |
+
actions.append(_move_action("村庄广场", priority=96))
|
| 746 |
+
return _dedupe_actions(actions)
|
| 747 |
+
|
| 748 |
+
if current_name == "村庄旅店":
|
| 749 |
+
actions.append(
|
| 750 |
+
_make_action(
|
| 751 |
+
action_type="TALK",
|
| 752 |
+
target="旅店老板娘莉娜",
|
| 753 |
+
text="与旅店老板娘莉娜对话",
|
| 754 |
+
priority=120,
|
| 755 |
+
)
|
| 756 |
+
)
|
| 757 |
+
actions.append(
|
| 758 |
+
_make_action(
|
| 759 |
+
action_type="RUMOR",
|
| 760 |
+
target={"source": "旅店老板娘莉娜", "topic": "rumor_menu"},
|
| 761 |
+
text="向莉娜打听最近的异状",
|
| 762 |
+
priority=116,
|
| 763 |
+
)
|
| 764 |
+
)
|
| 765 |
+
actions.append(
|
| 766 |
+
_make_action(
|
| 767 |
+
action_type="REST",
|
| 768 |
+
text="在旅店休息片刻",
|
| 769 |
+
priority=110,
|
| 770 |
+
)
|
| 771 |
+
)
|
| 772 |
+
if _has_named_map(game_state, "村庄地图"):
|
| 773 |
+
actions.append(
|
| 774 |
+
_make_action(
|
| 775 |
+
action_type="VIEW_MAP",
|
| 776 |
+
text="查看地图",
|
| 777 |
+
priority=104,
|
| 778 |
+
)
|
| 779 |
+
)
|
| 780 |
+
actions.append(_move_action("村庄广场", priority=96))
|
| 781 |
+
return _dedupe_actions(actions)
|
| 782 |
+
|
| 783 |
+
if current_name == "村庄杂货铺":
|
| 784 |
+
actions.append(
|
| 785 |
+
_make_action(
|
| 786 |
+
action_type="TALK",
|
| 787 |
+
target="杂货商人阿尔",
|
| 788 |
+
text="与杂货商人阿尔对话",
|
| 789 |
+
priority=120,
|
| 790 |
+
)
|
| 791 |
+
)
|
| 792 |
+
if _has_named_map(game_state, "村庄地图"):
|
| 793 |
+
actions.append(
|
| 794 |
+
_make_action(
|
| 795 |
+
action_type="VIEW_MAP",
|
| 796 |
+
text="查看地图",
|
| 797 |
+
priority=104,
|
| 798 |
+
)
|
| 799 |
+
)
|
| 800 |
+
actions.append(_move_action("村庄广场", priority=96))
|
| 801 |
+
return _dedupe_actions(actions)
|
| 802 |
+
|
| 803 |
+
if current_name == "黑暗森林入口":
|
| 804 |
+
goblin_defeated = game_state.world.global_flags.get(FOREST_GOBLIN_DEFEATED_FLAG)
|
| 805 |
+
tracks_found = game_state.world.global_flags.get(FOREST_TROLL_TRACKS_FOUND_FLAG)
|
| 806 |
+
barrier_seen = game_state.world.global_flags.get(DEEP_FOREST_BARRIER_SEEN_FLAG)
|
| 807 |
+
troll_hunt_active = _is_forest_troll_hunt_active(game_state)
|
| 808 |
+
if not goblin_defeated:
|
| 809 |
+
actions.append(
|
| 810 |
+
_make_action(
|
| 811 |
+
action_type="ATTACK",
|
| 812 |
+
target="哥布林",
|
| 813 |
+
text="击败哥布林",
|
| 814 |
+
priority=120,
|
| 815 |
+
)
|
| 816 |
+
)
|
| 817 |
+
else:
|
| 818 |
+
if not tracks_found:
|
| 819 |
+
actions.append(
|
| 820 |
+
_make_action(
|
| 821 |
+
action_type="EXPLORE",
|
| 822 |
+
target="黑暗森林入口",
|
| 823 |
+
text="调查哥布林留下的痕迹",
|
| 824 |
+
priority=120,
|
| 825 |
+
)
|
| 826 |
+
)
|
| 827 |
+
elif troll_hunt_active and "森林之钥" in game_state.player.inventory:
|
| 828 |
+
actions.append(
|
| 829 |
+
_move_action(
|
| 830 |
+
"森林深处",
|
| 831 |
+
priority=120,
|
| 832 |
+
text="前往森林深处探索",
|
| 833 |
+
preserve_text=True,
|
| 834 |
+
)
|
| 835 |
+
)
|
| 836 |
+
elif not barrier_seen:
|
| 837 |
+
actions.append(_move_action("森林深处", priority=120))
|
| 838 |
+
elif _is_main_quest_report_pending(game_state):
|
| 839 |
+
actions.append(
|
| 840 |
+
_move_action(
|
| 841 |
+
"村口小路",
|
| 842 |
+
priority=120,
|
| 843 |
+
text="返回村庄向村长汇报",
|
| 844 |
+
preserve_text=True,
|
| 845 |
+
)
|
| 846 |
+
)
|
| 847 |
+
if _has_named_map(game_state, "黑暗森林地图"):
|
| 848 |
+
actions.append(
|
| 849 |
+
_make_action(
|
| 850 |
+
action_type="VIEW_MAP",
|
| 851 |
+
text="查看地图",
|
| 852 |
+
priority=112 if tracks_found else 108,
|
| 853 |
+
)
|
| 854 |
+
)
|
| 855 |
+
actions.append(_move_action("溪边营地", priority=104))
|
| 856 |
+
if tracks_found and not barrier_seen and "森林之钥" in game_state.player.inventory:
|
| 857 |
+
actions.append(
|
| 858 |
+
_move_action(
|
| 859 |
+
"森林深处",
|
| 860 |
+
priority=116,
|
| 861 |
+
text="前往森林���处探索" if troll_hunt_active else None,
|
| 862 |
+
preserve_text=troll_hunt_active,
|
| 863 |
+
)
|
| 864 |
+
)
|
| 865 |
+
if _is_main_quest_report_pending(game_state) and not barrier_seen and not troll_hunt_active:
|
| 866 |
+
actions.append(
|
| 867 |
+
_move_action(
|
| 868 |
+
"村口小路",
|
| 869 |
+
priority=114,
|
| 870 |
+
text="先返回村庄汇报情况",
|
| 871 |
+
preserve_text=True,
|
| 872 |
+
)
|
| 873 |
+
)
|
| 874 |
+
elif not barrier_seen or troll_hunt_active:
|
| 875 |
+
actions.append(_move_action("村口小路", priority=96))
|
| 876 |
+
return _dedupe_actions(actions)
|
| 877 |
+
|
| 878 |
+
if current_name == "河边渡口":
|
| 879 |
+
actions.append(
|
| 880 |
+
_make_action(
|
| 881 |
+
action_type="TALK",
|
| 882 |
+
target="渡口老渔夫",
|
| 883 |
+
text="与渡口老渔夫对话",
|
| 884 |
+
priority=120,
|
| 885 |
+
)
|
| 886 |
+
)
|
| 887 |
+
if _has_named_map(game_state, "山麓地图"):
|
| 888 |
+
actions.append(
|
| 889 |
+
_make_action(
|
| 890 |
+
action_type="VIEW_MAP",
|
| 891 |
+
text="查看地图",
|
| 892 |
+
priority=116,
|
| 893 |
+
)
|
| 894 |
+
)
|
| 895 |
+
actions.append(_move_action("废弃矿洞入口", priority=112))
|
| 896 |
+
actions.append(_move_action("山麓盗贼营", priority=108))
|
| 897 |
+
actions.append(_move_action("村口小路", priority=104))
|
| 898 |
+
return _dedupe_actions(actions)
|
| 899 |
+
|
| 900 |
+
if current_name == "山麓盗贼营":
|
| 901 |
+
if not game_state.world.global_flags.get("encounter::bandit_scout_defeated"):
|
| 902 |
+
actions.append(
|
| 903 |
+
_make_action(
|
| 904 |
+
action_type="ATTACK",
|
| 905 |
+
target="盗贼斥候",
|
| 906 |
+
text="击败盗贼斥候",
|
| 907 |
+
priority=120,
|
| 908 |
+
)
|
| 909 |
+
)
|
| 910 |
+
if _has_named_map(game_state, "山麓地图"):
|
| 911 |
+
actions.append(
|
| 912 |
+
_make_action(
|
| 913 |
+
action_type="VIEW_MAP",
|
| 914 |
+
text="查看地图",
|
| 915 |
+
priority=112,
|
| 916 |
+
)
|
| 917 |
+
)
|
| 918 |
+
actions.append(_move_action("河边渡口", priority=108))
|
| 919 |
+
return _dedupe_actions(actions)
|
| 920 |
+
|
| 921 |
+
if current_name == "古塔废墟":
|
| 922 |
+
if not game_state.world.global_flags.get("encounter::ancient_tower_wraith_defeated"):
|
| 923 |
+
actions.append(
|
| 924 |
+
_make_action(
|
| 925 |
+
action_type="ATTACK",
|
| 926 |
+
target="游荡幽灵",
|
| 927 |
+
text="击退游荡幽灵",
|
| 928 |
+
priority=120,
|
| 929 |
+
)
|
| 930 |
+
)
|
| 931 |
+
if _has_named_map(game_state, "古塔地图"):
|
| 932 |
+
actions.append(
|
| 933 |
+
_make_action(
|
| 934 |
+
action_type="VIEW_MAP",
|
| 935 |
+
text="查看地图",
|
| 936 |
+
priority=108,
|
| 937 |
+
)
|
| 938 |
+
)
|
| 939 |
+
actions.append(_move_action("村口小路", priority=104))
|
| 940 |
+
return _dedupe_actions(actions)
|
| 941 |
+
|
| 942 |
+
if current_name == "废弃矿洞入口":
|
| 943 |
+
if not game_state.world.global_flags.get("encounter::mine_skeleton_defeated"):
|
| 944 |
+
actions.append(
|
| 945 |
+
_make_action(
|
| 946 |
+
action_type="ATTACK",
|
| 947 |
+
target="骷髅兵",
|
| 948 |
+
text="击退骷髅兵",
|
| 949 |
+
priority=120,
|
| 950 |
+
)
|
| 951 |
+
)
|
| 952 |
+
else:
|
| 953 |
+
actions.append(
|
| 954 |
+
_make_action(
|
| 955 |
+
action_type="EXPLORE",
|
| 956 |
+
target="废弃矿洞入口",
|
| 957 |
+
text="搜查矿洞入口的遗留痕迹",
|
| 958 |
+
priority=120,
|
| 959 |
+
)
|
| 960 |
+
)
|
| 961 |
+
if _has_named_map(game_state, "山麓地图"):
|
| 962 |
+
actions.append(
|
| 963 |
+
_make_action(
|
| 964 |
+
action_type="VIEW_MAP",
|
| 965 |
+
text="查看地图",
|
| 966 |
+
priority=108,
|
| 967 |
+
)
|
| 968 |
+
)
|
| 969 |
+
actions.append(_move_action("河边渡口", priority=104))
|
| 970 |
+
return _dedupe_actions(actions)
|
| 971 |
+
|
| 972 |
+
if current_name == "溪边营地":
|
| 973 |
+
actions.append(
|
| 974 |
+
_make_action(
|
| 975 |
+
action_type="REST",
|
| 976 |
+
text="在营地休息恢复体力",
|
| 977 |
+
priority=120,
|
| 978 |
+
)
|
| 979 |
+
)
|
| 980 |
+
actions.append(
|
| 981 |
+
_make_action(
|
| 982 |
+
action_type="EXPLORE",
|
| 983 |
+
target="溪边营地",
|
| 984 |
+
text="搜寻营地遗留的物资线索",
|
| 985 |
+
priority=112,
|
| 986 |
+
)
|
| 987 |
+
)
|
| 988 |
+
if _has_named_map(game_state, "黑暗森林地图"):
|
| 989 |
+
actions.append(
|
| 990 |
+
_make_action(
|
| 991 |
+
action_type="VIEW_MAP",
|
| 992 |
+
text="查看地图",
|
| 993 |
+
priority=104,
|
| 994 |
+
)
|
| 995 |
+
)
|
| 996 |
+
actions.append(_move_action("黑暗森林入口", priority=96))
|
| 997 |
+
return _dedupe_actions(actions)
|
| 998 |
+
|
| 999 |
+
if current_name == "精灵遗迹":
|
| 1000 |
+
actions.append(
|
| 1001 |
+
_make_action(
|
| 1002 |
+
action_type="TALK",
|
| 1003 |
+
target="遗迹守护者",
|
| 1004 |
+
text="与遗迹守护者对话",
|
| 1005 |
+
priority=120,
|
| 1006 |
+
)
|
| 1007 |
+
)
|
| 1008 |
+
if _has_named_map(game_state, "山麓地图"):
|
| 1009 |
+
actions.append(
|
| 1010 |
+
_make_action(
|
| 1011 |
+
action_type="VIEW_MAP",
|
| 1012 |
+
text="查看地图",
|
| 1013 |
+
priority=108,
|
| 1014 |
+
)
|
| 1015 |
+
)
|
| 1016 |
+
actions.append(_move_action("山麓盗贼营", priority=100))
|
| 1017 |
+
return _dedupe_actions(actions)
|
| 1018 |
+
|
| 1019 |
+
if current_name == "森林深处":
|
| 1020 |
+
if game_state.world.global_flags.get(FOREST_TROLL_HOARD_PENDING_FLAG):
|
| 1021 |
+
actions.append(
|
| 1022 |
+
_make_action(
|
| 1023 |
+
action_type="CLAIM_REWARD",
|
| 1024 |
+
target={"source": "forest_troll_hoard"},
|
| 1025 |
+
text="确认拾取洞穴中的战利品",
|
| 1026 |
+
priority=124,
|
| 1027 |
+
)
|
| 1028 |
+
)
|
| 1029 |
+
elif (
|
| 1030 |
+
_is_forest_troll_hunt_active(game_state)
|
| 1031 |
+
and game_state.world.global_flags.get(FOREST_TROLL_INTRO_SEEN_FLAG)
|
| 1032 |
+
):
|
| 1033 |
+
actions.append(
|
| 1034 |
+
_make_action(
|
| 1035 |
+
action_type="ATTACK",
|
| 1036 |
+
target="森林巨魔",
|
| 1037 |
+
text="攻击森林巨魔",
|
| 1038 |
+
priority=120,
|
| 1039 |
+
)
|
| 1040 |
+
)
|
| 1041 |
+
else:
|
| 1042 |
+
actions.append(
|
| 1043 |
+
_make_action(
|
| 1044 |
+
action_type="EXPLORE",
|
| 1045 |
+
target="森林深处",
|
| 1046 |
+
text="深入调查森林异变的根源",
|
| 1047 |
+
priority=120,
|
| 1048 |
+
)
|
| 1049 |
+
)
|
| 1050 |
+
if _has_named_map(game_state, "黑暗森林地图"):
|
| 1051 |
+
actions.append(
|
| 1052 |
+
_make_action(
|
| 1053 |
+
action_type="VIEW_MAP",
|
| 1054 |
+
text="查看地图",
|
| 1055 |
+
priority=108,
|
| 1056 |
+
)
|
| 1057 |
+
)
|
| 1058 |
+
actions.append(_move_action("黑暗森林入口", priority=100))
|
| 1059 |
+
return _dedupe_actions(actions)
|
| 1060 |
+
|
| 1061 |
+
return build_adjacent_actions(game_state)
|
| 1062 |
+
|
| 1063 |
+
|
| 1064 |
+
def build_arrival_event(game_state, location_name: str) -> dict[str, Any] | None:
|
| 1065 |
+
config = ARRIVAL_EVENT_CONFIG.get(str(location_name))
|
| 1066 |
+
if config is None:
|
| 1067 |
+
return None
|
| 1068 |
+
return {
|
| 1069 |
+
"event_key": config["event_key"],
|
| 1070 |
+
"story_text": config["story_text"],
|
| 1071 |
+
"options": build_scene_actions(game_state, location_name),
|
| 1072 |
+
}
|
| 1073 |
+
|
| 1074 |
+
|
| 1075 |
+
def build_goal_directed_actions(game_state) -> list[dict[str, Any]]:
|
| 1076 |
+
quest, objective = _first_incomplete_objective(game_state)
|
| 1077 |
+
if not quest or not objective:
|
| 1078 |
+
return []
|
| 1079 |
+
|
| 1080 |
+
actions: list[dict[str, Any]] = []
|
| 1081 |
+
inventory = set(game_state.player.inventory)
|
| 1082 |
+
current_location = game_state.world.locations.get(game_state.player.location)
|
| 1083 |
+
dialogue_target = _extract_dialogue_target(objective)
|
| 1084 |
+
if dialogue_target:
|
| 1085 |
+
npc = game_state.world.npcs.get(dialogue_target)
|
| 1086 |
+
if npc is None:
|
| 1087 |
+
npc = next(
|
| 1088 |
+
(
|
| 1089 |
+
candidate
|
| 1090 |
+
for candidate in game_state.world.npcs.values()
|
| 1091 |
+
if dialogue_target in candidate.name or candidate.name in objective
|
| 1092 |
+
),
|
| 1093 |
+
None,
|
| 1094 |
+
)
|
| 1095 |
+
if npc and npc.location == game_state.player.location:
|
| 1096 |
+
actions.append(
|
| 1097 |
+
_make_action(
|
| 1098 |
+
action_type="TALK",
|
| 1099 |
+
target=npc.name,
|
| 1100 |
+
text=f"与{npc.name}对话",
|
| 1101 |
+
priority=120,
|
| 1102 |
+
)
|
| 1103 |
+
)
|
| 1104 |
+
elif npc:
|
| 1105 |
+
next_step = _find_next_step(game_state, npc.location)
|
| 1106 |
+
if next_step:
|
| 1107 |
+
actions.append(
|
| 1108 |
+
_make_action(
|
| 1109 |
+
action_type="MOVE",
|
| 1110 |
+
target=next_step,
|
| 1111 |
+
text=f"前往{next_step}",
|
| 1112 |
+
priority=112,
|
| 1113 |
+
)
|
| 1114 |
+
)
|
| 1115 |
+
|
| 1116 |
+
location_target = _extract_location_target(objective)
|
| 1117 |
+
if location_target:
|
| 1118 |
+
if any("地图" in item for item in inventory):
|
| 1119 |
+
actions.append(
|
| 1120 |
+
_make_action(
|
| 1121 |
+
action_type="VIEW_MAP",
|
| 1122 |
+
text="查看地图",
|
| 1123 |
+
priority=110,
|
| 1124 |
+
)
|
| 1125 |
+
)
|
| 1126 |
+
if (
|
| 1127 |
+
location_target == "森林深处"
|
| 1128 |
+
and _is_forest_troll_hunt_active(game_state)
|
| 1129 |
+
and "森林之钥" in inventory
|
| 1130 |
+
and game_state.player.location != "森林深处"
|
| 1131 |
+
):
|
| 1132 |
+
actions.append(
|
| 1133 |
+
_make_action(
|
| 1134 |
+
action_type="MOVE",
|
| 1135 |
+
target="森林深处",
|
| 1136 |
+
text="赶赴森林深处",
|
| 1137 |
+
priority=105,
|
| 1138 |
+
)
|
| 1139 |
+
)
|
| 1140 |
+
else:
|
| 1141 |
+
next_step = _find_next_step(game_state, location_target)
|
| 1142 |
+
if next_step and next_step != game_state.player.location:
|
| 1143 |
+
actions.append(
|
| 1144 |
+
_make_action(
|
| 1145 |
+
action_type="MOVE",
|
| 1146 |
+
target=next_step,
|
| 1147 |
+
text=f"前往{next_step}",
|
| 1148 |
+
priority=105,
|
| 1149 |
+
)
|
| 1150 |
+
)
|
| 1151 |
+
|
| 1152 |
+
if location_target == "黑暗森林入口" and game_state.player.location == "村庄广场":
|
| 1153 |
+
if "火把" not in inventory:
|
| 1154 |
+
actions.append(
|
| 1155 |
+
_make_action(
|
| 1156 |
+
action_type="MOVE",
|
| 1157 |
+
target="村庄杂货铺",
|
| 1158 |
+
text="前往村庄杂货铺准备火把",
|
| 1159 |
+
priority=104,
|
| 1160 |
+
)
|
| 1161 |
+
)
|
| 1162 |
+
if (
|
| 1163 |
+
not game_state.player.equipment.get("weapon")
|
| 1164 |
+
and "铁剑" not in inventory
|
| 1165 |
+
and "短剑" not in inventory
|
| 1166 |
+
):
|
| 1167 |
+
actions.append(
|
| 1168 |
+
_make_action(
|
| 1169 |
+
action_type="MOVE",
|
| 1170 |
+
target="村庄铁匠铺",
|
| 1171 |
+
text="前往村庄铁匠铺准备武器",
|
| 1172 |
+
priority=103,
|
| 1173 |
+
)
|
| 1174 |
+
)
|
| 1175 |
+
|
| 1176 |
+
if "击败" in str(objective):
|
| 1177 |
+
enemy_name = str(objective).replace("击败", "").replace("森林中的", "").replace("矿洞中的", "").replace("的怪物", "").strip()
|
| 1178 |
+
encounter_location = _find_encounter_location(enemy_name)
|
| 1179 |
+
if enemy_name in {"怪物", "敌人"} and current_location:
|
| 1180 |
+
local_enemies = list(current_location.enemies or [])
|
| 1181 |
+
if local_enemies:
|
| 1182 |
+
enemy_name = local_enemies[0]
|
| 1183 |
+
encounter_location = _find_encounter_location(enemy_name)
|
| 1184 |
+
else:
|
| 1185 |
+
hunt_step = next(
|
| 1186 |
+
(
|
| 1187 |
+
neighbor
|
| 1188 |
+
for neighbor in current_location.connected_to
|
| 1189 |
+
if game_state.world.locations.get(neighbor)
|
| 1190 |
+
and game_state.world.locations[neighbor].enemies
|
| 1191 |
+
),
|
| 1192 |
+
None,
|
| 1193 |
+
)
|
| 1194 |
+
if hunt_step:
|
| 1195 |
+
actions.append(
|
| 1196 |
+
_make_action(
|
| 1197 |
+
action_type="MOVE",
|
| 1198 |
+
target=hunt_step,
|
| 1199 |
+
text=f"前往{hunt_step}搜索怪物",
|
| 1200 |
+
priority=108,
|
| 1201 |
+
)
|
| 1202 |
+
)
|
| 1203 |
+
enemy_name = ""
|
| 1204 |
+
if enemy_name and encounter_location and encounter_location != game_state.player.location:
|
| 1205 |
+
next_step = _find_next_step(game_state, encounter_location)
|
| 1206 |
+
if next_step and next_step != game_state.player.location:
|
| 1207 |
+
actions.append(
|
| 1208 |
+
_make_action(
|
| 1209 |
+
action_type="MOVE",
|
| 1210 |
+
target=next_step,
|
| 1211 |
+
text=f"前往{next_step}",
|
| 1212 |
+
priority=108,
|
| 1213 |
+
)
|
| 1214 |
+
)
|
| 1215 |
+
elif enemy_name:
|
| 1216 |
+
actions.append(
|
| 1217 |
+
_make_action(
|
| 1218 |
+
action_type="ATTACK",
|
| 1219 |
+
target=enemy_name,
|
| 1220 |
+
text=f"击败{enemy_name}",
|
| 1221 |
+
priority=100,
|
| 1222 |
+
)
|
| 1223 |
+
)
|
| 1224 |
+
|
| 1225 |
+
if "调查" in str(objective) or "找到" in str(objective):
|
| 1226 |
+
if (
|
| 1227 |
+
str(objective) == FOREST_CAUSE_OBJECTIVE
|
| 1228 |
+
and game_state.player.location == "黑暗森林入口"
|
| 1229 |
+
and game_state.world.global_flags.get(FOREST_GOBLIN_DEFEATED_FLAG)
|
| 1230 |
+
):
|
| 1231 |
+
actions.append(
|
| 1232 |
+
_make_action(
|
| 1233 |
+
action_type="EXPLORE",
|
| 1234 |
+
target="黑暗森林入口",
|
| 1235 |
+
text="调查哥布林留下的痕迹",
|
| 1236 |
+
priority=118,
|
| 1237 |
+
)
|
| 1238 |
+
)
|
| 1239 |
+
return _dedupe_actions(actions)
|
| 1240 |
+
actions.append(
|
| 1241 |
+
_make_action(
|
| 1242 |
+
action_type="EXPLORE",
|
| 1243 |
+
target=game_state.player.location,
|
| 1244 |
+
text=f"围绕“{objective}”继续调查",
|
| 1245 |
+
priority=92,
|
| 1246 |
+
)
|
| 1247 |
+
)
|
| 1248 |
+
|
| 1249 |
+
return _dedupe_actions(actions)
|
| 1250 |
+
|
| 1251 |
+
|
| 1252 |
+
def build_adjacent_actions(game_state) -> list[dict[str, Any]]:
|
| 1253 |
+
current_location = game_state.world.locations.get(game_state.player.location)
|
| 1254 |
+
if current_location is None:
|
| 1255 |
+
return []
|
| 1256 |
+
|
| 1257 |
+
actions: list[dict[str, Any]] = []
|
| 1258 |
+
owned_items = set(game_state.player.inventory) | {
|
| 1259 |
+
str(item)
|
| 1260 |
+
for item in game_state.player.equipment.values()
|
| 1261 |
+
if item
|
| 1262 |
+
}
|
| 1263 |
+
|
| 1264 |
+
for neighbor in current_location.connected_to:
|
| 1265 |
+
# 不显示"前往当前场景"的无效选项
|
| 1266 |
+
if neighbor == game_state.player.location:
|
| 1267 |
+
continue
|
| 1268 |
+
target_location = game_state.world.locations.get(neighbor)
|
| 1269 |
+
if target_location is None:
|
| 1270 |
+
continue
|
| 1271 |
+
if not target_location.is_accessible:
|
| 1272 |
+
required_item = str(target_location.required_item or "")
|
| 1273 |
+
if not required_item or required_item not in owned_items:
|
| 1274 |
+
continue
|
| 1275 |
+
actions.append(
|
| 1276 |
+
_make_action(
|
| 1277 |
+
action_type="MOVE",
|
| 1278 |
+
target=neighbor,
|
| 1279 |
+
text=f"前往{neighbor}",
|
| 1280 |
+
priority=78 if not target_location.is_discovered else 72,
|
| 1281 |
+
)
|
| 1282 |
+
)
|
| 1283 |
+
|
| 1284 |
+
for npc_name in current_location.npcs_present:
|
| 1285 |
+
if npc_name not in game_state.world.npcs:
|
| 1286 |
+
continue
|
| 1287 |
+
actions.append(
|
| 1288 |
+
_make_action(
|
| 1289 |
+
action_type="TALK",
|
| 1290 |
+
target=npc_name,
|
| 1291 |
+
text=f"与{npc_name}对话",
|
| 1292 |
+
priority=68,
|
| 1293 |
+
)
|
| 1294 |
+
)
|
| 1295 |
+
|
| 1296 |
+
return _dedupe_actions(actions)
|
| 1297 |
+
|
| 1298 |
+
|
| 1299 |
+
def merge_demo_options(
|
| 1300 |
+
base_options: list[dict[str, Any]],
|
| 1301 |
+
*extra_option_groups: list[dict[str, Any]],
|
| 1302 |
+
limit: int = 3,
|
| 1303 |
+
) -> list[dict[str, Any]]:
|
| 1304 |
+
merged: list[dict[str, Any]] = [option for option in base_options if isinstance(option, dict)]
|
| 1305 |
+
for group in extra_option_groups:
|
| 1306 |
+
merged.extend(group)
|
| 1307 |
+
deduped = _dedupe_actions(merged)
|
| 1308 |
+
return deduped[:limit]
|
| 1309 |
+
|
| 1310 |
+
|
| 1311 |
+
def build_contextual_actions(
|
| 1312 |
+
game_state,
|
| 1313 |
+
*,
|
| 1314 |
+
recent_gain: str | None = None,
|
| 1315 |
+
) -> list[dict[str, Any]]:
|
| 1316 |
+
actions: list[dict[str, Any]] = []
|
| 1317 |
+
inventory = set(game_state.player.inventory)
|
| 1318 |
+
light_level = str(game_state.world.light_level)
|
| 1319 |
+
location = game_state.world.locations.get(game_state.player.location)
|
| 1320 |
+
time_of_day = str(game_state.world.time_of_day)
|
| 1321 |
+
|
| 1322 |
+
if recent_gain:
|
| 1323 |
+
item_info = game_state.world.item_registry.get(recent_gain)
|
| 1324 |
+
if item_info and item_info.item_type in {"weapon", "armor", "accessory"}:
|
| 1325 |
+
slot = "weapon" if item_info.item_type == "weapon" else "armor"
|
| 1326 |
+
if game_state.player.equipment.get(slot) != recent_gain:
|
| 1327 |
+
actions.append(
|
| 1328 |
+
_make_action(
|
| 1329 |
+
action_type="EQUIP",
|
| 1330 |
+
target=recent_gain,
|
| 1331 |
+
text=f"装备{recent_gain}",
|
| 1332 |
+
priority=100,
|
| 1333 |
+
)
|
| 1334 |
+
)
|
| 1335 |
+
if "地图" in str(recent_gain):
|
| 1336 |
+
actions.append(
|
| 1337 |
+
_make_action(
|
| 1338 |
+
action_type="VIEW_MAP",
|
| 1339 |
+
text="查看地图",
|
| 1340 |
+
priority=95,
|
| 1341 |
+
)
|
| 1342 |
+
)
|
| 1343 |
+
|
| 1344 |
+
in_dark_area = (
|
| 1345 |
+
light_level in {"黑暗", "昏暗", "幽暗", "漆黑"}
|
| 1346 |
+
or (location is not None and location.location_type == "dungeon")
|
| 1347 |
+
or (
|
| 1348 |
+
location is not None
|
| 1349 |
+
and location.location_type in {"wilderness", "special"}
|
| 1350 |
+
and time_of_day in {"夜晚", "深夜"}
|
| 1351 |
+
)
|
| 1352 |
+
)
|
| 1353 |
+
if "火把" in inventory and in_dark_area and not _has_status(game_state, "火把"):
|
| 1354 |
+
actions.append(
|
| 1355 |
+
_make_action(
|
| 1356 |
+
action_type="USE_ITEM",
|
| 1357 |
+
target="火把",
|
| 1358 |
+
text="使用火把照明",
|
| 1359 |
+
priority=90,
|
| 1360 |
+
)
|
| 1361 |
+
)
|
| 1362 |
+
|
| 1363 |
+
if game_state.player.hp < max(1, game_state.player.max_hp // 2):
|
| 1364 |
+
for potion_name in ("小型治疗药水", "治疗药水"):
|
| 1365 |
+
if potion_name in inventory:
|
| 1366 |
+
actions.append(
|
| 1367 |
+
_make_action(
|
| 1368 |
+
action_type="USE_ITEM",
|
| 1369 |
+
target=potion_name,
|
| 1370 |
+
text=f"使用{potion_name}",
|
| 1371 |
+
priority=85,
|
| 1372 |
+
)
|
| 1373 |
+
)
|
| 1374 |
+
break
|
| 1375 |
+
|
| 1376 |
+
if game_state.player.hunger < 50:
|
| 1377 |
+
for food_name in ("面包", "烤肉", "麦酒", "草药包"):
|
| 1378 |
+
if food_name in inventory:
|
| 1379 |
+
actions.append(
|
| 1380 |
+
_make_action(
|
| 1381 |
+
action_type="USE_ITEM",
|
| 1382 |
+
target=food_name,
|
| 1383 |
+
text=f"食用{food_name}",
|
| 1384 |
+
priority=80,
|
| 1385 |
+
)
|
| 1386 |
+
)
|
| 1387 |
+
break
|
| 1388 |
+
|
| 1389 |
+
if (
|
| 1390 |
+
game_state.player.location in OVERNIGHT_REST_LOCATIONS
|
| 1391 |
+
and hasattr(game_state, "can_overnight_rest")
|
| 1392 |
+
and game_state.can_overnight_rest()
|
| 1393 |
+
):
|
| 1394 |
+
actions.append(
|
| 1395 |
+
_make_action(
|
| 1396 |
+
action_type="OVERNIGHT_REST",
|
| 1397 |
+
text="在此处过夜",
|
| 1398 |
+
priority=88,
|
| 1399 |
+
)
|
| 1400 |
+
)
|
| 1401 |
+
|
| 1402 |
+
return _dedupe_actions(actions)
|
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
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
story_engine.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|