Feast 2.0!
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.
 
 
 
 
 

662 lines
18 KiB

  1. import { Creature } from "./creature"
  2. import { TextLike, DynText, ToBe, LiveText, PairLineArgs, PairLine } from './language'
  3. import { LogEntry, LogLines, FAElem, LogLine, FormatEntry, FormatOpt, PropElem, nilLog } from './interface'
  4. import { Resistances } from './entity'
  5. import { World } from './world'
  6. export enum DamageType {
  7. Pierce = "Pierce",
  8. Slash = "Slash",
  9. Crush = "Crush",
  10. Acid = "Acid",
  11. Seduction = "Seduction",
  12. Dominance = "Dominance",
  13. Heal = "Heal",
  14. Pure = "Pure"
  15. }
  16. export interface DamageInstance {
  17. type: DamageType;
  18. amount: number;
  19. target: Vigor | Stat;
  20. }
  21. export enum Vigor {
  22. Health = "Health",
  23. Stamina = "Stamina",
  24. Resolve = "Resolve"
  25. }
  26. export const VigorIcons: {[key in Vigor]: string} = {
  27. Health: "fas fa-heart",
  28. Stamina: "fas fa-bolt",
  29. Resolve: "fas fa-brain"
  30. }
  31. export const VigorDescs: {[key in Vigor]: string} = {
  32. Health: "How much damage you can take",
  33. Stamina: "How much energy you have",
  34. Resolve: "How much dominance you can resist"
  35. }
  36. export type Vigors = {[key in Vigor]: number}
  37. export enum Stat {
  38. Toughness = "Toughness",
  39. Power = "Power",
  40. Speed = "Speed",
  41. Willpower = "Willpower",
  42. Charm = "Charm"
  43. }
  44. export type Stats = {[key in Stat]: number}
  45. export const StatIcons: {[key in Stat]: string} = {
  46. Toughness: 'fas fa-heartbeat',
  47. Power: 'fas fa-fist-raised',
  48. Speed: 'fas fa-feather',
  49. Willpower: 'fas fa-book',
  50. Charm: 'fas fa-comments'
  51. }
  52. export const StatDescs: {[key in Stat]: string} = {
  53. Toughness: 'Your physical resistance',
  54. Power: 'Your physical power',
  55. Speed: 'How quickly you can act',
  56. Willpower: 'Your mental resistance',
  57. Charm: 'Your mental power'
  58. }
  59. export enum VoreStat {
  60. Mass = "Mass",
  61. Bulk = "Bulk",
  62. PreyCount = "Prey Count"
  63. }
  64. export type VoreStats = {[key in VoreStat]: number}
  65. export const VoreStatIcons: {[key in VoreStat]: string} = {
  66. [VoreStat.Mass]: "fas fa-weight",
  67. [VoreStat.Bulk]: "fas fa-weight-hanging",
  68. [VoreStat.PreyCount]: "fas fa-utensils"
  69. }
  70. export const VoreStatDescs: {[key in VoreStat]: string} = {
  71. [VoreStat.Mass]: "How much you weigh",
  72. [VoreStat.Bulk]: "Your weight, plus the weight of your prey",
  73. [VoreStat.PreyCount]: "How many creatures you've got inside of you"
  74. }
  75. export interface CombatTest {
  76. test: (user: Creature, target: Creature) => boolean;
  77. odds: (user: Creature, target: Creature) => number;
  78. explain: (user: Creature, target: Creature) => LogEntry;
  79. }
  80. /**
  81. * An instance of damage. Contains zero or more [[DamageInstance]] objects
  82. */
  83. export class Damage {
  84. readonly damages: DamageInstance[]
  85. constructor (...damages: DamageInstance[]) {
  86. this.damages = damages
  87. }
  88. scale (factor: number): Damage {
  89. const results: Array<DamageInstance> = []
  90. this.damages.forEach(damage => {
  91. results.push({
  92. type: damage.type,
  93. amount: damage.amount * factor,
  94. target: damage.target
  95. })
  96. })
  97. return new Damage(...results)
  98. }
  99. // TODO make this combine damage instances when appropriate
  100. combine (other: Damage): Damage {
  101. return new Damage(...this.damages.concat(other.damages))
  102. }
  103. toString (): string {
  104. return this.damages.map(damage => damage.amount + " " + damage.type).join("/")
  105. }
  106. render (): LogEntry {
  107. return new LogLine(...this.damages.flatMap(instance => {
  108. if (instance.target in Vigor) {
  109. return [instance.amount.toString(), new FAElem(VigorIcons[instance.target as Vigor]), " " + instance.type]
  110. } else if (instance.target in Stat) {
  111. return [instance.amount.toString(), new FAElem(StatIcons[instance.target as Stat]), " " + instance.type]
  112. } else {
  113. // this should never happen!
  114. return []
  115. }
  116. }))
  117. }
  118. renderShort (): LogEntry {
  119. const vigorTotals: Vigors = Object.keys(Vigor).reduce((total: any, key) => { total[key] = 0; return total }, {})
  120. const statTotals: Stats = Object.keys(Stat).reduce((total: any, key) => { total[key] = 0; return total }, {})
  121. this.damages.forEach(instance => {
  122. const factor = instance.type === DamageType.Heal ? -1 : 1
  123. if (instance.target in Vigor) {
  124. vigorTotals[instance.target as Vigor] += factor * instance.amount
  125. } else if (instance.target in Stat) {
  126. statTotals[instance.target as Stat] += factor * instance.amount
  127. }
  128. })
  129. const vigorEntries = Object.keys(Vigor).flatMap(key => vigorTotals[key as Vigor] === 0 ? [] : [new PropElem(key as Vigor, vigorTotals[key as Vigor]), ' '])
  130. const statEntries = Object.keys(Stat).flatMap(key => statTotals[key as Stat] === 0 ? [] : [new PropElem(key as Stat, statTotals[key as Stat]), ' '])
  131. return new FormatEntry(new LogLine(...vigorEntries.concat(statEntries)), FormatOpt.DamageInst)
  132. }
  133. }
  134. /**
  135. * Computes damage given the source and target of the damage.
  136. */
  137. export interface DamageFormula {
  138. calc (user: Creature, target: Creature): Damage;
  139. describe (user: Creature, target: Creature): LogEntry;
  140. explain (user: Creature): LogEntry;
  141. }
  142. export class CompositeDamageFormula implements DamageFormula {
  143. constructor (private formulas: DamageFormula[]) {
  144. }
  145. calc (user: Creature, target: Creature): Damage {
  146. return this.formulas.reduce((total: Damage, next: DamageFormula) => total.combine(next.calc(user, target)), new Damage())
  147. }
  148. describe (user: Creature, target: Creature): LogEntry {
  149. return new LogLines(...this.formulas.map(formula => formula.describe(user, target)))
  150. }
  151. explain (user: Creature): LogEntry {
  152. return new LogLines(...this.formulas.map(formula => formula.explain(user)))
  153. }
  154. }
  155. /**
  156. * Simply returns the damage it was given.
  157. */
  158. export class ConstantDamageFormula implements DamageFormula {
  159. constructor (private damage: Damage) {
  160. }
  161. calc (user: Creature, target: Creature): Damage {
  162. return this.damage
  163. }
  164. describe (user: Creature, target: Creature): LogEntry {
  165. return this.explain(user)
  166. }
  167. explain (user: Creature): LogEntry {
  168. return new LogLine('Deal ', this.damage.renderShort(), '.')
  169. }
  170. }
  171. /**
  172. * Randomly scales the damage it was given with a factor of (1-x) to (1+x)
  173. */
  174. export class UniformRandomDamageFormula implements DamageFormula {
  175. constructor (private damage: Damage, private variance: number) {
  176. }
  177. calc (user: Creature, target: Creature): Damage {
  178. return this.damage.scale(Math.random() * this.variance * 2 - this.variance + 1)
  179. }
  180. describe (user: Creature, target: Creature): LogEntry {
  181. return this.explain(user)
  182. }
  183. explain (user: Creature): LogEntry {
  184. return new LogLine('Deal between ', this.damage.scale(1 - this.variance).renderShort(), ' and ', this.damage.scale(1 + this.variance).renderShort(), '.')
  185. }
  186. }
  187. export class StatDamageFormula implements DamageFormula {
  188. constructor (private factors: Array<{ stat: Stat|VoreStat; fraction: number; type: DamageType; target: Vigor|Stat }>) {
  189. }
  190. calc (user: Creature, target: Creature): Damage {
  191. const instances: Array<DamageInstance> = this.factors.map(factor => {
  192. if (factor.stat in Stat) {
  193. return {
  194. amount: factor.fraction * user.stats[factor.stat as Stat],
  195. target: factor.target,
  196. type: factor.type
  197. }
  198. } else if (factor.stat in VoreStat) {
  199. return {
  200. amount: factor.fraction * user.voreStats[factor.stat as VoreStat],
  201. target: factor.target,
  202. type: factor.type
  203. }
  204. } else {
  205. // should be impossible; .stat is Stat|VoreStat
  206. return {
  207. amount: 0,
  208. target: Vigor.Health,
  209. type: DamageType.Heal
  210. }
  211. }
  212. })
  213. return new Damage(...instances)
  214. }
  215. describe (user: Creature, target: Creature): LogEntry {
  216. return new LogLine(
  217. this.explain(user),
  218. `, for a total of `,
  219. this.calc(user, target).renderShort()
  220. )
  221. }
  222. explain (user: Creature): LogEntry {
  223. return new LogLine(
  224. `Deal `,
  225. ...this.factors.map(factor => new LogLine(
  226. `${factor.fraction * 100}% of your `,
  227. new PropElem(factor.stat),
  228. ` as `,
  229. new PropElem(factor.target)
  230. )).joinGeneral(new LogLine(`, `), new LogLine(` and `))
  231. )
  232. }
  233. }
  234. /**
  235. * Deals a percentage of the target's current vigors/stats
  236. */
  237. export class FractionDamageFormula implements DamageFormula {
  238. constructor (private factors: Array<{ fraction: number; target: Vigor|Stat; type: DamageType }>) {
  239. }
  240. calc (user: Creature, target: Creature): Damage {
  241. const instances: Array<DamageInstance> = this.factors.map(factor => {
  242. if (factor.target in Stat) {
  243. return {
  244. amount: Math.max(0, factor.fraction * target.stats[factor.target as Stat]),
  245. target: factor.target,
  246. type: factor.type
  247. }
  248. } else if (factor.target in Vigor) {
  249. return {
  250. amount: Math.max(factor.fraction * target.vigors[factor.target as Vigor]),
  251. target: factor.target,
  252. type: factor.type
  253. }
  254. } else {
  255. // should be impossible; .target is Stat|Vigor
  256. return {
  257. amount: 0,
  258. target: Vigor.Health,
  259. type: DamageType.Heal
  260. }
  261. }
  262. })
  263. return new Damage(...instances)
  264. }
  265. describe (user: Creature, target: Creature): LogEntry {
  266. return this.explain(user)
  267. }
  268. explain (user: Creature): LogEntry {
  269. return new LogLine(
  270. `Deal damage equal to `,
  271. ...this.factors.map(factor => new LogLine(
  272. `${factor.fraction * 100}% of your target's `,
  273. new PropElem(factor.target)
  274. )).joinGeneral(new LogLine(`, `), new LogLine(` and `))
  275. )
  276. }
  277. }
  278. export enum Side {
  279. Heroes,
  280. Monsters
  281. }
  282. /**
  283. * A Combatant has a list of possible actions to take, as well as a side.
  284. */
  285. export interface Combatant {
  286. actions: Array<Action>;
  287. groupActions: Array<GroupAction>;
  288. side: Side;
  289. }
  290. /**
  291. * An Action is anything that can be done by a [[Creature]] to a [[Creature]].
  292. */
  293. export abstract class Action {
  294. constructor (public name: TextLike, public desc: TextLike, public conditions: Array<Condition> = []) {
  295. }
  296. allowed (user: Creature, target: Creature): boolean {
  297. return this.conditions.every(cond => cond.allowed(user, target))
  298. }
  299. toString (): string {
  300. return this.name.toString()
  301. }
  302. abstract execute (user: Creature, target: Creature): LogEntry
  303. abstract describe (user: Creature, target: Creature): LogEntry
  304. }
  305. export type TestBundle = {
  306. test: CombatTest;
  307. fail: PairLine<Creature>;
  308. }
  309. export class CompositionAction extends Action {
  310. private consequences: Array<Consequence>;
  311. private tests: Array<TestBundle>;
  312. constructor (name: TextLike, desc: TextLike, properties: { conditions?: Array<Condition>; consequences?: Array<Consequence>; tests?: Array<TestBundle> }) {
  313. super(name, desc, properties.conditions ?? [])
  314. this.consequences = properties.consequences ?? []
  315. this.tests = properties.tests ?? []
  316. }
  317. execute (user: Creature, target: Creature): LogEntry {
  318. const failReason = this.tests.find(test => !test.test.test(user, target))
  319. if (failReason !== undefined) {
  320. return failReason.fail(user, target)
  321. } else {
  322. return new LogLines(
  323. ...this.consequences.filter(consequence => consequence.applicable(user, target)).map(consequence => consequence.apply(user, target))
  324. )
  325. }
  326. }
  327. describe (user: Creature, target: Creature): LogEntry {
  328. return new LogLines(
  329. ...this.consequences.map(consequence => consequence.describePair(user, target))
  330. )
  331. }
  332. }
  333. /**
  334. * A Condition describes whether or not something is permissible between two [[Creature]]s
  335. */
  336. export interface Condition {
  337. allowed: (user: Creature, target: Creature) => boolean;
  338. }
  339. export interface Actionable {
  340. actions: Array<Action>;
  341. }
  342. export abstract class GroupAction extends Action {
  343. constructor (name: TextLike, desc: TextLike, conditions: Array<Condition>) {
  344. super(name, desc, conditions)
  345. }
  346. allowedGroup (user: Creature, targets: Array<Creature>): Array<Creature> {
  347. return targets.filter(target => this.allowed(user, target))
  348. }
  349. executeGroup (user: Creature, targets: Array<Creature>): LogEntry {
  350. return new LogLines(...targets.map(target => this.execute(user, target)))
  351. }
  352. abstract describeGroup (user: Creature, targets: Array<Creature>): LogEntry
  353. }
  354. /**
  355. * Individual status effects, items, etc. should override some of these hooks.
  356. * Some hooks just produce a log entry.
  357. * Some hooks return results along with a log entry.
  358. */
  359. export class Effective {
  360. /**
  361. * Executes when the effect is initially applied
  362. */
  363. onApply (creature: Creature): LogEntry { return nilLog }
  364. /**
  365. * Executes when the effect is removed
  366. */
  367. onRemove (creature: Creature): LogEntry { return nilLog }
  368. /**
  369. * Executes before the creature tries to perform an action
  370. */
  371. preAction (creature: Creature): { prevented: boolean; log: LogEntry } {
  372. return {
  373. prevented: false,
  374. log: nilLog
  375. }
  376. }
  377. /**
  378. * Executes before another creature tries to perform an action that targets this creature
  379. */
  380. preReceiveAction (creature: Creature, attacker: Creature): { prevented: boolean; log: LogEntry } {
  381. return {
  382. prevented: false,
  383. log: nilLog
  384. }
  385. }
  386. /**
  387. * Executes before the creature receives damage (or healing)
  388. */
  389. preDamage (creature: Creature, damage: Damage): Damage {
  390. return damage
  391. }
  392. /**
  393. * Executes before the creature is attacked
  394. */
  395. preAttack (creature: Creature, attacker: Creature): { prevented: boolean; log: LogEntry } {
  396. return {
  397. prevented: false,
  398. log: nilLog
  399. }
  400. }
  401. /**
  402. * Executes when a creature's turn starts
  403. */
  404. preTurn (creature: Creature): { prevented: boolean; log: LogEntry } {
  405. return {
  406. prevented: false,
  407. log: nilLog
  408. }
  409. }
  410. /**
  411. * Modifies the effective resistance to a certain damage type
  412. */
  413. modResistance (type: DamageType, factor: number): number {
  414. return factor
  415. }
  416. /**
  417. * Called when a test is about to resolve. Decides if the creature should automatically fail.
  418. */
  419. failTest (creature: Creature, opponent: Creature): { failed: boolean; log: LogEntry } {
  420. return {
  421. failed: false,
  422. log: nilLog
  423. }
  424. }
  425. /**
  426. * Changes a creature's size. This represents the change in *mass*
  427. */
  428. scale (scale: number): number {
  429. return scale
  430. }
  431. }
  432. /**
  433. * A displayable status effect
  434. */
  435. export interface VisibleStatus {
  436. name: TextLike;
  437. desc: TextLike;
  438. icon: TextLike;
  439. topLeft: string;
  440. bottomRight: string;
  441. }
  442. /**
  443. * This kind of status is never explicitly applied to an entity -- e.g., a dead entity will show
  444. * a status indicating that it is dead, but entities cannot be "given" the dead effect
  445. */
  446. export class ImplicitStatus implements VisibleStatus {
  447. topLeft = ''
  448. bottomRight = ''
  449. constructor (public name: TextLike, public desc: TextLike, public icon: string) {
  450. }
  451. }
  452. /**
  453. * This kind of status is explicitly given to a creature.
  454. */
  455. export abstract class StatusEffect extends Effective implements VisibleStatus {
  456. constructor (public name: TextLike, public desc: TextLike, public icon: string) {
  457. super()
  458. }
  459. get topLeft () { return '' }
  460. get bottomRight () { return '' }
  461. }
  462. export type EncounterDesc = {
  463. name: TextLike;
  464. intro: (world: World) => LogEntry;
  465. }
  466. /**
  467. * An Encounter describes a fight: who is in it and whose turn it is
  468. */
  469. export class Encounter {
  470. initiatives: Map<Creature, number>
  471. currentMove: Creature
  472. turnTime = 100
  473. constructor (public desc: EncounterDesc, public combatants: Creature[]) {
  474. this.initiatives = new Map()
  475. combatants.forEach(combatant => this.initiatives.set(combatant, 0))
  476. this.currentMove = combatants[0]
  477. this.nextMove()
  478. }
  479. nextMove (): LogEntry {
  480. this.initiatives.set(this.currentMove, 0)
  481. const times = new Map<Creature, number>()
  482. this.combatants.forEach(combatant => {
  483. // this should never be undefined
  484. const currentProgress = this.initiatives.get(combatant) ?? 0
  485. const remaining = (this.turnTime - currentProgress) / Math.sqrt(Math.max(combatant.stats.Speed, 1))
  486. times.set(combatant, remaining)
  487. })
  488. this.currentMove = this.combatants.reduce((closest, next) => {
  489. const closestTime = times.get(closest) ?? 0
  490. const nextTime = times.get(next) ?? 0
  491. return closestTime <= nextTime ? closest : next
  492. }, this.combatants[0])
  493. const closestRemaining = (this.turnTime - (this.initiatives.get(this.currentMove) ?? 0)) / Math.sqrt(Math.max(this.currentMove.stats.Speed, 1))
  494. this.combatants.forEach(combatant => {
  495. // still not undefined...
  496. const currentProgress = this.initiatives.get(combatant) ?? 0
  497. this.initiatives.set(combatant, currentProgress + closestRemaining * Math.sqrt(Math.max(combatant.stats.Speed, 1)))
  498. })
  499. // TODO: still let the creature use drained-vigor moves
  500. if (this.currentMove.disabled) {
  501. return this.nextMove()
  502. } else {
  503. const effectResults = this.currentMove.effects.map(effect => effect.preTurn(this.currentMove)).filter(effect => effect.prevented)
  504. if (effectResults.some(result => result.prevented)) {
  505. const parts = effectResults.map(result => result.log).concat([this.nextMove()])
  506. return new LogLines(
  507. ...parts
  508. )
  509. }
  510. }
  511. return nilLog
  512. }
  513. /**
  514. * Combat is won once one side is completely disabled
  515. */
  516. get winner (): null|Side {
  517. const remaining: Set<Side> = new Set(this.combatants.filter(combatant => !combatant.disabled).map(combatant => combatant.side))
  518. if (remaining.size === 1) {
  519. return Array.from(remaining)[0]
  520. } else {
  521. return null
  522. }
  523. }
  524. /**
  525. * Combat is completely won once one side is completely destroyed
  526. */
  527. get totalWinner (): null|Side {
  528. const remaining: Set<Side> = new Set(this.combatants.filter(combatant => !combatant.destroyed).map(combatant => combatant.side))
  529. if (remaining.size === 1) {
  530. return Array.from(remaining)[0]
  531. } else {
  532. return null
  533. }
  534. }
  535. }
  536. export abstract class Consequence {
  537. constructor (public conditions: Condition[]) {
  538. }
  539. applicable (user: Creature, target: Creature): boolean {
  540. return this.conditions.every(cond => cond.allowed(user, target))
  541. }
  542. describeSolo (user: Creature): LogEntry {
  543. return nilLog
  544. }
  545. describePair (user: Creature, target: Creature): LogEntry {
  546. return nilLog
  547. }
  548. abstract apply (user: Creature, target: Creature): LogEntry
  549. }