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