import { Creature } from "./creature" import { TextLike } from '@/game/language' import { LogEntry, LogLines, FAElem, LogLine, FormatEntry, FormatOpt, PropElem, nilLog, Newline } from '@/game/interface' import { World } from '@/game/world' import { TestCategory } from '@/game/combat/tests' import { VoreContainer } from '@/game/vore' import { SoloTargeter } from '@/game/combat/targeters' export enum DamageType { Pierce = "Pierce", Slash = "Slash", Crush = "Crush", Acid = "Acid", Seduction = "Seduction", Dominance = "Dominance", Heal = "Heal", Pure = "Pure" } export interface DamageInstance { type: DamageType; amount: number; target: Vigor | Stat; } export enum Vigor { Health = "Health", Stamina = "Stamina", Resolve = "Resolve" } export const VigorIcons: {[key in Vigor]: string} = { Health: "fas fa-heart", Stamina: "fas fa-bolt", Resolve: "fas fa-brain" } export const VigorDescs: {[key in Vigor]: string} = { Health: "How much damage you can take", Stamina: "How much energy you have", Resolve: "How much dominance you can resist" } export type Vigors = {[key in Vigor]: number} export enum Stat { Toughness = "Toughness", Power = "Power", Reflexes = "Reflexes", Agility = "Agility", Willpower = "Willpower", Charm = "Charm" } export type Stats = {[key in Stat]: number} export const StatToVigor: {[key in Stat]: Vigor} = { Toughness: Vigor.Health, Power: Vigor.Health, Reflexes: Vigor.Stamina, Agility: Vigor.Stamina, Willpower: Vigor.Resolve, Charm: Vigor.Resolve } export const StatIcons: {[key in Stat]: string} = { Toughness: 'fas fa-heartbeat', Power: 'fas fa-fist-raised', Reflexes: 'fas fa-stopwatch', Agility: 'fas fa-feather', Willpower: 'fas fa-book', Charm: 'fas fa-comments' } export const StatDescs: {[key in Stat]: string} = { Toughness: 'Your brute resistance', Power: 'Your brute power', Reflexes: 'Your ability to dodge', Agility: 'Your ability to move quickly', Willpower: 'Your mental resistance', Charm: 'Your mental power' } export enum VoreStat { Mass = "Mass", Bulk = "Bulk", Prey = "Prey" } export type VoreStats = {[key in VoreStat]: number} export const VoreStatIcons: {[key in VoreStat]: string} = { [VoreStat.Mass]: "fas fa-weight", [VoreStat.Bulk]: "fas fa-weight-hanging", [VoreStat.Prey]: "fas fa-utensils" } export const VoreStatDescs: {[key in VoreStat]: string} = { [VoreStat.Mass]: "How much you weigh", [VoreStat.Bulk]: "Your weight, plus the weight of your prey", [VoreStat.Prey]: "How many creatures you've got inside of you" } export interface CombatTest { test: (user: Creature, target: Creature) => boolean; odds: (user: Creature, target: Creature) => number; explain: (user: Creature, target: Creature) => LogEntry; fail: (user: Creature, target: Creature) => LogEntry; } export interface Targeter { targets (primary: Creature, encounter: Encounter): Array; } /** * An instance of damage. Contains zero or more [[DamageInstance]] objects */ export class Damage { readonly damages: DamageInstance[] constructor (...damages: DamageInstance[]) { this.damages = damages } scale (factor: number): Damage { const results: Array = [] this.damages.forEach(damage => { results.push({ type: damage.type, amount: damage.amount * factor, target: damage.target }) }) return new Damage(...results) } // TODO make this combine damage instances when appropriate combine (other: Damage): Damage { return new Damage(...this.damages.concat(other.damages)) } toString (): string { return this.damages.map(damage => damage.amount + " " + damage.type).join("/") } render (): LogEntry { return new LogLine(...this.damages.flatMap(instance => { if (instance.target in Vigor) { return [instance.amount.toString(), new FAElem(VigorIcons[instance.target as Vigor]), " " + instance.type] } else if (instance.target in Stat) { return [instance.amount.toString(), new FAElem(StatIcons[instance.target as Stat]), " " + instance.type] } else { // this should never happen! return [] } })) } // TODO is there a way to do this that will satisfy the typechecker? renderShort (): LogEntry { /* eslint-disable-next-line */ const vigorTotals: Vigors = Object.keys(Vigor).reduce((total: any, key) => { total[key] = 0; return total }, {}) /* eslint-disable-next-line */ const statTotals: Stats = Object.keys(Stat).reduce((total: any, key) => { total[key] = 0; return total }, {}) this.damages.forEach(instance => { const factor = instance.type === DamageType.Heal ? -1 : 1 if (instance.target in Vigor) { vigorTotals[instance.target as Vigor] += factor * instance.amount } else if (instance.target in Stat) { statTotals[instance.target as Stat] += factor * instance.amount } }) const vigorEntries = Object.keys(Vigor).flatMap(key => vigorTotals[key as Vigor] === 0 ? [] : [new PropElem(key as Vigor, vigorTotals[key as Vigor]), ' ']) const statEntries = Object.keys(Stat).flatMap(key => statTotals[key as Stat] === 0 ? [] : [new PropElem(key as Stat, statTotals[key as Stat]), ' ']) return new FormatEntry(new LogLine(...vigorEntries.concat(statEntries)), FormatOpt.DamageInst) } nonzero (): boolean { /* eslint-disable-next-line */ const vigorTotals: Vigors = Object.keys(Vigor).reduce((total: any, key) => { total[key] = 0; return total }, {}) /* eslint-disable-next-line */ const statTotals: Stats = Object.keys(Stat).reduce((total: any, key) => { total[key] = 0; return total }, {}) this.damages.forEach(instance => { const factor = instance.type === DamageType.Heal ? -1 : 1 if (instance.target in Vigor) { vigorTotals[instance.target as Vigor] += factor * instance.amount } else if (instance.target in Stat) { statTotals[instance.target as Stat] += factor * instance.amount } }) return Object.values(vigorTotals).some(v => v !== 0) || Object.values(statTotals).some(v => v !== 0) } } /** * Computes damage given the source and target of the damage. */ export interface DamageFormula { calc (user: Creature, target: Creature): Damage; describe (user: Creature, target: Creature): LogEntry; explain (user: Creature): LogEntry; } export class CompositeDamageFormula implements DamageFormula { constructor (private formulas: DamageFormula[]) { } calc (user: Creature, target: Creature): Damage { return this.formulas.reduce((total: Damage, next: DamageFormula) => total.combine(next.calc(user, target)), new Damage()) } describe (user: Creature, target: Creature): LogEntry { return new LogLines(...this.formulas.map(formula => formula.describe(user, target))) } explain (user: Creature): LogEntry { return new LogLines(...this.formulas.map(formula => formula.explain(user))) } } /** * Simply returns the damage it was given. */ export class ConstantDamageFormula implements DamageFormula { constructor (private damage: Damage) { } calc (user: Creature, target: Creature): Damage { return this.damage } describe (user: Creature, target: Creature): LogEntry { return this.explain(user) } explain (user: Creature): LogEntry { return new LogLine('Deal ', this.damage.renderShort()) } } /** * Randomly scales the damage it was given with a factor of (1-x) to (1+x) */ export class UniformRandomDamageFormula implements DamageFormula { constructor (private damage: Damage, private variance: number) { } calc (user: Creature, target: Creature): Damage { return this.damage.scale(Math.random() * this.variance * 2 - this.variance + 1) } describe (user: Creature, target: Creature): LogEntry { return this.explain(user) } explain (user: Creature): LogEntry { return new LogLine('Deal between ', this.damage.scale(1 - this.variance).renderShort(), ' and ', this.damage.scale(1 + this.variance).renderShort(), '.') } } /** * A [[DamageFormula]] that uses the attacker's stats */ export class StatDamageFormula implements DamageFormula { constructor (private factors: Array<{ stat: Stat|VoreStat; fraction: number; type: DamageType; target: Vigor|Stat }>) { } calc (user: Creature, target: Creature): Damage { const instances: Array = this.factors.map(factor => { if (factor.stat in Stat) { return { amount: factor.fraction * user.stats[factor.stat as Stat], target: factor.target, type: factor.type } } else if (factor.stat in VoreStat) { return { amount: factor.fraction * user.voreStats[factor.stat as VoreStat], target: factor.target, type: factor.type } } else { // should be impossible; .stat is Stat|VoreStat return { amount: 0, target: Vigor.Health, type: DamageType.Heal } } }) return new Damage(...instances) } describe (user: Creature, target: Creature): LogEntry { return new LogLine( this.explain(user), `, for a total of `, this.calc(user, target).renderShort() ) } explain (user: Creature): LogEntry { return new LogLine( `Deal `, ...this.factors.map(factor => new LogLine( `${factor.fraction * 100}% of your `, new PropElem(factor.stat), ` as `, new PropElem(factor.target) )).joinGeneral(new LogLine(`, `), new LogLine(` and `)) ) } } /** * Deals a percentage of the target's current vigors/stats */ export class FractionDamageFormula implements DamageFormula { constructor (private factors: Array<{ fraction: number; target: Vigor|Stat; type: DamageType }>) { } calc (user: Creature, target: Creature): Damage { const instances: Array = this.factors.map(factor => { if (factor.target in Stat) { return { amount: Math.max(0, factor.fraction * target.stats[factor.target as Stat]), target: factor.target, type: factor.type } } else if (factor.target in Vigor) { return { amount: Math.max(factor.fraction * target.vigors[factor.target as Vigor]), target: factor.target, type: factor.type } } else { // should be impossible; .target is Stat|Vigor return { amount: 0, target: Vigor.Health, type: DamageType.Heal } } }) return new Damage(...instances) } describe (user: Creature, target: Creature): LogEntry { return this.explain(user) } explain (user: Creature): LogEntry { return new LogLine( `Deal damage equal to `, ...this.factors.map(factor => new LogLine( `${factor.fraction * 100}% of your target's `, new PropElem(factor.target) )).joinGeneral(new LogLine(`, `), new LogLine(` and `)) ) } } export enum Side { Heroes, Monsters } /** * A Combatant has a list of possible actions to take, as well as a side. */ export interface Combatant { actions: Array; side: Side; } /** * An Action is anything that can be done by a [[Creature]] to a [[Creature]]. */ export abstract class Action { constructor ( public name: TextLike, public desc: TextLike, public conditions: Array = [], public tests: Array = [] ) { } allowed (user: Creature, target: Creature): boolean { return this.conditions.every(cond => cond.allowed(user, target)) } toString (): string { return this.name.toString() } try (user: Creature, targets: Array): LogEntry { const results = targets.map(target => { const failReason = this.tests.find(test => !test.test(user, target)) if (failReason !== undefined) { return { failed: true, target: target, log: failReason.fail(user, target) } } else { return { failed: false, target: target, log: this.execute(user, target) } } }) return new LogLines( ...results.map(result => result.log), this.executeAll(user, results.filter(result => !result.failed).map(result => result.target)) ) } describe (user: Creature, target: Creature, verbose = true): LogEntry { return new LogLines( ...(verbose ? this.conditions.map(condition => condition.explain(user, target)).concat([new Newline()]) : []), new LogLine( `Success chance: ${(this.odds(user, target) * 100).toFixed(0)}%` ), new Newline(), ...this.tests.map(test => test.explain(user, target)) ) } odds (user: Creature, target: Creature): number { return this.tests.reduce((total, test) => total * test.odds(user, target), 1) } targets (primary: Creature, encounter: Encounter): Array { return [primary] } executeAll (user: Creature, targets: Array): LogEntry { return nilLog } abstract execute (user: Creature, target: Creature): LogEntry } export class CompositionAction extends Action { public consequences: Array; public groupConsequences: Array; public targeters: Array; constructor ( name: TextLike, desc: TextLike, properties: { conditions?: Array; consequences?: Array; groupConsequences?: Array; tests?: Array; targeters?: Array; } ) { super(name, desc, properties.conditions ?? [], properties.tests ?? []) this.consequences = properties.consequences ?? [] this.groupConsequences = properties.groupConsequences ?? [] this.targeters = properties.targeters ?? [new SoloTargeter()] } execute (user: Creature, target: Creature): LogEntry { return new LogLines( ...this.consequences.filter(consequence => consequence.applicable(user, target)).map(consequence => consequence.apply(user, target)) ) } executeAll (user: Creature, targets: Array): LogEntry { return new LogLines( ...this.groupConsequences.map(consequence => consequence.apply(user, targets.filter(target => consequence.applicable(user, target)))) ) } describe (user: Creature, target: Creature): LogEntry { return new LogLines( ...this.consequences.map(consequence => consequence.describe(user, target)).concat( new Newline(), super.describe(user, target) ) ) } targets (primary: Creature, encounter: Encounter) { return this.targeters.flatMap(targeter => targeter.targets(primary, encounter)).unique() } } /** * A Condition describes whether or not something is permissible between two [[Creature]]s */ export interface Condition { allowed: (user: Creature, target: Creature) => boolean; explain: (user: Creature, target: Creature) => LogEntry; } export interface Actionable { actions: Array; } /** * Individual status effects, items, etc. should override some of these hooks. * Some hooks just produce a log entry. * Some hooks return results along with a log entry. */ export class Effective { /** * Executes when the effect is initially applied */ onApply (creature: Creature): LogEntry { return nilLog } /** * Executes when the effect is removed */ onRemove (creature: Creature): LogEntry { return nilLog } /** * Executes before the creature tries to perform an action */ preAction (creature: Creature): { prevented: boolean; log: LogEntry } { return { prevented: false, log: nilLog } } /** * Executes before another creature tries to perform an action that targets this creature */ preReceiveAction (creature: Creature, attacker: Creature): { prevented: boolean; log: LogEntry } { return { prevented: false, log: nilLog } } /** * Executes before the creature receives damage (or healing) */ preDamage (creature: Creature, damage: Damage): Damage { return damage } /** * Executes before the creature is attacked */ preAttack (creature: Creature, attacker: Creature): { prevented: boolean; log: LogEntry } { return { prevented: false, log: nilLog } } /** * Executes when a creature's turn starts */ preTurn (creature: Creature): { prevented: boolean; log: LogEntry } { return { prevented: false, log: nilLog } } /** * Modifies the effective resistance to a certain damage type */ modResistance (type: DamageType, factor: number): number { return factor } /** * Called when a test is about to resolve. Decides if the creature should automatically fail. */ failTest (creature: Creature, opponent: Creature): { failed: boolean; log: LogEntry } { return { failed: false, log: nilLog } } /** * Changes a creature's size. This represents the change in *mass* */ scale (scale: number): number { return scale } /** * Additively modifies a creature's score for an offensive test */ modTestOffense (attacker: Creature, defender: Creature, kind: TestCategory): number { return 0 } /** * Additively modifies a creature's score for a defensive test */ modTestDefense (defender: Creature, attacker: Creature, kind: TestCategory): number { return 0 } /** * Affects digestion damage */ modDigestionDamage (predator: Creature, prey: Creature, container: VoreContainer, damage: Damage): Damage { return damage } /** * Triggers after consumption */ postConsume (predator: Creature, prey: Creature, container: VoreContainer): LogEntry { return nilLog } /** * Affects a stat */ modStat (creature: Creature, stat: Stat, current: number): number { return current } /** * Provides actions */ actions (user: Creature): Array { return [] } } /** * A displayable status effect */ export interface VisibleStatus { name: TextLike; desc: TextLike; icon: TextLike; topLeft: string; bottomRight: string; } /** * This kind of status is never explicitly applied to an entity -- e.g., a dead entity will show * a status indicating that it is dead, but entities cannot be "given" the dead effect */ export class ImplicitStatus implements VisibleStatus { topLeft = '' bottomRight = '' constructor (public name: TextLike, public desc: TextLike, public icon: string) { } } /** * This kind of status is explicitly given to a creature. */ export abstract class StatusEffect extends Effective implements VisibleStatus { constructor (public name: TextLike, public desc: TextLike, public icon: string) { super() } get topLeft () { return '' } get bottomRight () { return '' } } export type EncounterDesc = { name: TextLike; intro: (world: World) => LogEntry; } /** * An Encounter describes a fight: who is in it and whose turn it is */ export class Encounter { initiatives: Map currentMove: Creature turnTime = 100 reward = 50 // Gold constructor (public desc: EncounterDesc, public combatants: Creature[]) { this.initiatives = new Map() combatants.forEach(combatant => this.initiatives.set(combatant, 0)) this.currentMove = combatants[0] this.nextMove() } nextMove (totalTime = 0): LogEntry { this.initiatives.set(this.currentMove, 0) const times = new Map() this.combatants.forEach(combatant => { // this should never be undefined const currentProgress = this.initiatives.get(combatant) ?? 0 const remaining = (this.turnTime - currentProgress) / Math.sqrt(Math.max(combatant.stats.Agility, 1)) times.set(combatant, remaining) }) this.currentMove = this.combatants.reduce((closest, next) => { const closestTime = times.get(closest) ?? 0 const nextTime = times.get(next) ?? 0 return closestTime <= nextTime ? closest : next }, this.combatants[0]) const closestRemaining = (this.turnTime - (this.initiatives.get(this.currentMove) ?? 0)) / Math.sqrt(Math.max(this.currentMove.stats.Agility, 1)) this.combatants.forEach(combatant => { // still not undefined... const currentProgress = this.initiatives.get(combatant) ?? 0 this.initiatives.set(combatant, currentProgress + closestRemaining * Math.sqrt(Math.max(combatant.stats.Agility, 1))) }) // TODO: still let the creature use drained-vigor moves if (this.currentMove.disabled) { return this.nextMove(closestRemaining + totalTime) } else { // applies digestion every time combat advances const tickResults = this.combatants.flatMap( combatant => combatant.containers.map( container => container.tick(5 * (closestRemaining + totalTime)) ) ) const effectResults = this.currentMove.effects.map(effect => effect.preTurn(this.currentMove)).filter(effect => effect.prevented) if (effectResults.some(result => result.prevented)) { const parts = effectResults.map(result => result.log).concat([this.nextMove()]) return new LogLines( ...parts, ...tickResults ) } else { return new LogLines( ...tickResults ) } } return nilLog } /** * Combat is won once one side is completely disabled */ get winner (): null|Side { const remaining: Set = new Set(this.combatants.filter(combatant => !combatant.disabled).map(combatant => combatant.side)) if (remaining.size === 1) { return Array.from(remaining)[0] } else { return null } } /** * Combat is completely won once one side is completely destroyed */ get totalWinner (): null|Side { const remaining: Set = new Set(this.combatants.filter(combatant => !combatant.destroyed).map(combatant => combatant.side)) if (remaining.size === 1) { return Array.from(remaining)[0] } else { return null } } } export abstract class Consequence { constructor (public conditions: Condition[]) { } applicable (user: Creature, target: Creature): boolean { return this.conditions.every(cond => cond.allowed(user, target)) } abstract describe (user: Creature, target: Creature): LogEntry abstract apply (user: Creature, target: Creature): LogEntry } export abstract class GroupConsequence { constructor (public conditions: Condition[]) { } applicable (user: Creature, target: Creature): boolean { return this.conditions.every(cond => cond.allowed(user, target)) } abstract describe (user: Creature, targets: Array): LogEntry abstract apply (user: Creature, targets: Array): LogEntry }