diff --git a/docs/Commands/Advance a clock.md b/docs/Commands/Advance a clock.md index 3cd1f65..b74a88b 100644 --- a/docs/Commands/Advance a clock.md +++ b/docs/Commands/Advance a clock.md @@ -1,5 +1,7 @@ Advances a [[Clocks|Clock]], modifying its frontmatter, and inserts a [[Mechanics Blocks#`clock`|`clock` mechanics node]] into your active journal. Clocks which are already filled, or which are completed, cannot be advanced (and will not show up at all in the picker for the command). +If your clock has a "default odds" value other than "no roll", before the clock is advanced, you will be prompted to pick an odds for advancing the clock, with your "default odds" value preselected. Picking "certain" will advance the clock without a roll; otherwise, you can choose a different odds value as the situation warrants, or hit enter to roll with your default odds. A 1d100 roll will be made, and if the resulting value is less than or equal to the chosen odds, the clock will be advanced. + Advancing a Clock to completion will not automatically be marked as resolved. You will be asked if you wish to mark the clock as resolved. If you choose not to resolve it now, you can always resolve it as needed with the [[Resolve a clock]] command. ![[Mechanics Blocks#`clock`#Example|iv-embed]] diff --git a/docs/Commands/Create a clock.md b/docs/Commands/Create a clock.md index 4d30fe3..0de99f4 100644 --- a/docs/Commands/Create a clock.md +++ b/docs/Commands/Create a clock.md @@ -1,5 +1,12 @@ Creates a [[Entities/Clocks|Clock]] file and appends a [[Mechanics Blocks#`clock`|`clock` mechanics node]] to the current journal. The Clock file will have all necessary frontmatter for commands like [[Advance a clock]] to work. +When creating a clock, you'll need to set two clock-specific options: + +* Number of segments in the clock +* The "default odds" for the clock: + * If this is "no roll" (the default), then your clock will always be advanced automatically when you [[Advance a clock]]. + * If you pick a different odds value, when you [[Advance a clock]], you will be presented with the option to make a roll whenever you advance, and the chosen value will be preselected. + This command will also write a [[Clock Blocks|Clock Block]] to the newly-made Clock file: ![[Mechanics Blocks#`clock`#Example|iv-embed]] diff --git a/docs/Entities/Clocks.md b/docs/Entities/Clocks.md index def880a..f221126 100644 --- a/docs/Entities/Clocks.md +++ b/docs/Entities/Clocks.md @@ -13,10 +13,11 @@ You can create a clock using the [[Create a clock]] command. To advance the cloc The following schema describes the expected structure of clock file frontmatter in order for Iron Vault to be able to treat it as a clock. It is automatically generated by the [[Create a clock]] command, and modified by the [[Advance a clock]] command. -| Key | Type | About | -| --------------- | ---------------------- | ------------------------------------------------------------------ | -| name | text | Name of the clock, or a brief description of what it's for. | -| segments | number | Total number of segments in the clock. | -| progress | number | Number of *filled in* segments in the clock. | -| tags | tags | Either `complete` or `incomplete`. Completed clocks are read-only. | -| iron-vault-kind | text (must be "clock") | Marks this as a clock file. | +| Key | Type | About | +| ----------------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | +| `name` | text | Name of the clock, or a brief description of what it's for. | +| `segments` | number | Total number of segments in the clock. | +| `progress` | number | Number of *filled in* segments in the clock. | +| `tags` | tags | Either `complete` or `incomplete`. Completed clocks are read-only. | +| `iron-vault-kind` | text (must be "clock") | Marks this as a clock file. | +| `default-odds` | (optional) "small chance" \| "unlikely" \| "50 50" \| "likely" \| "almost certain" \| "certain" \| "no roll" | If present, sets the default odds to advance the clock. If `"no roll"`, you will never be prompted to roll. | diff --git a/src/clocks/clock-create-modal.ts b/src/clocks/clock-create-modal.ts index b8fd207..f550423 100644 --- a/src/clocks/clock-create-modal.ts +++ b/src/clocks/clock-create-modal.ts @@ -1,22 +1,30 @@ import { CampaignFile } from "campaigns/entity"; import IronVaultPlugin from "index"; import { + AbstractInputSuggest, + App, ButtonComponent, + FuzzyMatch, Modal, Setting, TextComponent, debounce, + prepareFuzzySearch, } from "obsidian"; import { generateObsidianFilename } from "utils/filename"; +import { capitalize } from "utils/strings"; +import { processMatches } from "utils/suggest"; import { CampaignSelectComponent } from "utils/ui/settings/campaign-suggest"; import { RelativeFolderSearchComponent } from "utils/ui/settings/relative-folder-search"; import { Clock } from "./clock"; +import { ClockOdds, STANDARD_ODDS } from "./clock-file"; export type ClockCreateResultType = { segments: number; name: string; fileName: string; targetFolder: string; + defaultOdds: ClockOdds | undefined; }; export class ClockCreateModal extends Modal { @@ -25,10 +33,27 @@ export class ClockCreateModal extends Modal { name: "", fileName: "", targetFolder: "", + defaultOdds: "no roll", }; public accepted: boolean = false; + static async show(plugin: IronVaultPlugin): Promise<{ + name: string; + targetFolder: string; + fileName: string; + defaultOdds: ClockOdds | undefined; + clock: Clock; + }> { + return await new Promise((onAccept, onReject) => { + try { + new ClockCreateModal(plugin, {}, onAccept, onReject).open(); + } catch (e) { + onReject(e); + } + }); + } + constructor( readonly plugin: IronVaultPlugin, defaults: Partial = {}, @@ -36,6 +61,7 @@ export class ClockCreateModal extends Modal { name: string; targetFolder: string; fileName: string; + defaultOdds: ClockOdds | undefined; clock: Clock; }) => void, protected readonly onCancel: () => void, @@ -63,7 +89,8 @@ export class ClockCreateModal extends Modal { const validate = debounce(() => { const valid = this.result.name.trim().length > 0 && - this.result.fileName.trim().length > 0; + this.result.fileName.trim().length > 0 && + this.result.defaultOdds != null; createButton.setDisabled(!valid); }, 0); @@ -133,6 +160,33 @@ export class ClockCreateModal extends Modal { }), ); + const frag = new DocumentFragment(); + frag.append( + "Default odds to roll when advancing this clock.", + document.createElement("br"), + "Choose 'no roll' if this clock should advance unconditionally (without prompting).", + ); + new Setting(contentEl) + .setName("Default odds") + .setDesc(frag) + .addSearch((search) => { + new OddsSuggest(this.app, search.inputEl); + + search + .setValue(String(this.result.defaultOdds ?? "")) + .onChange((newOdds) => { + newOdds = newOdds.toLowerCase(); + if (newOdds in STANDARD_ODDS) { + this.result.defaultOdds = newOdds as keyof typeof STANDARD_ODDS; + } else if (newOdds == "no roll") { + this.result.defaultOdds = "no roll"; + } else { + this.result.defaultOdds = undefined; + } + validate(); + }); + }); + onChangeCampaign(); validate(); @@ -158,9 +212,7 @@ export class ClockCreateModal extends Modal { this.accepted = true; this.close(); this.onAccept({ - name: this.result.name, - fileName: this.result.fileName, - targetFolder: this.result.targetFolder, + ...this.result, clock: Clock.create({ name: this.result.name, progress: 0, @@ -177,3 +229,71 @@ export class ClockCreateModal extends Modal { } } } + +export class OddsSuggest extends AbstractInputSuggest> { + readonly items = [...Object.keys(STANDARD_ODDS), "no roll"]; + + constructor( + app: App, + readonly inputEl: HTMLInputElement, + ) { + super(app, inputEl); + } + + getSuggestions(inputStr: string): FuzzyMatch[] { + const numberValue = Number.parseInt(inputStr); + if (!Number.isNaN(numberValue)) { + if (numberValue >= 0 && numberValue <= 100) { + return [ + { item: numberValue.toString(), match: { matches: [], score: 100 } }, + ]; + } else { + return []; + } + } + const searchFn = prepareFuzzySearch(inputStr); + return this.items + .flatMap((item) => { + const match = searchFn(item); + if (match) { + return [{ item, match }]; + } else { + return []; + } + }) + .sort((a, b) => a.match.score - b.match.score); + } + + renderSuggestion({ item, match }: FuzzyMatch, el: HTMLElement): void { + if (item == null) return; + + el.createDiv(undefined, (div) => { + processMatches( + capitalize(item), + match, + (text) => { + div.appendText(text); + }, + (text) => { + div.createEl("strong", { text }); + }, + ); + + if (item.toLowerCase() in STANDARD_ODDS) { + div.appendText( + ` (${(STANDARD_ODDS as Record)[item.toLowerCase()]}%)`, + ); + } + }); + // if (renderExtras != null) { + // renderExtras(match, el); + // } + } + + selectSuggestion({ item }: FuzzyMatch): void { + this.setValue(item); + if (this.inputEl.instanceOf(HTMLInputElement)) + this.inputEl.trigger("input"); + this.close(); + } +} diff --git a/src/clocks/clock-file.ts b/src/clocks/clock-file.ts index b2cc42e..7b1d13a 100644 --- a/src/clocks/clock-file.ts +++ b/src/clocks/clock-file.ts @@ -8,11 +8,40 @@ import { Either, Left } from "../utils/either"; import { updater } from "../utils/update"; import { Clock } from "./clock"; +export const clockOddsSchema = z.enum([ + "small chance", + "unlikely", + "50 50", + "likely", + "almost certain", + "certain", + "no roll", +]); + +export const namedOddsSchema = clockOddsSchema.exclude(["no roll"]); + +export type OddsTable = Record, number>; + +export const STANDARD_ODDS: OddsTable = { + "small chance": 10, + unlikely: 25, + "50 50": 50, + likely: 75, + "almost certain": 90, + certain: 100, +}; + +export type ClockOdds = z.infer; + const clockSchema = z .object({ name: z.string(), segments: z.number().positive(), progress: z.number().nonnegative(), + + /** Default odds of advancing the clock. Choose 'no roll' if you do not wish to be prompted ever. */ + "default-odds": clockOddsSchema.optional(), + tags: z .union([z.string().transform((arg) => [arg]), z.array(z.string())]) .refine( @@ -33,6 +62,7 @@ const clockSchema = z export const normalizedClockSchema = normalizeKeys(clockSchema); +export type ClockInputSchema = z.input; export type ClockSchema = z.output; export class ClockFileAdapter { @@ -48,14 +78,17 @@ export class ClockFileAdapter { static newFromClock({ name, clock, + defaultOdds, }: { name: string; clock: Clock; + defaultOdds: ClockOdds | undefined; }): Either { return this.create({ name, segments: clock.segments, progress: clock.progress, + "default-odds": defaultOdds, tags: !clock.active ? ["complete"] : ["incomplete"], [PLUGIN_KIND_FIELD]: IronVaultKind.Clock, } satisfies z.input); @@ -100,6 +133,22 @@ export class ClockFileAdapter { other, ); } + + /** Returns the normalized numeric default odds for this clock, or */ + normalizedOdds( + oddsTable: OddsTable = STANDARD_ODDS, + ): number | "no roll" | undefined { + const defaultOdds = this.raw["default-odds"]; + if ( + defaultOdds === undefined || + defaultOdds == "no roll" || + typeof defaultOdds == "number" + ) { + return defaultOdds; + } else { + return oddsTable[defaultOdds]; + } + } } export class ClockIndexer extends BaseIndexer { diff --git a/src/clocks/commands.ts b/src/clocks/commands.ts index bf12549..f18a4a3 100644 --- a/src/clocks/commands.ts +++ b/src/clocks/commands.ts @@ -1,5 +1,6 @@ import { determineCampaignContext } from "campaigns/manager"; import IronVaultPlugin from "index"; +import { Node } from "kdljs"; import { appendNodesToMoveOrMechanicsBlock } from "mechanics/editor"; import { createDetailsNode } from "mechanics/node-builders"; import { @@ -8,14 +9,22 @@ import { createClockNode, } from "mechanics/node-builders/clocks"; import { Editor, MarkdownFileInfo, MarkdownView, Notice } from "obsidian"; +import { Dice, DieKind } from "utils/dice"; +import { DiceGroup } from "utils/dice-group"; +import { node } from "utils/kdl"; +import { capitalize } from "utils/strings"; import { stripMarkdown } from "utils/strip-markdown"; import { YesNoPrompt } from "utils/ui/yesno"; -import { ClockFileAdapter, clockUpdater } from "../clocks/clock-file"; +import { + ClockFileAdapter, + clockUpdater, + namedOddsSchema, + STANDARD_ODDS, +} from "../clocks/clock-file"; import { selectClock } from "../clocks/select-clock"; import { BLOCK_TYPE__CLOCK, IronVaultKind } from "../constants"; import { createNewIronVaultEntityFile, vaultProcess } from "../utils/obsidian"; import { CustomSuggestModal } from "../utils/suggest"; -import { Clock } from "./clock"; import { ClockCreateModal } from "./clock-create-modal"; export async function advanceClock( @@ -40,6 +49,48 @@ export async function advanceClock( "Select number of segments to fill.", ); + const defaultOdds = clockInfo.raw.defaultOdds; + let wrapClockUpdates: (nodes: Node[]) => Node[]; + if (defaultOdds !== "no roll") { + const oddsIndex = namedOddsSchema.options.findIndex( + (val) => defaultOdds === val, + ); + const roll = await CustomSuggestModal.select( + plugin.app, + namedOddsSchema.options, + (odds) => `${capitalize(odds)} (${STANDARD_ODDS[odds]}%)`, + undefined, + "Choose the odds to advance", + oddsIndex > -1 ? oddsIndex : undefined, + ); + const rollOdds = STANDARD_ODDS[roll]; + const result = await campaignContext + .diceRollerFor("move") + .rollAsync(DiceGroup.of(Dice.fromDiceString("1d100", DieKind.Oracle))); + const shouldAdvance = result[0].value <= rollOdds; + + wrapClockUpdates = (nodes) => { + const props: { name: string; roll: number; result: string } = { + name: `Will [[${clockPath}|${stripMarkdown(plugin, clockInfo.name)}]] advance? (${capitalize(roll)})`, + roll: result[0].value, + result: shouldAdvance ? "Yes" : "No", + }; + return [ + node("oracle", { + properties: props, + children: nodes, + }), + ]; + }; + + if (!shouldAdvance) { + appendNodesToMoveOrMechanicsBlock(editor, ...wrapClockUpdates([])); + return; + } + } else { + wrapClockUpdates = (nodes) => nodes; + } + let shouldMarkResolved = false; if (clockInfo.clock.tick(ticks).isFilled) { shouldMarkResolved = await YesNoPrompt.asSuggest( @@ -71,10 +122,10 @@ export async function advanceClock( const clockName = stripMarkdown(plugin, clockInfo.name); appendNodesToMoveOrMechanicsBlock( editor, - ...[ + ...wrapClockUpdates([ createClockNode(clockName, clockPath, clockInfo, newClock.clock), ...(shouldMarkResolved ? [clockResolvedNode(clockName, clockPath)] : []), - ], + ]), ); } @@ -107,14 +158,7 @@ export async function createClock( plugin: IronVaultPlugin, editor: Editor, ): Promise { - const clockInput: { - targetFolder: string; - fileName: string; - name: string; - clock: Clock; - } = await new Promise((onAccept, onReject) => { - new ClockCreateModal(plugin, {}, onAccept, onReject).open(); - }); + const clockInput = await ClockCreateModal.show(plugin); const clock = ClockFileAdapter.newFromClock(clockInput).expect("invalid clock"); diff --git a/src/utils/suggest.ts b/src/utils/suggest.ts index c2f9708..983a46e 100644 --- a/src/utils/suggest.ts +++ b/src/utils/suggest.ts @@ -40,6 +40,7 @@ export class CustomSuggestModal extends SuggestModal> { getItemText: (item: T) => string, renderExtras?: (match: FuzzyMatch, el: HTMLElement) => void, placeholder?: string, + selectedIndex?: number, ): Promise { return await new Promise((resolve, reject) => { new this( @@ -66,6 +67,8 @@ export class CustomSuggestModal extends SuggestModal> { resolve, reject, placeholder, + undefined, + selectedIndex, ).open(); }); } @@ -87,6 +90,7 @@ export class CustomSuggestModal extends SuggestModal> { renderExtras: (match: FuzzyMatch, el: HTMLElement) => void, placeholder: string, createUserValue: (input: string) => U, + selectedIndex?: number, ): Promise>; static async selectWithUserEntry( app: App, @@ -96,6 +100,7 @@ export class CustomSuggestModal extends SuggestModal> { renderExtras: (match: FuzzyMatch, el: HTMLElement) => void, placeholder: string, createUserValue?: (input: string) => unknown, + selectedIndex?: number, ): Promise> { return await new Promise((resolve, reject) => { new this>( @@ -132,6 +137,7 @@ export class CustomSuggestModal extends SuggestModal> { custom, value: createUserValue ? createUserValue(custom) : undefined, }), + selectedIndex, ).open(); }); } @@ -168,11 +174,17 @@ export class CustomSuggestModal extends SuggestModal> { protected readonly onCancel: () => void, placeholder?: string, protected readonly customItem?: (input: string) => T, + selectedIndex?: number, ) { super(app); if (placeholder) { this.setPlaceholder(placeholder); } + if (selectedIndex != null) { + console.log("Setting selected item %d", selectedIndex); + setTimeout(() => this.chooser.setSelectedItem(selectedIndex)); + } + console.log(this); } getSuggestions(