refactor: make funbox settings an array (@fehmer) (#6487)

change funbox from "hash separated values" to array.

---------

Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
Christian Fehmer 2025-04-29 11:31:44 +02:00 committed by GitHub
parent b36bc9f39e
commit 212b8d38cb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 500 additions and 474 deletions

View file

@ -626,7 +626,7 @@ describe("result controller test", () => {
...result,
difficulty: "normal",
language: "english",
funbox: "none",
funbox: [],
lazyMode: false,
punctuation: false,
numbers: false,
@ -707,7 +707,7 @@ describe("result controller test", () => {
chartData: { wpm: [1, 2, 3], raw: [50, 55, 56], err: [0, 2, 0] },
consistency: 23.5,
difficulty: "normal",
funbox: "none",
funbox: [],
hash: "hash",
incompleteTestSeconds: 2,
incompleteTests: [{ acc: 75, seconds: 10 }],
@ -831,7 +831,7 @@ describe("result controller test", () => {
chartData: { wpm: [1, 2, 3], raw: [50, 55, 56], err: [0, 2, 0] },
consistency: 23.5,
difficulty: "normal",
funbox: "none",
funbox: [],
hash: "hash",
incompleteTestSeconds: 2,
incompleteTests: [{ acc: 75, seconds: 10 }],

View file

@ -3,14 +3,13 @@ import { ObjectId } from "mongodb";
import * as UserDal from "../../src/dal/user";
import { DBResult } from "../../src/utils/result";
let uid: string = "";
let uid: string;
const timestamp = Date.now() - 60000;
async function createDummyData(
uid: string,
count: number,
timestamp: number,
tag?: string
modify?: Partial<DBResult>
): Promise<void> {
const dummyUser: UserDal.DBUser = {
_id: new ObjectId(),
@ -28,51 +27,53 @@ async function createDummyData(
};
vi.spyOn(UserDal, "getUser").mockResolvedValue(dummyUser);
const tags: string[] = [];
if (tag !== undefined) tags.push(tag);
for (let i = 0; i < count; i++) {
await ResultDal.addResult(uid, {
_id: new ObjectId(),
wpm: i,
rawWpm: i,
charStats: [0, 0, 0, 0],
acc: 0,
mode: "time",
mode2: "10" as never,
quoteLength: 1,
timestamp,
restartCount: 0,
incompleteTestSeconds: 0,
incompleteTests: [],
testDuration: 10,
afkDuration: 0,
tags,
consistency: 100,
keyConsistency: 100,
chartData: { wpm: [], raw: [], err: [] },
uid,
keySpacingStats: { average: 0, sd: 0 },
keyDurationStats: { average: 0, sd: 0 },
difficulty: "normal",
language: "english",
isPb: false,
name: "Test",
} as DBResult);
...{
_id: new ObjectId(),
wpm: i,
rawWpm: i,
charStats: [0, 0, 0, 0],
acc: 0,
mode: "time",
mode2: "10" as never,
quoteLength: 1,
timestamp,
restartCount: 0,
incompleteTestSeconds: 0,
incompleteTests: [],
testDuration: 10,
afkDuration: 0,
tags: [],
consistency: 100,
keyConsistency: 100,
chartData: { wpm: [], raw: [], err: [] },
uid,
keySpacingStats: { average: 0, sd: 0 },
keyDurationStats: { average: 0, sd: 0 },
difficulty: "normal",
language: "english",
isPb: false,
name: "Test",
funbox: ["58008", "read_ahead"],
},
...modify,
});
}
}
describe("ResultDal", () => {
beforeEach(() => {
uid = new ObjectId().toHexString();
});
afterEach(async () => {
if (uid) await ResultDal.deleteAll(uid);
});
describe("getResults", () => {
beforeEach(() => {
uid = new ObjectId().toHexString();
});
afterEach(async () => {
if (uid) await ResultDal.deleteAll(uid);
});
it("should read lastest 10 results ordered by timestamp", async () => {
//GIVEN
await createDummyData(uid, 10, timestamp - 2000, "old");
await createDummyData(uid, 20, timestamp, "current");
await createDummyData(uid, 10, { timestamp: timestamp - 2000 });
await createDummyData(uid, 20, { tags: ["current"] });
//WHEN
const results = await ResultDal.getResults(uid, { limit: 10 });
@ -88,8 +89,8 @@ describe("ResultDal", () => {
});
it("should read all if not limited", async () => {
//GIVEN
await createDummyData(uid, 10, timestamp - 2000, "old");
await createDummyData(uid, 20, timestamp, "current");
await createDummyData(uid, 10, { timestamp: timestamp - 2000 });
await createDummyData(uid, 20);
//WHEN
const results = await ResultDal.getResults(uid, {});
@ -99,8 +100,8 @@ describe("ResultDal", () => {
});
it("should read results onOrAfterTimestamp", async () => {
//GIVEN
await createDummyData(uid, 10, timestamp - 2000, "old");
await createDummyData(uid, 20, timestamp, "current");
await createDummyData(uid, 10, { timestamp: timestamp - 2000 });
await createDummyData(uid, 20, { tags: ["current"] });
//WHEN
const results = await ResultDal.getResults(uid, {
@ -115,8 +116,11 @@ describe("ResultDal", () => {
});
it("should read next 10 results", async () => {
//GIVEN
await createDummyData(uid, 10, timestamp - 2000, "old");
await createDummyData(uid, 20, timestamp, "current");
await createDummyData(uid, 10, {
timestamp: timestamp - 2000,
tags: ["old"],
});
await createDummyData(uid, 20);
//WHEN
const results = await ResultDal.getResults(uid, {
@ -130,5 +134,84 @@ describe("ResultDal", () => {
expect(it.tags).toContain("old");
});
});
it("should convert legacy values", async () => {
//GIVEN
await createDummyData(uid, 1, { funbox: "58008#read_ahead" as any });
//WHEN
const results = await ResultDal.getResults(uid);
//THEN
expect(results[0]?.funbox).toEqual(["58008", "read_ahead"]);
});
});
describe("getResult", () => {
it("should convert legacy values", async () => {
//GIVEN
await createDummyData(uid, 1, { funbox: "58008#read_ahead" as any });
const resultId = (await ResultDal.getLastResult(uid))._id.toHexString();
//WHEN
const result = await ResultDal.getResult(uid, resultId);
//THEN
expect(result?.funbox).toEqual(["58008", "read_ahead"]);
});
});
describe("getLastResult", () => {
it("should convert legacy values", async () => {
//GIVEN
await createDummyData(uid, 1, { funbox: "58008#read_ahead" as any });
//WHEN
const result = await ResultDal.getLastResult(uid);
//THEN
expect(result?.funbox).toEqual(["58008", "read_ahead"]);
});
});
describe("getResultByTimestamp", () => {
it("should convert legacy values", async () => {
//GIVEN
await createDummyData(uid, 1, { funbox: "58008#read_ahead" as any });
//WHEN
const result = await ResultDal.getResultByTimestamp(uid, timestamp);
//THEN
expect(result?.funbox).toEqual(["58008", "read_ahead"]);
});
});
describe("converts legacy values", () => {
it("should convert funbox as string", async () => {
//GIVEN
await createDummyData(uid, 1, { funbox: "58008#read_ahead" as any });
//WHEN
const read = await ResultDal.getLastResult(uid);
//THEN
expect(read.funbox).toEqual(["58008", "read_ahead"]);
});
it("should convert funbox 'none'", async () => {
//GIVEN
await createDummyData(uid, 1, { funbox: "none" as any });
//WHEN
const read = await ResultDal.getLastResult(uid);
//THEN
expect(read.funbox).toEqual([]);
});
it("should not convert funbox as array", async () => {
//GIVEN
await createDummyData(uid, 1, { funbox: ["58008", "read_ahead"] });
//WHEN
const read = await ResultDal.getLastResult(uid);
//THEN
expect(read.funbox).toEqual(["58008", "read_ahead"]);
});
});
});

View file

@ -2,27 +2,33 @@ import _ from "lodash";
import * as pb from "../../src/utils/pb";
import { Mode, PersonalBests } from "@monkeytype/contracts/schemas/shared";
import { Result } from "@monkeytype/contracts/schemas/results";
import { FunboxName } from "@monkeytype/contracts/schemas/configs";
describe("Pb Utils", () => {
it("funboxCatGetPb", () => {
const testCases = [
{
funbox: "plus_one",
expected: true,
},
{
funbox: "none",
expected: true,
},
{
funbox: "nausea#plus_one",
expected: true,
},
{
funbox: "arrows",
expected: false,
},
];
const testCases: { funbox: FunboxName[] | undefined; expected: boolean }[] =
[
{
funbox: ["plus_one"],
expected: true,
},
{
funbox: [],
expected: true,
},
{
funbox: undefined,
expected: true,
},
{
funbox: ["nausea", "plus_one"],
expected: true,
},
{
funbox: ["arrows"],
expected: false,
},
];
_.each(testCases, (testCase) => {
const { funbox, expected } = testCase;

View file

@ -58,11 +58,7 @@ import {
getStartOfDayTimestamp,
} from "@monkeytype/util/date-and-time";
import { MonkeyRequest } from "../types";
import {
getFunbox,
checkCompatibility,
stringToFunboxNames,
} from "@monkeytype/funbox";
import { getFunbox, checkCompatibility } from "@monkeytype/funbox";
import { tryCatch } from "@monkeytype/util/trycatch";
try {
@ -175,8 +171,8 @@ export async function updateTags(
if (!(result.language ?? "")) {
result.language = "english";
}
if (!(result.funbox ?? "")) {
result.funbox = "none";
if (result.funbox === undefined) {
result.funbox = [];
}
if (!result.lazyMode) {
result.lazyMode = false;
@ -242,16 +238,11 @@ export async function addResult(
Logger.warning("Object hash check is disabled, skipping hash check");
}
if (completedEvent.funbox) {
const funboxes = completedEvent.funbox.split("#");
if (funboxes.length !== _.uniq(funboxes).length) {
throw new MonkeyError(400, "Duplicate funboxes");
}
if (completedEvent.funbox.length !== _.uniq(completedEvent.funbox).length) {
throw new MonkeyError(400, "Duplicate funboxes");
}
const funboxNames = stringToFunboxNames(completedEvent.funbox ?? "");
if (!checkCompatibility(funboxNames)) {
if (!checkCompatibility(completedEvent.funbox)) {
throw new MonkeyError(400, "Impossible funbox combination");
}
@ -732,15 +723,12 @@ async function calculateXp(
}
}
if (funboxBonusConfiguration > 0 && resultFunboxes !== "none") {
const funboxModifier = _.sumBy(
stringToFunboxNames(resultFunboxes),
(funboxName) => {
const funbox = getFunbox(funboxName);
const difficultyLevel = funbox?.difficultyLevel ?? 0;
return Math.max(difficultyLevel * funboxBonusConfiguration, 0);
}
);
if (funboxBonusConfiguration > 0 && resultFunboxes.length !== 0) {
const funboxModifier = _.sumBy(resultFunboxes, (funboxName) => {
const funbox = getFunbox(funboxName);
const difficultyLevel = funbox?.difficultyLevel ?? 0;
return Math.max(difficultyLevel * funboxBonusConfiguration, 0);
});
if (funboxModifier > 0) {
modifier += funboxModifier;
breakdown.funbox = Math.round(baseXp * funboxModifier);

View file

@ -10,6 +10,7 @@ import * as db from "../init/db";
import { getUser, getTags } from "./user";
import { DBResult } from "../utils/result";
import { FunboxName } from "@monkeytype/contracts/schemas/configs";
import { tryCatch } from "@monkeytype/util/trycatch";
export const getResultCollection = (): Collection<DBResult> =>
@ -65,7 +66,7 @@ export async function getResult(uid: string, id: string): Promise<DBResult> {
uid,
});
if (!result) throw new MonkeyError(404, "Result not found");
return result;
return convert(result);
}
export async function getLastResult(uid: string): Promise<DBResult> {
@ -75,14 +76,15 @@ export async function getLastResult(uid: string): Promise<DBResult> {
.limit(1)
.toArray();
if (!lastResult) throw new MonkeyError(404, "No results found");
return lastResult;
return convert(lastResult);
}
export async function getResultByTimestamp(
uid: string,
timestamp: number
): Promise<DBResult | null> {
return await getResultCollection().findOne({ uid, timestamp });
const result = await getResultCollection().findOne({ uid, timestamp });
return convert(result);
}
type GetResultsOpts = {
@ -125,5 +127,26 @@ export async function getResults(
const results = await query.toArray();
if (results === undefined) throw new MonkeyError(404, "Result not found");
return results;
return convert(results);
}
function convert<T extends DBResult | DBResult[] | null>(results: T): T {
if (results === null) return results;
const migrate = (result: DBResult): DBResult => {
if (typeof result.funbox === "string") {
if (result.funbox === "none") {
result.funbox = [];
} else {
result.funbox = (result.funbox as string).split("#") as FunboxName[];
}
}
return result;
};
if (Array.isArray(results)) {
return results.map(migrate) as T;
} else {
return migrate(results) as T;
}
}

View file

@ -5,7 +5,7 @@ import {
PersonalBests,
} from "@monkeytype/contracts/schemas/shared";
import { Result as ResultType } from "@monkeytype/contracts/schemas/results";
import { getFunboxesFromString } from "@monkeytype/funbox";
import { getFunbox } from "@monkeytype/funbox";
export type LbPersonalBests = {
time: Record<number, Record<string, PersonalBest>>;
@ -20,16 +20,9 @@ type CheckAndUpdatePbResult = {
type Result = Omit<ResultType<Mode>, "_id" | "name">;
export function canFunboxGetPb(result: Result): boolean {
const funboxString = result.funbox;
if (
funboxString === undefined ||
funboxString === "" ||
funboxString === "none"
) {
return true;
}
if (result.funbox === undefined || result.funbox.length === 0) return true;
return getFunboxesFromString(funboxString).every((f) => f.canGetPb);
return getFunbox(result.funbox).every((f) => f.canGetPb);
}
export function checkAndUpdatePb(

View file

@ -130,7 +130,7 @@ export function incrementResult(res: CompletedEvent, isPb?: boolean): void {
});
resultFunbox.inc({
funbox: funbox || "none",
funbox: (funbox ?? ["none"]).join("#"),
});
resultWpm.observe(

View file

@ -52,7 +52,7 @@ export function buildDbResult(
if (!ce.blindMode) delete res.blindMode;
if (!ce.lazyMode) delete res.lazyMode;
if (ce.difficulty === "normal") delete res.difficulty;
if (ce.funbox === "none") delete res.funbox;
if (ce.funbox.length === 0) delete res.funbox;
if (ce.language === "english") delete res.language;
if (!ce.numbers) delete res.numbers;
if (!ce.punctuation) delete res.punctuation;

View file

@ -1,6 +1,9 @@
import * as Config from "../../src/ts/config";
import { CustomThemeColors } from "@monkeytype/contracts/schemas/configs";
import {
CustomThemeColors,
FunboxName,
} from "@monkeytype/contracts/schemas/configs";
import { randomBytes } from "crypto";
describe("Config", () => {
@ -332,10 +335,10 @@ describe("Config", () => {
expect(Config.setFavThemes([stringOfLength(51)])).toBe(false);
});
it("setFunbox", () => {
expect(Config.setFunbox("mirror")).toBe(true);
expect(Config.setFunbox("mirror#58008")).toBe(true);
expect(Config.setFunbox(["mirror"])).toBe(true);
expect(Config.setFunbox(["mirror", "58008"])).toBe(true);
expect(Config.setFunbox(stringOfLength(101))).toBe(false);
expect(Config.setFunbox([stringOfLength(101) as FunboxName])).toBe(false);
});
it("setPaceCaretCustomSpeed", () => {
expect(Config.setPaceCaretCustomSpeed(0)).toBe(true);

View file

@ -1,6 +1,7 @@
import { canSetConfigWithCurrentFunboxes } from "../../../src/ts/test/funbox/funbox-validation";
import * as Notifications from "../../../src/ts/elements/notifications";
import { FunboxName } from "@monkeytype/contracts/schemas/configs";
describe("funbox-validation", () => {
describe("canSetConfigWithCurrentFunboxes", () => {
const addNotificationMock = vi.spyOn(Notifications, "add");
@ -60,7 +61,7 @@ describe("funbox-validation", () => {
`check $funbox with $key=$value`,
({ key, value, funbox, error }) => {
expect(
canSetConfigWithCurrentFunboxes(key, value, funbox.join("#"))
canSetConfigWithCurrentFunboxes(key, value, funbox as FunboxName[])
).toBe(error === undefined);
if (error !== undefined) {

View file

@ -8,6 +8,7 @@ import * as TestLogic from "../../src/ts/test/test-logic";
import * as TestState from "../../src/ts/test/test-state";
import * as Misc from "../../src/ts/utils/misc";
import { loadTestSettingsFromUrl } from "../../src/ts/utils/url-handler";
import { FunboxName } from "@monkeytype/contracts/schemas/configs";
//mock modules to avoid dependencies
vi.mock("../../src/ts/test/test-logic", () => ({
@ -167,6 +168,19 @@ describe("url-handler", () => {
expect(restartTestMock).toHaveBeenCalled();
});
it("sets funbox", () => {
//GIVEN
findGetParameterMock.mockReturnValue(
urlData({ funbox: ["crt", "choo_choo"] })
);
//WHEN
loadTestSettingsFromUrl("");
//THEN
expect(setFunboxMock).toHaveBeenCalledWith(["crt", "choo_choo"], true);
expect(restartTestMock).toHaveBeenCalled();
});
it("sets funbox legacy", () => {
//GIVEN
findGetParameterMock.mockReturnValue(
urlData({ funbox: "crt#choo_choo" })
@ -176,7 +190,7 @@ describe("url-handler", () => {
loadTestSettingsFromUrl("");
//THEN
expect(setFunboxMock).toHaveBeenCalledWith("crt#choo_choo", true);
expect(setFunboxMock).toHaveBeenCalledWith(["crt", "choo_choo"], true);
expect(restartTestMock).toHaveBeenCalled();
});
it("adds notification", () => {
@ -195,7 +209,7 @@ describe("url-handler", () => {
numbers: true,
language: "english",
difficulty: "master",
funbox: "a#b",
funbox: ["ascii", "crt"],
})
);
@ -204,7 +218,7 @@ describe("url-handler", () => {
//THEN
expect(addNotificationMock).toHaveBeenCalledWith(
"Settings applied from URL:<br><br>mode: time<br>mode2: 60<br>custom text settings<br>punctuation: on<br>numbers: on<br>language: english<br>difficulty: master<br>funbox: a#b<br>",
"Settings applied from URL:<br><br>mode: time<br>mode2: 60<br>custom text settings<br>punctuation: on<br>numbers: on<br>language: english<br>difficulty: master<br>funbox: ascii, crt<br>",
1,
{
duration: 10,
@ -246,7 +260,7 @@ describe("url-handler", () => {
\"3\" Expected boolean, received string
\"4\" Expected boolean, received string
\"6\" Invalid enum value. Expected 'normal' | 'expert' | 'master', received 'invalid'
\"7\" Expected string, received array`,
\"7\" Invalid input`,
0
);
});
@ -262,7 +276,7 @@ const urlData = (
numbers: boolean;
language: string;
difficulty: Difficulty;
funbox: string;
funbox: FunboxName[] | string;
}>
): string => {
return compressToURI(

View file

@ -150,7 +150,7 @@ function validateOthers() {
funbox: {
type: "object",
properties: {
exact: { type: "string" },
exact: { type: "array" },
},
},
},

View file

@ -388,11 +388,7 @@ async function showCommands(): Promise<void> {
} else if (configKey !== undefined) {
let isActive;
if (command.configValueMode === "funbox") {
isActive = (Config[configKey] as string)
.split("#")
.includes(command.configValue as string);
} else if (command.configValueMode === "include") {
if (command.configValueMode === "include") {
isActive = (
Config[configKey] as (
| string
@ -741,7 +737,7 @@ const modal = new AnimatedModal({
}
});
modalEl.addEventListener("mousemove", (e) => {
modalEl.addEventListener("mousemove", (_e) => {
mouseMode = true;
});
},

View file

@ -14,7 +14,7 @@ const list: Command[] = [
sticky: true,
exec: (): void => {
ManualRestart.set();
if (Funbox.setFunbox("none")) {
if (Funbox.setFunbox([])) {
TestLogic.restart();
}
},
@ -33,8 +33,7 @@ for (const funbox of getAllFunboxes()) {
sticky: true,
alias: funbox.alias,
configValue: funbox.name,
//todo remove funbox mode once Config.funbox is changed to an array
configValueMode: "funbox",
configValueMode: "include",
exec: (): void => {
Funbox.toggleFunbox(funbox.name);
ManualRestart.set();

View file

@ -25,7 +25,7 @@ export type Command = {
defaultValue?: () => string;
configKey?: keyof Config;
configValue?: string | number | boolean | number[];
configValueMode?: "include" | "funbox";
configValueMode?: "include";
exec?: (options: CommandExecOptions) => void;
hover?: () => void;
available?: () => boolean;

View file

@ -23,7 +23,7 @@ import {
typedKeys,
} from "./utils/misc";
import * as ConfigSchemas from "@monkeytype/contracts/schemas/configs";
import { Config } from "@monkeytype/contracts/schemas/configs";
import { Config, FunboxName } from "@monkeytype/contracts/schemas/configs";
import { Mode, ModeSchema } from "@monkeytype/contracts/schemas/shared";
import { Language, LanguageSchema } from "@monkeytype/contracts/schemas/util";
import { LocalStorageWithSchema } from "./utils/local-storage-with-schema";
@ -262,52 +262,42 @@ export function setFunbox(
if (!isConfigValueValid("funbox", funbox, ConfigSchemas.FunboxSchema))
return false;
for (const funbox of config.funbox.split("#")) {
for (const funbox of config.funbox) {
if (!canSetFunboxWithConfig(funbox, config)) {
return false;
}
}
const val = funbox || "none";
config.funbox = val;
config.funbox = funbox;
saveToLocalStorage("funbox", nosave);
ConfigEvent.dispatch("funbox", config.funbox);
return true;
}
export function toggleFunbox(
funbox: ConfigSchemas.Funbox,
nosave?: boolean
): number | boolean {
if (!isConfigValueValid("funbox", funbox, ConfigSchemas.FunboxSchema))
export function toggleFunbox(funbox: FunboxName, nosave?: boolean): boolean {
if (!canSetFunboxWithConfig(funbox, config)) {
return false;
let r;
const funboxArray = config.funbox.split("#");
if (funboxArray[0] === "none") funboxArray.splice(0, 1);
if (!funboxArray.includes(funbox)) {
if (!canSetFunboxWithConfig(funbox, config)) {
return false;
}
funboxArray.push(funbox);
config.funbox = funboxArray.sort().join("#");
r = funboxArray.indexOf(funbox);
} else {
r = funboxArray.indexOf(funbox);
funboxArray.splice(r, 1);
if (funboxArray.length === 0) {
config.funbox = "none";
} else {
config.funbox = funboxArray.join("#");
}
r = -r - 1;
}
let newConfig: FunboxName[] = config.funbox;
if (newConfig.includes(funbox)) {
newConfig = newConfig.filter((it) => it !== funbox);
} else {
newConfig.push(funbox);
newConfig.sort();
}
if (!isConfigValueValid("funbox", newConfig, ConfigSchemas.FunboxSchema)) {
return false;
}
config.funbox = newConfig;
saveToLocalStorage("funbox", nosave);
ConfigEvent.dispatch("funbox", config.funbox);
return r;
return true;
}
export function setBlindMode(blind: boolean, nosave?: boolean): boolean {

View file

@ -43,7 +43,7 @@ const obj = {
paceCaretStyle: "default",
flipTestColors: false,
layout: "default",
funbox: "none",
funbox: [],
confidenceMode: "off",
indicateTypos: "off",
timerStyle: "mini",

View file

@ -8,7 +8,7 @@ import { deepClone } from "../utils/misc";
import { getDefaultConfig } from "./default-config";
import { Mode } from "@monkeytype/contracts/schemas/shared";
import { Result } from "@monkeytype/contracts/schemas/results";
import { Config } from "@monkeytype/contracts/schemas/configs";
import { Config, FunboxName } from "@monkeytype/contracts/schemas/configs";
import {
ModifiableTestActivityCalendar,
TestActivityCalendar,
@ -42,7 +42,7 @@ export type SnapshotResult<M extends Mode> = Omit<
blindMode: boolean;
lazyMode: boolean;
difficulty: string;
funbox: string;
funbox: FunboxName[];
language: string;
numbers: boolean;
punctuation: boolean;

View file

@ -16,9 +16,11 @@ import {
import {
Config as ConfigType,
Difficulty,
FunboxName,
} from "@monkeytype/contracts/schemas/configs";
import { Mode } from "@monkeytype/contracts/schemas/shared";
import { CompletedEvent } from "@monkeytype/contracts/schemas/results";
import { areUnsortedArraysEqual } from "../utils/arrays";
import { tryCatch } from "@monkeytype/util/trycatch";
let challengeLoading = false;
@ -36,7 +38,10 @@ export function clearActive(): void {
function verifyRequirement(
result: CompletedEvent,
requirements: Record<string, Record<string, string | number | boolean>>,
requirements: Record<
string,
Record<string, string | number | boolean | FunboxName[]>
>,
requirementType: string
): [boolean, string[]] {
let requirementsMet = true;
@ -93,29 +98,21 @@ function verifyRequirement(
}
}
} else if (requirementType === "funbox") {
const funboxMode = requirementValue["exact"]
?.toString()
.split("#")
.sort()
.join("#");
const funboxMode = requirementValue["exact"] as FunboxName[];
if (funboxMode === undefined) {
throw new Error("Funbox mode is undefined");
}
if (funboxMode !== result.funbox) {
if (!areUnsortedArraysEqual(funboxMode, result.funbox)) {
requirementsMet = false;
for (const f of funboxMode.split("#")) {
if (
result.funbox?.split("#").find((rf: string) => rf === f) === undefined
) {
for (const f of funboxMode) {
if (!result.funbox?.includes(f)) {
failReasons.push(`${f} funbox not active`);
}
}
const funboxSplit = result.funbox?.split("#");
if (funboxSplit !== undefined && funboxSplit.length > 0) {
for (const f of funboxSplit) {
if (funboxMode.split("#").find((rf) => rf === f) === undefined) {
if (result.funbox !== undefined && result.funbox.length > 0) {
for (const f of result.funbox) {
if (!funboxMode.includes(f)) {
failReasons.push(`${f} funbox active`);
}
}
@ -218,7 +215,7 @@ export function verify(result: CompletedEvent): string | null {
export async function setup(challengeName: string): Promise<boolean> {
challengeLoading = true;
UpdateConfig.setFunbox("none");
UpdateConfig.setFunbox([]);
const { data: list, error } = await tryCatch(JSONData.getChallengeList());
if (error) {
@ -288,14 +285,14 @@ export async function setup(challengeName: string): Promise<boolean> {
UpdateConfig.setTheme(challenge.parameters[1] as string);
}
if (challenge.parameters[2] !== null) {
void Funbox.activate(challenge.parameters[2] as string);
void Funbox.activate(challenge.parameters[2] as FunboxName[]);
}
} else if (challenge.type === "accuracy") {
UpdateConfig.setTimeConfig(0, true);
UpdateConfig.setMode("time", true);
UpdateConfig.setDifficulty("master", true);
} else if (challenge.type === "funbox") {
UpdateConfig.setFunbox(challenge.parameters[0] as string, true);
UpdateConfig.setFunbox(challenge.parameters[0] as FunboxName[], true);
UpdateConfig.setDifficulty("normal", true);
if (challenge.parameters[1] === "words") {
UpdateConfig.setWordCount(challenge.parameters[2] as number, true);

View file

@ -307,7 +307,7 @@ export async function getUserResults(offset?: number): Promise<boolean> {
if (result.blindMode === undefined) result.blindMode = false;
if (result.lazyMode === undefined) result.lazyMode = false;
if (result.difficulty === undefined) result.difficulty = "normal";
if (result.funbox === undefined) result.funbox = "none";
if (result.funbox === undefined) result.funbox = [];
if (result.language === undefined || result.language === null) {
result.language = "english";
}

View file

@ -645,10 +645,10 @@ $(".pageAccount .topFilters button.currentConfigFilter").on("click", () => {
filters.language[Config.language] = true;
}
if (Config.funbox === "none") {
if (Config.funbox.length === 0) {
filters.funbox["none"] = true;
} else {
for (const f of Config.funbox.split("#")) {
for (const f of Config.funbox) {
filters.funbox[f] = true;
}
}

View file

@ -214,11 +214,11 @@ export async function update(): Promise<void> {
);
}
if (Config.funbox !== "none") {
if (Config.funbox.length > 0) {
$(".pageTest #testModesNotice").append(
`<button class="textButton" commands="funbox"><i class="fas fa-gamepad"></i>${Config.funbox
.replace(/_/g, " ")
.replace(/#/g, ", ")}</button>`
.map((it) => it.replace(/_/g, " "))
.join(", ")}</button>`
);
}

View file

@ -70,8 +70,8 @@ function fillData(): void {
if (r.punctuation) tt += "<br>punctuation";
if (r.blindMode) tt += "<br>blind";
if (r.lazyMode) tt += "<br>lazy";
if (r.funbox !== "none") {
tt += "<br>" + r.funbox.replace(/_/g, " ").replace(/#/g, ", ");
if (r.funbox.length > 0) {
tt += "<br>" + r.funbox.map((it) => it.replace(/_/g, " ")).join(",");
}
if (r.difficulty !== "normal") tt += "<br>" + r.difficulty;
if (r.tags.length > 0) tt += "<br>" + r.tags.length + " tags";
@ -116,11 +116,11 @@ const modal = new AnimatedModal({
setup: async (modalEl): Promise<void> => {
modalEl
.querySelector("button.save")
?.addEventListener("click", async (e) => {
?.addEventListener("click", async () => {
void syncNotSignedInLastResult(Auth?.currentUser?.uid as string);
hide();
});
modalEl.querySelector("button.discard")?.addEventListener("click", (e) => {
modalEl.querySelector("button.discard")?.addEventListener("click", () => {
TestLogic.clearNotSignedInResult();
Notifications.add("Last test result discarded", 0);
hide();

View file

@ -4,7 +4,7 @@ import { getMode2 } from "../utils/misc";
import * as CustomText from "../test/custom-text";
import { compressToURI } from "lz-ts";
import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
import { Difficulty } from "@monkeytype/contracts/schemas/configs";
import { Difficulty, FunboxName } from "@monkeytype/contracts/schemas/configs";
import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared";
function getCheckboxValue(checkbox: string): boolean {
@ -21,7 +21,7 @@ type SharedTestSettings = [
boolean | null,
string | null,
Difficulty | null,
string | null
FunboxName[] | null
];
function updateURL(): void {

View file

@ -97,11 +97,10 @@ function loadMoreLines(lineIndex?: number): void {
icons += `<span aria-label="lazy mode" data-balloon-pos="up"><i class="fas fa-fw fa-couch"></i></span>`;
}
if (result.funbox !== "none" && result.funbox !== undefined) {
if (result.funbox !== undefined && result.funbox.length > 0) {
icons += `<span aria-label="${result.funbox
.replace(/_/g, " ")
.replace(
/#/g,
.map((it) => it.replace(/_/g, " "))
.join(
", "
)}" data-balloon-pos="up"><i class="fas fa-gamepad"></i></span>`;
}
@ -414,7 +413,7 @@ async function fillContent(): Promise<void> {
return;
}
if (result.funbox === "none" || result.funbox === undefined) {
if (result.funbox === undefined || result.funbox.length === 0) {
if (!ResultFilters.getFilter("funbox", "none")) {
if (filterDebug) {
console.log(`skipping result due to funbox filter`, result);
@ -423,7 +422,7 @@ async function fillContent(): Promise<void> {
}
} else {
let counter = 0;
for (const f of result.funbox.split("#")) {
for (const f of result.funbox) {
if (ResultFilters.getFilter("funbox", f)) {
counter++;
break;

View file

@ -23,16 +23,14 @@ import * as CustomBackgroundFilter from "../elements/custom-background-filter";
import {
ConfigValue,
CustomBackgroundSchema,
} from "@monkeytype/contracts/schemas/configs";
import {
getAllFunboxes,
FunboxName,
checkCompatibility,
} from "@monkeytype/funbox";
} from "@monkeytype/contracts/schemas/configs";
import { getAllFunboxes, checkCompatibility } from "@monkeytype/funbox";
import { getActiveFunboxNames } from "../test/funbox/list";
import { SnapshotPreset } from "../constants/default-snapshot";
import { LayoutsList } from "../constants/layouts";
import { DataArrayPartial, Optgroup } from "slim-select/store";
import { areUnsortedArraysEqual } from "../utils/arrays";
import { tryCatch } from "@monkeytype/util/trycatch";
type SettingsGroups<T extends ConfigValue> = Record<string, SettingsGroup<T>>;
@ -708,7 +706,7 @@ async function fillSettingsPage(): Promise<void> {
events: {
afterChange: (newVal): void => {
const customPolyglot = newVal.map((it) => it.value);
if (customPolyglot.toSorted() !== Config.customPolyglot.toSorted()) {
if (!areUnsortedArraysEqual(customPolyglot, Config.customPolyglot)) {
void UpdateConfig.setCustomPolyglot(customPolyglot);
}
},
@ -755,18 +753,18 @@ function setActiveFunboxButton(): void {
getAllFunboxes().forEach((funbox) => {
if (
!checkCompatibility(getActiveFunboxNames(), funbox.name) &&
!Config.funbox.split("#").includes(funbox.name)
!Config.funbox.includes(funbox.name)
) {
$(
`.pageSettings .section[data-config-name='funbox'] .button[data-config-value='${funbox.name}']`
).addClass("disabled");
}
});
Config.funbox.split("#").forEach((funbox) => {
for (const funbox of Config.funbox) {
$(
`.pageSettings .section[data-config-name='funbox'] .button[data-config-value='${funbox}']`
).addClass("active");
});
}
}
function refreshTagsSettingsSection(): void {

View file

@ -7,7 +7,6 @@ import * as Strings from "../../utils/strings";
import { randomIntFromRange } from "@monkeytype/util/numbers";
import * as Arrays from "../../utils/arrays";
import { save } from "./funbox-memory";
import { type FunboxName } from "@monkeytype/funbox";
import * as TTSEvent from "../../observables/tts-event";
import * as Notifications from "../../elements/notifications";
import * as DDR from "../../utils/ddr";
@ -23,7 +22,11 @@ import * as WeakSpot from "../weak-spot";
import * as IPAddresses from "../../utils/ip-addresses";
import * as TestState from "../test-state";
import { WordGenError } from "../../utils/word-gen-error";
import { KeymapLayout, Layout } from "@monkeytype/contracts/schemas/configs";
import {
FunboxName,
KeymapLayout,
Layout,
} from "@monkeytype/contracts/schemas/configs";
export type FunboxFunctions = {
getWord?: (wordset?: Wordset, wordIndex?: number) => string;

View file

@ -1,7 +1,11 @@
import * as Notifications from "../../elements/notifications";
import * as Strings from "../../utils/strings";
import { Config, ConfigValue } from "@monkeytype/contracts/schemas/configs";
import { FunboxMetadata, getFunboxesFromString } from "@monkeytype/funbox";
import {
Config,
ConfigValue,
FunboxName,
} from "@monkeytype/contracts/schemas/configs";
import { FunboxMetadata, getFunbox } from "@monkeytype/funbox";
import { intersect } from "@monkeytype/util/arrays";
export function checkForcedConfig(
@ -73,19 +77,20 @@ export function checkForcedConfig(
export function canSetConfigWithCurrentFunboxes(
key: string,
value: ConfigValue,
funbox: string,
funbox: FunboxName[] = [],
noNotification = false
): boolean {
let errorCount = 0;
const funboxes = getFunbox(funbox);
if (key === "mode") {
let fb = getFunboxesFromString(funbox).filter(
let fb = getFunbox(funbox).filter(
(f) =>
f.frontendForcedConfig?.["mode"] !== undefined &&
!(f.frontendForcedConfig["mode"] as ConfigValue[]).includes(value)
);
if (value === "zen") {
fb = fb.concat(
getFunboxesFromString(funbox).filter((f) => {
funboxes.filter((f) => {
const funcs = f.frontendFunctions ?? [];
const props = f.properties ?? [];
return (
@ -106,7 +111,7 @@ export function canSetConfigWithCurrentFunboxes(
}
if (value === "quote" || value === "custom") {
fb = fb.concat(
getFunboxesFromString(funbox).filter((f) => {
funboxes.filter((f) => {
const funcs = f.frontendFunctions ?? [];
const props = f.properties ?? [];
return (
@ -124,7 +129,7 @@ export function canSetConfigWithCurrentFunboxes(
}
}
if (key === "words" || key === "time") {
if (!checkForcedConfig(key, value, getFunboxesFromString(funbox)).result) {
if (!checkForcedConfig(key, value, funboxes).result) {
if (!noNotification) {
Notifications.add("Active funboxes do not support infinite tests", 0);
return false;
@ -132,9 +137,7 @@ export function canSetConfigWithCurrentFunboxes(
errorCount += 1;
}
}
} else if (
!checkForcedConfig(key, value, getFunboxesFromString(funbox)).result
) {
} else if (!checkForcedConfig(key, value, funboxes).result) {
errorCount += 1;
}
@ -157,16 +160,12 @@ export function canSetConfigWithCurrentFunboxes(
}
export function canSetFunboxWithConfig(
funbox: string,
funbox: FunboxName,
config: Config
): boolean {
console.log("cansetfunboxwithconfig", funbox, config.mode);
let funboxToCheck = config.funbox;
if (funboxToCheck === "none") {
funboxToCheck = funbox;
} else {
funboxToCheck += "#" + funbox;
}
let funboxToCheck = [...config.funbox, funbox];
const errors = [];
for (const [configKey, configValue] of Object.entries(config)) {
if (

View file

@ -6,9 +6,12 @@ import * as ManualRestart from "../manual-restart-tracker";
import Config, * as UpdateConfig from "../../config";
import * as MemoryTimer from "./memory-funbox-timer";
import * as FunboxMemory from "./funbox-memory";
import { HighlightMode } from "@monkeytype/contracts/schemas/configs";
import {
HighlightMode,
FunboxName,
} from "@monkeytype/contracts/schemas/configs";
import { Mode } from "@monkeytype/contracts/schemas/shared";
import { FunboxName, checkCompatibility } from "@monkeytype/funbox";
import { checkCompatibility } from "@monkeytype/funbox";
import {
getActiveFunboxes,
getActiveFunboxNames,
@ -21,15 +24,15 @@ import { checkForcedConfig } from "./funbox-validation";
import { tryCatch } from "@monkeytype/util/trycatch";
export function toggleScript(...params: string[]): void {
if (Config.funbox === "none") return;
if (Config.funbox.length === 0) return;
for (const fb of getActiveFunboxesWithFunction("toggleScript")) {
fb.functions.toggleScript(params);
}
}
export function setFunbox(funbox: string): boolean {
if (funbox === "none") {
export function setFunbox(funbox: FunboxName[]): boolean {
if (funbox.length === 0) {
for (const fb of getActiveFunboxesWithFunction("clearGlobal")) {
fb.functions.clearGlobal();
}
@ -39,14 +42,10 @@ export function setFunbox(funbox: string): boolean {
return true;
}
export function toggleFunbox(funbox: "none" | FunboxName): boolean {
if (funbox === "none") setFunbox("none");
export function toggleFunbox(funbox: FunboxName): void {
if (
!checkCompatibility(
getActiveFunboxNames(),
funbox === "none" ? undefined : funbox
) &&
!Config.funbox.split("#").includes(funbox)
!checkCompatibility(getActiveFunboxNames(), funbox) &&
!Config.funbox.includes(funbox)
) {
Notifications.add(
`${Strings.capitalizeFirstLetter(
@ -54,20 +53,16 @@ export function toggleFunbox(funbox: "none" | FunboxName): boolean {
)} funbox is not compatible with the current funbox selection`,
0
);
return true;
return;
}
FunboxMemory.load();
const e = UpdateConfig.toggleFunbox(funbox, false);
UpdateConfig.toggleFunbox(funbox, false);
if (!getActiveFunboxNames().includes(funbox as FunboxName)) {
get(funbox as FunboxName).functions?.clearGlobal?.();
if (!getActiveFunboxNames().includes(funbox)) {
get(funbox).functions?.clearGlobal?.();
} else {
get(funbox as FunboxName).functions?.applyGlobalCSS?.();
get(funbox).functions?.applyGlobalCSS?.();
}
//todo find out what the hell this means
if (e === false || e === true) return false;
return true;
}
export async function clear(): Promise<boolean> {
@ -88,7 +83,9 @@ export async function clear(): Promise<boolean> {
return true;
}
export async function activate(funbox?: string): Promise<boolean | undefined> {
export async function activate(
funbox?: FunboxName[]
): Promise<boolean | undefined> {
if (funbox === undefined || funbox === null) {
funbox = Config.funbox;
} else if (Config.funbox !== funbox) {
@ -101,14 +98,13 @@ export async function activate(funbox?: string): Promise<boolean | undefined> {
Notifications.add(
Misc.createErrorMessage(
undefined,
`Failed to activate funbox: funboxes ${Config.funbox.replace(
/_/g,
" "
)} are not compatible`
`Failed to activate funbox: funboxes ${Config.funbox
.map((it) => it.replace(/_/g, " "))
.join(", ")} are not compatible`
),
-1
);
UpdateConfig.setFunbox("none", true);
UpdateConfig.setFunbox([], true);
await clear();
return false;
}
@ -127,7 +123,7 @@ export async function activate(funbox?: string): Promise<boolean | undefined> {
Misc.createErrorMessage(error, "Failed to activate funbox"),
-1
);
UpdateConfig.setFunbox("none", true);
UpdateConfig.setFunbox([], true);
await clear();
return false;
}
@ -138,7 +134,7 @@ export async function activate(funbox?: string): Promise<boolean | undefined> {
"Current language does not support this funbox mode",
0
);
UpdateConfig.setFunbox("none", true);
UpdateConfig.setFunbox([], true);
await clear();
return;
}
@ -183,7 +179,7 @@ export async function activate(funbox?: string): Promise<boolean | undefined> {
}
if (!canSetSoFar) {
if (Config.funbox.includes("#")) {
if (Config.funbox.length > 1) {
Notifications.add(
`Failed to activate funboxes ${Config.funbox}: no intersecting forced configs. Disabling funbox`,
-1
@ -194,7 +190,7 @@ export async function activate(funbox?: string): Promise<boolean | undefined> {
-1
);
}
UpdateConfig.setFunbox("none", true);
UpdateConfig.setFunbox([], true);
await clear();
return;
}

View file

@ -1,13 +1,12 @@
import Config from "../../config";
import {
FunboxName,
stringToFunboxNames,
FunboxMetadata,
getFunboxObject,
FunboxProperty,
} from "@monkeytype/funbox";
import { FunboxFunctions, getFunboxFunctions } from "./funbox-functions";
import { FunboxName } from "@monkeytype/contracts/schemas/configs";
type FunboxMetadataWithFunctions = FunboxMetadata & {
functions?: FunboxFunctions;
@ -45,18 +44,12 @@ export function getAllFunboxes(): FunboxMetadataWithFunctions[] {
return Object.values(metadataWithFunctions);
}
export function getFromString(
hashSeparatedFunboxes: string
): FunboxMetadataWithFunctions[] {
return get(stringToFunboxNames(hashSeparatedFunboxes));
}
export function getActiveFunboxes(): FunboxMetadataWithFunctions[] {
return get(stringToFunboxNames(Config.funbox));
return get(getActiveFunboxNames());
}
export function getActiveFunboxNames(): FunboxName[] {
return stringToFunboxNames(Config.funbox);
return Config.funbox ?? [];
}
/**

View file

@ -37,12 +37,8 @@ import type {
} from "chartjs-plugin-annotation";
import Ape from "../ape";
import { CompletedEvent } from "@monkeytype/contracts/schemas/results";
import {
getActiveFunboxes,
getFromString,
isFunboxActiveWithProperty,
} from "./funbox/list";
import { getFunboxesFromString } from "@monkeytype/funbox";
import { getActiveFunboxes, isFunboxActiveWithProperty } from "./funbox/list";
import { getFunbox } from "@monkeytype/funbox";
import { SnapshotUserTag } from "../constants/default-snapshot";
let result: CompletedEvent;
@ -80,7 +76,6 @@ async function updateGraph(): Promise<void> {
Numbers.roundTo2(typingSpeedUnit.fromWpm(a))
),
];
if (result.chartData === "toolong") return;
const chartData2 = [
@ -129,7 +124,7 @@ async function updateGraph(): Promise<void> {
ChartController.result.getDataset("error").data = result.chartData.err;
const fc = await ThemeColors.get("sub");
if (Config.funbox !== "none") {
if (Config.funbox.length > 0) {
let content = "";
for (const fb of getActiveFunboxes()) {
content += fb.name;
@ -184,7 +179,7 @@ export async function updateGraphPBLine(): Promise<void> {
result.language,
result.difficulty,
result.lazyMode ?? false,
getFunboxesFromString(result.funbox ?? "none")
getFunbox(result.funbox)
);
const localPbWpm = localPb?.wpm ?? 0;
if (localPbWpm === 0) return;
@ -481,12 +476,11 @@ type CanGetPbObject = {
};
async function resultCanGetPb(): Promise<CanGetPbObject> {
const funboxes = result.funbox?.split("#") ?? [];
const funboxObjects = getFromString(result.funbox);
const funboxes = result.funbox;
const funboxObjects = getFunbox(result.funbox);
const allFunboxesCanGetPb = funboxObjects.every((f) => f?.canGetPb);
const funboxesOk =
result.funbox === "none" || funboxes.length === 0 || allFunboxesCanGetPb;
const funboxesOk = funboxes.length === 0 || allFunboxesCanGetPb;
const notUsingStopOnLetter = Config.stopOnError !== "letter";
const notBailedOut = !result.bailedOut;
@ -699,8 +693,9 @@ function updateTestType(randomQuote: Quote | null): void {
if (Config.lazyMode) {
testType += "<br>lazy";
}
if (Config.funbox !== "none") {
testType += "<br>" + Config.funbox.replace(/_/g, " ").replace(/#/g, ", ");
if (Config.funbox.length > 0) {
testType +=
"<br>" + Config.funbox.map((it) => it.replace(/_/g, " ")).join(", ");
}
if (Config.difficulty === "expert") {
testType += "<br>expert";
@ -796,7 +791,7 @@ export function updateRateQuote(randomQuote: Quote | null): void {
quoteStats?.average?.toFixed(1) ?? ""
);
})
.catch((e: unknown) => {
.catch((_e: unknown) => {
$(".pageTest #result #rateQuoteButton .rating").text("?");
});
$(".pageTest #result #rateQuoteButton")

View file

@ -70,7 +70,7 @@ import {
getActiveFunboxes,
getActiveFunboxesWithFunction,
} from "./funbox/list";
import { getFunboxesFromString } from "@monkeytype/funbox";
import { getFunbox } from "@monkeytype/funbox";
import * as CompositionState from "../states/composition";
import { SnapshotResult } from "../constants/default-snapshot";
import { WordGenError } from "../utils/word-gen-error";
@ -1237,7 +1237,7 @@ async function saveResult(
completedEvent.language,
completedEvent.difficulty,
completedEvent.lazyMode,
getFunboxesFromString(completedEvent.funbox)
getFunbox(completedEvent.funbox)
);
if (localPb !== undefined) {

View file

@ -91,10 +91,7 @@ function calculateAcc(): number {
function layoutfluid(): void {
if (timerDebug) console.time("layoutfluid");
if (
Config.funbox.split("#").includes("layoutfluid") &&
Config.mode === "time"
) {
if (Config.funbox.includes("layoutfluid") && Config.mode === "time") {
const layouts = Config.customLayoutfluid
? Config.customLayoutfluid.split("#")
: ["qwerty", "dvorak", "colemak"];

View file

@ -146,7 +146,7 @@ const debouncedZipfCheck = debounce(250, async () => {
ConfigEvent.subscribe((eventKey, eventValue, nosave) => {
if (
(eventKey === "language" || eventKey === "funbox") &&
Config.funbox.split("#").includes("zipf")
Config.funbox.includes("zipf")
) {
void debouncedZipfCheck();
}
@ -1160,8 +1160,8 @@ export async function scrollTape(): Promise<void> {
export function updatePremid(): void {
const mode2 = Misc.getMode2(Config, TestWords.currentQuote);
let fbtext = "";
if (Config.funbox !== "none") {
fbtext = " " + Config.funbox.split("#").join(" ");
if (Config.funbox.length > 0) {
fbtext = " " + Config.funbox.join(" ");
}
$(".pageTest #premidTestMode").text(
`${Config.mode} ${mode2} ${Strings.getLanguageDisplayString(

View file

@ -39,7 +39,7 @@ ConfigEvent.subscribe((eventKey, eventValue) => {
void init();
}
}
if (eventKey === "language" && Config.funbox.split("#").includes("tts")) {
if (eventKey === "language" && Config.funbox.includes("tts")) {
void setLanguage();
}
});

View file

@ -62,7 +62,7 @@ export function lastElementFromArray<T>(array: T[]): T | undefined {
* @param b The second array.
* @returns True if the arrays are equal, false otherwise.
*/
export function areUnsortedArraysEqual(a: unknown[], b: unknown[]): boolean {
export function areUnsortedArraysEqual<T>(a: T[], b: T[]): boolean {
return a.length === b.length && a.every((v) => b.includes(v));
}
@ -72,7 +72,7 @@ export function areUnsortedArraysEqual(a: unknown[], b: unknown[]): boolean {
* @param b The second array.
* @returns True if the arrays are equal, false otherwise.
*/
export function areSortedArraysEqual(a: unknown[], b: unknown[]): boolean {
export function areSortedArraysEqual<T>(a: T[], b: T[]): boolean {
return a.length === b.length && a.every((v, i) => v === b[i]);
}

View file

@ -2,6 +2,7 @@ import {
Config,
ConfigValue,
PartialConfig,
FunboxName,
} from "@monkeytype/contracts/schemas/configs";
import { typedKeys } from "./misc";
import * as ConfigSchemas from "@monkeytype/contracts/schemas/configs";
@ -112,5 +113,15 @@ export function replaceLegacyValues(
configObj.soundVolume = parseFloat(configObj.soundVolume);
}
if (typeof configObj.funbox === "string") {
if (configObj.funbox === "none") {
configObj.funbox = [];
} else {
configObj.funbox = (configObj.funbox as string).split(
"#"
) as FunboxName[];
}
}
return configObj;
}

View file

@ -1,3 +1,4 @@
import { FunboxName } from "@monkeytype/contracts/schemas/configs";
import { Accents } from "../test/lazy-mode";
import { hexToHSL } from "./colors";
@ -306,7 +307,7 @@ export type Challenge = {
display: string;
autoRole: boolean;
type: string;
parameters: (string | number | boolean)[];
parameters: (string | number | boolean | FunboxName[])[];
message: string;
requirements: Record<string, Record<string, string | number | boolean>>;
};

View file

@ -23,6 +23,8 @@ import {
CustomBackgroundSizeSchema,
CustomThemeColors,
CustomThemeColorsSchema,
FunboxSchema,
FunboxName,
} from "@monkeytype/contracts/schemas/configs";
import { z } from "zod";
import { parseWithSchema as parseJsonWithSchema } from "@monkeytype/util/json";
@ -151,7 +153,7 @@ const TestSettingsSchema = z.tuple([
z.boolean().nullable(), //numbers
z.string().nullable(), //language
DifficultySchema.nullable(),
z.string().nullable(), //funbox
FunboxSchema.or(z.string().nullable()), //funbox as array or legacy string as hash separated values
]);
export function loadTestSettingsFromUrl(getOverride?: string): void {
@ -247,8 +249,15 @@ export function loadTestSettingsFromUrl(getOverride?: string): void {
}
if (de[7] !== null) {
UpdateConfig.setFunbox(de[7], true);
applied["funbox"] = de[7];
let val: FunboxName[] = [];
//convert legacy values
if (typeof de[7] === "string") {
val = de[7].split("#") as FunboxName[];
} else {
val = de[7];
}
UpdateConfig.setFunbox(val, true);
applied["funbox"] = val.join(", ");
}
restartTest({

View file

@ -258,7 +258,7 @@
"name": "inAGalaxyFarFarAway",
"display": "In a galaxy far far away",
"type": "script",
"parameters": ["episode4.txt",null,"space_balls"],
"parameters": ["episode4.txt",null,["space_balls"]],
"requirements": {
"config": {
"tapeMode": "off"
@ -269,7 +269,7 @@
"name": "beepBoop",
"display": "Beep Boop",
"type": "script",
"parameters": ["beepboop.txt",null,"nospace"],
"parameters": ["beepboop.txt",null,["nospace"]],
"message": "Mininum 45 WPM and 100% accuracy required.",
"requirements": {
"wpm": {
@ -279,7 +279,7 @@
"min": 100
},
"funbox": {
"exact": "nospace"
"exact": ["nospace"]
}
}
}
@ -287,7 +287,7 @@
"name": "whosYourDaddy",
"display": "Who's your daddy?",
"type": "script",
"parameters": ["episode5.txt",null,"space_balls"],
"parameters": ["episode5.txt",null,["space_balls"]],
"requirements": {
"config": {
"tapeMode": "off"
@ -298,7 +298,7 @@
"name": "itsATrap",
"display": "It's a trap!",
"type": "script",
"parameters": ["episode6.txt",null,"space_balls"],
"parameters": ["episode6.txt",null,["space_balls"]],
"requirements": {
"config": {
"tapeMode": "off"
@ -399,7 +399,7 @@
"name": "beLikeWater",
"display": "Be like water",
"type": "funbox",
"parameters": ["layoutfluid","time",60],
"parameters": [["layoutfluid"],"time",60],
"message": "Remember: You need to achieve at least 50 wpm in each layout."
}
,{
@ -407,13 +407,13 @@
"display": "Rollercoaster",
"autoRole": true,
"type": "funbox",
"parameters": ["round_round_baby","time",3600],
"parameters": [["round_round_baby"],"time",3600],
"requirements" : {
"time": {
"min": 3600
},
"funbox": {
"exact": "round_round_baby"
"exact": ["round_round_baby"]
}
}
}
@ -422,13 +422,13 @@
"display": "ɿoɿɿim ɿυoʜ ɘno",
"autoRole": true,
"type": "funbox",
"parameters": ["mirror","time",3600],
"parameters": [["mirror"],"time",3600],
"requirements" : {
"time": {
"min": 3600
},
"funbox": {
"exact": "mirror"
"exact": ["mirror"]
}
}
}
@ -437,13 +437,13 @@
"display": "Choo choo",
"autoRole": true,
"type": "funbox",
"parameters": ["choo_choo","time",3600],
"parameters": [["choo_choo"],"time",3600],
"requirements" : {
"time": {
"min": 3600
},
"funbox": {
"exact": "choo_choo"
"exact": ["choo_choo"]
}
}
}
@ -451,7 +451,7 @@
"name": "mnemonist",
"display": "Mnemonist",
"type": "funbox",
"parameters": ["memory","words",25,"master"],
"parameters": [["memory"],"words",25,"master"],
"requirements": {
"config": {
"tapeMode": "off"
@ -463,13 +463,13 @@
"display": "Earfquake",
"autoRole": true,
"type": "funbox",
"parameters": ["earthquake","time",3600],
"parameters": [["earthquake"],"time",3600],
"requirements" : {
"time": {
"min": 3600
},
"funbox": {
"exact": "earthquake"
"exact": ["earthquake"]
}
}
}
@ -478,13 +478,13 @@
"display": "Simon Sez",
"autoRole": true,
"type": "funbox",
"parameters": ["simon_says","time",3600],
"parameters": [["simon_says"],"time",3600],
"requirements" : {
"time": {
"min": 3600
},
"funbox": {
"exact": "simon_says"
"exact": ["simon_says"]
}
}
}
@ -493,13 +493,13 @@
"display": "Accountant",
"autoRole": true,
"type": "funbox",
"parameters": ["58008","time",3600],
"parameters": [["58008"],"time",3600],
"requirements" : {
"time": {
"min": 3600
},
"funbox": {
"exact": "58008"
"exact": ["58008"]
}
}
}
@ -508,7 +508,7 @@
"display": "Hidden",
"autoRole": true,
"type": "funbox",
"parameters": ["read_ahead","time",60],
"parameters": [["read_ahead"],"time",60],
"requirements" : {
"wpm": {
"min": 100
@ -517,7 +517,7 @@
"min": 60
},
"funbox": {
"exact": "read_ahead"
"exact": ["read_ahead"]
},
"config": {
"tapeMode": "off"
@ -529,7 +529,7 @@
"display": "I can see the future",
"autoRole": true,
"type": "funbox",
"parameters": ["read_ahead_hard","time",60],
"parameters": [["read_ahead_hard"],"time",60],
"requirements" : {
"wpm": {
"min": 100
@ -538,7 +538,7 @@
"min": 60
},
"funbox": {
"exact": "read_ahead_hard"
"exact": ["read_ahead_hard"]
},
"config": {
"tapeMode": "off"
@ -550,7 +550,7 @@
"display": "What are words at this point?",
"autoRole": true,
"type": "funbox",
"parameters": ["gibberish","time",3600],
"parameters": [["gibberish"],"time",3600],
"requirements" : {
"wpm": {
"min": 100
@ -559,7 +559,7 @@
"min": 60
},
"funbox": {
"exact": "gibberish"
"exact": ["gibberish"]
}
}
}
@ -568,7 +568,7 @@
"display": "Specials",
"autoRole": true,
"type": "funbox",
"parameters": ["specials","time",3600],
"parameters": [["specials"],"time",3600],
"requirements" : {
"wpm": {
"min": 100
@ -577,7 +577,7 @@
"min": 60
},
"funbox": {
"exact": "specials"
"exact": ["specials"]
}
}
@ -587,7 +587,7 @@
"display": "Aeiou.",
"autoRole": true,
"type": "funbox",
"parameters": ["tts","time",3600],
"parameters": [["tts"],"time",3600],
"requirements" : {
"wpm": {
"min": 100
@ -596,7 +596,7 @@
"min": 60
},
"funbox": {
"exact": "tts"
"exact": ["tts"]
}
}
}
@ -605,7 +605,7 @@
"display": "ASCII warrior",
"autoRole": true,
"type": "funbox",
"parameters": ["ascii","time",3600],
"parameters": [["ascii"],"time",3600],
"requirements" : {
"wpm": {
"min": 100
@ -614,7 +614,7 @@
"min": 60
},
"funbox": {
"exact": "ascii"
"exact": ["ascii"]
}
}
}
@ -623,7 +623,7 @@
"display": "I kInDa LiKe HoW iNeFfIcIeNt QwErTy Is",
"autoRole": true,
"type": "funbox",
"parameters": ["rAnDoMcAsE","time",3600],
"parameters": [["rAnDoMcAsE"],"time",3600],
"requirements" : {
"wpm": {
"min": 100
@ -632,7 +632,7 @@
"min": 60
},
"funbox": {
"exact": "rAnDoMcAsE"
"exact": ["rAnDoMcAsE"]
}
}
}
@ -641,7 +641,7 @@
"display": "One Nauseous Monkey",
"autoRole": true,
"type": "funbox",
"parameters": ["nausea","time",3600],
"parameters": [["nausea"],"time",3600],
"requirements" : {
"wpm": {
"min": 100
@ -650,7 +650,7 @@
"min": 60
},
"funbox": {
"exact": "nausea"
"exact": ["nausea"]
}
}
}

View file

@ -35,5 +35,5 @@ export const contract = c.router({
* Whenever there is a breaking change with old frontend clients increase this number.
* This will inform the frontend to refresh.
*/
export const COMPATIBILITY_CHECK = 1;
export const COMPATIBILITY_CHECK = 2;
export const COMPATIBILITY_CHECK_HEADER = "X-Compatibility-Check";

View file

@ -234,10 +234,56 @@ export type CustomThemeColors = z.infer<typeof CustomThemeColorsSchema>;
export const FavThemesSchema = z.array(token().max(50));
export type FavThemes = z.infer<typeof FavThemesSchema>;
export const FunboxSchema = z
.string()
.max(100)
.regex(/[\w#]+/);
export const FunboxNameSchema = z.enum([
"58008",
"mirror",
"upside_down",
"nausea",
"round_round_baby",
"simon_says",
"tts",
"choo_choo",
"arrows",
"rAnDoMcAsE",
"capitals",
"layout_mirror",
"layoutfluid",
"earthquake",
"space_balls",
"gibberish",
"ascii",
"specials",
"plus_one",
"plus_zero",
"plus_two",
"plus_three",
"read_ahead_easy",
"read_ahead",
"read_ahead_hard",
"memory",
"nospace",
"poetry",
"wikipedia",
"weakspot",
"pseudolang",
"IPv4",
"IPv6",
"binary",
"hexadecimal",
"zipf",
"morse",
"crt",
"backwards",
"ddoouubblleedd",
"instant_messaging",
"underscore_spaces",
"ALL_CAPS",
"polyglot",
"asl",
]);
export type FunboxName = z.infer<typeof FunboxNameSchema>;
export const FunboxSchema = z.array(FunboxNameSchema).max(15);
export type Funbox = z.infer<typeof FunboxSchema>;
export const PaceCaretCustomSpeedSchema = z.number().nonnegative();

View file

@ -5,9 +5,7 @@
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"moduleResolution": "Node",
"module": "ES6",
"target": "ES2015"
"target": "ES6"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]

View file

@ -1,20 +0,0 @@
import * as Util from "../src/util";
describe("util", () => {
describe("stringToFunboxNames", () => {
it("should get single funbox", () => {
expect(Util.stringToFunboxNames("58008")).toEqual(["58008"]);
});
it("should fail for unknown funbox name", () => {
expect(() => Util.stringToFunboxNames("unknown")).toThrowError(
new Error("Invalid funbox name: unknown")
);
});
it("should split multiple funboxes by hash", () => {
expect(Util.stringToFunboxNames("58008#choo_choo")).toEqual([
"58008",
"choo_choo",
]);
});
});
});

View file

@ -26,6 +26,9 @@
"dependencies": {
"@monkeytype/util": "workspace:*"
},
"peerDependencies": {
"@monkeytype/contracts": "workspace:*"
},
"exports": {
".": {
"types": "./src/index.ts",

View file

@ -1,14 +1,10 @@
import { getList, getFunbox, getObject } from "./list";
import { FunboxMetadata, FunboxName, FunboxProperty } from "./types";
import { stringToFunboxNames } from "./util";
import { FunboxName } from "@monkeytype/contracts/schemas/configs";
import { getList, getFunbox, getObject, getFunboxNames } from "./list";
import { FunboxMetadata, FunboxProperty } from "./types";
import { checkCompatibility } from "./validation";
export type { FunboxName, FunboxMetadata, FunboxProperty };
export { checkCompatibility, stringToFunboxNames, getFunbox };
export function getFunboxesFromString(names: string): FunboxMetadata[] {
return getFunbox(stringToFunboxNames(names));
}
export type { FunboxMetadata, FunboxProperty };
export { checkCompatibility, getFunbox, getFunboxNames };
export function getAllFunboxes(): FunboxMetadata[] {
return getList();

View file

@ -1,4 +1,5 @@
import { FunboxMetadata, FunboxName } from "./types";
import { FunboxName } from "@monkeytype/contracts/schemas/configs";
import { FunboxMetadata } from "./types";
const list: Record<FunboxName, FunboxMetadata> = {
"58008": {
@ -467,6 +468,7 @@ export function getFunbox(names: FunboxName[]): FunboxMetadata[];
export function getFunbox(
nameOrNames: FunboxName | FunboxName[]
): FunboxMetadata | FunboxMetadata[] {
if (nameOrNames === undefined) return [];
if (Array.isArray(nameOrNames)) {
const out = nameOrNames.map((name) => getObject()[name]);
@ -499,6 +501,6 @@ export function getList(): FunboxMetadata[] {
return out;
}
function getFunboxNames(): FunboxName[] {
export function getFunboxNames(): FunboxName[] {
return Object.keys(list) as FunboxName[];
}

View file

@ -1,49 +1,4 @@
export type FunboxName =
| "58008"
| "mirror"
| "upside_down"
| "nausea"
| "round_round_baby"
| "simon_says"
| "tts"
| "choo_choo"
| "arrows"
| "rAnDoMcAsE"
| "capitals"
| "layout_mirror"
| "layoutfluid"
| "earthquake"
| "space_balls"
| "gibberish"
| "ascii"
| "specials"
| "plus_one"
| "plus_zero"
| "plus_two"
| "plus_three"
| "read_ahead_easy"
| "read_ahead"
| "read_ahead_hard"
| "memory"
| "nospace"
| "poetry"
| "wikipedia"
| "weakspot"
| "pseudolang"
| "IPv4"
| "IPv6"
| "binary"
| "hexadecimal"
| "zipf"
| "morse"
| "crt"
| "backwards"
| "ddoouubblleedd"
| "instant_messaging"
| "underscore_spaces"
| "ALL_CAPS"
| "polyglot"
| "asl";
import { FunboxName } from "@monkeytype/contracts/schemas/configs";
export type FunboxForcedConfig = Record<string, string[] | boolean[]>;

View file

@ -1,17 +0,0 @@
import { getList } from "./list";
import { FunboxName } from "./types";
export function stringToFunboxNames(names: string): FunboxName[] {
if (names === "none" || names === "") return [];
const unsafeNames = names.split("#").map((name) => name.trim());
const out: FunboxName[] = [];
const list = new Set(getList().map((f) => f.name));
for (const unsafeName of unsafeNames) {
if (list.has(unsafeName as FunboxName)) {
out.push(unsafeName as FunboxName);
} else {
throw new Error("Invalid funbox name: " + unsafeName);
}
}
return out;
}

View file

@ -1,6 +1,7 @@
import { intersect } from "@monkeytype/util/arrays";
import { FunboxForcedConfig, FunboxName } from "./types";
import { FunboxForcedConfig } from "./types";
import { getFunbox } from "./list";
import { FunboxName } from "@monkeytype/contracts/schemas/configs";
export function checkCompatibility(
funboxNames: FunboxName[],

40
pnpm-lock.yaml generated
View file

@ -560,6 +560,9 @@ importers:
packages/funbox:
dependencies:
'@monkeytype/contracts':
specifier: workspace:*
version: link:../contracts
'@monkeytype/util':
specifier: workspace:*
version: link:../util
@ -668,7 +671,7 @@ importers:
version: 0.16.7
tsup:
specifier: 8.4.0
version: 8.4.0(postcss@8.5.1)(tsx@4.16.2)(typescript@5.5.4)(yaml@2.5.0)
version: 8.4.0(postcss@8.5.3)(tsx@4.16.2)(typescript@5.5.4)(yaml@2.5.0)
typescript:
specifier: 5.5.4
version: 5.5.4
@ -18064,14 +18067,6 @@ snapshots:
possible-typed-array-names@1.0.0: {}
postcss-load-config@6.0.1(postcss@8.5.1)(tsx@4.16.2)(yaml@2.5.0):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
postcss: 8.5.1
tsx: 4.16.2
yaml: 2.5.0
postcss-load-config@6.0.1(postcss@8.5.3)(tsx@4.16.2)(yaml@2.5.0):
dependencies:
lilconfig: 3.1.3
@ -19703,33 +19698,6 @@ snapshots:
tsscmp@1.0.6: {}
tsup@8.4.0(postcss@8.5.1)(tsx@4.16.2)(typescript@5.5.4)(yaml@2.5.0):
dependencies:
bundle-require: 5.1.0(esbuild@0.25.0)
cac: 6.7.14
chokidar: 4.0.3
consola: 3.4.0
debug: 4.4.0
esbuild: 0.25.0
joycon: 3.1.1
picocolors: 1.1.1
postcss-load-config: 6.0.1(postcss@8.5.1)(tsx@4.16.2)(yaml@2.5.0)
resolve-from: 5.0.0
rollup: 4.34.8
source-map: 0.8.0-beta.0
sucrase: 3.35.0
tinyexec: 0.3.2
tinyglobby: 0.2.12
tree-kill: 1.2.2
optionalDependencies:
postcss: 8.5.1
typescript: 5.5.4
transitivePeerDependencies:
- jiti
- supports-color
- tsx
- yaml
tsup@8.4.0(postcss@8.5.3)(tsx@4.16.2)(typescript@5.5.4)(yaml@2.5.0):
dependencies:
bundle-require: 5.1.0(esbuild@0.25.0)