src/llm/world_prompt.py
Rama main · réplica de lectura del repositorio original.
"""Localized prompt blocks for authoritative world truth (#284).
World truth and its legality verdicts are server-assembled trusted context
blocks: players cannot inject them, and the GM must follow them. They live in
one module so the GM context and the player-safe context render the same facts
from the same projection -- the difference is only the viewer, never a second
opinion about what is true.
"""
from __future__ import annotations
import json
from collections.abc import Iterable
from typing import Any
from src.engine.language import localized_text
from src.engine import module_hooks
from src.engine.world.read import project_visible_state
_HEADING = {
"en": (
"## World Truth · Must Follow\n"
"These are the authoritative world facts for this round. Never narrate anything that "
"contradicts them, and never reveal facts marked GM only to players:"
),
"zh-CN": (
"【世界真相·必须遵循】\n"
"以下是本轮的权威世界事实:叙事不得与之矛盾;标注为 GM 私有的事实不得透露给玩家:"
),
"ja": (
"【世界の事実・必ず従うこと】\n"
"以下はこのラウンドの権威ある世界事実である。矛盾する叙述をしてはならず、"
"GM 専用と記された事実をプレイヤーに明かしてはならない:"
),
"de": (
"## Welttatsachen · Muss befolgt werden\n"
"Dies sind die verbindlichen Welttatsachen dieser Runde. Erzähle nichts, was ihnen "
"widerspricht, und enthülle keine als „nur GM“ markierten Tatsachen:"
),
}
_GM_ONLY_SUFFIX = {
"en": " (GM only, players cannot see this)",
"zh-CN": "(GM 私有,玩家不可见)",
"ja": "(GM 専用・プレイヤーには見えない)",
"de": " (nur GM, für Spieler unsichtbar)",
}
_CLOCK = {
"en": "World time: day {day}, minute {minute}",
"zh-CN": "世界时间:第 {day} 天 {minute} 分",
"ja": "世界時間:{day} 日目 {minute} 分",
"de": "Weltzeit: Tag {day}, Minute {minute}",
}
_LEGALITY_HEADING = {
"en": (
"## Action Legality · Must Follow\n"
"These declared actions contradict authoritative world truth. Narrate them as an attempt "
"that requires moving first or cannot complete; never narrate them as already done, and "
"never move a character implicitly:"
),
"zh-CN": (
"【行动合法性·必须遵循】\n"
"以下行动与权威世界真相矛盾:必须叙述为「需要先移动」或「未能完成」,"
"不得叙述为已经完成,也不得让角色隐式瞬移:"
),
"ja": (
"【行動の適法性・必ず従うこと】\n"
"以下の行動は権威ある世界事実と矛盾する。まず移動が必要、または完了できない試みとして"
"叙述し、すでに完了したかのように叙述してはならず、暗黙の移動も認めない:"
),
"de": (
"## Handlungslegitimität · Muss befolgt werden\n"
"Diese erklärten Aktionen widersprechen der verbindlichen Welttatsache. Erzähle sie als "
"Versuch, der zuerst Bewegung erfordert oder nicht abgeschlossen werden kann; niemals als "
"bereits erledigt, und niemals als stillschweigende Bewegung:"
),
}
_LEGALITY_NOTE = {
"ROUTE_IMPASSABLE": {
"en": "{name} cannot pass {location}: the world marks it impassable",
"zh-CN": "{name} 无法通过 {location}:世界事实标记其不可通行",
"ja": "{name} は {location} を通過できない:世界事実が通行不可としている",
"de": "{name} kann {location} nicht passieren: die Welt markiert es als unpassierbar",
},
"ACTION_LOCATION_MISMATCH": {
"en": (
"{name} is at {current}, but this action is declared at {location}: "
"they must move first or the action cannot complete"
),
"zh-CN": (
"{name} 当前位于 {current},但本行动声明发生在 {location}:"
"必须先移动,否则该行动无法完成"
),
"ja": (
"{name} は現在 {current} にいるが、この行動は {location} で宣言されている:"
"先に移動しなければ行動は完了できない"
),
"de": (
"{name} ist in {current}, aber diese Aktion wird in {location} erklärt: "
"erst bewegen, sonst kann die Aktion nicht abgeschlossen werden"
),
},
}
_EVENTS_HEADING = { "en": (
"## Settled World Events · Must Follow\n"
"Logical world time advanced and these scheduled events took effect this round. Narrate "
"their consequences; they are settled facts, not suggestions:"
),
"zh-CN": (
"【世界时间推进·已结算事件】\n"
"世界逻辑时间已经推进,以下定时事件在本轮生效:请叙述它们的后果,"
"它们是已结算事实,不是建议:"
),
"ja": (
"【世界時間の進行・確定したイベント】\n"
"世界の論理時間が進み、以下の予定イベントがこのラウンドで確定した。"
"その結果を叙述すること。これは提案ではなく確定事実である:"
),
"de": (
"## Abgeschlossene Weltereignisse · Muss befolgt werden\n"
"Die logische Weltzeit ist vorangeschritten und diese geplanten Ereignisse sind in dieser "
"Runde eingetreten. Erzähle ihre Folgen; sie sind feststehende Tatsachen, keine Vorschläge:"
),
}
_EVENT_LINE = {
"en": "{label} ({event_id}) took effect at day {day}, minute {minute}",
"zh-CN": "{label}({event_id})于第 {day} 天 {minute} 分生效",
"ja": "{label}({event_id})が {day} 日目 {minute} 分に発動",
"de": "{label} ({event_id}) ist an Tag {day}, Minute {minute} eingetreten",
}
_EVENT_FAILED_LINE = {
"en": "{label} ({event_id}) could not be settled: {error}",
"zh-CN": "{label}({event_id})未能结算:{error}",
"ja": "{label}({event_id})は確定できなかった:{error}",
"de": "{label} ({event_id}) konnte nicht abgeschlossen werden: {error}",
}
# FIX-05 §7.3:有界 viewer-safe 投影的其余三段(相关实体 / 关系 / 进程)。
_ENTITY_HEADING = {
"en": "Relevant world entities (only what this viewer may see):",
"zh-CN": "相关世界实体(仅当前视角可见):",
"ja": "関連する世界エンティティ(この視点で見えるもののみ):",
"de": "Relevante Weltentitäten (nur was diese Sicht sehen darf):",
}
_RELATION_HEADING = {
"en": "Relevant world relations:",
"zh-CN": "相关世界关系:",
"ja": "関連する世界の関係:",
"de": "Relevante Weltbeziehungen:",
}
_PROCESS_HEADING = {
"en": "Relevant world processes:",
"zh-CN": "相关世界进程:",
"ja": "関連する世界プロセス:",
"de": "Relevante Weltprozesse:",
}
_TRUNCATED_NOTE = {
"en": "(more world records exist; only the most relevant are shown)",
"zh-CN": "(还有更多世界记录,这里只列出最相关的部分)",
"ja": "(他にも世界記録があるが、関連するもののみ表示)",
"de": "(es existieren weitere Weltdatensätze; nur die relevantesten werden gezeigt)",
}
def _entity_line(entity_id: str, entity: dict[str, Any]) -> str:
parts = [entity_id]
for key in ("kind", "status"):
value = str(entity.get(key) or "")
if value:
parts.append(value)
location = str(entity.get("location") or "")
if location:
parts.append(f"@{location}")
return "- " + " · ".join(parts)
def _relation_line(relation_id: str, relation: dict[str, Any]) -> str:
parts = [relation_id]
kind = str(relation.get("kind") or "")
if kind:
parts.append(kind)
endpoints = f"{relation.get('from_ref') or ''}→{relation.get('to_ref') or ''}"
parts.append(endpoints)
status = str(relation.get("status") or "")
if status:
parts.append(status)
return "- " + " · ".join(part for part in parts if part and part != "→")
def _process_line(process_id: str, process: dict[str, Any]) -> str:
parts = [process_id]
for key in ("kind", "status"):
value = str(process.get(key) or "")
if value:
parts.append(value)
due = process.get("due_at") if isinstance(process.get("due_at"), dict) else None
if due:
parts.append(f"due day {due.get('day', 1)} minute {due.get('minute', 0)}")
return "- " + " · ".join(parts)
def format_world_state_block(
instance: Any, *, viewer_is_gm: bool, viewer_uid: str = "",
location: str = "", participants: Iterable[str] = (),
) -> str:
"""Render the bounded, viewer-safe world projection(FIX-05 §7.3)。
内容 = 权威事实 + 相关实体 / 关系 / 进程 + 逻辑时钟;玩家视角不包含
``gm`` 可见性记录,且投影本身有上界(不会 dump 全世界)。世界为空时返回空串。
"""
projection = project_visible_state(
instance, viewer_uid=viewer_uid, viewer_is_gm=bool(viewer_is_gm),
location=location, participants=participants,
)
facts = projection.get("facts") or {}
entities = projection.get("entities") or {}
relations = projection.get("relations") or {}
processes = projection.get("processes") or {}
if not (facts or entities or relations or processes):
return ""
language = getattr(instance, "language", "zh-CN")
lines = []
for key, fact in facts.items():
value = json.dumps(fact.get("value"), ensure_ascii=False)
suffix = ""
if str(fact.get("visibility") or "") == "gm":
suffix = localized_text(language, _GM_ONLY_SUFFIX)
lines.append(f"- {key} = {value}{suffix}")
if entities:
lines.append(localized_text(language, _ENTITY_HEADING))
lines.extend(_entity_line(key, item) for key, item in entities.items())
if relations:
lines.append(localized_text(language, _RELATION_HEADING))
lines.extend(_relation_line(key, item) for key, item in relations.items())
if processes:
lines.append(localized_text(language, _PROCESS_HEADING))
lines.extend(_process_line(key, item) for key, item in processes.items())
if projection.get("truncated"):
lines.append(localized_text(language, _TRUNCATED_NOTE))
clock = projection.get("clock") or {}
clock_line = localized_text(language, _CLOCK).format(
day=clock.get("day", 1), minute=clock.get("minute", 0),
)
heading = localized_text(language, _HEADING)
return f"{heading}\n{clock_line}\n" + "\n".join(lines)
def format_world_legality_block(instance: Any) -> str:
"""Render this round's proven world contradictions (empty when none)."""
notes = list(module_hooks.read(instance, module_hooks.WORLD_LEGALITY))
if not notes:
return ""
language = getattr(instance, "language", "zh-CN")
players = getattr(instance, "players", None) or {}
lines = []
for note in notes:
if not isinstance(note, dict):
continue
template = _LEGALITY_NOTE.get(str(note.get("code") or ""))
if template is None:
continue
uid = str(note.get("player") or "")
name = (players.get(uid) or {}).get("character_name", uid) if uid else ""
lines.append("- " + localized_text(language, template).format(
name=str(name or uid),
location=str(note.get("location") or ""),
current=str(note.get("current") or ""),
))
if not lines:
return ""
return f"{localized_text(language, _LEGALITY_HEADING)}\n" + "\n".join(lines)
def format_world_events_block(instance: Any) -> str:
"""Render the scheduled events settled by this round's time advance."""
events = list(module_hooks.read(instance, module_hooks.WORLD_EVENTS))
if not events:
return ""
language = getattr(instance, "language", "zh-CN")
lines = []
for event in events:
if not isinstance(event, dict):
continue
due = event.get("due_at") if isinstance(event.get("due_at"), dict) else {}
values = {
"label": str(event.get("label") or event.get("event_id") or ""),
"event_id": str(event.get("event_id") or ""),
"day": due.get("day", 1),
"minute": due.get("minute", 0),
"error": str(event.get("error") or ""),
}
template = (
_EVENT_FAILED_LINE if event.get("status") == "failed" else _EVENT_LINE
)
lines.append("- " + localized_text(language, template).format(**values))
if not lines:
return ""
return f"{localized_text(language, _EVENTS_HEADING)}\n" + "\n".join(lines)
__all__ = [
"format_world_events_block",
"format_world_legality_block",
"format_world_state_block",
]