diff --git a/data/starforged.supplement.json b/data/starforged.supplement.json index d0ed1b2e..48210da6 100644 --- a/data/starforged.supplement.json +++ b/data/starforged.supplement.json @@ -4,7 +4,7 @@ "datasworn_version": "0.1.0", "ruleset": "starforged", "title": "Iron Vault support for Ironsworn: Starforged", - "description": "Collection of utility oracles for use with Starforged", + "description": "Collection of utility oracles and assets for use with Starforged", "authors": [ { "name": "Iron Vault Dev Team" diff --git a/data/sundered-isles.supplement.json b/data/sundered-isles.supplement.json index d22f185c..87bc09fc 100644 --- a/data/sundered-isles.supplement.json +++ b/data/sundered-isles.supplement.json @@ -12,7 +12,7 @@ "license": "MIT", "url": "https://ironvault.quest", "datasworn_version": "0.1.0", - "ruleset": "sundered_isles", + "ruleset": "starforged", "oracles": { "templates": { "_id": "oracle_collection:sundered_isles_supp/templates", diff --git a/data/sundered-isles.supplement.yaml b/data/sundered-isles.supplement.yaml index 79a8c444..737b541d 100644 --- a/data/sundered-isles.supplement.yaml +++ b/data/sundered-isles.supplement.yaml @@ -9,7 +9,7 @@ date: "2024-07-16" license: "MIT" url: "https://ironvault.quest" datasworn_version: 0.1.0 -ruleset: sundered_isles +ruleset: starforged oracles: templates: _id: oracle_collection:sundered_isles_supp/templates diff --git a/package.json b/package.json index 02699ed7..0c265369 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "@types/electron": "npm:@ophidian/electron-types@^24.3.1", "@types/jest": "^29.5.12", "@types/js-yaml": "^4.0.9", + "@types/lodash.ismatch": "^4.4.9", "@types/lodash.merge": "^4.6.9", "@types/node": "^20.12.12", "@types/obsidian-typings": "npm:obsidian-typings@^1.1.6", @@ -61,6 +62,7 @@ "js-yaml": "^4.1.0", "kdljs": "^0.2.0", "lit-html": "^3.1.3", + "lodash.ismatch": "^4.4.0", "lodash.merge": "^4.6.2", "loglevel": "^1.9.1", "minisearch": "^6.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1cb8438..db42d8cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 + '@types/lodash.ismatch': + specifier: ^4.4.9 + version: 4.4.9 '@types/lodash.merge': specifier: ^4.6.9 version: 4.6.9 @@ -116,6 +119,9 @@ importers: lit-html: specifier: ^3.1.3 version: 3.1.4 + lodash.ismatch: + specifier: ^4.4.0 + version: 4.4.0 lodash.merge: specifier: ^4.6.2 version: 4.6.2 @@ -819,6 +825,9 @@ packages: '@types/jsdom@20.0.1': resolution: {integrity: sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==} + '@types/lodash.ismatch@4.4.9': + resolution: {integrity: sha512-qWihnStOPKH8urljLGm6ZOEdN/5Bt4vxKR81tL3L4ArUNLvcf9RW3QSnPs21eix5BiqioSWq4aAXD4Iep+d0fw==} + '@types/lodash.merge@4.6.9': resolution: {integrity: sha512-23sHDPmzd59kUgWyKGiOMO2Qb9YtqRO/x4IhkgNUiPQ1+5MUVqi6bCZeq9nBJ17msjIMbEIO5u+XW4Kz6aGUhQ==} @@ -1786,6 +1795,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.ismatch@4.4.0: + resolution: {integrity: sha512-fPMfXjGQEV9Xsq/8MTSgUf255gawYRbjwMyDbcvDhXgV7enSZA0hynz6vMPnpAb5iONEzBHBPsT+0zes5Z301g==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} @@ -3084,6 +3096,10 @@ snapshots: '@types/tough-cookie': 4.0.5 parse5: 7.1.2 + '@types/lodash.ismatch@4.4.9': + dependencies: + '@types/lodash': 4.17.7 + '@types/lodash.merge@4.6.9': dependencies: '@types/lodash': 4.17.7 @@ -4336,6 +4352,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.ismatch@4.4.0: {} + lodash.merge@4.6.2: {} lodash@4.17.21: {} diff --git a/src/api.ts b/src/api.ts index 6a3ac0af..c4a01502 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,7 +1,10 @@ import { syntaxTree } from "@codemirror/language"; +import { CampaignDataContext } from "campaigns/context"; +import { IDataContext } from "datastore/data-context"; import { rootLogger, setLogLevel } from "logger"; import loglevel from "loglevel"; import { App, getLinkpath, parseLinktext } from "obsidian"; +import { OracleRoller } from "oracles/roller"; import { ProgressIndex } from "tracks/indexer"; import { CharacterTracker } from "./character-tracker"; import { Datastore } from "./datastore"; @@ -27,8 +30,24 @@ export class IronVaultAPI { return this.plugin.progressTracks; } + get globalDataContext(): IDataContext { + return this.plugin.datastore.dataContext; + } + + /** Active campaign context */ + get activeCampaignContext(): CampaignDataContext | undefined { + return this.plugin.campaignManager.lastActiveCampaignContext(); + } + + /** A campaign data context if available, otherwise global. */ + get activeDataContext(): IDataContext { + return this.activeCampaignContext ?? this.globalDataContext; + } + public async roll(oracle: string): Promise { - return this.datastore.roller.roll(oracle); + return new OracleRoller(this.plugin, this.activeDataContext.oracles).roll( + oracle, + ); } public stripLinks(input: string): string { diff --git a/src/assets/asset-block.ts b/src/assets/asset-block.ts index 3ed6a8f5..add7b593 100644 --- a/src/assets/asset-block.ts +++ b/src/assets/asset-block.ts @@ -1,61 +1,73 @@ -import { Asset } from "@datasworn/core/dist/Datasworn"; import { html, render } from "lit-html"; +import { CampaignDataContext } from "campaigns/context"; import IronVaultPlugin from "index"; +import { MarkdownRenderChild } from "obsidian"; +import { FileBasedCampaignWatch } from "sidebar/sidebar-block"; import renderAssetCard from "./asset-card"; export default function registerAssetBlock(plugin: IronVaultPlugin) { plugin.registerMarkdownCodeBlockProcessor( "iron-vault-asset", - async (source, el: AssetBlockContainerEl, _ctx) => { - // We can't render blocks until datastore is ready. - await plugin.datastore.waitForReady; - if (!el.assetRenderer) { - const asset = AssetBlockRenderer.getAsset(plugin, source); - if (!asset) { - render(html`

No such asset: ${source}

`, el); - return; - } - el.assetRenderer = new AssetBlockRenderer(el, plugin, asset); - } - await el.assetRenderer.render(); + (source, el: HTMLElement, ctx) => { + ctx.addChild(new AssetBlockRenderer(el, plugin, source, ctx.sourcePath)); }, ); } -interface AssetBlockContainerEl extends HTMLElement { - assetRenderer?: AssetBlockRenderer; -} - -class AssetBlockRenderer { - contentEl: HTMLElement; - plugin: IronVaultPlugin; - asset: Asset; +class AssetBlockRenderer extends MarkdownRenderChild { + campaignSource: FileBasedCampaignWatch; - constructor(contentEl: HTMLElement, plugin: IronVaultPlugin, asset: Asset) { - this.contentEl = contentEl; - this.plugin = plugin; - this.asset = asset; + constructor( + contentEl: HTMLElement, + readonly plugin: IronVaultPlugin, + readonly source: string, + sourcePath: string, + ) { + super(contentEl); + this.campaignSource = this.addChild( + new FileBasedCampaignWatch( + plugin.app.vault, + plugin.campaignManager, + sourcePath, + ).onUpdate(() => this.render()), + ); } - static getAsset(plugin: IronVaultPlugin, source: string) { - const trimmed = source.trim().toLowerCase(); + getAsset(dataContext: CampaignDataContext) { + const trimmed = this.source.trim().toLowerCase(); return ( - plugin.datastore.assets.get(trimmed) || - [...plugin.datastore.assets.values()].find( + dataContext.assets.get(trimmed) || + [...dataContext.assets.values()].find( (a) => a.name.toLowerCase() === trimmed, ) ); } + async render() { + const dataContext = this.campaignSource.campaignContext; + if (!dataContext) { + render( + html`
+ Asset block may only be used within a campaign folder. +
`, + this.containerEl, + ); + return; + } + const asset = this.getAsset(dataContext); + if (!asset) { + render(html`

No such asset: ${this.source}

`, this.containerEl); + return; + } render( - renderAssetCard(this.plugin, { - id: this.asset._id, + renderAssetCard(this.plugin, dataContext, { + id: asset._id, abilities: [true, false, false], options: {}, controls: {}, }), - this.contentEl, + this.containerEl, ); } } diff --git a/src/assets/asset-card.ts b/src/assets/asset-card.ts index 2398a637..4bb448bb 100644 --- a/src/assets/asset-card.ts +++ b/src/assets/asset-card.ts @@ -10,6 +10,7 @@ import { TemplateResult, html } from "lit-html"; import { map } from "lit-html/directives/map.js"; import { range } from "lit-html/directives/range.js"; +import { IDataContext } from "datastore/data-context"; import { produce } from "immer"; import IronVaultPlugin from "index"; import { repeat } from "lit-html/directives/repeat.js"; @@ -36,12 +37,13 @@ export function makeDefaultSheetAsset(asset: Asset) { export default function renderAssetCard( plugin: IronVaultPlugin, + dataContext: IDataContext, sheetAsset: IronVaultSheetAssetSchema, updateAsset?: (asset: Asset) => void, ) { let asset; try { - asset = integratedAssetLens(plugin.datastore).get(sheetAsset); + asset = integratedAssetLens(dataContext).get(sheetAsset); } catch (e) { // @ts-expect-error it's just an error. Let it crash if there's no message. return html`
Error: ${e.message}`; diff --git a/src/assets/asset-modal.ts b/src/assets/asset-modal.ts index 45680d21..e189aed3 100644 --- a/src/assets/asset-modal.ts +++ b/src/assets/asset-modal.ts @@ -2,18 +2,19 @@ import { html, render } from "lit-html"; import { App, Modal } from "obsidian"; import { Asset } from "@datasworn/core/dist/Datasworn"; +import { addAssetToCharacter } from "characters/commands"; +import { IDataContext } from "datastore/data-context"; import IronVaultPlugin from "index"; import renderAssetCard, { makeDefaultSheetAsset } from "./asset-card"; -import { addAssetToCharacter } from "characters/commands"; export class AssetModal extends Modal { - plugin: IronVaultPlugin; - asset: Asset; - - constructor(app: App, plugin: IronVaultPlugin, asset: Asset) { + constructor( + app: App, + readonly plugin: IronVaultPlugin, + readonly dataContext: IDataContext, + readonly asset: Asset, + ) { super(app); - this.plugin = plugin; - this.asset = asset; } openAsset(asset: Asset) { @@ -24,7 +25,11 @@ export class AssetModal extends Modal { contentEl.toggleClass("iron-vault-modal", true); render( html` - ${renderAssetCard(this.plugin, makeDefaultSheetAsset(asset))} + ${renderAssetCard( + this.plugin, + this.dataContext, + makeDefaultSheetAsset(asset), + )} ${renderAssetCard( this.plugin, + this.actionContext, makeDefaultSheetAsset(asset), )} @@ -155,10 +156,10 @@ export class AssetPickerModal extends Modal { > = {}; const results = filter ? this.searchIdx.search(filter) - : [...this.plugin.datastore.assets.values()].map((m) => ({ id: m._id })); + : [...this.actionContext.assets.values()].map((m) => ({ id: m._id })); let total = 0; for (const res of results) { - const asset = this.plugin.datastore.assets.get(res.id)!; + const asset = this.actionContext.assets.get(res.id)!; if (!asset) { console.error("couldn't find asset for", res); continue; @@ -192,8 +193,7 @@ export class AssetPickerModal extends Modal { boost: { name: 2 }, }, }); - // TODO: use the current context - idx.addAll([...this.plugin.datastore.assets.values()]); + idx.addAll([...this.actionContext.assets.values()]); return idx; } } diff --git a/src/campaigns/commands.ts b/src/campaigns/commands.ts index bd77efd9..da6314bb 100644 --- a/src/campaigns/commands.ts +++ b/src/campaigns/commands.ts @@ -1,4 +1,6 @@ +import { createNewCharacter } from "characters/commands"; import IronVaultPlugin from "index"; +import { generateTruthsForCampaign } from "truths/command"; import { createNewIronVaultEntityFile, getExistingOrNewFolder, @@ -6,8 +8,6 @@ import { import { IronVaultKind } from "../constants"; import { CampaignFile } from "./entity"; import { NewCampaignModal } from "./ui/new-campaign-modal"; -import { createNewCharacter } from "characters/commands"; -import { generateTruthsCommand } from "truths/command"; /** Obsidian command to create a new campaign. */ export async function createNewCampaignCommand(plugin: IronVaultPlugin) { @@ -17,18 +17,23 @@ export async function createNewCampaignCommand(plugin: IronVaultPlugin) { campaignInfo.folder, campaignInfo.campaignName, IronVaultKind.Campaign, - CampaignFile.generate({ name: campaignInfo.campaignName }), + CampaignFile.generate({ + name: campaignInfo.campaignName, + ironvault: { + playset: + campaignInfo.playsetOption == "custom" + ? { + type: "globs", + lines: campaignInfo.customPlaysetDefn.split(/\r\n?|\n/g), + } + : { type: "registry", key: campaignInfo.playsetOption }, + }, + }), undefined, `Welcome to your new campaign! This is a campaign index file, which marks its folder as a campaign. Any journals or game entities inside this folder will use this campaign for any mechanics or commands. You can replace all this text with any details or notes you have about your campaign. As long as the file properties remain the same, you don't have to worry about the contents of this file.\n`, ); if (campaignInfo.scaffold) { - await plugin.app.workspace.getLeaf(false).openFile(file); - - plugin.campaignManager.resetActiveCampaign(); - - await generateTruthsCommand(plugin, campaignInfo.folder, "Truths.md"); - if (plugin.settings.defaultCharactersFolder) { await getExistingOrNewFolder( plugin.app, @@ -65,8 +70,22 @@ export async function createNewCampaignCommand(plugin: IronVaultPlugin) { await getExistingOrNewFolder(plugin.app, campaignInfo.folder + "/Factions"); await getExistingOrNewFolder(plugin.app, campaignInfo.folder + "/Lore"); + await plugin.app.workspace.getLeaf(false).openFile(file); + + const campaign = await plugin.campaignManager.awaitCampaignAvailability( + file.path, + ); + + const campaignContext = plugin.campaignManager.campaignContextFor(campaign); + await generateTruthsForCampaign( + plugin, + campaignContext, + campaignInfo.folder, + "Truths.md", + ); + try { - await createNewCharacter(plugin); + await createNewCharacter(plugin, campaignContext); } catch (e) { if (e == null) { // modal got closed. Let's just skip character creation and move on... diff --git a/src/campaigns/context.ts b/src/campaigns/context.ts index 29c145bb..0d140f2b 100644 --- a/src/campaigns/context.ts +++ b/src/campaigns/context.ts @@ -1,31 +1,130 @@ import { CharacterContext } from "character-tracker"; import { ClockFileAdapter } from "clocks/clock-file"; +import { BaseDataContext, ICompleteDataContext } from "datastore/data-context"; +import { + DataIndexer, + SourcedByArray, + SourcedKindsArray, +} from "datastore/data-indexer"; +import { DataswornIndexer, DataswornTypes } from "datastore/datasworn-indexer"; +import { scopeTags } from "datastore/datasworn-symbols"; +import IronVaultPlugin from "index"; import { ReadonlyIndex } from "indexer/index-interface"; +import { OracleRoller } from "oracles/roller"; +import { Ruleset } from "rules/ruleset"; import { TrackedEntities } from "te/index-interface"; import { ProgressTrackFileAdapter } from "tracks/progress"; +import { + AsyncDiceRoller, + DiceRoller, + GraphicalDiceRoller, + PlainDiceRoller, +} from "utils/dice-roller"; import { projectedVersionedMap } from "utils/versioned-map"; import { ZodError } from "zod"; import { CampaignFile } from "./entity"; +import { Determination, IPlaysetConfig } from "./playsets/config"; + +export class CampaignDataContext + implements TrackedEntities, ICompleteDataContext +{ + readonly dataContext: PlaysetAwareDataContext; + readonly oracleRoller: OracleRoller; -export class CampaignTrackedEntities implements TrackedEntities { constructor( - private readonly base: TrackedEntities, + // TODO(@cwegrzyn): Once we have a campaign settings object (which must overlay the + // overall plugin settings somehow), we can replace this plugin call. + private readonly plugin: IronVaultPlugin, + base: TrackedEntities, + indexer: DataIndexer, public readonly campaign: CampaignFile, resolver: (path: string) => boolean, ) { const projection = (value: T, key: string): T | undefined => resolver(key) ? value : undefined; - this.campaigns = projectedVersionedMap(this.base.campaigns, projection); - this.characters = projectedVersionedMap(this.base.characters, projection); - this.clocks = projectedVersionedMap(this.base.clocks, projection); + this.campaigns = projectedVersionedMap(base.campaigns, projection); + this.characters = projectedVersionedMap(base.characters, projection); + this.clocks = projectedVersionedMap(base.clocks, projection); this.progressTracks = projectedVersionedMap( - this.base.progressTracks, + base.progressTracks, projection, ); + + this.dataContext = new PlaysetAwareDataContext(indexer, campaign.playset); + this.oracleRoller = new OracleRoller(plugin, this.oracles); } campaigns: ReadonlyIndex; characters: ReadonlyIndex; clocks: ReadonlyIndex; progressTracks: ReadonlyIndex; + + get moves() { + return this.dataContext.moves; + } + + get assets() { + return this.dataContext.assets; + } + + get moveCategories() { + return this.dataContext.moveCategories; + } + + get oracles() { + return this.dataContext.oracles; + } + + get truths() { + return this.dataContext.truths; + } + + get rulesPackages() { + return this.dataContext.rulesPackages; + } + + get ruleset(): Ruleset { + return this.dataContext.ruleset; + } + + get prioritized() { + return this.dataContext.prioritized; + } + + diceRollerFor(kind: "move"): AsyncDiceRoller & DiceRoller { + switch (kind) { + case "move": + return this.plugin.settings.graphicalActionDice + ? new GraphicalDiceRoller(this.plugin) + : PlainDiceRoller.INSTANCE; + default: + throw new Error(`unexpected kind ${kind}`); + } + } +} + +export class PlaysetAwareDataContext extends BaseDataContext { + constructor(base: DataswornIndexer, playsetConfig: IPlaysetConfig) { + super( + base.projected>( + ( + val: SourcedKindsArray[K], + _key: string, + ) => { + // NOTE: we look at the source id here instead of the key, in case things are indexed elsewhere + const filtered = val.filter( + (sourced) => + // TODO(@cwegrzyn): maybe I should be more open ended with the type on determine's obj? + // eslint-disable-next-line @typescript-eslint/no-explicit-any + playsetConfig.determine(sourced.id, { + tags: sourced.value[scopeTags], + }) === Determination.Include, + ); + return filtered.length > 0 + ? (filtered as SourcedByArray) + : undefined; + }, + ), + ); + } } diff --git a/src/campaigns/css/new-campaign.css b/src/campaigns/css/new-campaign.css new file mode 100644 index 00000000..f078ac73 --- /dev/null +++ b/src/campaigns/css/new-campaign.css @@ -0,0 +1,13 @@ +.iv-sub-setting { + padding-top: 0.1em; + padding-left: 2em; + border-top: initial; + + & .ruleset-img { + height: 2.75em; + } + + & .is-disabled { + background-color: var(--background-modifier-border-hover); + } +} diff --git a/src/campaigns/entity.ts b/src/campaigns/entity.ts index 1664084d..450d7e6f 100644 --- a/src/campaigns/entity.ts +++ b/src/campaigns/entity.ts @@ -2,25 +2,81 @@ import { TFile } from "obsidian"; import { Either } from "utils/either"; import { zodResultToEither } from "utils/zodutils"; import { z } from "zod"; +import { + IPlaysetConfig, + PlaysetConfig, + PlaysetLinesSchema, +} from "./playsets/config"; +import { + getStandardPlaysetDefinition, + STANDARD_PLAYSET_DEFNS, +} from "./playsets/standard"; /** Base campaign type. */ export type BaseCampaign = { name: string; + playset: IPlaysetConfig; }; +export const PlaysetConfigSchema = z.discriminatedUnion("type", [ + z.object({ + type: z.literal("globs"), + lines: PlaysetLinesSchema, + }), + z.object({ + type: z.literal("registry"), + key: z.string(), + }), +]); + +export const campaignConfigSchema = z.object({ + playset: PlaysetConfigSchema, +}); + export const campaignFileSchema = z.object({ name: z.string().nullish(), + ironvault: campaignConfigSchema, }); export type CampaignInput = z.input; export type CampaignOutput = z.output; +export const recoveringCampaignFileSchema = campaignFileSchema.extend({ + ironvault: campaignFileSchema.shape.ironvault + .extend({ + playset: PlaysetConfigSchema.catch((def) => def.input), + }) + .default({ playset: { type: "registry", key: "starforged" } }), +}); + /** A campaign that exists in an Obsidian markdown file. */ export class CampaignFile implements BaseCampaign { + playset: IPlaysetConfig; + private constructor( public readonly file: TFile, public readonly props: CampaignOutput, - ) {} + ) { + const playsetConfig = this.props.ironvault.playset; + switch (playsetConfig?.type) { + case "globs": + this.playset = PlaysetConfig.parse(playsetConfig.lines); + break; + case "registry": { + const standardDefn = getStandardPlaysetDefinition(playsetConfig.key); + if (standardDefn) { + this.playset = PlaysetConfig.parse(standardDefn.lines); + } else { + throw new Error(`Invalid playset key ${playsetConfig.key}`); + } + break; + } + default: + throw new Error( + `Invalid playset type '${(playsetConfig as null | undefined | { type?: string })?.type}`, + ); + } + } get name(): string { return this.props.name ?? this.file.basename; @@ -36,7 +92,19 @@ export class CampaignFile implements BaseCampaign { ): Either; static parse(file: TFile, data: unknown): Either; static parse(file: TFile, data: unknown): Either { - const result = zodResultToEither(campaignFileSchema.safeParse(data)); + const result = zodResultToEither( + campaignFileSchema + .refine( + (campaign) => + campaign.ironvault.playset?.type != "registry" || + campaign.ironvault.playset.key in STANDARD_PLAYSET_DEFNS, + { + path: ["ironvault", "playset", "key"], + message: "Not a valid playset key", + }, + ) + .safeParse(data), + ); return result.map((raw) => new CampaignFile(file, raw)); } @@ -44,4 +112,9 @@ export class CampaignFile implements BaseCampaign { static generate(data: CampaignInput): CampaignOutput { return campaignFileSchema.parse(data); } + + /** Attempt a permissive parse. */ + static permissiveParse(data: unknown): CampaignOutput { + return recoveringCampaignFileSchema.parse(data); + } } diff --git a/src/campaigns/manager.ts b/src/campaigns/manager.ts index e18762d5..65a6889e 100644 --- a/src/campaigns/manager.ts +++ b/src/campaigns/manager.ts @@ -1,5 +1,7 @@ +import Emittery, { UnsubscribeFunction } from "emittery"; import IronVaultPlugin from "index"; import { onlyValid } from "indexer/index-impl"; +import { EmittingIndex } from "indexer/index-interface"; import { rootLogger } from "logger"; import { Component, @@ -11,32 +13,204 @@ import { TAbstractFile, TFile, TFolder, - Vault, } from "obsidian"; import { EVENT_TYPES as LOCAL_SETTINGS_EVENT_TYPES } from "settings/local"; +import { Either, Left, Right } from "utils/either"; +import { childOfPath, parentFolderOf } from "utils/paths"; import { CustomSuggestModal } from "utils/suggest"; -import { CampaignTrackedEntities } from "./context"; +import { z } from "zod"; +import { CampaignDataContext } from "./context"; import { CampaignFile } from "./entity"; const logger = rootLogger.getLogger("campaign-manager"); +export class OverlappingCampaignError extends Error {} + +type CAMPAIGN_WATCHER_EVENT_TYPES = { + update: { + campaignPath: string; + campaignRoot: string; + campaign: CampaignFile | null; + }; +}; + +export class CampaignWatcher extends Component { + #lastSeen: Map = new Map(); + + #events: Emittery = new Emittery(); + + getAssignment( + sourcePath: string, + ): Either { + let foundAssignment: CampaignFile | null = null; + for (const [thisPath, thisCampaign] of this.#lastSeen.entries()) { + const thisRoot = parentFolderOf(thisPath); + if (childOfPath(thisRoot, sourcePath)) { + if (foundAssignment != null) { + const msg = `Path '%s' has two potential campaign roots: '%s' and '%s'. It is not valid for two campaigns to have overlapping roots.`; + logger.warn(msg); + return Left.create(new OverlappingCampaignError(msg)); + } else { + foundAssignment = thisCampaign; + } + } + } + return Right.create(foundAssignment); + } + + /** Get the campaign last seen by the watcher at this path. */ + get(campaignPath: string): CampaignFile | undefined { + return this.#lastSeen.get(campaignPath); + } + + /** Sets a watch for the campaign root of a file to change. */ + watch( + watchPath: string, + update: () => unknown, + ): { campaign: CampaignFile | null; unsubscribe: UnsubscribeFunction } { + // We ignore overlapping campaign errors for watch. + const originalAssignment = this.getAssignment(watchPath).getOrElse(null); + const unsubscribe = this.#events.on( + "update", + ({ campaignRoot, campaign }) => { + // If any parent of this has changed, check if the new assignment would differ from the old + if ( + childOfPath(campaignRoot, watchPath) && + originalAssignment != campaign + ) { + logger.debug( + "path=%s may have changed assignment. (root=%s old=%o new=%o)", + watchPath, + campaignRoot, + originalAssignment, + campaign, + ); + unsubscribe(); + update(); + } + }, + ); + + return { campaign: originalAssignment, unsubscribe }; + } + + constructor( + readonly campaigns: EmittingIndex, + readonly areSame: (left: CampaignFile, right: CampaignFile) => boolean, + ) { + super(); + } + + onload(): void { + super.onload(); + this.registerEvent( + this.campaigns.on("changed", (path) => { + const oldValue = this.#lastSeen.get(path); + const newValue = this.campaigns.get(path)?.getOrElse(undefined); + logger.debug( + "path=%s: detected change old=%o new=%o", + path, + oldValue, + newValue, + ); + if (newValue != null) { + if (oldValue == null || !this.areSame(oldValue, newValue)) { + logger.debug("path=%s: determined update", path); + // We have a new value for this path + this.#lastSeen.set(path, newValue); + + this.#events.emit("update", { + campaignPath: path, + campaignRoot: parentFolderOf(path), + campaign: newValue, + }); + } + } else if (oldValue != null) { + logger.debug("path=%s: determined remove", path); + // Removing the new value + this.#lastSeen.delete(path); + this.#events.emit("update", { + campaignPath: path, + campaignRoot: parentFolderOf(path), + campaign: null, + }); + } + }), + ); + this.registerEvent( + this.campaigns.on("renamed", (oldPath, newPath) => { + const original = this.#lastSeen.get(oldPath); + if (original == null) { + logger.warn("Missing value for %s -> %s", oldPath, newPath); + } else { + this.#lastSeen.delete(oldPath); + this.#lastSeen.set(newPath, original); + if (parentFolderOf(oldPath) != parentFolderOf(newPath)) { + this.#events.emit("update", { + campaignPath: newPath, + campaignRoot: parentFolderOf(newPath), + campaign: original, + }); + this.#events.emit("update", { + campaignPath: oldPath, + campaignRoot: parentFolderOf(oldPath), + campaign: original, + }); + } + } + }), + ); + } + + on( + event: K, + listener: (params: CAMPAIGN_WATCHER_EVENT_TYPES[K]) => void, + ) { + return this.#events.on(event, listener); + } + + off( + event: K, + listener: (params: CAMPAIGN_WATCHER_EVENT_TYPES[K]) => void, + ) { + return this.#events.off(event, listener); + } +} + +export function campaignsEqual( + left: CampaignFile, + right: CampaignFile, +): boolean { + return left.file == right.file && left.playset.equals(right.playset); +} + export class CampaignManager extends Component { #events: Events = new Events(); - #lastActiveCampaignFile: TFile | undefined = undefined; + /** The last open view file */ + #lastActive: + | { viewFile: TFile; campaign: CampaignFile | undefined } + | undefined = undefined; + + #campaignDataContexts: WeakMap = + new WeakMap(); + + readonly watcher: CampaignWatcher; constructor(readonly plugin: IronVaultPlugin) { super(); + this.watcher = this.addChild( + new CampaignWatcher(this.plugin.campaigns, campaignsEqual), + ); } lastActiveCampaign(): CampaignFile | undefined { - return this.#lastActiveCampaignFile != null - ? this.plugin.campaigns - .get(this.#lastActiveCampaignFile.path) - ?.expect( - `Campaign at ${this.#lastActiveCampaignFile.path} should be valid.`, - ) - : undefined; + return this.#lastActive?.campaign; + } + + lastActiveCampaignContext(): CampaignDataContext | undefined { + const campaign = this.lastActiveCampaign(); + return campaign && this.campaignContextFor(campaign); } onload(): void { @@ -50,24 +224,43 @@ export class CampaignManager extends Component { this.register( this.plugin.localSettings.on("change", (change) => { - if (change.campaignFile === this.#lastActiveCampaignFile) { + if (change.campaignFile === this.#lastActive?.campaign?.file) { this.trigger("active-campaign-settings-changed", change); } }), ); + + this.register( + this.watcher.on("update", () => { + this.updateActiveCampaign(); + }), + ); } - private setActiveCampaignFromFile(file: TFile) { - const viewCampaign = this.campaignForFile(file); - const lastActiveCampaignFile = this.#lastActiveCampaignFile; + private updateActiveCampaign() { + if (this.#lastActive?.viewFile) { + this.setActiveCampaignFromFile(this.#lastActive.viewFile, true); + } + } + + private setActiveCampaignFromFile(viewFile: TFile, force: boolean = false) { + // If the file is the same, nothing to do + if (!force && this.#lastActive?.viewFile == viewFile) return; + + const lastCampaign = this.#lastActive?.campaign; + const viewCampaign = this.campaignForFile(viewFile); - if (viewCampaign?.file !== lastActiveCampaignFile) { - logger.trace( + this.#lastActive = { + viewFile, + campaign: viewCampaign, + }; + + if (lastCampaign != viewCampaign) { + logger.debug( "Active campaign changed from %s to %s", - lastActiveCampaignFile?.path, + lastCampaign?.file.path, viewCampaign?.file.path, ); - this.#lastActiveCampaignFile = viewCampaign?.file; this.trigger("active-campaign-changed", { newCampaign: viewCampaign, }); @@ -81,51 +274,71 @@ export class CampaignManager extends Component { } } - campaignFolderAssignment(): ReadonlyMap { - const assignments = new Map(); - for (const entry of this.plugin.campaigns.values()) { - if (entry.isRight()) { - const campaign = entry.value; - const root = campaign.file.parent!; - Vault.recurseChildren(root, (file) => { - if (file instanceof TFolder) { - // Note: we don't do this when indexing because that would make one index entry possibly - // contingent on another file / the order they are indexed. This would make it hard to - // know what to reindex. - // That said, if it becomes an issue, this can be cached if tracked entity indexes were - // versioned maps. - const existing = assignments.get(file); - if (existing) { - const msg = `Campaign at '${campaign.file.path}' conflicts with '${existing.file.path}'. One cannot be in a parent folder of another.`; - new Notice(msg, 0); - throw new Error(msg); - } - assignments.set(file, campaign); - } - }); - } - } - return assignments; + awaitCampaignAvailability( + path: string, + timeout: number = 1000, + ): Promise { + logger.debug("Waiting for campaign at %s", path); + const existing = this.watcher.get(path); + if (existing) return Promise.resolve(existing); + return new Promise((resolve, reject) => { + let timeoutId: number | null = null; + const unsub = this.watcher.on("update", ({ campaign, campaignPath }) => { + logger.debug("watcher updated %s %o", campaignPath, campaign); + if (campaignPath == path && campaign != null) { + logger.debug("Campaign has been indexed."); + unsub(); + if (timeoutId != null) clearTimeout(timeoutId); + resolve(campaign); + } + }); + timeoutId = window.setTimeout(() => { + logger.debug( + "Wait for campaign at %s timed out after %d", + path, + timeout, + ); + unsub(); + reject(new Error("Timed out waiting for campaign")); + }, timeout); + }); } campaignForFile(file: TAbstractFile): CampaignFile | undefined { - const folder = file instanceof TFolder ? file : file.parent!; - return this.campaignFolderAssignment().get(folder); + return this.campaignForPath(file.path); } campaignForPath(path: string): CampaignFile | undefined { - const file = this.plugin.app.vault.getAbstractFileByPath(path); - if (file == null) return undefined; - return this.campaignForFile(file); + const assignment = this.watcher.getAssignment(path); + if (assignment.isLeft()) { + const error = assignment.error; + new Notice(error.message, 0); + throw error; + } + return assignment.value ?? undefined; } - campaignContextFor(campaign: CampaignFile): CampaignTrackedEntities { - return new CampaignTrackedEntities( - this.plugin, - campaign, - // TODO(cwegrzyn): need to confirm that file equality comparison is safe - (path) => this.campaignForPath(path)?.file === campaign.file, - ); + watchForReindex(path: string): CampaignFile | null { + return this.watcher.watch(path, () => + this.plugin.indexManager.markDirty(path), + ).campaign; + } + + campaignContextFor(campaign: CampaignFile): CampaignDataContext { + let context = this.#campaignDataContexts.get(campaign); + if (!context) { + this.#campaignDataContexts.set( + campaign, + (context = new CampaignDataContext( + this.plugin, // this is for the settings/for dice roller + this.plugin, // this is the tracked entities + this.plugin.datastore.indexer, + campaign, + (path) => this.campaignForPath(path)?.file === campaign.file, + )), + ); + } + return context; } on( @@ -162,7 +375,7 @@ export type EVENT_TYPES = { export async function determineCampaignContext( plugin: IronVaultPlugin, view?: MarkdownView | MarkdownFileInfo, -): Promise { +): Promise { logger.trace("Determining campaign context for", view); const file = view?.file; let campaign = file && plugin.campaignManager.campaignForFile(file); diff --git a/src/campaigns/playsets/config.test.ts b/src/campaigns/playsets/config.test.ts new file mode 100644 index 00000000..3ff14d70 --- /dev/null +++ b/src/campaigns/playsets/config.test.ts @@ -0,0 +1,411 @@ +import { Datasworn } from "@datasworn/core"; +import { + Determination, + IPlaysetConfig, + PlaysetConfig, + PlaysetGlobLine, + PlaysetIncludeLine, + PlaysetTagsFilter, +} from "./config"; +import { STANDARD_PLAYSET_DEFNS } from "./standard"; + +describe("PlaysetLine", () => { + describe("given oracle_rollable:starforged/test/oracle", () => { + const line = PlaysetGlobLine.fromString( + "oracle_rollable:starforged/test/oracle", + ); + + it.each` + path + ${"oracle_rollable:starforged/test/oracle"} + ${"oracle_rollable.foo.x:starforged/test/oracle.1.b"} + `("matches $path", ({ path }) => { + expect(line.match(path, {})).toBeTruthy(); + }); + + it.each` + path + ${"oracle_rollable:starforged/test/other"} + ${"oracle_rollable.x:starforged/test/other.x"} + ${"oracle_rollable:starforged/test/oracleblah"} + ${"move:starforged/test/oracle"} + `("does not match $path", ({ path }) => { + expect(line.match(path, {})).toBeFalsy(); + }); + }); + + describe("given oracle_rollable:starforged/test/*", () => { + const line = PlaysetGlobLine.fromString( + "oracle_rollable:starforged/test/*", + ); + + it.each` + path + ${"oracle_rollable:starforged/test/oracle"} + ${"oracle_rollable.foo.x:starforged/test/oracle.1.b"} + ${"oracle_rollable:starforged/test/other"} + `("matches $path", ({ path }) => { + expect(line.match(path, {})).not.toBeFalsy(); + }); + + it.each` + path + ${"oracle_rollable:starforged/test/oracle/extra"} + ${"move:starforged/test/other"} + `("does not match $path", ({ path }) => { + expect(line.match(path, {})).toBeFalsy(); + }); + }); + + describe("given oracle_rollable:starforged/*/name", () => { + const line = PlaysetGlobLine.fromString( + "oracle_rollable:starforged/*/name", + ); + + it.each` + path + ${"oracle_rollable:starforged/foo/name"} + ${"oracle_rollable.foo.x:starforged/bar/name.1.b"} + `("matches $path", ({ path }) => { + expect(line.match(path, {})).not.toBeFalsy(); + }); + + it.each` + path + ${"oracle_rollable:starforged/name"} + ${"oracle_rollable:starforged/foo/bar/name"} + ${"move:starforged/bar/name"} + `("does not match $path", ({ path }) => { + expect(line.match(path, {})).toBeFalsy(); + }); + }); + + describe("given oracle_rollable:starforged/**/name", () => { + const line = PlaysetGlobLine.fromString( + "oracle_rollable:starforged/**/name", + ); + + it.each` + path + ${"oracle_rollable:starforged/name"} + ${"oracle_rollable:starforged/foo/name"} + ${"oracle_rollable:starforged/foo/bar/name"} + ${"oracle_rollable.foo.x:starforged/foo/bar/name.1.b"} + `("matches $path", ({ path }) => { + expect(line.match(path, {})).not.toBeFalsy(); + }); + + it.each` + path + ${"oracle_rollable:starforged/foo/names"} + ${"move:starforged/bar/name"} + `("does not match $path", ({ path }) => { + expect(line.match(path, {})).toBeFalsy(); + }); + }); + + describe("given oracle_rollable:starforged/**", () => { + const line = PlaysetGlobLine.fromString("oracle_rollable:starforged/**"); + + it.each` + path + ${"oracle_rollable:starforged"} + ${"oracle_rollable:starforged/foo/name"} + ${"oracle_rollable:starforged/foo/bar/name"} + ${"oracle_rollable.foo.x:starforged.1.b"} + `("matches $path", ({ path }) => { + expect(line.match(path, {})).not.toBeFalsy(); + }); + + it.each` + path + ${"oracle_rollable:starforgeda"} + ${"oracle_rollable:starforgeda/foo"} + `("does not match $path", ({ path }) => { + expect(line.match(path, {})).toBeFalsy(); + }); + }); + + describe("given oracle_rollable:**/name", () => { + const line = PlaysetGlobLine.fromString("oracle_rollable:**/name"); + + it.each` + path + ${"oracle_rollable:name"} + ${"oracle_rollable:starforged/name"} + ${"oracle_rollable:starforged/foo/bar/name"} + `("matches $path", ({ path }) => { + expect(line.match(path, {})).not.toBeFalsy(); + }); + + it.each` + path + ${"oracle_rollable:starforged/names"} + `("does not match $path", ({ path }) => { + expect(line.match(path, {})).toBeFalsy(); + }); + }); + + describe("rules_package handling", () => { + it.each` + pattern + ${"rules_package:starforged"} + ${"*:starforged"} + ${"*:starforged/**"} + `("matches starforged rules_package in $pattern", ({ pattern }) => { + expect( + PlaysetGlobLine.fromString(pattern).match("starforged", {}), + ).toBeTruthy(); + expect( + PlaysetGlobLine.fromString(pattern).match("starforgeda", {}), + ).toBeFalsy(); + }); + }); + + it("identifies negation", () => { + expect(PlaysetGlobLine.fromString("!move:*").determination).toBe( + Determination.Exclude, + ); + expect(PlaysetGlobLine.fromString("move:*").determination).toBe( + Determination.Include, + ); + }); + + function item( + _id: string, + tags: Datasworn.Tags, + ): { _id: string; tags: Datasworn.Tags } { + return { _id, tags }; + } + + describe.each([ + { + line: "*:starforged/** [sundered_isles.recommended=true]", + matches: [ + item("asset:starforged/path/armored", { + sundered_isles: { + recommended: true, + }, + core: { + technological: true, + }, + }), + ], + non_matches: [ + // It shouldn't match a non-matching path + item("asset:foo/path/armored", { + sundered_isles: { + recommended: true, + }, + core: { + technological: true, + }, + }), + // It shouldn't match if the tag has a different value + item("asset:starforged/path/armored", { + sundered_isles: { + recommended: false, + }, + core: { + technological: true, + }, + }), + // It shouldn't match without the tag + item("asset:starforged/path/armored", { + core: { + technological: true, + }, + }), + ], + }, + { + line: "*:starforged/** [sundered_isles.recommended=true&core.technological=true]", + matches: [ + { + _id: "asset:starforged/path/armored", + tags: { + sundered_isles: { + recommended: true, + }, + core: { + technological: true, + }, + }, + }, + ], + non_matches: [ + // It shouldn't match unless both tags match + { + _id: "asset:starforged/path/armored", + tags: { + sundered_isles: { + recommended: true, + }, + core: { + technological: false, + }, + }, + }, + { + _id: "asset:starforged/path/armored", + tags: { + sundered_isles: { + recommended: false, + }, + core: { + technological: true, + }, + }, + }, + ], + }, + ])("given $line", ({ line, matches, non_matches }) => { + it.each(matches)("matches %s", (obj) => { + expect(PlaysetGlobLine.fromString(line).determine(obj._id, obj)).toBe( + Determination.Include, + ); + }); + + it.each(non_matches)("does not match %s", (obj) => { + expect(PlaysetGlobLine.fromString(line).determine(obj._id, obj)).toBe( + null, + ); + }); + }); +}); + +describe("PlaysetIncludeLine", () => { + it("parses an include line", () => { + expect( + PlaysetIncludeLine.tryFromString("@include(classic)"), + ).not.toBeNull(); + }); + + it("nests a config equivalent to the included config", () => { + const line = PlaysetIncludeLine.tryFromString("@include(classic)"); + expect( + line?.included.equals( + PlaysetConfig.parse(STANDARD_PLAYSET_DEFNS.classic.lines), + ), + ).toBeTruthy(); + }); + + it("simply passes to included config", () => { + const mockSubConfig: IPlaysetConfig = { + determine: jest + .fn() + .mockReturnValueOnce(Determination.Include) + .mockReturnValueOnce(Determination.Exclude), + equals: jest.fn(), + }; + const config = new PlaysetIncludeLine("mock", mockSubConfig); + + expect(config.determine("foo", {})).toBe(Determination.Include); + expect(config.determine("foo", {})).toBe(Determination.Exclude); + expect(mockSubConfig.determine).toHaveBeenCalledTimes(2); + }); +}); + +describe("PlaysetConfig", () => { + const TEST_CONFIG = ` +# Starforged +move:starforged/** + +# But none of that foo +! move:starforged/foo/** + +# But I do like anything with bar in the name +move:starforged/foo/**/bar/** +`; + + it("parses valid playset config", () => { + expect(() => PlaysetConfig.parseFile(TEST_CONFIG)).not.toThrow(); + }); + + describe("using valid config", () => { + const config: PlaysetConfig = PlaysetConfig.parseFile(TEST_CONFIG); + + it.each` + input | determination + ${"move:starforged/include/me"} | ${Determination.Include} + ${"move:starforged/foo/but/not/me"} | ${Determination.Exclude} + ${"move:starforged/foo/but/not/bar/me"} | ${Determination.Include} + ${"move:sundered-isles/include/me"} | ${null} + ${"oracle_rollable:starforged/include/me"} | ${null} + `("returns $determination for $input", ({ input, determination }) => { + expect(config.determine(input, {})).toBe(determination); + }); + }); + + describe("include statement", () => { + it("parses a config with an include statement", () => { + const starforged = PlaysetConfig.parseFile("@include(starforged)"); + expect(starforged.determine("asset:starforged/path/empath", {})).toBe( + Determination.Include, + ); + + const classic = PlaysetConfig.parseFile("@include(classic)"); + expect(classic.determine("asset:starforged/path/empath", {})).toBe(null); + expect(classic.determine("asset:classic/path/vestige", {})).toBe( + Determination.Include, + ); + }); + }); +}); + +function validate( + pattern: string, + matches: { tags?: Datasworn.Tags }[], + nonMatches: { tags?: Datasworn.Tags }[], +) { + describe(`given pattern ${pattern}`, () => { + let filter!: PlaysetTagsFilter; + beforeAll(() => { + filter = PlaysetTagsFilter.fromString(pattern); + }); + it.each(matches)("matches %o", (obj) => { + expect(filter.match("", obj)).toBeTruthy(); + }); + it.each(nonMatches)("does not match %o", (obj) => { + expect(filter.match("", obj)).toBeFalsy(); + }); + }); +} + +describe("PlaysetTagFilter", () => { + validate( + 'pkg.tagA="val"', + [{ tags: { pkg: { tagA: "val" } } }], + [ + {}, + { tags: { pkg: { tagB: "val" } } }, + { tags: { pkg: { tagA: 3 } } }, + { tags: { pkg: { tagA: "vab" } } }, + ], + ); + + validate( + 'pkg.tagA="val"&pkg.tagB=true', + [ + { + tags: { + pkg: { tagA: "val", tagB: true, tagC: "foo" }, + otherPkg: { tagD: "bar" }, + }, + }, + ], + [ + {}, + { + tags: { + pkg: { tagA: "val", tagB: false, tagC: "foo" }, + otherPkg: { tagD: "bar" }, + }, + }, + { tags: { pkg: { tagA: "val" } } }, + { tags: { pkg: { tagB: false } } }, + { tags: { pkg: { tagC: "val" } } }, + { tags: { pkg: { tagA: "val2", tagB: true } } }, + ], + ); +}); diff --git a/src/campaigns/playsets/config.ts b/src/campaigns/playsets/config.ts new file mode 100644 index 00000000..40e5aac8 --- /dev/null +++ b/src/campaigns/playsets/config.ts @@ -0,0 +1,369 @@ +import { Datasworn } from "@datasworn/core"; +import { sameValueElementsInArray } from "utils/arrays"; +import { z } from "zod"; +import { STANDARD_PLAYSET_DEFNS } from "./standard"; + +export const TAG_KEY_REGEX = /(?:[\w_]+)\.(?:[\w_]+)/; +export const ONLY_TAG_KEY_REGEX = /^(?[\w_]+)\.(?[\w_]+)$/; +export const TAG_REGEX = new RegExp( + String.raw`${TAG_KEY_REGEX.source}\s*=\s*(?:true|false|\d+|"[^"]*")`, +); + +export const PLAYSET_REGEX = new RegExp( + String.raw`^\s*(?!?)\s*(?(?:[-\w]+|\*)):(?[-\w*/]+)(?:\s+\[(?${TAG_REGEX.source}(?:\s*&\s*${TAG_REGEX.source})*)\])?$`, +); + +export const PlaysetLinesSchema = z + .array(z.string()) + .superRefine((lines, ctx) => { + try { + PlaysetConfig.parse(lines); + } catch (e) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Invalid playset line: ${e}`, + }); + } + }); + +export enum Determination { + Exclude = "exclude", + Include = "include", +} + +export class InvalidPlaysetLineError extends Error {} + +function parse( + line: string, +): [RegExp, Determination, PlaysetTagsFilter | undefined] | null { + const result = line.match(PLAYSET_REGEX); + + if (!result || !result.groups) return null; + + const determination = + result.groups.negate == "!" ? Determination.Exclude : Determination.Include; + + if (result.groups.kind == "rules_package") { + return [ + new RegExp(String.raw`^${result.groups.path}$`), + determination, + undefined, + ]; + } + + const kindRegex = + result.groups.kind == "*" + ? String.raw`(?:[-\w.]+:)?` + : String.raw`${result.groups.kind}(\.[-\w]+)*:`; + + const pathRegex = result.groups.path.replaceAll( + /\/?\*\*\/?|\*/g, + (pattern) => { + switch (pattern) { + case "**": + return String.raw`[-\w/]*`; + case "/**": + return String.raw`(?:/[-\w/]*)?`; + case "**/": + return String.raw`([-\w]+/)*`; + case "/**/": + return String.raw`/([-\w]+/)*`; + case "*": + return String.raw`[-\w]*`; + default: + throw new Error("illegal pattern"); + } + }, + ); + + const fullRegex = new RegExp( + String.raw`^(?:${kindRegex})(?:${pathRegex})(\.[-\w]+)*$`, + ); + + return [ + fullRegex, + determination, + result.groups.tags + ? PlaysetTagsFilter.fromString(result.groups.tags) + : undefined, + ]; +} + +function parseTagKey(input: string): [string, string] { + const result = input.match(ONLY_TAG_KEY_REGEX); + if (!result) + throw new InvalidPlaysetLineError( + `'${input}' not a valid tag key expression`, + ); + return [result.groups!["pkgid"], result.groups!["tagid"]]; +} + +export interface IPlaysetCondition { + match(id: string, obj: { tags?: Datasworn.Tags }): boolean; + equals(other: IPlaysetCondition): boolean; +} + +export class PlaysetTagFilter implements IPlaysetCondition { + static fromString(input: string): PlaysetTagFilter { + const [keyExpr, valueExpr] = input.split(/\s*=\s*/); + const key = parseTagKey(keyExpr); + let value: boolean | number | string; + if (valueExpr == "true") { + value = true; + } else if (valueExpr == "false") { + value = false; + } else { + const num = Number.parseInt(valueExpr); + value = Number.isNaN(num) + ? valueExpr.slice(1, valueExpr.length - 1) + : num; + } + return new this(key[0], key[1], value); + } + + toString(): string { + return `${this.packageId}.${this.tagId}=${this.targetValue}`; + } + + constructor( + readonly packageId: string, + readonly tagId: string, + readonly targetValue: Datasworn.Tag, + ) {} + + match(id: string, obj: { tags?: Datasworn.Tags }): boolean { + return sameTagValue( + this.targetValue, + obj.tags?.[this.packageId]?.[this.tagId], + ); + } + + equals(other: IPlaysetCondition): boolean { + return ( + other instanceof PlaysetTagFilter && + this.packageId === other.packageId && + this.tagId === other.tagId && + sameTagValue(this.targetValue, other.targetValue) + ); + } +} + +export class PlaysetTagsFilter implements IPlaysetCondition { + readonly filters: ReadonlyArray; + + /** Warning: Assumes that string was matched against regex already. */ + static fromString(input: string): PlaysetTagsFilter { + return new PlaysetTagsFilter( + input.split(/\s*&\s*/).map((expr) => { + return PlaysetTagFilter.fromString(expr); + }), + ); + } + + toString(): string { + return `${this.filters.map((f) => f.toString()).join(",")}`; + } + + constructor(filters: Iterable) { + this.filters = [...filters]; + } + + match(id: string, obj: { tags?: Datasworn.Tags }): boolean { + return this.filters.every((filter) => filter.match(id, obj)); + } + + equals(other: IPlaysetCondition): boolean { + return ( + other instanceof PlaysetTagsFilter && + this.filters.length == other.filters.length && + this.filters.every((filter) => + other.filters.find((otherFilter) => filter.equals(otherFilter)), + ) + ); + } +} + +function sameTagValue( + left: Datasworn.Tag | undefined, + right: Datasworn.Tag | undefined, +): boolean { + return ( + typeof left === typeof right && + (left instanceof Array && right instanceof Array + ? sameValueElementsInArray(left, right) + : left === right) + ); +} + +export interface IPlaysetLine { + /** A playset line returns a determination if it has one, or null if it does not apply. */ + determine(id: string, obj: { tags?: Datasworn.Tags }): Determination | null; + equals(other: IPlaysetConfig): boolean; +} + +export class PlaysetGlobLine implements IPlaysetLine { + static tryFromString(line: string): PlaysetGlobLine | null { + const result = parse(line); + return result && new this(...result); + } + + static fromString(line: string): PlaysetGlobLine { + const result = this.tryFromString(line); + if (!result) + throw new InvalidPlaysetLineError( + `'${line} is not a valid playset glob line.`, + ); + return result; + } + + constructor( + readonly regex: RegExp, + readonly determination: Determination, + readonly tags?: PlaysetTagsFilter, + ) {} + + determine(id: string, obj: { tags?: Datasworn.Tags }): Determination | null { + return this.match(id, obj) ? this.determination : null; + } + + match(id: string, obj: { tags?: Datasworn.Tags }): boolean { + return ( + !!id.match(this.regex) && (this.tags == null || this.tags.match(id, obj)) + ); + } + + equals(other: IPlaysetConfig): boolean { + return ( + other instanceof PlaysetGlobLine && + other.determination === this.determination && + other.regex.source == this.regex.source && + ((other.tags == null && this.tags == null) || + (other.tags != null && + this.tags != null && + other.tags.equals(this.tags))) + ); + } +} + +const PLAYSET_INCLUDE_STATEMENT_REGEX = new RegExp( + /^@include\((?[-\w_]+)\)$/, +); + +export class PlaysetIncludeLine implements IPlaysetLine { + static tryFromString(input: string): PlaysetIncludeLine | null { + const match = input.match(PLAYSET_INCLUDE_STATEMENT_REGEX); + + if (!match) return null; + + const name = match.groups!.name; + if (name in STANDARD_PLAYSET_DEFNS) { + return new this( + name, + PlaysetConfig.parse( + STANDARD_PLAYSET_DEFNS[name as keyof typeof STANDARD_PLAYSET_DEFNS] + .lines, + ), + ); + } else { + throw new InvalidPlaysetLineError( + `include statement references unknown playset '${name}'`, + ); + } + } + + constructor( + readonly name: string, + readonly included: IPlaysetConfig, + ) {} + + determine(id: string, obj: { tags?: Datasworn.Tags }): Determination | null { + return this.included.determine(id, obj); + } + equals(other: IPlaysetConfig): boolean { + return ( + other instanceof PlaysetIncludeLine && + this.included.equals(other.included) + ); + } +} + +export interface IPlaysetConfig { + determine(id: string, obj: { tags?: Datasworn.Tags }): Determination | null; + equals(other: IPlaysetConfig): boolean; +} + +export class PlaysetConfig implements IPlaysetConfig { + constructor(readonly lines: IPlaysetLine[]) {} + + static parse(lines: string[]): PlaysetConfig { + return new PlaysetConfig( + lines.flatMap((line, index) => { + if (line.trim().length == 0 || line.startsWith("#")) return []; + + const compiled = + PlaysetGlobLine.tryFromString(line) ?? + PlaysetIncludeLine.tryFromString(line); + if (!compiled) { + throw new InvalidPlaysetLineError( + `Line ${index + 1}: '${line}' is not a valid.`, + ); + } + return [compiled]; + }), + ); + } + + static parseFile(data: string): PlaysetConfig { + return this.parse(data.split(/\r\n|\r|\n/g)); + } + + determine(id: string, obj: { tags?: Datasworn.Tags }): Determination | null { + // Find the last matching determination + for (let index = this.lines.length - 1; index >= 0; index--) { + const line = this.lines[index]; + const determination = line.determine(id, obj); + if (determination != null) return determination; + } + + return null; + } + + equals(other: IPlaysetConfig): boolean { + return ( + other instanceof PlaysetConfig && + this.lines.length === other.lines.length && + this.lines.every((thisLine, idx) => thisLine.equals(other.lines[idx])) + ); + } +} + +export class DefaultingPlaysetConfig implements IPlaysetConfig { + constructor( + readonly config: IPlaysetConfig, + readonly fallbackDetermination: Determination, + ) {} + + determine(id: string, obj: { tags?: Datasworn.Tags }): Determination | null { + return this.config.determine(id, obj) ?? this.fallbackDetermination; + } + + equals(other: IPlaysetConfig): boolean { + return ( + other instanceof DefaultingPlaysetConfig && + this.config.equals(other.config) && + this.fallbackDetermination == other.fallbackDetermination + ); + } +} + +export class NullPlaysetConfig implements IPlaysetConfig { + static readonly instance = new NullPlaysetConfig(); + + determine(_id: string): Determination | null { + return Determination.Include; + } + + equals(other: IPlaysetConfig): boolean { + return other instanceof NullPlaysetConfig; + } +} diff --git a/src/campaigns/playsets/standard.ts b/src/campaigns/playsets/standard.ts new file mode 100644 index 00000000..dab9716c --- /dev/null +++ b/src/campaigns/playsets/standard.ts @@ -0,0 +1,71 @@ +export const STANDARD_PLAYSET_DEFNS = { + classic: { name: "Ironsworn", lines: ["*:classic/**"] } as { + name: string; + lines: string[]; + }, + classic_delve: { + name: "Ironsworn w/ Delve", + lines: ["@include(classic)", "*:delve/**"], + }, + starforged: { + name: "Starforged", + lines: ["*:starforged/**", "*:starforgedsupp/**"], + }, + starforged__si_assets: { + name: "Starforged w/ SI assets recommended for base game", + lines: [ + "@include(starforged)", + "asset:sundered_isles/** [starforged.recommended=true]", + ], + }, + + sundered_isles__assets_all: { + name: "Sundered Isles (all SF and SI assets)", + lines: [ + "rules_package:starforged", + "move:starforged/**", + "move_category:starforged/**", + // Sundered Isles p56 suggests creature oracles + "oracle_rollable:starforged/creature/**", + "*:sundered_isles/**", + "*:sundered_isles_supp/**", + "asset:starforged/** [sundered_isles.recommended=true]", + ], + }, + sundered_isles__assets_technological: { + name: "Sundered Isles ('technological' assets)", + lines: [ + "@include(sundered_isles__assets_all)", + "!asset:starforged/** [core.supernatural=true]", + "!asset:sundered_isles/** [core.supernatural=true]", + ], + }, + sundered_isles__assets_supernatural: { + name: "Sundered Isles ('supernatural' assets)", + lines: [ + "@include(sundered_isles__assets_all)", + "!asset:starforged/** [core.technological=true]", + "!asset:sundered_isles/** [core.technological=true]", + ], + }, + sundered_isles__assets_historical: { + name: "Sundered Isles (no 'supernatural' or 'technological' assets)", + lines: [ + "@include(sundered_isles__assets_all)", + "!asset:starforged/** [core.technological=true]", + "!asset:sundered_isles/** [core.technological=true]", + "!asset:starforged/** [core.supernatural=true]", + "!asset:sundered_isles/** [core.supernatural=true]", + ], + }, +}; + +export function getStandardPlaysetDefinition( + key: string, +): { name: string; lines: string[] } | undefined { + if (key in STANDARD_PLAYSET_DEFNS) { + return STANDARD_PLAYSET_DEFNS[key as keyof typeof STANDARD_PLAYSET_DEFNS]; + } else { + return undefined; + } +} diff --git a/src/campaigns/ui/data-suggest.ts b/src/campaigns/ui/data-suggest.ts new file mode 100644 index 00000000..15e35f9d --- /dev/null +++ b/src/campaigns/ui/data-suggest.ts @@ -0,0 +1,93 @@ +import { PlaysetAwareDataContext } from "campaigns/context"; +import { IPlaysetConfig } from "campaigns/playsets/config"; +import { DataswornIndexer } from "datastore/datasworn-indexer"; +import { html, HTMLTemplateResult, render } from "lit-html"; + +import MiniSearch, { SearchResult } from "minisearch"; +import { AbstractInputSuggest, App } from "obsidian"; + +export class DataSuggest extends AbstractInputSuggest { + #playsetContext?: PlaysetAwareDataContext; + #index: MiniSearch<{ + name?: string; + kind: string; + source: string; + _id: string; + }>; + + constructor( + app: App, + readonly inputEl: HTMLInputElement, + readonly indexer: DataswornIndexer, + ) { + super(app, inputEl); + + this.#index = new MiniSearch({ + fields: ["_id", "name", "kind", "source"], + storeFields: ["name", "kind", "source"], + idField: "_id", + searchOptions: { + prefix: true, + fuzzy: 0.3, + boost: { name: 2 }, + }, + }); + // this.#playsetContext = + // config && new PlaysetAwareDataContext(this.indexer, config); + for (const [_id, item] of indexer.prioritized.entries() ?? []) { + this.#index.add({ + _id, + kind: item.kind, + name: (item.value as { name?: string }).name, + source: item.source.path, + }); + } + } + + setPlaysetConfig(config?: IPlaysetConfig) { + this.#playsetContext = + config && new PlaysetAwareDataContext(this.indexer, config); + } + + protected getSuggestions( + query: string, + ): SearchResult[] | Promise { + return this.#index?.search(query) ?? []; + } + + renderSuggestion(value: SearchResult, el: HTMLElement): void { + el.addClass("iv-suggestion-item"); + const isIncluded = this.#playsetContext?.prioritized.has(value.id); + render( + html`${highlightTerms(value.kind, value.queryTerms)}: + ${highlightTerms(value.name, value.queryTerms)} + ${isIncluded ? "(included)" : "(not included)"}
+ ${highlightTerms(value.id, value.queryTerms)}`, + el, + ); + } +} + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping +function escapeRegExp(string: string): string { + return string.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string +} + +export function highlightTerms( + input: string, + terms: string[], +): HTMLTemplateResult { + if (!input) return html``; + + const search = new RegExp( + String.raw`(${terms.map(escapeRegExp).join("|")})`, + "gi", + ); + return html`${input + .split(search) + .map((term) => (term.match(search) ? html`${term}` : term))}`; +} diff --git a/src/campaigns/ui/edit-view.ts b/src/campaigns/ui/edit-view.ts new file mode 100644 index 00000000..ce5469f8 --- /dev/null +++ b/src/campaigns/ui/edit-view.ts @@ -0,0 +1,150 @@ +import { CampaignFile, CampaignOutput } from "campaigns/entity"; +import IronVaultPlugin from "index"; +import { html, render } from "lit-html"; +import { + App, + ButtonComponent, + FileView, + Setting, + TFile, + WorkspaceLeaf, +} from "obsidian"; +import { PlaysetSetting } from "./playset-setting"; + +export const CAMPAIGN_EDIT_VIEW_TYPE = "iron-vault-campaign-edit"; + +export class CampaignEditView extends FileView { + static async openFile(app: App, file: string) { + // TODO: check that it is a campaign file + const { workspace } = app; + + const leaves = workspace.getLeavesOfType(CAMPAIGN_EDIT_VIEW_TYPE); + let leaf: WorkspaceLeaf | undefined = leaves.find( + (leaf) => leaf.getViewState().state.file == file, + ); + + if (!leaf) { + // Our view could not be found in the workspace, create a new leaf + // in the right sidebar for it + leaf = workspace.getLeaf("tab"); + await leaf.setViewState({ + type: CAMPAIGN_EDIT_VIEW_TYPE, + active: true, + state: { file }, + }); + } + + // "Reveal" the leaf in case it is in a collapsed sidebar + workspace.revealLeaf(leaf); + } + + constructor( + leaf: WorkspaceLeaf, + readonly plugin: IronVaultPlugin, + ) { + super(leaf); + } + + getViewType(): string { + return CAMPAIGN_EDIT_VIEW_TYPE; + } + + async onLoadFile(file: TFile): Promise { + await super.onLoadFile(file); + + // TODO(@cwegrzyn): watch the campaign + const result = this.plugin.campaigns.get(file.path); + let campaign: CampaignOutput; + if (!result) { + render( + html`
+ File ${file.path} is not a campaign. +
`, + this.contentEl, + ); + return; + } else if (result.isLeft()) { + try { + campaign = CampaignFile.permissiveParse( + this.plugin.app.metadataCache.getCache(file.path), + ); + } catch (e) { + render( + html`
+ Campaign at '${file.path}' is invalid: +
${e}
+
`, + this.contentEl, + ); + return; + } + } else { + campaign = result.value.props; + } + + new Setting(this.contentEl).setName("Campaign name").addText((text) => + text + .setValue(campaign.name ?? "") + .setPlaceholder(campaign.name ?? file.basename) + .onChange((val) => { + campaign.name = val ? val : undefined; + }), + ); + + const playset = campaign.ironvault.playset; + + let playsetKey: string; + let customConfig: string; + switch (playset?.type) { + case "globs": + playsetKey = "custom"; + customConfig = playset.lines.join("\n"); + break; + case "registry": + playsetKey = playset.key; + customConfig = ""; + break; + default: + throw new Error( + `invalid playset type ${(playset as null | undefined | { type?: string })?.type}`, + ); + } + + const playsetSetting = new PlaysetSetting( + this.plugin, + this.contentEl, + ).onChange((setting) => { + saveButton.setDisabled(!setting.isValid()); + const key = setting.playsetKey(); + if (key == "custom") { + campaign.ironvault.playset = { + type: "globs", + lines: setting.customConfig.split(/\r?\n|\r/g), + }; + } else { + campaign.ironvault.playset = { type: "registry", key }; + } + }); + + let saveButton!: ButtonComponent; + new Setting(this.contentEl).addButton((button) => + (saveButton = button) + .setCta() + .setButtonText("Save") + .onClick(() => + this.plugin.app.fileManager.processFrontMatter( + this.file!, + (frontmatter) => { + Object.assign(frontmatter, CampaignFile.generate(campaign)); + }, + ), + ), + ); + + playsetSetting.setFromKeyAndConfig(playsetKey, customConfig); + } + + async onUnloadFile(_file: TFile): Promise { + this.contentEl.empty(); + } +} diff --git a/src/campaigns/ui/invalid-campaigns.ts b/src/campaigns/ui/invalid-campaigns.ts new file mode 100644 index 00000000..19f89ea2 --- /dev/null +++ b/src/campaigns/ui/invalid-campaigns.ts @@ -0,0 +1,123 @@ +import { CampaignInput } from "campaigns/entity"; +import IronVaultPlugin from "index"; +import { onlyInvalid } from "indexer/index-impl"; +import { html, render, TemplateResult } from "lit-html"; +import { map } from "lit-html/directives/map.js"; +import { App, debounce, IconName, ItemView, WorkspaceLeaf } from "obsidian"; +import { showSingletonView } from "utils/obsidian"; +import { ZodError } from "zod"; +import { CampaignEditView } from "./edit-view"; + +export const INVALID_CAMPAIGNS_VIEW_TYPE = "iron-vault-invalid-campaigns"; + +export class InvalidCampaignsView extends ItemView { + readonly navigation: boolean = false; + + static async showIfNeeded(plugin: IronVaultPlugin): Promise { + if (onlyInvalid(plugin.campaigns).size > 0) { + await this.show(plugin.app); + } + } + + static show(app: App): Promise { + return showSingletonView(app, INVALID_CAMPAIGNS_VIEW_TYPE); + } + + constructor( + leaf: WorkspaceLeaf, + readonly plugin: IronVaultPlugin, + ) { + super(leaf); + this.contentEl.addClass("iv-base-view", "iv-invalid-campaigns-view"); + this.render = debounce(this.render.bind(this), 100, true); + } + + getViewType(): string { + return INVALID_CAMPAIGNS_VIEW_TYPE; + } + + getDisplayText(): string { + return "Iron Vault: Fix invalid campaigns"; + } + + getIcon(): IconName { + return "iron-vault"; + } + + onload(): void { + super.onload(); + this.registerEvent(this.plugin.campaigns.on("changed", this.render)); + } + + protected async onOpen(): Promise { + this.render(); + } + + protected render() { + const invalid = onlyInvalid(this.plugin.campaigns); + if (invalid.size == 0) { + render( + html`

+ Congratulations. Your campaigns are all ready to go. You can close + this window at your leisure. +

`, + this.contentEl, + ); + } else { + render( + html` +

+ Iron vault detected that the following campaigns have errors that + must be resolved before you can use them: +

+
+ ${map( + onlyInvalid(this.plugin.campaigns), + ([k, e]) => + html`
${k}
+
${this.reason(k, e)}
`, + )} +
+ `, + this.contentEl, + ); + } + } + + protected reason(path: string, error: Error): TemplateResult { + if (error instanceof ZodError) { + const validationErrors = (error as ZodError).format(); + if ( + validationErrors.ironvault?._errors && + validationErrors.ironvault._errors.includes("Required") + ) { + return html`Campaign is from a previous version of Ironvault. We now + require campaigns to define a "playset". + Use the campaign editor to add a playset.`; + } else if ( + validationErrors.ironvault?.playset?._errors && + validationErrors.ironvault.playset._errors.length > 0 + ) { + return html`Playset is invalid or missing. + Use the campaign editor to add a playset.: ${validationErrors.ironvault.playset._errors.join("; ")}`; + } else { + return html`Campaign is invalid: +
${validationErrors}
`; + } + } else { + return html`Unexpected error: +
${error}
`; + } + } + + async openCampaign(ev: MouseEvent) { + const path = (ev.target as HTMLElement).dataset.campaignPath; + if (path) { + await CampaignEditView.openFile(this.app, path); + } + } +} diff --git a/src/campaigns/ui/new-campaign-modal.ts b/src/campaigns/ui/new-campaign-modal.ts index da09fb88..e151925e 100644 --- a/src/campaigns/ui/new-campaign-modal.ts +++ b/src/campaigns/ui/new-campaign-modal.ts @@ -9,11 +9,14 @@ import { TFolder, } from "obsidian"; import { FolderTextSuggest } from "utils/ui/settings/folder"; +import { PlaysetSetting } from "./playset-setting"; export type NewCampaignInfo = { campaignName: string; folder: string; scaffold: boolean; + playsetOption: string; + customPlaysetDefn: string; }; export class NewCampaignModal extends Modal { @@ -21,6 +24,8 @@ export class NewCampaignModal extends Modal { campaignName: "", folder: "/", scaffold: true, + playsetOption: "starforged", + customPlaysetDefn: "", }; static show(plugin: IronVaultPlugin): Promise { @@ -49,6 +54,8 @@ export class NewCampaignModal extends Modal { valid &&= (this.campaignInfo.campaignName ?? "").length > 0; + valid &&= playsetSetting.isValid(); + const existing = this.app.vault.getAbstractFileByPath( this.campaignInfo.folder, ); @@ -67,6 +74,18 @@ export class NewCampaignModal extends Modal { valid = false; } + const campaignFileName = normalizePath( + this.campaignInfo.folder + "/" + this.campaignInfo.campaignName, + ); + const existingCampaign = + this.plugin.campaignManager.campaignForPath(campaignFileName); + if (existingCampaign) { + resultSetting.setDesc( + `ERROR: The folder '${this.campaignInfo.folder}' is part of the campaign at '${existingCampaign.file.path}'. Campaigns may not be nested.`, + ); + valid = false; + } + createButton.setDisabled(!valid); }; @@ -112,6 +131,15 @@ export class NewCampaignModal extends Modal { const resultSetting = new Setting(contentEl).setDesc("X will be Y."); + const playsetSetting = new PlaysetSetting( + this.plugin, + this.contentEl, + ).onChange((setting) => { + this.campaignInfo.playsetOption = setting.playsetKey(); + this.campaignInfo.customPlaysetDefn = setting.customConfig; + validate(); + }); + new Setting(contentEl) .setName("Scaffold campaign") .setDesc( @@ -138,7 +166,12 @@ export class NewCampaignModal extends Modal { btn.setButtonText("Cancel").onClick(() => this.close()), ); - validate(); + // Set value after everything is set, so that we can successfully respond to the validation + // that this triggers. + playsetSetting.setFromKeyAndConfig( + this.campaignInfo.playsetOption, + this.campaignInfo.customPlaysetDefn, + ); } onClose(): void { diff --git a/src/campaigns/ui/playset-editor.ts b/src/campaigns/ui/playset-editor.ts new file mode 100644 index 00000000..2659a4a5 --- /dev/null +++ b/src/campaigns/ui/playset-editor.ts @@ -0,0 +1,243 @@ +import { PlaysetAwareDataContext } from "campaigns/context"; +import { PlaysetConfig } from "campaigns/playsets/config"; +import { + getStandardPlaysetDefinition, + STANDARD_PLAYSET_DEFNS, +} from "campaigns/playsets/standard"; +import { DataswornIndexer } from "datastore/datasworn-indexer"; +import { html, render } from "lit-html"; +import { map } from "lit-html/directives/map.js"; +import { ref } from "lit-html/directives/ref.js"; +import MiniSearch from "minisearch"; +import { + App, + ButtonComponent, + debounce, + DropdownComponent, + Modal, + setIcon, + Setting, +} from "obsidian"; +import { DataSuggest, highlightTerms } from "./data-suggest"; + +function createSearchIndex( + baseData: DataswornIndexer, +): MiniSearch<{ _id: string; name?: string; kind: string; source: string }> { + const index = new MiniSearch({ + fields: ["_id", "name", "kind", "source"], + storeFields: ["name", "kind", "source"], + idField: "_id", + searchOptions: { + prefix: true, + fuzzy: 0.3, + }, + }); + + for (const [_id, item] of baseData.prioritized.entries() ?? []) { + index.add({ + _id, + kind: item.kind, + name: (item.value as { name?: string }).name, + source: item.source.path, + }); + } + + return index; +} + +export class PlaysetEditor extends Modal { + currentPlaysetChoice: string = ""; + customConfig: string = ""; + currentSearch: string = ""; + resultSetting: Setting; + playsetDataContext?: PlaysetAwareDataContext; + dataSuggest!: DataSuggest; + #index: MiniSearch<{ + _id: string; + name?: string; + kind: string; + source: string; + }>; + searchResultsEl: HTMLDivElement; + configEditorEl: HTMLTextAreaElement; + okButton!: ButtonComponent; + + static open( + app: App, + baseData: DataswornIndexer, + initialPlaysetChoice?: string, + initialCustomConfig?: string, + ): Promise<{ playset: string; customConfig: string }> { + return new Promise((resolve, reject) => { + try { + const modal = new this( + app, + baseData, + resolve, + reject, + initialPlaysetChoice, + initialCustomConfig, + ); + modal.open(); + } catch (e) { + reject(e); + } + }); + } + + static playsetOptions(includeKey: boolean = false): Record { + return { + ...Object.fromEntries( + Object.entries(STANDARD_PLAYSET_DEFNS).map(([key, { name }]) => [ + key, + name + (includeKey ? ` (key: ${key})` : ""), + ]), + ), + custom: "Custom playset", + }; + } + + constructor( + app: App, + readonly baseData: DataswornIndexer, + readonly onAccept: (result: { + playset: string; + customConfig: string; + }) => void, + readonly onCancel: () => void, + readonly initialPlaysetChoice?: string, + readonly initialCustomConfig: string = "", + ) { + super(app); + this.setTitle("Choose playset"); + + this.#index = createSearchIndex(baseData); + this.currentPlaysetChoice = initialPlaysetChoice ?? "starforged"; + this.customConfig = initialCustomConfig; + + const refresh = debounce(() => this.refresh(), 200, true); + + this.contentEl.createEl("p", { + cls: "setting-item-description", + text: "Configure the playset for your campaign by choosing an existing playset config or creating a custom one.", + }); + + let playsetDropdown!: DropdownComponent; + new Setting(this.contentEl).setName("Playset").addDropdown((dropdown) => + (playsetDropdown = dropdown) + .addOptions(PlaysetEditor.playsetOptions(true)) + .setValue(this.currentPlaysetChoice) + .onChange((playsetChoice) => { + this.currentPlaysetChoice = playsetChoice; + // Make the editor reflect this config + if (this.currentPlaysetChoice == "custom") { + this.configEditorEl.value = this.customConfig; + this.configEditorEl.disabled = false; + } else { + this.configEditorEl.value = + getStandardPlaysetDefinition(playsetChoice)!.lines.join("\n"); + this.configEditorEl.disabled = true; + } + this.refresh(); + }), + ); + + this.configEditorEl = this.contentEl.createEl( + "textarea", + { cls: "iv-modal-text-area" }, + (el) => { + el.spellcheck = false; + el.addEventListener("input", () => { + this.customConfig = el.value; + refresh(); + }); + }, + ); + + this.resultSetting = new Setting(this.contentEl); + + new Setting(this.contentEl) + .setName("Search") + .setDesc( + "Use this to search through content to determine if it is included in your config", + ) + .addSearch((search) => { + search.onChange((query) => { + this.currentSearch = query; + this.updateList(); + }); + }); + + this.searchResultsEl = this.contentEl.createDiv({ + cls: "iv-modal-suggestion-list", + }); + + new Setting(this.contentEl) + .addButton((ok) => + (this.okButton = ok) + .setCta() + .setButtonText("Select") + .onClick(() => { + this.onAccept({ + playset: this.currentPlaysetChoice, + customConfig: this.customConfig, + }); + this.close(); + }), + ) + .addButton((cancel) => + cancel.setButtonText("Cancel").onClick(() => this.close()), + ); + + playsetDropdown.selectEl.trigger("change"); + } + + private refresh(): void { + let playset: PlaysetConfig | undefined = undefined; + try { + playset = PlaysetConfig.parseFile(this.configEditorEl.value); + this.resultSetting.setDesc(""); + this.okButton.setDisabled(false); + } catch (e) { + this.resultSetting.setDesc(`Error: ${String(e)}`); + this.okButton.setDisabled(true); + } + + this.playsetDataContext = + playset && new PlaysetAwareDataContext(this.baseData, playset); + + this.updateList(); + } + + private updateList() { + const results = + this.currentSearch == "" ? [] : this.#index.search(this.currentSearch); + render( + html`${map(results.slice(0, 50), (value) => { + const isIncluded = this.playsetDataContext?.prioritized.has(value.id); + return html`
+ + el instanceof HTMLElement && + setIcon(el, isIncluded ? "check" : "x"), + )} + >${highlightTerms(value.kind, value.queryTerms)}: + ${highlightTerms(value.name, value.queryTerms)}
+ ${highlightTerms(value.id, value.queryTerms)} +
`; + })}`, + this.searchResultsEl, + ); + } + + onClose(): void { + this.onCancel(); + super.onClose(); + } +} diff --git a/src/campaigns/ui/playset-setting.ts b/src/campaigns/ui/playset-setting.ts new file mode 100644 index 00000000..671138dd --- /dev/null +++ b/src/campaigns/ui/playset-setting.ts @@ -0,0 +1,340 @@ +import { PlaysetConfig } from "campaigns/playsets/config"; +import { STANDARD_PLAYSET_DEFNS } from "campaigns/playsets/standard"; +import IronVaultPlugin from "index"; +import isMatch from "lodash.ismatch"; +import { rootLogger } from "logger"; +import { Setting, ToggleComponent } from "obsidian"; +import { DELVE_LOGO, IS_LOGO, SF_LOGO, SI_LOGO } from "utils/logos"; +import { PlaysetEditor } from "./playset-editor"; + +const logger = rootLogger.getLogger("playset-setting"); + +/** Settings config states for playset options. */ +type PlaysetState = { + base: "classic" | "starforged" | "sundered_isles" | "custom"; + classicIncludeDelve: boolean; + starforgedIncludeSunderedIslesRecommended: boolean; + sunderedIslesIncludeTechnologicalAssets: boolean; + sunderedIslesIncludeSupernaturalAssets: boolean; + customPlaysetChoice: string | null; + customConfig: string; +}; + +const STANDARD_PLAYSET_SETTINGS: Record< + keyof typeof STANDARD_PLAYSET_DEFNS, + Partial +> = { + classic: { + base: "classic", + classicIncludeDelve: false, + }, + classic_delve: { + base: "classic", + classicIncludeDelve: true, + }, + starforged: { + base: "starforged", + starforgedIncludeSunderedIslesRecommended: false, + }, + starforged__si_assets: { + base: "starforged", + starforgedIncludeSunderedIslesRecommended: true, + }, + sundered_isles__assets_all: { + base: "sundered_isles", + sunderedIslesIncludeSupernaturalAssets: true, + sunderedIslesIncludeTechnologicalAssets: true, + }, + sundered_isles__assets_historical: { + base: "sundered_isles", + sunderedIslesIncludeSupernaturalAssets: false, + sunderedIslesIncludeTechnologicalAssets: false, + }, + sundered_isles__assets_supernatural: { + base: "sundered_isles", + sunderedIslesIncludeSupernaturalAssets: true, + sunderedIslesIncludeTechnologicalAssets: false, + }, + sundered_isles__assets_technological: { + base: "sundered_isles", + sunderedIslesIncludeSupernaturalAssets: false, + sunderedIslesIncludeTechnologicalAssets: true, + }, +}; + +export class PlaysetSetting { + playsetState: PlaysetState = { + base: "classic", + classicIncludeDelve: false, + starforgedIncludeSunderedIslesRecommended: false, + sunderedIslesIncludeTechnologicalAssets: true, + sunderedIslesIncludeSupernaturalAssets: true, + customConfig: "", + customPlaysetChoice: null, + }; + + changeCallback?: (setting: this) => void | Promise; + + private readonly updatePlaysetState: (updates: Partial) => void; + + constructor( + readonly plugin: IronVaultPlugin, + readonly contentEl: HTMLElement, + ) { + const toggles: Record = {}; + const subToggleSettings: Record = { + classic: [], + starforged: [], + sundered_isles: [], + custom: [], + }; + const subToggles: Record = {}; + + let updatingState = false; + this.updatePlaysetState = (updates: Partial) => { + if (updatingState) { + return; + } + + updatingState = true; + try { + Object.assign(this.playsetState, updates); + for (const [key, toggle] of Object.entries(toggles)) { + const thisKeySelected = key == this.playsetState.base; + if (toggle.getValue() != thisKeySelected) { + toggle.setValue(thisKeySelected); + } + toggle.setDisabled(thisKeySelected); + for (const subToggle of subToggleSettings[key]) { + subToggle.setDisabled(!thisKeySelected); + } + } + for (const [key, toggle] of Object.entries(subToggles)) { + const stateVal = (this.playsetState as Record)[key]; + if (typeof stateVal == "boolean" && stateVal !== toggle.getValue()) { + toggle.setValue(stateVal); + } + } + + this.changeCallback?.(this); + } finally { + updatingState = false; + } + }; + + new Setting(contentEl) + .setName("Playset") + .setDesc( + "The playset selects the content from the official rulebooks and from your " + + "configured Homebrew to make available in this campaign.", + ) + .setHeading(); + + new Setting(contentEl) + .setName("Ironsworn") + .addToggle((toggle) => { + toggle.onChange((val) => { + if (val) { + this.updatePlaysetState({ base: "classic" }); + } + }); + toggles["classic"] = toggle; + }) + .then((setting) => { + const isImg = document.createElement("img"); + isImg.src = IS_LOGO; + isImg.toggleClass("ruleset-img", true); + setting.settingEl.prepend(isImg); + }); + + new Setting(contentEl) + .setDesc("Include Delve expansion") + .setClass("iv-sub-setting") + .addToggle((toggle) => { + toggle.onChange((value) => + this.updatePlaysetState({ classicIncludeDelve: value }), + ); + subToggles["classicIncludeDelve"] = toggle; + }) + .then((delveSetting) => { + const delveImg = document.createElement("img"); + delveImg.src = DELVE_LOGO; + delveImg.toggleClass("ruleset-img", true); + delveSetting.settingEl.prepend(delveImg); + + subToggleSettings["classic"].push(delveSetting); + }); + + new Setting(contentEl) + .setName("Starforged") + .addToggle((toggle) => { + toggle.onChange((val) => { + if (val) { + this.updatePlaysetState({ base: "starforged" }); + } + }); + toggles["starforged"] = toggle; + }) + .then((sfSetting) => { + const sfImg = document.createElement("img"); + sfImg.src = SF_LOGO; + sfImg.toggleClass("ruleset-img", true); + sfSetting.settingEl.prepend(sfImg); + }); + + new Setting(contentEl) + .setDesc("Include Sundered Isles assets recommended for the base game") + .setClass("iv-sub-setting") + .addToggle((toggle) => { + toggle.onChange((val) => { + this.updatePlaysetState({ + starforgedIncludeSunderedIslesRecommended: val, + }); + }); + subToggles["starforgedIncludeSunderedIslesRecommended"] = toggle; + }) + .then((setting) => { + subToggleSettings["starforged"].push(setting); + }); + + new Setting(contentEl) + .setName("Sundered Isles") + .addToggle((toggle) => { + toggle.onChange((val) => { + if (val) { + this.updatePlaysetState({ base: "sundered_isles" }); + } + }); + + toggles["sundered_isles"] = toggle; + }) + .then((siSetting) => { + const siImg = document.createElement("img"); + siImg.src = SI_LOGO; + siImg.toggleClass("ruleset-img", true); + siSetting.settingEl.prepend(siImg); + }); + + new Setting(contentEl) + .setDesc( + "Include 'technological' assets from Starforged and Sundered Isles", + ) + .setClass("iv-sub-setting") + .addToggle((toggle) => { + toggle.onChange((val) => { + this.updatePlaysetState({ + sunderedIslesIncludeTechnologicalAssets: val, + }); + }); + subToggles["sunderedIslesIncludeTechnologicalAssets"] = toggle; + }) + .then((setting) => { + subToggleSettings["sundered_isles"].push(setting); + }); + new Setting(contentEl) + .setDesc( + "Include 'supernatural' assets from Starforged and Sundered Isles", + ) + .setClass("iv-sub-setting") + .addToggle((toggle) => { + toggle.onChange((val) => { + this.updatePlaysetState({ + sunderedIslesIncludeSupernaturalAssets: val, + }); + }); + subToggles["sunderedIslesIncludeSupernaturalAssets"] = toggle; + }) + .then((setting) => { + subToggleSettings["sundered_isles"].push(setting); + }); + + new Setting(contentEl) + .setName("Custom") + .setDesc("Define your own playset and customize the content you include") + + .addButton((button) => { + button + .setButtonText("Configure") + .onClick(async () => { + const { playset, customConfig } = await PlaysetEditor.open( + this.plugin.app, + this.plugin.datastore.indexer, + this.playsetKey(), + this.playsetState.customConfig, + ); + + this.setFromKeyAndConfig(playset, customConfig); + }) + .setTooltip("View playsets or configure a custom playset"); + }) + .addToggle((toggle) => { + toggle.onChange((val) => { + if (val) { + this.updatePlaysetState({ base: "custom" }); + } + }); + + toggles["custom"] = toggle; + }); + } + + playsetKey(): string { + if (this.playsetState.base == "custom") { + return this.playsetState.customPlaysetChoice ?? "custom"; + } else { + const standardPlayset = Object.entries(STANDARD_PLAYSET_SETTINGS).find( + ([_key, settings]) => isMatch(this.playsetState, settings), + ); + if (standardPlayset) { + return standardPlayset[0]; + } else { + logger.warn("Unable to determine playset for configuration"); + return "custom"; + } + } + } + + setFromKeyAndConfig(playset: string, customConfig: string) { + if (playset == "custom") { + this.updatePlaysetState({ + base: "custom", + customConfig, + customPlaysetChoice: null, + }); + } else { + const standardSettings = Object.entries(STANDARD_PLAYSET_SETTINGS).find( + ([standardPlayset]) => standardPlayset === playset, + ); + if (standardSettings) { + this.updatePlaysetState(standardSettings[1]); + } else { + this.updatePlaysetState({ + base: "custom", + customPlaysetChoice: playset, + }); + } + } + } + + /** Determines if this is a valid playset setting. */ + isValid(): boolean { + if (this.playsetState.base == "custom") { + try { + PlaysetConfig.parseFile(this.playsetState.customConfig); + } catch (e) { + return false; + } + } + + return true; + } + + onChange(cb?: (setting: this) => void | Promise): this { + this.changeCallback = cb; + return this; + } + + get customConfig(): string { + return this.playsetState.customConfig; + } +} diff --git a/src/character-tracker.ts b/src/character-tracker.ts index 3eda4dd7..1d390b57 100644 --- a/src/character-tracker.ts +++ b/src/character-tracker.ts @@ -1,9 +1,11 @@ -import { CampaignTrackedEntities } from "campaigns/context"; +import { CampaignDataContext } from "campaigns/context"; import { CampaignFile } from "campaigns/entity"; +import { CampaignManager } from "campaigns/manager"; import { CharacterActionContext } from "characters/action-context"; import IronVaultPlugin from "index"; import { onlyValid } from "indexer/index-impl"; import { TFile, type CachedMetadata } from "obsidian"; +import { Left } from "utils/either"; import { CustomSuggestModal } from "utils/suggest"; import { updaterWithContext } from "utils/update"; import { z } from "zod"; @@ -14,14 +16,20 @@ import { characterLens, } from "./characters/lens"; import { IronVaultKind } from "./constants"; -import { Datastore } from "./datastore"; -import { BaseIndexer, IndexOf, IndexUpdate } from "./indexer/indexer"; +import { + BaseIndexer, + IndexOf, + IndexUpdate, + UnexpectedIndexingError, +} from "./indexer/indexer"; export class CharacterError extends Error {} -export class MissingCharacterError extends Error {} +export class MissingCharacterError extends CharacterError {} + +export class MissingCampaignError extends CharacterError {} -export class InvalidCharacterError extends Error {} +export class InvalidCharacterError extends CharacterError {} export class CharacterIndexer extends BaseIndexer< CharacterContext, @@ -29,7 +37,7 @@ export class CharacterIndexer extends BaseIndexer< > { readonly id = IronVaultKind.Character; - constructor(protected readonly dataStore: Datastore) { + constructor(protected readonly campaignManager: CampaignManager) { super(); } @@ -37,10 +45,13 @@ export class CharacterIndexer extends BaseIndexer< file: TFile, cache: CachedMetadata, ): IndexUpdate { - if (cache.frontmatter == null) { - throw new Error("missing frontmatter cache"); + const campaign = this.campaignManager.watchForReindex(file.path); + if (campaign == null) { + // TODO(@cwegrzyn): this should yield the real error, but then I have to update the stuff that expects a zod error + return Left.create(new UnexpectedIndexingError("missing campaign")); } - const { validater, lens } = characterLens(this.dataStore.ruleset); + const context = this.campaignManager.campaignContextFor(campaign); + const { validater, lens } = characterLens(context.ruleset); return validater(cache.frontmatter).map( (character) => new CharacterContext(character, lens, validater), ); @@ -67,7 +78,7 @@ export type CharacterTracker = IndexOf; export function currentActiveCharacterForCampaign( plugin: IronVaultPlugin, - campaignContext: CampaignTrackedEntities, + campaignContext: CampaignDataContext, ): CharacterActionContext | undefined { const characters = onlyValid(campaignContext.characters); if (characters.size === 0) { @@ -105,7 +116,7 @@ export function currentActiveCharacterForCampaign( */ export async function requireActiveCharacterForCampaign( plugin: IronVaultPlugin, - campaignContext: CampaignTrackedEntities, + campaignContext: CampaignDataContext, ): Promise { let activeCharacter = currentActiveCharacterForCampaign( plugin, @@ -126,7 +137,7 @@ export async function requireActiveCharacterForCampaign( /** Shows a suggest modal listing the current campaign characters. */ export async function promptForCampaignCharacter( plugin: IronVaultPlugin, - campaignContext: CampaignTrackedEntities, + campaignContext: CampaignDataContext, ): Promise { // TODO(@cwegrzyn): would be nice if this showed the current active character when one is available const [path, charCtx] = await CustomSuggestModal.select( diff --git a/src/characters/action-context.ts b/src/characters/action-context.ts index 74df2b2f..803353a6 100644 --- a/src/characters/action-context.ts +++ b/src/characters/action-context.ts @@ -1,10 +1,13 @@ -import { CampaignTrackedEntities } from "campaigns/context"; +import { CampaignDataContext } from "campaigns/context"; import { determineCampaignContext } from "campaigns/manager"; +import { IDataContext } from "datastore/data-context"; import { StandardIndex } from "datastore/data-indexer"; -import { DataswornTypes, moveOrigin } from "datastore/datasworn-indexer"; +import { DataswornTypes } from "datastore/datasworn-indexer"; +import { moveOrigin } from "datastore/datasworn-symbols"; import { produce } from "immer"; import { App, MarkdownFileInfo } from "obsidian"; -import { ConditionMeterDefinition } from "rules/ruleset"; +import { OracleRoller } from "oracles/roller"; +import { ConditionMeterDefinition, Ruleset } from "rules/ruleset"; import { vaultProcess } from "utils/obsidian"; import { CharacterContext, @@ -25,13 +28,8 @@ import { type Datastore } from "../datastore"; import IronVaultPlugin from "../index"; import { InfoModal } from "../utils/ui/info"; -export interface IDataContext { - readonly moves: StandardIndex; - readonly assets: StandardIndex; -} - export interface IActionContext extends IDataContext { - readonly campaignContext: CampaignTrackedEntities; + readonly campaignContext: CampaignDataContext; readonly kind: "no_character" | "character"; readonly rollables: (MeterWithLens | MeterWithoutLens)[]; readonly conditionMeters: ( @@ -39,6 +37,8 @@ export interface IActionContext extends IDataContext { | MeterWithoutLens )[]; + readonly oracleRoller: OracleRoller; + readonly momentum?: number; getWithLens(op: (lenses: CharacterLens) => CharReader): T | undefined; @@ -50,19 +50,43 @@ export class NoCharacterActionConext implements IActionContext { constructor( public readonly datastore: Datastore, - public readonly campaignContext: CampaignTrackedEntities, + public readonly campaignContext: CampaignDataContext, ) {} + get oracleRoller(): OracleRoller { + return this.campaignContext.oracleRoller; + } + + get rulesPackages() { + return this.campaignContext.rulesPackages; + } + + get ruleset(): Ruleset { + return this.campaignContext.ruleset; + } + get moves() { - return this.datastore.moves; + return this.campaignContext.moves; } get assets() { - return this.datastore.assets; + return this.campaignContext.assets; + } + + get moveCategories() { + return this.campaignContext.moveCategories; + } + + get oracles() { + return this.campaignContext.oracles; + } + + get truths() { + return this.campaignContext.truths; } get rollables(): MeterWithoutLens[] { - return Object.entries(this.datastore.ruleset.stats).map(([key, stat]) => ({ + return Object.entries(this.ruleset.stats).map(([key, stat]) => ({ key, definition: stat, lens: undefined, @@ -76,7 +100,7 @@ export class NoCharacterActionConext implements IActionContext { get conditionMeters(): MeterWithoutLens[] { return [ - ...Object.entries(this.datastore.ruleset.condition_meters).map( + ...Object.entries(this.ruleset.condition_meters).map( ([key, definition]) => ({ key, definition, @@ -95,27 +119,35 @@ export class CharacterActionContext implements IActionContext { constructor( public readonly datastore: Datastore, - public readonly campaignContext: CampaignTrackedEntities, + public readonly campaignContext: CampaignDataContext, public readonly characterPath: string, public readonly characterContext: CharacterContext, ) {} + get oracleRoller(): OracleRoller { + return this.campaignContext.oracleRoller; + } + + get rulesPackages() { + return this.campaignContext.rulesPackages; + } + + get ruleset(): Ruleset { + return this.campaignContext.ruleset; + } + get assets(): StandardIndex { - return this.datastore.assets; + return this.campaignContext.assets; } get moves(): StandardIndex { if (!this.#moves) { - // TODO: might want to rethink this given the new set up. + // 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"); - // .map(({ move, asset }) => - // produce(move, (draft) => { - // draft.name = `${asset.name}: ${move.name}`; - // }), - // ); - this.#moves = this.datastore.moves.projected((move) => { + + this.#moves = this.campaignContext.moves.projected((move) => { if (move[moveOrigin].assetId == null) return move; const assetMove = characterMoves.find( ({ move: characterMove }) => move._id === characterMove._id, @@ -131,6 +163,18 @@ export class CharacterActionContext implements IActionContext { return this.#moves; } + get moveCategories() { + return this.campaignContext.moveCategories; + } + + get oracles() { + return this.campaignContext.oracles; + } + + get truths() { + return this.campaignContext.truths; + } + get rollables(): MeterWithLens[] { return rollablesReader(this.characterContext.lens, this).get( this.characterContext.character, diff --git a/src/characters/assets.test.ts b/src/characters/assets.test.ts index 34e594b5..f8b9f033 100644 --- a/src/characters/assets.test.ts +++ b/src/characters/assets.test.ts @@ -1,5 +1,5 @@ import { type Datasworn } from "@datasworn/core"; -import { Datastore } from "datastore"; +import { IDataContext, MockDataContext } from "datastore/data-context"; import { produce } from "immer"; import { integratedAssetLens, walkAsset } from "./assets"; @@ -204,13 +204,16 @@ describe("walkAsset", () => { }); describe("integratedAssetLens", () => { - const mockDatastore = { - assets: new Map([[starship()._id, starship()]]), - }; + let dataContext: IDataContext; + + beforeEach(() => { + dataContext = new MockDataContext({ assets: [starship()] }); + }); + describe("#get", () => { it("updates marked abilities", () => { expect( - integratedAssetLens(mockDatastore as unknown as Datastore).get({ + integratedAssetLens(dataContext).get({ id: starship()._id, abilities: [true, false, true], options: {}, @@ -223,7 +226,7 @@ describe("integratedAssetLens", () => { it("integrates option values", () => { expect( - integratedAssetLens(mockDatastore as unknown as Datastore).get({ + integratedAssetLens(dataContext).get({ id: starship()._id, abilities: [true, false, false], options: {}, @@ -231,7 +234,7 @@ describe("integratedAssetLens", () => { }), ).toHaveProperty("options.label.value", null); expect( - integratedAssetLens(mockDatastore as unknown as Datastore).get({ + integratedAssetLens(dataContext).get({ id: starship()._id, abilities: [true, false, false], options: { label: "arclight" }, @@ -241,7 +244,7 @@ describe("integratedAssetLens", () => { }); it("integrates meter values", () => { expect( - integratedAssetLens(mockDatastore as unknown as Datastore).get({ + integratedAssetLens(dataContext).get({ id: starship()._id, abilities: [true, false, false], options: {}, @@ -249,7 +252,7 @@ describe("integratedAssetLens", () => { }), ).toHaveProperty("controls.integrity.value", 5); expect( - integratedAssetLens(mockDatastore as unknown as Datastore).get({ + integratedAssetLens(dataContext).get({ id: starship()._id, abilities: [true, false, false], options: {}, @@ -260,7 +263,7 @@ describe("integratedAssetLens", () => { it("integrates meter subfield values", () => { expect( - integratedAssetLens(mockDatastore as unknown as Datastore).get({ + integratedAssetLens(dataContext).get({ id: starship()._id, abilities: [true, false, false], options: {}, @@ -268,7 +271,7 @@ describe("integratedAssetLens", () => { }), ).toHaveProperty("controls.integrity.controls.battered.value", false); expect( - integratedAssetLens(mockDatastore as unknown as Datastore).get({ + integratedAssetLens(dataContext).get({ id: starship()._id, abilities: [true, false, false], options: {}, @@ -279,7 +282,7 @@ describe("integratedAssetLens", () => { it("integrates meter subfield values", () => { expect( - integratedAssetLens(mockDatastore as unknown as Datastore).get({ + integratedAssetLens(dataContext).get({ id: starship()._id, abilities: [true, false, false], options: {}, @@ -287,7 +290,7 @@ describe("integratedAssetLens", () => { }), ).toHaveProperty("controls.integrity.controls.battered.value", false); expect( - integratedAssetLens(mockDatastore as unknown as Datastore).get({ + integratedAssetLens(dataContext).get({ id: starship()._id, abilities: [true, false, false], options: {}, @@ -298,7 +301,7 @@ describe("integratedAssetLens", () => { it("integrates ability values", () => { expect( - integratedAssetLens(mockDatastore as unknown as Datastore).get({ + integratedAssetLens(dataContext).get({ id: starship()._id, abilities: [false, false, false], options: {}, @@ -306,7 +309,7 @@ describe("integratedAssetLens", () => { }), ).toHaveProperty("abilities.0.options.made_up.value", null); expect( - integratedAssetLens(mockDatastore as unknown as Datastore).get({ + integratedAssetLens(dataContext).get({ id: starship()._id, abilities: [false, false, false], options: { "0/made_up": "foo" }, @@ -318,7 +321,7 @@ describe("integratedAssetLens", () => { describe("update", () => { expect( - integratedAssetLens(mockDatastore as unknown as Datastore).update( + integratedAssetLens(dataContext).update( { id: starship()._id, abilities: [false, false, false], diff --git a/src/characters/assets.ts b/src/characters/assets.ts index 74195e05..d51294f2 100644 --- a/src/characters/assets.ts +++ b/src/characters/assets.ts @@ -1,10 +1,9 @@ import { type Datasworn } from "@datasworn/core"; -import { Datastore } from "datastore"; +import { IDataContext } from "datastore/data-context"; import { produce } from "immer"; import { ConditionMeterDefinition } from "rules/ruleset"; import { Either, Left, Right } from "../utils/either"; import { Lens, addOrUpdateMatching, reader, writer } from "../utils/lens"; -import { IDataContext } from "./action-context"; import { CharLens, CharReader, @@ -154,10 +153,10 @@ export function addOrUpdateAssetData( */ export function addOrUpdateViaDataswornAsset( charLens: CharacterLens, - datastore: Datastore, + dataContext: IDataContext, ): CharWriter { const assetDataWriter = addOrUpdateAssetData(charLens); - const assetLens = integratedAssetLens(datastore); + const assetLens = integratedAssetLens(dataContext); return writer((source, newval) => { return assetDataWriter.update( @@ -177,11 +176,11 @@ function assetKey(key: string, parentKey: string | number | undefined): string { } export function integratedAssetLens( - datastore: Datastore, + dataContext: IDataContext, ): Lens { return { get(assetData) { - const dataswornAsset = datastore.assets.get(assetData.id); + const dataswornAsset = dataContext.assets.get(assetData.id); if (!dataswornAsset) { throw new AssetError(`unable to find asset ${assetData.id}`); } diff --git a/src/characters/character-block.ts b/src/characters/character-block.ts index ba7cde4d..c2f758f3 100644 --- a/src/characters/character-block.ts +++ b/src/characters/character-block.ts @@ -7,6 +7,7 @@ import Sortable from "sortablejs"; import { Asset } from "@datasworn/core/dist/Datasworn"; import IronVaultPlugin from "index"; import { normalizePath } from "obsidian"; +import { Ruleset } from "rules/ruleset"; import { IronVaultPluginSettings } from "settings"; import { ProgressTrack, legacyTrackXpEarned } from "tracks/progress"; import { renderTrack } from "tracks/track-block"; @@ -23,7 +24,7 @@ import { CharacterContext, setActiveCharacter } from "../character-tracker"; import { CharacterActionContext } from "./action-context"; import { addOrUpdateViaDataswornAsset } from "./assets"; import { addAssetToCharacter } from "./commands"; -import { CharacterLens, ValidatedCharacter, momentumOps } from "./lens"; +import { ValidatedCharacter, momentumOps } from "./lens"; export default function registerCharacterBlocks(plugin: IronVaultPlugin): void { registerBlock(); @@ -204,26 +205,20 @@ class CharacterRenderer extends TrackedEntityRenderer< @change=${charBoolFieldUpdater(lens.initiative)} >
-
- ${this.plugin.settings.enableIronsworn - ? "Alias" - : this.plugin.settings.enableSunderedIsles - ? "Moniker" - : "Callsign"} -
+
${labelForCharacterCallsign(campaignContext.ruleset)}
{ charCtx.updater(vaultProcess(this.plugin.app, this.sourcePath), (char) => - addOrUpdateViaDataswornAsset(lens, this.plugin.datastore).update( + addOrUpdateViaDataswornAsset(lens, this.campaignContext()).update( char, asset, ), @@ -551,7 +546,12 @@ class CharacterRenderer extends TrackedEntityRenderer< > ✕ - ${renderAssetCard(this.plugin, asset, updateAsset)} + ${renderAssetCard( + this.plugin, + this.campaignContext(), + asset, + updateAsset, + )} `, )} @@ -582,24 +582,54 @@ class CharacterRenderer extends TrackedEntityRenderer< `; } +} - initiativeValueLabel(lens: CharacterLens, val: boolean | undefined) { - const labels = []; - if (val === true && lens.ruleset.ids.contains("classic")) { - labels.push("Has initiative"); - } - if (val === false && lens.ruleset.ids.contains("classic")) { - labels.push("No initiative"); +/** Returns the ruleset-appropriate label for the initiative statuses. */ +export function labelForCharacterInitiativeValue( + ruleset: Ruleset, + val: boolean | undefined, +) { + // TODO(@cwegrzyn): Ideally, this is somewhat configurable in some way that + // lets third-party rulesets change it. + if (ruleset.baseRulesetId == "classic") { + if (val === true) { + return "Has initiative"; + } else if (val === false) { + return "No initiative"; } - if (val === true && lens.ruleset.ids.contains("starforged")) { - labels.push("In control"); + } else { + // Use Starforged language for any other base ruleset + if (val === true) { + return "In control"; + } else if (val === false) { + return "In a bad spot"; } - if (val === false && lens.ruleset.ids.contains("starforged")) { - labels.push("In a bad spot"); - } - if (val == null) { - labels.push("Out of combat"); + } + + return "Out of combat"; +} + +// TODO(@cwegrzyn): once we have a setting that controls which version of position/initiative +// to use, we'll use that instead here and in determining the label. +export function labelForCharacterInitiative( + ruleset: Ruleset, +): "initiative" | "position" { + return ruleset.baseRulesetId == "classic" ? "initiative" : "position"; +} + +/** Returns the ruleset-appropriate label for the callsign field. */ +export function labelForCharacterCallsign(ruleset: Ruleset): string { + // TODO(@cwegrzyn): Ideally, this is somewhat configurable in some way that + // lets third-party rulesets change it. + if (ruleset.baseRulesetId == "starforged") { + if (ruleset.rulesPackageIds.includes("sundered_isles")) { + return "Moniker"; + } else { + return "Callsign"; } - return labels.join("/"); + } else { + // This is the language used in base Ironsworn (e.g., ruleset == 'classic') + // and is most generic, so making it the default. + return "Alias"; } } diff --git a/src/characters/commands.ts b/src/characters/commands.ts index 6a6fee25..8e664a7d 100644 --- a/src/characters/commands.ts +++ b/src/characters/commands.ts @@ -1,6 +1,7 @@ import { type Datasworn } from "@datasworn/core"; import { Asset } from "@datasworn/core/dist/Datasworn"; import { AssetPickerModal } from "assets/asset-picker-modal"; +import { CampaignDataContext } from "campaigns/context"; import { determineCampaignContext } from "campaigns/manager"; import { promptForCampaignCharacter, @@ -11,7 +12,6 @@ import IronVaultPlugin from "index"; import { appendNodesToMoveOrMechanicsBlockWithActor } from "mechanics/editor"; import { createInitiativeNode } from "mechanics/node-builders"; import { Editor, MarkdownFileInfo, MarkdownView } from "obsidian"; -import { Ruleset } from "rules/ruleset"; import { createNewIronVaultEntityFile, vaultProcess } from "utils/obsidian"; import { capitalize } from "utils/strings"; import { CustomSuggestModal } from "utils/suggest"; @@ -26,6 +26,10 @@ import { defaultMarkedAbilitiesForAsset, walkAsset, } from "./assets"; +import { + labelForCharacterInitiative, + labelForCharacterInitiativeValue, +} from "./character-block"; import { characterLens, createValidCharacter } from "./lens"; import { CharacterCreateModal } from "./ui/new-character-modal"; @@ -47,7 +51,7 @@ export async function addAssetToCharacter( const characterAssets = lens.assets.get(character); const availableAssets: Datasworn.Asset[] = []; - for (const asset of plugin.datastore.assets.values()) { + for (const asset of actionContext.assets.values()) { if (!characterAssets.find(({ id }) => id === asset._id)) { // Character does not have this asset availableAssets.push(asset); @@ -116,7 +120,7 @@ export async function addAssetToCharacter( }); await context.updater(vaultProcess(plugin.app, path), (char) => - addOrUpdateViaDataswornAsset(lens, plugin.datastore).update( + addOrUpdateViaDataswornAsset(lens, actionContext).update( char, updatedAsset, ), @@ -125,9 +129,10 @@ export async function addAssetToCharacter( export async function createNewCharacter( plugin: IronVaultPlugin, + campaignContext: CampaignDataContext, defaultFolder?: string, ) { - const { lens, validater } = characterLens(plugin.datastore.ruleset); + const { lens, validater } = characterLens(campaignContext.ruleset); const { fileName, name, targetFolder } = await CharacterCreateModal.show( plugin, @@ -167,29 +172,6 @@ export async function createNewCharacter( ); } -export function initiativeValueLabel( - ruleset: Ruleset, - val: boolean | undefined, -): string { - const labels = []; - if (val === true && ruleset.ids.contains("classic")) { - labels.push("Has initiative"); - } - if (val === false && ruleset.ids.contains("classic")) { - labels.push("No initiative"); - } - if (val === true && ruleset.ids.contains("starforged")) { - labels.push("In control"); - } - if (val === false && ruleset.ids.contains("starforged")) { - labels.push("In a bad spot"); - } - if (val == null) { - labels.push("Out of combat"); - } - return labels.join("/"); -} - export const changeInitiative = async ( plugin: IronVaultPlugin, editor: Editor, @@ -197,7 +179,7 @@ export const changeInitiative = async ( ) => { const actionContext = await requireActiveCharacterContext(plugin, view); - const ruleset = actionContext.datastore.ruleset; + const ruleset = actionContext.ruleset; const { character, lens } = actionContext.characterContext; @@ -206,7 +188,7 @@ export const changeInitiative = async ( const newInitiative = await CustomSuggestModal.select( plugin.app, [true, false, undefined], - (n) => initiativeValueLabel(ruleset, n), + (n) => labelForCharacterInitiativeValue(ruleset, n), undefined, `Choose the new value for your initiative/position.`, ); @@ -220,11 +202,9 @@ export const changeInitiative = async ( plugin, actionContext, createInitiativeNode( - // TODO(@cwegrzyn): once we have a setting that controls which version of position/initiative - // to use, we'll use that instead here and in determining the label. - ruleset.ids.contains("starforged") ? "position" : "initiative", - initiativeValueLabel(ruleset, oldInitiative).toLowerCase(), - initiativeValueLabel(ruleset, newInitiative).toLowerCase(), + labelForCharacterInitiative(ruleset), + labelForCharacterInitiativeValue(ruleset, oldInitiative).toLowerCase(), + labelForCharacterInitiativeValue(ruleset, newInitiative).toLowerCase(), ), ); }; diff --git a/src/characters/lens.test.ts b/src/characters/lens.test.ts index 2ff9264c..85dd0b8e 100644 --- a/src/characters/lens.test.ts +++ b/src/characters/lens.test.ts @@ -1,11 +1,10 @@ import { type Datasworn } from "@datasworn/core"; import starforgedData from "@datasworn/starforged/json/starforged.json" with { type: "json" }; -import { VersionedMapImpl } from "utils/versioned-map"; +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 { IDataContext } from "./action-context"; import { BaseIronVaultSchema, IronVaultSheetAssetInput, @@ -18,49 +17,56 @@ import { } from "./lens"; const STARFORGED_RULESET = new Ruleset( - ["starforged"], - starforgedData.rules as Datasworn.Rules, + starforgedData as unknown as Datasworn.Ruleset, + [], ); -const TEST_RULESET = new Ruleset(["test"], { - condition_meters: { - health: { - label: "health", - description: "aka hp", - min: 0, - max: 5, - rollable: true, - shared: false, - value: 3, - }, - }, - stats: { - wits: { description: "thinking", label: "wits" }, - }, - impacts: { - misfortunes: { - label: "misfortunes", - description: "Oh no", - contents: { - wounded: { - label: "wounded", - prevents_recovery: ["health"], - permanent: false, + +const TEST_RULESET = new Ruleset( + { + _id: "test", + rules: { + condition_meters: { + health: { + label: "health", + description: "aka hp", + min: 0, + max: 5, + rollable: true, shared: false, - description: "You are severely injured.", + value: 3, }, - disappointed: { - label: "disappointed", - description: "You are disappointed", - permanent: false, - shared: false, - prevents_recovery: [], + }, + stats: { + wits: { description: "thinking", label: "wits" }, + }, + impacts: { + misfortunes: { + label: "misfortunes", + description: "Oh no", + contents: { + wounded: { + label: "wounded", + prevents_recovery: ["health"], + permanent: false, + shared: false, + description: "You are severely injured.", + }, + disappointed: { + label: "disappointed", + description: "You are disappointed", + permanent: false, + shared: false, + prevents_recovery: [], + }, + }, }, }, + special_tracks: {}, + tags: {}, }, - }, - special_tracks: {}, - tags: {}, -}); + } as unknown as Datasworn.Ruleset, + [], +); const VALID_INPUT = { name: "Bob", @@ -254,23 +260,19 @@ describe("momentumOps", () => { }); function createMockDataContext(...assets: Datasworn.Asset[]): IDataContext { - const assetMap = new VersionedMapImpl(); - for (const asset of assets) { - assetMap.set(asset._id, asset); - } - return { - assets: assetMap, - moves: new VersionedMapImpl(), - }; + return new MockDataContext({ assets }); } describe("movesReader", () => { let mockDataContext: IDataContext; beforeAll(() => { - mockDataContext = createMockDataContext( - starforgedData.assets.path.contents.empath as unknown as Datasworn.Asset, - ); + mockDataContext = new MockDataContext({ + assets: [ + starforgedData.assets.path.contents + .empath as unknown as Datasworn.Asset, + ], + }); }); describe("moves", () => { @@ -399,18 +401,23 @@ describe("meterLenses", () => { }); describe("Special Tracks", () => { - const { validater, lens } = characterLens({ - ...TEST_RULESET, - id: TEST_RULESET.id, - special_tracks: { - quests_legacy: { - label: "quests", - optional: false, - shared: false, - description: "Swear vows and do what you must to see them fulfilled.", + const { validater, lens } = characterLens( + TEST_RULESET.merge({ + _id: "moar", + type: "expansion", + rules: { + special_tracks: { + quests_legacy: { + label: "quests", + optional: false, + shared: false, + description: + "Swear vows and do what you must to see them fulfilled.", + }, + }, }, - }, - }); + } as unknown as Datasworn.Expansion), + ); it("defaults Progress field to 0", () => { expect( diff --git a/src/characters/lens.ts b/src/characters/lens.ts index 4c1aa172..faa0df70 100644 --- a/src/characters/lens.ts +++ b/src/characters/lens.ts @@ -1,4 +1,5 @@ import { type Datasworn } from "@datasworn/core"; +import { IDataContext } from "datastore/data-context"; import { ensureUnique } from "utils/ensure-unique"; import { zodResultToEither } from "utils/zodutils"; import { z } from "zod"; @@ -23,7 +24,6 @@ import { reader, updating, } from "../utils/lens"; -import { IDataContext } from "./action-context"; import { AssetError, assetMeters, assetWithDefnReader } from "./assets"; const ValidationTag: unique symbol = Symbol("validated ruleset"); @@ -140,7 +140,7 @@ export function validatedAgainst( ruleset: Ruleset, data: ValidatedCharacter, ): boolean { - return data[ValidationTag] === ruleset.id; + return data[ValidationTag] === ruleset.validationTag; } export function validated( @@ -150,25 +150,25 @@ export function validated( ) => Lens { return (lens: Lens): Lens => ({ get(a) { - if (a[ValidationTag] !== ruleset.id) { + if (a[ValidationTag] !== ruleset.validationTag) { throw new Error( - `expecting validation tag of ${ruleset.id}; found ${a[ValidationTag]}`, + `expecting validation tag of ${ruleset.validationTag}; found ${a[ValidationTag]}`, ); } // TODO: better way to deal with this (e.g., some way to verify that the ruleset specifies this?) return lens.get(a.raw as U); }, update(a, b) { - if (a[ValidationTag] !== ruleset.id) { + if (a[ValidationTag] !== ruleset.validationTag) { throw new Error( - `expecting validation tag of ${ruleset.id}; found ${a[ValidationTag]}`, + `expecting validation tag of ${ruleset.validationTag}; found ${a[ValidationTag]}`, ); } const updated = lens.update(a.raw as U, b) as ValidatedCharacter; // If the lens does not change the raw value, return source as is. if (a.raw === updated) return a; // TODO: theoretically, could the return value fall out of validation? yes, in broken code. - return { [ValidationTag]: ruleset.id, raw: updated }; + return { [ValidationTag]: ruleset.validationTag, raw: updated }; }, }); } @@ -572,7 +572,7 @@ export function characterLens(ruleset: Ruleset): { function validater(data: unknown): Either { return zodResultToEither(schema.safeParse(data)).map((raw) => ({ raw, - [ValidationTag]: ruleset.id, + [ValidationTag]: ruleset.validationTag, })); } diff --git a/src/commands.ts b/src/commands.ts index 63316d3d..38c09926 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,4 +1,5 @@ import { createNewCampaignCommand } from "campaigns/commands"; +import { determineCampaignContext } from "campaigns/manager"; import { addAssetToCharacter, changeInitiative, @@ -8,6 +9,7 @@ import { import { advanceClock, createClock } from "clocks/commands"; import { openDocsInBrowser, openDocsInTab } from "docs/commands"; import { generateEntityCommand } from "entity/command"; +import { createFactionInfluenceGrid } from "factions/commands"; import IronVaultPlugin from "index"; import { insertComment } from "mechanics/commands"; import { checkIfMigrationNeededCommand } from "migrate/command"; @@ -27,7 +29,6 @@ import { } from "utils/ui/generic-fuzzy-suggester"; import { PromptModal } from "utils/ui/prompt"; import * as meterCommands from "./characters/meter-commands"; -import { createFactionInfluenceGrid } from "factions/commands"; export class IronVaultCommands { plugin: IronVaultPlugin; @@ -82,7 +83,16 @@ export class IronVaultCommands { id: "character-create", name: "Create new character", icon: "user-round", - callback: () => createNewCharacter(this.plugin), + callback: async () => + createNewCharacter( + this.plugin, + await determineCampaignContext(this.plugin), + ), + editorCallback: async (editor, view) => + createNewCharacter( + this.plugin, + await determineCampaignContext(this.plugin, view), + ), }, { id: "burn-momentum", @@ -252,6 +262,8 @@ export class IronVaultCommands { name: "Generate truths", icon: "book", callback: () => generateTruthsCommand(this.plugin), + editorCallback: (_editor, view) => + generateTruthsCommand(this.plugin, view), }, { id: "open-docs-in-tab", diff --git a/src/datastore.ts b/src/datastore.ts index e25f9744..03209fd7 100644 --- a/src/datastore.ts +++ b/src/datastore.ts @@ -1,42 +1,33 @@ import { type Datasworn } from "@datasworn/core"; -import { Rules } from "@datasworn/core/dist/Datasworn"; import dataswornSchema from "@datasworn/core/json/datasworn.schema.json" assert { type: "json" }; import ironswornDelvePackage from "@datasworn/ironsworn-classic-delve/json/delve.json" assert { type: "json" }; import ironswornRuleset from "@datasworn/ironsworn-classic/json/classic.json" assert { type: "json" }; import starforgedRuleset from "@datasworn/starforged/json/starforged.json" assert { type: "json" }; import sunderedIslesPackage from "@datasworn/sundered-isles/json/sundered_isles.json" assert { type: "json" }; import Ajv from "ajv"; -import { IDataContext } from "characters/action-context"; -import { - DataIndexer, - SourceTag, - StandardIndex, - getHighestPriority, - kindFiltered, -} from "datastore/data-indexer"; +import { BaseDataContext } from "datastore/data-context"; +import { DataIndexer } from "datastore/data-indexer"; import { DataswornIndexer, - DataswornTypes, createSource, walkDataswornRulesPackage, } from "datastore/datasworn-indexer"; import Emittery from "emittery"; import IronVaultPlugin from "index"; -import merge from "lodash.merge"; import { isDebugEnabled, rootLogger } from "logger"; -import { Oracle } from "model/oracle"; import { Component, Notice, TFile, TFolder, type App } from "obsidian"; -import { OracleRoller } from "oracles/roller"; -import { Ruleset } from "rules/ruleset"; import starforgedSupp from "../data/starforged.supplement.json" assert { type: "json" }; import sunderedSupp from "../data/sundered-isles.supplement.json" assert { type: "json" }; import { PLUGIN_DATASWORN_VERSION } from "./constants"; const logger = rootLogger.getLogger("datastore"); -export class Datastore extends Component implements IDataContext { +export class Datastore extends Component { _ready: boolean; readonly indexer: DataswornIndexer = new DataIndexer(); + readonly dataContext: BaseDataContext = new BaseDataContext( + this.indexer.dataMap, + ); readonly waitForReady: Promise; @@ -52,14 +43,7 @@ export class Datastore extends Component implements IDataContext { this.emitter = new Emittery(); this.plugin.settings.on("change", ({ key }) => { - if ( - key === "enableIronsworn" || - key === "enableStarforged" || - key === "enableIronswornDelve" || - key === "enableSunderedIsles" || - key === "useHomebrew" || - key === "homebrewPath" - ) { + if (key === "useHomebrew" || key === "homebrewPath") { this.initialize(); } }); @@ -77,25 +61,17 @@ export class Datastore extends Component implements IDataContext { this._ready = false; this.indexer.clear(); - if (this.plugin.settings.enableIronsworn) { - this.indexBuiltInData(ironswornRuleset as Datasworn.Ruleset); - } + this.indexBuiltInData(ironswornRuleset as Datasworn.Ruleset); - if (this.plugin.settings.enableIronswornDelve) { - // @ts-expect-error tsc seems to infer type of data in an incompatible way - this.indexBuiltInData(ironswornDelvePackage as Datasworn.Expansion); - } + // @ts-expect-error tsc seems to infer type of data in an incompatible way + this.indexBuiltInData(ironswornDelvePackage as Datasworn.Expansion); - if (this.plugin.settings.enableStarforged) { - // @ts-expect-error tsc seems to infer type of data in an incompatible way - this.indexBuiltInData(starforgedRuleset as Datasworn.Ruleset); - this.indexBuiltInData(starforgedSupp as Datasworn.Expansion, 5); - } + // @ts-expect-error tsc seems to infer type of data in an incompatible way + this.indexBuiltInData(starforgedRuleset as Datasworn.Ruleset); + this.indexBuiltInData(starforgedSupp as Datasworn.Expansion, 5); - if (this.plugin.settings.enableSunderedIsles) { - this.indexBuiltInData(sunderedIslesPackage as Datasworn.Expansion); - this.indexBuiltInData(sunderedSupp as Datasworn.Expansion, 5); - } + this.indexBuiltInData(sunderedIslesPackage as Datasworn.Expansion); + this.indexBuiltInData(sunderedSupp as Datasworn.Expansion, 5); if (this.plugin.settings.useHomebrew) { if (this.plugin.settings.homebrewPath) { @@ -120,10 +96,10 @@ export class Datastore extends Component implements IDataContext { this._ready = true; console.info( "iron-vault: init complete. loaded: %d oracles, %d moves, %d assets, %d truths", - this.oracles.size, - this.moves.size, - this.assets.size, - this.truths.size, + this.dataContext.oracles.size, + this.dataContext.moves.size, + this.dataContext.assets.size, + this.dataContext.truths.size, ); this.emitter.emit("initialized"); this.#readyNow(); @@ -148,18 +124,13 @@ export class Datastore extends Component implements IDataContext { const source = createSource({ path: mainPath, priority, - sourceTags: { [SourceTag.RulesetId]: pkg._id }, }); - this.indexer.index( - source, - walkDataswornRulesPackage(source, pkg, this.plugin), - ); + this.indexer.index(source, walkDataswornRulesPackage(pkg)); this.app.metadataCache.trigger("iron-vault:index-changed"); } removeBuiltInData(pkg: Datasworn.RulesPackage) { - // TODO: properly support this. const mainPath = `@datasworn:${pkg._id}`; this.indexer.removeSource(mainPath); this.app.metadataCache.trigger("iron-vault:index-changed"); @@ -191,18 +162,13 @@ export class Datastore extends Component implements IDataContext { continue; } const dataswornPackage = data as Datasworn.RulesPackage; - const rulesetId = - dataswornPackage.type == "ruleset" - ? dataswornPackage._id - : dataswornPackage.ruleset; const source = createSource({ path: file.path, priority: 10, - sourceTags: { [SourceTag.RulesetId]: rulesetId }, }); this.indexer.index( source, - walkDataswornRulesPackage(source, dataswornPackage, this.plugin), + walkDataswornRulesPackage(dataswornPackage), ); } catch (e) { new Notice(`Unable to import homebrew file: ${file.basename}`, 0); @@ -291,79 +257,6 @@ export class Datastore extends Component implements IDataContext { return this._ready; } - get moves(): StandardIndex { - this.assertReady(); - return kindFiltered("move", this.indexer).projected( - (value) => getHighestPriority(value)?.value, - ); - } - - get moveCategories(): StandardIndex { - this.assertReady(); - return this.indexer.prioritized - .ofKind("move_category") - .projected((entry) => entry.value); - } - - get moveRulesets(): StandardIndex { - this.assertReady(); - return this.indexer.prioritized - .ofKind("move_ruleset") - .projected((entry) => entry.value); - } - - get oracles(): StandardIndex { - this.assertReady(); - return this.indexer.prioritized - .ofKind("oracle") - .projected((entry) => entry.value); - } - - get assets(): StandardIndex { - this.assertReady(); - return this.indexer.prioritized - .ofKind("asset") - .projected((entry) => entry.value); - } - - get truths(): StandardIndex { - this.assertReady(); - return this.indexer.prioritized - .ofKind("truth") - .projected((entry) => entry.value); - } - - get roller(): OracleRoller { - return new OracleRoller(this.oracles); - } - - get rulesPackages(): StandardIndex { - this.assertReady(); - return this.indexer.prioritized - .ofKind("rules_package") - .projected((entry) => entry.value); - } - - get ruleset(): Ruleset { - this.assertReady(); - - const ids: string[] = []; - const rules = [...this.indexer.prioritized.ofKind("rules_package").values()] - .map((pkg) => { - ids.push(pkg.id); - return pkg.value.rules; - }) - .reduce((acc, rules) => merge(acc, rules)) as Rules; - - return new Ruleset(ids, rules); - } - - private assertReady(): void { - if (!this._ready) { - throw new Error("data not loaded yet"); - } - } - on( event: K, listener: (params: EVENT_TYPES[K]) => void, diff --git a/src/datastore/data-context.ts b/src/datastore/data-context.ts new file mode 100644 index 00000000..5ce70d19 --- /dev/null +++ b/src/datastore/data-context.ts @@ -0,0 +1,126 @@ +import { Datasworn } from "@datasworn/core"; +import { Ruleset } from "rules/ruleset"; +import { VersionedMapImpl } from "utils/versioned-map"; +import { SourcedMap, SourcedMapImpl, StandardIndex } from "./data-indexer"; +import { DataswornIndex, DataswornTypes } from "./datasworn-indexer"; +import { moveOrigin, scopeSource, scopeTags } from "./datasworn-symbols"; + +export interface IDataContext { + readonly moves: StandardIndex; + readonly assets: StandardIndex; + readonly moveCategories: StandardIndex; + readonly oracles: StandardIndex; + readonly truths: StandardIndex; + + readonly rulesPackages: StandardIndex; + readonly ruleset: Ruleset; +} + +export interface ICompleteDataContext extends IDataContext { + readonly prioritized: SourcedMap; +} + +export class MockDataContext implements IDataContext { + readonly moves: StandardIndex; + readonly assets: StandardIndex; + readonly moveCategories: StandardIndex; + readonly oracles: StandardIndex; + readonly truths: StandardIndex; + + constructor({ + moves, + assets, + }: { + moves?: Datasworn.Move[]; + assets?: Datasworn.Asset[]; + }) { + this.moves = new VersionedMapImpl( + (moves ?? []).map((move) => [ + move._id, + { + ...move, + [moveOrigin]: {}, + [scopeSource]: move._source, + [scopeTags]: move.tags ?? {}, + }, + ]), + ); + this.assets = new VersionedMapImpl( + (assets ?? []).map((asset) => [ + asset._id, + { + ...asset, + [scopeSource]: asset._source, + [scopeTags]: asset.tags ?? {}, + }, + ]), + ); + this.moveCategories = new VersionedMapImpl(); + this.oracles = new VersionedMapImpl(); + this.truths = new VersionedMapImpl(); + } + + get rulesPackages(): StandardIndex { + throw new Error("not implemented"); + } + + get ruleset(): Ruleset { + throw new Error("not implemented"); + } +} + +// TODO(@cwegrzyn): make this cacheable +export class BaseDataContext implements ICompleteDataContext { + readonly prioritized: SourcedMap; + + constructor(public readonly index: DataswornIndex) { + this.prioritized = new SourcedMapImpl( + index, + ); + } + + get moves() { + return this.prioritized.ofKind("move").projected((entry) => entry.value); + } + + get assets() { + return this.prioritized.ofKind("asset").projected((entry) => entry.value); + } + + get moveCategories() { + return this.prioritized + .ofKind("move_category") + .projected((entry) => entry.value); + } + + get oracles() { + return this.prioritized.ofKind("oracle").projected((entry) => entry.value); + } + + get truths() { + return this.prioritized.ofKind("truth").projected((entry) => entry.value); + } + + get rulesPackages() { + return this.prioritized + .ofKind("rules_package") + .projected((entry) => entry.value); + } + + get ruleset() { + const rules = [...this.prioritized.ofKind("rules_package").values()].map( + ({ value }) => value, + ); + const base = rules.filter((pkg) => pkg.type == "ruleset"); + if (base.length == 0) { + throw new Error("Playset must include at least one base ruleset."); + } else if (base.length > 1) { + throw new Error( + `Playset may include only one base ruleset; found: ${base.map((pkg) => pkg._id).join(", ")}`, + ); + } + const expansions = rules.filter((pkg) => pkg.type == "expansion"); + + return new Ruleset(base[0], expansions); + } +} diff --git a/src/datastore/data-indexer.ts b/src/datastore/data-indexer.ts index 5efdc17c..e1e6827b 100644 --- a/src/datastore/data-indexer.ts +++ b/src/datastore/data-indexer.ts @@ -8,16 +8,10 @@ import { const logger = rootLogger.getLogger("data-indexer"); -export enum SourceTag { - RulesetId = "ruleset-id", - ExpansionId = "expansion-id", -} - export type Source = { path: string; priority: number; keys: Set; - sourceTags: Partial>; }; export type SourcedKinds = { @@ -28,6 +22,10 @@ export type SourcedKindsArray = { [K in keyof Kinds]: Array>; }; +export type PreSourcedKinds = { + [K in keyof Kinds]: PreSourced; +}; + export type SourcedBy = SourcedKinds[keyof Kinds]; // export type SourcedBy< @@ -37,6 +35,8 @@ export type SourcedBy = SourcedKinds[keyof Kinds]; export type SourcedByArray = SourcedKindsArray[keyof Kinds]; +export type PreSourcedBy = PreSourcedKinds[keyof Kinds]; + export interface Sourced { readonly id: string; readonly source: Source; @@ -44,6 +44,8 @@ export interface Sourced { readonly value: V; } +export type PreSourced = Omit, "source">; + export function assertIsKind( sourced: SourcedBy, kind: K, @@ -185,24 +187,34 @@ export class SourcedMapImpl< } } +export type DataIndex> = + ReadonlyVersionedMap>; + export class DataIndexer> implements ProjectableMap> { /** Maps keys to source data */ - public readonly dataMap: VersionedMapImpl> = + private readonly _dataMap: VersionedMapImpl> = new VersionedMapImpl(); /** Index of sources to source details (including key set) */ public readonly sourceIndex: Map = new Map(); public readonly prioritized: SourcedMap = new SourcedMapImpl( - this.dataMap, + this._dataMap, ); projected( - callbackfn: (value: SourcedByArray, key: string) => U | undefined, + callbackfn: ( + value: SourcedKindsArray[K], + key: string, + ) => U | undefined, ): ProjectableMap { - return projectedVersionedMap(this.dataMap, callbackfn); + return projectedVersionedMap(this._dataMap, callbackfn); + } + + get dataMap(): DataIndex { + return this._dataMap; } forEach( @@ -214,41 +226,41 @@ export class DataIndexer> // eslint-disable-next-line @typescript-eslint/no-explicit-any thisArg?: any, ): void { - return this.dataMap.forEach(callbackfn, thisArg); + return this._dataMap.forEach(callbackfn, thisArg); } get(key: string): SourcedByArray | undefined { - return this.dataMap.get(key); + return this._dataMap.get(key); } has(key: string): boolean { - return this.dataMap.has(key); + return this._dataMap.has(key); } get size(): number { - return this.dataMap.size; + return this._dataMap.size; } entries(): IterableIterator<[string, SourcedByArray]> { - return this.dataMap.entries(); + return this._dataMap.entries(); } keys(): IterableIterator { - return this.dataMap.keys(); + return this._dataMap.keys(); } values(): IterableIterator> { - return this.dataMap.values(); + return this._dataMap.values(); } [Symbol.iterator](): IterableIterator<[string, SourcedByArray]> { - return this.dataMap[Symbol.iterator](); + return this._dataMap[Symbol.iterator](); } constructor() {} clear(): void { - this.dataMap.clear(); + this._dataMap.clear(); this.sourceIndex.clear(); } @@ -270,9 +282,9 @@ export class DataIndexer> } // Remove all of the entries for this source - this.dataMap.asSingleRevision(() => { + this._dataMap.asSingleRevision(() => { for (const key of source.keys) { - const entries = this.dataMap.get(key); + const entries = this._dataMap.get(key); const sourceIndex = entries?.findIndex( ({ source: curSource }) => curSource.path === path, @@ -286,7 +298,7 @@ export class DataIndexer> } entries.splice(sourceIndex, 1); if (entries.length == 0) { - this.dataMap.delete(key); + this._dataMap.delete(key); } } }); @@ -295,14 +307,12 @@ export class DataIndexer> return true; } - // TODO: figure out a way to express type Omit, "source"> that then works when - // I try to add the source - index(source: Source, data: Iterable>): void { + index(source: Source, data: Iterable>): void { const { path } = source; logger.debug("[source:%s] Starting index", source.path); - this.dataMap.asSingleRevision(() => { + this._dataMap.asSingleRevision(() => { // TODO: maybe there is a more efficient way than removing and re-adding, but this will work this.removeSource(path); @@ -313,20 +323,15 @@ export class DataIndexer> for (const datum of data) { const { id } = datum; - // The data passed here should only ever originate from one source. - if (datum.source !== source) { - throw new Error(`datum ${id} had mismatched source`); - } - - const entries: SourcedByArray = this.dataMap.get(id) ?? []; + const entries: SourcedByArray = this._dataMap.get(id) ?? []; if (entries.length > 0 && entries[0].kind !== datum.kind) { throw new Error( `while indexing '${path}', '${id}' had kind '${String(datum.kind)}' which conflicted with existing kind '${String(entries[0].kind)}' from '${entries[0].source.path}'`, ); } keys.add(id); - entries.push(datum); - this.dataMap.set(id, entries); + entries.push({ ...datum, source }); + this._dataMap.set(id, entries); } logger.debug( @@ -346,7 +351,7 @@ export class DataIndexer> } get revision(): number { - return this.dataMap.revision; + return this._dataMap.revision; } } diff --git a/src/datastore/datasworn-indexer.test.ts b/src/datastore/datasworn-indexer.test.ts index 33ae5d4a..1c997f3c 100644 --- a/src/datastore/datasworn-indexer.test.ts +++ b/src/datastore/datasworn-indexer.test.ts @@ -6,22 +6,20 @@ import { createSource, DataswornIndexer, DataswornTypes, - moveOrigin, walkDataswornRulesPackage, } from "./datasworn-indexer"; +import { moveOrigin, scopeSource, scopeTags } from "./datasworn-symbols"; describe("Datasworn Indexer", () => { const createIndex = () => { const source: Source = createSource({ path: "@datasworn/starforged", priority: 0, - sourceTags: { "ruleset-id": Symbol.for(starforgedPackage._id) }, }); const indexer: DataswornIndexer = new DataIndexer(); indexer.index( source, walkDataswornRulesPackage( - source, // @ts-expect-error tsc compiler seems to infer starforged JSON types weirdly starforgedPackage as Datasworn.RulesPackage, ), @@ -59,6 +57,7 @@ describe("Datasworn Indexer", () => { }); it("should index asset-linked-moves", () => { + const empath = starforgedPackage.assets.path.contents.empath; const [readHeart] = indexer.dataMap.get( "asset.ability.move:starforged/path/empath.0.read_heart", )!; @@ -66,9 +65,12 @@ describe("Datasworn Indexer", () => { id: "asset.ability.move:starforged/path/empath.0.read_heart", kind: "move", source: { path: "@datasworn/starforged" }, - value: - starforgedPackage.assets.path.contents.empath.abilities[0].moves - ?.read_heart, + value: { + ...empath.abilities[0].moves!.read_heart, + // These should pull from the parent asset + [scopeSource]: empath._source, + [scopeTags]: empath.tags, + }, }); assertIsKind(readHeart, "move"); @@ -106,6 +108,19 @@ describe("Datasworn Indexer", () => { id: "starforged", name: "Ironsworn: Starforged Rulebook", }, + [scopeSource]: { + authors: [ + { + name: "Shawn Tomkin", + }, + ], + date: "2022-05-06", + license: "https://creativecommons.org/licenses/by/4.0", + page: 229, + title: "Ironsworn: Starforged Rulebook", + url: "https://ironswornrpg.com", + }, + [scopeTags]: {}, }); }); diff --git a/src/datastore/datasworn-indexer.ts b/src/datastore/datasworn-indexer.ts index 174067e3..8a82bc12 100644 --- a/src/datastore/datasworn-indexer.ts +++ b/src/datastore/datasworn-indexer.ts @@ -1,5 +1,5 @@ import { Datasworn } from "@datasworn/core"; -import IronVaultPlugin from "index"; +import merge from "lodash.merge"; import { Oracle, OracleCollectionGrouping, @@ -8,30 +8,39 @@ import { OracleRulesetGrouping, } from "model/oracle"; import { + DataIndex, DataIndexer, + PreSourced, + PreSourcedBy, Source, - SourceTag, - Sourced, SourcedBy, } from "./data-indexer"; +import { moveOrigin, scopeSource, scopeTags } from "./datasworn-symbols"; import { DataswornOracle } from "./parsers/datasworn/oracles"; -export const moveOrigin: unique symbol = Symbol("moveOrigin"); - -export type AnyDataswornMove = Datasworn.Move | Datasworn.EmbeddedMove; -export type MoveWithSelector = AnyDataswornMove & { +export type MoveWithSelector = Datasworn.AnyMove & { [moveOrigin]: { assetId?: Datasworn.AssetId }; }; -export type DataswornTypes = { +export type WithMetadata = T & { + [scopeTags]: Datasworn.Tags; + [scopeSource]: Datasworn.SourceInfo; +}; + +export type AllWithMetadata = { + [K in keyof T]: WithMetadata; +}; + +export type DataswornTypes = AllWithMetadata<{ move_category: Datasworn.MoveCategory; - move_ruleset: Datasworn.RulesPackage; move: MoveWithSelector; asset: Datasworn.Asset; oracle: Oracle; rules_package: Datasworn.RulesPackage; truth: Datasworn.Truth; -}; +}>; + +export type AnyDataswornMove = DataswornTypes["move"]; export type DataswornSourced = SourcedBy; @@ -42,30 +51,31 @@ export type DataswornIndexer = DataIndexer; export function createSource(fields: { path: string; priority?: number; - sourceTags: Partial>; }): Source { return { path: fields.path, priority: fields.priority ?? 0, keys: new Set(), - sourceTags: Object.fromEntries( - Object.entries(fields.sourceTags).map(([key, val]) => [ - key, - typeof val == "symbol" ? val : Symbol.for(val), - ]), - ), }; } export function* walkDataswornRulesPackage( - source: Source, input: Datasworn.RulesPackage, - plugin?: IronVaultPlugin, -): Iterable { +): Iterable> { function make( obj: T, - ): Sourced { - return { source, id: obj._id, kind: obj.type, value: obj }; + source: Datasworn.SourceInfo, + tags: Datasworn.Tags, + ): PreSourced> { + return { + id: obj._id, + kind: obj.type, + value: { + ...obj, + [scopeSource]: source, + [scopeTags]: tags, + }, + }; } const rootGrouping: OracleRulesetGrouping = { @@ -75,16 +85,12 @@ export function* walkDataswornRulesPackage( }; for (const [, category] of Object.entries(input.moves ?? {})) { - yield make(category); + const categoryTags = category.tags ?? {}; + yield make(category, category._source, categoryTags); for (const [, move] of Object.entries(category.contents ?? {})) { - yield make({ ...move, [moveOrigin]: {} }); - yield { - id: "ruleset_for_" + move._id, - kind: "move_ruleset", - value: input, - source, - }; + const moveTags = merge({}, categoryTags, move.tags ?? {}); + yield make({ ...move, [moveOrigin]: {} }, move._source, moveTags); const moveOracleGroup: OracleCollectionGrouping = { // TODO(@cwegrzyn): should this be its own grouping type? and what should the path be? @@ -92,51 +98,77 @@ export function* walkDataswornRulesPackage( name: move.name, parent: rootGrouping, id: move._id, + [scopeSource]: move._source, + [scopeTags]: moveTags, }; for (const [, oracle] of Object.entries(move.oracles ?? {})) { + const oracleTags = merge({}, moveTags, oracle.tags ?? {}); yield { id: oracle._id, kind: "oracle", - value: new DataswornOracle(oracle, moveOracleGroup, plugin), - source, + value: new DataswornOracle(oracle, moveOracleGroup, oracleTags), }; } } } for (const [, assetCollection] of Object.entries(input.assets ?? {})) { + const collectionTags = assetCollection.tags ?? {}; + for (const [, asset] of Object.entries(assetCollection.contents ?? {})) { - yield make(asset); + const assetTags = merge({}, collectionTags, asset.tags ?? {}); + yield make(asset, asset._source, assetTags); for (const ability of asset.abilities) { + const abilityTags = merge({}, assetTags, ability.tags ?? {}); + for (const [, move] of Object.entries(ability.moves ?? {})) { - yield make({ ...move, [moveOrigin]: { assetId: asset._id } }); - yield { - id: "ruleset_for_" + move._id, - kind: "move_ruleset", - value: input, - source, - }; + const moveTags = merge({}, abilityTags, move.tags); + + yield make( + { ...move, [moveOrigin]: { assetId: asset._id } }, + asset._source, + moveTags, + ); } } } } - for (const oracle of walkOracles(input, plugin)) { - yield { id: oracle.id, kind: "oracle", value: oracle, source }; + for (const oracle of walkOracles(input)) { + yield { id: oracle.id, kind: "oracle", value: oracle }; } for (const truth of Object.values(input.truths ?? {})) { - yield { id: truth._id, kind: "truth", value: truth, source }; + yield { + id: truth._id, + kind: "truth", + value: { + ...truth, + [scopeSource]: truth._source, + [scopeTags]: truth.tags ?? {}, + }, + }; } - yield { id: input._id, kind: "rules_package", value: input, source }; + yield { + id: input._id, + kind: "rules_package", + value: { + ...input, + [scopeSource]: { + authors: input.authors, + date: input.date, + license: input.license, + title: input.title, + url: input.url, + }, + [scopeTags]: {}, + }, + }; } -function* walkOracles( - data: Datasworn.RulesPackage, - plugin?: IronVaultPlugin, -): Generator { +function* walkOracles(data: Datasworn.RulesPackage): Generator { function* expand( collection: Datasworn.OracleCollection, parent: OracleGrouping, @@ -146,6 +178,14 @@ function* walkOracles( name: collection.name, parent, id: collection._id, + [scopeSource]: collection._source, + [scopeTags]: merge( + {}, + parent.grouping_type == OracleGroupingType.Collection + ? parent[scopeTags] + : {}, + collection.tags ?? {}, + ), }; // TODO: do we need/want to handle any of these differently? Main thing might be // different grouping types, so we can adjust display in some cases? @@ -161,7 +201,11 @@ function* walkOracles( for (const oracle of Object.values( collection.contents, )) { - yield new DataswornOracle(oracle, newParent, plugin); + yield new DataswornOracle( + oracle, + newParent, + merge({}, newParent[scopeTags], oracle.tags ?? {}), + ); } } @@ -189,3 +233,11 @@ function* walkOracles( yield* expand(set, rootGrouping); } } +export type DataswornIndex = DataIndex; + +/** Returns the source info of move or its nearest parent */ +export function scopeSourceForMove( + move: DataswornTypes["move"], +): Datasworn.SourceInfo { + return move[scopeSource]; +} diff --git a/src/datastore/datasworn-symbols.ts b/src/datastore/datasworn-symbols.ts new file mode 100644 index 00000000..0cf65c17 --- /dev/null +++ b/src/datastore/datasworn-symbols.ts @@ -0,0 +1,3 @@ +export const moveOrigin: unique symbol = Symbol("iv:moveOrigin"); +export const scopeTags: unique symbol = Symbol("iv:scopeTags"); +export const scopeSource: unique symbol = Symbol("iv:scopeSource"); diff --git a/src/datastore/parsers/datasworn/oracles.test.ts b/src/datastore/parsers/datasworn/oracles.test.ts index c8a7adfa..fb7165f9 100644 --- a/src/datastore/parsers/datasworn/oracles.test.ts +++ b/src/datastore/parsers/datasworn/oracles.test.ts @@ -4,14 +4,16 @@ import { OracleGrouping, OracleGroupingType } from "../../../model/oracle"; import { DataswornOracle } from "./oracles"; import rawSfData from "@datasworn/starforged/json/starforged.json" with { type: "json" }; +import { scopeSource, scopeTags } from "datastore/datasworn-symbols"; +import merge from "lodash.merge"; // @ts-expect-error Type inference of the raw SF json data seems to end up not matching Datasworn types. // Hoping it will be corrected in future tsc. const data = rawSfData as Datasworn.Ruleset; -function loadOracle(...[first, ...rest]: string[]): DataswornOracle { - let collection: Datasworn.OracleCollection = data.oracles[first]; - const tableName = rest.pop(); +function loadOracle(...path: string[]): DataswornOracle { + assert(path.length >= 2, "must have at least a collection and a table"); + const tableName = path.pop(); assert(tableName != null); let grouping: OracleGrouping = { @@ -19,31 +21,41 @@ function loadOracle(...[first, ...rest]: string[]): DataswornOracle { id: data._id, name: data._id, }; - + let contents: Record | null = + data.oracles; let name: string | undefined; - while ((name = rest.shift()) != null) { - assert(collection != null); - assert(collection.oracle_type == "tables", "expected tables oracle"); - assert( - collection.collections && name in collection.collections, - `expected ${name} to be in collection`, - ); + let collection!: Datasworn.OracleCollection; + while ((name = path.shift()) != null) { + assert(contents != null); + assert(name in contents, `expected ${name} to be in collection`); + collection = contents[name]; grouping = { grouping_type: OracleGroupingType.Collection, name: collection.name, id: collection._id, parent: grouping, + [scopeSource]: collection._source, + [scopeTags]: collection.tags ?? {}, }; - collection = collection.collections[name]; + contents = "collections" in collection ? collection.collections : null; } + assert(collection?.oracle_type == "tables", "expected tables oracle"); assert( collection.contents != null, "expected final step to include contents", ); + assert( + grouping.grouping_type == OracleGroupingType.Collection, + `unexpected grouping type ${grouping.grouping_type}`, + ); assert(tableName in collection.contents, `expected ${tableName} in contents`); - return new DataswornOracle(collection.contents[tableName], grouping); + return new DataswornOracle( + collection.contents[tableName], + grouping, + merge({}, collection.tags, collection.contents[tableName].tags ?? {}), + ); } describe("DataswornOracle", () => { diff --git a/src/datastore/parsers/datasworn/oracles.ts b/src/datastore/parsers/datasworn/oracles.ts index b3922613..cf8bd0c5 100644 --- a/src/datastore/parsers/datasworn/oracles.ts +++ b/src/datastore/parsers/datasworn/oracles.ts @@ -1,11 +1,12 @@ import { type Datasworn } from "@datasworn/core"; -import IronVaultPlugin from "index"; +import { scopeSource, scopeTags } from "datastore/datasworn-symbols"; import { rootLogger } from "logger"; +import { DiceGroup } from "utils/dice-group"; import { NoSuchOracleError } from "../../../model/errors"; import { CurseBehavior, Oracle, - OracleGrouping, + OracleCollectionGrouping, OracleRollableRow, OracleRow, RollContext, @@ -18,7 +19,6 @@ import { sameRoll, } from "../../../model/rolls"; import { Dice, DieKind } from "../../../utils/dice"; -import { DiceGroup } from "utils/dice-group"; const logger = rootLogger.getLogger("datasworn/oracles"); @@ -39,10 +39,20 @@ export class DataswornOracle implements Oracle { protected readonly table: | Datasworn.OracleRollable | Datasworn.EmbeddedOracleRollable, - public readonly parent: OracleGrouping, - private readonly plugin?: IronVaultPlugin, + public readonly parent: OracleCollectionGrouping, + private readonly _scopeTags: Datasworn.Tags, ) {} + get [scopeTags](): Datasworn.Tags { + return this._scopeTags; + } + + get [scopeSource](): Datasworn.SourceInfo { + return "_source" in this.table + ? this.table._source + : this.parent[scopeSource]; + } + get raw(): Datasworn.OracleRollable | Datasworn.EmbeddedOracleRollable { return this.table; } @@ -78,13 +88,13 @@ export class DataswornOracle implements Oracle { } get dice(): Dice { - return Dice.fromDiceString(this.table.dice, this.plugin, DieKind.Oracle); + return Dice.fromDiceString(this.table.dice, DieKind.Oracle); } - get cursedBy(): Oracle | undefined { + cursedBy(rollContext: RollContext): Oracle | undefined { for (const val of Object.values(this.table.tags ?? {})) { if (typeof val.cursed_by === "string") { - return this.plugin?.datastore.oracles.get(val.cursed_by); + return rollContext.lookup(val.cursed_by); } } return; @@ -100,35 +110,28 @@ export class DataswornOracle implements Oracle { } async roll(context: RollContext): Promise { - const cursed = this.cursedBy; - if (cursed && this.plugin && this.plugin.settings.enableCursedDie) { - const group = new DiceGroup( - [ - this.dice, - new Dice( - 1, - this.plugin.settings.cursedDieSides, - this.plugin, - DieKind.Cursed, - ), - ], - this.plugin, - ); - const res = await group.roll(this.plugin.settings.graphicalOracleDice); - return this.evaluate(context, res[0].value, res[1].value, cursed.id); + const diceRoller = context.diceRoller(); + + const cursed = this.cursedBy(context); + const cursedDice = context.cursedDice(); // non-null if cursed die is enabled + + if (cursed && cursedDice) { + const group = DiceGroup.of(this.dice, cursedDice); + const roll = await diceRoller.rollAsync(group); + + return { + ...(await this.evaluate(context, roll[0].value)), + cursedRoll: roll[1].value, + cursedTableId: cursed.id, + }; } return this.evaluate( context, - await this.dice.roll(this.plugin?.settings.graphicalOracleDice ?? true), + (await diceRoller.rollAsync(new DiceGroup([this.dice])))[0].value, ); } - async evaluate( - context: RollContext, - roll: number, - cursedRoll?: number, - cursedTableId?: string, - ): Promise { + async evaluate(context: RollContext, roll: number): Promise { const row = this.rowFor(roll); const subrolls: Record> = {}; @@ -244,8 +247,6 @@ export class DataswornOracle implements Oracle { roll, tableId: this.id, subrolls, - cursedRoll, - cursedTableId, }; } diff --git a/src/entity/command.ts b/src/entity/command.ts index 61be97b2..c086c978 100644 --- a/src/entity/command.ts +++ b/src/entity/command.ts @@ -1,3 +1,5 @@ +import { CampaignDataContext } from "campaigns/context"; +import { determineCampaignContext } from "campaigns/manager"; import { extractDataswornLinkParts } from "datastore/parsers/datasworn/id"; import Handlebars from "handlebars"; import { createOrAppendMechanics } from "mechanics/editor"; @@ -16,7 +18,6 @@ import { getExistingOrNewFolder } from "utils/obsidian"; import IronVaultPlugin from "../index"; import { Oracle, OracleRollableRow, RollContext } from "../model/oracle"; import { Roll, RollWrapper } from "../model/rolls"; -import { OracleRoller } from "../oracles/roller"; import { CustomSuggestModal } from "../utils/suggest"; import { EntityModal, EntityModalResults } from "./modal"; import { @@ -80,14 +81,11 @@ export async function promptOracleRow( } export async function generateEntity( - plugin: IronVaultPlugin, + app: App, + dataContext: CampaignDataContext, entityDesc: EntityDescriptor, ): Promise> { - const { datastore } = plugin; - if (!datastore.ready) { - throw new Error("data not ready"); - } - const rollContext = new OracleRoller(datastore.oracles); + const rollContext = dataContext.oracleRoller; const attributes = Object.entries(entityDesc.spec) .filter( (keyAndSpec): keyAndSpec is [string, EntityAttributeFieldSpec] => @@ -105,11 +103,11 @@ export async function generateEntity( if (!oracle) { throw new NoSuchOracleError(spec.id, `missing entity oracle for ${key}`); } - const roll = await promptOracleRow(plugin.app, oracle, rollContext, true); + const roll = await promptOracleRow(app, oracle, rollContext, true); initialEntity[key] = [new RollWrapper(oracle, rollContext, roll)]; } return EntityModal.create({ - app: plugin.app, + app, entityDesc, rollContext, initialEntity, @@ -119,23 +117,28 @@ export async function generateEntity( export async function generateEntityCommand( plugin: IronVaultPlugin, editor: Editor, - ctx: MarkdownView | MarkdownFileInfo, + view: MarkdownView | MarkdownFileInfo, selectedEntityDescriptor?: EntityDescriptor, ): Promise { + const campaignContext = await determineCampaignContext(plugin, view); + let entityDesc: EntityDescriptor; if (!selectedEntityDescriptor) { const [, desc] = await CustomSuggestModal.select( plugin.app, - Object.entries(ENTITIES).filter( - ([_k, v]) => - (plugin.settings.enableStarforged && - v.collectionId?.startsWith("oracle_collection:starforged/")) || - (plugin.settings.enableIronsworn && - v.collectionId?.startsWith("oracle_collection:classic/")) || - (plugin.settings.enableIronswornDelve && - v.collectionId?.startsWith("oracle_collection:delve/")) || - (plugin.settings.enableSunderedIsles && - v.collectionId?.startsWith("oracle_collection:sundered_isles/")), + Object.entries(ENTITIES).filter(([_k, v]) => + /* + * Here we check if every non-templated oracle is included. This is not + * necessarily the best approach for performance or usability reasons. + * However, it is the most robust strategy that is easily implemented. + * + *TODO(@cwegrzyn): alternatives to consider include: + * 1. Index these entity generators-- this would be nice because it gives a pathway to define custom entities too + * 2. Just check if ANY oracle in the faction is present + */ + Object.values(v.spec).every( + ({ id }) => id.includes("{{") || campaignContext.oracles.has(id), + ), ), ([_key, { label }]) => label, (match, el) => { @@ -143,7 +146,7 @@ export async function generateEntityCommand( if (collId) { const path = extractDataswornLinkParts(collId)!.path; const [rulesetId] = path.split("/"); - const ruleset = plugin.datastore.rulesPackages.get(rulesetId); + const ruleset = campaignContext.rulesPackages.get(rulesetId); if (ruleset) { el.createEl("small", { cls: "iron-vault-suggest-hint" }) .createEl("strong") @@ -160,7 +163,7 @@ export async function generateEntityCommand( let results: EntityModalResults; try { - results = await generateEntity(plugin, entityDesc); + results = await generateEntity(plugin.app, campaignContext, entityDesc); } catch (e) { new Notice(String(e)); throw e; @@ -210,7 +213,7 @@ export async function generateEntityCommand( ); oracleGroupTitle = plugin.app.fileManager.generateMarkdownLink( file, - ctx.file?.path ?? "", + view.file?.path ?? "", undefined, entityName, ); diff --git a/src/entity/specs.ts b/src/entity/specs.ts index fd579cb5..1fd69e72 100644 --- a/src/entity/specs.ts +++ b/src/entity/specs.ts @@ -7,7 +7,7 @@ export type EntityDescriptor = { spec: T; /** Id of oracle collection that this entity applies to. */ - collectionId?: Datasworn.OracleCollectionId; + collectionId: Datasworn.OracleCollectionId; }; export enum AttributeMechanism { diff --git a/src/index.ts b/src/index.ts index 6c6639a2..8320ea80 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,14 @@ import registerAssetBlock from "assets/asset-block"; import { CampaignIndex, CampaignIndexer } from "campaigns/indexer"; import { CampaignManager } from "campaigns/manager"; +import { + CAMPAIGN_EDIT_VIEW_TYPE, + CampaignEditView, +} from "campaigns/ui/edit-view"; +import { + INVALID_CAMPAIGNS_VIEW_TYPE, + InvalidCampaignsView, +} from "campaigns/ui/invalid-campaigns"; import registerCharacterBlock from "characters/character-block"; import registerClockBlock from "clocks/clock-block"; import { IronVaultCommands } from "commands"; @@ -15,7 +23,7 @@ import { IronVaultMigrationView, MIGRATION_VIEW_TYPE, } from "migrate/migration-view"; -import { addIcon, Plugin } from "obsidian"; +import { addIcon, Plugin, TFile } from "obsidian"; import { checkForOnboarding, ONBOARDING_VIEW_TYPE, @@ -36,7 +44,6 @@ import { ClockIndex, ClockIndexer } from "./clocks/clock-file"; import { Datastore } from "./datastore"; import registerMechanicsBlock from "./mechanics/mechanics-blocks"; import { registerMoveBlock } from "./moves/block"; -import { registerOracleBlock } from "./oracles/render"; import { IronVaultSettingTab } from "./settings/ui"; import { pluginAsset } from "./utils/obsidian"; @@ -111,9 +118,8 @@ export default class IronVaultPlugin extends Plugin implements TrackedEntities { "iron-vault", ``, ); - this.datastore = this.addChild(new Datastore(this)); - this.initializeIndexManager(); - this.campaignManager = this.addChild(new CampaignManager(this)); + + this.initializeDataSystems(); this.register( this.datastore.on("initialized", () => { // Because certain file schemas (characters mainly) are dependent on the loaded Datasworn @@ -123,7 +129,10 @@ export default class IronVaultPlugin extends Plugin implements TrackedEntities { ); this.registerEvent( - this.indexManager.on("initialized", () => checkForOnboarding(this)), + this.indexManager.on("initialized", () => { + checkForOnboarding(this); + InvalidCampaignsView.showIfNeeded(this); + }), ); this.registerEvent( @@ -150,6 +159,28 @@ export default class IronVaultPlugin extends Plugin implements TrackedEntities { ONBOARDING_VIEW_TYPE, (leaf) => new OnboardingView(leaf, this), ); + this.registerView( + INVALID_CAMPAIGNS_VIEW_TYPE, + (leaf) => new InvalidCampaignsView(leaf, this), + ); + this.registerView( + CAMPAIGN_EDIT_VIEW_TYPE, + (leaf) => new CampaignEditView(leaf, this), + ); + this.registerEvent( + this.app.workspace.on("file-menu", (menu, file) => { + if (file instanceof TFile && this.campaigns.has(file.path)) { + menu.addItem((item) => { + item + .setTitle("Edit campaign") + .setIcon("document") + .onClick(async () => + CampaignEditView.openFile(this.app, file.path), + ); + }); + } + }), + ); this.commands = new IronVaultCommands(this); this.commands.addCommands(); @@ -164,23 +195,27 @@ export default class IronVaultPlugin extends Plugin implements TrackedEntities { this.register(() => this.diceOverlay.removeDiceOverlay()); } - initializeIndexManager() { + initializeDataSystems() { + this.datastore = this.addChild(new Datastore(this)); this.indexManager = this.addChild(new IndexManager(this.app)); + + // Initializes campaigns and the campaign manager first + this.indexManager.registerHandler( + (this.campaignIndexer = new CampaignIndexer()), + ); + this.campaignManager = this.addChild(new CampaignManager(this)); + this.indexManager.registerHandler( - (this.characterIndexer = new CharacterIndexer(this.datastore)), + (this.characterIndexer = new CharacterIndexer(this.campaignManager)), ); this.indexManager.registerHandler( (this.progressIndexer = new ProgressIndexer()), ); this.indexManager.registerHandler((this.clockIndexer = new ClockIndexer())); - this.indexManager.registerHandler( - (this.campaignIndexer = new CampaignIndexer()), - ); } registerBlocks() { registerMoveBlock(this); - registerOracleBlock(this); registerMechanicsBlock(this); registerTrackBlock(this); registerClockBlock(this); diff --git a/src/indexer/index-impl.ts b/src/indexer/index-impl.ts index d5510d24..03056f3e 100644 --- a/src/indexer/index-impl.ts +++ b/src/indexer/index-impl.ts @@ -9,6 +9,7 @@ import { } from "utils/versioned-map"; import { EmittingIndex } from "./index-interface"; +/** Filter map down to only valid entries. */ export function onlyValid( map: ReadonlyVersionedMap>, ): ProjectableMap { @@ -17,6 +18,15 @@ export function onlyValid( ); } +/** Filter map down to only invalid entries. */ +export function onlyInvalid( + map: ReadonlyVersionedMap>, +): ProjectableMap { + return projectedVersionedMap(map, (result) => + result.isLeft() ? result.error : undefined, + ); +} + export class IndexImpl implements EmittingIndex { readonly events: Events = new Events(); readonly #map: VersionedMap> = new VersionedMapImpl(); diff --git a/src/indexer/indexer.ts b/src/indexer/indexer.ts index 36b1da03..206b7a99 100644 --- a/src/indexer/indexer.ts +++ b/src/indexer/indexer.ts @@ -40,14 +40,30 @@ export function wrapIndexUpdateError( ); } +export type CachedMetadataWithFrontMatter = CachedMetadata & { + frontmatter: NonNullable; +}; + +export function assertHasFrontmatter( + cache: CachedMetadata, +): asserts cache is CachedMetadataWithFrontMatter { + if (cache.frontmatter == null) { + throw new Error("Cache is missing frontmatter, how can that be?"); + } +} + export interface Indexer { readonly id: IronVaultKind; onChanged( file: TFile, - cache: CachedMetadata, + cache: CachedMetadataWithFrontMatter, ): IndexUpdateResult["type"]; onDeleted(path: string): IndexDeleteResult; - onRename(oldPath: string, newFile: TFile, cache: CachedMetadata): void; + onRename( + oldPath: string, + newFile: TFile, + cache: CachedMetadataWithFrontMatter, + ): void; } export type IndexerId = string; @@ -73,7 +89,7 @@ export abstract class BaseIndexer implements Indexer { onChanged( file: TFile, - cache: CachedMetadata, + cache: CachedMetadataWithFrontMatter, ): IndexUpdateResult["type"] { let result: IndexUpdate; try { @@ -122,7 +138,11 @@ export abstract class BaseIndexer implements Indexer { } } - onRename(oldPath: string, newFile: TFile, cache: CachedMetadata): void { + onRename( + oldPath: string, + newFile: TFile, + cache: CachedMetadataWithFrontMatter, + ): void { if (this.index.rename(oldPath, newFile.path)) { if (this.reprocessRenamedFiles) { // TODO: this is all hacky, and also what do I do if this returns something unexpected? @@ -137,7 +157,10 @@ export abstract class BaseIndexer implements Indexer { } /** This defines how your indexer processes the files into its indexed type. */ - abstract processFile(file: TFile, cache: CachedMetadata): IndexUpdate; + abstract processFile( + file: TFile, + cache: CachedMetadataWithFrontMatter, + ): IndexUpdate; /** Defines whether renamed files should be reindexed (e.g., if they include their path) */ protected readonly reprocessRenamedFiles: boolean = false; diff --git a/src/indexer/manager.ts b/src/indexer/manager.ts index 593c7e2b..3a5d4989 100644 --- a/src/indexer/manager.ts +++ b/src/indexer/manager.ts @@ -1,16 +1,16 @@ import { rootLogger } from "logger"; import { + CachedMetadata, Component, EventRef, Events, TFile, type App, - type CachedMetadata, type FileManager, type MetadataCache, type Vault, } from "obsidian"; -import { Indexer, IndexerId } from "./indexer"; +import { assertHasFrontmatter, Indexer, IndexerId } from "./indexer"; const logger = rootLogger.getLogger("index-manager"); @@ -59,11 +59,9 @@ export class IndexManager extends Component { const indexer = this.currentIndexerForFile(oldPath); if (indexer != null) { this.indexedFiles.delete(oldPath); - indexer.onRename( - oldPath, - file, - this.metadataCache.getFileCache(file)!, - ); + const cache = this.metadataCache.getFileCache(file); + assertHasFrontmatter(cache!); + indexer.onRename(oldPath, file, cache); // if onRename fails, we won't re-add the file here. this.indexedFiles.set(file.path, indexer.id); } @@ -142,7 +140,26 @@ export class IndexManager extends Component { return undefined; } - public indexFile(file: TFile, cache: CachedMetadata): void { + /** Mark a path for reindexing. + * Currently, this will just cause it to reindex right away. + */ + public markDirty(path: string): void { + const file = this.vault.getFileByPath(path); + if (file == null) { + logger.warn("No such file for %s", path); + return; + } + + const cache = this.metadataCache.getFileCache(file); + if (cache == null) { + logger.warn("Empty cache for %s", path); + return; + } + + this.indexFile(file, cache); + } + + private indexFile(file: TFile, cache: CachedMetadata): void { const indexKey = file.path; const priorIndexer = this.currentIndexerForFile(indexKey); @@ -162,6 +179,7 @@ export class IndexManager extends Component { let result: ReturnType | undefined = undefined; try { + assertHasFrontmatter(cache); result = newIndexer.onChanged(file, cache); } catch (error) { // This was a truly exceptional error -- the indexer would not have recorded it, so we diff --git a/src/link-handler.ts b/src/link-handler.ts index 844f55ef..2ac1e085 100644 --- a/src/link-handler.ts +++ b/src/link-handler.ts @@ -1,5 +1,6 @@ import { PluginValue, ViewPlugin, ViewUpdate } from "@codemirror/view"; import { AssetModal } from "assets/asset-modal"; +import { ICompleteDataContext, IDataContext } from "datastore/data-context"; import { DataswornSourced } from "datastore/datasworn-indexer"; import { extractDataswornLinkParts } from "datastore/parsers/datasworn/id"; import { rootLogger } from "logger"; @@ -12,6 +13,7 @@ const logger = rootLogger.getLogger("link-handler"); export default function installLinkHandler(plugin: IronVaultPlugin) { const findEntry = ( + dataContext: ICompleteDataContext, text: string | undefined, ): DataswornSourced | undefined => { const linkText = text?.toLowerCase(); @@ -21,10 +23,7 @@ export default function installLinkHandler(plugin: IronVaultPlugin) { if (!dataswornLinkCandidate) return undefined; // First, try to find the entry by ID - // TODO(@cwegrzyn): should use campaign context when ready? at the very least, should filter to enabled vs indexed? - const entry = plugin.datastore.indexer.prioritized.get( - dataswornLinkCandidate.id, - ); + const entry = dataContext.prioritized.get(dataswornLinkCandidate.id); if (entry) return entry; // Then, search by name in the major asset types @@ -36,20 +35,20 @@ export default function installLinkHandler(plugin: IronVaultPlugin) { return s.replaceAll(/\s*/g, "").toLowerCase(); } const searchString = normalize(dataswornLinkCandidate.path); - const index = plugin.datastore.indexer.prioritized.ofKind(entityType); + const index = dataContext.prioritized.ofKind(entityType); for (const entry of index.values()) { if (normalize(entry.value.name) == searchString) return entry; } return entry; }; - const present = (entry: DataswornSourced) => { + const present = (dataContext: IDataContext, entry: DataswornSourced) => { switch (entry.kind) { case "move": - new MoveModal(plugin.app, plugin, entry.value).open(); + new MoveModal(plugin.app, plugin, dataContext, entry.value).open(); break; case "asset": - new AssetModal(plugin.app, plugin, entry.value).open(); + new AssetModal(plugin.app, plugin, dataContext, entry.value).open(); break; case "oracle": new OracleModal(plugin.app, plugin, entry.value).open(); @@ -68,14 +67,20 @@ export default function installLinkHandler(plugin: IronVaultPlugin) { ev.target.href !== "app://obsidian.md/index.html#" ) return; - const editor = plugin.app.workspace.activeEditor?.editor; - if (editor) { - const token = editor.getClickableTokenAt(editor.posAtMouse(ev)); - const entry = findEntry(token?.text); + const view = plugin.app.workspace.activeEditor; + const editor = view?.editor; + const token = editor && editor.getClickableTokenAt(editor.posAtMouse(ev)); + if (view?.file && token) { + const campaign = plugin.campaignManager.campaignForFile(view.file); + const context = + campaign == null + ? plugin.datastore.dataContext + : plugin.campaignManager.campaignContextFor(campaign); + const entry = findEntry(context, token.text); if (entry) { ev.stopPropagation(); ev.preventDefault(); - present(entry); + present(context, entry); } } }; @@ -102,6 +107,11 @@ export default function installLinkHandler(plugin: IronVaultPlugin) { plugin.registerEditorExtension([cmPlugin]); plugin.app.workspace.updateOptions(); plugin.registerMarkdownPostProcessor((el, ctx) => { + // We get the file, since it survives renames, but sometimes these markdown blocks don't + // re-render on a rename! + // TODO(@cwegrzyn): if we could get the view from the click event, that might be better. Is + // that just the active view? That feels gross, but it does seem to make sense... + const file = plugin.app.vault.getFileByPath(ctx.sourcePath); el.querySelectorAll("a").forEach((a) => { // If the link is a potential datasworn link, let's register a handler just in case. const href = a.attributes.getNamedItem("href")?.textContent ?? ""; @@ -109,14 +119,19 @@ export default function installLinkHandler(plugin: IronVaultPlugin) { const component = new MarkdownRenderChild(a); ctx.addChild(component); component.registerDomEvent(a, "click", (ev) => { + const campaign = file && plugin.campaignManager.campaignForFile(file); + const dataContext = + campaign == null + ? plugin.datastore.dataContext + : plugin.campaignManager.campaignContextFor(campaign); const href = (ev.target as HTMLAnchorElement).attributes.getNamedItem("href") ?.textContent ?? undefined; - const entry = findEntry(href); + const entry = findEntry(dataContext, href); if (entry) { ev.stopPropagation(); ev.preventDefault(); - present(entry); + present(dataContext, entry); } }); } diff --git a/src/mechanics/mechanics-blocks.ts b/src/mechanics/mechanics-blocks.ts index eb932ecf..6e1a26a1 100644 --- a/src/mechanics/mechanics-blocks.ts +++ b/src/mechanics/mechanics-blocks.ts @@ -1,4 +1,7 @@ -import { Node as KdlNodeBare, parse, format } from "kdljs"; +import { format, Node as KdlNodeBare, parse } from "kdljs"; +import { html, render, TemplateResult } from "lit-html"; +import { ref } from "lit-html/directives/ref.js"; +import { styleMap } from "lit-html/directives/style-map.js"; import { ButtonComponent, MarkdownPostProcessorContext, @@ -6,16 +9,13 @@ import { MarkdownView, Menu, } from "obsidian"; -import { render, html, TemplateResult } from "lit-html"; -import { styleMap } from "lit-html/directives/style-map.js"; -import { ref } from "lit-html/directives/ref.js"; -import { ProgressTrack } from "tracks/progress"; -import IronVaultPlugin from "../index"; -import { md } from "utils/ui/directives"; import { repeat } from "lit-html/directives/repeat.js"; -import { node } from "utils/kdl"; import Sortable from "sortablejs"; +import { ProgressTrack } from "tracks/progress"; +import { node } from "utils/kdl"; +import { md } from "utils/ui/directives"; +import IronVaultPlugin from "../index"; interface KdlNode extends KdlNodeBare { parent?: KdlNode; @@ -316,7 +316,6 @@ See https://kdl.dev for syntax.( }; } -export function sameElementsInArray( - eq: (arg1: T, arg2: T) => boolean, -): (arg1: T[], arg2: T[]) => boolean { - return (arg1, arg2) => { - if (arg1.length !== arg2.length) return false; - return arg1.every((val1) => arg2.find((val2) => eq(val1, val2))); - }; -} - export const arrayOfRollsEqual = sameElementsInArray(sameRoll); export const subrollRecordsEqual = recordsEqual(subrollsEqual); @@ -174,6 +167,17 @@ export class RollWrapper { : undefined; } + async rerollCursed(): Promise { + const cursedDice = this.context.cursedDice(); + if (cursedDice == null) { + throw new Error("Cursed dice not in use. Cannot reroll curse!"); + } + const cursedRoll = ( + await this.context.diceRoller().rollAsync(DiceGroup.of(cursedDice)) + )[0]; + return this.withCursedRoll(cursedRoll.value); + } + withCursedRoll(cursedRoll: number | undefined, cursedTableId?: string) { return new RollWrapper(this.oracle, this.context, { ...this.roll, diff --git a/src/moves/action/action-modal.ts b/src/moves/action/action-modal.ts index e5acbe5f..8c93b017 100644 --- a/src/moves/action/action-modal.ts +++ b/src/moves/action/action-modal.ts @@ -11,6 +11,7 @@ import { Setting, } from "obsidian"; import { Dice, DieKind } from "utils/dice"; +import { DiceGroup } from "utils/dice-group"; import { node } from "utils/kdl"; import { CustomSuggestModal } from "utils/suggest"; import { PromptModal } from "utils/ui/prompt"; @@ -122,6 +123,7 @@ export async function rerollDie( view: MarkdownView | MarkdownFileInfo, ) { const actionContext = await determineCharacterActionContext(plugin, view); + const diceRoller = actionContext.campaignContext.diceRollerFor("move"); const dieName: "action" | "vs1" | "vs2" = await CustomSuggestModal.select( plugin.app, @@ -146,16 +148,21 @@ export async function rerollDie( } else { newValue = "" + - (await new Dice( - 1, - dieName === "action" ? 6 : 10, - plugin, - dieName === "action" - ? DieKind.Action - : dieName === "vs1" - ? DieKind.Challenge1 - : DieKind.Challenge2, - ).roll(plugin.settings.graphicalActionDice)); + ( + await diceRoller.rollAsync( + DiceGroup.of( + new Dice( + 1, + dieName === "action" ? 6 : 10, + dieName === "action" + ? DieKind.Action + : dieName === "vs1" + ? DieKind.Challenge1 + : DieKind.Challenge2, + ), + ), + ) + )[0].value; } const props: { action?: string; vs1?: string; vs2?: string } = {}; props[dieName] = newValue; diff --git a/src/moves/action/index.ts b/src/moves/action/index.ts index 0512329f..360ecb01 100644 --- a/src/moves/action/index.ts +++ b/src/moves/action/index.ts @@ -6,7 +6,10 @@ import { formatActionContextDescription, } from "characters/action-context"; import { labelForMeter } from "characters/display"; -import { AnyDataswornMove } from "datastore/datasworn-indexer"; +import { + AnyDataswornMove, + scopeSourceForMove, +} from "datastore/datasworn-indexer"; import IronVaultPlugin from "index"; import { rootLogger } from "logger"; import { @@ -27,6 +30,7 @@ import { } from "obsidian"; import { MeterCommon } from "rules/ruleset"; import { DiceGroup } from "utils/dice-group"; +import { AsyncDiceRoller } from "utils/dice-roller"; import { numberRange } from "utils/numbers"; import { MeterWithLens, @@ -58,7 +62,7 @@ enum MoveKind { Other = "Other", } -function getMoveKind(move: Datasworn.Move | Datasworn.EmbeddedMove): MoveKind { +function getMoveKind(move: Datasworn.AnyMove): MoveKind { switch (move.roll_type) { case "action_roll": return MoveKind.Action; @@ -86,13 +90,11 @@ const ROLL_TYPES: Record = { async function promptForMove( plugin: IronVaultPlugin, context: ActionContext, -): Promise { +): Promise { const moves = [...context.moves.values()].sort((a, b) => a.name.localeCompare(b.name), ); - const choice = await CustomSuggestModal.selectWithUserEntry< - Datasworn.Move | Datasworn.EmbeddedMove - >( + const choice = await CustomSuggestModal.selectWithUserEntry( plugin.app, moves, (move) => move.name, @@ -101,21 +103,18 @@ async function promptForMove( }, ({ item: move }, el: HTMLElement) => { const moveKind = getMoveKind(move); - const ruleset = moveRuleset(plugin, move); el.createEl("small", { text: `(${moveKind}) ${move.trigger.text}`, cls: "iron-vault-suggest-hint", }); - if (ruleset) { - el.createEl("br"); - el.createEl("small", { - cls: "iron-vault-suggest-hint", - }) - .createEl("strong") - .createEl("em", { - text: ruleset, - }); - } + el.createEl("br"); + el.createEl("small", { + cls: "iron-vault-suggest-hint", + }) + .createEl("strong") + .createEl("em", { + text: scopeSourceForMove(move).title, + }); }, `Select a move ${formatActionContextDescription(context)}`, ); @@ -231,7 +230,7 @@ function assertInRange( } async function processActionMove( - plugin: IronVaultPlugin, + diceRoller: AsyncDiceRoller, move: Datasworn.MoveActionRoll | Datasworn.EmbeddedActionRollMove, stat: string, statVal: number, @@ -239,14 +238,13 @@ async function processActionMove( roll?: { action: number; challenge1: number; challenge2: number } | undefined, ): Promise { if (!roll) { - const res = await new DiceGroup( - [ - new Dice(1, 6, plugin, DieKind.Action), - new Dice(1, 10, plugin, DieKind.Challenge1), - new Dice(1, 10, plugin, DieKind.Challenge2), - ], - plugin, - ).roll(plugin.settings.graphicalActionDice); + const res = await diceRoller.rollAsync( + DiceGroup.of( + new Dice(1, 6, DieKind.Action), + new Dice(1, 10, DieKind.Challenge1), + new Dice(1, 10, DieKind.Challenge2), + ), + ); roll = { action: res[0].value, challenge1: res[1].value, @@ -272,17 +270,16 @@ async function processActionMove( async function processProgressMove( move: Datasworn.MoveProgressRoll | Datasworn.EmbeddedProgressRollMove, tracker: ProgressTrackWriterContext, - plugin: IronVaultPlugin, + diceRoller: AsyncDiceRoller, roll?: { challenge1: number; challenge2: number }, ): Promise { if (!roll) { - const res = await new DiceGroup( - [ - new Dice(1, 10, plugin, DieKind.Challenge1), - new Dice(1, 10, plugin, DieKind.Challenge2), - ], - plugin, - ).roll(plugin.settings.graphicalActionDice); + const res = await diceRoller.rollAsync( + DiceGroup.of( + new Dice(1, 10, DieKind.Challenge1), + new Dice(1, 10, DieKind.Challenge2), + ), + ); roll = { challenge1: res[0].value, challenge2: res[1].value, @@ -324,8 +321,10 @@ export async function runMoveCommand( const context = await determineCharacterActionContext(plugin, view); + const diceRoller = context.campaignContext.diceRollerFor("move"); + // Use the provided move, or prompt the user for a move appropriate to the current action context. - const move: Datasworn.Move | Datasworn.EmbeddedMove = + const move: Datasworn.AnyMove = chosenMove ?? (await promptForMove(plugin, context)); let moveDescription: MoveDescription; @@ -336,6 +335,7 @@ export async function runMoveCommand( case "action_roll": { moveDescription = await handleActionRoll( plugin, + diceRoller, context, move, true, @@ -346,6 +346,7 @@ export async function runMoveCommand( case "progress_roll": { moveDescription = await handleProgressRoll( plugin, + diceRoller, new ProgressContext(plugin, context), move, ); @@ -375,7 +376,7 @@ export async function runMoveCommand( } function createEmptyMoveDescription( - move: AnyDataswornMove, + move: Datasworn.AnyMove, ): NoRollMoveDescription { return { id: move._id, @@ -385,6 +386,7 @@ function createEmptyMoveDescription( async function handleProgressRoll( plugin: IronVaultPlugin, + diceRoller: AsyncDiceRoller, progressContext: ProgressContext, move: Datasworn.MoveProgressRoll | Datasworn.EmbeddedProgressRollMove, ): Promise { @@ -419,7 +421,7 @@ async function handleProgressRoll( } // TODO: when would we mark complete? should we prompt on a hit? - return await processProgressMove(move, progressTrack, plugin, rolls); + return await processProgressMove(move, progressTrack, diceRoller, rolls); } const ORDINALS = [ @@ -473,6 +475,7 @@ export function suggestedRollablesForMove( async function handleActionRoll( plugin: IronVaultPlugin, + diceRoller: AsyncDiceRoller, actionContext: ActionContext, move: Datasworn.MoveActionRoll | Datasworn.EmbeddedActionRollMove, allowSkip: true, @@ -480,6 +483,7 @@ async function handleActionRoll( ): Promise; async function handleActionRoll( plugin: IronVaultPlugin, + diceRoller: AsyncDiceRoller, actionContext: ActionContext, move: Datasworn.MoveActionRoll | Datasworn.EmbeddedActionRollMove, allowSkip: false, @@ -487,6 +491,7 @@ async function handleActionRoll( ): Promise; async function handleActionRoll( plugin: IronVaultPlugin, + diceRoller: AsyncDiceRoller, actionContext: ActionContext, move: Datasworn.MoveActionRoll | Datasworn.EmbeddedActionRollMove, allowSkip: boolean, @@ -568,7 +573,7 @@ async function handleActionRoll( } let description = await processActionMove( - plugin, + diceRoller, move, labelForMeter(stat), statValue, @@ -681,21 +686,13 @@ async function promptForRollable( return stat; } -function moveRuleset( - plugin: IronVaultPlugin, - move: Datasworn.Move | Datasworn.EmbeddedMove, -) { - return ( - plugin.datastore.moveRulesets.get("ruleset_for_" + move._id)?.title ?? "" - ); -} - export async function makeActionRollCommand( plugin: IronVaultPlugin, editor: Editor, view: MarkdownView | MarkdownFileInfo, ): Promise { const context = await determineCharacterActionContext(plugin, view); + const diceRoller = context.campaignContext.diceRollerFor("move"); const priorBlock = findAdjacentMechanicsBlock(editor); let updatePriorMove = false; @@ -730,6 +727,7 @@ export async function makeActionRollCommand( const moveDescription: ActionMoveDescription = await handleActionRoll( plugin, + diceRoller, context, move, false, diff --git a/src/moves/block.ts b/src/moves/block.ts index 0ba57764..c6777a4f 100644 --- a/src/moves/block.ts +++ b/src/moves/block.ts @@ -1,4 +1,4 @@ -import { Datastore } from "datastore"; +import { IDataContext } from "datastore/data-context"; import { AnyDataswornMove } from "datastore/datasworn-indexer"; import { rootLogger } from "logger"; import { MarkdownRenderChild, MarkdownRenderer, type App } from "obsidian"; @@ -34,7 +34,7 @@ export function registerMoveBlock(plugin: IronVaultPlugin): void { plugin.app, ctx.sourcePath, validatedMove.value, - plugin.datastore, + plugin.datastore.dataContext, ); ctx.addChild(renderer); renderer.render(); @@ -63,11 +63,11 @@ class MoveMarkdownRenderChild extends MarkdownRenderChild { protected readonly app: App, protected readonly sourcePath: string, protected readonly doc: MoveDescription, - protected readonly datastore: Datastore, + protected readonly dataContext: IDataContext, ) { super(containerEl); - const moves = [...datastore.moves.values()].filter( + const moves = [...dataContext.moves.values()].filter( (move) => move.name === doc.name, ); if (moves.length != 1) { diff --git a/src/moves/move-modal.ts b/src/moves/move-modal.ts index 844962c3..8571e27e 100644 --- a/src/moves/move-modal.ts +++ b/src/moves/move-modal.ts @@ -1,5 +1,10 @@ import { determineCharacterActionContext } from "characters/action-context"; -import { AnyDataswornMove } from "datastore/datasworn-indexer"; +import { IDataContext } from "datastore/data-context"; +import { + AnyDataswornMove, + DataswornTypes, + scopeSourceForMove, +} from "datastore/datasworn-indexer"; import IronVaultPlugin from "index"; import { html, render } from "lit-html"; import { map } from "lit-html/directives/map.js"; @@ -19,14 +24,15 @@ import { runMoveCommand, suggestedRollablesForMove } from "./action"; const TABLE_REGEX = /\{\{table>([^}]+)\}\}/g; export class MoveModal extends Modal { - plugin: IronVaultPlugin; - move: AnyDataswornMove; moveHistory: AnyDataswornMove[] = []; - constructor(app: App, plugin: IronVaultPlugin, move: AnyDataswornMove) { + constructor( + app: App, + readonly plugin: IronVaultPlugin, + readonly dataContext: IDataContext, + readonly move: AnyDataswornMove, + ) { super(app); - this.plugin = plugin; - this.move = move; } private getActiveMarkdownView(): MarkdownView | undefined { @@ -34,7 +40,7 @@ export class MoveModal extends Modal { return view && view instanceof MarkdownView ? view : undefined; } - async openMove(move: AnyDataswornMove) { + async openMove(move: DataswornTypes["move"]) { this.setTitle(move.name); const { contentEl } = this; contentEl.empty(); @@ -42,8 +48,7 @@ export class MoveModal extends Modal { contentEl.toggleClass("iron-vault-modal", true); contentEl.toggleClass("iron-vault-move-modal", true); contentEl.createEl("header", { - text: this.plugin.datastore.moveRulesets.get("ruleset_for_" + move._id) - ?.title, + text: scopeSourceForMove(move).title, }); const view = this.getActiveMarkdownView(); // NOTE(@cwegrzyn): I've taken the approach here that if there is no active view, let's @@ -137,7 +142,7 @@ export class MoveModal extends Modal { if (!id) return; ev.preventDefault(); ev.stopPropagation(); - const newMove = this.plugin.datastore.moves.get(id); + const newMove = this.dataContext.moves.get(id); if (newMove) { this.moveHistory.push(move); this.openMove(newMove); @@ -159,9 +164,9 @@ export class MoveModal extends Modal { let moveText = move.text; const oracles = []; for (const match of move.text.matchAll(TABLE_REGEX)) { - const oracle = this.plugin.datastore.oracles.get(match[1]); + const oracle = this.dataContext.oracles.get(match[1]); if (oracle) { - const dom = await generateOracleTable(this.plugin, oracle); + const dom = await generateOracleTable(this.app, oracle); const oracleText = dom.outerHTML + "\n"; oracles.push({ oracleText, oracle }); moveText = moveText.replaceAll(match[0], ""); diff --git a/src/oracles/command.ts b/src/oracles/command.ts index f09f67d8..d4b43d5c 100644 --- a/src/oracles/command.ts +++ b/src/oracles/command.ts @@ -1,3 +1,4 @@ +import { determineCampaignContext } from "campaigns/manager"; import IronVaultPlugin from "index"; import { rootLogger } from "logger"; import { createOrAppendMechanics } from "mechanics/editor"; @@ -9,30 +10,22 @@ import { type MarkdownView, } from "obsidian"; import { numberRange } from "utils/numbers"; -import { CurseBehavior, Oracle, OracleGroupingType } from "../model/oracle"; +import { + CurseBehavior, + Oracle, + OracleGrouping, + OracleGroupingType, +} from "../model/oracle"; import { Roll, RollWrapper } from "../model/rolls"; import { CustomSuggestModal } from "../utils/suggest"; import { OracleRollerModal } from "./modal"; +import { oracleNameWithParents } from "./render"; import { OracleRoller } from "./roller"; const logger = rootLogger.getLogger("oracles"); -export function formatOraclePath(oracle: Oracle): string { - let current = oracle.parent; - const path = []; - while ( - current != null && - current.grouping_type != OracleGroupingType.Ruleset - ) { - path.unshift(current.name); - current = current.parent; - } - path.push(oracle.name); - return `${path.join(" / ")}`; -} - export function oracleRuleset(oracle: Oracle): string { - let current = oracle.parent; + let current: OracleGrouping = oracle.parent; while ( current != null && current.grouping_type !== OracleGroupingType.Ruleset @@ -83,15 +76,19 @@ export async function runOracleCommand( return; } + const campaignContext = await determineCampaignContext(plugin, view); + let oracle: Oracle; if (chosenOracle) { + // TODO(@cwegrzyn): if this is called with a specific oracle, should it + // also have a specific campaign context? oracle = chosenOracle; } else { - const oracles: Oracle[] = [...plugin.datastore.oracles.values()]; + const oracles: Oracle[] = [...campaignContext.oracles.values()]; oracle = await CustomSuggestModal.select( plugin.app, oracles, - formatOraclePath, + oracleNameWithParents, (match, el) => { const ruleset = oracleRuleset(match.item); el.createEl("small", { cls: "iron-vault-suggest-hint" }) @@ -101,7 +98,7 @@ export async function runOracleCommand( prompt ? `Select an oracle to answer '${prompt}'` : "Select an oracle", ); } - const rollContext = new OracleRoller(plugin.datastore.oracles); + const rollContext = new OracleRoller(plugin, campaignContext.oracles); // If user wishes to make their own roll, prompt them now. let initialRoll: Roll | undefined = undefined; diff --git a/src/oracles/modal.ts b/src/oracles/modal.ts index e242080f..3b8c3a72 100644 --- a/src/oracles/modal.ts +++ b/src/oracles/modal.ts @@ -2,7 +2,6 @@ import IronVaultPlugin from "index"; import { CurseBehavior, Oracle } from "model/oracle"; import { RollWrapper } from "model/rolls"; import { Modal, Setting } from "obsidian"; -import { Dice, DieKind } from "utils/dice"; import { stripMarkdown } from "utils/strip-markdown"; export class OracleRollerModal extends Modal { @@ -49,13 +48,8 @@ export class OracleRollerModal extends Modal { .setHeading() .addButton((btn) => btn.setIcon("refresh-cw").onClick(async () => { - const newCursedRoll = await new Dice( - 1, - this.plugin.settings.cursedDieSides, - this.plugin, - DieKind.Cursed, - ).roll(this.plugin.settings.graphicalOracleDice); - await setRoll(this.currentRoll.withCursedRoll(newCursedRoll)); + const newCursedRoll = await this.currentRoll.rerollCursed(); + await setRoll(newCursedRoll); await onUpdateCursedRoll(); }), ); diff --git a/src/oracles/oracle-modal.ts b/src/oracles/oracle-modal.ts index 84a3b455..09750d68 100644 --- a/src/oracles/oracle-modal.ts +++ b/src/oracles/oracle-modal.ts @@ -1,17 +1,16 @@ import IronVaultPlugin from "index"; -import { Oracle, OracleGroupingType } from "model/oracle"; +import { Oracle, OracleGrouping, OracleGroupingType } from "model/oracle"; import { App, ButtonComponent, MarkdownView, Modal } from "obsidian"; import { runOracleCommand } from "oracles/command"; import { generateOracleTable } from "./render"; export class OracleModal extends Modal { - plugin: IronVaultPlugin; - oracle: Oracle; - - constructor(app: App, plugin: IronVaultPlugin, oracle: Oracle) { + constructor( + readonly app: App, + readonly plugin: IronVaultPlugin, + readonly oracle: Oracle, + ) { super(app); - this.plugin = plugin; - this.oracle = oracle; } async openOracle(oracle: Oracle) { @@ -20,7 +19,7 @@ export class OracleModal extends Modal { contentEl.toggleClass("iron-vault-modal-content", true); contentEl.classList.toggle("iron-vault-oracle-modal", true); contentEl.toggleClass("iron-vault-modal", true); - let ruleset = oracle.parent; + let ruleset: OracleGrouping = oracle.parent; while ( oracle.parent && ruleset.grouping_type !== OracleGroupingType.Ruleset @@ -41,7 +40,7 @@ export class OracleModal extends Modal { this.close(); } }); - contentEl.appendChild(await generateOracleTable(this.plugin, oracle)); + contentEl.appendChild(await generateOracleTable(this.app, oracle)); } onOpen() { diff --git a/src/oracles/render.ts b/src/oracles/render.ts index 21a6445f..6065e88a 100644 --- a/src/oracles/render.ts +++ b/src/oracles/render.ts @@ -1,93 +1,10 @@ -import { type Datastore } from "datastore"; -import { - MarkdownRenderChild, - MarkdownRenderer, - MarkdownView, - parseYaml, - stringifyYaml, - type App, - type MarkdownPostProcessorContext, -} from "obsidian"; -import { Oracle, OracleGroupingType } from "../model/oracle"; -import { RollWrapper } from "../model/rolls"; -import { OracleRoller } from "./roller"; -import { oracleSchema, type OracleSchema, type RollSchema } from "./schema"; -import IronVaultPlugin from "index"; import { Datasworn } from "@datasworn/core"; - -export function registerOracleBlock(plugin: IronVaultPlugin): void { - plugin.registerMarkdownCodeBlockProcessor( - "oracle", - async (source, el, ctx) => { - const doc = parseYaml(source); - const validatedOracle = oracleSchema.safeParse(doc); - - if (validatedOracle.success) { - const renderer = new OracleMarkdownRenderChild( - el, - plugin.app, - ctx, - plugin.datastore, - validatedOracle.data, - ); - ctx.addChild(renderer); - renderer.render(); - } else { - el.createEl("pre", { - text: - "Error parsing oracle result\n" + - JSON.stringify(validatedOracle.error.format()), - }); - } - }, - ); -} - -// function renderRoll(roll: Roll): string { -// switch (roll.kind) { -// case "multi": -// return `(${roll.roll} on ${roll.table.Title.Standard} -> ${ -// roll.row.Result -// }): ${roll.results.map((r) => renderRoll(r)).join(", ")}`; -// case "simple": -// return `(${roll.roll} on ${roll.table.Title.Standard}) ${roll.row.Result}`; -// case "templated": -// return `(${roll.roll} on ${roll.table.Title.Standard}) ${roll.row[ -// "Roll template" -// ]?.Result?.replace(/\{\{([^{}]+)\}\}/g, (_match, id) => { -// // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -// return renderRoll(roll.templateRolls.get(id)!); -// })}`; -// default: { -// const _exhaustiveCheck: never = roll; -// return _exhaustiveCheck; -// } -// } -// } - -function renderDetails(roll: RollSchema): string { - let result = `${roll.tableName} (${roll.roll}: ${ - roll.raw ?? roll.results[0] - })`; - const subrolls = roll.subrolls ?? []; - if (subrolls.length > 0) { - result += ` -> (${subrolls.map(renderDetails).join(", ")})`; - } - return result; -} - -export function renderRollPath(roll: RollSchema): string { - let result = `${roll.tableId}:${roll.roll}`; - const subrolls = roll.subrolls ?? []; - if (subrolls.length > 0) { - result += `(${subrolls.map(renderRollPath).join(",")})`; - } - return result; -} +import { App, MarkdownRenderChild, MarkdownRenderer } from "obsidian"; +import { Oracle, OracleGrouping, OracleGroupingType } from "../model/oracle"; export function oracleNameWithParents(oracle: Oracle): string { const steps = [oracle.name]; - let next = oracle.parent; + let next: OracleGrouping = oracle.parent; while (next && next.grouping_type != OracleGroupingType.Ruleset) { steps.unshift(next.name); next = next.parent; @@ -95,109 +12,8 @@ export function oracleNameWithParents(oracle: Oracle): string { return steps.join(" / "); } -export function renderOracleCallout( - question: string | undefined, - rollWrapper: RollWrapper, -): string { - const roll = rollWrapper.dehydrate(); - return `> [!oracle] ${question ?? "Ask the Oracle"} (${oracleNameWithParents( - rollWrapper.oracle, - )}): ${roll.results.join("; ")} %%${renderRollPath(roll)}%%\n>\n\n`; -} - -export function renderDetailedOracleCallout(oracle: OracleSchema): string { - const { roll, question } = oracle; - return `> [!oracle] ${question ?? "Ask the Oracle"}: ${roll.results.join( - "; ", - )}\n> ${renderDetails(roll)}\n\n`; -} - -class OracleMarkdownRenderChild extends MarkdownRenderChild { - protected _renderEl?: HTMLElement; - - constructor( - containerEl: HTMLElement, - protected readonly app: App, - protected readonly ctx: MarkdownPostProcessorContext, - protected readonly datastore: Datastore, - protected readonly oracle: OracleSchema, - ) { - super(containerEl); - } - - template(): string { - return renderDetailedOracleCallout(this.oracle); - } - - async render(): Promise { - this._renderEl!.replaceChildren(); - await MarkdownRenderer.render( - this.app, - this.template(), - this._renderEl!, - this.ctx.sourcePath, - this, - ); - } - - async onload(): Promise { - const div = this.containerEl.createDiv(); - const button = div.createEl("button", { type: "button", text: "Re-roll" }); - // TODO: only render actions if we are in edit-only mode - button.onClickEvent(async (_ev) => { - const view = this.app.workspace.getActiveViewOfType(MarkdownView); - if (this.ctx.sourcePath !== view?.file?.path) { - throw new Error( - `ctx path ${this.ctx.sourcePath} that doesn't match view path ${view?.file?.path}`, - ); - } - - const sectionInfo = this.ctx.getSectionInfo(this.containerEl); - if (view?.editor != null && sectionInfo != null) { - const editor = view.editor; - - const oracles = this.datastore.oracles; - const result = await new OracleRoller(oracles).roll( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - oracles.get(this.oracle.roll.tableId)!, - ); - - editor.replaceRange( - "\n\n" + formatOracleBlock({ roll: result }), - { line: sectionInfo.lineEnd + 1, ch: 0 }, - { line: sectionInfo.lineEnd + 1, ch: 0 }, - ); - } - }); - this._renderEl = this.containerEl.createDiv(); - - if (this.datastore.ready) { - await this.render(); - } - this.registerEvent( - this.app.metadataCache.on("iron-vault:index-changed", async () => { - await this.render(); - }), - ); - } -} - -export function formatOracleBlock({ - question, - roll, -}: { - question?: string; - roll: RollWrapper; -}): string { - const oracle: OracleSchema = { - question, - roll: roll.dehydrate(), - }; - return `\`\`\`oracle\n${stringifyYaml(oracle)}\`\`\`\n\n`; -} - export async function generateOracleTable( - plugin: IronVaultPlugin, + app: App, oracle: Oracle, ): Promise { const table = document.createElement("table"); @@ -245,11 +61,11 @@ export async function generateOracleTable( } tr.createEl("td", { text: rangeText }); const td = tr.createEl("td"); - await renderMarkdown(plugin, td, row.text); + await renderMarkdown(app, td, row.text); if (numColumns >= 2) { const td = tr.createEl("td"); await renderMarkdown( - plugin, + app, td, (row as Datasworn.OracleRollableRowText2).text2 ?? "", ); @@ -257,7 +73,7 @@ export async function generateOracleTable( if (numColumns >= 3) { const td = tr.createEl("td"); await renderMarkdown( - plugin, + app, td, (row as Datasworn.OracleRollableRowText3).text3 ?? "", ); @@ -266,13 +82,9 @@ export async function generateOracleTable( return table; } -async function renderMarkdown( - plugin: IronVaultPlugin, - target: HTMLElement, - md: string, -) { +async function renderMarkdown(app: App, target: HTMLElement, md: string) { await MarkdownRenderer.render( - plugin.app, + app, md, target, "", diff --git a/src/oracles/roller.ts b/src/oracles/roller.ts index 179c989e..83b6fb68 100644 --- a/src/oracles/roller.ts +++ b/src/oracles/roller.ts @@ -1,14 +1,45 @@ import { StandardIndex } from "datastore/data-indexer"; +import IronVaultPlugin from "index"; import { Oracle, RollContext } from "model/oracle"; import { RollWrapper } from "model/rolls"; +import { Dice, DieKind } from "utils/dice"; +import { + AsyncDiceRoller, + DiceRoller, + GraphicalDiceRoller, + PlainDiceRoller, +} from "utils/dice-roller"; export class OracleRoller implements RollContext { - constructor(protected index: StandardIndex) {} + constructor( + protected readonly plugin: IronVaultPlugin, + protected readonly index: StandardIndex, + ) {} + + diceRoller(): AsyncDiceRoller & DiceRoller { + return this.plugin.settings.graphicalOracleDice + ? new GraphicalDiceRoller(this.plugin) + : PlainDiceRoller.INSTANCE; + } lookup(id: string): Oracle | undefined { return this.index.get(id); } + get useCursedDie(): boolean { + return this.plugin.settings.enableCursedDie; + } + + get cursedDieSides(): number { + return this.plugin.settings.cursedDieSides; + } + + cursedDice(): Dice | undefined { + return this.useCursedDie + ? new Dice(1, this.cursedDieSides, DieKind.Cursed) + : undefined; + } + async roll(oracle: Oracle | string): Promise { let table: Oracle | undefined; if (typeof oracle === "string") { @@ -19,47 +50,7 @@ export class OracleRoller implements RollContext { } else { table = oracle; } + return new RollWrapper(table, this, await table.roll(this)); } } - -// export function hydrateRoll(index: OracleIndex, rollData: RollSchema): Roll { -// const { kind, roll, table: tableId, row: rowId } = rollData; -// const table = index.getTable(tableId); -// if (table == null) { -// throw new Error(`oracle table with id ${tableId} not found in index`); -// } - -// // TODO: use information present (static result values) -// const row = table.Table.find( -// (row): row is OracleTableRow => "$id" in row && row.$id === rowId, -// ); -// if (row == null) { -// throw new Error(`missing oracle row ${rowId} in oracle table ${tableId}`); -// } -// switch (kind) { -// case "simple": -// return { kind, roll, table, row }; -// case "multi": -// return { -// kind, -// roll, -// table, -// row, -// results: rollData.results.map((r) => hydrateRoll(index, r)), -// }; -// case "templated": { -// const templateRolls = new Map(); -// for (const [k, v] of Object.entries(rollData.templateRolls)) { -// templateRolls.set(k, hydrateRoll(index, v)); -// } -// return { -// kind, -// roll, -// table, -// row, -// templateRolls, -// }; -// } -// } -// } diff --git a/src/rules/ruleset.ts b/src/rules/ruleset.ts index 9d5e4c57..321fc11b 100644 --- a/src/rules/ruleset.ts +++ b/src/rules/ruleset.ts @@ -1,4 +1,5 @@ import { type Datasworn } from "@datasworn/core"; +import { rootLogger } from "logger"; import { z } from "zod"; export type ImpactCategory = Omit; @@ -92,16 +93,51 @@ export class ConditionMeterDefinition implements Readonly { } } +const logger = rootLogger.getLogger("ruleset"); + +function warnSameBaseRuleset( + base: Datasworn.Ruleset, + expansions: Datasworn.Expansion[], +) { + const nonmatching = expansions.filter((exp) => exp.ruleset != base._id); + if (nonmatching.length > 0) { + // TODO(@cwegrzyn): should this be an error? + logger.warn( + "Expansions included that do not target current base ruleset %s: %s", + base._id, + nonmatching.map((exp) => `${exp._id} targets ${exp.ruleset}`).join("; "), + ); + } +} + export class Ruleset { readonly condition_meters: Record; readonly stats: Record; readonly impacts: Record; readonly special_tracks: Record; + /** Value that will be associated with parsed character docs that are compatible with this ruleset. */ + readonly validationTag: string; + + /** All rules packages included. Base is always first. */ + readonly rulesPackageIds: string[]; + constructor( - public readonly ids: string[], - rules: Datasworn.Rules, + private readonly base: Datasworn.Ruleset, + private readonly expansions: Datasworn.Expansion[], ) { + warnSameBaseRuleset(base, expansions); + const rules = mergeRules( + base.rules, + expansions.flatMap(({ rules }) => (rules != null ? [rules] : [])), + ); + + this.rulesPackageIds = [base._id, ...expansions.map((exp) => exp._id)]; + + // TODO(@cwegrzyn): in some kind of perfect magical world maybe we compute this as a + // hash of the contents or something else that would really mark the ruleset. + this.validationTag = this.rulesPackageIds.join(";"); + this.condition_meters = Object.fromEntries( Object.entries(rules.condition_meters).map(([key, meter]) => [ key, @@ -128,9 +164,11 @@ export class Ruleset { this.special_tracks = rules.special_tracks; } - get id() { - const allIds = [...this.ids]; - allIds.sort(); - return allIds.join("|"); + merge(updates: Datasworn.Expansion): Ruleset { + return new Ruleset(this.base, [...this.expansions, updates]); + } + + get baseRulesetId(): string { + return this.rulesPackageIds[0]; } } diff --git a/src/settings/index.ts b/src/settings/index.ts index 8a3bb575..9a694342 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -12,18 +12,6 @@ export class IronVaultPluginSettings { cursedDieSides: number = 10; alwaysPromptActiveCharacter: boolean = false; - /** Index and enable Ironsworn base content. */ - enableIronsworn: boolean = false; - - /** Index and enable Ironsworn: Delve expansion content. */ - enableIronswornDelve: boolean = false; - - /** Index and enable Starforged base content. */ - enableStarforged: boolean = true; - - /** Index and enable Sundered Isles expansion content. */ - enableSunderedIsles: boolean = false; - defaultProgressTrackFolder: string = "Progress"; defaultClockFolder: string = "Clocks"; defaultCharactersFolder: string = "Characters"; diff --git a/src/settings/ui.ts b/src/settings/ui.ts index 561cca2f..93211fcd 100644 --- a/src/settings/ui.ts +++ b/src/settings/ui.ts @@ -2,24 +2,6 @@ import IronVaultPlugin from "index"; import { PluginSettingTab, Setting, type App } from "obsidian"; import { IronVaultPluginSettings } from "settings"; import { FolderTextSuggest } from "utils/ui/settings/folder"; -import delveLogoBin from "../../media/ironvault_logo_delve.png"; -import ironswornLogoBin from "../../media/ironvault_logo_ironsworn.png"; -import starforgedLogoBin from "../../media/ironvault_logo_starforged.png"; -import sunderedIslesLogoBin from "../../media/ironvault_logo_sunderedisles.png"; - -function bytesToPngDataURI(bytes: Uint8Array) { - const binString = Array.from(bytes, (byte) => - String.fromCodePoint(byte), - ).join(""); - return "data:image/png;base64," + btoa(binString); -} - -const IS_LOGO = bytesToPngDataURI(ironswornLogoBin as unknown as Uint8Array); -const DELVE_LOGO = bytesToPngDataURI(delveLogoBin as unknown as Uint8Array); -const SF_LOGO = bytesToPngDataURI(starforgedLogoBin as unknown as Uint8Array); -const SI_LOGO = bytesToPngDataURI( - sunderedIslesLogoBin as unknown as Uint8Array, -); export class IronVaultSettingTab extends PluginSettingTab { plugin: IronVaultPlugin; @@ -77,72 +59,6 @@ export class IronVaultSettingTab extends PluginSettingTab { }); }); - new Setting(containerEl).setName("Rulesets").setHeading(); - - const isSetting = new Setting(containerEl) - .setName("Enable Ironsworn ruleset") - .setDesc( - "If enabled, Ironsworn Core oracles, assets, truths, and moves will be available for play.", - ) - .addToggle((toggle) => { - toggle - .setValue(settings.enableIronsworn) - .onChange((value) => this.updateSetting("enableIronsworn", value)); - }); - const isImg = document.createElement("img"); - isImg.src = IS_LOGO; - isImg.toggleClass("ruleset-img", true); - isSetting.settingEl.prepend(isImg); - - const delveSetting = new Setting(containerEl) - .setName("Enable Delve expansion for Ironsworn") - .setDesc( - "(experimental) If enabled, Ironsworn: Delve Core oracles, assets, and moves will be available for play.", - ) - .addToggle((toggle) => { - toggle - .setValue(settings.enableIronswornDelve) - .onChange((value) => - this.updateSetting("enableIronswornDelve", value), - ); - }); - const delveImg = document.createElement("img"); - delveImg.src = DELVE_LOGO; - delveImg.toggleClass("ruleset-img", true); - delveSetting.settingEl.prepend(delveImg); - - const sfSetting = new Setting(containerEl) - .setName("Enable Starforged ruleset") - .setDesc( - "If enabled, Ironsworn: Starforged oracles, assets, truths, and moves will be available for play.", - ) - .addToggle((toggle) => { - toggle - .setValue(settings.enableStarforged) - .onChange((value) => this.updateSetting("enableStarforged", value)); - }); - const sfImg = document.createElement("img"); - sfImg.src = SF_LOGO; - sfImg.toggleClass("ruleset-img", true); - sfSetting.settingEl.prepend(sfImg); - - const siSetting = new Setting(containerEl) - .setName("Enable Sundered Isles expansion for Starforged") - .setDesc( - "(experimental) If enabled, Sundered Isles oracles, assets, and moves will be available for play. Sundered Isles data is considered in preview, pending finalization of the rulebook.", - ) - .addToggle((toggle) => { - toggle - .setValue(settings.enableSunderedIsles) - .onChange((value) => - this.updateSetting("enableSunderedIsles", value), - ); - }); - const siImg = document.createElement("img"); - siImg.src = SI_LOGO; - siImg.toggleClass("ruleset-img", true); - siSetting.settingEl.prepend(siImg); - new Setting(containerEl).setName("Homebrew").setHeading(); new Setting(containerEl) diff --git a/src/sidebar/moves.ts b/src/sidebar/moves.ts index 0ca082b6..d0656435 100644 --- a/src/sidebar/moves.ts +++ b/src/sidebar/moves.ts @@ -3,31 +3,31 @@ import { html, render } from "lit-html"; import { map } from "lit-html/directives/map.js"; import MiniSearch from "minisearch"; +import { IDataContext } from "datastore/data-context"; +import { AnyDataswornMove } from "datastore/datasworn-indexer"; import IronVaultPlugin from "index"; import { MoveModal } from "moves/move-modal"; import { md } from "utils/ui/directives"; -export default async function renderIronVaultMoves( +export default function renderIronVaultMoves( cont: HTMLElement, plugin: IronVaultPlugin, + dataContext: IDataContext, ) { - const loading = cont.createEl("p", { text: "Loading data..." }); - await plugin.datastore.waitForReady; - loading.remove(); - litHtmlMoveList(cont, plugin, makeIndex(plugin)); + litHtmlMoveList(cont, plugin, dataContext, makeIndex(dataContext)); } function litHtmlMoveList( cont: HTMLElement, plugin: IronVaultPlugin, + dataContext: IDataContext, searchIdx: MiniSearch, filter?: string, ) { const results = filter ? searchIdx.search(filter) - : // TODO: use the current context - [...plugin.datastore.moves.values()].map((m) => ({ id: m._id })); - const categories = plugin.datastore.moveCategories.values(); + : [...dataContext.moves.values()].map((m) => ({ id: m._id })); + const categories = dataContext.moveCategories.values(); let total = 0; const sources: Record = {}; for (const cat of categories) { @@ -53,7 +53,7 @@ function litHtmlMoveList( placeholder="Filter moves..." @input=${(e: Event) => { const input = e.target as HTMLInputElement; - litHtmlMoveList(cont, plugin, searchIdx, input.value); + litHtmlMoveList(cont, plugin, dataContext, searchIdx, input.value); }} />