Implement streaks (#3385) typermonkeyuser, bruception, miodec

* Implement streaks

* Improve code

* Update package.json

* Store streak on the user

* Pretty-fix

* Ensure that streak is up to date

* Fix issue in streak calculation

* Revert prettier changes

* Update polish.json

* Update db.ts

* Remove initial calculation

* Write mandatory test

* using strict equality

* moved functions to the utils file

* importing utils

* adding streak to breakdown,
rounding and parsing streak modifier

* renamed variable

* renamed fields

* using correct field name

* added streaks to configuration

* showing streak during xp breakdown

* incrementing streak earlier
checking configuration before applying modifier

* returning streak to the client

* setting local streak with the number returned from the backedn

* only reading streak when updating profile instead of updating

* sending streak information in profile

* only showing streak if greater than 0

* setting to empty if no streak

* renamed config property

* updated streak calculation

* refactored isYesterday

* refactored streak update

* only displaying if streak larger than 1

* merged configuration properties into 1

* added configuration for max streak bonus

* added isToday check back (derp)

* reverted streaks back to multiplier approach

* using better maprange functin

* removed import

* moved test to dal spec

* clamping

* removed imports

* fixed test

* increased coverage

* removed angry console log

* typo

* using date now instead of dates

* mocking date now
awaiting expect

* not using date

Co-authored-by: Anonymous <110769200+fasttyperdog@users.noreply.github.com>
Co-authored-by: Rizwan Mustafa <rizwanmustafa0000@gmail.com>
Co-authored-by: Miodec <bartnikjack@gmail.com>
This commit is contained in:
typermonkeyuser 2022-09-05 12:13:55 +02:00 committed by GitHub
parent 20a5cac5e6
commit b08c194c3d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 192 additions and 7 deletions

View file

@ -1,5 +1,6 @@
import _ from "lodash";
import { ObjectId } from "mongodb";
import { updateStreak } from "../../src/api/controllers/user";
import * as UserDAL from "../../src/dal/user";
const mockPersonalBest = {
@ -488,6 +489,10 @@ describe("UserDal", () => {
expect(resetUser.bananas).toStrictEqual(0);
expect(resetUser.xp).toStrictEqual(0);
expect(resetUser.streak).toStrictEqual({
length: 0,
lastResultTimestamp: 0,
});
});
it("getInbox should return the user's inbox", async () => {
@ -600,4 +605,26 @@ describe("UserDal", () => {
},
]);
});
it("updateStreak should update streak", async () => {
await UserDAL.addUser("testStack", "test email", "TestID");
Date.now = jest.fn(() => 1662372000000);
const streak1 = await updateStreak("TestID", 1662372000000);
await expect(streak1).toBe(1);
Date.now = jest.fn(() => 1662458400000);
const streak2 = await updateStreak("TestID", 1662458400000);
await expect(streak2).toBe(2);
Date.now = jest.fn(() => 1999969721000000);
const streak3 = await updateStreak("TestID", 1999969721000);
await expect(streak3).toBe(1);
});
});

View file

@ -9,7 +9,7 @@ export default {
// These percentages should never decrease
statements: 40,
branches: 40,
functions: 24,
functions: 25,
lines: 43,
},
},

View file

