Action + effect work

main
Mari 1 year ago
parent 7325c46f96
commit 613dae80a2
  1. 30
      actions.py
  2. 29
      app.py
  3. 190
      effects.py
  4. 71
      embedgen.py
  5. 153
      enums.py
  6. 2
      jsonable.py
  7. 184
      model.py
  8. 157
      ui.py

@ -1,15 +1,41 @@
from dataclasses import dataclass from dataclasses import dataclass
from typing import Union
from effects import DamageEffect from effects import DamageEffect
from enums import CombatSide from enums import CombatSide
from jsonable import JsonableDataclassArgs 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) @dataclass(**JsonableDataclassArgs)
class AbilityAction(Action): class AbilityAction(Action):
name: str name: str
user: Target user: Target | None
costs: tuple[DamageEffect, ...] costs: tuple[DamageEffect, ...]
effects: tuple[Effect, ...] effects: tuple[Effect, ...]

@ -64,6 +64,7 @@ class FabulaApp(object):
color="a50ecf", color="a50ecf",
thumbnail=None, player_name=None, player_discord_id=None, ) thumbnail=None, player_name=None, player_discord_id=None, )
self.characters.add_character(vacana_hunger) self.characters.add_character(vacana_hunger)
self.clocks.update_session(1, 2, 7)
self.webhook = webhook self.webhook = webhook
self.character_list = (prandia, selia, prandia_voraphilia, vacana, flow, vacana_hunger) self.character_list = (prandia, selia, prandia_voraphilia, vacana, flow, vacana_hunger)
self.event_loop = None self.event_loop = None
@ -87,7 +88,7 @@ class FabulaApp(object):
def run(self): def run(self):
set_encoding("UTF-8") set_encoding("UTF-8")
self.event_loop = asyncio.new_event_loop() self.event_loop = asyncio.new_event_loop()
loop = MainLoop( self.loop = MainLoop(
widget=self.wrapper, widget=self.wrapper,
unhandled_input=self.keypress, unhandled_input=self.keypress,
event_loop=AsyncioEventLoop(loop=self.event_loop), event_loop=AsyncioEventLoop(loop=self.event_loop),
@ -95,11 +96,11 @@ class FabulaApp(object):
(None, "light gray", "default", "default"), (None, "light gray", "default", "default"),
("FrameTitle", "white,bold", "default", "bold"), ("FrameTitle", "white,bold", "default", "bold"),
("LogBold", "white,bold", "default", "bold"), ("LogBold", "white,bold", "default", "bold"),
("LogFocused", "light cyan,bold", "default", "bold"), ("LogFocused", "white,bold", "default", "bold"),
("LogUnfocused", "dark blue", "default", "default"), ("LogUnfocused", "light cyan", "default", "default"),
("ClockName", "white,bold", "default", "bold"), ("ClockName", "white,bold", "default", "bold"),
("ClockFocused", "light green,bold", "default", "bold"), ("ClockFocused", "white,bold", "default", "bold"),
("ClockUnfocused", "dark green", "default", "default"), ("ClockUnfocused", "light green", "default", "default"),
("ClockBarEdge", 'yellow,bold', 'default', 'bold'), ("ClockBarEdge", 'yellow,bold', 'default', 'bold'),
("ClockBarFilled", 'yellow', 'default', 'default'), ("ClockBarFilled", 'yellow', 'default', 'default'),
("ClockBarEmpty", 'light gray', 'default', 'default'), ("ClockBarEmpty", 'light gray', 'default', 'default'),
@ -112,12 +113,12 @@ class FabulaApp(object):
("FabulaNumber", 'white,bold', 'default', 'bold'), ("FabulaNumber", 'white,bold', 'default', 'bold'),
("UltimaLabel", 'light gray', 'default', 'default'), ("UltimaLabel", 'light gray', 'default', 'default'),
("UltimaNumber", 'white,bold', 'default', 'bold'), ("UltimaNumber", 'white,bold', 'default', 'bold'),
("CharacterUnfocused", 'dark red', 'default', 'default'), ("CharacterUnfocused", 'light red', 'default', 'default'),
("CharacterFocused", 'light red,bold', 'default', 'bold'), ("CharacterFocused", 'white,bold', 'default', 'bold'),
("CharacterPlayer", 'light green,bold', 'default', 'bold'), ("CharacterPlayer", 'light green,bold', 'default', 'bold'),
("CharacterAlly", 'dark green,bold', 'default', 'bold'), ("CharacterAlly", 'light cyan,bold', 'default', 'bold'),
("CharacterEnemy", 'dark red,bold', 'default', 'bold'), ("CharacterEnemy", 'light red,bold', 'default', 'bold'),
("CharacterEnemyPlayer", 'light red,bold', 'default', 'bold'), ("CharacterEnemyPlayer", 'yellow,bold', 'default', 'bold'),
("CharacterUnknown", 'light gray,bold', 'default', 'bold'), ("CharacterUnknown", 'light gray,bold', 'default', 'bold'),
("TurnDisabled", 'dark gray', 'default', 'default'), ("TurnDisabled", 'dark gray', 'default', 'default'),
("TurnAvailable", 'light green,bold', 'default', 'bold'), ("TurnAvailable", 'light green,bold', 'default', 'bold'),
@ -131,20 +132,20 @@ class FabulaApp(object):
("HPLabel", 'light gray', 'default', 'default'), ("HPLabel", 'light gray', 'default', 'default'),
("HPMax", 'light gray', 'default', 'default'), ("HPMax", 'light gray', 'default', 'default'),
("MPLabel", 'light gray', 'default', 'default'), ("MPLabel", 'light gray', 'default', 'default'),
("MPValue", 'light blue', 'default', 'default'), ("MPValue", 'light cyan', 'default', 'default'),
("MPMax", 'light gray', 'default', 'default'), ("MPMax", 'light gray', 'default', 'default'),
("FPLabel", 'light gray', 'default', 'default'), ("FPLabel", 'light gray', 'default', 'default'),
("UPLabel", 'light gray', 'default', 'default'), ("UPLabel", 'light gray', 'default', 'default'),
("FPValue", 'yellow', 'default', 'default'), ("FPValue", 'yellow', 'default', 'default'),
("UPValue", 'yellow', 'default', 'default'), ("UPValue", 'yellow', 'default', 'default'),
("IPLabel", 'light gray', 'default', 'default'), ("IPLabel", 'light gray', 'default', 'default'),
("IPValue", 'light cyan', 'default', 'default'), ("IPValue", 'light magenta', 'default', 'default'),
("IPMax", 'light gray', 'default', 'default'), ("IPMax", 'light gray', 'default', 'default'),
("StatusKO", 'light red,bold', 'default', 'bold,standout'), ("StatusKO", 'light red,bold', 'default', 'bold,standout'),
("StatusCrisis", 'yellow,bold', 'default', 'bold'), ("StatusCrisis", 'yellow,bold', 'default', 'bold'),
("Statuses", 'white', 'default', 'default'), ("Statuses", 'white', 'default', 'default'),
]) ])
loop.screen.set_terminal_properties( self.loop.screen.set_terminal_properties(
colors=2 ** 24, colors=2 ** 24,
has_underline=True, has_underline=True,
bright_is_bold=False, bright_is_bold=False,

@ -1,25 +1,50 @@
from dataclasses import dataclass 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 jsonable import JsonableDataclassArgs
from model import Effect, Target, Action from model import Effect, Target, Action, CombatStatus, LogOnly, log_substitute, Character, Clock
@dataclass(**JsonableDataclassArgs) @dataclass(**JsonableDataclassArgs)
class OpportunityEffect(Effect): class OpportunityEffect(LogOnly, Effect):
target: Target target: Target | None
opportunity_text: str 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) @dataclass(**JsonableDataclassArgs)
class SubActionEffect(Effect): class SubActionEffect(Effect):
action: Action action: Action
trigger_text: str 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) @dataclass(**JsonableDataclassArgs)
class DamageEffect(Effect): class DamageEffect(Effect):
target: Target | None target: Target
target_type: CharacterType
attribute: Counter attribute: Counter
damage: int damage: int
old_value: int old_value: int
@ -29,6 +54,45 @@ class DamageEffect(Effect):
affinity: Affinity affinity: Affinity
piercing: bool 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) @dataclass(**JsonableDataclassArgs)
class StatusEffect(Effect): class StatusEffect(Effect):
@ -36,24 +100,132 @@ class StatusEffect(Effect):
old_status: str | None old_status: str | None
new_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): def __post_init__(self):
if self.old_status is None and self.new_status is None: 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") 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) @dataclass(**JsonableDataclassArgs)
class FPBonusEffect(Effect): class FPBonusEffect(Effect):
user: Target
rerolls: int 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) @dataclass(**JsonableDataclassArgs)
class MissEffect(Effect): class MissEffect(LogOnly, Effect):
miss_type: MissType miss_type: MissType
target: Target 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) @dataclass(**JsonableDataclassArgs)
class GeneralEffect(Effect): class GeneralEffect(LogOnly, Effect):
text: str 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"}.')

