import {GameState, getCharacterById} from "./GameState"; import {CharacterId} from "./Character"; export interface BaseDoable { readonly type: string } export type LogEntry = { readonly markdown: string readonly children: readonly LogEntry[] } export interface DoableResults { readonly resultState: GameState readonly logEntry: LogEntry|null } export interface DoablesResults { readonly resultState: GameState readonly logEntries: readonly LogEntry[] } export interface DoableEvaluator { readonly type: DataType["type"] evaluate(data: DataType, state: GameState, direction: DoableDirection): DoableResults } export interface GenericAction extends BaseDoable { readonly type: "generic", readonly text: string readonly user: CharacterId|null readonly target: CharacterId|null readonly effects: readonly Doable[] } export const GenericActionEvaluator = { type: "generic", evaluate(data: GenericAction, state: GameState, direction: DoableDirection): DoableResults { function runEffects(currentState: GameState): DoablesResults { return evaluateDoables(data.effects, state, direction) } function logSelf(currentState: GameState): string { return data.text.replaceAll(/@[TU]/g, (substring: string): string => { switch (substring) { case "@T": // TODO: make "character links" a function, likely with identifier characters return data.target !== null ? `[${getCharacterById(currentState, data.target)?.name ?? "???"}](#character/${data.target})` : "@T" case "@U": return data.user !== null ? `[${getCharacterById(currentState, data.user)?.name ?? "???"}](#character/${data.user})` : "@U" default: return substring } }) } switch (direction) { case DoableDirection.Do: { const markdown = logSelf(state) const {resultState, logEntries} = runEffects(state) return { resultState, logEntry: { markdown, children: logEntries } } } case DoableDirection.Undo: { const {resultState, logEntries} = runEffects(state) const markdown = logSelf(resultState) return { resultState, logEntry: { markdown, children: logEntries } } } } }, } as const satisfies DoableEvaluator /** * @dataclass(**JsonableDataclassArgs) * class AbilityAction(Doable): * name: str * user: Target | None * costs: tuple[DamageEffect, ...] * effects: tuple[Effect, ...] * * * @dataclass(**JsonableDataclassArgs) * class ModifyCharacterEffect(Doable): * index: int * old_character_data: Character | None * new_character_data: Character | None * * def __post_init__(self): * if self.old_character_data is None and self.new_character_data is None: * raise ValueError("At least one of old_character_data or new_character_data must be non-None") * * @dataclass(**JsonableDataclassArgs) * class ModifyClockEffect(Doable): * index: int * old_clock_data: Clock | None * new_clock_data: Clock | None * * def __post_init__(self): * if self.old_clock_data is None and self.new_clock_data is None: * raise ValueError("At least one of old_clock_data or new_clock_data must be non-None") * * * @dataclass(**JsonableDataclassArgs) * class EndTurnAction(Doable): * turn_ending_index: int * activating_side: CombatSide * * * @dataclass(**JsonableDataclassArgs) * class StartTurnAction(Doable): * turn_starting_index: int * old_active_side: CombatSide * * * @dataclass(**JsonableDataclassArgs) * class StartRoundAction(Doable): * last_round: int * old_active_side: CombatSide * old_turns_remaining: tuple[int, ...] * next_round: int * activating_side: CombatSide * * * @dataclass(**JsonableDataclassArgs) * class StartBattleAction(Doable): * starting_side: CombatSide * starting_round: int * * * @dataclass(**JsonableDataclassArgs) * class EndBattleAction(Doable): * old_round_number: int * old_active_side: CombatSide * old_starting_side: CombatSide * old_turns_remaining: tuple[int, ...] * * @dataclass(**JsonableDataclassArgs) * 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 DamageEffect(Effect): * target: Target * target_type: CharacterType * attribute: Counter * damage: int * old_value: int * new_value: int * max_value: int | None * element: Element * 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): * target: Target * 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 * 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}!" * * TODO: add an FP gain effect for villains (affects all party members) and fumbles and trait failures * * @dataclass(**JsonableDataclassArgs) * 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 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"}.') */ const DoableEvaluators = [ GenericActionEvaluator, ] as const satisfies readonly DoableEvaluator[] export type Doable = typeof DoableEvaluators[number] extends DoableEvaluator ? ActionType : never export enum DoableDirection { Do = "do", Undo = "undo", } export function evaluateDoable(doable: T, gameState: GameState, direction: DoableDirection): DoableResults { const evaluator: DoableEvaluator|undefined = DoableEvaluators.find((evaluator) => evaluator.type === doable.type) if (!evaluator) { return { resultState: gameState, logEntry: null } } return evaluator.evaluate(doable, gameState, direction) } export function evaluateDoables(doables: readonly Doable[], gameState: GameState, direction: DoableDirection): DoablesResults { let currentState = gameState const sortedDoables = direction === DoableDirection.Undo ? doables.slice().reverse() : doables const logEntries: LogEntry[] = [] for (const doable of sortedDoables) { const {resultState, logEntry} = evaluateDoable(doable, gameState, direction) currentState = resultState if (logEntry) { logEntries.push(logEntry) } } return { resultState: currentState, logEntries: direction === DoableDirection.Undo ? logEntries.reverse() : logEntries, } }