You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
372 lines
15 KiB
372 lines
15 KiB
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<DataType extends BaseDoable> {
|
|
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<GenericAction>
|
|
/**
|
|
* @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<unknown & BaseDoable>[]
|
|
|
|
export type Doable = typeof DoableEvaluators[number] extends DoableEvaluator<infer ActionType> ? ActionType : never
|
|
|
|
export enum DoableDirection {
|
|
Do = "do",
|
|
Undo = "undo",
|
|
}
|
|
|
|
export function evaluateDoable<T extends Doable>(doable: T, gameState: GameState, direction: DoableDirection): DoableResults {
|
|
const evaluator: DoableEvaluator<T>|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,
|
|
}
|
|
} |