roguelike-cli 1.3.3 → 1.3.5

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.
@@ -1,13 +1,8 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import * as os from 'os';
4
-
5
- export interface Achievement {
6
- id: string;
7
- name: string;
8
- description: string;
9
- unlockedAt?: string;
10
- }
4
+ import { getDictionary, Dictionary } from '../data/dictionaries';
5
+ import { LootItem, rollForLoot, formatLootDrop, formatInventory, calculateInventoryValue } from '../data/loot';
11
6
 
12
7
  export interface UndoEntry {
13
8
  path: string;
@@ -24,35 +19,35 @@ export interface Profile {
24
19
  currentStreak: number;
25
20
  longestStreak: number;
26
21
  lastCompletionDate?: string;
27
- achievements: string[];
22
+ achievements: string[]; // Achievement IDs that were unlocked
23
+ inventory: LootItem[]; // Collected loot items
28
24
  undoHistory: UndoEntry[];
29
25
  stats: {
30
26
  completedByDay: Record<string, number>;
31
27
  createdAt: string;
28
+ deepestDepth: number;
29
+ speedruns: number;
30
+ nightOwlTasks: number;
31
+ earlyBirdTasks: number;
32
32
  };
33
33
  }
34
34
 
35
35
  const PROFILE_FILE = path.join(os.homedir(), '.rlc', 'profile.json');
36
36
 
37
- // Achievement definitions
38
- export const ACHIEVEMENTS: Achievement[] = [
39
- { id: 'first_blood', name: 'First Blood', description: 'Complete your first task' },
40
- { id: 'ten_tasks', name: 'Getting Started', description: 'Complete 10 tasks' },
41
- { id: 'fifty_tasks', name: 'Productive', description: 'Complete 50 tasks' },
42
- { id: 'hundred_tasks', name: 'Centurion', description: 'Complete 100 tasks' },
43
- { id: 'deep_nesting', name: 'Deep Diver', description: 'Complete a task at depth 5+' },
44
- { id: 'boss_slayer', name: 'Boss Slayer', description: 'Complete a boss task' },
45
- { id: 'five_bosses', name: 'Boss Hunter', description: 'Defeat 5 bosses' },
46
- { id: 'speedrunner', name: 'Speedrunner', description: 'Complete a task on the same day it was created' },
47
- { id: 'streak_3', name: 'On a Roll', description: '3 day completion streak' },
48
- { id: 'streak_7', name: 'Streak Master', description: '7 day completion streak' },
49
- { id: 'streak_30', name: 'Unstoppable', description: '30 day completion streak' },
50
- { id: 'level_5', name: 'Adventurer', description: 'Reach level 5' },
51
- { id: 'level_10', name: 'Veteran', description: 'Reach level 10' },
52
- { id: 'level_25', name: 'Legend', description: 'Reach level 25' },
53
- { id: 'xp_1000', name: 'XP Collector', description: 'Earn 1000 XP' },
54
- { id: 'xp_10000', name: 'XP Hoarder', description: 'Earn 10000 XP' },
55
- ];
37
+ // Achievement thresholds (infinite, level-based)
38
+ export const ACHIEVEMENT_THRESHOLDS = {
39
+ tasks: [1, 10, 50, 100, 250, 500, 1000, 2500, 5000, 10000],
40
+ bosses: [1, 5, 10, 25, 50, 100, 250, 500],
41
+ streaks: [3, 7, 14, 30, 60, 90, 180, 365],
42
+ depths: [3, 5, 7, 10, 15, 20],
43
+ levels: [5, 10, 25, 50, 100, 150, 200],
44
+ xp: [1000, 5000, 10000, 50000, 100000, 500000, 1000000],
45
+ };
46
+
47
+ // Map threshold to achievement ID
48
+ function getAchievementId(type: string, threshold: number): string {
49
+ return `${type}_${threshold}`;
50
+ }
56
51
 
57
52
  // XP required for each level (cumulative)
58
53
  export function xpForLevel(level: number): number {
@@ -95,6 +90,10 @@ export function readProfile(): Profile {
95
90
  return {
96
91
  ...createDefaultProfile(),
97
92
  ...profile,
93
+ stats: {
94
+ ...createDefaultProfile().stats,
95
+ ...profile.stats,
96
+ },
98
97
  };
99
98
  } catch {
100
99
  return createDefaultProfile();
@@ -122,40 +121,40 @@ function createDefaultProfile(): Profile {
122
121
  currentStreak: 0,
123
122
  longestStreak: 0,
124
123
  achievements: [],
124
+ inventory: [],
125
125
  undoHistory: [],
126
126
  stats: {
127
127
  completedByDay: {},
128
128
  createdAt: new Date().toISOString(),
129
+ deepestDepth: 0,
130
+ speedruns: 0,
131
+ nightOwlTasks: 0,
132
+ earlyBirdTasks: 0,
129
133
  },
130
134
  };
131
135
  }
132
136
 
133
- export function addXP(amount: number): { newXP: number; levelUp: boolean; newLevel: number } {
134
- const profile = readProfile();
135
- const oldLevel = profile.level;
136
-
137
- profile.totalXP += amount;
138
- profile.level = levelFromXP(profile.totalXP);
139
-
140
- saveProfile(profile);
141
-
142
- return {
143
- newXP: profile.totalXP,
144
- levelUp: profile.level > oldLevel,
145
- newLevel: profile.level,
146
- };
147
- }
148
-
149
- export function completeTask(xp: number, isBoss: boolean, depth: number, createdAt: string): {
137
+ export interface CompletionResult {
150
138
  xpGained: number;
151
139
  levelUp: boolean;
140
+ oldLevel: number;
152
141
  newLevel: number;
153
- newAchievements: Achievement[];
154
- } {
142
+ newAchievements: string[]; // Achievement IDs
143
+ lootDropped?: LootItem;
144
+ }
145
+
146
+ export function completeTask(
147
+ xp: number,
148
+ isBoss: boolean,
149
+ depth: number,
150
+ createdAt: string,
151
+ rulesPreset?: string
152
+ ): CompletionResult {
155
153
  const profile = readProfile();
156
154
  const oldLevel = profile.level;
157
155
  const today = new Date().toISOString().split('T')[0];
158
156
  const createdDate = createdAt.split('T')[0];
157
+ const hour = new Date().getHours();
159
158
 
160
159
  // Add XP
161
160
  profile.totalXP += xp;
@@ -165,6 +164,18 @@ export function completeTask(xp: number, isBoss: boolean, depth: number, created
165
164
  profile.bossesDefeated += 1;
166
165
  }
167
166
 
167
+ // Track special completions
168
+ if (createdDate === today) {
169
+ profile.stats.speedruns = (profile.stats.speedruns || 0) + 1;
170
+ }
171
+ if (hour >= 0 && hour < 6) {
172
+ profile.stats.earlyBirdTasks = (profile.stats.earlyBirdTasks || 0) + 1;
173
+ }
174
+ if (hour >= 0 && hour < 5) {
175
+ profile.stats.nightOwlTasks = (profile.stats.nightOwlTasks || 0) + 1;
176
+ }
177
+ profile.stats.deepestDepth = Math.max(profile.stats.deepestDepth || 0, depth);
178
+
168
179
  // Update streak
169
180
  if (profile.lastCompletionDate) {
170
181
  const lastDate = new Date(profile.lastCompletionDate);
@@ -176,7 +187,6 @@ export function completeTask(xp: number, isBoss: boolean, depth: number, created
176
187
  } else if (diffDays > 1) {
177
188
  profile.currentStreak = 1;
178
189
  }
179
- // Same day - streak continues but doesn't increment
180
190
  } else {
181
191
  profile.currentStreak = 1;
182
192
  }
@@ -189,84 +199,185 @@ export function completeTask(xp: number, isBoss: boolean, depth: number, created
189
199
 
190
200
  // Update level
191
201
  profile.level = levelFromXP(profile.totalXP);
202
+ const levelUp = profile.level > oldLevel;
192
203
 
193
204
  // Check for new achievements
194
- const newAchievements: Achievement[] = [];
205
+ const newAchievements: string[] = [];
195
206
 
196
207
  const checkAchievement = (id: string) => {
197
208
  if (!profile.achievements.includes(id)) {
198
209
  profile.achievements.push(id);
199
- const achievement = ACHIEVEMENTS.find(a => a.id === id);
200
- if (achievement) newAchievements.push(achievement);
210
+ newAchievements.push(id);
201
211
  }
202
212
  };
203
213
 
204
- // Task count achievements
205
- if (profile.tasksCompleted >= 1) checkAchievement('first_blood');
206
- if (profile.tasksCompleted >= 10) checkAchievement('ten_tasks');
207
- if (profile.tasksCompleted >= 50) checkAchievement('fifty_tasks');
208
- if (profile.tasksCompleted >= 100) checkAchievement('hundred_tasks');
214
+ // Task count achievements (infinite)
215
+ for (const threshold of ACHIEVEMENT_THRESHOLDS.tasks) {
216
+ if (profile.tasksCompleted >= threshold) {
217
+ checkAchievement(getAchievementId('tasks', threshold));
218
+ }
219
+ }
220
+
221
+ // Boss achievements (infinite)
222
+ for (const threshold of ACHIEVEMENT_THRESHOLDS.bosses) {
223
+ if (profile.bossesDefeated >= threshold) {
224
+ checkAchievement(getAchievementId('bosses', threshold));
225
+ }
226
+ }
227
+
228
+ // Depth achievements (infinite)
229
+ for (const threshold of ACHIEVEMENT_THRESHOLDS.depths) {
230
+ if (profile.stats.deepestDepth >= threshold) {
231
+ checkAchievement(getAchievementId('depth', threshold));
232
+ }
233
+ }
234
+
235
+ // Streak achievements (infinite)
236
+ for (const threshold of ACHIEVEMENT_THRESHOLDS.streaks) {
237
+ if (profile.currentStreak >= threshold) {
238
+ checkAchievement(getAchievementId('streak', threshold));
239
+ }
240
+ }
241
+
242
+ // Level achievements (infinite)
243
+ for (const threshold of ACHIEVEMENT_THRESHOLDS.levels) {
244
+ if (profile.level >= threshold) {
245
+ checkAchievement(getAchievementId('level', threshold));
246
+ }
247
+ }
209
248
 
210
- // Boss achievements
211
- if (isBoss) checkAchievement('boss_slayer');
212
- if (profile.bossesDefeated >= 5) checkAchievement('five_bosses');
249
+ // XP achievements (infinite)
250
+ for (const threshold of ACHIEVEMENT_THRESHOLDS.xp) {
251
+ if (profile.totalXP >= threshold) {
252
+ checkAchievement(getAchievementId('xp', threshold));
253
+ }
254
+ }
213
255
 
214
- // Depth achievement
215
- if (depth >= 5) checkAchievement('deep_nesting');
256
+ // Special achievements
257
+ if (createdDate === today) {
258
+ checkAchievement('speedrun');
259
+ }
260
+ if (hour >= 0 && hour < 5) {
261
+ checkAchievement('nightowl');
262
+ }
263
+ if (hour >= 5 && hour < 7) {
264
+ checkAchievement('earlybird');
265
+ }
216
266
 
217
- // Speedrunner achievement
218
- if (createdDate === today) checkAchievement('speedrunner');
267
+ // Roll for loot
268
+ let lootDropped: LootItem | undefined;
219
269
 
220
- // Streak achievements
221
- if (profile.currentStreak >= 3) checkAchievement('streak_3');
222
- if (profile.currentStreak >= 7) checkAchievement('streak_7');
223
- if (profile.currentStreak >= 30) checkAchievement('streak_30');
270
+ // Roll from task completion
271
+ const taskLoot = rollForLoot(profile.level, isBoss ? 'boss' : 'task', rulesPreset);
272
+ if (taskLoot.dropped && taskLoot.item) {
273
+ lootDropped = taskLoot.item;
274
+ profile.inventory = profile.inventory || [];
275
+ profile.inventory.push(taskLoot.item);
276
+ }
224
277
 
225
- // Level achievements
226
- if (profile.level >= 5) checkAchievement('level_5');
227
- if (profile.level >= 10) checkAchievement('level_10');
228
- if (profile.level >= 25) checkAchievement('level_25');
278
+ // Additional roll on level up
279
+ if (levelUp) {
280
+ const levelLoot = rollForLoot(profile.level, 'levelup', rulesPreset);
281
+ if (levelLoot.dropped && levelLoot.item) {
282
+ if (!lootDropped) {
283
+ lootDropped = levelLoot.item;
284
+ }
285
+ profile.inventory.push(levelLoot.item);
286
+ }
287
+ }
229
288
 
230
- // XP achievements
231
- if (profile.totalXP >= 1000) checkAchievement('xp_1000');
232
- if (profile.totalXP >= 10000) checkAchievement('xp_10000');
289
+ // Additional roll on new achievements
290
+ if (newAchievements.length > 0) {
291
+ const achievementLoot = rollForLoot(profile.level, 'achievement', rulesPreset);
292
+ if (achievementLoot.dropped && achievementLoot.item) {
293
+ if (!lootDropped) {
294
+ lootDropped = achievementLoot.item;
295
+ }
296
+ profile.inventory.push(achievementLoot.item);
297
+ }
298
+ }
233
299
 
234
300
  saveProfile(profile);
235
301
 
236
302
  return {
237
303
  xpGained: xp,
238
- levelUp: profile.level > oldLevel,
304
+ levelUp,
305
+ oldLevel,
239
306
  newLevel: profile.level,
240
307
  newAchievements,
308
+ lootDropped,
309
+ };
310
+ }
311
+
312
+ // Get achievement display info from dictionary
313
+ export function getAchievementInfo(achievementId: string, dict: Dictionary): { name: string; desc: string } | null {
314
+ // Parse achievement ID
315
+ const parts = achievementId.split('_');
316
+ const type = parts[0];
317
+ const threshold = parseInt(parts[1]);
318
+
319
+ // Map to dictionary keys
320
+ const keyMap: Record<string, Record<number, string>> = {
321
+ tasks: { 1: 'firstTask', 10: 'tasks10', 50: 'tasks50', 100: 'tasks100', 500: 'tasks500', 1000: 'tasks1000' },
322
+ bosses: { 1: 'boss1', 5: 'boss5', 10: 'boss10', 25: 'boss25' },
323
+ streak: { 3: 'streak3', 7: 'streak7', 14: 'streak14', 30: 'streak30' },
324
+ depth: { 3: 'depth3', 5: 'depth5', 10: 'depth10' },
325
+ };
326
+
327
+ // Special achievements
328
+ if (achievementId === 'speedrun') {
329
+ return dict.achievements.speedrun;
330
+ }
331
+ if (achievementId === 'nightowl') {
332
+ return dict.achievements.nightOwl;
333
+ }
334
+ if (achievementId === 'earlybird') {
335
+ return dict.achievements.earlyBird;
336
+ }
337
+
338
+ // Get from map
339
+ if (keyMap[type] && keyMap[type][threshold]) {
340
+ const key = keyMap[type][threshold] as keyof Dictionary['achievements'];
341
+ return dict.achievements[key];
342
+ }
343
+
344
+ // Generate for achievements beyond dictionary
345
+ return {
346
+ name: `${type.charAt(0).toUpperCase() + type.slice(1)} ${threshold}`,
347
+ desc: `Reach ${threshold} ${type}`,
241
348
  };
242
349
  }
243
350
 
244
- export function formatStats(): string {
351
+ export function formatStats(rulesPreset?: string): string {
245
352
  const profile = readProfile();
353
+ const dict = getDictionary(rulesPreset);
246
354
  const nextLevel = xpToNextLevel(profile.totalXP);
247
355
 
248
356
  const lines: string[] = [
249
357
  '',
250
358
  '=== PLAYER STATS ===',
251
359
  '',
252
- `Level: ${profile.level}`,
253
- `XP: ${profile.totalXP} (${nextLevel.current}/${nextLevel.required} to next level)`,
360
+ `${dict.stats.level}: ${profile.level}`,
361
+ `${dict.stats.xp}: ${profile.totalXP} (${nextLevel.current}/${nextLevel.required} to next)`,
254
362
  `Progress: [${'#'.repeat(Math.floor(nextLevel.progress / 5))}${'.'.repeat(20 - Math.floor(nextLevel.progress / 5))}] ${nextLevel.progress}%`,
255
363
  '',
256
- `Tasks Completed: ${profile.tasksCompleted}`,
257
- `Bosses Defeated: ${profile.bossesDefeated}`,
258
- `Current Streak: ${profile.currentStreak} days`,
259
- `Longest Streak: ${profile.longestStreak} days`,
364
+ `${dict.stats.tasksCompleted}: ${profile.tasksCompleted}`,
365
+ `${dict.stats.bossesDefeated}: ${profile.bossesDefeated}`,
366
+ `${dict.stats.currentStreak}: ${profile.currentStreak} days`,
367
+ `${dict.stats.longestStreak}: ${profile.longestStreak} days`,
260
368
  '',
261
- `Achievements: ${profile.achievements.length}/${ACHIEVEMENTS.length}`,
369
+ `Achievements: ${profile.achievements.length}`,
370
+ `${dict.stats.inventory}: ${(profile.inventory || []).length} items`,
371
+ `Inventory Value: ${calculateInventoryValue(profile.inventory || [])}`,
262
372
  '',
263
373
  ];
264
374
 
265
375
  return lines.join('\n');
266
376
  }
267
377
 
268
- export function formatAchievements(): string {
378
+ export function formatAchievements(rulesPreset?: string): string {
269
379
  const profile = readProfile();
380
+ const dict = getDictionary(rulesPreset);
270
381
 
271
382
  const lines: string[] = [
272
383
  '',
@@ -274,24 +385,76 @@ export function formatAchievements(): string {
274
385
  '',
275
386
  ];
276
387
 
277
- for (const achievement of ACHIEVEMENTS) {
278
- const unlocked = profile.achievements.includes(achievement.id);
279
- const status = unlocked ? '[x]' : '[ ]';
280
- lines.push(`${status} ${achievement.name}`);
281
- lines.push(` ${achievement.description}`);
388
+ // Show unlocked achievements
389
+ if (profile.achievements.length === 0) {
390
+ lines.push('No achievements yet. Complete tasks to unlock!');
391
+ } else {
392
+ lines.push('Unlocked:');
393
+ for (const id of profile.achievements) {
394
+ const info = getAchievementInfo(id, dict);
395
+ if (info) {
396
+ lines.push(`[x] ${info.name}`);
397
+ lines.push(` ${info.desc}`);
398
+ }
399
+ }
400
+ }
401
+
402
+ // Show next achievements to unlock
403
+ lines.push('');
404
+ lines.push('Next to unlock:');
405
+
406
+ // Find next task achievement
407
+ for (const threshold of ACHIEVEMENT_THRESHOLDS.tasks) {
408
+ const id = getAchievementId('tasks', threshold);
409
+ if (!profile.achievements.includes(id)) {
410
+ const info = getAchievementInfo(id, dict);
411
+ if (info) {
412
+ lines.push(`[ ] ${info.name} (${profile.tasksCompleted}/${threshold})`);
413
+ }
414
+ break;
415
+ }
416
+ }
417
+
418
+ // Find next boss achievement
419
+ for (const threshold of ACHIEVEMENT_THRESHOLDS.bosses) {
420
+ const id = getAchievementId('bosses', threshold);
421
+ if (!profile.achievements.includes(id)) {
422
+ const info = getAchievementInfo(id, dict);
423
+ if (info) {
424
+ lines.push(`[ ] ${info.name} (${profile.bossesDefeated}/${threshold})`);
425
+ }
426
+ break;
427
+ }
428
+ }
429
+
430
+ // Find next streak achievement
431
+ for (const threshold of ACHIEVEMENT_THRESHOLDS.streaks) {
432
+ const id = getAchievementId('streak', threshold);
433
+ if (!profile.achievements.includes(id)) {
434
+ const info = getAchievementInfo(id, dict);
435
+ if (info) {
436
+ lines.push(`[ ] ${info.name} (${profile.currentStreak}/${threshold})`);
437
+ }
438
+ break;
439
+ }
282
440
  }
283
441
 
284
442
  lines.push('');
285
- lines.push(`Unlocked: ${profile.achievements.length}/${ACHIEVEMENTS.length}`);
443
+ lines.push(`Total unlocked: ${profile.achievements.length}`);
286
444
  lines.push('');
287
445
 
288
446
  return lines.join('\n');
289
447
  }
290
448
 
449
+ export function formatInventoryDisplay(rulesPreset?: string): string {
450
+ const profile = readProfile();
451
+ const dict = getDictionary(rulesPreset);
452
+ return formatInventory(profile.inventory || [], dict);
453
+ }
454
+
291
455
  export function addToUndoHistory(entry: UndoEntry): void {
292
456
  const profile = readProfile();
293
457
 
294
- // Keep only last 10 undo entries
295
458
  profile.undoHistory = profile.undoHistory || [];
296
459
  profile.undoHistory.unshift(entry);
297
460
  if (profile.undoHistory.length > 10) {
@@ -318,7 +481,6 @@ export function performUndo(): { success: boolean; entry: UndoEntry | null; mess
318
481
 
319
482
  const entry = profile.undoHistory.shift()!;
320
483
 
321
- // Subtract XP
322
484
  profile.totalXP = Math.max(0, profile.totalXP - entry.xpLost);
323
485
  profile.tasksCompleted = Math.max(0, profile.tasksCompleted - 1);
324
486
 
@@ -326,10 +488,8 @@ export function performUndo(): { success: boolean; entry: UndoEntry | null; mess
326
488
  profile.bossesDefeated = Math.max(0, profile.bossesDefeated - 1);
327
489
  }
328
490
 
329
- // Update level
330
491
  profile.level = levelFromXP(profile.totalXP);
331
492
 
332
- // Update daily stats
333
493
  const today = new Date().toISOString().split('T')[0];
334
494
  if (profile.stats.completedByDay[today]) {
335
495
  profile.stats.completedByDay[today] = Math.max(0, profile.stats.completedByDay[today] - 1);
@@ -343,4 +503,3 @@ export function performUndo(): { success: boolean; entry: UndoEntry | null; mess
343
503
  message: `Undo: -${entry.xpLost} XP`
344
504
  };
345
505
  }
346
-