From 613dae80a21a6dd7d87895464a34e149a5852638 Mon Sep 17 00:00:00 2001 From: Mari Date: Mon, 20 Mar 2023 11:55:31 -0400 Subject: [PATCH] Action + effect work --- actions.py | 30 ++++++++- app.py | 29 ++++---- effects.py | 190 +++++++++++++++++++++++++++++++++++++++++++++++++--- embedgen.py | 71 ++------------------ enums.py | 153 ++++++++++++++++++++++++++++++++++++++++-- jsonable.py | 2 +- model.py | 184 +++++++++++++++++++++++++++++++++++++++++++++++--- ui.py | 157 +++++++++++++++++++++++-------------------- 8 files changed, 640 insertions(+), 176 deletions(-) diff --git a/actions.py b/actions.py index 9956649..b6120d7 100644 --- a/actions.py +++ b/actions.py @@ -1,15 +1,41 @@ from dataclasses import dataclass +from typing import Union from effects import DamageEffect from enums import CombatSide from jsonable import JsonableDataclassArgs -from model import Action, Target, Effect, Character, Clock +from model import Action, Target, Effect, Character, Clock, CombatStatus, log_substitute + + +@dataclass(**JsonableDataclassArgs) +class RoleplayAction(Action): + text: str + user: Target | None + effects: tuple[Effect, ...] + + def do(self, combat: "CombatStatus", source: Union["Action", "Effect", None] = None) -> "CombatStatus": + for effect in self.effects: + combat = effect.do(combat, self) + return combat + + def undo(self, combat: "CombatStatus", source: Union["Action", "Effect", None] = None) -> "CombatStatus": + for effect in reversed(self.effects): + combat = effect.undo(combat, self) + return combat + + def log_message(self, user: Target | None = None) -> str | None: + return "\n ".join( + [log_substitute(self.text, user=self.user, target=self.user)] + + [log_substitute(t, user=self.user, target=self.user) + for t in [v.log_message(self.user) for v in self.effects] + if t is not None] + ) @dataclass(**JsonableDataclassArgs) class AbilityAction(Action): name: str - user: Target + user: Target | None costs: tuple[DamageEffect, ...] effects: tuple[Effect, ...] diff --git a/app.py b/app.py index 778a577..23b8455 100644 --- a/app.py +++ b/app.py @@ -64,6 +64,7 @@ class FabulaApp(object): color="a50ecf", thumbnail=None, player_name=None, player_discord_id=None, ) self.characters.add_character(vacana_hunger) + self.clocks.update_session(1, 2, 7) self.webhook = webhook self.character_list = (prandia, selia, prandia_voraphilia, vacana, flow, vacana_hunger) self.event_loop = None @@ -87,7 +88,7 @@ class FabulaApp(object): def run(self): set_encoding("UTF-8") self.event_loop = asyncio.new_event_loop() - loop = MainLoop( + self.loop = MainLoop( widget=self.wrapper, unhandled_input=self.keypress, event_loop=AsyncioEventLoop(loop=self.event_loop), @@ -95,11 +96,11 @@ class FabulaApp(object): (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"), + ("LogFocused", "white,bold", "default", "bold"), + ("LogUnfocused", "light cyan", "default", "default"), ("ClockName", "white,bold", "default", "bold"), - ("ClockFocused", "light green,bold", "default", "bold"), - ("ClockUnfocused", "dark green", "default", "default"), + ("ClockFocused", "white,bold", "default", "bold"), + ("ClockUnfocused", "light green", "default", "default"), ("ClockBarEdge", 'yellow,bold', 'default', 'bold'), ("ClockBarFilled", 'yellow', 'default', 'default'), ("ClockBarEmpty", 'light gray', 'default', 'default'), @@ -112,12 +113,12 @@ class FabulaApp(object): ("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'), + ("CharacterUnfocused", 'light red', 'default', 'default'), + ("CharacterFocused", 'white,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'), + ("CharacterAlly", 'light cyan,bold', 'default', 'bold'), + ("CharacterEnemy", 'light red,bold', 'default', 'bold'), + ("CharacterEnemyPlayer", 'yellow,bold', 'default', 'bold'), ("CharacterUnknown", 'light gray,bold', 'default', 'bold'), ("TurnDisabled", 'dark gray', 'default', 'default'), ("TurnAvailable", 'light green,bold', 'default', 'bold'), @@ -131,20 +132,20 @@ class FabulaApp(object): ("HPLabel", 'light gray', 'default', 'default'), ("HPMax", 'light gray', 'default', 'default'), ("MPLabel", 'light gray', 'default', 'default'), - ("MPValue", 'light blue', 'default', 'default'), + ("MPValue", 'light cyan', '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'), + ("IPValue", 'light magenta', '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( + ]) + self.loop.screen.set_terminal_properties( colors=2 ** 24, has_underline=True, bright_is_bold=False, diff --git a/effects.py b/effects.py index 8fb9aaf..16c7f7f 100644 --- a/effects.py +++ b/effects.py @@ -1,25 +1,50 @@ from dataclasses import dataclass +from typing import Union -from enums import Counter, Element, Affinity, MissType +from enums import Counter, Element, Affinity, MissType, CharacterType from jsonable import JsonableDataclassArgs -from model import Effect, Target, Action +from model import Effect, Target, Action, CombatStatus, LogOnly, log_substitute, Character, Clock @dataclass(**JsonableDataclassArgs) -class OpportunityEffect(Effect): - target: Target +class OpportunityEffect(LogOnly, Effect): + target: Target | None opportunity_text: str + def log_message(self, user: Target | None = None) -> str: + return f'**Opportunity!!** {log_substitute(self.opportunity_text, user=user, target=self.target)}' + @dataclass(**JsonableDataclassArgs) class SubActionEffect(Effect): action: Action trigger_text: str + def do(self, combat: "CombatStatus", source: Union[Action, "Effect", None] = None) -> "CombatStatus": + return self.action.do(combat, source) + + def undo(self, combat: "CombatStatus", source: Union[Action, "Effect", None] = None) -> "CombatStatus": + return self.action.undo(combat, source) + + def log_message(self, user: Target | None = None) -> str | None: + action_user = (self.action.user + if hasattr(self.action, "user") and isinstance(self.action.user, Target) + else None) + action_log = self.action.log_message(user) + if len(self.trigger_text) != 0: + trigger_text = log_substitute(self.trigger_text, user=user, target=action_user) + if action_log is not None: + return trigger_text + "\n " + action_log.replace('\n', '\n ') + else: + return trigger_text + else: + return action_log + @dataclass(**JsonableDataclassArgs) class DamageEffect(Effect): - target: Target | None + target: Target + target_type: CharacterType attribute: Counter damage: int old_value: int @@ -29,6 +54,45 @@ class DamageEffect(Effect): affinity: Affinity piercing: bool + def do(self, combat: "CombatStatus", source: Union[Action, "Effect", None] = None) -> "CombatStatus": + return combat.alter_character(self.target, lambda c: c.set_counter_current(self.attribute, self.new_value)) + + def undo(self, combat: "CombatStatus", source: Union[Action, "Effect", None] = None) -> "CombatStatus": + return combat.alter_character(self.target, lambda c: c.set_counter_current(self.attribute, self.old_value)) + + def log_message(self, user: Target | None = None) -> str: + state_change = None + if self.attribute == Counter.HP: + if self.old_value > self.new_value == 0: + state_change = ['+ **KO**'] + elif self.new_value > self.old_value == 0: + if self.new_value * 2 > self.max_value: + state_change = ['- **KO**'] + else: + state_change = ['- **KO**', '+ **Crisis**'] + elif self.old_value * 2 > self.max_value >= self.new_value * 2: + state_change = '+ **Crisis**' + elif self.new_value * 2 > self.max_value >= self.old_value * 2: + state_change = '- **Crisis**' + affinity = '' + if self.affinity == Affinity.Absorb: + affinity = "?" + elif self.affinity == Affinity.Immune: + affinity = " ✖" + elif self.affinity == Affinity.Resistant: + affinity = "…" + elif self.affinity == Affinity.Vulnerable: + affinity = "‼" + attribute = (f'{self.element.value}{"!" if self.piercing else ""}' + if self.attribute == Counter.HP + else f'{self.attribute.ctr_name_abbr(self.target_type)}') + sign = '-' if self.damage >= 0 else '+' + return '\n'.join( + [log_substitute( + f'@T: [{sign}{abs(self.damage)}{affinity}] {attribute}', + user=user, target=self.target)] + + [log_substitute(f'@T: [{s}]') for s in state_change]) + @dataclass(**JsonableDataclassArgs) class StatusEffect(Effect): @@ -36,24 +100,132 @@ class StatusEffect(Effect): old_status: str | None new_status: str | None + @staticmethod + def alter_status(c: Character, old_status: str | None, new_status: str | None) -> Character: + if old_status is None and new_status is not None: + return c.add_status(new_status) + elif new_status is None and old_status is not None: + return c.remove_status(old_status) + elif new_status is not None and old_status is not None: + return c.replace_status(old_status, new_status) + else: + return c + 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") + def do(self, combat: CombatStatus, source: Union[Action, Effect, None] = None) -> CombatStatus: + return combat.alter_character( + self.target, lambda c: StatusEffect.alter_status(c, self.old_status, self.new_status)) + + def undo(self, combat: CombatStatus, source: Union[Action, Effect, None] = None) -> CombatStatus: + return combat.alter_character( + self.target, lambda c: StatusEffect.alter_status(c, self.new_status, self.old_status)) + + def log_message(self, user: Target | None = None) -> str | None: + if self.old_status is not None and self.new_status is None: + return log_substitute(v=f'@T: [- {self.old_status}]', user=user, target=self.target) + if self.new_status is not None and self.old_status is None: + return log_substitute(v=f'@T: [+ {self.old_status}]', user=user, target=self.target) + if self.old_status is not None and self.new_status is not None: + return log_substitute(v=f'@T: [{self.old_status} -> {self.new_status}]', user=user, target=self.target) + pass + @dataclass(**JsonableDataclassArgs) class FPBonusEffect(Effect): + user: Target rerolls: int - bond_bonus: int + modifier: int + fp_spent: int + old_fp: int + new_fp: int + + def do(self, combat: CombatStatus, source: Union[Action, Effect, None] = None) -> CombatStatus: + return combat.alter_character( + self.user, + lambda c: c.set_counter_current(Counter.SP, self.new_fp) + ).add_fp_spent(self.fp_spent) + + def undo(self, combat: CombatStatus, source: Union[Action, Effect, None] = None) -> CombatStatus: + return combat.alter_character( + self.user, + lambda c: c.set_counter_current(Counter.SP, self.old_fp) + ).add_fp_spent(-self.fp_spent) + + def log_message(self, user: Target | None = None) -> str | None: + bonuses = [] + if self.rerolls > 0: + if self.rerolls > 1: + bonuses.append(f"{self.rerolls} rerolls") + else: + bonuses.append("a reroll") + if self.modifier != 0: + bonuses.append(f"a {self.modifier:+} {'bonus' if self.modifier > 0 else 'penalty'}") + if len(bonuses) == 0: + return None + affected = '' + if self.user != user: + affected = log_substitute(" on @U's roll", user=user, target=self.user) + return f"{log_substitute('@T', user=user, target=self.user)} " \ + f"spent **{self.fp_spent} FP** for {' and '.join(bonuses)}{affected}!" @dataclass(**JsonableDataclassArgs) -class MissEffect(Effect): +class MissEffect(LogOnly, Effect): miss_type: MissType target: Target + def log_message(self, user: Target | None = None) -> str | None: + if self.miss_type == MissType.Missed: + return log_substitute(f"It missed @T!", user=user, target=self.target) + elif self.miss_type == MissType.Dodged: + return log_substitute(f"@T dodged it!", user=user, target=self.target) + elif self.miss_type == MissType.Avoided: + return log_substitute(f"@T avoided it!", user=user, target=self.target) + elif self.miss_type == MissType.Blocked: + return log_substitute("@T blocked it!", user=user, target=self.target) + elif self.miss_type == MissType.Immunity: + return log_substitute("@T is immune!", user=user, target=self.target) + elif self.miss_type == MissType.Repelled: + return log_substitute("@T repelled it!", user=user, target=self.target) + elif self.miss_type == MissType.Countered: + return log_substitute("@T countered it!", user=user, target=self.target) + elif self.miss_type == MissType.Parried: + return log_substitute("@T parried it!", user=user, target=self.target) + elif self.miss_type == MissType.Protected: + return log_substitute("@T was protected from it!", user=user, target=self.target) + elif self.miss_type == MissType.Resisted: + return log_substitute("@T resisted it!", user=user, target=self.target) + elif self.miss_type == MissType.Shielded: + return log_substitute("@T shielded against it!", user=user, target=self.target) + else: + return log_substitute(f"@T: {self.miss_type.value}", user=user, target=self.target) + @dataclass(**JsonableDataclassArgs) -class GeneralEffect(Effect): +class GeneralEffect(LogOnly, Effect): text: str - target: Target + target: Target | None + + def log_message(self, user: Target | None = None) -> str | None: + return log_substitute(self.text, user=user, target=self.target) + + +@dataclass(**JsonableDataclassArgs) +class ClockTickEffect(Effect): + clock_index: int + old_definition: Clock + new_value: int + delta: int + + def do(self, combat: "CombatStatus", source: Union[Action, "Effect", None] = None) -> "CombatStatus": + return combat.alter_clock(self.clock_index, lambda c: c.set_value(self.new_value)) + + def undo(self, combat: "CombatStatus", source: Union[Action, "Effect", None] = None) -> "CombatStatus": + return combat.alter_clock(self.clock_index, lambda c: c.set_value(self.old_definition.current)) + + def log_message(self, user: Target | None = None) -> str | None: + return (f'The clock **{self.old_definition.name}** ticked {"up" if self.delta > 0 else "down"} {self.delta} ' + f'tick{"" if abs(self.delta) == 1 else "s"}.') diff --git a/embedgen.py b/embedgen.py index c73b2dd..e60634b 100644 --- a/embedgen.py +++ b/embedgen.py @@ -3,66 +3,9 @@ from typing import Iterable from discord_webhook import DiscordEmbed, AsyncDiscordWebhook +from enums import CantMoveEmoji, DoneMovingEmoji, NotMovedEmoji, TurnsLeftEmoji, get_indicator_key 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) @@ -163,10 +106,10 @@ def detailed_character_embed(c: Character) -> DiscordEmbed: return result -def character_header(c): +def character_header(c: Character) -> tuple[list[str], list[str]]: header = [ turn_icon(c), - IndicatorKeys.get(c.access_key, IndicatorKeys[None]), + get_indicator_key(c.access_key), " ", f'**{c.name}**' if c.visibility.show_name else '***???***' ] @@ -180,12 +123,12 @@ def character_header(c): return header, statuses -def character_details(c, statuses): +def character_details(c: Character, statuses: list[str]): 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}") + result.append(f"**{c.character_type.sp_name_abbr}** {c.sp}") if len(statuses) > 0: result.append(f'_{", ".join(statuses)}_') return result @@ -217,9 +160,9 @@ def bar_string(val: int, mx: int, delta: int | None = None, length: int | None = def turn_icon(c: Character) -> str: - if c.max_turns == 0 or c.hp == 0: + if c.max_turns <= 0 or c.hp == 0: return CantMoveEmoji - elif c.turns_left == 0: + elif c.turns_left <= 0: return DoneMovingEmoji elif c.max_turns == 1: return NotMovedEmoji diff --git a/enums.py b/enums.py index eecbf11..0d19863 100644 --- a/enums.py +++ b/enums.py @@ -1,4 +1,91 @@ +from re import compile as compile_re, escape as escape_re from enum import Enum +from itertools import chain +from typing import Iterable + +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 get_indicator_key(key: str) -> str: + return IndicatorKeys.get(key, IndicatorKeys[None]) + + +EmojiDecoder: dict[str, str] = dict(chain( + ((v, (f'[{k}]' if k is not None else '[ ]')) for k, v in IndicatorKeys.items()), + {'☑️': '[✓]', '🔟': '[#]', '#️⃣': '[#]', '🟦': '[ ]', '◼': '[✖]'}.items() +)) + +EmojiDecoderRe = compile_re('|'.join(escape_re(k) for k in EmojiDecoder.keys())) + + +def decode_emoji(v: str) -> str: + return EmojiDecoderRe.sub(lambda m: EmojiDecoder.get(m, m), v) + + +DiscordDecoderRe = compile_re(r'\\([\\*|~_`])') +DiscordSplitterRe = compile_re(r'(? Iterable[tuple[str, str]]: + return [(normal_style if index % 2 == 0 else bold_style, + DiscordDecoderRe.sub(lambda m: m[1], decode_emoji(text))) + for index, text in enumerate(DiscordSplitterRe.split(v))] class Affinity(Enum): @@ -21,6 +108,7 @@ class Element(Enum): Ice = "ice" Light = "light" Dark = "dark" + Healing = "healing" class CharacterType(Enum): @@ -74,13 +162,35 @@ class Counter(Enum): IP = "IP" SP = "SP" + def ctr_name_abbr(self, t: CharacterType) -> str: + if self == Counter.SP: + return t.sp_name_abbr + else: + return self.value + + def ctr_name(self, t: CharacterType) -> str: + if self == Counter.HP: + return "Hit Points" + if self == Counter.MP: + return "Mind Points" + if self == Counter.IP: + return "Inventory Points" + if self == Counter.SP: + return t.sp_name + class MissType(Enum): + Dodged = "dodged" Missed = "missed" Blocked = "blocked" Avoided = "avoided" Repelled = "repelled" + Countered = "countered" + Parried = "parried" + Shielded = "shielded" Resisted = "resisted" + Immunity = "immune" + Protected = "protected" class CombatSide(Enum): @@ -95,7 +205,42 @@ class CombatSide(Enum): return CombatSide.Heroes -class StatusChange(Enum): - Added = "added" - Removed = "removed" - Changed = "changed" +class ClockIcon(Enum): + Hourglass = "⏳" + EmptyHourglass = "⌛" + Warning = "⚠" + Lightning = "⚡" + Silhouettes = "👥" + Flame = "🔥" + Sparkles = "✨" + Candle = "🕯" + Star = "⭐" + ShiningStar = "🌟" + ShootingStar = "🌠" + Dragon = "🐲" + Robot = "🤖" + Silhouette = "👤" + Question = "❓" + Skull = "💀" + AlarmClock = "⏰" + Stopwatch = "⏱️" + Watch = "⌚" + Clock = "🕓" + Gem = "💎" + Sleep = "💤" + Sound = "🔊" + Heart = "❤️" + + +class ClockTicks(Enum): + Good = "🟦/🔸" + Bad = "🟧/🔹" + Tug = "🟦/🟧" + + @property + def empty(self): + return self.value.split('/')[1] + + @property + def full(self): + return self.value.split('/')[0] diff --git a/jsonable.py b/jsonable.py index 9db02fa..ea557b6 100644 --- a/jsonable.py +++ b/jsonable.py @@ -10,7 +10,7 @@ JsonableDataclassArgs = {"init": True, "repr": True, "eq": True, "order": False, "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} + "kw_only": True, "slots": True, "weakref_slot": False} @dataclass(**JsonableParentArgs) diff --git a/model.py b/model.py index db47ed9..7e4754e 100644 --- a/model.py +++ b/model.py @@ -1,8 +1,12 @@ -from dataclasses import dataclass +from dataclasses import dataclass, replace +from typing import Union, Any, Callable -from enums import Affinity, Element, CharacterType, Visibility, CombatSide +from enums import Affinity, Element, CharacterType, Visibility, CombatSide, Counter, ClockIcon, ClockTicks -from jsonable import JsonableDataclassArgs, Jsonable, JsonableParentArgs +from jsonable import JsonableDataclassArgs, Jsonable, JsonableParentArgs, friendly_type +from abc import ABC, abstractmethod +from re import compile as compile_re +from embedgen import get_indicator_key @dataclass(**JsonableDataclassArgs) @@ -12,22 +16,94 @@ class Target(Jsonable): name: str +LogSubstituteRe = compile_re(r"@[Tt]|@[Uu]|\*\*|\*|~|_|\||`|\\\\") + + +def log_substitute(v: str, *, user: Target | None = None, target: Target | None = None) -> str: + def subs(m: str) -> str: + if m.upper() == '@T': + if target is None: + return '?' + else: + return f'{get_indicator_key(target.key)} {target.name}' + elif m.upper() == '@U': + if user is None: + return '?' + else: + return f'{get_indicator_key(user.key)} {user.name}' + elif m == '**': + return '**' + elif m in ('*', '_', '|', '\\', '`', '~'): + return f'\\{m}' + else: + return m + + return LogSubstituteRe.sub(subs, v) + + +class Doable(ABC): + @abstractmethod + def do(self, combat: "CombatStatus", source: None = None) -> "CombatStatus": + raise NotImplemented(f"{friendly_type(type(self))} does not have do implemented") + + @abstractmethod + def undo(self, combat: "CombatStatus", source: None = None) -> "CombatStatus": + raise NotImplemented(f"{friendly_type(type(self))} does not have undo implemented") + + @abstractmethod + def log_message(self, user: Target | None = None) -> str | None: + raise NotImplemented(f"{friendly_type(type(self))} does not have log_message implemented") + + +class LogOnly(Doable, ABC): + def do(self, combat: "CombatStatus", source: Any = None) -> "CombatStatus": + return combat + + def undo(self, combat: "CombatStatus", source: Any = None) -> "CombatStatus": + return combat + + @dataclass(**JsonableParentArgs) -class Effect(Jsonable): - pass +class Action(Jsonable, Doable, ABC): + @abstractmethod + def do(self, combat: "CombatStatus", source: Union["Action", "Effect", None] = None) -> "CombatStatus": + raise NotImplemented(f"{friendly_type(type(self))} does not have do implemented") + + @abstractmethod + def undo(self, combat: "CombatStatus", source: Union["Action", "Effect", None] = None) -> "CombatStatus": + raise NotImplemented(f"{friendly_type(type(self))} does not have undo implemented") + + @abstractmethod + def log_message(self, user: Target | None = None) -> str | None: + raise NotImplemented(f"{friendly_type(type(self))} does not have log_message implemented") @dataclass(**JsonableParentArgs) -class Action(Jsonable): - pass +class Effect(Jsonable, Doable, ABC): + @abstractmethod + def do(self, combat: "CombatStatus", source: Union[Action, "Effect", None] = None) -> "CombatStatus": + raise NotImplemented(f"{friendly_type(type(self))} does not have do implemented") + + @abstractmethod + def undo(self, combat: "CombatStatus", source: Union[Action, "Effect", None] = None) -> "CombatStatus": + raise NotImplemented(f"{friendly_type(type(self))} does not have undo implemented") + + @abstractmethod + def log_message(self, user: Target | None = None) -> str | None: + raise NotImplemented(f"{friendly_type(type(self))} does not have log_message implemented") @dataclass(**JsonableDataclassArgs) class Clock(Jsonable): name: str + icon: ClockIcon + ticks: ClockTicks current: int maximum: int + def set_value(self, value: int) -> "Clock": + return replace(self, current=min(self.maximum, max(0, value))) + @dataclass(**JsonableDataclassArgs) class Character(Jsonable): @@ -56,10 +132,57 @@ class Character(Jsonable): player_discord_id: str | None player_name: str | None + def get_counter(self, ctr: Counter) -> tuple[int, int] | tuple[int, None] | None: + if ctr == ctr.HP: + return self.hp, self.max_hp + elif ctr == ctr.MP: + return self.mp, self.max_mp + elif ctr == ctr.IP and self.max_ip > 0: + return self.ip, self.max_ip + elif ctr == ctr.SP and self.sp is not None: + return self.sp, None + else: + return None + + def set_counter_current(self, ctr: Counter, value: int) -> "Character": + if ctr == ctr.HP: + return replace(self, hp=min(max(0, value), self.max_hp)) + elif ctr == ctr.MP: + return replace(self, mp=min(max(0, value), self.max_mp)) + elif ctr == ctr.IP and self.max_ip > 0: + return replace(self, ip=min(max(0, value), self.max_ip)) + elif ctr == ctr.SP and self.sp is not None: + return replace(self, sp=max(0, value)) + else: + return self + + def add_status(self, status: str) -> "Character": + if status in self.statuses: + return self + return replace(self, statuses=self.statuses.union((status,))) + + def remove_status(self, status: str) -> "Character": + if status not in self.statuses: + return self + return replace(self, statuses=self.statuses.difference((status,))) + + def replace_status(self, old_status: str, new_status: str) -> "Character": + if old_status not in self.statuses: + return self + elif new_status in self.statuses: + return self.remove_status(old_status) + return replace(self, statuses=self.statuses.symmetric_difference((old_status, new_status))) + + def get_affinity(self, requested_element: Element) -> Affinity: + for element, affinity in self.affinities: + if element == requested_element: + return affinity + return Affinity.Normal + @dataclass(**JsonableDataclassArgs) class CombatStatus(Jsonable): - round_number: int + round_number: int | None fp_spent: int up_spent: int clocks: tuple[Clock, ...] @@ -68,3 +191,48 @@ class CombatStatus(Jsonable): starting_side: CombatSide | None active_side: CombatSide | None active_combatant: int | None + + def get_character(self, index: int | Target) -> Character: + if isinstance(index, Target): + index = index.index + return self.characters[index] + + def add_character(self, index: int | Target, character: Character) -> "CombatStatus": + if isinstance(index, Target): + index = index.index + return replace(self, characters=self.characters[:index] + (character,) + self.characters[index:]) + + def remove_character(self, index: int | Target) -> "CombatStatus": + if isinstance(index, Target): + index = index.index + return replace(self, characters=self.characters[:index] + self.characters[index + 1:]) + + def set_character(self, index: int | Target, character: Character) -> "CombatStatus": + if isinstance(index, Target): + index = index.index + if self.characters[index] == character: + return self + return replace(self, characters=self.characters[:index] + (character,) + self.characters[index + 1:]) + + def alter_character(self, index: int | Target, modifier: Callable[[Character], Character]) -> "CombatStatus": + return self.set_character(index, modifier(self.get_character(index))) + + def add_fp_spent(self, delta: int) -> "CombatStatus": + return replace(self, fp_spent=self.fp_spent + delta) + + def get_clock(self, index: int) -> Clock: + return self.characters[index] + + def set_clock(self, index: int, clock: Clock) -> "CombatStatus": + if self.clocks[index] == clock: + return self + return replace(self, clocks=self.clocks[:index] + (clock,) + self.clocks[index + 1:]) + + def add_clock(self, index: int, clock: Clock) -> "CombatStatus": + return replace(self, clocks=self.clocks[:index] + (clock,) + self.clocks[index:]) + + def remove_clock(self, index: int) -> "CombatStatus": + return replace(self, clocks=self.clocks[:index] + self.clocks[index + 1:]) + + def alter_clock(self, index: int, modifier: Callable[[Clock], Clock]) -> "CombatStatus": + return self.set_clock(index, modifier(self.get_clock(index))) diff --git a/ui.py b/ui.py index 9c1c24c..15dd53d 100644 --- a/ui.py +++ b/ui.py @@ -5,19 +5,20 @@ from urwid import Pile, Columns, ListBox, SimpleFocusListWalker, Text, WidgetWra Button from model import Character, Clock -from enums import CharacterType, Counter +from enums import CharacterType, Counter, decode_discord, CombatSide 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): + @staticmethod + def render_round_timer(rd: int | None, fp_spent: int, up_spent: int): + return ( + ([('RoundLabel', 'Round'), ':\u00A0', ('RoundNumber', str(rd)), ' / '] if rd is not None else []) + + ([('FabulaLabel', 'FP\u00A0Used'), ':\u00A0', ('FabulaNumber', str(fp_spent))]) + + ([' / ', ('UltimaLabel', 'UP\u00A0Used'), ':\u00A0', ('UltimaNumber', str(up_spent))] + if up_spent > 0 else []) + ) + + def __init__(self, rd: int | None, 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( @@ -29,7 +30,7 @@ class SessionStateUI(WidgetWrap): right=1)), ('weight', 1, self.text)])) - def update_session(self, rd: int, fp_spent: int, up_spent: int): + def update_session(self, rd: int | None, fp_spent: int, up_spent: int): self.text.set_text(self.render_round_timer(rd=rd, fp_spent=fp_spent, up_spent=up_spent)) @@ -69,27 +70,29 @@ class ClockUI(WidgetWrap): class ClocksUI(WidgetWrap): def __init__(self): self.state = SessionStateUI(rd=0, fp_spent=0, up_spent=0) - self.items = SimpleFocusListWalker([]) + self.items = SimpleFocusListWalker([self.state]) self.list = ListBox(self.items) - super().__init__(Pile([('pack', self.state), ('weight', 1, self.list)])) + super().__init__(self.list) - def update_session(self, rd: int, fp_spent: int, up_spent: int): + def update_session(self, rd: int | None, 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)) + if index < 0: + index = len(self.items) - 1 + self.items.insert(index + 1, ClockUI(clock=clock)) return index def update_clock(self, index: int, clock: Clock): - self.items[index].update(clock) + if index < 0: + return + self.items[index + 1].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)) + focused = self.list.focus_position == old + 1 + item = self.items.pop(old + 1) + new = new if new >= 0 else len(self.items) + self.items.insert(new + 1, self.items.pop(item)) if focused: self.items.set_focus(position=new) @@ -98,33 +101,36 @@ class ClocksUI(WidgetWrap): def clear(self): self.items.clear() + self.append(self.state) 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' + if character.side == CombatSide.Heroes: + if character.character_type == CharacterType.PlayerCharacter: + role_icon = '♡' + role_style = 'CharacterPlayer' + else: + role_icon = '♥' + role_style = 'CharacterAlly' else: - role_icon = '*' - role_style = 'CharacterUnknown' - if character.max_turns == 0 or character.hp == 0: + if character.character_type == CharacterType.NonPlayerCharacter: + role_icon = '♤' + role_style = 'CharacterEnemy' + else: + role_icon = '♠' + role_style = 'CharacterEnemyPlayer' + if character.max_turns <= 0 or character.hp == 0: turn_style = 'TurnDisabled' - turn_text = '[✗]' + turn_text = '[✖]' + elif character.turns_left > 9: + turn_style = 'TurnAvailable' + turn_text = f'[#]' elif character.turns_left > 1: turn_style = 'TurnAvailable' turn_text = f'[{character.turns_left}]' - elif character.turns_left == 0: + elif character.turns_left <= 0: turn_style = 'TurnActed' turn_text = '[✓]' elif character.max_turns > 1: @@ -151,43 +157,50 @@ class CharacterUI(WidgetWrap): else: hp_style = 'HPDown' hp_suffix = '✖' - return ([ - (turn_style, turn_text), - ' ', - (role_style, role_icon + character.name), - ], - [('HPLabel', hp_suffix + 'HP'), + return ([(turn_style, turn_text), ' ', (role_style, role_icon + character.name)], + ['\u00A0\u00A0\u00A0\u00A0', + (hp_style, hp_suffix), + ('HPLabel', 'HP'), '\u00A0', (hp_style, str(character.hp).rjust(3, '\u00A0')), '/', ('HPMax', str(character.max_hp).rjust(3, '\u00A0')), - '\u00A0 ', + ' \u00A0\u00A0\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 [])) + ('MPMax', str(character.max_mp).rjust(3, '\u00A0'))] + ([ + ' \u00A0\u00A0\u00A0\u00A0', + ('FPLabel' if character.character_type == CharacterType.PlayerCharacter else 'UPLabel', + '\u00A0FP' if character.character_type == CharacterType.PlayerCharacter else '\u00A0UP'), + '\u00A0', + ('FPValue' if character.character_type == CharacterType.PlayerCharacter else 'UPValue', + str(character.sp).rjust(3, '\u00A0')), + '\u00A0' * 4 + ] if character.sp is not None else []) + ([ + ' \u00A0\u00A0\u00A0\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 \u00A0\u00A0\u00A0\u00A0', + (('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) + text=f'({character.access_key if character.access_key != "" else " "})', cursor_position=1) super().__init__(Columns([ (4, Padding( w=AttrMap( @@ -219,10 +232,8 @@ class LogMessageUI(WidgetWrap): 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 != ""]))])) + ('weight', 1, Text(decode_discord(text, 'LogText', 'LogBold'))) + ])) class LogUI(WidgetWrap): @@ -260,13 +271,11 @@ class LogUI(WidgetWrap): 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", + ('weight', 1, LineBox(original_widget=clocks, title="Clocks", + title_attr="FrameTitle", title_align='left')), + ('weight', 7, LineBox(original_widget=characters, title="Character Status", + title_attr="FrameTitle", title_align='left')), + ('weight', 2, LineBox(original_widget=log, title="Log", title_attr="FrameTitle", title_align='left')), ])) @@ -282,7 +291,7 @@ class AbilityEntryPopup(WidgetWrap): 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) + self.sp_cost_editor = IntEdit(f'{user.character_type.sp_name_abbr} cost (of {user.sp}) ', 0) editors.append(self.sp_cost_editor) else: self.sp_cost_editor = None