Project structure and Modules - ahvonenj/synergism-hypersynergy GitHub Wiki
The mod tries to be modular and therefore all functionality has been split into different modules. There are some (static) core modules which can be used by all the other modules, but the modular approach comes in especially when it comes to having the mod change something with the game itself.
File: hs-elementhooker.ts
This module tries to solve two things:
- Implement an easy way to basically
await
fordocument.querySelector
to guarantee that the queried element is found in DOM. (hookElement) - Implement an easy way to watch for changes in some element (watchElement)
hookElement method
watchElement method
The module exposes a method called watchElement
, which allows one to setup a more permanent (MutationObserver based) watch on an element in the DOM for value changes. The HSHepteracts module uses it to e.g. watch for changes in the hepteract meter values to update hepteract ratios whenever the user expands their hepteracts:
HSElementHooker.watchElement(self.#ownedHepteractsElement, (value) => {
try {
const hepts = parseFloat(value);
self.#ownedHepteracts = hepts;
} catch (e) {
HSLogger.error(`Failed to parse owned hepteracts`, self.context);
}
self.#watchUpdatePending = false;
},
{
greedy: true,
overrideThrottle: true,
valueParser: (element) => {
const subElement = element.querySelector('span');
const value = subElement?.innerText;
return value;
}
});
The watchElement
method takes three parameters:
- element: HTMLElement (the element to be watched
- callback: (currentValue: any) => void (the callback to be called with the parsed value when changes have been detected)
- watcherOptions?: HSWatcherOptions (an object containing different options for the watcher)
HSWatcherOptions:
export interface HSWatcherOptions {
valueParser?: (watchedElement: HTMLElement, mutations: MutationRecord[]) => any
greedy?: boolean;
overrideThrottle?: boolean;
characterData?: boolean;
childList?: boolean;
subtree?: boolean;
attributes?: boolean;
attributeOldValue?: boolean;
attributeFilter?: string[];
}
The callback watchElement
takes is the callback for when the value changes and has been parsed. Optionally a valueParser
can be supplied which is a parser function for the element's value. The value is first parsed according to the supplied parser function and the parsed value is then passed to the value change callback.
File: hs-logger.ts
This is your basic static class for logging things. It supports log
, warn
and error
and includes a log level setting to suppress certain type of logs. It logs both to the console as well as the console found in the mod's panel.
File: hs-module-manager.ts
This one shouldn't need too much changing (well it doesn't support disabling modules yet). The Module Manager is responsible for enabling different modules within the mod itself. It also keeps track of all the enabled modules and allows for querying the instance of some specific module. I've mostly used it for getting the reference to the HSUI module's instance:
const hsui = this.#moduleManager.getModule<HSUI>('HSUI');
The module manager supports custom module load order, as well as "immediate initialization" of certain modules. These can both be defined in index.ts
's enabledModules
listing. There should rarely be need for immediate initialization for most modules. HSPrototypes
is a special case because it extends native js objects and needs to do that very early on.
File: hs-settings.ts
Types: hs-settings-types.ts
HSSettings module is responsible for:
- Parsing settings from JSON
- Building the settings panel with setting inputs
- Binding appropriate events to setting changes and on/off toggles
- Keeping internal settings states in sync with DOM
- Handling
SettingActions
if any action is defined for a setting
As of 29.3.2025, HSSettings settings aren't persistent between page or mod loads. I'll be implementing localStorage
save/load at some point.
File: hs-setting-action.ts
Types: hs-settings-types.ts
Helper wrapper used with HSSettings, which encapsulates SettingActions and their functionality. SettingAction is some action which a setting should perform when either the setting value or the setting state (on/off) changes.
SettingAction isn't needed for all settings, such as the hepteract quick expand cost protection setting, because the setting is only important when actually performing a quick expand. SettingAction is needed for e.g. setting the in-game achievement notification opacity, because "something should happen" immediately when the setting value or state is changed.
File: hs-ui.ts
Types: hs-ui-types.ts
The HSUI module should probably be called HSPanel these days. It mostly contains code related to the mod's panel, but there are also a couple of methods that could come in handy.
Modals
The Modal
method within HSUI can be used to open new modals. This is currently used e.g. to display the corruption reference sheet:
hsui.Modal({ htmlContent: `<img class="hs-modal-img" src="${corruption_ref_b64}" />`, needsToLoad: true })
The method takes an optional option needsToLoad
which should be set to true if the opened modal needs to spend time loading something such as an image. Here it is true because the corruption reference sheet is displayed as base64 encoded image and we need to wait for the image to load before we can get the true width and height of the modal to properly open the modal at the center of the screen.
injectStyle and injectHTML
HSUI exposes two static methods injectStyle
and injectHTML
for arbitrary CSS and HTML injections in to the page. The ibjectHTML
function takes an optional second argument injectFunction
if there is a need to control how exactly the html should be added to the page. HSHepteracts module uses this to inject it's hepteract ratio display like so:
HSUI.injectHTML(this.#ratioElementHtml, (node) => {
const heptGridParent = self.#heptGrid?.parentNode;
heptGridParent?.insertBefore(node, self.#heptGrid as Node);
});
File: hs-ui-components.ts
Types: hs-ui-types.ts
The mod implements a minimal "in house" UI component system (HSUIC). This means that for example, creating all of the contents for the mod's panel's Tab 3 looks like this:
// BUILD TAB 3
hsui.replaceTabContents(3,
HSUIC.Div({
class: 'hs-panel-setting-block',
html: [
HSUIC.Div({ class: 'hs-panel-setting-block-text', html: 'Expand cost protection' }),
HSUIC.Input({ class: 'hs-panel-setting-block-num-input', id: 'hs-setting-expand-cost-protection-value', type: HSInputType.NUMBER }),
HSUIC.Button({ class: 'hs-panel-setting-block-btn', id: 'hs-setting-expand-cost-protection-btn' }),
]
})
);
Resulting in an HTML output like this:
<div class="hs-panel-div hs-panel-setting-block">
<div class="hs-panel-div hs-panel-setting-block-text">Expand cost protection</div>
<input type="number" class="hs-panel-input-number hs-panel-setting-block-num-input" id="hs-setting-expand-cost-protection-value">
<div class="hs-panel-btn hs-panel-setting-block-btn" id="hs-setting-expand-cost-protection-btn">Button</div>
</div>
Pretty nice if I say so myself :)
Support for all common CSS properties
As of 30.3.2025 All HSUI Components support all the common CSS properties. These can be supplied with the styles
option like so:
hsui.replaceTabContents(4,
HSUIC.Grid({
html: [
HSUIC.Div({ id: 'hs-panel-debug-mousepos' }),
],
// All the supported CSS properties withing "styles" are typed, so your IDE will automatically suggest them
// We could have e.g. width, height, margin, etc. here
styles: {
gridTemplateColumns: 'repeat(2, 1fr)',
gridTemplateRows: '1fr',
columnGap: '5px',
rowGap: '5px'
}
})
);
The CSS property keys use the same convention as React, so for example if you want to supply the margin-top
CSS property, you'd give it as marginTop
(camelCase) and the HSUIC will automatically convert it to the valid margin-top
(kebab-case) CSS property.
You can find all supported CSS properties listed in the types/hs-ui-types.ts
file under HSUICCSSProperties
interface.
Expanding / making more HSUI Components
The UI component system is relatively straightforward to expand on by just fiddling around with the hs-core/hs-ui-components.ts
and types/hs-ui-types.ts
.
File: hs-prototypes.ts
Types: hs-proto-types.ts
The HSPrototypes module encapsulates prototype extensions.
Current prototype extensions are:
Delegate event listeners
Prototype extensions:
Element.prototype.delegateEventListener
Document.prototype.delegateEventListener
These mimic the behaviour of jQuery's .on()
-function, but for the delegate listener cases, meaning that with these extensions set, one can do something like:
document.querySelector('#some-element').delegateEventListener('click', '.some-child', function(e) { ... });
// or
document.delegateEventListener('click', '.some-child', function(e) { ... });
The delegateEventListener also supports optional argument singleton
, which when set to true, will automatically prevent duplicate delegate event listeners from being created:
document.delegateEventListener('click', '.some-child', function(e) { ... }, true);
Remove delegate event listeners
Prototype extensions:
Element.prototype.removeDelegateEventListener
Document.prototype.removeDelegateEventListener
These mimic the behaviour of jQuery's .off()
-function, but for the delegate listener cases. Can be used to remove event listeners created with delegateEventListener
if you have stored a handle to the event callback.
Typed entries
Prototype extensions:
Object.typedEntries
When working with TypeScript, the native Object.entries
doesn't work so well. Because of this, we extend Object
with typedEntries
, which is the same as Object.entries
but it is type-aware and works well with TypeScript. It can be used basically as an in-place replacement for Object.entries
:
// Normal Object.entries(), which doesn't work well with TS
for (const [key, value] of Object.entries(someObject)) { ... }
// Customer Object.typedEntries(), which works well with TS
// key and value ARE TYPED INSIDE THE LOOP
for (const [key, value] of Object.typedEntries(someObject)) { ... }
The HSMouse module is a static class and holds information about the mouse, such as it's position. This module is aimed to be used as sort of a "single source of truth" for everything mouse related.
Get mouse position
// returns HSMousePosition: { x: number, y: number }
HSMouse.getPosition();
This module is an interesting one and there might be powerful use cases for it later on. HSShadowDOM module is used to attach and detach elements from the DOM. Elements which are detached from DOM, but kind of sort of still exist as e.g. objects on JavaScript's side are called shadows
and they technically exist in the Shadow DOM
at that point.
Right now I am using HSShadowDOM with the "hepteract quick expand and max" feature of the mod to suppress the alert- and confirmation modals which would normally pop up when trying to expand or max hepteracts like so:
// Get instance of the Shadow DOM module
const shadowDOM = HSModuleManager.getModule<HSShadowDOM>('HSShadowDOM');
if(shadowDOM) {
// Query for the modal background and confirm modal elements
const bg = await HSElementHooker.HookElement('#transparentBG') as HTMLElement;
const confirm = await HSElementHooker.HookElement('#confirmationBox') as HTMLElement;
if(bg && confirm) {
// Create shadows of the modal background and confirm modal elements
// (this detaches them from DOM)
const bgShadow = shadowDOM.createShadow(bg);
const confirmShadow = shadowDOM.createShadow(confirm);
// Perform our cap- and max button clicking
if(bgShadow && confirmShadow) {
capBtn.click();
await HSUtils.wait(5);
(confirm.querySelector('#confirmWrapper > #confirm > #ok_confirm') as HTMLButtonElement).click();
await HSUtils.wait(5);
(confirm.querySelector('#alertWrapper > #alert > #ok_alert') as HTMLButtonElement).click();
await HSUtils.wait(5);
craftMaxBtn.click();
await HSUtils.wait(5);
(confirm.querySelector('#confirmWrapper > #confirm > #ok_confirm') as HTMLButtonElement).click();
await HSUtils.wait(5);
(confirm.querySelector('#alertWrapper > #alert > #ok_alert') as HTMLButtonElement).click();
await HSUtils.wait(5);
// Attach the elements back to the DOM by destroying the shadows
shadowDOM.destroyShadow(bgShadow);
shadowDOM.destroyShadow(confirmShadow);
}
}
} else {
HSLogger.warn(`Could not get HSShadowDOM module`, this.context);
}
The reason why we don't e.g. want to get rid of the elements is that the game needs them to exist and to be able to find the elements. Detached shadow elements gets us best of the both worlds - we can get rid of arbitrary elements while still having them exist for the game.
MDN Documentation about Shadow DOM: https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_shadow_DOM
Some plans / investigation for Shadow DOM use cases in this project: https://github.com/ahvonenj/synergism-hypersynergy/issues/2
HSGlobal module acts as kind of a static configuration object for all other modules as well as for other data which should be globally available and globally configurable.
HSGlobal class is tightly tied to the IHSGlobal
interface which should be extended / modified when introducing new global configurations.
Module implemented: 26.4.2025
This module watches for changes in different game states. Currently I've only set it to watch for changes in two types of UI view changes:
- When the "main view" is changed (user switches from e.g. challenge tab to cube tab and so on)
- When "cube tab" is changes (user switches from e.g. cube tribute tab to hepteract forge tab)
The idea of this module is that code can subscribe to these changes and trigger a callback when such change is detected. Currently I am using this module with the HSHepteracts
module to dynamically either start or stop the ownedHepteracts
watch depending on if the hepteract tab is visible or not.
The ownedHepteracts
watch is a sort of a "high frequency" watch because I want to be able to spam the quick expand and max as quickly as possible, which means that the value of ownedHepteracts
needs to be updated as fast as possible.
Example:
const gameStateMod = HSModuleManager.getModule<HSGameState>('HSGameState');
if(gameStateMod) {
gameStateMod.subscribeGameStateChange(GAME_STATE_CHANGE.MAIN_VIEW, (prevView, currentView) => {
if(currentView.getId() !== MAIN_VIEW.CUBES && gameStateMod.getCurrentCubeView().getId() === CUBE_VIEW.HEPTERACT_FORGE) {
if(self.#ownedHepteractsWatch) {
HSLogger.debug("Hepteract forge view closed, stopping watch", this.context);
HSElementHooker.stopWatching(self.#ownedHepteractsWatch);
}
}
});
gameStateMod.subscribeGameStateChange(GAME_STATE_CHANGE.CUBE_VIEW, async (prevView, currentView) => {
if(currentView.getId() === CUBE_VIEW.HEPTERACT_FORGE) {
HSLogger.debug("Hepteract forge view opened, starting watch", this.context);
self.#ownedHepteractsElement = await HSElementHooker.HookElement('#hepteractQuantity') as HTMLElement;
// Sets up a watch to watch for changes in the element which shows owned hepteracts amount
self.#ownedHepteractsWatch = HSElementHooker.watchElement(self.#ownedHepteractsElement, (value) => {
try {
const hepts = parseFloat(value);
self.#ownedHepteracts = hepts;
} catch (e) {
HSLogger.error(`Failed to parse owned hepteracts`, self.context);
}
self.#watchUpdatePending = false;
},
{
greedy: true,
overrideThrottle: true,
valueParser: (element) => {
const subElement = element.querySelector('span');
const value = subElement?.innerText;
return value;
}
});
} else if(prevView.getId() === CUBE_VIEW.HEPTERACT_FORGE) {
if(self.#ownedHepteractsWatch) {
HSLogger.debug("Hepteract forge view closed, stopping watch", this.context);
HSElementHooker.stopWatching(self.#ownedHepteractsWatch);
}
}
});
}
Please refer to Mod Framework Quick Reference / Persisting Data
These are the modules which actually change something with the game or implement QoL modifications to the game.
Changes the promotion code input prompt/modal to include all reusable promotion codes and makes them copy-on-click.
Implements the following QoL functionalities:
- In-game hepteract ratio display
- "Quick expand and max" functionality when the user clicks one of the hepteract icons
- "Quick expand and max" hepteract cost protection, which won't let the user quick expand a hepteract if it would cost too much
Adds "Buy 10x" and "Consume 10x" buttons to potions interface.
Makes the "Buy All"-button in the talisman interface behave better.
Allows the player to assign any of the ambrosia upgrade icons as icons for their ambrosia loadouts by simply dragging any of the ambrosia upgrade icons to any of the ambrosia loadout slots.