@ -3,66 +3,9 @@ from typing import Iterable
from discord_webhook import DiscordEmbed, AsyncDiscordWebhook from discord_webhook import DiscordEmbed, AsyncDiscordWebhook
from enums import CantMoveEmoji, DoneMovingEmoji, NotMovedEmoji, TurnsLeftEmoji, get_indicator_key
from model import Character 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]: def simple_character_field(c: Character) -> tuple[str, str]:
header, statuses = character_header(c) header, statuses = character_header(c)
@ -163,10 +106,10 @@ def detailed_character_embed(c: Character) -> DiscordEmbed:
return result return result
def character_header(c): def character_header(c: Character) -> tuple[list[str], list[str]]:
header = [ header = [
turn_icon(c), 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 '***???***' f'**{c.name}**' if c.visibility.show_name else '***???***'
] ]
@ -180,12 +123,12 @@ def character_header(c):
return header, statuses return header, statuses
def character_details(c, statuses): def character_details(c: Character, statuses: list[str]):
result = [] result = []
if c.max_ip > 0: if c.max_ip > 0:
result.append(f"**IP** {c.ip}/{c.max_ip}") result.append(f"**IP** {c.ip}/{c.max_ip}")
if c.sp is not None: 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: if len(statuses) > 0:
result.append(f'_{", ".join(statuses)}_') result.append(f'_{", ".join(statuses)}_')
return result 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: 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 return CantMoveEmoji
elif c.turns_left == 0: elif c.turns_left <= 0:
return DoneMovingEmoji return DoneMovingEmoji
elif c.max_turns == 1: elif c.max_turns == 1:
return NotMovedEmoji return NotMovedEmoji

