gridstamp 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cursorrules +74 -0
- package/CLAUDE.md +61 -0
- package/LICENSE +190 -0
- package/README.md +107 -0
- package/dist/index.js +194 -0
- package/package.json +84 -0
- package/src/antispoofing/detector.ts +509 -0
- package/src/antispoofing/index.ts +7 -0
- package/src/gamification/badges.ts +429 -0
- package/src/gamification/fleet-leaderboard.ts +293 -0
- package/src/gamification/index.ts +44 -0
- package/src/gamification/streaks.ts +243 -0
- package/src/gamification/trust-tiers.ts +393 -0
- package/src/gamification/zone-mastery.ts +256 -0
- package/src/index.ts +341 -0
- package/src/memory/index.ts +9 -0
- package/src/memory/place-cells.ts +279 -0
- package/src/memory/spatial-memory.ts +375 -0
- package/src/navigation/index.ts +1 -0
- package/src/navigation/pathfinding.ts +403 -0
- package/src/perception/camera.ts +249 -0
- package/src/perception/index.ts +2 -0
- package/src/types/index.ts +416 -0
- package/src/utils/crypto.ts +94 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/math.ts +204 -0
- package/src/verification/index.ts +9 -0
- package/src/verification/spatial-proof.ts +442 -0
- package/tests/antispoofing/detector.test.ts +196 -0
- package/tests/gamification/badges.test.ts +163 -0
- package/tests/gamification/fleet-leaderboard.test.ts +181 -0
- package/tests/gamification/streaks.test.ts +158 -0
- package/tests/gamification/trust-tiers.test.ts +165 -0
- package/tests/gamification/zone-mastery.test.ts +143 -0
- package/tests/memory/place-cells.test.ts +128 -0
- package/tests/stress/load.test.ts +499 -0
- package/tests/stress/security.test.ts +378 -0
- package/tests/stress/simulation.test.ts +361 -0
- package/tests/utils/crypto.test.ts +115 -0
- package/tests/utils/math.test.ts +195 -0
- package/tests/verification/spatial-proof.test.ts +299 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capability Badge System — Achievement tracking for robots
|
|
3
|
+
*
|
|
4
|
+
* Maps WeMeetWeMet's 25+ badges to robot operational achievements.
|
|
5
|
+
* Badges are HMAC-signed to prevent forgery.
|
|
6
|
+
* Categories: operational, safety, navigation, endurance, special
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { hmacSign, hmacVerify } from '../utils/crypto.js';
|
|
10
|
+
|
|
11
|
+
export enum BadgeCategory {
|
|
12
|
+
OPERATIONAL = 'operational',
|
|
13
|
+
SAFETY = 'safety',
|
|
14
|
+
NAVIGATION = 'navigation',
|
|
15
|
+
ENDURANCE = 'endurance',
|
|
16
|
+
SPECIAL = 'special',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export enum BadgeRarity {
|
|
20
|
+
COMMON = 'common', // easy to earn
|
|
21
|
+
UNCOMMON = 'uncommon', // requires consistent performance
|
|
22
|
+
RARE = 'rare', // significant achievement
|
|
23
|
+
EPIC = 'epic', // exceptional performance
|
|
24
|
+
LEGENDARY = 'legendary', // near-impossible feats
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface BadgeDefinition {
|
|
28
|
+
readonly id: string;
|
|
29
|
+
readonly name: string;
|
|
30
|
+
readonly description: string;
|
|
31
|
+
readonly category: BadgeCategory;
|
|
32
|
+
readonly rarity: BadgeRarity;
|
|
33
|
+
readonly criteria: BadgeCriteria;
|
|
34
|
+
readonly pointValue: number; // points awarded when earned
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface BadgeCriteria {
|
|
38
|
+
readonly type: 'count' | 'streak' | 'rate' | 'threshold' | 'compound';
|
|
39
|
+
readonly metric: string;
|
|
40
|
+
readonly target: number;
|
|
41
|
+
readonly secondaryMetric?: string;
|
|
42
|
+
readonly secondaryTarget?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface EarnedBadge {
|
|
46
|
+
readonly badgeId: string;
|
|
47
|
+
readonly robotId: string;
|
|
48
|
+
readonly earnedAt: number;
|
|
49
|
+
readonly signature: string; // HMAC-signed proof of earning
|
|
50
|
+
readonly metadata?: Record<string, number>; // stats at time of earning
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ============================================================
|
|
54
|
+
// BADGE CATALOG
|
|
55
|
+
// ============================================================
|
|
56
|
+
|
|
57
|
+
export const BADGE_CATALOG: readonly BadgeDefinition[] = [
|
|
58
|
+
// OPERATIONAL
|
|
59
|
+
{
|
|
60
|
+
id: 'first-delivery',
|
|
61
|
+
name: 'First Delivery',
|
|
62
|
+
description: 'Complete your first verified spatial delivery',
|
|
63
|
+
category: BadgeCategory.OPERATIONAL,
|
|
64
|
+
rarity: BadgeRarity.COMMON,
|
|
65
|
+
criteria: { type: 'count', metric: 'successful_verifications', target: 1 },
|
|
66
|
+
pointValue: 50,
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'century-club',
|
|
70
|
+
name: 'Century Club',
|
|
71
|
+
description: 'Complete 100 verified operations',
|
|
72
|
+
category: BadgeCategory.OPERATIONAL,
|
|
73
|
+
rarity: BadgeRarity.UNCOMMON,
|
|
74
|
+
criteria: { type: 'count', metric: 'successful_verifications', target: 100 },
|
|
75
|
+
pointValue: 200,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'thousand-strong',
|
|
79
|
+
name: 'Thousand Strong',
|
|
80
|
+
description: 'Complete 1,000 verified operations',
|
|
81
|
+
category: BadgeCategory.OPERATIONAL,
|
|
82
|
+
rarity: BadgeRarity.RARE,
|
|
83
|
+
criteria: { type: 'count', metric: 'successful_verifications', target: 1000 },
|
|
84
|
+
pointValue: 500,
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
id: 'ten-thousand-veteran',
|
|
88
|
+
name: 'Ten Thousand Veteran',
|
|
89
|
+
description: 'Complete 10,000 verified operations',
|
|
90
|
+
category: BadgeCategory.OPERATIONAL,
|
|
91
|
+
rarity: BadgeRarity.LEGENDARY,
|
|
92
|
+
criteria: { type: 'count', metric: 'successful_verifications', target: 10000 },
|
|
93
|
+
pointValue: 2000,
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
// SAFETY
|
|
97
|
+
{
|
|
98
|
+
id: 'clean-record',
|
|
99
|
+
name: 'Clean Record',
|
|
100
|
+
description: '50 operations with zero spoofing incidents',
|
|
101
|
+
category: BadgeCategory.SAFETY,
|
|
102
|
+
rarity: BadgeRarity.UNCOMMON,
|
|
103
|
+
criteria: {
|
|
104
|
+
type: 'compound',
|
|
105
|
+
metric: 'successful_verifications',
|
|
106
|
+
target: 50,
|
|
107
|
+
secondaryMetric: 'spoofing_incidents',
|
|
108
|
+
secondaryTarget: 0,
|
|
109
|
+
},
|
|
110
|
+
pointValue: 300,
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'impenetrable',
|
|
114
|
+
name: 'Impenetrable',
|
|
115
|
+
description: '500 operations, zero security incidents',
|
|
116
|
+
category: BadgeCategory.SAFETY,
|
|
117
|
+
rarity: BadgeRarity.EPIC,
|
|
118
|
+
criteria: {
|
|
119
|
+
type: 'compound',
|
|
120
|
+
metric: 'successful_verifications',
|
|
121
|
+
target: 500,
|
|
122
|
+
secondaryMetric: 'spoofing_incidents',
|
|
123
|
+
secondaryTarget: 0,
|
|
124
|
+
},
|
|
125
|
+
pointValue: 1000,
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
id: 'fraud-detector',
|
|
129
|
+
name: 'Fraud Detector',
|
|
130
|
+
description: 'Detect and report 10 adversarial attacks',
|
|
131
|
+
category: BadgeCategory.SAFETY,
|
|
132
|
+
rarity: BadgeRarity.RARE,
|
|
133
|
+
criteria: { type: 'count', metric: 'threats_detected', target: 10 },
|
|
134
|
+
pointValue: 400,
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
// NAVIGATION
|
|
138
|
+
{
|
|
139
|
+
id: 'explorer',
|
|
140
|
+
name: 'Explorer',
|
|
141
|
+
description: 'Map 5 distinct zones',
|
|
142
|
+
category: BadgeCategory.NAVIGATION,
|
|
143
|
+
rarity: BadgeRarity.COMMON,
|
|
144
|
+
criteria: { type: 'count', metric: 'zones_mapped', target: 5 },
|
|
145
|
+
pointValue: 100,
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: 'cartographer',
|
|
149
|
+
name: 'Cartographer',
|
|
150
|
+
description: 'Map 25 distinct zones',
|
|
151
|
+
category: BadgeCategory.NAVIGATION,
|
|
152
|
+
rarity: BadgeRarity.UNCOMMON,
|
|
153
|
+
criteria: { type: 'count', metric: 'zones_mapped', target: 25 },
|
|
154
|
+
pointValue: 300,
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
id: 'globe-trotter',
|
|
158
|
+
name: 'Globe Trotter',
|
|
159
|
+
description: 'Map 100 distinct zones',
|
|
160
|
+
category: BadgeCategory.NAVIGATION,
|
|
161
|
+
rarity: BadgeRarity.RARE,
|
|
162
|
+
criteria: { type: 'count', metric: 'zones_mapped', target: 100 },
|
|
163
|
+
pointValue: 750,
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
id: 'zone-master',
|
|
167
|
+
name: 'Zone Master',
|
|
168
|
+
description: 'Achieve 90%+ mastery in any single zone',
|
|
169
|
+
category: BadgeCategory.NAVIGATION,
|
|
170
|
+
rarity: BadgeRarity.RARE,
|
|
171
|
+
criteria: { type: 'threshold', metric: 'max_zone_mastery', target: 0.9 },
|
|
172
|
+
pointValue: 500,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
id: 'pathfinder',
|
|
176
|
+
name: 'Pathfinder',
|
|
177
|
+
description: 'Successfully navigate 50 unique routes',
|
|
178
|
+
category: BadgeCategory.NAVIGATION,
|
|
179
|
+
rarity: BadgeRarity.UNCOMMON,
|
|
180
|
+
criteria: { type: 'count', metric: 'unique_routes', target: 50 },
|
|
181
|
+
pointValue: 200,
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
// ENDURANCE
|
|
185
|
+
{
|
|
186
|
+
id: 'on-a-roll',
|
|
187
|
+
name: 'On a Roll',
|
|
188
|
+
description: '7 consecutive days of verified operations',
|
|
189
|
+
category: BadgeCategory.ENDURANCE,
|
|
190
|
+
rarity: BadgeRarity.COMMON,
|
|
191
|
+
criteria: { type: 'streak', metric: 'consecutive_days', target: 7 },
|
|
192
|
+
pointValue: 100,
|
|
193
|
+
},
|
|
194
|
+
{
|
|
195
|
+
id: 'hot-streak',
|
|
196
|
+
name: 'Hot Streak',
|
|
197
|
+
description: '30 consecutive days of verified operations',
|
|
198
|
+
category: BadgeCategory.ENDURANCE,
|
|
199
|
+
rarity: BadgeRarity.UNCOMMON,
|
|
200
|
+
criteria: { type: 'streak', metric: 'consecutive_days', target: 30 },
|
|
201
|
+
pointValue: 300,
|
|
202
|
+
},
|
|
203
|
+
{
|
|
204
|
+
id: 'unstoppable',
|
|
205
|
+
name: 'Unstoppable',
|
|
206
|
+
description: '90 consecutive days of verified operations',
|
|
207
|
+
category: BadgeCategory.ENDURANCE,
|
|
208
|
+
rarity: BadgeRarity.RARE,
|
|
209
|
+
criteria: { type: 'streak', metric: 'consecutive_days', target: 90 },
|
|
210
|
+
pointValue: 750,
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
id: 'eternal-flame',
|
|
214
|
+
name: 'Eternal Flame',
|
|
215
|
+
description: '365 consecutive days of verified operations',
|
|
216
|
+
category: BadgeCategory.ENDURANCE,
|
|
217
|
+
rarity: BadgeRarity.LEGENDARY,
|
|
218
|
+
criteria: { type: 'streak', metric: 'consecutive_days', target: 365 },
|
|
219
|
+
pointValue: 3000,
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
id: 'night-owl',
|
|
223
|
+
name: 'Night Owl',
|
|
224
|
+
description: 'Complete 50 verified operations between midnight and 6am',
|
|
225
|
+
category: BadgeCategory.ENDURANCE,
|
|
226
|
+
rarity: BadgeRarity.UNCOMMON,
|
|
227
|
+
criteria: { type: 'count', metric: 'night_operations', target: 50 },
|
|
228
|
+
pointValue: 200,
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
id: 'all-weather',
|
|
232
|
+
name: 'All Weather',
|
|
233
|
+
description: 'Maintain 95%+ success rate across 200 operations in varying conditions',
|
|
234
|
+
category: BadgeCategory.ENDURANCE,
|
|
235
|
+
rarity: BadgeRarity.EPIC,
|
|
236
|
+
criteria: {
|
|
237
|
+
type: 'compound',
|
|
238
|
+
metric: 'total_verifications',
|
|
239
|
+
target: 200,
|
|
240
|
+
secondaryMetric: 'success_rate',
|
|
241
|
+
secondaryTarget: 95,
|
|
242
|
+
},
|
|
243
|
+
pointValue: 800,
|
|
244
|
+
},
|
|
245
|
+
|
|
246
|
+
// SPECIAL
|
|
247
|
+
{
|
|
248
|
+
id: 'perfect-score',
|
|
249
|
+
name: 'Perfect Score',
|
|
250
|
+
description: 'Achieve SSIM > 0.99 on a spatial verification',
|
|
251
|
+
category: BadgeCategory.SPECIAL,
|
|
252
|
+
rarity: BadgeRarity.RARE,
|
|
253
|
+
criteria: { type: 'threshold', metric: 'max_ssim', target: 0.99 },
|
|
254
|
+
pointValue: 500,
|
|
255
|
+
},
|
|
256
|
+
{
|
|
257
|
+
id: 'cross-zone',
|
|
258
|
+
name: 'Cross-Zone Navigator',
|
|
259
|
+
description: 'Complete a delivery spanning 3+ zones in one trip',
|
|
260
|
+
category: BadgeCategory.SPECIAL,
|
|
261
|
+
rarity: BadgeRarity.UNCOMMON,
|
|
262
|
+
criteria: { type: 'threshold', metric: 'max_zones_per_trip', target: 3 },
|
|
263
|
+
pointValue: 300,
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
id: 'speed-demon',
|
|
267
|
+
name: 'Speed Demon',
|
|
268
|
+
description: 'Complete 10 operations in under 60 seconds each',
|
|
269
|
+
category: BadgeCategory.SPECIAL,
|
|
270
|
+
rarity: BadgeRarity.RARE,
|
|
271
|
+
criteria: { type: 'count', metric: 'fast_operations', target: 10 },
|
|
272
|
+
pointValue: 400,
|
|
273
|
+
},
|
|
274
|
+
];
|
|
275
|
+
|
|
276
|
+
function signBadgeAward(badgeId: string, robotId: string, earnedAt: number, secret: string): string {
|
|
277
|
+
const payload = `badge:${badgeId}:${robotId}:${earnedAt}`;
|
|
278
|
+
return hmacSign(Buffer.from(payload), secret);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function verifyBadgeAward(badge: EarnedBadge, secret: string): boolean {
|
|
282
|
+
const payload = `badge:${badge.badgeId}:${badge.robotId}:${badge.earnedAt}`;
|
|
283
|
+
return hmacVerify(Buffer.from(payload), badge.signature, secret);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export interface RobotMetrics {
|
|
287
|
+
successful_verifications: number;
|
|
288
|
+
spoofing_incidents: number;
|
|
289
|
+
threats_detected: number;
|
|
290
|
+
zones_mapped: number;
|
|
291
|
+
max_zone_mastery: number;
|
|
292
|
+
unique_routes: number;
|
|
293
|
+
consecutive_days: number;
|
|
294
|
+
night_operations: number;
|
|
295
|
+
total_verifications: number;
|
|
296
|
+
success_rate: number; // percentage 0-100
|
|
297
|
+
max_ssim: number;
|
|
298
|
+
max_zones_per_trip: number;
|
|
299
|
+
fast_operations: number;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export class BadgeSystem {
|
|
303
|
+
private readonly earned = new Map<string, EarnedBadge[]>(); // robotId → badges
|
|
304
|
+
private readonly secret: string;
|
|
305
|
+
private readonly catalog: readonly BadgeDefinition[];
|
|
306
|
+
|
|
307
|
+
constructor(secret: string, catalog?: readonly BadgeDefinition[]) {
|
|
308
|
+
if (!secret || secret.length < 32) {
|
|
309
|
+
throw new Error('BadgeSystem requires a secret of at least 32 chars');
|
|
310
|
+
}
|
|
311
|
+
this.secret = secret;
|
|
312
|
+
this.catalog = catalog ?? BADGE_CATALOG;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Check and award any newly qualified badges
|
|
317
|
+
*/
|
|
318
|
+
evaluate(robotId: string, metrics: RobotMetrics): EarnedBadge[] {
|
|
319
|
+
if (!robotId) throw new Error('robotId is required');
|
|
320
|
+
|
|
321
|
+
const existing = this.earned.get(robotId) ?? [];
|
|
322
|
+
const existingIds = new Set(existing.map(b => b.badgeId));
|
|
323
|
+
const newBadges: EarnedBadge[] = [];
|
|
324
|
+
|
|
325
|
+
for (const badge of this.catalog) {
|
|
326
|
+
if (existingIds.has(badge.id)) continue;
|
|
327
|
+
if (this.checkCriteria(badge.criteria, metrics)) {
|
|
328
|
+
const now = Date.now();
|
|
329
|
+
const earned: EarnedBadge = {
|
|
330
|
+
badgeId: badge.id,
|
|
331
|
+
robotId,
|
|
332
|
+
earnedAt: now,
|
|
333
|
+
signature: signBadgeAward(badge.id, robotId, now, this.secret),
|
|
334
|
+
metadata: { ...metrics },
|
|
335
|
+
};
|
|
336
|
+
newBadges.push(earned);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (newBadges.length > 0) {
|
|
341
|
+
this.earned.set(robotId, [...existing, ...newBadges]);
|
|
342
|
+
}
|
|
343
|
+
return newBadges;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private checkCriteria(criteria: BadgeCriteria, metrics: RobotMetrics): boolean {
|
|
347
|
+
const value = metrics[criteria.metric as keyof RobotMetrics] ?? 0;
|
|
348
|
+
|
|
349
|
+
switch (criteria.type) {
|
|
350
|
+
case 'count':
|
|
351
|
+
case 'streak':
|
|
352
|
+
case 'threshold':
|
|
353
|
+
return value >= criteria.target;
|
|
354
|
+
case 'rate':
|
|
355
|
+
return value >= criteria.target;
|
|
356
|
+
case 'compound': {
|
|
357
|
+
const primary = value >= criteria.target;
|
|
358
|
+
if (!primary) return false;
|
|
359
|
+
if (criteria.secondaryMetric && criteria.secondaryTarget !== undefined) {
|
|
360
|
+
const secondary = metrics[criteria.secondaryMetric as keyof RobotMetrics] ?? 0;
|
|
361
|
+
// For "zero incidents" badges, target is 0 and we want value <= target
|
|
362
|
+
// For rate badges, we want value >= target
|
|
363
|
+
if (criteria.secondaryTarget === 0) {
|
|
364
|
+
return secondary <= criteria.secondaryTarget;
|
|
365
|
+
}
|
|
366
|
+
return secondary >= criteria.secondaryTarget;
|
|
367
|
+
}
|
|
368
|
+
return true;
|
|
369
|
+
}
|
|
370
|
+
default:
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Get all badges earned by a robot
|
|
377
|
+
*/
|
|
378
|
+
getBadges(robotId: string): readonly EarnedBadge[] {
|
|
379
|
+
return this.earned.get(robotId) ?? [];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Get badge count for a robot
|
|
384
|
+
*/
|
|
385
|
+
getBadgeCount(robotId: string): number {
|
|
386
|
+
return (this.earned.get(robotId) ?? []).length;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Get total point value of all earned badges
|
|
391
|
+
*/
|
|
392
|
+
getTotalBadgePoints(robotId: string): number {
|
|
393
|
+
const badges = this.earned.get(robotId) ?? [];
|
|
394
|
+
let total = 0;
|
|
395
|
+
for (const earned of badges) {
|
|
396
|
+
const def = this.catalog.find(b => b.id === earned.badgeId);
|
|
397
|
+
if (def) total += def.pointValue;
|
|
398
|
+
}
|
|
399
|
+
return total;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Verify all badges for a robot are authentic
|
|
404
|
+
*/
|
|
405
|
+
verifyAllBadges(robotId: string): { valid: boolean; forged: string[] } {
|
|
406
|
+
const badges = this.earned.get(robotId) ?? [];
|
|
407
|
+
const forged: string[] = [];
|
|
408
|
+
for (const badge of badges) {
|
|
409
|
+
if (!verifyBadgeAward(badge, this.secret)) {
|
|
410
|
+
forged.push(badge.badgeId);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return { valid: forged.length === 0, forged };
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Get catalog size
|
|
418
|
+
*/
|
|
419
|
+
get catalogSize(): number {
|
|
420
|
+
return this.catalog.length;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Get badge definition by ID
|
|
425
|
+
*/
|
|
426
|
+
getDefinition(badgeId: string): BadgeDefinition | undefined {
|
|
427
|
+
return this.catalog.find(b => b.id === badgeId);
|
|
428
|
+
}
|
|
429
|
+
}
|