First commit

main
Mari 1 year ago
commit 7325c46f96
  1. 8
      .idea/.gitignore
  2. 10
      .idea/fabula.iml
  3. 6
      .idea/inspectionProfiles/profiles_settings.xml
  4. 4
      .idea/misc.xml
  5. 8
      .idea/modules.xml
  6. 6
      .idea/vcs.xml
  7. 71
      actions.py
  8. 157
      app.py
  9. 59
      effects.py
  10. 283
      embedgen.py
  11. 101
      enums.py
  12. 279
      jsonable.py
  13. 10
      main.py
  14. 70
      model.py
  15. 2
      requirements.txt
  16. 312
      ui.py

8
.idea/.gitignore vendored

@ -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

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.11 (fabula)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11 (fabula)" project-jdk-type="Python SDK" />
</project>

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/fabula.iml" filepath="$PROJECT_DIR$/.idea/fabula.iml" />
</modules>
</component>
</project>

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

@ -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, ...]

157
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

@ -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

@ -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)

@ -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"

@ -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)

@ -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/

@ -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

@ -0,0 +1,2 @@
discord-webhook[async]
urwid

312
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
Loading…
Cancel
Save