weed-sim / src /server /routes /api.ts
Yufok1
Real-time growth: growTime days become wall-clock seconds
6212a21
Raw
History Blame Contribute Delete
19 kB
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));