Allows for implementing various kinds of patches for the game.
Patches are integrated with HSSettings so that the user can toggle the patches on or off. Here is an example patch setting in hs-settings.json
:
"patch_ambrosiaViewOverflow": {
"settingName": "patch_ambrosiaViewOverflow",
"settingDescription": "Fix ambrosia view overflow",
"settingHelpText": "This patch fixes an overflow issue in the ambrosia view which makes the page jump around when hovering over ambrosia upgrades.",
"settingType": "boolean",
"enabled": false,
"settingAction": "patch",
"patchConfig": {
"patchName": "AmbrosiaViewOverflow"
},
"settingControl": {
"controlType": "switch",
"controlGroup": "patch",
"controlEnabledId": "hs-setting-patch-ambrosia-view-overflow-btn"
}
},
For a setting to be considered a "patch setting", these must apply:
- The setting contains
patchConfig
-key withpatchName
subkey - The
patchName
subkey must refer to a key in#patchCollection
found inhs-modules/hs-patches.ts
(*) -
settingControl.controlGroup
should be "patch" - I recommend to name patch settings like "patch_"
And to implement the actual patch:
First, add a new typescript file in /patches
and give it a name. An example path file contents look like this:
import { HSElementHooker } from "../hs-core/hs-elementhooker";
import { HSPatch } from "./hs-patch";
export class PATCH_TestPatch extends HSPatch {
async applyPatch(): Promise<void> {
const buildingBtn = await HSElementHooker.HookElement('#buildingstab') as HTMLButtonElement;
buildingBtn.style.color = 'red';
}
async revertPatch(): Promise<void> {
const buildingBtn = await HSElementHooker.HookElement('#buildingstab') as HTMLButtonElement;
buildingBtn.style.color = '';
}
}
So the patch class should:
- Be named like
PATCH_<patchname>
- Extend
HSPatch
class - Implement
applyPatch
method which when called enables the patch - Implement
revertPatch
method which when called should disable / revert the patch
After you have your patch file written, add a new entry to #patchCollection
found in hs-modules/hs-patches.ts
:
#patchCollection: Record<string, new (patchName: string) => HSPatch> = {
"AmbrosiaViewOverflow": PATCH_AmbrosiaViewOverflow,
"TestPatch": PATCH_TestPatch,
};
Make sure that the string key matches patchName
in hs-settings.json
and that the key value (the class name) matches the name of the patch class you created. This maps the patch to the setting.
With all these done, the mod should handle everything else automatically for you such as rendering the patch setting toggle into the settings menu of the mod's panel, handling applying and reverting the patch you've made and persisting the state of the patch toggle through reloads.