Feast 2.0!
Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 
 
 

641 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(), ' damage')
  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(), ' damage.')
  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, private 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. return new LogLines(
  319. ...this.consequences.filter(consequence => consequence.applicable(user, target)).map(consequence => consequence.apply(user, target))
  320. )
  321. }
  322. describe (user: Creature, target: Creature): LogEntry {
  323. return new LogLine(`No descriptions yet...`)
  324. }
  325. }
  326. /**
  327. * A Condition describes whether or not something is permissible between two [[Creature]]s
  328. */
  329. export interface Condition {
  330. allowed: (user: Creature, target: Creature) => boolean;
  331. }
  332. export interface Actionable {
  333. actions: Array<Action>;
  334. }
  335. export abstract class GroupAction extends Action {
  336. constructor (name: TextLike, desc: TextLike, conditions: Array<Condition>) {
  337. super(name, desc, conditions)
  338. }
  339. allowedGroup (user: Creature, targets: Array<Creature>): Array<Creature> {
  340. return targets.filter(target => this.allowed(user, target))
  341. }
  342. executeGroup (user: Creature, targets: Array<Creature>): LogEntry {
  343. return new LogLines(...targets.map(target => this.execute(user, target)))
  344. }
  345. abstract describeGroup (user: Creature, targets: Array<Creature>): LogEntry
  346. }
  347. /**
  348. * Individual status effects, items, etc. should override some of these hooks.
  349. * Some hooks just produce a log entry.
  350. * Some hooks return results along with a log entry.
  351. */
  352. export class Effective {
  353. /**
  354. * Executes when the effect is initially applied
  355. */
  356. onApply (creature: Creature): LogEntry { return nilLog }
  357. /**
  358. * Executes when the effect is removed
  359. */
  360. onRemove (creature: Creature): LogEntry { return nilLog }
  361. /**
  362. * Executes before the creature tries to perform an action
  363. */
  364. preAction (creature: Creature): { prevented: boolean; log: LogEntry } {
  365. return {
  366. prevented: false,
  367. log: nilLog
  368. }
  369. }
  370. /**
  371. * Executes before another creature tries to perform an action that targets this creature
  372. */
  373. preReceiveAction (creature: Creature, attacker: Creature): { prevented: boolean; log: LogEntry } {
  374. return {
  375. prevented: false,
  376. log: nilLog
  377. }
  378. }
  379. /**
  380. * Executes before the creature receives damage (or healing)
  381. */
  382. preDamage (creature: Creature, damage: Damage): Damage {
  383. return damage
  384. }
  385. /**
  386. * Executes before the creature is attacked
  387. */
  388. preAttack (creature: Creature, attacker: Creature): { prevented: boolean; log: LogEntry } {
  389. return {
  390. prevented: false,
  391. log: nilLog
  392. }
  393. }
  394. /**
  395. * Executes when a creature's turn starts
  396. */
  397. preTurn (creature: Creature): { prevented: boolean; log: LogEntry } {
  398. return {
  399. prevented: false,
  400. log: nilLog
  401. }
  402. }
  403. /**
  404. * Modifies the effective resistance to a certain damage type
  405. */
  406. modResistance (type: DamageType, factor: number): number {
  407. return factor
  408. }
  409. /**
  410. * Called when a test is about to resolve. Decides if the creature should automatically fail.
  411. */
  412. failTest (creature: Creature, opponent: Creature): { failed: boolean; log: LogEntry } {
  413. return {
  414. failed: false,
  415. log: nilLog
  416. }
  417. }
  418. }
  419. /**
  420. * A displayable status effect
  421. */
  422. export interface VisibleStatus {
  423. name: TextLike;
  424. desc: TextLike;
  425. icon: TextLike;
  426. topLeft: string;
  427. bottomRight: string;
  428. }
  429. /**
  430. * This kind of status is never explicitly applied to an entity -- e.g., a dead entity will show
  431. * a status indicating that it is dead, but entities cannot be "given" the dead effect
  432. */
  433. export class ImplicitStatus implements VisibleStatus {
  434. topLeft = ''
  435. bottomRight = ''
  436. constructor (public name: TextLike, public desc: TextLike, public icon: string) {
  437. }
  438. }
  439. /**
  440. * This kind of status is explicitly given to a creature.
  441. */
  442. export abstract class StatusEffect extends Effective implements VisibleStatus {
  443. constructor (public name: TextLike, public desc: TextLike, public icon: string) {
  444. super()
  445. }
  446. get topLeft () { return '' }
  447. get bottomRight () { return '' }
  448. }
  449. export type EncounterDesc = {
  450. name: TextLike;
  451. intro: (world: World) => LogEntry;
  452. }
  453. /**
  454. * An Encounter describes a fight: who is in it and whose turn it is
  455. */
  456. export class Encounter {
  457. initiatives: Map<Creature, number>
  458. currentMove: Creature
  459. turnTime = 100
  460. constructor (public desc: EncounterDesc, public combatants: Creature[]) {
  461. this.initiatives = new Map()
  462. combatants.forEach(combatant => this.initiatives.set(combatant, 0))
  463. this.currentMove = combatants[0]
  464. this.nextMove()
  465. }
  466. nextMove (): LogEntry {
  467. this.initiatives.set(this.currentMove, 0)
  468. const times = new Map<Creature, number>()
  469. this.combatants.forEach(combatant => {
  470. // this should never be undefined
  471. const currentProgress = this.initiatives.get(combatant) ?? 0
  472. const remaining = (this.turnTime - currentProgress) / Math.sqrt(Math.max(combatant.stats.Speed, 1))
  473. times.set(combatant, remaining)
  474. })
  475. this.currentMove = this.combatants.reduce((closest, next) => {
  476. const closestTime = times.get(closest) ?? 0
  477. const nextTime = times.get(next) ?? 0
  478. return closestTime <= nextTime ? closest : next
  479. }, this.combatants[0])
  480. const closestRemaining = (this.turnTime - (this.initiatives.get(this.currentMove) ?? 0)) / Math.sqrt(Math.max(this.currentMove.stats.Speed, 1))
  481. this.combatants.forEach(combatant => {
  482. // still not undefined...
  483. const currentProgress = this.initiatives.get(combatant) ?? 0
  484. this.initiatives.set(combatant, currentProgress + closestRemaining * Math.sqrt(Math.max(combatant.stats.Speed, 1)))
  485. })
  486. // TODO: still let the creature use drained-vigor moves
  487. if (this.currentMove.disabled) {
  488. return this.nextMove()
  489. } else {
  490. const effectResults = this.currentMove.effects.map(effect => effect.preTurn(this.currentMove)).filter(effect => effect.prevented)
  491. if (effectResults.some(result => result.prevented)) {
  492. const parts = effectResults.map(result => result.log).concat([this.nextMove()])
  493. console.log(parts)
  494. return new LogLines(
  495. ...parts
  496. )
  497. }
  498. }
  499. return nilLog
  500. }
  501. /**
  502. * Combat is won once one side is completely disabled
  503. */
  504. get winner (): null|Side {
  505. const remaining: Set<Side> = new Set(this.combatants.filter(combatant => !combatant.disabled).map(combatant => combatant.side))
  506. if (remaining.size === 1) {
  507. return Array.from(remaining)[0]
  508. } else {
  509. return null
  510. }
  511. }
  512. /**
  513. * Combat is completely won once one side is completely destroyed
  514. */
  515. get totalWinner (): null|Side {
  516. const remaining: Set<Side> = new Set(this.combatants.filter(combatant => !combatant.destroyed).map(combatant => combatant.side))
  517. if (remaining.size === 1) {
  518. return Array.from(remaining)[0]
  519. } else {
  520. return null
  521. }
  522. }
  523. }
  524. export abstract class Consequence {
  525. constructor (public conditions: Condition[]) {
  526. }
  527. applicable (user: Creature, target: Creature): boolean {
  528. return this.conditions.every(cond => cond.allowed(user, target))
  529. }
  530. abstract apply (user: Creature, target: Creature): LogEntry
  531. }