@ -11,6 +11,7 @@ import * as PublicStatsDAL from "../../dal/public-stats";
import {
getCurrentDayTimestamp,
getStartOfDayTimestamp,
mapRange,
roundTo2,
stdDev,
} from "../../utils/misc";
@ -35,6 +36,7 @@ import { getDailyLeaderboard } from "../../utils/daily-leaderboards";
import AutoRoleList from "../../constants/auto-roles";
import * as UserDAL from "../../dal/user";
import { buildMonkeyMail } from "../../utils/monkey-mail";
import { updateStreak } from "./user";
try {
if (anticheatImplemented() === false) throw new Error("undefined");
@ -96,6 +98,7 @@ interface AddResultData {
xp: number;
dailyXpBonus: boolean;
xpBreakdown: Record<string, number>;
streak: number;
}
export async function addResult(
@ -368,11 +371,14 @@ export async function addResult(
);
}
const streak = await updateStreak(uid, result.timestamp);
const xpGained = await calculateXp(
result,
req.ctx.configuration.users.xp,
uid,
user.xp ?? 0
user.xp ?? 0,
streak
);
if (result.bailedOut === false) delete result.bailedOut;
@ -410,6 +416,7 @@ export async function addResult(
xp: xpGained.xp,
dailyXpBonus: xpGained.dailyBonus ?? false,
xpBreakdown: xpGained.breakdown ?? {},
streak,
};
if (dailyLeaderboardRank !== -1) {
@ -430,7 +437,8 @@ async function calculateXp(
result,
xpConfiguration: MonkeyTypes.Configuration["users"]["xp"],
uid: string,
currentTotalXp: number
currentTotalXp: number,
streak: number
): Promise<XpResult> {
const {
mode,
@ -488,6 +496,24 @@ async function calculateXp(
}
}
if (xpConfiguration.streak.enabled) {
const streakModifier = parseFloat(
mapRange(
streak,
0,
xpConfiguration.streak.maxStreakDays,
0,
xpConfiguration.streak.maxStreakMultiplier,
true
).toFixed(1)
);
if (streakModifier > 0) {
modifier += streakModifier;
breakdown["streak"] = Math.round(baseXp * streakModifier);
}
}
const incompleteXp = Math.round(incompleteTestSeconds);
breakdown["incomplete"] = incompleteXp;

View file

@ -4,7 +4,12 @@ import MonkeyError from "../../utils/error";
import Logger from "../../utils/logger";
import { MonkeyResponse } from "../../utils/monkey-response";
import { getDiscordUser } from "../../utils/discord";
import { buildAgentLog, sanitizeString } from "../../utils/misc";
import {
buildAgentLog,
isToday,
isYesterday,
sanitizeString,
} from "../../utils/misc";
import * as George from "../../tasks/george";
import admin from "firebase-admin";
import { deleteAllApeKeys } from "../../dal/ape-keys";
@ -435,6 +440,7 @@ export async function getProfile(
discordId,
discordAvatar,
xp,
streak,
} = await UserDAL.getUser(uid, "get user profile");
const validTimePbs = _.pick(personalBests?.time, "15", "30", "60", "120");
@ -460,6 +466,7 @@ export async function getProfile(
discordId,
discordAvatar,
xp,
streak: streak?.length ?? 0,
};
if (banned) {
@ -526,3 +533,22 @@ export async function updateInbox(
return new MonkeyResponse("Inbox updated");
}
export async function updateStreak(uid, timestamp): Promise<number> {
const user = await UserDAL.getUser(uid, "calculate streak");
const streak: MonkeyTypes.UserStreak = {
lastResultTimestamp: user.streak?.lastResultTimestamp ?? 0,
length: user.streak?.length ?? 0,
};
if (isYesterday(streak.lastResultTimestamp)) {
streak.length += 1;
} else if (!isToday(streak.lastResultTimestamp)) {
streak.length = 1;
}
streak.lastResultTimestamp = timestamp;
await UserDAL.getUsersCollection().updateOne({ uid }, { $set: { streak } });
return streak.length;
}

View file

@ -46,6 +46,11 @@ export const BASE_CONFIGURATION: MonkeyTypes.Configuration = {
gainMultiplier: 0,
maxDailyBonus: 0,
minDailyBonus: 0,
streak: {
enabled: false,
maxStreakDays: 0,
maxStreakMultiplier: 0,
},
},
inbox: {
enabled: false,
@ -229,6 +234,24 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema = {
type: "number",
label: "Min Daily Bonus",
},
streak: {
type: "object",
label: "Streak",
fields: {
enabled: {
type: "boolean",
label: "Enabled",
},
maxStreakDays: {
type: "number",
label: "Max Streak Days",
},
maxStreakMultiplier: {
type: "number",
label: "Max Streak Multiplier",
},
},
},
},
},
discordIntegration: {

View file

@ -70,6 +70,10 @@ export async function resetUser(uid: string): Promise<void> {
customThemes: [],
tags: [],
xp: 0,
streak: {
length: 0,
lastResultTimestamp: 0,
},
},
$unset: {
discordAvatar: "",

View file

@ -45,6 +45,11 @@ declare namespace MonkeyTypes {
gainMultiplier: number;
maxDailyBonus: number;
minDailyBonus: number;
streak: {
enabled: boolean;
maxStreakDays: number;
maxStreakMultiplier: number;
};
};
inbox: {
enabled: boolean;
@ -161,6 +166,12 @@ declare namespace MonkeyTypes {
inventory?: UserInventory;
xp?: number;
inbox?: MonkeyMail[];
streak?: UserStreak;
}
interface UserStreak {
lastResultTimestamp: number;
length: number;
}
interface UserInventory {

View file

@ -165,12 +165,38 @@ export function getOrdinalNumberString(number: number): string {
return `${number}${suffix}`;
}
export function isYesterday(timestamp: number): boolean {
const yesterday = getStartOfDayTimestamp(Date.now() - MILLISECONDS_IN_DAY);
const date = getStartOfDayTimestamp(timestamp);
return yesterday === date;
}
export function isToday(timestamp: number): boolean {
const today = getStartOfDayTimestamp(Date.now());
const date = getStartOfDayTimestamp(timestamp);
return today === date;
}
export function mapRange(
value: number,
inMin: number,
inMax: number,
outMin: number,
outMax: number
outMax: number,
clamp = false
): number {
return ((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
const result =
((value - inMin) * (outMax - outMin)) / (inMax - inMin) + outMin;
if (clamp) {
if (outMin < outMax) {
return Math.min(Math.max(result, outMin), outMax);
} else {
return Math.max(Math.min(result, outMin), outMax);
}
}
return result;
}

View file

@ -292,8 +292,14 @@
font-size: 0.7em;
line-height: 0.7rem;
}
.streak {
color: var(--sub-color);
font-size: 0.7em;
line-height: 0.7rem;
}
.badge,
.joined {
.joined,
.streak {
margin-top: 0.35rem;
}
.levelAndBar {

View file

@ -27,4 +27,5 @@ export const defaultSnap: MonkeyTypes.Snapshot = {
addedAt: 0,
filterPresets: [],
xp: 0,
streak: 0,
};

View file

@ -96,6 +96,7 @@ export async function initSnapshot(): Promise<
snap.addedAt = userData.addedAt;
snap.inventory = userData.inventory;
snap.xp = userData.xp ?? 0;
snap.streak = userData?.streak?.length ?? 0;
if (userData.lbMemory?.time15 || userData.lbMemory?.time60) {
//old memory format
@ -814,6 +815,12 @@ export function addXp(xp: number): void {
setSnapshot(snapshot);
}
export function setStreak(streak: number): void {
const snapshot = getSnapshot();
snapshot.streak = streak;
setSnapshot(snapshot);
}
// export async function DB.getLocalTagPB(tagId) {
// function cont() {
// let ret = 0;

View file

@ -276,6 +276,14 @@ async function animateXpBreakdown(
if (skipBreakdown) return;
if (breakdown["streak"]) {
await Misc.sleep(delay);
await append(`streak +${breakdown["streak"]}`);
total += breakdown["streak"];
}
if (skipBreakdown) return;
if (breakdown["accPenalty"]) {
await Misc.sleep(delay);
await append(`accuracy penalty -${breakdown["accPenalty"]}`);

View file

@ -72,6 +72,18 @@ export async function update(
const balloonText = `${diffDays} day${diffDays != 1 ? "s" : ""} ago`;
details.find(".joined").text(joinedText).attr("aria-label", balloonText);
if (profile.streak && profile?.streak > 1) {
details
.find(".streak")
.text(
`Current streak: ${profile.streak} ${
profile.streak === 1 ? "day" : "days"
}`
);
} else {
details.find(".streak").text("");
}
const typingStatsEl = details.find(".typingStats");
typingStatsEl
.find(".started .value")

View file

@ -20,6 +20,7 @@ function reset(): void {
<div class="badges"></div>
<div class="allBadges"></div>
<div class="joined" data-balloon-pos="up">-</div>
<div class="streak">-</div>
</div>
<div class="levelAndBar">
<div class="level">-</div>

View file

@ -1698,6 +1698,10 @@ async function saveResult(
DB.addXp(response.data.xp);
}
if (response.data.streak) {
DB.setStreak(response.data.streak);
}
completedEvent._id = response.data.insertedId;
if (response.data.isPb) {
completedEvent.isPb = true;

View file

@ -473,6 +473,7 @@ declare namespace MonkeyTypes {
addedAt: number;
filterPresets: ResultFilters[];
xp: number;
streak: number;
}
interface UserDetails {

View file

@ -33,6 +33,7 @@
<div class="badges"></div>
<div class="allBadges"></div>
<div class="joined" data-balloon-pos="up">-</div>
<div class="streak">-</div>
</div>
<div class="levelAndBar">
<div class="level" data-balloon-pos="up">-</div>

View file

@ -17,6 +17,7 @@
<div class="badges"></div>
<div class="allBadges"></div>
<div class="joined" data-balloon-pos="up">-</div>
<div class="streak">-</div>
</div>
<div class="levelAndBar">
<div class="level">-</div>