From 789bfd3fede7463cf24d0feb28bcfa656c4b3803 Mon Sep 17 00:00:00 2001 From: Chris Wegrzyn Date: Thu, 7 Nov 2024 08:41:04 -0500 Subject: [PATCH 1/7] Improve title and make unrolled subrolls work --- src/oracles/new-modal.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/oracles/new-modal.ts b/src/oracles/new-modal.ts index 4ee91d6..6f298a8 100644 --- a/src/oracles/new-modal.ts +++ b/src/oracles/new-modal.ts @@ -15,7 +15,7 @@ import { ref } from "lit-html/directives/ref.js"; import { NoSuchOracleError } from "model/errors"; import { CurseBehavior, Oracle, RollContext } from "model/oracle"; import { Roll, RollWrapper, Subroll } from "model/rolls"; -import { Modal, Platform, setIcon, Setting, ToggleComponent } from "obsidian"; +import { Modal, Platform, setIcon, ToggleComponent } from "obsidian"; import { randomInt } from "utils/dice"; function generateOracleRows(currentRoll: RollWrapper): RollWrapper[] { @@ -84,9 +84,9 @@ class RowState { const oracle = context.lookup(id); if (!oracle) throw new NoSuchOracleError(id); - if (index != subrolls.rolls.length + 1) + if (index != subrolls.rolls.length) throw new Error( - `subroll requested at index ${index}, but existing subrolls length is ${subrolls.rolls.length}`, + `subroll requested at index ${index}, but expected to match existing subrolls length of ${subrolls.rolls.length}`, ); subroll = new ObservableRoll( new RollWrapper(oracle, context, oracle.rollDirect(context)), @@ -385,11 +385,12 @@ export class NewOracleRollerModal extends Modal { cursedRollState?: ObservableRoll, ) => void, protected readonly onCancel: () => void, + public titlePrefix: string[] = [], ) { super(plugin.app); const { contentEl } = this; - new Setting(contentEl).setName(this.rollContainer.oracle.name).setHeading(); + this.setTitle([...titlePrefix, this.rollContainer.oracle.name].join(" > ")); this.tableContainerEl = contentEl.createDiv(); this.scope.register([], "ArrowUp", () => { @@ -521,8 +522,14 @@ export class NewOracleRollerModal extends Modal { >`, ); } else { - // TODO(@cwegrzyn): Make it so that you can subroll these? Why was it not rolled? Not auto? - return html`${label}`; + const subOracle = rolled.context.lookup(id); + return html` + this._subrollClick(ev, i, id, 0)} + >${label}`; } }); }; @@ -624,6 +631,7 @@ export class NewOracleRollerModal extends Modal { } }, () => {}, + [...this.titlePrefix, state.oracle.name], ).open(); } From b3d2be25e1f5c0c9f7c8d0ed7fd6f139ac5b05dd Mon Sep 17 00:00:00 2001 From: Chris Wegrzyn Date: Fri, 8 Nov 2024 15:06:19 -0500 Subject: [PATCH 2/7] Make double click accept row --- src/oracles/new-modal.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/oracles/new-modal.ts b/src/oracles/new-modal.ts index 6f298a8..bda543f 100644 --- a/src/oracles/new-modal.ts +++ b/src/oracles/new-modal.ts @@ -540,6 +540,10 @@ export class NewOracleRollerModal extends Modal { this.accept(); } }} + @dblclick=${async (_ev: MouseEvent) => { + await this.updateState((s) => s.updateSelection(() => i)); + this.accept(); + }} ${selected ? ref( (el) => From b917f092d490a23e7d8db40dd5d0ef67ff4d32f1 Mon Sep 17 00:00:00 2001 From: Chris Wegrzyn Date: Tue, 12 Nov 2024 10:46:04 -0500 Subject: [PATCH 3/7] Reimplement entity modal using new oracle selector --- src/entity/command.ts | 41 ++- src/entity/modal.ts | 46 +--- src/entity/new-modal.ts | 520 +++++++++++++++++++++++++++++++++++++++ src/entity/specs.test.ts | 45 ++++ src/entity/specs.ts | 75 ++++++ src/oracles/new-modal.ts | 118 ++++++--- src/utils/dice.ts | 18 +- src/utils/suggest.ts | 3 +- 8 files changed, 780 insertions(+), 86 deletions(-) create mode 100644 src/entity/new-modal.ts create mode 100644 src/entity/specs.test.ts diff --git a/src/entity/command.ts b/src/entity/command.ts index c086c97..accdd8a 100644 --- a/src/entity/command.ts +++ b/src/entity/command.ts @@ -19,7 +19,8 @@ import IronVaultPlugin from "../index"; import { Oracle, OracleRollableRow, RollContext } from "../model/oracle"; import { Roll, RollWrapper } from "../model/rolls"; import { CustomSuggestModal } from "../utils/suggest"; -import { EntityModal, EntityModalResults } from "./modal"; +import { EntityModal } from "./modal"; +import { EntityModalResults, NewEntityModal } from "./new-modal"; import { ENTITIES, EntityAttributeFieldSpec, @@ -81,7 +82,7 @@ export async function promptOracleRow( } export async function generateEntity( - app: App, + plugin: IronVaultPlugin, dataContext: CampaignDataContext, entityDesc: EntityDescriptor, ): Promise> { @@ -103,17 +104,31 @@ export async function generateEntity( if (!oracle) { throw new NoSuchOracleError(spec.id, `missing entity oracle for ${key}`); } - const roll = await promptOracleRow(app, oracle, rollContext, true); + const roll = await promptOracleRow(plugin.app, oracle, rollContext, true); initialEntity[key] = [new RollWrapper(oracle, rollContext, roll)]; } return EntityModal.create({ - app, + app: plugin.app, entityDesc, rollContext, initialEntity, }); } +export async function generateEntityNewModal( + plugin: IronVaultPlugin, + dataContext: CampaignDataContext, + entityDesc: EntityDescriptor, +): Promise> { + const rollContext = dataContext.oracleRoller; + return NewEntityModal.create({ + plugin, + entityDesc, + rollContext, + initialEntity: {}, + }); +} + export async function generateEntityCommand( plugin: IronVaultPlugin, editor: Editor, @@ -163,10 +178,22 @@ export async function generateEntityCommand( let results: EntityModalResults; try { - results = await generateEntity(plugin.app, campaignContext, entityDesc); + if (plugin.settings.useOldRoller) { + results = await generateEntity(plugin, campaignContext, entityDesc); + } else { + results = await generateEntityNewModal( + plugin, + campaignContext, + entityDesc, + ); + } } catch (e) { - new Notice(String(e)); - throw e; + if (e) { + new Notice(String(e)); + throw e; + } else { + return; + } } const { entity, createFile } = results; diff --git a/src/entity/modal.ts b/src/entity/modal.ts index 9948390..d0ad098 100644 --- a/src/entity/modal.ts +++ b/src/entity/modal.ts @@ -1,4 +1,3 @@ -import { matchDataswornLink } from "datastore/parsers/datasworn/id"; import { App, ButtonComponent, @@ -14,57 +13,16 @@ import { FolderTextSuggest } from "utils/ui/settings/folder"; import { Oracle, RollContext } from "../model/oracle"; import { RollWrapper } from "../model/rolls"; import { - AttributeMechanism, EntityAttributeFieldSpec, EntityDescriptor, EntityFieldSpec, EntityResults, EntitySpec, + evaluateAttribute, + hasAllProperties, isEntityAttributeSpec, } from "./specs"; -const SAFE_SNAKECASE_RESULT = /^[a-z0-9\s]+$/i; -// [Rocky World](id:starforged/collections/oracles/planets/rocky) - -function evaluateAttribute( - spec: EntityAttributeFieldSpec, - roll: RollWrapper[], -): string { - if (roll.length != 1) { - throw new Error(`unexpected number of rolls for attribute: ${roll.length}`); - } - const rawResult = roll[0].simpleResult; - switch (spec.definesAttribute.mechanism) { - case AttributeMechanism.Snakecase: - if (!rawResult.match(SAFE_SNAKECASE_RESULT)) - throw new Error( - `attribute value did not have snakecase-compatible result: ${rawResult}`, - ); - return rawResult.replaceAll(/\s+/g, "_").toLowerCase(); - case AttributeMechanism.ParseId: { - const match = matchDataswornLink(rawResult); - if (!match) throw new Error(`no id link found: ${rawResult}`); - const parts = match.id.split("/"); - if (parts.length < 2) throw new Error(`no / separator in ${rawResult}`); - return parts.last()!; - } - } -} - -/** Check that all properties in `reqs` are present in `inst`. - * @param reqs all properties on this object must be present on `inst` - * @param inst the object to test against reqs - * @returns true if `inst` matches `reqs` - */ -function hasAllProperties( - reqs: Partial>, - inst: Partial>, -): boolean { - return (Object.entries(reqs) as [K, string][]) - .map(([reqKey, reqValue]) => inst[reqKey] === reqValue) - .reduce((acc, cond) => acc && cond); -} - export type EntityModalResults = { createFile: boolean; fileName: string; diff --git a/src/entity/new-modal.ts b/src/entity/new-modal.ts new file mode 100644 index 0000000..6c65082 --- /dev/null +++ b/src/entity/new-modal.ts @@ -0,0 +1,520 @@ +import { parseDataswornLinks } from "datastore/parsers/datasworn/id"; +import IronVaultPlugin from "index"; +import { html, render } from "lit-html"; +import { guard } from "lit-html/directives/guard.js"; +import { join } from "lit-html/directives/join.js"; +import { map } from "lit-html/directives/map.js"; +import { ref } from "lit-html/directives/ref.js"; +import { repeat } from "lit-html/directives/repeat.js"; +import { rootLogger } from "logger"; +import { + ButtonComponent, + ExtraButtonComponent, + Modal, + normalizePath, + Setting, + TextComponent, +} from "obsidian"; +import { + createRollContainer, + IRollContainer, + NewOracleRollerModal, + RollContainer, +} from "oracles/new-modal"; +import { FolderTextSuggest } from "utils/ui/settings/folder"; +import { RollContext } from "../model/oracle"; +import { RollWrapper } from "../model/rolls"; +import { + EntityAttributeFieldSpec, + EntityDescriptor, + EntityFieldSpec, + EntityResults, + EntitySpec, + evaluateAttribute, + evaluateSlotId, + hasAllProperties, + isEntityAttributeSpec, +} from "./specs"; + +const logger = rootLogger.getLogger("entity/new-modal"); + +export type EntityModalResults = { + createFile: boolean; + fileName: string; + targetFolder: string; + entity: EntityResults; +}; + +export type EntityState = { + [key in keyof T]: IRollContainer[]; +}; + +export class NewEntityModal extends Modal { + public accepted: boolean = false; + public readonly results: EntityModalResults; + attributesEl!: HTMLDivElement; + activeSlots!: [keyof T, EntityFieldSpec][]; + + static create({ + plugin, + entityDesc, + rollContext, + initialEntity, + }: { + plugin: IronVaultPlugin; + entityDesc: EntityDescriptor; + rollContext: RollContext; + initialEntity: Partial>; + }): Promise> { + return new Promise((onAccept, onCancel) => { + let modal; + try { + modal = new this( + plugin, + entityDesc, + initialEntity, + rollContext, + onAccept, + onCancel, + ); + modal.open(); + } catch (e) { + onCancel(e); + if (modal) modal.close(); + } + }); + } + + rolls: Map = new Map(); + + protected constructor( + public plugin: IronVaultPlugin, + public readonly entityDesc: EntityDescriptor, + public readonly initialEntity: Partial>, + public readonly rollContext: RollContext, + public readonly onAccept: (results: EntityModalResults) => void, + public readonly onCancel: () => void, + ) { + super(plugin.app); + this.results = { + createFile: false, + fileName: "", + targetFolder: "", + entity: Object.fromEntries( + Object.entries(entityDesc.spec).map(([key]) => [ + key, + initialEntity[key] ?? [], + ]), + ) as Record, + }; + // TODO: populate rolls table from initialEntity. tricky thing is just dealing with + // the id assignment + // entity: Object.fromEntries( + // Object.entries(entityDesc.spec).map(([key]) => [ + // key, + // (initialEntity[key] ?? []).map((r) => createRollContainer(r)), + // ]), + // ) as Record, + // this.entityProxy = new Proxy(this.results.entity, { + // get(target, p, receiver): RollWrapper[] { + // return Reflect.get(target, p, receiver).map((c) => + // c.activeRoll().currentRoll(), + // ); + // }, + // }) as unknown as EntityResults; + } + + renderRoll(roll: RollContainer, onClick?: (ev: MouseEvent) => void) { + const activeRoll = roll.activeRoll().currentRoll(); + const resultText = parseDataswornLinks(activeRoll.row.result).map( + (segment): [string, string] => { + if (typeof segment == "string") return [segment, segment]; + const { id, label } = segment; + if (activeRoll.subrolls[id]) { + return [ + label, + activeRoll.subrolls[id].rolls + .map((sr) => sr.simpleResult) + .join(", "), + ]; + } else { + return [label, label]; + } + }, + ); + const toolTipText = resultText.map(([label, _val]) => label); + const rowText = resultText.map(([_label, val]) => val); + return html`${rowText}`; + } + + renderRolls(key: keyof T, id: string, allowEdit: boolean) { + const keyAndId = `${key as string}:${id}`; + const rolls = this.rolls.get(keyAndId) ?? []; + const { label: entityLabel } = this.entityDesc; + if (rolls.length == 0) return ""; + + return html`${join( + map(rolls, (roll, index) => + this.renderRoll( + roll, + allowEdit + ? (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + new NewOracleRollerModal( + this.plugin, + roll, + (newRoll) => { + const newRolls = [...(this.rolls.get(keyAndId) ?? [])]; + newRolls[index] = newRoll; + this.updateSetting(key, id, newRolls); + }, + () => {}, + [entityLabel], + ).open(); + } + : undefined, + ), + ), + html`; `, + )}`; + } + + updateSetting(key: keyof T, id: string, values: RollContainer[]) { + this.rolls.set(`${key as string}:${id}`, values); + this.onUpdateRolls(); + } + + async updateRollForKey( + key: keyof T, + id: string, + mode: "append" | "replace", + allowGraphical: boolean = true, + ): Promise { + const keyAndId = `${key as string}:${id}`; + const currentRolls = this.rolls.get(keyAndId) ?? []; + + const table = this.rollContext.lookup(id); + if (!table) { + throw new Error( + `Unable to find table '${id}' referenced by attribute '${key as string}`, + ); + } + + const newRoll = createRollContainer( + new RollWrapper( + table, + this.rollContext, + allowGraphical + ? await table.roll(this.rollContext) + : table.rollDirect(this.rollContext), + ), + ); + + this.updateSetting( + key, + id, + mode == "append" ? [...currentRolls, newRoll] : [newRoll], + ); + } + + onUpdateRolls() { + this.updateActiveSlots(); + + const newEntityName = + this.entityDesc.nameGen && this.entityDesc.nameGen(this.results.entity); + if (newEntityName !== undefined) { + this.fileNameInput.setValue(newEntityName); + this.fileNameInput.onChanged(); + } + + this.render(); + } + + fileNameInput!: TextComponent; + + async onOpen(): Promise { + const { contentEl } = this; + + contentEl.toggleClass("iron-vault-modal", true); + + const { label: entityLabel } = this.entityDesc; + + new Setting(contentEl).setName(entityLabel).setHeading(); + + this.attributesEl = contentEl.createDiv(); + + new Setting(contentEl) + .addButton((btn) => + btn.setButtonText("Roll first look").onClick(async () => { + this.rolls.clear(); + this.onUpdateRolls(); + for (const [key, spec] of Object.entries(this.entityDesc.spec)) { + if (spec.firstLook) { + // We check if the key is in activeSlots after each roll, rather than just iterating + // through active slots, because each call to updateRoll could change an active + // slot + const [, activeSpec] = this.activeSlots.find( + ([activeKey, _spec]) => activeKey === key, + ) ?? [undefined, undefined]; + if (activeSpec) { + await this.updateRollForKey( + key as string, + activeSpec.id, + "replace", + ); + } + } + } + }), + ) + .addButton((btn) => + btn.setButtonText("Clear").onClick(() => { + this.rolls.clear(); + this.onUpdateRolls(); + }), + ); + + const updateAccept = () => { + acceptButton.setDisabled( + this.results.createFile && this.results.fileName.length == 0, + ); + }; + + const onCreateFileChange = (val: boolean) => { + this.results.createFile = val; + fileNameSetting.settingEl.toggle(val); + targetFolderSetting.settingEl.toggle(val); + updateAccept(); + }; + + new Setting(contentEl) + .setName("Create entity file") + .addToggle((toggle) => + toggle + .setTooltip( + "If enabled, a new file will be created with the entity template.", + ) + .setValue(this.results.createFile) + .onChange(onCreateFileChange), + ); + + const fileNameSetting = new Setting(contentEl) + .setName("File name") + .addText((text) => + (this.fileNameInput = text).onChange((value) => { + this.results.fileName = value; + updateAccept(); + }), + ); + + const targetFolderSetting = new Setting(contentEl) + .setName("Target folder") + .addSearch((search) => { + new FolderTextSuggest(this.app, search.inputEl); + + search + .setPlaceholder("Choose a folder") + .setValue(this.results.targetFolder) + .onChange((newFolder) => { + const normalized = normalizePath(newFolder); + this.results.targetFolder = normalized; + if (this.app.vault.getFolderByPath(normalized)) { + targetFolderSetting.setDesc( + `Creating ${this.entityDesc.label} in existing folder '${normalized}'`, + ); + } else { + targetFolderSetting.setDesc( + `Creating ${this.entityDesc.label} in new folder '${normalized}`, + ); + } + }); + }); + + let acceptButton!: ButtonComponent; + new Setting(contentEl) + .addButton((btn) => + (acceptButton = btn) + .setButtonText("Accept") + .setCta() + .onClick(() => { + this.accept(); + }), + ) + .addButton((btn) => + btn.setButtonText("Cancel").onClick(() => { + this.cancel(); + }), + ); + + this.onUpdateRolls(); + onCreateFileChange(this.results.createFile); + } + + calculateAttributeValues() { + const attributeValues: Partial> = {}; + + for (const [key, spec] of ( + Object.entries(this.entityDesc.spec) as [keyof T, EntityFieldSpec][] + ).filter((elem): elem is [keyof T, EntityAttributeFieldSpec] => + isEntityAttributeSpec(elem[1]), + )) { + const id = evaluateSlotId(spec.id, (attrid) => attributeValues[attrid]); + if (id) { + const rolls = this.rolls.get(`${key as string}:${id}`); + if (rolls && rolls.length > 0) { + if (attributeValues[key]) + logger.warn( + "Something already set %s to %o", + key, + attributeValues[key], + ); + + attributeValues[key] = evaluateAttribute( + spec, + rolls.map((c) => c.activeRoll().currentRoll()), + ); + } + } else { + logger.debug( + "For attribute %s = %o, missing attribute dep for %s", + key, + spec, + spec.id, + ); + } + } + + return attributeValues; + } + + updateActiveSlots() { + const attributeValues = this.calculateAttributeValues(); + + const activeSlots: [keyof T, EntityFieldSpec][] = []; + const currentEntity: Partial> = {}; + + for (const [key, slot] of Object.entries(this.entityDesc.spec) as [ + keyof T, + EntityFieldSpec, + ][]) { + const formattedId = evaluateSlotId( + slot.id, + (attrid) => attributeValues[attrid], + ); + + // We don't have the parameters for this or it is excluded by condition, so it can't be included. + if ( + !formattedId || + (slot.condition && + !slot.condition.find((reqs) => + hasAllProperties(reqs, attributeValues), + )) + ) { + currentEntity[key] = []; + continue; + } + + activeSlots.push([key, { ...slot, id: formattedId }]); + currentEntity[key] = ( + this.rolls.get(`${key as string}:${formattedId}`) ?? [] + ).map((r) => r.activeRoll().currentRoll()); + } + + this.activeSlots = activeSlots; + this.results.entity = currentEntity as EntityResults; // safe: we will have filled in every key at this point + } + + render() { + render( + repeat( + this.activeSlots, + // Because we use the id in the key, we can be sure that the tables for a key won't change + // any of the oracle-linked properties built-in below. + ([key, { id }]) => `${key as string}:${id}`, + ([key, { id, name }]) => { + const table = this.rollContext.lookup(id); + if (!table) { + return html`
Missing table ${id} for attribute ${key}
`; + } + + const isReRollerOracle = (table.recommended_rolls?.max ?? 0) > 1; + + let description = name ?? table.name; + if (isReRollerOracle) { + description = `${description} (Rolls: ${table.recommended_rolls?.min} - ${table.recommended_rolls?.max})`; + } + + return html`
+
+
+ ${this.renderRolls(key as string, id, true)} +
+
+ ${description} +
+
+ + ${guard( + [key, id], + () => + html`
{ + if (el === undefined) return; + if (!(el instanceof HTMLElement)) + throw new Error(`expected el to be HTMLElement, ${el}`); + const rollBtn = new ExtraButtonComponent(el) + .setIcon("dices") + .onClick(() => { + this.updateRollForKey( + key, + id, + isReRollerOracle ? "append" : "replace", + ); + if (isReRollerOracle) { + rollBtn.setIcon("rotate-cw"); + } + }); + new ExtraButtonComponent(el) + .setIcon("delete") + .onClick(() => { + this.updateSetting(key, id, []); + }); + })} + >
`, + )} +
`; + }, + ), + this.attributesEl, + ); + } + + onClose(): void { + this.contentEl.empty(); + if (!this.accepted) { + this.onCancel(); + } + } + + accept(): void { + this.accepted = true; + this.onAccept(this.results); + this.close(); + } + + cancel(): void { + this.accepted = false; + this.close(); + } +} diff --git a/src/entity/specs.test.ts b/src/entity/specs.test.ts new file mode 100644 index 0000000..50e382a --- /dev/null +++ b/src/entity/specs.test.ts @@ -0,0 +1,45 @@ +import { evaluateSlotId, parseIdForAttributes } from "./specs"; + +describe("parseIdForAttributes", () => { + it("yields expected pieces", () => { + expect([...parseIdForAttributes("test/{{ foo }}/bar")]).toEqual([ + "test/", + { id: "foo" }, + "/bar", + ]); + }); + + it("works at start", () => { + expect([...parseIdForAttributes("{{foo}}/test/bar")]).toEqual([ + { id: "foo" }, + "/test/bar", + ]); + }); + + it("works at end", () => { + expect([...parseIdForAttributes("test/bar/{{ foo }}")]).toEqual([ + "test/bar/", + { id: "foo" }, + ]); + }); + + it("passes strings without attributes through unmodified", () => { + expect([...parseIdForAttributes("test/foo/bar")]).toEqual(["test/foo/bar"]); + }); +}); + +describe("evaluateSlotId", () => { + it("is undefined if missing field", () => { + expect( + evaluateSlotId("test/{{ foo }}/bar", () => undefined), + ).toBeUndefined(); + }); + + it("returns substituted id otherwise", () => { + expect( + evaluateSlotId("test/{{ foo }}/bar", (id) => + id === "foo" ? "val" : undefined, + ), + ).toEqual("test/val/bar"); + }); +}); diff --git a/src/entity/specs.ts b/src/entity/specs.ts index 1fd69e7..43cc990 100644 --- a/src/entity/specs.ts +++ b/src/entity/specs.ts @@ -1,4 +1,5 @@ import { Datasworn } from "@datasworn/core"; +import { matchDataswornLink } from "datastore/parsers/datasworn/id"; import { RollWrapper } from "model/rolls"; export type EntityDescriptor = { @@ -52,6 +53,80 @@ export type EntityResults = { [key in keyof T]: RollWrapper[]; }; +const SAFE_SNAKECASE_RESULT = /^[a-z0-9\s]+$/i; +// [Rocky World](id:starforged/collections/oracles/planets/rocky) + +export function evaluateAttribute( + spec: EntityAttributeFieldSpec, + roll: RollWrapper[], +): string { + if (roll.length != 1) { + throw new Error(`unexpected number of rolls for attribute: ${roll.length}`); + } + const rawResult = roll[0].simpleResult; + switch (spec.definesAttribute.mechanism) { + case AttributeMechanism.Snakecase: + if (!rawResult.match(SAFE_SNAKECASE_RESULT)) + throw new Error( + `attribute value did not have snakecase-compatible result: ${rawResult}`, + ); + return rawResult.replaceAll(/\s+/g, "_").toLowerCase(); + case AttributeMechanism.ParseId: { + const match = matchDataswornLink(rawResult); + if (!match) throw new Error(`no id link found: ${rawResult}`); + const parts = match.id.split("/"); + if (parts.length < 2) throw new Error(`no / separator in ${rawResult}`); + return parts.last()!; + } + } +} + +export function* parseIdForAttributes(id: string) { + let lastIndex = 0; + for (const match of id.matchAll(/\{\{\s*(\w+)\s*\}\}/g)) { + if (match.index > lastIndex) { + yield id.slice(lastIndex, match.index); + } + yield { id: match[1] }; + lastIndex = match.index + match[0].length; + } + if (id.length > lastIndex) yield id.slice(lastIndex, id.length); +} + +export function evaluateSlotId( + id: string, + lookup: (id: string) => string | undefined, +) { + let finalId = ""; + for (const part of parseIdForAttributes(id)) { + if (typeof part == "string") { + finalId += part; + } else { + const result = lookup(part.id); + if (result) { + finalId += result; + } else { + return undefined; + } + } + } + return finalId; +} + +/** Check that all properties in `reqs` are present in `inst`. + * @param reqs all properties on this object must be present on `inst` + * @param inst the object to test against reqs + * @returns true if `inst` matches `reqs` + */ +export function hasAllProperties( + reqs: Partial>, + inst: Partial>, +): boolean { + return (Object.entries(reqs) as [K, string][]) + .map(([reqKey, reqValue]) => inst[reqKey] === reqValue) + .reduce((acc, cond) => acc && cond); +} + // TODO: these should maybe be indexed just like everything into the DataIndexer so we can // pull the appropriate ones for our active rulesets? export const ENTITIES: Record> = { diff --git a/src/oracles/new-modal.ts b/src/oracles/new-modal.ts index bda543f..1ce03d1 100644 --- a/src/oracles/new-modal.ts +++ b/src/oracles/new-modal.ts @@ -10,6 +10,7 @@ import { PartInfo, PartType, } from "lit-html/directive.js"; +import { join } from "lit-html/directives/join.js"; import { map } from "lit-html/directives/map.js"; import { ref } from "lit-html/directives/ref.js"; import { NoSuchOracleError } from "model/errors"; @@ -247,20 +248,27 @@ class ObservableRoll { } } -interface IRollContainer { +export interface IRollContainer { mainResult: ObservableRoll; oracle: Oracle; isCursable(): this is CursableRollContainer; - activeRoll(): [ObservableRoll, (state: RollerState) => boolean]; + activeRoll(): ObservableRoll; + activeRollForUpdate(): [ObservableRoll, (state: RollerState) => boolean]; + + copy(): IRollContainer; } -class SimpleRollContainer implements IRollContainer { +export class SimpleRollContainer implements IRollContainer { mainResult: ObservableRoll; - constructor(initialRoll: RollWrapper | RollerState) { - this.mainResult = new ObservableRoll(initialRoll); + constructor(initialRoll: RollWrapper | RollerState | SimpleRollContainer) { + if (initialRoll instanceof SimpleRollContainer) { + this.mainResult = initialRoll.mainResult; + } else { + this.mainResult = new ObservableRoll(initialRoll); + } } get oracle() { @@ -271,7 +279,11 @@ class SimpleRollContainer implements IRollContainer { return false; } - activeRoll(): [ObservableRoll, (state: RollerState) => boolean] { + activeRoll() { + return this.mainResult; + } + + activeRollForUpdate(): [ObservableRoll, (state: RollerState) => boolean] { return [ this.mainResult, (state) => { @@ -281,9 +293,13 @@ class SimpleRollContainer implements IRollContainer { }, ]; } + + copy() { + return new SimpleRollContainer(this); + } } -class CursableRollContainer implements IRollContainer { +export class CursableRollContainer implements IRollContainer { /** Value of cursed die, if rolled. */ cursedDie?: number; @@ -291,7 +307,14 @@ class CursableRollContainer implements IRollContainer { cursedResult: ObservableRoll; useCursedResult: boolean; - constructor(initialRoll: RollWrapper) { + constructor(initialRoll: RollWrapper | CursableRollContainer) { + if (initialRoll instanceof CursableRollContainer) { + this.cursedDie = initialRoll.cursedDie; + this.mainResult = initialRoll.mainResult; + this.cursedResult = initialRoll.cursedResult; + this.useCursedResult = initialRoll.useCursedResult; + return; + } if (!initialRoll.cursedTable) { throw new Error("must have a cursed table"); } @@ -319,7 +342,11 @@ class CursableRollContainer implements IRollContainer { return true; } - activeRoll(): [ObservableRoll, (state: RollerState) => boolean] { + activeRoll() { + return this.useCursedResult ? this.cursedResult : this.mainResult; + } + + activeRollForUpdate(): [ObservableRoll, (state: RollerState) => boolean] { return [ this.useCursedResult ? this.cursedResult : this.mainResult, (state) => { @@ -335,9 +362,13 @@ class CursableRollContainer implements IRollContainer { }, ]; } + + copy() { + return new CursableRollContainer(this); + } } -function createRollContainer(roll: RollWrapper): RollContainer { +export function createRollContainer(roll: RollWrapper): RollContainer { if (roll.cursedTable) { return new CursableRollContainer(roll); } else { @@ -345,7 +376,7 @@ function createRollContainer(roll: RollWrapper): RollContainer { } } -type RollContainer = SimpleRollContainer | CursableRollContainer; +export type RollContainer = SimpleRollContainer | CursableRollContainer; export class NewOracleRollerModal extends Modal { public accepted: boolean = false; @@ -364,10 +395,13 @@ export class NewOracleRollerModal extends Modal { new this( plugin, createRollContainer(new RollWrapper(oracle, context, initialRoll)), - (state, cursedState) => + (container) => resolve({ - roll: state.currentRoll(), - cursedRoll: cursedState && cursedState.currentRoll(), + roll: container.mainResult.currentRoll(), + cursedRoll: + container.isCursable() && container.useCursedResult + ? container.cursedResult.currentRoll() + : undefined, }), reject, ).open(); @@ -380,15 +414,14 @@ export class NewOracleRollerModal extends Modal { constructor( private plugin: IronVaultPlugin, public rollContainer: RollContainer, - protected readonly onAccept: ( - acceptedState: ObservableRoll, - cursedRollState?: ObservableRoll, - ) => void, + protected readonly onAccept: (rollContainer: RollContainer) => void, protected readonly onCancel: () => void, public titlePrefix: string[] = [], ) { super(plugin.app); + this.rollContainer = rollContainer = rollContainer.copy(); + const { contentEl } = this; this.setTitle([...titlePrefix, this.rollContainer.oracle.name].join(" > ")); this.tableContainerEl = contentEl.createDiv(); @@ -421,7 +454,7 @@ export class NewOracleRollerModal extends Modal { async updateState( fn: (state: RollerState) => RollerState | Promise, ) { - const [current, updater] = this.rollContainer.activeRoll(); + const [current, updater] = this.rollContainer.activeRollForUpdate(); const state = await Promise.resolve(fn(current.observe())); if (updater(state)) this.renderTable(); } @@ -429,8 +462,6 @@ export class NewOracleRollerModal extends Modal { renderTable() { // TODO(@cwegrzyn): need to render markdown - console.debug(this); - const renderCurseToggle = (rollContainer: CursableRollContainer) => { const cursedTable = rollContainer.cursedResult.oracle; const name = @@ -533,6 +564,30 @@ export class NewOracleRollerModal extends Modal { } }); }; + const renderSelfRolls = () => { + const selfRolls = + rolled.subrolls[rolled.oracle.id]?.rolls ?? []; + if (selfRolls.length == 0) return undefined; + return html` (${join( + map( + selfRolls, + (roll, subidx) => + html` + this._subrollClick( + ev, + i, + rolled.oracle.id, + subidx, + )} + >${roll.ownResult}`, + ), + html`, `, + )})`; + }; return html` { await this.updateState((s) => s.updateSelection(() => i)); @@ -560,8 +615,12 @@ export class NewOracleRollerModal extends Modal { > ${map( columns, - ({ getter }) => - html`${renderSubRolls(getter(row))}`, + ({ getter }, index) => + html` + ${renderSubRolls(getter(row))}${index == 1 + ? renderSelfRolls() + : undefined} + `, )} ${initial ? html` { + (container) => { const newRollerState = updateRoller( - updateRow(newSubrollState.observe()), + updateRow(container.mainResult.observe()), ).updateSelection(() => rowIndex); if (updateContainer(newRollerState)) { this.renderTable(); @@ -648,12 +707,7 @@ export class NewOracleRollerModal extends Modal { accept(): void { this.accepted = true; this.close(); - this.onAccept( - this.rollContainer.mainResult, - this.rollContainer.isCursable() && this.rollContainer.useCursedResult - ? this.rollContainer.cursedResult - : undefined, - ); + this.onAccept(this.rollContainer); } onClose(): void { diff --git a/src/utils/dice.ts b/src/utils/dice.ts index 1530bfc..9e6cfe4 100644 --- a/src/utils/dice.ts +++ b/src/utils/dice.ts @@ -1,3 +1,5 @@ +import { rootLogger } from "logger"; + export function randomInt(min: number, max: number): number { const randomBuffer = new Uint32Array(1); @@ -19,6 +21,8 @@ export enum DieKind { Cursed = "cursed", } +const logger = rootLogger.getLogger("dice"); + export class Dice { static fromDiceString(spec: string, kind?: DieKind): Dice { const parsed = spec.match(DICE_REGEX); @@ -36,7 +40,19 @@ export class Dice { public readonly count: number, public readonly sides: number, public readonly kind?: DieKind, - ) {} + ) { + if (sides > 100 && sides % 100 == 0) { + this.sides = 100; + this.count = sides / 100; + logger.debug( + "Converted %dd%d to %dd%d", + count, + sides, + this.count, + this.sides, + ); + } + } roll(): number { let total = 0; diff --git a/src/utils/suggest.ts b/src/utils/suggest.ts index 983a46e..4749082 100644 --- a/src/utils/suggest.ts +++ b/src/utils/suggest.ts @@ -181,10 +181,9 @@ export class CustomSuggestModal extends SuggestModal> { this.setPlaceholder(placeholder); } if (selectedIndex != null) { - console.log("Setting selected item %d", selectedIndex); + // console.log("Setting selected item %d", selectedIndex); setTimeout(() => this.chooser.setSelectedItem(selectedIndex)); } - console.log(this); } getSuggestions( From a4e135f28c928082a35ab3cf33753ea9a61125b3 Mon Sep 17 00:00:00 2001 From: Chris Wegrzyn Date: Fri, 15 Nov 2024 14:21:31 -0500 Subject: [PATCH 4/7] Add flipped roll marker --- src/model/rolls.ts | 7 +++-- src/oracles/new-modal.ts | 56 ++++++++++++++++++++++++++++------------ 2 files changed, 44 insertions(+), 19 deletions(-) diff --git a/src/model/rolls.ts b/src/model/rolls.ts index e6c1118..f9de5e5 100644 --- a/src/model/rolls.ts +++ b/src/model/rolls.ts @@ -316,8 +316,7 @@ export class RollWrapper { } withinRange(range?: NumberRange): boolean { - const { roll } = this.roll; - return range ? range.min <= roll && roll <= range.max : false; + return withinRange(this.roll.roll, range); } isSameRowAs(rollWrapper: RollWrapper): boolean { @@ -331,6 +330,10 @@ export class RollWrapper { } } +export function withinRange(value: number, range?: NumberRange) { + return range ? range.min <= value && value <= range.max : false; +} + export interface NumberRange { min: number; max: number; diff --git a/src/oracles/new-modal.ts b/src/oracles/new-modal.ts index 1ce03d1..fa7dbb3 100644 --- a/src/oracles/new-modal.ts +++ b/src/oracles/new-modal.ts @@ -15,7 +15,7 @@ import { map } from "lit-html/directives/map.js"; import { ref } from "lit-html/directives/ref.js"; import { NoSuchOracleError } from "model/errors"; import { CurseBehavior, Oracle, RollContext } from "model/oracle"; -import { Roll, RollWrapper, Subroll } from "model/rolls"; +import { Roll, RollWrapper, Subroll, withinRange } from "model/rolls"; import { Modal, Platform, setIcon, ToggleComponent } from "obsidian"; import { randomInt } from "utils/dice"; @@ -199,16 +199,25 @@ class RollerState { | Datasworn.OracleRollableRowText | Datasworn.OracleRollableRowText2 | Datasworn.OracleRollableRowText3; - isInitial: boolean; + marker: "initial" | "flipped" | null; isSelected: boolean; index: number; }> { + const flippedRoll = this.oracle.dice.flip( + this.rows[this.initialRowIndex].initialRoll.diceValue, + ); for (let index = 0; index < this.rows.length; index++) { const oracleRow = this.oracle.raw.rows[index]; + const roll = this.rows[index]; yield { - roll: this.rows[index], + roll, oracleRow, - isInitial: this.initialRowIndex == index, + marker: + this.initialRowIndex == index + ? "initial" + : withinRange(flippedRoll, oracleRow.roll ?? undefined) + ? "flipped" + : null, isSelected: this.selectedRowIndex == index, index, }; @@ -526,7 +535,7 @@ export class NewOracleRollerModal extends Modal { roll: rowState, oracleRow: row, index: i, - isInitial: initial, + marker, isSelected: selected, }) => { const renderedSubrolls = new Set(); @@ -622,18 +631,7 @@ export class NewOracleRollerModal extends Modal { : undefined} `, )} - ${initial - ? html` - el && - el instanceof HTMLElement && - setIcon(el, "bookmark"), - )} - />` - : html``} + ${renderMarker(marker, rolled.diceValue)} `; }, )} @@ -815,3 +813,27 @@ class ToggleComponentDirective extends Directive { } const toggleDirective = directive(ToggleComponentDirective); + +function renderMarker(marker: "initial" | "flipped" | null, diceValue: number) { + switch (marker) { + case "initial": + return html` el && el instanceof HTMLElement && setIcon(el, "bookmark"), + )} + />`; + case "flipped": + return html` + el && el instanceof HTMLElement && setIcon(el, "flip-vertical-2"), + )} + />`; + default: + return html``; + } +} From 5eacc7f5d0922b8ef6c0a1a2253e8be79d194c09 Mon Sep 17 00:00:00 2001 From: Chris Wegrzyn Date: Fri, 15 Nov 2024 16:41:41 -0500 Subject: [PATCH 5/7] Hide first look button if no first look oracles --- src/entity/new-modal.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/entity/new-modal.ts b/src/entity/new-modal.ts index 6c65082..39da9b4 100644 --- a/src/entity/new-modal.ts +++ b/src/entity/new-modal.ts @@ -250,8 +250,11 @@ export class NewEntityModal extends Modal { this.attributesEl = contentEl.createDiv(); - new Setting(contentEl) - .addButton((btn) => + const commandSetting = new Setting(contentEl); + if ( + Object.values(this.entityDesc.spec).some(({ firstLook }) => firstLook) + ) { + commandSetting.addButton((btn) => btn.setButtonText("Roll first look").onClick(async () => { this.rolls.clear(); this.onUpdateRolls(); @@ -273,13 +276,14 @@ export class NewEntityModal extends Modal { } } }), - ) - .addButton((btn) => - btn.setButtonText("Clear").onClick(() => { - this.rolls.clear(); - this.onUpdateRolls(); - }), ); + } + commandSetting.addButton((btn) => + btn.setButtonText("Clear").onClick(() => { + this.rolls.clear(); + this.onUpdateRolls(); + }), + ); const updateAccept = () => { acceptButton.setDisabled( From c3d4500851e1d5401a18b865b81d4c84f2bbd4c1 Mon Sep 17 00:00:00 2001 From: Chris Wegrzyn Date: Tue, 19 Nov 2024 12:33:34 -0500 Subject: [PATCH 6/7] Support detailed cursed results in entity rolls --- src/entity/command.ts | 17 ++++--- src/entity/modal.ts | 49 +++++++----------- src/entity/new-modal.ts | 74 +++++++--------------------- src/entity/specs.ts | 44 +++++++++++++++++ src/mechanics/node-builders/index.ts | 27 ++++++++-- src/oracles/command.ts | 18 ++----- src/oracles/new-modal.ts | 9 ++++ 7 files changed, 126 insertions(+), 112 deletions(-) diff --git a/src/entity/command.ts b/src/entity/command.ts index accdd8a..c492237 100644 --- a/src/entity/command.ts +++ b/src/entity/command.ts @@ -20,13 +20,14 @@ import { Oracle, OracleRollableRow, RollContext } from "../model/oracle"; import { Roll, RollWrapper } from "../model/rolls"; import { CustomSuggestModal } from "../utils/suggest"; import { EntityModal } from "./modal"; -import { EntityModalResults, NewEntityModal } from "./new-modal"; +import { NewEntityModal } from "./new-modal"; import { ENTITIES, EntityAttributeFieldSpec, EntityDescriptor, EntityResults, EntitySpec, + NewEntityModalResults, } from "./specs"; type OraclePromptOption = @@ -85,7 +86,7 @@ export async function generateEntity( plugin: IronVaultPlugin, dataContext: CampaignDataContext, entityDesc: EntityDescriptor, -): Promise> { +): Promise> { const rollContext = dataContext.oracleRoller; const attributes = Object.entries(entityDesc.spec) .filter( @@ -119,7 +120,7 @@ export async function generateEntityNewModal( plugin: IronVaultPlugin, dataContext: CampaignDataContext, entityDesc: EntityDescriptor, -): Promise> { +): Promise> { const rollContext = dataContext.oracleRoller; return NewEntityModal.create({ plugin, @@ -176,7 +177,7 @@ export async function generateEntityCommand( entityDesc = selectedEntityDescriptor; } - let results: EntityModalResults; + let results: NewEntityModalResults; try { if (plugin.settings.useOldRoller) { results = await generateEntity(plugin, campaignContext, entityDesc); @@ -197,10 +198,8 @@ export async function generateEntityCommand( } const { entity, createFile } = results; + const entityName = results.name ?? `New ${entityDesc.label}`; - const entityName = entityDesc.nameGen - ? entityDesc.nameGen(entity) - : `New ${entityDesc.label}`; let oracleGroupTitle: string; if (createFile) { const fileName = results.fileName; @@ -229,7 +228,9 @@ export async function generateEntityCommand( { spec: spec, label: spec.name ?? rolls[0].oracle.name, - rolls: rolls.map((roll) => roll.simpleResult).join(", "), + rolls: rolls + .map((roll) => roll.activeRollWrapper().simpleResult) + .join(", "), }, ]; }), diff --git a/src/entity/modal.ts b/src/entity/modal.ts index d0ad098..7ef1cd8 100644 --- a/src/entity/modal.ts +++ b/src/entity/modal.ts @@ -21,18 +21,12 @@ import { evaluateAttribute, hasAllProperties, isEntityAttributeSpec, + NewEntityModalResults, } from "./specs"; -export type EntityModalResults = { - createFile: boolean; - fileName: string; - targetFolder: string; - entity: EntityResults; -}; - export class EntityModal extends Modal { public accepted: boolean = false; - public readonly results: EntityModalResults; + public readonly results: NewEntityModalResults; static create({ app, @@ -44,7 +38,7 @@ export class EntityModal extends Modal { entityDesc: EntityDescriptor; rollContext: RollContext; initialEntity: Partial>; - }): Promise> { + }): Promise> { return new Promise((onAccept, onCancel) => { let modal; try { @@ -69,21 +63,11 @@ export class EntityModal extends Modal { public readonly entityDesc: EntityDescriptor, public readonly initialEntity: Partial>, public readonly rollContext: RollContext, - public readonly onAccept: (results: EntityModalResults) => void, + public readonly onAccept: (results: NewEntityModalResults) => void, public readonly onCancel: () => void, ) { super(app); - this.results = { - createFile: false, - fileName: "", - targetFolder: "", - entity: Object.fromEntries( - Object.entries(entityDesc.spec).map(([key]) => [ - key, - initialEntity[key] ?? [], - ]), - ) as Record, - }; + this.results = new NewEntityModalResults(entityDesc, initialEntity); } async onOpen(): Promise { @@ -131,10 +115,9 @@ export class EntityModal extends Modal { const appendRoll = async (key: keyof T): Promise => { const { table } = settings[key]; - this.results.entity[key]; const setting = [ - ...this.results.entity[key], + ...this.results.entityProxy[key], new RollWrapper( table, this.rollContext, @@ -151,17 +134,18 @@ export class EntityModal extends Modal { const updateSetting = (key: keyof T, values: RollWrapper[]) => { const { setting } = settings[key]; - this.results.entity[key] = values; + this.results.entityProxy[key] = values; if (values.length > 0) { - setting.setName(renderRolls(this.results.entity[key])); + setting.setName(renderRolls(this.results.entityProxy[key])); } else { setting.setName(""); } - const newEntityName = - this.entityDesc.nameGen && this.entityDesc.nameGen(this.results.entity); - if (newEntityName !== undefined) { - fileNameInput.setValue(newEntityName); + this.results.name = + this.entityDesc.nameGen && + this.entityDesc.nameGen(this.results.entityProxy); + if (this.results.name !== undefined) { + fileNameInput.setValue(this.results.name); fileNameInput.onChanged(); } }; @@ -191,12 +175,15 @@ export class EntityModal extends Modal { throw new Error("missing table " + id); } const setting = new Setting(contentEl) - .setName(renderRolls(this.results.entity[key])) + .setName(renderRolls(this.results.entityProxy[key])) .setDesc(spec.name ?? table.name); setting.descEl.ariaLabel = `(id: ${table.id})`; - attributeValues[key] = evaluateAttribute(spec, this.results.entity[key]); + attributeValues[key] = evaluateAttribute( + spec, + this.results.entityProxy[key], + ); settings[key] = { setting, table }; } diff --git a/src/entity/new-modal.ts b/src/entity/new-modal.ts index 39da9b4..b0c9600 100644 --- a/src/entity/new-modal.ts +++ b/src/entity/new-modal.ts @@ -17,7 +17,6 @@ import { } from "obsidian"; import { createRollContainer, - IRollContainer, NewOracleRollerModal, RollContainer, } from "oracles/new-modal"; @@ -34,26 +33,17 @@ import { evaluateSlotId, hasAllProperties, isEntityAttributeSpec, + NewEntityModalResults, } from "./specs"; const logger = rootLogger.getLogger("entity/new-modal"); -export type EntityModalResults = { - createFile: boolean; - fileName: string; - targetFolder: string; - entity: EntityResults; -}; - -export type EntityState = { - [key in keyof T]: IRollContainer[]; -}; - export class NewEntityModal extends Modal { public accepted: boolean = false; - public readonly results: EntityModalResults; + public readonly results: NewEntityModalResults; attributesEl!: HTMLDivElement; activeSlots!: [keyof T, EntityFieldSpec][]; + rolls: Map = new Map(); static create({ plugin, @@ -65,7 +55,7 @@ export class NewEntityModal extends Modal { entityDesc: EntityDescriptor; rollContext: RollContext; initialEntity: Partial>; - }): Promise> { + }): Promise> { return new Promise((onAccept, onCancel) => { let modal; try { @@ -85,47 +75,22 @@ export class NewEntityModal extends Modal { }); } - rolls: Map = new Map(); - protected constructor( public plugin: IronVaultPlugin, public readonly entityDesc: EntityDescriptor, public readonly initialEntity: Partial>, public readonly rollContext: RollContext, - public readonly onAccept: (results: EntityModalResults) => void, + public readonly onAccept: (results: NewEntityModalResults) => void, public readonly onCancel: () => void, ) { super(plugin.app); - this.results = { - createFile: false, - fileName: "", - targetFolder: "", - entity: Object.fromEntries( - Object.entries(entityDesc.spec).map(([key]) => [ - key, - initialEntity[key] ?? [], - ]), - ) as Record, - }; + this.results = new NewEntityModalResults(entityDesc); // TODO: populate rolls table from initialEntity. tricky thing is just dealing with // the id assignment - // entity: Object.fromEntries( - // Object.entries(entityDesc.spec).map(([key]) => [ - // key, - // (initialEntity[key] ?? []).map((r) => createRollContainer(r)), - // ]), - // ) as Record, - // this.entityProxy = new Proxy(this.results.entity, { - // get(target, p, receiver): RollWrapper[] { - // return Reflect.get(target, p, receiver).map((c) => - // c.activeRoll().currentRoll(), - // ); - // }, - // }) as unknown as EntityResults; } renderRoll(roll: RollContainer, onClick?: (ev: MouseEvent) => void) { - const activeRoll = roll.activeRoll().currentRoll(); + const activeRoll = roll.activeRollWrapper(); const resultText = parseDataswornLinks(activeRoll.row.result).map( (segment): [string, string] => { if (typeof segment == "string") return [segment, segment]; @@ -227,10 +192,11 @@ export class NewEntityModal extends Modal { onUpdateRolls() { this.updateActiveSlots(); - const newEntityName = - this.entityDesc.nameGen && this.entityDesc.nameGen(this.results.entity); - if (newEntityName !== undefined) { - this.fileNameInput.setValue(newEntityName); + this.results.name = + this.entityDesc.nameGen && + this.entityDesc.nameGen(this.results.entityProxy); + if (this.results.name !== undefined) { + this.fileNameInput.setValue(this.results.name); this.fileNameInput.onChanged(); } @@ -382,7 +348,7 @@ export class NewEntityModal extends Modal { attributeValues[key] = evaluateAttribute( spec, - rolls.map((c) => c.activeRoll().currentRoll()), + rolls.map((c) => c.activeRollWrapper()), ); } } else { @@ -402,7 +368,6 @@ export class NewEntityModal extends Modal { const attributeValues = this.calculateAttributeValues(); const activeSlots: [keyof T, EntityFieldSpec][] = []; - const currentEntity: Partial> = {}; for (const [key, slot] of Object.entries(this.entityDesc.spec) as [ keyof T, @@ -421,18 +386,15 @@ export class NewEntityModal extends Modal { hasAllProperties(reqs, attributeValues), )) ) { - currentEntity[key] = []; - continue; + this.results.entity[key] = []; + } else { + activeSlots.push([key, { ...slot, id: formattedId }]); + this.results.entity[key] = + this.rolls.get(`${key as string}:${formattedId}`) ?? []; } - - activeSlots.push([key, { ...slot, id: formattedId }]); - currentEntity[key] = ( - this.rolls.get(`${key as string}:${formattedId}`) ?? [] - ).map((r) => r.activeRoll().currentRoll()); } this.activeSlots = activeSlots; - this.results.entity = currentEntity as EntityResults; // safe: we will have filled in every key at this point } render() { diff --git a/src/entity/specs.ts b/src/entity/specs.ts index 43cc990..ccc7ab7 100644 --- a/src/entity/specs.ts +++ b/src/entity/specs.ts @@ -1,6 +1,7 @@ import { Datasworn } from "@datasworn/core"; import { matchDataswornLink } from "datastore/parsers/datasworn/id"; import { RollWrapper } from "model/rolls"; +import { createRollContainer, RollContainer } from "oracles/new-modal"; export type EntityDescriptor = { label: string; @@ -53,6 +54,49 @@ export type EntityResults = { [key in keyof T]: RollWrapper[]; }; +export type EntityState = { + [key in keyof T]: RollContainer[]; +}; + +export class NewEntityModalResults { + createFile: boolean = false; + fileName: string = ""; + targetFolder: string = ""; + name: string | undefined = undefined; + readonly entity: EntityState; + entityProxy: EntityResults; + + constructor( + entityDesc: EntityDescriptor, + initialEntity?: Partial>, + ) { + this.entity = Object.fromEntries( + Object.entries(entityDesc.spec).map(([key]) => [ + key, + (initialEntity?.[key] ?? ([] as RollWrapper[])).map((r) => + createRollContainer(r), + ), + ]), + ) as Record; + + this.entityProxy = new Proxy(this.entity, { + get(target, p, receiver): RollWrapper[] { + return Reflect.get(target, p, receiver).map((c) => + c.activeRollWrapper(), + ); + }, + set(target, p, newValue: RollWrapper[], receiver) { + return Reflect.set( + target, + p, + newValue.map((r) => createRollContainer(r)), + receiver, + ); + }, + }) as unknown as EntityResults; + } +} + const SAFE_SNAKECASE_RESULT = /^[a-z0-9\s]+$/i; // [Rocky World](id:starforged/collections/oracles/planets/rocky) diff --git a/src/mechanics/node-builders/index.ts b/src/mechanics/node-builders/index.ts index 8fdda11..747e8f3 100644 --- a/src/mechanics/node-builders/index.ts +++ b/src/mechanics/node-builders/index.ts @@ -1,6 +1,7 @@ import { createDataswornMarkdownLink } from "datastore/parsers/datasworn/id"; import * as kdl from "kdljs"; import { Document, Node } from "kdljs"; +import { CurseBehavior } from "model/oracle"; import { RollWrapper } from "model/rolls"; import { ActionMoveDescription, @@ -8,6 +9,7 @@ import { moveIsAction, moveIsProgress, } from "moves/desc"; +import { RollContainer } from "oracles/new-modal"; import { oracleNameWithParents } from "oracles/render"; import { ProgressTrackWriterContext } from "tracks/writer"; import { node } from "utils/kdl"; @@ -57,6 +59,7 @@ export function createOracleNode( roll: RollWrapper, prompt?: string, name?: string, + cursedResult?: RollWrapper, ): kdl.Node { const props: { name: string; roll: number; result: string; cursed?: number } = { @@ -71,7 +74,7 @@ export function createOracleNode( if (roll.cursedRoll != null) { props.cursed = roll.cursedRoll; } - return node("oracle", { + const baseResult = node("oracle", { properties: props, children: [ ...(prompt ? [node("-", { values: [prompt] })] : []), @@ -80,6 +83,12 @@ export function createOracleNode( .map((subroll) => createOracleNode(subroll)), ], }); + if (cursedResult) { + baseResult.children.push(createOracleNode(cursedResult)); + baseResult.properties.replaced = + cursedResult.oracle.curseBehavior === CurseBehavior.ReplaceResult; + } + return baseResult; } export function generateActionRoll(move: ActionMoveDescription): Node { @@ -150,14 +159,26 @@ function generateMoveLink(move: MoveDescription): string { export function createOracleGroup( name: string, - oracles: { name?: string; rolls: RollWrapper[] }[], + oracles: { + name?: string; + rolls: RollContainer[]; + }[], ): kdl.Node { return node("oracle-group", { properties: { name, }, children: oracles.flatMap(({ name, rolls }) => - rolls.map((roll) => createOracleNode(roll, undefined, name)), + rolls.map((rollContainer) => + createOracleNode( + rollContainer.mainResult.currentRoll(), + undefined, + name, + rollContainer.isCursable() && rollContainer.useCursedResult + ? rollContainer.cursedResult.currentRoll() + : undefined, + ), + ), ), }); } diff --git a/src/oracles/command.ts b/src/oracles/command.ts index 297eef2..134c955 100644 --- a/src/oracles/command.ts +++ b/src/oracles/command.ts @@ -10,12 +10,7 @@ import { type MarkdownView, } from "obsidian"; import { numberRange } from "utils/numbers"; -import { - CurseBehavior, - Oracle, - OracleGrouping, - OracleGroupingType, -} from "../model/oracle"; +import { Oracle, OracleGrouping, OracleGroupingType } from "../model/oracle"; import { Roll } from "../model/rolls"; import { CustomSuggestModal } from "../utils/suggest"; import { OracleRollerModal } from "./modal"; @@ -132,12 +127,7 @@ export async function runOracleCommand( // Delete the prompt and then inject the oracle node to a mechanics block editor.setSelection(replaceSelection.anchor, replaceSelection.head); editor.replaceSelection(""); - const oracleNode = createOracleNode(roll, prompt); - const oracleNodes = [oracleNode]; - if (cursedRoll) { - oracleNode.children.push(createOracleNode(cursedRoll)); - oracleNode.properties.replaced = - cursedRoll.oracle.curseBehavior === CurseBehavior.ReplaceResult; - } - createOrAppendMechanics(editor, oracleNodes); + createOrAppendMechanics(editor, [ + createOracleNode(roll, prompt, undefined, cursedRoll), + ]); } diff --git a/src/oracles/new-modal.ts b/src/oracles/new-modal.ts index fa7dbb3..dd054d0 100644 --- a/src/oracles/new-modal.ts +++ b/src/oracles/new-modal.ts @@ -264,6 +264,7 @@ export interface IRollContainer { isCursable(): this is CursableRollContainer; activeRoll(): ObservableRoll; + activeRollWrapper(): RollWrapper; activeRollForUpdate(): [ObservableRoll, (state: RollerState) => boolean]; copy(): IRollContainer; @@ -292,6 +293,10 @@ export class SimpleRollContainer implements IRollContainer { return this.mainResult; } + activeRollWrapper() { + return this.activeRoll().currentRoll(); + } + activeRollForUpdate(): [ObservableRoll, (state: RollerState) => boolean] { return [ this.mainResult, @@ -375,6 +380,10 @@ export class CursableRollContainer implements IRollContainer { copy() { return new CursableRollContainer(this); } + + activeRollWrapper() { + return this.activeRoll().currentRoll(); + } } export function createRollContainer(roll: RollWrapper): RollContainer { From 24dd1b28ee7aabb02ed39fc7f5c885699e849992 Mon Sep 17 00:00:00 2001 From: Chris Wegrzyn Date: Wed, 20 Nov 2024 21:05:31 -0500 Subject: [PATCH 7/7] Move oracle/roll state code to own file --- src/entity/new-modal.ts | 7 +- src/entity/specs.ts | 2 +- src/mechanics/node-builders/index.ts | 2 +- src/oracles/new-modal.ts | 388 +-------------------------- src/oracles/state.ts | 383 ++++++++++++++++++++++++++ 5 files changed, 395 insertions(+), 387 deletions(-) create mode 100644 src/oracles/state.ts diff --git a/src/entity/new-modal.ts b/src/entity/new-modal.ts index b0c9600..92c57bf 100644 --- a/src/entity/new-modal.ts +++ b/src/entity/new-modal.ts @@ -15,11 +15,8 @@ import { Setting, TextComponent, } from "obsidian"; -import { - createRollContainer, - NewOracleRollerModal, - RollContainer, -} from "oracles/new-modal"; +import { NewOracleRollerModal } from "oracles/new-modal"; +import { createRollContainer, RollContainer } from "oracles/state"; import { FolderTextSuggest } from "utils/ui/settings/folder"; import { RollContext } from "../model/oracle"; import { RollWrapper } from "../model/rolls"; diff --git a/src/entity/specs.ts b/src/entity/specs.ts index ccc7ab7..595c337 100644 --- a/src/entity/specs.ts +++ b/src/entity/specs.ts @@ -1,7 +1,7 @@ import { Datasworn } from "@datasworn/core"; import { matchDataswornLink } from "datastore/parsers/datasworn/id"; import { RollWrapper } from "model/rolls"; -import { createRollContainer, RollContainer } from "oracles/new-modal"; +import { createRollContainer, RollContainer } from "oracles/state"; export type EntityDescriptor = { label: string; diff --git a/src/mechanics/node-builders/index.ts b/src/mechanics/node-builders/index.ts index 747e8f3..cd160b8 100644 --- a/src/mechanics/node-builders/index.ts +++ b/src/mechanics/node-builders/index.ts @@ -9,8 +9,8 @@ import { moveIsAction, moveIsProgress, } from "moves/desc"; -import { RollContainer } from "oracles/new-modal"; import { oracleNameWithParents } from "oracles/render"; +import { RollContainer } from "oracles/state"; import { ProgressTrackWriterContext } from "tracks/writer"; import { node } from "utils/kdl"; diff --git a/src/oracles/new-modal.ts b/src/oracles/new-modal.ts index dd054d0..9f47570 100644 --- a/src/oracles/new-modal.ts +++ b/src/oracles/new-modal.ts @@ -13,388 +13,16 @@ import { import { join } from "lit-html/directives/join.js"; import { map } from "lit-html/directives/map.js"; import { ref } from "lit-html/directives/ref.js"; -import { NoSuchOracleError } from "model/errors"; import { CurseBehavior, Oracle, RollContext } from "model/oracle"; -import { Roll, RollWrapper, Subroll, withinRange } from "model/rolls"; +import { Roll, RollWrapper } from "model/rolls"; import { Modal, Platform, setIcon, ToggleComponent } from "obsidian"; -import { randomInt } from "utils/dice"; - -function generateOracleRows(currentRoll: RollWrapper): RollWrapper[] { - const { oracle, context } = currentRoll; - return oracle.rollableRows.map((row) => { - if ( - row.range.min <= currentRoll.diceValue && - currentRoll.diceValue <= row.range.max - ) { - return currentRoll; - } else { - // TODO(@cwegrzyn): this distribution is wrong-- assumes an even value between the two points, but that's not correct - return new RollWrapper( - oracle, - context, - oracle.evaluate(context, randomInt(row.range.min, row.range.max)), - ); - } - }); -} - -class RowState { - #subrollStates: Map>; - #initialRoll: RollWrapper; - - static fromRoll(initialRoll: RollWrapper): RowState { - return new this( - initialRoll, - new Map( - Object.entries(initialRoll.subrolls ?? {}).map(([id, subrolls]) => [ - id, - { - ...subrolls, - rolls: subrolls.rolls.map((r) => new ObservableRoll(r)), - }, - ]), - ), - ); - } - - private constructor( - initialRoll: RollWrapper, - subrollStates: Map>, - ) { - this.#initialRoll = initialRoll; - this.#subrollStates = subrollStates; - } - - get initialRoll(): RollWrapper { - return this.#initialRoll; - } - - observeSubroll( - id: string, - index: number, - ): [RollerState, (roll: RollerState) => RowState] { - let subrolls = this.#subrollStates.get(id); - if (subrolls == null) { - subrolls = { inTemplate: false, rolls: [] }; - this.#subrollStates.set(id, subrolls); - } - - let subroll = subrolls.rolls.at(index); - if (!subroll) { - const { context } = this.#initialRoll; - const oracle = context.lookup(id); - if (!oracle) throw new NoSuchOracleError(id); - - if (index != subrolls.rolls.length) - throw new Error( - `subroll requested at index ${index}, but expected to match existing subrolls length of ${subrolls.rolls.length}`, - ); - subroll = new ObservableRoll( - new RollWrapper(oracle, context, oracle.rollDirect(context)), - ); - subrolls.rolls.push(subroll); - } - return [subroll.observe(), (roll) => this.#updatingRoll(id, index, roll)]; - } - - #updatingRoll(id: string, index: number, subroll: RollerState): RowState { - const newSubrollStates = new Map(this.#subrollStates); - let subrolls = newSubrollStates.get(id); - if (subrolls == null) { - subrolls = { inTemplate: false, rolls: [] }; - } else { - subrolls = { ...subrolls, rolls: [...subrolls.rolls] }; - } - newSubrollStates.set(id, subrolls); - subrolls.rolls[index] = new ObservableRoll(subroll); - - return new RowState(this.#initialRoll, newSubrollStates); - } - - currentRoll(): RollWrapper { - if (!this.#initialRoll.subrolls || this.#subrollStates.size == 0) { - // If there were no subrolls, this is simple. - return this.#initialRoll; - } - return this.#initialRoll.replacingSubrolls( - [...this.#subrollStates.entries()].map(([key, subroll]) => [ - key, - { - ...subroll, - rolls: subroll.rolls.map((roll) => roll.currentRoll()), - }, - ]), - ); - } -} - -class RollerState { - static fromRoll(initialRoll: RollWrapper): RollerState { - const rows = generateOracleRows(initialRoll).map((roll) => - RowState.fromRoll(roll), - ); - const currentRowIndex = rows.findIndex( - (row) => row.initialRoll == initialRoll, - ); - return new this( - initialRoll.oracle, - initialRoll.context, - rows, - currentRowIndex, - currentRowIndex, - ); - } - - private constructor( - public oracle: Oracle, - public context: RollContext, - public rows: RowState[], - public selectedRowIndex: number, - public initialRowIndex: number, - ) {} - - rowForUpdate(index: number): [RowState, (state: RowState) => RollerState] { - return [ - this.rows[index], - (newRowState) => { - if (newRowState !== this.rows[index]) { - const rows = [...this.rows]; - rows[index] = newRowState; - return new RollerState( - this.oracle, - this.context, - rows, - this.selectedRowIndex, - this.initialRowIndex, - ); - } - return this; - }, - ]; - } - - updateSelection(updater: (oldRow: number) => number): RollerState { - const newIndex = updater(this.selectedRowIndex) % this.rows.length; - return new RollerState( - this.oracle, - this.context, - this.rows, - newIndex < 0 ? newIndex + this.rows.length : newIndex, - this.initialRowIndex, - ); - } - - currentRoll(): RollWrapper { - return this.rows[this.selectedRowIndex].currentRoll(); - } - - async reroll(): Promise { - const newRoll = await this.currentRoll().reroll(); - return RollerState.fromRoll(newRoll); - } - - *rowsIter(): Iterable<{ - roll: RowState; - oracleRow: - | Datasworn.OracleRollableRowText - | Datasworn.OracleRollableRowText2 - | Datasworn.OracleRollableRowText3; - marker: "initial" | "flipped" | null; - isSelected: boolean; - index: number; - }> { - const flippedRoll = this.oracle.dice.flip( - this.rows[this.initialRowIndex].initialRoll.diceValue, - ); - for (let index = 0; index < this.rows.length; index++) { - const oracleRow = this.oracle.raw.rows[index]; - const roll = this.rows[index]; - yield { - roll, - oracleRow, - marker: - this.initialRowIndex == index - ? "initial" - : withinRange(flippedRoll, oracleRow.roll ?? undefined) - ? "flipped" - : null, - isSelected: this.selectedRowIndex == index, - index, - }; - } - } -} - -class ObservableRoll { - #value: RollWrapper | RollerState; - - constructor(initialRoll: RollWrapper | RollerState) { - this.#value = initialRoll; - } - - currentRoll(): RollWrapper { - return this.#value instanceof RollWrapper - ? this.#value - : this.#value.currentRoll(); - } - - observe(): RollerState { - return this.#value instanceof RollWrapper - ? (this.#value = RollerState.fromRoll(this.#value)) - : this.#value; - } - - get oracle(): Oracle { - return this.#value.oracle; - } - - update(newState: RollerState): ObservableRoll { - if (newState != this.#value) { - return new ObservableRoll(newState); - } else { - return this; - } - } -} - -export interface IRollContainer { - mainResult: ObservableRoll; - oracle: Oracle; - - isCursable(): this is CursableRollContainer; - - activeRoll(): ObservableRoll; - activeRollWrapper(): RollWrapper; - activeRollForUpdate(): [ObservableRoll, (state: RollerState) => boolean]; - - copy(): IRollContainer; -} - -export class SimpleRollContainer implements IRollContainer { - mainResult: ObservableRoll; - - constructor(initialRoll: RollWrapper | RollerState | SimpleRollContainer) { - if (initialRoll instanceof SimpleRollContainer) { - this.mainResult = initialRoll.mainResult; - } else { - this.mainResult = new ObservableRoll(initialRoll); - } - } - - get oracle() { - return this.mainResult.oracle; - } - - isCursable(): this is CursableRollContainer { - return false; - } - - activeRoll() { - return this.mainResult; - } - - activeRollWrapper() { - return this.activeRoll().currentRoll(); - } - - activeRollForUpdate(): [ObservableRoll, (state: RollerState) => boolean] { - return [ - this.mainResult, - (state) => { - const oldResult = this.mainResult; - this.mainResult = oldResult.update(state); - return oldResult != this.mainResult; - }, - ]; - } - - copy() { - return new SimpleRollContainer(this); - } -} - -export class CursableRollContainer implements IRollContainer { - /** Value of cursed die, if rolled. */ - cursedDie?: number; - - mainResult: ObservableRoll; - cursedResult: ObservableRoll; - useCursedResult: boolean; - - constructor(initialRoll: RollWrapper | CursableRollContainer) { - if (initialRoll instanceof CursableRollContainer) { - this.cursedDie = initialRoll.cursedDie; - this.mainResult = initialRoll.mainResult; - this.cursedResult = initialRoll.cursedResult; - this.useCursedResult = initialRoll.useCursedResult; - return; - } - if (!initialRoll.cursedTable) { - throw new Error("must have a cursed table"); - } - this.cursedDie = initialRoll.cursedRoll; - this.useCursedResult = initialRoll.cursedRoll == 10; - this.mainResult = new ObservableRoll(initialRoll); - - const cursedTable = initialRoll.cursedTable; - this.cursedResult = new ObservableRoll( - new RollWrapper( - cursedTable, - initialRoll.context, - cursedTable.curseBehavior == CurseBehavior.ReplaceResult - ? cursedTable.evaluate(initialRoll.context, initialRoll.roll.roll) - : cursedTable.rollDirect(initialRoll.context), - ), - ); - } - - get oracle() { - return this.mainResult.oracle; - } - - isCursable(): this is CursableRollContainer { - return true; - } - - activeRoll() { - return this.useCursedResult ? this.cursedResult : this.mainResult; - } - - activeRollForUpdate(): [ObservableRoll, (state: RollerState) => boolean] { - return [ - this.useCursedResult ? this.cursedResult : this.mainResult, - (state) => { - if (this.useCursedResult) { - const oldResult = this.cursedResult; - this.cursedResult = oldResult.update(state); - return oldResult != this.cursedResult; - } else { - const oldResult = this.mainResult; - this.mainResult = oldResult.update(state); - return oldResult != this.mainResult; - } - }, - ]; - } - - copy() { - return new CursableRollContainer(this); - } - - activeRollWrapper() { - return this.activeRoll().currentRoll(); - } -} - -export function createRollContainer(roll: RollWrapper): RollContainer { - if (roll.cursedTable) { - return new CursableRollContainer(roll); - } else { - return new SimpleRollContainer(roll); - } -} - -export type RollContainer = SimpleRollContainer | CursableRollContainer; +import { + createRollContainer, + CursableRollContainer, + RollContainer, + RollerState, + SimpleRollContainer, +} from "./state"; export class NewOracleRollerModal extends Modal { public accepted: boolean = false; diff --git a/src/oracles/state.ts b/src/oracles/state.ts new file mode 100644 index 0000000..297435b --- /dev/null +++ b/src/oracles/state.ts @@ -0,0 +1,383 @@ +/** + * Classes that allow us to track the state of user oracle result selection. + */ +import { Datasworn } from "@datasworn/core"; +import { NoSuchOracleError } from "model/errors"; +import { CurseBehavior, Oracle, RollContext } from "model/oracle"; +import { RollWrapper, Subroll, withinRange } from "model/rolls"; +import { randomInt } from "utils/dice"; + +export class RollerState { + static fromRoll(initialRoll: RollWrapper): RollerState { + const rows = generateOracleRows(initialRoll).map((roll) => + RowState.fromRoll(roll), + ); + const currentRowIndex = rows.findIndex( + (row) => row.initialRoll == initialRoll, + ); + return new this( + initialRoll.oracle, + initialRoll.context, + rows, + currentRowIndex, + currentRowIndex, + ); + } + + private constructor( + public oracle: Oracle, + public context: RollContext, + public rows: RowState[], + public selectedRowIndex: number, + public initialRowIndex: number, + ) {} + + rowForUpdate(index: number): [RowState, (state: RowState) => RollerState] { + return [ + this.rows[index], + (newRowState) => { + if (newRowState !== this.rows[index]) { + const rows = [...this.rows]; + rows[index] = newRowState; + return new RollerState( + this.oracle, + this.context, + rows, + this.selectedRowIndex, + this.initialRowIndex, + ); + } + return this; + }, + ]; + } + + updateSelection(updater: (oldRow: number) => number): RollerState { + const newIndex = updater(this.selectedRowIndex) % this.rows.length; + return new RollerState( + this.oracle, + this.context, + this.rows, + newIndex < 0 ? newIndex + this.rows.length : newIndex, + this.initialRowIndex, + ); + } + + currentRoll(): RollWrapper { + return this.rows[this.selectedRowIndex].currentRoll(); + } + + async reroll(): Promise { + const newRoll = await this.currentRoll().reroll(); + return RollerState.fromRoll(newRoll); + } + + *rowsIter(): Iterable<{ + roll: RowState; + oracleRow: + | Datasworn.OracleRollableRowText + | Datasworn.OracleRollableRowText2 + | Datasworn.OracleRollableRowText3; + marker: "initial" | "flipped" | null; + isSelected: boolean; + index: number; + }> { + const flippedRoll = this.oracle.dice.flip( + this.rows[this.initialRowIndex].initialRoll.diceValue, + ); + for (let index = 0; index < this.rows.length; index++) { + const oracleRow = this.oracle.raw.rows[index]; + const roll = this.rows[index]; + yield { + roll, + oracleRow, + marker: + this.initialRowIndex == index + ? "initial" + : withinRange(flippedRoll, oracleRow.roll ?? undefined) + ? "flipped" + : null, + isSelected: this.selectedRowIndex == index, + index, + }; + } + } +} +export class ObservableRoll { + #value: RollWrapper | RollerState; + + constructor(initialRoll: RollWrapper | RollerState) { + this.#value = initialRoll; + } + + currentRoll(): RollWrapper { + return this.#value instanceof RollWrapper + ? this.#value + : this.#value.currentRoll(); + } + + observe(): RollerState { + return this.#value instanceof RollWrapper + ? (this.#value = RollerState.fromRoll(this.#value)) + : this.#value; + } + + get oracle(): Oracle { + return this.#value.oracle; + } + + update(newState: RollerState): ObservableRoll { + if (newState != this.#value) { + return new ObservableRoll(newState); + } else { + return this; + } + } +} +export function generateOracleRows(currentRoll: RollWrapper): RollWrapper[] { + const { oracle, context } = currentRoll; + return oracle.rollableRows.map((row) => { + if ( + row.range.min <= currentRoll.diceValue && + currentRoll.diceValue <= row.range.max + ) { + return currentRoll; + } else { + // TODO(@cwegrzyn): this distribution is wrong-- assumes an even value between the two points, but that's not correct + return new RollWrapper( + oracle, + context, + oracle.evaluate(context, randomInt(row.range.min, row.range.max)), + ); + } + }); +} + +export class RowState { + #subrollStates: Map>; + #initialRoll: RollWrapper; + + static fromRoll(initialRoll: RollWrapper): RowState { + return new this( + initialRoll, + new Map( + Object.entries(initialRoll.subrolls ?? {}).map(([id, subrolls]) => [ + id, + { + ...subrolls, + rolls: subrolls.rolls.map((r) => new ObservableRoll(r)), + }, + ]), + ), + ); + } + + private constructor( + initialRoll: RollWrapper, + subrollStates: Map>, + ) { + this.#initialRoll = initialRoll; + this.#subrollStates = subrollStates; + } + + get initialRoll(): RollWrapper { + return this.#initialRoll; + } + + observeSubroll( + id: string, + index: number, + ): [RollerState, (roll: RollerState) => RowState] { + let subrolls = this.#subrollStates.get(id); + if (subrolls == null) { + subrolls = { inTemplate: false, rolls: [] }; + this.#subrollStates.set(id, subrolls); + } + + let subroll = subrolls.rolls.at(index); + if (!subroll) { + const { context } = this.#initialRoll; + const oracle = context.lookup(id); + if (!oracle) throw new NoSuchOracleError(id); + + if (index != subrolls.rolls.length) + throw new Error( + `subroll requested at index ${index}, but expected to match existing subrolls length of ${subrolls.rolls.length}`, + ); + subroll = new ObservableRoll( + new RollWrapper(oracle, context, oracle.rollDirect(context)), + ); + subrolls.rolls.push(subroll); + } + return [subroll.observe(), (roll) => this.#updatingRoll(id, index, roll)]; + } + + #updatingRoll(id: string, index: number, subroll: RollerState): RowState { + const newSubrollStates = new Map(this.#subrollStates); + let subrolls = newSubrollStates.get(id); + if (subrolls == null) { + subrolls = { inTemplate: false, rolls: [] }; + } else { + subrolls = { ...subrolls, rolls: [...subrolls.rolls] }; + } + newSubrollStates.set(id, subrolls); + subrolls.rolls[index] = new ObservableRoll(subroll); + + return new RowState(this.#initialRoll, newSubrollStates); + } + + currentRoll(): RollWrapper { + if (!this.#initialRoll.subrolls || this.#subrollStates.size == 0) { + // If there were no subrolls, this is simple. + return this.#initialRoll; + } + return this.#initialRoll.replacingSubrolls( + [...this.#subrollStates.entries()].map(([key, subroll]) => [ + key, + { + ...subroll, + rolls: subroll.rolls.map((roll) => roll.currentRoll()), + }, + ]), + ); + } +} + +export interface IRollContainer { + mainResult: ObservableRoll; + oracle: Oracle; + + isCursable(): this is CursableRollContainer; + + activeRoll(): ObservableRoll; + activeRollWrapper(): RollWrapper; + activeRollForUpdate(): [ObservableRoll, (state: RollerState) => boolean]; + + copy(): IRollContainer; +} + +export class SimpleRollContainer implements IRollContainer { + mainResult: ObservableRoll; + + constructor(initialRoll: RollWrapper | RollerState | SimpleRollContainer) { + if (initialRoll instanceof SimpleRollContainer) { + this.mainResult = initialRoll.mainResult; + } else { + this.mainResult = new ObservableRoll(initialRoll); + } + } + + get oracle() { + return this.mainResult.oracle; + } + + isCursable(): this is CursableRollContainer { + return false; + } + + activeRoll() { + return this.mainResult; + } + + activeRollWrapper() { + return this.activeRoll().currentRoll(); + } + + activeRollForUpdate(): [ObservableRoll, (state: RollerState) => boolean] { + return [ + this.mainResult, + (state) => { + const oldResult = this.mainResult; + this.mainResult = oldResult.update(state); + return oldResult != this.mainResult; + }, + ]; + } + + copy() { + return new SimpleRollContainer(this); + } +} + +export class CursableRollContainer implements IRollContainer { + /** Value of cursed die, if rolled. */ + cursedDie?: number; + + mainResult: ObservableRoll; + cursedResult: ObservableRoll; + useCursedResult: boolean; + + constructor(initialRoll: RollWrapper | CursableRollContainer) { + if (initialRoll instanceof CursableRollContainer) { + this.cursedDie = initialRoll.cursedDie; + this.mainResult = initialRoll.mainResult; + this.cursedResult = initialRoll.cursedResult; + this.useCursedResult = initialRoll.useCursedResult; + return; + } + if (!initialRoll.cursedTable) { + throw new Error("must have a cursed table"); + } + this.cursedDie = initialRoll.cursedRoll; + this.useCursedResult = initialRoll.cursedRoll == 10; + this.mainResult = new ObservableRoll(initialRoll); + + const cursedTable = initialRoll.cursedTable; + this.cursedResult = new ObservableRoll( + new RollWrapper( + cursedTable, + initialRoll.context, + cursedTable.curseBehavior == CurseBehavior.ReplaceResult + ? cursedTable.evaluate(initialRoll.context, initialRoll.roll.roll) + : cursedTable.rollDirect(initialRoll.context), + ), + ); + } + + get oracle() { + return this.mainResult.oracle; + } + + isCursable(): this is CursableRollContainer { + return true; + } + + activeRoll() { + return this.useCursedResult ? this.cursedResult : this.mainResult; + } + + activeRollForUpdate(): [ObservableRoll, (state: RollerState) => boolean] { + return [ + this.useCursedResult ? this.cursedResult : this.mainResult, + (state) => { + if (this.useCursedResult) { + const oldResult = this.cursedResult; + this.cursedResult = oldResult.update(state); + return oldResult != this.cursedResult; + } else { + const oldResult = this.mainResult; + this.mainResult = oldResult.update(state); + return oldResult != this.mainResult; + } + }, + ]; + } + + copy() { + return new CursableRollContainer(this); + } + + activeRollWrapper() { + return this.activeRoll().currentRoll(); + } +} + +export function createRollContainer(roll: RollWrapper): RollContainer { + if (roll.cursedTable) { + return new CursableRollContainer(roll); + } else { + return new SimpleRollContainer(roll); + } +} + +export type RollContainer = SimpleRollContainer | CursableRollContainer;