Add game.py
This commit is contained in:
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
Interactive choose-your-own-adventure game using LangGraph and OpenAI.
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
python game.py
|
||||||
|
|
||||||
|
Make sure you have an OpenAI API key set in the environment variable OPENAI_API_KEY.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from typing import TypedDict, List, Dict, Any
|
||||||
|
|
||||||
|
from langgraph.graph import StateGraph, START
|
||||||
|
from langgraph.checkpoint.memory import InMemorySaver
|
||||||
|
from langgraph.types import interrupt, Command
|
||||||
|
from langchain_openai import ChatOpenAI
|
||||||
|
import questionary
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. Define the graph state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class GameState(TypedDict):
|
||||||
|
theme: str
|
||||||
|
story: str
|
||||||
|
options: List[str]
|
||||||
|
choice: str
|
||||||
|
ending: str
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Helper functions
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Simple parser that expects the LLM to return:
|
||||||
|
# <story>
|
||||||
|
# 1) <option1>
|
||||||
|
# 2) <option2>
|
||||||
|
# 3) <option3>
|
||||||
|
# The story may span multiple lines.
|
||||||
|
|
||||||
|
def parse_story_and_options(text: str) -> tuple[str, List[str]]:
|
||||||
|
"""Parse the LLM output into a story and a list of options.
|
||||||
|
|
||||||
|
The function is tolerant to small formatting variations.
|
||||||
|
"""
|
||||||
|
# Split into lines
|
||||||
|
lines = text.strip().splitlines()
|
||||||
|
# Find the first line that starts with a digit and a closing parenthesis or dot
|
||||||
|
option_pattern = re.compile(r"^\s*\d+[\).]\s*(.*)")
|
||||||
|
story_lines = []
|
||||||
|
options: List[str] = []
|
||||||
|
for line in lines:
|
||||||
|
m = option_pattern.match(line)
|
||||||
|
if m:
|
||||||
|
options.append(m.group(1).strip())
|
||||||
|
else:
|
||||||
|
story_lines.append(line)
|
||||||
|
story = "\n".join(story_lines).strip()
|
||||||
|
return story, options
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Graph nodes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# LLM model
|
||||||
|
llm = ChatOpenAI(temperature=0.7, model="gpt-4o-mini")
|
||||||
|
|
||||||
|
def generate_scene_and_interrupt(state: GameState) -> GameState:
|
||||||
|
"""Generate the opening scene and options, then interrupt for user choice.
|
||||||
|
|
||||||
|
The node returns a new state with story and options populated, and then
|
||||||
|
calls interrupt(). After the user responds, the graph resumes in this
|
||||||
|
same node with the updated state containing the choice.
|
||||||
|
"""
|
||||||
|
theme = state["theme"]
|
||||||
|
|
||||||
|
# If we have already received a choice, generate the ending and finish.
|
||||||
|
if state.get("choice"):
|
||||||
|
# Generate ending
|
||||||
|
prompt = (
|
||||||
|
f"Theme: {theme}\n"
|
||||||
|
f"Story: {state['story']}\n"
|
||||||
|
f"User choice: {state['choice']}\n"
|
||||||
|
"Write a short ending (2–3 sentences) that follows from the choice."
|
||||||
|
)
|
||||||
|
ending = llm.invoke(prompt).content
|
||||||
|
state["ending"] = ending.strip()
|
||||||
|
return state
|
||||||
|
|
||||||
|
# No choice yet: generate scene and options
|
||||||
|
prompt = (
|
||||||
|
f"Theme: {theme}\n"
|
||||||
|
"Create a short opening scene (2–3 sentences) and exactly three numbered options for the hero to choose.\n"
|
||||||
|
"Respond in the following format:\n"
|
||||||
|
"<story>\n"
|
||||||
|
"1) <option1>\n"
|
||||||
|
"2) <option2>\n"
|
||||||
|
"3) <option3>\n"
|
||||||
|
)
|
||||||
|
raw = llm.invoke(prompt).content
|
||||||
|
story, options = parse_story_and_options(raw)
|
||||||
|
state["story"] = story
|
||||||
|
state["options"] = options
|
||||||
|
|
||||||
|
# Prepare interrupt payload
|
||||||
|
interrupt_payload = {
|
||||||
|
"type": "choice",
|
||||||
|
"question": story,
|
||||||
|
"options": options,
|
||||||
|
}
|
||||||
|
# Interrupt the graph; the returned state will contain the user response
|
||||||
|
interrupt(interrupt_payload)
|
||||||
|
# After interrupt, the graph will resume in this same node with the updated state.
|
||||||
|
return state
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Build the graph
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
graph_builder = StateGraph(GameState)
|
||||||
|
graph_builder.add_node("scene", generate_scene_and_interrupt)
|
||||||
|
# All paths go through the same node; we finish when ending is set.
|
||||||
|
graph_builder.set_entry_point("scene")
|
||||||
|
# The node will keep looping until an ending is produced.
|
||||||
|
# We use a simple condition: if ending exists, we finish.
|
||||||
|
|
||||||
|
def is_finished(state: GameState) -> bool:
|
||||||
|
return bool(state.get("ending"))
|
||||||
|
|
||||||
|
graph_builder.add_conditional_edges("scene", lambda s: "finished" if is_finished(s) else "scene")
|
||||||
|
graph_builder.add_edge("finished", "finished") # terminal
|
||||||
|
|
||||||
|
# Compile the graph with a checkpoint
|
||||||
|
checkpoint = InMemorySaver()
|
||||||
|
graph = graph_builder.compile(checkpointer=checkpoint)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Main loop handling interrupts
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run_game(theme: str):
|
||||||
|
# Initial state
|
||||||
|
state: GameState = {
|
||||||
|
"theme": theme,
|
||||||
|
"story": "",
|
||||||
|
"options": [],
|
||||||
|
"choice": "",
|
||||||
|
"ending": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
thread_id = os.urandom(8).hex()
|
||||||
|
config = {"configurable": {"thread_id": thread_id}}
|
||||||
|
|
||||||
|
# Start streaming
|
||||||
|
stream = graph.stream(Command(state), config)
|
||||||
|
try:
|
||||||
|
for chunk in stream:
|
||||||
|
# The chunk may contain an interrupt
|
||||||
|
if "__interrupt__" in chunk:
|
||||||
|
interrupt_payload = chunk["__interrupt__"][0].value
|
||||||
|
# Show question and options to user
|
||||||
|
answer = questionary.select(
|
||||||
|
interrupt_payload["question"],
|
||||||
|
choices=interrupt_payload["options"],
|
||||||
|
).ask()
|
||||||
|
# Add the answer to the payload
|
||||||
|
interrupt_payload["choice"] = answer
|
||||||
|
# Resume the graph with the updated payload
|
||||||
|
stream = graph.stream(Command(resume=interrupt_payload), config)
|
||||||
|
continue
|
||||||
|
# Normal output: print to console
|
||||||
|
if "story" in chunk:
|
||||||
|
print(chunk["story"], end="\n\n")
|
||||||
|
if "ending" in chunk:
|
||||||
|
print("\n[LLM] " + chunk["ending"], end="\n\n")
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("\nGame interrupted by user.")
|
||||||
|
|
||||||
|
# Final state
|
||||||
|
final_state = graph.get_state(config)
|
||||||
|
print("--- Final state ---")
|
||||||
|
print("Theme:", final_state["theme"])
|
||||||
|
print("Story:", final_state["story"])
|
||||||
|
print("Choice:", final_state["choice"])
|
||||||
|
print("Ending:", final_state["ending"])
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
print("Welcome to the LLM choose‑your‑own‑adventure game!")
|
||||||
|
theme = questionary.text("Enter a theme for the story:").ask()
|
||||||
|
if not theme:
|
||||||
|
theme = "A mysterious space cat"
|
||||||
|
run_game(theme)
|
||||||
Reference in New Issue
Block a user