diff --git a/README.md b/README.md index e4bb44f9..4dc84d98 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,10 @@ planner that actually understands Py's tangled recipe graph. - **Browse the data.** A searchable catalogue of every item, fluid, and recipe in your exact mod set — used-in / produced-by, ingredients, products, machines. - **Track TURD choices.** Py's "There's Usually a Recipe Difference" tech upgrades - are first-class: pick a path and every block re-solves against it. + are first-class: each master shows every branch — its description, the recipes it + swaps or newly unlocks, and a hover diff of what actually changes (inputs, + outputs, and base throughput per second). Pick a path and every block re-solves + against it. - **Plan with AI.** An assistant (via OpenRouter) drafts whole production chains and multi-block plans using tools over the recipe data, honouring what you can build _now_ vs. _after research_. diff --git a/app/drizzle/0001_add_tech_description.sql b/app/drizzle/0001_add_tech_description.sql new file mode 100644 index 00000000..3642cb95 --- /dev/null +++ b/app/drizzle/0001_add_tech_description.sql @@ -0,0 +1 @@ +ALTER TABLE `technologies` ADD `description` text; \ No newline at end of file diff --git a/app/drizzle/meta/0001_snapshot.json b/app/drizzle/meta/0001_snapshot.json new file mode 100644 index 00000000..8ba4a4cc --- /dev/null +++ b/app/drizzle/meta/0001_snapshot.json @@ -0,0 +1,2284 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9b438434-4ee6-4433-b15c-08018de7fb2c", + "prevId": "f44fdf71-c921-4aec-8ca3-1206429b835a", + "tables": { + "beacons": { + "name": "beacons", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display": { + "name": "display", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "distribution_effectivity": { + "name": "distribution_effectivity", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "module_slots": { + "name": "module_slots", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "energy_usage_w": { + "name": "energy_usage_w", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden": { + "name": "hidden", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "allowed_effects": { + "name": "allowed_effects", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_module_categories": { + "name": "allowed_module_categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "profile": { + "name": "profile", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "belts": { + "name": "belts", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display": { + "name": "display", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "speed": { + "name": "speed", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "block_flows": { + "name": "block_flows", + "columns": { + "block_id": { + "name": "block_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item": { + "name": "item", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rate": { + "name": "rate", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bf_item_role_idx": { + "name": "bf_item_role_idx", + "columns": [ + "item", + "role" + ], + "isUnique": false + }, + "bf_block_idx": { + "name": "bf_block_idx", + "columns": [ + "block_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "block_groups": { + "name": "block_groups", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "block_machines": { + "name": "block_machines", + "columns": { + "block_id": { + "name": "block_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "machine": { + "name": "machine", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bm_machine_idx": { + "name": "bm_machine_idx", + "columns": [ + "machine" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "block_machines_block_id_machine_recipe_pk": { + "columns": [ + "block_id", + "machine", + "recipe" + ], + "name": "block_machines_block_id_machine_recipe_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "blocks": { + "name": "blocks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon_kind": { + "name": "icon_kind", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "icon_name": { + "name": "icon_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "electricity_w": { + "name": "electricity_w", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "solve_status": { + "name": "solve_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data_fingerprint": { + "name": "data_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "built_machines": { + "name": "built_machines", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "built_machines_name_recipe_pk": { + "columns": [ + "name", + "recipe" + ], + "name": "built_machines_name_recipe_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversation_messages": { + "name": "conversation_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parts": { + "name": "parts", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "cm_conv_idx": { + "name": "cm_conv_idx", + "columns": [ + "conversation_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "conversations": { + "name": "conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reasoning_effort": { + "name": "reasoning_effort", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_input_tokens": { + "name": "last_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_output_tokens": { + "name": "last_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_total_tokens": { + "name": "last_total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_model_id": { + "name": "last_model_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "cost_analysis": { + "name": "cost_analysis", + "columns": { + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cost": { + "name": "cost", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "cost_analysis_scope_name_pk": { + "columns": [ + "scope", + "name" + ], + "name": "cost_analysis_scope_name_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "crafting_machines": { + "name": "crafting_machines", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display": { + "name": "display", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "crafting_speed": { + "name": "crafting_speed", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "module_slots": { + "name": "module_slots", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "energy_usage_w": { + "name": "energy_usage_w", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "energy_source": { + "name": "energy_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_effects": { + "name": "allowed_effects", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_module_categories": { + "name": "allowed_module_categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "drill_resource_categories": { + "name": "drill_resource_categories", + "columns": { + "drill": { + "name": "drill", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resource_category": { + "name": "resource_category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "drill_resource_categories_drill_resource_category_pk": { + "columns": [ + "drill", + "resource_category" + ], + "name": "drill_resource_categories_drill_resource_category_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "fluids": { + "name": "fluids", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display": { + "name": "display", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_temperature": { + "name": "default_temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_temperature": { + "name": "max_temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fuel_value_j": { + "name": "fuel_value_j", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "heat_capacity_j": { + "name": "heat_capacity_j", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inserters": { + "name": "inserters", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display": { + "name": "display", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rotation_speed": { + "name": "rotation_speed", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "extension_speed": { + "name": "extension_speed", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pickup_x": { + "name": "pickup_x", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pickup_y": { + "name": "pickup_y", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "drop_x": { + "name": "drop_x", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "drop_y": { + "name": "drop_y", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "bulk": { + "name": "bulk", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "base_stack_bonus": { + "name": "base_stack_bonus", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "max_belt_stack_size": { + "name": "max_belt_stack_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "items": { + "name": "items", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display": { + "name": "display", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subgroup": { + "name": "subgroup", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stack_size": { + "name": "stack_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "weight": { + "name": "weight", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fuel_value_j": { + "name": "fuel_value_j", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fuel_category": { + "name": "fuel_category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spoil_result": { + "name": "spoil_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "spoil_ticks": { + "name": "spoil_ticks", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "burnt_result": { + "name": "burnt_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plant_result": { + "name": "plant_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "items_subgroup_idx": { + "name": "items_subgroup_idx", + "columns": [ + "subgroup" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "loaders": { + "name": "loaders", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display": { + "name": "display", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "speed": { + "name": "speed", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "machine_categories": { + "name": "machine_categories", + "columns": { + "machine": { + "name": "machine", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "mc_category_idx": { + "name": "mc_category_idx", + "columns": [ + "category" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "machine_categories_machine_category_pk": { + "columns": [ + "machine", + "category" + ], + "name": "machine_categories_machine_category_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "machine_fuel_categories": { + "name": "machine_fuel_categories", + "columns": { + "machine": { + "name": "machine", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fuel_category": { + "name": "fuel_category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "machine_fuel_categories_machine_fuel_category_pk": { + "columns": [ + "machine", + "fuel_category" + ], + "name": "machine_fuel_categories_machine_fuel_category_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "meta": { + "name": "meta", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "mining_drills": { + "name": "mining_drills", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "mining_speed": { + "name": "mining_speed", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "module_slots": { + "name": "module_slots", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "energy_usage_w": { + "name": "energy_usage_w", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "energy_source": { + "name": "energy_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "module_limitations": { + "name": "module_limitations", + "columns": { + "module": { + "name": "module", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "module_limitations_module_recipe_pk": { + "columns": [ + "module", + "recipe" + ], + "name": "module_limitations_module_recipe_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "module_presets": { + "name": "module_presets", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modules": { + "name": "modules", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "beacons": { + "name": "beacons", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "modules": { + "name": "modules", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display": { + "name": "display", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hidden": { + "name": "hidden", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "tier": { + "name": "tier", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "eff_speed": { + "name": "eff_speed", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "eff_productivity": { + "name": "eff_productivity", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "eff_consumption": { + "name": "eff_consumption", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "eff_pollution": { + "name": "eff_pollution", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notes": { + "name": "notes", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "production_stats": { + "name": "production_stats", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "produced": { + "name": "produced", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "consumed": { + "name": "consumed", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "recipe_categories": { + "name": "recipe_categories", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "recipe_ingredients": { + "name": "recipe_ingredients", + "columns": { + "recipe": { + "name": "recipe", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idx": { + "name": "idx", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "min_temp": { + "name": "min_temp", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_temp": { + "name": "max_temp", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "ing_recipe_idx": { + "name": "ing_recipe_idx", + "columns": [ + "recipe" + ], + "isUnique": false + }, + "ing_name_idx": { + "name": "ing_name_idx", + "columns": [ + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "recipe_ingredients_recipe_idx_pk": { + "columns": [ + "recipe", + "idx" + ], + "name": "recipe_ingredients_recipe_idx_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "recipe_products": { + "name": "recipe_products", + "columns": { + "recipe": { + "name": "recipe", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "idx": { + "name": "idx", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount_min": { + "name": "amount_min", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount_max": { + "name": "amount_max", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "probability": { + "name": "probability", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "temperature": { + "name": "temperature", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ignored_by_productivity": { + "name": "ignored_by_productivity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "prod_recipe_idx": { + "name": "prod_recipe_idx", + "columns": [ + "recipe" + ], + "isUnique": false + }, + "prod_name_idx": { + "name": "prod_name_idx", + "columns": [ + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "recipe_products_recipe_idx_pk": { + "columns": [ + "recipe", + "idx" + ], + "name": "recipe_products_recipe_idx_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "recipes": { + "name": "recipes", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display": { + "name": "display", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'real'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "energy_required": { + "name": "energy_required", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "hidden": { + "name": "hidden", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "allow_productivity": { + "name": "allow_productivity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "allowed_module_categories": { + "name": "allowed_module_categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "main_product": { + "name": "main_product", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subgroup": { + "name": "subgroup", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source_entity": { + "name": "source_entity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "recipes_category_idx": { + "name": "recipes_category_idx", + "columns": [ + "category" + ], + "isUnique": false + }, + "recipes_kind_idx": { + "name": "recipes_kind_idx", + "columns": [ + "kind" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_links": { + "name": "task_links", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "task_id": { + "name": "task_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_kind": { + "name": "ref_kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ref_name": { + "name": "ref_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "task_links_task_idx": { + "name": "task_links_task_idx", + "columns": [ + "task_id" + ], + "isUnique": false + }, + "task_links_ref_idx": { + "name": "task_links_ref_idx", + "columns": [ + "ref_kind", + "ref_name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "task_steps": { + "name": "task_steps", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "task_id": { + "name": "task_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "task_steps_task_idx": { + "name": "task_steps_task_idx", + "columns": [ + "task_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tasks": { + "name": "tasks", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "parent_id": { + "name": "parent_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'open'" + }, + "done": { + "name": "done", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "priority": { + "name": "priority", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority_reason": { + "name": "priority_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "priority_at": { + "name": "priority_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "task_parent_idx": { + "name": "task_parent_idx", + "columns": [ + "parent_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tech_ingredients": { + "name": "tech_ingredients", + "columns": { + "technology": { + "name": "technology", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "amount": { + "name": "amount", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "ti_tech_idx": { + "name": "ti_tech_idx", + "columns": [ + "technology" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tech_ingredients_technology_name_pk": { + "columns": [ + "technology", + "name" + ], + "name": "tech_ingredients_technology_name_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tech_prerequisites": { + "name": "tech_prerequisites", + "columns": { + "technology": { + "name": "technology", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prerequisite": { + "name": "prerequisite", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tp_prereq_idx": { + "name": "tp_prereq_idx", + "columns": [ + "prerequisite" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tech_prerequisites_technology_prerequisite_pk": { + "columns": [ + "technology", + "prerequisite" + ], + "name": "tech_prerequisites_technology_prerequisite_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tech_stack_bonuses": { + "name": "tech_stack_bonuses", + "columns": { + "technology": { + "name": "technology", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "effect": { + "name": "effect", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modifier": { + "name": "modifier", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tech_stack_bonuses_technology_effect_pk": { + "columns": [ + "technology", + "effect" + ], + "name": "tech_stack_bonuses_technology_effect_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tech_unlocks": { + "name": "tech_unlocks", + "columns": { + "technology": { + "name": "technology", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipe": { + "name": "recipe", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tu_recipe_idx": { + "name": "tu_recipe_idx", + "columns": [ + "recipe" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tech_unlocks_technology_recipe_pk": { + "columns": [ + "technology", + "recipe" + ], + "name": "tech_unlocks_technology_recipe_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "technologies": { + "name": "technologies", + "columns": { + "name": { + "name": "name", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "display": { + "name": "display", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit_count": { + "name": "unit_count", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_turd": { + "name": "is_turd", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "todos": { + "name": "todos", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "turd_replacements": { + "name": "turd_replacements", + "columns": { + "sub_tech": { + "name": "sub_tech", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "old_recipe": { + "name": "old_recipe", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "new_recipe": { + "name": "new_recipe", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tr_old_idx": { + "name": "tr_old_idx", + "columns": [ + "old_recipe" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "turd_replacements_sub_tech_old_recipe_pk": { + "columns": [ + "sub_tech", + "old_recipe" + ], + "name": "turd_replacements_sub_tech_old_recipe_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "turd_selections": { + "name": "turd_selections", + "columns": { + "master_tech": { + "name": "master_tech", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "sub_tech": { + "name": "sub_tech", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/app/drizzle/meta/_journal.json b/app/drizzle/meta/_journal.json index 042ce3fc..a8a06caa 100644 --- a/app/drizzle/meta/_journal.json +++ b/app/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1782787858823, "tag": "0000_salty_pixie", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1782914917684, + "tag": "0001_add_tech_description", + "breakpoints": true } ] } \ No newline at end of file diff --git a/app/src/db/import-factorio.ts b/app/src/db/import-factorio.ts index b632a1aa..24cdb282 100644 --- a/app/src/db/import-factorio.ts +++ b/app/src/db/import-factorio.ts @@ -210,7 +210,7 @@ export function importFactorioDump( `INSERT INTO beacons (name,display,distribution_effectivity,module_slots,energy_usage_w,hidden,allowed_effects,allowed_module_categories,profile) VALUES (@name,@display,@distribution_effectivity,@module_slots,@energy_usage_w,@hidden,@allowed_effects,@allowed_module_categories,@profile)`, ), tech: db.prepare( - `INSERT INTO technologies (name,display,"order",unit_count,enabled,is_turd) VALUES (@name,@display,@order,@unit_count,@enabled,@is_turd)`, + `INSERT INTO technologies (name,display,description,"order",unit_count,enabled,is_turd) VALUES (@name,@display,@description,@order,@unit_count,@enabled,@is_turd)`, ), techPrereq: db.prepare( `INSERT OR IGNORE INTO tech_prerequisites (technology,prerequisite) VALUES (?,?)`, @@ -456,6 +456,7 @@ export function importFactorioDump( ins.tech.run({ name, display: localeByKind.technology?.names?.[name] ?? null, + description: localeByKind.technology?.descriptions?.[name] ?? null, order: t.order ?? null, unit_count: t.unit?.count ?? null, enabled: t.enabled === false ? 0 : 1, diff --git a/app/src/db/queries.ts b/app/src/db/queries.ts index df93f1b2..d5152341 100644 --- a/app/src/db/queries.ts +++ b/app/src/db/queries.ts @@ -43,6 +43,7 @@ import { type BlockData, type BeaconConfig, } from "./schema.ts"; +import { stripRichText } from "../lib/factorio-text.ts"; import type { BeltProto, InserterProto, @@ -470,44 +471,177 @@ export function listTurdUpgrades() { .map((s) => [s.masterTech, s.subTech]), ); return masters - .map((m) => { - const subs = turdSubTechs(m.name).map((sub) => { - const tech = db.select().from(technologies).where(eq(technologies.name, sub)).get(); - const unlocks = db - .select({ recipe: techUnlocks.recipe }) - .from(techUnlocks) - .where(eq(techUnlocks.technology, sub)) - .all() - .map((u) => u.recipe); - const mods = turdModulesOf(sub); - return { - name: sub, - display: tech?.display ?? sub, - unlocks: unlocks.filter((r) => !mods.some((mod) => mod.name === r)), - modules: mods.map((mod) => ({ - name: mod.name, - effSpeed: mod.effSpeed, - effProductivity: mod.effProductivity, - effConsumption: mod.effConsumption, - })), - }; - }); - return { - name: m.name, - display: m.display ?? m.name, - science: db - .select({ name: techIngredients.name, amount: techIngredients.amount }) - .from(techIngredients) - .where(eq(techIngredients.technology, m.name)) - .all() - .map((s) => ({ ...s, display: compDisplay("item", s.name) ?? s.name })), - subTechs: subs, - selected: selections.get(m.name) ?? null, - }; - }) + .map((m) => ({ + name: m.name, + display: m.display ?? m.name, + description: stripRichText(m.description), + science: db + .select({ name: techIngredients.name, amount: techIngredients.amount }) + .from(techIngredients) + .where(eq(techIngredients.technology, m.name)) + .all() + .map((s) => ({ ...s, display: compDisplay("item", s.name) ?? s.name })), + subTechs: turdSubTechs(m.name).map(buildTurdSub), + selected: selections.get(m.name) ?? null, + })) .filter((m) => m.subTechs.length > 0); // respec helpers are turd-flagged but offer no choices } +/** One selectable TURD branch, fully described: the recipes it swaps (old→new) or + * newly unlocks, its always-on modules, and its localised description. Shared by + * the /turd board, the assistant's turdChoices tool, and recipeInfo. */ +export type TurdChange = { + from: string | null; // the base recipe this branch replaces, or null for a pure new unlock + fromDisplay: string | null; + to: string; // the recipe the branch grants + toDisplay: string; + // true when `to` crafts a building (a crafting machine). The choice's module is + // inserted INTO that building to boost what it produces — it does NOT apply to the + // building's own construction recipe, so throughput math must skip the bonus here. + buildsBuilding: boolean; +}; + +/** Does this recipe produce a building (a crafting machine)? Used to decide whether + * a TURD choice's module applies to the recipe's throughput. */ +function recipeBuildsBuilding(recipe: string): boolean { + const prods = db + .select({ name: recipeProducts.name }) + .from(recipeProducts) + .where(eq(recipeProducts.recipe, recipe)) + .all() + .map((p) => p.name); + if (!prods.length) return false; + return !!db + .select({ n: craftingMachines.name }) + .from(craftingMachines) + .where(inArray(craftingMachines.name, prods)) + .get(); +} + +function buildTurdSub(sub: string) { + const tech = db.select().from(technologies).where(eq(technologies.name, sub)).get(); + const mods = turdModulesOf(sub); + const modNames = new Set(mods.map((mod) => mod.name)); + const unlocks = db + .select({ recipe: techUnlocks.recipe }) + .from(techUnlocks) + .where(eq(techUnlocks.technology, sub)) + .all() + .map((u) => u.recipe) + .filter((r) => !modNames.has(r)); // hide the hidden -module recipes + // old→new swaps this branch performs, keyed by the new recipe it grants + const oldByNew = new Map( + db + .select() + .from(turdReplacements) + .where(eq(turdReplacements.subTech, sub)) + .all() + .map((r) => [r.newRecipe, r.oldRecipe] as const), + ); + const changes: TurdChange[] = unlocks.map((to) => { + const from = oldByNew.get(to) ?? null; + return { + from, + fromDisplay: from ? (getRecipe(from)?.display ?? from) : null, + to, + toDisplay: getRecipe(to)?.display ?? to, + buildsBuilding: recipeBuildsBuilding(to), + }; + }); + return { + name: sub, + display: tech?.display ?? sub, + description: stripRichText(tech?.description), + unlocks, + changes, + modules: mods.map((mod) => ({ + name: mod.name, + effSpeed: mod.effSpeed, + effProductivity: mod.effProductivity, + effConsumption: mod.effConsumption, + })), + }; +} + +/** Full detail for one TURD master: every mutually-exclusive branch it offers, + * each branch's description + changes + modules, and the current selection. Unlike + * turdOpportunities/turdConsistency (which key off recipe *replacements*), this + * walks the tech-prerequisite graph, so it also surfaces branches that unlock a + * BRAND-NEW recipe rather than swapping an existing one. Returns null for a + * non-TURD tech or a master with no selectable choices. */ +export function turdMasterDetail(masterName: string) { + const m = db.select().from(technologies).where(eq(technologies.name, masterName)).get(); + if (!m?.isTurd) return null; + const subs = turdSubTechs(masterName); + if (!subs.length) return null; + const selected = getTurdSelections().get(masterName) ?? null; + return { + master: masterName, + masterDisplay: m.display ?? masterName, + description: stripRichText(m.description), + selected, + choices: subs.map((sub) => { + const s = buildTurdSub(sub); + return { ...s, selected: s.name === selected }; + }), + }; +} + +/** Masters a recipe touches: it's a TURD-gated unlock (the recipe a branch grants) + * or a base recipe some branch replaces. */ +function turdMastersForRecipe(recipe: string): string[] { + const out = new Set(); + for (const u of recipeLockState(recipe)) if (u.isTurdSub && u.master) out.add(u.master); + for (const r of db + .select() + .from(turdReplacements) + .where(eq(turdReplacements.oldRecipe, recipe)) + .all()) { + const mo = turdMasterOf(r.subTech); + if (mo) out.add(mo.name); + } + return [...out]; +} + +/** Masters relevant to a good: resolve every recipe that produces or consumes it, + * then map those to their TURD masters. */ +function turdMastersForGood(good: string): string[] { + const recs = new Set([ + ...db + .select({ r: recipeProducts.recipe }) + .from(recipeProducts) + .where(eq(recipeProducts.name, good)) + .all() + .map((x) => x.r), + ...db + .select({ r: recipeIngredients.recipe }) + .from(recipeIngredients) + .where(eq(recipeIngredients.name, good)) + .all() + .map((x) => x.r), + ]); + const out = new Set(); + for (const r of recs) for (const mn of turdMastersForRecipe(r)) out.add(mn); + return [...out]; +} + +/** Resolve TURD masters from a master tech name (or its sub-tech), a recipe, or a + * good, and return full detail for each. Drives the assistant's turdChoices tool. */ +export function turdChoicesLookup(opts: { master?: string; recipe?: string; good?: string }) { + const masters = new Set(); + if (opts.master) { + const direct = db.select().from(technologies).where(eq(technologies.name, opts.master)).get(); + if (direct?.isTurd) masters.add(opts.master); + else { + const mo = turdMasterOf(opts.master); // maybe they passed a sub-tech name + if (mo) masters.add(mo.name); + } + } + if (opts.recipe) for (const mn of turdMastersForRecipe(opts.recipe)) masters.add(mn); + if (opts.good) for (const mn of turdMastersForGood(opts.good)) masters.add(mn); + return [...masters].map(turdMasterDetail).filter((d) => d !== null); +} + /** The hidden modules a sub-tech's choice inserts (named -module[-mk0N]). */ function turdModulesOf(subTech: string) { return db diff --git a/app/src/db/schema.ts b/app/src/db/schema.ts index be1d7c2f..167e4214 100644 --- a/app/src/db/schema.ts +++ b/app/src/db/schema.ts @@ -256,6 +256,7 @@ export const techStackBonuses = sqliteTable( export const technologies = sqliteTable("technologies", { name: text().primaryKey(), display: text(), + description: text(), // localised description; Factorio rich-text markup — strip before display order: text(), unitCount: real("unit_count"), // research cost multiplier (unit.count) enabled: bool("enabled").notNull().default(true), diff --git a/app/src/db/test-helpers.ts b/app/src/db/test-helpers.ts index b8a9e077..c5c4f1a2 100644 --- a/app/src/db/test-helpers.ts +++ b/app/src/db/test-helpers.ts @@ -1,34 +1,19 @@ /** * Test-only helpers: spin up a throwaway SQLite database with the REAL schema. * - * The schema DDL is derived straight from `schema.ts` via drizzle-kit's - * programmatic api, so fixtures never drift from the production schema — change a - * column and the next test run builds it. + * The db is provisioned by applying the bundled drizzle migrations — the exact + * same path production uses (`server/provision.ts`) — so a test db is byte-for-byte + * what a real install gets, incremental ALTER migrations and all. Keep migrations + * in sync with `schema.ts` via `vp run db:generate` and fixtures never drift. * Not imported by app code; only `*.test.ts` files use it. */ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import Database from "better-sqlite3"; -import { generateSQLiteDrizzleJson, generateSQLiteMigration } from "drizzle-kit/api"; +import { drizzle } from "drizzle-orm/better-sqlite3"; import * as schema from "./schema.ts"; - -type MigrationSnapshot = Parameters[0]; - -let cached: Promise | null = null; -/** CREATE-TABLE statements for the whole schema, generated once per run. */ -function schemaStatements(): Promise { - if (!cached) { - cached = (async () => { - const empty = (await generateSQLiteDrizzleJson({})) as unknown as MigrationSnapshot; - const full = (await generateSQLiteDrizzleJson( - schema as unknown as Record, - )) as unknown as MigrationSnapshot; - return generateSQLiteMigration(empty, full); - })(); - } - return cached; -} +import { migrateConnection } from "../server/provision.ts"; export type TestDb = { /** raw better-sqlite3 handle, schema applied */ @@ -39,13 +24,12 @@ export type TestDb = { cleanup: () => void; }; -/** Create a fresh temp-file database with every table from `schema.ts`. */ +/** Create a fresh temp-file database migrated to the current schema. */ export async function makeTestDb(): Promise { - const stmts = await schemaStatements(); const dir = mkdtempSync(join(tmpdir(), "pyops-test-")); const file = join(dir, "test.db"); const db = new Database(file); - for (const s of stmts) db.exec(s); + migrateConnection(drizzle(db, { schema })); return { db, file, diff --git a/app/src/lib/factorio-text.ts b/app/src/lib/factorio-text.ts new file mode 100644 index 00000000..1fa0e507 --- /dev/null +++ b/app/src/lib/factorio-text.ts @@ -0,0 +1,16 @@ +/** Strip Factorio rich-text markup from a localised string. + * + * Factorio descriptions embed inline tags — `[img=turd]`, `[item=copper-ore]`, + * `[color=1,1,1]…[/color]`, `[font=default-semibold]…[/font]`, `[/color]`, etc. + * These are meaningless as plain text (in the assistant, in a tooltip), so we drop + * the tags and keep their inner text. Newlines are preserved (they mark paragraph + * breaks in the source); horizontal whitespace is collapsed. */ +export function stripRichText(s: string | null | undefined): string { + if (!s) return ""; + return s + .replace(/\[\.?\/?[a-z][^\]]*\]/gi, "") // [img=x] [color=..] [/color] [font=..] [.recipe=..] … + .replace(/[ \t]+/g, " ") + .replace(/ *\n */g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} diff --git a/app/src/lib/recipe-card.tsx b/app/src/lib/recipe-card.tsx index 886fbcab..57ec26d2 100644 --- a/app/src/lib/recipe-card.tsx +++ b/app/src/lib/recipe-card.tsx @@ -402,6 +402,280 @@ function RecipeList({ ); } +/* ── Recipe comparison ───────────────────────────────────────────────────────── + * Side-by-side diff of two recipes — what a TURD swap or a Py recipe tier actually + * changes: which inputs/outputs are added, dropped, or re-sized, plus time. */ + +type RecipeComp = Parameters[0]; +// products (not ingredients) additionally carry this — productivity doesn't apply +// to ignored-by-productivity outputs like barrels/catalysts. +type DiffInput = RecipeComp & { ignoredByProductivity?: boolean | null }; +// Always-on module effects a TURD choice grants; applied to the chosen recipe's +// output rate (speed scales all outputs, productivity skips ignored ones). +type RateBonus = { speed: number; prod: number }; + +const compAmount = (c: RecipeComp) => + c.amount ?? (c.amountMin != null && c.amountMax != null ? (c.amountMin + c.amountMax) / 2 : 0); + +const fmtAmount = (n: number) => + Number.isInteger(n) ? String(n) : n.toFixed(2).replace(/\.?0+$/, ""); + +const fmtRate = (n: number) => `${n >= 10 ? n.toFixed(1) : n >= 1 ? n.toFixed(2) : n.toFixed(3)}/s`; + +type DiffCell = { + kind: string; + name: string; + display?: string | null; + amount: number; + rate: number | null; // per-second base output, 1 building at 1× speed; null for inputs +}; + +/** Map a recipe's inputs/outputs by good, tagging each with its per-second rate + * (only meaningful for outputs, where amount ÷ crafting time is the yield). `bonus` + * is the chosen recipe's always-on module effects: speed scales every output, + * productivity only the non-ignored ones (barrels/catalysts don't get duplicated). + * Pass null for the base side (or a recipe the module doesn't touch). */ +function ioByName( + comps: DiffInput[], + time: number, + withRate: boolean, + bonus: RateBonus | null, +): Map { + const m = new Map(); + for (const c of comps) { + let rate: number | null = null; + if (withRate && time > 0) { + const speedMult = bonus ? 1 + bonus.speed : 1; + const prodMult = bonus && !c.ignoredByProductivity ? 1 + bonus.prod : 1; + rate = ((compAmount(c) * (c.probability ?? 1)) / time) * speedMult * prodMult; + } + m.set(c.name, { kind: c.kind, name: c.name, display: c.display, amount: compAmount(c), rate }); + } + return m; +} + +// Conventional diff coloring — read at a glance regardless of in/out: added is +// green, removed is red, a resized amount is amber. (Whether a change is *good* is +// conveyed separately by the output-rate line, which greens/reds by direction.) +const DIFF_TONE = { + added: "text-emerald-400", + removed: "text-rose-400", + changed: "text-amber-400", + same: "text-muted-foreground", +}; + +/** One diffed input/output line: the good, its amount delta, and — for outputs — + * the base /s rate delta (colored by whether throughput went up or down). */ +function DiffRow({ before, after }: { before: DiffCell | null; after: DiffCell | null }) { + const row = before ?? after!; + const status = !before + ? "added" + : !after + ? "removed" + : before.amount !== after.amount + ? "changed" + : "same"; + const amountBody = + status === "added" ? ( + +{fmtAmount(after!.amount)} + ) : status === "removed" ? ( + −{fmtAmount(before!.amount)} + ) : status === "changed" ? ( + + {fmtAmount(before!.amount)} {" "} + {fmtAmount(after!.amount)} + + ) : ( + {fmtAmount(before!.amount)} + ); + const rb = before?.rate ?? null; + const ra = after?.rate ?? null; + const hasRate = rb != null || ra != null; + const rateUp = rb != null && ra != null ? (ra > rb ? 1 : ra < rb ? -1 : 0) : ra != null ? 1 : -1; + const rateTone = + rateUp > 0 ? "text-emerald-400" : rateUp < 0 ? "text-rose-400" : "text-muted-foreground"; + // % change in throughput when both sides have a rate (e.g. same yield but faster) + const pctStr = + rb != null && ra != null && rb > 0 && ra !== rb + ? ` (${ra > rb ? "+" : ""}${Math.round(((ra - rb) / rb) * 100)}%)` + : ""; + return ( +
+ + {row.display ?? row.name} + + {amountBody} + {hasRate && ( + + {rb != null && ra != null && rb !== ra + ? `${fmtRate(rb)} → ${fmtRate(ra)}${pctStr}` + : ra != null && rb == null + ? `+${fmtRate(ra)}` + : ra == null && rb != null + ? `−${fmtRate(rb)}` + : fmtRate(ra ?? rb!)} + + )} + +
+ ); +} + +function DiffGroup({ + label, + a, + b, + timeA, + timeB, + rate, + bonusB = null, +}: { + label: string; + a: DiffInput[]; + b: DiffInput[]; + timeA: number; + timeB: number; + rate: boolean; + bonusB?: RateBonus | null; +}) { + const am = ioByName(a, timeA, rate, null); + const bm = ioByName(b, timeB, rate, bonusB); + const names = [...new Set([...am.keys(), ...bm.keys()])]; + const rank = (n: string) => { + const x = am.get(n); + const y = bm.get(n); + if (!x || !y) return 0; // added/removed first + return x.amount !== y.amount ? 1 : 2; // then resized, then unchanged + }; + names.sort((p, q) => rank(p) - rank(q) || p.localeCompare(q)); + return ( +
+
{label}
+ {names.length === 0 ? ( +
+ ) : ( + names.map((n) => ) + )} +
+ ); +} + +/** Compare two recipes (a = base/old tier, b = upgraded/new tier). `bonus` is the + * chosen recipe's always-on module effects (a TURD choice's +prod/±speed), applied + * to side b's output rate — pass it only when the module actually affects this + * recipe (i.e. NOT a recipe that just builds a building; see turd.tsx). */ +export function RecipeDiffCard({ a, b, bonus }: { a: string; b: string; bonus?: RateBonus }) { + const qa = useQuery({ + queryKey: ["recipe", a], + queryFn: () => recipeDetailFn({ data: a }), + staleTime: 60_000, + }); + const qb = useQuery({ + queryKey: ["recipe", b], + queryFn: () => recipeDetailFn({ data: b }), + staleTime: 60_000, + }); + const ra = qa.data?.recipe; + const rb = qb.data?.recipe; + return ( +
+
+ + {ra?.display ?? a} + + + {rb?.display ?? b} +
+ {!ra || !rb ? ( +
+ ) : ( + (() => { + const ta = ra.energyRequired ?? 0; + const tb = rb.energyRequired ?? 0; + const bonusParts = [ + bonus?.prod ? `${bonus.prod > 0 ? "+" : ""}${Math.round(bonus.prod * 100)}% prod` : "", + bonus?.speed + ? `${bonus.speed > 0 ? "+" : ""}${Math.round(bonus.speed * 100)}% speed` + : "", + ].filter(Boolean); + return ( + <> + {ta !== tb && ( +
+ + {ta}s + + {tb}s + + ({tb < ta ? "faster" : "slower"}) + +
+ )} +
+ + +
+ {bonusParts.length > 0 && ( +
+ out /s includes this choice's module ({bonusParts.join(", ")}); productivity + skips barrels & catalysts +
+ )} + + ); + })() + )} +
+ ); +} + +/** Wraps a trigger; shows a floating RecipeDiffCard near the cursor on hover. */ +export function RecipeDiffHover({ + a, + b, + bonus, + className, + children, +}: { + a: string; + b: string; + bonus?: RateBonus; + className?: string; + children: React.ReactNode; +}) { + const [pos, setPos] = useState<{ x: number; y: number } | null>(null); + return ( +
setPos({ x: e.clientX, y: e.clientY })} + onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })} + onMouseLeave={() => setPos(null)} + > + {children} + {pos && typeof document !== "undefined" && ( + + + + )} +
+ ); +} + /** Hover wrapper showing an ItemCard near the cursor (portaled, like RecipeHover). */ export function ItemHover({ name, diff --git a/app/src/routes/assistant.tsx b/app/src/routes/assistant.tsx index 64d83abc..8a45ddcb 100644 --- a/app/src/routes/assistant.tsx +++ b/app/src/routes/assistant.tsx @@ -1,6 +1,6 @@ import { useChat } from "@ai-sdk/react"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { createFileRoute, useNavigate } from "@tanstack/react-router"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { AlertTriangle, ArrowUp, @@ -27,7 +27,13 @@ import remarkGfm from "remark-gfm"; import { Icon, IconProvider } from "#/lib/icons"; import { SidebarShell } from "#/components/sidebar-shell.tsx"; import { ItemHover, RecipeHover } from "#/lib/recipe-card"; -import { classifyRefFn, saveBlockFn, setAiConfigFn, setBlockRateFn } from "#/server/factorio"; +import { + aiConfigFn, + classifyRefFn, + saveBlockFn, + setAiConfigFn, + setBlockRateFn, +} from "#/server/factorio"; import { activeAssistantRunsFn, conversationModelFn, @@ -243,8 +249,11 @@ function ChatView({ chat, active }: { chat: ChatInstance; active: boolean }) { // Bind to the store-owned Chat. Persistence + titling live in the store (wired to // the Chat's onFinish), so a run that finishes while this view is unmounted still // saves. We only render + send here. - const { messages, status } = useChat({ chat, resume: true }); + const { messages, status, error } = useChat({ chat, resume: true }); const qc = useQueryClient(); + // Surface a missing OpenRouter key up front — otherwise a send just fails silently. + const aiConfig = useQuery({ queryKey: ["aiConfig"], queryFn: () => aiConfigFn() }); + const noKey = aiConfig.data ? !aiConfig.data.keyStored && !aiConfig.data.keyFromEnv : false; const [input, setInput] = useState(""); const [editingId, setEditingId] = useState(null); const busy = status === "submitted" || status === "streaming"; @@ -407,6 +416,26 @@ function ChatView({ chat, active }: { chat: ChatInstance; active: boolean }) { className="flex shrink-0 items-end gap-2 border-t border-border bg-card px-6 py-3" >
+ {noKey && ( +
+ + + No OpenRouter API key set — the assistant can't respond. Add one in{" "} + + Settings → Assistant + + , or set the OPENROUTER_API_KEY env var. + +
+ )} + {error && ( +
+ + + {error.message?.trim() || "The assistant request failed. See the server log."} + +
+ )} {editingId && (
Editing an earlier message; sending will replace it and retry from there. diff --git a/app/src/routes/turd.tsx b/app/src/routes/turd.tsx index 4923c7f4..ed76fd87 100644 --- a/app/src/routes/turd.tsx +++ b/app/src/routes/turd.tsx @@ -1,8 +1,9 @@ import { createFileRoute } from "@tanstack/react-router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Check } from "lucide-react"; +import { ArrowRight, Check, Plus } from "lucide-react"; import { listTurdUpgradesFn, setTurdSelectionFn, turdSyncStatusFn } from "../server/factorio"; import { Icon, IconProvider } from "../lib/icons"; +import { RecipeDiffHover, RecipeHover } from "../lib/recipe-card"; import { Badge } from "#/components/ui/badge.tsx"; import { Card } from "#/components/ui/card.tsx"; import { HelpButton } from "#/components/help-drawer.tsx"; @@ -26,22 +27,110 @@ function timeAgo(iso: string): string { return `${Math.round(sec / 3600)}h ago`; } -function subEffectSummary(s: { +type TurdChange = { + from: string | null; + fromDisplay: string | null; + to: string; + toDisplay: string; + buildsBuilding: boolean; +}; +/** A choice's always-on module effects, applied to swap-recipe throughput. */ +type RateBonus = { speed: number; prod: number }; +type SubTech = { + name: string; + display: string; + description: string; + changes: TurdChange[]; modules: { effSpeed: number; effProductivity: number; effConsumption: number }[]; - unlocks: string[]; -}): string[] { - const out: string[] = []; - // per-tier modules share the intent; summarize from the lowest tier +}; + +/** The always-on module effects a choice grants (summarized from the lowest tier). + * Recipe changes are shown explicitly below, not folded into a bare count. Each + * effect is color-coded by kind so speed/productivity/energy stand out. */ +type Effect = { label: string; className: string }; +function subEffectSummary(s: Pick): Effect[] { + const out: Effect[] = []; const m = s.modules[0]; + const suffix = s.modules.length > 1 ? " (mk01)" : ""; if (m) { - if (m.effSpeed) out.push(`${pct(m.effSpeed)} speed${s.modules.length > 1 ? " (mk01)" : ""}`); - if (m.effProductivity) out.push(`${pct(m.effProductivity)} productivity`); - if (m.effConsumption) out.push(`${pct(m.effConsumption)} energy`); + if (m.effSpeed) + out.push({ + label: `${pct(m.effSpeed)} speed${suffix}`, + className: "border-sky-500/40 bg-sky-500/15 text-sky-300", + }); + if (m.effProductivity) + out.push({ + label: `${pct(m.effProductivity)} productivity${suffix}`, + className: "border-emerald-500/40 bg-emerald-500/15 text-emerald-300", + }); + if (m.effConsumption) + out.push({ + label: `${pct(m.effConsumption)} energy${suffix}`, + className: "border-amber-500/40 bg-amber-500/15 text-amber-300", + }); } - if (s.unlocks.length) out.push(`${s.unlocks.length} recipe${s.unlocks.length > 1 ? "s" : ""}`); return out; } +/** One recipe change a choice makes — a swap (old→new, hover for the full diff) or + * a brand-new unlock (hover for the recipe). This is what "5 recipes" now expands + * to: exactly which recipes, and what actually changes. */ +function ChangeRow({ change: c, moduleBonus }: { change: TurdChange; moduleBonus?: RateBonus }) { + if (c.from) { + // the module boosts recipes the affected building RUNS — not a recipe that just + // builds a building, so skip the bonus there (its rate stays pure recipe math) + const bonus = c.buildsBuilding ? undefined : moduleBonus; + return ( + + + {c.fromDisplay} + + + {c.toDisplay} + + ); + } + return ( + + + + {c.toDisplay} + new + + ); +} + +/** The expandable body under a choice: its flavor description and the concrete + * recipe changes it makes. */ +function ChoiceDetails({ s }: { s: SubTech }) { + if (!s.description && s.changes.length === 0) return null; + // the choice's always-on module (lowest tier) boosts the recipes it runs in the + // affected buildings; ChangeRow applies it to each swap that isn't a building recipe + const m = s.modules[0]; + const moduleBonus: RateBonus | undefined = m + ? { speed: m.effSpeed, prod: m.effProductivity } + : undefined; + return ( +
+ {s.description && ( +

{s.description}

+ )} + {s.changes.length > 0 && ( +
+ {s.changes.map((c) => ( + + ))} +
+ )} +
+ ); +} + /** Pyanodon TURD board: one selectable sub-tech per master upgrade. Click a * choice to select it (re-solves every cached block); click again to clear. */ function TurdPage() { @@ -145,7 +234,7 @@ function TurdPage() {
{list.map((u) => ( -
+
{u.display} @@ -154,38 +243,53 @@ function TurdPage() { ))}
+ {u.description && ( +

{u.description}

+ )}
{u.subTechs.map((s) => { const sel = u.selected === s.name; return ( - + {sel && } + + +
); })}
diff --git a/app/src/server/agent-tools.ts b/app/src/server/agent-tools.ts index c12b6b42..55f87543 100644 --- a/app/src/server/agent-tools.ts +++ b/app/src/server/agent-tools.ts @@ -281,7 +281,7 @@ export const recipeOptionsBatch = tool({ export const recipeInfo = tool({ description: - "Full detail for one recipe: exact ingredients/products with amounts, energy/time, category, cost, and unlock state (the techs that unlock it, their science-pack cost, and any TURD master›choice it belongs to). Use after recipeOptions to inspect a specific candidate.", + "Full detail for one recipe: exact ingredients/products with amounts, energy/time, category, cost, and unlock state (the techs that unlock it, their science-pack cost, and any TURD master›choice it belongs to). `turd` lists the FULL branch set of every TURD master that affects this recipe — whether the recipe is a branch's new unlock OR a base recipe some branch replaces — so you see all sibling choices, not just this one. Use after recipeOptions to inspect a specific candidate.", inputSchema: z.object({ recipe: z.string().describe("Internal recipe name, e.g. 'molten-iron-01'"), }), @@ -328,6 +328,8 @@ export const recipeInfo = tool({ probability: c.probability, })), unlocks, + // Full branch set of every TURD master touching this recipe (empty if none). + turd: q.turdChoicesLookup({ recipe }), }; }, }); @@ -506,6 +508,27 @@ export const availableTurds = tool({ }, }); +export const turdChoices = tool({ + description: + "Every choice a Pyanodons TURD upgrade offers — the whole mutually-exclusive branch set for a master, each branch's description, the recipes it swaps (old→new) or newly UNLOCKS, its always-on modules, and which branch (if any) is selected. Unlike availableTurds/turdConsistency (which only see branches that REPLACE a recipe), this also surfaces branches that grant a brand-new recipe. Use it whenever the user asks what a TURD gives, which choice is best, or to compare branches. Look up by master tech name, or pass a recipe or good to find the master(s) affecting it. Read each branch's description — it often carries flavor/consequences that matter (e.g. a choice that makes a creature explode).", + inputSchema: z.object({ + master: z + .string() + .optional() + .describe("TURD master tech name (e.g. 'moondrop-upgrade') or one of its sub-tech names"), + recipe: z.string().optional().describe("A recipe to find the TURD master(s) that affect it"), + good: z + .string() + .optional() + .describe("A good — finds TURD masters on recipes that produce or consume it"), + }), + execute: async ({ master, recipe, good }) => { + if (!master && !recipe && !good) return { error: "pass one of master, recipe, or good" }; + const q = await lib(); + return { masters: q.turdChoicesLookup({ master, recipe, good }) }; + }, +}); + const blockDraftInput = z.object({ name: z.string().optional().describe("Optional display name for this block"), target: z.string().describe("Internal name of the good this block produces"), @@ -1122,6 +1145,7 @@ export const agentTools = { byproductSinks, turdConsistency, availableTurds, + turdChoices, chainStatus, submitBlock, reviseBlock, diff --git a/app/src/server/agent.ts b/app/src/server/agent.ts index 5f868f06..4b3de36f 100644 --- a/app/src/server/agent.ts +++ b/app/src/server/agent.ts @@ -56,6 +56,7 @@ You have tools over the planner's reference data. Use them — NEVER invent reci - byproductSinks: where a byproduct can GO — recipes that consume it + existing blocks that import it. Use to route the block's waste. - turdConsistency: check TURD-choice consistency (one choice per master). Pass a recipe set or omit for the whole factory. - availableTurds: given a NOW plan's recipes, the researched-but-unpicked TURD upgrades that would replace one of them — what each option swaps + its modules. Call at the END of a NOW plan to surface "TURD opportunities" as advice (never applied). +- turdChoices: the FULL choice set of a TURD master — every mutually-exclusive branch, each branch's description, the recipes it swaps (old→new) or newly UNLOCKS, and its modules. Look up by master, recipe, or good. Use this — NOT availableTurds/turdConsistency — whenever the user asks what a TURD gives or which choice is best: a master can have branches that unlock a brand-new recipe (not just swaps), and those are INVISIBLE to the replacement-based tools. Read each branch's description; it often carries consequences that matter. Never claim a master has only one choice without checking here first. - chainStatus: closure check — given your chosen recipes, what inputs are still open and what byproducts appear. - submitBlock: finalize a proposed block (call once, at the very end). - reviseBlock: propose RAISING/LOWERING an EXISTING block's output rate (by its factoryBlocks id) so it meets new demand — instead of building a duplicate. Re-solves at the new rate; the user approves before it applies. Use when a good you need is already produced by a block but at too low a rate. diff --git a/docs/ai-assistant.md b/docs/ai-assistant.md index 765f9cc6..de5b0eab 100644 --- a/docs/ai-assistant.md +++ b/docs/ai-assistant.md @@ -115,6 +115,14 @@ about "how do I make X": - **Fuzzy name resolution** — map a loose item/recipe name to a stable internal handle. - **Recipe-candidate ranking** — find and rank the recipes that produce a good. +- **TURD choices** (`turdChoices`) — the full mutually-exclusive branch set of a + TURD master (looked up by master, recipe, or good): each branch's description, + the recipes it swaps (old→new) or newly **unlocks**, and its always-on modules. + It walks the tech-prerequisite graph, so unlike `availableTurds`/`turdConsistency` + (which key off recipe *replacements*) it also sees branches that grant a brand-new + recipe. `recipeInfo.turd` returns the same full detail for every master touching a + recipe. This is what the agent consults for "what does this TURD give / which + branch is best" — never assume a master has a single choice. - **Additive/commodity classifier** (`app/src/server/additives.ts`) — decides whether an input should be _imported_ (a cross-cutting commodity like an acid, gas, or solvent — stop recursing) or _built_ (part of the target's own lineage —