diff --git a/mcp/pyproject.toml b/mcp/pyproject.toml index ee527e28..8af15dea 100644 --- a/mcp/pyproject.toml +++ b/mcp/pyproject.toml @@ -10,3 +10,11 @@ dependencies = [ [project.scripts] sts2-mcp = "server:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["."] +exclude = ["tests"] 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 new file mode 100644 index 00000000..a46fbd1d --- /dev/null +++ b/mcp/states/common/card.py @@ -0,0 +1,67 @@ +from pydantic import BaseModel + +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: str | None = None + description: str + 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.""" + + target_type: str + 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 new file mode 100644 index 00000000..eb063bc1 --- /dev/null +++ b/mcp/states/common/keyword.py @@ -0,0 +1,74 @@ +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.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: + """Create a keywords dict from a list of keyword dicts.""" + keywords = 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): + # iterate field values directly to preserve pydantic types + for _, value in state: + 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/multiplayer.py b/mcp/states/common/multiplayer.py new file mode 100644 index 00000000..6f6fedf1 --- /dev/null +++ b/mcp/states/common/multiplayer.py @@ -0,0 +1,76 @@ +from pydantic import BaseModel + +from states.common.pet import Pet + + +class PlayerSummary(BaseModel): + """The summary of a player in multiplayer mode.""" + + 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): + """The state of the multiplayer game.""" + + net_type: str + player_count: int + 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.""" + + 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/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 new file mode 100644 index 00000000..a1a19333 --- /dev/null +++ b/mcp/states/common/potion.py @@ -0,0 +1,21 @@ +from pydantic import BaseModel + +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 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 new file mode 100644 index 00000000..28da4cde --- /dev/null +++ b/mcp/states/common/relic.py @@ -0,0 +1,67 @@ +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 _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.""" + + 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 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 + 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/status_effect.py b/mcp/states/common/status_effect.py new file mode 100644 index 00000000..96c1d055 --- /dev/null +++ b/mcp/states/common/status_effect.py @@ -0,0 +1,26 @@ +from typing import Literal + +from pydantic import BaseModel + +from states.common.keyword import Keywords + + +class StatusEffect(BaseModel): + """The status effect object.""" + + id: str + name: str + 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 new file mode 100644 index 00000000..548a72c5 --- /dev/null +++ b/mcp/states/game.py @@ -0,0 +1,77 @@ +from typing import Literal + +from pydantic import BaseModel, Field, model_validator + +from states.common.multiplayer import MultiplayerState +from states.player import PlayerState +from states.scenario import ScenarioState + + +class RunState(BaseModel): + """The state of the current run.""" + + act: int + 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.""" + + # gamemode + game_mode: Literal["singleplayer", "multiplayer"] + + # multiplayer fields + multiplayer_state: MultiplayerState | None = None + + # common fields + run: RunState | None = None + player: PlayerState | None = None + + # 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: + """Create a GameState instance from a JSON state.""" + game_state = dict() + multiplayer_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] + 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/__init__.py b/mcp/states/player/__init__.py new file mode 100644 index 00000000..210492d6 --- /dev/null +++ b/mcp/states/player/__init__.py @@ -0,0 +1,3 @@ +from states.player.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..fba8b9f0 --- /dev/null +++ b/mcp/states/player/combat.py @@ -0,0 +1,99 @@ +from pydantic import BaseModel + +from states.common.card import HandCard +from states.common.pet import Pet +from states.player.orb import Orb +from states.player.pile_card import PileCards + + +class CombatPlayerState(BaseModel): + """The combat-specific state of the player.""" + + energy: int + max_energy: int + stars: int | None = None + # 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] | 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 new file mode 100644 index 00000000..e1cfe8da --- /dev/null +++ b/mcp/states/player/orb.py @@ -0,0 +1,20 @@ +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 + + 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 new file mode 100644 index 00000000..65834777 --- /dev/null +++ b/mcp/states/player/pile_card.py @@ -0,0 +1,66 @@ +from pydantic import BaseModel, model_validator + + +class PileCard(BaseModel): + """The card in the player's pile (draw pile, discard pile, exhaust pile).""" + + name: str + description: str + 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: "PileCards") -> "PileCards": + """Calculate the difference between two pile card states.""" + 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 + 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 new file mode 100644 index 00000000..c65fbc85 --- /dev/null +++ b/mcp/states/player/player.py @@ -0,0 +1,97 @@ +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 + + +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: 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 new file mode 100644 index 00000000..09e7b756 --- /dev/null +++ b/mcp/states/scenario/__init__.py @@ -0,0 +1,39 @@ +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 = ( + 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 new file mode 100644 index 00000000..ff7cb392 --- /dev/null +++ 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 new file mode 100644 index 00000000..bbaad70b --- /dev/null +++ b/mcp/states/scenario/card_reward.py @@ -0,0 +1,38 @@ +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 + + 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/card_select.py b/mcp/states/scenario/card_select.py new file mode 100644 index 00000000..54039011 --- /dev/null +++ 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 new file mode 100644 index 00000000..eca63953 --- /dev/null +++ b/mcp/states/scenario/combat.py @@ -0,0 +1,47 @@ +from typing import Literal + +from pydantic import BaseModel + +from states.common.status_effect import StatusEffect + + +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: list[StatusEffect] + intents: list[Intent] + + +class Battle(BaseModel): + """The state of the battle.""" + + round: int + turn: Literal["player", "enemy"] + is_play_phase: bool + enemies: list[Enemy] + + # multiplayer fields + all_players_ready: bool | None = None + + +class CombatState(BaseModel): + """The state when the scenario is in the combat (monster or elite or boss).""" + + state_type: Literal["monster", "elite", "boss"] + message: str | None = None + battle: Battle | None = None 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..6731d223 --- /dev/null +++ b/mcp/states/scenario/event.py @@ -0,0 +1,43 @@ +from typing import Literal + +from pydantic import BaseModel + +from states.common.keyword import Keywords +from states.common.multiplayer import EventVote + + +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 | None + options: list[EventOption] + + # multiplayer fields + is_shared: bool | None = None + votes: list[EventVote] | None = None + all_voted: bool | None = None + + +class EventState(BaseModel): + """The state when the scenario is in an event.""" + + state_type: Literal["event"] + event: Event 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 new file mode 100644 index 00000000..8c01a241 --- /dev/null +++ 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/map.py b/mcp/states/scenario/map.py new file mode 100644 index 00000000..0d1e169f --- /dev/null +++ b/mcp/states/scenario/map.py @@ -0,0 +1,52 @@ +from typing import Literal + +from pydantic import BaseModel + +from states.common.multiplayer import MapVote + + +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.""" + + index: int + leads_to: list[NodeWithType] | None = None # null when the next node is a boss node + + +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 | None = None + visited: list[NodeWithType] + next_options: list[NextNode] + nodes: list[DAGNode] + boss: Node + + # multiplayer fields + votes: list[MapVote] | None = None + all_voted: bool | None = None + + +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..8a38ccc5 --- /dev/null +++ b/mcp/states/scenario/menu.py @@ -0,0 +1,14 @@ +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 + + 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 new file mode 100644 index 00000000..b0643daa --- /dev/null +++ b/mcp/states/scenario/overlay.py @@ -0,0 +1,25 @@ +from typing import Literal + +from pydantic import BaseModel + + +class Overlay(BaseModel): + """The information of the overlay.""" + + 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/relic_select.py b/mcp/states/scenario/relic_select.py new file mode 100644 index 00000000..a2420eb4 --- /dev/null +++ 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/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..54bd63df --- /dev/null +++ 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 + price: 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 new file mode 100644 index 00000000..f821bf57 --- /dev/null +++ b/mcp/states/scenario/treasure.py @@ -0,0 +1,26 @@ +from typing import Literal + +from pydantic import BaseModel + +from states.common.multiplayer import TreasureVote +from states.common.relic import RewardRelic + + +class Treasure(BaseModel): + """The treasure object.""" + + 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): + """The state when the scenario is in the treasure room.""" + + state_type: Literal["treasure"] + treasure: Treasure diff --git a/mcp/states/scenario/unknown.py b/mcp/states/scenario/unknown.py new file mode 100644 index 00000000..1e769015 --- /dev/null +++ b/mcp/states/scenario/unknown.py @@ -0,0 +1,18 @@ +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") + + 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/tests/test_game_state.py b/mcp/tests/test_game_state.py new file mode 100644 index 00000000..e69de29b diff --git a/mcp/tools/context.py b/mcp/tools/context.py new file mode 100644 index 00000000..e69de29b 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}"