Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .gitignore
Binary file not shown.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.

Expand All @@ -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
Expand All @@ -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` |
Expand All @@ -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!

Expand All @@ -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` |
Expand Down
4 changes: 2 additions & 2 deletions dice_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down Expand Up @@ -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:")
Expand Down
2 changes: 1 addition & 1 deletion forge/character_creator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
10 changes: 5 additions & 5 deletions forge/config_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions forge/formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---
Expand All @@ -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}")
98 changes: 89 additions & 9 deletions game_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]")
Expand Down
Loading