You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
602 lines
19 KiB
602 lines
19 KiB
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 leader?: string
|
|
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<typeof CharacterResourceMax[Resource] | typeof CharacterResourceValue[Resource], undefined>,
|
|
Character, Resource>(CharacterResourceValue, CharacterResourceMax)
|
|
|
|
export const CharacterResources: ResourceManipulator<Character, Resource> = {
|
|
...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
|
|
}
|
|
},
|
|
clear(object: Character, resource: Resource): Character {
|
|
const result: {-readonly [key in keyof Character]: Character[key]} = baseCharacterResource.clear(object, resource)
|
|
if (resource === MeteredResource.Health) {
|
|
delete result.health
|
|
}
|
|
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 = {
|
|
hasStatus(c: Character, id: string): number|null {
|
|
const status = c.statuses?.find((s) => s.id === id)
|
|
return status ? status.count : null
|
|
},
|
|
addStatus(c: Character, id: string, stacks: number): Character {
|
|
if (!c.statuses || this.hasStatus(c, id) !== null) {
|
|
return c
|
|
} else {
|
|
return this.setStatus(c, id, stacks)
|
|
}
|
|
},
|
|
setStatus(c: Character, id: string, stacks: number): Character {
|
|
if (!c.statuses || this.hasStatus(c, id) === stacks) {
|
|
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 (this.hasStatus(c, id) === null) {
|
|
return c
|
|
} else {
|
|
return {
|
|
...c,
|
|
statuses: c.statuses ? c.statuses.filter((s) => s.id !== id) : []
|
|
}
|
|
}
|
|
}
|
|
} |