[WIP] Improve lazy proxy typing #16

Draft
thepaperpilot wants to merge 4 commits from fix/lazy-proxy-typing into main
3 changed files with 324 additions and 115 deletions
Showing only changes of commit c39982b1bc - Show all commits

View file

@ -9,7 +9,6 @@ import {
GatherProps, GatherProps,
GenericComponent, GenericComponent,
OptionsFunc, OptionsFunc,
Replace,
StyleValue, StyleValue,
Visibility, Visibility,
getUniqueID, getUniqueID,
@ -30,16 +29,10 @@ import {
} from "game/requirements"; } from "game/requirements";
import settings, { registerSettingField } from "game/settings"; import settings, { registerSettingField } from "game/settings";
import { camelToTitle } from "util/common"; import { camelToTitle } from "util/common";
import type { import { Computable, Defaults, ProcessedFeature, convertComputable } from "util/computed";
Computable,
GetComputableType,
GetComputableTypeWithDefault,
ProcessedComputable
} from "util/computed";
import { processComputable } from "util/computed";
import { createLazyProxy } from "util/proxies"; import { createLazyProxy } from "util/proxies";
import { coerceComponent, isCoercableComponent } from "util/vue"; import { coerceComponent, isCoercableComponent } from "util/vue";
import { unref, watchEffect } from "vue"; import { ComputedRef, nextTick, unref, watchEffect } from "vue";
import { useToast } from "vue-toastification"; import { useToast } from "vue-toastification";
const toast = useToast(); const toast = useToast();
@ -98,6 +91,8 @@ export interface AchievementOptions {
export interface BaseAchievement { export interface BaseAchievement {
/** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */ /** An auto-generated ID for identifying features that appear in the DOM. Will not persist between refreshes or updates. */
id: string; id: string;
/** Whether this achievement should be visible. */
visibility: ComputedRef<Visibility | boolean>;
/** Whether or not this achievement has been earned. */ /** Whether or not this achievement has been earned. */
earned: Persistent<boolean>; earned: Persistent<boolean>;
/** A function to complete this achievement. */ /** A function to complete this achievement. */
@ -111,34 +106,191 @@ export interface BaseAchievement {
} }
/** An object that represents a feature with requirements that is passively earned upon meeting certain requirements. */ /** An object that represents a feature with requirements that is passively earned upon meeting certain requirements. */
export type Achievement<T extends AchievementOptions> = Replace< // export type Achievement<T extends AchievementOptions> = Replace<
T & BaseAchievement, // T & BaseAchievement,
{ // {
visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>; // visibility: GetComputableTypeWithDefault<T["visibility"], Visibility.Visible>;
display: GetComputableType<T["display"]>; // display: GetComputableType<T["display"]>;
mark: GetComputableType<T["mark"]>; // mark: GetComputableType<T["mark"]>;
image: GetComputableType<T["image"]>; // image: GetComputableType<T["image"]>;
style: GetComputableType<T["style"]>; // style: GetComputableType<T["style"]>;
classes: GetComputableType<T["classes"]>; // classes: GetComputableType<T["classes"]>;
showPopups: GetComputableTypeWithDefault<T["showPopups"], true>; // showPopups: GetComputableTypeWithDefault<T["showPopups"], true>;
} // }
>; // >;
/** A type that matches any valid {@link Achievement} object. */ // export interface Achievement extends AchievementOptions, BaseAchievement {
export type GenericAchievement = Replace< // visibility: GetComputableTypeWithDefault<AchievementOptions["visibility"], Visibility.Visible>;
Achievement<AchievementOptions>, // display: GetComputableType<AchievementOptions["display"]>;
{ // mark: GetComputableType<AchievementOptions["mark"]>;
visibility: ProcessedComputable<Visibility | boolean>; // image: GetComputableType<AchievementOptions["image"]>;
showPopups: ProcessedComputable<boolean>; // style: GetComputableType<AchievementOptions["style"]>;
} // classes: GetComputableType<AchievementOptions["classes"]>;
>; // showPopups: GetComputableTypeWithDefault<AchievementOptions["showPopups"], true>;
// }
export type Achievement<T extends AchievementOptions> = BaseAchievement &
ProcessedFeature<AchievementOptions, Exclude<T, BaseAchievement>> &
Defaults<
Exclude<T, BaseAchievement>,
{
showPopups: true;
}
>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type GenericAchievement = Achievement<any>;
/** /**
* Lazily creates an achievement with the given options. * Lazily creates an achievement with the given options.
* @param optionsFunc Achievement options. * @param optionsFunc Achievement options.
*/ */
// export function createAchievement<T extends AchievementOptions>(
// optionsFunc?: OptionsFunc<T, GenericAchievement>,
// ...decorators: GenericDecorator[]
// ): Achievement<T> {
// const earned = persistent<boolean>(false, false);
// const decoratedData = decorators.reduce(
// (current, next) => Object.assign(current, next.getPersistentData?.()),
// {}
// );
// return createLazyProxy(feature => {
// const achievement =
// optionsFunc?.call(feature, feature) ??
// ({} as ReturnType<NonNullable<typeof optionsFunc>>);
// achievement.id = getUniqueID("achievement-");
// achievement.type = AchievementType;
// achievement[Component] = AchievementComponent as GenericComponent;
// for (const decorator of decorators) {
// decorator.preConstruct?.(achievement);
// }
// achievement.display = convertComputable(achievement.display, achievement);
// achievement.mark = convertComputable(achievement.mark, achievement);
// achievement.small = convertComputable(achievement.small, achievement);
// achievement.image = convertComputable(achievement.image, achievement);
// achievement.style = convertComputable(achievement.style, achievement);
// achievement.classes = convertComputable(achievement.classes, achievement);
// achievement.showPopups = convertComputable(achievement.showPopups, achievement) ?? true;
// achievement.earned = earned;
// achievement.complete = function () {
// earned.value = true;
// achievement.onComplete?.();
// if (achievement.display != null && unref(achievement.showPopups) === true) {
// const display = unref((achievement as GenericAchievement).display);
// let Display;
// if (isCoercableComponent(display)) {
// Display = coerceComponent(display);
// } else if (display.requirement != null) {
// Display = coerceComponent(display.requirement);
// } else {
// Display = displayRequirements(achievement.requirements ?? []);
// }
// toast.info(
// <div>
// <h3>Achievement earned!</h3>
// <div>
// {/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
// {/* @ts-ignore */}
// <Display />
// </div>
// </div>
// );
// }
// };
// Object.assign(achievement, decoratedData);
// const visibility =
// convertComputable(achievement.visibility, achievement) ?? Visibility.Visible;
// achievement.visibility = computed(() => {
// const display = unref(achievement.display);
// switch (settings.msDisplay) {
// default:
// case AchievementDisplay.All:
// return unref(visibility);
// case AchievementDisplay.Configurable:
// if (
// unref(achievement.earned) &&
// !(
// display != null &&
// typeof display == "object" &&
// "optionsDisplay" in (display as Record<string, unknown>)
// )
// ) {
// return Visibility.None;
// }
// return unref(visibility);
// case AchievementDisplay.Incomplete:
// if (unref(achievement.earned)) {
// return Visibility.None;
// }
// return unref(visibility);
// case AchievementDisplay.None:
// return Visibility.None;
// }
// });
// for (const decorator of decorators) {
// decorator.postConstruct?.(achievement);
// }
// const decoratedProps = decorators.reduce(
// (current, next) => Object.assign(current, next.getGatheredProps?.(achievement)),
// {}
// );
// achievement[GatherProps] = function () {
// const {
// visibility,
// display,
// requirements,
// earned,
// image,
// style,
// classes,
// mark,
// small,
// id
// } = this;
// return {
// visibility,
// display,
// requirements,
// earned,
// image,
// style: unref(style),
// classes,
// mark,
// small,
// id,
// ...decoratedProps
// };
// };
// if (achievement.requirements) {
// const requirements = [
// createVisibilityRequirement(achievement),
// createBooleanRequirement(() => !achievement.earned.value),
// ...(isArray(achievement.requirements)
// ? achievement.requirements
// : [achievement.requirements])
// ];
// watchEffect(() => {
// if (settings.active !== player.id) return;
// if (requirementsMet(requirements)) {
// achievement.complete();
// }
// });
// }
// return achievement;
// });
// }
export function createAchievement<T extends AchievementOptions>( export function createAchievement<T extends AchievementOptions>(
optionsFunc?: OptionsFunc<T, BaseAchievement, GenericAchievement>, optionsFunc?: OptionsFunc<T, GenericAchievement>,
...decorators: GenericDecorator[] ...decorators: GenericDecorator[]
): Achievement<T> { ): Achievement<T> {
const earned = persistent<boolean>(false, false); const earned = persistent<boolean>(false, false);
@ -146,35 +298,25 @@ export function createAchievement<T extends AchievementOptions>(
(current, next) => Object.assign(current, next.getPersistentData?.()), (current, next) => Object.assign(current, next.getPersistentData?.()),
{} {}
); );
return createLazyProxy(feature => { return createLazyProxy(achievement => {
const achievement = const options =
optionsFunc?.call(feature, feature) ?? optionsFunc?.call(achievement, achievement) ??
({} as ReturnType<NonNullable<typeof optionsFunc>>); ({} as ReturnType<NonNullable<typeof optionsFunc>>);
achievement.id = getUniqueID("achievement-");
achievement.type = AchievementType;
achievement[Component] = AchievementComponent as GenericComponent;
for (const decorator of decorators) { function complete() {
decorator.preConstruct?.(achievement);
}
achievement.earned = earned;
achievement.complete = function () {
earned.value = true; earned.value = true;
const genericAchievement = achievement as GenericAchievement; (achievement as GenericAchievement).onComplete?.();
genericAchievement.onComplete?.(); if (achievement.display != null && unref(achievement.showPopups) === true) {
if ( const display = unref((achievement as GenericAchievement).display);
genericAchievement.display != null &&
unref(genericAchievement.showPopups) === true
) {
const display = unref(genericAchievement.display);
let Display; let Display;
if (isCoercableComponent(display)) { if (isCoercableComponent(display)) {
Display = coerceComponent(display); Display = coerceComponent(display);
} else if (display.requirement != null) { } else if (display.requirement != null) {
Display = coerceComponent(display.requirement); Display = coerceComponent(display.requirement);
} else { } else {
Display = displayRequirements(genericAchievement.requirements ?? []); Display = displayRequirements(
(achievement as GenericAchievement).requirements ?? []
);
} }
toast.info( toast.info(
<div> <div>
@ -187,19 +329,16 @@ export function createAchievement<T extends AchievementOptions>(
</div> </div>
); );
} }
}; }
Object.assign(achievement, decoratedData); const optionsVisibility =
convertComputable(options.visibility, options) ?? Visibility.Visible;
processComputable(achievement as T, "visibility"); const visibility = computed(() => {
setDefault(achievement, "visibility", Visibility.Visible); const display = unref(achievement.display);
const visibility = achievement.visibility as ProcessedComputable<Visibility | boolean>;
achievement.visibility = computed(() => {
const display = unref((achievement as GenericAchievement).display);
switch (settings.msDisplay) { switch (settings.msDisplay) {
default: default:
case AchievementDisplay.All: case AchievementDisplay.All:
return unref(visibility); return unref(optionsVisibility);
case AchievementDisplay.Configurable: case AchievementDisplay.Configurable:
if ( if (
unref(achievement.earned) && unref(achievement.earned) &&
@ -211,35 +350,28 @@ export function createAchievement<T extends AchievementOptions>(
) { ) {
return Visibility.None; return Visibility.None;
} }
return unref(visibility); return unref(optionsVisibility);
case AchievementDisplay.Incomplete: case AchievementDisplay.Incomplete:
if (unref(achievement.earned)) { if (unref(achievement.earned)) {
return Visibility.None; return Visibility.None;
} }
return unref(visibility); return unref(optionsVisibility);
case AchievementDisplay.None: case AchievementDisplay.None:
return Visibility.None; return Visibility.None;
} }
}); });
processComputable(achievement as T, "display");
processComputable(achievement as T, "mark");
processComputable(achievement as T, "small");
processComputable(achievement as T, "image");
processComputable(achievement as T, "style");
processComputable(achievement as T, "classes");
processComputable(achievement as T, "showPopups");
setDefault(achievement, "showPopups", true);
for (const decorator of decorators) { for (const decorator of decorators) {
decorator.postConstruct?.(achievement); decorator.preConstruct?.(achievement);
} }
Object.assign(achievement, decoratedData);
const decoratedProps = decorators.reduce( const decoratedProps = decorators.reduce(
(current, next) => Object.assign(current, next.getGatheredProps?.(achievement)), (current, next) => Object.assign(current, next.getGatheredProps?.(achievement)),
{} {}
); );
achievement[GatherProps] = function (this: GenericAchievement) {
function gatherProps(this: Achievement<T>) {
const { const {
visibility, visibility,
display, display,
@ -265,29 +397,67 @@ export function createAchievement<T extends AchievementOptions>(
id, id,
...decoratedProps ...decoratedProps
}; };
};
if (achievement.requirements) {
const genericAchievement = achievement as GenericAchievement;
const requirements = [
createVisibilityRequirement(genericAchievement),
createBooleanRequirement(() => !genericAchievement.earned.value),
...(isArray(achievement.requirements)
? achievement.requirements
: [achievement.requirements])
];
watchEffect(() => {
if (settings.active !== player.id) return;
if (requirementsMet(requirements)) {
genericAchievement.complete();
}
});
} }
return achievement as unknown as Achievement<T>; nextTick(() => {
for (const decorator of decorators) {
decorator.postConstruct?.(achievement);
}
if (achievement.requirements) {
const requirements = [
createVisibilityRequirement(achievement as GenericAchievement),
createBooleanRequirement(() => !achievement.earned.value),
...(isArray(achievement.requirements)
? achievement.requirements
: [achievement.requirements])
];
watchEffect(() => {
if (settings.active !== player.id) return;
if (requirementsMet(requirements)) {
achievement.complete();
}
});
}
});
return {
id: getUniqueID("achievement-"),
visibility,
earned,
complete,
type: AchievementType,
[Component]: AchievementComponent as GenericComponent,
[GatherProps]: gatherProps,
requirements: options.requirements,
display: convertComputable(options.display, options),
mark: convertComputable(options.mark, options),
small: convertComputable(options.small, options),
image: convertComputable(options.image, options),
style: convertComputable(options.style, options),
classes: convertComputable(options.classes, options),
showPopups: convertComputable(options.showPopups, options) ?? true,
onComplete: options.onComplete
} /* as Achievement<T>*/;
}); });
} }
const ach = createAchievement(ach => ({
image: "",
showPopups: computed(() => false),
small: () => true,
foo: "bar",
bar: () => "foo"
}));
ach;
ach.image; // string
ach.showPopups; // ComputedRef<false>
ach.small; // ComputedRef<true>
ach.foo; // "bar"
ach.bar; // () => "foo"
ach.mark; // TS should yell about this not existing (or at least mark it undefined)
ach.visibility; // ComputedRef<Visibility | boolean>
declare module "game/settings" { declare module "game/settings" {
interface Settings { interface Settings {
msDisplay: AchievementDisplay; msDisplay: AchievementDisplay;

View file

@ -42,9 +42,9 @@ export type Replace<T, S> = S & Omit<T, keyof S>;
* with "this" bound to what the type will eventually be processed into. * with "this" bound to what the type will eventually be processed into.
* Intended for making lazily evaluated objects. * Intended for making lazily evaluated objects.
*/ */
export type OptionsFunc<T, R = unknown, S = R> = (obj: R) => OptionsObject<T, R, S>; export type OptionsFunc<T, R = unknown> = (this: R, obj: R) => OptionsObject<T, R>;
export type OptionsObject<T, R = unknown, S = R> = T & Partial<R> & ThisType<T & S>; export type OptionsObject<T, R = unknown> = Exclude<T, R> & Partial<R>;
let id = 0; let id = 0;
/** /**

View file

@ -1,6 +1,6 @@
import type { JSXFunction } from "features/feature"; import type { JSXFunction } from "features/feature";
import { isFunction } from "util/common"; import { isFunction } from "util/common";
import type { Ref } from "vue"; import type { ComputedRef, Ref } from "vue";
import { computed } from "vue"; import { computed } from "vue";
export const DoNotCache = Symbol("DoNotCache"); export const DoNotCache = Symbol("DoNotCache");
@ -26,32 +26,71 @@ export type ComputableKeysOf<T> = Pick<
}[keyof T] }[keyof T]
>; >;
export type ProcessedFeature<T, S extends T> = {
[K in keyof S]: K extends keyof T
? T[K] extends Computable<infer R>
? S[K] extends () => infer Q
? ComputedRef<Q>
: S[K]
: S[K]
: S[K];
};
export type Defaults<T, S> = {
[K in keyof S]: K extends keyof T ? (T[K] extends undefined ? S[K] : T[K]) : S[K];
};
// TODO fix the typing of this function, such that casting isn't necessary and can be used to // TODO fix the typing of this function, such that casting isn't necessary and can be used to
// detect if a createX function is validly written // detect if a createX function is validly written
export function processComputable<T, S extends keyof ComputableKeysOf<T>>( // export function processComputable<
obj: T, // T extends object,
key: S // S extends keyof {
): asserts obj is T & { [K in S]: ProcessedComputable<UnwrapComputableType<T[S]>> } { // [K in keyof T]: T[K] extends Computable<unknown> ? K : never;
const computable = obj[key]; // },
if ( // R = T[S]
isFunction(computable) && // >(
computable.length === 0 && // obj: T,
!(computable as unknown as JSXFunction)[DoNotCache] // key: S
) { // ): asserts obj is {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // [K in keyof T]: K extends keyof S ? S[K] extends ProcessedComputable<UnwrapComputableType<R>> : T[K];
// @ts-ignore // } {
obj[key] = computed(computable.bind(obj)); // const computable = obj[key];
} else if (isFunction(computable)) { // if (
obj[key] = computable.bind(obj) as unknown as T[S]; // isFunction(computable) &&
(obj[key] as unknown as JSXFunction)[DoNotCache] = true; // 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];
// (obj[key] as unknown as JSXFunction)[DoNotCache] = true;
// }
// }
function isJSXFunction(value: unknown): value is JSXFunction {
return typeof value === "function" && DoNotCache in value && value[DoNotCache] === true;
} }
export function convertComputable<T>(obj: Computable<T>): ProcessedComputable<T> { export function convertComputable<T>(
if (isFunction(obj) && !(obj as unknown as JSXFunction)[DoNotCache]) { obj: Computable<NonNullable<T>>,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment thisArg?: object
// @ts-ignore ): typeof obj extends JSXFunction ? typeof obj : ProcessedComputable<T>;
export function convertComputable(obj: undefined, thisArg?: object): undefined;
export function convertComputable<T>(
obj: Computable<T> | undefined,
thisArg?: object
): (typeof obj extends JSXFunction ? typeof obj : ProcessedComputable<T>) | undefined;
export function convertComputable<T>(
obj: Computable<T> | undefined,
thisArg?: object
) /*: (typeof obj extends JSXFunction ? typeof obj : ProcessedComputable<T>) | undefined*/ {
if (isFunction(obj) && !isJSXFunction(obj)) {
if (thisArg != null) {
obj = obj.bind(thisArg);
}
obj = computed(obj); obj = computed(obj);
} }
return obj as ProcessedComputable<T>; return obj;
} }