diff --git a/.gitignore b/.gitignore index ba756d4..849a498 100644 Binary files a/.gitignore and b/.gitignore differ diff --git a/README.md b/README.md index 8a76679..23e54f0 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,9 @@ Most AI RPGs let the language model make up numbers. Project Infinity runs every - **Combat Registry** — GM registers all combatants once per battle. Initiative auto-rolled for everyone. The engine tracks every combatant's HP across attacks. - **Combat Healing** — Cure Wounds, Healing Word, Mass Cure Wounds, Heal, and all SRD healing spells resolve through the combat registry. Player-to-NPC, NPC-to-player, and NPC-to-NPC healing all apply correctly with HP capped at maximum. Power Word Heal restores full HP. - **In-Game Commands** — `/stats`, `/save`, `/sync`, `/quit` from within the game. +- **Session Timeline** — Every 5 rounds, the GM auto-summarizes key events, NPCs met, mechanical changes, and active hooks into a structured `.timeline.md`. Persists between sessions and injected on next load. Cache-friendly: old entries reused by the LLM without re-processing. + +> **Encoding Note for Chinese Windows**: Python defaults to CP936 (GBK) on Chinese Windows, which cannot decode UTF-8 files. All `open()` calls in this project now use `encoding="utf-8"` to prevent `UnicodeDecodeError` when reading YAML configs, JSON player data, world `.wwf` files, and spell databases. Read on for quick start, gameplay hints, and the full engine overview under **How It Works**. @@ -37,6 +40,7 @@ Read on for quick start, gameplay hints, and the full engine overview under **Ho - **Python 3.11** or newer - **One AI backend** (pick one): + - **DeepSeek** — cloud-based, requires a paid API key (OpenAI-compatible, no proxy needed) - **Ollama** — cloud-based, free and paid - **OpenAI** — cloud-based, requires a paid API key - **Gemini** — cloud-based, requires a paid API key @@ -58,6 +62,7 @@ pip install -r requirements.txt | Backend | Requirements | Supported Models | Play Command | |---------|-------------|------------------|--------------| | **Ollama** | Install [Ollama](https://ollama.ai/), pull your model | `kimi-k2.6:cloud`, `deepseek-v4-flash:cloud`, `deepseek-v4-pro:cloud` | `python3 play.py` | +| **DeepSeek** | `export DEEPSEEK_API_KEY=your-api-key` | `deepseek-v4-flash`, `deepseek-v4-pro` | `python3 play_with_deepseek.py` | | **OpenAI** | `export OPENAI_API_KEY=your-api-key` | `gpt-5.5-pro`, `gpt-5.5`, `gpt-5.4`, `gpt-5.4-mini`, `gpt-5.4-nano` | `python3 play_with_gpt.py` | | **Gemini** | `export GEMINI_API_KEY=your-api-key` | `gemini-3.1-pro-preview`, `gemini-3-flash-preview`, `gemini-2.5-pro` | `python3 play_with_gemini.py` | | **Claude** | `export ANTHROPIC_API_KEY=your-api-key` | `claude-opus-4-7`, `claude-opus-4-6` | `python3 play_with_claude.py` | @@ -75,6 +80,7 @@ python3 main.py This generates two files in the `output/` directory: - `yourcharacter_weave.wwf` — the world data (kingdoms, NPCs, guilds, history) - `yourcharacter_weave.player` — your character's stats and inventory +- `yourcharacter_weave.timeline.md` — session timeline (auto-created on first save checkpoint) ### 5. Play! @@ -83,6 +89,7 @@ Launch the game with the script that matches your backend: | Backend | Command | |---------|---------| | Ollama | `python3 play.py` | +| DeepSeek | `python3 play_with_deepseek.py` | | OpenAI | `python3 play_with_gpt.py` | | Gemini | `python3 play_with_gemini.py` | | Claude | `python3 play_with_claude.py` | diff --git a/dice_server.py b/dice_server.py index 75c2778..d5b40db 100644 --- a/dice_server.py +++ b/dice_server.py @@ -57,7 +57,7 @@ def _load_spells() -> dict: if not os.path.exists(config_path): _SPELLS_DB = {} return _SPELLS_DB - with open(config_path, "r") as f: + with open(config_path, "r", encoding="utf-8") as f: spells_list = yaml.safe_load(f) if spells_list is None: _SPELLS_DB = {} @@ -180,7 +180,7 @@ def get_level_for_xp(xp: int) -> int: def init_player_db(player_file_path: str) -> str: global DB_CONNECTION try: - with open(player_file_path, 'r') as f: + with open(player_file_path, 'r', encoding="utf-8") as f: data = json.load(f) DB_CONNECTION = sqlite3.connect(":memory:") diff --git a/forge/character_creator.py b/forge/character_creator.py index 5665b89..343df44 100644 --- a/forge/character_creator.py +++ b/forge/character_creator.py @@ -450,7 +450,7 @@ def create_character(config: Config) -> PlayerCharacter: weapon_categories = build_weapon_categories(weapon_data) weapon_names = set(weapon_data.keys()) - with open(_os.path.join(_os.path.dirname(__file__), '..', 'config', 'spells.yml'), 'r') as f: + with open(_os.path.join(_os.path.dirname(__file__), '..', 'config', 'spells.yml'), 'r', encoding="utf-8") as f: all_spells = yaml.safe_load(f) spell_names = {s['name'] for s in all_spells} diff --git a/forge/config_loader.py b/forge/config_loader.py index a3fbfa0..fdf1f74 100644 --- a/forge/config_loader.py +++ b/forge/config_loader.py @@ -48,19 +48,19 @@ class Config(BaseModel): weapons: List[Weapon] = [] def load_config() -> Config: - with open(os.path.join(config_dir, 'races.yml'), 'r') as f: + with open(os.path.join(config_dir, 'races.yml'), 'r', encoding="utf-8") as f: races_data = yaml.safe_load(f) - with open(os.path.join(config_dir, 'classes.yml'), 'r') as f: + with open(os.path.join(config_dir, 'classes.yml'), 'r', encoding="utf-8") as f: classes_data = yaml.safe_load(f) - with open(os.path.join(config_dir, 'backgrounds.yml'), 'r') as f: + with open(os.path.join(config_dir, 'backgrounds.yml'), 'r', encoding="utf-8") as f: backgrounds_data = yaml.safe_load(f) - with open(os.path.join(config_dir, 'alignments.yml'), 'r') as f: + with open(os.path.join(config_dir, 'alignments.yml'), 'r', encoding="utf-8") as f: alignments_data = yaml.safe_load(f) - with open(os.path.join(config_dir, 'weapons.yml'), 'r') as f: + with open(os.path.join(config_dir, 'weapons.yml'), 'r', encoding="utf-8") as f: weapons_data = yaml.safe_load(f) return Config( diff --git a/forge/formatter.py b/forge/formatter.py index 9dae8ad..28806a5 100644 --- a/forge/formatter.py +++ b/forge/formatter.py @@ -105,7 +105,7 @@ def format_world_to_wwf(world_state: WorldState, output_path: str): # Save player data as JSON for MCP/SQLite player_json_path = os.path.splitext(output_path)[0] + ".player" - with open(player_json_path, 'w') as pf: + with open(player_json_path, 'w', encoding="utf-8") as pf: pf.write(get_player_json(pc, world_state.kingdoms)) # --- History --- @@ -129,7 +129,7 @@ def format_world_to_wwf(world_state: WorldState, output_path: str): output.append(f" leader: {json.dumps(get_npc_array(g.leader))}") output.append(f" right_hand: {json.dumps(get_npc_array(g.right_hand))}") - with open(output_path, 'w') as f: + with open(output_path, 'w', encoding="utf-8") as f: f.write("\n".join(output)) print(f"Schema-driven World-Weave File successfully generated at: {output_path}") diff --git a/game_engine.py b/game_engine.py index 7945ef6..f349a8d 100644 --- a/game_engine.py +++ b/game_engine.py @@ -13,6 +13,46 @@ LOCK_FILE = "GameMaster_MCP.md" OUTPUT_DIR = "output" +TIMELINE_INTERVAL = 5 # rounds between timeline snapshots + +TIMELINE_PROMPT = """SYSTEM INSTRUCTION: You have just completed several rounds of gameplay. +Write a session timeline entry in the following EXACT format. Replace bracketed +text with actual content. Keep it concise. Output ONLY the entry — no extra narration. + +## Rounds X-Y | [current location] | [in-game time] +**Key Events**: +- [event 1 in one sentence] +- [event 2 in one sentence] +**NPCs**: [names and roles of new NPCs encountered, or "none"] +**Mechanical Changes**: +- Gold: [old]→[new] (reason) +- Items: [gained/used key items] +- Reputation: [changed factions, if any] +**Active Hooks**: [all unresolved plot threads, one per line]""" + + +def load_timeline(timeline_path): + """Load existing timeline if available.""" + if os.path.exists(timeline_path): + with open(timeline_path, "r", encoding="utf-8") as f: + return f.read().strip() + return "" + + +def append_timeline_file(timeline_path, entry): + """Append a new timeline entry to the file.""" + # Ensure output dir exists + os.makedirs(os.path.dirname(timeline_path) or ".", exist_ok=True) + # Write header on first entry + if not os.path.exists(timeline_path): + header = "# Session Timeline\n\n" + else: + header = "" + with open(timeline_path, "a", encoding="utf-8") as f: + if header: + f.write(header) + f.write(entry.strip() + "\n\n") + console = Console() VERBOSE = False @@ -78,10 +118,11 @@ async def chat_fn(messages, tools, model, context_window) -> dict console.print(f"\n[green]Selected world:[/green] {wwf_path}") player_path = os.path.splitext(wwf_path)[0] + ".player" + timeline_path = os.path.splitext(wwf_path)[0] + ".timeline.md" - with open(LOCK_FILE, "r") as f: + with open(LOCK_FILE, "r", encoding="utf-8") as f: lock_content = f.read() - with open(wwf_path, "r") as f: + with open(wwf_path, "r", encoding="utf-8") as f: key_content = f.read() try: @@ -104,9 +145,24 @@ async def chat_fn(messages, tools, model, context_window) -> dict } }) + # ── Load session timeline ───────────────────────────── + existing_timeline = load_timeline(timeline_path) + round_counter = 0 + messages = [ {"role": "system", "content": lock_content} ] + if existing_timeline: + messages.append({ + "role": "system", + "content": f"""SESSION_TIMELINE — these are events that happened earlier this session. +Refer to them when the player asks about past events. Do not replay or re-describe them. + +{existing_timeline}""" + }) + if VERBOSE: + console.print(f"[dim]Timeline loaded: {timeline_path} ({len(existing_timeline)} chars)[/dim]") + current_context_tokens = 0 async def chat_with_tools(role_content): @@ -141,14 +197,15 @@ async def chat_with_tools(role_content): response_msg = response['message'] content = response_msg['content'] if response_msg else "" - messages.append({ + msg_entry = { "role": "assistant", "content": content or "", - "tool_calls": response_msg.get('tool_calls') or None, - } if response_msg.get('tool_calls') else { - "role": "assistant", - "content": content or "", - }) + } + if response_msg.get('tool_calls'): + msg_entry["tool_calls"] = response_msg['tool_calls'] + if response.get('thinking'): + msg_entry["thinking"] = response['thinking'] + messages.append(msg_entry) thinking_retries = 0 MAX_THINKING_RETRIES = 3 @@ -280,7 +337,7 @@ async def handle_slash_command(cmd): cleared.append(spell_name) db_data["active_effects"] = [] db_data["_active_buff_data"] = {} - with open(player_path, "w") as f: + with open(player_path, "w", encoding="utf-8") as f: json.dump(db_data, f, indent=2) msg = f"[green]Character sheet saved to {player_path}[/green]" if cleared: @@ -368,6 +425,29 @@ async def handle_slash_command(cmd): )) console.print("\n") + # ── Timeline checkpoint ──────────────────────── + round_counter += 1 + if round_counter % TIMELINE_INTERVAL == 0: + if VERBOSE: + console.print(f"\n[dim]⏳ Timeline checkpoint (round {round_counter})...[/dim]") + try: + # Calculate round range for this entry + start_round = round_counter - TIMELINE_INTERVAL + 1 + prompt = TIMELINE_PROMPT.replace("X-Y", f"{start_round}-{round_counter}") + tl_response = await chat_with_tools(prompt) + if tl_response and tl_response != "__SYSTEM_PAUSE__": + # Clean up sync tokens from timeline entry + entry = tl_response.replace("{{_NEED_AN_OTHER_PROMPT}}", "").replace("{{_NEED_ANOTHER_PROMPT}}", "").strip() + if entry and "**Key Events**" in entry: + append_timeline_file(timeline_path, entry) + if VERBOSE: + console.print(f"[dim]✅ Timeline saved ({len(entry)} chars)[/dim]") + elif VERBOSE: + console.print(f"[dim]⚠️ Timeline entry missing Key Events — skipped[/dim]") + except Exception as e: + if VERBOSE: + console.print(f"[dim]⚠️ Timeline checkpoint failed: {e}[/dim]") + except KeyboardInterrupt: console.print("\n[yellow]Game interrupted. Goodbye.[/yellow]") diff --git a/play_with_deepseek.py b/play_with_deepseek.py new file mode 100644 index 0000000..aab6ae8 --- /dev/null +++ b/play_with_deepseek.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Project Infinity x DeepSeek — direct cloud API adapter, no proxy needed. + +Usage: + $env:DEEPSEEK_API_KEY="sk-xxx" # PowerShell, set once + python play_with_deepseek.py # default: deepseek-v4-flash + python play_with_deepseek.py --think # enable thinking mode + python play_with_deepseek.py --pro # use v4-pro (more expensive) +""" + +import os +import sys +import json +import argparse +import asyncio +from collections import deque +from rich.panel import Panel +from openai import AsyncOpenAI, APIStatusError +from game_engine import run_game, console + +# ── DeepSeek configuration ───────────────────────────────────── +# API docs: https://api-docs.deepseek.com/ +# Note: base_url without /v1 suffix (official docs: https://api.deepseek.com) +DEEPSEEK_BASE_URL = "https://api.deepseek.com" +# deepseek-v4-flash: recommended default (cheapest) +# deepseek-v4-pro: higher performance, use --pro +DEFAULT_MODEL = "deepseek-v4-flash" +MODEL_CONTEXT_LENGTHS = { + "deepseek-v4-flash": 1000000, + "deepseek-v4-pro": 1000000, +} +DEFAULT_TEMP = 0.0 + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Project Infinity: D&D RPG powered by DeepSeek API" + ) + parser.add_argument("--verbose", "-v", action="store_true", + help="Show detailed MCP tool calls and responses") + parser.add_argument("--debug", "-d", action="store_true", + help="Show raw LLM responses and tool calls") + parser.add_argument("--temperature", "-t", type=float, default=DEFAULT_TEMP, + help=f"Sampling temperature (default: {DEFAULT_TEMP})") + parser.add_argument("--think", action="store_true", + help="Enable thinking mode (thinking.type=enabled)") + parser.add_argument("--pro", action="store_true", + help="Use deepseek-v4-pro instead of default deepseek-v4-flash") + return parser.parse_args() + + +def create_deepseek_chat_fn(api_key, debug=False, think=False, temperature=DEFAULT_TEMP): + """Create a DeepSeek chat function compatible with Project Infinity engine.""" + client = AsyncOpenAI( + api_key=api_key, + base_url=DEEPSEEK_BASE_URL, + ) + openai_messages = [] + system_instruction = None + last_processed = 0 + tool_call_id_counter = 0 + + def _next_tool_call_id(): + nonlocal tool_call_id_counter + tool_call_id_counter += 1 + return f"tc_{tool_call_id_counter}" + + async def chat_fn(messages, tools, model, context_window): + nonlocal openai_messages, system_instruction, last_processed + + pending_tool_call_ids = deque() + + # ── Build OpenAI-format messages incrementally ───────── + for msg in messages[last_processed:]: + role = msg.get("role", "") + content = msg.get("content", "") + + if role == "system": + system_instruction = content + continue + + if role == "user": + openai_messages.append({"role": "user", "content": content}) + + elif role == "assistant": + tool_calls_raw = msg.get("tool_calls") + if tool_calls_raw: + oai_tool_calls = [] + for tc in tool_calls_raw: + fn = tc.get("function", {}) + tc_id = _next_tool_call_id() + fn_args = fn.get("arguments", {}) + if isinstance(fn_args, dict): + fn_args = json.dumps(fn_args) + oai_tool_calls.append({ + "id": tc_id, + "type": "function", + "function": {"name": fn.get("name", ""), "arguments": fn_args}, + }) + pending_tool_call_ids.append(tc_id) + oai_msg = { + "role": "assistant", + "content": content or None, + "tool_calls": oai_tool_calls, + } + reasoning = msg.get("thinking") + if reasoning: + oai_msg["reasoning_content"] = reasoning + openai_messages.append(oai_msg) + else: + oai_msg = {"role": "assistant", "content": content or ""} + # DeepSeek requires reasoning_content to be echoed back in thinking mode + reasoning = msg.get("thinking") + if reasoning: + oai_msg["reasoning_content"] = reasoning + openai_messages.append(oai_msg) + + elif role == "tool": + tool_msg = { + "role": "tool", + "tool_call_id": ( + pending_tool_call_ids.popleft() if pending_tool_call_ids + else _next_tool_call_id() + ), + "content": msg.get("content", ""), + } + openai_messages.append(tool_msg) + + last_processed = len(messages) + + # ── Build tools ─────────────────────────────────────── + openai_tools = [] + if tools: + for tool in tools: + fn = tool.get("function", {}) + openai_tools.append({ + "type": "function", + "function": { + "name": fn.get("name", ""), + "description": fn.get("description", ""), + "parameters": fn.get("parameters", {}), + } + }) + + # ── Chat Completions API call ───────────────────────── + kwargs = { + "model": model, + "messages": openai_messages, + "temperature": temperature, + "max_tokens": min(context_window, 16384), + } + + if openai_tools: + kwargs["tools"] = openai_tools + kwargs["tool_choice"] = "auto" + + # DeepSeek thinking mode: pass via extra_body per official docs + if think: + kwargs["extra_body"] = {"thinking": {"type": "enabled"}} + + # Inject system prompt + if system_instruction: + has_system = any(m.get("role") == "system" for m in openai_messages) + if not has_system: + openai_messages.insert(0, {"role": "system", "content": system_instruction}) + kwargs["messages"] = openai_messages + + max_retries = 3 + max_empty_retries = 3 + empty_retries = 0 + + for attempt in range(max_retries + max_empty_retries): + try: + response = await client.chat.completions.create(**kwargs) + + prompt_tokens = 0 + if response.usage: + prompt_tokens = response.usage.prompt_tokens or 0 + + choice = response.choices[0] if response.choices else None + if not choice: + return { + "prompt_eval_count": prompt_tokens, + "message": {"content": "[No response generated.]", "tool_calls": None}, + } + + msg = choice.message + content = msg.content or "" + + # DeepSeek returns reasoning_content when thinking is enabled + reasoning = getattr(msg, "reasoning_content", None) + + tool_calls = None + if msg.tool_calls: + tool_calls = [] + for tc in msg.tool_calls: + args = {} + if tc.function.arguments: + try: + args = json.loads(tc.function.arguments) + except (json.JSONDecodeError, TypeError): + args = {} + tool_calls.append({ + "function": { + "name": tc.function.name, + "arguments": args, + } + }) + + # Auto-retry on empty response + if not content.strip() and tool_calls is None: + empty_retries += 1 + if empty_retries <= max_empty_retries: + if debug: + console.print( + f"[bold yellow]DEBUG: Empty response. " + f"Retrying... ({empty_retries}/{max_empty_retries})[/bold yellow]" + ) + last_user_msg = None + for m in reversed(messages): + if m.get("role") == "user": + last_user_msg = m.get("content", "") + break + if last_user_msg: + openai_messages.append({"role": "user", "content": last_user_msg}) + kwargs["messages"] = openai_messages + await asyncio.sleep(1) + continue + if debug: + console.print("[bold red]DEBUG: Empty response persists.[/bold red]") + + result = { + "prompt_eval_count": prompt_tokens, + "message": { + "content": content, + "tool_calls": tool_calls, + }, + } + if reasoning: + result["thinking"] = reasoning + return result + + except APIStatusError as e: + if e.status_code in (429, 500, 502, 503) and attempt < max_retries - 1: + if debug: + console.print( + f"[bold yellow]DEBUG: API error {e.status_code}. " + f"Retrying... ({attempt+1}/{max_retries})[/bold yellow]" + ) + await asyncio.sleep(2) + continue + raise e + except Exception as e: + if debug: + import traceback + traceback.print_exc() + console.print( + f"[bold red]DEBUG: {type(e).__name__}: {e}[/bold red]" + ) + raise e + + return { + "prompt_eval_count": 0, + "message": { + "content": "[Error: max retries exceeded]", + "tool_calls": None, + }, + } + + return chat_fn + + +async def main(): + api_key = os.environ.get("DEEPSEEK_API_KEY") + if not api_key: + console.print("[bold red]Error:[/bold red] DEEPSEEK_API_KEY not set.") + console.print("[yellow]PowerShell: $env:DEEPSEEK_API_KEY=\"sk-your-key\"[/yellow]") + console.print("[yellow]Bash: export DEEPSEEK_API_KEY=\"sk-your-key\"[/yellow]") + sys.exit(1) + + args = parse_args() + debug = args.debug + verbose = args.verbose or args.debug + + model = "deepseek-v4-pro" if args.pro else DEFAULT_MODEL + context_window = MODEL_CONTEXT_LENGTHS.get(model, 1000000) + + console.print(Panel( + f"[bold cyan]Project Infinity × DeepSeek[/bold cyan]\n" + f"[dim]Model: {model} | Context: {context_window:,} tokens | " + f"api.deepseek.com[/dim]", + expand=False + )) + + chat_fn = create_deepseek_chat_fn( + api_key, + debug=debug, + think=args.think, + temperature=args.temperature, + ) + + await run_game(chat_fn, model, context_window, verbose=verbose, debug=debug) + + +if __name__ == "__main__": + try: + asyncio.run(main()) + except KeyboardInterrupt: + console.print("\n[dim]Goodbye.[/dim]")