From ed32079dda5ef73d2a900c601cbb149235f20fab Mon Sep 17 00:00:00 2001 From: robojumper Date: Sat, 2 Mar 2024 16:39:13 +0100 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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) {