From fdff013dbe5bd60345d06e5924a60e82dd7503a0 Mon Sep 17 00:00:00 2001 From: fanqiNO1 <1848839264@qq.com> Date: Wed, 1 Apr 2026 12:06:45 +0800 Subject: [PATCH 1/7] refactor [1/N]: tools modularization --- mcp/pyproject.toml | 7 +++++++ mcp/server.py | 1 - mcp/utils/__init__.py | 0 3 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 mcp/utils/__init__.py diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml index ee527e28..11b3bda3 100644 --- a/mcp/pyproject.toml +++ b/mcp/pyproject.toml @@ -10,3 +10,10 @@ dependencies = [ [project.scripts] sts2-mcp = "server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["."] diff --git a/mcp/server.py b/mcp/server.py index 90398ff3..8959f161 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -9,7 +9,6 @@ import json import sys -import httpx from mcp.server.fastmcp import FastMCP mcp = FastMCP("sts2") diff --git a/mcp/utils/__init__.py b/mcp/utils/__init__.py new file mode 100644 index 00000000..e69de29b From f1d227673009ddb1a3bd7961f4266f4b636441a0 Mon Sep 17 00:00:00 2001 From: fanqiNO1 <1848839264@qq.com> Date: Fri, 3 Apr 2026 19:55:01 +0800 Subject: [PATCH 2/7] refactor [2/N] some states with pydantic --- mcp/pyproject.toml | 1 + mcp/states/common/card.py | 29 ++++++++++++++ mcp/states/common/keyword.py | 44 +++++++++++++++++++++ mcp/states/common/potion.py | 19 +++++++++ mcp/states/common/relics.py | 17 +++++++++ mcp/states/common/status_effect.py | 20 ++++++++++ mcp/states/game.py | 23 +++++++++++ mcp/states/player/__init__.py | 3 ++ mcp/states/player/combat.py | 9 +++++ mcp/states/player/orb.py | 14 +++++++ mcp/states/player/pile_card.py | 12 ++++++ mcp/states/player/player.py | 22 +++++++++++ mcp/states/scenario/__init__.py | 7 ++++ mcp/states/scenario/bundle_select.py | 0 mcp/states/scenario/card_reward.py | 0 mcp/states/scenario/card_select.py | 0 mcp/states/scenario/combat.py | 41 ++++++++++++++++++++ mcp/states/scenario/crystal_sphere.py | 55 +++++++++++++++++++++++++++ mcp/states/scenario/event.py | 37 ++++++++++++++++++ mcp/states/scenario/hand_select.py | 0 mcp/states/scenario/map.py | 45 ++++++++++++++++++++++ mcp/states/scenario/menu.py | 10 +++++ mcp/states/scenario/overlay.py | 17 +++++++++ mcp/states/scenario/relic_select.py | 0 mcp/states/scenario/rest_site.py | 27 +++++++++++++ mcp/states/scenario/rewards.py | 29 ++++++++++++++ mcp/states/scenario/shop.py | 0 mcp/states/scenario/treasure.py | 0 mcp/states/scenario/unknown.py | 12 ++++++ mcp/tests/test_game_state.py | 0 30 files changed, 493 insertions(+) create mode 100644 mcp/states/common/card.py create mode 100644 mcp/states/common/keyword.py create mode 100644 mcp/states/common/potion.py create mode 100644 mcp/states/common/relics.py create mode 100644 mcp/states/common/status_effect.py create mode 100644 mcp/states/game.py create mode 100644 mcp/states/player/__init__.py create mode 100644 mcp/states/player/combat.py create mode 100644 mcp/states/player/orb.py create mode 100644 mcp/states/player/pile_card.py create mode 100644 mcp/states/player/player.py create mode 100644 mcp/states/scenario/__init__.py create mode 100644 mcp/states/scenario/bundle_select.py create mode 100644 mcp/states/scenario/card_reward.py create mode 100644 mcp/states/scenario/card_select.py create mode 100644 mcp/states/scenario/combat.py create mode 100644 mcp/states/scenario/crystal_sphere.py create mode 100644 mcp/states/scenario/event.py create mode 100644 mcp/states/scenario/hand_select.py create mode 100644 mcp/states/scenario/map.py create mode 100644 mcp/states/scenario/menu.py create mode 100644 mcp/states/scenario/overlay.py create mode 100644 mcp/states/scenario/relic_select.py create mode 100644 mcp/states/scenario/rest_site.py create mode 100644 mcp/states/scenario/rewards.py create mode 100644 mcp/states/scenario/shop.py create mode 100644 mcp/states/scenario/treasure.py create mode 100644 mcp/states/scenario/unknown.py create mode 100644 mcp/tests/test_game_state.py diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml index 11b3bda3..8af15dea 100644 --- a/mcp/pyproject.toml +++ b/mcp/pyproject.toml @@ -17,3 +17,4 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["."] +exclude = ["tests"] diff --git a/mcp/states/common/card.py b/mcp/states/common/card.py new file mode 100644 index 00000000..c0bb4501 --- /dev/null +++ b/mcp/states/common/card.py @@ -0,0 +1,29 @@ +from pydantic import BaseModel, model_validator + +from states.common.keyword import Keywords + + +class Card(BaseModel): + """The card object.""" + + index: int + id: str + name: str + type: str + cost: str # can be int or "X" (variable cost) + star_cost: int | None = None + description: str + is_upgraded: bool + keywords: Keywords + + def to_markdown(self) -> str: + """Convert the card to a markdown string.""" + pass + + +class HandCard(Card): + """The card in the player's hand.""" + + def to_markdown(self) -> str: + """Convert the hand card to a markdown string.""" + pass diff --git a/mcp/states/common/keyword.py b/mcp/states/common/keyword.py new file mode 100644 index 00000000..63c00c7e --- /dev/null +++ b/mcp/states/common/keyword.py @@ -0,0 +1,44 @@ +from pydantic import BaseModel, model_validator + + +class Keyword(BaseModel): + """The keyword object.""" + + name: str + description: str + + def to_markdown(self): + """Convert the keyword to a markdown string.""" + return f"**{self.name}**: {self.description}" + + +class Keywords(BaseModel): + """The keywords object, which is a collection of keywords.""" + + keywords: dict[str, Keyword] # keyword name -> keyword + + def __add__(self, other_keywords: "Keywords") -> "Keywords": + """Combine two sets of keywords.""" + combined_keywords = self.keywords.copy() + for name, keyword in other_keywords.keywords.items(): + if name not in combined_keywords: + combined_keywords[name] = keyword + return Keywords(keywords=combined_keywords) + + def to_markdown(self) -> str: + """Convert the keywords to a markdown string.""" + if not self.keywords: + return "" + lines = ["## Keyword Glossary"] + for keyword in self.keywords.values(): + lines.append(f"- {keyword.to_markdown()}") + return "\n".join(lines) + + @model_validator(mode="before") + @classmethod + def from_keyword_list(cls, keyword_list: list[dict]) -> dict: + """Create a keywords dict from a list of keyword dicts.""" + keywords = dict() + for keyword in keyword_list: + keywords[keyword["name"]] = keyword + return {"keywords": keywords} diff --git a/mcp/states/common/potion.py b/mcp/states/common/potion.py new file mode 100644 index 00000000..12b8f3ff --- /dev/null +++ b/mcp/states/common/potion.py @@ -0,0 +1,19 @@ +from pydantic import BaseModel, model_validator + +from states.common.keyword import Keywords + + +class Potion(BaseModel): + """The potion object.""" + + id: str + name: str + description: str + slot: int + can_use_in_combat: bool + target_type: str + keywords: Keywords + + def to_markdown(self) -> str: + """Convert the potion to a markdown string.""" + pass diff --git a/mcp/states/common/relics.py b/mcp/states/common/relics.py new file mode 100644 index 00000000..c6b1a3ac --- /dev/null +++ b/mcp/states/common/relics.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel, model_validator + +from states.common.keyword import Keywords + + +class Relic(BaseModel): + """The relic object.""" + + id: str + name: str + description: str + counter: int | None = None # number if relic shows a counter, null otherwise + keywords: Keywords + + def to_markdown(self) -> str: + """Convert the relic to a markdown string.""" + pass diff --git a/mcp/states/common/status_effect.py b/mcp/states/common/status_effect.py new file mode 100644 index 00000000..634be388 --- /dev/null +++ b/mcp/states/common/status_effect.py @@ -0,0 +1,20 @@ +from typing import Literal + +from pydantic import BaseModel, model_validator + +from states.common.keyword import Keywords + + +class StatusEffect(BaseModel): + """The status effect object.""" + + id: str + name: str + amount: int + type: Literal["Buff", "Debuff"] + description: str + keywords: Keywords + + def to_markdown(self) -> str: + """Convert the status effect to a markdown string.""" + pass \ No newline at end of file diff --git a/mcp/states/game.py b/mcp/states/game.py new file mode 100644 index 00000000..c5c7524b --- /dev/null +++ b/mcp/states/game.py @@ -0,0 +1,23 @@ +from pydantic import BaseModel, Field, model_validator + +from states.player import PlayerState + + + +class RunState(BaseModel): + """The state of the current run.""" + + act: int + floor: int + ascension: int + + +class GameState(BaseModel): + """The state of the game.""" + + # common fields + run: RunState + player: PlayerState + + # scenario-specific fields + scenario_state: ScenarioState = Field(discriminator="state_type") \ No newline at end of file diff --git a/mcp/states/player/__init__.py b/mcp/states/player/__init__.py new file mode 100644 index 00000000..8a78f17a --- /dev/null +++ b/mcp/states/player/__init__.py @@ -0,0 +1,3 @@ +from states.player import PlayerState + +__all__ = ["PlayerState"] diff --git a/mcp/states/player/combat.py b/mcp/states/player/combat.py new file mode 100644 index 00000000..5b316681 --- /dev/null +++ b/mcp/states/player/combat.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel + + +class CombatPlayerState(BaseModel): + """The combat-specific state of the player.""" + + energy: int + max_energy: int + stars: int diff --git a/mcp/states/player/orb.py b/mcp/states/player/orb.py new file mode 100644 index 00000000..882ad78e --- /dev/null +++ b/mcp/states/player/orb.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + +from states.common.keyword import Keywords + + +class Orb(BaseModel): + """The orb object.""" + + id: str + name: str + description: str + passive_val: int + evoke_val: int + keywords: Keywords diff --git a/mcp/states/player/pile_card.py b/mcp/states/player/pile_card.py new file mode 100644 index 00000000..ef0c255b --- /dev/null +++ b/mcp/states/player/pile_card.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + + +class PileCard(BaseModel): + """The card in the player's pile (draw pile, discard pile, exhaust pile).""" + + name: str + description: str + + def to_markdown(self) -> str: + """Convert the pile card to a markdown string.""" + pass \ No newline at end of file diff --git a/mcp/states/player/player.py b/mcp/states/player/player.py new file mode 100644 index 00000000..13305d74 --- /dev/null +++ b/mcp/states/player/player.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, model_validator + +from states.player.combat import CombatPlayerState + + +class PlayerState(BaseModel): + """The state of the player.""" + + # common fields + character: str + hp: int + max_hp: int + block: int + gold: int + + # combat-only fields + combat_state: CombatPlayerState | None = None + + # always present fields + status: + relics: + potions: diff --git a/mcp/states/scenario/__init__.py b/mcp/states/scenario/__init__.py new file mode 100644 index 00000000..ca0d4bd3 --- /dev/null +++ b/mcp/states/scenario/__init__.py @@ -0,0 +1,7 @@ +from typing import TypeAlias + + + +ScenarioState: TypeAlias = "ScenarioState" + +__all__ = ["ScenarioState"] diff --git a/mcp/states/scenario/bundle_select.py b/mcp/states/scenario/bundle_select.py new file mode 100644 index 00000000..e69de29b diff --git a/mcp/states/scenario/card_reward.py b/mcp/states/scenario/card_reward.py new file mode 100644 index 00000000..e69de29b diff --git a/mcp/states/scenario/card_select.py b/mcp/states/scenario/card_select.py new file mode 100644 index 00000000..e69de29b diff --git a/mcp/states/scenario/combat.py b/mcp/states/scenario/combat.py new file mode 100644 index 00000000..69624cc4 --- /dev/null +++ b/mcp/states/scenario/combat.py @@ -0,0 +1,41 @@ +from typing import Literal + +from pydantic import BaseModel + + +class Intent(BaseModel): + """The intent of the enemy.""" + + type: str + label: str + title: str + description: str + + +class Enemy(BaseModel): + """The enemy object.""" + + entity_id: str + combat_id: int + name: str + hp: int + max_hp: int + block: int + status: + intents: list[Intent] + + +class BattleState(BaseModel): + """The state of the battle.""" + + round: int + turn: Literal["player", "enemy"] + is_play_phase: bool + enemies: list[Enemy] + + +class CombatState(BaseModel): + """The state when the scenario is in the combat (monster or elite or boss).""" + + state_type: Literal["monster", "elite", "boss"] + battle: BattleState diff --git a/mcp/states/scenario/crystal_sphere.py b/mcp/states/scenario/crystal_sphere.py new file mode 100644 index 00000000..32220445 --- /dev/null +++ b/mcp/states/scenario/crystal_sphere.py @@ -0,0 +1,55 @@ +from typing import Literal + +from pydantic import BaseModel + + +class Coordinate(BaseModel): + """The coordinate of the crystal sphere.""" + + x: int + y: int + + +class Cell(Coordinate): + """The cell in the crystal sphere.""" + + coordinate: Coordinate + is_hidden: bool + is_clickable: bool + is_highlighted: bool + is_hovered: bool + item_type: str | None = None # only on revealed cells + is_good: bool | None = None # only on revealed cells + + +class RevealedItem(Coordinate): + """The revealed item in the crystal sphere.""" + + item_type: str + width: int + height: int + is_good: bool + + +class CrystalSphere(BaseModel): + """The crystal sphere event state.""" + + instructions_title: str + instructions_description: str + grid_width: int + grid_height: int + cells: list[Cell] + clickable_cells: list[Coordinate] + revealed_items: list[RevealedItem] + tool: str # big, small, or none + can_use_big_tool: bool + can_use_small_tool: bool + divinations_left_text: str + can_proceed: bool + + +class CrystalSphereState(BaseModel): + """The state when the scenario is in the crystal sphere event.""" + + state_type: Literal["crystal_sphere"] + crystal_sphere: CrystalSphere diff --git a/mcp/states/scenario/event.py b/mcp/states/scenario/event.py new file mode 100644 index 00000000..da7f832d --- /dev/null +++ b/mcp/states/scenario/event.py @@ -0,0 +1,37 @@ +from typing import Literal + +from pydantic import BaseModel + +from states.common.keyword import Keywords + + +class EventOption(BaseModel): + """The option in the event.""" + + index: int + title: str + description: str + is_locked: bool + is_proceed: bool + was_chosen: bool + relic_name: str | None = None # only if option has a relic + relic_description: str | None = None # only if option has a relic + keywords: Keywords + + +class Event(BaseModel): + """The event object.""" + + event_id: str + event_name: str + is_ancient: bool + in_dialogue: bool + body: str + options: list[EventOption] + + +class EventState(BaseModel): + """The state when the scenario is in an event.""" + + state_type: Literal["event"] + event: Event diff --git a/mcp/states/scenario/hand_select.py b/mcp/states/scenario/hand_select.py new file mode 100644 index 00000000..e69de29b diff --git a/mcp/states/scenario/map.py b/mcp/states/scenario/map.py new file mode 100644 index 00000000..b314cf19 --- /dev/null +++ b/mcp/states/scenario/map.py @@ -0,0 +1,45 @@ +from typing import Literal + +from pydantic import BaseModel + + +class Node(BaseModel): + """The node on the map.""" + + col: int + row: int + + +class NodeWithType(Node): + """Node with type.""" + + type: str + + +class NextNode(NodeWithType): + """The next node on the map with 1-level lookahead.""" + + leads_to: list[NodeWithType] + + +class DAGNode(NodeWithType): + """The node in the DAG map.""" + + children: list[tuple[int, int]] # list of (col, row) of the children nodes + + +class Map(BaseModel): + """The map navigation information.""" + + current_position: Node + visited: list[NodeWithType] + next_options: list[NextNode] + nodes: list[DAGNode] + boss: Node + + +class MapState(BaseModel): + """The state when the scenario is on the map.""" + + state_type: Literal["map"] + map: Map diff --git a/mcp/states/scenario/menu.py b/mcp/states/scenario/menu.py new file mode 100644 index 00000000..d3f08203 --- /dev/null +++ b/mcp/states/scenario/menu.py @@ -0,0 +1,10 @@ +from typing import Literal + +from pydantic import BaseModel + + +class MenuState(BaseModel): + """The state when the scenario is in the menu.""" + + state_type: Literal["menu"] + message: str diff --git a/mcp/states/scenario/overlay.py b/mcp/states/scenario/overlay.py new file mode 100644 index 00000000..5a40097a --- /dev/null +++ b/mcp/states/scenario/overlay.py @@ -0,0 +1,17 @@ +from typing import Literal + +from pydantic import BaseModel + + +class Overlay(BaseModel): + """The information of the overlay.""" + + screen_type: str + message: str + + +class OverlayState(BaseModel): + """The state when an unrecognized overlay is active.""" + + state_type: Literal["overlay"] = "overlay" + overlay: Overlay diff --git a/mcp/states/scenario/relic_select.py b/mcp/states/scenario/relic_select.py new file mode 100644 index 00000000..e69de29b diff --git a/mcp/states/scenario/rest_site.py b/mcp/states/scenario/rest_site.py new file mode 100644 index 00000000..7dbec404 --- /dev/null +++ b/mcp/states/scenario/rest_site.py @@ -0,0 +1,27 @@ +from typing import Literal + +from pydantic import BaseModel + + +class RestSiteOption(BaseModel): + """The option at the rest site.""" + + index: int + id: str + name: str + description: str + is_enabled: bool + + +class RestSite(BaseModel): + """The rest site object.""" + + options: list[RestSiteOption] + can_proceed: bool + + +class RestSiteState(BaseModel): + """The state when the scenario is at the rest site.""" + + state_type: Literal["rest_site"] + rest_site: RestSite diff --git a/mcp/states/scenario/rewards.py b/mcp/states/scenario/rewards.py new file mode 100644 index 00000000..9721e217 --- /dev/null +++ b/mcp/states/scenario/rewards.py @@ -0,0 +1,29 @@ +from typing import Literal + +from pydantic import BaseModel, ConfigDict + + +class RewardItem(BaseModel): + """The reward item.""" + + index: int + type: str + description: str + + # there are several types of reward items, each type has its own fields + # therefore we allow extra fields for different types of reward items + model_config = ConfigDict(extra="allow") + + +class Rewards(BaseModel): + """The rewards after a combat or event.""" + + items: list[RewardItem] + can_proceed: bool + + +class RewardsState(BaseModel): + """The state when the scenario is in the rewards screen after a combat or event.""" + + state_type: Literal["rewards"] + rewards: Rewards diff --git a/mcp/states/scenario/shop.py b/mcp/states/scenario/shop.py new file mode 100644 index 00000000..e69de29b diff --git a/mcp/states/scenario/treasure.py b/mcp/states/scenario/treasure.py new file mode 100644 index 00000000..e69de29b diff --git a/mcp/states/scenario/unknown.py b/mcp/states/scenario/unknown.py new file mode 100644 index 00000000..582ff7f6 --- /dev/null +++ b/mcp/states/scenario/unknown.py @@ -0,0 +1,12 @@ +from typing import Literal + +from pydantic import BaseModel, ConfigDict + + +class UnknownState(BaseModel): + """The state when the scenario is unknown (not recognized).""" + + state_type: Literal["unknown"] + + # As the scenario is unknown, we allow any extra fields to be stored in the model. + model_config = ConfigDict(extra="allow") diff --git a/mcp/tests/test_game_state.py b/mcp/tests/test_game_state.py new file mode 100644 index 00000000..e69de29b From 0f8f893cc5195387a40d104b35051dcf74592714 Mon Sep 17 00:00:00 2001 From: fanqiNO1 <1848839264@qq.com> Date: Sat, 4 Apr 2026 17:10:24 +0800 Subject: [PATCH 3/7] refactor [3/N] singleplayer state support with pydantic --- mcp/states/common/card.py | 16 +++++- mcp/states/common/keyword.py | 2 +- mcp/states/common/relic.py | 56 +++++++++++++++++++++ mcp/states/common/relics.py | 17 ------- mcp/states/common/status_effect.py | 2 +- mcp/states/game.py | 41 ++++++++++++++-- mcp/states/player/__init__.py | 2 +- mcp/states/player/combat.py | 17 +++++++ mcp/states/player/pile_card.py | 34 ++++++++++++- mcp/states/player/player.py | 9 ++-- mcp/states/scenario/__init__.py | 35 ++++++++++++- mcp/states/scenario/bundle_select.py | 32 ++++++++++++ mcp/states/scenario/card_reward.py | 19 ++++++++ mcp/states/scenario/card_select.py | 23 +++++++++ mcp/states/scenario/combat.py | 7 ++- mcp/states/scenario/event.py | 2 +- mcp/states/scenario/hand_select.py | 33 +++++++++++++ mcp/states/scenario/relic_select.py | 20 ++++++++ mcp/states/scenario/shop.py | 73 ++++++++++++++++++++++++++++ mcp/states/scenario/treasure.py | 20 ++++++++ mcp/tools/utils.py | 25 ++++++++++ 21 files changed, 451 insertions(+), 34 deletions(-) create mode 100644 mcp/states/common/relic.py delete mode 100644 mcp/states/common/relics.py create mode 100644 mcp/tools/utils.py diff --git a/mcp/states/common/card.py b/mcp/states/common/card.py index c0bb4501..c80e4af4 100644 --- a/mcp/states/common/card.py +++ b/mcp/states/common/card.py @@ -11,7 +11,7 @@ class Card(BaseModel): name: str type: str cost: str # can be int or "X" (variable cost) - star_cost: int | None = None + star_cost: str | None = None description: str is_upgraded: bool keywords: Keywords @@ -24,6 +24,20 @@ def to_markdown(self) -> str: class HandCard(Card): """The card in the player's hand.""" + target_type: str + can_play: bool + unplayable_reason: str | None = None + def to_markdown(self) -> str: """Convert the hand card to a markdown string.""" pass + + +class RewardCard(Card): + """The card in the reward screen.""" + + rarity: str + + def to_markdown(self) -> str: + """Convert the reward card to a markdown string.""" + pass diff --git a/mcp/states/common/keyword.py b/mcp/states/common/keyword.py index 63c00c7e..972abd36 100644 --- a/mcp/states/common/keyword.py +++ b/mcp/states/common/keyword.py @@ -40,5 +40,5 @@ def from_keyword_list(cls, keyword_list: list[dict]) -> dict: """Create a keywords dict from a list of keyword dicts.""" keywords = dict() for keyword in keyword_list: - keywords[keyword["name"]] = keyword + keywords[keyword["name"]] = Keyword.model_validate(keyword) return {"keywords": keywords} diff --git a/mcp/states/common/relic.py b/mcp/states/common/relic.py new file mode 100644 index 00000000..8031eb81 --- /dev/null +++ b/mcp/states/common/relic.py @@ -0,0 +1,56 @@ +from pydantic import BaseModel, model_validator + +from states.common.keyword import Keywords + + +class Relic(BaseModel): + """The relic object.""" + + id: str + name: str + description: str + counter: int | None = None # number if relic shows a counter, null otherwise + keywords: Keywords + + def to_markdown(self) -> str: + """Convert the relic to a markdown string.""" + counter = f" [{self.counter}]" if self.counter is not None else "" + return f"**{self.name}**{counter}: {self.description}" + + +class RewardRelic(Relic): + """The relic in the reward screen.""" + + index: int + rarity: str + + def to_markdown(self): + return super().to_markdown() + + +class Relics(BaseModel): + """The state of the player's relics.""" + + relics: dict[str, list[int | Relic]] # relic markdown -> [count, relic] + + def __sub__(self, old_relics: "Relics") -> "Relics": + """Calculate the difference between two relic states.""" + pass + + def to_markdown(self, is_diff = False) -> str: + """Convert the relics to a markdown string.""" + pass + + @model_validator(mode="before") + @classmethod + def from_relic_list(cls, relic_list: list[dict]) -> dict: + """Create a relics dict from a list of relic dicts.""" + relics = dict() + for relic in relic_list: + relic_obj = Relic.model_validate(relic) + relic_markdown = relic_obj.to_markdown() + if relic_markdown not in relics: + relics[relic_markdown] = [0, relic_obj] + else: + relics[relic_markdown][0] += 1 + return {"relics": relics} diff --git a/mcp/states/common/relics.py b/mcp/states/common/relics.py deleted file mode 100644 index c6b1a3ac..00000000 --- a/mcp/states/common/relics.py +++ /dev/null @@ -1,17 +0,0 @@ -from pydantic import BaseModel, model_validator - -from states.common.keyword import Keywords - - -class Relic(BaseModel): - """The relic object.""" - - id: str - name: str - description: str - counter: int | None = None # number if relic shows a counter, null otherwise - keywords: Keywords - - def to_markdown(self) -> str: - """Convert the relic to a markdown string.""" - pass diff --git a/mcp/states/common/status_effect.py b/mcp/states/common/status_effect.py index 634be388..5043cece 100644 --- a/mcp/states/common/status_effect.py +++ b/mcp/states/common/status_effect.py @@ -17,4 +17,4 @@ class StatusEffect(BaseModel): def to_markdown(self) -> str: """Convert the status effect to a markdown string.""" - pass \ No newline at end of file + pass diff --git a/mcp/states/game.py b/mcp/states/game.py index c5c7524b..88238df7 100644 --- a/mcp/states/game.py +++ b/mcp/states/game.py @@ -1,7 +1,9 @@ +from typing import Literal + from pydantic import BaseModel, Field, model_validator from states.player import PlayerState - +from states.scenario import ScenarioState class RunState(BaseModel): @@ -11,13 +13,44 @@ class RunState(BaseModel): floor: int ascension: int + def to_markdown(self) -> str: + """Convert the run state to a markdown string.""" + return f"**Act {self.act}** | Floor {self.floor} | Ascension {self.ascension}" + class GameState(BaseModel): """The state of the game.""" + # gamemode + game_mode: Literal["singleplayer", "multiplayer"] + # common fields - run: RunState - player: PlayerState + run: RunState | None = None + player: PlayerState | None = None # scenario-specific fields - scenario_state: ScenarioState = Field(discriminator="state_type") \ No newline at end of file + scenario_state: ScenarioState = Field(discriminator="state_type") + + def to_markdown(self, is_diff: bool = False) -> str: + """Convert the game state to a markdown string.""" + pass + + @model_validator(mode="before") + @classmethod + def from_json_state(cls, json_state: dict) -> dict: + """Create a GameState instance from a JSON state.""" + game_state = dict() + scenario_state = dict() + + # get game mode + game_mode = json_state.get("game_mode", "singleplayer") + game_state["game_mode"] = game_mode + + # parse scenario-specific fields + for key in json_state: + if key in ["run", "player"]: + game_state[key] = json_state[key] + else: + scenario_state[key] = json_state[key] + game_state["scenario_state"] = scenario_state + return game_state diff --git a/mcp/states/player/__init__.py b/mcp/states/player/__init__.py index 8a78f17a..210492d6 100644 --- a/mcp/states/player/__init__.py +++ b/mcp/states/player/__init__.py @@ -1,3 +1,3 @@ -from states.player import PlayerState +from states.player.player import PlayerState __all__ = ["PlayerState"] diff --git a/mcp/states/player/combat.py b/mcp/states/player/combat.py index 5b316681..51c51532 100644 --- a/mcp/states/player/combat.py +++ b/mcp/states/player/combat.py @@ -1,5 +1,9 @@ from pydantic import BaseModel +from states.common.card import HandCard +from states.player.pile_card import PileCards +from states.player.orb import Orb + class CombatPlayerState(BaseModel): """The combat-specific state of the player.""" @@ -7,3 +11,16 @@ class CombatPlayerState(BaseModel): energy: int max_energy: int stars: int + # cards + hand: list[HandCard] + # piles + draw_pile_count: int + discard_pile_count: int + exhaust_pile_count: int + draw_pile: PileCards + discard_pile: PileCards + exhaust_pile: PileCards + # orbs + orbs: list[Orb] + orb_slots: int + orb_empty_slots: int diff --git a/mcp/states/player/pile_card.py b/mcp/states/player/pile_card.py index ef0c255b..c53dba50 100644 --- a/mcp/states/player/pile_card.py +++ b/mcp/states/player/pile_card.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel +from pydantic import BaseModel, model_validator class PileCard(BaseModel): @@ -6,7 +6,37 @@ class PileCard(BaseModel): name: str description: str + cost: str + star_cost: str | None = None def to_markdown(self) -> str: """Convert the pile card to a markdown string.""" - pass \ No newline at end of file + pass + + +class PileCards(BaseModel): + """The state of the player's pile cards.""" + + pile_cards: dict[str, list[int | PileCard]] # pile card markdown -> [count, pile card] + + def __sub__(self, old_pile_cards: "PileCards") -> "PileCards": + """Calculate the difference between two pile card states.""" + pass + + def to_markdown(self, is_diff = False) -> str: + """Convert the pile cards to a markdown string.""" + pass + + @model_validator(mode="before") + @classmethod + def from_pile_card_list(cls, pile_card_list: list[dict]) -> dict: + """Create a pile cards dict from a list of pile card dicts.""" + pile_cards = dict() + for pile_card in pile_card_list: + pile_card_obj = PileCard.model_validate(pile_card) + pile_card_markdown = pile_card_obj.to_markdown() + if pile_card_markdown not in pile_cards: + pile_cards[pile_card_markdown] = [0, pile_card_obj] + else: + pile_cards[pile_card_markdown][0] += 1 + return {"pile_cards": pile_cards} diff --git a/mcp/states/player/player.py b/mcp/states/player/player.py index 13305d74..c473ce10 100644 --- a/mcp/states/player/player.py +++ b/mcp/states/player/player.py @@ -1,5 +1,8 @@ from pydantic import BaseModel, model_validator +from states.common.potion import Potion +from states.common.relic import Relics +from states.common.status_effect import StatusEffect from states.player.combat import CombatPlayerState @@ -17,6 +20,6 @@ class PlayerState(BaseModel): combat_state: CombatPlayerState | None = None # always present fields - status: - relics: - potions: + status: list[StatusEffect] + relics: Relics + potions: list[Potion] diff --git a/mcp/states/scenario/__init__.py b/mcp/states/scenario/__init__.py index ca0d4bd3..6b83d8ea 100644 --- a/mcp/states/scenario/__init__.py +++ b/mcp/states/scenario/__init__.py @@ -1,7 +1,40 @@ from typing import TypeAlias +from states.scenario.bundle_select import BundleSelectState +from states.scenario.card_reward import CardRewardState +from states.scenario.card_select import CardSelectState +from states.scenario.combat import CombatState +from states.scenario.crystal_sphere import CrystalSphereState +from states.scenario.event import EventState +from states.scenario.hand_select import HandSelectState +from states.scenario.map import MapState +from states.scenario.menu import MenuState +from states.scenario.overlay import OverlayState +from states.scenario.relic_select import RelicSelectState +from states.scenario.rest_site import RestSiteState +from states.scenario.rewards import RewardsState +from states.scenario.shop import ShopState +from states.scenario.treasure import TreasureState +from states.scenario.unknown import UnknownState -ScenarioState: TypeAlias = "ScenarioState" +ScenarioState: TypeAlias = ( + BundleSelectState + | CardRewardState + | CardSelectState + | CombatState + | CrystalSphereState + | EventState + | HandSelectState + | MapState + | MenuState + | OverlayState + | RelicSelectState + | RestSiteState + | RewardsState + | ShopState + | TreasureState + | UnknownState +) __all__ = ["ScenarioState"] diff --git a/mcp/states/scenario/bundle_select.py b/mcp/states/scenario/bundle_select.py index e69de29b..ff7cb392 100644 --- a/mcp/states/scenario/bundle_select.py +++ b/mcp/states/scenario/bundle_select.py @@ -0,0 +1,32 @@ +from typing import Literal + +from pydantic import BaseModel + +from states.common.card import RewardCard + + +class Bundle(BaseModel): + """The bundle object.""" + + index: int + card_count: int + cards: list[RewardCard] + + +class BundleSelect(BaseModel): + """The bundle select object.""" + + screen_type: str + prompt: str + bundles: list[Bundle] + preview_showing: bool + preview_cards: list[RewardCard] + can_confirm: bool + can_cancel: bool + + +class BundleSelectState(BaseModel): + """The state when the scenario is in the bundle selection screen.""" + + state_type: Literal["bundle_select"] + bundle_select: BundleSelect diff --git a/mcp/states/scenario/card_reward.py b/mcp/states/scenario/card_reward.py index e69de29b..2fda765c 100644 --- a/mcp/states/scenario/card_reward.py +++ b/mcp/states/scenario/card_reward.py @@ -0,0 +1,19 @@ +from typing import Literal + +from pydantic import BaseModel + +from states.common.card import RewardCard + + +class CardReward(BaseModel): + """The card reward object.""" + + cards: list[RewardCard] + can_skip: bool + + +class CardRewardState(BaseModel): + """The state when the scenario is card reward selection.""" + + state_type: Literal["card_reward"] + card_reward: CardReward diff --git a/mcp/states/scenario/card_select.py b/mcp/states/scenario/card_select.py index e69de29b..54039011 100644 --- a/mcp/states/scenario/card_select.py +++ b/mcp/states/scenario/card_select.py @@ -0,0 +1,23 @@ +from typing import Literal + +from pydantic import BaseModel + +from states.common.card import RewardCard + + +class CardSelect(BaseModel): + """The card select object.""" + + screen_type: str + prompt: str + cards: list[RewardCard] + preview_showing: bool + can_confirm: bool + can_cancel: bool + + +class CardSelectState(BaseModel): + """The state when the scenario is in the card selection screen.""" + + state_type: Literal["card_select"] + card_select: CardSelect diff --git a/mcp/states/scenario/combat.py b/mcp/states/scenario/combat.py index 69624cc4..45f865a0 100644 --- a/mcp/states/scenario/combat.py +++ b/mcp/states/scenario/combat.py @@ -2,6 +2,8 @@ from pydantic import BaseModel +from states.common.status_effect import StatusEffect + class Intent(BaseModel): """The intent of the enemy.""" @@ -21,7 +23,7 @@ class Enemy(BaseModel): hp: int max_hp: int block: int - status: + status: list[StatusEffect] intents: list[Intent] @@ -38,4 +40,5 @@ class CombatState(BaseModel): """The state when the scenario is in the combat (monster or elite or boss).""" state_type: Literal["monster", "elite", "boss"] - battle: BattleState + message: str | None = None + battle: BattleState | None = None diff --git a/mcp/states/scenario/event.py b/mcp/states/scenario/event.py index da7f832d..9f87e09b 100644 --- a/mcp/states/scenario/event.py +++ b/mcp/states/scenario/event.py @@ -26,7 +26,7 @@ class Event(BaseModel): event_name: str is_ancient: bool in_dialogue: bool - body: str + body: str | None options: list[EventOption] diff --git a/mcp/states/scenario/hand_select.py b/mcp/states/scenario/hand_select.py index e69de29b..8c01a241 100644 --- a/mcp/states/scenario/hand_select.py +++ b/mcp/states/scenario/hand_select.py @@ -0,0 +1,33 @@ +from typing import Literal + +from pydantic import BaseModel + +from states.common.card import Card +from states.scenario.combat import CombatState + + +class SelectedCard(BaseModel): + """The card selected by the player during hand selection.""" + + index: int + name: str + + +class HandSelect(BaseModel): + """The state of the hand selection.""" + + mode: str + prompt: str + cards: list[Card] + selected_cards: list[SelectedCard] | None = None # only present if cards has been selected + can_confirm: bool + + +class HandSelectState(CombatState): + """The state when the scenario is in-combat card selection. + + As the full battle state is included for context, this class inherits from CombatState. + """ + + state_type: Literal["hand_select"] + hand_select: HandSelect diff --git a/mcp/states/scenario/relic_select.py b/mcp/states/scenario/relic_select.py index e69de29b..a2420eb4 100644 --- a/mcp/states/scenario/relic_select.py +++ b/mcp/states/scenario/relic_select.py @@ -0,0 +1,20 @@ +from typing import Literal + +from pydantic import BaseModel + +from states.common.relic import RewardRelic + + +class RelicSelect(BaseModel): + """The relic select object.""" + + prompt: str + relics: list[RewardRelic] + can_skip: bool + + +class RelicSelectState(BaseModel): + """The state when the scenario is in the relic selection screen.""" + + state_type: Literal["relic_select"] + relic_select: RelicSelect diff --git a/mcp/states/scenario/shop.py b/mcp/states/scenario/shop.py index e69de29b..19870af3 100644 --- a/mcp/states/scenario/shop.py +++ b/mcp/states/scenario/shop.py @@ -0,0 +1,73 @@ +from typing import Literal, TypeAlias + +from pydantic import BaseModel + +from states.common.keyword import Keywords + + +class BaseShopItem(BaseModel): + """The item in the shop.""" + + index: int + cost: int + is_stocked: bool + can_afford: bool + + +class ShopCard(BaseShopItem): + """The card in the shop.""" + + category: Literal["card"] + on_sale: bool | None = None + card_id: str | None = None # None when purchased + card_name: str | None = None + card_type: str | None = None + card_rarity: str | None = None + card_cost: str | None = None # can be int or "X" (variable cost) + card_star_cost: str | None = None + card_description: str | None = None + keywords: Keywords | None = None + + +class ShopRelic(BaseShopItem): + """The relic in the shop.""" + + category: Literal["relic"] + relic_id: str | None = None # None when purchased + relic_name: str | None = None + relic_description: str | None = None + keywords: Keywords | None = None + + +class ShopPotion(BaseShopItem): + """The potion in the shop.""" + + category: Literal["potion"] + potion_id: str | None = None # None when purchased + potion_name: str | None = None + potion_description: str | None = None + keywords: Keywords | None = None + + +class ShopCardRemoval(BaseShopItem): + """The card removal option in the shop.""" + + category: Literal["card_removal"] + + +ShopItem: TypeAlias = ShopCard | ShopRelic | ShopPotion | ShopCardRemoval + + +class Shop(BaseModel): + """The state of the shop.""" + + items: list[ShopItem] + can_proceed: bool + error: str | None = None # only present if inventory isn't ready; retry in a moment + + +class ShopState(BaseModel): + """The state when the scenario is in the shop.""" + + state_type: Literal["shop"] + shop: Shop diff --git a/mcp/states/scenario/treasure.py b/mcp/states/scenario/treasure.py index e69de29b..c0f4ff3e 100644 --- a/mcp/states/scenario/treasure.py +++ b/mcp/states/scenario/treasure.py @@ -0,0 +1,20 @@ +from typing import Literal + +from pydantic import BaseModel + +from states.common.relic import RewardRelic + + +class Treasure(BaseModel): + """The treasure object.""" + + message: str + relics: list[RewardRelic] + can_proceed: bool + + +class TreasureState(BaseModel): + """The state when the scenario is in the treasure room.""" + + state_type: Literal["treasure"] + treasure: Treasure diff --git a/mcp/tools/utils.py b/mcp/tools/utils.py new file mode 100644 index 00000000..666fc6df --- /dev/null +++ b/mcp/tools/utils.py @@ -0,0 +1,25 @@ +import httpx + + +async def _get(url: str, timeout: int, trust_env: bool, params: dict | None = None) -> str: + """Make a GET request to the given URL with the given parameters.""" + async with httpx.AsyncClient(timeout=timeout, trust_env=trust_env) as client: + response = await client.get(url, params=params) + response.raise_for_status() + return response.text + + +async def _post(url: str, timeout: int, trust_env: bool, body: dict | None = None) -> str: + """Make a POST request to the given URL with the given body.""" + async with httpx.AsyncClient(timeout=timeout, trust_env=trust_env) as client: + response = await client.post(url, json=body) + response.raise_for_status() + return response.text + + +def _handle_error(e: Exception) -> str: + if isinstance(e, httpx.ConnectError): + return "Error: Cannot connect to STS2_MCP mod. Is the game running with the mod enabled?" + if isinstance(e, httpx.HTTPStatusError): + return f"Error: HTTP {e.response.status_code} — {e.response.text}" + return f"Error: {e}" From a79df823444b2b1c7b4a4011d4bfcba6cd0ebb13 Mon Sep 17 00:00:00 2001 From: fanqiNO1 <1848839264@qq.com> Date: Sat, 4 Apr 2026 20:56:11 +0800 Subject: [PATCH 4/7] refactor [4/N] format as markdown support by LLM --- mcp/states/common/card.py | 14 ++++-- mcp/states/common/keyword.py | 32 ++++++++++++-- mcp/states/common/potion.py | 4 +- mcp/states/common/relic.py | 9 +++- mcp/states/common/status_effect.py | 3 +- mcp/states/game.py | 41 ++++++++++++++++- mcp/states/player/combat.py | 36 +++++++++++++++ mcp/states/player/orb.py | 5 +++ mcp/states/player/pile_card.py | 12 +++-- mcp/states/player/player.py | 51 +++++++++++++++++++++ mcp/states/scenario/bundle_select.py | 34 ++++++++++++++ mcp/states/scenario/card_reward.py | 13 ++++++ mcp/states/scenario/card_select.py | 30 +++++++++++++ mcp/states/scenario/combat.py | 35 +++++++++++++++ mcp/states/scenario/crystal_sphere.py | 31 +++++++++++++ mcp/states/scenario/event.py | 31 +++++++++++++ mcp/states/scenario/fake_merchant.py | 0 mcp/states/scenario/hand_select.py | 32 ++++++++++++++ mcp/states/scenario/map.py | 64 +++++++++++++++++++++++++++ mcp/states/scenario/menu.py | 3 ++ mcp/states/scenario/overlay.py | 7 +++ mcp/states/scenario/relic_select.py | 17 +++++++ mcp/states/scenario/rest_site.py | 17 +++++++ mcp/states/scenario/rewards.py | 25 +++++++++++ mcp/states/scenario/shop.py | 55 +++++++++++++++++++++++ mcp/states/scenario/treasure.py | 21 +++++++++ mcp/states/scenario/unknown.py | 3 ++ 27 files changed, 608 insertions(+), 17 deletions(-) create mode 100644 mcp/states/scenario/fake_merchant.py diff --git a/mcp/states/common/card.py b/mcp/states/common/card.py index c80e4af4..5277d3c2 100644 --- a/mcp/states/common/card.py +++ b/mcp/states/common/card.py @@ -16,9 +16,16 @@ class Card(BaseModel): is_upgraded: bool keywords: Keywords + def _star_cost_str(self) -> str: + return f" + {self.star_cost} star" if self.star_cost is not None else "" + + def _keywords_str(self) -> str: + kw_names = list(self.keywords.keywords.keys()) + return f" [{', '.join(kw_names)}]" if kw_names else "" + def to_markdown(self) -> str: """Convert the card to a markdown string.""" - pass + return f"[{self.index}] **{self.name}** ({self.cost} energy{self._star_cost_str()}) [{self.type}]{self._keywords_str()} - {self.description}" class HandCard(Card): @@ -30,7 +37,8 @@ class HandCard(Card): def to_markdown(self) -> str: """Convert the hand card to a markdown string.""" - pass + playable = "\u2713" if self.can_play else "\u2717" + return f"[{self.index}] **{self.name}** ({self.cost} energy{self._star_cost_str()}) [{self.type}] {playable}{self._keywords_str()} - {self.description} (target: {self.target_type})" class RewardCard(Card): @@ -40,4 +48,4 @@ class RewardCard(Card): def to_markdown(self) -> str: """Convert the reward card to a markdown string.""" - pass + return f"[{self.index}] **{self.name}** ({self.cost} energy{self._star_cost_str()}) [{self.type}] {self.rarity}{self._keywords_str()} - {self.description}" diff --git a/mcp/states/common/keyword.py b/mcp/states/common/keyword.py index 972abd36..357f7dfc 100644 --- a/mcp/states/common/keyword.py +++ b/mcp/states/common/keyword.py @@ -23,16 +23,18 @@ def __add__(self, other_keywords: "Keywords") -> "Keywords": for name, keyword in other_keywords.keywords.items(): if name not in combined_keywords: combined_keywords[name] = keyword - return Keywords(keywords=combined_keywords) + return Keywords.model_construct(keywords=combined_keywords) def to_markdown(self) -> str: """Convert the keywords to a markdown string.""" if not self.keywords: return "" - lines = ["## Keyword Glossary"] + lines = [] + lines.append("## Keyword Glossary\n") for keyword in self.keywords.values(): - lines.append(f"- {keyword.to_markdown()}") - return "\n".join(lines) + lines.append(f"- {keyword.to_markdown()}\n") + lines.append("\n") + return "".join(lines) @model_validator(mode="before") @classmethod @@ -42,3 +44,25 @@ def from_keyword_list(cls, keyword_list: list[dict]) -> dict: for keyword in keyword_list: keywords[keyword["name"]] = Keyword.model_validate(keyword) return {"keywords": keywords} + + +def _get_keywords(state, keywords: Keywords) -> Keywords: + """Get the keywords from the state.""" + if isinstance(state, Keywords): + keywords += state + elif isinstance(state, BaseModel): + for value in state.model_dump().values(): + keywords = _get_keywords(value, keywords) + elif isinstance(state, (list, tuple)): + for item in state: + keywords = _get_keywords(item, keywords) + elif isinstance(state, dict): + for item in state.values(): + keywords = _get_keywords(item, keywords) + return keywords + + +def collect_keywords(state: BaseModel) -> Keywords: + """Collect all keywords from the state.""" + keywords = Keywords.model_construct(keywords=dict()) + return _get_keywords(state, keywords) diff --git a/mcp/states/common/potion.py b/mcp/states/common/potion.py index 12b8f3ff..010762a0 100644 --- a/mcp/states/common/potion.py +++ b/mcp/states/common/potion.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, model_validator +from pydantic import BaseModel from states.common.keyword import Keywords @@ -16,4 +16,4 @@ class Potion(BaseModel): def to_markdown(self) -> str: """Convert the potion to a markdown string.""" - pass + return f"[{self.slot}] **{self.name}**: {self.description}" diff --git a/mcp/states/common/relic.py b/mcp/states/common/relic.py index 8031eb81..c9537055 100644 --- a/mcp/states/common/relic.py +++ b/mcp/states/common/relic.py @@ -37,9 +37,14 @@ def __sub__(self, old_relics: "Relics") -> "Relics": """Calculate the difference between two relic states.""" pass - def to_markdown(self, is_diff = False) -> str: + def to_markdown(self) -> str: """Convert the relics to a markdown string.""" - pass + if not self.relics: + return "" + lines = [] + for markdown_key in self.relics: + lines.append(f"- {markdown_key}\n") + return "".join(lines) @model_validator(mode="before") @classmethod diff --git a/mcp/states/common/status_effect.py b/mcp/states/common/status_effect.py index 5043cece..57f75d98 100644 --- a/mcp/states/common/status_effect.py +++ b/mcp/states/common/status_effect.py @@ -17,4 +17,5 @@ class StatusEffect(BaseModel): def to_markdown(self) -> str: """Convert the status effect to a markdown string.""" - pass + amount_str = "indefinite" if self.amount == -1 else str(self.amount) + return f"**{self.name}** ({amount_str}): {self.description}" diff --git a/mcp/states/game.py b/mcp/states/game.py index 88238df7..8a19aeed 100644 --- a/mcp/states/game.py +++ b/mcp/states/game.py @@ -2,8 +2,12 @@ from pydantic import BaseModel, Field, model_validator +from states.common.keyword import collect_keywords from states.player import PlayerState from states.scenario import ScenarioState +from states.scenario.combat import CombatState +from states.scenario.hand_select import HandSelectState +from states.scenario.menu import MenuState class RunState(BaseModel): @@ -31,9 +35,42 @@ class GameState(BaseModel): # scenario-specific fields scenario_state: ScenarioState = Field(discriminator="state_type") - def to_markdown(self, is_diff: bool = False) -> str: + def _is_combat_scenario(self) -> bool: + """Check if the current scenario involves combat (battle state present).""" + return isinstance(self.scenario_state, (CombatState, HandSelectState)) + + def to_markdown(self) -> str: """Convert the game state to a markdown string.""" - pass + lines = [] + + # Header + lines.append(f"# Game State: {self.scenario_state.state_type}\n\n") + + # Run info + if self.run is not None: + lines.append(self.run.to_markdown() + "\n\n") + + # Message short-circuit (MenuState) + if isinstance(self.scenario_state, MenuState): + lines.append(self.scenario_state.to_markdown()) + return "".join(lines) + + # Player state + has_battle = self._is_combat_scenario() + if self.player is not None: + if has_battle: + lines.append(self.player.to_markdown_combat()) + else: + lines.append(self.player.to_markdown_non_combat()) + + # Scenario state + lines.append(self.scenario_state.to_markdown()) + + # Keyword glossary + keywords = collect_keywords(self) + lines.append(keywords.to_markdown()) + + return "".join(lines) @model_validator(mode="before") @classmethod diff --git a/mcp/states/player/combat.py b/mcp/states/player/combat.py index 51c51532..13a91c1e 100644 --- a/mcp/states/player/combat.py +++ b/mcp/states/player/combat.py @@ -24,3 +24,39 @@ class CombatPlayerState(BaseModel): orbs: list[Orb] orb_slots: int orb_empty_slots: int + + def hand_to_markdown(self) -> str: + """Convert the hand to a markdown string.""" + if not self.hand: + return "" + lines = ["### Hand\n"] + for card in self.hand: + lines.append(f"- {card.to_markdown()}\n") + lines.append("\n") + return "".join(lines) + + def piles_to_markdown(self) -> str: + """Convert deck piles to markdown.""" + lines = ["### Deck Information\n\n"] + for pile, count, label, suffix in [ + (self.draw_pile, self.draw_pile_count, "Draw Pile", " in random order"), + (self.discard_pile, self.discard_pile_count, "Discard Pile", ""), + (self.exhaust_pile, self.exhaust_pile_count, "Exhaust Pile", ""), + ]: + comma = "," if suffix else "" + lines.append(f"#### {label} ({count} cards{comma}{suffix})\n") + lines.append(pile.to_markdown()) + lines.append("\n") + return "".join(lines) + + def orbs_to_markdown(self) -> str: + """Convert orbs to markdown.""" + if not self.orbs: + return "" + lines = [f"### Orbs ({len(self.orbs)}/{self.orb_slots} slots)\n"] + for orb in self.orbs: + lines.append(f"- {orb.to_markdown()}\n") + if self.orb_empty_slots > 0: + lines.append(f"- *{self.orb_empty_slots} empty slot(s)*\n") + lines.append("\n") + return "".join(lines) diff --git a/mcp/states/player/orb.py b/mcp/states/player/orb.py index 882ad78e..e9a28026 100644 --- a/mcp/states/player/orb.py +++ b/mcp/states/player/orb.py @@ -12,3 +12,8 @@ class Orb(BaseModel): passive_val: int evoke_val: int keywords: Keywords + + def to_markdown(self) -> str: + """Convert the orb to a markdown string.""" + desc = f" - {self.description}" if self.description else "" + return f"**{self.name}** (passive: {self.passive_val}, evoke: {self.evoke_val}){desc}" diff --git a/mcp/states/player/pile_card.py b/mcp/states/player/pile_card.py index c53dba50..7e669217 100644 --- a/mcp/states/player/pile_card.py +++ b/mcp/states/player/pile_card.py @@ -11,7 +11,8 @@ class PileCard(BaseModel): def to_markdown(self) -> str: """Convert the pile card to a markdown string.""" - pass + star_cost = f" + {self.star_cost} star" if self.star_cost is not None else "" + return f"{self.name} ({self.cost}{star_cost}): {self.description}" class PileCards(BaseModel): @@ -23,9 +24,14 @@ def __sub__(self, old_pile_cards: "PileCards") -> "PileCards": """Calculate the difference between two pile card states.""" pass - def to_markdown(self, is_diff = False) -> str: + def to_markdown(self) -> str: """Convert the pile cards to a markdown string.""" - pass + if not self.pile_cards: + return "- *(empty)*\n" + lines = [] + for markdown_key in self.pile_cards: + lines.append(f"- {markdown_key}\n") + return "".join(lines) @model_validator(mode="before") @classmethod diff --git a/mcp/states/player/player.py b/mcp/states/player/player.py index c473ce10..0ddb96f8 100644 --- a/mcp/states/player/player.py +++ b/mcp/states/player/player.py @@ -23,3 +23,54 @@ class PlayerState(BaseModel): status: list[StatusEffect] relics: Relics potions: list[Potion] + + def _status_to_markdown(self, indent: str = "") -> str: + if not self.status: + return "" + lines = ["### Status\n"] + for s in self.status: + lines.append(f"{indent}- {s.to_markdown()}\n") + lines.append("\n") + return "".join(lines) + + def _relics_to_markdown(self) -> str: + if not self.relics.relics: + return "" + lines = ["### Relics\n"] + lines.append(self.relics.to_markdown()) + lines.append("\n") + return "".join(lines) + + def _potions_to_markdown(self) -> str: + if not self.potions: + return "" + lines = ["### Potions\n"] + for p in self.potions: + lines.append(f"- {p.to_markdown()}\n") + lines.append("\n") + return "".join(lines) + + def to_markdown_non_combat(self) -> str: + """Render player summary for non-combat scenarios (map, event, etc.).""" + lines = ["## Player (You)\n"] + stars = f" | Stars: {self.combat_state.stars}" if self.combat_state and self.combat_state.stars else "" + lines.append(f"**{self.character}** - HP: {self.hp}/{self.max_hp} | Gold: {self.gold}{stars}\n\n") + lines.append(self._relics_to_markdown()) + lines.append(self._potions_to_markdown()) + return "".join(lines) + + def to_markdown_combat(self) -> str: + """Render player details for combat scenarios.""" + lines = ["## Player (You)\n"] + cs = self.combat_state + stars = f" | Stars: {cs.stars}" if cs and cs.stars else "" + energy = f" | Energy: {cs.energy}/{cs.max_energy}" if cs else "" + lines.append(f"**{self.character}** - HP: {self.hp}/{self.max_hp} | Block: {self.block}{energy}{stars} | Gold: {self.gold}\n\n") + lines.append(self._status_to_markdown()) + lines.append(self._relics_to_markdown()) + lines.append(self._potions_to_markdown()) + if cs: + lines.append(cs.hand_to_markdown()) + lines.append(cs.piles_to_markdown()) + lines.append(cs.orbs_to_markdown()) + return "".join(lines) diff --git a/mcp/states/scenario/bundle_select.py b/mcp/states/scenario/bundle_select.py index ff7cb392..1265444c 100644 --- a/mcp/states/scenario/bundle_select.py +++ b/mcp/states/scenario/bundle_select.py @@ -12,6 +12,13 @@ class Bundle(BaseModel): card_count: int cards: list[RewardCard] + def to_markdown(self) -> str: + lines = [f"[{self.index}] Bundle with {self.card_count} card(s)\n"] + for card in self.cards: + star_cost = f" + {card.star_cost} star" if card.star_cost is not None else "" + lines.append(f" {card.name} ({card.cost}{star_cost}) [{card.type}] {card.rarity}\n") + return "".join(lines) + class BundleSelect(BaseModel): """The bundle select object.""" @@ -24,9 +31,36 @@ class BundleSelect(BaseModel): can_confirm: bool can_cancel: bool + def to_markdown(self) -> str: + lines = ["## Bundle Selection\n"] + if self.prompt: + lines.append(f"*{self.prompt}*\n") + lines.append("\n") + + if self.bundles: + lines.append("### Bundles\n") + for bundle in self.bundles: + lines.append(f"- {bundle.to_markdown()}") + lines.append("\n") + + if self.preview_showing: + lines.append("**Preview is showing** - use `confirm_bundle_selection()` to confirm or `cancel_bundle_selection()` to go back.\n") + if self.preview_cards: + lines.append("### Preview Cards\n") + for card in self.preview_cards: + star_cost = f" + {card.star_cost} star" if card.star_cost is not None else "" + lines.append(f"- **{card.name}** ({card.cost} energy{star_cost}) [{card.type}] {card.rarity} - {card.description}\n") + lines.append("\n") + else: + lines.append(f"Use `select_bundle(index)` to open a bundle preview. Can confirm: {'Yes' if self.can_confirm else 'No'} | Can cancel: {'Yes' if self.can_cancel else 'No'}\n\n") + return "".join(lines) + class BundleSelectState(BaseModel): """The state when the scenario is in the bundle selection screen.""" state_type: Literal["bundle_select"] bundle_select: BundleSelect + + def to_markdown(self) -> str: + return self.bundle_select.to_markdown() diff --git a/mcp/states/scenario/card_reward.py b/mcp/states/scenario/card_reward.py index 2fda765c..5f142230 100644 --- a/mcp/states/scenario/card_reward.py +++ b/mcp/states/scenario/card_reward.py @@ -11,9 +11,22 @@ class CardReward(BaseModel): cards: list[RewardCard] can_skip: bool + def to_markdown(self) -> str: + lines = ["## Card Reward Selection\n"] + lines.append("Choose a card to add to your deck:\n\n") + if self.cards: + for card in self.cards: + lines.append(f"- {card.to_markdown()}\n") + lines.append("\n") + lines.append(f"**Can skip:** {'Yes' if self.can_skip else 'No'}\n\n") + return "".join(lines) + class CardRewardState(BaseModel): """The state when the scenario is card reward selection.""" state_type: Literal["card_reward"] card_reward: CardReward + + def to_markdown(self) -> str: + return self.card_reward.to_markdown() diff --git a/mcp/states/scenario/card_select.py b/mcp/states/scenario/card_select.py index 54039011..0fade7b3 100644 --- a/mcp/states/scenario/card_select.py +++ b/mcp/states/scenario/card_select.py @@ -4,6 +4,13 @@ from states.common.card import RewardCard +_SCREEN_LABELS = { + "transform": "Transform", + "upgrade": "Upgrade", + "select": "Select", + "simple_select": "Select", +} + class CardSelect(BaseModel): """The card select object.""" @@ -15,9 +22,32 @@ class CardSelect(BaseModel): can_confirm: bool can_cancel: bool + def to_markdown(self) -> str: + screen_label = _SCREEN_LABELS.get(self.screen_type, self.screen_type) + lines = [f"## Card Selection: {screen_label}\n"] + if self.prompt: + lines.append(f"*{self.prompt}*\n") + lines.append("\n") + + if self.cards: + lines.append("### Cards\n") + for card in self.cards: + lines.append(f"- {card.to_markdown()}\n") + lines.append("\n") + + if self.preview_showing: + lines.append("**Preview is showing** - use `confirm_selection` to confirm or `cancel_selection` to go back.\n") + else: + lines.append(f"**Select cards** using `select_card(index)`. Can confirm: {'Yes' if self.can_confirm else 'No'} | Can cancel: {'Yes' if self.can_cancel else 'No'}\n") + lines.append("\n") + return "".join(lines) + class CardSelectState(BaseModel): """The state when the scenario is in the card selection screen.""" state_type: Literal["card_select"] card_select: CardSelect + + def to_markdown(self) -> str: + return self.card_select.to_markdown() diff --git a/mcp/states/scenario/combat.py b/mcp/states/scenario/combat.py index 45f865a0..745804fd 100644 --- a/mcp/states/scenario/combat.py +++ b/mcp/states/scenario/combat.py @@ -13,6 +13,13 @@ class Intent(BaseModel): title: str description: str + def to_markdown(self) -> str: + title = self.title if self.title else self.type + type_tag = f" ({self.type})" + label = f" {self.label}" if self.label else "" + desc = f" - {self.description}" if self.description else "" + return f"{title}{type_tag}{label}{desc}" + class Enemy(BaseModel): """The enemy object.""" @@ -26,6 +33,19 @@ class Enemy(BaseModel): status: list[StatusEffect] intents: list[Intent] + def to_markdown(self) -> str: + lines = [f"### {self.name} (`{self.entity_id}`)\n"] + lines.append(f"HP: {self.hp}/{self.max_hp} | Block: {self.block}\n") + if self.intents: + intent_strs = [i.to_markdown() for i in self.intents] + lines.append(f"**Intent:** {', '.join(intent_strs)}\n") + if self.status: + lines.append("### Status\n") + for s in self.status: + lines.append(f" - {s.to_markdown()}\n") + lines.append("\n") + return "".join(lines) + class BattleState(BaseModel): """The state of the battle.""" @@ -35,6 +55,14 @@ class BattleState(BaseModel): is_play_phase: bool enemies: list[Enemy] + def to_markdown(self) -> str: + lines = [f"**Round {self.round}** | Turn: {self.turn} | Play Phase: {self.is_play_phase}\n\n"] + if self.enemies: + lines.append("## Enemies\n") + for enemy in self.enemies: + lines.append(enemy.to_markdown()) + return "".join(lines) + class CombatState(BaseModel): """The state when the scenario is in the combat (monster or elite or boss).""" @@ -42,3 +70,10 @@ class CombatState(BaseModel): state_type: Literal["monster", "elite", "boss"] message: str | None = None battle: BattleState | None = None + + def to_markdown(self) -> str: + if self.message is not None: + return self.message + "\n" + if self.battle is not None: + return self.battle.to_markdown() + return "" diff --git a/mcp/states/scenario/crystal_sphere.py b/mcp/states/scenario/crystal_sphere.py index 32220445..caba81b9 100644 --- a/mcp/states/scenario/crystal_sphere.py +++ b/mcp/states/scenario/crystal_sphere.py @@ -30,6 +30,9 @@ class RevealedItem(Coordinate): height: int is_good: bool + def to_markdown(self) -> str: + return f"**{self.item_type}** at ({self.x}, {self.y}) size {self.width}x{self.height}" + class CrystalSphere(BaseModel): """The crystal sphere event state.""" @@ -47,9 +50,37 @@ class CrystalSphere(BaseModel): divinations_left_text: str can_proceed: bool + def to_markdown(self) -> str: + lines = ["## Crystal Sphere\n"] + lines.append(f"**{self.instructions_title}**\n") + lines.append(f"{self.instructions_description}\n\n") + lines.append(f"**Tool:** {self.tool} | **Divinations:** {self.divinations_left_text}\n\n") + + if self.clickable_cells: + lines.append("### Clickable Cells\n") + for cell in self.clickable_cells: + lines.append(f"- ({cell.x}, {cell.y})\n") + lines.append("\n") + + if self.revealed_items: + lines.append("### Revealed Items\n") + for item in self.revealed_items: + lines.append(f"- {item.to_markdown()}\n") + lines.append("\n") + + if self.can_proceed: + lines.append("Use `crystal_sphere_proceed()` to continue.\n") + else: + lines.append("Use `crystal_sphere_set_tool(tool)` with `big` or `small`, then `crystal_sphere_click_cell(x, y)`.\n") + lines.append("\n") + return "".join(lines) + class CrystalSphereState(BaseModel): """The state when the scenario is in the crystal sphere event.""" state_type: Literal["crystal_sphere"] crystal_sphere: CrystalSphere + + def to_markdown(self) -> str: + return self.crystal_sphere.to_markdown() diff --git a/mcp/states/scenario/event.py b/mcp/states/scenario/event.py index 9f87e09b..4dcaacdd 100644 --- a/mcp/states/scenario/event.py +++ b/mcp/states/scenario/event.py @@ -18,6 +18,17 @@ class EventOption(BaseModel): relic_description: str | None = None # only if option has a relic keywords: Keywords + def to_markdown(self) -> str: + tag = "" + if self.is_locked: + tag = " (LOCKED)" + elif self.was_chosen: + tag = " (CHOSEN)" + elif self.is_proceed: + tag = " (PROCEED)" + relic = f" [Relic: {self.relic_name}]" if self.relic_name is not None else "" + return f"[{self.index}] **{self.title}**{tag}{relic} - {self.description}" + class Event(BaseModel): """The event object.""" @@ -29,9 +40,29 @@ class Event(BaseModel): body: str | None options: list[EventOption] + def to_markdown(self) -> str: + label = "Ancient" if self.is_ancient else "Event" + lines = [f"## {label}: {self.event_name}\n\n"] + + if self.in_dialogue: + lines.append("*Ancient dialogue in progress - use `advance_dialogue` to continue.*\n\n") + return "".join(lines) + + if self.options: + lines.append("### Options\n") + for opt in self.options: + lines.append(f"- {opt.to_markdown()}\n") + lines.append("\n") + else: + lines.append("No options available.\n\n") + return "".join(lines) + class EventState(BaseModel): """The state when the scenario is in an event.""" state_type: Literal["event"] event: Event + + def to_markdown(self) -> str: + return self.event.to_markdown() diff --git a/mcp/states/scenario/fake_merchant.py b/mcp/states/scenario/fake_merchant.py new file mode 100644 index 00000000..e69de29b diff --git a/mcp/states/scenario/hand_select.py b/mcp/states/scenario/hand_select.py index 8c01a241..fa0136be 100644 --- a/mcp/states/scenario/hand_select.py +++ b/mcp/states/scenario/hand_select.py @@ -22,6 +22,31 @@ class HandSelect(BaseModel): selected_cards: list[SelectedCard] | None = None # only present if cards has been selected can_confirm: bool + def to_markdown(self) -> str: + lines = ["## In-Combat Card Selection\n"] + lines.append(f"*{self.prompt}*\n") + + if self.mode == "upgrade_select": + lines.append("**Mode:** Upgrade selection\n") + lines.append("\n") + + if self.cards: + lines.append("### Selectable Cards\n") + for card in self.cards: + star_cost = f" + {card.star_cost} star" if card.star_cost is not None else "" + lines.append(f"- [{card.index}] **{card.name}** ({card.cost} energy{star_cost}) [{card.type}] - {card.description}\n") + lines.append("\n") + + if self.selected_cards: + lines.append("### Already Selected\n") + for card in self.selected_cards: + lines.append(f"- {card.name}\n") + lines.append("\n") + + can_confirm_str = "Yes - use `combat_confirm_selection`" if self.can_confirm else "No - select more cards" + lines.append(f"Use `combat_select_card(card_index)` to select. Can confirm: {can_confirm_str}\n\n") + return "".join(lines) + class HandSelectState(CombatState): """The state when the scenario is in-combat card selection. @@ -31,3 +56,10 @@ class HandSelectState(CombatState): state_type: Literal["hand_select"] hand_select: HandSelect + + def to_markdown(self) -> str: + lines = [] + # Render battle context first (from parent CombatState) + lines.append(super().to_markdown()) + lines.append(self.hand_select.to_markdown()) + return "".join(lines) diff --git a/mcp/states/scenario/map.py b/mcp/states/scenario/map.py index b314cf19..82c02077 100644 --- a/mcp/states/scenario/map.py +++ b/mcp/states/scenario/map.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Literal from pydantic import BaseModel @@ -19,6 +21,7 @@ class NodeWithType(Node): class NextNode(NodeWithType): """The next node on the map with 1-level lookahead.""" + index: int leads_to: list[NodeWithType] @@ -37,9 +40,70 @@ class Map(BaseModel): nodes: list[DAGNode] boss: Node + def _build_future_path_tree(self, start_node: NextNode, node_lookup: dict[str, DAGNode]) -> str: + """BFS from a node through its children to build a future path tree string.""" + start_key = f"{start_node.col},{start_node.row}" + canonical = node_lookup.get(start_key) + current_keys: set[str] = set() + if canonical: + current_keys = {f"{c},{r}" for c, r in canonical.children} + + parts: list[str] = [] + while current_keys: + level_nodes: list[tuple[str, int, int]] = [] + next_keys: set[str] = set() + + for key in sorted(current_keys): + node = node_lookup.get(key) + if node: + level_nodes.append((node.type, node.col, node.row)) + for c, r in node.children: + next_keys.add(f"{c},{r}") + + if not level_nodes: + break + + level_str = " or ".join(f"{t} ({c},{r})" for t, c, r in level_nodes) + parts.append(f"-> {level_str}") + current_keys = next_keys + + return " ".join(parts) + + def to_markdown(self) -> str: + lines: list[str] = [] + + # Path taken + if self.visited: + lines.append("## Path Taken\n") + parts = [f"{i + 1}. {v.type} ({v.col},{v.row})" for i, v in enumerate(self.visited)] + lines.append(" -> ".join(parts) + " <- current\n\n") + + # Build node lookup + node_lookup: dict[str, DAGNode] = {} + for node in self.nodes: + node_lookup[f"{node.col},{node.row}"] = node + + # Next options + if self.next_options: + lines.append("## Choose Next Node\n") + for opt in self.next_options: + lines.append(f"- [{opt.index}] **{opt.type}** ({opt.col},{opt.row})\n") + tree = self._build_future_path_tree(opt, node_lookup) + if tree: + lines.append(f" Future paths: {tree}\n") + lines.append("\n") + else: + lines.append("## Map\n") + lines.append("No travelable nodes available.\n\n") + + return "".join(lines) + class MapState(BaseModel): """The state when the scenario is on the map.""" state_type: Literal["map"] map: Map + + def to_markdown(self) -> str: + return self.map.to_markdown() diff --git a/mcp/states/scenario/menu.py b/mcp/states/scenario/menu.py index d3f08203..460044c1 100644 --- a/mcp/states/scenario/menu.py +++ b/mcp/states/scenario/menu.py @@ -8,3 +8,6 @@ class MenuState(BaseModel): state_type: Literal["menu"] message: str + + def to_markdown(self) -> str: + return self.message + "\n" diff --git a/mcp/states/scenario/overlay.py b/mcp/states/scenario/overlay.py index 5a40097a..4f4f0378 100644 --- a/mcp/states/scenario/overlay.py +++ b/mcp/states/scenario/overlay.py @@ -15,3 +15,10 @@ class OverlayState(BaseModel): state_type: Literal["overlay"] = "overlay" overlay: Overlay + + def to_markdown(self) -> str: + """Convert the overlay state to a markdown string.""" + lines = [] + lines.append(f"## Overlay: {self.overlay.screen_type}\n") + lines.append(self.overlay.message + "\n\n") + return "".join(lines) diff --git a/mcp/states/scenario/relic_select.py b/mcp/states/scenario/relic_select.py index a2420eb4..0d1bc709 100644 --- a/mcp/states/scenario/relic_select.py +++ b/mcp/states/scenario/relic_select.py @@ -12,9 +12,26 @@ class RelicSelect(BaseModel): relics: list[RewardRelic] can_skip: bool + def to_markdown(self) -> str: + lines = ["## Relic Selection\n"] + if self.prompt: + lines.append(f"*{self.prompt}*\n") + lines.append("\n") + + if self.relics: + for relic in self.relics: + lines.append(f"- [{relic.index}] {relic.to_markdown()}\n") + lines.append("\n") + + lines.append(f"Use `select_relic(index)` to choose. Can skip: {'Yes' if self.can_skip else 'No'}\n\n") + return "".join(lines) + class RelicSelectState(BaseModel): """The state when the scenario is in the relic selection screen.""" state_type: Literal["relic_select"] relic_select: RelicSelect + + def to_markdown(self) -> str: + return self.relic_select.to_markdown() diff --git a/mcp/states/scenario/rest_site.py b/mcp/states/scenario/rest_site.py index 7dbec404..90b0d446 100644 --- a/mcp/states/scenario/rest_site.py +++ b/mcp/states/scenario/rest_site.py @@ -12,6 +12,10 @@ class RestSiteOption(BaseModel): description: str is_enabled: bool + def to_markdown(self) -> str: + enabled = "" if self.is_enabled else " (DISABLED)" + return f"[{self.index}] **{self.name}**{enabled} - {self.description}" + class RestSite(BaseModel): """The rest site object.""" @@ -19,9 +23,22 @@ class RestSite(BaseModel): options: list[RestSiteOption] can_proceed: bool + def to_markdown(self) -> str: + lines = [] + if self.options: + lines.append("## Rest Site Options\n") + for opt in self.options: + lines.append(f"- {opt.to_markdown()}\n") + lines.append("\n") + lines.append(f"**Can proceed:** {'Yes' if self.can_proceed else 'No'}\n\n") + return "".join(lines) + class RestSiteState(BaseModel): """The state when the scenario is at the rest site.""" state_type: Literal["rest_site"] rest_site: RestSite + + def to_markdown(self) -> str: + return self.rest_site.to_markdown() diff --git a/mcp/states/scenario/rewards.py b/mcp/states/scenario/rewards.py index 9721e217..b86eafde 100644 --- a/mcp/states/scenario/rewards.py +++ b/mcp/states/scenario/rewards.py @@ -14,6 +14,18 @@ class RewardItem(BaseModel): # therefore we allow extra fields for different types of reward items model_config = ConfigDict(extra="allow") + def to_markdown(self) -> str: + extra = "" + # Access extra fields via model_extra + extras = self.model_extra or {} + if "gold_amount" in extras and extras["gold_amount"] is not None: + extra = f" ({extras['gold_amount']} gold)" + elif "potion_description" in extras and extras["potion_description"] is not None: + extra = f" - {extras['potion_description']}" + elif "potion_name" in extras and extras["potion_name"] is not None: + extra = f" ({extras['potion_name']})" + return f"[{self.index}] **{self.type}**: {self.description}{extra}" + class Rewards(BaseModel): """The rewards after a combat or event.""" @@ -21,9 +33,22 @@ class Rewards(BaseModel): items: list[RewardItem] can_proceed: bool + def to_markdown(self) -> str: + lines = ["## Rewards\n"] + if self.items: + for item in self.items: + lines.append(f"- {item.to_markdown()}\n") + else: + lines.append("No rewards available.\n") + lines.append(f"\n**Can proceed:** {'Yes' if self.can_proceed else 'No'}\n\n") + return "".join(lines) + class RewardsState(BaseModel): """The state when the scenario is in the rewards screen after a combat or event.""" state_type: Literal["rewards"] rewards: Rewards + + def to_markdown(self) -> str: + return self.rewards.to_markdown() diff --git a/mcp/states/scenario/shop.py b/mcp/states/scenario/shop.py index 19870af3..cea44cc1 100644 --- a/mcp/states/scenario/shop.py +++ b/mcp/states/scenario/shop.py @@ -13,6 +13,12 @@ class BaseShopItem(BaseModel): is_stocked: bool can_afford: bool + def _cost_tag(self) -> str: + return f"{self.cost}g" if self.is_stocked else "SOLD" + + def _afford_tag(self) -> str: + return " (can't afford)" if self.is_stocked and not self.can_afford else "" + class ShopCard(BaseShopItem): """The card in the shop.""" @@ -28,6 +34,12 @@ class ShopCard(BaseShopItem): card_description: str | None = None keywords: Keywords | None = None + def to_markdown(self) -> str: + star_cost = f" ({self.card_star_cost} star)" if self.card_star_cost is not None else "" + desc = f"**{self.card_name}** [{self.card_type}]{star_cost} {self.card_rarity} - {self.card_description}" + sale = " **SALE**" if self.on_sale else "" + return f"[{self.index}] {desc} - {self._cost_tag()}{sale}{self._afford_tag()}" + class ShopRelic(BaseShopItem): """The relic in the shop.""" @@ -38,6 +50,10 @@ class ShopRelic(BaseShopItem): relic_description: str | None = None keywords: Keywords | None = None + def to_markdown(self) -> str: + desc = f"**{self.relic_name}** - {self.relic_description}" + return f"[{self.index}] {desc} - {self._cost_tag()}{self._afford_tag()}" + class ShopPotion(BaseShopItem): """The potion in the shop.""" @@ -48,15 +64,30 @@ class ShopPotion(BaseShopItem): potion_description: str | None = None keywords: Keywords | None = None + def to_markdown(self) -> str: + desc = f"**{self.potion_name}** - {self.potion_description}" + return f"[{self.index}] {desc} - {self._cost_tag()}{self._afford_tag()}" + class ShopCardRemoval(BaseShopItem): """The card removal option in the shop.""" category: Literal["card_removal"] + def to_markdown(self) -> str: + desc = "**Remove a card** from your deck" + return f"[{self.index}] {desc} - {self._cost_tag()}{self._afford_tag()}" + ShopItem: TypeAlias = ShopCard | ShopRelic | ShopPotion | ShopCardRemoval +_CATEGORY_HEADERS = { + "card": "Cards", + "relic": "Relics", + "potion": "Potions", + "card_removal": "Services", +} + class Shop(BaseModel): """The state of the shop.""" @@ -65,9 +96,33 @@ class Shop(BaseModel): can_proceed: bool error: str | None = None # only present if inventory isn't ready; retry in a moment + def to_markdown(self) -> str: + lines = [] + if self.error is not None: + lines.append("## Shop\n") + lines.append(f"**Note:** {self.error}\n\n") + + if self.items: + lines.append("## Shop Inventory\n") + last_category = None + for item in self.items: + category = item.category + if category != last_category: + header = _CATEGORY_HEADERS.get(category, category) + lines.append(f"### {header}\n") + last_category = category + lines.append(f"- {item.to_markdown()}\n") + lines.append("\n") + + lines.append(f"**Can proceed:** {'Yes' if self.can_proceed else 'No'}\n\n") + return "".join(lines) + class ShopState(BaseModel): """The state when the scenario is in the shop.""" state_type: Literal["shop"] shop: Shop + + def to_markdown(self) -> str: + return self.shop.to_markdown() diff --git a/mcp/states/scenario/treasure.py b/mcp/states/scenario/treasure.py index c0f4ff3e..7dd9fe47 100644 --- a/mcp/states/scenario/treasure.py +++ b/mcp/states/scenario/treasure.py @@ -12,9 +12,30 @@ class Treasure(BaseModel): relics: list[RewardRelic] can_proceed: bool + def to_markdown(self) -> str: + lines = [] + if self.relics: + lines.append("## Treasure Relics\n") + for relic in self.relics: + rarity = f" ({relic.rarity})" if relic.rarity else "" + lines.append(f"- [{relic.index}] **{relic.name}**{rarity} - {relic.description}\n") + lines.append("\n") + lines.append("Use `treasure_claim_relic(relic_index)` to claim a relic.\n") + else: + lines.append("Chest is opening...\n") + lines.append("\n") + + if self.can_proceed: + lines.append("**Can proceed:** Yes\n") + lines.append("\n") + return "".join(lines) + class TreasureState(BaseModel): """The state when the scenario is in the treasure room.""" state_type: Literal["treasure"] treasure: Treasure + + def to_markdown(self) -> str: + return self.treasure.to_markdown() diff --git a/mcp/states/scenario/unknown.py b/mcp/states/scenario/unknown.py index 582ff7f6..31cff372 100644 --- a/mcp/states/scenario/unknown.py +++ b/mcp/states/scenario/unknown.py @@ -10,3 +10,6 @@ class UnknownState(BaseModel): # As the scenario is unknown, we allow any extra fields to be stored in the model. model_config = ConfigDict(extra="allow") + + def to_markdown(self) -> str: + return "" From f0fceb639c58ee67bdc219f8c2ffde6d81b739bc Mon Sep 17 00:00:00 2001 From: fanqiNO1 <1848839264@qq.com> Date: Sat, 4 Apr 2026 21:17:25 +0800 Subject: [PATCH 5/7] refactor [4/N] fix to_markdown --- mcp/states/common/keyword.py | 4 ++-- mcp/states/player/combat.py | 6 +++--- mcp/states/player/pile_card.py | 2 +- mcp/states/player/player.py | 14 ++++++++++++++ 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/mcp/states/common/keyword.py b/mcp/states/common/keyword.py index 357f7dfc..ee553cf1 100644 --- a/mcp/states/common/keyword.py +++ b/mcp/states/common/keyword.py @@ -33,7 +33,6 @@ def to_markdown(self) -> str: lines.append("## Keyword Glossary\n") for keyword in self.keywords.values(): lines.append(f"- {keyword.to_markdown()}\n") - lines.append("\n") return "".join(lines) @model_validator(mode="before") @@ -51,7 +50,8 @@ def _get_keywords(state, keywords: Keywords) -> Keywords: if isinstance(state, Keywords): keywords += state elif isinstance(state, BaseModel): - for value in state.model_dump().values(): + # iterate field values directly to preserve pydantic types + for _field_name, value in state: keywords = _get_keywords(value, keywords) elif isinstance(state, (list, tuple)): for item in state: diff --git a/mcp/states/player/combat.py b/mcp/states/player/combat.py index 13a91c1e..87c30469 100644 --- a/mcp/states/player/combat.py +++ b/mcp/states/player/combat.py @@ -21,9 +21,9 @@ class CombatPlayerState(BaseModel): discard_pile: PileCards exhaust_pile: PileCards # orbs - orbs: list[Orb] - orb_slots: int - orb_empty_slots: int + orbs: list[Orb] | None = None + orb_slots: int | None = None + orb_empty_slots: int | None = None def hand_to_markdown(self) -> str: """Convert the hand to a markdown string.""" diff --git a/mcp/states/player/pile_card.py b/mcp/states/player/pile_card.py index 7e669217..f31134db 100644 --- a/mcp/states/player/pile_card.py +++ b/mcp/states/player/pile_card.py @@ -6,7 +6,7 @@ class PileCard(BaseModel): name: str description: str - cost: str + cost: str | None = None star_cost: str | None = None def to_markdown(self) -> str: diff --git a/mcp/states/player/player.py b/mcp/states/player/player.py index 0ddb96f8..8dadf32f 100644 --- a/mcp/states/player/player.py +++ b/mcp/states/player/player.py @@ -74,3 +74,17 @@ def to_markdown_combat(self) -> str: lines.append(cs.piles_to_markdown()) lines.append(cs.orbs_to_markdown()) return "".join(lines) + + @model_validator(mode="before") + @classmethod + def from_json_state(cls, json_state: dict) -> dict: + player_state = dict() + combat_state = dict() + + for key, value in json_state.items(): + if key in ["character", "hp", "max_hp", "block", "gold", "status", "relics", "potions"]: + player_state[key] = value + else: + combat_state[key] = value + player_state["combat_state"] = combat_state if combat_state else None + return player_state From b2b39fd0f72451b1458923626441fd04e14bf7f9 Mon Sep 17 00:00:00 2001 From: fanqiNO1 <1848839264@qq.com> Date: Sun, 5 Apr 2026 15:41:45 +0800 Subject: [PATCH 6/7] refactor [5/N] remove to_markdown to prepare diff state and enhance robustness --- mcp/states/common/card.py | 19 ------- mcp/states/common/keyword.py | 14 ------ mcp/states/common/multiplayer.py | 50 +++++++++++++++++++ mcp/states/common/potion.py | 3 -- mcp/states/common/relic.py | 17 ------- mcp/states/common/status_effect.py | 4 -- mcp/states/game.py | 49 +++--------------- mcp/states/player/combat.py | 37 +------------- mcp/states/player/orb.py | 5 -- mcp/states/player/pile_card.py | 14 ------ mcp/states/player/player.py | 65 ------------------------ mcp/states/scenario/bundle_select.py | 34 ------------- mcp/states/scenario/card_reward.py | 13 ----- mcp/states/scenario/card_select.py | 30 ----------- mcp/states/scenario/combat.py | 40 ++------------- mcp/states/scenario/crystal_sphere.py | 31 ------------ mcp/states/scenario/event.py | 35 ++----------- mcp/states/scenario/hand_select.py | 32 ------------ mcp/states/scenario/map.py | 71 +++------------------------ mcp/states/scenario/menu.py | 3 -- mcp/states/scenario/overlay.py | 7 --- mcp/states/scenario/relic_select.py | 17 ------- mcp/states/scenario/rest_site.py | 17 ------- mcp/states/scenario/rewards.py | 25 ---------- mcp/states/scenario/shop.py | 57 +-------------------- mcp/states/scenario/treasure.py | 33 ++++--------- mcp/states/scenario/unknown.py | 3 -- 27 files changed, 85 insertions(+), 640 deletions(-) create mode 100644 mcp/states/common/multiplayer.py diff --git a/mcp/states/common/card.py b/mcp/states/common/card.py index 5277d3c2..71b8beb8 100644 --- a/mcp/states/common/card.py +++ b/mcp/states/common/card.py @@ -16,17 +16,6 @@ class Card(BaseModel): is_upgraded: bool keywords: Keywords - def _star_cost_str(self) -> str: - return f" + {self.star_cost} star" if self.star_cost is not None else "" - - def _keywords_str(self) -> str: - kw_names = list(self.keywords.keywords.keys()) - return f" [{', '.join(kw_names)}]" if kw_names else "" - - def to_markdown(self) -> str: - """Convert the card to a markdown string.""" - return f"[{self.index}] **{self.name}** ({self.cost} energy{self._star_cost_str()}) [{self.type}]{self._keywords_str()} - {self.description}" - class HandCard(Card): """The card in the player's hand.""" @@ -35,17 +24,9 @@ class HandCard(Card): can_play: bool unplayable_reason: str | None = None - def to_markdown(self) -> str: - """Convert the hand card to a markdown string.""" - playable = "\u2713" if self.can_play else "\u2717" - return f"[{self.index}] **{self.name}** ({self.cost} energy{self._star_cost_str()}) [{self.type}] {playable}{self._keywords_str()} - {self.description} (target: {self.target_type})" - class RewardCard(Card): """The card in the reward screen.""" rarity: str - def to_markdown(self) -> str: - """Convert the reward card to a markdown string.""" - return f"[{self.index}] **{self.name}** ({self.cost} energy{self._star_cost_str()}) [{self.type}] {self.rarity}{self._keywords_str()} - {self.description}" diff --git a/mcp/states/common/keyword.py b/mcp/states/common/keyword.py index ee553cf1..04a8c65d 100644 --- a/mcp/states/common/keyword.py +++ b/mcp/states/common/keyword.py @@ -7,10 +7,6 @@ class Keyword(BaseModel): name: str description: str - def to_markdown(self): - """Convert the keyword to a markdown string.""" - return f"**{self.name}**: {self.description}" - class Keywords(BaseModel): """The keywords object, which is a collection of keywords.""" @@ -24,16 +20,6 @@ def __add__(self, other_keywords: "Keywords") -> "Keywords": if name not in combined_keywords: combined_keywords[name] = keyword return Keywords.model_construct(keywords=combined_keywords) - - def to_markdown(self) -> str: - """Convert the keywords to a markdown string.""" - if not self.keywords: - return "" - lines = [] - lines.append("## Keyword Glossary\n") - for keyword in self.keywords.values(): - lines.append(f"- {keyword.to_markdown()}\n") - return "".join(lines) @model_validator(mode="before") @classmethod diff --git a/mcp/states/common/multiplayer.py b/mcp/states/common/multiplayer.py new file mode 100644 index 00000000..bfde7a0f --- /dev/null +++ b/mcp/states/common/multiplayer.py @@ -0,0 +1,50 @@ +from pydantic import BaseModel + + +class PlayerSummary(BaseModel): + """The summary of a player in multiplayer mode.""" + + character: str + hp: int + max_hp: int + gold: int + is_local: bool + is_alive: bool + is_ready_to_end_turn: bool | None = None + + +class MultiplayerState(BaseModel): + """The state of the multiplayer game.""" + + net_type: str + player_count: int + local_player_slot: int + players: list[PlayerSummary] + + +class BaseVote(BaseModel): + """The base class for a vote in multiplayer mode.""" + + player: str + is_local: bool + voted: bool + + +class MapVote(BaseVote): + """A vote for the next map in multiplayer mode.""" + + vote_col: int | None = None + vote_row: int | None = None + + +class EventVote(BaseVote): + """A vote for the next event in multiplayer mode.""" + + vote_option_index: int | None = None + + +class TreasureVote(BaseVote): + """A vote for the next treasure in multiplayer mode.""" + + vote_relic_index: int | None = None + diff --git a/mcp/states/common/potion.py b/mcp/states/common/potion.py index 010762a0..e8276ccd 100644 --- a/mcp/states/common/potion.py +++ b/mcp/states/common/potion.py @@ -14,6 +14,3 @@ class Potion(BaseModel): target_type: str keywords: Keywords - def to_markdown(self) -> str: - """Convert the potion to a markdown string.""" - return f"[{self.slot}] **{self.name}**: {self.description}" diff --git a/mcp/states/common/relic.py b/mcp/states/common/relic.py index c9537055..5ff9e4ae 100644 --- a/mcp/states/common/relic.py +++ b/mcp/states/common/relic.py @@ -12,11 +12,6 @@ class Relic(BaseModel): counter: int | None = None # number if relic shows a counter, null otherwise keywords: Keywords - def to_markdown(self) -> str: - """Convert the relic to a markdown string.""" - counter = f" [{self.counter}]" if self.counter is not None else "" - return f"**{self.name}**{counter}: {self.description}" - class RewardRelic(Relic): """The relic in the reward screen.""" @@ -24,9 +19,6 @@ class RewardRelic(Relic): index: int rarity: str - def to_markdown(self): - return super().to_markdown() - class Relics(BaseModel): """The state of the player's relics.""" @@ -37,15 +29,6 @@ def __sub__(self, old_relics: "Relics") -> "Relics": """Calculate the difference between two relic states.""" pass - def to_markdown(self) -> str: - """Convert the relics to a markdown string.""" - if not self.relics: - return "" - lines = [] - for markdown_key in self.relics: - lines.append(f"- {markdown_key}\n") - return "".join(lines) - @model_validator(mode="before") @classmethod def from_relic_list(cls, relic_list: list[dict]) -> dict: diff --git a/mcp/states/common/status_effect.py b/mcp/states/common/status_effect.py index 57f75d98..30b14dc8 100644 --- a/mcp/states/common/status_effect.py +++ b/mcp/states/common/status_effect.py @@ -15,7 +15,3 @@ class StatusEffect(BaseModel): description: str keywords: Keywords - def to_markdown(self) -> str: - """Convert the status effect to a markdown string.""" - amount_str = "indefinite" if self.amount == -1 else str(self.amount) - return f"**{self.name}** ({amount_str}): {self.description}" diff --git a/mcp/states/game.py b/mcp/states/game.py index 8a19aeed..aa5270a9 100644 --- a/mcp/states/game.py +++ b/mcp/states/game.py @@ -3,6 +3,7 @@ from pydantic import BaseModel, Field, model_validator from states.common.keyword import collect_keywords +from states.common.multiplayer import MultiplayerState from states.player import PlayerState from states.scenario import ScenarioState from states.scenario.combat import CombatState @@ -17,10 +18,6 @@ class RunState(BaseModel): floor: int ascension: int - def to_markdown(self) -> str: - """Convert the run state to a markdown string.""" - return f"**Act {self.act}** | Floor {self.floor} | Ascension {self.ascension}" - class GameState(BaseModel): """The state of the game.""" @@ -28,6 +25,9 @@ class GameState(BaseModel): # gamemode game_mode: Literal["singleplayer", "multiplayer"] + # multiplayer fields + multiplayer_state: MultiplayerState | None = None + # common fields run: RunState | None = None player: PlayerState | None = None @@ -35,48 +35,12 @@ class GameState(BaseModel): # scenario-specific fields scenario_state: ScenarioState = Field(discriminator="state_type") - def _is_combat_scenario(self) -> bool: - """Check if the current scenario involves combat (battle state present).""" - return isinstance(self.scenario_state, (CombatState, HandSelectState)) - - def to_markdown(self) -> str: - """Convert the game state to a markdown string.""" - lines = [] - - # Header - lines.append(f"# Game State: {self.scenario_state.state_type}\n\n") - - # Run info - if self.run is not None: - lines.append(self.run.to_markdown() + "\n\n") - - # Message short-circuit (MenuState) - if isinstance(self.scenario_state, MenuState): - lines.append(self.scenario_state.to_markdown()) - return "".join(lines) - - # Player state - has_battle = self._is_combat_scenario() - if self.player is not None: - if has_battle: - lines.append(self.player.to_markdown_combat()) - else: - lines.append(self.player.to_markdown_non_combat()) - - # Scenario state - lines.append(self.scenario_state.to_markdown()) - - # Keyword glossary - keywords = collect_keywords(self) - lines.append(keywords.to_markdown()) - - return "".join(lines) - @model_validator(mode="before") @classmethod def from_json_state(cls, json_state: dict) -> dict: """Create a GameState instance from a JSON state.""" game_state = dict() + multiplayer_state = dict() scenario_state = dict() # get game mode @@ -87,7 +51,10 @@ def from_json_state(cls, json_state: dict) -> dict: for key in json_state: if key in ["run", "player"]: game_state[key] = json_state[key] + elif key in ["net_type", "player_count", "local_player_slot", "players"]: + multiplayer_state[key] = json_state[key] else: scenario_state[key] = json_state[key] game_state["scenario_state"] = scenario_state + game_state["multiplayer_state"] = multiplayer_state if multiplayer_state else None return game_state diff --git a/mcp/states/player/combat.py b/mcp/states/player/combat.py index 87c30469..d4c682b9 100644 --- a/mcp/states/player/combat.py +++ b/mcp/states/player/combat.py @@ -10,7 +10,7 @@ class CombatPlayerState(BaseModel): energy: int max_energy: int - stars: int + stars: int | None = None # cards hand: list[HandCard] # piles @@ -25,38 +25,3 @@ class CombatPlayerState(BaseModel): orb_slots: int | None = None orb_empty_slots: int | None = None - def hand_to_markdown(self) -> str: - """Convert the hand to a markdown string.""" - if not self.hand: - return "" - lines = ["### Hand\n"] - for card in self.hand: - lines.append(f"- {card.to_markdown()}\n") - lines.append("\n") - return "".join(lines) - - def piles_to_markdown(self) -> str: - """Convert deck piles to markdown.""" - lines = ["### Deck Information\n\n"] - for pile, count, label, suffix in [ - (self.draw_pile, self.draw_pile_count, "Draw Pile", " in random order"), - (self.discard_pile, self.discard_pile_count, "Discard Pile", ""), - (self.exhaust_pile, self.exhaust_pile_count, "Exhaust Pile", ""), - ]: - comma = "," if suffix else "" - lines.append(f"#### {label} ({count} cards{comma}{suffix})\n") - lines.append(pile.to_markdown()) - lines.append("\n") - return "".join(lines) - - def orbs_to_markdown(self) -> str: - """Convert orbs to markdown.""" - if not self.orbs: - return "" - lines = [f"### Orbs ({len(self.orbs)}/{self.orb_slots} slots)\n"] - for orb in self.orbs: - lines.append(f"- {orb.to_markdown()}\n") - if self.orb_empty_slots > 0: - lines.append(f"- *{self.orb_empty_slots} empty slot(s)*\n") - lines.append("\n") - return "".join(lines) diff --git a/mcp/states/player/orb.py b/mcp/states/player/orb.py index e9a28026..882ad78e 100644 --- a/mcp/states/player/orb.py +++ b/mcp/states/player/orb.py @@ -12,8 +12,3 @@ class Orb(BaseModel): passive_val: int evoke_val: int keywords: Keywords - - def to_markdown(self) -> str: - """Convert the orb to a markdown string.""" - desc = f" - {self.description}" if self.description else "" - return f"**{self.name}** (passive: {self.passive_val}, evoke: {self.evoke_val}){desc}" diff --git a/mcp/states/player/pile_card.py b/mcp/states/player/pile_card.py index f31134db..b1a9bdbc 100644 --- a/mcp/states/player/pile_card.py +++ b/mcp/states/player/pile_card.py @@ -9,11 +9,6 @@ class PileCard(BaseModel): cost: str | None = None star_cost: str | None = None - def to_markdown(self) -> str: - """Convert the pile card to a markdown string.""" - star_cost = f" + {self.star_cost} star" if self.star_cost is not None else "" - return f"{self.name} ({self.cost}{star_cost}): {self.description}" - class PileCards(BaseModel): """The state of the player's pile cards.""" @@ -24,15 +19,6 @@ def __sub__(self, old_pile_cards: "PileCards") -> "PileCards": """Calculate the difference between two pile card states.""" pass - def to_markdown(self) -> str: - """Convert the pile cards to a markdown string.""" - if not self.pile_cards: - return "- *(empty)*\n" - lines = [] - for markdown_key in self.pile_cards: - lines.append(f"- {markdown_key}\n") - return "".join(lines) - @model_validator(mode="before") @classmethod def from_pile_card_list(cls, pile_card_list: list[dict]) -> dict: diff --git a/mcp/states/player/player.py b/mcp/states/player/player.py index 8dadf32f..c473ce10 100644 --- a/mcp/states/player/player.py +++ b/mcp/states/player/player.py @@ -23,68 +23,3 @@ class PlayerState(BaseModel): status: list[StatusEffect] relics: Relics potions: list[Potion] - - def _status_to_markdown(self, indent: str = "") -> str: - if not self.status: - return "" - lines = ["### Status\n"] - for s in self.status: - lines.append(f"{indent}- {s.to_markdown()}\n") - lines.append("\n") - return "".join(lines) - - def _relics_to_markdown(self) -> str: - if not self.relics.relics: - return "" - lines = ["### Relics\n"] - lines.append(self.relics.to_markdown()) - lines.append("\n") - return "".join(lines) - - def _potions_to_markdown(self) -> str: - if not self.potions: - return "" - lines = ["### Potions\n"] - for p in self.potions: - lines.append(f"- {p.to_markdown()}\n") - lines.append("\n") - return "".join(lines) - - def to_markdown_non_combat(self) -> str: - """Render player summary for non-combat scenarios (map, event, etc.).""" - lines = ["## Player (You)\n"] - stars = f" | Stars: {self.combat_state.stars}" if self.combat_state and self.combat_state.stars else "" - lines.append(f"**{self.character}** - HP: {self.hp}/{self.max_hp} | Gold: {self.gold}{stars}\n\n") - lines.append(self._relics_to_markdown()) - lines.append(self._potions_to_markdown()) - return "".join(lines) - - def to_markdown_combat(self) -> str: - """Render player details for combat scenarios.""" - lines = ["## Player (You)\n"] - cs = self.combat_state - stars = f" | Stars: {cs.stars}" if cs and cs.stars else "" - energy = f" | Energy: {cs.energy}/{cs.max_energy}" if cs else "" - lines.append(f"**{self.character}** - HP: {self.hp}/{self.max_hp} | Block: {self.block}{energy}{stars} | Gold: {self.gold}\n\n") - lines.append(self._status_to_markdown()) - lines.append(self._relics_to_markdown()) - lines.append(self._potions_to_markdown()) - if cs: - lines.append(cs.hand_to_markdown()) - lines.append(cs.piles_to_markdown()) - lines.append(cs.orbs_to_markdown()) - return "".join(lines) - - @model_validator(mode="before") - @classmethod - def from_json_state(cls, json_state: dict) -> dict: - player_state = dict() - combat_state = dict() - - for key, value in json_state.items(): - if key in ["character", "hp", "max_hp", "block", "gold", "status", "relics", "potions"]: - player_state[key] = value - else: - combat_state[key] = value - player_state["combat_state"] = combat_state if combat_state else None - return player_state diff --git a/mcp/states/scenario/bundle_select.py b/mcp/states/scenario/bundle_select.py index 1265444c..ff7cb392 100644 --- a/mcp/states/scenario/bundle_select.py +++ b/mcp/states/scenario/bundle_select.py @@ -12,13 +12,6 @@ class Bundle(BaseModel): card_count: int cards: list[RewardCard] - def to_markdown(self) -> str: - lines = [f"[{self.index}] Bundle with {self.card_count} card(s)\n"] - for card in self.cards: - star_cost = f" + {card.star_cost} star" if card.star_cost is not None else "" - lines.append(f" {card.name} ({card.cost}{star_cost}) [{card.type}] {card.rarity}\n") - return "".join(lines) - class BundleSelect(BaseModel): """The bundle select object.""" @@ -31,36 +24,9 @@ class BundleSelect(BaseModel): can_confirm: bool can_cancel: bool - def to_markdown(self) -> str: - lines = ["## Bundle Selection\n"] - if self.prompt: - lines.append(f"*{self.prompt}*\n") - lines.append("\n") - - if self.bundles: - lines.append("### Bundles\n") - for bundle in self.bundles: - lines.append(f"- {bundle.to_markdown()}") - lines.append("\n") - - if self.preview_showing: - lines.append("**Preview is showing** - use `confirm_bundle_selection()` to confirm or `cancel_bundle_selection()` to go back.\n") - if self.preview_cards: - lines.append("### Preview Cards\n") - for card in self.preview_cards: - star_cost = f" + {card.star_cost} star" if card.star_cost is not None else "" - lines.append(f"- **{card.name}** ({card.cost} energy{star_cost}) [{card.type}] {card.rarity} - {card.description}\n") - lines.append("\n") - else: - lines.append(f"Use `select_bundle(index)` to open a bundle preview. Can confirm: {'Yes' if self.can_confirm else 'No'} | Can cancel: {'Yes' if self.can_cancel else 'No'}\n\n") - return "".join(lines) - class BundleSelectState(BaseModel): """The state when the scenario is in the bundle selection screen.""" state_type: Literal["bundle_select"] bundle_select: BundleSelect - - def to_markdown(self) -> str: - return self.bundle_select.to_markdown() diff --git a/mcp/states/scenario/card_reward.py b/mcp/states/scenario/card_reward.py index 5f142230..2fda765c 100644 --- a/mcp/states/scenario/card_reward.py +++ b/mcp/states/scenario/card_reward.py @@ -11,22 +11,9 @@ class CardReward(BaseModel): cards: list[RewardCard] can_skip: bool - def to_markdown(self) -> str: - lines = ["## Card Reward Selection\n"] - lines.append("Choose a card to add to your deck:\n\n") - if self.cards: - for card in self.cards: - lines.append(f"- {card.to_markdown()}\n") - lines.append("\n") - lines.append(f"**Can skip:** {'Yes' if self.can_skip else 'No'}\n\n") - return "".join(lines) - class CardRewardState(BaseModel): """The state when the scenario is card reward selection.""" state_type: Literal["card_reward"] card_reward: CardReward - - def to_markdown(self) -> str: - return self.card_reward.to_markdown() diff --git a/mcp/states/scenario/card_select.py b/mcp/states/scenario/card_select.py index 0fade7b3..54039011 100644 --- a/mcp/states/scenario/card_select.py +++ b/mcp/states/scenario/card_select.py @@ -4,13 +4,6 @@ from states.common.card import RewardCard -_SCREEN_LABELS = { - "transform": "Transform", - "upgrade": "Upgrade", - "select": "Select", - "simple_select": "Select", -} - class CardSelect(BaseModel): """The card select object.""" @@ -22,32 +15,9 @@ class CardSelect(BaseModel): can_confirm: bool can_cancel: bool - def to_markdown(self) -> str: - screen_label = _SCREEN_LABELS.get(self.screen_type, self.screen_type) - lines = [f"## Card Selection: {screen_label}\n"] - if self.prompt: - lines.append(f"*{self.prompt}*\n") - lines.append("\n") - - if self.cards: - lines.append("### Cards\n") - for card in self.cards: - lines.append(f"- {card.to_markdown()}\n") - lines.append("\n") - - if self.preview_showing: - lines.append("**Preview is showing** - use `confirm_selection` to confirm or `cancel_selection` to go back.\n") - else: - lines.append(f"**Select cards** using `select_card(index)`. Can confirm: {'Yes' if self.can_confirm else 'No'} | Can cancel: {'Yes' if self.can_cancel else 'No'}\n") - lines.append("\n") - return "".join(lines) - class CardSelectState(BaseModel): """The state when the scenario is in the card selection screen.""" state_type: Literal["card_select"] card_select: CardSelect - - def to_markdown(self) -> str: - return self.card_select.to_markdown() diff --git a/mcp/states/scenario/combat.py b/mcp/states/scenario/combat.py index 745804fd..eca63953 100644 --- a/mcp/states/scenario/combat.py +++ b/mcp/states/scenario/combat.py @@ -13,13 +13,6 @@ class Intent(BaseModel): title: str description: str - def to_markdown(self) -> str: - title = self.title if self.title else self.type - type_tag = f" ({self.type})" - label = f" {self.label}" if self.label else "" - desc = f" - {self.description}" if self.description else "" - return f"{title}{type_tag}{label}{desc}" - class Enemy(BaseModel): """The enemy object.""" @@ -33,21 +26,8 @@ class Enemy(BaseModel): status: list[StatusEffect] intents: list[Intent] - def to_markdown(self) -> str: - lines = [f"### {self.name} (`{self.entity_id}`)\n"] - lines.append(f"HP: {self.hp}/{self.max_hp} | Block: {self.block}\n") - if self.intents: - intent_strs = [i.to_markdown() for i in self.intents] - lines.append(f"**Intent:** {', '.join(intent_strs)}\n") - if self.status: - lines.append("### Status\n") - for s in self.status: - lines.append(f" - {s.to_markdown()}\n") - lines.append("\n") - return "".join(lines) - -class BattleState(BaseModel): +class Battle(BaseModel): """The state of the battle.""" round: int @@ -55,13 +35,8 @@ class BattleState(BaseModel): is_play_phase: bool enemies: list[Enemy] - def to_markdown(self) -> str: - lines = [f"**Round {self.round}** | Turn: {self.turn} | Play Phase: {self.is_play_phase}\n\n"] - if self.enemies: - lines.append("## Enemies\n") - for enemy in self.enemies: - lines.append(enemy.to_markdown()) - return "".join(lines) + # multiplayer fields + all_players_ready: bool | None = None class CombatState(BaseModel): @@ -69,11 +44,4 @@ class CombatState(BaseModel): state_type: Literal["monster", "elite", "boss"] message: str | None = None - battle: BattleState | None = None - - def to_markdown(self) -> str: - if self.message is not None: - return self.message + "\n" - if self.battle is not None: - return self.battle.to_markdown() - return "" + battle: Battle | None = None diff --git a/mcp/states/scenario/crystal_sphere.py b/mcp/states/scenario/crystal_sphere.py index caba81b9..32220445 100644 --- a/mcp/states/scenario/crystal_sphere.py +++ b/mcp/states/scenario/crystal_sphere.py @@ -30,9 +30,6 @@ class RevealedItem(Coordinate): height: int is_good: bool - def to_markdown(self) -> str: - return f"**{self.item_type}** at ({self.x}, {self.y}) size {self.width}x{self.height}" - class CrystalSphere(BaseModel): """The crystal sphere event state.""" @@ -50,37 +47,9 @@ class CrystalSphere(BaseModel): divinations_left_text: str can_proceed: bool - def to_markdown(self) -> str: - lines = ["## Crystal Sphere\n"] - lines.append(f"**{self.instructions_title}**\n") - lines.append(f"{self.instructions_description}\n\n") - lines.append(f"**Tool:** {self.tool} | **Divinations:** {self.divinations_left_text}\n\n") - - if self.clickable_cells: - lines.append("### Clickable Cells\n") - for cell in self.clickable_cells: - lines.append(f"- ({cell.x}, {cell.y})\n") - lines.append("\n") - - if self.revealed_items: - lines.append("### Revealed Items\n") - for item in self.revealed_items: - lines.append(f"- {item.to_markdown()}\n") - lines.append("\n") - - if self.can_proceed: - lines.append("Use `crystal_sphere_proceed()` to continue.\n") - else: - lines.append("Use `crystal_sphere_set_tool(tool)` with `big` or `small`, then `crystal_sphere_click_cell(x, y)`.\n") - lines.append("\n") - return "".join(lines) - class CrystalSphereState(BaseModel): """The state when the scenario is in the crystal sphere event.""" state_type: Literal["crystal_sphere"] crystal_sphere: CrystalSphere - - def to_markdown(self) -> str: - return self.crystal_sphere.to_markdown() diff --git a/mcp/states/scenario/event.py b/mcp/states/scenario/event.py index 4dcaacdd..6731d223 100644 --- a/mcp/states/scenario/event.py +++ b/mcp/states/scenario/event.py @@ -3,6 +3,7 @@ from pydantic import BaseModel from states.common.keyword import Keywords +from states.common.multiplayer import EventVote class EventOption(BaseModel): @@ -18,17 +19,6 @@ class EventOption(BaseModel): relic_description: str | None = None # only if option has a relic keywords: Keywords - def to_markdown(self) -> str: - tag = "" - if self.is_locked: - tag = " (LOCKED)" - elif self.was_chosen: - tag = " (CHOSEN)" - elif self.is_proceed: - tag = " (PROCEED)" - relic = f" [Relic: {self.relic_name}]" if self.relic_name is not None else "" - return f"[{self.index}] **{self.title}**{tag}{relic} - {self.description}" - class Event(BaseModel): """The event object.""" @@ -40,22 +30,10 @@ class Event(BaseModel): body: str | None options: list[EventOption] - def to_markdown(self) -> str: - label = "Ancient" if self.is_ancient else "Event" - lines = [f"## {label}: {self.event_name}\n\n"] - - if self.in_dialogue: - lines.append("*Ancient dialogue in progress - use `advance_dialogue` to continue.*\n\n") - return "".join(lines) - - if self.options: - lines.append("### Options\n") - for opt in self.options: - lines.append(f"- {opt.to_markdown()}\n") - lines.append("\n") - else: - lines.append("No options available.\n\n") - return "".join(lines) + # multiplayer fields + is_shared: bool | None = None + votes: list[EventVote] | None = None + all_voted: bool | None = None class EventState(BaseModel): @@ -63,6 +41,3 @@ class EventState(BaseModel): state_type: Literal["event"] event: Event - - def to_markdown(self) -> str: - return self.event.to_markdown() diff --git a/mcp/states/scenario/hand_select.py b/mcp/states/scenario/hand_select.py index fa0136be..8c01a241 100644 --- a/mcp/states/scenario/hand_select.py +++ b/mcp/states/scenario/hand_select.py @@ -22,31 +22,6 @@ class HandSelect(BaseModel): selected_cards: list[SelectedCard] | None = None # only present if cards has been selected can_confirm: bool - def to_markdown(self) -> str: - lines = ["## In-Combat Card Selection\n"] - lines.append(f"*{self.prompt}*\n") - - if self.mode == "upgrade_select": - lines.append("**Mode:** Upgrade selection\n") - lines.append("\n") - - if self.cards: - lines.append("### Selectable Cards\n") - for card in self.cards: - star_cost = f" + {card.star_cost} star" if card.star_cost is not None else "" - lines.append(f"- [{card.index}] **{card.name}** ({card.cost} energy{star_cost}) [{card.type}] - {card.description}\n") - lines.append("\n") - - if self.selected_cards: - lines.append("### Already Selected\n") - for card in self.selected_cards: - lines.append(f"- {card.name}\n") - lines.append("\n") - - can_confirm_str = "Yes - use `combat_confirm_selection`" if self.can_confirm else "No - select more cards" - lines.append(f"Use `combat_select_card(card_index)` to select. Can confirm: {can_confirm_str}\n\n") - return "".join(lines) - class HandSelectState(CombatState): """The state when the scenario is in-combat card selection. @@ -56,10 +31,3 @@ class HandSelectState(CombatState): state_type: Literal["hand_select"] hand_select: HandSelect - - def to_markdown(self) -> str: - lines = [] - # Render battle context first (from parent CombatState) - lines.append(super().to_markdown()) - lines.append(self.hand_select.to_markdown()) - return "".join(lines) diff --git a/mcp/states/scenario/map.py b/mcp/states/scenario/map.py index 82c02077..0d1e169f 100644 --- a/mcp/states/scenario/map.py +++ b/mcp/states/scenario/map.py @@ -1,9 +1,9 @@ -from __future__ import annotations - from typing import Literal from pydantic import BaseModel +from states.common.multiplayer import MapVote + class Node(BaseModel): """The node on the map.""" @@ -22,7 +22,7 @@ class NextNode(NodeWithType): """The next node on the map with 1-level lookahead.""" index: int - leads_to: list[NodeWithType] + leads_to: list[NodeWithType] | None = None # null when the next node is a boss node class DAGNode(NodeWithType): @@ -34,69 +34,15 @@ class DAGNode(NodeWithType): class Map(BaseModel): """The map navigation information.""" - current_position: Node + current_position: Node | None = None visited: list[NodeWithType] next_options: list[NextNode] nodes: list[DAGNode] boss: Node - def _build_future_path_tree(self, start_node: NextNode, node_lookup: dict[str, DAGNode]) -> str: - """BFS from a node through its children to build a future path tree string.""" - start_key = f"{start_node.col},{start_node.row}" - canonical = node_lookup.get(start_key) - current_keys: set[str] = set() - if canonical: - current_keys = {f"{c},{r}" for c, r in canonical.children} - - parts: list[str] = [] - while current_keys: - level_nodes: list[tuple[str, int, int]] = [] - next_keys: set[str] = set() - - for key in sorted(current_keys): - node = node_lookup.get(key) - if node: - level_nodes.append((node.type, node.col, node.row)) - for c, r in node.children: - next_keys.add(f"{c},{r}") - - if not level_nodes: - break - - level_str = " or ".join(f"{t} ({c},{r})" for t, c, r in level_nodes) - parts.append(f"-> {level_str}") - current_keys = next_keys - - return " ".join(parts) - - def to_markdown(self) -> str: - lines: list[str] = [] - - # Path taken - if self.visited: - lines.append("## Path Taken\n") - parts = [f"{i + 1}. {v.type} ({v.col},{v.row})" for i, v in enumerate(self.visited)] - lines.append(" -> ".join(parts) + " <- current\n\n") - - # Build node lookup - node_lookup: dict[str, DAGNode] = {} - for node in self.nodes: - node_lookup[f"{node.col},{node.row}"] = node - - # Next options - if self.next_options: - lines.append("## Choose Next Node\n") - for opt in self.next_options: - lines.append(f"- [{opt.index}] **{opt.type}** ({opt.col},{opt.row})\n") - tree = self._build_future_path_tree(opt, node_lookup) - if tree: - lines.append(f" Future paths: {tree}\n") - lines.append("\n") - else: - lines.append("## Map\n") - lines.append("No travelable nodes available.\n\n") - - return "".join(lines) + # multiplayer fields + votes: list[MapVote] | None = None + all_voted: bool | None = None class MapState(BaseModel): @@ -104,6 +50,3 @@ class MapState(BaseModel): state_type: Literal["map"] map: Map - - def to_markdown(self) -> str: - return self.map.to_markdown() diff --git a/mcp/states/scenario/menu.py b/mcp/states/scenario/menu.py index 460044c1..d3f08203 100644 --- a/mcp/states/scenario/menu.py +++ b/mcp/states/scenario/menu.py @@ -8,6 +8,3 @@ class MenuState(BaseModel): state_type: Literal["menu"] message: str - - def to_markdown(self) -> str: - return self.message + "\n" diff --git a/mcp/states/scenario/overlay.py b/mcp/states/scenario/overlay.py index 4f4f0378..5a40097a 100644 --- a/mcp/states/scenario/overlay.py +++ b/mcp/states/scenario/overlay.py @@ -15,10 +15,3 @@ class OverlayState(BaseModel): state_type: Literal["overlay"] = "overlay" overlay: Overlay - - def to_markdown(self) -> str: - """Convert the overlay state to a markdown string.""" - lines = [] - lines.append(f"## Overlay: {self.overlay.screen_type}\n") - lines.append(self.overlay.message + "\n\n") - return "".join(lines) diff --git a/mcp/states/scenario/relic_select.py b/mcp/states/scenario/relic_select.py index 0d1bc709..a2420eb4 100644 --- a/mcp/states/scenario/relic_select.py +++ b/mcp/states/scenario/relic_select.py @@ -12,26 +12,9 @@ class RelicSelect(BaseModel): relics: list[RewardRelic] can_skip: bool - def to_markdown(self) -> str: - lines = ["## Relic Selection\n"] - if self.prompt: - lines.append(f"*{self.prompt}*\n") - lines.append("\n") - - if self.relics: - for relic in self.relics: - lines.append(f"- [{relic.index}] {relic.to_markdown()}\n") - lines.append("\n") - - lines.append(f"Use `select_relic(index)` to choose. Can skip: {'Yes' if self.can_skip else 'No'}\n\n") - return "".join(lines) - class RelicSelectState(BaseModel): """The state when the scenario is in the relic selection screen.""" state_type: Literal["relic_select"] relic_select: RelicSelect - - def to_markdown(self) -> str: - return self.relic_select.to_markdown() diff --git a/mcp/states/scenario/rest_site.py b/mcp/states/scenario/rest_site.py index 90b0d446..7dbec404 100644 --- a/mcp/states/scenario/rest_site.py +++ b/mcp/states/scenario/rest_site.py @@ -12,10 +12,6 @@ class RestSiteOption(BaseModel): description: str is_enabled: bool - def to_markdown(self) -> str: - enabled = "" if self.is_enabled else " (DISABLED)" - return f"[{self.index}] **{self.name}**{enabled} - {self.description}" - class RestSite(BaseModel): """The rest site object.""" @@ -23,22 +19,9 @@ class RestSite(BaseModel): options: list[RestSiteOption] can_proceed: bool - def to_markdown(self) -> str: - lines = [] - if self.options: - lines.append("## Rest Site Options\n") - for opt in self.options: - lines.append(f"- {opt.to_markdown()}\n") - lines.append("\n") - lines.append(f"**Can proceed:** {'Yes' if self.can_proceed else 'No'}\n\n") - return "".join(lines) - class RestSiteState(BaseModel): """The state when the scenario is at the rest site.""" state_type: Literal["rest_site"] rest_site: RestSite - - def to_markdown(self) -> str: - return self.rest_site.to_markdown() diff --git a/mcp/states/scenario/rewards.py b/mcp/states/scenario/rewards.py index b86eafde..9721e217 100644 --- a/mcp/states/scenario/rewards.py +++ b/mcp/states/scenario/rewards.py @@ -14,18 +14,6 @@ class RewardItem(BaseModel): # therefore we allow extra fields for different types of reward items model_config = ConfigDict(extra="allow") - def to_markdown(self) -> str: - extra = "" - # Access extra fields via model_extra - extras = self.model_extra or {} - if "gold_amount" in extras and extras["gold_amount"] is not None: - extra = f" ({extras['gold_amount']} gold)" - elif "potion_description" in extras and extras["potion_description"] is not None: - extra = f" - {extras['potion_description']}" - elif "potion_name" in extras and extras["potion_name"] is not None: - extra = f" ({extras['potion_name']})" - return f"[{self.index}] **{self.type}**: {self.description}{extra}" - class Rewards(BaseModel): """The rewards after a combat or event.""" @@ -33,22 +21,9 @@ class Rewards(BaseModel): items: list[RewardItem] can_proceed: bool - def to_markdown(self) -> str: - lines = ["## Rewards\n"] - if self.items: - for item in self.items: - lines.append(f"- {item.to_markdown()}\n") - else: - lines.append("No rewards available.\n") - lines.append(f"\n**Can proceed:** {'Yes' if self.can_proceed else 'No'}\n\n") - return "".join(lines) - class RewardsState(BaseModel): """The state when the scenario is in the rewards screen after a combat or event.""" state_type: Literal["rewards"] rewards: Rewards - - def to_markdown(self) -> str: - return self.rewards.to_markdown() diff --git a/mcp/states/scenario/shop.py b/mcp/states/scenario/shop.py index cea44cc1..54bd63df 100644 --- a/mcp/states/scenario/shop.py +++ b/mcp/states/scenario/shop.py @@ -9,16 +9,10 @@ class BaseShopItem(BaseModel): """The item in the shop.""" index: int - cost: int + price: int is_stocked: bool can_afford: bool - def _cost_tag(self) -> str: - return f"{self.cost}g" if self.is_stocked else "SOLD" - - def _afford_tag(self) -> str: - return " (can't afford)" if self.is_stocked and not self.can_afford else "" - class ShopCard(BaseShopItem): """The card in the shop.""" @@ -34,12 +28,6 @@ class ShopCard(BaseShopItem): card_description: str | None = None keywords: Keywords | None = None - def to_markdown(self) -> str: - star_cost = f" ({self.card_star_cost} star)" if self.card_star_cost is not None else "" - desc = f"**{self.card_name}** [{self.card_type}]{star_cost} {self.card_rarity} - {self.card_description}" - sale = " **SALE**" if self.on_sale else "" - return f"[{self.index}] {desc} - {self._cost_tag()}{sale}{self._afford_tag()}" - class ShopRelic(BaseShopItem): """The relic in the shop.""" @@ -50,10 +38,6 @@ class ShopRelic(BaseShopItem): relic_description: str | None = None keywords: Keywords | None = None - def to_markdown(self) -> str: - desc = f"**{self.relic_name}** - {self.relic_description}" - return f"[{self.index}] {desc} - {self._cost_tag()}{self._afford_tag()}" - class ShopPotion(BaseShopItem): """The potion in the shop.""" @@ -64,30 +48,15 @@ class ShopPotion(BaseShopItem): potion_description: str | None = None keywords: Keywords | None = None - def to_markdown(self) -> str: - desc = f"**{self.potion_name}** - {self.potion_description}" - return f"[{self.index}] {desc} - {self._cost_tag()}{self._afford_tag()}" - class ShopCardRemoval(BaseShopItem): """The card removal option in the shop.""" category: Literal["card_removal"] - def to_markdown(self) -> str: - desc = "**Remove a card** from your deck" - return f"[{self.index}] {desc} - {self._cost_tag()}{self._afford_tag()}" - ShopItem: TypeAlias = ShopCard | ShopRelic | ShopPotion | ShopCardRemoval -_CATEGORY_HEADERS = { - "card": "Cards", - "relic": "Relics", - "potion": "Potions", - "card_removal": "Services", -} - class Shop(BaseModel): """The state of the shop.""" @@ -96,33 +65,9 @@ class Shop(BaseModel): can_proceed: bool error: str | None = None # only present if inventory isn't ready; retry in a moment - def to_markdown(self) -> str: - lines = [] - if self.error is not None: - lines.append("## Shop\n") - lines.append(f"**Note:** {self.error}\n\n") - - if self.items: - lines.append("## Shop Inventory\n") - last_category = None - for item in self.items: - category = item.category - if category != last_category: - header = _CATEGORY_HEADERS.get(category, category) - lines.append(f"### {header}\n") - last_category = category - lines.append(f"- {item.to_markdown()}\n") - lines.append("\n") - - lines.append(f"**Can proceed:** {'Yes' if self.can_proceed else 'No'}\n\n") - return "".join(lines) - class ShopState(BaseModel): """The state when the scenario is in the shop.""" state_type: Literal["shop"] shop: Shop - - def to_markdown(self) -> str: - return self.shop.to_markdown() diff --git a/mcp/states/scenario/treasure.py b/mcp/states/scenario/treasure.py index 7dd9fe47..f821bf57 100644 --- a/mcp/states/scenario/treasure.py +++ b/mcp/states/scenario/treasure.py @@ -2,33 +2,21 @@ from pydantic import BaseModel +from states.common.multiplayer import TreasureVote from states.common.relic import RewardRelic class Treasure(BaseModel): """The treasure object.""" - message: str - relics: list[RewardRelic] - can_proceed: bool - - def to_markdown(self) -> str: - lines = [] - if self.relics: - lines.append("## Treasure Relics\n") - for relic in self.relics: - rarity = f" ({relic.rarity})" if relic.rarity else "" - lines.append(f"- [{relic.index}] **{relic.name}**{rarity} - {relic.description}\n") - lines.append("\n") - lines.append("Use `treasure_claim_relic(relic_index)` to claim a relic.\n") - else: - lines.append("Chest is opening...\n") - lines.append("\n") - - if self.can_proceed: - lines.append("**Can proceed:** Yes\n") - lines.append("\n") - return "".join(lines) + message: str | None = None + relics: list[RewardRelic] | None = None # only present when loaded + can_proceed: bool | None = None + + # multiplayer fields + is_bidding_phase: bool | None = None + bids: list[TreasureVote] | None = None + all_bid: bool | None = None class TreasureState(BaseModel): @@ -36,6 +24,3 @@ class TreasureState(BaseModel): state_type: Literal["treasure"] treasure: Treasure - - def to_markdown(self) -> str: - return self.treasure.to_markdown() diff --git a/mcp/states/scenario/unknown.py b/mcp/states/scenario/unknown.py index 31cff372..582ff7f6 100644 --- a/mcp/states/scenario/unknown.py +++ b/mcp/states/scenario/unknown.py @@ -10,6 +10,3 @@ class UnknownState(BaseModel): # As the scenario is unknown, we allow any extra fields to be stored in the model. model_config = ConfigDict(extra="allow") - - def to_markdown(self) -> str: - return "" From 1f0e5458c7b8db6c9c2d76071e3df26c94e6b29c Mon Sep 17 00:00:00 2001 From: fanqiNO1 <1848839264@qq.com> Date: Mon, 13 Apr 2026 17:19:52 +0800 Subject: [PATCH 7/7] refactor [6/N] player diff state --- mcp/server.py | 1 + mcp/server_next.py | 10 +++ mcp/states/__init__.py | 3 + mcp/states/common/card.py | 37 ++++++++++- mcp/states/common/keyword.py | 22 +++++- mcp/states/common/multiplayer.py | 26 ++++++++ mcp/states/common/pet.py | 33 +++++++++ mcp/states/common/potion.py | 5 ++ mcp/states/common/relic.py | 29 +++++++- mcp/states/common/status_effect.py | 13 +++- mcp/states/game.py | 25 +++++-- mcp/states/player/combat.py | 74 ++++++++++++++++++++- mcp/states/player/orb.py | 6 ++ mcp/states/player/pile_card.py | 38 ++++++++++- mcp/states/player/player.py | 72 ++++++++++++++++++++ mcp/states/scenario/__init__.py | 1 - mcp/states/scenario/card_reward.py | 19 ++++++ mcp/states/scenario/menu.py | 4 ++ mcp/states/scenario/overlay.py | 8 +++ mcp/states/scenario/unknown.py | 6 ++ mcp/{utils/__init__.py => tools/context.py} | 0 21 files changed, 416 insertions(+), 16 deletions(-) create mode 100644 mcp/server_next.py create mode 100644 mcp/states/__init__.py create mode 100644 mcp/states/common/pet.py rename mcp/{utils/__init__.py => tools/context.py} (100%) diff --git a/mcp/server.py b/mcp/server.py index 8959f161..90398ff3 100644 --- a/mcp/server.py +++ b/mcp/server.py @@ -9,6 +9,7 @@ import json import sys +import httpx from mcp.server.fastmcp import FastMCP mcp = FastMCP("sts2") diff --git a/mcp/server_next.py b/mcp/server_next.py new file mode 100644 index 00000000..be24c2f7 --- /dev/null +++ b/mcp/server_next.py @@ -0,0 +1,10 @@ +"""MCP server bridge for Slay the Spire 2. + +Connects to the STS2_MCP mod's HTTP server and exposes game actions +as MCP tools for Claude Desktop / Claude Code. +""" + + + + + diff --git a/mcp/states/__init__.py b/mcp/states/__init__.py new file mode 100644 index 00000000..b27f20f2 --- /dev/null +++ b/mcp/states/__init__.py @@ -0,0 +1,3 @@ +from states.game import GameState + +__all__ = ["GameState"] diff --git a/mcp/states/common/card.py b/mcp/states/common/card.py index 71b8beb8..a46fbd1d 100644 --- a/mcp/states/common/card.py +++ b/mcp/states/common/card.py @@ -1,4 +1,4 @@ -from pydantic import BaseModel, model_validator +from pydantic import BaseModel from states.common.keyword import Keywords @@ -16,6 +16,22 @@ class Card(BaseModel): is_upgraded: bool keywords: Keywords + def _get_cost_str(self) -> str: + """Get the cost of the card""" + star_cost_str = f" + {self.star_cost} star" if self.star_cost else "" + return f"{self.cost} energy{star_cost_str}" + + def _get_base_markdown_str(self) -> str: + """Get the base markdown string for the card, without description and keywords.""" + cost_str = self._get_cost_str() + return f"[{self.index}] **{self.name}** ({cost_str}) [{self.type}]" + + def to_markdown(self) -> str: + """Convert the card to a markdown string.""" + base_markdown_str = self._get_base_markdown_str() + keyword_names_str = self.keywords.get_keyword_names_str() + return f"{base_markdown_str}{keyword_names_str} - {self.description}" + class HandCard(Card): """The card in the player's hand.""" @@ -24,9 +40,28 @@ class HandCard(Card): can_play: bool unplayable_reason: str | None = None + def to_markdown(self) -> str: + """Convert the hand card to a markdown string.""" + if self.can_play: + playability_str = "\u2713" # check mark + else: + unplayable_reason_str = f" ({self.unplayable_reason})" if self.unplayable_reason else "" + playability_str = f"\u2717{unplayable_reason_str}" # cross mark + + target_str = f"(target: {self.target_type})" + + base_markdown_str = self._get_base_markdown_str() + keyword_names_str = self.keywords.get_keyword_names_str() + return f"{base_markdown_str} {playability_str}{keyword_names_str} - {self.description} {target_str}" + class RewardCard(Card): """The card in the reward screen.""" rarity: str + def to_markdown(self) -> str: + """Convert the reward card to a markdown string.""" + base_markdown_str = self._get_base_markdown_str() + keyword_names_str = self.keywords.get_keyword_names_str() + return f"{base_markdown_str} {self.rarity}{keyword_names_str} - {self.description}" diff --git a/mcp/states/common/keyword.py b/mcp/states/common/keyword.py index 04a8c65d..eb063bc1 100644 --- a/mcp/states/common/keyword.py +++ b/mcp/states/common/keyword.py @@ -7,6 +7,10 @@ class Keyword(BaseModel): name: str description: str + def to_markdown(self): + """Convert the keyword to a markdown string.""" + return f"**{self.name}**: {self.description}" + class Keywords(BaseModel): """The keywords object, which is a collection of keywords.""" @@ -21,6 +25,22 @@ def __add__(self, other_keywords: "Keywords") -> "Keywords": combined_keywords[name] = keyword return Keywords.model_construct(keywords=combined_keywords) + def get_keyword_names_str(self) -> str: + """Get the names of the keywords, separated by commas.""" + if not self.keywords: + return "" + keyword_names = [name for name in self.keywords] + return f" [{', '.join(keyword_names)}]" + + def to_markdown(self) -> str: + """Convert the keywords to a markdown string.""" + if not self.keywords: + return "" + lines = [] + for keyword in self.keywords.values(): + lines.append(f"- {keyword.to_markdown()}\n") + return "".join(lines) + @model_validator(mode="before") @classmethod def from_keyword_list(cls, keyword_list: list[dict]) -> dict: @@ -37,7 +57,7 @@ def _get_keywords(state, keywords: Keywords) -> Keywords: keywords += state elif isinstance(state, BaseModel): # iterate field values directly to preserve pydantic types - for _field_name, value in state: + for _, value in state: keywords = _get_keywords(value, keywords) elif isinstance(state, (list, tuple)): for item in state: diff --git a/mcp/states/common/multiplayer.py b/mcp/states/common/multiplayer.py index bfde7a0f..6f6fedf1 100644 --- a/mcp/states/common/multiplayer.py +++ b/mcp/states/common/multiplayer.py @@ -1,5 +1,7 @@ from pydantic import BaseModel +from states.common.pet import Pet + class PlayerSummary(BaseModel): """The summary of a player in multiplayer mode.""" @@ -7,10 +9,27 @@ class PlayerSummary(BaseModel): character: str hp: int max_hp: int + block: int gold: int is_local: bool is_alive: bool is_ready_to_end_turn: bool | None = None + pets: list[Pet] | None = None + + def to_markdown(self) -> str: + """Convert the player summary to a markdown string.""" + local_str = " **(YOU)**" if self.is_local else "" + alive_str = " [DEAD]" if not self.is_alive else "" + ready_str = " [READY]" if self.is_ready_to_end_turn else "" + detail_str = f"HP: {self.hp}/{self.max_hp} | Block: {self.block} | Gold: {self.gold}" + + lines = [f"**{self.character}**{local_str}{alive_str}{ready_str} - {detail_str}\n"] + if self.pets: + lines.append("Pets:\n") + for pet in self.pets: + lines.append(f" - {pet.to_markdown(indent=2)}\n") + + return "".join(lines) class MultiplayerState(BaseModel): @@ -21,6 +40,13 @@ class MultiplayerState(BaseModel): local_player_slot: int players: list[PlayerSummary] + def to_markdown(self) -> str: + """Convert the multiplayer state to a markdown string.""" + lines = [] + for player in self.players: + lines.append(f"- {player.to_markdown()}\n") + return "".join(lines) + class BaseVote(BaseModel): """The base class for a vote in multiplayer mode.""" diff --git a/mcp/states/common/pet.py b/mcp/states/common/pet.py new file mode 100644 index 00000000..014f9716 --- /dev/null +++ b/mcp/states/common/pet.py @@ -0,0 +1,33 @@ +from pydantic import BaseModel + +from states.common.status_effect import StatusEffect + + +class Pet(BaseModel): + """The pet object.""" + + id: str + name: str + alive: bool | None = None + hp: int | None = None + max_hp: int | None = None + block: int | None = None + status: list[StatusEffect] | None = None + + def to_markdown(self, indent: int = 0) -> str: + indent_str = " " * indent + """Convert the pet to a markdown string.""" + if self.alive is None: + alive_str = "Unknown" + else: + hp = self.hp or 0 + max_hp = self.max_hp or 0 + block = self.block or 0 + alive_str = f"HP: {hp}/{max_hp} | Block: {block}" if self.alive else "Dead" + + lines = [f"**{self.name}** - (`{self.id}`) - {alive_str}\n"] + if self.status: + lines.append("Status Effects:\n") + for status_effect in self.status: + lines.append(f" - {status_effect.to_markdown()}\n") + return indent_str.join(lines) diff --git a/mcp/states/common/potion.py b/mcp/states/common/potion.py index e8276ccd..a1a19333 100644 --- a/mcp/states/common/potion.py +++ b/mcp/states/common/potion.py @@ -14,3 +14,8 @@ class Potion(BaseModel): target_type: str keywords: Keywords + def to_markdown(self) -> str: + """Convert the potion to markdown string.""" + keyword_names_str = self.keywords.get_keyword_names_str() + target_str = f"(target: {self.target_type})" + return f"[{self.slot}] **{self.name}**{keyword_names_str} - {self.description} {target_str}" diff --git a/mcp/states/common/relic.py b/mcp/states/common/relic.py index 5ff9e4ae..28da4cde 100644 --- a/mcp/states/common/relic.py +++ b/mcp/states/common/relic.py @@ -12,6 +12,17 @@ class Relic(BaseModel): counter: int | None = None # number if relic shows a counter, null otherwise keywords: Keywords + def _get_base_markdown_str(self) -> str: + """Get the base markdown string for the relic, without description and keywords.""" + counter_str = f" [{self.counter}]" if self.counter is not None else "" + return f"**{self.name}**{counter_str}" + + def to_markdown(self) -> str: + """Convert the relic to a markdown string.""" + base_markdown_str = self._get_base_markdown_str() + keyword_names_str = self.keywords.get_keyword_names_str() + return f"{base_markdown_str}{keyword_names_str} - {self.description}" + class RewardRelic(Relic): """The relic in the reward screen.""" @@ -19,15 +30,27 @@ class RewardRelic(Relic): index: int rarity: str + def to_markdown(self) -> str: + """Convert the reward relic to a markdown string.""" + base_markdown_str = self._get_base_markdown_str() + keyword_names_str = self.keywords.get_keyword_names_str() + return f"[{self.index}] {base_markdown_str} {self.rarity}{keyword_names_str} - {self.description}" + class Relics(BaseModel): """The state of the player's relics.""" relics: dict[str, list[int | Relic]] # relic markdown -> [count, relic] - def __sub__(self, old_relics: "Relics") -> "Relics": - """Calculate the difference between two relic states.""" - pass + def to_markdown(self) -> str: + """Convert the relics to a markdown string.""" + if not self.relics: + return "No relics.\n" + + lines = [] + for (count, relic) in self.relics.values(): + lines.append(f"- {count}x {relic.to_markdown()}\n") + return "".join(lines) @model_validator(mode="before") @classmethod diff --git a/mcp/states/common/status_effect.py b/mcp/states/common/status_effect.py index 30b14dc8..96c1d055 100644 --- a/mcp/states/common/status_effect.py +++ b/mcp/states/common/status_effect.py @@ -1,6 +1,6 @@ from typing import Literal -from pydantic import BaseModel, model_validator +from pydantic import BaseModel from states.common.keyword import Keywords @@ -10,8 +10,17 @@ class StatusEffect(BaseModel): id: str name: str - amount: int + amount: int | None = None type: Literal["Buff", "Debuff"] description: str keywords: Keywords + def to_markdown(self) -> str: + """Convert the status effect to a markdown string.""" + if self.amount == -1 or self.amount is None: + amount_str = "indefinite" + else: + amount_str = str(self.amount) + + keyword_names_str = self.keywords.get_keyword_names_str() + return f"**{self.name}** ({amount_str}){keyword_names_str} - {self.description}" diff --git a/mcp/states/game.py b/mcp/states/game.py index aa5270a9..548a72c5 100644 --- a/mcp/states/game.py +++ b/mcp/states/game.py @@ -2,13 +2,9 @@ from pydantic import BaseModel, Field, model_validator -from states.common.keyword import collect_keywords from states.common.multiplayer import MultiplayerState from states.player import PlayerState from states.scenario import ScenarioState -from states.scenario.combat import CombatState -from states.scenario.hand_select import HandSelectState -from states.scenario.menu import MenuState class RunState(BaseModel): @@ -18,6 +14,10 @@ class RunState(BaseModel): floor: int ascension: int + def to_markdown(self) -> str: + """Convert the run state to a markdown string.""" + return f"**Act {self.act}** | Floor {self.floor} | Ascension {self.ascension}\n" + class GameState(BaseModel): """The state of the game.""" @@ -35,6 +35,23 @@ class GameState(BaseModel): # scenario-specific fields scenario_state: ScenarioState = Field(discriminator="state_type") + def __sub__(self, old: "GameState") -> "GameState": + """Calculate the difference between two game states.""" + pass + + def to_markdown(self, *, is_diff: bool = False) -> str: + """Convert the game state to a markdown string.""" + lines = [f"# {self.game_mode.capitalize()} Game State: {self.scenario_state.state_type}\n\n"] + # run state + if self.run: + lines.append(f"{self.run.to_markdown()}\n") + # multiplayer state + if self.multiplayer_state: + lines.append(f"## Party\n\n{self.multiplayer_state.to_markdown()}\n") + # player state + # scenario-specific state + # TODO + @model_validator(mode="before") @classmethod def from_json_state(cls, json_state: dict) -> dict: diff --git a/mcp/states/player/combat.py b/mcp/states/player/combat.py index d4c682b9..fba8b9f0 100644 --- a/mcp/states/player/combat.py +++ b/mcp/states/player/combat.py @@ -1,8 +1,9 @@ from pydantic import BaseModel from states.common.card import HandCard -from states.player.pile_card import PileCards +from states.common.pet import Pet from states.player.orb import Orb +from states.player.pile_card import PileCards class CombatPlayerState(BaseModel): @@ -24,4 +25,75 @@ class CombatPlayerState(BaseModel): orbs: list[Orb] | None = None orb_slots: int | None = None orb_empty_slots: int | None = None + # pets + pets: list[Pet] | None = None + + def __sub__(self, old: "CombatPlayerState") -> "CombatPlayerState": + """Calculate the difference between two combat states.""" + data = self.model_dump() + # replace with diff + data["draw_pile_count"] = self.draw_pile_count - old.draw_pile_count + data["discard_pile_count"] = self.discard_pile_count - old.discard_pile_count + data["exhaust_pile_count"] = self.exhaust_pile_count - old.exhaust_pile_count + data["draw_pile"] = self.draw_pile - old.draw_pile + data["discard_pile"] = self.discard_pile - old.discard_pile + data["exhaust_pile"] = self.exhaust_pile - old.exhaust_pile + return CombatPlayerState(**data) + + def energy_to_markdown(self) -> str: + """Convert the player's energy to a markdown string.""" + star_str = f" | Stars: {self.stars}" if self.stars is not None else "" + return f" | Energy: {self.energy}/{self.max_energy}{star_str}" + + def _hand_to_markdown(self) -> str: + """Convert the player's hand to a markdown string.""" + if not self.hand: + return "" + lines = ["### Hand\n\n"] + for card in self.hand: + lines.append(f"- {card.to_markdown()}\n") + lines.append("\n") + return "".join(lines) + + def _piles_to_markdown(self, *, is_diff: bool = False) -> str: + """Convert the player's piles to a markdown string.""" + lines = ["### Deck Information\n\n"] + # draw pile + lines.append(f"#### Draw Pile ({self.draw_pile_count} cards, sorted by rarity)\n\n") + lines.append(f"{self.draw_pile.to_markdown(is_diff=is_diff)}\n") + # discard pile + lines.append(f"#### Discard Pile ({self.discard_pile_count} cards)\n\n") + lines.append(f"{self.discard_pile.to_markdown(is_diff=is_diff)}\n") + # exhaust pile + lines.append(f"#### Exhaust Pile ({self.exhaust_pile_count} cards)\n\n") + lines.append(f"{self.exhaust_pile.to_markdown(is_diff=is_diff)}\n") + return "".join(lines) + + def _orbs_to_markdown(self) -> str: + """Convert the player's orbs to a markdown string.""" + lines = [f"### Orbs ({len(self.orbs)}/{self.orb_slots} slots)\n\n"] + for orb in self.orbs: + lines.append(f"- {orb.to_markdown()}\n") + if self.orb_empty_slots and self.orb_empty_slots > 0: + lines.append(f"- {self.orb_empty_slots} empty slots\n") + lines.append("\n") + return "".join(lines) + + def _pets_to_markdown(self) -> str: + """Convert the player's pets to a markdown string.""" + lines = ["### Pets\n\n"] + for pet in self.pets: + lines.append(f"- {pet.to_markdown()}\n") + lines.append("\n") + return "".join(lines) + def to_markdown(self, *, is_diff: bool = False) -> str: + """Convert the combat player state to a markdown string.""" + lines = [] + lines.append(self._hand_to_markdown()) + lines.append(self._piles_to_markdown(is_diff=is_diff)) + if self.orbs is not None: + lines.append(self._orbs_to_markdown()) + if self.pets is not None: + lines.append(self._pets_to_markdown()) + return "".join(lines) diff --git a/mcp/states/player/orb.py b/mcp/states/player/orb.py index 882ad78e..e1cfe8da 100644 --- a/mcp/states/player/orb.py +++ b/mcp/states/player/orb.py @@ -12,3 +12,9 @@ class Orb(BaseModel): passive_val: int evoke_val: int keywords: Keywords + + def to_markdown(self) -> str: + """Convert the orb to a markdown string.""" + val_str = f"Passive: {self.passive_val}, Evoke: {self.evoke_val}" + keyword_names_str = self.keywords.get_keyword_names_str() + return f"**{self.name}** ({val_str}){keyword_names_str} - {self.description}" diff --git a/mcp/states/player/pile_card.py b/mcp/states/player/pile_card.py index b1a9bdbc..65834777 100644 --- a/mcp/states/player/pile_card.py +++ b/mcp/states/player/pile_card.py @@ -6,18 +6,50 @@ class PileCard(BaseModel): name: str description: str - cost: str | None = None + cost: str star_cost: str | None = None + def to_markdown(self) -> str: + """Convert the pile card to a markdown string.""" + star_cost_str = f" + {self.star_cost} star" if self.star_cost else "" + cost_str = f"{self.cost} energy{star_cost_str}" + return f"**{self.name}** ({cost_str}) - {self.description}" + class PileCards(BaseModel): """The state of the player's pile cards.""" pile_cards: dict[str, list[int | PileCard]] # pile card markdown -> [count, pile card] - def __sub__(self, old_pile_cards: "PileCards") -> "PileCards": + def __sub__(self, old: "PileCards") -> "PileCards": """Calculate the difference between two pile card states.""" - pass + diff_pile_cards = dict() + + all_pile_cards = set(self.pile_cards.keys()) | set(old.pile_cards.keys()) + + for pile_card in all_pile_cards: + new_pile_card_info = self.pile_cards.get(pile_card, [0, None]) + old_pile_card_info = old.pile_cards.get(pile_card, [0, None]) + # get pile card object and diff count + pile_card_obj = new_pile_card_info[1] or old_pile_card_info[1] + diff_count = new_pile_card_info[0] - old_pile_card_info[0] + if diff_count != 0: + diff_pile_cards[pile_card] = [diff_count, pile_card_obj] + return PileCards(pile_cards=diff_pile_cards) + + def to_markdown(self, *, is_diff: bool = False) -> str: + """Convert the pile cards to a markdown string.""" + if not self.pile_cards: + return "No pile cards.\n" + + lines = [] + for (count, pile_card) in self.pile_cards.values(): + if is_diff: + count_action = "Added" if count > 0 else "Removed" + lines.append(f"- {count_action} {abs(count)}x {pile_card.to_markdown()}\n") + else: + lines.append(f"- {count}x {pile_card.to_markdown()}\n") + return "".join(lines) @model_validator(mode="before") @classmethod diff --git a/mcp/states/player/player.py b/mcp/states/player/player.py index c473ce10..c65fbc85 100644 --- a/mcp/states/player/player.py +++ b/mcp/states/player/player.py @@ -23,3 +23,75 @@ class PlayerState(BaseModel): status: list[StatusEffect] relics: Relics potions: list[Potion] + + def __sub__(self, old: "PlayerState") -> "PlayerState" | None: + """Calculate the difference between two player states.""" + if self.combat_state is None or old.combat_state is None: + # in this case, the diff state is meaningless + # so we return None to indicate that there is no diff state + return None + else: + data = self.model_dump() + data["combat_state"] = self.combat_state - old.combat_state + return PlayerState(**data) + + def _character_to_markdown(self) -> str: + """Convert the player's character to a markdown string.""" + hp_block_str = f"HP: {self.hp}/{self.max_hp} | Block: {self.block}" + gold_str = f" | Gold: {self.gold}" + if self.combat_state is not None: + energy_str = self.combat_state.energy_to_markdown() + else: + energy_str = "" + return f"**{self.character}** - {hp_block_str}{energy_str}{gold_str}" + + def _status_to_markdown(self) -> str: + """Convert the player's status effects to a markdown string.""" + if not self.status: + return "" + lines = ["### Status\n\n"] + for status in self.status: + lines.append(f"- {status.to_markdown()}\n") + lines.append("\n") + return "".join(lines) + + def _relics_to_markdown(self) -> str: + """Convert the player's relics to a markdown string.""" + return f"### Relics\n\n{self.relics.to_markdown()}\n" + + def _potions_to_markdown(self) -> str: + """Convert the player's potions to a markdown string.""" + if not self.potions: + return "" + lines = ["### Potions\n\n"] + for potion in self.potions: + lines.append(f"- {potion.to_markdown()}\n") + lines.append("\n") + return "".join(lines) + + def to_markdown(self, *, is_diff: bool = False) -> str: + """Convert the player state to a markdown string.""" + lines = ["## Player (YOU)\n\n"] + lines.append(f"{self._character_to_markdown()}\n\n") + lines.append(self._status_to_markdown()) + lines.append(self._relics_to_markdown()) + lines.append(self._potions_to_markdown()) + if self.combat_state is not None: + lines.append(self.combat_state.to_markdown(is_diff=is_diff)) + return "".join(lines) + + @model_validator(mode="before") + @classmethod + def from_json_state(cls, json_state: dict) -> dict: + """Create a player state from a JSON state dict.""" + player_state = dict() + combat_state = dict() + + for key, value in json_state.items(): + if key in ["character", "hp", "max_hp", "block", "gold", "status", "relics", "potions"]: + player_state[key] = value + else: + combat_state[key] = value + + player_state["combat_state"] = combat_state if combat_state else None + return player_state diff --git a/mcp/states/scenario/__init__.py b/mcp/states/scenario/__init__.py index 6b83d8ea..09e7b756 100644 --- a/mcp/states/scenario/__init__.py +++ b/mcp/states/scenario/__init__.py @@ -17,7 +17,6 @@ from states.scenario.treasure import TreasureState from states.scenario.unknown import UnknownState - ScenarioState: TypeAlias = ( BundleSelectState | CardRewardState diff --git a/mcp/states/scenario/card_reward.py b/mcp/states/scenario/card_reward.py index 2fda765c..bbaad70b 100644 --- a/mcp/states/scenario/card_reward.py +++ b/mcp/states/scenario/card_reward.py @@ -11,9 +11,28 @@ class CardReward(BaseModel): cards: list[RewardCard] can_skip: bool + def to_markdown(self) -> str: + """Convert the card reward information to markdown format.""" + if not self.cards: + return "No card rewards available.\n" + + lines = [] + for card in self.cards: + lines.append(f"- {card.to_markdown()}\n") + lines.append("\n") + + can_skip_str = "Yes" if self.can_skip else "No" + lines.append(f"**Can Skip**: {can_skip_str}\n") + return "".join(lines) + class CardRewardState(BaseModel): """The state when the scenario is card reward selection.""" state_type: Literal["card_reward"] card_reward: CardReward + + def to_markdown(self) -> str: + """Convert the state to markdown format.""" + base_str = "## Card Reward Selection State\n\nChoose a card to add to your deck:" + return f"{base_str}\n\n{self.card_reward.to_markdown()}" diff --git a/mcp/states/scenario/menu.py b/mcp/states/scenario/menu.py index d3f08203..8a38ccc5 100644 --- a/mcp/states/scenario/menu.py +++ b/mcp/states/scenario/menu.py @@ -8,3 +8,7 @@ class MenuState(BaseModel): state_type: Literal["menu"] message: str + + def to_markdown(self) -> str: + """Convert the menu state to markdown format.""" + return f"## Menu State\n\n{self.message}" diff --git a/mcp/states/scenario/overlay.py b/mcp/states/scenario/overlay.py index 5a40097a..b0643daa 100644 --- a/mcp/states/scenario/overlay.py +++ b/mcp/states/scenario/overlay.py @@ -9,9 +9,17 @@ class Overlay(BaseModel): screen_type: str message: str + def to_markdown(self) -> str: + """Convert the overlay information to markdown format.""" + return f"{self.message}\n\n**Screen Type**: {self.screen_type}" + class OverlayState(BaseModel): """The state when an unrecognized overlay is active.""" state_type: Literal["overlay"] = "overlay" overlay: Overlay + + def to_markdown(self) -> str: + """Convert the overlay state to markdown format.""" + return f"## Overlay State\n\n{self.overlay.to_markdown()}" diff --git a/mcp/states/scenario/unknown.py b/mcp/states/scenario/unknown.py index 582ff7f6..1e769015 100644 --- a/mcp/states/scenario/unknown.py +++ b/mcp/states/scenario/unknown.py @@ -10,3 +10,9 @@ class UnknownState(BaseModel): # As the scenario is unknown, we allow any extra fields to be stored in the model. model_config = ConfigDict(extra="allow") + + def to_markdown(self) -> str: + """Convert the unknown state to a markdown string.""" + extra_data = self.__pydantic_extra__ + extra_data_str = "\n".join(f"- **{key}**: {value}" for key, value in extra_data.items()) + return f"## Unknown State\n\n{extra_data_str}" diff --git a/mcp/utils/__init__.py b/mcp/tools/context.py similarity index 100% rename from mcp/utils/__init__.py rename to mcp/tools/context.py