@ -1,4 +1,91 @@
from re import compile as compile_re, escape as escape_re
from enum import Enum 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'(?<!\\)\*\*')
def decode_discord(v: str, normal_style: str, bold_style: str) -> 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): class Affinity(Enum):
@ -21,6 +108,7 @@ class Element(Enum):
Ice = "ice" Ice = "ice"
Light = "light" Light = "light"
Dark = "dark" Dark = "dark"
Healing = "healing"
class CharacterType(Enum): class CharacterType(Enum):
@ -74,13 +162,35 @@ class Counter(Enum):
IP = "IP" IP = "IP"
SP = "SP" 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): class MissType(Enum):
Dodged = "dodged"
Missed = "missed" Missed = "missed"
Blocked = "blocked" Blocked = "blocked"
Avoided = "avoided" Avoided = "avoided"
Repelled = "repelled" Repelled = "repelled"
Countered = "countered"
Parried = "parried"
Shielded = "shielded"
Resisted = "resisted" Resisted = "resisted"
Immunity = "immune"
Protected = "protected"
class CombatSide(Enum): class CombatSide(Enum):
@ -95,7 +205,42 @@ class CombatSide(Enum):
return CombatSide.Heroes return CombatSide.Heroes
class StatusChange(Enum): class ClockIcon(Enum):
Added = "added" Hourglass = ""
Removed = "removed" EmptyHourglass = ""
Changed = "changed" 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]

@ -10,7 +10,7 @@ JsonableDataclassArgs = {"init": True, "repr": True, "eq": True, "order": False,
"kw_only": True, "slots": True, "weakref_slot": True} "kw_only": True, "slots": True, "weakref_slot": True}
JsonableParentArgs = {"init": False, "repr": True, "eq": True, "order": False, "frozen": True, "match_args": 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) @dataclass(**JsonableParentArgs)

