Spaces:
Running on Zero
Running on Zero
| """Memory is a *budget*, not an archive. | |
| Every turn we assemble the user-side context from (in priority order): the rolling summary, | |
| the current scene, the present characters' sheets, the last k verbatim turns, and the player's | |
| new line β trimmed to a conservative token budget. Older turns get folded into `summary` by | |
| `orchestrator.compact_memory`. This is the literal "Thousand Token Wood". | |
| """ | |
| from __future__ import annotations | |
| from . import characters, config | |
| from .schemas import GameState | |
| # Flags that drive the engine/UI, not story facts β never surfaced in the FACTS: line. | |
| _INTERNAL_FLAGS = { | |
| "current_music", | |
| "situation_intro", | |
| "beat_started_turn", | |
| "ending_kind", | |
| "ending_text", | |
| "player_name", | |
| } | |
| _INTERNAL_PREFIXES = ("milestone50_",) | |
| def assemble_context(state: GameState, player_input: str, action_note: str = "") -> str: | |
| parts: list[str] = [] # the head: never trimmed (style, scene, sheets, milestones) | |
| parts.append(f"WORLD STYLE: {state.style_guide}") | |
| parts.append(f"VIBE: {state.vibe} | BEAT: {state.beat}") | |
| player_name = state.flags.get("player_name", "") | |
| if player_name: | |
| parts.append(f'The player is called "{player_name}" - characters address them by name.') | |
| if state.summary: | |
| parts.append(f"THE TALE SO FAR:\n{state.summary}") | |
| parts.append( | |
| f"WHERE WE ARE:\n- place: {state.scene.place}\n" | |
| f"- description: {state.scene.description}\n- mood: {state.scene.mood}" | |
| ) | |
| parts.append(f"SPIRITS ON STAGE:\n{characters.present_sheets_block(state)}") | |
| # Characters the player has already met but who are currently off-stage. | |
| # The LLM can bring them back via new_character (same id) β data is preserved. | |
| off_stage = [ | |
| f" - {ch.name} (id: {ch.id}, rel: {ch.relationship:+d})" | |
| for cid, ch in state.characters.items() | |
| if cid not in state.scene.present | |
| ] | |
| if off_stage: | |
| parts.append( | |
| "KNOWN BUT OFF STAGE (can return β use their existing id):\n" + "\n".join(off_stage) | |
| ) | |
| # ββ Milestone-50 special moment β one-shot hint armed by apply_directives ββ | |
| for ch in state.characters.values(): | |
| if ch.id in state.scene.present and state.flags.get(f"milestone50_{ch.id}") == "pending": | |
| parts.append( | |
| f"*** GROWING CLOSE: {ch.name}'s affection just reached 50+/100. ***\n" | |
| f"This turn, have {ch.name} initiate a small special moment: suggest going " | |
| f"somewhere together (emit scene_change), confide a small secret, or give " | |
| f"the player an affectionate nickname. Make it feel earned, not sudden." | |
| ) | |
| # ββ Pacing nudge β deterministic, fires when a beat lingers too long ββββββ | |
| turns_in_beat = state.turn_index - int(state.flags.get("beat_started_turn", "0") or 0) | |
| if turns_in_beat >= 7 and state.beat in ("opening", "rising", "turn"): | |
| parts.append( | |
| f"PACING: {turns_in_beat} turns in beat '{state.beat}' - the scene is " | |
| f"lingering. Consider advance_beat=true this turn." | |
| ) | |
| # ββ Ending milestone hints β injected when a character hits Β±100 ββββββββββ | |
| for ch in state.characters.values(): | |
| if ch.relationship >= 100 and ch.id in state.scene.present: | |
| parts.append( | |
| f"*** ROMANCE PEAK: {ch.name}'s affection is at MAX (100/100). ***\n" | |
| f'This turn MUST be their heartfelt confession. Emit ending(kind="warm") ' | |
| f'with a poetic 2-4 sentence epilogue. Also emit set_music="romantic".' | |
| ) | |
| elif ch.relationship <= -100 and ch.id in state.scene.present: | |
| parts.append( | |
| f"*** RELATIONSHIP BROKEN: {ch.name} is at MIN (-100/100). ***\n" | |
| f'They storm off with a final cutting line. MUST emit exit_character="{ch.id}". ' | |
| f'If the stage becomes empty after this, also emit ending(kind="defeat") ' | |
| f'with a dark 2-4 sentence epilogue. Emit set_music="sad".' | |
| ) | |
| # If scene already empty and not yet ended β nudge for defeat | |
| if not state.scene.present and state.beat != "ended" and state.characters: | |
| parts.append( | |
| "*** THE STAGE IS EMPTY β the wanderer is completely alone. ***\n" | |
| 'Emit ending(kind="defeat") with a melancholic epilogue. Emit set_music="sad".' | |
| ) | |
| # Surface the current music track prominently so the LLM doesn't switch it needlessly. | |
| current_music = state.flags.get("current_music") | |
| if current_music: | |
| parts.append( | |
| f'CURRENT BGM: "{current_music}" β leave set_music null unless a MAJOR tonal ' | |
| f"shift justifies a change (max once every 6-8 turns)." | |
| ) | |
| other_flags = { | |
| k: v | |
| for k, v in state.flags.items() | |
| if k not in _INTERNAL_FLAGS and not k.startswith(_INTERNAL_PREFIXES) | |
| } | |
| if other_flags: | |
| parts.append("FACTS: " + ", ".join(f"{k}={v}" for k, v in other_flags.items())) | |
| # All turns since the last compaction β bounded by compaction (oldest get folded | |
| # into `summary`) and by the budget below (oldest lines dropped first). | |
| recent_lines = [ | |
| f'wanderer: "{t.player}"\n{t.speaker}: "{t.dialogue}"' for t in state.recent_turns | |
| ] | |
| tail_parts = [] | |
| if action_note: | |
| # Kept OUTSIDE the quoted player line β inside the quotes the model reads | |
| # directives as player speech and ignores them. | |
| tail_parts.append(f"DIRECTOR NOTE (mandatory):\n{action_note}") | |
| tail_parts += [ | |
| f'{(player_name or "the wanderer").upper()} NOW SAYS: "{player_input.strip() or "β¦"}"', | |
| "Reply as one present spirit, then emit the directives JSON. " | |
| f"You may speak as one of: {characters.actor_roster(state)}.", | |
| ] | |
| return _fit_to_budget(parts, recent_lines, tail_parts, config.CONTEXT_TOKEN_BUDGET) | |
| def should_compact(state: GameState) -> bool: | |
| # Compaction keeps RECENT_TURNS_K turns verbatim; trigger once at least as many | |
| # again have piled up, so each compaction call folds a meaningful chunk. | |
| return len(state.recent_turns) > 2 * config.RECENT_TURNS_K | |
| def _fit_to_budget( | |
| head_parts: list[str], | |
| recent_lines: list[str], | |
| tail_parts: list[str], | |
| token_budget: int, | |
| ) -> str: | |
| """Rough charβtoken/4 budget. Drops the oldest RECENT EXCHANGE lines first; the | |
| system-critical head (style, scene, sheets) and the player's line are preserved. | |
| Hard-clamps keeping the tail only as a last resort (head alone over budget).""" | |
| char_budget = token_budget * 4 | |
| def _join(lines: list[str]) -> str: | |
| parts = list(head_parts) | |
| if lines: | |
| parts.append("RECENT EXCHANGE:\n" + "\n".join(lines)) | |
| parts.extend(tail_parts) | |
| return "\n\n".join(parts) | |
| ctx = _join(recent_lines) | |
| if len(ctx) <= char_budget: | |
| return ctx | |
| lines = list(recent_lines) | |
| while lines: | |
| lines = lines[1:] | |
| ctx = _join(["β¦(earlier exchange trimmed)β¦", *lines]) | |
| if len(ctx) <= char_budget: | |
| return ctx | |
| return "β¦(earlier dream trimmed)β¦\n\n" + ctx[-char_budget:] | |