Feast 2.0!
Du kannst nicht mehr als 25 Themen auswählen Themen müssen entweder mit einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.
 
 
 
 
 

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