@ -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) @dataclass(**JsonableDataclassArgs)
@ -12,22 +16,94 @@ class Target(Jsonable):
name: str 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) @dataclass(**JsonableParentArgs)
class Effect(Jsonable): class Action(Jsonable, Doable, ABC):
pass @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) @dataclass(**JsonableParentArgs)
class Action(Jsonable): class Effect(Jsonable, Doable, ABC):
pass @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) @dataclass(**JsonableDataclassArgs)
class Clock(Jsonable): class Clock(Jsonable):
name: str name: str
icon: ClockIcon
ticks: ClockTicks
current: int current: int
maximum: int maximum: int
def set_value(self, value: int) -> "Clock":
return replace(self, current=min(self.maximum, max(0, value)))
@dataclass(**JsonableDataclassArgs) @dataclass(**JsonableDataclassArgs)
class Character(Jsonable): class Character(Jsonable):
@ -56,10 +132,57 @@ class Character(Jsonable):
player_discord_id: str | None player_discord_id: str | None
player_name: 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) @dataclass(**JsonableDataclassArgs)
class CombatStatus(Jsonable): class CombatStatus(Jsonable):
round_number: int round_number: int | None
fp_spent: int fp_spent: int
up_spent: int up_spent: int
clocks: tuple[Clock, ...] clocks: tuple[Clock, ...]
@ -68,3 +191,48 @@ class CombatStatus(Jsonable):
starting_side: CombatSide | None starting_side: CombatSide | None
active_side: CombatSide | None active_side: CombatSide | None
active_combatant: int | 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)))

157
ui.py

