From 7325c46f96f6b147e7b5948ea50f82c870bcab31 Mon Sep 17 00:00:00 2001 From: Mari Date: Sat, 18 Mar 2023 02:24:46 -0400 Subject: [PATCH] First commit --- .idea/.gitignore | 8 + .idea/fabula.iml | 10 + .../inspectionProfiles/profiles_settings.xml | 6 + .idea/misc.xml | 4 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + actions.py | 71 ++++ app.py | 157 +++++++++ effects.py | 59 ++++ embedgen.py | 283 ++++++++++++++++ enums.py | 101 ++++++ jsonable.py | 279 ++++++++++++++++ main.py | 10 + model.py | 70 ++++ requirements.txt | 2 + ui.py | 312 ++++++++++++++++++ 16 files changed, 1386 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 .idea/fabula.iml create mode 100644 .idea/inspectionProfiles/profiles_settings.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 actions.py create mode 100644 app.py create mode 100644 effects.py create mode 100644 embedgen.py create mode 100644 enums.py create mode 100644 jsonable.py create mode 100644 main.py create mode 100644 model.py create mode 100644 requirements.txt create mode 100644 ui.py diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/fabula.iml b/.idea/fabula.iml new file mode 100644 index 0000000..f5beddb --- /dev/null +++ b/.idea/fabula.iml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..11e08a1 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..a3e1af8 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/actions.py b/actions.py new file mode 100644 index 0000000..9956649 --- /dev/null +++ b/actions.py @@ -0,0 +1,71 @@ +from dataclasses import dataclass + +from effects import DamageEffect +from enums import CombatSide +from jsonable import JsonableDataclassArgs +from model import Action, Target, Effect, Character, Clock + + +@dataclass(**JsonableDataclassArgs) +class AbilityAction(Action): + name: str + user: Target + costs: tuple[DamageEffect, ...] + effects: tuple[Effect, ...] + + +@dataclass(**JsonableDataclassArgs) +class ModifyCharacterAction(Action): + index: int + old_character_data: Character | None + new_character_data: Character | None + + def __post_init__(self): + if self.old_character_data is None and self.new_character_data is None: + raise ValueError("At least one of old_character_data or new_character_data must be non-None") + + +@dataclass(**JsonableDataclassArgs) +class ModifyClockAction(Action): + index: int + old_clock_data: Clock | None + new_clock_data: Clock | None + + def __post_init__(self): + if self.old_clock_data is None and self.new_clock_data is None: + raise ValueError("At least one of old_clock_data or new_clock_data must be non-None") + + +@dataclass(**JsonableDataclassArgs) +class EndTurnAction(Action): + turn_ending_index: int + activating_side: CombatSide + + +@dataclass(**JsonableDataclassArgs) +class StartTurnAction(Action): + turn_starting_index: int + old_active_side: CombatSide + + +@dataclass(**JsonableDataclassArgs) +class StartRoundAction(Action): + last_round: int + old_active_side: CombatSide + old_turns_remaining: tuple[int, ...] + next_round: int + activating_side: CombatSide + + +@dataclass(**JsonableDataclassArgs) +class StartBattleAction(Action): + starting_side: CombatSide + starting_round: int + + +@dataclass(**JsonableDataclassArgs) +class EndBattleAction(Action): + old_round_number: int + old_active_side: CombatSide + old_starting_side: CombatSide + old_turns_remaining: tuple[int, ...] diff --git a/app.py b/app.py new file mode 100644 index 0000000..778a577 --- /dev/null +++ b/app.py @@ -0,0 +1,157 @@ +import asyncio + +from discord_webhook import DiscordEmbed +from urwid import MainLoop, AsyncioEventLoop, set_encoding + +from embedgen import WebhookExecutor, simple_character_field +from model import Character +from enums import CharacterType, Visibility, CombatSide +from ui import LogUI, CharactersUI, ClocksUI, FabulaUI, MainUI + + +class FabulaApp(object): + def __init__(self, wrapper: FabulaUI, clocks: ClocksUI, characters: CharactersUI, + log: LogUI, webhook: WebhookExecutor): + self.wrapper = wrapper + self.clocks = clocks + self.characters = characters + self.log = log + self.log.add_message("this is a log message") + self.log.add_message("this is a second log message with some **bolded** text") + self.log.move_divider() + self.log.add_message("this is the newest message, since the last log was sent") + prandia = Character(name="Prandia", hp=53, max_hp=65, mp=55, max_mp=55, ip=6, max_ip=6, max_turns=1, + turns_left=0, sp=3, statuses=frozenset(("Delicious", "Dazed")), affinities=tuple(), + access_key='1', visibility=Visibility.ShowEvenIfKO, mdf=7, df=11, dr=0, + character_type=CharacterType.PlayerCharacter, side=CombatSide.Heroes, + color="34eb89", player_discord_id="579932273558945792", player_name="Fak", + thumbnail="https://media.discordapp.net/attachments/989315969920929862/1058455278221271120/" + + "342px-Bluhen_Epic_Quest_Square.png") + self.characters.add_character(prandia) + selia = Character(name="Sélia", hp=12, max_hp=50, mp=55, max_mp=55, ip=0, max_ip=0, max_turns=1, + turns_left=1, sp=None, statuses=frozenset(["Enraged"]), affinities=tuple(), + access_key='2', visibility=Visibility.ShowAll, mdf=7, df=11, dr=0, + character_type=CharacterType.NonPlayerCharacter, side=CombatSide.Heroes, + color=None, thumbnail=None, player_discord_id=None, player_name=None) + self.characters.add_character(selia) + prandia_voraphilia = Character(name="Prandia's Voraphilia Resistance", hp=0, max_hp=1, mp=1, max_mp=1, ip=0, + max_ip=0, + max_turns=1, turns_left=1, sp=None, statuses=frozenset(), affinities=tuple(), + access_key='', + character_type=CharacterType.NonPlayerCharacter, side=CombatSide.Heroes, + visibility=Visibility.Hide, mdf=7, df=11, dr=0, color="34eb89", + thumbnail=None, player_discord_id=None, player_name=None) + self.characters.add_character(prandia_voraphilia) + vacana = Character(name="Hungry Vacana", hp=53, max_hp=53, mp=55, max_mp=55, ip=6, max_ip=6, max_turns=2, + turns_left=1, sp=3, statuses=frozenset(["Transformed"]), affinities=tuple(), access_key='A', + visibility=Visibility.MaskStats, mdf=7, df=11, dr=0, + character_type=CharacterType.PlayerCharacter, side=CombatSide.Villains, + player_name="Nan", player_discord_id="158766486725328897", color="a50ecf", + thumbnail="https://images-ext-1.discordapp.net/external/" + + "bMwLWSrrCyGiqgJ14icQjXXllStzsKbQ-htrANQdSJA/" + + "https/cdn.picrew.me/shareImg/org/202211/1561797_pzHTS4JJ.png") + self.characters.add_character(vacana) + flow = Character(name="Hungry Flow", hp=69, max_hp=120, mp=55, max_mp=55, ip=0, max_ip=0, max_turns=2, + turns_left=2, sp=3, statuses=frozenset(), affinities=tuple(), access_key='B', + visibility=Visibility.MaskName, mdf=7, df=11, dr=0, + character_type=CharacterType.NonPlayerCharacter, side=CombatSide.Villains, player_name=None, + player_discord_id=None, color="0073ff", thumbnail=None) + self.characters.add_character(flow) + vacana_hunger = Character(name="Vacana's Hunger", hp=27, max_hp=60, mp=1, max_mp=1, ip=0, max_ip=0, max_turns=2, + turns_left=0, sp=None, statuses=frozenset(), affinities=tuple(), access_key='', + visibility=Visibility.Hide, mdf=7, df=11, dr=0, + character_type=CharacterType.NonPlayerCharacter, side=CombatSide.Villains, + color="a50ecf", + thumbnail=None, player_name=None, player_discord_id=None, ) + self.characters.add_character(vacana_hunger) + self.webhook = webhook + self.character_list = (prandia, selia, prandia_voraphilia, vacana, flow, vacana_hunger) + self.event_loop = None + self.loop = None + + @classmethod + def new(cls, webhook_url: str): + clocks = ClocksUI() + characters = CharactersUI() + log = LogUI() + main = MainUI(clocks, characters, log) + wrapper = FabulaUI(main) + webhook = WebhookExecutor(url=webhook_url) + return cls(wrapper=wrapper, clocks=clocks, characters=characters, + log=log, webhook=webhook) + + def keypress(self, key): + if key == "l": + self.log.add_message("Another log message, eh? Sure, I can hook you up with **one of those.** Here I go!") + + def run(self): + set_encoding("UTF-8") + self.event_loop = asyncio.new_event_loop() + loop = MainLoop( + widget=self.wrapper, + unhandled_input=self.keypress, + event_loop=AsyncioEventLoop(loop=self.event_loop), + palette=[ + (None, "light gray", "default", "default"), + ("FrameTitle", "white,bold", "default", "bold"), + ("LogBold", "white,bold", "default", "bold"), + ("LogFocused", "light cyan,bold", "default", "bold"), + ("LogUnfocused", "dark blue", "default", "default"), + ("ClockName", "white,bold", "default", "bold"), + ("ClockFocused", "light green,bold", "default", "bold"), + ("ClockUnfocused", "dark green", "default", "default"), + ("ClockBarEdge", 'yellow,bold', 'default', 'bold'), + ("ClockBarFilled", 'yellow', 'default', 'default'), + ("ClockBarEmpty", 'light gray', 'default', 'default'), + ("ClockCurrent", 'light green,bold', 'default', 'bold'), + ("ClockDivider", 'white', 'default', 'default'), + ("ClockMax", 'dark green', 'default', 'default'), + ("RoundLabel", 'light gray', 'default', 'default'), + ("RoundNumber", 'white,bold', 'default', 'bold'), + ("FabulaLabel", 'light gray', 'default', 'default'), + ("FabulaNumber", 'white,bold', 'default', 'bold'), + ("UltimaLabel", 'light gray', 'default', 'default'), + ("UltimaNumber", 'white,bold', 'default', 'bold'), + ("CharacterUnfocused", 'dark red', 'default', 'default'), + ("CharacterFocused", 'light red,bold', 'default', 'bold'), + ("CharacterPlayer", 'light green,bold', 'default', 'bold'), + ("CharacterAlly", 'dark green,bold', 'default', 'bold'), + ("CharacterEnemy", 'dark red,bold', 'default', 'bold'), + ("CharacterEnemyPlayer", 'light red,bold', 'default', 'bold'), + ("CharacterUnknown", 'light gray,bold', 'default', 'bold'), + ("TurnDisabled", 'dark gray', 'default', 'default'), + ("TurnAvailable", 'light green,bold', 'default', 'bold'), + ("TurnActed", 'light gray', 'default', 'default'), + ("HPFull", 'light green,bold', 'default', 'bold'), + ("HPScratched", 'light green', 'default', 'default'), + ("HPWounded", 'white', 'default', 'default'), + ("HPCrisis", 'yellow', 'default', 'default'), + ("HPPeril", 'light red', 'default', 'default'), + ("HPDown", 'dark red,bold', 'default', 'bold'), + ("HPLabel", 'light gray', 'default', 'default'), + ("HPMax", 'light gray', 'default', 'default'), + ("MPLabel", 'light gray', 'default', 'default'), + ("MPValue", 'light blue', 'default', 'default'), + ("MPMax", 'light gray', 'default', 'default'), + ("FPLabel", 'light gray', 'default', 'default'), + ("UPLabel", 'light gray', 'default', 'default'), + ("FPValue", 'yellow', 'default', 'default'), + ("UPValue", 'yellow', 'default', 'default'), + ("IPLabel", 'light gray', 'default', 'default'), + ("IPValue", 'light cyan', 'default', 'default'), + ("IPMax", 'light gray', 'default', 'default'), + ("StatusKO", 'light red,bold', 'default', 'bold,standout'), + ("StatusCrisis", 'yellow,bold', 'default', 'bold'), + ("Statuses", 'white', 'default', 'default'), + ]) + loop.screen.set_terminal_properties( + colors=2 ** 24, + has_underline=True, + bright_is_bold=False, + ) + embed = DiscordEmbed() + for header, field in [simple_character_field(character) for character in self.character_list]: + embed.add_embed_field(name=header, value=field, inline=False) + self.loop.run() + self.loop = None + self.event_loop = None diff --git a/effects.py b/effects.py new file mode 100644 index 0000000..8fb9aaf --- /dev/null +++ b/effects.py @@ -0,0 +1,59 @@ +from dataclasses import dataclass + +from enums import Counter, Element, Affinity, MissType +from jsonable import JsonableDataclassArgs +from model import Effect, Target, Action + + +@dataclass(**JsonableDataclassArgs) +class OpportunityEffect(Effect): + target: Target + opportunity_text: str + + +@dataclass(**JsonableDataclassArgs) +class SubActionEffect(Effect): + action: Action + trigger_text: str + + +@dataclass(**JsonableDataclassArgs) +class DamageEffect(Effect): + target: Target | None + attribute: Counter + damage: int + old_value: int + new_value: int + max_value: int | None + element: Element + affinity: Affinity + piercing: bool + + +@dataclass(**JsonableDataclassArgs) +class StatusEffect(Effect): + target: Target + old_status: str | None + new_status: str | None + + def __post_init__(self): + if self.old_status is None and self.new_status is None: + raise ValueError("At least one of old_status or new_status must be non-None") + + +@dataclass(**JsonableDataclassArgs) +class FPBonusEffect(Effect): + rerolls: int + bond_bonus: int + + +@dataclass(**JsonableDataclassArgs) +class MissEffect(Effect): + miss_type: MissType + target: Target + + +@dataclass(**JsonableDataclassArgs) +class GeneralEffect(Effect): + text: str + target: Target diff --git a/embedgen.py b/embedgen.py new file mode 100644 index 0000000..c73b2dd --- /dev/null +++ b/embedgen.py @@ -0,0 +1,283 @@ +from math import ceil +from typing import Iterable + +from discord_webhook import DiscordEmbed, AsyncDiscordWebhook + +from model import Character + +CantMoveEmoji = "◼" +DoneMovingEmoji = "☑️" +NotMovedEmoji = "🟦" +TurnsLeftEmoji = [ + "0️⃣", + "1️⃣", + "2️⃣", + "3️⃣", + "4️⃣", + "5️⃣", + "6️⃣", + "7️⃣", + "8️⃣", + "9️⃣", + "🔟", + "#️⃣", +] + +IndicatorKeys = { + None: "◼", + "0": "0️⃣", + "1": "1️⃣", + "2": "2️⃣", + "3": "3️⃣", + "4": "4️⃣", + "5": "5️⃣", + "6": "6️⃣", + "7": "7️⃣", + "8": "8️⃣", + "9": "9️⃣", + "A": "🇦", + "B": "🇧", + "C": "🇨", + "D": "🇩", + "E": "🇪", + "F": "🇫", + "G": "🇬", + "H": "🇭", + "I": "🇮", + "J": "🇯", + "K": "🇰", + "L": "🇱", + "M": "🇲", + "N": "🇳", + "O": "🇴", + "P": "🇵", + "Q": "🇶", + "R": "🇷", + "S": "🇸", + "T": "🇹", + "U": "🇺", + "V": "🇻", + "W": "🇼", + "X": "🇽", + "Y": "🇾", + "Z": "🇿", +} + + +def simple_character_field(c: Character) -> tuple[str, str]: + header, statuses = character_header(c) + + if c.visibility.show_stats: + body = [ + f"**HP** {c.hp}/{c.max_hp}", + f"**MP** {c.mp}/{c.max_mp}", + ] + else: + hp_caption, _ = hp_text(c.hp, c.max_hp) + mp_caption, _ = mp_text(c.mp, c.max_mp) + body = [ + f"**HP** {hp_caption}", + f"**MP** {mp_caption}", + ] + + body.extend(character_details(c, statuses)) + + return "".join(header), " / ".join(body) + + +def detailed_character_field(c: Character) -> tuple[str, str]: + header, statuses = character_header(c) + + if c.visibility.show_stats: + body = [ + f'**HP** {bar_string(c.hp, c.max_hp, length=80)} {c.hp}/{c.max_hp}', + f'**MP** {bar_string(c.mp, c.max_mp, length=80)} {c.mp}/{c.max_mp}', + ] + else: + hp_caption, hp_value = hp_text(c.hp, c.max_hp) + mp_caption, mp_value = mp_text(c.mp, c.max_mp) + body = [ + f'**HP** {bar_string(hp_value, 5, length=80)} {hp_caption}', + f'**MP** {bar_string(mp_value, 5, length=80)} {mp_caption}', + ] + + details = character_details(c, statuses) + + if len(details) > 0: + body.append(" / ".join(details)) + + return "".join(header), "\n".join(body) + + +def detailed_character_embed(c: Character) -> DiscordEmbed: + header, statuses = character_header(c) + + if c.visibility.show_stats: + fields = [ + ( + f"**HP** {c.hp}/{c.max_hp}", + bar_string(c.hp, c.max_hp, length=80), + False, + ), + ( + f"**MP** {c.mp}/{c.max_mp}", + bar_string(c.mp, c.max_mp, length=80), + False, + ), + ] + else: + hp_caption, _ = hp_text(c.hp, c.max_hp) + mp_caption, _ = mp_text(c.mp, c.max_mp) + fields = [ + ( + f"**HP**", + hp_caption, + True, + ), + ( + f"**MP**", + mp_caption, + True, + ), + ] + + if c.max_ip > 0: + fields.append((f"**IP**", f"{c.ip}/{c.max_ip}", True)) + if c.sp is not None: + fields.append((f"**{c.character_type.sp_name_abbr}**", str(c.sp), True)) + if len(statuses) > 0: + fields.append(("**Status**", f'_{", ".join(statuses)}_', True)) + + result = DiscordEmbed( + title="".join(header), + ) + if c.player_name is not None: + result.set_footer(text=f'Player: {c.player_name}') + if c.color is not None: + result.set_color(c.color) + if c.thumbnail is not None: + result.set_thumbnail(c.thumbnail) + for name, value, inline in fields: + result.add_embed_field(name, value, inline) + + return result + + +def character_header(c): + header = [ + turn_icon(c), + IndicatorKeys.get(c.access_key, IndicatorKeys[None]), + " ", + f'**{c.name}**' if c.visibility.show_name else '***???***' + ] + statuses = list(sorted(str(s) for s in c.statuses)) + hp = hp_marker(c.hp, c.max_hp) + if hp is not None: + header.append(" ") + icon, status = hp + statuses.insert(0, status), + header.append(icon) + return header, statuses + + +def character_details(c, statuses): + result = [] + if c.max_ip > 0: + result.append(f"**IP** {c.ip}/{c.max_ip}") + if c.sp is not None: + result.append(f"**{c.role.sp_name_abbr}** {c.sp}") + if len(statuses) > 0: + result.append(f'_{", ".join(statuses)}_') + return result + + +def bar_string(val: int, mx: int, delta: int | None = None, length: int | None = None) -> str: + effective_delta = delta if delta is not None else 0 + length = length if length is not None else mx + if effective_delta >= 0: + filled_size = ceil(length * val / mx) + prev_size = ceil(length * (val + effective_delta) / mx) + delta_size = filled_size - prev_size + bar = '\\|' if prev_size % 2 == 1 else "" + bar += (prev_size // 2) * "\\||" + if delta_size > 0: + bar += f'**{"⏐" * delta_size}**' + bar += (length - filled_size) * "·" + else: + filled_size = ceil(length * val / mx) + prev_size = ceil(length * (val - effective_delta) / mx) + delta_size = prev_size - filled_size + bar = '\\|' if filled_size % 2 == 1 else "" + bar += (filled_size // 2) * "\\||" + if delta_size > 0: + bar += f'~~{"·" * delta_size}~~' + bar += (length - prev_size) * "·" + + return bar + + +def turn_icon(c: Character) -> str: + if c.max_turns == 0 or c.hp == 0: + return CantMoveEmoji + elif c.turns_left == 0: + return DoneMovingEmoji + elif c.max_turns == 1: + return NotMovedEmoji + else: + return TurnsLeftEmoji[-1] if c.turns_left >= len(TurnsLeftEmoji) else TurnsLeftEmoji[c.turns_left] + + +def hp_marker(hp: int, max_hp: int) -> tuple[str, str] | None: + if hp == 0: + return "🏳️", "**Surrendered**" + elif hp * 2 < max_hp: + return "⚠️", "**Crisis**" + else: + return None + + +def hp_text(hp: int, max_hp: int) -> tuple[str, int]: + if hp == 0: + return "Defeated", 0 + elif hp * 4 < max_hp: + return "Peril", 1 + elif hp * 2 < max_hp: + return "Crisis", 2 + elif hp * 4 < 3 * max_hp: + return "Wounded", 3 + elif hp < max_hp: + return "Scratched", 4 + else: + return "Full", 5 + + +def mp_text(mp: int, max_mp: int) -> tuple[str, int]: + if mp == 0: + return "Empty", 0 + elif mp * 4 < max_mp: + return "Exhausted", 1 + elif mp * 2 < max_mp: + return "Tired", 2 + elif mp * 4 < 3 * max_mp: + return "Flagging", 3 + elif mp < max_mp: + return "Energetic", 4 + else: + return "Full", 5 + + +class WebhookExecutor(object): + __slots__ = ["url"] + + def __init__(self, url: str): + self.url = url + + async def run(self, embeds: Iterable[DiscordEmbed]): + webhook = AsyncDiscordWebhook( + url=self.url, + username="Party Status", + avatar_url="https://media.discordapp.net/attachments/989315969920929862/" + + "1084018762606444634/heartmonitor.png", + embeds=embeds, + ) + await webhook.execute(False) diff --git a/enums.py b/enums.py new file mode 100644 index 0000000..eecbf11 --- /dev/null +++ b/enums.py @@ -0,0 +1,101 @@ +from enum import Enum + + +class Affinity(Enum): + Absorb = "absorb" + Immune = "immune" + Resistant = "resistant" + Normal = "normal" + Vulnerable = "vulnerable" + + +class Element(Enum): + NonElemental = "non-elemental" + Physical = "physical" + Poison = "poison" + Fire = "fire" + Water = "water" + Lightning = "lightning" + Earth = "earth" + Wind = "wind" + Ice = "ice" + Light = "light" + Dark = "dark" + + +class CharacterType(Enum): + PlayerCharacter = "pc" + NonPlayerCharacter = "npc" + + @property + def sp_name_abbr(self): + if self is CharacterType.PlayerCharacter: + return "FP" + elif self is CharacterType.NonPlayerCharacter: + return "UP" + else: + return "SP" + + @property + def sp_name(self): + if self is CharacterType.PlayerCharacter: + return "Fabula Points" + elif self is CharacterType.NonPlayerCharacter: + return "Ultima Points" + else: + return "Special Points" + + +class Visibility(Enum): + ShowEvenIfKO = "show_even_if_ko" + ShowAll = "show" + MaskStats = "mask_stats" + MaskName = "mask_name" + Hide = "hide" + + @property + def show_name(self): + return self not in (Visibility.MaskName, Visibility.Hide) + + @property + def show_stats(self): + return self not in (Visibility.MaskStats, Visibility.MaskName, Visibility.Hide) + + def show_in_party_list(self, down=False): + if down: + return self == Visibility.ShowEvenIfKO + else: + return self != Visibility.Hide + + +class Counter(Enum): + HP = "HP" + MP = "MP" + IP = "IP" + SP = "SP" + + +class MissType(Enum): + Missed = "missed" + Blocked = "blocked" + Avoided = "avoided" + Repelled = "repelled" + Resisted = "resisted" + + +class CombatSide(Enum): + Heroes = "heroes" + Villains = "villains" + + @property + def opposite(self): + if self == CombatSide.Heroes: + return CombatSide.Villains + else: + return CombatSide.Heroes + + +class StatusChange(Enum): + Added = "added" + Removed = "removed" + Changed = "changed" diff --git a/jsonable.py b/jsonable.py new file mode 100644 index 0000000..9db02fa --- /dev/null +++ b/jsonable.py @@ -0,0 +1,279 @@ +from dataclasses import dataclass, fields, MISSING +from enum import Enum +from functools import reduce +from itertools import chain +from typing import ClassVar, get_origin, Union, get_args, Mapping, Any, Sequence +from types import UnionType +from warnings import warn + +JsonableDataclassArgs = {"init": True, "repr": True, "eq": True, "order": False, "frozen": True, "match_args": True, + "kw_only": True, "slots": True, "weakref_slot": True} + +JsonableParentArgs = {"init": False, "repr": True, "eq": True, "order": False, "frozen": True, "match_args": True, + "kw_only": True, "slots": True, "weakref_slot": True} + + +@dataclass(**JsonableParentArgs) +class Jsonable(object): + subclass_unique_attr_dict: ClassVar[dict[str, type["Jsonable"]]] = {} + subclass_attr_set: ClassVar[set[str]] = set() + + def __init__(self, **kwargs): + raise TypeError(f"{friendly_type(type(self))} is not instantiable") + + @classmethod + def instantiable(cls): + return "__init__" in cls.__dict__ and cls.__init__ is not Jsonable.__init__ + + def __init_subclass__(cls): + cls.subclass_attr_set = set(field.name for field in fields(cls)) + cls.subclass_unique_attr_dict = dict.fromkeys(cls.subclass_attr_set, cls) if cls.instantiable() else {} + bases = list(cls.__bases__) + while len(bases) > 0: + base = bases.pop(0) + if (hasattr(base, "subclass_attr_set") and isinstance(base.subclass_attr_set, set) + and hasattr(base, "subclass_unique_attr_dict") and isinstance(base.subclass_unique_attr_dict, + dict)): + if hasattr(base, "instantiable") and callable(base.instantiable) and base.instantiable(): + warn(f"Created subclass {cls} of instantiable class {base}") + bases.extend(base.__bases__) + deleted_classes = {cls} if cls.instantiable() else set() + for key in cls.subclass_attr_set.intersection(base.subclass_unique_attr_dict.keys()): + deleted_classes.update((base.subclass_unique_attr_dict.pop(key),)) + if cls.instantiable(): + unique_attrs = cls.subclass_attr_set.difference(base.subclass_attr_set) + base.subclass_unique_attr_dict.update(((key, cls) for key in unique_attrs)) + lost_classes = deleted_classes.difference(base.subclass_unique_attr_dict.values()) + if len(lost_classes) > 0: + lost_class_string = "/".join(friendly_type(subclass) for subclass in lost_classes) + warn(f'No unique attributes for {lost_class_string} in {friendly_type(base)}', + category=RuntimeWarning, stacklevel=5) + base.subclass_attr_set.update(cls.subclass_attr_set) + + @classmethod + def from_json_object_self_only(cls, value: Mapping[str, Any]): + result = {} + raised = [] + missing = set() + unused = set(value.keys()) + for field in fields(cls): + if field.name not in value: + if field.default is MISSING and field.default_factory is MISSING: + missing.add(field.name) + else: + unused.remove(field.name) + try: + result[field.name] = convert_json_to_object(value[field.name], field.type) + except Exception as ex: + ex.add_note( + f"when deserializing value {repr(value[field.name])} ({friendly_type(type(field.name))}) " + f"for field {field.name} ({friendly_type(field.type)}) " + f"of {friendly_type(cls)}") + raised.append(ex) + if len(unused) > 0: + raised.append(ValueError(f'Unused fields {", ".join(sorted(repr(n) for n in unused))}')) + if len(missing) > 0: + raised.append(ValueError(f'Missing fields {", ".join(sorted(repr(n) for n in missing))}')) + raise_exceptions(raised, for_type=cls, value=value) + return cls(**result) + + @classmethod + def from_json_object(cls, value: Mapping[str, Any]): + if cls.instantiable(): + return cls.from_json_object_self_only(value) + else: + for key, subclass in cls.subclass_unique_attr_dict.items(): + if key in value: + return subclass.from_json_object_self_only(value) + raise TypeError( + f"No obvious subclass of {friendly_type(cls)} " + f"(didn't find any of {', '.join(sorted(repr(key) for key in cls.subclass_unique_attr_dict.keys()))}) " + f"for {repr(value)}") + + def to_json_object(self) -> Mapping[str, Any]: + return dict(chain(((field.name, convert_object_to_json(getattr(self, field.name))) + for field in fields(self)), + ((key, convert_object_to_json(value)) + for key, value in self.__dict__.items()) + if hasattr(self, "__dict__") else tuple())) + + +def raise_exceptions(raised: Sequence[Exception], for_type, value) -> None: + if len(raised) == 0: + return + elif len(raised) == 1: + raised = raised[0] + raised.add_note(f'for {friendly_type(for_type)}') + raise raised + else: + raise ExceptionGroup( + f'When deserializing {repr(value)} ({friendly_type(type(value))}) ' + f'to {friendly_type(for_type)}', raised) + + +class WrongTypeError(TypeError): + def __init__(self, actual, *, for_type, expected_type): + super().__init__(f'Wrong type {friendly_type(type(actual))} for value {repr(actual)} ' + f'when deserializing {friendly_type(for_type)} ' + f'- expected {friendly_type(expected_type)}') + + +def expected_json_type(value_type): + if get_origin(value_type) in [Union, UnionType]: + return reduce(lambda x, y: x | y, (expected_json_type(t) for t in get_args(value_type))) + elif value_type in (str, int, float, bool, type(None)): + return value_type + elif (is_heterogeneous_tuple_type(value_type) or is_homogeneous_tuple_type(value_type) + or get_origin(value_type) in (list, set, frozenset)): + return list + elif issubclass(value_type, Jsonable) or value_type is dict: + return dict + elif issubclass(value_type, Enum): + return get_enum_type(value_type) + else: + raise TypeError(f'Cannot deserialize objects of type {friendly_type(value_type)}') + + +def convert_json_to_object(value, value_type): + expect_type = expected_json_type(value_type) + if not isinstance(value, expect_type): + raise WrongTypeError(value, for_type=value_type, expected_type=expect_type) + if get_origin(value_type) in [Union, UnionType]: + raised = [] + for subtype in get_args(value_type): + if not isinstance(value, expected_json_type(subtype)): + continue + try: + return convert_json_to_object(value, subtype) + except Exception as ex: + raised.append(ex) + raise_exceptions(raised, for_type=value_type, value=value) + raise AssertionError( + f'Unexpectedly failed to throw or return when deserializing ' + f'value {repr(value)} to {friendly_type(value_type)} - expected {friendly_type(expect_type)} ' + f'and was {friendly_type(type(value))} but still no type matched') + elif value_type in (str, int, float, bool, type(None)): + return value + elif is_heterogeneous_tuple_type(value_type): + if not isinstance(value, list): + raise WrongTypeError(value, for_type=value_type, expected_type=list) + item_types = get_args(value_type) + if len(value) != len(item_types): + raise ValueError( + f'Wrong number of elements {len(value)} (should be {len(item_types)}) ' + f'for {repr(value)} ({friendly_type(type(value))}) ' + f'to deserialize it to {friendly_type(value_type)}') + result = [] + raised = [] + for index, pair in enumerate(zip(value, item_types)): + item, item_type = pair + try: + result.append(convert_json_to_object(item, item_type)) + except Exception as ex: + ex.add_note(f'when deserializing element #{index}' + f' - {repr(item)} ({friendly_type(type(item))}) - ' + f'to {friendly_type(item_type)}') + raised.append(ex) + raise_exceptions(raised, for_type=value_type, value=value) + return value_type(result) + elif is_homogeneous_tuple_type(value_type) or get_origin(value_type) in [set, frozenset, list]: + if not isinstance(value, list): + raise WrongTypeError(value, for_type=value_type, expected_type=list) + item_type = get_args(value_type)[0] + result = [] + raised = [] + for index, item in enumerate(value): + try: + result.append(convert_json_to_object(item, item_type)) + except Exception as ex: + ex.add_note(f'when deserializing element #{index}' + f' - {repr(item)} ({friendly_type(type(item))}) - ' + f'to {friendly_type(item_type)}') + raised.append(ex) + raise_exceptions(raised, for_type=value_type, value=value) + return value_type(result) + elif get_origin(value_type) is dict: + if not isinstance(value, dict): + raise WrongTypeError(value, for_type=value_type, expected_type=dict) + k_type, v_type = get_args(value_type) + result = {} + raised = [] + for k, v in value.items(): + success = True + converted_key, converted_value = None, None + try: + converted_key = convert_json_to_object(k, k_type) + except Exception as ex: + success = False + ex.add_note( + f'while deserializing key {repr(k)} ({friendly_type(type(k))}) ' + f'to {friendly_type(k_type)}') + raised.append(ex) + try: + converted_value = convert_json_to_object(v, v_type) + except Exception as ex: + success = False + ex.add_note(f'while deserializing value {repr(v)} ({friendly_type(type(v))}) ' + f'corresponding to key {repr(k)} ' + f'to {friendly_type(v_type)}') + raised.append(ex) + if success: + result[converted_key] = converted_value + raise_exceptions(raised, for_type=value_type, value=value) + return dict(result) + elif issubclass(value_type, Jsonable): + if not isinstance(value, dict): + raise WrongTypeError(value, for_type=value_type, expected_type=dict) + return value_type.from_json_object(value) + elif issubclass(value_type, Enum): + return value_type(value) + raise TypeError(f'Cannot deserialize objects of type {friendly_type(value_type)}') + + +def get_enum_type(enum_type): + if all(isinstance(v.value, str) for v in enum_type): + return str + elif all(isinstance(v.value, int) for v in enum_type): + return int + else: + raise TypeError(f"Enum type {friendly_type(enum_type)} is not all str or all int") + + +def convert_object_to_json(source): + if (isinstance(source, int) or isinstance(source, float) or isinstance(source, str) + or isinstance(source, bool) or isinstance(source, type(None))): + return source + if isinstance(source, list) or isinstance(source, tuple) or isinstance(source, set): + return [convert_object_to_json(item) for item in source] + if isinstance(source, dict): + return dict((str(key), convert_object_to_json(value)) for key, value in source.items()) + if isinstance(source, Jsonable): + return source.to_json_object() + if isinstance(source, Enum): + return convert_object_to_json(source.value) + raise TypeError(f"No adapter for {friendly_type(type(source))} to deserialize it to JSON") + + +def is_homogeneous_tuple_type(t) -> bool: + if get_origin(t) is not tuple: + return False + a = get_args(t) + if len(a) == 2 and a[1] is ...: + return True + return False + + +def is_heterogeneous_tuple_type(t) -> bool: + if get_origin(t) is not tuple: + return False + a = get_args(t) + if len(a) != 2 or a[1] is not ...: + return True + return False + + +def friendly_type(t) -> str: + if isinstance(t, type): + return t.__name__ + else: + return str(t) diff --git a/main.py b/main.py new file mode 100644 index 0000000..2768d41 --- /dev/null +++ b/main.py @@ -0,0 +1,10 @@ +import asyncio + +from app import FabulaApp + +if __name__ == '__main__': + FabulaApp.new( + webhook_url="https://discord.com/api/webhooks/1084019766559260714/" + + "s6CPtH48h6_y5QIE0Cbq4MJMXoEH7K63jcMYUNTVu3msTneGNb09tCEt4tUV7WAC-ACZ").run() + +# See PyCharm help at https://www.jetbrains.com/help/pycharm/ diff --git a/model.py b/model.py new file mode 100644 index 0000000..db47ed9 --- /dev/null +++ b/model.py @@ -0,0 +1,70 @@ +from dataclasses import dataclass + +from enums import Affinity, Element, CharacterType, Visibility, CombatSide + +from jsonable import JsonableDataclassArgs, Jsonable, JsonableParentArgs + + +@dataclass(**JsonableDataclassArgs) +class Target(Jsonable): + index: int + key: str + name: str + + +@dataclass(**JsonableParentArgs) +class Effect(Jsonable): + pass + + +@dataclass(**JsonableParentArgs) +class Action(Jsonable): + pass + + +@dataclass(**JsonableDataclassArgs) +class Clock(Jsonable): + name: str + current: int + maximum: int + + +@dataclass(**JsonableDataclassArgs) +class Character(Jsonable): + name: str + hp: int + max_hp: int + mp: int + max_mp: int + ip: int + max_ip: int + sp: int | None + df: int + mdf: int + dr: int + statuses: frozenset[str] + affinities: tuple[tuple[Element, Affinity], ...] + turns_left: int + max_turns: int + visibility: Visibility + character_type: CharacterType + side: CombatSide + + color: str | None + thumbnail: str | None + access_key: str + player_discord_id: str | None + player_name: str | None + + +@dataclass(**JsonableDataclassArgs) +class CombatStatus(Jsonable): + round_number: int + fp_spent: int + up_spent: int + clocks: tuple[Clock, ...] + characters: tuple[Character, ...] + log: tuple[Action, ...] + starting_side: CombatSide | None + active_side: CombatSide | None + active_combatant: int | None diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2a776a9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +discord-webhook[async] +urwid \ No newline at end of file diff --git a/ui.py b/ui.py new file mode 100644 index 0000000..9c1c24c --- /dev/null +++ b/ui.py @@ -0,0 +1,312 @@ +from typing import Callable + +from urwid import Pile, Columns, ListBox, SimpleFocusListWalker, Text, WidgetWrap, SelectableIcon, AttrMap, Padding, \ + Divider, LineBox, WidgetPlaceholder, Overlay, Widget, Edit, IntEdit, \ + Button + +from model import Character, Clock +from enums import CharacterType, Counter + + +class SessionStateUI(WidgetWrap): + @classmethod + def render_round_timer(cls, rd: int, fp_spent: int, up_spent: int): + return ([('RoundLabel', 'Round'), ': ', ('RoundNumber', str(rd)), ' / '] if rd > 0 else []) + [ + ('FabulaLabel', 'FP Used'), + ': ', + ('FabulaNumber', str(fp_spent)), + ] + ([' / ', ('UltimaLabel', 'UP Used'), ': ', ('UltimaNumber', str(up_spent))] if up_spent > 0 else []) + + def __init__(self, rd: int, fp_spent: int, up_spent: int): + self.text = Text(self.render_round_timer(rd=rd, fp_spent=fp_spent, up_spent=up_spent)) + super().__init__(Columns([ + (4, Padding( + w=AttrMap( + SelectableIcon(text='<◕>', cursor_position=1), + 'ClockUnfocused', 'ClockFocused'), + align='left', + width=3, + right=1)), + ('weight', 1, self.text)])) + + def update_session(self, rd: int, fp_spent: int, up_spent: int): + self.text.set_text(self.render_round_timer(rd=rd, fp_spent=fp_spent, up_spent=up_spent)) + + +class ClockUI(WidgetWrap): + @classmethod + def render_clock_text(cls, clock: Clock): + return [ + ('ClockName', clock.name), + ' ', + ('ClockBarEdge', '❰'), + ('ClockBarFilled', '❚' * clock.current), + ('ClockBarEmpty', '·' * (clock.maximum - clock.current)), + ('ClockBarEdge', '❱'), + ' ', + ('ClockCurrent', str(clock.current)), + '/', + ('ClockMax', str(clock.maximum)) + ] + + def __init__(self, clock: Clock): + self.text = Text(self.render_clock_text(clock=clock)) + super().__init__(Columns([ + (4, Padding( + w=AttrMap( + SelectableIcon(text='<◕>', cursor_position=1), + 'ClockUnfocused', 'ClockFocused'), + align='left', + width=3, + right=1)), + ('weight', 1, self.text)])) + + def update(self, clock): + self.text.set_text( + self.render_clock_text(clock=clock)) + + +class ClocksUI(WidgetWrap): + def __init__(self): + self.state = SessionStateUI(rd=0, fp_spent=0, up_spent=0) + self.items = SimpleFocusListWalker([]) + self.list = ListBox(self.items) + super().__init__(Pile([('pack', self.state), ('weight', 1, self.list)])) + + def update_session(self, rd: int, fp_spent: int, up_spent: int): + self.state.update_session(rd=rd, fp_spent=fp_spent, up_spent=up_spent) + + def add_clock(self, clock: Clock, index: int = -1) -> int: + if index == -1: + index = len(self.items) + self.items.insert(index, ClockUI(clock=clock)) + return index + + def update_clock(self, index: int, clock: Clock): + self.items[index].update(clock) + + def reorder_clock(self, old: int, new: int = -1): + focused = self.list.focus_position == old + item = self.items.pop(old) + new = new if new != -1 else len(self.items) + self.items.insert(new, self.items.pop(item)) + if focused: + self.items.set_focus(position=new) + + def remove_clock(self, index: int): + self.items.pop(index + 1) + + def clear(self): + self.items.clear() + + +class CharacterUI(WidgetWrap): + @classmethod + def render_character_text(cls, character: Character): + if character.role == CharacterType.Player: + role_icon = '♥' + role_style = 'CharacterPlayer' + elif character.role == CharacterType.Ally: + role_icon = '♡' + role_style = 'CharacterAlly' + elif character.role == CharacterType.Enemy: + role_icon = '♤' + role_style = 'CharacterEnemy' + elif character.role == CharacterType.EnemyPlayer: + role_icon = '♠' + role_style = 'CharacterEnemyPlayer' + else: + role_icon = '*' + role_style = 'CharacterUnknown' + if character.max_turns == 0 or character.hp == 0: + turn_style = 'TurnDisabled' + turn_text = '[✗]' + elif character.turns_left > 1: + turn_style = 'TurnAvailable' + turn_text = f'[{character.turns_left}]' + elif character.turns_left == 0: + turn_style = 'TurnActed' + turn_text = '[✓]' + elif character.max_turns > 1: + turn_style = 'TurnAvailable' + turn_text = '[1]' + else: + turn_style = 'TurnAvailable' + turn_text = '[ ]' + if character.hp == character.max_hp: + hp_style = 'HPFull' + hp_suffix = '★' + elif character.hp * 4 > character.max_hp * 3: + hp_style = 'HPScratched' + hp_suffix = '☆' + elif character.hp * 2 > character.max_hp: + hp_style = 'HPWounded' + hp_suffix = '\u00A0' + elif character.hp * 4 > character.max_hp: + hp_style = 'HPCrisis' + hp_suffix = '!' + elif character.hp > 0: + hp_style = 'HPPeril' + hp_suffix = '‼' + else: + hp_style = 'HPDown' + hp_suffix = '✖' + return ([ + (turn_style, turn_text), + ' ', + (role_style, role_icon + character.name), + ], + [('HPLabel', hp_suffix + 'HP'), + '\u00A0', + (hp_style, str(character.hp).rjust(3, '\u00A0')), + '/', + ('HPMax', str(character.max_hp).rjust(3, '\u00A0')), + '\u00A0 ', + ('MPLabel', '\u00A0MP'), + '\u00A0', + ('MPValue', str(character.mp).rjust(3, '\u00A0')), + '/', + ('MPMax', str(character.max_mp).rjust(3, '\u00A0'))] + ( + ['\u00A0 ', + ('FPLabel' if character.role in (CharacterType.Player, CharacterType.EnemyPlayer) else 'UPLabel', '\u00A0FP' if character.role in (CharacterType.Player, CharacterType.EnemyPlayer) else '\u00A0UP'), + '\u00A0', + ('FPValue' if character.role in (CharacterType.Player, CharacterType.EnemyPlayer) else 'UPValue', str(character.sp).rjust(3, '\u00A0')), + '\u00A0' * 4] if character.sp is not None else []) + ( + ['\u00A0 ', ('IPLabel', '\u00A0IP'), '\u00A0', ('IPValue', str(character.ip).rjust(3, '\u00A0')), '/', + ('IPMax', str(character.max_ip).rjust(3, '\u00A0'))] if character.max_ip > 0 else []) + + (['\n ', + (('StatusKO', "KO") if character.hp == 0 else + ('StatusCrisis', "Crisis") if character.hp * 2 < character.max_hp else ""), + ('Statuses', + ", ".join(([""] if character.hp * 2 < character.max_hp else []) + + sorted(list(str(s) for s in character.statuses)))), + ] if len(character.statuses) > 0 or character.hp * 2 < character.max_hp else [])) + + def __init__(self, character: Character): + name_text, condition_text = self.render_character_text(character=character) + self.nameText = Text(name_text) + self.conditionText = Text(condition_text) + self.icon = SelectableIcon( + text=f'({character.access_key if character.access_key != "" else "*"})', cursor_position=1) + super().__init__(Columns([ + (4, Padding( + w=AttrMap( + self.icon, + 'CharacterUnfocused', 'CharacterFocused'), + align='left', + width=3, + right=1)), + ('weight', 1, Pile([self.nameText, self.conditionText]))])) + + +class CharactersUI(WidgetWrap): + def __init__(self): + self.items = SimpleFocusListWalker([]) + self.list = ListBox(self.items) + super().__init__(ListBox(self.items)) + + def add_character(self, character: Character): + self.items.append(CharacterUI(character=character)) + + +class LogMessageUI(WidgetWrap): + def __init__(self, text): + super().__init__(Columns([ + (4, Padding( + w=AttrMap( + SelectableIcon(text='[▶]', cursor_position=1), + 'LogUnfocused', 'LogFocused'), + align='left', + width=3, + right=1)), + ('weight', 1, Text([ + ('LogBold', v) if index % 2 != 0 else ('LogText', v) + for index, v + in enumerate(text.split("**")) if v != ""]))])) + + +class LogUI(WidgetWrap): + def __init__(self): + self.items = SimpleFocusListWalker([]) + self.divider = Divider('=') + self.divider_by_zero = True + self.list = ListBox(self.items) + super().__init__(self.list) + + def add_message(self, text: str): + if self.divider not in self.items and not self.divider_by_zero: + if len(self.items) == 0: + self.divider_by_zero = True + else: + self.items.insert(0, self.divider) + self.divider_by_zero = False + self.items.insert(0, LogMessageUI(text)) + self.items.set_focus(0) + + def move_divider(self, location: int = -1): + if self.divider in self.items: + self.items.remove(self.divider) + location = location if location > -1 else len(self.items) + if 0 < location <= len(self.items): + self.items.insert(len(self.items) - location, self.divider) + self.divider_by_zero = False + else: + self.divider_by_zero = True + + def clear(self): + self.items.clear() + + +class MainUI(WidgetWrap): + def __init__(self, clocks: ClocksUI, characters: CharactersUI, log: LogUI): + super().__init__(Pile([ + ('weight', 5, Columns([ + ('weight', 5, LineBox(original_widget=characters, title="Character Status", + title_attr="FrameTitle", title_align='left')), + ('weight', 3, LineBox(original_widget=clocks, title="Clocks", + title_attr="FrameTitle", title_align='left')), + ])), + ('weight', 3, LineBox(original_widget=log, title="Combat Log", + title_attr="FrameTitle", title_align='left')), + ])) + + +class AbilityEntryPopup(WidgetWrap): + def __init__(self, user: Character, callback: Callable[[str, list[tuple[Counter]]], None]): + self.user = user + self.index = index + self.complete_callback = callback + self.ability_name_editor = Edit(f'{user.name} uses ') + self.mp_cost_editor = IntEdit(f'Mind Points cost (of {user.mp}) ', 0) + self.ip_cost_editor = IntEdit(f'Inventory Points cost (of {user.ip}) ', 0) + self.hp_cost_editor = IntEdit(f'Health Points cost (of {user.hp}) ', 0) + editors = [self.ability_name_editor, self.mp_cost_editor, self.ip_cost_editor, self.hp_cost_editor] + if user.sp is not None: + self.sp_cost_editor = IntEdit(f'{user.role.sp_name_abbr} cost (of {user.sp}) ', 0) + editors.append(self.sp_cost_editor) + else: + self.sp_cost_editor = None + ok_button = Button('Initiate') + cancel_button = Button('Abort') + contents = Pile([ + *editors, + Padding(Columns((ok_button, cancel_button)), 'right'), + ]) + super().__init__(LineBox( + original_widget=contents, + title='Use Ability', + )) + pass + + +class FabulaUI(WidgetPlaceholder): + def __init__(self, main: MainUI): + self.main = main + super().__init__(main) + + def open_popup(self, popup: Widget): + self.original_widget = Overlay(popup, self.original_widget, 'center', ('relative', 50), 'middle', 'min_height') + + def close_popup(self): + if isinstance(self.original_widget, Overlay): + self.original_widget = self.original_widget.bottom_w