Merge remote-tracking branch 'upstream/main'

This commit is contained in:
Seth Posner 2023-02-17 15:56:32 -08:00
commit 40d2bcf55d
63 changed files with 7187 additions and 1071 deletions

13
.vscode/settings.json vendored
View file

@ -1,3 +1,12 @@
{
"vitest.commandLine": "npx vitest"
}
"vitest.commandLine": "npx vitest",
"editor.codeActionsOnSave": {
"source.fixAll.eslint": true
},
"editor.defaultFormatter": "esbenp.prettier-vscode",
"git.ignoreLimitWarning": true,
"[typescriptreact]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"typescript.tsdk": "node_modules/typescript/lib"
}

1655
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -48,7 +48,7 @@
"jsdom": "^20.0.0",
"prettier": "^2.5.1",
"typescript": "^4.7.4",
"vitest": "^0.17.1",
"vitest": "^0.28.5",
"vue-tsc": "^0.38.1"
},
"engines": {

View file

@ -4,7 +4,6 @@
v-for="(tab, index) in tabs"
:key="index"
class="tab"
:ref="`tab-${index}`"
:style="unref(layers[tab]?.style)"
:class="unref(layers[tab]?.classes)"
>
@ -14,7 +13,7 @@
v-if="layerKeys.includes(tab)"
v-bind="gatherLayerProps(layers[tab]!)"
:index="index"
:tab="() => (($refs[`tab-${index}`] as HTMLElement[] | undefined)?.[0])"
@set-minimized="value => (layers[tab]!.minimized.value = value)"
/>
<component :is="tab" :index="index" v-else />
</div>
@ -36,8 +35,8 @@ const layerKeys = computed(() => Object.keys(layers));
const useHeader = projInfo.useHeader;
function gatherLayerProps(layer: GenericLayer) {
const { display, minimized, minWidth, name, color, minimizable, nodes } = layer;
return { display, minimized, minWidth, name, color, minimizable, nodes };
const { display, minimized, name, color, minimizable, nodes, minimizedDisplay } = layer;
return { display, minimized, name, color, minimizable, nodes, minimizedDisplay };
}
</script>

View file

@ -1,15 +1,22 @@
<template>
<div class="layer-container" :style="{ '--layer-color': unref(color) }">
<button v-if="showGoBack" class="goBack" @click="goBack"></button>
<button class="layer-tab minimized" v-if="minimized.value" @click="minimized.value = false">
<div>{{ unref(name) }}</div>
<button v-if="showGoBack" class="goBack" @click="goBack"></button>
<button
class="layer-tab minimized"
v-if="unref(minimized)"
@click="$emit('setMinimized', false)"
>
<component v-if="minimizedComponent" :is="minimizedComponent" />
<div v-else>{{ unref(name) }}</div>
</button>
<div class="layer-tab" :class="{ showGoBack }" v-else>
<Context @update-nodes="updateNodes">
<component :is="component" />
</Context>
</div>
<button v-if="unref(minimizable)" class="minimize" @click="minimized.value = true">
<button v-if="unref(minimizable)" class="minimize" @click="$emit('setMinimized', true)">
</button>
</div>
@ -19,11 +26,10 @@
import projInfo from "data/projInfo.json";
import type { CoercableComponent } from "features/feature";
import type { FeatureNode } from "game/layers";
import type { Persistent } from "game/persistence";
import player from "game/player";
import { computeComponent, processedPropType, wrapRef } from "util/vue";
import { computeComponent, computeOptionalComponent, processedPropType, unwrapRef } from "util/vue";
import type { PropType, Ref } from "vue";
import { computed, defineComponent, nextTick, toRefs, unref, watch } from "vue";
import { computed, defineComponent, toRefs, unref } from "vue";
import Context from "./Context.vue";
export default defineComponent({
@ -33,20 +39,13 @@ export default defineComponent({
type: Number,
required: true
},
tab: {
type: Function as PropType<() => HTMLElement | undefined>,
required: true
},
display: {
type: processedPropType<CoercableComponent>(Object, String, Function),
required: true
},
minimizedDisplay: processedPropType<CoercableComponent>(Object, String, Function),
minimized: {
type: Object as PropType<Persistent<boolean>>,
required: true
},
minWidth: {
type: processedPropType<number | string>(Number, String),
type: Object as PropType<Ref<boolean>>,
required: true
},
name: {
@ -60,52 +59,31 @@ export default defineComponent({
required: true
}
},
emits: ["setMinimized"],
setup(props) {
const { display, index, minimized, minWidth, tab } = toRefs(props);
const { display, index, minimized, minimizedDisplay } = toRefs(props);
const component = computeComponent(display);
const minimizedComponent = computeOptionalComponent(minimizedDisplay);
const showGoBack = computed(
() => projInfo.allowGoBack && index.value > 0 && !minimized.value
() => projInfo.allowGoBack && index.value > 0 && !unwrapRef(minimized)
);
function goBack() {
player.tabs.splice(unref(props.index), Infinity);
}
nextTick(() => updateTab(minimized.value, unref(minWidth.value)));
watch([minimized, wrapRef(minWidth)], ([minimized, minWidth]) =>
updateTab(minimized, minWidth)
);
function setMinimized(min: boolean) {
minimized.value = min;
}
function updateNodes(nodes: Record<string, FeatureNode | undefined>) {
props.nodes.value = nodes;
}
function updateTab(minimized: boolean, minWidth: number | string) {
const width =
typeof minWidth === "number" || Number.isNaN(parseInt(minWidth))
? minWidth + "px"
: minWidth;
const tabValue = tab.value();
if (tabValue != undefined) {
if (minimized) {
tabValue.style.flexGrow = "0";
tabValue.style.flexShrink = "0";
tabValue.style.width = "60px";
tabValue.style.minWidth = tabValue.style.flexBasis = "";
tabValue.style.margin = "0";
} else {
tabValue.style.flexGrow = "";
tabValue.style.flexShrink = "";
tabValue.style.width = "";
tabValue.style.minWidth = tabValue.style.flexBasis = width;
tabValue.style.margin = "";
}
}
}
return {
component,
minimizedComponent,
showGoBack,
updateNodes,
unref,
@ -155,9 +133,10 @@ export default defineComponent({
background-color: transparent;
}
.layer-tab.minimized div {
.layer-tab.minimized > * {
margin: 0;
writing-mode: vertical-rl;
text-align: left;
padding-left: 10px;
width: 50px;
}
@ -201,8 +180,8 @@ export default defineComponent({
.goBack {
position: sticky;
top: 6px;
left: 20px;
top: 10px;
left: 10px;
line-height: 30px;
margin-top: -50px;
margin-left: -35px;
@ -211,7 +190,7 @@ export default defineComponent({
box-shadow: var(--background) 0 2px 3px 5px;
border-radius: 50%;
color: var(--foreground);
font-size: 40px;
font-size: 30px;
cursor: pointer;
z-index: 7;
}
@ -221,3 +200,10 @@ export default defineComponent({
text-shadow: 0 0 7px var(--foreground);
}
</style>
<style>
.layer-tab.minimized > * > .desc {
color: var(--accent1);
font-size: 30px;
}
</style>

View file

@ -14,9 +14,12 @@
</div>
<br />
<div>
<a :href="discordLink" class="nan-modal-discord-link">
<a
:href="discordLink || 'https://discord.gg/WzejVAx'"
class="nan-modal-discord-link"
>
<span class="material-icons nan-modal-discord">discord</span>
{{ discordName }}
{{ discordName || "The Paper Pilot Community" }}
</a>
</div>
<br />
@ -50,49 +53,51 @@ import state from "game/state";
import type { DecimalSource } from "util/bignum";
import Decimal, { format } from "util/bignum";
import type { ComponentPublicInstance } from "vue";
import { computed, ref, toRef } from "vue";
import { computed, ref, toRef, watch } from "vue";
import Toggle from "./fields/Toggle.vue";
import SavesManager from "./SavesManager.vue";
const { discordName, discordLink } = projInfo;
const autosave = toRef(player, "autosave");
const autosave = ref(true);
const isPaused = ref(true);
const hasNaN = toRef(state, "hasNaN");
const savesManager = ref<ComponentPublicInstance<typeof SavesManager> | null>(null);
const path = computed(() => state.NaNPath?.join("."));
const property = computed(() => state.NaNPath?.slice(-1)[0]);
const previous = computed<DecimalSource | null>(() => {
if (state.NaNReceiver && property.value != null) {
return state.NaNReceiver[property.value] as DecimalSource;
}
return null;
});
const isPaused = computed({
get() {
return player.devSpeed === 0;
},
set(value: boolean) {
player.devSpeed = value ? null : 0;
watch(hasNaN, hasNaN => {
if (hasNaN) {
autosave.value = player.autosave;
isPaused.value = player.devSpeed === 0;
} else {
player.autosave = autosave.value;
player.devSpeed = isPaused.value ? 0 : null;
}
});
const path = computed(() => state.NaNPath?.join("."));
const previous = computed<DecimalSource | null>(() => {
if (state.NaNPersistent != null) {
return state.NaNPersistent.value;
}
return null;
});
function setZero() {
if (state.NaNReceiver && property.value != null) {
state.NaNReceiver[property.value] = new Decimal(0);
if (state.NaNPersistent != null) {
state.NaNPersistent.value = new Decimal(0);
state.hasNaN = false;
}
}
function setOne() {
if (state.NaNReceiver && property.value != null) {
state.NaNReceiver[property.value] = new Decimal(1);
if (state.NaNPersistent) {
state.NaNPersistent.value = new Decimal(1);
state.hasNaN = false;
}
}
function ignore() {
if (state.NaNReceiver && property.value != null) {
state.NaNReceiver[property.value] = new Decimal(NaN);
if (state.NaNPersistent) {
state.NaNPersistent.value = new Decimal(NaN);
state.hasNaN = false;
}
}

View file

@ -42,7 +42,7 @@
</Tooltip>
</div>
<div @click="options?.open()">
<Tooltip display="Options" :direction="Direction.Down" xoffset="-66px">
<Tooltip display="Settings" :direction="Direction.Down" xoffset="-66px">
<span class="material-icons">settings</span>
</Tooltip>
</div>
@ -59,7 +59,7 @@
</Tooltip>
</div>
<div @click="options?.open()">
<Tooltip display="Options" :direction="Direction.Right">
<Tooltip display="Settings" :direction="Direction.Right">
<span class="material-icons">settings</span>
</Tooltip>
</div>

View file

@ -2,18 +2,27 @@
<Modal v-model="isOpen">
<template v-slot:header>
<div class="header">
<h2>Options</h2>
<h2>Settings</h2>
<div class="option-tabs">
<button :class="{selected: isTab('behaviour')}" @click="setTab('behaviour')">Behaviour</button>
<button :class="{selected: isTab('appearance')}" @click="setTab('appearance')">Appearance</button>
</div>
</div>
</template>
<template v-slot:body>
<Select title="Theme" :options="themes" v-model="theme" />
<component :is="settingFieldsComponent" />
<Toggle title="Show TPS" v-model="showTPS" />
<hr />
<Toggle title="Unthrottled" v-model="unthrottled" />
<Toggle :title="offlineProdTitle" v-model="offlineProd" />
<Toggle :title="autosaveTitle" v-model="autosave" />
<Toggle v-if="projInfo.enablePausing" :title="isPausedTitle" v-model="isPaused" />
<div v-if="isTab('behaviour')">
<Toggle :title="unthrottledTitle" v-model="unthrottled" />
<Toggle v-if="projInfo.enablePausing" :title="isPausedTitle" v-model="isPaused" />
<Toggle :title="offlineProdTitle" v-model="offlineProd" />
<Toggle :title="autosaveTitle" v-model="autosave" />
<FeedbackButton v-if="!autosave" class="button save-button" @click="save()">Manually save</FeedbackButton>
</div>
<div v-if="isTab('appearance')">
<Select :title="themeTitle" :options="themes" v-model="theme" />
<component :is="settingFieldsComponent" />
<Toggle :title="showTPSTitle" v-model="showTPS" />
<Toggle :title="alignModifierUnitsTitle" v-model="alignUnits" />
</div>
</template>
</Modal>
</template>
@ -21,20 +30,34 @@
<script setup lang="tsx">
import Modal from "components/Modal.vue";
import projInfo from "data/projInfo.json";
import { save } from "util/save";
import rawThemes from "data/themes";
import { jsx } from "features/feature";
import Tooltip from "features/tooltips/Tooltip.vue";
import player from "game/player";
import settings, { settingFields } from "game/settings";
import { camelToTitle } from "util/common";
import { camelToTitle, Direction } from "util/common";
import { coerceComponent, render } from "util/vue";
import { computed, ref, toRefs } from "vue";
import Select from "./fields/Select.vue";
import Toggle from "./fields/Toggle.vue";
import FeedbackButton from "./fields/FeedbackButton.vue";
const isOpen = ref(false);
const currentTab = ref("behaviour");
function isTab(tab: string): boolean {
return tab == currentTab.value;
}
function setTab(tab: string) {
currentTab.value = tab;
}
defineExpose({
isTab,
setTab,
save,
open() {
isOpen.value = true;
}
@ -49,7 +72,7 @@ const settingFieldsComponent = computed(() => {
return coerceComponent(jsx(() => (<>{settingFields.map(render)}</>)));
});
const { showTPS, theme, unthrottled } = toRefs(settings);
const { showTPS, theme, unthrottled, alignUnits } = toRefs(settings);
const { autosave, offlineProd } = toRefs(player);
const isPaused = computed({
get() {
@ -60,30 +83,85 @@ const isPaused = computed({
}
});
const unthrottledTitle = jsx(() => (
<span class="option-title">
Unthrottled
<desc>Allow the game to run as fast as possible. Not battery friendly.</desc>
</span>
));
const offlineProdTitle = jsx(() => (
<span>
Offline Production<Tooltip display="Save-specific">*</Tooltip>
<span class="option-title">
Offline Production<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
<desc>Simulate production that occurs while the game is closed.</desc>
</span>
));
const autosaveTitle = jsx(() => (
<span>
Autosave<Tooltip display="Save-specific">*</Tooltip>
<span class="option-title">
Autosave<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
<desc>Automatically save the game every second or when the game is closed.</desc>
</span>
));
const isPausedTitle = jsx(() => (
<span>
Pause game<Tooltip display="Save-specific">*</Tooltip>
<span class="option-title">
Pause game<Tooltip display="Save-specific" direction={Direction.Right}>*</Tooltip>
<desc>Stop everything from moving.</desc>
</span>
));
const themeTitle = jsx(() => (
<span class="option-title">
Theme
<desc>How the game looks.</desc>
</span>
));
const showTPSTitle = jsx(() => (
<span class="option-title">
Show TPS
<desc>Show TPS meter at the bottom-left corner of the page.</desc>
</span>
));
const alignModifierUnitsTitle = jsx(() => (
<span class="option-title">
Align modifier units
<desc>Align numbers to the beginning of the unit in modifier view.</desc>
</span>
));
</script>
<style scoped>
.header {
<style>
.option-tabs {
border-bottom: 2px solid var(--outline);
margin-top: 10px;
margin-bottom: -10px;
}
*:deep() .tooltip-container {
.option-tabs button {
background-color: transparent;
color: var(--foreground);
margin-bottom: -2px;
font-size: 14px;
cursor: pointer;
padding: 5px 20px;
border: none;
border-bottom: 2px solid var(--foreground);
}
.option-tabs button:not(.selected) {
border-bottom-color: transparent;
}
.option-title .tooltip-container {
display: inline;
margin-left: 5px;
}
.option-title desc {
display: block;
opacity: 0.6;
font-size: small;
width: 300px;
margin-left: 0;
}
.save-button {
text-align: right;
}
</style>

View file

@ -59,11 +59,10 @@
<script setup lang="ts">
import Modal from "components/Modal.vue";
import projInfo from "data/projInfo.json";
import type { PlayerData } from "game/player";
import type { Player } from "game/player";
import player, { stringifySave } from "game/player";
import settings from "game/settings";
import LZString from "lz-string";
import { ProxyState } from "util/proxies";
import { getUniqueID, loadSave, newSave, save } from "util/save";
import type { ComponentPublicInstance } from "vue";
import { computed, nextTick, ref, shallowReactive, watch } from "vue";
@ -72,7 +71,7 @@ import Select from "./fields/Select.vue";
import Text from "./fields/Text.vue";
import Save from "./Save.vue";
export type LoadablePlayerData = Omit<Partial<PlayerData>, "id"> & { id: string; error?: unknown };
export type LoadablePlayerData = Omit<Partial<Player>, "id"> & { id: string; error?: unknown };
const isOpen = ref(false);
const modal = ref<ComponentPublicInstance<typeof Modal> | null>(null);
@ -195,7 +194,7 @@ const saves = computed(() =>
function exportSave(id: string) {
let saveToExport;
if (player.id === id) {
saveToExport = stringifySave(player[ProxyState]);
saveToExport = stringifySave(player);
} else {
saveToExport = JSON.stringify(saves.value[id]);
}
@ -228,7 +227,7 @@ function duplicateSave(id: string) {
}
const playerData = { ...saves.value[id], id: getUniqueID() };
save(playerData as PlayerData);
save(playerData as Player);
settings.saves.push(playerData.id);
}
@ -272,7 +271,7 @@ function newFromPreset(preset: string) {
}
const playerData = JSON.parse(preset);
playerData.id = getUniqueID();
save(playerData as PlayerData);
save(playerData as Player);
settings.saves.push(playerData.id);
@ -287,7 +286,7 @@ function editSave(id: string, newName: string) {
player.name = newName;
save();
} else {
save(currSave as PlayerData);
save(currSave as Player);
cachedSaves[id] = undefined;
}
}

View file

@ -8,10 +8,13 @@
}
.modifier-amount {
flex-basis: 100px;
flex-shrink: 0;
text-align: right;
}
:not(:first-of-type, :last-of-type) > .modifier-amount::after {
content: var(--unit);
opacity: 0;
}
.modifier-description {
flex-grow: 1;

View file

@ -87,6 +87,10 @@ function onUpdate(value: SelectOption) {
background-color: var(--bought);
}
.vue-input input {
font-size: inherit;
}
.vue-input input::placeholder {
color: var(--link);
}

View file

@ -43,14 +43,16 @@ input {
span {
width: 100%;
padding-right: 41px;
position: relative;
}
/* track */
input + span::before {
content: "";
float: right;
margin: 5px 0 5px 10px;
position: absolute;
top: calc(50% - 7px);
right: 0px;
border-radius: 7px;
width: 36px;
height: 14px;
@ -64,7 +66,7 @@ input + span::before {
input + span::after {
content: "";
position: absolute;
top: 2px;
top: calc(50% - 10px);
right: 16px;
border-radius: 50%;
width: 20px;

View file

@ -5,15 +5,17 @@ import type { GenericConversion } from "features/conversion";
import type { CoercableComponent, JSXFunction, OptionsFunc, Replace } from "features/feature";
import { jsx, setDefault } from "features/feature";
import { GenericMilestone } from "features/milestones/milestone";
import { displayResource } from "features/resources/resource";
import { displayResource, Resource } from "features/resources/resource";
import type { GenericTree, GenericTreeNode, TreeNode, TreeNodeOptions } from "features/trees/tree";
import { createTreeNode } from "features/trees/tree";
import { GenericFormula } from "game/formulas";
import type { Modifier } from "game/modifiers";
import type { Persistent } from "game/persistence";
import { DefaultValue, persistent } from "game/persistence";
import player from "game/player";
import settings from "game/settings";
import type { DecimalSource } from "util/bignum";
import Decimal, { format } from "util/bignum";
import Decimal, { format, formatSmall, formatTime } from "util/bignum";
import type { WithRequired } from "util/common";
import type {
Computable,
@ -23,7 +25,7 @@ import type {
} from "util/computed";
import { convertComputable, processComputable } from "util/computed";
import { getFirstFeature, renderColJSX, renderJSX } from "util/vue";
import type { Ref } from "vue";
import type { ComputedRef, Ref } from "vue";
import { computed, unref } from "vue";
import "./common.css";
@ -73,7 +75,7 @@ export type ResetButton<T extends ResetButtonOptions> = Replace<
display: GetComputableTypeWithDefault<T["display"], Ref<JSX.Element>>;
canClick: GetComputableTypeWithDefault<T["canClick"], Ref<boolean>>;
minimumGain: GetComputableTypeWithDefault<T["minimumGain"], 1>;
onClick: VoidFunction;
onClick: (event?: MouseEvent | TouchEvent) => void;
}
>;
@ -153,7 +155,7 @@ export function createResetButton<T extends ClickableOptions & ResetButtonOption
}
const onClick = resetButton.onClick;
resetButton.onClick = function () {
resetButton.onClick = function (event?: MouseEvent | TouchEvent) {
if (unref(resetButton.canClick) === false) {
return;
}
@ -162,7 +164,7 @@ export function createResetButton<T extends ClickableOptions & ResetButtonOption
if (resetButton.resetTime) {
resetButton.resetTime.value = resetButton.resetTime[DefaultValue];
}
onClick?.();
onClick?.(event);
};
return resetButton;
@ -256,9 +258,11 @@ export interface Section {
* Takes an array of modifier "sections", and creates a JSXFunction that can render all those sections, and allow each section to be collapsed.
* Also returns a list of persistent refs that are used to control which sections are currently collapsed.
* @param sectionsFunc A function that returns the sections to display.
* @param smallerIsBetter Determines whether numbers larger or smaller than the base should be displayed as red.
*/
export function createCollapsibleModifierSections(
sectionsFunc: () => Section[]
sectionsFunc: () => Section[],
smallerIsBetter = false
): [JSXFunction, Persistent<Record<number, boolean>>] {
const sections: Section[] = [];
const processed:
@ -326,20 +330,37 @@ export function createCollapsibleModifierSections(
const hasPreviousSection = !firstVisibleSection;
firstVisibleSection = false;
const base = unref(processed.base[i]) ?? 1;
const total = s.modifier.apply(base);
return (
<>
{hasPreviousSection ? <br /> : null}
<div>
<div
style={{
"--unit":
settings.alignUnits && s.unit != null ? "'" + s.unit + "'" : ""
}}
>
{header}
<br />
{modifiers}
<hr />
<div class="modifier-container">
<span class="modifier-description">
Total
</span>
<span class="modifier-amount">
{format(s.modifier.apply(unref(processed.base[i]) ?? 1))}
<span class="modifier-description">Total</span>
<span
class="modifier-amount"
style={
(
smallerIsBetter === true
? Decimal.gt(total, base ?? 1)
: Decimal.lt(total, base ?? 1)
)
? "color: var(--danger)"
: ""
}
>
{formatSmall(total)}
{s.unit}
</span>
</div>
@ -402,3 +423,70 @@ export function createCollapsibleMilestones(milestones: Record<string, GenericMi
display
};
}
/**
* Utility function for getting an ETA for when a target will be reached by a resource with a known (and assumed consistent) gain.
* @param resource The resource that will be increasing over time.
* @param rate The rate at which the resource is increasing.
* @param target The target amount of the resource to estimate the duration until.
*/
export function estimateTime(
resource: Resource,
rate: Computable<DecimalSource>,
target: Computable<DecimalSource>
) {
const processedRate = convertComputable(rate);
const processedTarget = convertComputable(target);
return computed(() => {
const currRate = unref(processedRate);
const currTarget = unref(processedTarget);
if (Decimal.gte(resource.value, currTarget)) {
return "Now";
} else if (Decimal.lt(currRate, 0)) {
return "Never";
}
return formatTime(Decimal.sub(currTarget, resource.value).div(currRate));
});
}
/**
* Utility function for displaying the result of a formula such that it will, when told to, preview how the formula's result will change.
* Requires a formula with a single variable inside.
* @param formula The formula to display the result of.
* @param showPreview Whether or not to preview how the formula's result will change.
* @param previewAmount The amount to _add_ to the current formula's variable amount to preview the change in result.
*/
export function createFormulaPreview(
formula: GenericFormula,
showPreview: Computable<boolean>,
previewAmount: Computable<DecimalSource> = 1
): ComputedRef<CoercableComponent> {
const processedShowPreview = convertComputable(showPreview);
const processedPreviewAmount = convertComputable(previewAmount);
if (!formula.hasVariable()) {
throw "Cannot create formula preview if the formula does not have a variable";
}
return computed(() => {
if (unref(processedShowPreview)) {
const curr = formatSmall(formula.evaluate());
const preview = formatSmall(
formula.evaluate(
Decimal.add(
unref(formula.innermostVariable ?? 0),
unref(processedPreviewAmount)
)
)
);
return jsx(() => (
<>
<b>
<i>
{curr}{preview}
</i>
</b>
</>
));
}
return formatSmall(formula.evaluate());
});
}

View file

@ -6,7 +6,7 @@ import { branchedResetPropagation, createTree } from "features/trees/tree";
import { globalBus } from "game/events";
import type { BaseLayer, GenericLayer } from "game/layers";
import { createLayer } from "game/layers";
import type { PlayerData } from "game/player";
import type { Player } from "game/player";
import player from "game/player";
import type { DecimalSource } from "util/bignum";
import Decimal, { format, formatTime } from "util/bignum";
@ -79,7 +79,7 @@ export const main = createLayer("main", function (this: BaseLayer) {
*/
export const getInitialLayers = (
/* eslint-disable-next-line @typescript-eslint/no-unused-vars */
player: Partial<PlayerData>
player: Partial<Player>
): Array<GenericLayer> => [main, prestige];
/**
@ -97,7 +97,7 @@ export const hasWon = computed(() => {
/* eslint-disable @typescript-eslint/no-unused-vars */
export function fixOldSave(
oldVersion: string | undefined,
player: Partial<PlayerData>
player: Partial<Player>
// eslint-disable-next-line @typescript-eslint/no-empty-function
): void {}
/* eslint-enable @typescript-eslint/no-unused-vars */

View file

@ -1,9 +1,9 @@
<template>
<div
v-if="unref(visibility) !== Visibility.None"
v-if="isVisible(visibility)"
:style="[
{
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined,
visibility: isHidden(visibility) ? 'hidden' : undefined,
backgroundImage: (earned && image && `url(${image})`) || ''
},
unref(style) ?? []
@ -27,7 +27,7 @@ import "components/common/features.css";
import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue";
import type { CoercableComponent } from "features/feature";
import { Visibility } from "features/feature";
import { Visibility, isHidden, isVisible } from "features/feature";
import { computeOptionalComponent, processedPropType } from "util/vue";
import type { StyleValue } from "vue";
import { defineComponent, toRefs, unref } from "vue";
@ -35,7 +35,7 @@ import { defineComponent, toRefs, unref } from "vue";
export default defineComponent({
props: {
visibility: {
type: processedPropType<Visibility>(Number),
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
},
display: processedPropType<CoercableComponent>(Object, String, Function),
@ -62,7 +62,9 @@ export default defineComponent({
return {
component: computeOptionalComponent(display),
unref,
Visibility
Visibility,
isVisible,
isHidden
};
}
});

View file

@ -4,6 +4,7 @@ import {
Component,
GatherProps,
getUniqueID,
isVisible,
OptionsFunc,
Replace,
setDefault,
@ -32,7 +33,7 @@ const toast = useToast();
export const AchievementType = Symbol("Achievement");
export interface AchievementOptions {
visibility?: Computable<Visibility>;
visibility?: Computable<Visibility | boolean>;
shouldEarn?: () => boolean;
display?: Computable<CoercableComponent>;
mark?: Computable<boolean | string>;
@ -66,7 +67,7 @@ export type Achievement<T extends AchievementOptions> = Replace<
export type GenericAchievement = Replace<
Achievement<AchievementOptions>,
{
visibility: ProcessedComputable<Visibility>;
visibility: ProcessedComputable<Visibility | boolean>;
}
>;
@ -104,7 +105,7 @@ export function createAchievement<T extends AchievementOptions>(
if (settings.active !== player.id) return;
if (
!genericAchievement.earned.value &&
unref(genericAchievement.visibility) === Visibility.Visible &&
isVisible(genericAchievement.visibility) &&
genericAchievement.shouldEarn?.()
) {
genericAchievement.earned.value = true;

247
src/features/action.tsx Normal file
View file

@ -0,0 +1,247 @@
import { isArray } from "@vue/shared";
import ClickableComponent from "features/clickables/Clickable.vue";
import {
Component,
findFeatures,
GatherProps,
GenericComponent,
getUniqueID,
jsx,
JSXFunction,
OptionsFunc,
Replace,
setDefault,
StyleValue,
Visibility
} from "features/feature";
import { globalBus } from "game/events";
import { persistent } from "game/persistence";
import Decimal, { DecimalSource } from "lib/break_eternity";
import { Unsubscribe } from "nanoevents";
import { Direction } from "util/common";
import type {
Computable,
GetComputableType,
GetComputableTypeWithDefault,
ProcessedComputable
} from "util/computed";
import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { coerceComponent, isCoercableComponent, render } from "util/vue";
import { computed, Ref, ref, unref } from "vue";
import { BarOptions, createBar, GenericBar } from "./bars/bar";
import { ClickableOptions } from "./clickables/clickable";
export const ActionType = Symbol("Action");
export interface ActionOptions extends Omit<ClickableOptions, "onClick" | "onHold"> {
duration: Computable<DecimalSource>;
autoStart?: Computable<boolean>;
onClick: (amount: DecimalSource) => void;
barOptions?: Partial<BarOptions>;
}
export interface BaseAction {
id: string;
type: typeof ActionType;
isHolding: Ref<boolean>;
progress: Ref<DecimalSource>;
progressBar: GenericBar;
update: (diff: number) => void;
[Component]: GenericComponent;
[GatherProps]: () => Record<string, unknown>;
}
export type Action<T extends ActionOptions> = Replace<
T & BaseAction,
{
duration: GetComputableType<T["duration"]>;
autoStart: GetComputableTypeWithDefault<T["autoStart"], false>;
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
canClick: GetComputableTypeWithDefault<T["canClick"], true>;
classes: GetComputableType<T["classes"]>;
style: GetComputableType<T["style"]>;
mark: GetComputableType<T["mark"]>;
display: JSXFunction;
onClick: VoidFunction;
}
>;
export type GenericAction = Replace<
Action<ActionOptions>,
{
autoStart: ProcessedComputable<boolean>;
visibility: ProcessedComputable<Visibility | boolean>;
canClick: ProcessedComputable<boolean>;
}
>;
export function createAction<T extends ActionOptions>(
optionsFunc?: OptionsFunc<T, BaseAction, GenericAction>
): Action<T> {
const progress = persistent<DecimalSource>(0);
return createLazyProxy(() => {
const action = optionsFunc?.() ?? ({} as ReturnType<NonNullable<typeof optionsFunc>>);
action.id = getUniqueID("action-");
action.type = ActionType;
action[Component] = ClickableComponent as GenericComponent;
// Required because of display changing types
const genericAction = action as unknown as GenericAction;
action.isHolding = ref(false);
action.progress = progress;
processComputable(action as T, "visibility");
setDefault(action, "visibility", Visibility.Visible);
processComputable(action as T, "duration");
processComputable(action as T, "autoStart");
setDefault(action, "autoStart", false);
processComputable(action as T, "canClick");
setDefault(action, "canClick", true);
processComputable(action as T, "classes");
processComputable(action as T, "style");
processComputable(action as T, "mark");
processComputable(action as T, "display");
const style = action.style as ProcessedComputable<StyleValue | undefined>;
action.style = computed(() => {
const currStyle: StyleValue[] = [
{
cursor: Decimal.gte(
progress.value,
unref(action.duration as ProcessedComputable<DecimalSource>)
)
? "pointer"
: "progress",
display: "flex",
flexDirection: "column"
}
];
const originalStyle = unref(style);
if (isArray(originalStyle)) {
currStyle.push(...originalStyle);
} else if (originalStyle != null) {
currStyle.push(originalStyle);
}
return currStyle as StyleValue;
});
action.progressBar = createBar(() => ({
direction: Direction.Right,
width: 100,
height: 10,
style: "margin-top: 8px",
borderStyle: "border-color: black",
baseStyle: "margin-top: -1px",
progress: () => Decimal.div(progress.value, unref(genericAction.duration)),
...action.barOptions
}));
const canClick = action.canClick as ProcessedComputable<boolean>;
action.canClick = computed(
() =>
unref(canClick) &&
Decimal.gte(
progress.value,
unref(action.duration as ProcessedComputable<DecimalSource>)
)
);
const display = action.display as GetComputableType<ClickableOptions["display"]>;
action.display = jsx(() => {
const currDisplay = unref(display);
let Comp: GenericComponent | undefined;
if (isCoercableComponent(currDisplay)) {
Comp = coerceComponent(currDisplay);
} else if (currDisplay != null) {
const Title = coerceComponent(currDisplay.title ?? "", "h3");
const Description = coerceComponent(currDisplay.description, "div");
Comp = coerceComponent(
jsx(() => (
<span>
{currDisplay.title != null ? (
<div>
<Title />
</div>
) : null}
<Description />
</span>
))
);
}
return (
<>
<div style="flex-grow: 1" />
{Comp == null ? null : <Comp />}
<div style="flex-grow: 1" />
{render(genericAction.progressBar)}
</>
);
});
const onClick = action.onClick.bind(action);
action.onClick = function () {
if (unref(action.canClick) === false) {
return;
}
const amount = Decimal.div(progress.value, unref(genericAction.duration));
onClick?.(amount);
progress.value = 0;
};
action.update = function (diff) {
const duration = unref(genericAction.duration);
if (Decimal.gte(progress.value, duration)) {
progress.value = duration;
} else {
progress.value = Decimal.add(progress.value, diff);
if (genericAction.isHolding.value || unref(genericAction.autoStart)) {
genericAction.onClick();
}
}
};
action[GatherProps] = function (this: GenericAction) {
const {
display,
visibility,
style,
classes,
onClick,
isHolding,
canClick,
small,
mark,
id
} = this;
return {
display,
visibility,
style: unref(style),
classes,
onClick,
isHolding,
canClick,
small,
mark,
id
};
};
return action as unknown as Action<T>;
});
}
const listeners: Record<string, Unsubscribe | undefined> = {};
globalBus.on("addLayer", layer => {
const actions: GenericAction[] = findFeatures(layer, ActionType) as GenericAction[];
listeners[layer.id] = layer.on("postUpdate", diff => {
actions.forEach(action => action.update(diff));
});
});
globalBus.on("removeLayer", layer => {
// unsubscribe from postUpdate
listeners[layer.id]?.();
listeners[layer.id] = undefined;
});

View file

@ -1,11 +1,11 @@
<template>
<div
v-if="unref(visibility) !== Visibility.None"
v-if="isVisible(visibility)"
:style="[
{
width: unref(width) + 'px',
height: unref(height) + 'px',
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined
visibility: isHidden(visibility) ? 'hidden' : undefined
},
unref(style) ?? {}
]"
@ -44,7 +44,7 @@
<script lang="ts">
import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue";
import { CoercableComponent, Visibility } from "features/feature";
import { CoercableComponent, isHidden, isVisible, Visibility } from "features/feature";
import type { DecimalSource } from "util/bignum";
import Decimal from "util/bignum";
import { Direction } from "util/common";
@ -72,7 +72,7 @@ export default defineComponent({
},
display: processedPropType<CoercableComponent>(Object, String, Function),
visibility: {
type: processedPropType<Visibility>(Number),
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
},
style: processedPropType<StyleValue>(Object, String, Array),
@ -136,7 +136,9 @@ export default defineComponent({
barStyle,
component,
unref,
Visibility
Visibility,
isVisible,
isHidden
};
}
});

View file

@ -22,7 +22,7 @@ import { unref } from "vue";
export const BarType = Symbol("Bar");
export interface BarOptions {
visibility?: Computable<Visibility>;
visibility?: Computable<Visibility | boolean>;
width: Computable<number>;
height: Computable<number>;
direction: Computable<Direction>;
@ -66,7 +66,7 @@ export type Bar<T extends BarOptions> = Replace<
export type GenericBar = Replace<
Bar<BarOptions>,
{
visibility: ProcessedComputable<Visibility>;
visibility: ProcessedComputable<Visibility | boolean>;
}
>;

View file

@ -1,7 +1,7 @@
<template>
<panZoom
v-if="unref(visibility) !== Visibility.None"
v-show="unref(visibility) === Visibility.Visible"
v-if="isVisible(visibility)"
v-show="isHidden(visibility)"
:style="[
{
width,
@ -60,7 +60,7 @@ import type {
} from "features/boards/board";
import { getNodeProperty } from "features/boards/board";
import type { StyleValue } from "features/feature";
import { Visibility } from "features/feature";
import { isHidden, isVisible, Visibility } from "features/feature";
import type { ProcessedComputable } from "util/computed";
import { computed, ref, Ref, toRefs, unref } from "vue";
import BoardLinkVue from "./BoardLink.vue";
@ -70,7 +70,7 @@ const _props = defineProps<{
nodes: Ref<BoardNode[]>;
types: Record<string, GenericNodeType>;
state: Ref<BoardData>;
visibility: ProcessedComputable<Visibility>;
visibility: ProcessedComputable<Visibility | boolean>;
width?: ProcessedComputable<string>;
height?: ProcessedComputable<string>;
style?: ProcessedComputable<StyleValue>;

View file

@ -129,7 +129,7 @@ export type GenericNodeType = Replace<
export interface BoardNodeActionOptions {
id: string;
visibility?: NodeComputable<Visibility>;
visibility?: NodeComputable<Visibility | boolean>;
icon: NodeComputable<string>;
fillColor?: NodeComputable<string>;
tooltip: NodeComputable<string>;
@ -155,12 +155,12 @@ export type BoardNodeAction<T extends BoardNodeActionOptions> = Replace<
export type GenericBoardNodeAction = Replace<
BoardNodeAction<BoardNodeActionOptions>,
{
visibility: NodeComputable<Visibility>;
visibility: NodeComputable<Visibility | boolean>;
}
>;
export interface BoardOptions {
visibility?: Computable<Visibility>;
visibility?: Computable<Visibility | boolean>;
height?: Computable<string>;
width?: Computable<string>;
classes?: Computable<Record<string, boolean>>;
@ -199,7 +199,7 @@ export type Board<T extends BoardOptions> = Replace<
export type GenericBoard = Replace<
Board<BoardOptions>,
{
visibility: ProcessedComputable<Visibility>;
visibility: ProcessedComputable<Visibility | boolean>;
state: ProcessedComputable<BoardData>;
links: ProcessedComputable<BoardNodeLink[] | null>;
}

View file

@ -1,9 +1,9 @@
<template>
<div
v-if="unref(visibility) !== Visibility.None"
v-if="isVisible(visibility)"
:style="[
{
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined
visibility: isHidden(visibility) ? 'hidden' : undefined
},
notifyStyle,
unref(style) ?? {}
@ -36,7 +36,7 @@ import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue";
import type { GenericChallenge } from "features/challenges/challenge";
import type { StyleValue } from "features/feature";
import { jsx, Visibility } from "features/feature";
import { isHidden, isVisible, jsx, Visibility } from "features/feature";
import { getHighNotifyStyle, getNotifyStyle } from "game/notifications";
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
import type { Component, PropType, UnwrapRef } from "vue";
@ -62,7 +62,7 @@ export default defineComponent({
Function
),
visibility: {
type: processedPropType<Visibility>(Number),
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
},
style: processedPropType<StyleValue>(String, Object, Array),
@ -167,6 +167,8 @@ export default defineComponent({
notifyStyle,
comp,
Visibility,
isVisible,
isHidden,
unref
};
}

View file

@ -2,7 +2,15 @@ import { isArray } from "@vue/shared";
import Toggle from "components/fields/Toggle.vue";
import ChallengeComponent from "features/challenges/Challenge.vue";
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
import { Component, GatherProps, getUniqueID, jsx, setDefault, Visibility } from "features/feature";
import {
Component,
GatherProps,
getUniqueID,
isVisible,
jsx,
setDefault,
Visibility
} from "features/feature";
import type { GenericReset } from "features/reset";
import type { Resource } from "features/resources/resource";
import { globalBus } from "game/events";
@ -25,7 +33,7 @@ import { computed, unref, watch } from "vue";
export const ChallengeType = Symbol("ChallengeType");
export interface ChallengeOptions {
visibility?: Computable<Visibility>;
visibility?: Computable<Visibility | boolean>;
canStart?: Computable<boolean>;
reset?: GenericReset;
canComplete?: Computable<boolean | DecimalSource>;
@ -81,7 +89,7 @@ export type Challenge<T extends ChallengeOptions> = Replace<
export type GenericChallenge = Replace<
Challenge<ChallengeOptions>,
{
visibility: ProcessedComputable<Visibility>;
visibility: ProcessedComputable<Visibility | boolean>;
canStart: ProcessedComputable<boolean>;
canComplete: ProcessedComputable<boolean | DecimalSource>;
completionLimit: ProcessedComputable<DecimalSource>;
@ -145,7 +153,7 @@ export function createChallenge<T extends ChallengeOptions>(
genericChallenge.reset?.reset();
} else if (
unref(genericChallenge.canStart) &&
unref(genericChallenge.visibility) === Visibility.Visible &&
isVisible(genericChallenge.visibility) &&
!genericChallenge.maxed.value
) {
genericChallenge.reset?.reset();
@ -179,7 +187,7 @@ export function createChallenge<T extends ChallengeOptions>(
};
processComputable(challenge as T, "visibility");
setDefault(challenge, "visibility", Visibility.Visible);
const visibility = challenge.visibility as ProcessedComputable<Visibility>;
const visibility = challenge.visibility as ProcessedComputable<Visibility | boolean>;
challenge.visibility = computed(() => {
if (settings.hideChallenges === true && unref(challenge.maxed)) {
return Visibility.None;
@ -305,7 +313,12 @@ globalBus.on("loadSettings", settings => {
registerSettingField(
jsx(() => (
<Toggle
title="Hide Maxed Challenges"
title={jsx(() => (
<span class="option-title">
Hide maxed challenges
<desc>Hide challenges that have been fully completed.</desc>
</span>
))}
onUpdate:modelValue={value => (settings.hideChallenges = value)}
modelValue={settings.hideChallenges}
/>

View file

@ -1,8 +1,8 @@
<template>
<button
v-if="unref(visibility) !== Visibility.None"
v-if="isVisible(visibility)"
:style="[
{ visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined },
{ visibility: isHidden(visibility) ? 'hidden' : undefined },
unref(style) ?? []
]"
@click="onClick"
@ -33,7 +33,7 @@ import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue";
import type { GenericClickable } from "features/clickables/clickable";
import type { StyleValue } from "features/feature";
import { jsx, Visibility } from "features/feature";
import { isHidden, isVisible, jsx, Visibility } from "features/feature";
import {
coerceComponent,
isCoercableComponent,
@ -55,7 +55,7 @@ export default defineComponent({
required: true
},
visibility: {
type: processedPropType<Visibility>(Number),
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
},
style: processedPropType<StyleValue>(Object, String, Array),
@ -92,7 +92,7 @@ export default defineComponent({
comp.value = coerceComponent(currDisplay);
return;
}
const Title = coerceComponent(currDisplay.title || "", "h3");
const Title = coerceComponent(currDisplay.title ?? "", "h3");
const Description = coerceComponent(currDisplay.description, "div");
comp.value = coerceComponent(
jsx(() => (
@ -115,6 +115,8 @@ export default defineComponent({
stop,
comp,
Visibility,
isVisible,
isHidden,
unref
};
}

View file

@ -22,7 +22,7 @@ import { computed, unref } from "vue";
export const ClickableType = Symbol("Clickable");
export interface ClickableOptions {
visibility?: Computable<Visibility>;
visibility?: Computable<Visibility | boolean>;
canClick?: Computable<boolean>;
classes?: Computable<Record<string, boolean>>;
style?: Computable<StyleValue>;
@ -61,7 +61,7 @@ export type Clickable<T extends ClickableOptions> = Replace<
export type GenericClickable = Replace<
Clickable<ClickableOptions>,
{
visibility: ProcessedComputable<Visibility>;
visibility: ProcessedComputable<Visibility | boolean>;
canClick: ProcessedComputable<boolean>;
}
>;

View file

@ -460,7 +460,9 @@ export function setupPassiveGeneration(
conversion.gainResource.value = Decimal.add(
conversion.gainResource.value,
Decimal.times(currRate, diff).times(Decimal.ceil(unref(conversion.actualGain)))
).min(unref(processedCap) ?? Decimal.dInf);
)
.min(unref(processedCap) ?? Decimal.dInf)
.max(conversion.gainResource.value);
}
});
}

View file

@ -1,7 +1,7 @@
import Decimal from "util/bignum";
import { DoNotCache } from "util/computed";
import { DoNotCache, ProcessedComputable } from "util/computed";
import type { CSSProperties, DefineComponent } from "vue";
import { isRef } from "vue";
import { isRef, unref } from "vue";
/**
* A symbol to use as a key for a vue component a feature can be rendered with
@ -67,6 +67,16 @@ export enum Visibility {
None
}
export function isVisible(visibility: ProcessedComputable<Visibility | boolean>) {
const currVisibility = unref(visibility);
return currVisibility !== Visibility.None && currVisibility !== false;
}
export function isHidden(visibility: ProcessedComputable<Visibility | boolean>) {
const currVisibility = unref(visibility);
return currVisibility === Visibility.Hidden;
}
/**
* Takes a function and marks it as JSX so it won't get auto-wrapped into a ComputedRef.
* The function may also return empty string as empty JSX tags cause issues.
@ -76,11 +86,6 @@ export function jsx(func: () => JSX.Element | ""): JSXFunction {
return func as JSXFunction;
}
/** Utility function to convert a boolean value into a Visbility value */
export function showIf(condition: boolean, otherwise = Visibility.None): Visibility {
return condition ? Visibility.Visible : otherwise;
}
/** Utility function to set a property on an object if and only if it doesn't already exist */
export function setDefault<T, K extends keyof T>(
object: T,

View file

@ -1,8 +1,8 @@
<template>
<div
v-if="unref(visibility) !== Visibility.None"
v-if="isVisible(visibility)"
:style="{
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined
visibility: isHidden(visibility) ? 'hidden' : undefined
}"
class="table-grid"
>
@ -19,7 +19,7 @@
<script lang="ts">
import "components/common/table.css";
import themes from "data/themes";
import { Visibility } from "features/feature";
import { isHidden, isVisible, Visibility } from "features/feature";
import type { GridCell } from "features/grids/grid";
import settings from "game/settings";
import { processedPropType } from "util/vue";
@ -29,7 +29,7 @@ import GridCellVue from "./GridCell.vue";
export default defineComponent({
props: {
visibility: {
type: processedPropType<Visibility>(Number),
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
},
rows: {
@ -54,7 +54,7 @@ export default defineComponent({
return { visibility, onClick, onHold, display, title, style, canClick, id };
}
return { unref, gatherCellProps, Visibility, mergeAdjacent };
return { unref, gatherCellProps, Visibility, mergeAdjacent, isVisible, isHidden };
}
});
</script>

View file

@ -1,10 +1,10 @@
<template>
<button
v-if="unref(visibility) !== Visibility.None"
v-if="isVisible(visibility)"
:class="{ feature: true, tile: true, can: unref(canClick), locked: !unref(canClick) }"
:style="[
{
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined
visibility: isHidden(visibility) ? 'hidden' : undefined
},
unref(style) ?? {}
]"
@ -26,7 +26,7 @@
import "components/common/features.css";
import Node from "components/Node.vue";
import type { CoercableComponent, StyleValue } from "features/feature";
import { Visibility } from "features/feature";
import { isHidden, isVisible, Visibility } from "features/feature";
import {
computeComponent,
computeOptionalComponent,
@ -39,7 +39,7 @@ import { defineComponent, toRefs, unref } from "vue";
export default defineComponent({
props: {
visibility: {
type: processedPropType<Visibility>(Number),
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
},
onClick: Function as PropType<(e?: MouseEvent | TouchEvent) => void>,
@ -76,7 +76,9 @@ export default defineComponent({
titleComponent,
component,
Visibility,
unref
unref,
isVisible,
isHidden
};
}
});

View file

@ -171,7 +171,7 @@ function getCellHandler(id: string): ProxyHandler<GenericGrid> {
export interface GridCell {
id: string;
visibility: Visibility;
visibility: Visibility | boolean;
canClick: boolean;
startState: State;
state: State;
@ -184,10 +184,10 @@ export interface GridCell {
}
export interface GridOptions {
visibility?: Computable<Visibility>;
visibility?: Computable<Visibility | boolean>;
rows: Computable<number>;
cols: Computable<number>;
getVisibility?: CellComputable<Visibility>;
getVisibility?: CellComputable<Visibility | boolean>;
getCanClick?: CellComputable<boolean>;
getStartState: Computable<State> | ((id: string | number) => State);
getStyle?: CellComputable<StyleValue>;
@ -229,8 +229,8 @@ export type Grid<T extends GridOptions> = Replace<
export type GenericGrid = Replace<
Grid<GridOptions>,
{
visibility: ProcessedComputable<Visibility>;
getVisibility: ProcessedComputable<Visibility>;
visibility: ProcessedComputable<Visibility | boolean>;
getVisibility: ProcessedComputable<Visibility | boolean>;
getCanClick: ProcessedComputable<boolean>;
}
>;
@ -277,9 +277,9 @@ export function createGrid<T extends GridOptions>(
if (grid.onClick) {
const onClick = grid.onClick.bind(grid);
grid.onClick = function (id, state) {
grid.onClick = function (id, state, e) {
if (unref((grid as GenericGrid).cells[id].canClick)) {
onClick(id, state);
onClick(id, state, e);
}
};
}

View file

@ -1,11 +1,11 @@
<template>
<div
class="infobox"
v-if="unref(visibility) !== Visibility.None"
v-if="isVisible(visibility)"
:style="[
{
borderColor: unref(color),
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined
visibility: isHidden(visibility) ? 'hidden' : undefined
},
unref(style) ?? {}
]"
@ -33,7 +33,7 @@ import CollapseTransition from "@ivanv/vue-collapse-transition/src/CollapseTrans
import Node from "components/Node.vue";
import themes from "data/themes";
import type { CoercableComponent } from "features/feature";
import { Visibility } from "features/feature";
import { isHidden, isVisible, Visibility } from "features/feature";
import settings from "game/settings";
import { computeComponent, processedPropType } from "util/vue";
import type { PropType, Ref, StyleValue } from "vue";
@ -42,7 +42,7 @@ import { computed, defineComponent, toRefs, unref } from "vue";
export default defineComponent({
props: {
visibility: {
type: processedPropType<Visibility>(Number),
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
},
display: {
@ -83,7 +83,9 @@ export default defineComponent({
bodyComponent,
stacked,
unref,
Visibility
Visibility,
isVisible,
isHidden
};
}
});

View file

@ -16,7 +16,7 @@ import { unref } from "vue";
export const InfoboxType = Symbol("Infobox");
export interface InfoboxOptions {
visibility?: Computable<Visibility>;
visibility?: Computable<Visibility | boolean>;
color?: Computable<string>;
style?: Computable<StyleValue>;
titleStyle?: Computable<StyleValue>;
@ -51,7 +51,7 @@ export type Infobox<T extends InfoboxOptions> = Replace<
export type GenericInfobox = Replace<
Infobox<InfoboxOptions>,
{
visibility: ProcessedComputable<Visibility>;
visibility: ProcessedComputable<Visibility | boolean>;
}
>;

View file

@ -1,9 +1,9 @@
<template>
<div
v-if="unref(visibility) !== Visibility.None"
v-if="isVisible(visibility)"
:style="[
{
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined
visibility: isHidden(visibility) ? 'hidden' : undefined
},
unref(style) ?? {}
]"
@ -18,7 +18,7 @@
import "components/common/features.css";
import Node from "components/Node.vue";
import type { StyleValue } from "features/feature";
import { jsx, Visibility } from "features/feature";
import { isHidden, isVisible, jsx, Visibility } from "features/feature";
import type { GenericMilestone } from "features/milestones/milestone";
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
import type { Component, UnwrapRef } from "vue";
@ -27,7 +27,7 @@ import { defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
export default defineComponent({
props: {
visibility: {
type: processedPropType<Visibility>(Number),
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
},
display: {
@ -92,7 +92,9 @@ export default defineComponent({
return {
comp,
unref,
Visibility
Visibility,
isVisible,
isHidden
};
}
});

View file

@ -6,7 +6,15 @@ import type {
Replace,
StyleValue
} from "features/feature";
import { Component, GatherProps, getUniqueID, jsx, setDefault, Visibility } from "features/feature";
import {
Component,
GatherProps,
getUniqueID,
isVisible,
jsx,
setDefault,
Visibility
} from "features/feature";
import MilestoneComponent from "features/milestones/Milestone.vue";
import { globalBus } from "game/events";
import "game/notifications";
@ -40,7 +48,7 @@ export enum MilestoneDisplay {
}
export interface MilestoneOptions {
visibility?: Computable<Visibility>;
visibility?: Computable<Visibility | boolean>;
shouldEarn?: () => boolean;
style?: Computable<StyleValue>;
classes?: Computable<Record<string, boolean>>;
@ -79,7 +87,7 @@ export type Milestone<T extends MilestoneOptions> = Replace<
export type GenericMilestone = Replace<
Milestone<MilestoneOptions>,
{
visibility: ProcessedComputable<Visibility>;
visibility: ProcessedComputable<Visibility | boolean>;
}
>;
@ -118,7 +126,7 @@ export function createMilestone<T extends MilestoneOptions>(
processComputable(milestone as T, "visibility");
setDefault(milestone, "visibility", Visibility.Visible);
const visibility = milestone.visibility as ProcessedComputable<Visibility>;
const visibility = milestone.visibility as ProcessedComputable<Visibility | boolean>;
milestone.visibility = computed(() => {
const display = unref((milestone as GenericMilestone).display);
switch (settings.msDisplay) {
@ -163,7 +171,7 @@ export function createMilestone<T extends MilestoneOptions>(
if (settings.active !== player.id) return;
if (
!genericMilestone.earned.value &&
unref(genericMilestone.visibility) === Visibility.Visible &&
isVisible(genericMilestone.visibility) &&
genericMilestone.shouldEarn?.()
) {
genericMilestone.earned.value = true;
@ -213,7 +221,12 @@ const msDisplayOptions = Object.values(MilestoneDisplay).map(option => ({
registerSettingField(
jsx(() => (
<Select
title="Show Milestones"
title={jsx(() => (
<span class="option-title">
Show milestones
<desc>Select which milestones to display based on criterias.</desc>
</span>
))}
options={msDisplayOptions}
onUpdate:modelValue={value => (settings.msDisplay = value as MilestoneDisplay)}
modelValue={settings.msDisplay}

275
src/features/repeatable.tsx Normal file
View file

@ -0,0 +1,275 @@
import { isArray } from "@vue/shared";
import ClickableComponent from "features/clickables/Clickable.vue";
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
import { Component, GatherProps, getUniqueID, jsx, setDefault, Visibility } from "features/feature";
import { DefaultValue, Persistent, persistent } from "game/persistence";
import {
createVisibilityRequirement,
displayRequirements,
maxRequirementsMet,
payRequirements,
Requirements,
requirementsMet
} from "game/requirements";
import type { DecimalSource } from "util/bignum";
import Decimal, { formatWhole } from "util/bignum";
import type {
Computable,
GetComputableType,
GetComputableTypeWithDefault,
ProcessedComputable
} from "util/computed";
import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
import { coerceComponent, isCoercableComponent } from "util/vue";
import type { Ref } from "vue";
import { computed, unref } from "vue";
/** A symbol used to identify {@link Repeatable} features. */
export const RepeatableType = Symbol("Repeatable");
/** A type that can be used to customize the {@link Repeatable} display. */
export type RepeatableDisplay =
| CoercableComponent
| {
/** A header to appear at the top of the display. */
title?: CoercableComponent;
/** The main text that appears in the display. */
description?: CoercableComponent;
/** A description of the current effect of this repeatable, bsed off its amount. */
effectDisplay?: CoercableComponent;
/** Whether or not to show the current amount of this repeatable at the bottom of the display. */
showAmount?: boolean;
};
/** An object that configures a {@link Repeatable}. */
export interface RepeatableOptions {
/** Whether this repeatable should be visible. */
visibility?: Computable<Visibility | boolean>;
/** The requirement(s) to increase this repeatable. */
requirements: Requirements;
/** The maximum amount obtainable for this repeatable. */
limit?: Computable<DecimalSource>;
/** The initial amount this repeatable has on a new save / after reset. */
initialAmount?: DecimalSource;
/** Dictionary of CSS classes to apply to this feature. */
classes?: Computable<Record<string, boolean>>;
/** CSS to apply to this feature. */
style?: Computable<StyleValue>;
/** Shows a marker on the corner of the feature. */
mark?: Computable<boolean | string>;
/** Toggles a smaller design for the feature. */
small?: Computable<boolean>;
/** Whether or not clicking this repeatable should attempt to maximize amount based on the requirements met. Requires {@link requirements} to be a requirement or array of requirements with {@link Requirement.canMaximize} true. */
maximize?: Computable<boolean>;
/** The display to use for this repeatable. */
display?: Computable<RepeatableDisplay>;
}
/**
* The properties that are added onto a processed {@link RepeatableOptions} to create a {@link Repeatable}.
*/
export interface BaseRepeatable {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persistent between refreshes or updates. */
id: string;
/** The current amount this repeatable has. */
amount: Persistent<DecimalSource>;
/** Whether or not this repeatable's amount is at it's limit. */
maxed: Ref<boolean>;
/** Whether or not this repeatable can be clicked. */
canClick: ProcessedComputable<boolean>;
/** A function that gets called when this repeatable is clicked. */
onClick: (event?: MouseEvent | TouchEvent) => void;
/** A symbol that helps identify features of the same type. */
type: typeof RepeatableType;
/** The Vue component used to render this feature. */
[Component]: typeof ClickableComponent;
/** A function to gather the props the vue component requires for this feature. */
[GatherProps]: () => Record<string, unknown>;
}
/** An object that represents a feature with multiple "levels" with scaling requirements. */
export type Repeatable<T extends RepeatableOptions> = Replace<
T & BaseRepeatable,
{
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
requirements: GetComputableType<T["requirements"]>;
limit: GetComputableTypeWithDefault<T["limit"], Decimal>;
classes: GetComputableType<T["classes"]>;
style: GetComputableType<T["style"]>;
mark: GetComputableType<T["mark"]>;
small: GetComputableType<T["small"]>;
maximize: GetComputableType<T["maximize"]>;
display: Ref<CoercableComponent>;
}
>;
/** A type that matches any valid {@link Repeatable} object. */
export type GenericRepeatable = Replace<
Repeatable<RepeatableOptions>,
{
visibility: ProcessedComputable<Visibility | boolean>;
limit: ProcessedComputable<DecimalSource>;
}
>;
/**
* Lazily creates a repeatable with the given options.
* @param optionsFunc Repeatable options.
*/
export function createRepeatable<T extends RepeatableOptions>(
optionsFunc: OptionsFunc<T, BaseRepeatable, GenericRepeatable>
): Repeatable<T> {
const amount = persistent<DecimalSource>(0);
return createLazyProxy(() => {
const repeatable = optionsFunc();
repeatable.id = getUniqueID("repeatable-");
repeatable.type = RepeatableType;
repeatable[Component] = ClickableComponent;
repeatable.amount = amount;
repeatable.amount[DefaultValue] = repeatable.initialAmount ?? 0;
const limitRequirement = {
requirementMet: computed(() =>
Decimal.sub(
unref((repeatable as GenericRepeatable).limit),
(repeatable as GenericRepeatable).amount.value
)
),
requiresPay: false,
visibility: Visibility.None
} as const;
const visibilityRequirement = createVisibilityRequirement(repeatable as GenericRepeatable);
if (isArray(repeatable.requirements)) {
repeatable.requirements.unshift(visibilityRequirement);
repeatable.requirements.push(limitRequirement);
} else {
repeatable.requirements = [
visibilityRequirement,
repeatable.requirements,
limitRequirement
];
}
repeatable.maxed = computed(() =>
Decimal.gte(
(repeatable as GenericRepeatable).amount.value,
unref((repeatable as GenericRepeatable).limit)
)
);
processComputable(repeatable as T, "classes");
const classes = repeatable.classes as
| ProcessedComputable<Record<string, boolean>>
| undefined;
repeatable.classes = computed(() => {
const currClasses = unref(classes) || {};
if ((repeatable as GenericRepeatable).maxed.value) {
currClasses.bought = true;
}
return currClasses;
});
repeatable.canClick = computed(() => requirementsMet(repeatable.requirements));
const onClick = repeatable.onClick;
repeatable.onClick = function (this: GenericRepeatable, event?: MouseEvent | TouchEvent) {
const genericRepeatable = repeatable as GenericRepeatable;
if (!unref(genericRepeatable.canClick)) {
return;
}
payRequirements(
repeatable.requirements,
unref(genericRepeatable.maximize)
? maxRequirementsMet(genericRepeatable.requirements)
: 1
);
genericRepeatable.amount.value = Decimal.add(genericRepeatable.amount.value, 1);
onClick?.(event);
};
processComputable(repeatable as T, "display");
const display = repeatable.display;
repeatable.display = jsx(() => {
// TODO once processComputable types correctly, remove this "as X"
const currDisplay = unref(display) as RepeatableDisplay;
if (isCoercableComponent(currDisplay)) {
const CurrDisplay = coerceComponent(currDisplay);
return <CurrDisplay />;
}
if (currDisplay != null) {
const genericRepeatable = repeatable as GenericRepeatable;
const Title = coerceComponent(currDisplay.title ?? "", "h3");
const Description = coerceComponent(currDisplay.description ?? "");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay ?? "");
return (
<span>
{currDisplay.title == null ? null : (
<div>
<Title />
</div>
)}
{currDisplay.description == null ? null : <Description />}
{currDisplay.showAmount === false ? null : (
<div>
<br />
{unref(genericRepeatable.limit) === Decimal.dInf ? (
<>Amount: {formatWhole(genericRepeatable.amount.value)}</>
) : (
<>
Amount: {formatWhole(genericRepeatable.amount.value)} /{" "}
{formatWhole(unref(genericRepeatable.limit))}
</>
)}
</div>
)}
{currDisplay.effectDisplay == null ? null : (
<div>
<br />
Currently: <EffectDisplay />
</div>
)}
{genericRepeatable.maxed.value ? null : (
<div>
<br />
{displayRequirements(
genericRepeatable.requirements,
unref(genericRepeatable.maximize)
? maxRequirementsMet(genericRepeatable.requirements)
: 1
)}
</div>
)}
</span>
);
}
return "";
});
processComputable(repeatable as T, "visibility");
setDefault(repeatable, "visibility", Visibility.Visible);
processComputable(repeatable as T, "limit");
setDefault(repeatable, "limit", Decimal.dInf);
processComputable(repeatable as T, "style");
processComputable(repeatable as T, "mark");
processComputable(repeatable as T, "small");
processComputable(repeatable as T, "maximize");
repeatable[GatherProps] = function (this: GenericRepeatable) {
const { display, visibility, style, classes, onClick, canClick, small, mark, id } =
this;
return {
display,
visibility,
style: unref(style),
classes,
onClick,
canClick,
small,
mark,
id
};
};
return repeatable as unknown as Repeatable<T>;
});
}

View file

@ -1,11 +1,11 @@
<template>
<button
v-if="unref(visibility) !== Visibility.None"
v-if="isVisible(visibility)"
@click="selectTab"
class="tabButton"
:style="[
{
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined
visibility: isHidden(visibility) ? 'hidden' : undefined
},
glowColorStyle,
unref(style) ?? {}
@ -21,7 +21,7 @@
<script lang="ts">
import type { CoercableComponent, StyleValue } from "features/feature";
import { Visibility } from "features/feature";
import { isHidden, isVisible, Visibility } from "features/feature";
import { getNotifyStyle } from "game/notifications";
import { computeComponent, processedPropType, unwrapRef } from "util/vue";
import { computed, defineComponent, toRefs, unref } from "vue";
@ -29,7 +29,7 @@ import { computed, defineComponent, toRefs, unref } from "vue";
export default defineComponent({
props: {
visibility: {
type: processedPropType<Visibility>(Number),
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
},
display: {
@ -50,7 +50,7 @@ export default defineComponent({
const glowColorStyle = computed(() => {
const color = unwrapRef(glowColor);
if (!color) {
if (color != null) {
return {};
}
if (unref(floating)) {
@ -68,7 +68,9 @@ export default defineComponent({
component,
glowColorStyle,
unref,
Visibility
Visibility,
isVisible,
isHidden
};
}
});

View file

@ -1,11 +1,11 @@
<template>
<div
v-if="unref(visibility) !== Visibility.None"
v-if="isVisible(visibility)"
class="tab-family-container"
:class="{ ...unref(classes), ...tabClasses }"
:style="[
{
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined
visibility: isHidden(visibility) ? 'hidden' : undefined
},
unref(style) ?? [],
tabStyle ?? []
@ -37,7 +37,7 @@
import Sticky from "components/layout/Sticky.vue";
import themes from "data/themes";
import type { CoercableComponent, StyleValue } from "features/feature";
import { Visibility } from "features/feature";
import { isHidden, isVisible, Visibility } from "features/feature";
import type { GenericTab } from "features/tabs/tab";
import TabButton from "features/tabs/TabButton.vue";
import type { GenericTabButton } from "features/tabs/tabFamily";
@ -49,7 +49,7 @@ import { computed, defineComponent, shallowRef, toRefs, unref, watchEffect } fro
export default defineComponent({
props: {
visibility: {
type: processedPropType<Visibility>(Number),
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
},
activeTab: {
@ -123,7 +123,9 @@ export default defineComponent({
Visibility,
component,
gatherButtonProps,
unref
unref,
isVisible,
isHidden
};
}
});

View file

@ -1,5 +1,12 @@
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
import { Component, GatherProps, getUniqueID, setDefault, Visibility } from "features/feature";
import {
Component,
GatherProps,
getUniqueID,
isVisible,
setDefault,
Visibility
} from "features/feature";
import TabButtonComponent from "features/tabs/TabButton.vue";
import TabFamilyComponent from "features/tabs/TabFamily.vue";
import type { Persistent } from "game/persistence";
@ -20,7 +27,7 @@ export const TabButtonType = Symbol("TabButton");
export const TabFamilyType = Symbol("TabFamily");
export interface TabButtonOptions {
visibility?: Computable<Visibility>;
visibility?: Computable<Visibility | boolean>;
tab: Computable<GenericTab | CoercableComponent>;
display: Computable<CoercableComponent>;
classes?: Computable<Record<string, boolean>>;
@ -48,12 +55,12 @@ export type TabButton<T extends TabButtonOptions> = Replace<
export type GenericTabButton = Replace<
TabButton<TabButtonOptions>,
{
visibility: ProcessedComputable<Visibility>;
visibility: ProcessedComputable<Visibility | boolean>;
}
>;
export interface TabFamilyOptions {
visibility?: Computable<Visibility>;
visibility?: Computable<Visibility | boolean>;
classes?: Computable<Record<string, boolean>>;
style?: Computable<StyleValue>;
buttonContainerClasses?: Computable<Record<string, boolean>>;
@ -81,7 +88,7 @@ export type TabFamily<T extends TabFamilyOptions> = Replace<
export type GenericTabFamily = Replace<
TabFamily<TabFamilyOptions>,
{
visibility: ProcessedComputable<Visibility>;
visibility: ProcessedComputable<Visibility | boolean>;
}
>;
@ -123,15 +130,10 @@ export function createTabFamily<T extends TabFamilyOptions>(
tabFamily.selected = selected;
tabFamily.activeTab = computed(() => {
const tabs = unref(processedTabFamily.tabs);
if (
selected.value in tabs &&
unref(tabs[selected.value].visibility) === Visibility.Visible
) {
if (selected.value in tabs && isVisible(tabs[selected.value].visibility)) {
return unref(tabs[selected.value].tab);
}
const firstTab = Object.values(tabs).find(
tab => unref(tab.visibility) === Visibility.Visible
);
const firstTab = Object.values(tabs).find(tab => isVisible(tab.visibility));
if (firstTab) {
return unref(firstTab.tab);
}

View file

@ -1,7 +1,7 @@
<template>
<div
v-if="unref(visibility) !== Visibility.None"
:style="{ visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined }"
v-if="isVisible(visibility)"
:style="{ visibility: isHidden(visibility) ? 'hidden' : undefined }"
:class="{
treeNode: true,
can: unref(canClick),
@ -37,7 +37,7 @@
import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue";
import type { CoercableComponent, StyleValue } from "features/feature";
import { Visibility } from "features/feature";
import { isHidden, isVisible, Visibility } from "features/feature";
import {
computeOptionalComponent,
isCoercableComponent,
@ -51,7 +51,7 @@ export default defineComponent({
props: {
display: processedPropType<CoercableComponent>(Object, String, Function),
visibility: {
type: processedPropType<Visibility>(Number),
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
},
style: processedPropType<StyleValue>(String, Object, Array),
@ -87,7 +87,9 @@ export default defineComponent({
comp,
unref,
Visibility,
isCoercableComponent
isCoercableComponent,
isVisible,
isHidden
};
}
});

View file

@ -23,7 +23,7 @@ export const TreeNodeType = Symbol("TreeNode");
export const TreeType = Symbol("Tree");
export interface TreeNodeOptions {
visibility?: Computable<Visibility>;
visibility?: Computable<Visibility | boolean>;
canClick?: Computable<boolean>;
color?: Computable<string>;
display?: Computable<CoercableComponent>;
@ -60,7 +60,7 @@ export type TreeNode<T extends TreeNodeOptions> = Replace<
export type GenericTreeNode = Replace<
TreeNode<TreeNodeOptions>,
{
visibility: ProcessedComputable<Visibility>;
visibility: ProcessedComputable<Visibility | boolean>;
canClick: ProcessedComputable<boolean>;
}
>;
@ -87,9 +87,9 @@ export function createTreeNode<T extends TreeNodeOptions>(
if (treeNode.onClick) {
const onClick = treeNode.onClick.bind(treeNode);
treeNode.onClick = function () {
treeNode.onClick = function (e) {
if (unref(treeNode.canClick) !== false) {
onClick();
onClick(e);
}
};
}
@ -141,7 +141,7 @@ export interface TreeBranch extends Omit<Link, "startNode" | "endNode"> {
}
export interface TreeOptions {
visibility?: Computable<Visibility>;
visibility?: Computable<Visibility | boolean>;
nodes: Computable<GenericTreeNode[][]>;
leftSideNodes?: Computable<GenericTreeNode[]>;
rightSideNodes?: Computable<GenericTreeNode[]>;
@ -175,7 +175,7 @@ export type Tree<T extends TreeOptions> = Replace<
export type GenericTree = Replace<
Tree<TreeOptions>,
{
visibility: ProcessedComputable<Visibility>;
visibility: ProcessedComputable<Visibility | boolean>;
}
>;

View file

@ -1,9 +1,9 @@
<template>
<button
v-if="unref(visibility) !== Visibility.None"
v-if="isVisible(visibility)"
:style="[
{
visibility: unref(visibility) === Visibility.Hidden ? 'hidden' : undefined
visibility: isHidden(visibility) ? 'hidden' : undefined
},
unref(style) ?? {}
]"
@ -29,11 +29,9 @@ import "components/common/features.css";
import MarkNode from "components/MarkNode.vue";
import Node from "components/Node.vue";
import type { StyleValue } from "features/feature";
import { jsx, Visibility } from "features/feature";
import type { Resource } from "features/resources/resource";
import { displayResource } from "features/resources/resource";
import { isHidden, isVisible, jsx, Visibility } from "features/feature";
import type { GenericUpgrade } from "features/upgrades/upgrade";
import type { DecimalSource } from "util/bignum";
import { displayRequirements, Requirements } from "game/requirements";
import { coerceComponent, isCoercableComponent, processedPropType, unwrapRef } from "util/vue";
import type { Component, PropType, UnwrapRef } from "vue";
import { defineComponent, shallowRef, toRefs, unref, watchEffect } from "vue";
@ -45,13 +43,15 @@ export default defineComponent({
required: true
},
visibility: {
type: processedPropType<Visibility>(Number),
type: processedPropType<Visibility | boolean>(Number, Boolean),
required: true
},
style: processedPropType<StyleValue>(String, Object, Array),
classes: processedPropType<Record<string, boolean>>(Object),
resource: Object as PropType<Resource>,
cost: processedPropType<DecimalSource>(String, Object, Number),
requirements: {
type: Object as PropType<Requirements>,
required: true
},
canPurchase: {
type: processedPropType<boolean>(Boolean),
required: true
@ -75,7 +75,7 @@ export default defineComponent({
MarkNode
},
setup(props) {
const { display, cost } = toRefs(props);
const { display, requirements, bought } = toRefs(props);
const component = shallowRef<Component | string>("");
@ -89,7 +89,6 @@ export default defineComponent({
component.value = coerceComponent(currDisplay);
return;
}
const currCost = unwrapRef(cost);
const Title = coerceComponent(currDisplay.title || "", "h3");
const Description = coerceComponent(currDisplay.description, "div");
const EffectDisplay = coerceComponent(currDisplay.effectDisplay || "");
@ -107,14 +106,7 @@ export default defineComponent({
Currently: <EffectDisplay />
</div>
) : null}
{props.resource != null ? (
<>
<br />
Cost: {props.resource &&
displayResource(props.resource, currCost)}{" "}
{props.resource?.displayName}
</>
) : null}
{bought.value ? null : <><br />{displayRequirements(requirements.value)}</>}
</span>
))
);
@ -123,7 +115,9 @@ export default defineComponent({
return {
component,
unref,
Visibility
Visibility,
isVisible,
isHidden
};
}
});

View file

@ -1,4 +1,11 @@
import type { CoercableComponent, OptionsFunc, Replace, StyleValue } from "features/feature";
import { isArray } from "@vue/shared";
import type {
CoercableComponent,
GenericComponent,
OptionsFunc,
Replace,
StyleValue
} from "features/feature";
import {
Component,
findFeatures,
@ -7,13 +14,16 @@ import {
setDefault,
Visibility
} from "features/feature";
import type { Resource } from "features/resources/resource";
import UpgradeComponent from "features/upgrades/Upgrade.vue";
import type { GenericLayer } from "game/layers";
import type { Persistent } from "game/persistence";
import { persistent } from "game/persistence";
import type { DecimalSource } from "util/bignum";
import Decimal from "util/bignum";
import {
createVisibilityRequirement,
payRequirements,
Requirements,
requirementsMet
} from "game/requirements";
import { isFunction } from "util/common";
import type {
Computable,
@ -29,7 +39,7 @@ import { computed, unref } from "vue";
export const UpgradeType = Symbol("Upgrade");
export interface UpgradeOptions {
visibility?: Computable<Visibility>;
visibility?: Computable<Visibility | boolean>;
classes?: Computable<Record<string, boolean>>;
style?: Computable<StyleValue>;
display?: Computable<
@ -40,10 +50,8 @@ export interface UpgradeOptions {
effectDisplay?: CoercableComponent;
}
>;
requirements: Requirements;
mark?: Computable<boolean | string>;
cost?: Computable<DecimalSource>;
resource?: Resource;
canAfford?: Computable<boolean>;
onPurchase?: VoidFunction;
}
@ -64,17 +72,15 @@ export type Upgrade<T extends UpgradeOptions> = Replace<
classes: GetComputableType<T["classes"]>;
style: GetComputableType<T["style"]>;
display: GetComputableType<T["display"]>;
requirements: GetComputableType<T["requirements"]>;
mark: GetComputableType<T["mark"]>;
cost: GetComputableType<T["cost"]>;
canAfford: GetComputableTypeWithDefault<T["canAfford"], Ref<boolean>>;
}
>;
export type GenericUpgrade = Replace<
Upgrade<UpgradeOptions>,
{
visibility: ProcessedComputable<Visibility>;
canPurchase: ProcessedComputable<boolean>;
visibility: ProcessedComputable<Visibility | boolean>;
}
>;
@ -88,55 +94,31 @@ export function createUpgrade<T extends UpgradeOptions>(
upgrade.type = UpgradeType;
upgrade[Component] = UpgradeComponent;
if (upgrade.canAfford == null && (upgrade.resource == null || upgrade.cost == null)) {
console.warn(
"Error: can't create upgrade without a canAfford property or a resource and cost property",
upgrade
);
}
upgrade.bought = bought;
if (upgrade.canAfford == null) {
upgrade.canAfford = computed(() => {
const genericUpgrade = upgrade as GenericUpgrade;
return (
genericUpgrade.resource != null &&
genericUpgrade.cost != null &&
Decimal.gte(genericUpgrade.resource.value, unref(genericUpgrade.cost))
);
});
} else {
processComputable(upgrade as T, "canAfford");
}
upgrade.canPurchase = computed(
() =>
unref((upgrade as GenericUpgrade).visibility) === Visibility.Visible &&
unref((upgrade as GenericUpgrade).canAfford) &&
!unref(upgrade.bought)
);
upgrade.canPurchase = computed(() => requirementsMet(upgrade.requirements));
upgrade.purchase = function () {
const genericUpgrade = upgrade as GenericUpgrade;
if (!unref(genericUpgrade.canPurchase)) {
return;
}
if (genericUpgrade.resource != null && genericUpgrade.cost != null) {
genericUpgrade.resource.value = Decimal.sub(
genericUpgrade.resource.value,
unref(genericUpgrade.cost)
);
}
payRequirements(upgrade.requirements);
bought.value = true;
genericUpgrade.onPurchase?.();
};
const visibilityRequirement = createVisibilityRequirement(upgrade as GenericUpgrade);
if (isArray(upgrade.requirements)) {
upgrade.requirements.unshift(visibilityRequirement);
} else {
upgrade.requirements = [visibilityRequirement, upgrade.requirements];
}
processComputable(upgrade as T, "visibility");
setDefault(upgrade, "visibility", Visibility.Visible);
processComputable(upgrade as T, "classes");
processComputable(upgrade as T, "style");
processComputable(upgrade as T, "display");
processComputable(upgrade as T, "mark");
processComputable(upgrade as T, "cost");
processComputable(upgrade as T, "resource");
upgrade[GatherProps] = function (this: GenericUpgrade) {
const {
@ -144,8 +126,7 @@ export function createUpgrade<T extends UpgradeOptions>(
visibility,
style,
classes,
resource,
cost,
requirements,
canPurchase,
bought,
mark,
@ -157,8 +138,7 @@ export function createUpgrade<T extends UpgradeOptions>(
visibility,
style: unref(style),
classes,
resource,
cost,
requirements,
canPurchase,
bought,
mark,

View file

@ -54,4 +54,8 @@ export interface GlobalEvents {
/** A global event bus for hooking into {@link GlobalEvents}. */
export const globalBus = createNanoEvents<GlobalEvents>();
document.fonts.onloadingdone = () => globalBus.emit("fontsLoaded");
if ("fonts" in document) {
// This line breaks tests
// JSDom doesn't add document.fonts, and Object.defineProperty doesn't seem to work on document
document.fonts.onloadingdone = () => globalBus.emit("fontsLoaded");
}

2247
src/game/formulas.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -21,7 +21,7 @@ import type {
} from "util/computed";
import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies";
import type { InjectionKey, Ref } from "vue";
import { computed, InjectionKey, Ref } from "vue";
import { ref, shallowReactive, unref } from "vue";
/** A feature's node in the DOM that has its size tracked. */
@ -109,7 +109,7 @@ export interface LayerOptions {
color?: Computable<string>;
/**
* The layout of this layer's features.
* When the layer is open in {@link game/player.PlayerData.tabs}, this is the content that is display.
* When the layer is open in {@link game/player.PlayerData.tabs}, this is the content that is displayed.
*/
display: Computable<CoercableComponent>;
/** An object of classes that should be applied to the display. */
@ -126,6 +126,11 @@ export interface LayerOptions {
* Defaults to true.
*/
minimizable?: Computable<boolean>;
/**
* The layout of this layer's features.
* When the layer is open in {@link game/player.PlayerData.tabs}, but the tab is {@link Layer.minimized} this is the content that is displayed.
*/
minimizedDisplay?: Computable<CoercableComponent>;
/**
* Whether or not to force the go back button to be hidden.
* If true, go back will be hidden regardless of {@link data/projInfo.allowGoBack}.
@ -170,6 +175,7 @@ export type Layer<T extends LayerOptions> = Replace<
name: GetComputableTypeWithDefault<T["name"], string>;
minWidth: GetComputableTypeWithDefault<T["minWidth"], 600>;
minimizable: GetComputableTypeWithDefault<T["minimizable"], true>;
minimizedDisplay: GetComputableType<T["minimizedDisplay"]>;
forceHideGoBack: GetComputableType<T["forceHideGoBack"]>;
}
>;
@ -225,12 +231,43 @@ export function createLayer<T extends LayerOptions>(
processComputable(layer as T, "color");
processComputable(layer as T, "display");
processComputable(layer as T, "classes");
processComputable(layer as T, "style");
processComputable(layer as T, "name");
setDefault(layer, "name", layer.id);
processComputable(layer as T, "minWidth");
setDefault(layer, "minWidth", 600);
processComputable(layer as T, "minimizable");
setDefault(layer, "minimizable", true);
processComputable(layer as T, "minimizedDisplay");
const style = layer.style as ProcessedComputable<StyleValue> | undefined;
layer.style = computed(() => {
let width = unref(layer.minWidth as ProcessedComputable<number | string>);
if (typeof width === "number" || !Number.isNaN(parseInt(width))) {
width = width + "px";
}
return [
unref(style) ?? "",
layer.minimized?.value
? {
flexGrow: "0",
flexShrink: "0",
width: "60px",
minWidth: "",
flexBasis: "",
margin: "0"
}
: {
flexGrow: "",
flexShrink: "",
width: "",
minWidth: width,
flexBasis: width,
margin: ""
}
];
}) as Ref<StyleValue>;
return layer as unknown as Layer<T>;
});

View file

@ -1,6 +1,7 @@
import "components/common/modifiers.css";
import type { CoercableComponent } from "features/feature";
import { jsx } from "features/feature";
import settings from "game/settings";
import type { DecimalSource } from "util/bignum";
import Decimal, { formatSmall } from "util/bignum";
import type { WithRequired } from "util/common";
@ -14,8 +15,7 @@ import { computed, unref } from "vue";
* An object that can be used to apply or unapply some modification to a number.
* Being reversible requires the operation being invertible, but some features may rely on that.
* Descriptions can be optionally included for displaying them to the player.
* The built-in modifier creators are designed to display the modifiers using.
* {@link createModifierSection}.
* The built-in modifier creators are designed to display the modifiers using {@link createModifierSection}.
*/
export interface Modifier {
/** Applies some operation on the input and returns the result. */
@ -53,6 +53,8 @@ export interface AdditiveModifierOptions {
description?: Computable<CoercableComponent> | undefined;
/** A computable that will be processed and passed directly into the returned modifier. */
enabled?: Computable<boolean> | undefined;
/** Determines if numbers larger or smaller than 0 should be displayed as red. */
smallerIsBetter?: boolean;
}
/**
@ -63,7 +65,7 @@ export function createAdditiveModifier<T extends AdditiveModifierOptions>(
optionsFunc: () => T
): ModifierFromOptionalParams<T["description"], T["enabled"]> {
return createLazyProxy(() => {
const { addend, description, enabled } = optionsFunc();
const { addend, description, enabled, smallerIsBetter } = optionsFunc();
const processedAddend = convertComputable(addend);
const processedDescription = convertComputable(description);
@ -83,7 +85,18 @@ export function createAdditiveModifier<T extends AdditiveModifierOptions>(
{renderJSX(unref(processedDescription)!)}
</span>
) : null}
<span class="modifier-amount">
<span
class="modifier-amount"
style={
(
smallerIsBetter === true
? Decimal.gt(unref(processedAddend), 0)
: Decimal.lt(unref(processedAddend), 0)
)
? "color: var(--danger)"
: ""
}
>
{Decimal.gte(unref(processedAddend), 0) ? "+" : ""}
{formatSmall(unref(processedAddend))}
</span>
@ -101,6 +114,8 @@ export interface MultiplicativeModifierOptions {
description?: Computable<CoercableComponent> | undefined;
/** A computable that will be processed and passed directly into the returned modifier. */
enabled?: Computable<boolean> | undefined;
/** Determines if numbers larger or smaller than 1 should be displayed as red. */
smallerIsBetter?: boolean;
}
/**
@ -111,7 +126,7 @@ export function createMultiplicativeModifier<T extends MultiplicativeModifierOpt
optionsFunc: () => T
): ModifierFromOptionalParams<T["description"], T["enabled"]> {
return createLazyProxy(() => {
const { multiplier, description, enabled } = optionsFunc();
const { multiplier, description, enabled, smallerIsBetter } = optionsFunc();
const processedMultiplier = convertComputable(multiplier);
const processedDescription = convertComputable(description);
@ -131,7 +146,18 @@ export function createMultiplicativeModifier<T extends MultiplicativeModifierOpt
{renderJSX(unref(processedDescription)!)}
</span>
) : null}
<span class="modifier-amount">
<span
class="modifier-amount"
style={
(
smallerIsBetter === true
? Decimal.gt(unref(processedMultiplier), 1)
: Decimal.lt(unref(processedMultiplier), 1)
)
? "color: var(--danger)"
: ""
}
>
×{formatSmall(unref(processedMultiplier))}
</span>
</div>
@ -150,6 +176,8 @@ export interface ExponentialModifierOptions {
enabled?: Computable<boolean> | undefined;
/** Add 1 before calculating, then remove it afterwards. This prevents low numbers from becoming lower. */
supportLowNumbers?: boolean;
/** Determines if numbers larger or smaller than 1 should be displayed as red. */
smallerIsBetter?: boolean;
}
/**
@ -160,7 +188,8 @@ export function createExponentialModifier<T extends ExponentialModifierOptions>(
optionsFunc: () => T
): ModifierFromOptionalParams<T["description"], T["enabled"]> {
return createLazyProxy(() => {
const { exponent, description, enabled, supportLowNumbers } = optionsFunc();
const { exponent, description, enabled, supportLowNumbers, smallerIsBetter } =
optionsFunc();
const processedExponent = convertComputable(exponent);
const processedDescription = convertComputable(description);
@ -201,7 +230,18 @@ export function createExponentialModifier<T extends ExponentialModifierOptions>(
{supportLowNumbers ? " (+1 effective)" : null}
</span>
) : null}
<span class="modifier-amount">
<span
class="modifier-amount"
style={
(
smallerIsBetter === true
? Decimal.gt(unref(processedExponent), 1)
: Decimal.lt(unref(processedExponent), 1)
)
? "color: var(--danger)"
: ""
}
>
^{formatSmall(unref(processedExponent))}
</span>
</div>
@ -253,35 +293,50 @@ export function createSequentialModifier<
}) as unknown as S;
}
/** An object that configures a modifier section via {@link createModifierSection}. */
export interface ModifierSectionOptions {
/** The header for the section. */
title: string;
/** Smaller text that appears in the header after the title. */
subtitle?: string;
/** The modifier to render. */
modifier: WithRequired<Modifier, "description">;
/** The base value that'll be passed into the modifier. Defaults to 1. */
base?: DecimalSource;
/** The unit of the value being modified, if any. */
unit?: string;
/** The label to use for the base value. Defaults to "Base". */
baseText?: CoercableComponent;
/** Determines if numbers larger or smaller than the base should be displayed as red. */
smallerIsBetter?: boolean;
}
/**
* Create a JSX element that displays a modifier.
* Intended to be used with the output from {@link createSequentialModifier}.
* @param title The header for the section.
* @param subtitle Smaller text that appears in the header after the title.
* @param modifier The modifier to render.
* @param base The base value that'll be passed into the modifier.
* @param unit The unit of the value being modified, if any.
* @param baseText The label to use for the base value.
* @param options Modifier section options.
*/
export function createModifierSection(
title: string,
subtitle: string,
modifier: WithRequired<Modifier, "description">,
base: DecimalSource = 1,
unit = "",
baseText: CoercableComponent = "Base"
) {
export function createModifierSection({
title,
subtitle,
modifier,
base,
unit,
baseText,
smallerIsBetter
}: ModifierSectionOptions) {
const total = modifier.apply(base ?? 1);
return (
<div>
<div style={{ "--unit": settings.alignUnits && unit != null ? "'" + unit + "'" : "" }}>
<h3>
{title}
{subtitle ? <span class="subtitle"> ({subtitle})</span> : null}
{subtitle == null ? null : <span class="subtitle"> ({subtitle})</span>}
</h3>
<br />
<div class="modifier-container">
<span class="modifier-description">{renderJSX(baseText)}</span>
<span class="modifier-description">{renderJSX(baseText ?? "Base")}</span>
<span class="modifier-amount">
{formatSmall(base)}
{formatSmall(base ?? 1)}
{unit}
</span>
</div>
@ -289,8 +344,19 @@ export function createModifierSection(
<hr />
<div class="modifier-container">
<span class="modifier-description">Total</span>
<span class="modifier-amount">
{formatSmall(modifier.apply(base))}
<span
class="modifier-amount"
style={
(
smallerIsBetter === true
? Decimal.gt(total, base ?? 1)
: Decimal.lt(total, base ?? 1)
)
? "color: var(--danger)"
: ""
}
>
{formatSmall(total)}
{unit}
</span>
</div>

View file

@ -7,6 +7,8 @@ import Decimal from "util/bignum";
import { ProxyState } from "util/proxies";
import type { Ref, WritableComputedRef } from "vue";
import { computed, isReactive, isRef, ref } from "vue";
import player from "./player";
import state from "./state";
/**
* A symbol used in {@link Persistent} objects.
@ -56,6 +58,7 @@ export type State =
* A {@link Ref} that has been augmented with properties to allow it to be saved and loaded within the player save data object.
*/
export type Persistent<T extends State = State> = Ref<T> & {
value: T;
/** A flag that this is a persistent property. Typically a circular reference. */
[PersistentState]: Ref<T>;
/** The value the ref should be set to in a fresh save, or when updating an old save to the current version. */
@ -89,31 +92,65 @@ function getStackTrace() {
);
}
function checkNaNAndWrite<T extends State>(persistent: Persistent<T>, value: T) {
// Decimal is smart enough to return false on things that aren't supposed to be numbers
if (Decimal.isNaN(value as DecimalSource)) {
if (!state.hasNaN) {
player.autosave = false;
state.hasNaN = true;
state.NaNPath = persistent[SaveDataPath];
state.NaNPersistent = persistent as Persistent<DecimalSource>;
}
console.error(
`Attempted to save NaN value to`,
persistent[SaveDataPath]?.join("."),
persistent
);
throw "Attempted to set NaN value. See above for details";
}
persistent[PersistentState].value = value;
}
/**
* Create a persistent ref, which can be saved and loaded.
* All (non-deleted) persistent refs must be included somewhere within the layer object returned by that layer's options func.
* @param defaultValue The value the persistent ref should start at on fresh saves or when reset.
*/
export function persistent<T extends State>(defaultValue: T | Ref<T>): Persistent<T> {
const persistent = (
isRef(defaultValue) ? defaultValue : (ref<T>(defaultValue) as unknown)
) as Persistent<T>;
const persistentState: Ref<T> = isRef(defaultValue)
? defaultValue
: (ref<T>(defaultValue) as Ref<T>);
persistent[PersistentState] = persistent;
persistent[DefaultValue] = isRef(defaultValue) ? defaultValue.value : defaultValue;
persistent[StackTrace] = getStackTrace();
persistent[Deleted] = false;
const nonPersistent: Partial<NonPersistent<T>> = computed({
if (isRef(defaultValue)) {
defaultValue = defaultValue.value;
}
const nonPersistent = computed({
get() {
return persistent.value;
return persistentState.value;
},
set(value) {
persistent.value = value;
checkNaNAndWrite(persistent, value);
}
});
nonPersistent[DefaultValue] = persistent[DefaultValue];
persistent[NonPersistent] = nonPersistent as NonPersistent<T>;
persistent[SaveDataPath] = undefined;
}) as NonPersistent<T>;
nonPersistent[DefaultValue] = defaultValue;
// We're trying to mock a vue ref, which means the type expects a private [RefSymbol] property that we can't access, but the actual implementation of isRef just checks for `__v_isRef`
const persistent = {
get value() {
return persistentState.value as T;
},
set value(value: T) {
checkNaNAndWrite(persistent, value);
},
__v_isRef: true,
[PersistentState]: persistentState,
[DefaultValue]: defaultValue,
[StackTrace]: getStackTrace(),
[Deleted]: false,
[NonPersistent]: nonPersistent,
[SaveDataPath]: undefined
} as unknown as Persistent<T>;
if (addingLayers.length === 0) {
console.warn(
@ -125,15 +162,14 @@ export function persistent<T extends State>(defaultValue: T | Ref<T>): Persisten
persistentRefs[addingLayers[addingLayers.length - 1]].add(persistent);
}
return persistent as Persistent<T>;
return persistent;
}
/**
* Type guard for whether an arbitrary value is a persistent ref
* @param value The value that may or may not be a persistent ref
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function isPersistent(value: any): value is Persistent {
export function isPersistent(value: unknown): value is Persistent {
return value != null && typeof value === "object" && PersistentState in value;
}

View file

@ -1,13 +1,8 @@
import { isPlainObject } from "is-plain-object";
import Decimal from "util/bignum";
import type { ProxiedWithState } from "util/proxies";
import { ProxyPath, ProxyState } from "util/proxies";
import { reactive, unref } from "vue";
import type { Ref } from "vue";
import transientState from "./state";
import { reactive, unref } from "vue";
/** The player save data object. */
export interface PlayerData {
export interface Player {
/** The ID of this save. */
id: string;
/** A multiplier for time passing. Set to 0 when the game is paused. */
@ -36,9 +31,6 @@ export interface PlayerData {
layers: Record<string, LayerData<unknown>>;
}
/** The proxied player that is used to track NaN values. */
export type Player = ProxiedWithState<PlayerData>;
/** A layer's save data. Automatically unwraps refs. */
export type LayerData<T> = {
[P in keyof T]?: T[P] extends (infer U)[]
@ -52,7 +44,7 @@ export type LayerData<T> = {
: T[P];
};
const state = reactive<PlayerData>({
const player = reactive<Player>({
id: "",
devSpeed: null,
name: "",
@ -68,90 +60,16 @@ const state = reactive<PlayerData>({
layers: {}
});
export default window.player = player;
/** Convert a player save data object into a JSON string. Unwraps refs. */
export function stringifySave(player: PlayerData): string {
export function stringifySave(player: Player): string {
return JSON.stringify(player, (key, value) => unref(value));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const playerHandler: ProxyHandler<Record<PropertyKey, any>> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get(target: Record<PropertyKey, any>, key: PropertyKey): any {
if (key === ProxyState || key === ProxyPath) {
return target[key];
}
const value = target[ProxyState][key];
if (key !== "value" && (isPlainObject(value) || Array.isArray(value))) {
if (value !== target[key]?.[ProxyState]) {
const path = [...target[ProxyPath], key];
target[key] = new Proxy({ [ProxyState]: value, [ProxyPath]: path }, playerHandler);
}
return target[key];
}
return value;
},
set(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
target: Record<PropertyKey, any>,
property: PropertyKey,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any,
receiver: ProxyConstructor
): boolean {
if (
!transientState.hasNaN &&
((typeof value === "number" && isNaN(value)) ||
(value instanceof Decimal &&
(isNaN(value.sign) || isNaN(value.layer) || isNaN(value.mag))))
) {
const currentValue = target[ProxyState][property];
if (
!(
(typeof currentValue === "number" && isNaN(currentValue)) ||
(currentValue instanceof Decimal &&
(isNaN(currentValue.sign) ||
isNaN(currentValue.layer) ||
isNaN(currentValue.mag)))
)
) {
state.autosave = false;
transientState.hasNaN = true;
transientState.NaNPath = [...target[ProxyPath], property];
transientState.NaNReceiver = receiver as unknown as Record<string, unknown>;
console.error(
`Attempted to set NaN value`,
[...target[ProxyPath], property],
target[ProxyState]
);
throw "Attempted to set NaN value. See above for details";
}
}
target[ProxyState][property] = value;
return true;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ownKeys(target: Record<PropertyKey, any>) {
return Reflect.ownKeys(target[ProxyState]);
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
has(target: Record<PropertyKey, any>, key: string) {
return Reflect.has(target[ProxyState], key);
},
getOwnPropertyDescriptor(target, key) {
return Object.getOwnPropertyDescriptor(target[ProxyState], key);
}
};
declare global {
/** Augment the window object so the player can be accessed from the console. */
interface Window {
player: Player;
}
}
/** The player save data object. */
export default window.player = new Proxy(
{ [ProxyState]: state, [ProxyPath]: ["player"] },
playerHandler
) as Player;

326
src/game/requirements.tsx Normal file
View file

@ -0,0 +1,326 @@
import { isArray } from "@vue/shared";
import { CoercableComponent, isVisible, jsx, setDefault, Visibility } from "features/feature";
import { displayResource, Resource } from "features/resources/resource";
import Decimal, { DecimalSource } from "lib/break_eternity";
import {
Computable,
convertComputable,
processComputable,
ProcessedComputable
} from "util/computed";
import { createLazyProxy } from "util/proxies";
import { joinJSX, renderJSX } from "util/vue";
import { computed, unref } from "vue";
import Formula, {
calculateCost,
calculateMaxAffordable,
GenericFormula,
InvertibleFormula
} from "./formulas";
import { DefaultValue, Persistent } from "./persistence";
/**
* An object that can be used to describe a requirement to perform some purchase or other action.
* @see {@link createCostRequirement}
*/
export interface Requirement {
/**
* The display for this specific requirement. This is used for displays multiple requirements condensed. Required if {@link visibility} can be {@link Visibility.Visible}.
*/
partialDisplay?: (amount?: DecimalSource) => JSX.Element;
/**
* The display for this specific requirement. Required if {@link visibility} can be {@link Visibility.Visible}.
*/
display?: (amount?: DecimalSource) => JSX.Element;
/**
* Whether or not this requirement should be displayed in Vue Features. {@link displayRequirements} will respect this property.
*/
visibility: ProcessedComputable<Visibility.Visible | Visibility.None | boolean>;
/**
* Whether or not this requirement has been met.
*/
requirementMet: ProcessedComputable<DecimalSource | boolean>;
/**
* Whether or not this requirement will need to affect the game state when whatever is using this requirement gets triggered.
*/
requiresPay: ProcessedComputable<boolean>;
/**
* Whether or not this requirement can have multiple levels of requirements that can be met at once. Requirement is assumed to not have multiple levels if this property not present.
*/
canMaximize?: ProcessedComputable<boolean>;
/**
* Perform any effects to the game state that should happen when the requirement gets triggered.
* @param amount The amount of levels of requirements to pay for.
*/
pay?: (amount?: DecimalSource) => void;
}
/**
* Utility type for accepting 1 or more {@link Requirement}s.
*/
export type Requirements = Requirement | Requirement[];
/** An object that configures a {@link Requirement} based on a resource cost. */
export interface CostRequirementOptions {
/**
* The resource that will be checked for meeting the {@link cost}.
*/
resource: Resource;
/**
* The amount of {@link resource} that must be met for this requirement. You can pass a formula, in which case maximizing will work out of the box (assuming its invertible and, for more accurate calculations, its integral is invertible). If you don't pass a formula then you can still support maximizing by passing a custom {@link pay} function.
*/
cost: Computable<DecimalSource> | GenericFormula;
/**
* Pass-through to {@link Requirement.visibility}.
*/
visibility?: Computable<Visibility.Visible | Visibility.None | boolean>;
/**
* Pass-through to {@link Requirement.requiresPay}. If not set to false, the default {@link pay} function will remove {@link cost} from {@link resource}.
*/
requiresPay?: Computable<boolean>;
/**
* When calculating multiple levels to be handled at once, whether it should consider resources used for each level as spent. Setting this to false causes calculations to be faster with larger numbers and supports more math functions.
* @see {Formula}
*/
spendResources?: Computable<boolean>;
/**
* Pass-through to {@link Requirement.pay}. May be required for maximizing support.
* @see {@link cost} for restrictions on maximizing support.
*/
pay?: (amount?: DecimalSource) => void;
}
export type CostRequirement = Requirement & CostRequirementOptions;
/**
* Lazily creates a requirement with the given options, that is based on meeting an amount of a resource.
* @param optionsFunc Cost requirement options.
*/
export function createCostRequirement<T extends CostRequirementOptions>(
optionsFunc: () => T
): CostRequirement {
return createLazyProxy(() => {
const req = optionsFunc() as T & Partial<Requirement>;
req.partialDisplay = amount => (
<span
style={
unref(req.requirementMet as ProcessedComputable<boolean>)
? ""
: "color: var(--danger)"
}
>
{displayResource(
req.resource,
req.cost instanceof Formula
? calculateCost(
req.cost,
amount ?? 1,
unref(
req.spendResources as ProcessedComputable<boolean> | undefined
) ?? true
)
: unref(req.cost as ProcessedComputable<DecimalSource>)
)}{" "}
{req.resource.displayName}
</span>
);
req.display = amount => (
<div>
{unref(req.requiresPay as ProcessedComputable<boolean>) ? "Costs: " : "Requires: "}
{displayResource(
req.resource,
req.cost instanceof Formula
? calculateCost(
req.cost,
amount ?? 1,
unref(
req.spendResources as ProcessedComputable<boolean> | undefined
) ?? true
)
: unref(req.cost as ProcessedComputable<DecimalSource>)
)}{" "}
{req.resource.displayName}
</div>
);
processComputable(req as T, "visibility");
setDefault(req, "visibility", Visibility.Visible);
processComputable(req as T, "cost");
processComputable(req as T, "requiresPay");
processComputable(req as T, "spendResources");
setDefault(req, "requiresPay", true);
setDefault(req, "pay", function (amount?: DecimalSource) {
const cost =
req.cost instanceof Formula
? calculateCost(
req.cost,
amount ?? 1,
unref(req.spendResources as ProcessedComputable<boolean> | undefined) ??
true
)
: unref(req.cost as ProcessedComputable<DecimalSource>);
req.resource.value = Decimal.sub(req.resource.value, cost).max(0);
});
req.canMaximize = req.cost instanceof Formula && req.cost.isInvertible();
if (req.canMaximize) {
req.requirementMet = calculateMaxAffordable(
req.cost as InvertibleFormula,
req.resource,
unref(req.spendResources as ProcessedComputable<boolean> | undefined) ?? true
);
} else {
req.requirementMet = computed(() => {
if (req.cost instanceof Formula) {
return Decimal.gte(req.resource.value, req.cost.evaluate());
} else {
return Decimal.gte(
req.resource.value,
unref(req.cost as ProcessedComputable<DecimalSource>)
);
}
});
}
return req as CostRequirement;
});
}
/**
* Utility function for creating a requirement that a specified vue feature is visible
* @param feature The feature to check the visibility of
*/
export function createVisibilityRequirement(feature: {
visibility: ProcessedComputable<Visibility | boolean>;
}): Requirement {
return createLazyProxy(() => ({
requirementMet: computed(() => isVisible(feature.visibility)),
visibility: Visibility.None,
requiresPay: false
}));
}
/**
* Creates a requirement based on a true/false value
* @param requirement The boolean requirement to use
* @param display How to display this requirement to the user
*/
export function createBooleanRequirement(
requirement: Computable<boolean>,
display?: CoercableComponent
): Requirement {
return createLazyProxy(() => ({
requirementMet: convertComputable(requirement),
partialDisplay: display == null ? undefined : jsx(() => renderJSX(display)),
display: display == null ? undefined : jsx(() => <>Req: {renderJSX(display)}</>),
visibility: display == null ? Visibility.None : Visibility.Visible,
requiresPay: false
}));
}
/**
* Utility for checking if 1+ requirements are all met
* @param requirements The 1+ requirements to check
*/
export function requirementsMet(requirements: Requirements): boolean {
if (isArray(requirements)) {
return requirements.every(requirementsMet);
}
const reqsMet = unref(requirements.requirementMet);
return typeof reqsMet === "boolean" ? reqsMet : Decimal.gt(reqsMet, 0);
}
/**
* Calculates the maximum number of levels that could be acquired with the current requirement states. True/false requirements will be counted as Infinity or 0.
* @param requirements The 1+ requirements to check
*/
export function maxRequirementsMet(requirements: Requirements): DecimalSource {
if (isArray(requirements)) {
return requirements.map(maxRequirementsMet).reduce(Decimal.min);
}
const reqsMet = unref(requirements.requirementMet);
if (typeof reqsMet === "boolean") {
return reqsMet ? Infinity : 0;
} else if (Decimal.gt(reqsMet, 1) && unref(requirements.canMaximize) !== true) {
return 1;
}
return reqsMet;
}
/**
* Utility function for display 1+ requirements compactly.
* @param requirements The 1+ requirements to display
* @param amount The amount of levels earned to be displayed
*/
export function displayRequirements(requirements: Requirements, amount: DecimalSource = 1) {
if (isArray(requirements)) {
requirements = requirements.filter(r => isVisible(r.visibility));
if (requirements.length === 1) {
requirements = requirements[0];
}
}
if (isArray(requirements)) {
requirements = requirements.filter(r => "partialDisplay" in r);
const withCosts = requirements.filter(r => unref(r.requiresPay));
const withoutCosts = requirements.filter(r => !unref(r.requiresPay));
return (
<>
{withCosts.length > 0 ? (
<div>
Costs:{" "}
{joinJSX(
withCosts.map(r => r.partialDisplay!(amount)),
<>, </>
)}
</div>
) : null}
{withoutCosts.length > 0 ? (
<div>
Requires:{" "}
{joinJSX(
withoutCosts.map(r => r.partialDisplay!(amount)),
<>, </>
)}
</div>
) : null}
</>
);
}
return requirements.display?.() ?? <></>;
}
/**
* Utility function for paying the costs for 1+ requirements
* @param requirements The 1+ requirements to pay
* @param amount How many levels to pay for
*/
export function payRequirements(requirements: Requirements, amount: DecimalSource = 1) {
if (isArray(requirements)) {
requirements.filter(r => unref(r.requiresPay)).forEach(r => r.pay?.(amount));
} else if (unref(requirements.requiresPay)) {
requirements.pay?.(amount);
}
}
export function payByDivision(this: CostRequirement, amount?: DecimalSource) {
const cost =
this.cost instanceof Formula
? calculateCost(
this.cost,
amount ?? 1,
unref(this.spendResources as ProcessedComputable<boolean> | undefined) ?? true
)
: unref(this.cost as ProcessedComputable<DecimalSource>);
this.resource.value = Decimal.div(this.resource.value, cost);
}
export function payByReset(overrideDefaultValue?: DecimalSource) {
return function (this: CostRequirement) {
this.resource.value =
overrideDefaultValue ??
(this.resource as Resource & Persistent<DecimalSource>)[DefaultValue] ??
0;
};
}

View file

@ -18,6 +18,8 @@ export interface Settings {
theme: Themes;
/** Whether or not to cap the project at 20 ticks per second. */
unthrottled: boolean;
/** Whether to align modifiers to the unit. */
alignUnits: boolean;
}
const state = reactive<Partial<Settings>>({
@ -25,7 +27,8 @@ const state = reactive<Partial<Settings>>({
saves: [],
showTPS: true,
theme: Themes.Nordic,
unthrottled: false
unthrottled: false,
alignUnits: false
});
watch(
@ -57,7 +60,8 @@ export const hardResetSettings = (window.hardResetSettings = () => {
active: "",
saves: [],
showTPS: true,
theme: Themes.Nordic
theme: Themes.Nordic,
alignUnits: false
};
globalBus.emit("loadSettings", settings);
Object.assign(state, settings);

View file

@ -1,4 +1,6 @@
import type { DecimalSource } from "util/bignum";
import { shallowReactive } from "vue";
import type { Persistent } from "./persistence";
/** An object of global data that is not persistent. */
export interface Transient {
@ -8,8 +10,8 @@ export interface Transient {
hasNaN: boolean;
/** The location within the player save data object of the NaN value. */
NaNPath?: string[];
/** The parent object of the NaN value. */
NaNReceiver?: Record<string, unknown>;
/** The ref that was being set to NaN. */
NaNPersistent?: Persistent<DecimalSource>;
}
declare global {

File diff suppressed because it is too large Load diff

139
src/lib/lru-cache.ts Normal file
View file

@ -0,0 +1,139 @@
/**
* A LRU cache intended for caching pure functions.
*/
export class LRUCache<K, V> {
private map = new Map<K, ListNode<K, V>>();
// Invariant: Exactly one of the below is true before and after calling a
// LRUCache method:
// - first and last are both undefined, and map.size() is 0.
// - first and last are the same object, and map.size() is 1.
// - first and last are different objects, and map.size() is greater than 1.
private first: ListNode<K, V> | undefined = undefined;
private last: ListNode<K, V> | undefined = undefined;
maxSize: number;
/**
* @param maxSize The maximum size for this cache. We recommend setting this
* to be one less than a power of 2, as most hashtables - including V8's
* Object hashtable (https://crsrc.org/c/v8/src/objects/ordered-hash-table.cc)
* - uses powers of two for hashtable sizes. It can't exactly be a power of
* two, as a .set() call could temporarily set the size of the map to be
* maxSize + 1.
*/
constructor(maxSize: number) {
this.maxSize = maxSize;
}
get size(): number {
return this.map.size;
}
/**
* Gets the specified key from the cache, or undefined if it is not in the
* cache.
* @param key The key to get.
* @returns The cached value, or undefined if key is not in the cache.
*/
get(key: K): V | undefined {
const node = this.map.get(key);
if (node === undefined) {
return undefined;
}
// It is guaranteed that there is at least one item in the cache.
// Therefore, first and last are guaranteed to be a ListNode...
// but if there is only one item, they might be the same.
// Update the order of the list to make this node the first node in the
// list.
// This isn't needed if this node is already the first node in the list.
if (node !== this.first) {
// As this node is DIFFERENT from the first node, it is guaranteed that
// there are at least two items in the cache.
// However, this node could possibly be the last item.
if (node === this.last) {
// This node IS the last node.
this.last = node.prev;
// From the invariants, there must be at least two items in the cache,
// so node - which is the original "last node" - must have a defined
// previous node. Therefore, this.last - set above - must be defined
// here.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.last!.next = undefined;
} else {
// This node is somewhere in the middle of the list, so there must be at
// least THREE items in the list, and this node's prev and next must be
// defined here.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
node.prev!.next = node.next;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
node.next!.prev = node.prev;
}
node.next = this.first;
// From the invariants, there must be at least two items in the cache, so
// this.first must be a valid ListNode.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.first!.prev = node;
this.first = node;
}
return node.value;
}
/**
* Sets an entry in the cache.
*
* @param key The key of the entry.
* @param value The value of the entry.
* @throws Error, if the map already contains the key.
*/
set(key: K, value: V): void {
// Ensure that this.maxSize >= 1.
if (this.maxSize < 1) {
return;
}
if (this.map.has(key)) {
throw new Error("Cannot update existing keys in the cache");
}
const node = new ListNode(key, value);
// Move node to the front of the list.
if (this.first === undefined) {
// If the first is undefined, the last is undefined too.
// Therefore, this cache has no items in it.
this.first = node;
this.last = node;
} else {
// This cache has at least one item in it.
node.next = this.first;
this.first.prev = node;
this.first = node;
}
this.map.set(key, node);
while (this.map.size > this.maxSize) {
// We are guaranteed that this.maxSize >= 1,
// so this.map.size is guaranteed to be >= 2,
// so this.first and this.last must be different valid ListNodes,
// and this.last.prev must also be a valid ListNode (possibly this.first).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const last = this.last!;
this.map.delete(last.key);
this.last = last.prev;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.last!.next = undefined;
}
}
}
/**
* A node in a doubly linked list.
*/
class ListNode<K, V> {
key: K;
value: V;
next: ListNode<K, V> | undefined = undefined;
prev: ListNode<K, V> | undefined = undefined;
constructor(key: K, value: V) {
this.key = key;
this.value = value;
}
}

View file

@ -18,7 +18,7 @@ export const {
export type DecimalSource = RawDecimalSource;
declare global {
/** Augment the window object so the big num functions can be access from the console. */
/** Augment the window object so the big num functions can be accessed from the console. */
interface Window {
Decimal: typeof Decimal;
exponentialFormat: (num: DecimalSource, precision: number, mantissa: boolean) => string;

View file

@ -1,6 +1,7 @@
import type { JSXFunction } from "features/feature";
import { isFunction } from "util/common";
import type { Ref } from "vue";
import { computed } from "vue";
import { isFunction } from "util/common";
export const DoNotCache = Symbol("DoNotCache");
@ -32,21 +33,22 @@ export function processComputable<T, S extends keyof ComputableKeysOf<T>>(
key: S
): asserts obj is T & { [K in S]: ProcessedComputable<UnwrapComputableType<T[S]>> } {
const computable = obj[key];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (isFunction(computable) && computable.length === 0 && !(computable as any)[DoNotCache]) {
if (
isFunction(computable) &&
computable.length === 0 &&
!(computable as unknown as JSXFunction)[DoNotCache]
) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
obj[key] = computed(computable.bind(obj));
} else if (isFunction(computable)) {
obj[key] = computable.bind(obj) as unknown as T[S];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(obj[key] as any)[DoNotCache] = true;
(obj[key] as unknown as JSXFunction)[DoNotCache] = true;
}
}
export function convertComputable<T>(obj: Computable<T>): ProcessedComputable<T> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
if (isFunction(obj) && !(obj as any)[DoNotCache]) {
if (isFunction(obj) && !(obj as unknown as JSXFunction)[DoNotCache]) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
obj = computed(obj);

View file

@ -1,11 +1,11 @@
import type { Persistent } from "game/persistence";
import { NonPersistent } from "game/persistence";
import Decimal from "util/bignum";
export const ProxyState = Symbol("ProxyState");
export const ProxyPath = Symbol("ProxyPath");
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type ProxiedWithState<T> = NonNullable<T> extends Record<PropertyKey, any>
export type ProxiedWithState<T> = NonNullable<T> extends Record<PropertyKey, unknown>
? NonNullable<T> extends Decimal
? T
: {
@ -16,6 +16,18 @@ export type ProxiedWithState<T> = NonNullable<T> extends Record<PropertyKey, any
}
: T;
export type Proxied<T> = NonNullable<T> extends Record<PropertyKey, unknown>
? NonNullable<T> extends Persistent<infer S>
? NonPersistent<S>
: NonNullable<T> extends Decimal
? T
: {
[K in keyof T]: Proxied<T[K]>;
} & {
[ProxyState]: T;
}
: T;
// Takes a function that returns an object and pretends to be that object
// Note that the object is lazily calculated
export function createLazyProxy<T extends object, S extends T>(

View file

@ -1,13 +1,12 @@
import projInfo from "data/projInfo.json";
import { globalBus } from "game/events";
import type { Player, PlayerData } from "game/player";
import type { Player } from "game/player";
import player, { stringifySave } from "game/player";
import settings, { loadSettings } from "game/settings";
import LZString from "lz-string";
import { ProxyState } from "util/proxies";
import { ref } from "vue";
export function setupInitialStore(player: Partial<PlayerData> = {}): Player {
export function setupInitialStore(player: Partial<Player> = {}): Player {
return Object.assign(
{
id: `${projInfo.id}-0`,
@ -27,11 +26,9 @@ export function setupInitialStore(player: Partial<PlayerData> = {}): Player {
) as Player;
}
export function save(playerData?: PlayerData): string {
const stringifiedSave = LZString.compressToUTF16(
stringifySave(playerData ?? player[ProxyState])
);
localStorage.setItem((playerData ?? player[ProxyState]).id, stringifiedSave);
export function save(playerData?: Player): string {
const stringifiedSave = LZString.compressToUTF16(stringifySave(playerData ?? player));
localStorage.setItem((playerData ?? player).id, stringifiedSave);
return stringifiedSave;
}
@ -70,7 +67,7 @@ export async function load(): Promise<void> {
}
}
export function newSave(): PlayerData {
export function newSave(): Player {
const id = getUniqueID();
const player = setupInitialStore({ id });
save(player);
@ -91,7 +88,7 @@ export function getUniqueID(): string {
export const loadingSave = ref(false);
export async function loadSave(playerObj: Partial<PlayerData>): Promise<void> {
export async function loadSave(playerObj: Partial<Player>): Promise<void> {
console.info("Loading save", playerObj);
loadingSave.value = true;
const { layers, removeLayer, addLayer } = await import("game/layers");
@ -143,14 +140,22 @@ window.onbeforeunload = () => {
declare global {
/**
* Augment the window object so the save function, and the hard reset function can be access from the console.
* Augment the window object so the save, hard reset, and deleteLowerSaves functions can be accessed from the console.
*/
interface Window {
save: VoidFunction;
hardReset: VoidFunction;
deleteLowerSaves: VoidFunction;
}
}
window.save = save;
export const hardReset = (window.hardReset = async () => {
await loadSave(newSave());
});
export const deleteLowerSaves = (window.deleteLowerSaves = () => {
const index = Object.values(settings.saves).indexOf(player.id) + 1;
Object.values(settings.saves)
.slice(index)
.forEach(id => localStorage.removeItem(id));
settings.saves = settings.saves.slice(0, index);
});

View file

@ -1,7 +1,13 @@
import Col from "components/layout/Column.vue";
import Row from "components/layout/Row.vue";
import type { CoercableComponent, GenericComponent, JSXFunction } from "features/feature";
import { Component as ComponentKey, GatherProps, jsx, Visibility } from "features/feature";
import {
Component as ComponentKey,
GatherProps,
isVisible,
jsx,
Visibility
} from "features/feature";
import type { ProcessedComputable } from "util/computed";
import { DoNotCache } from "util/computed";
import type { Component, ComputedRef, DefineComponent, PropType, Ref, ShallowRef } from "vue";
@ -147,7 +153,7 @@ export function setupHoldToClick(
}
export function getFirstFeature<
T extends VueFeature & { visibility: ProcessedComputable<Visibility> }
T extends VueFeature & { visibility: ProcessedComputable<Visibility | boolean> }
>(
features: T[],
filter: (feature: T) => boolean
@ -157,9 +163,7 @@ export function getFirstFeature<
hasCollapsedContent: Ref<boolean>;
} {
const filteredFeatures = computed(() =>
features.filter(
feature => unref(feature.visibility) === Visibility.Visible && filter(feature)
)
features.filter(feature => isVisible(feature.visibility) && filter(feature))
);
return {
firstFeature: computed(() => filteredFeatures.value[0]),

1012
tests/game/formulas.test.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,182 @@
import { Visibility } from "features/feature";
import { createResource, Resource } from "features/resources/resource";
import Formula from "game/formulas";
import {
createBooleanRequirement,
createCostRequirement,
createVisibilityRequirement,
maxRequirementsMet,
payRequirements,
Requirement,
requirementsMet
} from "game/requirements";
import { beforeAll, describe, expect, test } from "vitest";
import { isRef, ref, unref } from "vue";
import "../utils";
describe("Creating cost requirement", () => {
describe("Minimal requirement", () => {
let resource: Resource;
let requirement: Requirement;
beforeAll(() => {
resource = createResource(ref(10));
requirement = createCostRequirement(() => ({
resource,
cost: 10
}));
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
test("resource pass-through", () => expect((requirement as any).resource).toBe(resource));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
test("cost pass-through", () => expect((requirement as any).cost).toBe(10));
test("partialDisplay exists", () =>
expect(typeof requirement.partialDisplay).toBe("function"));
test("display exists", () => expect(typeof requirement.display).toBe("function"));
test("pay exists", () => expect(typeof requirement.pay).toBe("function"));
test("requirementMet exists", () => {
expect(requirement.requirementMet).not.toBeNull();
expect(isRef(requirement.requirementMet)).toBe(true);
});
test("is visible", () => expect(requirement.visibility).toBe(Visibility.Visible));
test("requires pay", () => expect(requirement.requiresPay).toBe(true));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
test("spends resources", () => expect((requirement as any).spendResources).toBe(true));
test("cannot maximize", () => expect(unref(requirement.canMaximize)).toBe(false));
});
describe("Fully customized", () => {
let resource: Resource;
let requirement: Requirement;
beforeAll(() => {
resource = createResource(ref(10));
requirement = createCostRequirement(() => ({
resource,
cost: 10,
visibility: Visibility.None,
requiresPay: false,
maximize: true,
spendResources: false,
// eslint-disable-next-line @typescript-eslint/no-empty-function
pay() {}
}));
});
test("pay is empty function", () =>
requirement.pay != null &&
typeof requirement.pay === "function" &&
requirement.pay.length === 1);
test("is not visible", () => expect(requirement.visibility).toBe(Visibility.None));
test("does not require pay", () => expect(requirement.requiresPay).toBe(false));
test("does not spend resources", () =>
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expect((requirement as any).spendResources).toBe(false));
test("can maximize", () => expect(unref(requirement.canMaximize)).toBe(true));
});
test("Requirement met when meeting the cost", () => {
const resource = createResource(ref(10));
const requirement = createCostRequirement(() => ({
resource,
cost: 10
}));
expect(unref(requirement.requirementMet)).toBe(true);
});
test("Requirement not met when not meeting the cost", () => {
const resource = createResource(ref(10));
const requirement = createCostRequirement(() => ({
resource,
cost: 100
}));
expect(unref(requirement.requirementMet)).toBe(false);
});
});
describe("Creating visibility requirement", () => {
test("Requirement met when visible", () => {
const requirement = createVisibilityRequirement({ visibility: Visibility.Visible });
expect(unref(requirement.requirementMet)).toBe(true);
});
test("Requirement not met when not visible", () => {
let requirement = createVisibilityRequirement({ visibility: Visibility.None });
expect(unref(requirement.requirementMet)).toBe(false);
requirement = createVisibilityRequirement({ visibility: false });
expect(unref(requirement.requirementMet)).toBe(false);
});
});
describe("Creating boolean requirement", () => {
test("Requirement met when true", () => {
const requirement = createBooleanRequirement(ref(true));
expect(unref(requirement.requirementMet)).toBe(true);
});
test("Requirement not met when false", () => {
const requirement = createBooleanRequirement(ref(false));
expect(unref(requirement.requirementMet)).toBe(false);
});
});
describe("Checking all requirements met", () => {
let metRequirement: Requirement;
let unmetRequirement: Requirement;
beforeAll(() => {
metRequirement = createBooleanRequirement(true);
unmetRequirement = createBooleanRequirement(false);
});
test("Returns true if no requirements", () => {
expect(requirementsMet([])).toBe(true);
});
test("Returns true if all requirements met", () => {
expect(requirementsMet([metRequirement, metRequirement])).toBe(true);
});
test("Returns false if any requirements unmet", () => {
expect(requirementsMet([metRequirement, unmetRequirement])).toBe(false);
});
});
describe("Checking maximum levels of requirements met", () => {
test("Returns 0 if any requirement is not met", () => {
const requirements = [
createBooleanRequirement(false),
createBooleanRequirement(true),
createCostRequirement(() => ({
resource: createResource(ref(10)),
cost: Formula.variable(0)
}))
];
expect(maxRequirementsMet(requirements)).compare_tolerance(0);
});
test("Returns correct number of requirements met", () => {
const requirements = [
createBooleanRequirement(true),
createCostRequirement(() => ({
resource: createResource(ref(10)),
cost: Formula.variable(0)
}))
];
expect(maxRequirementsMet(requirements)).compare_tolerance(10);
});
});
test("Paying requirements", () => {
const resource = createResource(ref(100));
const noPayment = createCostRequirement(() => ({
resource,
cost: 10,
requiresPay: false
}));
const payment = createCostRequirement(() => ({
resource,
cost: 10
}));
payRequirements([noPayment, payment]);
expect(resource.value).compare_tolerance(90);
});

40
tests/utils.ts Normal file
View file

@ -0,0 +1,40 @@
import Decimal, { DecimalSource, format } from "util/bignum";
import { expect } from "vitest";
interface CustomMatchers<R = unknown> {
compare_tolerance(expected: DecimalSource): R;
}
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Vi {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface Assertion extends CustomMatchers {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface AsymmetricMatchersContaining extends CustomMatchers {}
}
}
expect.extend({
compare_tolerance(received: DecimalSource, expected: DecimalSource) {
const { isNot } = this;
let pass = false;
if (!Decimal.isFinite(expected)) {
pass = !Decimal.isFinite(received);
} else if (Decimal.isNaN(expected)) {
pass = Decimal.isNaN(received);
} else {
pass = Decimal.eq_tolerance(received, expected);
}
return {
// do not alter your "pass" based on isNot. Vitest does it for you
pass,
message: () =>
`Expected ${received} to${
(isNot as boolean) ? " not" : ""
} be close to ${expected}`,
expected: format(expected),
actual: format(received)
};
}
});

View file

@ -57,8 +57,5 @@ export default defineConfig({
]
}
})
],
test: {
environment: "jsdom"
}
]
});

9
vitest.config.ts Normal file
View file

@ -0,0 +1,9 @@
import { mergeConfig } from 'vite'
import { defineConfig } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(viteConfig, defineConfig({
test: {
environment: "jsdom"
}
}))