clawcity 2.3.1 → 2.5.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.
@@ -0,0 +1,415 @@
1
+ import { api, handleError } from '../lib/api.js';
2
+ function asRecord(value) {
3
+ return value && typeof value === 'object' && !Array.isArray(value)
4
+ ? value
5
+ : null;
6
+ }
7
+ function asRecordArray(value) {
8
+ return Array.isArray(value)
9
+ ? value.filter((entry) => Boolean(asRecord(entry)))
10
+ : [];
11
+ }
12
+ function asNumber(value) {
13
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
14
+ }
15
+ function asString(value) {
16
+ return typeof value === 'string' && value.length > 0 ? value : null;
17
+ }
18
+ function asBoolean(value) {
19
+ return value === true;
20
+ }
21
+ function parseTarget(rawTarget) {
22
+ return rawTarget.trim().toLowerCase();
23
+ }
24
+ function formatResourceCost(cost) {
25
+ const parts = [];
26
+ const gold = asNumber(cost.gold);
27
+ const wood = asNumber(cost.wood);
28
+ const food = asNumber(cost.food);
29
+ const stone = asNumber(cost.stone);
30
+ const foodClaimCost = asNumber(cost.food_claim_cost);
31
+ const staminaCost = asNumber(cost.stamina_cost);
32
+ const foodTotal = asNumber(cost.food_total);
33
+ if (gold !== null && gold > 0)
34
+ parts.push(`${gold} gold`);
35
+ if (wood !== null && wood > 0)
36
+ parts.push(`${wood} wood`);
37
+ if (stone !== null && stone > 0)
38
+ parts.push(`${stone} stone`);
39
+ if (food !== null && food > 0)
40
+ parts.push(`${food} food`);
41
+ if (foodClaimCost !== null)
42
+ parts.push(`${foodClaimCost} food (claim cost)`);
43
+ if (staminaCost !== null)
44
+ parts.push(`${staminaCost} food (stamina cost)`);
45
+ if (foodTotal !== null)
46
+ parts.push(`${foodTotal} food (total)`);
47
+ return parts.length > 0 ? parts.join(', ') : 'no resource cost';
48
+ }
49
+ function getInventory(stats) {
50
+ return {
51
+ gold: asNumber(stats.gold) || 0,
52
+ wood: asNumber(stats.wood) || 0,
53
+ food: asNumber(stats.food) || 0,
54
+ stone: asNumber(stats.stone) || 0,
55
+ };
56
+ }
57
+ function buildRequirements(inventory, cost) {
58
+ const toRequirement = (need, have) => ({
59
+ need,
60
+ have,
61
+ missing: Math.max(0, need - have),
62
+ });
63
+ return {
64
+ gold: toRequirement(asNumber(cost.gold) || 0, inventory.gold),
65
+ wood: toRequirement(asNumber(cost.wood) || 0, inventory.wood),
66
+ food: toRequirement(asNumber(cost.food) || 0, inventory.food),
67
+ stone: toRequirement(asNumber(cost.stone) || 0, inventory.stone),
68
+ };
69
+ }
70
+ function missingRequirementLines(requirements) {
71
+ return Object.entries(requirements)
72
+ .filter(([, requirement]) => requirement.missing > 0)
73
+ .map(([resource, requirement]) => (`${resource} +${requirement.missing} (need ${requirement.need}, have ${requirement.have})`));
74
+ }
75
+ function printReasons(reasons) {
76
+ if (!Array.isArray(reasons))
77
+ return;
78
+ const parsedReasons = reasons.filter((entry) => typeof entry === 'string' && entry.length > 0);
79
+ if (parsedReasons.length === 0)
80
+ return;
81
+ console.log(`Blocked by: ${parsedReasons.join(', ')}`);
82
+ }
83
+ export function registerPlanningCommands(program) {
84
+ program
85
+ .command('cost <target>')
86
+ .description('Show costs for claim, upgrade, buildings, and craft/shop items')
87
+ .option('--json', 'Print raw JSON response')
88
+ .action(async (target, opts) => {
89
+ const normalizedTarget = parseTarget(target);
90
+ const recipesRes = await api('/api/crafting/recipes', { profile: 'none' });
91
+ if (!recipesRes.ok)
92
+ handleError(recipesRes);
93
+ const data = recipesRes.data;
94
+ const info = asRecord(data.info) || {};
95
+ const costs = asRecord(info.costs) || {};
96
+ const claimCost = asRecord(costs.claim);
97
+ const upgradeCost = asRecord(costs.upgrade);
98
+ const buildingCosts = asRecord(costs.buildings) || {};
99
+ const craftable = asRecordArray(data.craftable);
100
+ const shop = asRecordArray(data.shop);
101
+ if (normalizedTarget === 'claim' || normalizedTarget === 'territory') {
102
+ if (!claimCost) {
103
+ console.error('Error: claim cost metadata unavailable');
104
+ process.exit(1);
105
+ }
106
+ if (opts.json) {
107
+ console.log(JSON.stringify(claimCost, null, 2));
108
+ return;
109
+ }
110
+ const baseCost = asRecord(claimCost.base_cost) || {};
111
+ const discounts = asRecord(claimCost.discounts) || {};
112
+ console.log(`Claim cost (base): ${formatResourceCost({
113
+ gold: baseCost.gold,
114
+ wood: baseCost.wood,
115
+ stone: baseCost.stone,
116
+ food_claim_cost: baseCost.food_claim_cost,
117
+ stamina_cost: baseCost.stamina_cost,
118
+ food_total: baseCost.food_total,
119
+ })}`);
120
+ const firstClaimDiscount = asNumber(discounts.first_claim_percent);
121
+ const deedDiscount = asNumber(discounts.territory_deed_percent);
122
+ console.log(`Discounts: first claim ${firstClaimDiscount ?? 0}%, territory deed ${deedDiscount ?? 0}%`);
123
+ const note = asString(discounts.first_claim_note);
124
+ if (note) {
125
+ console.log(`Note: ${note}`);
126
+ }
127
+ return;
128
+ }
129
+ if (normalizedTarget === 'upgrade' || normalizedTarget.startsWith('upgrade')) {
130
+ const levels = asRecordArray(upgradeCost?.levels);
131
+ if (levels.length === 0) {
132
+ console.error('Error: upgrade cost metadata unavailable');
133
+ process.exit(1);
134
+ }
135
+ let selectedLevels = levels;
136
+ const levelMatch = normalizedTarget.match(/^upgrade[:_-]?(\d+)$/);
137
+ if (levelMatch) {
138
+ const level = Number(levelMatch[1]);
139
+ selectedLevels = levels.filter((entry) => asNumber(entry.level) === level);
140
+ if (selectedLevels.length === 0) {
141
+ console.error(`Error: Unknown upgrade level ${level}`);
142
+ process.exit(1);
143
+ }
144
+ }
145
+ if (opts.json) {
146
+ console.log(JSON.stringify({
147
+ max_level: asNumber(upgradeCost?.max_level),
148
+ levels: selectedLevels,
149
+ }, null, 2));
150
+ return;
151
+ }
152
+ for (const level of selectedLevels) {
153
+ const levelNumber = asNumber(level.level) || '?';
154
+ const levelCost = asRecord(level.cost) || {};
155
+ const bonusPercent = asNumber(level.territory_gather_bonus_percent);
156
+ console.log(`Upgrade Lv${levelNumber}: ${formatResourceCost(levelCost)}${bonusPercent !== null ? ` | bonus +${bonusPercent}%` : ''}`);
157
+ }
158
+ return;
159
+ }
160
+ const building = asRecord(buildingCosts[normalizedTarget]);
161
+ if (building) {
162
+ if (opts.json) {
163
+ console.log(JSON.stringify(building, null, 2));
164
+ return;
165
+ }
166
+ const name = asString(building.name) || normalizedTarget;
167
+ const buildCost = asRecord(building.build_cost) || {};
168
+ const upkeep = asRecord(building.hourly_upkeep) || {};
169
+ console.log(`${name}: build ${formatResourceCost(buildCost)} | upkeep ${formatResourceCost(upkeep)}/hour`);
170
+ const effect = asString(building.effect_description);
171
+ if (effect) {
172
+ console.log(`Effect: ${effect}`);
173
+ }
174
+ return;
175
+ }
176
+ const craftItem = craftable.find((entry) => asString(entry.id)?.toLowerCase() === normalizedTarget);
177
+ if (craftItem) {
178
+ const recipe = asRecord(craftItem.recipe) || {};
179
+ if (opts.json) {
180
+ console.log(JSON.stringify({
181
+ type: 'craft',
182
+ id: asString(craftItem.id),
183
+ name: asString(craftItem.name),
184
+ recipe,
185
+ requires_workshop: craftItem.requires_workshop === true,
186
+ }, null, 2));
187
+ return;
188
+ }
189
+ console.log(`${asString(craftItem.name) || normalizedTarget} (${normalizedTarget}) craft cost: ${formatResourceCost(recipe)}`);
190
+ if (craftItem.requires_workshop === true) {
191
+ console.log('Requires workshop: yes');
192
+ }
193
+ return;
194
+ }
195
+ const shopItem = shop.find((entry) => asString(entry.id)?.toLowerCase() === normalizedTarget);
196
+ if (shopItem) {
197
+ const price = asNumber(shopItem.price);
198
+ const payload = {
199
+ gold: price || 0,
200
+ };
201
+ if (opts.json) {
202
+ console.log(JSON.stringify({
203
+ type: 'shop',
204
+ id: asString(shopItem.id),
205
+ name: asString(shopItem.name),
206
+ cost: payload,
207
+ }, null, 2));
208
+ return;
209
+ }
210
+ console.log(`${asString(shopItem.name) || normalizedTarget} (${normalizedTarget}) shop cost: ${formatResourceCost(payload)}`);
211
+ return;
212
+ }
213
+ console.error(`Error: Unknown target "${target}". Use claim, upgrade, building type, or item_id.`);
214
+ process.exit(1);
215
+ });
216
+ program
217
+ .command('afford <target>')
218
+ .description('Check if you can currently afford an action/item and what is missing')
219
+ .option('--json', 'Print raw JSON response')
220
+ .action(async (target, opts) => {
221
+ const normalizedTarget = parseTarget(target);
222
+ const [statsRes, recipesRes] = await Promise.all([
223
+ api('/api/agents/me/stats'),
224
+ api('/api/crafting/recipes', { profile: 'none' }),
225
+ ]);
226
+ if (!statsRes.ok)
227
+ handleError(statsRes);
228
+ if (!recipesRes.ok)
229
+ handleError(recipesRes);
230
+ const stats = statsRes.data;
231
+ const inventory = getInventory(stats);
232
+ const eligibility = asRecord(stats.action_eligibility) || {};
233
+ const recipesData = recipesRes.data;
234
+ const craftable = asRecordArray(recipesData.craftable);
235
+ const shop = asRecordArray(recipesData.shop);
236
+ if (normalizedTarget === 'claim' || normalizedTarget === 'territory') {
237
+ const claim = asRecord(eligibility.claim) || {};
238
+ const result = {
239
+ target: 'claim',
240
+ can_execute: asBoolean(claim.can_execute),
241
+ can_afford: asBoolean(claim.can_afford),
242
+ affordable_now: asBoolean(claim.can_execute) && asBoolean(claim.can_afford),
243
+ reasons: Array.isArray(claim.reasons) ? claim.reasons : [],
244
+ effective_cost: asRecord(claim.effective_cost) || {},
245
+ missing_resources: Array.isArray(claim.missing_resources) ? claim.missing_resources : [],
246
+ requirements: asRecord(claim.requirements) || {},
247
+ current_tile: asRecord(stats.current_tile) || {},
248
+ };
249
+ if (opts.json) {
250
+ console.log(JSON.stringify(result, null, 2));
251
+ return;
252
+ }
253
+ console.log(`Claim here: ${result.affordable_now ? 'YES' : 'NO'}`);
254
+ console.log(`Cost: ${formatResourceCost(result.effective_cost)}`);
255
+ if (result.missing_resources.length > 0) {
256
+ console.log(`Missing: ${result.missing_resources.join('; ')}`);
257
+ }
258
+ printReasons(result.reasons);
259
+ return;
260
+ }
261
+ if (normalizedTarget === 'upgrade') {
262
+ const upgrade = asRecord(eligibility.upgrade) || {};
263
+ const result = {
264
+ target: 'upgrade',
265
+ can_execute: asBoolean(upgrade.can_execute),
266
+ can_afford: asBoolean(upgrade.can_afford),
267
+ affordable_now: asBoolean(upgrade.can_execute) && asBoolean(upgrade.can_afford),
268
+ reasons: Array.isArray(upgrade.reasons) ? upgrade.reasons : [],
269
+ current_level: asNumber(upgrade.current_level),
270
+ next_level: asNumber(upgrade.next_level),
271
+ cost: asRecord(upgrade.cost) || {},
272
+ missing_resources: Array.isArray(upgrade.missing_resources) ? upgrade.missing_resources : [],
273
+ requirements: asRecord(upgrade.requirements) || {},
274
+ current_tile: asRecord(stats.current_tile) || {},
275
+ };
276
+ if (opts.json) {
277
+ console.log(JSON.stringify(result, null, 2));
278
+ return;
279
+ }
280
+ console.log(`Upgrade here: ${result.affordable_now ? 'YES' : 'NO'}`);
281
+ if (result.next_level !== null) {
282
+ console.log(`Next level: Lv${result.next_level}`);
283
+ console.log(`Cost: ${formatResourceCost(result.cost)}`);
284
+ }
285
+ else {
286
+ console.log('Cost: unavailable (already max level or not upgradeable)');
287
+ }
288
+ if (result.missing_resources.length > 0) {
289
+ console.log(`Missing: ${result.missing_resources.join('; ')}`);
290
+ }
291
+ printReasons(result.reasons);
292
+ return;
293
+ }
294
+ const build = asRecord(eligibility.build) || {};
295
+ const buildOptions = asRecord(build.options) || {};
296
+ const buildOption = asRecord(buildOptions[normalizedTarget]);
297
+ if (buildOption) {
298
+ const canExecute = asBoolean(build.can_execute);
299
+ const canAffordTarget = asBoolean(buildOption.can_afford);
300
+ const result = {
301
+ target: normalizedTarget,
302
+ can_execute: canExecute,
303
+ can_afford: canAffordTarget,
304
+ affordable_now: canExecute && canAffordTarget,
305
+ reasons: Array.isArray(build.reasons) ? build.reasons : [],
306
+ cost: asRecord(buildOption.cost) || {},
307
+ missing_resources: Array.isArray(buildOption.missing_resources) ? buildOption.missing_resources : [],
308
+ requirements: asRecord(buildOption.requirements) || {},
309
+ current_tile: asRecord(stats.current_tile) || {},
310
+ };
311
+ if (opts.json) {
312
+ console.log(JSON.stringify(result, null, 2));
313
+ return;
314
+ }
315
+ console.log(`Build ${normalizedTarget}: ${result.affordable_now ? 'YES' : 'NO'}`);
316
+ console.log(`Cost: ${formatResourceCost(result.cost)}`);
317
+ if (result.missing_resources.length > 0) {
318
+ console.log(`Missing: ${result.missing_resources.join('; ')}`);
319
+ }
320
+ printReasons(result.reasons);
321
+ return;
322
+ }
323
+ const craftItem = craftable.find((entry) => asString(entry.id)?.toLowerCase() === normalizedTarget);
324
+ if (craftItem) {
325
+ const recipe = asRecord(craftItem.recipe) || {};
326
+ const requirements = buildRequirements(inventory, recipe);
327
+ const missing = missingRequirementLines(requirements);
328
+ const requiresWorkshop = craftItem.requires_workshop === true;
329
+ const result = {
330
+ target: normalizedTarget,
331
+ type: 'craft',
332
+ affordable_now: missing.length === 0,
333
+ requires_workshop: requiresWorkshop,
334
+ cost: recipe,
335
+ missing_resources: missing,
336
+ requirements,
337
+ inventory,
338
+ };
339
+ if (opts.json) {
340
+ console.log(JSON.stringify(result, null, 2));
341
+ return;
342
+ }
343
+ console.log(`Craft ${normalizedTarget}: ${result.affordable_now ? 'YES' : 'NO'}`);
344
+ console.log(`Cost: ${formatResourceCost(recipe)}`);
345
+ if (requiresWorkshop) {
346
+ console.log('Requires workshop: yes');
347
+ }
348
+ if (missing.length > 0) {
349
+ console.log(`Missing: ${missing.join('; ')}`);
350
+ }
351
+ return;
352
+ }
353
+ const shopItem = shop.find((entry) => asString(entry.id)?.toLowerCase() === normalizedTarget);
354
+ if (shopItem) {
355
+ const price = asNumber(shopItem.price) || 0;
356
+ const cost = { gold: price };
357
+ const requirements = buildRequirements(inventory, cost);
358
+ const missing = missingRequirementLines(requirements);
359
+ const result = {
360
+ target: normalizedTarget,
361
+ type: 'shop',
362
+ affordable_now: missing.length === 0,
363
+ cost,
364
+ missing_resources: missing,
365
+ requirements,
366
+ inventory,
367
+ };
368
+ if (opts.json) {
369
+ console.log(JSON.stringify(result, null, 2));
370
+ return;
371
+ }
372
+ console.log(`Buy ${normalizedTarget}: ${result.affordable_now ? 'YES' : 'NO'}`);
373
+ console.log(`Cost: ${formatResourceCost(cost)}`);
374
+ if (missing.length > 0) {
375
+ console.log(`Missing: ${missing.join('; ')}`);
376
+ }
377
+ return;
378
+ }
379
+ console.error(`Error: Unknown target "${target}". Use claim, upgrade, building type, or item_id.`);
380
+ process.exit(1);
381
+ });
382
+ program
383
+ .command('territories')
384
+ .description('List your owned territories with upgrade/building details')
385
+ .option('--json', 'Print raw JSON response')
386
+ .action(async (opts) => {
387
+ const res = await api('/api/agents/me', {
388
+ query: { fields: 'territories,position' },
389
+ });
390
+ if (!res.ok)
391
+ handleError(res);
392
+ const data = res.data;
393
+ const territories = asRecordArray(data.territories);
394
+ if (opts.json) {
395
+ console.log(JSON.stringify({
396
+ count: territories.length,
397
+ territories,
398
+ }, null, 2));
399
+ return;
400
+ }
401
+ if (territories.length === 0) {
402
+ console.log('No territories owned.');
403
+ return;
404
+ }
405
+ for (const territory of territories) {
406
+ const x = asNumber(territory.x);
407
+ const y = asNumber(territory.y);
408
+ const terrain = asString(territory.terrain) || 'unknown';
409
+ const level = asNumber(territory.upgrade_level) || 1;
410
+ const buildingType = asString(territory.building_type);
411
+ console.log(`(${x ?? '?'},${y ?? '?'}) ${terrain} | Lv${level}${buildingType ? ` | building:${buildingType}` : ''}`);
412
+ }
413
+ console.log(`Total territories: ${territories.length}`);
414
+ });
415
+ }
@@ -10,6 +10,9 @@ function asNumber(value) {
10
10
  function asString(value) {
11
11
  return typeof value === 'string' && value.length > 0 ? value : null;
12
12
  }
13
+ function asBoolean(value) {
14
+ return typeof value === 'boolean' ? value : null;
15
+ }
13
16
  export function registerScanCommands(program) {
14
17
  program
15
18
  .command('scan [terrain]')
@@ -41,33 +44,70 @@ export function registerScanCommands(program) {
41
44
  const message = asString(data.message) || 'No harvestable tile found in range.';
42
45
  const effectiveRadius = asNumber(scan?.effective_radius);
43
46
  const maxRadius = asNumber(scan?.max_radius);
47
+ const harvestableTiles = asNumber(scan?.harvestable_tiles);
48
+ const blockedByBuildings = asNumber(scan?.blocked_by_buildings);
44
49
  if (effectiveRadius !== null && maxRadius !== null && effectiveRadius < maxRadius) {
45
50
  console.log(`${message} (scan capped at ${effectiveRadius}/${maxRadius}).`);
46
51
  return;
47
52
  }
48
- console.log(message);
53
+ const parts = [message];
54
+ if (harvestableTiles !== null) {
55
+ parts.push(`harvestable_seen:${harvestableTiles}`);
56
+ }
57
+ if (blockedByBuildings !== null) {
58
+ parts.push(`blocked:${blockedByBuildings}`);
59
+ }
60
+ console.log(parts.join(' | '));
49
61
  return;
50
62
  }
51
63
  const terrainLabel = asString(target.terrain) || 'unknown';
52
64
  const x = asNumber(target.x);
53
65
  const y = asNumber(target.y);
54
66
  const distance = asNumber(target.distance);
67
+ const harvestable = asBoolean(target.harvestable);
68
+ const riskPercent = asNumber(target.depletion_chance_percent)
69
+ ?? asNumber(target.risk_percent)
70
+ ?? asNumber(target.risk);
71
+ const health = asString(target.tile_health) || asString(target.health);
72
+ const nextGatherMs = asNumber(target.cooldown_remaining_ms)
73
+ ?? asNumber(target.next_gather_in_ms);
74
+ const nextGatherSeconds = nextGatherMs === null ? null : Math.ceil(nextGatherMs / 1000);
55
75
  const effectiveRadius = asNumber(scan?.effective_radius);
56
76
  const maxRadius = asNumber(scan?.max_radius);
77
+ const harvestableTiles = asNumber(scan?.harvestable_tiles);
57
78
  const depleted = asNumber(scan?.depleted_tiles);
79
+ const blockedByBuildings = asNumber(scan?.blocked_by_buildings);
58
80
  const pieces = [
59
- `Next fresh ${terrainLabel} tile: (${x ?? '?'},${y ?? '?'})`,
60
- `distance:${distance ?? '?'}`,
81
+ `Nearest ${terrainLabel} tile: (${x ?? '?'},${y ?? '?'})`,
82
+ `dist:${distance ?? '?'}`,
61
83
  ];
84
+ if (harvestable !== null) {
85
+ pieces.push(`harvestable:${harvestable ? 'yes' : 'no'}`);
86
+ }
87
+ if (riskPercent !== null) {
88
+ pieces.push(`risk:${riskPercent}%`);
89
+ }
90
+ if (health) {
91
+ pieces.push(`health:${health}`);
92
+ }
93
+ if (nextGatherSeconds !== null) {
94
+ pieces.push(`next_gather:${nextGatherSeconds > 0 ? `${nextGatherSeconds}s` : 'now'}`);
95
+ }
62
96
  if (effectiveRadius !== null) {
63
97
  pieces.push(`radius:${effectiveRadius}`);
64
98
  }
65
99
  if (maxRadius !== null && effectiveRadius !== null && effectiveRadius < maxRadius) {
66
100
  pieces.push(`capped:${effectiveRadius}/${maxRadius}`);
67
101
  }
102
+ if (harvestableTiles !== null) {
103
+ pieces.push(`harvestable_seen:${harvestableTiles}`);
104
+ }
68
105
  if (depleted !== null) {
69
106
  pieces.push(`depleted_seen:${depleted}`);
70
107
  }
108
+ if (blockedByBuildings !== null) {
109
+ pieces.push(`blocked:${blockedByBuildings}`);
110
+ }
71
111
  if (usedSpyglass) {
72
112
  const usesRemaining = asNumber(scan?.spyglass_uses_remaining);
73
113
  if (usesRemaining !== null) {
@@ -37,7 +37,7 @@ export function registerStatsCommands(program) {
37
37
  program
38
38
  .command('status')
39
39
  .description('Full agent status with all details')
40
- .option('-f, --fields <fields>', 'Comma-separated fields: inventory,position,wealth,items,buildings,nearby,trades,announcements')
40
+ .option('-f, --fields <fields>', 'Comma-separated fields: inventory,position,wealth,items,buildings,territories,nearby,trades,announcements,avatar')
41
41
  .action(async (opts) => {
42
42
  const path = opts.fields
43
43
  ? `/api/agents/me?fields=${opts.fields}`