From 9a9e72f483163787ccd0cbfd4503674fe9cc7dc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=9C=A8=20Audrey=20=E2=9C=A8?= Date: Tue, 11 Mar 2025 18:23:35 -0400 Subject: [PATCH] factor measurement and achievement earning into separate phases --- .../src/tools/achievements/event-processor.ts | 25 ++++++++--- libs/common/src/tools/achievements/events.ts | 12 +---- .../tools/achievements/example-validators.ts | 45 +++++++++---------- libs/common/src/tools/achievements/types.ts | 18 ++++---- 4 files changed, 52 insertions(+), 48 deletions(-) diff --git a/libs/common/src/tools/achievements/event-processor.ts b/libs/common/src/tools/achievements/event-processor.ts index 4f3c4d7516a..d422c51265b 100644 --- a/libs/common/src/tools/achievements/event-processor.ts +++ b/libs/common/src/tools/achievements/event-processor.ts @@ -10,6 +10,12 @@ import { import { isEarnedEvent, isProgressEvent } from "./meta"; import { AchievementEvent, AchievementValidator } from "./types"; +function mapProgressByName(status: AchievementEvent[]) { + return new Map( + status.filter(isProgressEvent).map((e) => [e.achievement.name, e.achievement.value] as const), + ); +} + // OPTIMIZATION: compute the list of active monitors from trigger criteria function active( status$: Observable, @@ -18,9 +24,7 @@ function active( withLatestFrom(status$), map(([monitors, log]) => { // partition the log into progress and earned achievements - const progressByName = new Map( - log.filter(isProgressEvent).map((e) => [e.achievement.name, e.achievement.value]), - ); + const progressByName = mapProgressByName(log); const earnedByName = new Set( log.filter((e) => isEarnedEvent(e)).map((e) => e.achievement.name), ); @@ -36,7 +40,7 @@ function active( } // monitor disabled if outside of threshold - const progress = progressByName.get(m.progress) ?? 0; + const progress = (m.metric && progressByName.get(m.metric)) || 0; if (progress > (m.trigger.high ?? Number.POSITIVE_INFINITY)) { return false; } else if (progress < (m.trigger.low ?? 0)) { @@ -70,10 +74,17 @@ function validate( // process achievement monitors sequentially, accumulating result records for (const monitor of monitors) { - const statusEntry = status.find((s) => s.achievement.name === monitor.achievement); - const progress = isProgressEvent(statusEntry) ? statusEntry : undefined; + const progress = mapProgressByName(status); - results.push(...monitor.action(action, progress)); + const measurements = monitor.measure(action, progress); + results.push(...measurements); + + // modify copy produced by filter to avoid reallocation + for (const m of measurements) { + progress.set(m.achievement.name, m.achievement.value); + } + + results.push(...monitor.earn(progress)); } // deliver results as a stream containing individual records to maintain diff --git a/libs/common/src/tools/achievements/events.ts b/libs/common/src/tools/achievements/events.ts index 64b592efb87..33273ab852d 100644 --- a/libs/common/src/tools/achievements/events.ts +++ b/libs/common/src/tools/achievements/events.ts @@ -1,16 +1,8 @@ import { UserId } from "../../types/guid"; -import { - AchievementEarnedEvent, - AchievementId, - AchievementProgressEvent, - AchievementProgressId, -} from "./types"; +import { AchievementEarnedEvent, AchievementId, AchievementProgressEvent, MetricId } from "./types"; -export function progressEvent( - name: AchievementProgressId, - value: number = 1, -): AchievementProgressEvent { +export function progressEvent(name: MetricId, value: number = 1): AchievementProgressEvent { return { "@timestamp": Date.now(), event: { diff --git a/libs/common/src/tools/achievements/example-validators.ts b/libs/common/src/tools/achievements/example-validators.ts index f675db69ce8..3a775cb7a6a 100644 --- a/libs/common/src/tools/achievements/example-validators.ts +++ b/libs/common/src/tools/achievements/example-validators.ts @@ -1,8 +1,8 @@ import { Type } from "./data"; import { earnedEvent, progressEvent } from "./events"; -import { AchievementId, AchievementProgressId, AchievementValidator } from "./types"; +import { AchievementId, MetricId, AchievementValidator } from "./types"; -const ItemCreatedProgress = "item-created-progress" as AchievementProgressId; +const ItemCreatedProgress = "item-quantity" as MetricId; const ItemCreatedAchievement = "item-created" as AchievementId; const ThreeItemsCreatedAchievement = "three-vault-items-created" as AchievementId; @@ -10,58 +10,57 @@ const FiveItemsCreatedAchievement = "five-vault-items-created" as AchievementId; const ItemCreatedValidator = { achievement: ItemCreatedAchievement, - progress: ItemCreatedProgress, + metric: ItemCreatedProgress, evaluator: Type.Threshold, trigger: "once", hidden: false, filter(item) { return item.action === "vault-item-added"; }, - action(item, progress) { - return [progressEvent(ItemCreatedProgress), earnedEvent(ItemCreatedAchievement)]; + measure(item, progress) { + return [progressEvent(ItemCreatedProgress)]; + }, + earn(progress) { + return [earnedEvent(ItemCreatedAchievement)]; }, } satisfies AchievementValidator; const ThreeItemsCreatedValidator = { achievement: ThreeItemsCreatedAchievement, - progress: ItemCreatedProgress, + metric: ItemCreatedProgress, evaluator: Type.Threshold, trigger: { low: 2, high: 3 }, hidden: false, filter(item) { return item.action === "vault-item-added"; }, - action(item, progress) { - if (!progress) { - return [progressEvent(ItemCreatedProgress)]; - } - - const value = progress.achievement.value + 1; - if (value >= 3) { - return [earnedEvent(ThreeItemsCreatedAchievement)]; - } - + measure(_item, progress) { + const value = 1 + (progress.get(ItemCreatedProgress) ?? 0); return [progressEvent(ItemCreatedProgress, value)]; }, + earn(progress) { + const value = progress.get(ItemCreatedProgress) ?? 0; + return value >= 3 ? [earnedEvent(ItemCreatedAchievement)] : []; + }, } satisfies AchievementValidator; const FiveItemsCreatedValidator = { achievement: ThreeItemsCreatedAchievement, - progress: ItemCreatedProgress, + metric: ItemCreatedProgress, evaluator: Type.Threshold, trigger: { low: 4, high: 5 }, hidden: false, filter(item) { return item.action === "vault-item-added"; }, - action(item, progress) { - const value = 1 + (progress?.achievement?.value ?? 0); - if (value >= 3) { - return [earnedEvent(FiveItemsCreatedAchievement)]; - } - + measure(_item, progress) { + const value = 1 + (progress.get(ItemCreatedProgress) ?? 0); return [progressEvent(ItemCreatedProgress, value)]; }, + earn(progress) { + const value = progress.get(ItemCreatedProgress) ?? 0; + return value >= 5 ? [earnedEvent(ItemCreatedAchievement)] : []; + }, } satisfies AchievementValidator; export { diff --git a/libs/common/src/tools/achievements/types.ts b/libs/common/src/tools/achievements/types.ts index b8b323850b6..b04228893c9 100644 --- a/libs/common/src/tools/achievements/types.ts +++ b/libs/common/src/tools/achievements/types.ts @@ -7,11 +7,11 @@ import { Type } from "./data"; export type EvaluatorType = keyof typeof Type; export type AchievementId = string & Tagged<"achievement">; -export type AchievementProgressId = string & Tagged<"achievement-progress">; +export type MetricId = string & Tagged<"metric-id">; export type AchievementProgressEvent = EventFormat & ServiceFormat & - UserFormat & { achievement: { type: "progress"; name: AchievementProgressId; value: number } }; + UserFormat & { achievement: { type: "progress"; name: MetricId; value: number } }; export type AchievementEarnedEvent = EventFormat & ServiceFormat & UserFormat & { achievement: { type: "earned"; name: AchievementId } }; @@ -20,7 +20,9 @@ export type AchievementEvent = AchievementProgressEvent | AchievementEarnedEvent // consumed by validator and achievement list (should this include a "toast-alerter"?) export type Achievement = { achievement: AchievementId; - progress: AchievementProgressId; + + metric?: MetricId; + evaluator: EvaluatorType; // pre-filter that disables the rule if it's met @@ -34,9 +36,9 @@ export type AchievementValidator = Achievement & { // when the watch triggers on incoming user events filter: (item: EventFormat) => boolean; - // what to do when an incoming event is triggered - action: ( - item: EventFormat, - progress?: AchievementProgressEvent, - ) => [AchievementEvent] | [AchievementEvent, AchievementEvent]; + // observe data from the event stream and produces measurements + measure: (item: EventFormat, progress: Map) => AchievementProgressEvent[]; + + // monitors achievement progress and emits earned achievements + earn: (progress: Map) => AchievementEarnedEvent[]; };