import {isDefined} from "../types/type_check"; import { createResourceManipulator, MeteredResource, Resource, ResourceManipulator, UnmeteredResource } from "./Resources"; export enum CharacterHealth { Full = "Full", Healthy = "Healthy", Wounded = "Wounded", Crisis = "Crisis", Peril = "Peril", KO = "KO", } export interface CharacterHealthLevel { readonly minPercentage: number|null readonly minInclusive: boolean|null readonly maxPercentage: number|null readonly maxInclusive: boolean|null readonly health: CharacterHealth } export const CharacterHealthLevels= { [CharacterHealth.Full]: { maxPercentage: null, maxInclusive: null, minPercentage: 100, minInclusive: true, health: CharacterHealth.Full, }, [CharacterHealth.Healthy]: { maxPercentage: 100, maxInclusive: false, minPercentage: 90, minInclusive: true, health: CharacterHealth.Healthy, }, [CharacterHealth.Wounded]: { maxPercentage: 90, maxInclusive: false, minPercentage: 50, minInclusive: false, health: CharacterHealth.Wounded, }, [CharacterHealth.Crisis]: { maxPercentage: 50, maxInclusive: true, minPercentage: 10, minInclusive: false, health: CharacterHealth.Crisis, }, [CharacterHealth.Peril]: { maxPercentage: 10, maxInclusive: true, minPercentage: 0, minInclusive: false, health: CharacterHealth.Peril, }, [CharacterHealth.KO]: { maxPercentage: 0, maxInclusive: true, minPercentage: null, minInclusive: null, health: CharacterHealth.KO, }, } as const satisfies {readonly [value in CharacterHealth]: CharacterHealthLevel & {health: value}} export const CharacterHealthLevelList: readonly CharacterHealthLevel[] = Object.values(CharacterHealthLevels) export function healthToFraction(health: CharacterHealth | undefined): number { const {minPercentage, maxPercentage} = CharacterHealthLevels[health ?? CharacterHealth.Wounded] return ((minPercentage ?? maxPercentage ?? 0) + (maxPercentage ?? minPercentage ?? 0)) / 200 } export function healthToBounds(health: CharacterHealth | undefined): string { if (!health) { return "???" } const {minPercentage = null, maxPercentage = null} = CharacterHealthLevels[health] return `${minPercentage ?? ""}${minPercentage !== null && maxPercentage !== null ? "-" : ""}${maxPercentage ?? ""}%` } export function hpToHealth({hp, maxHp}: {hp?: number, maxHp?: number}): CharacterHealth | undefined { if (!isDefined(hp) || !isDefined(maxHp) || maxHp <= 0) { return } const compareHp = 100 * hp for (const level of CharacterHealthLevelList) { if (level.minPercentage === null || level.minInclusive === null) { return level.health } const compareLevel = maxHp * level.minPercentage ?? 0 if (level.minInclusive ? compareHp >= compareLevel : compareHp > compareLevel) { return level.health } } // Returns undefined by falling off here. // However, won't reach this point; will always return at the last level in the loop } export enum CharacterTurnState { None = "None", Ready = "Ready", HighTurns = "HighTurns", Done = "Done", CantAct = "CantAct", Downed = "Downed", Active = "Active", } export interface CharacterTurnStateData { readonly title: string readonly description: string } export const CharacterTurnStates = { [CharacterTurnState.None]: { title: "None", description: "Does not take turns.", }, [CharacterTurnState.Ready]: { title: "Ready", description: "Has not acted yet this round.", }, [CharacterTurnState.HighTurns]: { title: "Multiple Turns", description: "Has %c% turns left out of %m% turns. Must still alternate with opponents.", }, [CharacterTurnState.Done]: { title: "Done", description: "Has finished acting this round.", }, [CharacterTurnState.CantAct]: { title: "Can't Act", description: "Is currently unable to act.", }, [CharacterTurnState.Downed]: { title: "Downed", description: "Has 0 HP. Is currently down and out of the action and unable to act.", }, [CharacterTurnState.Active]: { title: "Active", description: "Currently taking a turn." } } as const satisfies {readonly [value in CharacterTurnState]: CharacterTurnStateData} export function turnStateToTitle(state: CharacterTurnState): string { return CharacterTurnStates[state].title } export function turnStateToDescription(state: CharacterTurnState): string { return CharacterTurnStates[state].description } export function isZpReady({zp, maxZp}: {zp?: number, maxZp?: number}): boolean|undefined { if (typeof zp === "undefined" || typeof maxZp === "undefined") { return } return zp === maxZp } export enum SPType { UltimaPoints = "Ultima", FabulaPoints = "Fabula", } export interface SPTypeData { readonly description: string } export const SPTypes = { [SPType.UltimaPoints]: { description: "The number of Ultima Points. Ultima Points can be used to make a getaway, " + "recover MP and clear status effects, or perform special villainy moves.", }, [SPType.FabulaPoints]: { description: "The number of Fabula Points. Fabula Points can be used to buy rerolls by " + "invoking your Traits, boost your rolls by invoking your Bonds, or add elements to the story." } } as const satisfies {readonly [value in SPType]: SPTypeData} export function spTypeToDescription(sp: SPType): string { return SPTypes[sp].description } export interface StatusEffectInstance { readonly id: string readonly count: number } export enum CharacterSide { Ally = "ally", Enemy = "enemy" } export interface Character { readonly id: string readonly side?: CharacterSide readonly minion?: boolean readonly portraitUrl?: string readonly name?: string readonly altName?: string readonly description?: string readonly level?: number readonly xp?: number readonly maxXp?: number readonly hp?: number readonly maxHp?: number readonly health?: CharacterHealth readonly koText?: string readonly mp?: number readonly maxMp?: number readonly ip?: number readonly maxIp?: number readonly sp?: number readonly spBank?: number readonly spType?: SPType readonly zp?: number readonly maxZp?: number readonly bp?: number readonly maxBp?: number readonly turnsLeft?: number readonly turnsTotal?: number readonly canAct?: boolean readonly statuses?: readonly StatusEffectInstance[] readonly order?: number readonly zenit?: number readonly materials?: number readonly privacy?: CharacterPrivacy } interface CharacterPrivacySetting { readonly showCharacter: boolean readonly showHp: boolean readonly showHealth: boolean readonly showMp: boolean readonly showIp: boolean readonly showSp: boolean readonly showZp: boolean readonly showName: boolean readonly showPortrait: boolean readonly showTurns: boolean readonly showStatuses: boolean readonly showLevel: boolean } export enum CharacterPrivacy { Friend = "friend", FullyScannedEnemy = "fully scanned enemy", ScannedEnemy = "scanned enemy", LightlyScannedEnemy = "lightly scanned enemy", UnscannedEnemy = "unscanned enemy", SecretiveEnemy = "secretive", Hidden = "hidden", } export const CharacterPrivacySettings = { [CharacterPrivacy.Friend]: { showCharacter: true, showHp: true, showHealth: true, showMp: true, showIp: true, showSp: true, showZp: true, showName: true, showPortrait: true, showTurns: true, showStatuses: true, showLevel: true, }, [CharacterPrivacy.FullyScannedEnemy]: { showCharacter: true, showHp: true, showHealth: true, showMp: true, showIp: false, showSp: true, showZp: true, showName: true, showPortrait: true, showTurns: true, showStatuses: true, showLevel: true, }, [CharacterPrivacy.ScannedEnemy]: { showCharacter: true, showHp: true, showHealth: true, showMp: false, showIp: false, showSp: true, showZp: true, showName: true, showPortrait: true, showTurns: true, showStatuses: true, showLevel: true, }, [CharacterPrivacy.LightlyScannedEnemy]: { showCharacter: true, showHp: true, showHealth: true, showMp: false, showIp: false, showSp: true, showZp: true, showName: true, showPortrait: true, showTurns: true, showStatuses: true, showLevel: true, }, [CharacterPrivacy.UnscannedEnemy]: { showCharacter: true, showHp: false, showHealth: true, showMp: false, showIp: false, showSp: true, showZp: true, showName: true, showPortrait: true, showTurns: true, showStatuses: true, showLevel: false, }, [CharacterPrivacy.SecretiveEnemy]: { showCharacter: true, showHp: false, showHealth: true, showMp: false, showIp: false, showSp: false, showZp: false, showName: false, showPortrait: false, showTurns: true, showStatuses: true, showLevel: false, }, [CharacterPrivacy.Hidden]: { showCharacter: false, showHp: false, showHealth: false, showMp: false, showIp: false, showSp: false, showZp: false, showName: false, showPortrait: false, showTurns: false, showStatuses: false, showLevel: false, } } as const satisfies {readonly [value in CharacterPrivacy]: CharacterPrivacySetting} const CharacterResourceValue = { [MeteredResource.Blood]: "bp", [MeteredResource.Experience]: "xp", [MeteredResource.Health]: "hp", [MeteredResource.Items]: "ip", [MeteredResource.Magic]: "mp", [MeteredResource.Segments]: undefined, [MeteredResource.Turns]: "turnsLeft", [MeteredResource.Zero]: "zp", [UnmeteredResource.Order]: "order", [UnmeteredResource.Level]: "level", [UnmeteredResource.Zenit]: "zenit", [UnmeteredResource.Fabula]: "sp", [UnmeteredResource.Ultima]: "sp", [UnmeteredResource.Special]: "sp", [UnmeteredResource.Materials]: "materials", } as const satisfies {[key in Resource]: keyof Character|undefined} const CharacterResourceMax = { [MeteredResource.Blood]: "maxBp", [MeteredResource.Experience]: "maxXp", [MeteredResource.Health]: "maxHp", [MeteredResource.Items]: "maxIp", [MeteredResource.Magic]: "maxMp", [MeteredResource.Segments]: undefined, [MeteredResource.Turns]: "turnsTotal", [MeteredResource.Zero]: "maxZp", [UnmeteredResource.Materials]: undefined, [UnmeteredResource.Fabula]: undefined, [UnmeteredResource.Ultima]: undefined, [UnmeteredResource.Special]: undefined, [UnmeteredResource.Zenit]: undefined, [UnmeteredResource.Order]: undefined, [UnmeteredResource.Level]: undefined, } as const satisfies {[key in MeteredResource]: keyof Character|undefined} & {[key in UnmeteredResource]: undefined} const baseCharacterResource = createResourceManipulator< Exclude, Character, Resource>(CharacterResourceValue, CharacterResourceMax) export const CharacterResources: ResourceManipulator = { ...baseCharacterResource, getValue(object: Character, resource: Resource): number | undefined { if ((resource === UnmeteredResource.Fabula && object.spType !== SPType.FabulaPoints) || (resource === UnmeteredResource.Ultima && object.spType !== SPType.UltimaPoints)) { return undefined } return baseCharacterResource.getValue(object, resource) }, applyDelta(object: Character, resource: Resource, delta: number): Character { if ((resource === UnmeteredResource.Fabula && object.spType !== SPType.FabulaPoints) || (resource === UnmeteredResource.Ultima && object.spType !== SPType.UltimaPoints)) { return object } if ((resource === MeteredResource.Experience)) { const oldExp = this.getValue(object, resource) const maxExp = this.getMax(object, resource) if (typeof maxExp === "undefined" || typeof oldExp === "undefined" || maxExp < 1) { return object } let levelDiff = 0 let newExp = oldExp + delta while (newExp > maxExp) { levelDiff += 1 newExp -= maxExp } while (newExp < 0) { levelDiff -= 1 newExp += maxExp } return this.setValue(this.applyDelta(object, UnmeteredResource.Level, levelDiff), MeteredResource.Experience, newExp) } const updated = baseCharacterResource.applyDelta(object, resource, delta) if (resource === MeteredResource.Health) { return {...updated, health: hpToHealth(updated)} } else { return updated } }, setValue(object: Character, resource: Resource, value: number): Character { const updated = baseCharacterResource.setValue(object, resource, value) switch (resource) { case MeteredResource.Health: return {...updated, health: hpToHealth(updated)} case UnmeteredResource.Fabula: return {...updated, spType: SPType.FabulaPoints} case UnmeteredResource.Ultima: return {...updated, spType: SPType.UltimaPoints} default: return updated } }, setMetered(object: Character, resource: Resource, value: number, max: number): Character { if (resource === MeteredResource.Experience && value === max) { return baseCharacterResource.applyDelta( baseCharacterResource.setMetered(object, resource, 0, max), UnmeteredResource.Level, 1) } else { const result = baseCharacterResource.setMetered(object, resource, value, max) if (resource === MeteredResource.Health) { return {...result, health: hpToHealth(result)} } else { return result } } }, setMax(object: Character, resource: Resource, max: number): Character { const result = baseCharacterResource.setMax(object, resource, max) if (resource === MeteredResource.Health) { return {...result, health: hpToHealth(result)} } else { return result } }, } export function applyCharacterPrivacy(character: Character): Character|null { const privacySettings = CharacterPrivacySettings[character.privacy ?? CharacterPrivacy.Hidden] if (!privacySettings.showCharacter) { return null } const out: {-readonly [Field in keyof Character]: Character[Field]} = Object.assign({}, character) delete out.privacy if (!privacySettings.showHp) { delete out.hp delete out.maxHp if (!privacySettings.showHealth) { delete out.health } } if (!privacySettings.showMp) { delete out.mp delete out.maxMp } if (!privacySettings.showIp) { delete out.ip delete out.maxIp } if (!privacySettings.showSp) { delete out.sp delete out.spBank delete out.spType } if (!privacySettings.showZp) { delete out.zp delete out.maxZp delete out.bp delete out.maxBp } if (!privacySettings.showName) { if (isDefined(out.altName)) { out.name = out.altName delete out.altName } else { delete out.name } } if (!privacySettings.showPortrait) { delete out.portraitUrl } if (!privacySettings.showTurns) { delete out.turnsLeft delete out.turnsTotal delete out.canAct } if (!privacySettings.showStatuses) { delete out.statuses } if (!privacySettings.showLevel) { delete out.level delete out.xp delete out.maxXp } return out } export const CharacterStatuses = { addStatus(c: Character, id: string, stacks: number): Character { if (!c.statuses || c.statuses.some((s) => s.id === id)) { return c } else { return this.setStatus(c, id, stacks) } }, setStatus(c: Character, id: string, stacks: number): Character { if (!c.statuses) { return c } if (c.statuses.some((s) => s.id === id)) { return { ...c, statuses: c.statuses.map((s) => s.id === id ? {...s, count: Math.max(0, stacks)} : s), } } else { return { ...c, statuses: [...c.statuses, {id: id, count: Math.max(0, stacks)}] } } }, applyStatusDelta(c: Character, id: string, delta: number): Character { if (!c.statuses) { return c } const status = c.statuses.find((s) => s.id === id) if (!status) { if (delta > 0) { return this.addStatus(c, id, delta) } else { return c } } if (status.count + delta > 0) { return this.setStatus(c, id, status.count + delta) } else { return this.removeStatus(c, id) } }, removeStatus(c: Character, id: string): Character { if (!c.statuses || !c.statuses.some((s) => s.id === id)) { return c } else { return { ...c, statuses: c.statuses.filter((s) => s.id !== id) } } } }