diff --git a/src/character-tracker.ts b/src/character-tracker.ts index 1d390b5..7fc9edd 100644 --- a/src/character-tracker.ts +++ b/src/character-tracker.ts @@ -29,8 +29,6 @@ export class MissingCharacterError extends CharacterError {} export class MissingCampaignError extends CharacterError {} -export class InvalidCharacterError extends CharacterError {} - export class CharacterIndexer extends BaseIndexer< CharacterContext, z.ZodError diff --git a/src/characters/action-context.ts b/src/characters/action-context.ts index 803353a..ece1b25 100644 --- a/src/characters/action-context.ts +++ b/src/characters/action-context.ts @@ -5,7 +5,7 @@ import { StandardIndex } from "datastore/data-indexer"; import { DataswornTypes } from "datastore/datasworn-indexer"; import { moveOrigin } from "datastore/datasworn-symbols"; import { produce } from "immer"; -import { App, MarkdownFileInfo } from "obsidian"; +import { App, MarkdownFileInfo, Notice } from "obsidian"; import { OracleRoller } from "oracles/roller"; import { ConditionMeterDefinition, Ruleset } from "rules/ruleset"; import { vaultProcess } from "utils/obsidian"; @@ -27,6 +27,7 @@ import { import { type Datastore } from "../datastore"; import IronVaultPlugin from "../index"; import { InfoModal } from "../utils/ui/info"; +import { InvalidCharacterError } from "./errors"; export interface IActionContext extends IDataContext { readonly campaignContext: CampaignDataContext; @@ -142,23 +143,31 @@ export class CharacterActionContext implements IActionContext { get moves(): StandardIndex { if (!this.#moves) { - // TODO(@cwegrzyn): we should let the user know if they have a missing move, I think - const characterMoves = movesReader(this.characterContext.lens, this) - .get(this.characterContext.character) - .expect("unexpected failure finding assets for moves"); - - this.#moves = this.campaignContext.moves.projected((move) => { - if (move[moveOrigin].assetId == null) return move; - const assetMove = characterMoves.find( - ({ move: characterMove }) => move._id === characterMove._id, - ); - if (assetMove) { - return produce(move, (draft) => { - draft.name = `${assetMove.asset.name}: ${draft.name}`; - }); + try { + const characterMoves = movesReader( + this.characterContext.lens, + this, + ).get(this.characterContext.character); + + this.#moves = this.campaignContext.moves.projected((move) => { + if (move[moveOrigin].assetId == null) return move; + const assetMove = characterMoves.find( + ({ move: characterMove }) => move._id === characterMove._id, + ); + if (assetMove) { + return produce(move, (draft) => { + draft.name = `${assetMove.asset.name}: ${draft.name}`; + }); + } + return undefined; + }); + } catch (err) { + if (err instanceof InvalidCharacterError) { + console.error(err); + new Notice(`Invalid character definition: ${err.message}`, 0); } - return undefined; - }); + throw err; + } } return this.#moves; } diff --git a/src/characters/assets.test.ts b/src/characters/assets.test.ts index f8b9f03..17d8a35 100644 --- a/src/characters/assets.test.ts +++ b/src/characters/assets.test.ts @@ -280,25 +280,6 @@ describe("integratedAssetLens", () => { ).toHaveProperty("controls.integrity.controls.battered.value", true); }); - it("integrates meter subfield values", () => { - expect( - integratedAssetLens(dataContext).get({ - id: starship()._id, - abilities: [true, false, false], - options: {}, - controls: {}, - }), - ).toHaveProperty("controls.integrity.controls.battered.value", false); - expect( - integratedAssetLens(dataContext).get({ - id: starship()._id, - abilities: [true, false, false], - options: {}, - controls: { "integrity/battered": true }, - }), - ).toHaveProperty("controls.integrity.controls.battered.value", true); - }); - it("integrates ability values", () => { expect( integratedAssetLens(dataContext).get({ diff --git a/src/characters/assets.ts b/src/characters/assets.ts index d51294f..2288d94 100644 --- a/src/characters/assets.ts +++ b/src/characters/assets.ts @@ -1,41 +1,39 @@ import { type Datasworn } from "@datasworn/core"; import { IDataContext } from "datastore/data-context"; import { produce } from "immer"; +import merge from "lodash.merge"; import { ConditionMeterDefinition } from "rules/ruleset"; -import { Either, Left, Right } from "../utils/either"; -import { Lens, addOrUpdateMatching, reader, writer } from "../utils/lens"; +import { Lens, addOrUpdateMatching, writer } from "../utils/lens"; +import { MissingAssetError } from "./errors"; import { CharLens, - CharReader, CharWriter, CharacterLens, IronVaultSheetAssetSchema, } from "./lens"; -export class AssetError extends Error {} - -export function assetWithDefnReader( - charLens: CharacterLens, - dataContext: IDataContext, -): CharReader< - Array< - Either< - AssetError, - { asset: IronVaultSheetAssetSchema; defn: Datasworn.Asset } - > - > -> { - return reader((source) => { - return charLens.assets.get(source).map((asset) => { - const defn = dataContext.assets.get(asset.id); - if (defn) { - return Right.create({ asset, defn }); - } else { - return Left.create(new AssetError(`missing asset with id ${asset.id}`)); - } - }); - }); -} +// export function assetWithDefnReader( +// charLens: CharacterLens, +// dataContext: IDataContext, +// ): CharReader< +// Array< +// Either< +// AssetError, +// { asset: IronVaultSheetAssetSchema; defn: Datasworn.Asset } +// > +// > +// > { +// return reader((source) => { +// return charLens.assets.get(source).map((asset) => { +// const defn = dataContext.assets.get(asset.id); +// if (defn) { +// return Right.create({ asset, defn }); +// } else { +// return Left.create(new AssetError(`missing asset with id ${asset.id}`)); +// } +// }); +// }); +// } export type AssetWalker = { onAnyOption?: ( @@ -169,12 +167,11 @@ export function addOrUpdateViaDataswornAsset( }); } -export class MissingAssetError extends Error {} - function assetKey(key: string, parentKey: string | number | undefined): string { return parentKey != null ? `${parentKey}/${key}` : key; } +/** A lens that takes a character asset choice and produces an asset w/ choices merged in. */ export function integratedAssetLens( dataContext: IDataContext, ): Lens { @@ -182,13 +179,19 @@ export function integratedAssetLens( get(assetData) { const dataswornAsset = dataContext.assets.get(assetData.id); if (!dataswornAsset) { - throw new AssetError(`unable to find asset ${assetData.id}`); + throw new MissingAssetError(`unable to find asset ${assetData.id}`); } return produce(dataswornAsset, (draft) => { assetData.abilities.forEach((enabled, index) => { - if (enabled) { + if (enabled != null) { draft.abilities[index].enabled = enabled; } + if ( + draft.abilities[index].enabled && + draft.abilities[index].enhance_asset + ) { + draft = merge(draft, draft.abilities[index].enhance_asset); + } }); walkAsset(draft, { onAnyOption(key, option, parentKey) { @@ -230,7 +233,6 @@ export function integratedAssetLens( export function assetMeters( charLens: CharacterLens, asset: Datasworn.Asset, - markedAbilities: boolean[], ): { key: string; definition: ConditionMeterDefinition; @@ -246,7 +248,7 @@ export function assetMeters( } }, }, - markedAbilities, + asset.abilities.map(({ enabled }) => enabled), ); return meters.map(([key, control]) => { diff --git a/src/characters/errors.ts b/src/characters/errors.ts new file mode 100644 index 0000000..edc05a9 --- /dev/null +++ b/src/characters/errors.ts @@ -0,0 +1,4 @@ +/** Base class for errors indicating an invalid character. */ + +export class InvalidCharacterError extends Error {} +export class MissingAssetError extends InvalidCharacterError {} diff --git a/src/characters/lens.test.ts b/src/characters/lens.test.ts index 85dd0b8..41f4766 100644 --- a/src/characters/lens.test.ts +++ b/src/characters/lens.test.ts @@ -3,7 +3,6 @@ import starforgedData from "@datasworn/starforged/json/starforged.json" with { t import { IDataContext, MockDataContext } from "datastore/data-context"; import { Ruleset } from "../rules/ruleset"; import { ChallengeRanks } from "../tracks/progress"; -import { Right } from "../utils/either"; import { Lens, updating } from "../utils/lens"; import { BaseIronVaultSchema, @@ -283,7 +282,7 @@ describe("movesReader", () => { movesReader(lens, mockDataContext).get( validater({ ...VALID_INPUT }).unwrap(), ), - ).toEqual(Right.create([])); + ).toEqual([]); }); it("does not include moves for unmarked asset abilities", () => { @@ -299,42 +298,38 @@ describe("movesReader", () => { ], } satisfies BaseIronVaultSchema).unwrap(), ), - ).toEqual(Right.create([])); + ).toEqual([]); }); it("includes moves for marked asset abilities", () => { // This ability has no additional moves. expect( - movesReader(lens, mockDataContext) - .get( - validater({ - ...VALID_INPUT, - assets: [ - { - id: "asset:starforged/path/empath", - abilities: [false, true, false], - }, - ], - } satisfies BaseIronVaultSchema).unwrap(), - ) - .unwrap(), + movesReader(lens, mockDataContext).get( + validater({ + ...VALID_INPUT, + assets: [ + { + id: "asset:starforged/path/empath", + abilities: [false, true, false], + }, + ], + } satisfies BaseIronVaultSchema).unwrap(), + ), ).toHaveLength(0); // This ability adds one extra move. expect( - movesReader(lens, mockDataContext) - .get( - validater({ - ...VALID_INPUT, - assets: [ - { - id: "asset:starforged/path/empath", - abilities: [true, true, false], - }, - ], - } satisfies BaseIronVaultSchema).unwrap(), - ) - .unwrap(), + movesReader(lens, mockDataContext).get( + validater({ + ...VALID_INPUT, + assets: [ + { + id: "asset:starforged/path/empath", + abilities: [true, true, false], + }, + ], + } satisfies BaseIronVaultSchema).unwrap(), + ), ).toMatchObject([ { move: { @@ -354,6 +349,8 @@ describe("meterLenses", () => { mockDataContext = createMockDataContext( starforgedData.assets.companion.contents .protocol_bot as unknown as Datasworn.Asset, + starforgedData.assets.companion.contents + .symbiote as unknown as Datasworn.Asset, ); }); @@ -398,6 +395,48 @@ describe("meterLenses", () => { expect(meters.length).toBe(new Set(meters.map(({ key }) => key)).size); }); + + it("updates meters according to enhance_assets", () => { + const character1 = validater({ + ...VALID_INPUT, + assets: [ + { + id: "asset:starforged/companion/symbiote", + abilities: [true, false, false], + }, + ], + }).expect("valid character"); + const result1 = meterLenses(lens, character1, mockDataContext); + expect(result1).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "asset:starforged/companion/symbiote@health", + parent: { label: "Symbiote" }, + definition: expect.objectContaining({ max: 2 }), + }), + ]), + ); + + const character2 = validater({ + ...VALID_INPUT, + assets: [ + { + id: "asset:starforged/companion/symbiote", + abilities: [true, false, true], + }, + ], + }).expect("valid character"); + const result2 = meterLenses(lens, character2, mockDataContext); + expect(result2).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "asset:starforged/companion/symbiote@health", + parent: { label: "Symbiote" }, + definition: expect.objectContaining({ max: 3 }), + }), + ]), + ); + }); }); describe("Special Tracks", () => { @@ -405,6 +444,7 @@ describe("Special Tracks", () => { TEST_RULESET.merge({ _id: "moar", type: "expansion", + ruleset: "test", rules: { special_tracks: { quests_legacy: { diff --git a/src/characters/lens.ts b/src/characters/lens.ts index faa0df7..e8f7421 100644 --- a/src/characters/lens.ts +++ b/src/characters/lens.ts @@ -14,17 +14,21 @@ import { ProgressTrack, legacyTrackXpEarned, } from "../tracks/progress"; -import { Either, collectEither } from "../utils/either"; +import { Either } from "../utils/either"; import { Lens, Reader, Writer, + arrayReader, + get, lensForSchemaProp, objectMap, + pipe, reader, updating, } from "../utils/lens"; -import { AssetError, assetMeters, assetWithDefnReader } from "./assets"; +import { assetMeters, integratedAssetLens } from "./assets"; +import { InvalidCharacterError } from "./errors"; const ValidationTag: unique symbol = Symbol("validated ruleset"); @@ -151,7 +155,7 @@ export function validated( return (lens: Lens): Lens => ({ get(a) { if (a[ValidationTag] !== ruleset.validationTag) { - throw new Error( + throw new InvalidCharacterError( `expecting validation tag of ${ruleset.validationTag}; found ${a[ValidationTag]}`, ); } @@ -160,7 +164,7 @@ export function validated( }, update(a, b) { if (a[ValidationTag] !== ruleset.validationTag) { - throw new Error( + throw new InvalidCharacterError( `expecting validation tag of ${ruleset.validationTag}; found ${a[ValidationTag]}`, ); } @@ -197,7 +201,7 @@ function createImpactLens( const updates: [string, boolean][] = []; for (const key in newval) { if (!(key in impactProps)) { - throw new Error(`unexpected key in impacts: ${key}`); + throw new InvalidCharacterError(`unexpected key in impacts: ${key}`); } } for (const key in impactProps) { @@ -308,23 +312,17 @@ export function countMarked(impacts: Record): number { export function movesReader( charLens: CharacterLens, dataContext: IDataContext, -): CharReader< - Either< - AssetError[], - { move: Datasworn.EmbeddedMove; asset: Datasworn.Asset }[] - > -> { - const assetReader = assetWithDefnReader(charLens, dataContext); +): CharReader<{ move: Datasworn.EmbeddedMove; asset: Datasworn.Asset }[]> { + const assetLens = integratedAssetLens(dataContext); return reader((source) => { - return collectEither(assetReader.get(source)).map((assets) => - assets.flatMap(({ asset: assetConfig, defn }) => - defn.abilities + return get(pipe(charLens.assets, arrayReader(assetLens)), source).flatMap( + (asset) => + asset.abilities // Take only enabled abilities - .filter((_ability, index) => assetConfig.abilities[index]) + .filter((ability) => ability.enabled) // Gather moves .flatMap((ability) => Object.values(ability.moves ?? {})) - .map((move) => ({ move, asset: defn })), - ), + .map((move) => ({ move, asset })), ); }); } @@ -380,18 +378,11 @@ export function meterLenses( value: charLens.momentum.get(character), }); + const assetLens = integratedAssetLens(dataContext); meters.push( - ...assetWithDefnReader(charLens, dataContext) - .get(character) - .flatMap((assetResult) => { - if (assetResult.isLeft()) { - // TODO: should we handle this error differently? pass it up? - console.warn("Missing asset: %o", assetResult.error); - return []; - } else { - const { asset, defn } = assetResult.value; - return assetMeters(charLens, defn, asset.abilities); - } + ...get(pipe(charLens.assets, arrayReader(assetLens)), character) + .flatMap((asset) => { + return assetMeters(charLens, asset); }) .map((meter) => ({ ...meter, value: meter.lens.get(character) })), ); diff --git a/src/utils/lens.ts b/src/utils/lens.ts index d8e156c..36f7c26 100644 --- a/src/utils/lens.ts +++ b/src/utils/lens.ts @@ -118,3 +118,30 @@ export function composeRightWriter( return leftLens.update(source, rightLens.update(intermediate, newval)); }); } + +/** Apply a reader to every element of an array. */ +export function arrayReader( + lens: Reader, +): Reader, Array> { + return { + get(source) { + return source.map((item) => lens.get(item)); + }, + }; +} + +/** Read from a lens. */ +export function get(reader: Reader, source: T): U { + return reader.get(source); +} + +export function pipe( + left: Reader, + right: Reader, +): Reader { + return { + get(source) { + return right.get(left.get(source)); + }, + }; +} diff --git a/test-vault/CampaignSI/Characters/Jack Sparrow.md b/test-vault/CampaignSI/Characters/Jack Sparrow.md new file mode 100644 index 0000000..cdfb07b --- /dev/null +++ b/test-vault/CampaignSI/Characters/Jack Sparrow.md @@ -0,0 +1,50 @@ +--- +name: Jack Sparrow +xp_spent: 0 +xp_added: 0 +momentum: 2 +edge: 0 +heart: 0 +iron: 0 +shadow: 0 +wits: 0 +health: 5 +spirit: 5 +supply: 5 +Quests_Progress: 0 +Quests_XPEarned: 0 +Bonds_Progress: 0 +Bonds_XPEarned: 0 +Discoveries_Progress: 0 +Discoveries_XPEarned: 0 +iron-vault-kind: character +assets: + - id: asset:sundered_isles/path/crew_commander + abilities: + - true + - true + - false + controls: + command: 5 + options: {} +--- + + +```iron-vault-character-info +``` + +```iron-vault-character-stats +``` + +```iron-vault-character-meters +``` + +```iron-vault-character-special-tracks +``` + +```iron-vault-character-impacts +``` + +```iron-vault-character-assets +``` +