diff --git a/src/actions/combat/start-combat-actions.tsx b/src/actions/combat/start-combat-actions.tsx index 7b69445..b16b5b0 100644 --- a/src/actions/combat/start-combat-actions.tsx +++ b/src/actions/combat/start-combat-actions.tsx @@ -23,8 +23,7 @@ interface Init extends Omit, 'namespace' | 'category registry: TypedKeys>; } -function execMob({area, mob}: Props) { - game.stopActiveAction(); +function execMob({area, mob}: Props): void { game.combat.selectMonster(area.monsters[mob!], area); } @@ -104,7 +103,6 @@ mkAction({ ), execute({area}) { - game.stopActiveAction(); game.combat.selectDungeon(area as Dungeon); }, label: 'Start Dungeon', diff --git a/src/actions/core/delay-action.tsx b/src/actions/core/delay-action.tsx index acaaa10..ed61667 100644 --- a/src/actions/core/delay-action.tsx +++ b/src/actions/core/delay-action.tsx @@ -1,5 +1,6 @@ +import {noop} from 'lodash-es'; import {Fragment} from 'preact'; -import {map, noop, timer} from 'rxjs'; +import {map, timer} from 'rxjs'; import {InternalCategory} from '../../lib/registries/action-registry.mjs'; import {defineLocalAction} from '../../lib/util/define-local.mjs'; @@ -21,7 +22,7 @@ defineLocalAction({ media: cdnMedia('assets/media/main/timer.svg'), options: [ { - description: 'Wait for the given number of milliseconds. The actual wait may be significantly longer if the game is minimised in the browser.', + description: 'Wait for the given number of milliseconds. The actual wait may be significantly longer if the game is minimised in the browser. Does NOT work offline.', label: 'Duration', localID: 'duration', min: 0, diff --git a/src/actions/lib/recipe-action.mts b/src/actions/lib/recipe-action.mts index 8e192d3..6f31f72 100644 --- a/src/actions/lib/recipe-action.mts +++ b/src/actions/lib/recipe-action.mts @@ -1,6 +1,7 @@ import {nextComplete} from '@aloreljs/rxutils'; +import {noop} from 'lodash-es'; import type {GatheringSkill} from 'melvor'; -import {noop, Observable} from 'rxjs'; +import {Observable} from 'rxjs'; import PersistClassName from '../../lib/decorators/PersistClassName.mjs'; import type {Obj} from '../../public_api'; import type {SkillActionInit} from './skill-action.mjs'; @@ -86,11 +87,9 @@ export class RecipeAction /** @inheritDoc */ public execute(data: T): Observable { - return new Observable(s => { - const prep = this.prepareExec(data); - game.stopActiveAction(); - this.exec(data, prep); - nextComplete(s); + return new Observable(subscriber => { + this.exec(data, this.prepareExec(data)); + nextComplete(subscriber); }); } } diff --git a/src/actions/start-skill/agility.mts b/src/actions/start-skill/agility.mts index ddd6e03..108d419 100644 --- a/src/actions/start-skill/agility.mts +++ b/src/actions/start-skill/agility.mts @@ -1,6 +1,4 @@ -import {nextComplete} from '@aloreljs/rxutils'; import type {Agility} from 'melvor'; -import {Observable} from 'rxjs'; import {defineAction} from '../../lib/api.mjs'; import PersistClassName from '../../lib/decorators/PersistClassName.mjs'; import {InternalCategory} from '../../lib/registries/action-registry.mjs'; @@ -10,12 +8,8 @@ import SkillAction from '../lib/skill-action.mjs'; class AgilityAction extends SkillAction<{}, Agility> { /** @inheritDoc */ - public override execute(): Observable { - return new Observable(subscriber => { - game.stopActiveAction(); - this.skill.start(); - nextComplete(subscriber); - }); + public override execute(): void { + this.skill.start(); } } diff --git a/src/actions/start-skill/alt-magic.mts b/src/actions/start-skill/alt-magic.mts index 49f31b9..aa1f9a0 100644 --- a/src/actions/start-skill/alt-magic.mts +++ b/src/actions/start-skill/alt-magic.mts @@ -1,5 +1,4 @@ import type {AltMagicSpell, FoodItem, Item, SingleProductArtisanSkillRecipe} from 'melvor'; -import {asyncScheduler, Observable, scheduled} from 'rxjs'; import {defineAction} from '../../lib/api.mjs'; import {InternalCategory} from '../../lib/registries/action-registry.mjs'; import {namespace} from '../../manifest.json'; @@ -22,37 +21,29 @@ interface Props { defineAction({ category: InternalCategory.START_SKILL, - execute: ({bar, food, junk, item, recipe, superGem}) => ( - scheduled( - new Observable(subscriber => { - game.stopActiveAction(); - magic.selectSpellOnClick(recipe); + execute({bar, food, junk, item, recipe, superGem}) { + magic.selectSpellOnClick(recipe); - switch (recipe.specialCost.type) { - case AltMagicConsumptionID.AnyItem: - magic.selectItemOnClick(item!); - break; - case AltMagicConsumptionID.AnyNormalFood: - magic.selectItemOnClick(food!); - break; - case AltMagicConsumptionID.JunkItem: - magic.selectItemOnClick(junk!); - break; - case AltMagicConsumptionID.BarIngredientsWithoutCoal: - case AltMagicConsumptionID.BarIngredientsWithCoal: - magic.selectBarOnClick(bar!); - break; - case AltMagicConsumptionID.AnySuperiorGem: - magic.selectItemOnClick(superGem!); - } + switch (recipe.specialCost.type) { + case AltMagicConsumptionID.AnyItem: + magic.selectItemOnClick(item!); + break; + case AltMagicConsumptionID.AnyNormalFood: + magic.selectItemOnClick(food!); + break; + case AltMagicConsumptionID.JunkItem: + magic.selectItemOnClick(junk!); + break; + case AltMagicConsumptionID.BarIngredientsWithoutCoal: + case AltMagicConsumptionID.BarIngredientsWithCoal: + magic.selectBarOnClick(bar!); + break; + case AltMagicConsumptionID.AnySuperiorGem: + magic.selectItemOnClick(superGem!); + } - magic.castButtonOnClick(); - - subscriber.complete(); - }), - asyncScheduler - ) - ), + magic.castButtonOnClick(); + }, label: 'Start Alt. Magic', localID: 'startAltMagic', media: magic.media, diff --git a/src/lib/data-update.mts b/src/lib/data-update.mts new file mode 100644 index 0000000..54a8be2 --- /dev/null +++ b/src/lib/data-update.mts @@ -0,0 +1,48 @@ +import type {Workflow} from './data/workflow.mjs'; +import update0001 from './updates/update-0001.mjs'; + +export interface SerialisedWorkflow extends Pick { + steps: [string[], any[][]]; +} + +export type DataUpdateFn = (workflows: SerialisedWorkflow[]) => void; + +export interface RunUpdatesResult { + + /** Whether at least one update got applied or not */ + applied: boolean; + + /** The data version for this mod version */ + update: number; +} + +function getUpdatesArray(): DataUpdateFn[] { + return [ + update0001, + ]; +} + +/** + * Run data updates when the storage format changes to avoid users having to redefine all their workflows + * @param dataVersion The current data version + * @param data The raw loaded data + * @return Whether at least one update got applied or not + */ +export function runUpdates(dataVersion: number, data: SerialisedWorkflow[]): RunUpdatesResult { + const updateFns = getUpdatesArray(); + + // The version defaults to -1 - add 1 to get array index 0 + const firstIdx = dataVersion + 1; + for (let i = firstIdx; i < updateFns.length; ++i) { + updateFns[i](data); + } + + return { + applied: firstIdx < updateFns.length, + update: updateFns.length - 1, + }; +} + +export function getUpdateNumber(): number { + return getUpdatesArray().length - 1; +} diff --git a/src/lib/data/workflow.mts b/src/lib/data/workflow.mts index 1e2de5e..a8d21f1 100644 --- a/src/lib/data/workflow.mts +++ b/src/lib/data/workflow.mts @@ -6,6 +6,7 @@ import {FormatToJsonArrayCompressed} from '../decorators/to-json-formatters/form import type {FromJSON, ToJSON} from '../decorators/to-json.mjs'; import {JsonProp, Serialisable} from '../decorators/to-json.mjs'; import type {ReadonlyBehaviorSubject} from '../registries/workflow-registry.mjs'; +import WorkflowRegistry from '../registries/workflow-registry.mjs'; import {WorkflowStep} from './workflow-step.mjs'; type Init = Partial>; @@ -55,9 +56,14 @@ export class Workflow { return this.steps.length > 1; } - public get isValid(): boolean { + /** + * @param editedName Duplicate workflow names aren't permitted, but this check would trigger on itself when editing + * a workflow, so the workflow's original name should be provided on edits so it can be used as an exception. + */ + public isValid(editedName?: string): boolean { return this.name.trim().length !== 0 && this.steps.length !== 0 + && (editedName === this.name || !WorkflowRegistry.inst.workflows.some(w => w.name === editedName)) && this.steps.every(s => s.isValid); } diff --git a/src/lib/execution/workflow-execution.mts b/src/lib/execution/workflow-execution.mts index 3ca036f..1ba8e7b 100644 --- a/src/lib/execution/workflow-execution.mts +++ b/src/lib/execution/workflow-execution.mts @@ -4,8 +4,6 @@ import {nextComplete} from '@aloreljs/rxutils'; import {logError} from '@aloreljs/rxutils/operators'; import type {MonoTypeOperatorFunction, Observer, Subscription, TeardownLogic} from 'rxjs'; import { - asapScheduler, - asyncScheduler, BehaviorSubject, concat, defer, @@ -16,7 +14,6 @@ import { map, Observable, of, - scheduled, startWith, takeUntil, tap @@ -32,6 +29,7 @@ import WorkflowRegistry from '../registries/workflow-registry.mjs'; import {debugLog, errorLog} from '../util/log.mjs'; import prependErrorWith from '../util/rxjs/prepend-error-with.mjs'; import ShareReplayLike from '../util/share-replay-like-observable.mjs'; +import {stopAction} from '../util/stop-action.mjs'; import type { ActionExecutionEvent, StepCompleteEvent, @@ -43,6 +41,8 @@ import {WorkflowEventType} from './workflow-event.mjs'; type Out = WorkflowEvent; +const DESC_EVENTS = Object.getOwnPropertyDescriptor(ShareReplayLike.prototype, 'events')!; + /** Represents a workflow in the middle of being executed */ @PersistClassName('WorkflowTrigger') export class WorkflowExecution extends ShareReplayLike { @@ -79,7 +79,7 @@ export class WorkflowExecution extends ShareReplayLike { this.activeStepIdxManual = false; ++this.activeStepIdx; } - this.mainSub = asapScheduler.schedule(this.tick); + this.tick(); }, error: (e: Error) => { const evt = this.mkCompleteEvent(false, e.message); @@ -94,9 +94,12 @@ export class WorkflowExecution extends ShareReplayLike { }, }; - public constructor(public readonly workflow: Workflow) { + public constructor( + public readonly workflow: Workflow, + step = 0 + ) { super(); - this._activeStepIdx$ = new BehaviorSubject(0); + this._activeStepIdx$ = new BehaviorSubject(step); this.activeStepIdx$ = this._activeStepIdx$.asObservable(); } @@ -113,6 +116,10 @@ export class WorkflowExecution extends ShareReplayLike { this._activeStepIdx$.next(v); } + public get isFinished(): boolean { + return this.finished; + } + public get running(): boolean { return !this.finished; } @@ -161,6 +168,7 @@ export class WorkflowExecution extends ShareReplayLike { } this.finished = false; } + this.tick(); } @@ -231,7 +239,9 @@ export class WorkflowExecution extends ShareReplayLike { return EMPTY; } - return concat(...step.actions.map((_, i) => this.executeAction(step, stepIdx, i))); + return stopAction().pipe( + switchMap(() => concat(...step.actions.map((_, i) => this.executeAction(step, stepIdx, i)))) + ); } /** @@ -245,10 +255,9 @@ export class WorkflowExecution extends ShareReplayLike { workflow: this.workflow, }; - const trigger$ = step.trigger.listen().pipe(take(1)); - - const exec$: Observable = scheduled(trigger$, asyncScheduler) + const exec$: Observable = step.trigger.listen() .pipe( + take(1), switchMap(() => this.executeActions(step, stepIdx)), logError(`Error executing step ${stepIdx} in workflow ${this.workflow.name}:`), prependErrorWith(e => of({ @@ -351,7 +360,6 @@ export class WorkflowExecution extends ShareReplayLike { } /** Main ticking function for the workflow */ - @BoundMethod() private tick(): void { const step = this.activeStep; if (!step) { @@ -368,5 +376,3 @@ export class WorkflowExecution extends ShareReplayLike { return takeUntil(this._activeStepIdx$.pipe(filter(i => i !== idx))); } } - -const DESC_EVENTS = Object.getOwnPropertyDescriptor(WorkflowExecution.prototype, 'events')!; diff --git a/src/lib/registries/workflow-registry.mts b/src/lib/registries/workflow-registry.mts index 49d1354..01b655d 100644 --- a/src/lib/registries/workflow-registry.mts +++ b/src/lib/registries/workflow-registry.mts @@ -1,6 +1,7 @@ import {LazyGetter} from 'lazy-get-decorator'; import type {Observable} from 'rxjs'; -import {BehaviorSubject} from 'rxjs'; +import {BehaviorSubject, distinctUntilChanged, map, of, switchMap} from 'rxjs'; +import {getUpdateNumber, runUpdates} from '../data-update.mjs'; import {Workflow} from '../data/workflow.mjs'; import PersistClassName from '../decorators/PersistClassName.mjs'; import { @@ -9,15 +10,31 @@ import { } from '../decorators/to-json-formatters/format-to-json-array-compressed.mjs'; import {WorkflowExecution} from '../execution/workflow-execution.mjs'; import {alertError} from '../util/alert'; -import {debugLog, errorLog} from '../util/log.mjs'; +import {errorLog, warnLog} from '../util/log.mjs'; -const enum Strings { - CFG_KEY = 'workflows:v2', +const enum StorageKey { + WORKFLOWS = 'workflows:v2', + + DATA_VERSION = 'dataVersion', + + PRIMARY_EXECUTION = 'primaryExec', +} + +interface PrimaryExecutionRef { + + /** Active step idx */ + step: number; + + /** Name */ + workflow: string; } export type ReadonlyBehaviorSubject = Pick, 'value' | keyof Observable>; -/** Container for all the workflows */ +/** + * Container for all the workflows. + * As long as anything is subscribed to the primary execution, it'll keep on ticking. + */ @PersistClassName('WorkflowRegistry') export default class WorkflowRegistry { @@ -34,8 +51,12 @@ export default class WorkflowRegistry { private readonly _workflows$: BehaviorSubject; /** Use the singleton @ {@link #inst} */ - private constructor(workflows: Workflow[]) { - this.primaryExecution$ = this._primaryExecution$ = new BehaviorSubject(undefined); + private constructor( + workflows: Workflow[] = [], // eslint-disable-line default-param-last + primaryExecution?: WorkflowExecution + ) { + this.primaryExecution$ = this._primaryExecution$ + = new BehaviorSubject(primaryExecution); this.workflows$ = this._workflows$ = new BehaviorSubject(workflows); } @@ -47,11 +68,9 @@ export default class WorkflowRegistry { out = WorkflowRegistry.fromStorage(); } catch (e) { errorLog('Error initialising workflow registry from storage', e); - return new WorkflowRegistry([]); + return new WorkflowRegistry(); } - debugLog('Initialised workflow registry', out); - return out; } @@ -66,29 +85,45 @@ export default class WorkflowRegistry { /** Load from storage */ private static fromStorage(): WorkflowRegistry { - const out: Workflow[] = []; + const workflowsOut: Workflow[] = []; - const raw = ctx.accountStorage.getItem(Strings.CFG_KEY); + // Get workflows from storage + const raw = ctx.accountStorage.getItem(StorageKey.WORKFLOWS); if (!raw) { - return new WorkflowRegistry(out); + storeDataVersion(); + + return new WorkflowRegistry(workflowsOut); } - const rawWorkflows = instantiateCompressedToJsonArray(raw); + // Decompress their JSON + const rawWorkflows: any[] | undefined = instantiateCompressedToJsonArray(raw); if (!rawWorkflows) { errorLog('Malformed workflows in storage: not a compressed array'); - return new WorkflowRegistry(out); + storeDataVersion(); + + return new WorkflowRegistry(workflowsOut); } + // Run data format updates + const updateResult = runUpdates(ctx.accountStorage.getItem(StorageKey.DATA_VERSION) ?? -1, rawWorkflows); + + // Instantiate Workflow classes for (const rawWF of rawWorkflows) { const instance = Workflow.fromJSON(rawWF); if (instance) { - out.push(instance); + workflowsOut.push(instance); } else { errorLog('Error instantiating workflow', rawWF, 'from storage: malformed data'); } } - return new WorkflowRegistry(out); + const registryOut = new WorkflowRegistry(workflowsOut, loadPrimaryExecution(workflowsOut)); + if (updateResult.applied) { + registryOut.save(); + storeDataVersion(updateResult.update); + } + + return registryOut; } /** Add a new workflow to the list */ @@ -98,12 +133,58 @@ export default class WorkflowRegistry { this.setWorkflows([...this.workflows, ...workflow]); } + /** Should be called ONCE, enabled offline support by subscribing to the primary execution at an appropriate time. */ + public monitorPrimaryExecutionState(): void { + this.primaryExecution$ + .pipe( + switchMap((exec): Observable => { + if (!exec) { + return of(null); + } + + // Need to subscribe to the execution itself and not just the primary step index as that's what controls ticks + return exec.pipe( + map(() => exec.activeStepIdx), + distinctUntilChanged(), + map((step): PrimaryExecutionRef | null => { + if (step >= exec.workflow.steps.length) { + return null; + } + + return { + step, + workflow: exec.workflow.name, + }; + }) + ); + }) + ) + .subscribe(state => { + const storage = ctx.characterStorage; + if (state) { + try { + storage.setItem(StorageKey.PRIMARY_EXECUTION, state); + } catch (e) { + errorLog('Failed to save primary execution state', e); + } + } else { + storage.removeItem(StorageKey.PRIMARY_EXECUTION); + } + }); + } + /** Overwrite the workflow at the given index */ public patch(workflow: Workflow, idx: number): void { if (idx < 0 || idx >= this.workflows.length) { return; } + // Don't patch the current workflow - unset it first + const executedWorkflow = this.primaryExecution?.workflow?.listId; + if (executedWorkflow && this.workflows.findIndex(w => w.listId === executedWorkflow) === idx) { + this.setPrimaryExecution(); + } + const out = [...this.workflows]; out[idx] = workflow; this.setWorkflows(out); @@ -143,9 +224,24 @@ export default class WorkflowRegistry { } } +function loadPrimaryExecution(liveWorkflows: Workflow[]): WorkflowExecution | undefined { + const execRef = ctx.characterStorage.getItem(StorageKey.PRIMARY_EXECUTION); + if (!execRef) { + return; + } + + const workflow = liveWorkflows.find(w => w.name === execRef!.workflow); + if (!workflow) { + warnLog('Got primary execution', execRef, 'from storage, but the workflow can\'t be found'); + return; + } + + return new WorkflowExecution(workflow, execRef.step); +} + function store(workflows: Workflow[] | readonly Workflow[]): void | never { try { - ctx.accountStorage.setItem(Strings.CFG_KEY, compressArray(workflows)); + ctx.accountStorage.setItem(StorageKey.WORKFLOWS, compressArray(workflows)); } catch (e) { alertError( 'Melvor mods have a 8kB storage limit and we\'ve reached it. Gonna have to delete some workflows.', @@ -156,4 +252,10 @@ function store(workflows: Workflow[] | readonly Workflow[]): void | never { } } - +function storeDataVersion(version: number = getUpdateNumber()): void { + try { + ctx.accountStorage.setItem(StorageKey.DATA_VERSION, version); + } catch (e) { + errorLog('Error storing data version', e); + } +} diff --git a/src/lib/updates/update-0001.mts b/src/lib/updates/update-0001.mts new file mode 100644 index 0000000..e6ddd60 --- /dev/null +++ b/src/lib/updates/update-0001.mts @@ -0,0 +1,16 @@ +import type {DataUpdateFn, SerialisedWorkflow} from '../data-update.mjs'; + +/** Ensure workflow names are unique */ +const update0001: DataUpdateFn = (rawWorkflows: SerialisedWorkflow[]): void => { + const occurrences: Record = {}; + + for (const wf of rawWorkflows) { + if (wf.name in occurrences) { + wf.name += ` (${++occurrences[wf.name]})`; + } else { + occurrences[wf.name] = 1; + } + } +}; + +export default update0001; diff --git a/src/lib/util.mts b/src/lib/util.mts index a3a486a..a621a6e 100644 --- a/src/lib/util.mts +++ b/src/lib/util.mts @@ -4,4 +4,3 @@ export const EMPTY_ARR = Object.freeze([]) as any[]; export function isFalsy(v: any): v is (0 | false | null | undefined | '') { return !v; } - diff --git a/src/lib/util/stop-action.mts b/src/lib/util/stop-action.mts new file mode 100644 index 0000000..e8b9068 --- /dev/null +++ b/src/lib/util/stop-action.mts @@ -0,0 +1,36 @@ +import type {Observable} from 'rxjs'; +import {of, Subject, tap} from 'rxjs'; +import {take} from 'rxjs/operators'; +import LazyValue from './lazy-value.mjs'; +import {debugLog} from './log.mjs'; + +const tick$ = new LazyValue>(() => { + const out = new Subject(); + ctx.patch(Game, 'tick').after(() => { + out.next(); + }); + + return out; +}); + +/** + * Gods, this thrice damned abomination of an implementation… + * You'd expect `action.stop()` to stop the action, right? YOU FOOL. + * The stupid game code does a ton of nonsense even after you stop the action AND gives you unrecoverable-from errors + * to boot. + */ +export function stopAction(): Observable { + const action: any = game.activeAction; + if (!action) { + debugLog('No action to stop'); + + return of(undefined); + } + + return tick$.value.pipe( + take(1), + tap(() => { + action.stop(); + }) + ); +} diff --git a/src/setup.tsx b/src/setup.tsx index 5304dca..d122944 100644 --- a/src/setup.tsx +++ b/src/setup.tsx @@ -1,10 +1,15 @@ -import {setDefaultLogger} from '@aloreljs/rxutils'; +import {nextComplete, setDefaultLogger} from '@aloreljs/rxutils'; +import {takeTruthy} from '@aloreljs/rxutils/operators'; import type {Signal} from '@preact/signals'; import {signal} from '@preact/signals'; import {render} from 'preact'; +import type {Observable} from 'rxjs'; +import {AsyncSubject, EMPTY, map, switchMap, takeUntil} from 'rxjs'; import './actions/actions.mjs'; +import {WorkflowEventType} from './lib/execution/workflow-event.mjs'; import {TRIGGER_REGISTRY} from './lib/registries/trigger-registry.mjs'; -import {errorLog} from './lib/util/log.mjs'; +import WorkflowRegistry from './lib/registries/workflow-registry.mjs'; +import {debugLog, errorLog} from './lib/util/log.mjs'; import './option-types/option-types.mjs'; import './triggers/index.mjs'; import App from './ui/app'; @@ -16,14 +21,10 @@ import './ui/ui.mjs'; setDefaultLogger(errorLog); let sidenavIconContainer: Signal; +let reg: WorkflowRegistry; +const interfaceReady$ = new AsyncSubject(); ctx.onCharacterLoaded(() => { - sidenavIconContainer = signal(null); - render(, document.createElement('div')); -}); - -ctx.onInterfaceReady(() => { - // Don't start checking triggers for offline time for (const {def, id} of TRIGGER_REGISTRY.registeredObjects.values()) { try { def.init?.(); @@ -32,6 +33,48 @@ ctx.onInterfaceReady(() => { } } + debugLog('Triggers initialised'); + + reg = WorkflowRegistry.inst; + + // Start listening on the primary execution to enable offline support + reg.monitorPrimaryExecutionState(); + + // Prevent the execution from showing up as active when it completes + if (reg.primaryExecution) { + if (reg.primaryExecution.isFinished) { + reg.setPrimaryExecution(); + } else { + reg.primaryExecution$ + .pipe( + switchMap((exec): Observable => { + if (!exec) { + return EMPTY; + } + + return exec.pipe( + map(evt => evt.type === WorkflowEventType.WORKFLOW_COMPLETE), + takeTruthy(1) + ); + }), + takeUntil(interfaceReady$) + ) + .subscribe(() => { + reg.setPrimaryExecution(); + }); + } + } + + debugLog('Primary execution monitored'); + + sidenavIconContainer = signal(null); + render(, document.createElement('div')); +}); + +ctx.onInterfaceReady(() => { + debugLog('Interface ready'); + nextComplete(interfaceReady$); + sidebar .category('') .item('Action Workflows', { diff --git a/src/ui/components/workflow-editor/header-block.tsx b/src/ui/components/workflow-editor/header-block.tsx index 0ac3d7f..cb3229a 100644 --- a/src/ui/components/workflow-editor/header-block.tsx +++ b/src/ui/components/workflow-editor/header-block.tsx @@ -2,6 +2,7 @@ import type {Signal} from '@preact/signals'; import {useSignal} from '@preact/signals'; import {memo} from 'preact/compat'; import {useCallback} from 'preact/hooks'; +import WorkflowRegistry from '../../../lib/registries/workflow-registry.mjs'; import {EMPTY_ARR} from '../../../lib/util.mjs'; import useReRender from '../../hooks/re-render'; import {mkClass} from '../../util/mk-class.mjs'; @@ -10,15 +11,22 @@ import Btn from '../btn'; import {EDITOR_SECTION_CLASS, useWorkflow} from './editor-contexts'; export interface WorkflowEditorHeaderBlockProps { + + /** + * Duplicate workflow names aren't permitted, but this check would trigger on itself when editing a workflow, so the + * workflow's original name should be provided on edits so it can be used as an exception. + */ + permitDupeName?: string; + onSave(e: Event): void; } const WorkflowEditorHeaderBlock = memo( - function WorkflowEditorHeaderBlock({children, onSave}) { + function WorkflowEditorHeaderBlock({children, permitDupeName, onSave}) { return (
- +
@@ -33,7 +41,7 @@ const WorkflowEditorHeaderBlock = memo( export default WorkflowEditorHeaderBlock; -const WorkflowNameEditor = memo(function WorkflowNameEditor() { +const WorkflowNameEditor = memo>(function WorkflowNameEditor({permitDupeName}) { const [touched$, onBlur] = useTouched(); const workflow$ = useWorkflow(); const reRender = useReRender(); @@ -44,16 +52,25 @@ const WorkflowNameEditor = memo(function WorkflowNameEditor() { }, [workflow$]); const name = workflow$.value.name; + const err = name.trim() + ? name !== permitDupeName && WorkflowRegistry.inst.workflows.some(w => w.name === name) + ? 'Workflow names must be unique' + : null + : 'Required'; + + const touched = touched$.value; return ( -
- {'Workflow name'} -
- + {'Workflow name'} +
+ + {touched && err &&
{err}
}
); diff --git a/src/ui/pages/help-page/help-pages.tsx b/src/ui/pages/help-page/help-pages.tsx index 69e8914..786d25c 100644 --- a/src/ui/pages/help-page/help-pages.tsx +++ b/src/ui/pages/help-page/help-pages.tsx @@ -28,7 +28,7 @@ export function LoopsHelp(): VNode { export function OfflineHelp(): VNode { return ( - The mod doesn't work offline + Yes! Only actions with an explicit wait time (e.g. the Wait action) don't work. ); } diff --git a/src/ui/pages/import-export-page.tsx b/src/ui/pages/import-export-page.tsx index d1de272..d2d3c43 100644 --- a/src/ui/pages/import-export-page.tsx +++ b/src/ui/pages/import-export-page.tsx @@ -26,7 +26,7 @@ export default function ImportExportPage(): VNode { function ImportAll(): VNode { const onClick = useCallback(() => { - showImportModal('Import workflow', json => { + const import$ = showImportModal('Import workflow', json => { try { const parsed = JSON.parse(json); if (!Array.isArray(parsed)) { @@ -45,10 +45,22 @@ function ImportAll(): VNode { } catch (e) { return e.message; } - }) + }); + + import$ .subscribe(rsp => { - const parsed = (JSON.parse(rsp) as any[]).map(Workflow.fromJSON); - WorkflowRegistry.inst.add(...parsed as [Workflow, ...Workflow[]]); + const reg = WorkflowRegistry.inst; + + // Overwrite workflows with duplicate names, add workflows with unique names + for (const workflow of (JSON.parse(rsp) as any[]).map(Workflow.fromJSON) as Workflow[]) { + const idx = reg.workflows.findIndex(w => w.name === workflow.name); + if (idx === -1) { + reg.add(workflow); + } else { + reg.patch(workflow, idx); + } + } + alertDone(); }); }, EMPTY_ARR); @@ -58,15 +70,26 @@ function ImportAll(): VNode { function ImportOne(): VNode { const onClick = useCallback(() => { - showImportModal('Import workflow', json => { + const import$ = showImportModal('Import workflow', json => { try { return Workflow.fromJSON(JSON.parse(json)) ? null : 'Invalid workflow'; } catch (e) { return e.message; } - }) + }); + + import$ .subscribe(rsp => { - WorkflowRegistry.inst.add(Workflow.fromJSON(JSON.parse(rsp))!); + const reg = WorkflowRegistry.inst; + const workflow = Workflow.fromJSON(JSON.parse(rsp))!; + + // Overwrite on duplicate name + const existingIdx = reg.workflows.findIndex(w => w.name === workflow.name); + if (existingIdx === -1) { + reg.add(workflow); + } else { + reg.patch(workflow, existingIdx); + } alertDone(); }); }, EMPTY_ARR); diff --git a/src/ui/pages/new-workflow.tsx b/src/ui/pages/new-workflow.tsx index b87fd06..1ec361d 100644 --- a/src/ui/pages/new-workflow.tsx +++ b/src/ui/pages/new-workflow.tsx @@ -18,7 +18,7 @@ export default function NewWorkflow(): VNode { const onSave = useCallback((): void => { const wf = workflow.peek(); - if (!wf.isValid) { + if (!wf.isValid()) { touched.value = true; return; } diff --git a/src/ui/pages/workflows-dashboard.tsx b/src/ui/pages/workflows-dashboard.tsx index 9df440c..84c0b8f 100644 --- a/src/ui/pages/workflows-dashboard.tsx +++ b/src/ui/pages/workflows-dashboard.tsx @@ -35,7 +35,7 @@ export const WORKFLOWS_DASHBOARD_ID = autoId(); function WithWorkflows({workflows}: Pick): VNode { const [ProvideEditedWorkflow, editedWorkflow$] = useEditedWorkflowHost(); - const [ProvideActiveWorkflow] = useActiveWorkflowHost(); + const [ProvideActiveWorkflow] = useActiveWorkflowHost(WorkflowRegistry.inst.primaryExecution?.workflow); return ( @@ -208,13 +208,17 @@ function Editor(): VNode { }, [editedWorkflow$]); const onSave = useCallback(() => { const wf = editedWorkflow$.peek(); - if (!wf?.isValid) { + const { + listId: activeWorkflowId, + name: activeWorkflowName, + } = activeWorkflow$.peek()!; + + if (!wf?.isValid(activeWorkflowName)) { touched$.value = true; return; } const reg = WorkflowRegistry.inst; - const activeWorkflowId = activeWorkflow$.peek()!.listId; const idx = reg.workflows.findIndex(w => w.listId === activeWorkflowId); if (idx === -1) { @@ -231,7 +235,7 @@ function Editor(): VNode { return ( }> - + {'Cancel'} diff --git a/types/melvor/game/core.d.ts b/types/melvor/game/core.d.ts index 0ab64e1..6592b24 100644 --- a/types/melvor/game/core.d.ts +++ b/types/melvor/game/core.d.ts @@ -249,5 +249,9 @@ export class Game { checkRequirement(req: Requirement): boolean; + public clearActiveAction(save?: boolean): void; + public stopActiveAction(): void; + + tick(): void; } diff --git a/types/melvor/global.d.ts b/types/melvor/global.d.ts index c512768..3daf58c 100644 --- a/types/melvor/global.d.ts +++ b/types/melvor/global.d.ts @@ -4,6 +4,7 @@ declare const Skill: typeof import('./index').Skill; declare const Player: typeof import('./index').Player; declare const PetManager: typeof import('./index').PetManager; declare const Bank: typeof import('./index').Bank; +declare const Game: typeof import('./index').Game; declare const NamespaceRegistry: typeof import('./index').NamespaceRegistry; declare const AttackTypeID: typeof import('./index').AttackTypeID; declare const CombatManager: typeof import('./index').CombatManager;