Tracker made in React for keeping track of HP and MP and so on.
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

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