@ -0,0 +1,122 @@ |
||||
{ |
||||
"session": { |
||||
"fpUsed": 0, |
||||
"upUsed": 0 |
||||
}, |
||||
"conflict": { |
||||
"round": 0, |
||||
"activeSideIsAllies": true, |
||||
"activeCharacterId": null |
||||
}, |
||||
"clocks": [], |
||||
"characters": [ |
||||
{ |
||||
"id": "aelica", |
||||
"ally": true, |
||||
"name": "Aelica", |
||||
"level": 32, |
||||
"hp": 0, |
||||
"maxHp": 82, |
||||
"mp": 77, |
||||
"maxMp": 77, |
||||
"ip": 6, |
||||
"maxIp": 6, |
||||
"sp": 3, |
||||
"spBank": 2, |
||||
"spType": "Fabula", |
||||
"portraitUrl": "/portraits/aelica.png", |
||||
"turnsTotal": 1, |
||||
"turnsLeft": 1 |
||||
}, |
||||
{ |
||||
"id": "athetyz", |
||||
"ally": true, |
||||
"name": "Athetyz", |
||||
"level": 32, |
||||
"hp": 0, |
||||
"maxHp": 63, |
||||
"mp": 45, |
||||
"maxMp": 55, |
||||
"ip": 9, |
||||
"maxIp": 10, |
||||
"sp": 3, |
||||
"spBank": 1, |
||||
"spType": "Fabula", |
||||
"portraitUrl": "/portraits/athetyz.png", |
||||
"turnsTotal": 1, |
||||
"turnsLeft": 0 |
||||
}, |
||||
{ |
||||
"id": "echo", |
||||
"ally": true, |
||||
"name": "Echo", |
||||
"level": 32, |
||||
"hp": 0, |
||||
"maxHp": 67, |
||||
"mp": 62, |
||||
"maxMp": 62, |
||||
"ip": 4, |
||||
"maxIp": 12, |
||||
"sp": 3, |
||||
"spBank": 4, |
||||
"spType": "Fabula", |
||||
"portraitUrl": "/portraits/echo.png", |
||||
"turnsTotal": 1, |
||||
"turnsLeft": 1 |
||||
}, |
||||
{ |
||||
"id": "gravitas", |
||||
"ally": true, |
||||
"name": "Gravitas", |
||||
"level": 32, |
||||
"hp": 0, |
||||
"maxHp": 72, |
||||
"mp": 43, |
||||
"maxMp": 117, |
||||
"ip": 5, |
||||
"maxIp": 8, |
||||
"sp": 3, |
||||
"spBank": 6, |
||||
"spType": "Fabula", |
||||
"portraitUrl": "/portraits/gravitas.png", |
||||
"turnsTotal": 1, |
||||
"turnsLeft": 0 |
||||
}, |
||||
{ |
||||
"id": "linnet", |
||||
"ally": true, |
||||
"name": "Linnet", |
||||
"level": 32, |
||||
"hp": 0, |
||||
"maxHp": 117, |
||||
"mp": 73, |
||||
"maxMp": 76, |
||||
"ip": 6, |
||||
"maxIp": 6, |
||||
"sp": 3, |
||||
"spBank": 4, |
||||
"spType": "Fabula", |
||||
"portraitUrl": "/portraits/linnet.png", |
||||
"turnsTotal": 1, |
||||
"turnsLeft": 1 |
||||
}, |
||||
{ |
||||
"id": "prandia", |
||||
"ally": true, |
||||
"name": "Prandia", |
||||
"level": 32, |
||||
"hp": 0, |
||||
"maxHp": 90, |
||||
"mp": 0, |
||||
"maxMp": 70, |
||||
"ip": 8, |
||||
"maxIp": 8, |
||||
"sp": 3, |
||||
"spBank": 4, |
||||
"spType": "Fabula", |
||||
"portraitUrl": "/portraits/prandia.png", |
||||
"turnsTotal": 1, |
||||
"turnsLeft": 0 |
||||
} |
||||
] |
||||
} |
@ -1,105 +0,0 @@ |
||||
import React, {ChangeEvent, useCallback, useMemo, useState} from 'react'; |
||||
import './App.css'; |
||||
import {Col, Container, Form, InputGroup, Row,} from "react-bootstrap"; |
||||
import {Character, CharacterStatus, hpToHealth, SPType} from "./CharacterStatus"; |
||||
import DefaultPortrait from "./default-portrait.svg" |
||||
|
||||
function App() { |
||||
const [maxHp, _setMaxHp] = useState(50) |
||||
const [hp, _setHp] = useState(40) |
||||
const setHp = useCallback(function (v: number) { |
||||
v = Math.floor(v) |
||||
if (Number.isNaN(v) || v < 0 || v > maxHp || v === hp) { |
||||
return |
||||
} |
||||
_setHp(v) |
||||
}, [hp, maxHp]) |
||||
const setHpMax = useCallback(function (v: number) { |
||||
v = Math.floor(v) |
||||
if (Number.isNaN(v) || v < 0 || v > 9999 || v === maxHp) { |
||||
return |
||||
} |
||||
if (v < hp) { |
||||
setHp(v) |
||||
} |
||||
_setMaxHp(v) |
||||
}, [hp, maxHp, setHp]) |
||||
const onHpChange = useCallback( |
||||
(e: ChangeEvent<HTMLInputElement>) => setHp(parseInt(e.target.value)), [setHp]) |
||||
const onMaxHpChange = useCallback( |
||||
(e: ChangeEvent<HTMLInputElement>) => setHpMax(parseInt(e.target.value)), [setHpMax]) |
||||
const [maxMp, _setMaxMp] = useState(1) |
||||
const [mp, _setMp] = useState(1) |
||||
const setMp = useCallback(function (v: number) { |
||||
v = Math.floor(v) |
||||
if (Number.isNaN(v) || v < 0 || v > maxMp || v === mp) { |
||||
return |
||||
} |
||||
_setMp(v) |
||||
}, [mp, maxMp]) |
||||
const setMpMax = useCallback(function (v: number) { |
||||
v = Math.floor(v) |
||||
if (Number.isNaN(v) || v < 0 || v > 9999 || v === maxMp) { |
||||
return |
||||
} |
||||
if (v < mp) { |
||||
setMp(v) |
||||
} |
||||
_setMaxMp(v) |
||||
}, [mp, maxMp, setMp]) |
||||
const onMpChange = useCallback( |
||||
(e: ChangeEvent<HTMLInputElement>) => setMp(parseInt(e.target.value)), [setMp]) |
||||
const onMaxMpChange = useCallback( |
||||
(e: ChangeEvent<HTMLInputElement>) => setMpMax(parseInt(e.target.value)), [setMpMax]) |
||||
|
||||
const character = useMemo<Character>(() => ({ |
||||
name: "Test", |
||||
level: 26, |
||||
hp: hp, |
||||
maxHp: maxHp, |
||||
mp: 40, |
||||
maxMp: 50, |
||||
ip: 3, |
||||
maxIp: 6, |
||||
sp: 3, |
||||
spType: SPType.FabulaPoints, |
||||
portraitUrl: DefaultPortrait, |
||||
health: hpToHealth(hp, maxHp), |
||||
turnsTotal: 3, |
||||
turnsLeft: 2,}), [hp, maxHp, mp, maxMp]) |
||||
return <React.Fragment> |
||||
<Container fluid> |
||||
<Row> |
||||
<Col> |
||||
<InputGroup> |
||||
<InputGroup.Text>Max HP</InputGroup.Text> |
||||
<Form.Control type="number" max="9999" min="0" step="1" value={maxHp} onChange={onMaxHpChange} /> |
||||
</InputGroup> |
||||
</Col> |
||||
<Col> |
||||
<InputGroup> |
||||
<InputGroup.Text>Current HP</InputGroup.Text> |
||||
<Form.Control type="number" max={maxHp} min="0" step="1" value={hp} onChange={onHpChange} /> |
||||
</InputGroup> |
||||
</Col> |
||||
</Row> |
||||
<Row> |
||||
<Col> |
||||
<InputGroup> |
||||
<InputGroup.Text>Max MP</InputGroup.Text> |
||||
<Form.Control type="number" max="9999" min="0" step="1" value={maxMp} onChange={onMaxMpChange} /> |
||||
</InputGroup> |
||||
</Col> |
||||
<Col> |
||||
<InputGroup> |
||||
<InputGroup.Text>Current MP</InputGroup.Text> |
||||
<Form.Control type="number" max={maxMp} min="0" step="1" value={mp} onChange={onMpChange} /> |
||||
</InputGroup> |
||||
</Col> |
||||
</Row> |
||||
</Container> |
||||
<CharacterStatus character={character} active={false} /> |
||||
</React.Fragment>; |
||||
} |
||||
|
||||
export default App; |
@ -1,155 +0,0 @@ |
||||
import {Character} from "./CharacterStatus"; |
||||
|
||||
interface CharacterPrivacySettings { |
||||
readonly showCharacter: boolean |
||||
readonly showHp: boolean |
||||
readonly showHealth: boolean |
||||
readonly showMp: boolean |
||||
readonly showIp: boolean |
||||
readonly showSp: boolean |
||||
readonly showName: boolean |
||||
readonly showPortrait: boolean |
||||
readonly showTurns: boolean |
||||
readonly showStatuses: boolean |
||||
readonly showLevel: boolean |
||||
} |
||||
|
||||
enum CharacterPrivacy { |
||||
Friend = "friend", |
||||
ScannedEnemy = "scanned enemy", |
||||
LightlyScannedEnemy = "lightly scanned enemy", |
||||
UnscannedEnemy = "unscanned enemy", |
||||
SecretiveEnemy = "secretive", |
||||
Hidden = "hidden", |
||||
} |
||||
|
||||
const CharacterPrivacySettings: Record<CharacterPrivacy, CharacterPrivacySettings> = { |
||||
[CharacterPrivacy.Friend]: { |
||||
showCharacter: true, |
||||
showHp: true, |
||||
showHealth: true, |
||||
showMp: true, |
||||
showIp: true, |
||||
showSp: true, |
||||
showName: true, |
||||
showPortrait: true, |
||||
showTurns: true, |
||||
showStatuses: true, |
||||
showLevel: true, |
||||
}, |
||||
[CharacterPrivacy.ScannedEnemy]: { |
||||
showCharacter: true, |
||||
showHp: true, |
||||
showHealth: true, |
||||
showMp: true, |
||||
showIp: false, |
||||
showSp: 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, |
||||
showName: true, |
||||
showPortrait: true, |
||||
showTurns: true, |
||||
showStatuses: true, |
||||
showLevel: true, |
||||
}, |
||||
[CharacterPrivacy.UnscannedEnemy]: { |
||||
showCharacter: true, |
||||
showHp: false, |
||||
showHealth: true, |
||||
showMp: false, |
||||
showIp: false, |
||||
showSp: 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, |
||||
showName: false, |
||||
showPortrait: true, |
||||
showTurns: true, |
||||
showStatuses: true, |
||||
showLevel: false, |
||||
}, |
||||
[CharacterPrivacy.Hidden]: { |
||||
showCharacter: false, |
||||
showHp: false, |
||||
showHealth: false, |
||||
showMp: false, |
||||
showIp: false, |
||||
showSp: false, |
||||
showName: false, |
||||
showPortrait: false, |
||||
showTurns: false, |
||||
showStatuses: false, |
||||
showLevel: false, |
||||
} |
||||
} |
||||
|
||||
function applyPrivacy(c: EditableCharacter): Character|null { |
||||
const p = CharacterPrivacySettings[c.privacy ?? CharacterPrivacy.Hidden] |
||||
if (!p.showCharacter) { |
||||
return null |
||||
} |
||||
const out: {-readonly [Field in keyof EditableCharacter]?: EditableCharacter[Field]} = Object.assign({}, c) |
||||
delete out.privacy |
||||
if (!p.showHp) { |
||||
delete out.hp |
||||
delete out.maxHp |
||||
if (!p.showHealth) { |
||||
delete out.health |
||||
} |
||||
} |
||||
if (!p.showMp) { |
||||
delete out.mp |
||||
delete out.maxMp |
||||
} |
||||
if (!p.showIp) { |
||||
delete out.ip |
||||
delete out.maxIp |
||||
} |
||||
if (!p.showSp) { |
||||
delete out.sp |
||||
delete out.spType |
||||
} |
||||
if (!p.showName) { |
||||
delete out.name |
||||
} |
||||
if (!p.showPortrait) { |
||||
delete out.portraitUrl |
||||
} |
||||
if (!p.showTurns) { |
||||
delete out.turnsLeft |
||||
delete out.turnsTotal |
||||
delete out.canAct |
||||
} |
||||
if (!p.showStatuses) { |
||||
delete out.statuses |
||||
} |
||||
if (!p.showLevel) { |
||||
delete out.level |
||||
} |
||||
return out |
||||
} |
||||
|
||||
interface EditableCharacter extends Character { |
||||
privacy: CharacterPrivacy |
||||
} |
@ -1,11 +0,0 @@ |
||||
import {Interpolation, InterpolatorArgs, Globals} from "@react-spring/web"; |
||||
import {getAnimated} from "@react-spring/animated"; |
||||
|
||||
export class FixedInterpolation<Input = any, Output = any> extends Interpolation<Input, Output> { |
||||
constructor(readonly source: unknown, args: InterpolatorArgs<Input, Output>) { |
||||
super(source, args); |
||||
getAnimated(this)!.setValue(this._get()) |
||||
} |
||||
} |
||||
|
||||
Globals.assign({to: (source, args) => new FixedInterpolation(source, args)}) |
@ -0,0 +1,19 @@ |
||||
import {CharacterHealth, CharacterHealthLevelList} from "./Character"; |
||||
describe('CharacterHealth', function () { |
||||
test("covers all possible values of health, descending", () => { |
||||
let nextBound = Number.POSITIVE_INFINITY |
||||
let nextBoundInclusive = null |
||||
for (const level of CharacterHealthLevelList) { |
||||
expect(level.maxPercentage ?? Number.POSITIVE_INFINITY).toEqual(nextBound) |
||||
expect(level.maxInclusive).toEqual(nextBoundInclusive) |
||||
nextBound = level.minPercentage ?? Number.NEGATIVE_INFINITY |
||||
nextBoundInclusive = typeof level.minInclusive === "boolean" ? !level.minInclusive : null |
||||
if (Number.isFinite(nextBound)) { |
||||
expect(typeof level.minInclusive).toEqual("boolean") |
||||
} |
||||
} |
||||
expect(nextBound).toEqual(Number.NEGATIVE_INFINITY) |
||||
expect(nextBoundInclusive).toBeNull() |
||||
}) |
||||
|
||||
}); |
@ -0,0 +1,357 @@ |
||||
import {isDefined} from "../types/type_check"; |
||||
|
||||
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: number | undefined, maxHp: number | undefined): 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 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 type StatusId = string |
||||
|
||||
export interface StatusEffect { |
||||
readonly id: StatusId |
||||
readonly name: string |
||||
readonly count?: number |
||||
readonly iconUrl: string |
||||
readonly description?: string |
||||
} |
||||
|
||||
export type CharacterId = string |
||||
|
||||
export interface Character { |
||||
readonly id: CharacterId |
||||
readonly portraitUrl?: string |
||||
readonly name?: string |
||||
readonly level?: number |
||||
readonly hp?: number |
||||
readonly maxHp?: number |
||||
readonly health?: CharacterHealth |
||||
readonly mp?: number |
||||
readonly maxMp?: number |
||||
readonly ip?: number |
||||
readonly maxIp?: number |
||||
readonly sp?: number |
||||
readonly spBank?: number |
||||
readonly spType?: SPType |
||||
readonly turnsLeft?: number |
||||
readonly turnsTotal?: number |
||||
readonly canAct?: boolean |
||||
readonly statuses?: readonly StatusEffect[] |
||||
} |
||||
|
||||
interface CharacterPrivacySettings { |
||||
readonly showCharacter: boolean |
||||
readonly showHp: boolean |
||||
readonly showHealth: boolean |
||||
readonly showMp: boolean |
||||
readonly showIp: boolean |
||||
readonly showSp: boolean |
||||
readonly showName: boolean |
||||
readonly showPortrait: boolean |
||||
readonly showTurns: boolean |
||||
readonly showStatuses: boolean |
||||
readonly showLevel: boolean |
||||
} |
||||
|
||||
export enum CharacterPrivacy { |
||||
Friend = "friend", |
||||
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, |
||||
showName: true, |
||||
showPortrait: true, |
||||
showTurns: true, |
||||
showStatuses: true, |
||||
showLevel: true, |
||||
}, |
||||
[CharacterPrivacy.ScannedEnemy]: { |
||||
showCharacter: true, |
||||
showHp: true, |
||||
showHealth: true, |
||||
showMp: true, |
||||
showIp: false, |
||||
showSp: 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, |
||||
showName: true, |
||||
showPortrait: true, |
||||
showTurns: true, |
||||
showStatuses: true, |
||||
showLevel: true, |
||||
}, |
||||
[CharacterPrivacy.UnscannedEnemy]: { |
||||
showCharacter: true, |
||||
showHp: false, |
||||
showHealth: true, |
||||
showMp: false, |
||||
showIp: false, |
||||
showSp: 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, |
||||
showName: false, |
||||
showPortrait: true, |
||||
showTurns: true, |
||||
showStatuses: true, |
||||
showLevel: false, |
||||
}, |
||||
[CharacterPrivacy.Hidden]: { |
||||
showCharacter: false, |
||||
showHp: false, |
||||
showHealth: false, |
||||
showMp: false, |
||||
showIp: false, |
||||
showSp: false, |
||||
showName: false, |
||||
showPortrait: false, |
||||
showTurns: false, |
||||
showStatuses: false, |
||||
showLevel: false, |
||||
} |
||||
} as const satisfies {readonly [value in CharacterPrivacy]: CharacterPrivacySettings} |
||||
|
||||
export function applyCharacterPrivacy(character: Character, privacy: CharacterPrivacy): Character|null { |
||||
const privacySettings = CharacterPrivacySettings[privacy ?? CharacterPrivacy.Hidden] |
||||
if (!privacySettings.showCharacter) { |
||||
return null |
||||
} |
||||
const out: {-readonly [Field in keyof Character]: Character[Field]} = Object.assign({}, character) |
||||
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.showName) { |
||||
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 |
||||
} |
||||
return out |
||||
} |
@ -0,0 +1,372 @@ |
||||
import {GameState, getCharacterById} from "./GameState"; |
||||
import {CharacterId} from "./Character"; |
||||
|
||||
export interface BaseDoable { |
||||
readonly type: string |
||||
} |
||||
|
||||
export type LogEntry = { |
||||
readonly markdown: string |
||||
readonly children: readonly LogEntry[] |
||||
} |
||||
|
||||
export interface DoableResults { |
||||
readonly resultState: GameState |
||||
readonly logEntry: LogEntry|null |
||||
} |
||||
|
||||
export interface DoablesResults { |
||||
readonly resultState: GameState |
||||
readonly logEntries: readonly LogEntry[] |
||||
} |
||||
|
||||
export interface DoableEvaluator<DataType extends BaseDoable> { |
||||
readonly type: DataType["type"] |
||||
evaluate(data: DataType, state: GameState, direction: DoableDirection): DoableResults |
||||
} |
||||
|
||||
export interface GenericAction extends BaseDoable { |
||||
readonly type: "generic", |
||||
readonly text: string |
||||
readonly user: CharacterId|null |
||||
readonly target: CharacterId|null |
||||
readonly effects: readonly Doable[] |
||||
} |
||||
|
||||
export const GenericActionEvaluator = { |
||||
type: "generic", |
||||
evaluate(data: GenericAction, state: GameState, direction: DoableDirection): DoableResults { |
||||
function runEffects(currentState: GameState): DoablesResults { |
||||
return evaluateDoables(data.effects, state, direction) |
||||
} |
||||
function logSelf(currentState: GameState): string { |
||||
return data.text.replaceAll(/@[TU]/g, (substring: string): string => { |
||||
switch (substring) { |
||||
case "@T": |
||||
// TODO: make "character links" a function, likely with identifier characters
|
||||
return data.target !== null ? `[${getCharacterById(currentState, data.target)?.name ?? "???"}](#character/${data.target})` : "@T" |
||||
case "@U": |
||||
return data.user !== null ? `[${getCharacterById(currentState, data.user)?.name ?? "???"}](#character/${data.user})` : "@U" |
||||
default: |
||||
return substring |
||||
} |
||||
}) |
||||
} |
||||
switch (direction) { |
||||
case DoableDirection.Do: { |
||||
const markdown = logSelf(state) |
||||
const {resultState, logEntries} = runEffects(state) |
||||
return { |
||||
resultState, |
||||
logEntry: { |
||||
markdown, |
||||
children: logEntries |
||||
} |
||||
} |
||||
} |
||||
case DoableDirection.Undo: { |
||||
const {resultState, logEntries} = runEffects(state) |
||||
const markdown = logSelf(resultState) |
||||
return { |
||||
resultState, |
||||
logEntry: { |
||||
markdown, |
||||
children: logEntries |
||||
} |
||||
} |
||||
} |
||||
} |
||||
}, |
||||
} as const satisfies DoableEvaluator<GenericAction> |
||||
/** |
||||
* @dataclass(**JsonableDataclassArgs) |
||||
* class AbilityAction(Doable): |
||||
* name: str |
||||
* user: Target | None |
||||
* costs: tuple[DamageEffect, ...] |
||||
* effects: tuple[Effect, ...] |
||||
* |
||||
* |
||||
* @dataclass(**JsonableDataclassArgs) |
||||
* class ModifyCharacterEffect(Doable): |
||||
* index: int |
||||
* old_character_data: Character | None |
||||
* new_character_data: Character | None |
||||
* |
||||
* def __post_init__(self): |
||||
* if self.old_character_data is None and self.new_character_data is None: |
||||
* raise ValueError("At least one of old_character_data or new_character_data must be non-None") |
||||
* |
||||
* @dataclass(**JsonableDataclassArgs) |
||||
* class ModifyClockEffect(Doable): |
||||
* index: int |
||||
* old_clock_data: Clock | None |
||||
* new_clock_data: Clock | None |
||||
* |
||||
* def __post_init__(self): |
||||
* if self.old_clock_data is None and self.new_clock_data is None: |
||||
* raise ValueError("At least one of old_clock_data or new_clock_data must be non-None") |
||||
* |
||||
* |
||||
* @dataclass(**JsonableDataclassArgs) |
||||
* class EndTurnAction(Doable): |
||||
* turn_ending_index: int |
||||
* activating_side: CombatSide |
||||
* |
||||
* |
||||
* @dataclass(**JsonableDataclassArgs) |
||||
* class StartTurnAction(Doable): |
||||
* turn_starting_index: int |
||||
* old_active_side: CombatSide |
||||
* |
||||
* |
||||
* @dataclass(**JsonableDataclassArgs) |
||||
* class StartRoundAction(Doable): |
||||
* last_round: int |
||||
* old_active_side: CombatSide |
||||
* old_turns_remaining: tuple[int, ...] |
||||
* next_round: int |
||||
* activating_side: CombatSide |
||||
* |
||||
* |
||||
* @dataclass(**JsonableDataclassArgs) |
||||
* class StartBattleAction(Doable): |
||||
* starting_side: CombatSide |
||||
* starting_round: int |
||||
* |
||||
* |
||||
* @dataclass(**JsonableDataclassArgs) |
||||
* class EndBattleAction(Doable): |
||||
* old_round_number: int |
||||
* old_active_side: CombatSide |
||||
* old_starting_side: CombatSide |
||||
* old_turns_remaining: tuple[int, ...] |
||||
* |
||||
* @dataclass(**JsonableDataclassArgs) |
||||
* class OpportunityEffect(LogOnly, Effect): |
||||
* target: Target | None |
||||
* opportunity_text: str |
||||
* |
||||
* def log_message(self, user: Target | None = None) -> str: |
||||
* return f'**Opportunity!!** {log_substitute(self.opportunity_text, user=user, target=self.target)}' |
||||
* |
||||
* @dataclass(**JsonableDataclassArgs) |
||||
* class DamageEffect(Effect): |
||||
* target: Target |
||||
* target_type: CharacterType |
||||
* attribute: Counter |
||||
* damage: int |
||||
* old_value: int |
||||
* new_value: int |
||||
* max_value: int | None |
||||
* element: Element |
||||
* affinity: Affinity |
||||
* piercing: bool |
||||
* |
||||
* def do(self, combat: "CombatStatus", source: Union[Action, "Effect", None] = None) -> "CombatStatus": |
||||
* return combat.alter_character(self.target, lambda c: c.set_counter_current(self.attribute, self.new_value)) |
||||
* |
||||
* def undo(self, combat: "CombatStatus", source: Union[Action, "Effect", None] = None) -> "CombatStatus": |
||||
* return combat.alter_character(self.target, lambda c: c.set_counter_current(self.attribute, self.old_value)) |
||||
* |
||||
* def log_message(self, user: Target | None = None) -> str: |
||||
* state_change = None |
||||
* if self.attribute == Counter.HP: |
||||
* if self.old_value > self.new_value == 0: |
||||
* state_change = ['+ **KO**'] |
||||
* elif self.new_value > self.old_value == 0: |
||||
* if self.new_value * 2 > self.max_value: |
||||
* state_change = ['- **KO**'] |
||||
* else: |
||||
* state_change = ['- **KO**', '+ **Crisis**'] |
||||
* elif self.old_value * 2 > self.max_value >= self.new_value * 2: |
||||
* state_change = '+ **Crisis**' |
||||
* elif self.new_value * 2 > self.max_value >= self.old_value * 2: |
||||
* state_change = '- **Crisis**' |
||||
* affinity = '' |
||||
* if self.affinity == Affinity.Absorb: |
||||
* affinity = "?" |
||||
* elif self.affinity == Affinity.Immune: |
||||
* affinity = " ✖" |
||||
* elif self.affinity == Affinity.Resistant: |
||||
* affinity = "…" |
||||
* elif self.affinity == Affinity.Vulnerable: |
||||
* affinity = "‼" |
||||
* attribute = (f'{self.element.value}{"!" if self.piercing else ""}' |
||||
* if self.attribute == Counter.HP |
||||
* else f'{self.attribute.ctr_name_abbr(self.target_type)}') |
||||
* sign = '-' if self.damage >= 0 else '+' |
||||
* return '\n'.join( |
||||
* [log_substitute( |
||||
* f'@T: [{sign}{abs(self.damage)}{affinity}] {attribute}', |
||||
* user=user, target=self.target)] + |
||||
* [log_substitute(f'@T: [{s}]') for s in state_change]) |
||||
* |
||||
* |
||||
* @dataclass(**JsonableDataclassArgs) |
||||
* class StatusEffect(Effect): |
||||
* target: Target |
||||
* old_status: str | None |
||||
* new_status: str | None |
||||
* |
||||
* @staticmethod |
||||
* def alter_status(c: Character, old_status: str | None, new_status: str | None) -> Character: |
||||
* if old_status is None and new_status is not None: |
||||
* return c.add_status(new_status) |
||||
* elif new_status is None and old_status is not None: |
||||
* return c.remove_status(old_status) |
||||
* elif new_status is not None and old_status is not None: |
||||
* return c.replace_status(old_status, new_status) |
||||
* else: |
||||
* return c |
||||
* |
||||
* def __post_init__(self): |
||||
* if self.old_status is None and self.new_status is None: |
||||
* raise ValueError("At least one of old_status or new_status must be non-None") |
||||
* |
||||
* def do(self, combat: CombatStatus, source: Union[Action, Effect, None] = None) -> CombatStatus: |
||||
* return combat.alter_character( |
||||
* self.target, lambda c: StatusEffect.alter_status(c, self.old_status, self.new_status)) |
||||
* |
||||
* def undo(self, combat: CombatStatus, source: Union[Action, Effect, None] = None) -> CombatStatus: |
||||
* return combat.alter_character( |
||||
* self.target, lambda c: StatusEffect.alter_status(c, self.new_status, self.old_status)) |
||||
* |
||||
* def log_message(self, user: Target | None = None) -> str | None: |
||||
* if self.old_status is not None and self.new_status is None: |
||||
* return log_substitute(v=f'@T: [- {self.old_status}]', user=user, target=self.target) |
||||
* if self.new_status is not None and self.old_status is None: |
||||
* return log_substitute(v=f'@T: [+ {self.old_status}]', user=user, target=self.target) |
||||
* if self.old_status is not None and self.new_status is not None: |
||||
* return log_substitute(v=f'@T: [{self.old_status} -> {self.new_status}]', user=user, target=self.target) |
||||
* pass |
||||
* |
||||
* |
||||
* @dataclass(**JsonableDataclassArgs) |
||||
* class FPBonusEffect(Effect): |
||||
* user: Target |
||||
* rerolls: int |
||||
* modifier: int |
||||
* fp_spent: int |
||||
* old_fp: int |
||||
* new_fp: int |
||||
* |
||||
* def do(self, combat: CombatStatus, source: Union[Action, Effect, None] = None) -> CombatStatus: |
||||
* return combat.alter_character( |
||||
* self.user, |
||||
* lambda c: c.set_counter_current(Counter.SP, self.new_fp) |
||||
* ).add_fp_spent(self.fp_spent) |
||||
* |
||||
* def undo(self, combat: CombatStatus, source: Union[Action, Effect, None] = None) -> CombatStatus: |
||||
* return combat.alter_character( |
||||
* self.user, |
||||
* lambda c: c.set_counter_current(Counter.SP, self.old_fp) |
||||
* ).add_fp_spent(-self.fp_spent) |
||||
* |
||||
* def log_message(self, user: Target | None = None) -> str | None: |
||||
* bonuses = [] |
||||
* if self.rerolls > 0: |
||||
* if self.rerolls > 1: |
||||
* bonuses.append(f"{self.rerolls} rerolls") |
||||
* else: |
||||
* bonuses.append("a reroll") |
||||
* if self.modifier != 0: |
||||
* bonuses.append(f"a {self.modifier:+} {'bonus' if self.modifier > 0 else 'penalty'}") |
||||
* if len(bonuses) == 0: |
||||
* return None |
||||
* affected = '' |
||||
* if self.user != user: |
||||
* affected = log_substitute(" on @U's roll", user=user, target=self.user) |
||||
* return f"{log_substitute('@T', user=user, target=self.user)} " \ |
||||
* f"spent **{self.fp_spent} FP** for {' and '.join(bonuses)}{affected}!" |
||||
* |
||||
* TODO: add an FP gain effect for villains (affects all party members) and fumbles and trait failures |
||||
* |
||||
* @dataclass(**JsonableDataclassArgs) |
||||
* class MissEffect(LogOnly, Effect): |
||||
* miss_type: MissType |
||||
* target: Target |
||||
* |
||||
* def log_message(self, user: Target | None = None) -> str | None: |
||||
* if self.miss_type == MissType.Missed: |
||||
* return log_substitute(f"It missed @T!", user=user, target=self.target) |
||||
* elif self.miss_type == MissType.Dodged: |
||||
* return log_substitute(f"@T dodged it!", user=user, target=self.target) |
||||
* elif self.miss_type == MissType.Avoided: |
||||
* return log_substitute(f"@T avoided it!", user=user, target=self.target) |
||||
* elif self.miss_type == MissType.Blocked: |
||||
* return log_substitute("@T blocked it!", user=user, target=self.target) |
||||
* elif self.miss_type == MissType.Immunity: |
||||
* return log_substitute("@T is immune!", user=user, target=self.target) |
||||
* elif self.miss_type == MissType.Repelled: |
||||
* return log_substitute("@T repelled it!", user=user, target=self.target) |
||||
* elif self.miss_type == MissType.Countered: |
||||
* return log_substitute("@T countered it!", user=user, target=self.target) |
||||
* elif self.miss_type == MissType.Parried: |
||||
* return log_substitute("@T parried it!", user=user, target=self.target) |
||||
* elif self.miss_type == MissType.Protected: |
||||
* return log_substitute("@T was protected from it!", user=user, target=self.target) |
||||
* elif self.miss_type == MissType.Resisted: |
||||
* return log_substitute("@T resisted it!", user=user, target=self.target) |
||||
* elif self.miss_type == MissType.Shielded: |
||||
* return log_substitute("@T shielded against it!", user=user, target=self.target) |
||||
* else: |
||||
* return log_substitute(f"@T: {self.miss_type.value}", user=user, target=self.target) |
||||
* |
||||
* |
||||
* @dataclass(**JsonableDataclassArgs) |
||||
* class ClockTickEffect(Effect): |
||||
* clock_index: int |
||||
* old_definition: Clock |
||||
* new_value: int |
||||
* delta: int |
||||
* |
||||
* def do(self, combat: "CombatStatus", source: Union[Action, "Effect", None] = None) -> "CombatStatus": |
||||
* return combat.alter_clock(self.clock_index, lambda c: c.set_value(self.new_value)) |
||||
* |
||||
* def undo(self, combat: "CombatStatus", source: Union[Action, "Effect", None] = None) -> "CombatStatus": |
||||
* return combat.alter_clock(self.clock_index, lambda c: c.set_value(self.old_definition.current)) |
||||
* |
||||
* def log_message(self, user: Target | None = None) -> str | None: |
||||
* return (f'The clock **{self.old_definition.name}** ticked {"up" if self.delta > 0 else "down"} {self.delta} ' |
||||
* f'tick{"" if abs(self.delta) == 1 else "s"}.') |
||||
*/ |
||||
|
||||
const DoableEvaluators = [ |
||||
GenericActionEvaluator, |
||||
] as const satisfies readonly DoableEvaluator<unknown & BaseDoable>[] |
||||
|
||||
export type Doable = typeof DoableEvaluators[number] extends DoableEvaluator<infer ActionType> ? ActionType : never |
||||
|
||||
export enum DoableDirection { |
||||
Do = "do", |
||||
Undo = "undo", |
||||
} |
||||
|
||||
export function evaluateDoable<T extends Doable>(doable: T, gameState: GameState, direction: DoableDirection): DoableResults { |
||||
const evaluator: DoableEvaluator<T>|undefined = DoableEvaluators.find((evaluator) => evaluator.type === doable.type) |
||||
if (!evaluator) { |
||||
return { |
||||
resultState: gameState, |
||||
logEntry: null |
||||
} |
||||
} |
||||
return evaluator.evaluate(doable, gameState, direction) |
||||
} |
||||
|
||||
export function evaluateDoables(doables: readonly Doable[], gameState: GameState, direction: DoableDirection): DoablesResults { |
||||
let currentState = gameState |
||||
const sortedDoables = direction === DoableDirection.Undo ? doables.slice().reverse() : doables |
||||
const logEntries: LogEntry[] = [] |
||||
for (const doable of sortedDoables) { |
||||
const {resultState, logEntry} = evaluateDoable(doable, gameState, direction) |
||||
currentState = resultState |
||||
if (logEntry) { |
||||
logEntries.push(logEntry) |
||||
} |
||||
} |
||||
return { |
||||
resultState: currentState, |
||||
logEntries: direction === DoableDirection.Undo ? logEntries.reverse() : logEntries, |
||||
} |
||||
} |
@ -0,0 +1,27 @@ |
||||
import {Character, CharacterId} from "./Character"; |
||||
|
||||
export interface Clock {} |
||||
|
||||
export interface SessionState { |
||||
readonly fpUsed: number |
||||
readonly upUsed: number |
||||
} |
||||
|
||||
export interface ConflictState { |
||||
readonly round: number|null |
||||
readonly activeSideIsAllies: boolean |
||||
readonly activeCharacterId: string|null |
||||
readonly timeoutAt: number|null |
||||
} |
||||
|
||||
export interface GameState { |
||||
readonly session: SessionState |
||||
readonly conflict?: ConflictState |
||||
readonly clocks: readonly Clock[] |
||||
readonly characters: readonly Character[] |
||||
// TODO: add "status definitions" and have character statuses reference them
|
||||
} |
||||
|
||||
export function getCharacterById(state: GameState, id: CharacterId): Character|undefined { |
||||
return state.characters.find((character) => character.id === id) |
||||
} |
@ -0,0 +1,45 @@ |
||||
import React, {useEffect, useState} from 'react'; |
||||
import './App.css'; |
||||
import {Container} from "react-bootstrap"; |
||||
import {Character, CharacterStatus} from "./CharacterStatus"; |
||||
|
||||
export interface PastState { |
||||
// This is the timestamp of the change that exited this state.
|
||||
readonly timestampMs: number |
||||
// This is the list of actions that changed this state.
|
||||
|
||||
} |
||||
|
||||
|
||||
function useJson<T>(url: string): T | null { |
||||
const [data, setData] = useState<T|null>(null); |
||||
useEffect(() => { |
||||
let ignore = false; |
||||
fetch(url) |
||||
.then(response => response.json() as T) |
||||
.then(json => { |
||||
if (!ignore) { |
||||
setData(json); |
||||
} |
||||
}); |
||||
return () => { |
||||
ignore = true; |
||||
}; |
||||
}, [url]); |
||||
return data as T|null; |
||||
} |
||||
|
||||
function App() { |
||||
const state = useJson<SavedState>("/api/current.json") |
||||
return <React.Fragment> |
||||
<Container fluid> |
||||
{state && state.characters.map((character) => |
||||
<CharacterStatus |
||||
key={character.id} |
||||
character={character} |
||||
active={character.id === state.conflict?.activeCharacterId} />)} |
||||
</Container> |
||||
</React.Fragment>; |
||||
} |
||||
|
||||
export default App; |
@ -0,0 +1,17 @@ |
||||
import {Interpolation, InterpolatorArgs, InterpolatorFn, Arrify} from "@react-spring/web"; |
||||
import {getAnimated} from "@react-spring/animated"; |
||||
|
||||
export class FixedInterpolation<Input = any, Output = any> extends Interpolation<Input, Output> { |
||||
constructor(readonly source: unknown, args: InterpolatorArgs<Input, Output>) { |
||||
super(source, args); |
||||
getAnimated(this)!.setValue(this._get()) |
||||
} |
||||
} |
||||
|
||||
export function altTo<Input = any, Output = any>(source: unknown, args: InterpolatorArgs<Input, Output>|InterpolatorFn<Arrify<Input>, Output>): Interpolation<Input, Output> { |
||||
if (Array.isArray(args)) { |
||||
return new FixedInterpolation(source, args) |
||||
} else { |
||||
return new FixedInterpolation(source, [args]) |
||||
} |
||||
} |
Before Width: | Height: | Size: 312 KiB After Width: | Height: | Size: 312 KiB |
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.7 KiB |
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |