diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml
new file mode 100644
index 0000000..03d9549
--- /dev/null
+++ b/.idea/inspectionProfiles/Project_Default.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/runConfigurations/All_Tests.xml b/.idea/runConfigurations/All_Tests.xml
new file mode 100644
index 0000000..66f97cd
--- /dev/null
+++ b/.idea/runConfigurations/All_Tests.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/battlers/Assertions.test.ts b/src/battlers/Assertions.test.ts
new file mode 100644
index 0000000..4b4f789
--- /dev/null
+++ b/src/battlers/Assertions.test.ts
@@ -0,0 +1,174 @@
+import {AssertionConfig, AssertionMode, Assertions} from "./Assertions";
+
+describe("Assertion", () => {
+ describe("check", () => {
+ const defaults = {
+ value: Symbol("test value"),
+ config: AssertionConfig.Skip,
+ conditionImpl: () => true,
+ messageImpl: () => "Mockery",
+ } as const
+
+ function run({value, config, conditionImpl, messageImpl}: {value: T, config: typeof AssertionConfig.Skip, conditionImpl: (value: T) => boolean, messageImpl: (value: T) => string}): {value: T, condition: jest.Mock, message: jest.Mock, retval: T}
+ function run({value, config, conditionImpl, messageImpl}: {value: T, config: typeof AssertionConfig.Throw, conditionImpl: (value: T) => boolean, messageImpl: (value: T) => string}): {value: T, condition: jest.Mock, message: jest.Mock, retval?: T, error?: Error}
+ function run({value, config, conditionImpl, messageImpl}: {value: T, config: ReturnType, conditionImpl: (value: T) => boolean, messageImpl: (value: T) => string}): {value: T, log: jest.Mock, condition: jest.Mock, message: jest.Mock, retval: T}
+ function run({value, config, conditionImpl, messageImpl}: {value: T, config: AssertionConfig.Type, conditionImpl: (value: T) => boolean, messageImpl: (value: T) => string}): {value: T, log?: jest.Mock, condition: jest.Mock, message: jest.Mock, retval?: T, error?: Error}
+ function run({value, config, conditionImpl, messageImpl}: {value: T, config: AssertionConfig.Type, conditionImpl: (value: T) => boolean, messageImpl: (value: T) => string}): {value: T, log?: jest.Mock, condition: jest.Mock, message: jest.Mock, retval?: T, error?: Error} {
+ const assertion = new Assertions()
+ assertion.config = config
+ let log: jest.Mock|undefined = undefined
+ if (config.mode === AssertionMode.LOG) {
+ log = jest.fn(config.logger)
+ assertion.config = AssertionConfig.Log(log)
+ }
+ const condition = jest.fn(conditionImpl)
+ const message = jest.fn(messageImpl)
+
+ let error: Error|undefined = undefined
+ let retval: T|undefined = undefined
+ try {
+ retval = assertion.check(value, condition, message)
+ } catch (e) {
+ if (e instanceof Error && config.mode === AssertionMode.THROW) {
+ error = e
+ } else {
+ throw e
+ }
+ }
+
+ return {
+ value, log, condition, message, error, retval
+ }
+ }
+
+ it(`returns the input and does not throw in SKIP`, () => {
+ const {value, retval} = run({
+ ...defaults,
+ config: AssertionConfig.Skip
+ })
+
+ expect(retval).toBe(value)
+ });
+ it(`does not call the condition or message in SKIP`, () => {
+ const {condition, message} = run({
+ ...defaults,
+ config: AssertionConfig.Skip
+ })
+
+ expect(condition).not.toHaveBeenCalled()
+ expect(message).not.toHaveBeenCalled()
+ });
+
+ it(`calls the log function with an error, returns the value and does not throw when the condition fails in LOG`, () => {
+ const {value, log, retval} = run({
+ ...defaults,
+ config: AssertionConfig.Log(() => {}),
+ conditionImpl: () => false,
+ messageImpl: () => "Mockery"
+ })
+
+ expect(log).toHaveBeenCalledTimes(1)
+ expect(log).toHaveBeenCalledWith(expect.objectContaining({message: "Mockery"}))
+ expect(log).toHaveBeenCalledWith(expect.any(Error))
+ expect(retval).toBe(value)
+ });
+ it(`throws an error when the condition fails in THROW`, () => {
+ const {error} = run({
+ ...defaults,
+ config: AssertionConfig.Throw,
+ conditionImpl: () => false,
+ messageImpl: () => "Mockery"
+ })
+
+ expect(error).toEqual(expect.objectContaining({message: "Mockery"}))
+ expect(error).toBeInstanceOf(Error)
+ });
+ it(`does not log and returns the input when the condition succeeds in LOG`, () => {
+ const {value, retval, log} = run({
+ ...defaults,
+ config: AssertionConfig.Log(() => {}),
+ conditionImpl: () => true
+ })
+
+ expect(log).not.toHaveBeenCalled()
+ expect(retval).toBe(value)
+ });
+ it(`does not throw and returns the input when the condition succeeds in THROW`, () => {
+ const {value, retval, error} = run({
+ ...defaults,
+ config: AssertionConfig.Throw,
+ conditionImpl: () => true
+ })
+
+ expect(error).toBeUndefined()
+ expect(retval).toBe(value)
+ });
+ [AssertionConfig.Log(() => {}), AssertionConfig.Throw].forEach((config) => {
+ it(`calls the condition, but not the message, once with the value in ${config.mode} when the condition succeeds`, () => {
+ const {value, condition, message} = run({
+ ...defaults,
+ config,
+ conditionImpl: () => true
+ })
+
+ expect(condition).toHaveBeenCalledWith(value)
+ expect(condition).toHaveBeenCalledTimes(1)
+ expect(message).not.toHaveBeenCalled()
+ })
+ it(`calls the condition and message once each with the value in ${config.mode} when the condition fails`, () => {
+ const {value, condition, message} = run({
+ ...defaults,
+ config,
+ conditionImpl: () => false
+ })
+
+ expect(condition).toHaveBeenCalledWith(value)
+ expect(condition).toHaveBeenCalledTimes(1)
+ expect(message).toHaveBeenCalledWith(value)
+ expect(message).toHaveBeenCalledTimes(1)
+ })
+ })
+ })
+
+ function testCheck(run: (input: T) => void, valid: T[], invalid: T[]) {
+ valid.forEach((input) => {
+ test(`passes ${input}`, () => {
+ expect(() => run(input)).not.toThrow()
+ })
+ })
+
+ invalid.forEach((input) => {
+ test(`fails ${input}`, () => {
+ expect(() => run(input)).toThrow()
+ })
+ })
+ }
+
+ describe("checkInteger", () => {
+ function run(input: number) {
+ const assertion = new Assertions()
+ assertion.config = AssertionConfig.Throw
+
+ assertion.checkInteger(input, () => "Assertion failed")
+ }
+
+ const valid: number[] = [0, 1, -1, Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER]
+ const invalid: number[] = [Number.NaN, Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 999, Number.MAX_VALUE, Number.MIN_SAFE_INTEGER - 999, Number.EPSILON]
+
+ testCheck(run, valid, invalid)
+ })
+
+ describe("checkPositiveIntegerOrZero", () => {
+ function run(input: number) {
+ const assertion = new Assertions()
+ assertion.config = AssertionConfig.Throw
+
+ assertion.checkPositiveIntegerOrZero(input, () => "Assertion failed")
+ }
+
+ const valid: number[] = [0, 1, Number.MAX_SAFE_INTEGER]
+ const invalid: number[] = [-1, Number.MIN_SAFE_INTEGER, Number.NaN, Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY, Number.MAX_SAFE_INTEGER + 999, Number.MAX_VALUE, Number.MIN_SAFE_INTEGER - 999, Number.EPSILON]
+
+ testCheck(run, valid, invalid)
+ })
+})
\ No newline at end of file
diff --git a/src/battlers/Assertions.ts b/src/battlers/Assertions.ts
new file mode 100644
index 0000000..7fb9e71
--- /dev/null
+++ b/src/battlers/Assertions.ts
@@ -0,0 +1,58 @@
+export enum AssertionMode {
+ SKIP = "SKIP",
+ LOG = "LOG",
+ THROW = "THROW",
+}
+
+/** Configurations for the different assertion modes. */
+export namespace AssertionConfig {
+ /** The types of the assertion configurations. */
+ export type Type = typeof Skip|typeof Throw|ReturnType
+ /** An assertion configuration suitable for production, that skips assertions without evaluating them. */
+ export const Skip = {mode: AssertionMode.SKIP} as const
+ /** An assertion configuration suitable for testing, that throws an exception when an assertion fails. */
+ export const Throw = {mode: AssertionMode.THROW} as const
+ /** An assertion configuration suitable for debugging, that logs an exception when an assertion fails but allows the program to continue. */
+ export function Log(logger: (message?: any, ...params: any[]) => void) {
+ return {mode: AssertionMode.LOG, logger} as const
+ }
+}
+
+/** Assertion class for test-only checks on preconditions and postconditions. */
+export class Assertions {
+ config: AssertionConfig.Type = AssertionConfig.Skip
+
+ /** General assert method. Checks that the condition is true. */
+ check(value: T, condition: (value: T) => boolean, message: (value: T) => string): T {
+ if (this.config.mode === AssertionMode.SKIP) {
+ return value
+ }
+ const result = condition(value)
+ if (result) {
+ return value
+ }
+ const err = Error(message(value))
+ if (this.config.mode === AssertionMode.THROW) {
+ throw err
+ } else {
+ // All we can do is stand by and watch in horror...
+ this.config.logger(err)
+ return value
+ }
+ }
+
+ /** Checks that the value is an integer. */
+ checkInteger(value: number, message: (value: number) => string): number {
+ return this.check(value, Number.isSafeInteger, message)
+ }
+
+ /** Checks that the value is an integer and either positive or zero. */
+ checkPositiveIntegerOrZero(value: number, message: (value: number) => string): number {
+ return this.check(value, (value) => {
+ return Number.isSafeInteger(value) && value >= 0
+ }, message)
+ }
+}
+
+/** Global assertion object for use by other modules. */
+export const assertion = new Assertions();
\ No newline at end of file
diff --git a/src/battlers/HitPoints.test.ts b/src/battlers/HitPoints.test.ts
new file mode 100644
index 0000000..f01b265
--- /dev/null
+++ b/src/battlers/HitPoints.test.ts
@@ -0,0 +1,558 @@
+import {
+ checkValidConfidenceLimit,
+ checkValidCurrentHitPoints,
+ checkValidHitPoints,
+ checkValidHitPointsWithoutConfidence,
+ checkValidMaxHitPoints,
+ confidenceLimit,
+ damageConfidence,
+ damageHealth,
+ HitPoints,
+ HitPointsWithoutConfidence,
+ hitPointsWithoutConfidenceToString,
+ hitPointsToString, isValidConfidenceLimit,
+ isValidCurrentHitPoints,
+ isValidHitPoints,
+ isValidHitPointsWithoutConfidence,
+ isValidMaxHitPoints,
+ recoverConfidence,
+ recoverHealth, setCurrentConfidence,
+ setCurrentHealth,
+ setMaxConfidence,
+ setMaxHealth, checkConsistentConfidenceDelta
+} from "./HitPoints";
+import {assertion, AssertionConfig} from "./Assertions";
+
+const InvalidHitPoints = {currentHealth: 999, maxHealth: 99, maxConfidence: 100, currentConfidence: 20};
+let originalConfig: AssertionConfig.Type
+
+beforeEach(() => {
+ originalConfig = assertion.config
+ assertion.config = AssertionConfig.Throw
+});
+
+afterEach(() => {
+ assertion.config = originalConfig
+});
+
+describe("hitPointsWithoutConfidenceToString", () => {
+ const pairs: [HitPointsWithoutConfidence, string][] = [
+ [{currentHealth: 99, maxHealth: 100, maxConfidence: 200}, "{Health: 99/100, Confidence: -/-/200}"],
+ [{currentHealth: -25, maxHealth: 0, maxConfidence: -200}, "{Health: -25/0, Confidence: -/-/-200}"],
+ ]
+ pairs.forEach(([hp, expected]) => {
+ test(`for ${hp} = "${expected}"`, () => {
+ expect(hitPointsWithoutConfidenceToString(hp)).toStrictEqual(expected)
+ })
+ })
+});
+
+describe("hitPointsToString", () => {
+ const pairs: [HitPoints, string][] = [
+ [{currentHealth: 99, maxHealth: 100, maxConfidence: 200, currentConfidence: 10}, "{Health: 99/100, Confidence: 10/198/200}"],
+ [{currentHealth: -25, maxHealth: 0, maxConfidence: -200, currentConfidence: -15}, "{Health: -25/0, Confidence: -15//-200}"],
+ ]
+ pairs.forEach(([hp, expected]) => {
+ test(`for ${hp} = "${expected}"`, () => {
+ expect(hitPointsToString(hp)).toStrictEqual(expected)
+ })
+ })
+});
+
+describe("isValidMaxHitPoints", () => {
+ const validValues = [1, 2, 99999]
+ const invalidValues = [0, -1, 1.5, NaN, Infinity, -Infinity, 100000, 999999]
+ validValues.forEach((value) => {
+ test(`returns true for ${value}`, () => {
+ expect(isValidMaxHitPoints(value)).toBe(true)
+ })
+ })
+ invalidValues.forEach((value) => {
+ test(`returns false for ${value}`, () => {
+ expect(isValidMaxHitPoints(value)).toBe(false)
+ })
+ })
+});
+
+describe("isValidCurrentHitPoints", () => {
+ const validValues: [number, number, number][] = [[1, 1, 0], [-1, 1, -1], [0, 1, 0], [0, 1, -1], [50, 99999, 0]]
+ const invalidValues: [number, number, number][] = [[2, 1, 0], [-1, 1, 0], [0.5, 1, 0], [-2, 1, -1]]
+ validValues.forEach(([current, max, min]) => {
+ test(`returns true for ${current} between ${min} and ${max}`, () => {
+ expect(isValidCurrentHitPoints(current, max, min)).toBe(true)
+ })
+ })
+ invalidValues.forEach(([current, max, min]) => {
+ test(`returns false for ${current} between ${min} and ${max}`, () => {
+ expect(isValidCurrentHitPoints(current, max, min)).toBe(false)
+ })
+ })
+});
+
+describe("isValidHitPointsWithoutConfidence", () => {
+ const validValues: HitPointsWithoutConfidence[] = [{
+ currentHealth: 100,
+ maxHealth: 100,
+ maxConfidence: 100,
+ }, {
+ currentHealth: 50,
+ maxHealth: 100,
+ maxConfidence: 25
+ }, {
+ currentHealth: 0,
+ maxHealth: 1,
+ maxConfidence: 1,
+ }]
+ const invalidValues: HitPointsWithoutConfidence[] = [{
+ currentHealth: 0,
+ maxHealth: 0,
+ maxConfidence: 99,
+ }, {
+ currentHealth: 0,
+ maxHealth: 20,
+ maxConfidence: 0,
+ }, {
+ currentHealth: -300,
+ maxHealth: 100,
+ maxConfidence: 99,
+ }]
+ validValues.forEach((value) => {
+ test(`returns true for ${hitPointsWithoutConfidenceToString(value)}`, () => {
+ expect(isValidHitPointsWithoutConfidence(value)).toBe(true)
+ })
+ })
+ invalidValues.forEach((value) => {
+ test(`returns false for ${hitPointsWithoutConfidenceToString(value)}`, () => {
+ expect(isValidHitPointsWithoutConfidence(value)).toBe(false)
+ })
+ })
+})
+
+describe("isValidHitPoints", () => {
+ const validValues: HitPoints[] = [{
+ currentHealth: 100,
+ maxHealth: 100,
+ currentConfidence: 100,
+ maxConfidence: 100,
+ }, {
+ currentHealth: 50,
+ maxHealth: 100,
+ currentConfidence: 30,
+ maxConfidence: 60,
+ }, {
+ currentHealth: 0,
+ maxHealth: 100,
+ currentConfidence: 0,
+ maxConfidence: 30,
+ }]
+ const invalidValues: HitPoints[] = [{
+ currentHealth: 11,
+ maxHealth: 10,
+ currentConfidence: 99,
+ maxConfidence: 99,
+ }, {
+ currentHealth: 10,
+ maxHealth: 20,
+ currentConfidence: 20,
+ maxConfidence: 20,
+ }, {
+ currentHealth: 20,
+ maxHealth: 20,
+ currentConfidence: 25,
+ maxConfidence: 20,
+ }]
+ validValues.forEach((value) => {
+ test(`returns true for ${hitPointsToString(value)}`, () => {
+ expect(isValidHitPoints(value)).toBe(true)
+ })
+ })
+ invalidValues.forEach((value) => {
+ test(`returns false for ${hitPointsToString(value)}`, () => {
+ expect(isValidHitPoints(value)).toBe(false)
+ })
+ })
+})
+
+describe("isValidConfidenceLimit", () => {
+ const validValues: [number, HitPointsWithoutConfidence][] = [
+ [200, {
+ currentHealth: 100,
+ maxHealth: 100,
+ maxConfidence: 200,
+ }], [0, {
+ currentHealth: 50,
+ maxHealth: 100,
+ maxConfidence: 60
+ }], [10, {
+ currentHealth: 50,
+ maxHealth: 100,
+ maxConfidence: 60
+ }], [0, {
+ currentHealth: 0,
+ maxHealth: 100,
+ maxConfidence: 100,
+ }]]
+ const invalidValues: [number, HitPointsWithoutConfidence][] = [
+ [195, {
+ currentHealth: 100,
+ maxHealth: 100,
+ maxConfidence: 200,
+ }], [25, {
+ currentHealth: 0,
+ maxHealth: 100,
+ maxConfidence: 100,
+ }]]
+ validValues.forEach(([value, hp]) => {
+ test(`returns true for ${value} and ${hitPointsWithoutConfidenceToString(hp)}`, () => {
+ expect(isValidConfidenceLimit(value, hp)).toBe(true)
+ })
+ })
+ invalidValues.forEach(([value, hp]) => {
+ test(`returns false for ${value} and ${hitPointsWithoutConfidenceToString(hp)}`, () => {
+ expect(isValidConfidenceLimit(value, hp)).toBe(false)
+ })
+ })
+})
+
+describe("checkValidMaxHitPoints", () => {
+ test("returns on valid max HP", () => {
+ expect(checkValidMaxHitPoints(30)).toEqual(30)
+ })
+ test("throws on invalid max HP", () => {
+ expect(() => checkValidMaxHitPoints(-1)).toThrow()
+ })
+})
+
+describe("checkValidCurrentHitPoints", () => {
+ test("returns on valid current HP", () => {
+ expect(checkValidCurrentHitPoints(30, 30, 0)).toEqual(30)
+ })
+ test("throws on invalid current HP", () => {
+ expect(() => checkValidCurrentHitPoints(-10, 30, 0)).toThrow()
+ })
+})
+
+describe("checkValidHitPointsWithoutConfidence", () => {
+ test("returns on valid hit points base", () => {
+ expect(checkValidHitPointsWithoutConfidence({currentHealth: 30, maxHealth: 40, maxConfidence: 99})).toStrictEqual({currentHealth: 30, maxHealth: 40, maxConfidence: 99})
+ })
+ test("throws on invalid hit points base", () => {
+ expect(() => checkValidHitPointsWithoutConfidence({currentHealth: -20, maxHealth: 10, maxConfidence: 30})).toThrow()
+ })
+})
+
+describe("checkValidHitPoints", () => {
+ test("returns on valid hit points", () => {
+ expect(checkValidHitPoints({currentHealth: 30, maxHealth: 40, maxConfidence: 99, currentConfidence: 30})).toStrictEqual({currentHealth: 30, maxHealth: 40, maxConfidence: 99, currentConfidence: 30})
+ })
+ test("throws on invalid hit points", () => {
+ expect(() => checkValidHitPoints({currentHealth: -20, maxHealth: 10, maxConfidence: 30, currentConfidence: 50})).toThrow()
+ })
+})
+
+describe("checkValidConfidenceLimit", () => {
+ test("returns on valid confidence limit", () => {
+ expect(checkValidConfidenceLimit(30, {currentHealth: 15, maxHealth: 20, maxConfidence: 40})).toEqual(30)
+ })
+ test("throws on invalid confidence limit", () => {
+ expect(() => checkValidConfidenceLimit(40, {currentHealth: 15, maxHealth: 20, maxConfidence: 40})).toThrow()
+ })
+})
+
+describe("checkConsistentConfidenceDelta", () => {
+ const consistentValues: [HitPoints, number, number][] = [
+ [{currentHealth: 30, maxHealth: 30, currentConfidence: 10, maxConfidence: 20}, -10, 0],
+ [{currentHealth: 30, maxHealth: 30, currentConfidence: 20, maxConfidence: 20}, 0, 0],
+ [{currentHealth: 30, maxHealth: 30, currentConfidence: 20, maxConfidence: 20}, 10, 15],
+ [{currentHealth: 7, maxHealth: 10, currentConfidence: 17, maxConfidence: 25}, 10, 4],
+ [{currentHealth: 7, maxHealth: 10, currentConfidence: 17, maxConfidence: 25}, 9, 4],
+ [{currentHealth: 7, maxHealth: 10, currentConfidence: 17, maxConfidence: 25}, 25, 10],
+ [{currentHealth: 7, maxHealth: 10, currentConfidence: 17, maxConfidence: 25}, 24, 10],
+ ]
+ consistentValues.forEach(([hp, confidence, health]) => {
+ const newHealth = {...hp, currentConfidence: confidence, currentHealth: health}
+ test(`returns for ${hitPointsToString(hp)} -> ${hitPointsToString(newHealth)}`, () => {
+ expect(checkConsistentConfidenceDelta(confidence, "testing", hp, health)).toEqual(confidence)
+ })
+ })
+ const inconsistentValues: [HitPoints, number, number][] = [
+ [{currentHealth: 30, maxHealth: 30, currentConfidence: 10, maxConfidence: 20}, 0, 0],
+ [{currentHealth: 30, maxHealth: 30, currentConfidence: 20, maxConfidence: 20}, 20, 0],
+ [{currentHealth: 30, maxHealth: 30, currentConfidence: 20, maxConfidence: 20}, 5, 15],
+ [{currentHealth: 7, maxHealth: 10, currentConfidence: 10, maxConfidence: 25}, 8, 4],
+ [{currentHealth: 7, maxHealth: 10, currentConfidence: 17, maxConfidence: 25}, 23, 10],
+ ]
+ inconsistentValues.forEach(([hp, confidence, health]) => {
+ const newHealth = {...hp, currentConfidence: confidence, currentHealth: health}
+ test(`throws for ${hitPointsToString(hp)} -> ${hitPointsToString(newHealth)}`, () => {
+ expect(() => checkConsistentConfidenceDelta(confidence, "testing", hp, health)).toThrow()
+ })
+ })
+ test("throws an error starting with the evaluation of the action function", () => {
+ expect(() => checkConsistentConfidenceDelta(
+ 30, "frozzing the snagler",
+ {maxHealth: 10, currentHealth: 0, maxConfidence: 30, currentConfidence: 0},
+ 9
+ )).toThrow(/^frozzing the snagler /)
+ })
+})
+
+describe("confidenceLimit", () => {
+ test("throws on invalid input", () => {
+ expect(() => confidenceLimit({ currentHealth: -99, maxHealth: 30, maxConfidence: 20})).toThrow()
+ })
+
+ const values: [HitPointsWithoutConfidence, number][] = [
+ [{maxConfidence: 99, maxHealth: 30, currentHealth: 30}, 99],
+ // Even 1 missing health results in a loss of at least 1 confidence.
+ [{maxConfidence: 10, maxHealth: 99999, currentHealth: 99998}, 9],
+ // Confidence is 0 when each point of confidence represents multiple points of health and there aren't enough to make 1 point of confidence
+ [{maxConfidence: 10, maxHealth: 50, currentHealth: 4}, 0],
+ // Confidence is 0 when health is 0
+ [{maxConfidence: 99999, maxHealth: 99, currentHealth: 0}, 0],
+ // or when it's below 0
+ [{maxConfidence: 99999, maxHealth: 99, currentHealth: -50}, 0],
+ ]
+
+ values.forEach(([hp, expected]) => {
+ test(`returns ${expected} for ${hitPointsWithoutConfidenceToString(hp)}`, () => {
+ expect(confidenceLimit(hp)).toBe(expected)
+ })
+ })
+})
+
+describe("damageHealth", () => {
+ const invalidValues: [HitPoints, number][] = [
+ [InvalidHitPoints, 10],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 10}, -1],
+ ]
+ invalidValues.forEach(([hp, damage]) => {
+ test(`for ${damage} on ${hitPointsToString(hp)} throws`, () => {
+ expect(() => damageHealth(hp, damage, {damageConfidence: false})).toThrow()
+ })
+ })
+ const values: [[HitPoints, number, {damageConfidence: boolean}?], Partial][] = [
+ [[{currentHealth: -10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 0], {}],
+ [[{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 0], {}],
+ [[{currentHealth: -5, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 5], {currentHealth: -10}],
+ [[{currentHealth: -5, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 10], {currentHealth: -10}],
+ [[{currentHealth: 0, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 10], {currentHealth: -10}],
+ [[{currentHealth: 0, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 15], {currentHealth: -10}],
+ [[{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 15], {currentHealth: -5}],
+ [[{currentHealth: 10, maxHealth: 10, maxConfidence: 20, currentConfidence: 18}, 5, {damageConfidence: true}], {currentHealth: 5, currentConfidence: 8}],
+ [[{currentHealth: 10, maxHealth: 10, maxConfidence: 20, currentConfidence: 18}, 5, {damageConfidence: false}], {currentHealth: 5, currentConfidence: 10}],
+ [[{currentHealth: 10, maxHealth: 10, maxConfidence: 20, currentConfidence: 18}, 15, {damageConfidence: true}], {currentHealth: -5, currentConfidence: 0}],
+ [[{currentHealth: 10, maxHealth: 10, maxConfidence: 20, currentConfidence: 18}, 15, {damageConfidence: false}], {currentHealth: -5, currentConfidence: 0}],
+ [[{currentHealth: 100, maxHealth: 100, maxConfidence: 20, currentConfidence: 18}, 7, {damageConfidence: true}], {currentHealth: 93, currentConfidence: 16}],
+ [[{currentHealth: 100, maxHealth: 100, maxConfidence: 20, currentConfidence: 18}, 7, {damageConfidence: false}], {currentHealth: 93, currentConfidence: 18}],
+ [[{currentHealth: 93, maxHealth: 100, maxConfidence: 20, currentConfidence: 18}, 2, {damageConfidence: true}], {currentHealth: 91, currentConfidence: 17}],
+ [[{currentHealth: 93, maxHealth: 100, maxConfidence: 20, currentConfidence: 18}, 2, {damageConfidence: false}], {currentHealth: 91, currentConfidence: 18}],
+ ]
+ values.forEach(([[hp, damage, options = {damageConfidence: true}], expectedChanges]) => {
+ const expected = {...hp, ...expectedChanges}
+ test(`for ${damage} on ${hitPointsToString(hp)} with damageConfidence ${options.damageConfidence ? "on" : "off"} results in ${hitPointsToString(expected)}`, () => {
+ expect(damageHealth(hp, damage, options)).toStrictEqual(expected)
+ })
+ })
+})
+
+describe("recoverHealth", () => {
+ const invalidValues: [HitPoints, number][] = [
+ [InvalidHitPoints, 10],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 10}, -1],
+ ]
+ invalidValues.forEach(([hp, healing]) => {
+ test(`for ${healing} on ${hitPointsToString(hp)} throws`, () => {
+ expect(() => recoverHealth(hp, healing, {recoverConfidence: false})).toThrow()
+ })
+ })
+ const values: [[HitPoints, number, {recoverConfidence: boolean}?], Partial][] = [
+ [[{currentHealth: -10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 0], {}],
+ [[{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 0], {}],
+ [[{currentHealth: -5, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 5], {currentHealth: 0}],
+ [[{currentHealth: -10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 10], {currentHealth: 0}],
+ [[{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 10], {}],
+ [[{currentHealth: 7, maxHealth: 10, maxConfidence: 20, currentConfidence: 8}, 2, {recoverConfidence: true}], {currentHealth: 9, currentConfidence: 12}],
+ [[{currentHealth: 7, maxHealth: 10, maxConfidence: 20, currentConfidence: 8}, 2, {recoverConfidence: false}], {currentHealth: 9, currentConfidence: 8}],
+ [[{currentHealth: 7, maxHealth: 10, maxConfidence: 20, currentConfidence: 8}, 5, {recoverConfidence: true}], {currentHealth: 10, currentConfidence: 14}],
+ [[{currentHealth: 7, maxHealth: 10, maxConfidence: 20, currentConfidence: 8}, 5, {recoverConfidence: false}], {currentHealth: 10, currentConfidence: 8}],
+ [[{currentHealth: -10, maxHealth: 10, maxConfidence: 20, currentConfidence: 0}, 15, {recoverConfidence: true}], {currentHealth: 5, currentConfidence: 10}],
+ [[{currentHealth: -10, maxHealth: 10, maxConfidence: 20, currentConfidence: 0}, 15, {recoverConfidence: false}], {currentHealth: 5, currentConfidence: 0}],
+ [[{currentHealth: 0, maxHealth: 10, maxConfidence: 25, currentConfidence: 0}, 3, {recoverConfidence: true}], {currentHealth: 3, currentConfidence: 7}],
+ [[{currentHealth: 3, maxHealth: 10, maxConfidence: 25, currentConfidence: 7}, 3, {recoverConfidence: true}], {currentHealth: 6, currentConfidence: 14}],
+ [[{currentHealth: 0, maxHealth: 10, maxConfidence: 25, currentConfidence: 0}, 3, {recoverConfidence: false}], {currentHealth: 3, currentConfidence: 0}],
+ ]
+ values.forEach(([[hp, damage, options = {recoverConfidence: true}], expectedChanges]) => {
+ const expected = {...hp, ...expectedChanges}
+ test(`for ${damage} on ${hitPointsToString(hp)} with recoverConfidence ${options.recoverConfidence ? "on" : "off"} results in ${hitPointsToString(expected)}`, () => {
+ expect(recoverHealth(hp, damage, options)).toStrictEqual(expected)
+ })
+ })
+})
+describe("setMaxHealth", () => {
+ const invalidValues: [HitPoints, number][] = [
+ [InvalidHitPoints, 10],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 0],
+ ]
+ invalidValues.forEach(([hp, newMax]) => {
+ test(`to ${newMax} on ${hitPointsToString(hp)} throws`, () => {
+ expect(() => setMaxHealth(hp, newMax)).toThrow()
+ })
+ })
+ const values: [HitPoints, number, Partial][] = [
+ [{currentHealth: 8, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 10, {}],
+ [{currentHealth: 8, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 5, {maxHealth: 5, currentHealth: 5}],
+ [{currentHealth: 8, maxHealth: 10, maxConfidence: 10, currentConfidence: 4}, 20, {maxHealth: 20}],
+ [{currentHealth: 8, maxHealth: 10, maxConfidence: 10, currentConfidence: 4}, 19, {maxHealth: 19}],
+ [{currentHealth: 8, maxHealth: 10, maxConfidence: 10, currentConfidence: 4}, 21, {maxHealth: 21, currentConfidence: 3}],
+ ]
+ values.forEach(([hp, newMax, expectedChanges]) => {
+ const expected = {...hp, ...expectedChanges}
+ test(`to ${newMax} on ${hitPointsToString(hp)} results in ${hitPointsToString(expected)}`, () => {
+ expect(setMaxHealth(hp, newMax)).toStrictEqual(expected)
+ })
+ })
+})
+
+describe("setCurrentHealth", () => {
+ const invalidValues: [HitPoints, number][] = [
+ [InvalidHitPoints, 10],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, -11],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 11],
+ ]
+ invalidValues.forEach(([hp, newValue]) => {
+ test(`to ${newValue} on ${hitPointsToString(hp)} throws`, () => {
+ expect(() => setCurrentHealth(hp, newValue)).toThrow()
+ })
+ })
+ const values: [HitPoints, number, Partial][] = [
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 0, {currentHealth: 0}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 10, {}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 5, {currentHealth: 5}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 10, {currentHealth: 10}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, -5, {currentHealth: -5}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, -10, {currentHealth: -10}],
+ [{currentHealth: 4, maxHealth: 10, maxConfidence: 20, currentConfidence: 5}, 0, {currentHealth: 0, currentConfidence: 0}],
+ [{currentHealth: 4, maxHealth: 10, maxConfidence: 20, currentConfidence: 5}, 8, {currentHealth: 8}],
+ ]
+ values.forEach(([hp, newValue, expectedChanges]) => {
+ const expected = {...hp, ...expectedChanges}
+ test(`to ${newValue} on ${hitPointsToString(hp)} results in ${hitPointsToString(expected)}`, () => {
+ expect(setCurrentHealth(hp, newValue)).toStrictEqual(expected)
+ })
+ })
+})
+
+describe("damageConfidence", () => {
+ const invalidValues: [HitPoints, number][] = [
+ [InvalidHitPoints, 10],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, -1],
+ ]
+ invalidValues.forEach(([hp, damage]) => {
+ test(`for ${damage} on ${hitPointsToString(hp)} throws`, () => {
+ expect(() => damageConfidence(hp, damage)).toThrow()
+ })
+ })
+
+ const values: [HitPoints, number, Partial][] = [
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 0, {}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 10, {}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 6, {currentConfidence: 0}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 5, {currentConfidence: 0}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 4, {currentConfidence: 1}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 10}, 9, {currentConfidence: 1}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 10}, 11, {currentConfidence: 0}],
+ ]
+ values.forEach(([hp, damage, expectedChanges]) => {
+ const expected = {...hp, ...expectedChanges}
+ test(`for ${damage} on ${hitPointsToString(hp)} results in ${hitPointsToString(expected)}`, () => {
+ expect(damageConfidence(hp, damage)).toStrictEqual(expected)
+ })
+ })
+})
+
+describe("recoverConfidence", () => {
+ const invalidValues: [HitPoints, number][] = [
+ [InvalidHitPoints, 10],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, -1],
+ ]
+ invalidValues.forEach(([hp, healing]) => {
+ test(`for ${healing} on ${hitPointsToString(hp)} throws`, () => {
+ expect(() => recoverConfidence(hp, healing)).toThrow()
+ })
+ })
+
+ const values: [HitPoints, number, Partial][] = [
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 0, {}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 10}, 10, {}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 6, {currentConfidence: 10}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 5, {currentConfidence: 10}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 4, {currentConfidence: 9}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 9, {currentConfidence: 9}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 11, {currentConfidence: 10}],
+ [{currentHealth: 5, maxHealth: 10, maxConfidence: 20, currentConfidence: 5}, 6, {currentConfidence: 10}],
+ [{currentHealth: 5, maxHealth: 10, maxConfidence: 20, currentConfidence: 5}, 5, {currentConfidence: 10}],
+ [{currentHealth: 5, maxHealth: 10, maxConfidence: 20, currentConfidence: 5}, 4, {currentConfidence: 9}],
+ ]
+ values.forEach(([hp, healing, expectedChanges]) => {
+ const expected = {...hp, ...expectedChanges}
+ test(`for ${healing} on ${hitPointsToString(hp)} results in ${hitPointsToString(expected)}`, () => {
+ expect(recoverConfidence(hp, healing)).toStrictEqual(expected)
+ })
+ })
+})
+
+describe("setMaxConfidence", () => {
+ const invalidValues: [HitPoints, number][] = [
+ [InvalidHitPoints, 10],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 0],
+ ]
+ invalidValues.forEach(([hp, newMax]) => {
+ test(`to ${newMax} on ${hitPointsToString(hp)} throws`, () => {
+ expect(() => setMaxConfidence(hp, newMax)).toThrow()
+ })
+ })
+ const values: [HitPoints, number, Partial][] = [
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 10, {}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 10, {}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 10}, 10, {}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 6, {maxConfidence: 6}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 5, {maxConfidence: 5}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 4, {maxConfidence: 4, currentConfidence: 4}],
+ [{currentHealth: 4, maxHealth: 10, maxConfidence: 20, currentConfidence: 5}, 30, {maxConfidence: 30}],
+ [{currentHealth: 4, maxHealth: 10, maxConfidence: 20, currentConfidence: 5}, 5, {maxConfidence: 5, currentConfidence: 2}],
+ ]
+ values.forEach(([hp, newMax, expectedChanges]) => {
+ const expected = {...hp, ...expectedChanges}
+ test(`to ${newMax} on ${hitPointsToString(hp)} results in ${hitPointsToString(expected)}`, () => {
+ expect(setMaxConfidence(hp, newMax)).toStrictEqual(expected)
+ })
+ })
+})
+
+describe("setCurrentConfidence", () => {
+ const invalidValues: [HitPoints, number][] = [
+ [InvalidHitPoints, 10],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, -1],
+ [{currentHealth: 5, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 6],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 5}, 11],
+ ]
+ invalidValues.forEach(([hp, newValue]) => {
+ test(`to ${newValue} on ${hitPointsToString(hp)} throws`, () => {
+ expect(() => setCurrentConfidence(hp, newValue)).toThrow()
+ })
+ })
+ const values: [HitPoints, number, Partial][] = [
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 0, {}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 5, {currentConfidence: 5}],
+ [{currentHealth: 10, maxHealth: 10, maxConfidence: 10, currentConfidence: 0}, 10, {currentConfidence: 10}],
+ [{currentHealth: 4, maxHealth: 10, maxConfidence: 20, currentConfidence: 5}, 0, {currentConfidence: 0}],
+ [{currentHealth: 4, maxHealth: 10, maxConfidence: 20, currentConfidence: 5}, 8, {currentConfidence: 8}],
+ ]
+ values.forEach(([hp, newValue, expectedChanges]) => {
+ const expected = {...hp, ...expectedChanges}
+ test(`to ${newValue} on ${hitPointsToString(hp)} results in ${hitPointsToString(expected)}`, () => {
+ expect(setCurrentConfidence(hp, newValue)).toStrictEqual(expected)
+ })
+ })
+})
\ No newline at end of file
diff --git a/src/battlers/HitPoints.ts b/src/battlers/HitPoints.ts
new file mode 100644
index 0000000..52f2827
--- /dev/null
+++ b/src/battlers/HitPoints.ts
@@ -0,0 +1,314 @@
+import {assertion} from "./Assertions";
+
+/**
+ * HitPointsWithoutConfidence contains the hit points data without the current confidence as is needed for calculating
+ * the confidence limit itself. It's impossible to check whether a hit points object is valid without checking its
+ * confidence limit, and when calculating said confidence limit, this would lead to endless recursion.
+ */
+export type HitPointsWithoutConfidence = Omit
+
+/** Formats the HitPointsWithoutConfidence object into a human-readable string for logging. Should never throw. */
+export function hitPointsWithoutConfidenceToString(hp: HitPointsWithoutConfidence): string {
+ return `{Health: ${hp.currentHealth}/${hp.maxHealth}, Confidence: -/-/${hp.maxConfidence}}`
+}
+
+/**
+ * The full data for a character's hit points - i.e., their counters that limit their survival, and which are primarily
+ * impacted by the opponent's attacks.
+ */
+export interface HitPoints {
+ /** The maximum Health the character can have, which they recover to when resting. Valid values are integers between 1 and 99999. */
+ readonly maxHealth: number
+ /** The current Health the character has, after any damage taken since the last rest. Valid values are integers between -maxHealth and maxHealth. */
+ readonly currentHealth: number
+ /**
+ * The maximum Confidence the character can have when maxHealth = currentHealth.
+ * Below this, the confidence limit is scaled down proportionately.
+ * Valid values are integers between 1 and 99999.
+ */
+ readonly maxConfidence: number
+ /** The current Confidence the character has, after any damage taken since the last victorious battle. Between 0 and confidenceLimit(this). */
+ readonly currentConfidence: number
+}
+
+/** Formats the HitPoints object into a human-readable string for logging. Should never throw. */
+export function hitPointsToString(hp: HitPoints): string {
+ let confidenceLimitString: string
+ try {
+ confidenceLimitString = confidenceLimit(hp).toString()
+ } catch (e) {
+ confidenceLimitString = ""
+ }
+ return `{Health: ${hp.currentHealth}/${hp.maxHealth}, Confidence: ${hp.currentConfidence}/${confidenceLimitString}/${hp.maxConfidence}}`
+}
+
+/** Returns true if the value given is valid for maxHealth or maxConfidence. See their documentation for what that means. */
+export function isValidMaxHitPoints(maxHitPoints: number): boolean {
+ return maxHitPoints > 0 && maxHitPoints <= 99999 && Number.isSafeInteger(maxHitPoints)
+}
+
+/** Returns true if the value given is valid for currentHealth or currentConfidence. See their documentation for what that means. */
+export function isValidCurrentHitPoints(currentHitPoints: number, maxHitPoints: number, minHitPoints: number) {
+ return currentHitPoints <= maxHitPoints && currentHitPoints >= minHitPoints && Number.isSafeInteger(currentHitPoints)
+}
+
+/** Returns true if the given HitPointsWithoutConfidence object lives up to all its requirements. See the HitPoints documentation for what that means. */
+export function isValidHitPointsWithoutConfidence(hp: HitPointsWithoutConfidence): boolean {
+ return isValidMaxHitPoints(hp.maxHealth)
+ && isValidMaxHitPoints(hp.maxConfidence)
+ && isValidCurrentHitPoints(hp.currentHealth, hp.maxHealth, -hp.maxHealth)
+}
+
+/** Returns true if the given HitPoints object lives up to all its requirements. See the HitPoints documentation for what that means. */
+export function isValidHitPoints(hp: HitPoints): boolean {
+ return isValidHitPointsWithoutConfidence(hp)
+ && isValidCurrentHitPoints(hp.currentConfidence, confidenceLimit(hp), 0)
+}
+
+/** Returns true if the given confidenceLimit value is at least plausible. See the confidenceLimit documentation for what that means.*/
+export function isValidConfidenceLimit(limit: number, hp: HitPointsWithoutConfidence): boolean {
+ return (hp.currentHealth > 0 ? limit >= 0 : limit === 0)
+ && (hp.currentHealth === hp.maxHealth ? limit === hp.maxConfidence : limit < hp.maxConfidence)
+ && Number.isSafeInteger(limit)
+}
+
+/** Asserts that the given value is valid for maxHealth or maxConfidence. */
+export function checkValidMaxHitPoints(maxHitPoints: number): number {
+ return assertion.check(maxHitPoints, isValidMaxHitPoints, (maxHitPoints) => `Invalid max hit points: ${maxHitPoints}`)
+}
+
+/** Asserts that the given value is valid for currentHealth or currentConfidence. */
+export function checkValidCurrentHitPoints(currentHitPoints: number, maxHitPoints: number, minHitPoints: number): number {
+ return assertion.check(currentHitPoints,
+ (currentHitPoints) => isValidCurrentHitPoints(currentHitPoints, maxHitPoints, minHitPoints),
+ (currentHitPoints) => `Invalid current hit points: ${minHitPoints} <= ${currentHitPoints} <= ${maxHitPoints}`)
+}
+
+/** Asserts that the given HitPointsWithoutConfidence object lives up to all its requirements. */
+export function checkValidHitPointsWithoutConfidence(hp: HitPointsWithoutConfidence): HitPointsWithoutConfidence {
+ return assertion.check(hp, isValidHitPointsWithoutConfidence, (hp) => `Invalid hit points: ${hitPointsWithoutConfidenceToString(hp)}`)
+}
+
+/** Asserts that the given HitPoints object lives up to all its requirements. */
+export function checkValidHitPoints(hp: HitPoints): HitPoints {
+ return assertion.check(hp, isValidHitPoints, (hp) => `Invalid hit points object: ${hitPointsToString(hp)}`)
+}
+
+/** Asserts that the given confidenceLimit value is at least plausible. */
+export function checkValidConfidenceLimit(limit: number, hp: HitPointsWithoutConfidence): number {
+ return assertion.check(limit, (limit) => isValidConfidenceLimit(limit, hp), (limit) => {
+ return `Invalid confidence limit: ${limit} for ${hp.currentHealth}/${hp.maxHealth} Health, ${hp.maxConfidence} Max Confidence`
+ })
+}
+
+/**
+ * Asserts that the given change in health has resulted in a congruent change in confidence - i.e., that the new
+ * confidence is the same distance below the new confidence limit as the old confidence was below the old one.
+ * A delta of 1 is permitted due to the fact that the confidence change will be rounded (up in the case of damage,
+ * down in the case of healing).
+ *
+ * Used by the damageHealth and recoverHealth functions.
+ */
+export function checkConsistentConfidenceDelta(newConfidenceUnclamped: number, action: string, oldHp: HitPoints, newHealth: number): number {
+ return assertion.check(newConfidenceUnclamped, (newConfidenceUnclamped) => {
+ return Math.abs((confidenceLimit({...oldHp, currentHealth: newHealth}) - newConfidenceUnclamped) - (confidenceLimit(oldHp) - oldHp.currentConfidence)) <= 1
+ }, (newConfidenceUnclamped) => {
+ const newLimit = confidenceLimit({...oldHp, currentHealth: newHealth})
+ const oldLimit = confidenceLimit(oldHp)
+ return (`${action} from ${hitPointsToString(oldHp)} to ${hitPointsToString({...oldHp, currentHealth: newHealth, currentConfidence: newConfidenceUnclamped})} `
+ + `resulted in a change in the difference between the confidenceLimit and the currentConfidenceUnclamped `
+ + `from ${oldHp.currentConfidence}/${oldLimit} = ${oldLimit - oldHp.currentConfidence} `
+ + `to ${newConfidenceUnclamped}/${newLimit} = ${newLimit - newConfidenceUnclamped}`)
+ })
+}
+
+/**
+ * The effective maximum confidence, based on scaling maxConfidence by the character's current health percentage.
+ * Always an integer, rounding down if the result is fractional.
+ * Is 0 if currentHealth <= 0.
+ * Is maxConfidence if and only if currentHealth === maxHealth, due to rounding down (floor).
+ * Otherwise, scaled to somewhere between them.
+ */
+export function confidenceLimit(hp: HitPointsWithoutConfidence): number {
+ checkValidHitPointsWithoutConfidence(hp)
+
+ const result = Math.max(0, Math.floor(hp.currentHealth * hp.maxConfidence / hp.maxHealth))
+
+ return checkValidConfidenceLimit(result, hp)
+}
+
+/**
+ * Inflicts the given amount of damage on the character's health.
+ * The character's health will never be reduced below -maxHealth this way.
+ * If damageConfidence is true, confidence will be reduced proportionately.
+ * e.g., if Confidence is 50/80/100 and Health is 40/50, damaging Health by 10 with damageConfidence off
+ * results in Health 30/50 and Confidence 50/60/100 - the currentConfidence has not changed.
+ * On the other hand, with damageConfidence on, that same scenario
+ * results in Health 30/50 and Confidence 30/60/100 - the currentConfidence has fallen by 20 due to scaling the
+ * 10 Health damage to 20 Confidence damage. The character's confidence will not be reduced below 0 this way.
+ * If damageConfidence is off, currentConfidence will still change if the health change causes the confidenceLimit to
+ * drop below the currentConfidence. In this case, currentConfidence will be clamped to the new confidenceLimit.
+ */
+export function damageHealth(hp: HitPoints, damage: number, options: {damageConfidence: boolean}): HitPoints {
+ checkValidHitPoints(hp)
+ assertion.checkPositiveIntegerOrZero(damage, (damage) => `Tried to damage health by negative or non-integer value ${damage}`)
+
+ const {damageConfidence} = options
+ let currentHealth = Math.max(-hp.maxHealth,hp.currentHealth - damage)
+ let currentConfidence = damageConfidence
+ ? Math.max(0,
+ checkConsistentConfidenceDelta(
+ hp.currentConfidence - Math.ceil((Math.max(0, hp.currentHealth) - Math.max(0, currentHealth)) * hp.maxConfidence / hp.maxHealth),
+ "damageHealth",
+ hp,
+ currentHealth))
+ : Math.min(hp.currentConfidence, confidenceLimit({
+ ...hp,
+ currentHealth: currentHealth
+ }))
+
+ return checkValidHitPoints({
+ ...hp,
+ currentHealth,
+ currentConfidence,
+ })
+}
+
+/**
+ * Heals the character's health by the given amount.
+ * The character's health will never be increased above maxHealth this way.
+ * If recoverConfidence is true, confidence will be increased proportionately to the increase in health.
+ * e.g., if Confidence is 50/60/100 and Health is 30/50, recovering Health by 10 with recoverConfidence off
+ * results in Health 40/50 and Confidence 50/80/100 - the currentConfidence has not changed.
+ * On the other hand, with recoverConfidence on, that same scenario
+ * results in Health 40/50 and Confidence 70/80/100 - the currentConfidence has risen by 20 due to scaling the
+ * 10 Health recovery to 20 Confidence recovery.
+ */
+export function recoverHealth(hp: HitPoints, healing: number, options: {recoverConfidence: boolean}): HitPoints {
+ checkValidHitPoints(hp)
+ assertion.checkPositiveIntegerOrZero(healing, (healing) => `Tried to heal health by negative or non-integer value ${healing}`)
+
+ const {recoverConfidence} = options
+ const currentHealth = Math.min(hp.maxHealth, hp.currentHealth + healing)
+ // We don't need to check against the confidenceLimit here. When recoverConfidence is on, we're raising confidence
+ // by the same amount the confidenceLimit went up. So if it was under the limit before, it should be the same amount
+ // under the limit now. And if recoverConfidence is off, we're not raising confidence at all, so we're even more
+ // under the limit than we were before.
+ // The assertion checks that the former case holds.
+ const currentConfidence = recoverConfidence
+ ? checkConsistentConfidenceDelta(
+ hp.currentConfidence + Math.floor((Math.max(0, currentHealth) - Math.max(0, hp.currentHealth)) * hp.maxConfidence / hp.maxHealth),
+ "recoverHealth",
+ hp,
+ currentHealth)
+ : hp.currentConfidence
+
+ return checkValidHitPoints({
+ ...hp,
+ currentHealth,
+ currentConfidence,
+ })
+}
+
+/**
+ * Sets the character's new maximum health.
+ * If the new value is below the character's current absolute-value health, the character's current health will be clamped to that value.
+ * If the new value is above the character's previous maximum health, the character's confidence will be clamped to the new confidence limit, which will be lower.
+ */
+export function setMaxHealth(hp: HitPoints, maxHealth: number): HitPoints {
+ checkValidHitPoints(hp)
+ checkValidMaxHitPoints(maxHealth)
+
+ let {currentHealth, currentConfidence} = hp
+ if (maxHealth <= Math.abs(currentHealth)) {
+ currentHealth = Math.sign(currentHealth) * maxHealth
+ }
+ if (maxHealth > hp.maxHealth) {
+ currentConfidence = Math.min(currentConfidence, confidenceLimit({...hp, maxHealth}))
+ }
+
+ return checkValidHitPoints({
+ ...hp,
+ maxHealth,
+ currentHealth,
+ currentConfidence
+ })
+}
+
+/**
+ * Sets the character's new current health.
+ * The character's confidence will be clamped to the new confidence limit.
+ */
+export function setCurrentHealth(hp: HitPoints, currentHealth: number): HitPoints {
+ checkValidHitPoints(hp)
+ checkValidCurrentHitPoints(currentHealth, hp.maxHealth, -hp.maxHealth)
+
+ const currentConfidence = Math.min(confidenceLimit({...hp, currentHealth}), hp.currentConfidence)
+
+ return checkValidHitPoints({
+ ...hp,
+ currentHealth,
+ currentConfidence
+ })
+}
+
+/**
+ * Inflicts the given amount of damage on the character's confidence.
+ * The character's confidence will never be reduced below 0 this way.
+ */
+export function damageConfidence(hp: HitPoints, damage: number): HitPoints {
+ checkValidHitPoints(hp)
+ assertion.checkPositiveIntegerOrZero(damage, (damage) => `Tried to damage confidence by negative or non-integer value ${damage}`)
+
+ let newConfidence = Math.max(0, hp.currentConfidence - damage)
+
+ return checkValidHitPoints({
+ ...hp,
+ currentConfidence: newConfidence,
+ })
+}
+
+/**
+ * Heals the given amount of damage to the character's confidence.
+ * The character's confidence will never be increased above the current confidenceLimit this way.
+ */
+export function recoverConfidence(hp: HitPoints, healing: number): HitPoints {
+ checkValidHitPoints(hp)
+ assertion.checkPositiveIntegerOrZero(healing, (healing) => `Tried to heal confidence by negative or non-integer value ${healing}`)
+
+ let newConfidence = Math.min(confidenceLimit(hp), hp.currentConfidence + healing)
+
+ return checkValidHitPoints({
+ ...hp,
+ currentConfidence: newConfidence,
+ })
+}
+
+/**
+ * Sets the character's new maximum confidence.
+ * The current confidence will be clamped to the new confidenceLimit.
+ */
+export function setMaxConfidence(hp: HitPoints, maxConfidence: number): HitPoints {
+ checkValidHitPoints(hp)
+ checkValidMaxHitPoints(maxConfidence)
+
+ let {currentConfidence} = hp
+ currentConfidence = Math.min(currentConfidence, confidenceLimit({...hp, maxConfidence}))
+
+ return checkValidHitPoints({
+ ...hp,
+ maxConfidence,
+ currentConfidence
+ })
+}
+
+/** Sets the character's new current confidence. */
+export function setCurrentConfidence(hp: HitPoints, currentConfidence: number): HitPoints {
+ checkValidHitPoints(hp)
+ checkValidCurrentHitPoints(currentConfidence, confidenceLimit(hp), 0)
+
+ return checkValidHitPoints({
+ ...hp,
+ currentConfidence
+ })
+}
\ No newline at end of file