commit 7325c46f96f6b147e7b5948ea50f82c870bcab31 Author: Mari Date: Sat Mar 18 02:24:46 2023 -0400 First commit 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