| 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<T>(key: string, fallback: T): Promise<T> { |
| 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<T>(key: string, value: T): Promise<void> { |
| await redis.set(key, JSON.stringify(value)); |
| } |
|
|
| async function clearOwnedResetResidue(id: RuntimeIdentity): Promise<void> { |
| const ownsRecord = (value: unknown) => { |
| if (typeof value !== 'object' || value === null) return false; |
| const record = value as Record<string, unknown>; |
| const asset = typeof record.asset === 'object' && record.asset !== null ? record.asset as Record<string, unknown> : {}; |
| 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<Array<Record<string, unknown>>>(leaderboardKey(id.postId), []), |
| loadJson<Array<Record<string, unknown>>>(marketListingsKey(id.postId), []), |
| loadJson<Array<Record<string, unknown>>>(marketOffersKey(id.postId), []), |
| loadJson<Array<Record<string, unknown>>>(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<GameState> { |
| 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<GameState> { |
| const normalized = matureReadySeeds(normalizeGameState(state)); |
| await redis.set(stateKey(id), JSON.stringify(normalized)); |
| return normalized; |
| } |
|
|
| async function loadReceipts(id: RuntimeIdentity): Promise<BusReceipt[]> { |
| 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<string, unknown>, |
| source: BusReceipt['source'] = 'weed-sim' |
| ): Promise<BusReceipt> { |
| 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<InitResponse>({ |
| 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<BreedRequest>(); |
| 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<CloneRequest>(); |
| 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<SelectionRequest>(); |
| 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<CrossRequest>(); |
| 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<Record<string, unknown>>(); |
| 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<string, unknown>, '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)); |
|
|
|
|
|
|
|
|