From ed32079dda5ef73d2a900c601cbb149235f20fab Mon Sep 17 00:00:00 2001 From: robojumper Date: Sat, 2 Mar 2024 16:39:13 +0100 Subject: [PATCH 01/10] Consolidate logic loading, use typesafe settings --- .eslintignore | 1 + package.json | 1 + src/ImportExport.tsx | 18 +-- src/Options.tsx | 157 +++++++++------------ src/TrackerContainer.tsx | 13 +- src/itemTracker/DungeonTracker.tsx | 4 +- src/logic/Inventory.ts | 14 +- src/logic/Locations.ts | 66 ++------- src/logic/Logic.ts | 13 +- src/logic/LogicHelper.ts | 17 ++- src/logic/LogicLoader.ts | 30 +++- src/logic/LogicTweaks.ts | 16 +-- src/permalink/GeneratedSettings.ts | 143 +++++++++++++++++++ src/permalink/Settings.ts | 211 ++++++++++------------------- src/permalink/SettingsTypes.ts | 70 +++------- src/selectors/Dungeons.ts | 8 +- src/state/Tracker.ts | 4 +- tools/generateOptions.mjs | 59 ++++++++ tools/tsconfig.json | 19 +++ 19 files changed, 461 insertions(+), 403 deletions(-) create mode 100644 .eslintignore create mode 100644 src/permalink/GeneratedSettings.ts create mode 100644 tools/generateOptions.mjs create mode 100644 tools/tsconfig.json diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..3d29a9ca --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +tools/* \ No newline at end of file diff --git a/package.json b/package.json index 2379a143..235a4973 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "build": "react-scripts build", "test": "react-scripts test", "eject": "react-scripts eject", + "generate:options": "node ./tools/generateOptions.mjs", "lint": "npm run lint:ts && npm run lint:eslint", "lint:eslint": "eslint --max-warnings 0 \"src/**/*.ts\" \"src/**/*.js\" \"src/**/*.tsx\"", "lint:ts": "tsc --noEmit" diff --git a/src/ImportExport.tsx b/src/ImportExport.tsx index 4c509f55..f6f2782e 100644 --- a/src/ImportExport.tsx +++ b/src/ImportExport.tsx @@ -1,10 +1,11 @@ import { ChangeEvent } from 'react'; import { TrackerState, loadTracker } from './state/Tracker'; -import Settings from './permalink/Settings'; +import { defaultSettings } from './permalink/Settings'; import Logic from './logic/Logic'; import { AppDispatch, RootState } from './state/Store'; import { loadLogic } from './state/Logic'; import { useDispatch, useSelector } from 'react-redux'; +import LogicLoader from './logic/LogicLoader'; export interface ExportState { state: TrackerState; @@ -16,22 +17,17 @@ async function importState(importedState: ExportState, dispatch: AppDispatch) { const source = importedState.source if (!source) { alert('invalid source'); + return; } - const settings = new Settings(); - if (importedState.state.settings) { - settings.loadFrom(importedState.state.settings); - } else { - await settings.init(source); - importedState.state.settings = settings; - } + const { requirements, locations, hints, options } = await LogicLoader.loadLogicFiles(source); - const logic = new Logic(); - await logic.initialize(settings, source); + importedState.state.settings ??= defaultSettings(options); + const logic = new Logic(requirements, locations, hints, importedState.state.settings); const state = importedState.state; dispatch(loadTracker(state)); - dispatch(loadLogic({ logic, options: settings.allOptions, source })); + dispatch(loadLogic({ logic, options, source })); } export default function ImportExport() { diff --git a/src/Options.tsx b/src/Options.tsx index 29ddf2db..f3781837 100644 --- a/src/Options.tsx +++ b/src/Options.tsx @@ -4,11 +4,13 @@ import { import React, { CSSProperties, ChangeEvent } from 'react'; import './options.css'; import { Link } from 'react-router-dom'; -import Settings from './permalink/Settings'; import Acknowledgement from './Acknowledgment'; -import { RawOptions } from './permalink/SettingsTypes'; +import { Option, OptionsCommand, Settings } from './permalink/SettingsTypes'; +import { decodePermalink, defaultSettings, encodePermalink } from './permalink/Settings'; +import LogicLoader from './logic/LogicLoader'; interface State { + options: Option[]; settings: Settings; ready: boolean; latestVersion: string; @@ -24,10 +26,23 @@ export default class Options extends React.Component, Stat changeTriforceRequired: () => void; changeSkywardStrike: () => void; + changeStartingTablets: (ev: ChangeEvent) => void; + changeEntranceRando: (ev: ChangeEvent) => void; + changeStartingSword: (ev: ChangeEvent) => void; + changeThunderhead: (ev: ChangeEvent) => void; + changeLMF: (ev: ChangeEvent) => void; + changeTriforceShuffle: (ev: ChangeEvent) => void; + constructor(props: Record) { super(props); this.state = { - settings: new Settings(), + // With ready === false, we don't do anything, + // so we lie about the types for convenience. Eventually + // this will be connected to Redux + // @ts-ignore + settings: undefined, + // @ts-ignore + options: undefined, ready: false, latestVersion: '', source: 'main', @@ -38,37 +53,27 @@ export default class Options extends React.Component, Stat const latestVersion = value[0].tag_name; this.setState({ source: latestVersion, latestVersion }) }); - /* - _.forEach(regions, (region) => { - this[_.camelCase(`changeRegion${region.internal}`)] = this.changeBannedLocation.bind(this, region.internal); - }); - _.forEach(types, (type) => { - this[_.camelCase(`changeType${type.internal}`)] = this.changeBannedLocation.bind(this, type.internal); - }); - _.forEach(cubes, (cube) => { - this[_.camelCase(`changeCube${cube.internal}`)] = this.changeBannedLocation.bind(this, cube.internal); - }); - */ this.changeBinaryOption = this.changeBinaryOption.bind(this); - this.changeStartingTablets = this.changeStartingTablets.bind(this); - this.changeEntranceRando = this.changeEntranceRando.bind(this); - this.changeShopsanity = this.changeBinaryOption.bind(this, 'Shopsanity'); - this.changeTadtonesanity = this.changeBinaryOption.bind(this, 'Tadtonesanity'); - this.changeRupeesanity = this.changeBinaryOption.bind(this, 'Rupeesanity'); - // this.changeGoddess = this.changeBannedLocation.bind(this, 'goddess'); - this.changeStartingSword = this.changeStartingSword.bind(this); - this.changeRaceMode = this.changeBinaryOption.bind(this, 'Empty Unrequired Dungeons'); - this.changeThunderhead = this.changeThunderhead.bind(this); - this.changeLMF = this.changeLMF.bind(this); - this.changeTriforceRequired = this.changeBinaryOption.bind(this, 'Triforce Required'); - this.changeTriforceShuffle = this.changeTriforceShuffle.bind(this); - this.changeSkywardStrike = this.changeBinaryOption.bind(this, 'Upgraded Skyward Strike'); + this.changeStringOption = this.changeStringOption.bind(this); + + this.changeStartingTablets = this.changeIntOption.bind(this, 'starting-tablet-count'); + this.changeEntranceRando = this.changeStringOption.bind(this, 'randomize-entrances'); + this.changeShopsanity = this.changeBinaryOption.bind(this, 'shopsanity'); + this.changeTadtonesanity = this.changeBinaryOption.bind(this, 'tadtonesanity'); + this.changeRupeesanity = this.changeBinaryOption.bind(this, 'rupeesanity'); + this.changeStartingSword = this.changeStringOption.bind(this, 'starting-sword'); + this.changeRaceMode = this.changeBinaryOption.bind(this, 'empty-unrequired-dungeons'); + this.changeThunderhead = this.changeStringOption.bind(this, 'open-thunderhead'); + this.changeLMF = this.changeStringOption.bind(this, 'open-lmf'); + this.changeTriforceRequired = this.changeBinaryOption.bind(this, 'triforce-required'); + this.changeTriforceShuffle = this.changeStringOption.bind(this, 'triforce-shuffle'); + this.changeSkywardStrike = this.changeBinaryOption.bind(this, 'upgraded-skyward-strike'); + this.permalinkChanged = this.permalinkChanged.bind(this); this.updateSource = this.updateSource.bind(this); - this.state.settings.init(this.state.source).then(() => { - this.state.settings.loadDefaults(); - this.setState({ ready: true }); + LogicLoader.loadLogicFiles(this.state.source).then(({ options }) => { + this.setState({ ready: true, options, settings: defaultSettings(options) }); }); } @@ -78,71 +83,39 @@ export default class Options extends React.Component, Stat return await releaseData.json() as { tag_name: string }[]; } - changeBinaryOption(option: keyof RawOptions) { - // for some reason this correct method of setting state does not work correctly in our case - // as such we must revert to the incorrect method which may result in unexpected/undefined behavior - // also need to disable the eslint error for it to allow the code to compile - // this.setState((prevState) => { - // const newstate = prevState.options; - // newstate[option] = !prevState.options[option]; - // return { options: newstate }; - // }); - // eslint-disable-next-line react/no-access-state-in-setstate - this.state.settings.toggleOption(option); - this.forceUpdate(); - } - - changeStartingTablets(e: ChangeEvent) { - const { value } = e.target; - this.state.settings.setOption('Starting Tablet Count', parseInt(value, 10)); - this.forceUpdate(); - } - - changeEntranceRando(e: ChangeEvent) { - const { value } = e.target; - this.state.settings.setOption('Randomize Entrances', value); - this.forceUpdate(); - } - - changeThunderhead(e: ChangeEvent) { - const { value } = e.target; - this.state.settings.setOption('Open Thunderhead', value); - this.forceUpdate(); - } - - changeLMF(e: ChangeEvent) { - const { value } = e.target; - this.state.settings.setOption('Open Lanayru Mining Facility', value); - this.forceUpdate(); + changeBinaryOption(option: OptionsCommand) { + this.setState((prevState) => { + return { settings: { ...prevState.settings, [option]: !prevState.settings[option] } satisfies Settings }; + }); } - changeTriforceShuffle(e: ChangeEvent) { + changeIntOption(option: OptionsCommand, e: ChangeEvent) { const { value } = e.target; - this.state.settings.setOption('Triforce Shuffle', value); - this.forceUpdate(); + this.setState((prevState) => { + return { settings: { ...prevState.settings, [option]: parseInt(value, 10) } satisfies Settings }; + }); } - changeStartingSword(e: ChangeEvent) { + changeStringOption(option: OptionsCommand, e: ChangeEvent) { const { value } = e.target; - this.state.settings.setOption('Starting Sword', value); - this.forceUpdate(); + this.setState((prevState) => { + return { settings: { ...prevState.settings, [option]: value } satisfies Settings }; + }); } permalinkChanged(e: ChangeEvent) { try { - this.state.settings.updateFromPermalink(e.target.value); + const settings = decodePermalink(this.state.options, e.target.value); + this.setState({ settings }) } catch (err) { // squash the error for now } - this.forceUpdate(); } updateSource(e: ChangeEvent) { const { value } = e.target; - this.state.settings.init(value).then(() => { - this.state.settings.loadDefaults(); - this.setState({ source: value }); - this.forceUpdate(); + LogicLoader.loadLogicFiles(value).then(({ options }) => { + this.setState({ source: value, options, settings: defaultSettings(options) }); }); } @@ -177,7 +150,7 @@ export default class Options extends React.Component, Stat
@@ -209,7 +182,7 @@ export default class Options extends React.Component, Stat type="switch" label="Shuffle Beedle's Shop" id="shopsanity" - checked={this.state.settings.getOption('Shopsanity')} + checked={this.state.settings['shopsanity']} onChange={this.changeShopsanity} /> @@ -218,7 +191,7 @@ export default class Options extends React.Component, Stat type="switch" label="Rupeesanity" id="rupeesanity" - checked={this.state.settings.getOption('Rupeesanity') as boolean} + checked={this.state.settings['rupeesanity']} onChange={this.changeRupeesanity} /> @@ -227,7 +200,7 @@ export default class Options extends React.Component, Stat type="switch" label="Tadtonesanity" id="tadtonesanity" - checked={this.state.settings.getOption('Tadtonesanity')} + checked={this.state.settings['tadtonesanity']} onChange={this.changeTadtonesanity} /> @@ -247,7 +220,7 @@ export default class Options extends React.Component, Stat as="select" id="entranceRandoOptions" onChange={this.changeEntranceRando} - value={this.state.settings.getOption('Randomize Entrances')} + value={this.state.settings['randomize-entrances']} > @@ -269,7 +242,7 @@ export default class Options extends React.Component, Stat as="select" id="startingSword" onChange={this.changeStartingSword} - value={this.state.settings.getOption('Starting Sword')} + value={this.state.settings['starting-sword']} > @@ -294,7 +267,7 @@ export default class Options extends React.Component, Stat as="select" id="startingTabletCounter" onChange={this.changeStartingTablets} - value={this.state.settings.getOption('Starting Tablet Count')} + value={this.state.settings['starting-tablet-count']} > @@ -318,7 +291,7 @@ export default class Options extends React.Component, Stat as="select" id="openThunderhead" onChange={this.changeThunderhead} - value={this.state.settings.getOption('Open Thunderhead')} + value={this.state.settings['open-thunderhead']} > @@ -338,7 +311,7 @@ export default class Options extends React.Component, Stat as="select" id="openLMF" onChange={this.changeLMF} - value={this.state.settings.getOption('Open Lanayru Mining Facility')} + value={this.state.settings['open-lmf']} > @@ -353,7 +326,7 @@ export default class Options extends React.Component, Stat type="switch" label="Upgraded Skyward Strike" id="skyward-strike" - checked={this.state.settings.getOption('Upgraded Skyward Strike')} + checked={this.state.settings['upgraded-skyward-strike']} onChange={this.changeSkywardStrike} /> @@ -364,7 +337,7 @@ export default class Options extends React.Component, Stat type="switch" label="Empty Unrequired Dungeons" id="racemode" - checked={this.state.settings.getOption('Empty Unrequired Dungeons')} + checked={this.state.settings['empty-unrequired-dungeons']} onChange={this.changeRaceMode} /> @@ -373,7 +346,7 @@ export default class Options extends React.Component, Stat type="switch" label="Triforce Required" id="triforceRequired" - checked={this.state.settings.getOption('Triforce Required')} + checked={this.state.settings['triforce-required']} onChange={this.changeTriforceRequired} /> @@ -388,7 +361,7 @@ export default class Options extends React.Component, Stat as="select" id="triforceShuffle" onChange={this.changeTriforceShuffle} - value={this.state.settings.getOption('Triforce Shuffle')} + value={this.state.settings['triforce-shuffle']} > @@ -402,7 +375,7 @@ export default class Options extends React.Component, Stat - + diff --git a/src/TrackerContainer.tsx b/src/TrackerContainer.tsx index 40350a7b..e5079490 100644 --- a/src/TrackerContainer.tsx +++ b/src/TrackerContainer.tsx @@ -5,11 +5,12 @@ import { isLogicLoadedSelector, loadingErrorSelector, } from './selectors/LogicInput'; -import Settings from './permalink/Settings'; +import { decodePermalink } from './permalink/Settings'; import Logic from './logic/Logic'; import Tracker from './Tracker'; import { parseError } from './utils/Error'; import { reset } from './state/Tracker'; +import LogicLoader from './logic/LogicLoader'; export default function TrackerContainer() { const dispatch = useDispatch(); @@ -19,16 +20,14 @@ export default function TrackerContainer() { async (source: string) => { setLoadingSource(source); try { - const settings = new Settings(); - await settings.init(source); + const { requirements, locations, hints, options } = await LogicLoader.loadLogicFiles(source); const path = new URLSearchParams(window.location.search); const permalink = decodeURIComponent(path.get('options')!); - settings.updateFromPermalink(permalink); - const logic = new Logic(); - await logic.initialize(settings, source); + const settings = decodePermalink(options, permalink); + const logic = new Logic(requirements, locations, hints, settings); dispatch(reset({ settings })); dispatch( - loadLogic({ logic, options: settings.allOptions, source }), + loadLogic({ logic, options, source }), ); } catch (e) { dispatch(setLoadingError({ error: parseError(e) })); diff --git a/src/itemTracker/DungeonTracker.tsx b/src/itemTracker/DungeonTracker.tsx index 79d7e843..030969d2 100644 --- a/src/itemTracker/DungeonTracker.tsx +++ b/src/itemTracker/DungeonTracker.tsx @@ -39,8 +39,8 @@ export default function DungeonTracker(props: DungeonTrackerProps) { const settings = useSelector(settingsSelector); const skyKeep = useSelector(skyKeepShownSelector); - const entranceRando = settings.getOption('Randomize Entrances'); - const trialRando = settings.getOption('Randomize Silent Realms'); + const entranceRando = settings['randomize-entrances']; + const trialRando = settings['randomize-trials']; useResizeObserver(divElement, () => { const elem = divElement.current; diff --git a/src/logic/Inventory.ts b/src/logic/Inventory.ts index a243c445..45d70f45 100644 --- a/src/logic/Inventory.ts +++ b/src/logic/Inventory.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; -import Settings from '../permalink/Settings'; import { TrackerState } from '../state/Tracker'; +import { Settings } from '../permalink/SettingsTypes'; export const itemMaxes = { 'Progressive Sword': 6, @@ -69,7 +69,7 @@ export function getInitialItems(settings: Settings): TrackerState['inventory'] { startingItems[item]! += 1; }; addItem('Sailcloth'); - if (settings.getOption('Starting Tablet Count') === 3) { + if (settings['starting-tablet-count'] === 3) { addItem('Emerald Tablet'); addItem('Ruby Tablet'); addItem('Amber Tablet'); @@ -77,26 +77,26 @@ export function getInitialItems(settings: Settings): TrackerState['inventory'] { for ( let crystalPacksAdded = 0; crystalPacksAdded < - settings.getOption('Starting Gratitude Crystal Packs'); + settings['starting-crystal-packs']; crystalPacksAdded++ ) { addItem('Gratitude Crystal Pack'); } for ( let tadtonesAdded = 0; - tadtonesAdded < settings.getOption('Starting Tadtone Count'); + tadtonesAdded < settings['starting-tadtones']; tadtonesAdded++ ) { addItem('Group of Tadtones'); } for ( let bottlesAdded = 0; - bottlesAdded < settings.getOption('Starting Empty Bottles'); + bottlesAdded < settings['starting-bottles']; bottlesAdded++ ) { addItem('Empty Bottle'); } - const startingSword = settings.getOption('Starting Sword'); + const startingSword = settings['starting-sword']; if (!(startingSword === 'Swordless')) { const swordsToAdd: Record = { 'Practice Sword': 1, @@ -115,7 +115,7 @@ export function getInitialItems(settings: Settings): TrackerState['inventory'] { addItem('Progressive Sword'); } } - _.forEach(settings.getOption('Starting Items'), (item) => { + _.forEach(settings['starting-items'], (item) => { if (item.includes('Song of the Hero')) { addItem('Song of the Hero'); } else if (item.includes('Triforce')) { diff --git a/src/logic/Locations.ts b/src/logic/Locations.ts index 6d616bc9..e237b0b4 100644 --- a/src/logic/Locations.ts +++ b/src/logic/Locations.ts @@ -1,7 +1,7 @@ import _ from 'lodash'; -import Settings from '../permalink/Settings'; import ItemLocation from './ItemLocation'; import potentialBannedLocations_ from '../data/potentialBannedLocations.json'; +import { Settings } from '../permalink/SettingsTypes'; const potentialBannedLocations: { [area: string]: { [locationName: string]: { requiredDungeon: string } }; @@ -43,13 +43,13 @@ export function createIsCheckBannedPredicate( requiredDungeons: string[], ) { return ({ id, area, name, rawType: loctype }: ItemLocation) => { - const bannedLocations = settings.getOption('Excluded Locations'); + const bannedLocations = settings['excluded-locations']; if (bannedLocations.includes(id)) { return true; } - if (settings.getOption('Empty Unrequired Dungeons')) { + if (settings['empty-unrequired-dungeons']) { const potentialBanReason = potentialBannedLocations[area]?.[name]; if (potentialBanReason && !requiredDungeons.includes(potentialBanReason.requiredDungeon)) { @@ -57,8 +57,8 @@ export function createIsCheckBannedPredicate( } } - let maxRelics = settings.getOption('Trial Treasure Amount'); - if (!settings.getOption('Treasuresanity in Silent Realms')) { + let maxRelics = settings['trial-treasure-amount']; + if (!settings['treasuresanity-in-silent-realms']) { maxRelics = 0; } if ( @@ -68,9 +68,7 @@ export function createIsCheckBannedPredicate( return true; } - const emptyUnrequiredDungeons = settings.getOption( - 'Empty Unrequired Dungeons', - ); + const emptyUnrequiredDungeons = settings['empty-unrequired-dungeons']; if ( emptyUnrequiredDungeons && (isDungeon(area)) && @@ -79,70 +77,32 @@ export function createIsCheckBannedPredicate( return true; } - // old 1.4.1 options - const shopMode = settings.getOption('Shop Mode'); - const batMode = settings.getOption('Max Batreaux Reward'); if (loctype !== null) { // have to specifically check Shopsanity being false, otherwise it being null on new versions disables Beedle if ( - (settings.getOption('Shopsanity') === false && + (settings['shopsanity'] === false && loctype.includes("Beedle's Shop Purchases")) || - (!settings.getOption('Rupeesanity') && + (!settings['rupeesanity'] && loctype.includes('Rupees')) || - (!settings.getOption('Tadtonesanity') && + (!settings['tadtonesanity'] && loctype.includes('Tadtones') && name !== "Water Dragon's Reward") ) { return true; } - // 1.4.1 rupeesanity & shopsanity compatibility - if ( - settings.getOption('Rupeesanity') === 'Vanilla' && - loctype.includes('Rupees') - ) { - return true; - } - if ( - shopMode !== undefined && - loctype.includes("Beedle's Shop Purchases") - ) { - if (shopMode === 'Vanilla') { - return true; - } - if ( - shopMode.includes('Cheap') && - parseInt(name.replace(/^\D+/g, ''), 10) > 300 - ) { - return true; - } - if ( - shopMode.includes('Medium') && - parseInt(name.replace(/^\D+/g, ''), 10) > 1000 - ) { - return true; - } - } // Post-shop split compatibility // have to specifically check Beedle Shopsanity being false, otherwise it being null on old versions disables Beedle if ( - (settings.getOption('Beedle Shopsanity') === false && + (settings['beedle-shopsanity'] === false && loctype.includes("Beedle's Shop")) || - (!settings.getOption('Gear Shopsanity') && + (!settings['rupin-shopsanity'] && loctype.includes('Gear Shop')) || - (!settings.getOption('Potion Shopsanity') && - loctype.includes('Potion Shop')) + (!settings['luv-shopsanity']) && + loctype.includes('Potion Shop') ) { return true; } } - // Must check this outside the loctype block because Batreaux checks have no type. 1.4.1 batreaux compatibility - if ( - batMode !== undefined && - area.includes('Batreaux') && - parseInt(name.replace(/^\D+/g, ''), 10) > batMode - ) { - return true; - } }; } diff --git a/src/logic/Logic.ts b/src/logic/Logic.ts index d0327ac3..8c5a0ea5 100644 --- a/src/logic/Logic.ts +++ b/src/logic/Logic.ts @@ -1,21 +1,18 @@ import _ from 'lodash'; -import LogicLoader from './LogicLoader'; import LogicHelper from './LogicHelper'; import Requirements from './Requirements'; import LogicTweaks from './LogicTweaks'; import goddessCubes from '../data/goddessCubes.json'; import ItemLocation from './ItemLocation'; import crystalLocations from '../data/crystals.json'; -import logicFileNames from '../data/logicModeFiles.json'; -import Settings from '../permalink/Settings'; import BooleanExpression from './BooleanExpression'; import { completionRequirementToDungeon, splitLocationName } from './Locations'; import { InventoryItem, isItem } from './Inventory'; +import { RawHints, RawLocations, RawRequirements } from './UpstreamTypes'; +import { Settings } from '../permalink/SettingsTypes'; class Logic { - // @ts-expect-error ts(2564) settings: Settings; - // @ts-expect-error ts(2564) requirements: Requirements; locations: Record> = {}; @@ -23,11 +20,9 @@ class Logic { cubeList: Record = {}; crystalList: Record = {}; - - async initialize(settings: Settings, source: string) { + + constructor(requirements: RawRequirements, locations: RawLocations, hints: RawHints, settings: Settings) { this.settings = settings; - console.log(settings.getOption('Logic Mode')); - const { requirements, locations, hints } = await LogicLoader.loadLogicFiles(_.get(logicFileNames, settings.getOption('Logic Mode')), source); this.requirements = new Requirements(requirements); _.forEach(locations, (data, id) => { diff --git a/src/logic/LogicHelper.ts b/src/logic/LogicHelper.ts index c4b7a551..1f56b946 100644 --- a/src/logic/LogicHelper.ts +++ b/src/logic/LogicHelper.ts @@ -1,9 +1,8 @@ import _ from 'lodash'; import BooleanExpression, { Op, ReducerArg } from './BooleanExpression'; import prettytemNames from '../data/prettyItemNames.json'; -import { RawOptions } from '../permalink/SettingsTypes'; import Requirements from './Requirements'; -import Settings from '../permalink/Settings'; +import { OptionValue, OptionsCommand, Settings } from '../permalink/SettingsTypes'; type NestedArray = (T | NestedArray)[]; @@ -255,7 +254,7 @@ class LogicHelper { static checkOptionEnabledRequirement(requirement: string) { const matchers: { regex: RegExp, - value: (optionValue: string, expectedValue: string) => boolean + value: (optionValue: OptionValue, expectedValue: string) => boolean }[] = [ { regex: /^Option "([^"]+)" Enabled$/, @@ -272,7 +271,7 @@ class LogicHelper { // special case for integers after 'Is' { regex: /^Option "([^"]+)" Is ([^"]+)$/, - value: (optionValue, expectedValue) => parseInt(optionValue, 10) === parseInt(expectedValue, 10), + value: (optionValue, expectedValue) => optionValue === parseInt(expectedValue, 10), }, { regex: /^Option "([^"]+)" Is Not "([^"]+)"$/, @@ -280,11 +279,11 @@ class LogicHelper { }, { regex: /^Option "([^"]+)" Contains "([^"]+)"$/, - value: (optionValue, expectedValue) => optionValue.includes(expectedValue), + value: (optionValue, expectedValue) => (Array.isArray(optionValue) || typeof optionValue === 'string') && optionValue.includes(expectedValue), }, { regex: /^Option "([^"]+)" Does Not Contain "([^"]+)"$/, - value: (optionValue, expectedValue) => !optionValue.includes(expectedValue), + value: (optionValue, expectedValue) => (Array.isArray(optionValue) || typeof optionValue === 'string') && !optionValue.includes(expectedValue), }, ]; @@ -293,10 +292,10 @@ class LogicHelper { _.forEach(matchers, (matcher) => { const requirementMatch = requirement.match(matcher.regex); if (requirementMatch) { - const optionName = requirementMatch[1] as keyof RawOptions; - const optionValue = this.settings.getOption(optionName) as string; + const option = requirementMatch[1] as OptionsCommand; + const optionValue = this.settings[option]; const expectedValue = requirementMatch[2]; - optionEnabledRequirementValue = matcher.value(optionValue, expectedValue); + optionEnabledRequirementValue = optionValue !== undefined && matcher.value(optionValue, expectedValue); return false; // break loop } diff --git a/src/logic/LogicLoader.ts b/src/logic/LogicLoader.ts index d1edd589..a3acf234 100644 --- a/src/logic/LogicLoader.ts +++ b/src/logic/LogicLoader.ts @@ -1,12 +1,32 @@ import yaml from 'js-yaml'; import { RawHints, RawLocations, RawRequirements } from './UpstreamTypes'; +import { MultiChoiceOption, Option } from '../permalink/SettingsTypes'; +import _ from 'lodash'; class LogicLoader { - static async loadLogicFiles(logicFile: string, branch: string) { - const requirements = await LogicLoader.loadLogicFile(logicFile, branch); - const locations = await LogicLoader.loadLogicFile('checks.yaml', branch); - const hints = await LogicLoader.loadLogicFile('hints.yaml', branch); - return { requirements, locations, hints }; + static async loadLogicFiles(branch: string) { + const [ + requirements, + locations, + hints, + options + ] = await Promise.all([ + LogicLoader.loadLogicFile('SS Rando Logic - Glitchless Requirements.yaml', branch), + LogicLoader.loadLogicFile('checks.yaml', branch), + LogicLoader.loadLogicFile('hints.yaml', branch), + LogicLoader.loadLogicFile('options.yaml', branch), + ]); + + // correctly load the choices for excluded locations + const excludedLocs = options.find( + (x) => x.command === 'excluded-locations', + )! as MultiChoiceOption; + excludedLocs.choices = []; + _.forEach(locations, (_data, location) => { + excludedLocs.choices.push(location); + }); + + return { requirements, locations, hints, options }; } static async loadLogicFile(file: string, branch: string) { diff --git a/src/logic/LogicTweaks.ts b/src/logic/LogicTweaks.ts index 1f6c4cab..a94d16b4 100644 --- a/src/logic/LogicTweaks.ts +++ b/src/logic/LogicTweaks.ts @@ -1,23 +1,23 @@ import _ from 'lodash'; import goddessCubes from '../data/goddessCubes.json'; import crystalMacros from '../data/gratitudeCrystalMacros.json'; -import type Settings from '../permalink/Settings'; +import type { Settings } from '../permalink/SettingsTypes'; import type Requirements from './Requirements'; // import LogicHelper from './LogicHelper'; class LogicTweaks { static applyTweaks(requirements: Requirements, settings: Settings) { - LogicTweaks.createDungeonMacros(requirements, settings.getOption('Randomize Entrances')); - LogicTweaks.createTrialMacros(requirements, settings.getOption('Randomize Silent Realms')); + LogicTweaks.createDungeonMacros(requirements, settings['randomize-entrances']); + LogicTweaks.createTrialMacros(requirements, settings['randomize-trials']); LogicTweaks.tweakTMSAndRequiredDungeons(requirements); LogicTweaks.tweakGoddessChestRequirements(requirements); LogicTweaks.tweakGratitudeCrystalRequirements(requirements); - if (!settings.getOption('Randomize Silent Realms')) { + if (!settings['randomize-trials']) { LogicTweaks.tweakSoTH(requirements); } } - static createDungeonMacros(requirements: Requirements, entrancesRandomized: string) { + static createDungeonMacros(requirements: Requirements, entrancesRandomized: Settings['randomize-entrances']) { if (entrancesRandomized === 'None') { // no entrance randomizer, sub default requirements in requirements.set('Can Access Skyview', 'Can Access Dungeon Entrance in Deep Woods'); @@ -43,7 +43,7 @@ class LogicTweaks { } } - static createTrialMacros(requirements: Requirements, entrancesRandomized: boolean) { + static createTrialMacros(requirements: Requirements, entrancesRandomized: Settings['randomize-trials']) { if (entrancesRandomized) { // entrances are shuffled, create fake items for the tracker requirements.set('Can Access Skyloft Silent Realm', 'Entered Skyloft Silent Realm'); @@ -91,9 +91,7 @@ class LogicTweaks { settings: Settings, requiredDungeons: string[], ): string { - let newReqs = `Can Access Sealed Temple & Goddess's Harp & ${settings.getOption( - 'Gate of Time Sword Requirement', - )} & `; + let newReqs = `Can Access Sealed Temple & Goddess's Harp & ${settings['got-sword-requirement']} & `; _.forEach(requiredDungeons, (dungeon) => { if (dungeon !== 'Sky Keep') { newReqs += `${dungeon} Completed & `; diff --git a/src/permalink/GeneratedSettings.ts b/src/permalink/GeneratedSettings.ts new file mode 100644 index 00000000..c41ff80d --- /dev/null +++ b/src/permalink/GeneratedSettings.ts @@ -0,0 +1,143 @@ +/** + * DO NOT MANUALLY EDIT! + * Automatically generated option types based on ssrando options.yaml + * Run `npm run generate:options` to regenerate. + */ +export interface GeneratedSettings { + /** Starting Tablet Count */ + 'starting-tablet-count': number; + /** Open Thunderhead */ + 'open-thunderhead': 'Ballad' | 'Open'; + /** Starting Sword */ + 'starting-sword': 'Swordless' | 'Practice Sword' | 'Goddess Sword' | 'Goddess Longsword' | 'Goddess White Sword' | 'Master Sword' | 'True Master Sword'; + /** Required Dungeon Count */ + 'required-dungeon-count': number; + /** Imp 2 */ + 'imp2-skip': boolean; + /** Empty unrequired Dungeons */ + 'empty-unrequired-dungeons': boolean; + /** Triforce Required */ + 'triforce-required': boolean; + /** Triforce Shuffle */ + 'triforce-shuffle': 'Vanilla' | 'Sky Keep' | 'Anywhere'; + /** Randomize Entrances */ + 'randomize-entrances': 'None' | 'Required Dungeons Separately' | 'All Surface Dungeons' | 'All Surface Dungeons + Sky Keep'; + /** Randomize Silent Realms */ + 'randomize-trials': boolean; + /** No Spoiler Log */ + 'no-spoiler-log': boolean; + /** Shopsanity */ + 'shopsanity': boolean; + /** Rupoor Mode */ + 'rupoor-mode': 'Off' | 'Added' | 'Rupoor Mayhem' | 'Rupoor Insanity'; + /** Rupeesanity */ + 'rupeesanity': boolean; + /** Place Scrap Shop Upgrades */ + 'gondo-upgrades': boolean; + /** Gate of Time Starting State */ + 'got-start': 'Lowered' | 'Raised'; + /** Gate of Time Sword Requirement */ + 'got-sword-requirement': 'Goddess Sword' | 'Goddess Longsword' | 'Goddess White Sword' | 'Master Sword' | 'True Master Sword'; + /** Gate of Time Dungeon Requirements */ + 'got-dungeon-requirement': 'Required' | 'Unrequired'; + /** Open Lanayru Mining Facility */ + 'open-lmf': 'Nodes' | 'Main Node' | 'Open'; + /** Skip Horde */ + 'skip-horde': boolean; + /** Skip Ghirahim 3 */ + 'skip-g3': boolean; + /** Skip Demise */ + 'skip-demise': boolean; + /** Map Mode */ + 'map-mode': 'Removed' | 'Vanilla' | 'Own Dungeon - Restricted' | 'Own Dungeon - Unrestricted' | 'Anywhere'; + /** Small Key Mode */ + 'small-key-mode': 'Vanilla' | 'Own Dungeon - Restricted' | 'Lanayru Caves Key Only' | 'Anywhere'; + /** Boss Key Mode */ + 'boss-key-mode': 'Vanilla' | 'Own Dungeon' | 'Anywhere'; + /** Logic Mode */ + 'logic-mode': 'BiTless' | 'No Logic'; + /** Enabled Tricks BiTless */ + 'enabled-tricks-bitless': string[]; + /** Enabled Tricks Glitched */ + 'enabled-tricks-glitched': string[]; + /** Song Hints */ + 'song-hints': 'None' | 'Basic' | 'Advanced' | 'Direct'; + /** BiT Changes */ + 'bit-patches': 'Disable BiT' | 'Vanilla' | 'Fix BiT Crashes'; + /** Past Impa Stone of Trials Hint */ + 'impa-sot-hint': boolean; + /** Force Sword Dungeon Reward */ + 'sword-dungeon-reward': 'None' | 'Heart Container' | 'Final Check'; + /** Open Earth Temple */ + 'open-et': boolean; + /** Demise Count */ + 'demise-count': number; + /** Separate Cube SotS */ + 'cube-sots': boolean; + /** Precise Item Hints */ + 'precise-item': boolean; + /** Shuffle Trial Objects */ + 'shuffle-trial-objects': 'None' | 'Simple' | 'Advanced' | 'Full'; + /** Fill Dowsing on White Sword */ + 'dowsing-after-whitesword': boolean; + /** Chest Dowsing */ + 'chest-dowsing': 'Vanilla' | 'All Chests' | 'Progress Items'; + /** Allow Dowsing in Dungeons */ + 'dungeon-dowsing': boolean; + /** Excluded Locations */ + 'excluded-locations': string[]; + /** Open Lake Floria */ + 'open-lake-floria': 'Vanilla' | 'Talk to Yerbal' | 'Open'; + /** Upgraded Skyward Strike */ + 'upgraded-skyward-strike': boolean; + /** Faster Air Meter Drain */ + 'fast-air-meter': boolean; + /** Heart Drops */ + 'enable-heart-drops': boolean; + /** Damage Multiplier */ + 'damage-multiplier': number; + /** Hint Distribution */ + 'hint-distribution': 'Weak' | 'Balanced' | 'Junk' | 'Co-op S1' | 'S2 - 2D' | 'S2 - 3D' | 'S2 - 3D EUD Off' | 'CDMC' | 'Dowsing & Fi Hints' | '2D Dowsing & Fi Hints' | 'Boss Keysanity Fi Hints' | 'Strong Dowsing All Dungeons' | 'Remlits Tournament' | 'Custom'; + /** Starting Items */ + 'starting-items': string[]; + /** Starting Gratitude Crystal Packs */ + 'starting-crystal-packs': number; + /** Random Starting Item */ + 'random-starting-item': boolean; + /** Starting Empty Bottles */ + 'starting-bottles': number; + /** Start with Hylian Shield */ + 'start-with-hylian-shield': boolean; + /** Starting Heart Containers */ + 'starting-heart-containers': number; + /** Starting Heart Pieces */ + 'starting-heart-pieces': number; + /** Start with Full Wallet */ + 'full-starting-wallet': boolean; + /** Full Wallet Upgrades */ + 'full-wallet-upgrades': boolean; + /** Start with Max Bugs */ + 'max-starting-bugs': boolean; + /** Start with Max Treasures */ + 'max-starting-treasures': boolean; + /** Randomize Boss Key Puzzles */ + 'randomize-boss-key-puzzles': boolean; + /** Tadtonesanity */ + 'tadtonesanity': boolean; + /** Starting Tadtone Count */ + 'starting-tadtones': number; + /** FS Last Room Lava Flow */ + 'fs-lava-flow': boolean; + /** Peatrice Conversations */ + 'peatrice-conversations': number; + /** Random Starting Spawn */ + 'random-start-entrance': 'Vanilla' | 'Bird Statues' | 'Any Surface Region' | 'Any'; + /** Limit Starting Location */ + 'limit-start-entrance': boolean; + /** Treasuresanity in Silent Realms */ + 'treasuresanity-in-silent-realms': boolean; + /** Trial Treasure Amount */ + 'trial-treasure-amount': number; + /** Random Starting Statues */ + 'random-start-statues': boolean; +} diff --git a/src/permalink/Settings.ts b/src/permalink/Settings.ts index 9fc0fbbb..7e827136 100644 --- a/src/permalink/Settings.ts +++ b/src/permalink/Settings.ts @@ -1,153 +1,80 @@ import _ from 'lodash'; -import yaml from 'js-yaml'; import PackedBitsWriter from './PackedBitsWriter'; import PackedBitsReader from './PackedBitsReader'; -import LogicLoader from '../logic/LogicLoader'; -import { RawLocations } from '../logic/UpstreamTypes'; -import { OptionValue, MultiChoiceOption, Option, RawOptions } from './SettingsTypes'; +import { OptionValue, Option, OptionsCommand, Settings } from './SettingsTypes'; -class Settings { - options: Record = {}; - allOptions: Option[] = []; - - async init(source: string) { - this.options = {}; - this.allOptions = []; - await this.loadSettingsFromRepo(source); - } - - loadFrom(settings: Settings) { - this.options = settings.options; - this.allOptions = settings.allOptions; - } - - updateFromPermalink(permalink: string) { - const permaNoSeed = permalink.split('#')[0]; - const reader = PackedBitsReader.fromBase64(permaNoSeed); - _.forEach(this.allOptions, (option) => { - if (option.permalink !== false) { - if (option.type === 'boolean') { - this.setOption(option.name, reader.read(1) === 1); - } else if (option.type === 'int') { - this.setOption(option.name, reader.read(option.bits)); - } else if (option.type === 'multichoice') { - const values: string[] = []; - _.forEach(option.choices, (choice) => { - // console.log(`reading one bit for ${choice}`); - if (reader.read(1)) { - values.push(choice); - } - }); - this.setOption(option.name, values); - } else if (option.type === 'singlechoice') { - this.setOption( - option.name, - option.choices[reader.read(option.bits)], - ); - } +export function decodePermalink( + options: Option[], + permalink: string, +): Settings { + const permaNoSeed = permalink.split('#')[0]; + const settings: Partial> = {}; + const reader = PackedBitsReader.fromBase64(permaNoSeed); + _.forEach(options, (option) => { + if (option.permalink !== false) { + if (option.type === 'boolean') { + settings[option.command] = reader.read(1) === 1; + } else if (option.type === 'int') { + settings[option.command] = reader.read(option.bits); + } else if (option.type === 'multichoice') { + const values: string[] = []; + _.forEach(option.choices, (choice) => { + if (reader.read(1)) { + values.push(choice); + } + }); + settings[option.command] = values; + } else if (option.type === 'singlechoice') { + settings[option.command] = + option.choices[reader.read(option.bits)]; } - }); - } - - generatePermalink() { - const writer = new PackedBitsWriter(); - _.forEach(this.allOptions, (option) => { - if (option.permalink !== false) { - if (option.type === 'boolean') { - writer.write(this.getOption(option.name) ? 1 : 0, 1); - } else if (option.type === 'int') { - writer.write( - this.getOption(option.name) as number, - option.bits, - ); - } else if (option.type === 'multichoice') { - const values = [ - ...(this.getOption(option.name) as string[]), - ]; - _.forEach(option.choices, (choice) => { - writer.write(values.includes(choice) ? 1 : 0, 1); - // ensure the items are included the correct number of times - if ( - values.includes(choice) && - option.name === 'Starting Items' - ) { - values.splice(values.indexOf(choice), 1); - } - }); - } else if (option.type === 'singlechoice') { - writer.write( - option.choices.indexOf( - this.getOption(option.name) as string, - ), - option.bits, - ); - } - } - }); - writer.flush(); - return writer.toBase64(); - } - - setOption(option: K, value: RawOptions[K]) { - _.set(this.options, Settings.convertOptionKey(option), value); - } + } + }); + return settings as Settings; +} - getOption(option: K): RawOptions[K] { - const optionKey = Settings.convertOptionKey(option); - if (optionKey === 'enabledTricks') { - const bitlessTricks = this.getOption( - 'Enabled Tricks BiTless', - ); - if (bitlessTricks.length > 0) { - return bitlessTricks as RawOptions[K]; - } - const glitchedTricks = this.getOption( - 'Enabled Tricks Glitched', - ); - if (glitchedTricks.length > 0) { - return glitchedTricks as RawOptions[K]; +export function encodePermalink( + options: Option[], + settings: Settings, +): string { + const writer = new PackedBitsWriter(); + _.forEach(options, (option) => { + if (option.permalink !== false) { + if (option.type === 'boolean') { + writer.write(settings[option.command] ? 1 : 0, 1); + } else if (option.type === 'int') { + writer.write(settings[option.command] as number, option.bits); + } else if (option.type === 'multichoice') { + const values = [...(settings[option.command] as string[])]; + _.forEach(option.choices, (choice) => { + writer.write(values.includes(choice) ? 1 : 0, 1); + // ensure the items are included the correct number of times + if ( + values.includes(choice) && + option.command === 'starting-items' + ) { + values.splice(values.indexOf(choice), 1); + } + }); + } else if (option.type === 'singlechoice') { + writer.write( + option.choices.indexOf(settings[option.command] as string), + option.bits, + ); } - // there are no enabled tricks so we can just return an empty array - return [] as string[] as RawOptions[K]; } - return _.get(this.options, Settings.convertOptionKey(option)) as RawOptions[K]; - } - - loadDefaults() { - _.forEach(this.allOptions, (option) => { - this.setOption(option.name, option.default); - }); - } - - toggleOption(option: keyof RawOptions) { - this.setOption(option, !this.getOption(option)); - } + }); + writer.flush(); + return writer.toBase64(); +} - static convertOptionKey(option: string) { - return _.camelCase(option); - } - async loadSettingsFromRepo(branch: string) { - const response = await fetch( - `https://raw.githubusercontent.com/ssrando/ssrando/${branch}/options.yaml`, - ); - const text = await response.text(); - this.allOptions = (await yaml.load(text)) as Option[]; - // correctly load the choices for excluded locations - const excludedLocsIndex = this.allOptions.findIndex( - (x) => x.name === 'Excluded Locations', - ); - const checks = (await LogicLoader.loadLogicFile( - 'checks.yaml', - branch, - )); - (this.allOptions[excludedLocsIndex] as MultiChoiceOption).choices = []; - _.forEach(checks, (_data, location) => { - ( - this.allOptions[excludedLocsIndex] as MultiChoiceOption - ).choices.push(location); - }); - } +export function defaultSettings(options: Option[]): Settings { + const settings: Partial> = {}; + _.forEach(options, (option) => { + if (option.permalink !== false) { + settings[option.command] = option.default; + } + }); + return settings as Settings; } - -export default Settings; diff --git a/src/permalink/SettingsTypes.ts b/src/permalink/SettingsTypes.ts index 246642c4..4ee05d02 100644 --- a/src/permalink/SettingsTypes.ts +++ b/src/permalink/SettingsTypes.ts @@ -1,6 +1,11 @@ +import { GeneratedSettings } from "./GeneratedSettings"; + +export type OptionsCommand = keyof Settings; + export type BaseOption = { permalink: boolean; - name: keyof RawOptions; + name: string; + command: OptionsCommand; }; export type BooleanOption = BaseOption & { @@ -36,54 +41,17 @@ export type Option = export type OptionValue = string | string[] | number | boolean; export type OptionType = Option['type']; -export type RawOptions = { - 'Logic Mode': 'BiTless' | 'Glitched'; - - Rupeesanity: boolean | string; - Tadtonesanity: boolean; - 'Randomize Entrances': string; - 'Starting Sword': string; - 'Starting Tabled Count': number; - 'Open Thunderhead': string; - 'Open Earth Temple': boolean; - 'Open Lanayru Mining Facility': string; - 'Upgraded Skyward Strike': boolean; - 'Empty Unrequired Dungeons': boolean; - 'Triforce Required': boolean; - 'Triforce Shuffle': string; - - 'Starting Tablet Count': number; - 'Starting Gratitude Crystal Packs': number; - 'Starting Tadtone Count': number; - 'Starting Empty Bottles': number; - 'Starting Items': string[]; - - 'Randomize Silent Realms': boolean - 'Treasuresanity in Silent Realms': boolean; - 'Trial Treasure Amount': number; - - - 'Excluded Locations': string[]; - 'Enabled Tricks': string[]; - 'Enabled Tricks BiTless': string[]; - 'Enabled Tricks Glitched': string[]; - - 'Gate of Time Sword Requirement': string; - - 'open-et': boolean; - 'open-lmf': string; - - // deprecated - 'Shop Mode': string; - 'Max Batreaux Reward': number; - +export interface Settings + extends Omit { + // Bizzare Bazaar splits Shopsanity into three settings // https://github.com/ssrando/ssrando/pull/442 - // to be deprecated - Shopsanity: boolean; - // future - 'Beedle Shopsanity': boolean; - 'Gear Shopsanity': boolean; - 'Potion Shopsanity': boolean; - - -} \ No newline at end of file + shopsanity: GeneratedSettings['shopsanity'] | undefined; + 'beedle-shopsanity': boolean | undefined; + 'rupin-shopsanity': boolean | undefined; + 'luv-shopsanity': boolean | undefined; + + // ER renames randomize-entrances -> randomize-dungeon-entrances + // https://github.com/ssrando/ssrando/pull/497 + 'randomize-entrances': GeneratedSettings['randomize-entrances'] | 'All' | 'Vanilla', + 'randomize-dungeon-entrances': GeneratedSettings['randomize-entrances'] | undefined, +} diff --git a/src/selectors/Dungeons.ts b/src/selectors/Dungeons.ts index 9f141a6b..12ef3db0 100644 --- a/src/selectors/Dungeons.ts +++ b/src/selectors/Dungeons.ts @@ -8,7 +8,7 @@ import { inventorySelector } from './Inventory'; export const skyKeepShownSelector = (state: RootState) => { const settings = settingsSelector(state); - if (!settings.getOption('Empty Unrequired Dungeons')) { + if (!settings['empty-unrequired-dungeons']) { return true; } @@ -18,10 +18,10 @@ export const skyKeepShownSelector = (state: RootState) => { const skyKeepRequiredSelector = (state: RootState) => { const settings = settingsSelector(state); - if (!settings.getOption('Triforce Required')) { + if (!settings['triforce-required']) { return false; } - return settings.getOption('Triforce Shuffle') !== 'Anywhere'; + return settings['triforce-shuffle'] !== 'Anywhere'; }; export const requiredDungeonsSelector = createSelector( @@ -63,7 +63,7 @@ export const dungeonCompletedSelector = currySelector( // Sky Keep is shown as "completed" if it contains the Triforces // and you found them all. return ( - settings.getOption('Triforce Shuffle') !== 'Anywhere' && + settings['triforce-shuffle'] !== 'Anywhere' && inventory['Triforce'] === 3 ); } else { diff --git a/src/state/Tracker.ts b/src/state/Tracker.ts index 39ff833e..70bf2942 100644 --- a/src/state/Tracker.ts +++ b/src/state/Tracker.ts @@ -1,6 +1,6 @@ import { PayloadAction, createSlice } from '@reduxjs/toolkit'; -import Settings from '../permalink/Settings'; import { InventoryItem, isItem, itemMaxes, getInitialItems } from '../logic/Inventory'; +import { Settings } from '../permalink/SettingsTypes'; export interface TrackerState { /** Checks we've acquired. */ @@ -13,7 +13,7 @@ export interface TrackerState { discoveredDungeonEntrances: string[]; /** Item hints by check name */ checkHints: Record; - /** Fully decoded settings. */ + /** Loaded settings */ settings: Settings | undefined; } diff --git a/tools/generateOptions.mjs b/tools/generateOptions.mjs new file mode 100644 index 00000000..6eb1085b --- /dev/null +++ b/tools/generateOptions.mjs @@ -0,0 +1,59 @@ +import { load } from 'js-yaml'; +import fs from 'node:fs'; + +const baseFileUrl = (/** @type {string} */ file) => + `https://raw.githubusercontent.com/ssrando/ssrando/main/${file}.yaml`; + +const loadFileFromUrl = async (/** @type {string | URL | Request} */ url) => { + const response = await fetch(url); + return response.text(); +}; + +const loadFile = async (/** @type {string} */ file) => { + const fileUrl = baseFileUrl(file); + const data = await loadFileFromUrl(fileUrl); + return load(data); +}; + +/** + * @typedef {import('../src/permalink/SettingsTypes').Option} Option + */ + +const data = /** @type Option[] */ (await loadFile('options')); +let output = + '/**\n' + + ' * DO NOT MANUALLY EDIT!\n' + + ' * Automatically generated option types based on ssrando options.yaml\n' + + ' * Run `npm run generate:options` to regenerate.\n' + + ' */\n'; +output += 'export interface GeneratedSettings {\n'; +for (const option of data) { + if (option.permalink === false) { + continue; + } + let type; + switch (option.type) { + case 'boolean': + type = 'boolean'; + break; + case 'int': + type = 'number'; + break; + case 'singlechoice': + if (option.choices?.length) { + type = option.choices.map((s) => `'${s}'`).join(' | '); + } else { + type = 'string'; + } + break; + case 'multichoice': + type = 'string[]'; + break; + default: + throw new Error('unknown option type'); + } + output += ` /** ${option.name} */\n`; + output += ` '${option.command}': ${type};\n`; +} +output += '}\n'; +await fs.promises.writeFile('./src/permalink/GeneratedSettings.ts', output); diff --git a/tools/tsconfig.json b/tools/tsconfig.json new file mode 100644 index 00000000..e15cd979 --- /dev/null +++ b/tools/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "lib": [ + "ESNext", + "DOM" + ], + "module": "ESNext", + "target": "ESNext", + "noEmit": true, + "allowJs": true, + "checkJs": true + }, + + "include": [ + "**/*.mjs", + "**/*.cjs" + ] +} \ No newline at end of file From 0212cdde8ed2f78ef08202a5cdbbfc3718092b5e Mon Sep 17 00:00:00 2001 From: robojumper Date: Sat, 2 Mar 2024 16:39:46 +0100 Subject: [PATCH 02/10] Update reselect to prevent spurious warning --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2577d05c..4b99b8a7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21350,9 +21350,9 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, "node_modules/reselect": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.0.1.tgz", - "integrity": "sha512-D72j2ubjgHpvuCiORWkOUxndHJrxDaSolheiz5CO+roz8ka97/4msh2E8F5qay4GawR5vzBt5MkbDHT+Rdy/Wg==" + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.0.tgz", + "integrity": "sha512-aw7jcGLDpSgNDyWBQLv2cedml85qd95/iszJjN988zX1t7AVRJi19d9kto5+W7oCfQ94gyo40dVbT6g2k4/kXg==" }, "node_modules/resolve": { "version": "1.22.1", @@ -39669,9 +39669,9 @@ "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" }, "reselect": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.0.1.tgz", - "integrity": "sha512-D72j2ubjgHpvuCiORWkOUxndHJrxDaSolheiz5CO+roz8ka97/4msh2E8F5qay4GawR5vzBt5MkbDHT+Rdy/Wg==" + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.0.tgz", + "integrity": "sha512-aw7jcGLDpSgNDyWBQLv2cedml85qd95/iszJjN988zX1t7AVRJi19d9kto5+W7oCfQ94gyo40dVbT6g2k4/kXg==" }, "resolve": { "version": "1.22.1", From 185a8155ba4a269f79e57208980098a0e195a3b9 Mon Sep 17 00:00:00 2001 From: robojumper Date: Sat, 2 Mar 2024 16:53:44 +0100 Subject: [PATCH 03/10] Document and use the enabled-tricks hack --- src/logic/LogicHelper.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/logic/LogicHelper.ts b/src/logic/LogicHelper.ts index 1f56b946..a421ef09 100644 --- a/src/logic/LogicHelper.ts +++ b/src/logic/LogicHelper.ts @@ -45,6 +45,7 @@ class LogicHelper { if (trickMatch) { const trickName = trickMatch[1]; + // Hack: make up an "enabled tricks" setting expandedRequirement = `Option "enabled-tricks" Contains "${trickName}"`; } else { expandedRequirement = requirement; @@ -293,7 +294,17 @@ class LogicHelper { const requirementMatch = requirement.match(matcher.regex); if (requirementMatch) { const option = requirementMatch[1] as OptionsCommand; - const optionValue = this.settings[option]; + + let optionValue: OptionValue | undefined; + if ((option as string) === 'enabled-tricks') { + // Hack: if this is our made up 'enabled-tricks' setting, retrieve + // the right setting + optionValue = this.settings['enabled-tricks-bitless'].length + ? this.settings['enabled-tricks-bitless'] + : this.settings['enabled-tricks-glitched']; + } else { + optionValue = this.settings[option]; + } const expectedValue = requirementMatch[2]; optionEnabledRequirementValue = optionValue !== undefined && matcher.value(optionValue, expectedValue); From 820d60f49c23043b0ef5bc0e8573670edf0ddf8c Mon Sep 17 00:00:00 2001 From: robojumper Date: Sat, 2 Mar 2024 17:04:27 +0100 Subject: [PATCH 04/10] Even more type safety for starting sword --- src/logic/Inventory.ts | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/logic/Inventory.ts b/src/logic/Inventory.ts index 45d70f45..67f71de4 100644 --- a/src/logic/Inventory.ts +++ b/src/logic/Inventory.ts @@ -97,23 +97,22 @@ export function getInitialItems(settings: Settings): TrackerState['inventory'] { addItem('Empty Bottle'); } const startingSword = settings['starting-sword']; - if (!(startingSword === 'Swordless')) { - const swordsToAdd: Record = { - 'Practice Sword': 1, - 'Goddess Sword': 2, - 'Goddess Longsword': 3, - 'Goddess White Sword': 4, - 'Master Sword': 5, - 'True Master Sword': 6, - }; + const swordsToAdd: Record = { + 'Swordless': 0, + 'Practice Sword': 1, + 'Goddess Sword': 2, + 'Goddess Longsword': 3, + 'Goddess White Sword': 4, + 'Master Sword': 5, + 'True Master Sword': 6, + }; - for ( - let swordsAdded = 0; - swordsAdded < swordsToAdd[startingSword]; - swordsAdded++ - ) { - addItem('Progressive Sword'); - } + for ( + let swordsAdded = 0; + swordsAdded < swordsToAdd[startingSword]; + swordsAdded++ + ) { + addItem('Progressive Sword'); } _.forEach(settings['starting-items'], (item) => { if (item.includes('Song of the Hero')) { From 8b63b7b7889d3930f75bde859964dd790ed0017b Mon Sep 17 00:00:00 2001 From: robojumper Date: Sat, 2 Mar 2024 17:30:49 +0100 Subject: [PATCH 05/10] Settings are serializable --- src/state/Store.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/state/Store.ts b/src/state/Store.ts index 3b89bc85..e88dc3dd 100644 --- a/src/state/Store.ts +++ b/src/state/Store.ts @@ -16,13 +16,12 @@ export const store = configureStore({ logic, tracker, }, - // FIXME: Make logic and settings dumb objects + // FIXME: Make logic a dumb object middleware: (getDefaultMiddleware) => getDefaultMiddleware({ serializableCheck: { - ignoredActions: ['tracker/reset', 'logic/loadLogic'], - ignoredActionPaths: ['meta.arg', 'payload.timestamp'], - ignoredPaths: ['logic.logic', 'tracker.settings'], + ignoredActions: ['logic/loadLogic'], + ignoredPaths: ['logic.logic'], }, }), }); From 5e3d7b38adf976d28cb51e613f01ae2204c50d24 Mon Sep 17 00:00:00 2001 From: robojumper Date: Fri, 8 Mar 2024 15:45:25 +0100 Subject: [PATCH 06/10] Consolidate the individual parts of a logic dump --- src/ImportExport.tsx | 4 ++-- src/TrackerContainer.tsx | 4 ++-- src/logic/Logic.ts | 4 ++-- src/logic/LogicLoader.ts | 9 ++++++++- 4 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/ImportExport.tsx b/src/ImportExport.tsx index f6f2782e..9d57404e 100644 --- a/src/ImportExport.tsx +++ b/src/ImportExport.tsx @@ -19,11 +19,11 @@ async function importState(importedState: ExportState, dispatch: AppDispatch) { alert('invalid source'); return; } - const { requirements, locations, hints, options } = await LogicLoader.loadLogicFiles(source); + const { rawLogic, options } = await LogicLoader.loadLogicFiles(source); importedState.state.settings ??= defaultSettings(options); - const logic = new Logic(requirements, locations, hints, importedState.state.settings); + const logic = new Logic(rawLogic, importedState.state.settings); const state = importedState.state; dispatch(loadTracker(state)); diff --git a/src/TrackerContainer.tsx b/src/TrackerContainer.tsx index e5079490..c9449865 100644 --- a/src/TrackerContainer.tsx +++ b/src/TrackerContainer.tsx @@ -20,11 +20,11 @@ export default function TrackerContainer() { async (source: string) => { setLoadingSource(source); try { - const { requirements, locations, hints, options } = await LogicLoader.loadLogicFiles(source); + const { rawLogic, options } = await LogicLoader.loadLogicFiles(source); const path = new URLSearchParams(window.location.search); const permalink = decodeURIComponent(path.get('options')!); const settings = decodePermalink(options, permalink); - const logic = new Logic(requirements, locations, hints, settings); + const logic = new Logic(rawLogic, settings); dispatch(reset({ settings })); dispatch( loadLogic({ logic, options, source }), diff --git a/src/logic/Logic.ts b/src/logic/Logic.ts index 8c5a0ea5..b4f6cfa4 100644 --- a/src/logic/Logic.ts +++ b/src/logic/Logic.ts @@ -8,8 +8,8 @@ import crystalLocations from '../data/crystals.json'; import BooleanExpression from './BooleanExpression'; import { completionRequirementToDungeon, splitLocationName } from './Locations'; import { InventoryItem, isItem } from './Inventory'; -import { RawHints, RawLocations, RawRequirements } from './UpstreamTypes'; import { Settings } from '../permalink/SettingsTypes'; +import { RawLogic } from './LogicLoader'; class Logic { settings: Settings; @@ -21,7 +21,7 @@ class Logic { cubeList: Record = {}; crystalList: Record = {}; - constructor(requirements: RawRequirements, locations: RawLocations, hints: RawHints, settings: Settings) { + constructor({ hints, locations, requirements }: RawLogic, settings: Settings) { this.settings = settings; this.requirements = new Requirements(requirements); diff --git a/src/logic/LogicLoader.ts b/src/logic/LogicLoader.ts index a3acf234..046b700b 100644 --- a/src/logic/LogicLoader.ts +++ b/src/logic/LogicLoader.ts @@ -3,6 +3,13 @@ import { RawHints, RawLocations, RawRequirements } from './UpstreamTypes'; import { MultiChoiceOption, Option } from '../permalink/SettingsTypes'; import _ from 'lodash'; +/** Raw data loaded from a ssrando repository */ +export interface RawLogic { + requirements: RawRequirements, + hints: RawHints, + locations: RawLocations, +} + class LogicLoader { static async loadLogicFiles(branch: string) { const [ @@ -26,7 +33,7 @@ class LogicLoader { excludedLocs.choices.push(location); }); - return { requirements, locations, hints, options }; + return { rawLogic: { requirements, locations, hints }, options }; } static async loadLogicFile(file: string, branch: string) { From 9d44334bee4e16cd382c187066f63b0e46518d37 Mon Sep 17 00:00:00 2001 From: robojumper Date: Fri, 8 Mar 2024 17:19:55 +0100 Subject: [PATCH 07/10] Make requirements reactive to settings --- src/ImportExport.tsx | 4 +- src/TrackerContainer.tsx | 4 +- src/logic/Logic.ts | 245 +++++---------- src/logic/LogicHelper.ts | 558 ++++++++++++++++++--------------- src/logic/LogicTweaks.ts | 171 +++++----- src/logic/Requirements.ts | 91 ++++-- src/permalink/Settings.ts | 33 ++ src/permalink/SettingsTypes.ts | 2 + src/selectors/LogicInput.ts | 3 + src/selectors/LogicOutput.ts | 69 ++-- src/selectors/Settings.ts | 22 +- src/state/Logic.ts | 2 +- 12 files changed, 606 insertions(+), 598 deletions(-) diff --git a/src/ImportExport.tsx b/src/ImportExport.tsx index 9d57404e..e58dae69 100644 --- a/src/ImportExport.tsx +++ b/src/ImportExport.tsx @@ -1,11 +1,11 @@ import { ChangeEvent } from 'react'; import { TrackerState, loadTracker } from './state/Tracker'; import { defaultSettings } from './permalink/Settings'; -import Logic from './logic/Logic'; import { AppDispatch, RootState } from './state/Store'; import { loadLogic } from './state/Logic'; import { useDispatch, useSelector } from 'react-redux'; import LogicLoader from './logic/LogicLoader'; +import { parseLogic } from './logic/Logic'; export interface ExportState { state: TrackerState; @@ -23,7 +23,7 @@ async function importState(importedState: ExportState, dispatch: AppDispatch) { importedState.state.settings ??= defaultSettings(options); - const logic = new Logic(rawLogic, importedState.state.settings); + const logic = parseLogic(rawLogic); const state = importedState.state; dispatch(loadTracker(state)); diff --git a/src/TrackerContainer.tsx b/src/TrackerContainer.tsx index c9449865..8c49e686 100644 --- a/src/TrackerContainer.tsx +++ b/src/TrackerContainer.tsx @@ -6,11 +6,11 @@ import { loadingErrorSelector, } from './selectors/LogicInput'; import { decodePermalink } from './permalink/Settings'; -import Logic from './logic/Logic'; import Tracker from './Tracker'; import { parseError } from './utils/Error'; import { reset } from './state/Tracker'; import LogicLoader from './logic/LogicLoader'; +import { parseLogic } from './logic/Logic'; export default function TrackerContainer() { const dispatch = useDispatch(); @@ -24,7 +24,7 @@ export default function TrackerContainer() { const path = new URLSearchParams(window.location.search); const permalink = decodeURIComponent(path.get('options')!); const settings = decodePermalink(options, permalink); - const logic = new Logic(rawLogic, settings); + const logic = parseLogic(rawLogic); dispatch(reset({ settings })); dispatch( loadLogic({ logic, options, source }), diff --git a/src/logic/Logic.ts b/src/logic/Logic.ts index b4f6cfa4..6cdb6efb 100644 --- a/src/logic/Logic.ts +++ b/src/logic/Logic.ts @@ -1,191 +1,90 @@ import _ from 'lodash'; -import LogicHelper from './LogicHelper'; -import Requirements from './Requirements'; -import LogicTweaks from './LogicTweaks'; import goddessCubes from '../data/goddessCubes.json'; import ItemLocation from './ItemLocation'; import crystalLocations from '../data/crystals.json'; -import BooleanExpression from './BooleanExpression'; import { completionRequirementToDungeon, splitLocationName } from './Locations'; -import { InventoryItem, isItem } from './Inventory'; -import { Settings } from '../permalink/SettingsTypes'; import { RawLogic } from './LogicLoader'; +import { applyTweaks } from './LogicTweaks'; -class Logic { - settings: Settings; - requirements: Requirements; +export type Requirements = Record; - locations: Record> = {}; - additionalLocations: Record> = {}; - - cubeList: Record = {}; - crystalList: Record = {}; - - constructor({ hints, locations, requirements }: RawLogic, settings: Settings) { - this.settings = settings; - this.requirements = new Requirements(requirements); - - _.forEach(locations, (data, id) => { - const { - area, - location, - } = splitLocationName(id); - const logicSentence = this.requirements.get(id); - const itemLocation: ItemLocation = { - id, - area, - name: location, - logicSentence, - rawType: data.type, - }; - - const dungeonName = completionRequirementToDungeon[id]; - if (dungeonName) { - itemLocation.giveItemOnCheck = `${dungeonName} Completed`; - } - _.set(this.locations, [area, location], itemLocation); - }); - - LogicTweaks.applyTweaks(this.requirements, settings); - - _.forEach(goddessCubes, (cube, cubeRequirementName) => { - const requirement = this.getRequirement(`Can Reach ${cubeRequirementName}`); - const extraLocation: ItemLocation = { - id: cubeRequirementName, - area: cube.area, - name: cube.displayName, - logicSentence: requirement, - giveItemOnCheck: cubeRequirementName, - rawType: cube.type, - } - _.set(this.additionalLocations, [cube.area, cubeRequirementName], extraLocation); - _.set(this.cubeList, cubeRequirementName, extraLocation); - }); - - _.forEach(crystalLocations, (crystal, crystalRequirementName) => { - const crystalRequirementFullName = `${crystal.area} - ${crystalRequirementName}`; - const requirement = this.getRequirement(crystalRequirementFullName); - const extraLocation: ItemLocation = { - id: crystalRequirementFullName, - area: crystal.area, - name: crystal.displayName, - logicSentence: requirement, - rawType: 'loose gratitude crystals', - giveItemOnCheck: 'Gratitude Crystal', - } - delete this.locations[crystal.area]?.[crystalRequirementName]; - _.set(this.additionalLocations, [crystal.area, crystalRequirementName], extraLocation); - _.set(this.crystalList, crystalRequirementName, extraLocation); - }); - _.forEach(hints, (_hint, hintName) => { - const { area, location } = splitLocationName(hintName); - const requirement = this.getRequirement(hintName); - const extraLocation: ItemLocation = { - id: hintName, - area, - name: location, - logicSentence: requirement, - rawType: 'gossip stones', - } - _.set(this.additionalLocations, [area, location], extraLocation); - }); - this.isRequirementMet = this.isRequirementMet.bind(this); - this.itemsRemainingForRequirement = this.itemsRemainingForRequirement.bind(this); - } - - getRequirement(requirement: string) { - return this.requirements.get(requirement); - } - - allLocations() { - return this.locations; - } - - areas() { - return Object.keys(this.locations); - } - - locationsForArea(area: string) { - return this.locations[area]; - } - - getLocation(area: string, location: string) { - return this.locations[area]?.[location]; - } - - areRequirementsMet(requirements: BooleanExpression, itemCounts: Record, additionalItems: Record) { - return requirements.evaluate( - (requirement) => this.isRequirementMet(requirement, itemCounts, additionalItems), - ); - } - - isRequirementMet(requirement: string, itemCounts: Record, additionalItems: Record) { - const itemsRemaining = this.itemsRemainingForRequirement(requirement, itemCounts, additionalItems); - return itemsRemaining === 0; - } - - itemsRemainingForRequirement(requirement: string, itemCounts: Record, additionalItems: Record) { - const remainingItemsForRequirements = [ - Logic.impossibleRequirementRemaining(requirement), - Logic.nothingRequirementRemaining(requirement), - this.itemCountRequirementRemaining(requirement, itemCounts, additionalItems), - this.itemRequirementRemaining(requirement, itemCounts, additionalItems), - // this.hasAccessedOtherLocationRequirementRemaining(requirement), - ]; +export interface Area { + locations: Record; + additionalLocations: Record; +} - const remainingItems = _.find(remainingItemsForRequirements, (result) => !_.isNil(result)); +export interface Logic { + requirements: Requirements, + areas: Record; +} - if (!_.isNil(remainingItems)) { - return remainingItems; +export function parseLogic({ hints, locations: rawLocations, requirements: rawRequirements }: RawLogic): Logic { + const areas: Logic['areas'] = {}; + + const getArea = (name: string) => (areas[name] ??= { additionalLocations: {}, locations: {} }); + + _.forEach(rawLocations, (data, id) => { + const { + area, + location, + } = splitLocationName(id); + const itemLocation: ItemLocation = { + id, + area, + name: location, + logicSentence: id, + rawType: data.type, + }; + + const dungeonName = completionRequirementToDungeon[id]; + if (dungeonName) { + itemLocation.giveItemOnCheck = `${dungeonName} Completed`; } - throw Error(`Could not parse requirement: ${requirement}`); - } - - static impossibleRequirementRemaining(requirement: string) { - if (requirement === 'Impossible') { - return 1; + getArea(area).locations[location] = itemLocation; + }); + + _.forEach(goddessCubes, (cube, cubeRequirementName) => { + const extraLocation: ItemLocation = { + id: cubeRequirementName, + area: cube.area, + name: cube.displayName, + logicSentence: `Can Reach ${cubeRequirementName}`, + giveItemOnCheck: cubeRequirementName, + rawType: cube.type, } - return null; - } - - static nothingRequirementRemaining(requirement: string) { - if (requirement === 'Nothing') { - return 0; + getArea(cube.area).additionalLocations[cubeRequirementName] = extraLocation; + }); + + _.forEach(crystalLocations, (crystal, crystalRequirementName) => { + const crystalRequirementFullName = `${crystal.area} - ${crystalRequirementName}`; + const extraLocation: ItemLocation = { + id: crystalRequirementFullName, + area: crystal.area, + name: crystal.displayName, + logicSentence: crystalRequirementFullName, + rawType: 'loose gratitude crystals', + giveItemOnCheck: 'Gratitude Crystal', } - return null; - } - - itemCountRequirementRemaining(requirement: string, itemCounts: Record, additionalItems: Partial>) { - const itemCountRequirement = LogicHelper.parseItemCountRequirement(requirement); - if (!_.isNil(itemCountRequirement)) { - const { - countRequired, - itemName, - } = itemCountRequirement; - - const itemCount = isItem(itemName) ? itemCounts[itemName] : additionalItems[itemName]; - if (_.isNil(itemCount)) { - throw new Error('failed to find item count ' + itemName); - } - return Math.max(countRequired - itemCount, 0); + delete areas[crystal.area]?.locations[crystalRequirementName]; + getArea(crystal.area).additionalLocations[crystalRequirementName] = extraLocation; + }); + _.forEach(hints, (_hint, hintName) => { + const { area, location } = splitLocationName(hintName); + const extraLocation: ItemLocation = { + id: hintName, + area, + name: location, + logicSentence: hintName, + rawType: 'gossip stones', } - return null; - } + getArea(area).additionalLocations[location] = extraLocation; + }); - itemRequirementRemaining(requirement: string, itemCounts: Record, additionalItems: Partial>) { - const itemValue = isItem(requirement) ? itemCounts[requirement] : additionalItems[requirement]; - if (!_.isNil(itemValue)) { - if (itemValue > 0) { - return 0; - } - return 1; - } - return null; - } + const requirements = { ...rawRequirements }; + applyTweaks(requirements); - getExtraChecksForArea(area: string) { - return _.values(this.additionalLocations[area]); + return { + requirements, + areas, } } - -export default Logic; diff --git a/src/logic/LogicHelper.ts b/src/logic/LogicHelper.ts index a421ef09..a30bac5a 100644 --- a/src/logic/LogicHelper.ts +++ b/src/logic/LogicHelper.ts @@ -1,8 +1,12 @@ import _ from 'lodash'; import BooleanExpression, { Op, ReducerArg } from './BooleanExpression'; import prettytemNames from '../data/prettyItemNames.json'; -import Requirements from './Requirements'; -import { OptionValue, OptionsCommand, Settings } from '../permalink/SettingsTypes'; +import { + OptionValue, + OptionsCommand, + Settings, +} from '../permalink/SettingsTypes'; +import { Requirements, parseItemCountRequirement } from './Requirements'; type NestedArray = (T | NestedArray)[]; @@ -12,309 +16,341 @@ interface EvaluatedBooleanExpression { value: boolean; } -type EvaluatedRequirement = EvaluatedBooleanExpression | { - item: string, - value: boolean; -} +type EvaluatedRequirement = + | EvaluatedBooleanExpression + | { + item: string; + value: boolean; + }; export interface ReadableRequirement { item: string; name: string; } -class LogicHelper { - static requirements: Requirements; - static settings: Settings; - - static bindRequirements(requirements: Requirements, settings: Settings) { - this.requirements = requirements; - this.settings = settings; - } - - static parseRequirement(requirement: string, visitedRequirements: Set) { - const requirementValue = this.requirements.get(requirement); - if (requirementValue) { - if (visitedRequirements.has(requirement)) { - return 'Impossible'; - } - return this.booleanExpressionForRequirements(requirementValue, new Set(visitedRequirements).add(requirement)); +function expandRequirement( + requirement: string, + requirements: Requirements, + settings: Settings, + visitedRequirements: Set, +) { + const requirementValue = requirements[requirement]; + if (requirementValue) { + if (visitedRequirements.has(requirement)) { + return 'Impossible'; } + return booleanExpressionForRequirements( + requirementValue, + requirements, + settings, + new Set(visitedRequirements).add(requirement), + ); + } - const trickMatch = requirement.match(/^(.+) Trick$/); - let expandedRequirement; - - if (trickMatch) { - const trickName = trickMatch[1]; - // Hack: make up an "enabled tricks" setting - expandedRequirement = `Option "enabled-tricks" Contains "${trickName}"`; - } else { - expandedRequirement = requirement; - } + const trickMatch = requirement.match(/^(.+) Trick$/); + let expandedRequirement; - const optionEnabledRequirementValue = this.checkOptionEnabledRequirement(expandedRequirement); - if (!_.isNil(optionEnabledRequirementValue)) { - return optionEnabledRequirementValue ? 'Nothing' : 'Impossible'; - } - if (expandedRequirement.includes('damage')) { - // console.log(expandedRequirement); - } - return expandedRequirement; + if (trickMatch) { + const trickName = trickMatch[1]; + // Hack: make up an "enabled tricks" setting + expandedRequirement = `Option "enabled-tricks" Contains "${trickName}"`; + } else { + expandedRequirement = requirement; } - static booleanExpressionForTokens(expressionTokens: string[], visitedRequirements: Set): BooleanExpression { - const itemsForExpression = []; - let expressionTypeToken; - while (!_.isEmpty(expressionTokens)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const currentToken = expressionTokens.shift()!; - if (currentToken === '&' || currentToken === '|') { - expressionTypeToken = currentToken; - } else if (currentToken === '(') { - const childExpression = this.booleanExpressionForTokens(expressionTokens, visitedRequirements); - itemsForExpression.push(childExpression); - } else if (currentToken === ')') { - break; - } else { - itemsForExpression.push(this.parseRequirement(currentToken, visitedRequirements)); - } - } - if (expressionTypeToken === '|') { - return BooleanExpression.or(...itemsForExpression); - } - return BooleanExpression.and(...itemsForExpression); + const optionEnabledRequirementValue = checkOptionEnabledRequirement( + expandedRequirement, + settings, + ); + if (!_.isNil(optionEnabledRequirementValue)) { + return optionEnabledRequirementValue ? 'Nothing' : 'Impossible'; } + return expandedRequirement; +} - static requirementImplies(firstRequirement: string, secondRequirement: string) { - if (firstRequirement === secondRequirement) { - return true; - } - if (firstRequirement === 'Impossible') { - return true; +function booleanExpressionForTokens( + expressionTokens: string[], + requirements: Requirements, + settings: Settings, + visitedRequirements: Set, +): BooleanExpression { + const itemsForExpression = []; + let expressionTypeToken; + while (!_.isEmpty(expressionTokens)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const currentToken = expressionTokens.shift()!; + if (currentToken === '&' || currentToken === '|') { + expressionTypeToken = currentToken; + } else if (currentToken === '(') { + const childExpression = booleanExpressionForTokens( + expressionTokens, + requirements, + settings, + visitedRequirements, + ); + itemsForExpression.push(childExpression); + } else if (currentToken === ')') { + break; + } else { + itemsForExpression.push( + expandRequirement( + currentToken, + requirements, + settings, + visitedRequirements, + ), + ); } + } + if (expressionTypeToken === '|') { + return BooleanExpression.or(...itemsForExpression); + } + return BooleanExpression.and(...itemsForExpression); +} - if (secondRequirement === 'Nothing') { - return true; - } - const firstItemCountRequirement = LogicHelper.parseItemCountRequirement(firstRequirement); - const secondItemCountRequirement = LogicHelper.parseItemCountRequirement(secondRequirement); +function splitExpression(expression: string) { + // console.log(expression); + return _.compact(_.map(expression.split(/\s*([(&|)])\s*/g), _.trim)); +} - if (!_.isNil(firstItemCountRequirement) && !_.isNil(secondItemCountRequirement) && - firstItemCountRequirement.itemName === secondItemCountRequirement.itemName) { - return firstItemCountRequirement.countRequired > secondItemCountRequirement.countRequired; - } - return false; +export function requirementImplies(firstRequirement: string, secondRequirement: string) { + if (firstRequirement === secondRequirement) { + return true; + } + if (firstRequirement === 'Impossible') { + return true; } - static splitExpression(expression: string) { - // console.log(expression); - return _.compact( - _.map(expression.split(/\s*([(&|)])\s*/g), _.trim), - ); + if (secondRequirement === 'Nothing') { + return true; } + const firstItemCountRequirement = parseItemCountRequirement(firstRequirement); + const secondItemCountRequirement = parseItemCountRequirement(secondRequirement); - static booleanExpressionForRequirements(requirements: string, visitedRequirements = new Set()) { - // console.log(requirements); - const expressionTokens = this.splitExpression(requirements); - return this.booleanExpressionForTokens(expressionTokens, visitedRequirements); + if (!_.isNil(firstItemCountRequirement) && !_.isNil(secondItemCountRequirement) && + firstItemCountRequirement.itemName === secondItemCountRequirement.itemName) { + return firstItemCountRequirement.countRequired > secondItemCountRequirement.countRequired; } + return false; +} + +export function booleanExpressionForRequirements( + requirement: string, + requirements: Requirements, + settings: Settings, + visitedRequirements = new Set(), +) { + const expressionTokens = splitExpression(requirement); + return booleanExpressionForTokens( + expressionTokens, + requirements, + settings, + visitedRequirements, + ); +} + +function checkOptionEnabledRequirement( + requirement: string, + settings: Settings, +) { + const matchers: { + regex: RegExp; + value: (optionValue: OptionValue, expectedValue: string) => boolean; + }[] = [ + { + regex: /^Option "([^"]+)" Enabled$/, + value: (optionValue) => Boolean(optionValue), + }, + { + regex: /^Option "([^"]+)" Disabled$/, + value: (optionValue) => !optionValue, + }, + { + regex: /^Option "([^"]+)" Is "([^"]+)"$/, + value: (optionValue, expectedValue) => + optionValue === expectedValue, + }, + // special case for integers after 'Is' + { + regex: /^Option "([^"]+)" Is ([^"]+)$/, + value: (optionValue, expectedValue) => + optionValue === parseInt(expectedValue, 10), + }, + { + regex: /^Option "([^"]+)" Is Not "([^"]+)"$/, + value: (optionValue, expectedValue) => + optionValue !== expectedValue, + }, + { + regex: /^Option "([^"]+)" Contains "([^"]+)"$/, + value: (optionValue, expectedValue) => + (Array.isArray(optionValue) || + typeof optionValue === 'string') && + optionValue.includes(expectedValue), + }, + { + regex: /^Option "([^"]+)" Does Not Contain "([^"]+)"$/, + value: (optionValue, expectedValue) => + (Array.isArray(optionValue) || + typeof optionValue === 'string') && + !optionValue.includes(expectedValue), + }, + ]; + + let optionEnabledRequirementValue; + + _.forEach(matchers, (matcher) => { + const requirementMatch = requirement.match(matcher.regex); + if (requirementMatch) { + const option = requirementMatch[1] as OptionsCommand; + + let optionValue: OptionValue | undefined; + if ((option as string) === 'enabled-tricks') { + // Hack: if this is our made up 'enabled-tricks' setting, retrieve + // the right setting + optionValue = settings['enabled-tricks-bitless'].length + ? settings['enabled-tricks-bitless'] + : settings['enabled-tricks-glitched']; + } else { + optionValue = settings[option]; + } + const expectedValue = requirementMatch[2]; + optionEnabledRequirementValue = + optionValue !== undefined && + matcher.value(optionValue, expectedValue); - static createReadableRequirements(requirements: EvaluatedBooleanExpression) { - switch (requirements.type) { - case Op.And: - return _.map(requirements.items, (item) => _.flattenDeep(this.createReadableRequirementsHelper(item))); - case Op.Or: - return [_.flattenDeep(this.createReadableRequirementsHelper(requirements))]; + return false; // break loop } + return true; // continue + }); + + return optionEnabledRequirementValue; +} + +export function createReadableRequirements(requirements: EvaluatedBooleanExpression) { + switch (requirements.type) { + case Op.And: + return _.map(requirements.items, (item) => + _.flattenDeep(createReadableRequirementsHelper(item)), + ); + case Op.Or: + return [ + _.flattenDeep(createReadableRequirementsHelper(requirements)), + ]; } +} - static createReadableRequirementsHelper(requirements: EvaluatedRequirement): NestedArray { - if ('item' in requirements) { - const prettyItemName = LogicHelper.prettyNameForItemRequirement(requirements.item); - return [{ +function createReadableRequirementsHelper( + requirements: EvaluatedRequirement, +): NestedArray { + if ('item' in requirements) { + const prettyItemName = prettyNameForItemRequirement(requirements.item); + return [ + { item: requirements.item, name: prettyItemName, - }]; + }, + ]; + } + return _.map(requirements.items, (item, index) => { + const currentResult: NestedArray = []; + if ('items' in item) { + // expression + currentResult.push([ + { + item: '(', + name: '(', + }, + createReadableRequirementsHelper(item), + { + item: ')', + name: ')', + }, + ]); + } else { + currentResult.push(createReadableRequirementsHelper(item)); } - return _.map(requirements.items, (item, index) => { - const currentResult: NestedArray = []; - if ('items' in item) { // expression - currentResult.push([ - { - item: '(', - name: '(', - }, - this.createReadableRequirementsHelper(item), - { - item: ')', - name: ')', - }, - ]); + + if (index < requirements.items.length - 1) { + if (requirements.type === Op.And) { + currentResult.push({ + item: ' and ', + name: ' and ', + }); } else { - currentResult.push(this.createReadableRequirementsHelper(item)); + currentResult.push({ + item: ' or ', + name: ' or ', + }); } + } + return currentResult; + }); +} - if (index < requirements.items.length - 1) { - if (requirements.type === Op.And) { - currentResult.push({ - item: ' and ', - name: ' and ', - }); - } else { - currentResult.push({ - item: ' or ', - name: ' or ', - }); +export function evaluateRequirements(requirements: BooleanExpression) { + const generateReducerFunction = + (getAccumulatorValue: (acc: boolean, value: boolean) => boolean) => + ({ + accumulator, + item, + isReduced, + }: ReducerArg) => { + if (isReduced) { + return { + items: _.concat(accumulator.items, item), + type: accumulator.type, + value: getAccumulatorValue(accumulator.value, item.value), + }; } - } - return currentResult; - }); - } - static evaluatedRequirements(requirements: BooleanExpression) { - const generateReducerFunction = (getAccumulatorValue: (acc: boolean, value: boolean) => boolean) => ({ - accumulator, - item, - isReduced, - }: ReducerArg) => { - if (isReduced) { + const wrappedItem = { + item, + value: false, + }; + return { - items: _.concat(accumulator.items, item), + items: _.concat(accumulator.items, wrappedItem), type: accumulator.type, - value: getAccumulatorValue(accumulator.value, item.value), + value: getAccumulatorValue( + accumulator.value, + wrappedItem.value, + ), }; - } - - const wrappedItem = { - item, - value: false, - }; - - return { - items: _.concat(accumulator.items, wrappedItem), - type: accumulator.type, - value: getAccumulatorValue(accumulator.value, wrappedItem.value), }; - }; - return requirements.reduce({ - andInitialValue: { - items: [], - type: Op.And, - value: true, - }, - andReducer: (reducerArgs) => generateReducerFunction( + return requirements.reduce({ + andInitialValue: { + items: [], + type: Op.And, + value: true, + }, + andReducer: (reducerArgs) => + generateReducerFunction( (accumulatorValue, itemValue) => accumulatorValue && itemValue, )(reducerArgs), - orInitialValue: { - items: [], - type: Op.Or, - value: false, - }, - orReducer: (reducerArgs) => generateReducerFunction( + orInitialValue: { + items: [], + type: Op.Or, + value: false, + }, + orReducer: (reducerArgs) => + generateReducerFunction( (accumulatorValue, itemValue) => accumulatorValue || itemValue, )(reducerArgs), - }); - } - - static parseItemCountRequirement(requirement: string) { - const itemCountRequirementMatch = requirement.match(/((?:\w|\s)+) x(\d+)/); - if (itemCountRequirementMatch) { - return { - itemName: itemCountRequirementMatch[1], - countRequired: _.toSafeInteger(itemCountRequirementMatch[2]), - }; - } - return null; - } - - static prettyNameForItemRequirement(itemRequirement: string) { - const itemCountRequirement = this.parseItemCountRequirement(itemRequirement); - if (!_.isNil(itemCountRequirement)) { - const { - itemName, - countRequired, - } = itemCountRequirement; - - return this.prettyNameOverride(itemName, countRequired) || itemRequirement; - } - return this.prettyNameOverride(itemRequirement) || itemRequirement; - } - - static prettyNameForItem(itemName: string, itemCount: number) { - const prettyNameOverride = this.prettyNameOverride(itemName, itemCount); - if (!_.isNil(prettyNameOverride)) { - return prettyNameOverride; - } - return itemName; - } - - static prettyNameOverride(itemName: string, itemCount = 1) { - return _.get(prettytemNames, [itemName, itemCount]) as string; - } - - static checkOptionEnabledRequirement(requirement: string) { - const matchers: { - regex: RegExp, - value: (optionValue: OptionValue, expectedValue: string) => boolean - }[] = [ - { - regex: /^Option "([^"]+)" Enabled$/, - value: (optionValue) => Boolean(optionValue), - }, - { - regex: /^Option "([^"]+)" Disabled$/, - value: (optionValue) => !optionValue, - }, - { - regex: /^Option "([^"]+)" Is "([^"]+)"$/, - value: (optionValue, expectedValue) => optionValue === expectedValue, - }, - // special case for integers after 'Is' - { - regex: /^Option "([^"]+)" Is ([^"]+)$/, - value: (optionValue, expectedValue) => optionValue === parseInt(expectedValue, 10), - }, - { - regex: /^Option "([^"]+)" Is Not "([^"]+)"$/, - value: (optionValue, expectedValue) => optionValue !== expectedValue, - }, - { - regex: /^Option "([^"]+)" Contains "([^"]+)"$/, - value: (optionValue, expectedValue) => (Array.isArray(optionValue) || typeof optionValue === 'string') && optionValue.includes(expectedValue), - }, - { - regex: /^Option "([^"]+)" Does Not Contain "([^"]+)"$/, - value: (optionValue, expectedValue) => (Array.isArray(optionValue) || typeof optionValue === 'string') && !optionValue.includes(expectedValue), - }, - ]; - - let optionEnabledRequirementValue; + }); +} - _.forEach(matchers, (matcher) => { - const requirementMatch = requirement.match(matcher.regex); - if (requirementMatch) { - const option = requirementMatch[1] as OptionsCommand; - - let optionValue: OptionValue | undefined; - if ((option as string) === 'enabled-tricks') { - // Hack: if this is our made up 'enabled-tricks' setting, retrieve - // the right setting - optionValue = this.settings['enabled-tricks-bitless'].length - ? this.settings['enabled-tricks-bitless'] - : this.settings['enabled-tricks-glitched']; - } else { - optionValue = this.settings[option]; - } - const expectedValue = requirementMatch[2]; - optionEnabledRequirementValue = optionValue !== undefined && matcher.value(optionValue, expectedValue); +function prettyNameForItemRequirement(itemRequirement: string) { + const itemCountRequirement = parseItemCountRequirement(itemRequirement); + if (!_.isNil(itemCountRequirement)) { + const { itemName, countRequired } = itemCountRequirement; - return false; // break loop - } - return true; // continue - }); - - return optionEnabledRequirementValue; + return prettyNameOverride(itemName, countRequired) || itemRequirement; } + return prettyNameOverride(itemRequirement) || itemRequirement; } -export default LogicHelper; +function prettyNameOverride(itemName: string, itemCount = 1) { + return _.get(prettytemNames, [itemName, itemCount]) as string; +} diff --git a/src/logic/LogicTweaks.ts b/src/logic/LogicTweaks.ts index a94d16b4..7213b982 100644 --- a/src/logic/LogicTweaks.ts +++ b/src/logic/LogicTweaks.ts @@ -2,103 +2,102 @@ import _ from 'lodash'; import goddessCubes from '../data/goddessCubes.json'; import crystalMacros from '../data/gratitudeCrystalMacros.json'; import type { Settings } from '../permalink/SettingsTypes'; -import type Requirements from './Requirements'; -// import LogicHelper from './LogicHelper'; +import { Requirements } from './Logic'; -class LogicTweaks { - static applyTweaks(requirements: Requirements, settings: Settings) { - LogicTweaks.createDungeonMacros(requirements, settings['randomize-entrances']); - LogicTweaks.createTrialMacros(requirements, settings['randomize-trials']); - LogicTweaks.tweakTMSAndRequiredDungeons(requirements); - LogicTweaks.tweakGoddessChestRequirements(requirements); - LogicTweaks.tweakGratitudeCrystalRequirements(requirements); - if (!settings['randomize-trials']) { - LogicTweaks.tweakSoTH(requirements); - } - } +export function applyTweaks(requirements: Requirements) { + tweakTMSAndRequiredDungeons(requirements); + tweakGoddessChestRequirements(requirements); + tweakGratitudeCrystalRequirements(requirements); +} - static createDungeonMacros(requirements: Requirements, entrancesRandomized: Settings['randomize-entrances']) { - if (entrancesRandomized === 'None') { - // no entrance randomizer, sub default requirements in - requirements.set('Can Access Skyview', 'Can Access Dungeon Entrance in Deep Woods'); - requirements.set('Can Access Earth Temple', 'Can Access Dungeon Entrance in Eldin Volcano'); - requirements.set('Can Access Lanayru Mining Facility', 'Can Access Dungeon Entrance in Lanayru Desert'); - requirements.set('Can Access Ancient Cistern', 'Can Access Dungeon Entrance in Lake Floria'); - requirements.set('Can Access Sandship', 'Can Access Dungeon Entrance in Lanayru Sand Sea'); - requirements.set('Can Access Fire Sanctuary', 'Can Access Dungeon Entrance in Volcano Summit'); - requirements.set('Can Access Sky Keep', 'Can Access Dungeon Entrance on Skyloft'); - requirements.set('Can Beat Dungeon Entrance in Lanayru Desert', 'Can Beat Lanayru Mining Facility'); - } else { - requirements.set('Can Access Skyview', 'Entered Skyview'); - requirements.set('Can Access Earth Temple', 'Entered Earth Temple'); - requirements.set('Can Access Lanayru Mining Facility', 'Entered Lanayru Mining Facility'); - requirements.set('Can Access Ancient Cistern', 'Entered Ancient Cistern'); - requirements.set('Can Access Sandship', 'Entered Sandship'); - requirements.set('Can Access Fire Sanctuary', 'Entered Fire Sanctuary'); - if (entrancesRandomized !== 'All Surface Dungeons + Sky Keep') { - requirements.set('Can Access Sky Keep', 'Can Access Dungeon Entrance on Skyloft'); - } else { - requirements.set('Can Access Sky Keep', 'Entered Sky Keep'); - } - } - } +export function getSettingsRequirements(settings: Settings): Requirements { + const requirements = {}; + createDungeonMacros(requirements, settings['randomize-entrances']); + createTrialMacros(requirements, settings['randomize-trials']); + tweakSoTH(requirements); + + return requirements; +} - static createTrialMacros(requirements: Requirements, entrancesRandomized: Settings['randomize-trials']) { - if (entrancesRandomized) { - // entrances are shuffled, create fake items for the tracker - requirements.set('Can Access Skyloft Silent Realm', 'Entered Skyloft Silent Realm'); - requirements.set('Can Access Faron Silent Realm', 'Entered Faron Silent Realm'); - requirements.set('Can Access Eldin Silent Realm', 'Entered Eldin Silent Realm'); - requirements.set('Can Access Lanayru Silent Realm', 'Entered Lanayru Silent Realm'); +function createDungeonMacros(requirements: Requirements, entrancesRandomized: Settings['randomize-entrances']) { + if (entrancesRandomized === 'None') { + // no entrance randomizer, sub default requirements in + requirements['Can Access Skyview'] = 'Can Access Dungeon Entrance in Deep Woods'; + requirements['Can Access Earth Temple'] = 'Can Access Dungeon Entrance in Eldin Volcano'; + requirements['Can Access Lanayru Mining Facility'] = 'Can Access Dungeon Entrance in Lanayru Desert'; + requirements['Can Access Ancient Cistern'] = 'Can Access Dungeon Entrance in Lake Floria'; + requirements['Can Access Sandship'] = 'Can Access Dungeon Entrance in Lanayru Sand Sea'; + requirements['Can Access Fire Sanctuary'] = 'Can Access Dungeon Entrance in Volcano Summit'; + requirements['Can Access Sky Keep'] = 'Can Access Dungeon Entrance on Skyloft'; + requirements['Can Beat Dungeon Entrance in Lanayru Desert'] = 'Can Beat Lanayru Mining Facility'; + } else { + requirements['Can Access Skyview'] = 'Entered Skyview'; + requirements['Can Access Earth Temple'] = 'Entered Earth Temple'; + requirements['Can Access Lanayru Mining Facility'] = 'Entered Lanayru Mining Facility'; + requirements['Can Access Ancient Cistern'] = 'Entered Ancient Cistern'; + requirements['Can Access Sandship'] = 'Entered Sandship'; + requirements['Can Access Fire Sanctuary'] = 'Entered Fire Sanctuary'; + if (entrancesRandomized !== 'All Surface Dungeons + Sky Keep') { + requirements['Can Access Sky Keep'] = 'Can Access Dungeon Entrance on Skyloft'; } else { - // no entrance randomizer, sub default requirements in - requirements.set('Can Access Skyloft Silent Realm', 'Can Open Trial Gate on Skyloft'); - requirements.set('Can Access Faron Silent Realm', 'Can Open Trial Gate in Faron Woods'); - requirements.set('Can Access Eldin Silent Realm', 'Can Open Trial Gate in Eldin Volcano'); - requirements.set('Can Access Lanayru Silent Realm', 'Can Open Trial Gate in Lanayru Desert'); + requirements['Can Access Sky Keep'] = 'Entered Sky Keep'; } } +} - static tweakTMSAndRequiredDungeons(requirements: Requirements) { - requirements.set('Can Access Past', 'Goddess\'s Harp & Master Sword & Can Complete Required Dungeons'); - requirements.set('Can Complete Required Dungeons', 'Nothing'); +function createTrialMacros(requirements: Requirements, entrancesRandomized: Settings['randomize-trials']) { + if (entrancesRandomized) { + // entrances are shuffled, create fake items for the tracker + requirements['Can Access Skyloft Silent Realm'] = 'Entered Skyloft Silent Realm'; + requirements['Can Access Faron Silent Realm'] = 'Entered Faron Silent Realm'; + requirements['Can Access Eldin Silent Realm'] = 'Entered Eldin Silent Realm'; + requirements['Can Access Lanayru Silent Realm'] = 'Entered Lanayru Silent Realm'; + } else { + // no entrance randomizer, sub default requirements in + requirements['Can Access Skyloft Silent Realm'] = 'Can Open Trial Gate on Skyloft'; + requirements['Can Access Faron Silent Realm'] = 'Can Open Trial Gate in Faron Woods'; + requirements['Can Access Eldin Silent Realm'] = 'Can Open Trial Gate in Eldin Volcano'; + requirements['Can Access Lanayru Silent Realm'] = 'Can Open Trial Gate in Lanayru Desert'; } +} - static tweakGoddessChestRequirements(requirements: Requirements) { - _.forEach(goddessCubes, (__, macro) => { - // console.log(macro); - // rename the macro so that the logic can differentiate from the fake cube items used in the tracker and the actual logic - requirements.set(`Can Reach ${macro}`, requirements.get(macro)); - requirements.remove(macro); - }); - } +function tweakTMSAndRequiredDungeons(requirements: Requirements) { + requirements['Can Access Past'] = 'Goddess\'s Harp & Master Sword & Can Complete Required Dungeons'; + requirements['Can Complete Required Dungeons'] = 'Nothing'; +} - static tweakGratitudeCrystalRequirements(requirements: Requirements) { - _.forEach(crystalMacros, (macro) => { - const macroSplit = macro.split(/\s+/); - // ${n} Gratitude Crystals - const newMacro = `${macroSplit[1]} ${macroSplit[2].slice(0, -1)} x${macroSplit[0]}`; - // Gratitude Crystal x${n} - requirements.set(macro, newMacro); - }); - } +function tweakGoddessChestRequirements(requirements: Requirements) { + _.forEach(goddessCubes, (__, macro) => { + // console.log(macro); + // rename the macro so that the logic can differentiate from the fake cube items used in the tracker and the actual logic + requirements[`Can Reach ${macro}`] = requirements[macro]; + delete requirements[macro]; + }); +} - static tweakSoTH(requirements: Requirements) { - requirements.set('Can Open Trial Gate on Skyloft', 'Song of the Hero x3 & Goddess\'s Harp'); - } +function tweakGratitudeCrystalRequirements(requirements: Requirements) { + _.forEach(crystalMacros, (macro) => { + const macroSplit = macro.split(/\s+/); + // ${n} Gratitude Crystals + const newMacro = `${macroSplit[1]} ${macroSplit[2].slice(0, -1)} x${macroSplit[0]}`; + // Gratitude Crystal x${n} + requirements[macro] = newMacro; + }); +} - static getPastRequirementsExpression( - settings: Settings, - requiredDungeons: string[], - ): string { - let newReqs = `Can Access Sealed Temple & Goddess's Harp & ${settings['got-sword-requirement']} & `; - _.forEach(requiredDungeons, (dungeon) => { - if (dungeon !== 'Sky Keep') { - newReqs += `${dungeon} Completed & `; - } - }); - return newReqs.slice(0, -3); - } +function tweakSoTH(requirements: Requirements) { + requirements['Can Open Trial Gate on Skyloft'] = 'Song of the Hero x3 & Goddess\'s Harp'; } -export default LogicTweaks; +export function getPastRequirementsExpression( + settings: Settings, + requiredDungeons: string[], +): string { + let newReqs = `Can Access Sealed Temple & Goddess's Harp & ${settings['got-sword-requirement']} & `; + _.forEach(requiredDungeons, (dungeon) => { + if (dungeon !== 'Sky Keep') { + newReqs += `${dungeon} Completed & `; + } + }); + return newReqs.slice(0, -3); +} diff --git a/src/logic/Requirements.ts b/src/logic/Requirements.ts index ed5f7d8b..b9b126f7 100644 --- a/src/logic/Requirements.ts +++ b/src/logic/Requirements.ts @@ -1,44 +1,67 @@ import _ from 'lodash'; +import { InventoryItem, isItem } from './Inventory'; +import BooleanExpression from './BooleanExpression'; -type RequirementsData = Record; +export type Requirements = Record; -class Requirements { - requirements: RequirementsData | null; - constructor(requirementsFile?: RequirementsData) { - if (requirementsFile) { - this.requirements = requirementsFile; - } else { - this.requirements = null; - } - } - - initialize(requirementsFile: RequirementsData) { - this.requirements = requirementsFile; - } - - reset() { - this.requirements = null; - } - - all() { - return this.requirements; - } - - get(requirementName: string) { - return _.get(this.requirements!, requirementName); +export function parseItemCountRequirement(requirement: string) { + const itemCountRequirementMatch = requirement.match(/((?:\w|\s)+) x(\d+)/); + if (itemCountRequirementMatch) { + return { + itemName: itemCountRequirementMatch[1], + countRequired: _.toSafeInteger(itemCountRequirementMatch[2]), + }; } + return null; +} - set(requirementName: string, value: string) { - _.set(this.requirements!, requirementName, value); - } +export function evaluateRequirement( + requirement: string, + itemCounts: Record, + additionalItems: Record, +) { + if (requirement === 'Nothing') { + return true; + } else if (requirement === 'Impossible') { + return false; + } else { + const itemCountRequirement = parseItemCountRequirement(requirement); + if (itemCountRequirement) { + const { countRequired, itemName } = itemCountRequirement; - remove(requirementName: string) { - _.unset(this.requirements, requirementName); + const itemCount = isItem(itemName) + ? itemCounts[itemName] + : additionalItems[itemName]; + if (_.isNil(itemCount)) { + throw new Error('failed to find item count ' + itemName); + } + return countRequired <= itemCount; + } else { + const itemValue = isItem(requirement) + ? itemCounts[requirement] + : additionalItems[requirement]; + if (_.isNil(itemValue)) { + throw new Error('failed to find item ' + requirement); + } + return itemValue > 0; + } } +} - clone() { - return new Requirements({ ...this.requirements }); - } +export function areRequirementsMet( + requirements: BooleanExpression, + itemCounts: Record, + additionalItems: Record, +) { + return requirements.evaluate((requirement) => + isRequirementMet(requirement, itemCounts, additionalItems), + ); } -export default Requirements; +export function isRequirementMet( + requirement: string, + itemCounts: Record, + additionalItems: Record, +) { + return evaluateRequirement(requirement, itemCounts, additionalItems); +} diff --git a/src/permalink/Settings.ts b/src/permalink/Settings.ts index 7e827136..65af043c 100644 --- a/src/permalink/Settings.ts +++ b/src/permalink/Settings.ts @@ -78,3 +78,36 @@ export function defaultSettings(options: Option[]): Settings { }); return settings as Settings; } + +function validateValue(option: Option, value: unknown): OptionValue | undefined { + switch (option.type) { + case 'boolean': + return typeof value === 'boolean' ? value : undefined; + case 'singlechoice': + return typeof value === 'string' && option.choices.includes(value) ? value : undefined; + case 'multichoice': { + return _.isArray(value) ? value : undefined; + } + case 'int': + return typeof value === 'number' && + Number.isInteger(value) && + value >= option.min && + value <= option.max + ? value + : undefined; + } +} + +export function validateSettings(optionDefs: Option[], userSettings: Partial): Settings { + const settings: Partial> = {}; + for (const optionDef of optionDefs) { + if (optionDef.permalink === false) { + continue; + } + const key = optionDef.command; + const value = userSettings[key]; + settings[key] = validateValue(optionDef, value) ?? optionDef.default; + } + + return settings as Settings; +} diff --git a/src/permalink/SettingsTypes.ts b/src/permalink/SettingsTypes.ts index 4ee05d02..da0ecfe7 100644 --- a/src/permalink/SettingsTypes.ts +++ b/src/permalink/SettingsTypes.ts @@ -30,6 +30,8 @@ export type IntOption = BaseOption & { type: 'int'; bits: number; default: number; + min: number; + max: number; }; export type Option = diff --git a/src/selectors/LogicInput.ts b/src/selectors/LogicInput.ts index ac055e83..36b69b3e 100644 --- a/src/selectors/LogicInput.ts +++ b/src/selectors/LogicInput.ts @@ -7,5 +7,8 @@ export const isLogicLoadedSelector = (state: RootState) => /** The loaded and parsed logic. Only use this if you know that logic has been loaded. */ export const logicSelector = (state: RootState) => state.logic.logic!; +/** The static option definitions. Only use this if you know that options have been loaded. */ +export const optionsSelector = (state: RootState) => state.logic.options!; + /** The error that occurred during loading, if any. */ export const loadingErrorSelector = (state: RootState) => state.logic.error; diff --git a/src/selectors/LogicOutput.ts b/src/selectors/LogicOutput.ts index 149c7b35..34ceaec1 100644 --- a/src/selectors/LogicOutput.ts +++ b/src/selectors/LogicOutput.ts @@ -8,14 +8,15 @@ import { createIsCheckBannedPredicate, } from '../logic/Locations'; import ItemLocation from '../logic/ItemLocation'; -import LogicHelper, { ReadableRequirement } from '../logic/LogicHelper'; +import { ReadableRequirement, booleanExpressionForRequirements, createReadableRequirements, evaluateRequirements, requirementImplies } from '../logic/LogicHelper'; import _ from 'lodash'; import BooleanExpression from '../logic/BooleanExpression'; import { inventorySelector } from './Inventory'; import { checkedChecksSelector } from './Locations'; import { discoveredDungeonEntrancesSelector, requiredDungeonsSelector } from './Dungeons'; -import LogicTweaks from '../logic/LogicTweaks'; import { settingsSelector } from './Settings'; +import { areRequirementsMet, isRequirementMet } from '../logic/Requirements'; +import { getPastRequirementsExpression, getSettingsRequirements } from '../logic/LogicTweaks'; export type LogicalState = 'checked' | 'inLogic' | 'semiLogic' | 'outLogic'; @@ -39,17 +40,20 @@ export interface AreaState { /** The requirements that are patched in to access the past. */ const pastRequirementsSelector = createSelector( [settingsSelector, requiredDungeonsSelector], - LogicTweaks.getPastRequirementsExpression, + getPastRequirementsExpression, ); -const flatLocationsSelector = createSelector([logicSelector], (logic) => - logic - .areas() - .flatMap((area) => [ - ...Object.values(logic.locationsForArea(area)), - ...logic.getExtraChecksForArea(area), - ]), -); +const settingsRequirementsSelector = createSelector( + [settingsSelector], + getSettingsRequirements, +) + +const flatLocationsSelector = createSelector([logicSelector], (logic) => [ + ...Object.values(logic.areas).flatMap((area) => [ + Object.values(area.locations), + Object.values(area.additionalLocations), + ]).flat(), +]); /** Fake items that are patched in to support goddess cubes, dungeon entrance tracking and so on. */ const additionalItemsSelector = createSelector( @@ -86,13 +90,10 @@ export const totalGratitudeCrystalsSelector = createSelector( /** The effective macros that are used to parse the logical expressions. */ const allRequirementsSelector = createSelector( - [logicSelector, pastRequirementsSelector], - (logic, pastRequirements) => { - const requirements = logic.requirements.clone(); - requirements.set( - 'Can Complete Required Dungeons', - pastRequirements, - ); + [logicSelector, settingsRequirementsSelector, pastRequirementsSelector], + (logic, settingsRequirements, pastRequirements) => { + const requirements = { ...logic.requirements, ...settingsRequirements }; + requirements['Can Complete Required Dungeons'] = pastRequirements; return requirements; }, ); @@ -118,20 +119,18 @@ const expandedExpressionsSelector = createSelector( needs: ReadableRequirement[][]; } > = {}; - LogicHelper.bindRequirements(allRequirements, settings); for (const loc of locations) { const booleanExpression = - LogicHelper.booleanExpressionForRequirements(loc.logicSentence); + booleanExpressionForRequirements(loc.logicSentence, allRequirements, settings); const simplifiedExpression = booleanExpression.simplify( (firstRequirement, secondRequirement) => - LogicHelper.requirementImplies( + requirementImplies( firstRequirement, secondRequirement, ), ); - const evaluatedRequirements = - LogicHelper.evaluatedRequirements(simplifiedExpression); - const needs = LogicHelper.createReadableRequirements( + const evaluatedRequirements = evaluateRequirements(simplifiedExpression); + const needs = createReadableRequirements( evaluatedRequirements, ); @@ -148,7 +147,6 @@ const expandedExpressionsSelector = createSelector( /** Calculates logical state for all locations (does not return the `checked` state). */ export const logicalStateSelector = createSelector( [ - logicSelector, flatLocationsSelector, expandedExpressionsSelector, checkedChecksSelector, @@ -156,7 +154,6 @@ export const logicalStateSelector = createSelector( additionalItemsSelector, ], ( - logic, locations, expandedExpressions, checkedChecks, @@ -165,7 +162,7 @@ export const logicalStateSelector = createSelector( ) => { const result: Record = {}; for (const location of locations) { - result[location.id] = logic.areRequirementsMet( + result[location.id] = areRequirementsMet( expandedExpressions[location.id].expression, inventory, additionalItems, @@ -189,7 +186,7 @@ export const logicalStateSelector = createSelector( for (const location of locations) { if ( result[location.id] === 'outLogic' && - logic.areRequirementsMet( + areRequirementsMet( expandedExpressions[location.id].expression, inventory, assumedAdditionalItems, @@ -219,7 +216,7 @@ export const areasSelector = createSelector( isCheckBanned, ) => { const result: { [area: string]: AreaState } = {}; - for (const areaName of logic.areas()) { + for (const [areaName, logicArea] of Object.entries(logic.areas)) { const deriveLocation = (location: ItemLocation): LocationState => { const { needs, @@ -236,11 +233,11 @@ export const areasSelector = createSelector( }; }; const locations = Object.values( - logic.locationsForArea(areaName), + logicArea.locations, + ).map(deriveLocation); + const extraLocations = Object.values( + logicArea.additionalLocations, ).map(deriveLocation); - const extraLocations = logic - .getExtraChecksForArea(areaName) - .map(deriveLocation); const progressLocations = locations.filter((l) => !l.nonProgress); const remainingLocations = progressLocations.filter( @@ -288,7 +285,7 @@ export const totalCountersSelector = createSelector( ); export const isRequirementMetSelector = createSelector( - [logicSelector, inventorySelector, additionalItemsSelector], - (logic, inventory, additionalItems) => (item: string) => - logic.isRequirementMet(item, inventory, additionalItems), + [inventorySelector, additionalItemsSelector], + (inventory, additionalItems) => (item: string) => + isRequirementMet(item, inventory, additionalItems), ); diff --git a/src/selectors/Settings.ts b/src/selectors/Settings.ts index 4c0e68c2..71770189 100644 --- a/src/selectors/Settings.ts +++ b/src/selectors/Settings.ts @@ -1,6 +1,22 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { validateSettings } from '../permalink/Settings'; import { RootState } from '../state/Store'; +import { optionsSelector } from './LogicInput'; +import { currySelector } from '../utils/Redux'; +import { Settings } from '../permalink/SettingsTypes'; -// TODO: Selector for single setting, once settings are reactive -// TODO: Validate stored settings values by filling in missing values, once settings are reactive +/** Selects all settings, even the ones not logically relevant. */ +export const settingsSelector = createSelector( + [optionsSelector, (state: RootState) => state.tracker.settings ?? {}], + validateSettings, +); -export const settingsSelector = (state: RootState) => state.tracker.settings!; +/** Selects a particular settings value. */ +export const settingSelector: ( + setting: K, +) => (state: RootState) => Settings[K] = currySelector( + ( + state: RootState, + setting: K, + ): Settings[K] => settingsSelector(state)[setting], +); \ No newline at end of file diff --git a/src/state/Logic.ts b/src/state/Logic.ts index 96623cae..5380da50 100644 --- a/src/state/Logic.ts +++ b/src/state/Logic.ts @@ -1,4 +1,4 @@ -import Logic from '../logic/Logic'; +import { Logic } from '../logic/Logic'; import { Option } from '../permalink/SettingsTypes'; import { PayloadAction, createSlice } from '@reduxjs/toolkit'; From 03d9c091dd42b923fca6216d8773e0971df9dcbd Mon Sep 17 00:00:00 2001 From: robojumper Date: Fri, 8 Mar 2024 21:01:45 +0100 Subject: [PATCH 08/10] Remove more dead code --- src/logic/LogicHelper.ts | 291 ++--------------------------------- src/logic/LogicParser.ts | 192 +++++++++++++++++++++++ src/logic/Requirements.ts | 31 ++-- src/selectors/LogicOutput.ts | 8 +- 4 files changed, 231 insertions(+), 291 deletions(-) create mode 100644 src/logic/LogicParser.ts diff --git a/src/logic/LogicHelper.ts b/src/logic/LogicHelper.ts index a30bac5a..1e669d47 100644 --- a/src/logic/LogicHelper.ts +++ b/src/logic/LogicHelper.ts @@ -1,233 +1,21 @@ import _ from 'lodash'; -import BooleanExpression, { Op, ReducerArg } from './BooleanExpression'; +import BooleanExpression, { Op } from './BooleanExpression'; import prettytemNames from '../data/prettyItemNames.json'; -import { - OptionValue, - OptionsCommand, - Settings, -} from '../permalink/SettingsTypes'; -import { Requirements, parseItemCountRequirement } from './Requirements'; +import { parseItemCountRequirement } from './Requirements'; type NestedArray = (T | NestedArray)[]; -interface EvaluatedBooleanExpression { - items: EvaluatedRequirement[]; - type: Op; - value: boolean; -} - -type EvaluatedRequirement = - | EvaluatedBooleanExpression - | { - item: string; - value: boolean; - }; - export interface ReadableRequirement { item: string; name: string; } -function expandRequirement( - requirement: string, - requirements: Requirements, - settings: Settings, - visitedRequirements: Set, -) { - const requirementValue = requirements[requirement]; - if (requirementValue) { - if (visitedRequirements.has(requirement)) { - return 'Impossible'; - } - return booleanExpressionForRequirements( - requirementValue, - requirements, - settings, - new Set(visitedRequirements).add(requirement), - ); - } - - const trickMatch = requirement.match(/^(.+) Trick$/); - let expandedRequirement; - - if (trickMatch) { - const trickName = trickMatch[1]; - // Hack: make up an "enabled tricks" setting - expandedRequirement = `Option "enabled-tricks" Contains "${trickName}"`; - } else { - expandedRequirement = requirement; - } - - const optionEnabledRequirementValue = checkOptionEnabledRequirement( - expandedRequirement, - settings, - ); - if (!_.isNil(optionEnabledRequirementValue)) { - return optionEnabledRequirementValue ? 'Nothing' : 'Impossible'; - } - return expandedRequirement; -} - -function booleanExpressionForTokens( - expressionTokens: string[], - requirements: Requirements, - settings: Settings, - visitedRequirements: Set, -): BooleanExpression { - const itemsForExpression = []; - let expressionTypeToken; - while (!_.isEmpty(expressionTokens)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const currentToken = expressionTokens.shift()!; - if (currentToken === '&' || currentToken === '|') { - expressionTypeToken = currentToken; - } else if (currentToken === '(') { - const childExpression = booleanExpressionForTokens( - expressionTokens, - requirements, - settings, - visitedRequirements, - ); - itemsForExpression.push(childExpression); - } else if (currentToken === ')') { - break; - } else { - itemsForExpression.push( - expandRequirement( - currentToken, - requirements, - settings, - visitedRequirements, - ), - ); - } - } - if (expressionTypeToken === '|') { - return BooleanExpression.or(...itemsForExpression); - } - return BooleanExpression.and(...itemsForExpression); -} - -function splitExpression(expression: string) { - // console.log(expression); - return _.compact(_.map(expression.split(/\s*([(&|)])\s*/g), _.trim)); -} - -export function requirementImplies(firstRequirement: string, secondRequirement: string) { - if (firstRequirement === secondRequirement) { - return true; - } - if (firstRequirement === 'Impossible') { - return true; - } - - if (secondRequirement === 'Nothing') { - return true; - } - const firstItemCountRequirement = parseItemCountRequirement(firstRequirement); - const secondItemCountRequirement = parseItemCountRequirement(secondRequirement); - - if (!_.isNil(firstItemCountRequirement) && !_.isNil(secondItemCountRequirement) && - firstItemCountRequirement.itemName === secondItemCountRequirement.itemName) { - return firstItemCountRequirement.countRequired > secondItemCountRequirement.countRequired; - } - return false; -} - -export function booleanExpressionForRequirements( - requirement: string, - requirements: Requirements, - settings: Settings, - visitedRequirements = new Set(), -) { - const expressionTokens = splitExpression(requirement); - return booleanExpressionForTokens( - expressionTokens, - requirements, - settings, - visitedRequirements, - ); -} - -function checkOptionEnabledRequirement( - requirement: string, - settings: Settings, -) { - const matchers: { - regex: RegExp; - value: (optionValue: OptionValue, expectedValue: string) => boolean; - }[] = [ - { - regex: /^Option "([^"]+)" Enabled$/, - value: (optionValue) => Boolean(optionValue), - }, - { - regex: /^Option "([^"]+)" Disabled$/, - value: (optionValue) => !optionValue, - }, - { - regex: /^Option "([^"]+)" Is "([^"]+)"$/, - value: (optionValue, expectedValue) => - optionValue === expectedValue, - }, - // special case for integers after 'Is' - { - regex: /^Option "([^"]+)" Is ([^"]+)$/, - value: (optionValue, expectedValue) => - optionValue === parseInt(expectedValue, 10), - }, - { - regex: /^Option "([^"]+)" Is Not "([^"]+)"$/, - value: (optionValue, expectedValue) => - optionValue !== expectedValue, - }, - { - regex: /^Option "([^"]+)" Contains "([^"]+)"$/, - value: (optionValue, expectedValue) => - (Array.isArray(optionValue) || - typeof optionValue === 'string') && - optionValue.includes(expectedValue), - }, - { - regex: /^Option "([^"]+)" Does Not Contain "([^"]+)"$/, - value: (optionValue, expectedValue) => - (Array.isArray(optionValue) || - typeof optionValue === 'string') && - !optionValue.includes(expectedValue), - }, - ]; - - let optionEnabledRequirementValue; - - _.forEach(matchers, (matcher) => { - const requirementMatch = requirement.match(matcher.regex); - if (requirementMatch) { - const option = requirementMatch[1] as OptionsCommand; - - let optionValue: OptionValue | undefined; - if ((option as string) === 'enabled-tricks') { - // Hack: if this is our made up 'enabled-tricks' setting, retrieve - // the right setting - optionValue = settings['enabled-tricks-bitless'].length - ? settings['enabled-tricks-bitless'] - : settings['enabled-tricks-glitched']; - } else { - optionValue = settings[option]; - } - const expectedValue = requirementMatch[2]; - optionEnabledRequirementValue = - optionValue !== undefined && - matcher.value(optionValue, expectedValue); - - return false; // break loop - } - return true; // continue - }); - - return optionEnabledRequirementValue; -} - -export function createReadableRequirements(requirements: EvaluatedBooleanExpression) { +/** + * Turn a boolean expression into a readable tooltip requirements list. + * The top-level list is essentially a list of bullet points, while + * the nested lists are the brackets, ` and `s, ` or `s, and actual items. + */ +export function createReadableRequirements(requirements: BooleanExpression) { switch (requirements.type) { case Op.And: return _.map(requirements.items, (item) => @@ -241,20 +29,20 @@ export function createReadableRequirements(requirements: EvaluatedBooleanExpress } function createReadableRequirementsHelper( - requirements: EvaluatedRequirement, + requirements: BooleanExpression | string, ): NestedArray { - if ('item' in requirements) { - const prettyItemName = prettyNameForItemRequirement(requirements.item); + if (!BooleanExpression.isExpression(requirements)) { + const prettyItemName = prettyNameForItemRequirement(requirements); return [ { - item: requirements.item, + item: requirements, name: prettyItemName, }, ]; } return _.map(requirements.items, (item, index) => { const currentResult: NestedArray = []; - if ('items' in item) { + if (BooleanExpression.isExpression(item)) { // expression currentResult.push([ { @@ -288,59 +76,6 @@ function createReadableRequirementsHelper( }); } -export function evaluateRequirements(requirements: BooleanExpression) { - const generateReducerFunction = - (getAccumulatorValue: (acc: boolean, value: boolean) => boolean) => - ({ - accumulator, - item, - isReduced, - }: ReducerArg) => { - if (isReduced) { - return { - items: _.concat(accumulator.items, item), - type: accumulator.type, - value: getAccumulatorValue(accumulator.value, item.value), - }; - } - - const wrappedItem = { - item, - value: false, - }; - - return { - items: _.concat(accumulator.items, wrappedItem), - type: accumulator.type, - value: getAccumulatorValue( - accumulator.value, - wrappedItem.value, - ), - }; - }; - - return requirements.reduce({ - andInitialValue: { - items: [], - type: Op.And, - value: true, - }, - andReducer: (reducerArgs) => - generateReducerFunction( - (accumulatorValue, itemValue) => accumulatorValue && itemValue, - )(reducerArgs), - orInitialValue: { - items: [], - type: Op.Or, - value: false, - }, - orReducer: (reducerArgs) => - generateReducerFunction( - (accumulatorValue, itemValue) => accumulatorValue || itemValue, - )(reducerArgs), - }); -} - function prettyNameForItemRequirement(itemRequirement: string) { const itemCountRequirement = parseItemCountRequirement(itemRequirement); if (!_.isNil(itemCountRequirement)) { diff --git a/src/logic/LogicParser.ts b/src/logic/LogicParser.ts new file mode 100644 index 00000000..b0debd5e --- /dev/null +++ b/src/logic/LogicParser.ts @@ -0,0 +1,192 @@ +import _ from 'lodash'; +import { + Settings, + OptionValue, + OptionsCommand, +} from '../permalink/SettingsTypes'; +import BooleanExpression from './BooleanExpression'; +import { Requirements } from './Logic'; + +/** + * Given a requirement name, a list of macros, and the current settings, + * builds a boolean expression that only depends on tracker items + * (settings are pre-evaluated). + */ +export function booleanExpressionForRequirements( + requirement: string, + requirements: Requirements, + settings: Settings, + visitedRequirements = new Set(), +) { + const expressionTokens = splitExpression(requirement); + return booleanExpressionForTokens( + expressionTokens, + requirements, + settings, + visitedRequirements, + ); +} + + +function expandRequirement( + requirement: string, + requirements: Requirements, + settings: Settings, + visitedRequirements: Set, +) { + const requirementValue = requirements[requirement]; + if (requirementValue) { + if (visitedRequirements.has(requirement)) { + return 'Impossible'; + } + return booleanExpressionForRequirements( + requirementValue, + requirements, + settings, + new Set(visitedRequirements).add(requirement), + ); + } + + const trickMatch = requirement.match(/^(.+) Trick$/); + let expandedRequirement; + + if (trickMatch) { + const trickName = trickMatch[1]; + // Hack: make up an "enabled tricks" setting + expandedRequirement = `Option "enabled-tricks" Contains "${trickName}"`; + } else { + expandedRequirement = requirement; + } + + const optionEnabledRequirementValue = checkOptionEnabledRequirement( + expandedRequirement, + settings, + ); + if (!_.isNil(optionEnabledRequirementValue)) { + return optionEnabledRequirementValue ? 'Nothing' : 'Impossible'; + } + return expandedRequirement; +} + +function booleanExpressionForTokens( + expressionTokens: string[], + requirements: Requirements, + settings: Settings, + visitedRequirements: Set, +): BooleanExpression { + const itemsForExpression = []; + let expressionTypeToken; + while (!_.isEmpty(expressionTokens)) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const currentToken = expressionTokens.shift()!; + if (currentToken === '&' || currentToken === '|') { + expressionTypeToken = currentToken; + } else if (currentToken === '(') { + const childExpression = booleanExpressionForTokens( + expressionTokens, + requirements, + settings, + visitedRequirements, + ); + itemsForExpression.push(childExpression); + } else if (currentToken === ')') { + break; + } else { + itemsForExpression.push( + expandRequirement( + currentToken, + requirements, + settings, + visitedRequirements, + ), + ); + } + } + if (expressionTypeToken === '|') { + return BooleanExpression.or(...itemsForExpression); + } + return BooleanExpression.and(...itemsForExpression); +} + +function splitExpression(expression: string) { + // console.log(expression); + return _.compact(_.map(expression.split(/\s*([(&|)])\s*/g), _.trim)); +} + +function checkOptionEnabledRequirement( + requirement: string, + settings: Settings, +) { + const matchers: { + regex: RegExp; + value: (optionValue: OptionValue, expectedValue: string) => boolean; + }[] = [ + { + regex: /^Option "([^"]+)" Enabled$/, + value: (optionValue) => Boolean(optionValue), + }, + { + regex: /^Option "([^"]+)" Disabled$/, + value: (optionValue) => !optionValue, + }, + { + regex: /^Option "([^"]+)" Is "([^"]+)"$/, + value: (optionValue, expectedValue) => + optionValue === expectedValue, + }, + // special case for integers after 'Is' + { + regex: /^Option "([^"]+)" Is ([^"]+)$/, + value: (optionValue, expectedValue) => + optionValue === parseInt(expectedValue, 10), + }, + { + regex: /^Option "([^"]+)" Is Not "([^"]+)"$/, + value: (optionValue, expectedValue) => + optionValue !== expectedValue, + }, + { + regex: /^Option "([^"]+)" Contains "([^"]+)"$/, + value: (optionValue, expectedValue) => + (Array.isArray(optionValue) || + typeof optionValue === 'string') && + optionValue.includes(expectedValue), + }, + { + regex: /^Option "([^"]+)" Does Not Contain "([^"]+)"$/, + value: (optionValue, expectedValue) => + (Array.isArray(optionValue) || + typeof optionValue === 'string') && + !optionValue.includes(expectedValue), + }, + ]; + + let optionEnabledRequirementValue; + + _.forEach(matchers, (matcher) => { + const requirementMatch = requirement.match(matcher.regex); + if (requirementMatch) { + const option = requirementMatch[1] as OptionsCommand; + + let optionValue: OptionValue | undefined; + if ((option as string) === 'enabled-tricks') { + // Hack: if this is our made up 'enabled-tricks' setting, retrieve + // the right setting + optionValue = settings['enabled-tricks-bitless'].length + ? settings['enabled-tricks-bitless'] + : settings['enabled-tricks-glitched']; + } else { + optionValue = settings[option]; + } + const expectedValue = requirementMatch[2]; + optionEnabledRequirementValue = + optionValue !== undefined && + matcher.value(optionValue, expectedValue); + + return false; // break loop + } + return true; // continue + }); + + return optionEnabledRequirementValue; +} diff --git a/src/logic/Requirements.ts b/src/logic/Requirements.ts index b9b126f7..9c858493 100644 --- a/src/logic/Requirements.ts +++ b/src/logic/Requirements.ts @@ -15,7 +15,28 @@ export function parseItemCountRequirement(requirement: string) { return null; } -export function evaluateRequirement( +export function requirementImplies(firstRequirement: string, secondRequirement: string) { + if (firstRequirement === secondRequirement) { + return true; + } + if (firstRequirement === 'Impossible') { + return true; + } + + if (secondRequirement === 'Nothing') { + return true; + } + const firstItemCountRequirement = parseItemCountRequirement(firstRequirement); + const secondItemCountRequirement = parseItemCountRequirement(secondRequirement); + + if (!_.isNil(firstItemCountRequirement) && !_.isNil(secondItemCountRequirement) && + firstItemCountRequirement.itemName === secondItemCountRequirement.itemName) { + return firstItemCountRequirement.countRequired > secondItemCountRequirement.countRequired; + } + return false; +} + +export function isRequirementMet( requirement: string, itemCounts: Record, additionalItems: Record, @@ -57,11 +78,3 @@ export function areRequirementsMet( isRequirementMet(requirement, itemCounts, additionalItems), ); } - -export function isRequirementMet( - requirement: string, - itemCounts: Record, - additionalItems: Record, -) { - return evaluateRequirement(requirement, itemCounts, additionalItems); -} diff --git a/src/selectors/LogicOutput.ts b/src/selectors/LogicOutput.ts index 34ceaec1..cc2ca38a 100644 --- a/src/selectors/LogicOutput.ts +++ b/src/selectors/LogicOutput.ts @@ -8,15 +8,16 @@ import { createIsCheckBannedPredicate, } from '../logic/Locations'; import ItemLocation from '../logic/ItemLocation'; -import { ReadableRequirement, booleanExpressionForRequirements, createReadableRequirements, evaluateRequirements, requirementImplies } from '../logic/LogicHelper'; +import { ReadableRequirement, createReadableRequirements } from '../logic/LogicHelper'; import _ from 'lodash'; import BooleanExpression from '../logic/BooleanExpression'; import { inventorySelector } from './Inventory'; import { checkedChecksSelector } from './Locations'; import { discoveredDungeonEntrancesSelector, requiredDungeonsSelector } from './Dungeons'; import { settingsSelector } from './Settings'; -import { areRequirementsMet, isRequirementMet } from '../logic/Requirements'; +import { areRequirementsMet, isRequirementMet, requirementImplies } from '../logic/Requirements'; import { getPastRequirementsExpression, getSettingsRequirements } from '../logic/LogicTweaks'; +import { booleanExpressionForRequirements } from '../logic/LogicParser'; export type LogicalState = 'checked' | 'inLogic' | 'semiLogic' | 'outLogic'; @@ -129,9 +130,8 @@ const expandedExpressionsSelector = createSelector( secondRequirement, ), ); - const evaluatedRequirements = evaluateRequirements(simplifiedExpression); const needs = createReadableRequirements( - evaluatedRequirements, + simplifiedExpression, ); result[loc.id] = { From a634a19664c3a2c6f4aa02231afacced87312c3e Mon Sep 17 00:00:00 2001 From: robojumper Date: Sat, 9 Mar 2024 17:14:18 +0100 Subject: [PATCH 09/10] Use the same requirements type everywhere --- src/logic/Logic.ts | 3 +-- src/logic/LogicParser.ts | 2 +- src/logic/LogicTweaks.ts | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/logic/Logic.ts b/src/logic/Logic.ts index 6cdb6efb..ed1cd8e9 100644 --- a/src/logic/Logic.ts +++ b/src/logic/Logic.ts @@ -5,8 +5,7 @@ import crystalLocations from '../data/crystals.json'; import { completionRequirementToDungeon, splitLocationName } from './Locations'; import { RawLogic } from './LogicLoader'; import { applyTweaks } from './LogicTweaks'; - -export type Requirements = Record; +import { Requirements } from './Requirements'; export interface Area { locations: Record; diff --git a/src/logic/LogicParser.ts b/src/logic/LogicParser.ts index b0debd5e..d662e978 100644 --- a/src/logic/LogicParser.ts +++ b/src/logic/LogicParser.ts @@ -5,7 +5,7 @@ import { OptionsCommand, } from '../permalink/SettingsTypes'; import BooleanExpression from './BooleanExpression'; -import { Requirements } from './Logic'; +import { Requirements } from './Requirements'; /** * Given a requirement name, a list of macros, and the current settings, diff --git a/src/logic/LogicTweaks.ts b/src/logic/LogicTweaks.ts index 7213b982..ebb4b62b 100644 --- a/src/logic/LogicTweaks.ts +++ b/src/logic/LogicTweaks.ts @@ -2,7 +2,7 @@ import _ from 'lodash'; import goddessCubes from '../data/goddessCubes.json'; import crystalMacros from '../data/gratitudeCrystalMacros.json'; import type { Settings } from '../permalink/SettingsTypes'; -import { Requirements } from './Logic'; +import { Requirements } from './Requirements'; export function applyTweaks(requirements: Requirements) { tweakTMSAndRequiredDungeons(requirements); From 1a718c2db8dde1db84b9016e5cb423a289338a6b Mon Sep 17 00:00:00 2001 From: robojumper Date: Sat, 9 Mar 2024 19:05:02 +0100 Subject: [PATCH 10/10] This also makes logic serializable --- src/state/Store.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/state/Store.ts b/src/state/Store.ts index e88dc3dd..53a3cc5a 100644 --- a/src/state/Store.ts +++ b/src/state/Store.ts @@ -16,14 +16,6 @@ export const store = configureStore({ logic, tracker, }, - // FIXME: Make logic a dumb object - middleware: (getDefaultMiddleware) => - getDefaultMiddleware({ - serializableCheck: { - ignoredActions: ['logic/loadLogic'], - ignoredPaths: ['logic.logic'], - }, - }), }); export type RootState = ReturnType;