From 0df230b231466a67f156e7a78c1226174fae2218 Mon Sep 17 00:00:00 2001 From: Itay Donanhirsh Date: Tue, 9 Dec 2025 16:21:32 -0800 Subject: [PATCH 01/30] wip --- pydantic/ask/Makefile | 20 +++++++++++++ pydantic/ask/autokitteh.yaml | 21 ++++++++++++++ pydantic/ask/handlers.py | 35 +++++++++++++++++++++++ pydantic/ask/requirements.txt | 1 + pydantic/chat/Makefile | 20 +++++++++++++ pydantic/chat/autokitteh.yaml | 22 +++++++++++++++ pydantic/chat/handlers.py | 51 ++++++++++++++++++++++++++++++++++ pydantic/chat/requirements.txt | 1 + 8 files changed, 171 insertions(+) create mode 100644 pydantic/ask/Makefile create mode 100644 pydantic/ask/autokitteh.yaml create mode 100644 pydantic/ask/handlers.py create mode 100644 pydantic/ask/requirements.txt create mode 100644 pydantic/chat/Makefile create mode 100644 pydantic/chat/autokitteh.yaml create mode 100644 pydantic/chat/handlers.py create mode 100644 pydantic/chat/requirements.txt diff --git a/pydantic/ask/Makefile b/pydantic/ask/Makefile new file mode 100644 index 00000000..12e0cb39 --- /dev/null +++ b/pydantic/ask/Makefile @@ -0,0 +1,20 @@ +UVX=uvx --with autokitteh --with-requirements requirements.txt + +.PHONY: all +all: lint typecheck format + +.PHONY: lint +lint: + $(UVX) ruff check --config ../../pyproject.toml --ignore I001 + +.PHONY: format +format: + $(UVX) ruff format --check --config ../../pyproject.toml + +.PHONY: typecheck +typecheck: + $(UVX) mypy --follow-untyped-imports . + +.PHONY: deploy +deploy: + ak deploy --manifest autokitteh.yaml diff --git a/pydantic/ask/autokitteh.yaml b/pydantic/ask/autokitteh.yaml new file mode 100644 index 00000000..a2cdb8f6 --- /dev/null +++ b/pydantic/ask/autokitteh.yaml @@ -0,0 +1,21 @@ +version: v2 + +project: + name: pydantic_ask + + vars: + - name: ANTHROPIC_API_KEY + value: "" + - name: MODEL_NAME + value: "anthropic:claude-sonnet-4-0" + + connections: + - name: slack + integration: slack + + triggers: + - name: slack_message + connection: slack + event_type: message + filter: "data.thread_ts == '' && data.text.startsWith('!ask')" + call: handlers.py:on_slack_message diff --git a/pydantic/ask/handlers.py b/pydantic/ask/handlers.py new file mode 100644 index 00000000..28b7e528 --- /dev/null +++ b/pydantic/ask/handlers.py @@ -0,0 +1,35 @@ +"""Simple Q&A using AI.""" + +from os import getenv + +from pydantic_ai import Agent + +from autokitteh import Event +from autokitteh.slack import slack_client + + +_slack = slack_client("slack") + +_MODEL_NAME = getenv("MODEL_NAME", "anthropic:claude-sonnet-4-0") + +agent = Agent( + _MODEL_NAME, + instructions="Be concise, reply with one sentence.", +) + + +def on_slack_message(event: Event) -> None: + data = event.data + q = data.text.removeprefix("!ask").strip() + + print(f"Q: {q}") + + a = agent.run_sync(q).output + + print(f"A: {a}") + + _slack.chat_postMessage( + channel=event.data.channel, + text=f"`{_MODEL_NAME}` says:\n```{a}```", + thread_ts=event.data.ts, + ) diff --git a/pydantic/ask/requirements.txt b/pydantic/ask/requirements.txt new file mode 100644 index 00000000..a8e20fee --- /dev/null +++ b/pydantic/ask/requirements.txt @@ -0,0 +1 @@ +pydantic-ai diff --git a/pydantic/chat/Makefile b/pydantic/chat/Makefile new file mode 100644 index 00000000..12e0cb39 --- /dev/null +++ b/pydantic/chat/Makefile @@ -0,0 +1,20 @@ +UVX=uvx --with autokitteh --with-requirements requirements.txt + +.PHONY: all +all: lint typecheck format + +.PHONY: lint +lint: + $(UVX) ruff check --config ../../pyproject.toml --ignore I001 + +.PHONY: format +format: + $(UVX) ruff format --check --config ../../pyproject.toml + +.PHONY: typecheck +typecheck: + $(UVX) mypy --follow-untyped-imports . + +.PHONY: deploy +deploy: + ak deploy --manifest autokitteh.yaml diff --git a/pydantic/chat/autokitteh.yaml b/pydantic/chat/autokitteh.yaml new file mode 100644 index 00000000..504c6149 --- /dev/null +++ b/pydantic/chat/autokitteh.yaml @@ -0,0 +1,22 @@ +version: v2 + +project: + name: pydantic_chat + + vars: + - name: ANTHROPIC_API_KEY + value: "" + - name: MODEL_NAME + value: "anthropic:claude-sonnet-4-0" + + connections: + - name: slack + integration: slack + + triggers: + - name: slack_message + connection: slack + event_type: message + filter: "data.thread_ts == '' && data.text.startsWith('!chat')" + call: handlers.py:on_slack_message + is_durable: true diff --git a/pydantic/chat/handlers.py b/pydantic/chat/handlers.py new file mode 100644 index 00000000..34399eb6 --- /dev/null +++ b/pydantic/chat/handlers.py @@ -0,0 +1,51 @@ +"""Simple Q&A using AI.""" + +from os import getenv + +from pydantic_ai import Agent + +from autokitteh import Event, next_event, subscribe +from autokitteh.slack import slack_client + + +_slack = slack_client("slack") + +_MODEL_NAME = getenv("MODEL_NAME", "anthropic:claude-sonnet-4-0") + +agent = Agent( + _MODEL_NAME, + instructions="Be concise, reply with one sentence.", +) + + +def on_slack_message(event: Event) -> None: + data = event.data + q = data.text.removeprefix("!chat").strip() + + ch, ts = event.data.channel, event.data.ts + + s = subscribe( + "slack", + f"data.type == 'message' && data.bot_id == '' && data.thread_ts == '{ts}'", + ) + + history: list = [] + + while True: + print(f"Q: {q}") + + result = agent.run_sync(q, message_history=history) + + history = result.all_messages() + + a = result.output + + print(f"A: {a}") + + _slack.chat_postMessage( + channel=ch, + thread_ts=ts, + text=f"`{_MODEL_NAME}` says:\n```{a}```", + ) + + q = next_event(s).text diff --git a/pydantic/chat/requirements.txt b/pydantic/chat/requirements.txt new file mode 100644 index 00000000..a8e20fee --- /dev/null +++ b/pydantic/chat/requirements.txt @@ -0,0 +1 @@ +pydantic-ai From 7bcc309d34e3b91c07d308d043a348026940a830 Mon Sep 17 00:00:00 2001 From: Itay Donanhirsh Date: Wed, 10 Dec 2025 19:24:53 -0800 Subject: [PATCH 02/30] wip --- pydantic/ask/Makefile | 20 ------- pydantic/ask/autokitteh.yaml | 1 + pydantic/chat/Makefile | 20 ------- pydantic/chat/autokitteh.yaml | 1 + pydantic/roulette/autokitteh.yaml | 23 ++++++++ pydantic/roulette/handlers.py | 90 ++++++++++++++++++++++++++++++ pydantic/roulette/requirements.txt | 1 + 7 files changed, 116 insertions(+), 40 deletions(-) delete mode 100644 pydantic/ask/Makefile delete mode 100644 pydantic/chat/Makefile create mode 100644 pydantic/roulette/autokitteh.yaml create mode 100644 pydantic/roulette/handlers.py create mode 100644 pydantic/roulette/requirements.txt diff --git a/pydantic/ask/Makefile b/pydantic/ask/Makefile deleted file mode 100644 index 12e0cb39..00000000 --- a/pydantic/ask/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -UVX=uvx --with autokitteh --with-requirements requirements.txt - -.PHONY: all -all: lint typecheck format - -.PHONY: lint -lint: - $(UVX) ruff check --config ../../pyproject.toml --ignore I001 - -.PHONY: format -format: - $(UVX) ruff format --check --config ../../pyproject.toml - -.PHONY: typecheck -typecheck: - $(UVX) mypy --follow-untyped-imports . - -.PHONY: deploy -deploy: - ak deploy --manifest autokitteh.yaml diff --git a/pydantic/ask/autokitteh.yaml b/pydantic/ask/autokitteh.yaml index a2cdb8f6..45806a78 100644 --- a/pydantic/ask/autokitteh.yaml +++ b/pydantic/ask/autokitteh.yaml @@ -6,6 +6,7 @@ project: vars: - name: ANTHROPIC_API_KEY value: "" + secret: true - name: MODEL_NAME value: "anthropic:claude-sonnet-4-0" diff --git a/pydantic/chat/Makefile b/pydantic/chat/Makefile deleted file mode 100644 index 12e0cb39..00000000 --- a/pydantic/chat/Makefile +++ /dev/null @@ -1,20 +0,0 @@ -UVX=uvx --with autokitteh --with-requirements requirements.txt - -.PHONY: all -all: lint typecheck format - -.PHONY: lint -lint: - $(UVX) ruff check --config ../../pyproject.toml --ignore I001 - -.PHONY: format -format: - $(UVX) ruff format --check --config ../../pyproject.toml - -.PHONY: typecheck -typecheck: - $(UVX) mypy --follow-untyped-imports . - -.PHONY: deploy -deploy: - ak deploy --manifest autokitteh.yaml diff --git a/pydantic/chat/autokitteh.yaml b/pydantic/chat/autokitteh.yaml index 504c6149..45d15796 100644 --- a/pydantic/chat/autokitteh.yaml +++ b/pydantic/chat/autokitteh.yaml @@ -6,6 +6,7 @@ project: vars: - name: ANTHROPIC_API_KEY value: "" + secret: true - name: MODEL_NAME value: "anthropic:claude-sonnet-4-0" diff --git a/pydantic/roulette/autokitteh.yaml b/pydantic/roulette/autokitteh.yaml new file mode 100644 index 00000000..4b9046b3 --- /dev/null +++ b/pydantic/roulette/autokitteh.yaml @@ -0,0 +1,23 @@ +version: v2 + +project: + name: pydantic_roulette + + vars: + - name: ANTHROPIC_API_KEY + value: "" + secret: true + - name: MODEL_NAME + value: "anthropic:claude-sonnet-4-0" + + connections: + - name: slack + integration: slack + + triggers: + - name: slack_message + connection: slack + event_type: message + filter: "data.thread_ts == '' && data.text.startsWith('!roulette')" + call: handlers.py:on_slack_message + is_durable: true diff --git a/pydantic/roulette/handlers.py b/pydantic/roulette/handlers.py new file mode 100644 index 00000000..cc9a9956 --- /dev/null +++ b/pydantic/roulette/handlers.py @@ -0,0 +1,90 @@ +"""Simple Q&A using AI.""" + +from os import getenv +from random import randint + +from pydantic_ai import Agent + +from autokitteh import Event, next_event, subscribe +from autokitteh.slack import slack_client + + +_slack = slack_client("slack") + +_MODEL_NAME = getenv("MODEL_NAME", "anthropic:claude-sonnet-4-0") + +roulette_agent = Agent( + _MODEL_NAME, + system_prompt=( + "Be concise, reply with one sentence." + "\n" + "Determine if the user wants to play roulette or black-jack based on their " + "message." + "\n" + "If the user wishes to roll the roulette, use the `roulette_wheel` function " + "to see if the user has won based on the number they provide, which must be " + "between 1 and 38. Always ask the number the user wants to bet on before " + "calling the `roulette_wheel` function." + "\n" + "If the user wishes to play black-jack, play as the dealer. Deal two cards to " + "the user and two cards to yourself. Reveal one of your cards. Ask the user if " + "they want to 'hit' or 'stand'. If they choose 'hit', deal them another card. " + "If they choose 'stand', reveal your hidden card and play according to " + "standard black-jack rules (hit until you reach 17 or higher). Determine the " + "winner based on who has the higher total without going over 21." + "To draw cards, use the `draw_card` function." + ), +) + + +@roulette_agent.tool_plain +async def roulette_wheel() -> int: + return randint(1, 38) + + +@roulette_agent.tool_plain +async def draw_card() -> int: + """Draw a card from a standard deck (1-13).""" + return randint(1, 13) + + +def on_slack_message(event: Event) -> None: + data = event.data + q = data.text.removeprefix("!chat").strip() + + ch, ts = event.data.channel, event.data.ts + + s = subscribe( + "slack", + f"data.type == 'message' && data.bot_id == '' && data.thread_ts == '{ts}'", + ) + + history: list = [] + + while True: + print(f"Q: {q}") + + result = roulette_agent.run_sync(q, message_history=history) + + history, new = result.all_messages(), result.new_messages() + + a = result.output + + for message in new: + if hasattr(message, "parts"): + for part in message.parts: + if part.part_kind == "tool-call": + print(f"Tool called: {part.tool_name}") + print(f"Arguments: {part.args}") + elif part.part_kind == "tool-return": + print(f"Tool result: {part.content}") + + print(f"A: {a}") + + _slack.chat_postMessage( + channel=ch, + thread_ts=ts, + text=f"`{_MODEL_NAME}` says:\n```{a}```", + ) + + q = next_event(s).text diff --git a/pydantic/roulette/requirements.txt b/pydantic/roulette/requirements.txt new file mode 100644 index 00000000..a8e20fee --- /dev/null +++ b/pydantic/roulette/requirements.txt @@ -0,0 +1 @@ +pydantic-ai From 96f87432499b7e1d740467bc045266ec3418d771 Mon Sep 17 00:00:00 2001 From: Itay Donanhirsh Date: Wed, 10 Dec 2025 20:04:52 -0800 Subject: [PATCH 03/30] wip --- pydantic/{roulette => gamble}/autokitteh.yaml | 4 ++-- pydantic/{roulette => gamble}/handlers.py | 0 pydantic/{roulette => gamble}/requirements.txt | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename pydantic/{roulette => gamble}/autokitteh.yaml (78%) rename pydantic/{roulette => gamble}/handlers.py (100%) rename pydantic/{roulette => gamble}/requirements.txt (100%) diff --git a/pydantic/roulette/autokitteh.yaml b/pydantic/gamble/autokitteh.yaml similarity index 78% rename from pydantic/roulette/autokitteh.yaml rename to pydantic/gamble/autokitteh.yaml index 4b9046b3..d4601b90 100644 --- a/pydantic/roulette/autokitteh.yaml +++ b/pydantic/gamble/autokitteh.yaml @@ -1,7 +1,7 @@ version: v2 project: - name: pydantic_roulette + name: pydantic_gamble vars: - name: ANTHROPIC_API_KEY @@ -18,6 +18,6 @@ project: - name: slack_message connection: slack event_type: message - filter: "data.thread_ts == '' && data.text.startsWith('!roulette')" + filter: "data.thread_ts == '' && data.text.startsWith('!gamble')" call: handlers.py:on_slack_message is_durable: true diff --git a/pydantic/roulette/handlers.py b/pydantic/gamble/handlers.py similarity index 100% rename from pydantic/roulette/handlers.py rename to pydantic/gamble/handlers.py diff --git a/pydantic/roulette/requirements.txt b/pydantic/gamble/requirements.txt similarity index 100% rename from pydantic/roulette/requirements.txt rename to pydantic/gamble/requirements.txt From 5577d170ef3daf6203f280f52f8e4e6903fde788 Mon Sep 17 00:00:00 2001 From: Itay Donanhirsh Date: Wed, 10 Dec 2025 20:33:32 -0800 Subject: [PATCH 04/30] wip --- pydantic/gamble/autokitteh.yaml | 3 +++ pydantic/gamble/handlers.py | 6 +++++- pydantic/gamble/requirements.txt | 1 + 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/pydantic/gamble/autokitteh.yaml b/pydantic/gamble/autokitteh.yaml index d4601b90..f3a81681 100644 --- a/pydantic/gamble/autokitteh.yaml +++ b/pydantic/gamble/autokitteh.yaml @@ -7,6 +7,9 @@ project: - name: ANTHROPIC_API_KEY value: "" secret: true + - name: LOGFIRE_TOKEN + value: "" + secret: true - name: MODEL_NAME value: "anthropic:claude-sonnet-4-0" diff --git a/pydantic/gamble/handlers.py b/pydantic/gamble/handlers.py index cc9a9956..fcd886a7 100644 --- a/pydantic/gamble/handlers.py +++ b/pydantic/gamble/handlers.py @@ -1,8 +1,9 @@ -"""Simple Q&A using AI.""" +"""Pydantic AI gambling.""" from os import getenv from random import randint +import logfire from pydantic_ai import Agent from autokitteh import Event, next_event, subscribe @@ -13,6 +14,9 @@ _MODEL_NAME = getenv("MODEL_NAME", "anthropic:claude-sonnet-4-0") +logfire.configure() +logfire.instrument_pydantic_ai() + roulette_agent = Agent( _MODEL_NAME, system_prompt=( diff --git a/pydantic/gamble/requirements.txt b/pydantic/gamble/requirements.txt index a8e20fee..5e8b5df2 100644 --- a/pydantic/gamble/requirements.txt +++ b/pydantic/gamble/requirements.txt @@ -1 +1,2 @@ pydantic-ai +logfire From afdc60fdaf4b19f9bdd0eb6050841b220ebebc2b Mon Sep 17 00:00:00 2001 From: Itay Donanhirsh Date: Wed, 17 Dec 2025 20:09:09 -0800 Subject: [PATCH 05/30] wip --- pydantic/chat_with_ui/autokitteh.yaml | 24 ++ pydantic/chat_with_ui/chat.html | 419 +++++++++++++++++++++++++ pydantic/chat_with_ui/handlers.py | 50 +++ pydantic/chat_with_ui/requirements.txt | 1 + 4 files changed, 494 insertions(+) create mode 100644 pydantic/chat_with_ui/autokitteh.yaml create mode 100644 pydantic/chat_with_ui/chat.html create mode 100644 pydantic/chat_with_ui/handlers.py create mode 100644 pydantic/chat_with_ui/requirements.txt diff --git a/pydantic/chat_with_ui/autokitteh.yaml b/pydantic/chat_with_ui/autokitteh.yaml new file mode 100644 index 00000000..57e57a0a --- /dev/null +++ b/pydantic/chat_with_ui/autokitteh.yaml @@ -0,0 +1,24 @@ +version: v2 + +project: + name: pydantic_chat_with_ui + + vars: + - name: ANTHROPIC_API_KEY + value: "" + secret: true + - name: MODEL_NAME + value: "anthropic:claude-sonnet-4-0" + + triggers: + - name: start + webhook: {} + event_type: get + call: handlers.py:on_start + is_durable: true + is_sync: true + + - name: ask + webhook: {} + event_type: post + is_sync: true diff --git a/pydantic/chat_with_ui/chat.html b/pydantic/chat_with_ui/chat.html new file mode 100644 index 00000000..c7a04395 --- /dev/null +++ b/pydantic/chat_with_ui/chat.html @@ -0,0 +1,419 @@ + + + + + + Simple Chat Interface + + + + +
+
+

Chat Interface

+
+ +
+
+ Hello! Send a message to get started. +
+
+ +
+ + + + Thinking... +
+ +
+ + +
+
+ + + + diff --git a/pydantic/chat_with_ui/handlers.py b/pydantic/chat_with_ui/handlers.py new file mode 100644 index 00000000..a5154448 --- /dev/null +++ b/pydantic/chat_with_ui/handlers.py @@ -0,0 +1,50 @@ +"""Simple Q&A using AI.""" + +from os import getenv +from pathlib import Path + +from autokitteh import Event, get_webhook_url, http_outcome, next_event, subscribe +from pydantic_ai import Agent + + +_CHAT_HTML = Path("chat.html").read_text() + +_WEBHOOK_URL = get_webhook_url("ask") + +_MODEL_NAME = getenv("MODEL_NAME", "anthropic:claude-sonnet-4-0") + + +agent = Agent( + _MODEL_NAME, + instructions="Be concise, reply with one sentence.", +) + + +def on_start(_: Event, session_id: str) -> None: + http_outcome( + 200, + body=_CHAT_HTML.replace("{{API_ENDPOINT}}", f"{_WEBHOOK_URL}/{session_id}"), + ) + + s = subscribe("ask", f"data.url.path_suffix == '{session_id}'") + + history: list = [] + + while True: + event = next_event(s, full=True) + + print(f"E: {event}") + + q = event.data.body.text + + print(f"Q: {q}") + + result = agent.run_sync(q, message_history=history) + + history = result.all_messages() + + a = result.output + + print(f"A: {a}") + + http_outcome(200, body=a, event_id=event.event_id) diff --git a/pydantic/chat_with_ui/requirements.txt b/pydantic/chat_with_ui/requirements.txt new file mode 100644 index 00000000..a8e20fee --- /dev/null +++ b/pydantic/chat_with_ui/requirements.txt @@ -0,0 +1 @@ +pydantic-ai From 0f55df6e52a504394b9ac82e519fe7ac66b7b2c6 Mon Sep 17 00:00:00 2001 From: Itay Donanhirsh Date: Thu, 18 Dec 2025 19:14:16 -0800 Subject: [PATCH 06/30] wip --- pydantic/chat_with_ui/autokitteh.yaml | 2 +- pydantic/chat_with_ui/handlers.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pydantic/chat_with_ui/autokitteh.yaml b/pydantic/chat_with_ui/autokitteh.yaml index 57e57a0a..f593edfe 100644 --- a/pydantic/chat_with_ui/autokitteh.yaml +++ b/pydantic/chat_with_ui/autokitteh.yaml @@ -18,7 +18,7 @@ project: is_durable: true is_sync: true - - name: ask + - name: chat webhook: {} event_type: post is_sync: true diff --git a/pydantic/chat_with_ui/handlers.py b/pydantic/chat_with_ui/handlers.py index a5154448..f60eb31e 100644 --- a/pydantic/chat_with_ui/handlers.py +++ b/pydantic/chat_with_ui/handlers.py @@ -26,7 +26,7 @@ def on_start(_: Event, session_id: str) -> None: body=_CHAT_HTML.replace("{{API_ENDPOINT}}", f"{_WEBHOOK_URL}/{session_id}"), ) - s = subscribe("ask", f"data.url.path_suffix == '{session_id}'") + s = subscribe("chat", f"data.url.path_suffix == '{session_id}'") history: list = [] From 445a804fe4b18b9e494baef00a9c2bf9da8429e3 Mon Sep 17 00:00:00 2001 From: Itay Donanhirsh Date: Sun, 21 Dec 2025 01:06:00 -0800 Subject: [PATCH 07/30] wip --- pydantic/chat_with_ui/handlers.py | 2 +- pydantic/dnd/ai.py | 29 + pydantic/dnd/autokitteh.yaml | 27 + pydantic/dnd/data.py | 143 +++ pydantic/dnd/game.html | 1379 +++++++++++++++++++++++++++++ pydantic/dnd/game.py | 59 ++ pydantic/dnd/handlers.py | 70 ++ pydantic/dnd/protocol.md | 563 ++++++++++++ pydantic/dnd/protocol.py | 292 ++++++ pydantic/dnd/requirements.txt | 1 + pydantic/dnd/test_protocol.py | 416 +++++++++ 11 files changed, 2980 insertions(+), 1 deletion(-) create mode 100644 pydantic/dnd/ai.py create mode 100644 pydantic/dnd/autokitteh.yaml create mode 100644 pydantic/dnd/data.py create mode 100644 pydantic/dnd/game.html create mode 100644 pydantic/dnd/game.py create mode 100644 pydantic/dnd/handlers.py create mode 100644 pydantic/dnd/protocol.md create mode 100644 pydantic/dnd/protocol.py create mode 100644 pydantic/dnd/requirements.txt create mode 100644 pydantic/dnd/test_protocol.py diff --git a/pydantic/chat_with_ui/handlers.py b/pydantic/chat_with_ui/handlers.py index f60eb31e..590c59e4 100644 --- a/pydantic/chat_with_ui/handlers.py +++ b/pydantic/chat_with_ui/handlers.py @@ -9,7 +9,7 @@ _CHAT_HTML = Path("chat.html").read_text() -_WEBHOOK_URL = get_webhook_url("ask") +_WEBHOOK_URL = get_webhook_url("chat") _MODEL_NAME = getenv("MODEL_NAME", "anthropic:claude-sonnet-4-0") diff --git a/pydantic/dnd/ai.py b/pydantic/dnd/ai.py new file mode 100644 index 00000000..ff2ab6a0 --- /dev/null +++ b/pydantic/dnd/ai.py @@ -0,0 +1,29 @@ +"""Docstring for pydantic.dnd.game""" + +from os import getenv + +from pydantic_ai.models.anthropic import AnthropicModel + +from autokitteh.anthropic import anthropic_pydantic_ai_provider +import protocol +from pydantic_ai import Agent + + +_DM_MODEL_NAME = getenv("DM_MODEL_NAME", "claude-sonnet-4-5") +_PLAYER_MODEL_NAME = getenv("PLAYER_MODEL_NAME", "claude-sonnet-4-5") + +_dm_model = AnthropicModel( + _DM_MODEL_NAME, provider=anthropic_pydantic_ai_provider("anthropic") +) + +_player_model = AnthropicModel( + _PLAYER_MODEL_NAME, provider=anthropic_pydantic_ai_provider("anthropic") +) + +_player_stats_agent = Agent(_player_model, output_type=protocol.PlayerStats) + + +def create_player_stats(cls: str, race: str) -> protocol.Player: + return _player_stats_agent.run_sync( + f"Create a D&D player of class {cls} and race {race}." + ).output diff --git a/pydantic/dnd/autokitteh.yaml b/pydantic/dnd/autokitteh.yaml new file mode 100644 index 00000000..7aae1afb --- /dev/null +++ b/pydantic/dnd/autokitteh.yaml @@ -0,0 +1,27 @@ +version: v2 + +project: + name: pydantic_dnd + + vars: + - name: DM_MODEL_NAME + value: "claude-sonnet-4-5" + - name: PLAYER_MODEL_NAME + value: "claude-sonnet-4-5" + + connections: + - name: anthropic + integration: anthropic + + triggers: + - name: game + webhook: {} + event_type: get + call: handlers.py:on_game + is_durable: true + is_sync: true + + - name: turn + webhook: {} + event_type: post + is_sync: true diff --git a/pydantic/dnd/data.py b/pydantic/dnd/data.py new file mode 100644 index 00000000..812993be --- /dev/null +++ b/pydantic/dnd/data.py @@ -0,0 +1,143 @@ +"""Docstring for pydantic.dnd.data""" + +import random + + +# TODO: Make the AI come up with these. + +_CLASS_NAMES = [ + "Warrior", + "Mage", + "Rogue", + "Cleric", +] + +_RACE_NAMES = [ + "Human", + "Elf", + "Dwarf", + "Halfling", + "Half-Elf", + "Half-Orc", + "Dragonborn", + "Gnome", + "Tiefling", +] + +_NAMES = { + "Human": [ + "Aldric", + "Gareth", + "Theron", + "Cedric", + "Rowan", + "Elara", + "Seraphina", + "Lyra", + "Brynn", + "Aria", + ], + "Elf": [ + "Thalion", + "Aerendil", + "Legolas", + "Finrod", + "Galaeron", + "Arwen", + "Silvariel", + "Naeris", + "LΓΊthien", + "Amriel", + ], + "Dwarf": [ + "Thorin", + "Gimli", + "Bruenor", + "Balin", + "Dwalin", + "Mera", + "Kathra", + "Finellen", + "Bardryn", + "Gurdis", + ], + "Halfling": [ + "Bilbo", + "Merric", + "Roscoe", + "Osborn", + "Lyle", + "Lidda", + "Vani", + "Cora", + "Trym", + "Shaena", + ], + "Half-Elf": [ + "Kael", + "Finnian", + "Talin", + "Celeste", + "Mira", + "Quinn", + "Elian", + "Seren", + "Aiden", + "Rhys", + ], + "Half-Orc": [ + "Grog", + "Thrak", + "Krusk", + "Uruk", + "Gorak", + "Shava", + "Keth", + "Yevelda", + "Baggi", + "Vola", + ], + "Dragonborn": [ + "Drax", + "Balasar", + "Torinn", + "Rhogar", + "Arjhan", + "Kava", + "Nala", + "Thava", + "Mishann", + "Sora", + ], + "Gnome": [ + "Dimble", + "Fonkin", + "Glim", + "Jebeddo", + "Sindri", + "Bree", + "Caramip", + "Nissa", + "Oda", + "Waywocket", + ], + "Tiefling": [ + "Zephyr", + "Akira", + "Damakos", + "Kallista", + "Nemeia", + "Hope", + "Glory", + "Torment", + "Mercy", + "Sorrow", + ], +} + + +def random_character_attrs() -> tuple[str, str, str]: + cls = random.choice(_CLASS_NAMES) + race = random.choice(_RACE_NAMES) + name = random.choice(_NAMES[race]) + return cls, race, name diff --git a/pydantic/dnd/game.html b/pydantic/dnd/game.html new file mode 100644 index 00000000..fc36588f --- /dev/null +++ b/pydantic/dnd/game.html @@ -0,0 +1,1379 @@ + + + + + + D&D Party Chat + + + +
+ + +
+
+ +
+
+ +
+ + +
+ +
+ + + + + + + +
+
+
+
+ + + + diff --git a/pydantic/dnd/game.py b/pydantic/dnd/game.py new file mode 100644 index 00000000..ffae67b9 --- /dev/null +++ b/pydantic/dnd/game.py @@ -0,0 +1,59 @@ +"""Docstring for pydantic.dnd.game""" + +from collections.abc import Generator +import random + +import ai +import data +import protocol + + +_SSEEventGenerator = Generator[protocol.SSEEvent, None, None] + + +class Game: + """Game context""" + + _players: list[protocol.Player] = [] + + def turn(self, req: protocol.TurnRequest) -> _SSEEventGenerator: + match a := req.action: + case protocol.JoinAction(): + yield from self._join(a) + case _: + yield protocol.MessageEvent(message="Action not implemented yet.") + + def _join(self, a: protocol.JoinAction) -> _SSEEventGenerator: + if self._players: + yield protocol.MessageEvent( + message="Game already started, cannot join.", player_id=None + ) + return + + yield from self._create_players(a) + + def _create_players(self, a: protocol.JoinAction) -> _SSEEventGenerator: + for i in range(a.player_count): + print("Creating player", i) + + if i == 0: + cls, race, name = a.class_name, a.race, a.player_name + else: + cls, race, name = data.random_character_attrs() + + stats = ai.create_player_stats(cls, race) + + player = protocol.Player( + id=i, + name=name, + class_name=cls, + race=race, + stats=stats, + color=f"#{random.randint(0, 0xFFFFFF):06x}", + ) + + print("Created player:", player) + + self._players.append(player) + + yield protocol.PlayerJoinedEvent(player=player, is_you=i == 0) diff --git a/pydantic/dnd/handlers.py b/pydantic/dnd/handlers.py new file mode 100644 index 00000000..983053b9 --- /dev/null +++ b/pydantic/dnd/handlers.py @@ -0,0 +1,70 @@ +"""Simple Q&A using AI.""" + +from pathlib import Path + +from autokitteh import Event, get_webhook_url, http_outcome, next_event, subscribe +import game +import protocol +from pydantic import ValidationError + + +_GAME_HTML = Path("game.html").read_text() + +_API_ENDPOINT_URL = get_webhook_url("turn") + + +def on_game(event: Event, session_id: str) -> None: + data = event.data + + if sid := data.url.path_suffix: + _existing_game(sid) + else: + _new_game(session_id) + + +def _existing_game(session_id: str) -> None: + http_outcome(404, "Not implemented yet.") + + +def _new_game(session_id: str) -> None: + print(f"Starting new game with session ID: {session_id}") + + http_outcome( + 200, + body=_GAME_HTML.replace( + "{{API_ENDPOINT}}", f"{_API_ENDPOINT_URL}/{session_id}" + ), + ) + + g = game.Game() + + s = subscribe("turn", f"data.url.path_suffix == '{session_id}'") + + while True: + print("Waiting for next request...") + event = next_event(s, full=True) + + body = event.data.body.json + + print(f"<- {body}") + + try: + req = protocol.TurnRequest.model_validate(body) + except ValidationError as e: + print(f"Invalid request: {e}") + http_outcome(400, f"Invalid request: {e}", event_id=event.id) + continue + + if req.player_id: # must be either None or 0. + print(f"Only player ID 0 is supported, got: {req.player_id}") + http_outcome(400, "Only player ID 0 is supported.", event_id=event.id) + continue + + for update in g.turn(req): + body = update.serialized + print(f"-> {body}") + http_outcome(body=body, more=True, event_id=event.event_id) + + print("CLOSE") + + http_outcome(body=protocol.CloseEvent().serialized, event_id=event.event_id) diff --git a/pydantic/dnd/protocol.md b/pydantic/dnd/protocol.md new file mode 100644 index 00000000..68ff9104 --- /dev/null +++ b/pydantic/dnd/protocol.md @@ -0,0 +1,563 @@ +# D&D Chat Multiplayer Protocol (HTTP Streaming) + +## Architecture Overview + +``` +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Client 1 β”‚ β”‚ β”‚ +β”‚ (Thorin) │──── POST ─────────►│ β”‚ +β”‚ β”‚ β”‚ Server β”‚ +β”‚ │◄─── Stream ────────│ (Node.js/ β”‚ +β”‚ β”‚ "Elara: Hello" β”‚ Express) β”‚ +β”‚ β”‚ "Finn rolled 18" β”‚ β”‚ +β”‚ β”‚ "Your turn!" β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ Connection ends β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ + └──── POST (next turn) β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + "I attack!" +``` + +## How It Works + +### The Single Call Per Turn + +Each player makes **ONE HTTP POST** when it's their turn: +1. **Client sends** - Their action (message, dice roll, stat update, or "pass") +2. **Server streams back** - All game events until it's their turn again +3. **Connection closes** - When their turn comes back around +4. **Client makes new POST** - For their next action + +### Turn Order + +- Dynamic rotation based on initiative or server logic +- Server tracks whose turn it is +- Each player only POSTs when it's their turn +- Between turns, they watch the stream for updates +- **Supports any number of players** - not limited to 4! + +## The Single API Endpoint + +### POST /api/game/:gameId/turn + +**Request Headers:** +``` +Content-Type: application/json +Accept: text/event-stream +``` + +**Request Body:** +```json +{ + "playerId": 1, + "action": { + "type": "message", + "text": "I attack the goblin!" + } +} +``` + +**Note:** Before taking turns, players must first join the game. On first connection (before `playerId` is assigned), clients should send a join request: + +```json +{ + "action": { + "type": "join", + "playerName": "Alice", + "playerCount": 3 + } +} +``` + +The server will: +1. Assign them a player ID and character +2. Create or join a game session with the specified number of players +3. Stream back the `player_joined` event with `isYou: true` +4. Stream additional `player_joined` events as other players join + +**Action Types:** + +1. **Message** - Player says something +```json +{ + "type": "message", + "text": "Let's explore the dungeon" +} +``` + +2. **Dice Roll** - Player rolls dice +```json +{ + "type": "dice", + "sides": 20, + "modifier": 5, + "reason": "Attack roll" + // NOTE: No "roll" value sent - server generates the random result +} +``` + +3. **Stat Update** - Player updates their stats +```json +{ + "type": "stat_update", + "stats": { + "hp": 38 + } +} +``` + +4. **Pass** - Player does nothing this turn +```json +{ + "type": "pass" +} +``` + +5. **Multiple Actions** - Combine actions in one turn +```json +{ + "type": "multiple", + "actions": [ + { "type": "message", "text": "I cast fireball!" }, + { "type": "dice", "sides": 6, "count": 8, "reason": "Fireball damage" }, + { "type": "stat_update", "stats": { "hp": 42 } } + ] +} +``` + +**Response (Server-Sent Events Stream):** + +The server sends a continuous stream of events using SSE format: + +``` +HTTP/1.1 200 OK +Content-Type: text/event-stream +Cache-Control: no-cache +Connection: keep-alive + +event: action_confirmed +data: {"playerId":1,"action":"message","text":"I attack the goblin!"} + +event: turn_start +data: {"playerId":2,"playerName":"Elara"} + +event: message +data: {"playerId":2,"playerName":"Elara","text":"I'll cast Magic Missile!","timestamp":"2025-12-18T10:30:00Z"} + +event: turn_start +data: {"playerId":3,"playerName":"Finn"} + +event: dice +data: {"playerId":3,"playerName":"Finn","sides":20,"roll":18,"modifier":5,"total":23,"reason":"Sneak attack","timestamp":"2025-12-18T10:30:15Z"} + +event: stat_update +data: {"playerId":3,"playerName":"Finn","stats":{"hp":28,"maxHp":32}} + +event: turn_start +data: {"playerId":4,"playerName":"Lyra"} + +event: message +data: {"playerId":4,"playerName":"Lyra","text":"I heal Finn","timestamp":"2025-12-18T10:30:30Z"} + +event: stat_update +data: {"playerId":3,"playerName":"Finn","stats":{"hp":32,"maxHp":32}} + +event: dm_message +data: {"text":"The goblin falls defeated!","timestamp":"2025-12-18T10:30:45Z"} + +event: your_turn +data: {"playerId":1,"playerName":"Thorin"} + +event: close +data: {} +``` + +## Event Types + +### 1. action_confirmed +Confirms the player's action was received (and includes server-generated dice roll if applicable) +```json +{ + "playerId": 1, + "action": { + "type": "dice", + "sides": 20, + "roll": 15 // Server-generated random result + } +} +``` + +### 2. player_joined +A new player has joined the game +```json +{ + "player": { + "id": 2, + "name": "Elara", + "class": "Mage", + "color": "#2980b9", + "stats": { + "hp": 28, + "maxHp": 28, + "ac": 12, + "str": 8, + "dex": 14, + "con": 12, + "int": 18, + "wis": 13, + "cha": 10 + } + }, + "isYou": false // true if this is the connecting player +} +``` + +### 3. turn_start +Indicates whose turn is starting +```json +{ + "playerId": 2, + "playerName": "Elara", + "color": "#2980b9" +} +``` + +### 4. message +A player sends a chat message +```json +{ + "playerId": 2, + "playerName": "Elara", + "color": "#2980b9", + "text": "I cast fireball!", + "timestamp": "2025-12-18T10:30:00Z" +} +``` + +### 5. dice +A player rolls dice +```json +{ + "playerId": 3, + "playerName": "Finn", + "color": "#27ae60", + "sides": 20, + "roll": 18, + "modifier": 5, + "total": 23, + "reason": "Attack roll", + "timestamp": "2025-12-18T10:30:15Z" +} +``` + +### 6. stat_update +Player stats change +```json +{ + "playerId": 3, + "playerName": "Finn", + "stats": { + "hp": 28, + "maxHp": 32 + } +} +``` + +### 7. dm_message +Dungeon Master narration (can be from server AI or fifth player) +```json +{ + "text": "The goblin falls defeated! You gain 50 XP.", + "timestamp": "2025-12-18T10:30:45Z" +} +``` + +### 8. your_turn +Indicates it's this player's turn again (triggers connection close) +```json +{ + "playerId": 1, + "playerName": "Thorin" +} +``` + +### 9. close +Signals the stream is ending +```json +{} +``` + +## Client Implementation + +```javascript +let myPlayerId = 1; // Assigned at game start +let gameId = "game-abc123"; + +async function takeTurn(action) { + const response = await fetch(`/api/game/${gameId}/turn`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream' + }, + body: JSON.stringify({ + playerId: myPlayerId, + action: action + }) + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n\n'); + buffer = lines.pop(); // Keep incomplete message + + for (const line of lines) { + if (line.startsWith('event: ')) { + const eventMatch = line.match(/event: (.+)\ndata: (.+)/); + if (eventMatch) { + const eventType = eventMatch[1]; + const data = JSON.parse(eventMatch[2]); + + handleEvent(eventType, data); + + // Connection closes when it's our turn again + if (eventType === 'your_turn') { + enableInput(); // Let player take their next action + return; + } + } + } + } + } +} + +function handleEvent(eventType, data) { + switch(eventType) { + case 'action_confirmed': + console.log('My action was processed:', data); + disableInput(); // Wait for turn to come back + break; + + case 'turn_start': + displayTurnIndicator(data.playerName); + break; + + case 'message': + addMessageToChat(data); + break; + + case 'dice': + addDiceRollToChat(data); + break; + + case 'stat_update': + updatePlayerStats(data.playerId, data.stats); + break; + + case 'dm_message': + addDMMessageToChat(data); + break; + + case 'your_turn': + showTurnNotification(); + break; + } +} + +// Example: Player clicks "Send Message" +document.getElementById('sendBtn').onclick = () => { + const text = document.getElementById('messageInput').value; + takeTurn({ + type: 'message', + text: text + }); +}; + +// Example: Player clicks "Roll d20" +document.getElementById('d20Btn').onclick = () => { + takeTurn({ + type: 'dice', + sides: 20, + modifier: 3, + reason: 'Initiative' + }); +}; +``` + +## Server Implementation (Node.js/Express) + +```javascript +const express = require('express'); +const app = express(); + +// Game state +const games = { + "game-abc123": { + players: [ + { id: 1, name: "Thorin", ... }, + { id: 2, name: "Elara", ... }, + { id: 3, name: "Finn", ... }, + { id: 4, name: "Lyra", ... } + ], + currentTurnIndex: 0, // Whose turn it is (0-3) + waitingConnections: [], // SSE connections for players waiting + turnQueue: [] // Actions waiting to be processed + } +}; + +app.post('/api/game/:gameId/turn', async (req, res) => { + const { gameId } = req.params; + const { playerId, action } = req.body; + const game = games[gameId]; + + // Verify it's this player's turn + const currentPlayer = game.players[game.currentTurnIndex]; + if (currentPlayer.id !== playerId) { + return res.status(403).json({ error: "Not your turn" }); + } + + // Set up SSE stream + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive' + }); + + // Confirm action received + sendEvent(res, 'action_confirmed', { playerId, action }); + + // Process the action + await processAction(game, playerId, action); + + // Broadcast to all waiting connections + broadcastAction(game, playerId, action); + + // Move to next turn + game.currentTurnIndex = (game.currentTurnIndex + 1) % 4; + + // Store this connection for future updates + game.waitingConnections.push({ playerId, res }); + + // Keep streaming updates until it's this player's turn again + await waitForTurn(game, playerId, res); +}); + +async function waitForTurn(game, playerId, res) { + return new Promise((resolve) => { + const checkTurn = () => { + const currentPlayer = game.players[game.currentTurnIndex]; + + if (currentPlayer.id === playerId) { + // It's their turn again! + sendEvent(res, 'your_turn', { + playerId, + playerName: currentPlayer.name + }); + sendEvent(res, 'close', {}); + res.end(); + + // Remove from waiting connections + game.waitingConnections = game.waitingConnections.filter( + c => c.playerId !== playerId + ); + + resolve(); + } + }; + + // Check whenever a turn completes + game.onTurnComplete = checkTurn; + }); +} + +function sendEvent(res, event, data) { + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); +} + +function broadcastAction(game, playerId, action) { + const player = game.players.find(p => p.id === playerId); + + for (const connection of game.waitingConnections) { + if (connection.playerId !== playerId) { + // Send to other players + if (action.type === 'message') { + sendEvent(connection.res, 'message', { + playerId, + playerName: player.name, + color: player.color, + text: action.text, + timestamp: new Date().toISOString() + }); + } else if (action.type === 'dice') { + const roll = Math.floor(Math.random() * action.sides) + 1; + sendEvent(connection.res, 'dice', { + playerId, + playerName: player.name, + color: player.color, + sides: action.sides, + roll: roll, + timestamp: new Date().toISOString() + }); + } + } + } +} +``` + +## Flow Example + +**Turn 1 - Thorin's Turn:** +``` +1. Thorin: POST /turn with "I attack!" + β†’ Server confirms + β†’ Server broadcasts to Elara, Finn, Lyra + β†’ Thorin's connection stays open, waiting... + +2. Elara's turn begins + β†’ All waiting connections get "turn_start: Elara" + +3. Elara: POST /turn with dice roll + β†’ Server broadcasts roll to everyone + β†’ Elara's connection stays open + +4. Finn's turn, Lyra's turn... + +5. Back to Thorin's turn + β†’ Server sends "your_turn" to Thorin + β†’ Thorin's connection closes + β†’ Thorin can now POST again +``` + +## Advantages + +βœ… **Simple** - Only ONE endpoint to implement +βœ… **Efficient** - No polling, updates pushed immediately +βœ… **Turn-based** - Perfect for D&D's structure +βœ… **HTTP-based** - No WebSocket complexity +βœ… **Firewall-friendly** - Standard HTTP POST +βœ… **Stateful** - Server naturally tracks turn order + +## Edge Cases + +**Player disconnects mid-turn:** +- Their connection drops +- Server auto-passes their turn after 60s timeout +- They can rejoin and POST when it's their turn again + +**Player tries to act out of turn:** +- Server returns 403 Forbidden +- Client shows "Wait for your turn" + +**Multiple actions in one turn:** +- Use `type: "multiple"` with array of actions +- All execute atomically during that turn + +This design perfectly matches D&D's turn-based nature! \ No newline at end of file diff --git a/pydantic/dnd/protocol.py b/pydantic/dnd/protocol.py new file mode 100644 index 00000000..6516f0f3 --- /dev/null +++ b/pydantic/dnd/protocol.py @@ -0,0 +1,292 @@ +"""D&D Chat Protocol - Server-Sent Events Message Definitions (Pydantic) + +All message types for the streaming HTTP protocol, with automatic serialization. +Each message has a .serialized property that returns the SSE-formatted string. +""" + +from datetime import datetime, UTC +from typing import Any, Literal + +from pydantic import BaseModel +from pydantic import Field + + +# ============================================================================ +# Base Classes +# ============================================================================ + + +class SSEEvent(BaseModel): + """Base class for all Server-Sent Events""" + + @property + def event_type(self) -> str: + """Override in subclasses to specify event type""" + raise NotImplementedError + + @property + def serialized(self) -> str: + r"""Returns the SSE-formatted string for this event. + + Format: event: type\ndata: json\n\n + """ + event_str = f"event: {self.event_type}\n" + event_str += f"data: {self.model_dump_json(exclude_none=True, by_alias=True)}\n" + event_str += "\n" # Blank line signals end of event + return event_str + + +class PlayerStats(BaseModel): + """Player statistics""" + + hp: int + max_hp: int = Field(..., serialization_alias="maxHp") + ac: int + strength: int = Field(..., serialization_alias="str") + dex: int + con: int + intelligence: int = Field(..., serialization_alias="int") + wis: int + cha: int + + model_config = { + "populate_by_name": True # Allow parsing with either name + } + + +class Player(BaseModel): + """Player information""" + + id: int + name: str + race: str + class_name: str = Field(..., serialization_alias="class") + color: str + stats: PlayerStats + + model_config = {"populate_by_name": True} + + +# ============================================================================ +# Event Messages +# ============================================================================ + + +class ActionConfirmedEvent(SSEEvent): + """Confirms the player's action was received. + + For dice rolls, includes the server-generated roll result. + """ + + player_id: int = Field(..., serialization_alias="playerId") + action: dict[str, Any] + + @property + def event_type(self) -> str: + return "action_confirmed" + + +class PlayerJoinedEvent(SSEEvent): + """A new player has joined the game. + + is_you indicates if this is the connecting player. + """ + + player: Player + is_you: bool = Field(..., serialization_alias="isYou") + + @property + def event_type(self) -> str: + return "player_joined" + + +class TurnStartEvent(SSEEvent): + """Indicates whose turn is starting""" + + player_id: int = Field(..., serialization_alias="playerId") + player_name: str = Field(..., serialization_alias="playerName") + color: str + + @property + def event_type(self) -> str: + return "turn_start" + + +class MessageEvent(SSEEvent): + """A player sends a chat message""" + + player_id: int = Field(..., serialization_alias="playerId") + player_name: str = Field(..., serialization_alias="playerName") + player_color: str = Field(..., serialization_alias="playerColor") + text: str + timestamp: str + + @property + def event_type(self) -> str: + return "message" + + @classmethod + def create(cls, player_id: int, player_name: str, player_color: str, text: str): + """Convenience constructor that auto-generates timestamp""" + return cls( + player_id=player_id, + player_name=player_name, + player_color=player_color, + text=text, + timestamp=datetime.now(UTC).isoformat().replace("+00:00", "Z"), + ) + + +class DiceEvent(SSEEvent): + """A player rolls dice. Roll result is generated server-side.""" + + player_id: int = Field(..., serialization_alias="playerId") + player_name: str = Field(..., serialization_alias="playerName") + player_color: str = Field(..., serialization_alias="playerColor") + sides: int + roll: int # Server-generated result + modifier: int | None = None + total: int | None = None + reason: str | None = None + timestamp: str | None = None + + @property + def event_type(self) -> str: + return "dice" + + @classmethod + def create( + cls, + player_id: int, + player_name: str, + player_color: str, + sides: int, + roll: int, + modifier: int | None = None, + reason: str | None = None, + ): + """Convenience constructor that auto-generates timestamp and total""" + total = roll + (modifier or 0) if modifier else None + return cls( + player_id=player_id, + player_name=player_name, + player_color=player_color, + sides=sides, + roll=roll, + modifier=modifier, + total=total, + reason=reason, + timestamp=datetime.now(UTC).isoformat().replace("+00:00", "Z"), + ) + + +class StatUpdateEvent(SSEEvent): + """Player stats change (e.g., HP, AC)""" + + player_id: int = Field(..., serialization_alias="playerId") + player_name: str = Field(..., serialization_alias="playerName") + stats: dict[str, int] # Partial stats update + + @property + def event_type(self) -> str: + return "stat_update" + + +class DMMessageEvent(SSEEvent): + """Dungeon Master narration. + + Can be from server AI or a fifth player acting as DM. + """ + + text: str + timestamp: str + + @property + def event_type(self) -> str: + return "dm_message" + + @classmethod + def create(cls, text: str): + """Convenience constructor that auto-generates timestamp""" + return cls( + text=text, + timestamp=datetime.now(UTC).isoformat().replace("+00:00", "Z"), + ) + + +class YourTurnEvent(SSEEvent): + """Indicates it's this player's turn again. + + Triggers connection close on client side. + """ + + player_id: int = Field(..., serialization_alias="playerId") + player_name: str = Field(..., serialization_alias="playerName") + + @property + def event_type(self) -> str: + return "your_turn" + + +class CloseEvent(SSEEvent): + """Signals the stream is ending""" + + @property + def event_type(self) -> str: + return "close" + + @property + def serialized(self) -> str: + """Close event has empty data""" + return "event: close\ndata: {}\n\n" + + +# ============================================================================ +# Request Models (for parsing incoming requests) +# ============================================================================ + + +class JoinAction(BaseModel): + """Join game action""" + + type: Literal["join"] = "join" + player_name: str = Field(..., alias="playerName") + player_count: int = Field(..., alias="playerCount") + race: str + class_name: str = Field(..., alias="class") + + model_config = {"populate_by_name": True} + + +class MessageAction(BaseModel): + """Send message action""" + + type: Literal["message"] = "message" + text: str + + +class DiceAction(BaseModel): + """Roll dice action (client sends, server generates roll)""" + + type: Literal["dice"] = "dice" + sides: int + modifier: int | None = None + reason: str | None = None + + +class StatUpdateAction(BaseModel): + """Update stats action""" + + type: Literal["stat_update"] = "stat_update" + stats: dict[str, int] + + +class TurnRequest(BaseModel): + """Request body for /api/game/:gameId/turn endpoint""" + + player_id: int | None = Field(None, alias="playerId") + action: JoinAction | MessageAction | DiceAction | StatUpdateAction = Field( + ..., discriminator="type" + ) + + model_config = {"populate_by_name": True} diff --git a/pydantic/dnd/requirements.txt b/pydantic/dnd/requirements.txt new file mode 100644 index 00000000..a8e20fee --- /dev/null +++ b/pydantic/dnd/requirements.txt @@ -0,0 +1 @@ +pydantic-ai diff --git a/pydantic/dnd/test_protocol.py b/pydantic/dnd/test_protocol.py new file mode 100644 index 00000000..a263ae91 --- /dev/null +++ b/pydantic/dnd/test_protocol.py @@ -0,0 +1,416 @@ +"""Tests for D&D Chat Protocol - Server-Sent Events Message Definitions""" + +import json + +import protocol +from protocol import ActionConfirmedEvent +from protocol import CloseEvent +from protocol import DiceEvent +from protocol import DMMessageEvent +from protocol import MessageEvent +from protocol import Player +from protocol import PlayerJoinedEvent +from protocol import PlayerStats +from protocol import StatUpdateEvent +from protocol import TurnRequest +from protocol import TurnStartEvent +from protocol import YourTurnEvent + + +# Test helper functions +def create_warrior(player_id: int, name: str) -> Player: + """Create a warrior character""" + return Player( + id=player_id, + name=name, + class_name="Warrior", + color="#c0392b", + stats=PlayerStats( + hp=45, + max_hp=45, + ac=18, + strength=16, + dex=12, + con=15, + intelligence=10, + wis=11, + cha=9, + ), + ) + + +def create_mage(player_id: int, name: str) -> Player: + """Create a mage character""" + return Player( + id=player_id, + name=name, + class_name="Mage", + color="#2980b9", + stats=PlayerStats( + hp=28, + max_hp=28, + ac=12, + strength=8, + dex=14, + con=12, + intelligence=18, + wis=13, + cha=10, + ), + ) + + +def create_rogue(player_id: int, name: str) -> Player: + """Create a rogue character""" + return Player( + id=player_id, + name=name, + class_name="Rogue", + color="#27ae60", + stats=PlayerStats( + hp=32, + max_hp=32, + ac=15, + strength=10, + dex=18, + con=13, + intelligence=12, + wis=14, + cha=11, + ), + ) + + +def create_cleric(player_id: int, name: str) -> Player: + """Create a cleric character""" + return Player( + id=player_id, + name=name, + class_name="Cleric", + color="#f39c12", + stats=PlayerStats( + hp=38, + max_hp=38, + ac=16, + strength=14, + dex=10, + con=14, + intelligence=11, + wis=17, + cha=13, + ), + ) + + +class TestCharacterCreation: + """Test character creation helper functions""" + + def test_create_warrior(self): + player = create_warrior(1, "Alice") + assert player.id == 1 + assert player.name == "Alice" + assert player.class_name == "Warrior" + assert player.color == "#c0392b" + assert player.stats.hp == 45 + assert player.stats.max_hp == 45 + assert player.stats.ac == 18 + assert player.stats.strength == 16 + + def test_create_mage(self): + player = create_mage(2, "Bob") + assert player.id == 2 + assert player.name == "Bob" + assert player.class_name == "Mage" + assert player.color == "#2980b9" + assert player.stats.intelligence == 18 + assert player.stats.ac == 12 + + def test_create_rogue(self): + player = create_rogue(3, "Carol") + assert player.id == 3 + assert player.name == "Carol" + assert player.class_name == "Rogue" + assert player.color == "#27ae60" + assert player.stats.dex == 18 + + def test_create_cleric(self): + player = create_cleric(4, "Dave") + assert player.id == 4 + assert player.name == "Dave" + assert player.class_name == "Cleric" + assert player.color == "#f39c12" + assert player.stats.wis == 17 + + +class TestSSEEventSerialization: + """Test SSE event serialization to proper format""" + + def test_player_joined_event(self): + player = create_warrior(1, "Alice") + event = PlayerJoinedEvent(player=player, is_you=True) + serialized = event.serialized + + # Check format + assert serialized.startswith("event: player_joined\n") + assert "data: " in serialized + assert serialized.endswith("\n\n") + + # Parse the data line + data_line = serialized.split("\n")[1] + data_json = data_line.replace("data: ", "") + data = json.loads(data_json) + + assert data["isYou"] is True + assert data["player"]["name"] == "Alice" + assert data["player"]["class"] == "Warrior" + assert data["player"]["stats"]["maxHp"] == 45 + + def test_message_event(self): + event = MessageEvent.create( + player_id=1, + player_name="Alice", + player_color="#c0392b", + text="I attack the goblin!", + ) + serialized = event.serialized + + assert serialized.startswith("event: message\n") + assert "data: " in serialized + + data_line = serialized.split("\n")[1] + data_json = data_line.replace("data: ", "") + data = json.loads(data_json) + + assert data["playerId"] == 1 + assert data["playerName"] == "Alice" + assert data["playerColor"] == "#c0392b" + assert data["text"] == "I attack the goblin!" + assert "timestamp" in data + + def test_dice_event(self): + event = DiceEvent.create( + player_id=1, + player_name="Alice", + player_color="#c0392b", + sides=20, + roll=18, + modifier=5, + reason="Attack roll", + ) + serialized = event.serialized + + assert serialized.startswith("event: dice\n") + + data_line = serialized.split("\n")[1] + data_json = data_line.replace("data: ", "") + data = json.loads(data_json) + + assert data["playerId"] == 1 + assert data["playerName"] == "Alice" + assert data["sides"] == 20 + assert data["roll"] == 18 + assert data["modifier"] == 5 + assert data["total"] == 23 + assert data["reason"] == "Attack roll" + + def test_dice_event_without_modifier(self): + event = DiceEvent.create( + player_id=1, + player_name="Alice", + player_color="#c0392b", + sides=6, + roll=4, + ) + + data_line = event.serialized.split("\n")[1] + data_json = data_line.replace("data: ", "") + data = json.loads(data_json) + + assert data["roll"] == 4 + assert "modifier" not in data # exclude_none should remove it + assert "total" not in data + + def test_stat_update_event(self): + event = StatUpdateEvent( + player_id=1, player_name="Alice", stats={"hp": 38, "maxHp": 45} + ) + serialized = event.serialized + + assert serialized.startswith("event: stat_update\n") + + data_line = serialized.split("\n")[1] + data_json = data_line.replace("data: ", "") + data = json.loads(data_json) + + assert data["playerId"] == 1 + assert data["playerName"] == "Alice" + assert data["stats"]["hp"] == 38 + assert data["stats"]["maxHp"] == 45 + + def test_dm_message_event(self): + event = DMMessageEvent.create("The goblin falls defeated! You gain 50 XP.") + serialized = event.serialized + + assert serialized.startswith("event: dm_message\n") + + data_line = serialized.split("\n")[1] + data_json = data_line.replace("data: ", "") + data = json.loads(data_json) + + assert data["text"] == "The goblin falls defeated! You gain 50 XP." + assert "timestamp" in data + + def test_turn_start_event(self): + event = TurnStartEvent(player_id=2, player_name="Bob", color="#2980b9") + serialized = event.serialized + + assert serialized.startswith("event: turn_start\n") + + data_line = serialized.split("\n")[1] + data_json = data_line.replace("data: ", "") + data = json.loads(data_json) + + assert data["playerId"] == 2 + assert data["playerName"] == "Bob" + assert data["color"] == "#2980b9" + + def test_your_turn_event(self): + event = YourTurnEvent(player_id=1, player_name="Alice") + serialized = event.serialized + + assert serialized.startswith("event: your_turn\n") + + data_line = serialized.split("\n")[1] + data_json = data_line.replace("data: ", "") + data = json.loads(data_json) + + assert data["playerId"] == 1 + assert data["playerName"] == "Alice" + + def test_close_event(self): + event = CloseEvent() + serialized = event.serialized + + assert serialized == "event: close\ndata: {}\n\n" + + def test_action_confirmed_event(self): + event = ActionConfirmedEvent( + player_id=1, action={"type": "message", "text": "Hello"} + ) + serialized = event.serialized + + assert serialized.startswith("event: action_confirmed\n") + + data_line = serialized.split("\n")[1] + data_json = data_line.replace("data: ", "") + data = json.loads(data_json) + + assert data["playerId"] == 1 + assert data["action"]["type"] == "message" + assert data["action"]["text"] == "Hello" + + +class TestRequestParsing: + """Test parsing incoming requests""" + + def test_parse_join_request(self): + request_data = { + "action": {"type": "join", "playerName": "Alice", "playerCount": 3}, + } + request = TurnRequest(**request_data) + + assert request.action.type == "join" + assert isinstance(request.action, protocol.JoinAction) + assert request.action.player_name == "Alice" + assert request.action.player_count == 3 + + def test_parse_message_request(self): + request_data = { + "playerId": 1, + "action": {"type": "message", "text": "Hello!"}, + } + request = TurnRequest(**request_data) + + assert request.action.type == "message" + assert isinstance(request.action, protocol.MessageAction) + assert request.action.text == "Hello!" + assert request.player_id == 1 + + def test_parse_dice_request(self): + request_data = { + "playerId": 1, + "action": {"type": "dice", "sides": 20, "modifier": 3, "reason": "Attack"}, + } + request = TurnRequest(**request_data) + + assert request.action.type == "dice" + assert isinstance(request.action, protocol.DiceAction) + assert request.action.sides == 20 + assert request.action.modifier == 3 + assert request.action.reason == "Attack" + + def test_parse_unknown_action_type(self): + """Unknown action types should raise validation error""" + import pytest + from pydantic import ValidationError + + request_data = { + "playerId": 1, + "action": {"type": "unknown_action"}, + } + + with pytest.raises(ValidationError) as exc_info: + TurnRequest(**request_data) + + # Verify it's a union tag error (discriminated union validation) + error_msg = str(exc_info.value).lower() + assert "input tag" in error_msg or "union_tag" in error_msg + + +class TestPlayerStats: + """Test player stats model""" + + def test_player_stats_serialization(self): + stats = PlayerStats( + hp=30, + max_hp=40, + ac=15, + strength=14, + dex=12, + con=13, + intelligence=10, + wis=11, + cha=9, + ) + data = json.loads(stats.model_dump_json(by_alias=True)) + + assert data["hp"] == 30 + assert data["maxHp"] == 40 + assert data["ac"] == 15 + assert data["str"] == 14 + assert data["int"] == 10 + + def test_player_with_stats(self): + player = Player( + id=1, + name="Test", + class_name="Warrior", + color="#fff", + stats=PlayerStats( + hp=30, + max_hp=40, + ac=15, + strength=14, + dex=12, + con=13, + intelligence=10, + wis=11, + cha=9, + ), + ) + data = json.loads(player.model_dump_json(by_alias=True)) + + assert data["id"] == 1 + assert data["class"] == "Warrior" + assert data["stats"]["maxHp"] == 40 + assert data["stats"]["str"] == 14 From 597ab2615f5534c3eebbb719d320845c1f2ad1fe Mon Sep 17 00:00:00 2001 From: Itay Donanhirsh Date: Sun, 21 Dec 2025 22:45:30 -0800 Subject: [PATCH 08/30] wip --- pydantic/dnd/ai.py | 66 +++- pydantic/dnd/game.html | 419 ++++++++----------------- pydantic/dnd/game.py | 39 ++- pydantic/dnd/handlers.py | 4 +- pydantic/dnd/protocol.md | 563 ---------------------------------- pydantic/dnd/protocol.py | 95 +----- pydantic/dnd/test_protocol.py | 416 ------------------------- 7 files changed, 235 insertions(+), 1367 deletions(-) delete mode 100644 pydantic/dnd/protocol.md delete mode 100644 pydantic/dnd/test_protocol.py diff --git a/pydantic/dnd/ai.py b/pydantic/dnd/ai.py index ff2ab6a0..4752bb18 100644 --- a/pydantic/dnd/ai.py +++ b/pydantic/dnd/ai.py @@ -1,29 +1,87 @@ """Docstring for pydantic.dnd.game""" +from collections.abc import Sequence +import json from os import getenv +from random import randint from pydantic_ai.models.anthropic import AnthropicModel from autokitteh.anthropic import anthropic_pydantic_ai_provider import protocol from pydantic_ai import Agent +from pydantic_ai import RunContext _DM_MODEL_NAME = getenv("DM_MODEL_NAME", "claude-sonnet-4-5") _PLAYER_MODEL_NAME = getenv("PLAYER_MODEL_NAME", "claude-sonnet-4-5") -_dm_model = AnthropicModel( - _DM_MODEL_NAME, provider=anthropic_pydantic_ai_provider("anthropic") -) - _player_model = AnthropicModel( _PLAYER_MODEL_NAME, provider=anthropic_pydantic_ai_provider("anthropic") ) _player_stats_agent = Agent(_player_model, output_type=protocol.PlayerStats) +_dm_model = AnthropicModel( + _DM_MODEL_NAME, + provider=anthropic_pydantic_ai_provider("anthropic"), +) + +DM_RESPONSE_TYPES = ( + protocol.DMMessageEvent | protocol.DiceEvent | protocol.StatUpdateEvent +) + +_dm_agent = Agent( + _dm_model, + output_type=DM_RESPONSE_TYPES, + system_prompt=""" +You are the Dungeon Master running a D&D game. + +It's your turn as DM. + +In each invocation, you must take one of the following actions: +1. Send a message to the players. Return a DMMessageEvent with the text of your message. +2. Roll a dice, by returning a DiceEvent with the result of the roll. +3. Update a player's stat, by returning a StatUpdateEvent. + +After each time you take an action, the players will respond in turn. +The first player in the list goes first. Then the rest. + +Tool available to you: +- list_players: Returns the list of all players and their stats in the game. +- roll_dice: Rolls a dice with the given number of sides and returns the result. + +If this is the start of the game, introduce the setting and scenario to the players. +""", +) + + +@_dm_agent.tool(name="list_players") +def _list_players(ctx: RunContext[list[protocol.Player]]) -> str: + return ctx.deps + + +@_dm_agent.tool(name="roll_dice") +def _roll_dice(sides: int) -> int: + return randint(1, sides) + def create_player_stats(cls: str, race: str) -> protocol.Player: return _player_stats_agent.run_sync( f"Create a D&D player of class {cls} and race {race}." ).output + + +def next_dm_event( + turn_summary: list[protocol.MessageAction | protocol.JoinAction], + players: list[protocol.Player], + history: Sequence, +) -> tuple[DM_RESPONSE_TYPES, Sequence]: + result = _dm_agent.run_sync( + f"Actions made since last turn: {json.dumps([a.dict() for a in turn_summary])}" + if history + else "This is the beginning of the game.", + deps=players, + message_history=history, + ) + return result.output, result.all_messages() diff --git a/pydantic/dnd/game.html b/pydantic/dnd/game.html index fc36588f..fff4e14c 100644 --- a/pydantic/dnd/game.html +++ b/pydantic/dnd/game.html @@ -357,19 +357,6 @@ transform: none; } - .dice-buttons { - display: flex; - gap: 0.5rem; - margin-top: 0.5rem; - flex-wrap: wrap; - } - - .dice-btn { - padding: 0.5rem 1rem; - font-size: 0.9rem; - min-width: 60px; - } - /* Scrollbar styling */ ::-webkit-scrollbar { width: 10px; @@ -388,6 +375,30 @@ background: #a87e18; } + .thinking-indicator { + text-align: center; + padding: 1rem; + color: #a89968; + font-style: italic; + animation: pulse 1.5s ease-in-out infinite; + } + + .thinking-dots::after { + content: '...'; + animation: dots 1.5s steps(4, end) infinite; + } + + @keyframes pulse { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 1; } + } + + @keyframes dots { + 0%, 20% { content: '.'; } + 40% { content: '..'; } + 60%, 100% { content: '...'; } + } + @media (max-width: 768px) { .sidebar { display: none; @@ -420,73 +431,11 @@

Party Members

> - -
- - - - - - - -