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

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

@ -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"}.')

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

@ -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'(?<!\\)\*\*')
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):
@ -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]

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

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

157
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

Loading…
Cancel
Save