From 16af6a3fca3a5af7ee9c715116cb2433a18d9888 Mon Sep 17 00:00:00 2001 From: Chris Wegrzyn Date: Fri, 17 May 2024 13:19:14 -0400 Subject: [PATCH] Support custom meters in take/suffer --- src/characters/action-context.ts | 98 +++++++++++++++++---- src/characters/commands.ts | 13 ++- src/characters/lens.ts | 51 +++++++---- src/characters/meter-commands.ts | 143 ++++++++++++++++++++++--------- src/index.ts | 6 +- src/moves/action/index.ts | 121 +++++++++++++++----------- src/utils/strings.ts | 3 + src/utils/suggest.ts | 37 ++++++-- 8 files changed, 333 insertions(+), 139 deletions(-) create mode 100644 src/utils/strings.ts diff --git a/src/characters/action-context.ts b/src/characters/action-context.ts index 25c62730..ae082bf5 100644 --- a/src/characters/action-context.ts +++ b/src/characters/action-context.ts @@ -1,45 +1,77 @@ import { Move } from "@datasworn/core"; +import { App } from "obsidian"; +import { ConditionMeterDefinition } from "rules/ruleset"; +import { vaultProcess } from "utils/obsidian"; import { CharacterContext } from "../character-tracker"; -import { movesReader, rollablesReader } from "../characters/lens"; +import { + CharReader, + MOMENTUM_METER_DEFINITION, + MeterWithLens, + MeterWithoutLens, + ValidatedCharacter, + meterLenses, + movesReader, + rollablesReader, +} from "../characters/lens"; import { type Datastore } from "../datastore"; import ForgedPlugin from "../index"; -import { MeterCommon } from "../rules/ruleset"; import { InfoModal } from "../utils/ui/info"; -export interface ActionContext { +export interface IActionContext { + readonly kind: "no_character" | "character"; readonly moves: Move[]; - readonly rollables: { - key: string; - value?: number | undefined; - definition: MeterCommon; - }[]; + readonly rollables: (MeterWithLens | MeterWithoutLens)[]; + readonly conditionMeters: ( + | MeterWithLens + | MeterWithoutLens + )[]; + readonly momentum?: number; + + getWithLens(lens: CharReader): T | undefined; } -export class NoCharacterActionConext implements ActionContext { +export class NoCharacterActionConext implements IActionContext { + readonly kind = "no_character"; + readonly momentum: undefined = undefined; + constructor(public readonly datastore: Datastore) {} get moves(): Move[] { return this.datastore.moves; } - get rollables(): { - key: string; - value?: number | undefined; - definition: MeterCommon; - }[] { + get rollables(): MeterWithoutLens[] { return Object.entries(this.datastore.ruleset.stats).map(([key, stat]) => ({ key, definition: stat, + lens: undefined, + value: undefined, })); } - get momentum() { + getWithLens(_lens: CharReader): undefined { return undefined; } + + get conditionMeters(): MeterWithoutLens[] { + return [ + ...Object.entries(this.datastore.ruleset.condition_meters).map( + ([key, definition]) => ({ + key, + definition, + lens: undefined, + value: undefined, + }), + ), + MOMENTUM_METER_DEFINITION, + ]; + } } -export class CharacterActionContext implements ActionContext { +export class CharacterActionContext implements IActionContext { + readonly kind = "character"; + constructor( public readonly datastore: Datastore, public readonly characterPath: string, @@ -57,7 +89,7 @@ export class CharacterActionContext implements ActionContext { return this.datastore.moves.concat(characterMoves); } - get rollables(): { key: string; value?: number; definition: MeterCommon }[] { + get rollables(): MeterWithLens[] { return rollablesReader( this.characterContext.lens, this.datastore.index, @@ -69,7 +101,39 @@ export class CharacterActionContext implements ActionContext { this.characterContext.character, ); } + + getWithLens(lens: CharReader): T { + return lens.get(this.characterContext.character); + } + + get conditionMeters(): MeterWithLens[] { + const { character, lens } = this.characterContext; + return Object.values( + meterLenses(lens, character, this.datastore.index), + ).map(({ key, definition, lens }) => ({ + key, + definition, + lens, + value: lens.get(character), + })); + } + + async update( + app: App, + updater: ( + obj: ValidatedCharacter, + context: CharacterContext, + ) => ValidatedCharacter, + ) { + return await this.characterContext.updater( + vaultProcess(app, this.characterPath), + updater, + ); + } } + +export type ActionContext = CharacterActionContext | NoCharacterActionConext; + export async function determineCharacterActionContext( plugin: ForgedPlugin, ): Promise { diff --git a/src/characters/commands.ts b/src/characters/commands.ts index 1275424b..eca474fe 100644 --- a/src/characters/commands.ts +++ b/src/characters/commands.ts @@ -2,6 +2,7 @@ import { Asset } from "@datasworn/core"; import ForgedPlugin from "index"; import { Editor, FuzzyMatch, MarkdownView } from "obsidian"; import { vaultProcess } from "utils/obsidian"; +import { titleCase } from "utils/strings"; import { CustomSuggestModal } from "utils/suggest"; import { PromptModal } from "utils/ui/prompt"; import { @@ -12,14 +13,10 @@ import { updateAssetWithOptions, } from "./assets"; -export function titleCase(str: string): string { - return str.slice(0, 1).toUpperCase() + str.slice(1); -} - export async function addAssetToCharacter( plugin: ForgedPlugin, - editor: Editor, - view: MarkdownView, + _editor: Editor, + _view: MarkdownView, ): Promise { const [path, context] = plugin.characters.activeCharacter(); const { character, lens } = context; @@ -37,7 +34,7 @@ export async function addAssetToCharacter( plugin.app, availableAssets, (asset) => asset.name, - ({ item: asset, match }: FuzzyMatch, el: HTMLElement) => { + ({ item: asset }: FuzzyMatch, el: HTMLElement) => { el.createEl("small", { text: asset.category + @@ -61,7 +58,7 @@ export async function addAssetToCharacter( const choice = await CustomSuggestModal.select( plugin.app, Object.entries(optionControl.choices), - ([choiceKey, choice]) => choice.label, + ([_choiceKey, choice]) => choice.label, undefined, titleCase(optionControl.label), ); diff --git a/src/characters/lens.ts b/src/characters/lens.ts index 64133c28..df0151d0 100644 --- a/src/characters/lens.ts +++ b/src/characters/lens.ts @@ -340,14 +340,24 @@ export function conditionMetersReader( }); } +export const MOMENTUM_METER_DEFINITION: MeterWithoutLens = + { + key: "momentum", + lens: undefined, + value: undefined, + definition: new ConditionMeterDefinition({ + label: "momentum", + min: -6, + max: 10, + rollable: true, + }), + }; + export function meterLenses( charLens: CharacterLens, character: ValidatedCharacter, dataIndex: DataIndex, -): Record< - string, - { key: string; definition: ConditionMeterDefinition; lens: CharLens } -> { +): Record> { const baseMeters = Object.fromEntries( Object.entries(charLens.condition_meters).map(([key, lens]) => [ key, @@ -377,37 +387,46 @@ export function meterLenses( .map((val) => [val.key, val]); return { ...baseMeters, - momentum: { - key: "momentum", - lens: charLens.momentum, - definition: new ConditionMeterDefinition({ - label: "momentum", - min: -6, - max: 10, - rollable: true, - }), - }, + momentum: { ...MOMENTUM_METER_DEFINITION, lens: charLens.momentum }, ...Object.fromEntries(allAssetMeters), }; } +export type KeyWithDefinition = { key: string; definition: T }; + +export type WithCharLens = Base & { lens: CharLens; value: T }; +export type WithoutCharLens = Base & { + lens: undefined; + value: undefined; +}; + +export type MeterWithoutLens = + WithoutCharLens>; + +export type MeterWithLens = WithCharLens< + KeyWithDefinition, + number +>; + export function rollablesReader( charLens: CharacterLens, dataIndex: DataIndex, -): CharReader<{ key: string; value: number; definition: MeterCommon }[]> { +): CharReader { return reader((character) => { return [ ...Object.values(meterLenses(charLens, character, dataIndex)).map( ({ key, definition, lens }) => ({ key, definition, + lens, value: lens.get(character), }), ), ...Object.entries(charLens.stats).map(([key, lens]) => ({ key, - value: lens.get(character), definition: charLens.ruleset.stats[key], + lens, + value: lens.get(character), })), ]; }); diff --git a/src/characters/meter-commands.ts b/src/characters/meter-commands.ts index b318e613..8c47c72d 100644 --- a/src/characters/meter-commands.ts +++ b/src/characters/meter-commands.ts @@ -1,13 +1,19 @@ import { updatePreviousMoveOrCreateBlock } from "mechanics/editor"; -import { Editor } from "obsidian"; +import { App, Editor } from "obsidian"; import { ConditionMeterDefinition } from "rules/ruleset"; import { MoveBlockFormat } from "settings"; import { node } from "utils/kdl"; import { updating } from "utils/lens"; +import { numberRange } from "utils/numbers"; import { vaultProcess } from "utils/obsidian"; import { CustomSuggestModal } from "utils/suggest"; import ForgedPlugin from "../index"; -import { meterLenses, momentumOps } from "./lens"; +import { + ActionContext, + CharacterActionContext, + determineCharacterActionContext, +} from "./action-context"; +import { MeterWithLens, MeterWithoutLens, momentumOps } from "./lens"; export async function burnMomentum( plugin: ForgedPlugin, @@ -57,55 +63,105 @@ export async function burnMomentum( } } +export async function promptForMeter( + app: App, + actionContext: ActionContext, + meterFilter: ( + meter: + | MeterWithLens + | MeterWithoutLens, + ) => boolean, +): Promise< + | MeterWithLens + | MeterWithoutLens +> { + const { value: meter } = await CustomSuggestModal.selectWithUserEntry( + app, + actionContext.conditionMeters.filter(meterFilter), + ({ definition }) => definition.label, + (input, el) => { + el.setText(`Use custom meter '${input}'`); + }, + (match, el) => { + el.createEl("small", { text: `${match.item.value}` }); + }, + "Choose a meter", + (input): MeterWithoutLens => ({ + key: input, + lens: undefined, + value: undefined, + definition: { + kind: "condition_meter", + label: input, + min: -10, + max: 10, + rollable: true, + }, + }), + ); + return meter; +} + export const modifyMeterCommand = async ( plugin: ForgedPlugin, editor: Editor, verb: string, - meterFilter: (meter: { - value: number; - definition: ConditionMeterDefinition; - }) => boolean, - allowableValues: (measure: { - value: number; - definition: ConditionMeterDefinition; - }) => number[], + meterFilter: ( + meter: + | MeterWithLens + | MeterWithoutLens, + ) => boolean, + allowableValues: ( + // This is a meter with a defined value but possibly no lens + measure: Omit, "lens">, + ) => number[], ) => { // todo: multichar - const [path, context] = plugin.characters.activeCharacter(); - const { character, lens } = context; - const measure = await CustomSuggestModal.select( - plugin.app, - Object.values(meterLenses(lens, character, plugin.datastore.index)) - .map(({ key, definition, lens }) => ({ - key, - definition, - lens, - value: lens.get(character), - })) - .filter(meterFilter), - ({ definition }) => definition.label, - (match, el) => { - el.createEl("small", { text: `${match.item.value}` }); - }, - ); + const actionContext = await determineCharacterActionContext(plugin); + if (!actionContext) { + return; + } + + const choice = await promptForMeter(plugin.app, actionContext, meterFilter); + + let measure: + | MeterWithLens + | (Omit, "value"> & { + value: number; + }); + if (!choice.value) { + const value = await CustomSuggestModal.select( + plugin.app, + numberRange(choice.definition.min, choice.definition.max), + (n) => n.toString(10), + undefined, + `What is the starting value of ${choice.definition.label}?`, + ); + measure = { ...choice, value }; + } else { + measure = choice; + } + const modifier = await CustomSuggestModal.select( plugin.app, allowableValues(measure), (n) => n.toString(), undefined, - `${verb} ${measure.definition.label}`, - ); - const updated = await context.updater( - vaultProcess(plugin.app, path), - (character) => { - return updating( - measure.lens, - (startVal) => startVal + modifier, - )(character); - }, + `Choose the amount to ${verb} on the '${measure.definition.label}' meter.`, ); + + let newValue: number; + if (actionContext instanceof CharacterActionContext && measure.lens) { + const updated = await actionContext.update( + plugin.app, + updating(measure.lens, (startVal) => startVal + modifier), + ); + newValue = measure.lens.get(updated); + } else { + newValue = measure.value + modifier; + } + if (plugin.settings.moveBlockFormat == MoveBlockFormat.Mechanics) { - const newValue = measure.lens.get(updated); const meterNode = node("meter", { values: [measure.key], properties: { from: measure.value, to: newValue }, @@ -126,9 +182,16 @@ export const modifyMeterCommand = async ( }); editor.replaceSelection( template({ - character: { name: lens.name.get(character) }, + character: { + name: + actionContext instanceof CharacterActionContext + ? actionContext.getWithLens( + actionContext.characterContext.lens.name, + ) + : "Unknown", + }, measure, - newValue: measure.lens.get(updated), + newValue, }), ); } diff --git a/src/index.ts b/src/index.ts index d62f58f1..ae39168c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -175,7 +175,8 @@ export default class ForgedPlugin extends Plugin { this, editor, "take", - ({ value, definition: { max } }) => value < max, + ({ value, definition: { max } }) => + value === undefined || value < max, (measure) => Array(measure.definition.max - measure.value) .fill(0) @@ -191,7 +192,8 @@ export default class ForgedPlugin extends Plugin { this, editor, "suffer", - ({ value, definition: { min } }) => value > min, + ({ value, definition: { min } }) => + value === undefined || value > min, (measure) => Array(measure.value - measure.definition.min) .fill(0) diff --git a/src/moves/action/index.ts b/src/moves/action/index.ts index b56e84e3..730d1966 100644 --- a/src/moves/action/index.ts +++ b/src/moves/action/index.ts @@ -17,7 +17,11 @@ import { type MarkdownView, } from "obsidian"; import { MeterCommon } from "rules/ruleset"; -import { momentumOps } from "../../characters/lens"; +import { + MeterWithLens, + MeterWithoutLens, + momentumOps, +} from "../../characters/lens"; import { ProgressContext } from "../../tracks/context"; import { selectProgressTrack } from "../../tracks/select"; import { ProgressTrackWriterContext } from "../../tracks/writer"; @@ -240,57 +244,14 @@ async function handleActionRoll( move: MoveActionRoll, ) { const suggestedRollables = suggestedRollablesForMove(move); - const availableRollables = actionContext.rollables; - const choice = await CustomSuggestModal.selectWithUserEntry( + const stat = await promptForRollable( app, - availableRollables - .map((meter) => { - return { ...meter, condition: suggestedRollables[meter.key] ?? [] }; - }) - .sort((a, b) => { - if (a.condition.length > 0 && b.condition.length == 0) { - return -1; - } else if (a.condition.length == 0 && b.condition.length > 0) { - return 1; - } else { - return ( - (b.value ?? 0) - (a.value ?? 0) || - a.definition.label.localeCompare(b.definition.label) - ); - } - }), - (m) => `${m.definition.label}: ${m.value ?? "unknown"}`, - (input, el) => { - el.setText(`Use custom meter '${input}'`); - }, - ({ item }, el) => { - if (item.condition.length > 0) { - el.createEl("small", { - text: `Trigger: ${item.condition.flatMap((cond) => cond.text ?? []).join("; ")}`, - cls: "forged-suggest-hint", - }); - } - }, - move.trigger.text, + actionContext, + suggestedRollables, + move, ); - const stat = - choice.kind == "pick" - ? choice.value - : { - key: choice.custom, - value: undefined, - condition: [], - definition: { - kind: "stat", - label: choice.custom, - min: 0, - max: 10, - rollable: true, - } satisfies MeterCommon, - }; - // This stat has an unknown value, so we need to prompt the user for a value. if (!stat.value) { stat.value = await CustomSuggestModal.select( @@ -343,3 +304,67 @@ async function handleActionRoll( return description; } + +async function promptForRollable( + app: App, + actionContext: ActionContext, + suggestedRollables: Record< + string, + Omit[] + >, + move: MoveActionRoll, +): Promise< + (MeterWithLens | MeterWithoutLens) & { + condition: Omit[]; + } +> { + const availableRollables = actionContext.rollables; + + const { value: stat } = await CustomSuggestModal.selectWithUserEntry( + app, + availableRollables + .map((meter) => ({ + ...meter, + condition: suggestedRollables[meter.key] ?? [], + })) + .sort((a, b) => { + if (a.condition.length > 0 && b.condition.length == 0) { + return -1; + } else if (a.condition.length == 0 && b.condition.length > 0) { + return 1; + } else { + return ( + (b.value ?? 0) - (a.value ?? 0) || + a.definition.label.localeCompare(b.definition.label) + ); + } + }), + (m) => `${m.definition.label}: ${m.value ?? "unknown"}`, + (input, el) => { + el.setText(`Use custom meter '${input}'`); + }, + ({ item }, el) => { + if (item.condition.length > 0) { + el.createEl("small", { + text: `Trigger: ${item.condition.flatMap((cond) => cond.text ?? []).join("; ")}`, + cls: "forged-suggest-hint", + }); + } + }, + move.trigger.text, + (custom) => ({ + key: custom, + value: undefined, + lens: undefined, + condition: [], + definition: { + kind: "stat", + label: custom, + min: 0, + max: 10, + rollable: true, + } satisfies MeterCommon, + }), + ); + return stat; +} diff --git a/src/utils/strings.ts b/src/utils/strings.ts new file mode 100644 index 00000000..b18e8124 --- /dev/null +++ b/src/utils/strings.ts @@ -0,0 +1,3 @@ +export function titleCase(str: string): string { + return str.slice(0, 1).toUpperCase() + str.slice(1); +} diff --git a/src/utils/suggest.ts b/src/utils/suggest.ts index 61c235e2..c4da2b10 100644 --- a/src/utils/suggest.ts +++ b/src/utils/suggest.ts @@ -27,8 +27,8 @@ export function processMatches( } } -export type UserChoice = - | { kind: "custom"; custom: string } +export type UserChoice = + | { kind: "custom"; custom: string; value: U } | { kind: "pick"; value: T }; export class CustomSuggestModal extends SuggestModal> { @@ -76,11 +76,29 @@ export class CustomSuggestModal extends SuggestModal> { items: T[], getItemText: (item: T) => string, renderUserEntry: (input: string, el: HTMLElement) => void, - renderExtras?: (match: FuzzyMatch, el: HTMLElement) => void, - placeholder?: string, - ): Promise> { + renderExtras: (match: FuzzyMatch, el: HTMLElement) => void, + placeholder: string, + ): Promise>; + static async selectWithUserEntry( + app: App, + items: T[], + getItemText: (item: T) => string, + renderUserEntry: (input: string, el: HTMLElement) => void, + renderExtras: (match: FuzzyMatch, el: HTMLElement) => void, + placeholder: string, + createUserValue: (input: string) => U, + ): Promise>; + static async selectWithUserEntry( + app: App, + items: T[], + getItemText: (item: T) => string, + renderUserEntry: (input: string, el: HTMLElement) => void, + renderExtras: (match: FuzzyMatch, el: HTMLElement) => void, + placeholder: string, + createUserValue?: (input: string) => unknown, + ): Promise> { return await new Promise((resolve, reject) => { - new this>( + new this>( app, items.map((value) => ({ kind: "pick", value })), (choice) => @@ -109,7 +127,11 @@ export class CustomSuggestModal extends SuggestModal> { resolve, reject, placeholder, - (custom) => ({ kind: "custom", custom }), + (custom) => ({ + kind: "custom", + custom, + value: createUserValue ? createUserValue(custom) : undefined, + }), ).open(); }); } @@ -174,7 +196,6 @@ export class CustomSuggestModal extends SuggestModal> { match: { score: Number.NEGATIVE_INFINITY, matches: [] }, }); } - console.log(results); sortSearchResults(results); return results; }