@ -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 |