Skip to content

Commit 3146bae

Browse files
authored
feat(galgame): 新增角色决策视角与上下文记忆 (Project-N-E-K-O#1417)
* feat(galgame): add host play character strategy mode * fix(galgame): address host play review findings * fix(galgame): localize character anchor prompt * fix(galgame): persist cross-scene memory updates * fix: clear character mode and fixed name on game bind When binding a new game, the character mode and fixed name state were not being reset, which could cause stale configuration to persist. This change ensures that `character_mode`, `character_fixed_name`, and `character_mode_stale` are properly cleared in both runtime state and persisted storage, preventing unintended behavior from previous character profile configurations. * fix(galgame): refresh zero-seq story summaries * fix(galgame): load user-only character profiles * fix(galgame): preserve user character profiles without preset * fix(galgame): preserve restored character profile state
1 parent 127501e commit 3146bae

44 files changed

Lines changed: 8283 additions & 43 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

plugin/plugins/galgame_plugin/__init__.py

Lines changed: 986 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
"""Cat consultation decision + prompt assembly for GameLLMAgent.
2+
3+
GameLLM consults the catgirl at narrative decision points (visible choices,
4+
scene transitions, dialogue accumulation). The catgirl reply is **reference
5+
information**, not a directive — GameLLM keeps full decision authority.
6+
7+
The plan-level integration is fire-and-forget: the agent sends a consultation
8+
prompt through ``AgentMessageRouter`` and continues immediately. Replies arrive
9+
through the existing inbound message channel and are merged into
10+
``shared["cat_opinions"]`` by :func:`inject_cat_opinion`. ``cat_opinions`` is
11+
capped at ``MAX_CAT_OPINIONS`` so the strategy context cannot grow unbounded.
12+
13+
This module is intentionally pure (no I/O, no agent reference) so it is easy to
14+
unit-test. ``GameLLMAgent`` is expected to call ``decide_consultation`` from
15+
its ``tick`` loop, build the prompt with ``build_consult_prompt``, and dispatch
16+
the message via its existing outbound channel.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import time
22+
from dataclasses import dataclass, field
23+
from typing import Any
24+
25+
from .llm_prompts import (
26+
CONSULT_CAT_CHOICE_QUESTION_TEMPLATE,
27+
CONSULT_CAT_PROMPT_TEMPLATE,
28+
CONSULT_CAT_SCENE_CHANGE_QUESTION_TEMPLATE,
29+
CONSULT_CAT_STORY_PROGRESS_QUESTION_TEMPLATE,
30+
)
31+
32+
33+
CONSULT_COOLDOWN_SECONDS: float = 30.0
34+
"""Lower bound on time between consultations (any reason)."""
35+
36+
CONSULT_PROGRESS_LINE_THRESHOLD: int = 8
37+
"""Dialogue lines since last consult before the progress reason fires."""
38+
39+
MAX_CAT_OPINIONS: int = 5
40+
"""Cap on retained ``shared['cat_opinions']`` entries — older entries drop."""
41+
42+
MAX_RECENT_LINES_IN_PROMPT: int = 5
43+
"""When building a progress prompt, fold at most this many recent lines."""
44+
45+
CONSULT_REASON_CHOICE: str = "choice"
46+
CONSULT_REASON_SCENE_CHANGE: str = "scene_change"
47+
CONSULT_REASON_STORY_PROGRESS: str = "story_progress"
48+
49+
_REASON_PRIORITY: tuple[str, ...] = (
50+
CONSULT_REASON_CHOICE,
51+
CONSULT_REASON_SCENE_CHANGE,
52+
CONSULT_REASON_STORY_PROGRESS,
53+
)
54+
55+
56+
@dataclass(frozen=True, slots=True)
57+
class ConsultInputs:
58+
"""Snapshot of the data needed to make a consultation decision."""
59+
60+
character_mode: str = "off"
61+
character_fixed_name: str = ""
62+
scene_id: str = ""
63+
visible_choices: tuple[str, ...] = ()
64+
scene_changed: bool = False
65+
lines_since_last_consult: int = 0
66+
now: float = 0.0
67+
last_consult_ts: float = 0.0
68+
profile_known: bool = False
69+
70+
71+
@dataclass(frozen=True, slots=True)
72+
class ConsultDecision:
73+
"""Outcome of :func:`decide_consultation`."""
74+
75+
should_consult: bool
76+
reason: str = ""
77+
skip_reason: str = ""
78+
character_name: str = ""
79+
80+
@property
81+
def consulted(self) -> bool:
82+
return self.should_consult
83+
84+
85+
@dataclass(frozen=True, slots=True)
86+
class CatOpinion:
87+
"""A single recorded catgirl reply, returned by :func:`inject_cat_opinion`."""
88+
89+
opinion: str
90+
scene_id: str
91+
reason: str
92+
ts: float
93+
metadata: dict[str, Any] = field(default_factory=dict)
94+
95+
def to_dict(self) -> dict[str, Any]:
96+
return {
97+
"opinion": self.opinion,
98+
"scene_id": self.scene_id,
99+
"reason": self.reason,
100+
"ts": self.ts,
101+
"metadata": dict(self.metadata),
102+
}
103+
104+
105+
# ---------------------------------------------------------------------------
106+
# Decision
107+
# ---------------------------------------------------------------------------
108+
109+
110+
def decide_consultation(inputs: ConsultInputs) -> ConsultDecision:
111+
"""Decide whether to consult the catgirl, and why.
112+
113+
Order of checks (highest priority first):
114+
115+
1. Mode gate — only ``fixed`` mode consults; ``off`` never does.
116+
2. Profile gate — refuse when the fixed character is not in the profile
117+
set (the consultation prompt depends on its voice block).
118+
3. Cooldown — refuse when the last consult was < ``CONSULT_COOLDOWN_SECONDS``
119+
ago. Cooldown wins over every trigger to prevent fast-forward flooding.
120+
4. Trigger — first matching reason among choice / scene change / progress.
121+
"""
122+
123+
if (inputs.character_mode or "").strip().lower() != "fixed":
124+
return ConsultDecision(False, skip_reason="mode_off")
125+
if not (inputs.character_fixed_name or "").strip():
126+
return ConsultDecision(False, skip_reason="no_fixed_character")
127+
if not inputs.profile_known:
128+
return ConsultDecision(
129+
False,
130+
skip_reason="profile_missing",
131+
character_name=inputs.character_fixed_name,
132+
)
133+
134+
elapsed = max(0.0, float(inputs.now) - float(inputs.last_consult_ts))
135+
if inputs.last_consult_ts > 0 and elapsed < CONSULT_COOLDOWN_SECONDS:
136+
return ConsultDecision(
137+
False,
138+
skip_reason="cooldown",
139+
character_name=inputs.character_fixed_name,
140+
)
141+
142+
reason = _select_reason(inputs)
143+
if not reason:
144+
return ConsultDecision(
145+
False,
146+
skip_reason="no_trigger",
147+
character_name=inputs.character_fixed_name,
148+
)
149+
return ConsultDecision(
150+
True,
151+
reason=reason,
152+
character_name=inputs.character_fixed_name,
153+
)
154+
155+
156+
def _select_reason(inputs: ConsultInputs) -> str:
157+
for candidate in _REASON_PRIORITY:
158+
if _reason_active(candidate, inputs):
159+
return candidate
160+
return ""
161+
162+
163+
def _reason_active(reason: str, inputs: ConsultInputs) -> bool:
164+
if reason == CONSULT_REASON_CHOICE:
165+
return bool(inputs.visible_choices)
166+
if reason == CONSULT_REASON_SCENE_CHANGE:
167+
return bool(inputs.scene_changed)
168+
if reason == CONSULT_REASON_STORY_PROGRESS:
169+
return inputs.lines_since_last_consult >= CONSULT_PROGRESS_LINE_THRESHOLD
170+
return False
171+
172+
173+
# ---------------------------------------------------------------------------
174+
# Prompt construction
175+
# ---------------------------------------------------------------------------
176+
177+
178+
def build_consult_prompt(
179+
*,
180+
reason: str,
181+
character_name: str,
182+
character_voice_summary: str,
183+
scene_summary: str,
184+
visible_choices: tuple[str, ...] = (),
185+
recent_lines: tuple[str, ...] = (),
186+
) -> str:
187+
"""Render the consultation prompt using the shared templates."""
188+
189+
question = _build_consult_question(
190+
reason=reason,
191+
character_name=character_name,
192+
visible_choices=visible_choices,
193+
recent_lines=recent_lines,
194+
)
195+
return CONSULT_CAT_PROMPT_TEMPLATE.format(
196+
scene_summary=(scene_summary or "").strip() or "(暂无场景摘要)",
197+
consult_question=question,
198+
character_name=(character_name or "").strip() or "(未指定角色)",
199+
character_voice_summary=(character_voice_summary or "").strip()
200+
or "(角色说话方式未提供)",
201+
)
202+
203+
204+
def _build_consult_question(
205+
*,
206+
reason: str,
207+
character_name: str,
208+
visible_choices: tuple[str, ...],
209+
recent_lines: tuple[str, ...],
210+
) -> str:
211+
name = (character_name or "").strip() or "(未指定角色)"
212+
if reason == CONSULT_REASON_CHOICE:
213+
return CONSULT_CAT_CHOICE_QUESTION_TEMPLATE.format(
214+
choices=";".join(choice for choice in visible_choices if choice)
215+
or "(未提供选项)",
216+
character_name=name,
217+
)
218+
if reason == CONSULT_REASON_SCENE_CHANGE:
219+
return CONSULT_CAT_SCENE_CHANGE_QUESTION_TEMPLATE.format(
220+
character_name=name
221+
)
222+
if reason == CONSULT_REASON_STORY_PROGRESS:
223+
recent_text = "\n".join(
224+
line.strip()
225+
for line in recent_lines[-MAX_RECENT_LINES_IN_PROMPT:]
226+
if (line or "").strip()
227+
) or "(暂无最近台词)"
228+
return CONSULT_CAT_STORY_PROGRESS_QUESTION_TEMPLATE.format(
229+
recent_lines=recent_text
230+
)
231+
return f"作为 {name},你有什么想说的?"
232+
233+
234+
def summarize_character_voice(profile: dict[str, Any] | None) -> str:
235+
"""Compact voice summary suitable for the consultation prompt header."""
236+
if not isinstance(profile, dict):
237+
return ""
238+
voice = profile.get("character_voice")
239+
if not isinstance(voice, dict):
240+
return ""
241+
traits = voice.get("core_traits")
242+
if not isinstance(traits, list):
243+
return ""
244+
parts: list[str] = []
245+
for trait_obj in traits[:2]:
246+
if not isinstance(trait_obj, dict):
247+
continue
248+
trait = str(trait_obj.get("trait") or "").strip()
249+
speech = str(trait_obj.get("speech_effect") or "").strip()
250+
if trait and speech:
251+
parts.append(f"{trait}{speech}")
252+
pronoun = str(voice.get("first_person_pronoun") or "").strip()
253+
if pronoun:
254+
parts.append(f"自称「{pronoun}」")
255+
return ";".join(parts)
256+
257+
258+
# ---------------------------------------------------------------------------
259+
# Opinion injection
260+
# ---------------------------------------------------------------------------
261+
262+
263+
def inject_cat_opinion(
264+
shared: dict[str, Any],
265+
opinion: str,
266+
*,
267+
scene_id: str = "",
268+
reason: str = "",
269+
ts: float | None = None,
270+
metadata: dict[str, Any] | None = None,
271+
) -> CatOpinion | None:
272+
"""Append a catgirl opinion to ``shared['cat_opinions']`` (capped)."""
273+
text = (opinion or "").strip()
274+
if not text:
275+
return None
276+
record = CatOpinion(
277+
opinion=text,
278+
scene_id=(scene_id or "").strip(),
279+
reason=(reason or "").strip(),
280+
ts=float(ts if ts is not None else time.time()),
281+
metadata=dict(metadata or {}),
282+
)
283+
queue = shared.get("cat_opinions")
284+
if not isinstance(queue, list):
285+
queue = []
286+
queue = list(queue)
287+
queue.append(record.to_dict())
288+
if len(queue) > MAX_CAT_OPINIONS:
289+
queue = queue[-MAX_CAT_OPINIONS:]
290+
shared["cat_opinions"] = queue
291+
return record
292+
293+
294+
def get_recent_cat_opinions(
295+
shared: dict[str, Any], n: int = MAX_CAT_OPINIONS
296+
) -> list[dict[str, Any]]:
297+
if n <= 0:
298+
return []
299+
queue = shared.get("cat_opinions")
300+
if not isinstance(queue, list):
301+
return []
302+
return [dict(entry) for entry in queue[-n:]]
303+
304+
305+
def render_cat_opinions_for_strategy(
306+
shared: dict[str, Any], n: int = MAX_CAT_OPINIONS
307+
) -> str:
308+
"""Render recent opinions as a strategy-context block.
309+
310+
The header is intentional: cat replies are high-priority fixed-character
311+
POV advice, but GameLLM still keeps final decision authority.
312+
"""
313+
recent = get_recent_cat_opinions(shared, n)
314+
if not recent:
315+
return ""
316+
lines = ["[Fixed-character POV advice: high-priority reference, not a command]"]
317+
for entry in recent:
318+
opinion = str(entry.get("opinion") or "").strip()
319+
if not opinion:
320+
continue
321+
reason = str(entry.get("reason") or "").strip()
322+
tag = f"({reason})" if reason else ""
323+
lines.append(f"· {opinion}{tag}")
324+
return "\n".join(lines) if len(lines) > 1 else ""
325+
326+
327+
__all__ = [
328+
"ConsultInputs",
329+
"ConsultDecision",
330+
"CatOpinion",
331+
"decide_consultation",
332+
"build_consult_prompt",
333+
"summarize_character_voice",
334+
"inject_cat_opinion",
335+
"get_recent_cat_opinions",
336+
"render_cat_opinions_for_strategy",
337+
"CONSULT_COOLDOWN_SECONDS",
338+
"CONSULT_PROGRESS_LINE_THRESHOLD",
339+
"MAX_CAT_OPINIONS",
340+
"MAX_RECENT_LINES_IN_PROMPT",
341+
"CONSULT_REASON_CHOICE",
342+
"CONSULT_REASON_SCENE_CHANGE",
343+
"CONSULT_REASON_STORY_PROGRESS",
344+
]

0 commit comments

Comments
 (0)