@ -5,19 +5,20 @@ from urwid import Pile, Columns, ListBox, SimpleFocusListWalker, Text, WidgetWra
Button Button
from model import Character, Clock from model import Character, Clock
from enums import CharacterType, Counter from enums import CharacterType, Counter, decode_discord, CombatSide
class SessionStateUI(WidgetWrap): class SessionStateUI(WidgetWrap):
@classmethod @staticmethod
def render_round_timer(cls, rd: int, fp_spent: int, up_spent: int): def render_round_timer(rd: int | None, fp_spent: int, up_spent: int):
return ([('RoundLabel', 'Round'), ': ', ('RoundNumber', str(rd)), ' / '] if rd > 0 else []) + [ return (
('FabulaLabel', 'FP Used'), ([('RoundLabel', 'Round'), ':\u00A0', ('RoundNumber', str(rd)), ' / '] if rd is not None else []) +
': ', ([('FabulaLabel', 'FP\u00A0Used'), ':\u00A0', ('FabulaNumber', str(fp_spent))]) +
('FabulaNumber', str(fp_spent)), ([' / ', ('UltimaLabel', 'UP\u00A0Used'), ':\u00A0', ('UltimaNumber', str(up_spent))]
] + ([' / ', ('UltimaLabel', 'UP Used'), ': ', ('UltimaNumber', str(up_spent))] if up_spent > 0 else []) if up_spent > 0 else [])
)
def __init__(self, rd: int, fp_spent: int, up_spent: int):
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)) self.text = Text(self.render_round_timer(rd=rd, fp_spent=fp_spent, up_spent=up_spent))
super().__init__(Columns([ super().__init__(Columns([
(4, Padding( (4, Padding(
@ -29,7 +30,7 @@ class SessionStateUI(WidgetWrap):
right=1)), right=1)),
('weight', 1, self.text)])) ('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)) 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): class ClocksUI(WidgetWrap):
def __init__(self): def __init__(self):
self.state = SessionStateUI(rd=0, fp_spent=0, up_spent=0) self.state = SessionStateUI(rd=0, fp_spent=0, up_spent=0)
self.items = SimpleFocusListWalker([]) self.items = SimpleFocusListWalker([self.state])
self.list = ListBox(self.items) 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) self.state.update_session(rd=rd, fp_spent=fp_spent, up_spent=up_spent)
def add_clock(self, clock: Clock, index: int = -1) -> int: def add_clock(self, clock: Clock, index: int = -1) -> int:
if index == -1: if index < 0:
index = len(self.items) index = len(self.items) - 1
self.items.insert(index, ClockUI(clock=clock)) self.items.insert(index + 1, ClockUI(clock=clock))
return index return index
def update_clock(self, index: int, clock: Clock): 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): def reorder_clock(self, old: int, new: int = -1):
focused = self.list.focus_position == old focused = self.list.focus_position == old + 1
item = self.items.pop(old) item = self.items.pop(old + 1)
new = new if new != -1 else len(self.items) new = new if new >= 0 else len(self.items)
self.items.insert(new, self.items.pop(item)) self.items.insert(new + 1, self.items.pop(item))
if focused: if focused:
self.items.set_focus(position=new) self.items.set_focus(position=new)
@ -98,33 +101,36 @@ class ClocksUI(WidgetWrap):
def clear(self): def clear(self):
self.items.clear() self.items.clear()
self.append(self.state)
class CharacterUI(WidgetWrap): class CharacterUI(WidgetWrap):
@classmethod @classmethod
def render_character_text(cls, character: Character): def render_character_text(cls, character: Character):
if character.role == CharacterType.Player: if character.side == CombatSide.Heroes:
role_icon = '' if character.character_type == CharacterType.PlayerCharacter:
role_style = 'CharacterPlayer' role_icon = ''
elif character.role == CharacterType.Ally: role_style = 'CharacterPlayer'
role_icon = '' else:
role_style = 'CharacterAlly' role_icon = ''
elif character.role == CharacterType.Enemy: role_style = 'CharacterAlly'
role_icon = ''
role_style = 'CharacterEnemy'
elif character.role == CharacterType.EnemyPlayer:
role_icon = ''
role_style = 'CharacterEnemyPlayer'
else: else:
role_icon = '*' if character.character_type == CharacterType.NonPlayerCharacter:
role_style = 'CharacterUnknown' role_icon = ''
if character.max_turns == 0 or character.hp == 0: role_style = 'CharacterEnemy'
else:
role_icon = ''
role_style = 'CharacterEnemyPlayer'
if character.max_turns <= 0 or character.hp == 0:
turn_style = 'TurnDisabled' turn_style = 'TurnDisabled'
turn_text = '[✗]' turn_text = '[✖]'
elif character.turns_left > 9:
turn_style = 'TurnAvailable'
turn_text = f'[#]'
elif character.turns_left > 1: elif character.turns_left > 1:
turn_style = 'TurnAvailable' turn_style = 'TurnAvailable'
turn_text = f'[{character.turns_left}]' turn_text = f'[{character.turns_left}]'
elif character.turns_left == 0: elif character.turns_left <= 0:
turn_style = 'TurnActed' turn_style = 'TurnActed'
turn_text = '[✓]' turn_text = '[✓]'
elif character.max_turns > 1: elif character.max_turns > 1:
@ -151,43 +157,50 @@ class CharacterUI(WidgetWrap):
else: else:
hp_style = 'HPDown' hp_style = 'HPDown'
hp_suffix = '' hp_suffix = ''
return ([ return ([(turn_style, turn_text), ' ', (role_style, role_icon + character.name)],
(turn_style, turn_text), ['\u00A0\u00A0\u00A0\u00A0',
' ', (hp_style, hp_suffix),
(role_style, role_icon + character.name), ('HPLabel', 'HP'),
],
[('HPLabel', hp_suffix + 'HP'),
'\u00A0', '\u00A0',
(hp_style, str(character.hp).rjust(3, '\u00A0')), (hp_style, str(character.hp).rjust(3, '\u00A0')),
'/', '/',
('HPMax', str(character.max_hp).rjust(3, '\u00A0')), ('HPMax', str(character.max_hp).rjust(3, '\u00A0')),
'\u00A0 ', ' \u00A0\u00A0\u00A0\u00A0',
('MPLabel', '\u00A0MP'), ('MPLabel', '\u00A0MP'),
'\u00A0', '\u00A0',
('MPValue', str(character.mp).rjust(3, '\u00A0')), ('MPValue', str(character.mp).rjust(3, '\u00A0')),
'/', '/',
('MPMax', str(character.max_mp).rjust(3, '\u00A0'))] + ( ('MPMax', str(character.max_mp).rjust(3, '\u00A0'))] + ([
['\u00A0 ', ' \u00A0\u00A0\u00A0\u00A0',
('FPLabel' if character.role in (CharacterType.Player, CharacterType.EnemyPlayer) else 'UPLabel', '\u00A0FP' if character.role in (CharacterType.Player, CharacterType.EnemyPlayer) else '\u00A0UP'), ('FPLabel' if character.character_type == CharacterType.PlayerCharacter else 'UPLabel',
'\u00A0', '\u00A0FP' if character.character_type == CharacterType.PlayerCharacter else '\u00A0UP'),
('FPValue' if character.role in (CharacterType.Player, CharacterType.EnemyPlayer) else 'UPValue', str(character.sp).rjust(3, '\u00A0')), '\u00A0',
'\u00A0' * 4] if character.sp is not None else []) + ( ('FPValue' if character.character_type == CharacterType.PlayerCharacter else 'UPValue',
['\u00A0 ', ('IPLabel', '\u00A0IP'), '\u00A0', ('IPValue', str(character.ip).rjust(3, '\u00A0')), '/', str(character.sp).rjust(3, '\u00A0')),
('IPMax', str(character.max_ip).rjust(3, '\u00A0'))] if character.max_ip > 0 else []) + '\u00A0' * 4
(['\n ', ] if character.sp is not None else []) + ([
(('StatusKO', "KO") if character.hp == 0 else ' \u00A0\u00A0\u00A0\u00A0',
('StatusCrisis', "Crisis") if character.hp * 2 < character.max_hp else ""), ('IPLabel', '\u00A0IP'),
('Statuses', '\u00A0',
", ".join(([""] if character.hp * 2 < character.max_hp else []) + ('IPValue', str(character.ip).rjust(3, '\u00A0')),
sorted(list(str(s) for s in character.statuses)))), '/',
] if len(character.statuses) > 0 or character.hp * 2 < character.max_hp else [])) ('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): def __init__(self, character: Character):
name_text, condition_text = self.render_character_text(character=character) name_text, condition_text = self.render_character_text(character=character)
self.nameText = Text(name_text) self.nameText = Text(name_text)
self.conditionText = Text(condition_text) self.conditionText = Text(condition_text)
self.icon = SelectableIcon( 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([ super().__init__(Columns([
(4, Padding( (4, Padding(
w=AttrMap( w=AttrMap(
@ -219,10 +232,8 @@ class LogMessageUI(WidgetWrap):
align='left', align='left',
width=3, width=3,
right=1)), right=1)),
('weight', 1, Text([ ('weight', 1, Text(decode_discord(text, 'LogText', 'LogBold')))
('LogBold', v) if index % 2 != 0 else ('LogText', v) ]))
for index, v
in enumerate(text.split("**")) if v != ""]))]))
class LogUI(WidgetWrap): class LogUI(WidgetWrap):
@ -260,13 +271,11 @@ class LogUI(WidgetWrap):
class MainUI(WidgetWrap): class MainUI(WidgetWrap):
def __init__(self, clocks: ClocksUI, characters: CharactersUI, log: LogUI): def __init__(self, clocks: ClocksUI, characters: CharactersUI, log: LogUI):
super().__init__(Pile([ super().__init__(Pile([
('weight', 5, Columns([ ('weight', 1, LineBox(original_widget=clocks, title="Clocks",
('weight', 5, LineBox(original_widget=characters, title="Character Status", title_attr="FrameTitle", title_align='left')),
title_attr="FrameTitle", title_align='left')), ('weight', 7, LineBox(original_widget=characters, title="Character Status",
('weight', 3, LineBox(original_widget=clocks, title="Clocks", title_attr="FrameTitle", title_align='left')),
title_attr="FrameTitle", title_align='left')), ('weight', 2, LineBox(original_widget=log, title="Log",
])),
('weight', 3, LineBox(original_widget=log, title="Combat Log",
title_attr="FrameTitle", title_align='left')), 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) 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] editors = [self.ability_name_editor, self.mp_cost_editor, self.ip_cost_editor, self.hp_cost_editor]
if user.sp is not None: 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) editors.append(self.sp_cost_editor)
else: else:
self.sp_cost_editor = None self.sp_cost_editor = None

Loading…
Cancel
Save