import { Hono, type Context } from 'hono'; import { identity, redis } from '../platform'; import { WEED_SIM_FACILITIES, beginGrowing, breedInState, canAttempt, cloneInState, createStarterSeeds, deckSummary, emptyGameState, findSeed, growSecondsLeft, matureReadySeeds, normalizeGameState, inboxFromReceipts, labPayload, newId, nowIso, predictCross, replaceSeeds, toNewick, } from '../../shared'; import type { BreedRequest, CloneRequest, CrossRequest, ErrorResponse, InitResponse, SelectionRequest, } from '../../shared/api'; import type { BusReceipt, GameState, SeedProfile } from '../../shared'; type InventorySeed = SeedProfile & { id: string; name: string; stage: string; grow_time: number; bud_color: SeedProfile['budColor']; leaf_color: SeedProfile['leafColor']; bud_palette: SeedProfile['budPalette']; leaf_palette: SeedProfile['leafPalette']; bud_pattern: SeedProfile['budPattern']; leaf_pattern: SeedProfile['leafPattern']; base_image_name: string; can_attempt: boolean; attempts_used: number; max_attempts: number; is_starter: boolean; sprite: string; matures_at: string | null; grow_seconds_left: number; }; type RuntimeIdentity = { postId: string; username: string; }; export const api = new Hono(); export const external = new Hono(); function stateKey({ postId, username }: RuntimeIdentity): string { return `weedsim:v1:${postId}:${username}:state`; } function receiptsKey({ postId, username }: RuntimeIdentity): string { return `weedsim:v1:${postId}:${username}:receipts`; } function leaderboardKey(postId: string): string { return `weedsim:v1:${postId}:leaderboard`; } function marketListingsKey(postId: string): string { return `weedsim:v1:${postId}:market:listings`; } function marketOffersKey(postId: string): string { return `weedsim:v1:${postId}:market:offers`; } function marketRequestsKey(postId: string): string { return `weedsim:v1:${postId}:market:requests`; } function agentEventsKey({ postId, username }: RuntimeIdentity): string { return `weedsim:v1:${postId}:${username}:agent-link:events`; } function jsonError(_c: Context, message: string, status = 400) { return new Response(JSON.stringify({ ok: false, status: 'error', message } satisfies ErrorResponse & { ok: false }), { status, headers: { 'Content-Type': 'application/json' }, }); } async function loadJson(key: string, fallback: T): Promise { const raw = await redis.get(key); if (!raw) return fallback; try { return JSON.parse(raw) as T; } catch (error) { console.error('WEED-SIM JSON parse failed', key, error); return fallback; } } async function saveJson(key: string, value: T): Promise { await redis.set(key, JSON.stringify(value)); } async function clearOwnedResetResidue(id: RuntimeIdentity): Promise { const ownsRecord = (value: unknown) => { if (typeof value !== 'object' || value === null) return false; const record = value as Record; const asset = typeof record.asset === 'object' && record.asset !== null ? record.asset as Record : {}; return record.owner === id.username || record.seller === id.username || record.maker === id.username || record.requester === id.username || asset.owner === id.username; }; const [leaderboard, listings, offers, requests] = await Promise.all([ loadJson>>(leaderboardKey(id.postId), []), loadJson>>(marketListingsKey(id.postId), []), loadJson>>(marketOffersKey(id.postId), []), loadJson>>(marketRequestsKey(id.postId), []), ]); await Promise.all([ saveJson(leaderboardKey(id.postId), leaderboard.filter((entry) => entry.owner !== id.username)), saveJson(marketListingsKey(id.postId), listings.filter((listing) => !ownsRecord(listing))), saveJson(marketOffersKey(id.postId), offers.filter((offer) => !ownsRecord(offer))), saveJson(marketRequestsKey(id.postId), requests.filter((request) => !ownsRecord(request))), redis.del(agentEventsKey(id)), ]); } async function loadState(id: RuntimeIdentity): Promise { const raw = await redis.get(stateKey(id)); if (!raw) return emptyGameState(); try { const parsed = JSON.parse(raw) as GameState; if (parsed?.schema === 'weedsim.game_state/v1' && Array.isArray(parsed.seeds)) { return matureReadySeeds(normalizeGameState({ ...emptyGameState(), ...parsed })); } } catch (error) { console.error('WEED-SIM state parse failed', error); } return emptyGameState(); } async function saveState(id: RuntimeIdentity, state: GameState): Promise { const normalized = matureReadySeeds(normalizeGameState(state)); await redis.set(stateKey(id), JSON.stringify(normalized)); return normalized; } async function loadReceipts(id: RuntimeIdentity): Promise { const raw = await redis.get(receiptsKey(id)); if (!raw) return []; try { const parsed = JSON.parse(raw) as BusReceipt[]; return Array.isArray(parsed) ? parsed : []; } catch (error) { console.error('WEED-SIM receipt parse failed', error); return []; } } async function appendReceipt( id: RuntimeIdentity, eventType: string, subject: string, payload: Record, source: BusReceipt['source'] = 'weed-sim' ): Promise { const receipt: BusReceipt = { id: newId('receipt'), eventType, subject, source, createdAt: nowIso(), payload: { ...payload, postId: id.postId, username: id.username, }, }; const current = await loadReceipts(id); await redis.set(receiptsKey(id), JSON.stringify([receipt, ...current].slice(0, 100))); return receipt; } function addMissingStarters(state: GameState): GameState { const existingStarterNames = new Set(state.seeds.filter((seed) => seed.isStarter).map((seed) => seed.strainName)); const missing = createStarterSeeds().filter((seed) => !existingStarterNames.has(seed.strainName)); return missing.length ? replaceSeeds(state, [...state.seeds, ...missing]) : state; } function spritePath(seed: SeedProfile, stage = seed.growthStage): string { return `/assets_px/${seed.baseImageName}_${stage.toLowerCase()}.png`; } function toInventorySeed(seed: SeedProfile): InventorySeed { return { ...seed, id: seed.seedId, name: seed.strainName, stage: seed.growthStage, grow_time: seed.growTime, bud_color: seed.budColor, leaf_color: seed.leafColor, bud_palette: seed.budPalette, leaf_palette: seed.leafPalette, bud_pattern: seed.budPattern, leaf_pattern: seed.leafPattern, base_image_name: seed.baseImageName, can_attempt: canAttempt(seed), attempts_used: seed.attemptsUsed, max_attempts: seed.maxAttempts, is_starter: seed.isStarter, sprite: spritePath(seed), matures_at: seed.maturesAt ?? null, grow_seconds_left: growSecondsLeft(seed), }; } function routeManifest() { return { schema: 'weedsim.route_manifest/v1', routes: [ { method: 'GET', path: '/api/init', label: 'Init', kind: 'get' }, { method: 'GET', path: '/api/inventory', label: 'Inventory', kind: 'get' }, { method: 'POST', path: '/api/inventory/starter', label: 'Load starters', kind: 'post' }, { method: 'POST', path: '/api/inventory/clear', label: 'Clear inventory', kind: 'post' }, { method: 'POST', path: '/api/seed/{id}/grow', label: 'Grow specimen', kind: 'post' }, { method: 'POST', path: '/api/breed', label: 'Breed', kind: 'post' }, { method: 'POST', path: '/api/clone', label: 'Clone', kind: 'post' }, { method: 'GET', path: '/api/lab/germplasm', label: 'Germplasm', kind: 'get' }, { method: 'POST', path: '/api/lab/predict-cross', label: 'Predict cross', kind: 'post' }, { method: 'GET', path: '/api/lab/newick', label: 'Newick export', kind: 'get' }, { method: 'GET', path: '/api/deck/summary', label: 'Deck summary', kind: 'get' }, { method: 'GET', path: '/api/deck/signals', label: 'Deck signals', kind: 'get' }, { method: 'GET', path: '/api/selection', label: 'Selection', kind: 'get' }, { method: 'POST', path: '/api/selection', label: 'Set selection', kind: 'post' }, { method: 'GET', path: '/api/bus/status', label: 'Observer Bus facade status', kind: 'get' }, { method: 'GET', path: '/api/bus/signals', label: 'Observer Bus signals', kind: 'get' }, { method: 'GET', path: '/api/bus/facilities', label: 'Observer Bus facilities', kind: 'get' }, { method: 'GET', path: '/api/bus/receipts', label: 'Observer Bus receipts', kind: 'get' }, { method: 'POST', path: '/api/bus/receipts', label: 'Publish receipt', kind: 'post' }, { method: 'GET', path: '/api/agent-link/status', label: 'Agent Control status', kind: 'get' }, { method: 'POST', path: '/api/agent-link/session', label: 'Create Agent Control session', kind: 'post' }, { method: 'POST', path: '/api/agent-link/call', label: 'Call Agent Control tool', kind: 'post' }, { method: 'GET', path: '/api/market/listings', label: 'Clone exchange listings', kind: 'get' }, { method: 'POST', path: '/api/market/listings', label: 'Create clone exchange listing', kind: 'post' }, { method: 'GET', path: '/api/market/requests', label: 'Genetics requests', kind: 'get' }, { method: 'POST', path: '/api/market/requests', label: 'Create genetics request', kind: 'post' }, { method: 'GET', path: '/api/market/offers', label: 'Clone exchange offers', kind: 'get' }, { method: 'POST', path: '/api/market/offers', label: 'Create barter offer', kind: 'post' }, { method: 'GET', path: '/api/passport/{seedId}', label: 'Seed passport', kind: 'get' }, { method: 'GET', path: '/api/leaderboard', label: 'Leaderboard', kind: 'get' }, ], }; } api.get('/init', async (c) => { const id = await identity(); const state = await loadState(id); return c.json({ status: 'ok', postId: id.postId, username: id.username, state, deck: deckSummary(state.seeds), }); }); api.get('/status', async (c) => { const id = await identity(); const [state, receipts] = await Promise.all([loadState(id), loadReceipts(id)]); return c.json({ schema: 'weedsim.status/v1', postId: id.postId, username: id.username, seeds: state.seeds.length, receipts: receipts.length, routes: routeManifest().routes.length, provider: 'agent-control', }); }); api.get('/routes', (c) => c.json(routeManifest())); api.get('/inventory', async (c) => { const id = await identity(); const state = await loadState(id); return c.json({ seeds: state.seeds.map(toInventorySeed) }); }); api.post('/inventory/starter', async (c) => { const id = await identity(); const state = addMissingStarters(await loadState(id)); await saveState(id, state); await appendReceipt(id, 'starter_load', 'Starter registry loaded', { count: state.seeds.length }); return c.json({ ok: true, seeds: state.seeds.map(toInventorySeed) }); }); api.post('/inventory/clear', async (c) => { const id = await identity(); const state = await saveState(id, emptyGameState()); await clearOwnedResetResidue(id); await appendReceipt(id, 'inventory_clear', 'Inventory cleared', { count: 0, marketResidue: 'owned listings/offers/requests cleared', agentEvents: 'cleared' }); return c.json({ ok: true, seeds: state.seeds, cleared: { ownedMarketResidue: true, agentEvents: true } }); }); api.post('/seed/:seedId/grow', async (c) => { const id = await identity(); const seedId = c.req.param('seedId'); const state = await loadState(id); const seed = findSeed(state, seedId); if (!seed) return jsonError(c, 'Seed not found', 404); if (seed.growthStage === 'MATURE') { return c.json({ ok: true, status: 'mature', seed: toInventorySeed(seed) }); } if (seed.growthStage === 'SEEDLING') { return c.json({ ok: true, status: 'growing', seconds_left: growSecondsLeft(seed), seed: toInventorySeed(seed) }); } const seeds = state.seeds.map((item) => (item.seedId === seedId ? beginGrowing(item) : item)); const next = await saveState(id, replaceSeeds(state, seeds)); const grown = findSeed(next, seedId); await appendReceipt(id, 'grow', grown?.strainName ?? seedId, { seedId, stage: grown?.growthStage, secondsToMature: grown ? growSecondsLeft(grown) : 0, }); return c.json({ ok: true, status: 'started', seconds_left: grown ? growSecondsLeft(grown) : 0, seed: grown ? toInventorySeed(grown) : null }); }); api.post('/breed', async (c) => { const id = await identity(); const request = await c.req.json(); const state = await loadState(id); try { const result = breedInState(state, request.parent1_id, request.parent2_id); await saveState(id, result.state); const parentOne = findSeed(state, request.parent1_id); const parentTwo = findSeed(state, request.parent2_id); await appendReceipt(id, 'cross', result.offspring.strainName, { offspringId: result.offspring.seedId, parents: [parentOne?.strainName ?? request.parent1_id, parentTwo?.strainName ?? request.parent2_id], budColor: result.offspring.budColor, leafColor: result.offspring.leafColor, }); return c.json({ ok: true, offspring_id: result.offspring.seedId, offspring: toInventorySeed(result.offspring) }); } catch (error) { return jsonError(c, error instanceof Error ? error.message : 'Breed failed', 400); } }); api.post('/clone', async (c) => { const id = await identity(); const request = await c.req.json(); const state = await loadState(id); try { const result = cloneInState(state, request.seed_id); await saveState(id, result.state); const parent = findSeed(state, request.seed_id); await appendReceipt(id, 'clone', result.clone.strainName, { cloneId: result.clone.seedId, parent: parent?.strainName ?? request.seed_id, }); return c.json({ ok: true, clone_id: result.clone.seedId, clone: toInventorySeed(result.clone) }); } catch (error) { return jsonError(c, error instanceof Error ? error.message : 'Clone failed', 400); } }); api.get('/selection', async (c) => { const id = await identity(); const state = await loadState(id); return c.json({ ids: state.selection }); }); api.post('/selection', async (c) => { const id = await identity(); const request = await c.req.json(); const state = await loadState(id); const valid = new Set(state.seeds.map((seed) => seed.seedId)); const selection = (Array.isArray(request.ids) ? request.ids : []).filter((seedId) => valid.has(seedId)).slice(0, 8); await saveState(id, { ...state, selection, updatedAt: nowIso() }); return c.json({ ids: selection }); }); api.get('/lab/germplasm', async (c) => { const id = await identity(); const state = await loadState(id); return c.json(labPayload(state.seeds)); }); api.post('/lab/predict-cross', async (c) => { const id = await identity(); const request = await c.req.json(); const state = await loadState(id); const parentOne = findSeed(state, request.parent1_id); const parentTwo = findSeed(state, request.parent2_id); if (!parentOne || !parentTwo) return jsonError(c, 'Parents not found', 404); return c.json(predictCross(parentOne, parentTwo, request.n ?? 200, request.summary_only === true)); }); api.get('/lab/newick', async (c) => { const id = await identity(); const state = await loadState(id); return c.text(toNewick(state.seeds), 200, { 'Content-Type': 'text/plain; charset=utf-8' }); }); api.get('/deck/summary', async (c) => { const id = await identity(); const state = await loadState(id); return c.json(deckSummary(state.seeds)); }); api.get('/deck/signals', async (c) => { const id = await identity(); const receipts = await loadReceipts(id); const inbox = inboxFromReceipts(receipts); return c.json({ available: true, count: inbox.count, task_list: inbox.task_list, signals: inbox.signals }); }); api.get('/image/:seedId/:size', async (c) => { const id = await identity(); const seed = findSeed(await loadState(id), c.req.param('seedId')); if (!seed) return jsonError(c, 'Seed not found', 404); const stage = c.req.query('stage') || seed.growthStage; return c.redirect(spritePath(seed, stage as SeedProfile['growthStage']), 302); }); api.get('/image_mature/:seedId/:size', async (c) => { const id = await identity(); const seed = findSeed(await loadState(id), c.req.param('seedId')); if (!seed) return jsonError(c, 'Seed not found', 404); return c.redirect(spritePath(seed, 'MATURE'), 302); }); api.get('/bus/status', async (c) => { const id = await identity(); const receipts = await loadReceipts(id); return c.json({ surface: 'weedsim-devvit-observer-bus', localOnly: true, root: 'redis', postId: id.postId, username: id.username, receipt_count: receipts.length, facilities: WEED_SIM_FACILITIES.length, }); }); api.get('/bus/facilities', (c) => c.json({ facilities: WEED_SIM_FACILITIES })); api.get('/bus/receipts', async (c) => { const id = await identity(); return c.json({ receipts: await loadReceipts(id) }); }); api.post('/bus/receipts', async (c) => { const id = await identity(); const request = await c.req.json>(); const eventType = typeof request.eventType === 'string' ? request.eventType : 'manual'; const subject = typeof request.subject === 'string' ? request.subject : 'manual receipt'; const payload = typeof request.payload === 'object' && request.payload !== null ? request.payload : {}; const receipt = await appendReceipt(id, eventType, subject, payload as Record, 'system'); return c.json({ receipt }); }); api.get('/bus/signals', async (c) => { const id = await identity(); return c.json(inboxFromReceipts(await loadReceipts(id))); }); api.get('/bus/api/signals', async (c) => { const id = await identity(); return c.json(inboxFromReceipts(await loadReceipts(id))); }); api.get('/bus/api/facilities', (c) => c.json({ facilities: WEED_SIM_FACILITIES })); api.get('/bus/api/receipts', async (c) => { const id = await identity(); return c.json({ receipts: await loadReceipts(id) }); }); api.get('/agent/models', (c) => c.json({ provider: 'agent-control', count: 0, models: [], defaultModel: null, keyConfigured: false, keyHeader: null, links: {}, message: 'Embedded LLM provider calls are disabled. Use Agent Control with any player-authorized automation client through /api/agent-link/session and /api/agent-link/call.', })); api.post('/agent/chat', (c) => jsonError(c, 'Embedded LLM provider calls are disabled. Use /api/agent-link/session and /api/agent-link/call with a player-authorized agent client.', 410));