diff --git a/.example.env b/.example.env index 32cce77c3..9e4e80e4f 100644 --- a/.example.env +++ b/.example.env @@ -9,6 +9,9 @@ TOGETHER_API_KEY=XXX DEEPSEEK_API_KEY=XXX OPEN_ROUTER_API_KEY=XXX +# Used for downloading image files for rendering +HF_TOKEN=XXX + # Database Configuration # Options: "sqlite" (default) or "postgres" FLE_DB_TYPE="sqlite" diff --git a/.gitignore b/.gitignore index d21dae37e..2f1ac1910 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,25 @@ trajectory_logs/ *.mp4 *.jsonl *.db +/log/ -#fle -.fle/ \ No newline at end of file +.fle/ + +# Jack's stuff. +/docker-compose.yml +/data/plans/factorio_guides/ +/docs/assets/videos/ +/fle/agents/data/blueprints_to_policies/blueprints/ +/fle/eval/open/plots/technology_icons/ +/data/icons/ +/eval/open/plots/ + +/data/vqa/logs/ +/data/vqa/tasks/spatial_reasoning/.compose.yaml +/data/vqa/tasks/spatial_reasoning/logs/ +/data/vqa/tasks/logs/ +/data/vqa/images/ +/data/vqa/dataset/images/ +/data/vqa/tasks/basic/logs/ +/data/vqa/tasks/contrastive_alignment/logs/ +/data/vqa/tasks/contrastive_alignment/logs/ diff --git a/data/vqa/CLAUDE.md b/data/vqa/CLAUDE.md new file mode 100644 index 000000000..476b5103b --- /dev/null +++ b/data/vqa/CLAUDE.md @@ -0,0 +1,113 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +This is the Visual Question Answering (VQA) module for the Factorio Learning Environment (FLE). The goal is to train visual encoders for Factorio using text representations as ground truth. + +## Key Commands + +### Running Tasks +```bash +# Run all VQA tasks +python tasks.py + +# Run specific task type +python tasks/basic/task.py +python tasks/spatial_reasoning/task.py +python tasks/denoising/task.py +# etc. +``` + +### Installation +```bash +pip install -r requirements.txt +``` + +## Architecture + +### Core Components + +1. **Blueprint Loading**: Blueprints are loaded from `.fle/blueprints/` via `utils.find_blueprints_dir()` +2. **Rendering**: Use `instance.namespace._render(blueprint=blueprint)` to render blueprints to images +3. **Dataset Generation**: `dataset.py` creates `MemoryDataset` from blueprints +4. **Task System**: Each task type has: + - `task.py`: Task definitions using `@task` decorator + - `solver.py`: Question generation logic using `@solver` decorator + - `templates/`: Jinja2 templates for prompts +5. **Common Solvers**: `common_solvers.py` contains: + - `validate_qa_answerability()`: Validates if questions are answerable and unambiguous + - `convert_directions_to_compass()`: Converts numeric directions to compass directions +6. **Direction Utilities**: `direction_utils.py` provides Direction enum and conversion utilities + +### Task Types + +1. **Basic** (`tasks/basic/`) + - Entity name prediction from position + - Position prediction from entity name + - Entity counting + +2. **Spatial Reasoning** (`tasks/spatial_reasoning/`) + - Relative entity positions + - Distance calculations + - Spatial context questions + +3. **State Prediction** - **NOT IMPLEMENTED** + - Should predict entity states from live factories + - Needs access to live game state, not just blueprints + +4. **Denoising** (`tasks/denoising/`) + - Remove/modify/replace entities + - Predict original entity + +5. **Action Prediction** (`tasks/action_prediction/`) + - Predict next construction action + - Construction order questions + +6. **Productivity Planning** (`tasks/productivity_planning/`) + - Throughput predictions + - Bottleneck analysis + - Optimization suggestions + +7. **Contrastive Alignment** (`tasks/contrastive_alignment/`) + - Blueprint title/purpose matching + - Multiple choice format + +### Data Flow + +1. Blueprint JSON → `raw_blueprint_dataset()` → Task +2. Task uses solver to generate questions +3. Solver renders blueprint: `instance.namespace._render(blueprint=blueprint)` +4. Image saved to `dataset/images/{blueprint_name}/{variant_hash}.jpg` +5. **Validation Pipeline**: + - `convert_directions_to_compass()`: Converts numeric directions (0,2,4,6) to compass (north/east/south/west) + - `validate_qa_answerability()`: Validates questions are answerable and unambiguous, regenerates if needed +6. QA pairs collected by `VQAPairsHook` +7. Results saved as JSONL in `dataset/` + +### Key Integration Points + +- **FLE Instance**: Create with `create_factorio_instance()` from `fle.agents.data.screenshots_from_run` +- **Rendering**: Returns `RenderedImage` object, save with `.save(path)` +- **Image IDs**: Use folder structure `/dataset/images/{blueprint_name}/{variant_hash}` with clean blueprint names and variant-specific hashes +- **Hooks**: `VQAPairsHook` automatically serializes QA pairs after evaluation + +### Adding New Task Types + +1. Create directory: `tasks/new_task_type/` +2. Create `task.py` with `@task` decorated functions +3. Create `solver.py` with `@solver` decorated question generators +4. Add templates in `templates/` if needed +5. Update `tasks/__init__.py` to export new tasks +6. Add normalization method in `hook.py` for new QA format + +### Important Notes + +- **State Prediction tasks** need live game state, not implemented yet +- **Direction Handling**: All tasks now convert numeric directions to compass directions automatically +- **Question Validation**: All generated questions are validated for answerability and clarity +- **Images** are saved with organized folder structure `{blueprint_name}/{variant_hash}.jpg` for better organization +- **QA Pairs** are normalized to consistent format in `hook.py` +- **Framework**: Use `inspect_ai` framework for task/solver definitions +- **Validation Steps**: Always include `convert_directions_to_compass()` and `validate_qa_answerability()` in solver pipelines \ No newline at end of file diff --git a/data/vqa/blueprint.example.json b/data/vqa/blueprint.example.json new file mode 100644 index 000000000..45939d6e6 --- /dev/null +++ b/data/vqa/blueprint.example.json @@ -0,0 +1,3091 @@ +{ + "icons": [ + { + "signal": { + "type": "item", + "name": "transport-belt" + }, + "index": 1 + }, + { + "signal": { + "type": "item", + "name": "stone-furnace" + }, + "index": 2 + }, + { + "signal": { + "type": "item", + "name": "steel-plate" + }, + "index": 3 + } + ], + "entities": [ + { + "entity_number": 1, + "name": "transport-belt", + "position": { + "x": -27, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 2, + "name": "transport-belt", + "position": { + "x": -25, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 3, + "name": "transport-belt", + "position": { + "x": -26, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 4, + "name": "transport-belt", + "position": { + "x": -23, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 5, + "name": "transport-belt", + "position": { + "x": -24, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 6, + "name": "transport-belt", + "position": { + "x": -21, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 7, + "name": "transport-belt", + "position": { + "x": -22, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 8, + "name": "transport-belt", + "position": { + "x": -19, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 9, + "name": "transport-belt", + "position": { + "x": -20, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 10, + "name": "transport-belt", + "position": { + "x": -17, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 11, + "name": "transport-belt", + "position": { + "x": -18, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 12, + "name": "transport-belt", + "position": { + "x": -15, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 13, + "name": "transport-belt", + "position": { + "x": -16, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 14, + "name": "transport-belt", + "position": { + "x": -13, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 15, + "name": "transport-belt", + "position": { + "x": -14, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 16, + "name": "transport-belt", + "position": { + "x": -11, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 17, + "name": "transport-belt", + "position": { + "x": -12, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 18, + "name": "transport-belt", + "position": { + "x": -9, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 19, + "name": "transport-belt", + "position": { + "x": -10, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 20, + "name": "transport-belt", + "position": { + "x": -7, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 21, + "name": "transport-belt", + "position": { + "x": -8, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 22, + "name": "transport-belt", + "position": { + "x": -5, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 23, + "name": "transport-belt", + "position": { + "x": -6, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 24, + "name": "transport-belt", + "position": { + "x": -3, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 25, + "name": "transport-belt", + "position": { + "x": -4, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 26, + "name": "transport-belt", + "position": { + "x": -1, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 27, + "name": "transport-belt", + "position": { + "x": -2, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 28, + "name": "underground-belt", + "position": { + "x": 0, + "y": -5 + }, + "direction": 4, + "type": "input" + }, + { + "entity_number": 29, + "name": "transport-belt", + "position": { + "x": 1, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 30, + "name": "transport-belt", + "position": { + "x": 3, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 31, + "name": "transport-belt", + "position": { + "x": 2, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 32, + "name": "transport-belt", + "position": { + "x": 5, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 33, + "name": "transport-belt", + "position": { + "x": 4, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 34, + "name": "transport-belt", + "position": { + "x": 7, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 35, + "name": "transport-belt", + "position": { + "x": 6, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 36, + "name": "transport-belt", + "position": { + "x": 9, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 37, + "name": "transport-belt", + "position": { + "x": 8, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 38, + "name": "transport-belt", + "position": { + "x": 11, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 39, + "name": "transport-belt", + "position": { + "x": 10, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 40, + "name": "transport-belt", + "position": { + "x": 13, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 41, + "name": "transport-belt", + "position": { + "x": 12, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 42, + "name": "transport-belt", + "position": { + "x": 15, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 43, + "name": "transport-belt", + "position": { + "x": 14, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 44, + "name": "transport-belt", + "position": { + "x": 17, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 45, + "name": "transport-belt", + "position": { + "x": 16, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 46, + "name": "transport-belt", + "position": { + "x": 19, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 47, + "name": "transport-belt", + "position": { + "x": 18, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 48, + "name": "transport-belt", + "position": { + "x": 21, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 49, + "name": "transport-belt", + "position": { + "x": 20, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 50, + "name": "transport-belt", + "position": { + "x": 23, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 51, + "name": "transport-belt", + "position": { + "x": 22, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 52, + "name": "transport-belt", + "position": { + "x": 24, + "y": -5 + }, + "direction": 2 + }, + { + "entity_number": 53, + "name": "transport-belt", + "position": { + "x": -27, + "y": -4 + } + }, + { + "entity_number": 54, + "name": "transport-belt", + "position": { + "x": -27, + "y": -3 + } + }, + { + "entity_number": 55, + "name": "inserter", + "position": { + "x": -23, + "y": -4 + } + }, + { + "entity_number": 56, + "name": "stone-furnace", + "position": { + "x": -23.5, + "y": -2.5 + } + }, + { + "entity_number": 57, + "name": "inserter", + "position": { + "x": -22, + "y": -4 + } + }, + { + "entity_number": 58, + "name": "stone-furnace", + "position": { + "x": -21.5, + "y": -2.5 + } + }, + { + "entity_number": 59, + "name": "small-electric-pole", + "position": { + "x": -21, + "y": -4 + } + }, + { + "entity_number": 60, + "name": "inserter", + "position": { + "x": -19, + "y": -4 + } + }, + { + "entity_number": 61, + "name": "stone-furnace", + "position": { + "x": -19.5, + "y": -2.5 + } + }, + { + "entity_number": 62, + "name": "inserter", + "position": { + "x": -18, + "y": -4 + } + }, + { + "entity_number": 63, + "name": "stone-furnace", + "position": { + "x": -17.5, + "y": -2.5 + } + }, + { + "entity_number": 64, + "name": "inserter", + "position": { + "x": -15, + "y": -4 + } + }, + { + "entity_number": 65, + "name": "stone-furnace", + "position": { + "x": -15.5, + "y": -2.5 + } + }, + { + "entity_number": 66, + "name": "small-electric-pole", + "position": { + "x": -16, + "y": -4 + } + }, + { + "entity_number": 67, + "name": "inserter", + "position": { + "x": -14, + "y": -4 + } + }, + { + "entity_number": 68, + "name": "stone-furnace", + "position": { + "x": -13.5, + "y": -2.5 + } + }, + { + "entity_number": 69, + "name": "inserter", + "position": { + "x": -11, + "y": -4 + } + }, + { + "entity_number": 70, + "name": "stone-furnace", + "position": { + "x": -11.5, + "y": -2.5 + } + }, + { + "entity_number": 71, + "name": "inserter", + "position": { + "x": -10, + "y": -4 + } + }, + { + "entity_number": 72, + "name": "stone-furnace", + "position": { + "x": -9.5, + "y": -2.5 + } + }, + { + "entity_number": 73, + "name": "small-electric-pole", + "position": { + "x": -9, + "y": -4 + } + }, + { + "entity_number": 74, + "name": "inserter", + "position": { + "x": -7, + "y": -4 + } + }, + { + "entity_number": 75, + "name": "stone-furnace", + "position": { + "x": -7.5, + "y": -2.5 + } + }, + { + "entity_number": 76, + "name": "inserter", + "position": { + "x": -6, + "y": -4 + } + }, + { + "entity_number": 77, + "name": "stone-furnace", + "position": { + "x": -5.5, + "y": -2.5 + } + }, + { + "entity_number": 78, + "name": "inserter", + "position": { + "x": -3, + "y": -4 + } + }, + { + "entity_number": 79, + "name": "stone-furnace", + "position": { + "x": -3.5, + "y": -2.5 + } + }, + { + "entity_number": 80, + "name": "small-electric-pole", + "position": { + "x": -4, + "y": -4 + } + }, + { + "entity_number": 81, + "name": "inserter", + "position": { + "x": -2, + "y": -4 + } + }, + { + "entity_number": 82, + "name": "stone-furnace", + "position": { + "x": -1.5, + "y": -2.5 + } + }, + { + "entity_number": 83, + "name": "underground-belt", + "position": { + "x": 0, + "y": -4 + }, + "direction": 4, + "type": "output" + }, + { + "entity_number": 84, + "name": "transport-belt", + "position": { + "x": 1, + "y": -4 + } + }, + { + "entity_number": 85, + "name": "transport-belt", + "position": { + "x": 1, + "y": -3 + } + }, + { + "entity_number": 86, + "name": "transport-belt", + "position": { + "x": 0, + "y": -3 + }, + "direction": 2 + }, + { + "entity_number": 87, + "name": "inserter", + "position": { + "x": 3, + "y": -4 + } + }, + { + "entity_number": 88, + "name": "stone-furnace", + "position": { + "x": 2.5, + "y": -2.5 + } + }, + { + "entity_number": 89, + "name": "inserter", + "position": { + "x": 4, + "y": -4 + } + }, + { + "entity_number": 90, + "name": "small-electric-pole", + "position": { + "x": 5, + "y": -4 + } + }, + { + "entity_number": 91, + "name": "stone-furnace", + "position": { + "x": 4.5, + "y": -2.5 + } + }, + { + "entity_number": 92, + "name": "inserter", + "position": { + "x": 7, + "y": -4 + } + }, + { + "entity_number": 93, + "name": "stone-furnace", + "position": { + "x": 6.5, + "y": -2.5 + } + }, + { + "entity_number": 94, + "name": "inserter", + "position": { + "x": 8, + "y": -4 + } + }, + { + "entity_number": 95, + "name": "stone-furnace", + "position": { + "x": 8.5, + "y": -2.5 + } + }, + { + "entity_number": 96, + "name": "inserter", + "position": { + "x": 11, + "y": -4 + } + }, + { + "entity_number": 97, + "name": "small-electric-pole", + "position": { + "x": 10, + "y": -4 + } + }, + { + "entity_number": 98, + "name": "stone-furnace", + "position": { + "x": 10.5, + "y": -2.5 + } + }, + { + "entity_number": 99, + "name": "inserter", + "position": { + "x": 12, + "y": -4 + } + }, + { + "entity_number": 100, + "name": "stone-furnace", + "position": { + "x": 12.5, + "y": -2.5 + } + }, + { + "entity_number": 101, + "name": "stone-furnace", + "position": { + "x": 14.5, + "y": -2.5 + } + }, + { + "entity_number": 102, + "name": "inserter", + "position": { + "x": 15, + "y": -4 + } + }, + { + "entity_number": 103, + "name": "small-electric-pole", + "position": { + "x": 17, + "y": -4 + } + }, + { + "entity_number": 104, + "name": "stone-furnace", + "position": { + "x": 16.5, + "y": -2.5 + } + }, + { + "entity_number": 105, + "name": "inserter", + "position": { + "x": 16, + "y": -4 + } + }, + { + "entity_number": 106, + "name": "stone-furnace", + "position": { + "x": 18.5, + "y": -2.5 + } + }, + { + "entity_number": 107, + "name": "inserter", + "position": { + "x": 19, + "y": -4 + } + }, + { + "entity_number": 108, + "name": "stone-furnace", + "position": { + "x": 20.5, + "y": -2.5 + } + }, + { + "entity_number": 109, + "name": "inserter", + "position": { + "x": 20, + "y": -4 + } + }, + { + "entity_number": 110, + "name": "small-electric-pole", + "position": { + "x": 22, + "y": -4 + } + }, + { + "entity_number": 111, + "name": "stone-furnace", + "position": { + "x": 22.5, + "y": -2.5 + } + }, + { + "entity_number": 112, + "name": "inserter", + "position": { + "x": 23, + "y": -4 + } + }, + { + "entity_number": 113, + "name": "stone-furnace", + "position": { + "x": 24.5, + "y": -2.5 + } + }, + { + "entity_number": 114, + "name": "inserter", + "position": { + "x": 24, + "y": -4 + } + }, + { + "entity_number": 115, + "name": "splitter", + "position": { + "x": -28, + "y": -0.5 + }, + "direction": 2 + }, + { + "entity_number": 116, + "name": "transport-belt", + "position": { + "x": -27, + "y": -2 + } + }, + { + "entity_number": 117, + "name": "transport-belt", + "position": { + "x": -27, + "y": -1 + } + }, + { + "entity_number": 118, + "name": "splitter", + "position": { + "x": -26, + "y": -0.5 + }, + "direction": 6 + }, + { + "entity_number": 119, + "name": "inserter", + "position": { + "x": -23, + "y": -1 + } + }, + { + "entity_number": 120, + "name": "inserter", + "position": { + "x": -22, + "y": -1 + } + }, + { + "entity_number": 121, + "name": "small-electric-pole", + "position": { + "x": -21, + "y": -1 + } + }, + { + "entity_number": 122, + "name": "inserter", + "position": { + "x": -19, + "y": -1 + } + }, + { + "entity_number": 123, + "name": "inserter", + "position": { + "x": -18, + "y": -1 + } + }, + { + "entity_number": 124, + "name": "inserter", + "position": { + "x": -15, + "y": -1 + } + }, + { + "entity_number": 125, + "name": "small-electric-pole", + "position": { + "x": -16, + "y": -1 + } + }, + { + "entity_number": 126, + "name": "inserter", + "position": { + "x": -14, + "y": -1 + } + }, + { + "entity_number": 127, + "name": "inserter", + "position": { + "x": -11, + "y": -1 + } + }, + { + "entity_number": 128, + "name": "inserter", + "position": { + "x": -10, + "y": -1 + } + }, + { + "entity_number": 129, + "name": "small-electric-pole", + "position": { + "x": -9, + "y": -1 + } + }, + { + "entity_number": 130, + "name": "inserter", + "position": { + "x": -7, + "y": -1 + } + }, + { + "entity_number": 131, + "name": "inserter", + "position": { + "x": -6, + "y": -1 + } + }, + { + "entity_number": 132, + "name": "inserter", + "position": { + "x": -3, + "y": -1 + } + }, + { + "entity_number": 133, + "name": "small-electric-pole", + "position": { + "x": -4, + "y": -1 + } + }, + { + "entity_number": 134, + "name": "inserter", + "position": { + "x": -2, + "y": -1 + } + }, + { + "entity_number": 135, + "name": "transport-belt", + "position": { + "x": 0, + "y": -2 + } + }, + { + "entity_number": 136, + "name": "transport-belt", + "position": { + "x": 0, + "y": -1 + } + }, + { + "entity_number": 137, + "name": "inserter", + "position": { + "x": 3, + "y": -1 + } + }, + { + "entity_number": 138, + "name": "inserter", + "position": { + "x": 4, + "y": -1 + } + }, + { + "entity_number": 139, + "name": "small-electric-pole", + "position": { + "x": 5, + "y": -1 + } + }, + { + "entity_number": 140, + "name": "inserter", + "position": { + "x": 7, + "y": -1 + } + }, + { + "entity_number": 141, + "name": "inserter", + "position": { + "x": 8, + "y": -1 + } + }, + { + "entity_number": 142, + "name": "inserter", + "position": { + "x": 11, + "y": -1 + } + }, + { + "entity_number": 143, + "name": "small-electric-pole", + "position": { + "x": 10, + "y": -1 + } + }, + { + "entity_number": 144, + "name": "inserter", + "position": { + "x": 12, + "y": -1 + } + }, + { + "entity_number": 145, + "name": "inserter", + "position": { + "x": 15, + "y": -1 + } + }, + { + "entity_number": 146, + "name": "small-electric-pole", + "position": { + "x": 17, + "y": -1 + } + }, + { + "entity_number": 147, + "name": "inserter", + "position": { + "x": 16, + "y": -1 + } + }, + { + "entity_number": 148, + "name": "inserter", + "position": { + "x": 19, + "y": -1 + } + }, + { + "entity_number": 149, + "name": "inserter", + "position": { + "x": 20, + "y": -1 + } + }, + { + "entity_number": 150, + "name": "small-electric-pole", + "position": { + "x": 22, + "y": -1 + } + }, + { + "entity_number": 151, + "name": "inserter", + "position": { + "x": 23, + "y": -1 + } + }, + { + "entity_number": 152, + "name": "inserter", + "position": { + "x": 24, + "y": -1 + } + }, + { + "entity_number": 153, + "name": "underground-belt", + "position": { + "x": -28, + "y": 1 + }, + "direction": 2, + "type": "input" + }, + { + "entity_number": 154, + "name": "transport-belt", + "position": { + "x": -27, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 155, + "name": "transport-belt", + "position": { + "x": -27, + "y": 0 + }, + "direction": 4 + }, + { + "entity_number": 156, + "name": "transport-belt", + "position": { + "x": -25, + "y": 0 + }, + "direction": 6 + }, + { + "entity_number": 157, + "name": "transport-belt", + "position": { + "x": -25, + "y": 1 + } + }, + { + "entity_number": 158, + "name": "underground-belt", + "position": { + "x": -26, + "y": 1 + }, + "direction": 2, + "type": "output" + }, + { + "entity_number": 159, + "name": "inserter", + "position": { + "x": -23, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 160, + "name": "transport-belt", + "position": { + "x": -23, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 161, + "name": "inserter", + "position": { + "x": -22, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 162, + "name": "small-lamp", + "position": { + "x": -21, + "y": 1 + } + }, + { + "entity_number": 163, + "name": "transport-belt", + "position": { + "x": -22, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 164, + "name": "transport-belt", + "position": { + "x": -21, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 165, + "name": "inserter", + "position": { + "x": -19, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 166, + "name": "transport-belt", + "position": { + "x": -20, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 167, + "name": "transport-belt", + "position": { + "x": -19, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 168, + "name": "inserter", + "position": { + "x": -18, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 169, + "name": "transport-belt", + "position": { + "x": -18, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 170, + "name": "transport-belt", + "position": { + "x": -17, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 171, + "name": "inserter", + "position": { + "x": -15, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 172, + "name": "small-lamp", + "position": { + "x": -16, + "y": 1 + } + }, + { + "entity_number": 173, + "name": "transport-belt", + "position": { + "x": -16, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 174, + "name": "transport-belt", + "position": { + "x": -15, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 175, + "name": "inserter", + "position": { + "x": -14, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 176, + "name": "transport-belt", + "position": { + "x": -14, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 177, + "name": "transport-belt", + "position": { + "x": -13, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 178, + "name": "inserter", + "position": { + "x": -11, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 179, + "name": "transport-belt", + "position": { + "x": -12, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 180, + "name": "transport-belt", + "position": { + "x": -11, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 181, + "name": "inserter", + "position": { + "x": -10, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 182, + "name": "small-lamp", + "position": { + "x": -9, + "y": 1 + } + }, + { + "entity_number": 183, + "name": "transport-belt", + "position": { + "x": -10, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 184, + "name": "transport-belt", + "position": { + "x": -9, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 185, + "name": "inserter", + "position": { + "x": -7, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 186, + "name": "transport-belt", + "position": { + "x": -8, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 187, + "name": "transport-belt", + "position": { + "x": -7, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 188, + "name": "underground-belt", + "position": { + "x": -6, + "y": 0 + }, + "direction": 2, + "type": "input" + }, + { + "entity_number": 189, + "name": "inserter", + "position": { + "x": -6, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 190, + "name": "underground-belt", + "position": { + "x": -3, + "y": 0 + }, + "direction": 2, + "type": "output" + }, + { + "entity_number": 191, + "name": "inserter", + "position": { + "x": -3, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 192, + "name": "small-lamp", + "position": { + "x": -4, + "y": 1 + } + }, + { + "entity_number": 193, + "name": "transport-belt", + "position": { + "x": -2, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 194, + "name": "splitter", + "position": { + "x": -1, + "y": 0.5 + }, + "direction": 2 + }, + { + "entity_number": 195, + "name": "inserter", + "position": { + "x": -2, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 196, + "name": "transport-belt", + "position": { + "x": 0, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 197, + "name": "transport-belt", + "position": { + "x": 0, + "y": 0 + } + }, + { + "entity_number": 198, + "name": "inserter", + "position": { + "x": 3, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 199, + "name": "transport-belt", + "position": { + "x": 3, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 200, + "name": "inserter", + "position": { + "x": 4, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 201, + "name": "small-lamp", + "position": { + "x": 5, + "y": 1 + } + }, + { + "entity_number": 202, + "name": "transport-belt", + "position": { + "x": 5, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 203, + "name": "transport-belt", + "position": { + "x": 4, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 204, + "name": "inserter", + "position": { + "x": 7, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 205, + "name": "transport-belt", + "position": { + "x": 7, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 206, + "name": "transport-belt", + "position": { + "x": 6, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 207, + "name": "inserter", + "position": { + "x": 8, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 208, + "name": "transport-belt", + "position": { + "x": 9, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 209, + "name": "transport-belt", + "position": { + "x": 8, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 210, + "name": "inserter", + "position": { + "x": 11, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 211, + "name": "small-lamp", + "position": { + "x": 10, + "y": 1 + } + }, + { + "entity_number": 212, + "name": "transport-belt", + "position": { + "x": 10, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 213, + "name": "transport-belt", + "position": { + "x": 11, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 214, + "name": "inserter", + "position": { + "x": 12, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 215, + "name": "transport-belt", + "position": { + "x": 12, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 216, + "name": "transport-belt", + "position": { + "x": 13, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 217, + "name": "transport-belt", + "position": { + "x": 14, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 218, + "name": "transport-belt", + "position": { + "x": 15, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 219, + "name": "inserter", + "position": { + "x": 15, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 220, + "name": "small-lamp", + "position": { + "x": 17, + "y": 1 + } + }, + { + "entity_number": 221, + "name": "transport-belt", + "position": { + "x": 16, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 222, + "name": "transport-belt", + "position": { + "x": 17, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 223, + "name": "inserter", + "position": { + "x": 16, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 224, + "name": "transport-belt", + "position": { + "x": 18, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 225, + "name": "transport-belt", + "position": { + "x": 19, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 226, + "name": "inserter", + "position": { + "x": 19, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 227, + "name": "transport-belt", + "position": { + "x": 20, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 228, + "name": "transport-belt", + "position": { + "x": 21, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 229, + "name": "inserter", + "position": { + "x": 20, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 230, + "name": "small-lamp", + "position": { + "x": 22, + "y": 1 + } + }, + { + "entity_number": 231, + "name": "transport-belt", + "position": { + "x": 22, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 232, + "name": "transport-belt", + "position": { + "x": 23, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 233, + "name": "inserter", + "position": { + "x": 23, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 234, + "name": "transport-belt", + "position": { + "x": 24, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 235, + "name": "transport-belt", + "position": { + "x": 25, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 236, + "name": "inserter", + "position": { + "x": 24, + "y": 1 + }, + "direction": 4 + }, + { + "entity_number": 237, + "name": "transport-belt", + "position": { + "x": 26, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 238, + "name": "transport-belt", + "position": { + "x": 27, + "y": 0 + }, + "direction": 2 + }, + { + "entity_number": 239, + "name": "transport-belt", + "position": { + "x": -27, + "y": 3 + }, + "direction": 4 + }, + { + "entity_number": 240, + "name": "transport-belt", + "position": { + "x": -27, + "y": 2 + }, + "direction": 4 + }, + { + "entity_number": 241, + "name": "stone-furnace", + "position": { + "x": -23.5, + "y": 2.5 + } + }, + { + "entity_number": 242, + "name": "stone-furnace", + "position": { + "x": -21.5, + "y": 2.5 + } + }, + { + "entity_number": 243, + "name": "stone-furnace", + "position": { + "x": -19.5, + "y": 2.5 + } + }, + { + "entity_number": 244, + "name": "stone-furnace", + "position": { + "x": -17.5, + "y": 2.5 + } + }, + { + "entity_number": 245, + "name": "stone-furnace", + "position": { + "x": -15.5, + "y": 2.5 + } + }, + { + "entity_number": 246, + "name": "stone-furnace", + "position": { + "x": -13.5, + "y": 2.5 + } + }, + { + "entity_number": 247, + "name": "stone-furnace", + "position": { + "x": -11.5, + "y": 2.5 + } + }, + { + "entity_number": 248, + "name": "stone-furnace", + "position": { + "x": -9.5, + "y": 2.5 + } + }, + { + "entity_number": 249, + "name": "stone-furnace", + "position": { + "x": -7.5, + "y": 2.5 + } + }, + { + "entity_number": 250, + "name": "stone-furnace", + "position": { + "x": -5.5, + "y": 2.5 + } + }, + { + "entity_number": 251, + "name": "stone-furnace", + "position": { + "x": -3.5, + "y": 2.5 + } + }, + { + "entity_number": 252, + "name": "stone-furnace", + "position": { + "x": -1.5, + "y": 2.5 + } + }, + { + "entity_number": 253, + "name": "small-electric-pole", + "position": { + "x": 1, + "y": 2 + } + }, + { + "entity_number": 254, + "name": "transport-belt", + "position": { + "x": 1, + "y": 3 + }, + "direction": 4 + }, + { + "entity_number": 255, + "name": "transport-belt", + "position": { + "x": 0, + "y": 3 + }, + "direction": 2 + }, + { + "entity_number": 256, + "name": "transport-belt", + "position": { + "x": 0, + "y": 2 + }, + "direction": 4 + }, + { + "entity_number": 257, + "name": "stone-furnace", + "position": { + "x": 2.5, + "y": 2.5 + } + }, + { + "entity_number": 258, + "name": "stone-furnace", + "position": { + "x": 4.5, + "y": 2.5 + } + }, + { + "entity_number": 259, + "name": "stone-furnace", + "position": { + "x": 6.5, + "y": 2.5 + } + }, + { + "entity_number": 260, + "name": "stone-furnace", + "position": { + "x": 8.5, + "y": 2.5 + } + }, + { + "entity_number": 261, + "name": "stone-furnace", + "position": { + "x": 10.5, + "y": 2.5 + } + }, + { + "entity_number": 262, + "name": "stone-furnace", + "position": { + "x": 12.5, + "y": 2.5 + } + }, + { + "entity_number": 263, + "name": "stone-furnace", + "position": { + "x": 14.5, + "y": 2.5 + } + }, + { + "entity_number": 264, + "name": "stone-furnace", + "position": { + "x": 16.5, + "y": 2.5 + } + }, + { + "entity_number": 265, + "name": "stone-furnace", + "position": { + "x": 18.5, + "y": 2.5 + } + }, + { + "entity_number": 266, + "name": "stone-furnace", + "position": { + "x": 20.5, + "y": 2.5 + } + }, + { + "entity_number": 267, + "name": "stone-furnace", + "position": { + "x": 22.5, + "y": 2.5 + } + }, + { + "entity_number": 268, + "name": "stone-furnace", + "position": { + "x": 24.5, + "y": 2.5 + } + }, + { + "entity_number": 269, + "name": "transport-belt", + "position": { + "x": -27, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 270, + "name": "transport-belt", + "position": { + "x": -27, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 271, + "name": "small-electric-pole", + "position": { + "x": -26, + "y": 4 + } + }, + { + "entity_number": 272, + "name": "transport-belt", + "position": { + "x": -25, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 273, + "name": "transport-belt", + "position": { + "x": -26, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 274, + "name": "inserter", + "position": { + "x": -23, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 275, + "name": "transport-belt", + "position": { + "x": -23, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 276, + "name": "transport-belt", + "position": { + "x": -24, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 277, + "name": "inserter", + "position": { + "x": -22, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 278, + "name": "small-electric-pole", + "position": { + "x": -21, + "y": 4 + } + }, + { + "entity_number": 279, + "name": "transport-belt", + "position": { + "x": -21, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 280, + "name": "transport-belt", + "position": { + "x": -22, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 281, + "name": "inserter", + "position": { + "x": -19, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 282, + "name": "transport-belt", + "position": { + "x": -19, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 283, + "name": "transport-belt", + "position": { + "x": -20, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 284, + "name": "inserter", + "position": { + "x": -18, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 285, + "name": "transport-belt", + "position": { + "x": -17, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 286, + "name": "transport-belt", + "position": { + "x": -18, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 287, + "name": "inserter", + "position": { + "x": -15, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 288, + "name": "small-electric-pole", + "position": { + "x": -16, + "y": 4 + } + }, + { + "entity_number": 289, + "name": "transport-belt", + "position": { + "x": -15, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 290, + "name": "transport-belt", + "position": { + "x": -16, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 291, + "name": "inserter", + "position": { + "x": -14, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 292, + "name": "transport-belt", + "position": { + "x": -13, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 293, + "name": "transport-belt", + "position": { + "x": -14, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 294, + "name": "inserter", + "position": { + "x": -11, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 295, + "name": "transport-belt", + "position": { + "x": -11, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 296, + "name": "transport-belt", + "position": { + "x": -12, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 297, + "name": "inserter", + "position": { + "x": -10, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 298, + "name": "small-electric-pole", + "position": { + "x": -9, + "y": 4 + } + }, + { + "entity_number": 299, + "name": "transport-belt", + "position": { + "x": -9, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 300, + "name": "transport-belt", + "position": { + "x": -10, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 301, + "name": "inserter", + "position": { + "x": -7, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 302, + "name": "transport-belt", + "position": { + "x": -7, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 303, + "name": "transport-belt", + "position": { + "x": -8, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 304, + "name": "inserter", + "position": { + "x": -6, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 305, + "name": "transport-belt", + "position": { + "x": -5, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 306, + "name": "transport-belt", + "position": { + "x": -6, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 307, + "name": "inserter", + "position": { + "x": -3, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 308, + "name": "small-electric-pole", + "position": { + "x": -4, + "y": 4 + } + }, + { + "entity_number": 309, + "name": "transport-belt", + "position": { + "x": -3, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 310, + "name": "transport-belt", + "position": { + "x": -4, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 311, + "name": "transport-belt", + "position": { + "x": -1, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 312, + "name": "inserter", + "position": { + "x": -2, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 313, + "name": "transport-belt", + "position": { + "x": -2, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 314, + "name": "underground-belt", + "position": { + "x": 0, + "y": 4 + }, + "type": "output" + }, + { + "entity_number": 315, + "name": "underground-belt", + "position": { + "x": 0, + "y": 5 + }, + "type": "input" + }, + { + "entity_number": 316, + "name": "transport-belt", + "position": { + "x": 1, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 317, + "name": "transport-belt", + "position": { + "x": 1, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 318, + "name": "inserter", + "position": { + "x": 3, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 319, + "name": "transport-belt", + "position": { + "x": 3, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 320, + "name": "transport-belt", + "position": { + "x": 2, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 321, + "name": "inserter", + "position": { + "x": 4, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 322, + "name": "small-electric-pole", + "position": { + "x": 5, + "y": 4 + } + }, + { + "entity_number": 323, + "name": "transport-belt", + "position": { + "x": 5, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 324, + "name": "transport-belt", + "position": { + "x": 4, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 325, + "name": "inserter", + "position": { + "x": 7, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 326, + "name": "transport-belt", + "position": { + "x": 7, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 327, + "name": "transport-belt", + "position": { + "x": 6, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 328, + "name": "inserter", + "position": { + "x": 8, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 329, + "name": "transport-belt", + "position": { + "x": 9, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 330, + "name": "transport-belt", + "position": { + "x": 8, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 331, + "name": "inserter", + "position": { + "x": 11, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 332, + "name": "small-electric-pole", + "position": { + "x": 10, + "y": 4 + } + }, + { + "entity_number": 333, + "name": "transport-belt", + "position": { + "x": 11, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 334, + "name": "transport-belt", + "position": { + "x": 10, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 335, + "name": "inserter", + "position": { + "x": 12, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 336, + "name": "transport-belt", + "position": { + "x": 13, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 337, + "name": "transport-belt", + "position": { + "x": 12, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 338, + "name": "transport-belt", + "position": { + "x": 15, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 339, + "name": "transport-belt", + "position": { + "x": 14, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 340, + "name": "inserter", + "position": { + "x": 15, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 341, + "name": "small-electric-pole", + "position": { + "x": 17, + "y": 4 + } + }, + { + "entity_number": 342, + "name": "transport-belt", + "position": { + "x": 17, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 343, + "name": "transport-belt", + "position": { + "x": 16, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 344, + "name": "transport-belt", + "position": { + "x": 19, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 345, + "name": "transport-belt", + "position": { + "x": 18, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 346, + "name": "inserter", + "position": { + "x": 19, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 347, + "name": "transport-belt", + "position": { + "x": 21, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 348, + "name": "transport-belt", + "position": { + "x": 20, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 349, + "name": "inserter", + "position": { + "x": 20, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 350, + "name": "small-electric-pole", + "position": { + "x": 22, + "y": 4 + } + }, + { + "entity_number": 351, + "name": "transport-belt", + "position": { + "x": 23, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 352, + "name": "transport-belt", + "position": { + "x": 22, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 353, + "name": "inserter", + "position": { + "x": 23, + "y": 4 + }, + "direction": 4 + }, + { + "entity_number": 354, + "name": "transport-belt", + "position": { + "x": 24, + "y": 5 + }, + "direction": 2 + }, + { + "entity_number": 355, + "name": "inserter", + "position": { + "x": 24, + "y": 4 + }, + "direction": 4 + } + ], + "item": "blueprint", + "label": "3. Basic Steel Smelting", + "version": 64426344449 +} \ No newline at end of file diff --git a/data/vqa/blueprint_subchunks.py b/data/vqa/blueprint_subchunks.py new file mode 100644 index 000000000..1b23d9784 --- /dev/null +++ b/data/vqa/blueprint_subchunks.py @@ -0,0 +1,316 @@ +# Add this to blueprint_transforms.py or create a new file blueprint_subchunks.py + +import copy +import math +from typing import Dict, Any, Tuple +from typing import List + +from inspect_ai.dataset import Dataset, Sample, MemoryDataset + +from data.vqa.blueprint_transforms import get_blueprint_bounds + + +class SubchunkConfig: + """Configuration for subchunk extraction.""" + + def __init__(self, chunk_size: Tuple[int, int], step_size: Tuple[int, int], + min_entities: int = 3, padding: float = 1.0): + """ + Args: + chunk_size: (width, height) of each chunk + step_size: (x_step, y_step) for sliding window + min_entities: Minimum entities required in a chunk to keep it + padding: Extra padding around chunk boundaries + """ + self.chunk_size = chunk_size + self.step_size = step_size + self.min_entities = min_entities + self.padding = padding + + +def get_entities_in_region(entities: List[Dict[str, Any]], + min_x: float, min_y: float, + max_x: float, max_y: float) -> List[Dict[str, Any]]: + """ + Get all entities within a rectangular region. + + Args: + entities: List of blueprint entities + min_x, min_y, max_x, max_y: Region boundaries + + Returns: + List of entities within the region + """ + entities_in_region = [] + + for entity in entities: + pos = entity.get("position", {}) + x = pos.get("x", 0) + y = pos.get("y", 0) + + if min_x <= x <= max_x and min_y <= y <= max_y: + entities_in_region.append(entity) + + return entities_in_region + + +def extract_subchunk(blueprint: Dict[str, Any], + min_x: float, min_y: float, + max_x: float, max_y: float, + normalize: bool = True) -> Dict[str, Any]: + """ + Extract a subchunk from a blueprint. + + Args: + blueprint: Original blueprint + min_x, min_y, max_x, max_y: Chunk boundaries + normalize: Whether to normalize positions to start near (0, 0) + + Returns: + New blueprint containing only entities in the chunk + """ + chunk_blueprint = copy.deepcopy(blueprint) + + # Get entities in the region + original_entities = blueprint.get("entities", []) + chunk_entities = get_entities_in_region(original_entities, min_x, min_y, max_x, max_y) + + if normalize: + # Normalize positions so chunk starts near (0, 0) + normalized_entities = [] + for entity in chunk_entities: + new_entity = copy.deepcopy(entity) + pos = new_entity.get("position", {}) + new_entity["position"] = { + "x": pos.get("x", 0) - min_x, + "y": pos.get("y", 0) - min_y + } + normalized_entities.append(new_entity) + chunk_entities = normalized_entities + + chunk_blueprint["entities"] = chunk_entities + + # Update metadata + if "metadata" not in chunk_blueprint: + chunk_blueprint["metadata"] = {} + + chunk_blueprint["metadata"]["subchunk"] = { + "original_bounds": {"min_x": min_x, "min_y": min_y, "max_x": max_x, "max_y": max_y}, + "chunk_size": {"width": max_x - min_x, "height": max_y - min_y}, + "entity_count": len(chunk_entities), + "normalized": normalize + } + + # Update label if present + if "label" in chunk_blueprint: + chunk_blueprint["label"] = f"{chunk_blueprint['label']} (chunk)" + + return chunk_blueprint + + +def generate_subchunks(blueprint: Dict[str, Any], + config: SubchunkConfig) -> List[Dict[str, Any]]: + """ + Generate all subchunks from a blueprint using sliding window. + + Args: + blueprint: Original blueprint + config: Subchunk configuration + + Returns: + List of subchunk blueprints + """ + entities = blueprint.get("entities", []) + if not entities: + return [] + + # Get blueprint bounds + min_x, min_y, max_x, max_y = get_blueprint_bounds(entities) + + # Calculate blueprint dimensions + blueprint_width = max_x - min_x + blueprint_height = max_y - min_y + + chunk_width, chunk_height = config.chunk_size + step_x, step_y = config.step_size + + subchunks = [] + + # Generate chunks using sliding window + y = min_y + chunk_id = 0 + + while y + chunk_height <= max_y + config.padding: + x = min_x + while x + chunk_width <= max_x + config.padding: + # Extract chunk + chunk_min_x = x - config.padding + chunk_min_y = y - config.padding + chunk_max_x = x + chunk_width + config.padding + chunk_max_y = y + chunk_height + config.padding + + chunk = extract_subchunk( + blueprint, + chunk_min_x, chunk_min_y, + chunk_max_x, chunk_max_y, + normalize=True + ) + + # Only keep chunks with enough entities + if len(chunk["entities"]) >= config.min_entities: + # Add chunk position info + chunk["metadata"]["subchunk"]["id"] = chunk_id + chunk["metadata"]["subchunk"]["grid_position"] = { + "x": int((x - min_x) / step_x), + "y": int((y - min_y) / step_y) + } + subchunks.append(chunk) + chunk_id += 1 + + x += step_x + y += step_y + + return subchunks + + +def create_subchunk_augmented_dataset(base_dataset: Dataset, + chunk_sizes: List[Tuple[int, int]] = None, + step_sizes: List[Tuple[int, int]] = None, + min_entities: int = 3) -> MemoryDataset: + """ + Create a subchunk-augmented dataset from a base dataset. + + Args: + base_dataset: The original dataset + chunk_sizes: List of (width, height) tuples for chunk sizes + step_sizes: List of (x_step, y_step) tuples for step sizes + min_entities: Minimum entities required in a chunk + + Returns: + MemoryDataset with subchunk variations + """ + if chunk_sizes is None: + chunk_sizes = [(10, 10), (15, 15), (20, 20)] + + if step_sizes is None: + step_sizes = [(5, 5), (10, 10)] + + augmented_samples = [] + + for original_sample in base_dataset: + blueprint = original_sample.metadata.get("blueprint", {}) + + if not blueprint: + augmented_samples.append(original_sample) + continue + + # Add original + augmented_samples.append(original_sample) + + # Generate subchunks for each configuration + for chunk_size in chunk_sizes: + for step_size in step_sizes: + config = SubchunkConfig( + chunk_size=chunk_size, + step_size=step_size, + min_entities=min_entities + ) + + subchunks = generate_subchunks(blueprint, config) + + for i, chunk_blueprint in enumerate(subchunks): + # Create new sample + new_metadata = copy.deepcopy(original_sample.metadata) + new_metadata["blueprint"] = chunk_blueprint + new_metadata["original_filename"] = original_sample.metadata.get("filename", "") + new_metadata["augmentation_type"] = "subchunk" + new_metadata["chunk_config"] = { + "chunk_size": chunk_size, + "step_size": step_size, + "chunk_index": i, + "total_chunks": len(subchunks) + } + + # Create unique ID + chunk_suffix = f"chunk_{chunk_size[0]}x{chunk_size[1]}_step_{step_size[0]}x{step_size[1]}_{i}" + + new_sample = Sample( + input=original_sample.input, + target=original_sample.target, + metadata=new_metadata, + id=f"{original_sample.id}_{chunk_suffix}" if original_sample.id else None, + files=original_sample.files + ) + + augmented_samples.append(new_sample) + + return MemoryDataset(samples=augmented_samples) + + +def create_overlapping_subchunks(blueprint: Dict[str, Any], + chunk_size: Tuple[int, int], + overlap: float = 0.5) -> List[Dict[str, Any]]: + """ + Create overlapping subchunks with specified overlap ratio. + + Args: + blueprint: Original blueprint + chunk_size: (width, height) of each chunk + overlap: Overlap ratio (0.5 = 50% overlap) + + Returns: + List of overlapping subchunk blueprints + """ + chunk_width, chunk_height = chunk_size + step_x = int(chunk_width * (1 - overlap)) + step_y = int(chunk_height * (1 - overlap)) + + config = SubchunkConfig( + chunk_size=chunk_size, + step_size=(step_x, step_y), + min_entities=3 + ) + + return generate_subchunks(blueprint, config) + + +def create_adaptive_subchunks(blueprint: Dict[str, Any], + target_entities_per_chunk: int = 20, + max_chunks: int = 10) -> List[Dict[str, Any]]: + """ + Create subchunks with adaptive sizing based on entity density. + + Args: + blueprint: Original blueprint + target_entities_per_chunk: Target number of entities per chunk + max_chunks: Maximum number of chunks to generate + + Returns: + List of adaptively-sized subchunk blueprints + """ + entities = blueprint.get("entities", []) + if not entities: + return [] + + total_entities = len(entities) + + # Calculate ideal chunk count + ideal_chunks = min(max_chunks, max(1, total_entities // target_entities_per_chunk)) + + # Get blueprint bounds + min_x, min_y, max_x, max_y = get_blueprint_bounds(entities) + blueprint_width = max_x - min_x + blueprint_height = max_y - min_y + + # Calculate chunk dimensions + chunks_per_side = int(math.sqrt(ideal_chunks)) + chunk_width = int(blueprint_width / chunks_per_side) + chunk_height = int(blueprint_height / chunks_per_side) + + config = SubchunkConfig( + chunk_size=(chunk_width, chunk_height), + step_size=(chunk_width, chunk_height), + min_entities=3 + ) + + return generate_subchunks(blueprint, config) \ No newline at end of file diff --git a/data/vqa/blueprint_transforms.py b/data/vqa/blueprint_transforms.py new file mode 100644 index 000000000..9f86bf97f --- /dev/null +++ b/data/vqa/blueprint_transforms.py @@ -0,0 +1,573 @@ +"""Blueprint transformation utilities for data augmentation using flips instead of rotations.""" + +import copy +from typing import Dict, Any, List, Tuple, Set, Optional +from enum import Enum + + +class FlipType(Enum): + """Types of flips for blueprint transformations.""" + NONE = "none" # Original orientation + HORIZONTAL = "horizontal" # Flip along Y-axis (X = -X) + VERTICAL = "vertical" # Flip along X-axis (Y = -Y) + BOTH = "both" # Flip both axes (X = -X, Y = -Y) + + +class DirectionSystem(Enum): + """Factorio direction systems.""" + OLD_SYSTEM = "old" # 8-direction system (0-7) + NEW_SYSTEM = "new" # 16-direction system (0-15) + + +def detect_direction_system(blueprint: Dict[str, Any]) -> DirectionSystem: + """ + Detect which direction system a blueprint uses by analyzing entity directions. + + The old system uses values 0-7, while the new system uses 0-15. + + Args: + blueprint: Blueprint dictionary + + Returns: + DirectionSystem enum indicating which system is in use + """ + if "entities" not in blueprint: + return DirectionSystem.OLD_SYSTEM # Default to old system if no entities + + directions_found: Set[int] = set() + + for entity in blueprint["entities"]: + if "direction" in entity and entity["direction"] is not None: + direction = int(entity["direction"]) + directions_found.add(direction) + + # If any direction >= 8, it's definitely the new system + if any(d >= 8 for d in directions_found): + return DirectionSystem.NEW_SYSTEM + + # If all directions are 0-7, assume old system + return DirectionSystem.OLD_SYSTEM + + +def flip_direction_old_system(direction: int, flip_type: FlipType) -> int: + """ + Flip a direction in the old 8-direction system. + + Old system directions: + - 0: North + - 1: Northeast + - 2: East + - 3: Southeast + - 4: South + - 5: Southwest + - 6: West + - 7: Northwest + """ + if direction is None or flip_type == FlipType.NONE: + return direction + + # Map directions for different flip types + horizontal_flip_map = { + 0: 0, # North -> North + 1: 7, # Northeast -> Northwest + 2: 6, # East -> West + 3: 5, # Southeast -> Southwest + 4: 4, # South -> South + 5: 3, # Southwest -> Southeast + 6: 2, # West -> East + 7: 1, # Northwest -> Northeast + } + + vertical_flip_map = { + 0: 4, # North -> South + 1: 3, # Northeast -> Southeast + 2: 2, # East -> East + 3: 1, # Southeast -> Northeast + 4: 0, # South -> North + 5: 7, # Southwest -> Northwest + 6: 6, # West -> West + 7: 5, # Northwest -> Southwest + } + + both_flip_map = { + 0: 4, # North -> South + 1: 5, # Northeast -> Southwest + 2: 6, # East -> West + 3: 7, # Southeast -> Northwest + 4: 0, # South -> North + 5: 1, # Southwest -> Northeast + 6: 2, # West -> East + 7: 3, # Northwest -> Southeast + } + + if flip_type == FlipType.HORIZONTAL: + return horizontal_flip_map.get(direction, direction) + elif flip_type == FlipType.VERTICAL: + return vertical_flip_map.get(direction, direction) + elif flip_type == FlipType.BOTH: + return both_flip_map.get(direction, direction) + + return direction + + +def flip_direction_new_system(direction: int, flip_type: FlipType) -> int: + """ + Flip a direction in the new 16-direction system. + + New system uses 16 directions (0-15) representing 22.5° increments. + """ + if direction is None or flip_type == FlipType.NONE: + return direction + + # For horizontal flip (X = -X), we mirror across the Y-axis + # For vertical flip (Y = -Y), we mirror across the X-axis + + if flip_type == FlipType.HORIZONTAL: + # Mirror across Y-axis: East <-> West + horizontal_flip_map = { + 0: 0, # N -> N + 1: 15, # NNE -> NNW + 2: 14, # NE -> NW + 3: 13, # ENE -> WNW + 4: 12, # E -> W + 5: 11, # ESE -> WSW + 6: 10, # SE -> SW + 7: 9, # SSE -> SSW + 8: 8, # S -> S + 9: 7, # SSW -> SSE + 10: 6, # SW -> SE + 11: 5, # WSW -> ESE + 12: 4, # W -> E + 13: 3, # WNW -> ENE + 14: 2, # NW -> NE + 15: 1, # NNW -> NNE + } + return horizontal_flip_map.get(direction, direction) + + elif flip_type == FlipType.VERTICAL: + # Mirror across X-axis: North <-> South + vertical_flip_map = { + 0: 8, # N -> S + 1: 7, # NNE -> SSE + 2: 6, # NE -> SE + 3: 5, # ENE -> ESE + 4: 4, # E -> E + 5: 3, # ESE -> ENE + 6: 2, # SE -> NE + 7: 1, # SSE -> NNE + 8: 0, # S -> N + 9: 15, # SSW -> NNW + 10: 14, # SW -> NW + 11: 13, # WSW -> WNW + 12: 12, # W -> W + 13: 11, # WNW -> WSW + 14: 10, # NW -> SW + 15: 9, # NNW -> SSW + } + return vertical_flip_map.get(direction, direction) + + elif flip_type == FlipType.BOTH: + # 180-degree rotation equivalent + vertical_flip_direction = flip_direction_new_system(direction, FlipType.VERTICAL) + final_direction = flip_direction_new_system(vertical_flip_direction, FlipType.HORIZONTAL) + return final_direction + + return direction + + +def flip_direction(direction: Optional[int], flip_type: FlipType, + direction_system: DirectionSystem) -> Optional[int]: + """ + Flip a Factorio direction value using the appropriate system. + + Args: + direction: Original direction value + flip_type: Type of flip to apply + direction_system: Which direction system to use + + Returns: + New direction value + """ + if direction is None: + return None + + # Handle both int and float directions + original_type = type(direction) + direction_int = int(direction) + + if direction_system == DirectionSystem.OLD_SYSTEM: + new_direction = flip_direction_old_system(direction_int, flip_type) + else: + new_direction = flip_direction_new_system(direction_int, flip_type) + + # Return in original type + return original_type(new_direction) if original_type == float else new_direction + + +def get_blueprint_bounds(entities: List[Dict[str, Any]]) -> Tuple[float, float, float, float]: + """ + Get the bounding box of all entities in the blueprint. + + Args: + entities: List of blueprint entities + + Returns: + Tuple of (min_x, min_y, max_x, max_y) + """ + if not entities: + return 0, 0, 0, 0 + + positions = [] + for entity in entities: + pos = entity.get("position", {}) + x, y = pos.get("x", 0), pos.get("y", 0) + positions.append((x, y)) + + xs, ys = zip(*positions) + return min(xs), min(ys), max(xs), max(ys) + + +def normalize_blueprint_positions(entities: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """ + Normalize blueprint positions so the bounding box starts near (0, 0). + + Args: + entities: List of blueprint entities + + Returns: + List of entities with normalized positions + """ + if not entities: + return entities + + # Get current bounds + min_x, min_y, max_x, max_y = get_blueprint_bounds(entities) + + # Calculate offset to move blueprint close to origin + offset_x = -min_x + offset_y = -min_y + + # Apply offset to all entities + normalized_entities = [] + for entity in entities: + new_entity = copy.deepcopy(entity) + pos = new_entity.get("position", {}) + + new_entity["position"] = { + "x": pos.get("x", 0) + offset_x, + "y": pos.get("y", 0) + offset_y + } + + normalized_entities.append(new_entity) + + return normalized_entities + + +def should_swap_underground_belt_type(entity: Dict[str, Any], flip_type: FlipType, + direction_system: DirectionSystem) -> bool: + """ + Determine if an underground belt's type should be swapped based on flip type and direction. + + Underground belts need their type swapped when: + 1. BOTH flip (always swap - 180 degree rotation) + 2. Flipping along the axis the belt pair extends along + + Args: + entity: The underground belt entity + flip_type: Type of flip being applied + direction_system: Which direction system is in use + + Returns: + True if the belt type should be swapped + """ + if flip_type == FlipType.BOTH: + # Always swap for 180-degree equivalent + return True + + if flip_type == FlipType.NONE: + return False + + direction = entity.get("direction", 0) + if direction is None: + direction = 0 + direction = int(direction) + + # Determine which axis the belt extends along based on direction + if direction_system == DirectionSystem.OLD_SYSTEM: + # In old system: 0=N, 2=E, 4=S, 6=W + north_south = direction in [0, 4] # Belt extends vertically + east_west = direction in [2, 6] # Belt extends horizontally + else: + # In new system: 0=N, 4=E, 8=S, 12=W + north_south = direction in [0, 8] # Belt extends vertically + east_west = direction in [4, 12] # Belt extends horizontally + + # Swap if: + # - Horizontal flip and belt extends horizontally (E-W) + # - Vertical flip and belt extends vertically (N-S) + if flip_type == FlipType.HORIZONTAL and east_west: + return True + elif flip_type == FlipType.VERTICAL and north_south: + return True + + return False + + +def flip_entity(entity: Dict[str, Any], flip_type: FlipType, + center_x: float, center_y: float, + direction_system: DirectionSystem) -> Dict[str, Any]: + """Flip a single entity with special handling for different entity types.""" + new_entity = copy.deepcopy(entity) + entity_name = entity.get("name", "") + + # Get original position + pos = entity.get("position", {}) + x = pos.get("x", 0) + y = pos.get("y", 0) + + # Apply flip transformation + if flip_type == FlipType.HORIZONTAL: + # Flip X coordinate around center + new_x = center_x - (x - center_x) + new_y = y + elif flip_type == FlipType.VERTICAL: + # Flip Y coordinate around center + new_x = x + new_y = center_y - (y - center_y) + elif flip_type == FlipType.BOTH: + # Flip both coordinates + new_x = center_x - (x - center_x) + new_y = center_y - (y - center_y) + else: + new_x = x + new_y = y + + new_entity["position"] = { + "x": new_x, + "y": new_y + } + + # Handle direction flipping + if "direction" in entity and entity["direction"] is not None: + new_entity["direction"] = flip_direction( + entity["direction"], flip_type, direction_system + ) + + # Special handling for underground belts + if "underground-belt" in entity_name: + if should_swap_underground_belt_type(entity, flip_type, direction_system): + belt_type = entity.get("type", "input") + #new_entity["type"] = "output" if belt_type == "input" else "input" + + return new_entity + + +def flip_blueprint(input_blueprint: Dict[str, Any], flip_type: FlipType, + direction_system: Optional[DirectionSystem] = None) -> Dict[str, Any]: + """ + Flip a blueprint by the specified type. + + Args: + input_blueprint: Original blueprint + flip_type: Type of flip to apply + direction_system: Direction system to use (auto-detected if None) + + Returns: + Flipped blueprint + """ + blueprint = copy.deepcopy(input_blueprint) + + # Auto-detect direction system if not specified + if direction_system is None: + direction_system = detect_direction_system(blueprint) + + # Fill in empty directions with default (0) + if "entities" in blueprint: + for entity in blueprint["entities"]: + if "direction" not in entity: + entity['direction'] = 0 + + if flip_type == FlipType.NONE: + flipped_blueprint = copy.deepcopy(blueprint) + if "entities" in flipped_blueprint: + flipped_blueprint["entities"] = normalize_blueprint_positions(flipped_blueprint["entities"]) + return flipped_blueprint + + flipped_blueprint = copy.deepcopy(blueprint) + + if "entities" not in flipped_blueprint: + return flipped_blueprint + + entities = flipped_blueprint["entities"] + + # Get center of blueprint for flipping + min_x, min_y, max_x, max_y = get_blueprint_bounds(entities) + center_x = (min_x + max_x) / 2 + center_y = (min_y + max_y) / 2 + + # Flip each entity + flipped_entities = [] + for entity in entities: + flipped_entity = flip_entity(entity, flip_type, center_x, center_y, direction_system) + flipped_entities.append(flipped_entity) + + # Normalize positions to keep blueprint near origin + flipped_entities = normalize_blueprint_positions(flipped_entities) + flipped_blueprint["entities"] = flipped_entities + + # Add metadata about the flip and direction system used + if "metadata" not in flipped_blueprint: + flipped_blueprint["metadata"] = {} + flipped_blueprint["metadata"]["flip_type"] = flip_type.value + flipped_blueprint["metadata"]["direction_system"] = direction_system.value + + if direction_system == DirectionSystem.NEW_SYSTEM: + n_entities = [] + for entity in flipped_entities: + if entity["direction"] == 12: + entity["direction"] = 6 + elif entity["direction"] == 8: + entity["direction"] = 4 + elif entity["direction"] == 4: + entity["direction"] = 2 + else: + entity["direction"] = 0 + n_entities.append(entity) + flipped_blueprint["entities"] = n_entities + + return flipped_blueprint + + +def generate_flipped_blueprints(blueprint: Dict[str, Any], + direction_system: Optional[DirectionSystem] = None) -> Dict[FlipType, Dict[str, Any]]: + """ + Generate all 4 flipped variations of a blueprint. + + Args: + blueprint: Original blueprint dictionary + direction_system: Direction system to use (auto-detected if None) + + Returns: + Dictionary mapping flip type to flipped blueprint + """ + # Auto-detect direction system if not specified + if direction_system is None: + direction_system = detect_direction_system(blueprint) + + flipped_blueprints = {} + + for flip_type in FlipType: + flipped_blueprints[flip_type] = flip_blueprint(blueprint, flip_type, direction_system) + + return flipped_blueprints + + +def get_flip_suffix(flip_type: FlipType) -> str: + """ + Get a string suffix for the flip type. + + Args: + flip_type: FlipType enum value + + Returns: + String suffix like "original", "h_flip", etc. + """ + suffix_map = { + FlipType.NONE: "original", + FlipType.HORIZONTAL: "h_flip", + FlipType.VERTICAL: "v_flip", + FlipType.BOTH: "hv_flip" + } + return suffix_map[flip_type] + + +def update_metadata_for_flip(metadata: Dict[str, Any], flip_type: FlipType, + direction_system: DirectionSystem) -> Dict[str, Any]: + """ + Update metadata to reflect the flip applied. + + Args: + metadata: Original metadata dictionary + flip_type: Applied flip type + direction_system: Direction system used + + Returns: + Updated metadata dictionary + """ + updated_metadata = copy.deepcopy(metadata) + + # Add flip information + updated_metadata["flip_type"] = flip_type.value + updated_metadata["flip_suffix"] = get_flip_suffix(flip_type) + updated_metadata["direction_system"] = direction_system.value + + # Update filename to include flip type + if "filename" in updated_metadata: + base_filename = updated_metadata["filename"] + # Remove extension and add flip suffix + if "." in base_filename: + name, ext = base_filename.rsplit(".", 1) + updated_metadata["filename"] = f"{name}_{get_flip_suffix(flip_type)}.{ext}" + else: + updated_metadata["filename"] = f"{base_filename}_{get_flip_suffix(flip_type)}" + + return updated_metadata + + +# Example usage and testing +if __name__ == "__main__": + # Test with underground belts in different orientations + underground_belt_test = { + "entities": [ + # Horizontal underground belt pair (East-West) + {"name": "underground-belt", "position": {"x": 0, "y": 0}, "direction": 2, "type": "input"}, # East + {"name": "underground-belt", "position": {"x": 5, "y": 0}, "direction": 2, "type": "output"}, # East + + # Vertical underground belt pair (North-South) + {"name": "underground-belt", "position": {"x": 0, "y": 2}, "direction": 0, "type": "input"}, # North + {"name": "underground-belt", "position": {"x": 0, "y": 7}, "direction": 0, "type": "output"}, # North + ] + } + + print("Underground Belt Flip Test (Old System):") + print("Original configuration:") + for i, entity in enumerate(underground_belt_test["entities"]): + print(f" Entity {i}: pos=({entity['position']['x']}, {entity['position']['y']}), " + f"dir={entity['direction']}, type={entity['type']}") + + # Test each flip type + for flip_type in FlipType: + print(f"\n{flip_type.value} flip:") + flipped = flip_blueprint(underground_belt_test, flip_type, DirectionSystem.OLD_SYSTEM) + for i, entity in enumerate(flipped["entities"]): + if "underground-belt" in entity["name"]: + print(f" Entity {i}: pos=({entity['position']['x']:.1f}, {entity['position']['y']:.1f}), " + f"dir={entity['direction']}, type={entity['type']}") + + # Test with new system directions + print("\n" + "="*50 + "\n") + underground_belt_test_new = { + "entities": [ + # Horizontal underground belt pair (East-West) + {"name": "underground-belt", "position": {"x": 0, "y": 0}, "direction": 4, "type": "input"}, # East + {"name": "underground-belt", "position": {"x": 5, "y": 0}, "direction": 4, "type": "output"}, # East + + # Vertical underground belt pair (North-South) + {"name": "underground-belt", "position": {"x": 0, "y": 2}, "direction": 0, "type": "input"}, # North + {"name": "underground-belt", "position": {"x": 0, "y": 7}, "direction": 0, "type": "output"}, # North + ] + } + + print("Underground Belt Flip Test (New System):") + print("Original configuration:") + for i, entity in enumerate(underground_belt_test_new["entities"]): + print(f" Entity {i}: pos=({entity['position']['x']}, {entity['position']['y']}), " + f"dir={entity['direction']}, type={entity['type']}") + + # Test horizontal flip specifically + print(f"\nHorizontal flip (should swap E-W belt types):") + h_flipped = flip_blueprint(underground_belt_test_new, FlipType.HORIZONTAL, DirectionSystem.NEW_SYSTEM) + for i, entity in enumerate(h_flipped["entities"]): + if "underground-belt" in entity["name"]: + print(f" Entity {i}: pos=({entity['position']['x']:.1f}, {entity['position']['y']:.1f}), " + f"dir={entity['direction']}, type={entity['type']}") \ No newline at end of file diff --git a/data/vqa/bounding_box_utils.py b/data/vqa/bounding_box_utils.py new file mode 100644 index 000000000..641943581 --- /dev/null +++ b/data/vqa/bounding_box_utils.py @@ -0,0 +1,146 @@ +"""Bounding box utilities for VQA tasks.""" + +from typing import Dict, List, Tuple, Any, Optional + + +def calculate_blueprint_bounding_box(blueprint: Dict[str, Any]) -> Dict[str, float]: + """ + Calculate the bounding box of a blueprint from its entities. + + Args: + blueprint: Blueprint dictionary containing entities + + Returns: + Dictionary with min_x, min_y, max_x, max_y, width, height + """ + entities = blueprint.get("entities", []) + + if not entities: + return { + "min_x": 0.0, + "min_y": 0.0, + "max_x": 0.0, + "max_y": 0.0, + "width": 0.0, + "height": 0.0 + } + + # Extract all positions + x_coords = [] + y_coords = [] + + for entity in entities: + position = entity.get("position", {}) + x = position.get("x", 0) + y = position.get("y", 0) + x_coords.append(x) + y_coords.append(y) + + # Calculate bounding box + min_x = min(x_coords) + max_x = max(x_coords) + min_y = min(y_coords) + max_y = max(y_coords) + + # Calculate dimensions + width = max_x - min_x + height = max_y - min_y + + return { + "min_x": min_x, + "min_y": min_y, + "max_x": max_x, + "max_y": max_y, + "width": width, + "height": height + } + + +def get_blueprint_center(bounding_box: Dict[str, float]) -> Tuple[float, float]: + """ + Get the center point of a bounding box. + + Args: + bounding_box: Bounding box dictionary + + Returns: + Tuple of (center_x, center_y) + """ + center_x = (bounding_box["min_x"] + bounding_box["max_x"]) / 2 + center_y = (bounding_box["min_y"] + bounding_box["max_y"]) / 2 + return (center_x, center_y) + + +def is_position_in_bounds(x: float, y: float, bounding_box: Dict[str, float], + margin: float = 0.0) -> bool: + """ + Check if a position is within the bounding box (with optional margin). + + Args: + x: X coordinate + y: Y coordinate + bounding_box: Bounding box dictionary + margin: Optional margin to expand the bounding box + + Returns: + True if position is within bounds + """ + return (bounding_box["min_x"] - margin <= x <= bounding_box["max_x"] + margin and + bounding_box["min_y"] - margin <= y <= bounding_box["max_y"] + margin) + + +def get_relative_position_description(x: float, y: float, + bounding_box: Dict[str, float]) -> str: + """ + Get a relative position description within the bounding box. + + Args: + x: X coordinate + y: Y coordinate + bounding_box: Bounding box dictionary + + Returns: + String description like "northwest", "center", "southeast", etc. + """ + center_x, center_y = get_blueprint_center(bounding_box) + + # Determine horizontal position + if x < center_x - bounding_box["width"] * 0.1: + horizontal = "west" + elif x > center_x + bounding_box["width"] * 0.1: + horizontal = "east" + else: + horizontal = "center" + + # Determine vertical position + if y < center_y - bounding_box["height"] * 0.1: + vertical = "north" + elif y > center_y + bounding_box["height"] * 0.1: + vertical = "south" + else: + vertical = "center" + + # Combine descriptions + if horizontal == "center" and vertical == "center": + return "center" + elif horizontal == "center": + return vertical + elif vertical == "center": + return horizontal + else: + return f"{vertical}{horizontal}" + + +def format_bounding_box_info(bounding_box: Dict[str, float]) -> str: + """ + Format bounding box information as a readable string. + + Args: + bounding_box: Bounding box dictionary + + Returns: + Formatted string with bounding box information + """ + return (f"Bounds: ({bounding_box['min_x']:.1f}, {bounding_box['min_y']:.1f}) to " + f"({bounding_box['max_x']:.1f}, {bounding_box['max_y']:.1f}), " + f"Size: {bounding_box['width']:.1f}×{bounding_box['height']:.1f}") \ No newline at end of file diff --git a/data/vqa/common_solvers.py b/data/vqa/common_solvers.py new file mode 100644 index 000000000..3ff9e386c --- /dev/null +++ b/data/vqa/common_solvers.py @@ -0,0 +1,431 @@ +"""Common solvers used across multiple VQA tasks.""" + +import json +import re +import random +from inspect_ai.model import ChatMessageUser +from inspect_ai.solver import Solver, solver, TaskState, Generate + +from data.vqa.blueprint_transforms import detect_direction_system +from data.vqa.position_utils import normalize_position_references_in_qa +from data.vqa.bounding_box_utils import calculate_blueprint_bounding_box +from data.vqa.direction_utils import Direction +from fle.agents.data.screenshots_from_run import create_factorio_instance +from fle.commons.models.rendered_image import RenderedImage + + +@solver +def validate_qa_answerability() -> Solver: + """ + Followup solver that validates if generated questions are answerable and unambiguous. + + This solver checks each generated Q&A pair to ensure: + 1. The question is clear and specific + 2. The answer directly addresses the question + 3. There's enough context to answer the question + 4. The question avoids ambiguity + + It will regenerate questions that fail validation. + """ + + async def solve(state: TaskState, generate: Generate) -> TaskState: + # Get all question fields from metadata + question_fields = [ + "basic_questions", "position_questions", "counting_questions", + "spatial_questions", "state_questions", "inventory_questions", + "qa_pairs", "next_action_questions", "construction_order_questions", + "throughput_questions", "bottleneck_questions", "optimization_questions", + "direction_questions" + ] + blueprint = state.metadata['blueprint'] + + for field in question_fields: + if field not in state.metadata: + continue + + questions = state.metadata[field] + if not isinstance(questions, list): + continue + + validated_questions = [] + + for qa in questions: + question = qa.get("question", "") + answer = qa.get("answer", "") + + if not question or not answer: + continue + + # Create validation prompt + validation_prompt = f"""You are validating a Visual Question Answering (VQA) pair for a Factorio blueprint analysis task. + +Question: +``` +{question} +``` +Answer: `{answer}` + +Please evaluate if this Q&A pair meets the following criteria: + +1. **Specificity**: Is the question specific enough that it has a single, unambiguous answer? +2. **Visual Answerability**: Can the question be answered by looking at a blueprint image? +3. **Clarity**: Is the question clearly worded without confusing terminology? +4. **Answer Match**: Does the provided answer directly and completely answer the question? +5. **Triviality/Tautology**: Is there actual informational content in the question? Or is it self-referential? + +Common issues to check for: +- Vague positional references (e.g., "the inserter" when there are multiple) +- Unclear directional terms (using numbers instead of compass directions) +- Ambiguous entity references without specific positions +- Questions that require game knowledge beyond what's visible + +If the Q&A pair has issues, provide a revised version that fixes them. + +If the question includes multiple choice - it is critical that you keep them! + +Return your response in this exact JSON format: +```json +{{ + "is_valid": true/false, + "issues": ["list of specific issues if any"], + "revised_question": "improved question if needed", + "revised_answer": "improved answer if needed", + "explanation": "brief explanation of changes" +}} +```""" + + # Validate the Q&A pair + state.messages = [ChatMessageUser(content=validation_prompt)] + response = await generate(state) + + try: + completion = response.output.completion + json_match = re.search(r'```json\s*\n(.*?)\n```', completion, re.DOTALL) + + if json_match: + validation_result = json.loads(json_match.group(1)) + + if validation_result.get("is_valid", False): + # Keep original if valid + validated_questions.append(qa) + else: + # Use revised version + revised_qa = qa.copy() + revised_qa["question"] = validation_result.get("revised_question", question) + revised_qa["answer"] = validation_result.get("revised_answer", answer) + revised_qa["validation_notes"] = { + "original_question": question, + "original_answer": answer, + "issues": validation_result.get("issues", []), + "explanation": validation_result.get("explanation", "") + } + validated_questions.append(revised_qa) + else: + # If parsing fails, keep original + validated_questions.append(qa) + + except (json.JSONDecodeError, AttributeError): + # If validation fails, keep original but mark + qa["validation_failed"] = True + validated_questions.append(qa) + + # Update metadata with validated questions + state.metadata[field] = validated_questions + + return state + + return solve + + +@solver +def convert_directions_to_compass() -> Solver: + """ + Solver that converts numeric directions to compass directions. + + Converts Factorio's numeric direction system: + - 0 → North/Up + - 2 → East/Right + - 4 → South/Down + - 6 → West/Left + """ + + # Direction mapping + direction_map = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" + } + + async def solve(state: TaskState, generate: Generate) -> TaskState: + # Convert directions in all question types + question_fields = [ + "basic_questions", "position_questions", "counting_questions", + "spatial_questions", "qa_pairs" + ] + + for field in question_fields: + if field not in state.metadata: + continue + + questions = state.metadata[field] + if not isinstance(questions, list): + continue + + for qa in questions: + # Update question text + question = qa.get("question", "") + answer = qa.get("answer", "") + + # Replace direction references + for num_dir, compass_dir in direction_map.items(): + # Replace in questions + question = re.sub( + rf'\b(direction|facing)\s*{num_dir}\b', + f'facing {compass_dir}', + question, + flags=re.IGNORECASE + ) + question = re.sub( + rf'\bdirection\s*=\s*{num_dir}\b', + f'facing {compass_dir}', + question, + flags=re.IGNORECASE + ) + + # Replace in answers + answer = re.sub( + rf'\b{num_dir}\b', + compass_dir, + answer + ) + + qa["question"] = question + qa["answer"] = answer + + # Update entity properties if present + if "entity_properties" in qa and "direction" in qa["entity_properties"]: + direction_value = qa["entity_properties"]["direction"] + if isinstance(direction_value, (int, float)) and direction_value in direction_map: + qa["entity_properties"]["direction_compass"] = direction_map[direction_value] + + return state + + return solve + + +@solver +def normalize_position_format() -> Solver: + """ + Solver that converts position references from (x, y) format to Position(x={x}, y={y}) format. + + This solver ensures consistent position formatting across all QA pairs. + """ + + async def solve(state: TaskState, generate: Generate) -> TaskState: + # Convert positions in all question types + question_fields = [ + "basic_questions", "position_questions", "counting_questions", + "spatial_questions", "state_questions", "inventory_questions", + "qa_pairs", "next_action_questions", "construction_order_questions", + "throughput_questions", "bottleneck_questions", "optimization_questions", + "direction_questions" + ] + + for field in question_fields: + if field not in state.metadata: + continue + + questions = state.metadata[field] + if not isinstance(questions, list): + continue + + normalized_questions = [] + for qa in questions: + # Normalize position format in question and answer + normalized_qa = normalize_position_references_in_qa(qa) + normalized_questions.append(normalized_qa) + + # Update metadata with normalized questions + state.metadata[field] = normalized_questions + + return state + + return solve + + +@solver +def render_blueprint_image() -> Solver: + """ + Solver that renders and saves the blueprint image once per task. + + This solver ensures that only one image is generated per blueprint, + preventing duplicate images when multiple solvers run on the same blueprint. + + Should be run early in the solver chain. + """ + instance = create_factorio_instance() + + async def solve(state: TaskState, generate: Generate) -> TaskState: + # Check if image is already rendered + if "image" in state.metadata: + return state + + blueprint = state.metadata.get("blueprint", {}) + if not blueprint: + return state + + # Render the image (use a copy to avoid modifying the original blueprint) + import copy + blueprint_copy = copy.deepcopy(blueprint) + image: RenderedImage = instance.namespace._render(blueprint=blueprint_copy) + + # Save the image using the new folder structure + from data.vqa.image_utils import save_rendered_image + image_id = save_rendered_image(image, blueprint, state.metadata) + + # Store the image ID in metadata for other solvers to use + state.metadata["image"] = image_id + + return state + + return solve + + +@solver +def attach_bounding_box() -> Solver: + """ + Solver that calculates and attaches the blueprint bounding box to metadata. + + This ensures the bounding box information is available for grounding positions + in questions and answers, and gets included in the JSONL output. + """ + + async def solve(state: TaskState, generate: Generate) -> TaskState: + blueprint = state.metadata.get("blueprint", {}) + + if blueprint: + # Calculate bounding box + bounding_box = calculate_blueprint_bounding_box(blueprint) + + # Attach to metadata + state.metadata["bounding_box"] = bounding_box + + # Also calculate and attach center point for convenience + center_x = (bounding_box["min_x"] + bounding_box["max_x"]) / 2 + center_y = (bounding_box["min_y"] + bounding_box["max_y"]) / 2 + state.metadata["blueprint_center"] = {"x": center_x, "y": center_y} + + return state + + return solve + + +@solver +def generate_direction_questions(questions_per_blueprint: int = 2) -> Solver: + """ + Solver that generates questions about entity orientations using Direction enums. + + This solver analyzes blueprint entities that have directional properties + and generates questions about their orientations using the Direction enum. + + Args: + questions_per_blueprint: Number of direction questions to generate per blueprint + """ + + async def solve(state: TaskState, generate: Generate) -> TaskState: + blueprint = state.metadata.get("blueprint", {}) + entities = blueprint.get("entities", []) + direction_system = detect_direction_system(blueprint) + + # Filter entities that have direction properties + directional_entities = [] + for entity in entities: + if "direction" in entity and entity.get("direction") is not None: + directional_entities.append(entity) + + if not directional_entities: + # No directional entities, skip generation + state.metadata["direction_questions"] = [] + return state + + # Create prompt for generating direction questions + entity_info = [] + for entity in directional_entities[:10]: # Limit to first 10 for prompt length + pos = entity.get("position", {}) + direction_val = entity.get("direction", 0) + direction_enum = Direction.from_value(direction_val, direction_system) + entity_info.append({ + "name": entity.get("name", "unknown"), + "position": f"Position(x={pos.get('x', 0)}, y={pos.get('y', 0)})", + "direction": direction_enum.name if direction_enum else f"Direction({direction_val})" + }) + + # Generate direction-focused questions + direction_prompt = f"""You are analyzing a Factorio blueprint and need to generate {questions_per_blueprint} questions about entity orientations. + +Blueprint has {len(directional_entities)} entities with directional properties: +{json.dumps(entity_info, indent=2)} + +Generate {questions_per_blueprint} questions about entity orientations. Focus on: + +1. **Specific entity directions**: Ask about the direction/orientation of specific entities +2. **Relative orientations**: Compare directions between entities +3. **Direction patterns**: Identify orientation patterns in the layout +4. **Functional directions**: Questions about how entity directions affect function + +**Important guidelines:** +- Use Direction enum values in answers: Direction.NORTH, Direction.SOUTH, Direction.EAST, Direction.WEST +- Reference entities by their exact positions using Position(x=X, y=Y) format +- Be specific about which entity you're asking about +- Focus on orientations that are visually apparent and functionally relevant + +Return your response as a JSON array of question-answer pairs: +```json +[ + {{ + "question": "What direction is the [entity] facing at Position(x=X, y=Y)?", + "answer": "Direction.NORTH", + "entity_type": "entity_name", + "position": {{"x": X, "y": Y}}, + "direction_value": 0, + "question_type": "entity_direction" + }} +] +```""" + + # Generate the questions + state.messages = [ChatMessageUser(content=direction_prompt)] + response = await generate(state) + + try: + completion = response.output.completion + json_match = re.search(r'```json\s*\n(.*?)\n```', completion, re.DOTALL) + + if json_match: + direction_questions = json.loads(json_match.group(1)) + + # Validate and clean up the questions + validated_questions = [] + for qa in direction_questions[:questions_per_blueprint]: + if isinstance(qa, dict) and "question" in qa and "answer" in qa: + # Ensure answer uses Direction enum format + answer = qa["answer"] + if not answer.startswith("Direction."): + # Try to convert numeric or string directions to Direction enum + direction = Direction.from_value(answer, direction_system) + if direction: + qa["answer"] = f"Direction.{direction.name}" + + validated_questions.append(qa) + + state.metadata["direction_questions"] = validated_questions + else: + state.metadata["direction_questions"] = [] + + except (json.JSONDecodeError, AttributeError): + state.metadata["direction_questions"] = [] + + return state + + return solve \ No newline at end of file diff --git a/data/vqa/dataset.py b/data/vqa/dataset.py new file mode 100644 index 000000000..0f34621ac --- /dev/null +++ b/data/vqa/dataset.py @@ -0,0 +1,289 @@ +import copy +import json +from typing import List + +from inspect_ai.dataset import MemoryDataset, Sample + +from data.vqa.blueprint_subchunks import SubchunkConfig, generate_subchunks +from data.vqa.utils import find_blueprints_dir + +from typing import List, Union, Optional +from inspect_ai.dataset import Dataset, Sample, MemoryDataset +from data.vqa.blueprint_transforms import generate_flipped_blueprints, update_metadata_for_flip, FlipType, detect_direction_system + + +def create_flip_augmented_dataset(base_dataset: Dataset, + include_flips: List[str] = None) -> MemoryDataset: + """ + Create a flip-augmented dataset from a base dataset. + + Args: + base_dataset: The original dataset + include_flips: List of flip type names to include (e.g., ["none", "horizontal", "vertical", "both"]) + If None, includes all flip types + + Returns: + MemoryDataset with flipped variations + """ + if include_flips is None: + flip_types = list(FlipType) + else: + flip_map = {f.value: f for f in FlipType} + # Also support shorthand names + shorthand_map = { + "h": FlipType.HORIZONTAL, + "v": FlipType.VERTICAL, + "hv": FlipType.BOTH, + "original": FlipType.NONE, + "h_flip": FlipType.HORIZONTAL, + "v_flip": FlipType.VERTICAL, + "hv_flip": FlipType.BOTH + } + + flip_types = [] + for name in include_flips: + name_lower = name.lower() + if name_lower in flip_map: + flip_types.append(flip_map[name_lower]) + elif name_lower in shorthand_map: + flip_types.append(shorthand_map[name_lower]) + + augmented_samples = [] + + for original_sample in base_dataset: + blueprint = original_sample.metadata.get("blueprint", {}) + + if not blueprint: + # If no blueprint, just keep original + augmented_samples.append(original_sample) + continue + + # Detect direction system for this blueprint + direction_system = detect_direction_system(blueprint) + + # Generate flipped blueprints + flipped_blueprints = generate_flipped_blueprints(blueprint, direction_system) + + for flip_type in flip_types: + flipped_blueprint = flipped_blueprints[flip_type] + + # Create new sample with flipped blueprint + new_metadata = update_metadata_for_flip(original_sample.metadata, flip_type, direction_system) + new_metadata["blueprint"] = flipped_blueprint + new_metadata["original_filename"] = original_sample.metadata.get("filename", "") + + # Create unique ID for the flipped sample + flip_suffix = { + FlipType.NONE: "original", + FlipType.HORIZONTAL: "h_flip", + FlipType.VERTICAL: "v_flip", + FlipType.BOTH: "hv_flip" + }[flip_type] + + new_sample = Sample( + input=original_sample.input, + target=original_sample.target, + metadata=new_metadata, + id=f"{original_sample.id}_{flip_suffix}" if original_sample.id else None, + files=original_sample.files + ) + + augmented_samples.append(new_sample) + + return MemoryDataset(samples=augmented_samples) + + +def create_single_flip_dataset(base_dataset: Dataset, flip: str) -> MemoryDataset: + """ + Create a dataset with only a single flip type applied. + + Args: + base_dataset: The original dataset + flip: Flip type name ("none", "horizontal", "vertical", "both") + or shorthand ("h", "v", "hv", "original") + + Returns: + MemoryDataset with single flip type + """ + return create_flip_augmented_dataset(base_dataset, [flip]) + + +def create_all_flips_dataset(base_dataset: Dataset) -> MemoryDataset: + """ + Create a dataset with all possible flips (4x augmentation). + + Args: + base_dataset: The original dataset + + Returns: + MemoryDataset with all flip variations + """ + return create_flip_augmented_dataset(base_dataset, None) + +def raw_test_dataset() -> MemoryDataset: + blueprint = { + "icons": [ + { + "signal": { + "type": "item", + "name": "transport-belt" + }, + "index": 1 + } + ], + "entities": [ + { + "name": "transport-belt", + "position": { + "x": -0.5, + "y": -0.5 + }, + "direction": 12, + "entity_number": 1 + }, + { + "name": "transport-belt", + "position": { + "x": 0.5, + "y": -0.5 + }, + "direction": 8, + "entity_number": 1 + } + ], + "item": "blueprint", + "version": 281479274299391, + "label": "Blueprint" + } + dataset = MemoryDataset(samples=[ + Sample( + input="dummpy", + metadata={"filename": "dummy", "blueprint": blueprint}, + ) + ]) + return dataset + +def raw_blueprint_dataset() -> MemoryDataset: + # Load blueprints from directory + blueprint_dir = find_blueprints_dir() + samples = [] + + for blueprint_path in blueprint_dir.glob("*.json"): + with open(blueprint_path, 'r') as f: + blueprint_json = f.read() + + blueprint = json.loads(blueprint_json) + sample = Sample( + input=blueprint['label'] if 'label' in blueprint else blueprint_path.name, + metadata={"filename": blueprint_path.name, "blueprint": blueprint}, + ) + samples.append(sample) + + # Create dataset + dataset = MemoryDataset(samples=samples) + return dataset + + +def augmented_blueprint_dataset() -> MemoryDataset: + """ + Create an augmented blueprint dataset with rotations. + + Args: + rotations: List of rotation names to include (e.g., ["north", "east"]) + If None, includes all 4 rotations + + Returns: + MemoryDataset with rotated blueprint variations + """ + base_dataset = raw_blueprint_dataset() + return create_all_flips_dataset(base_dataset) + + +def create_combined_augmented_dataset(base_dataset: Dataset, + include_flips: List[str] = None, + chunk_configs: List[SubchunkConfig] = None) -> MemoryDataset: + """ + Create a dataset with both flip and subchunk augmentations. + + Args: + base_dataset: The original dataset + include_flips: List of flip types to include + chunk_configs: List of SubchunkConfig objects + + Returns: + MemoryDataset with combined augmentations + """ + # First apply flip augmentation + flip_augmented = create_flip_augmented_dataset(base_dataset, include_flips) + + # Then apply subchunk augmentation to the flipped dataset + if chunk_configs is None: + chunk_configs = [ + SubchunkConfig((10, 10), (5, 5)), + SubchunkConfig((15, 15), (10, 10)), + SubchunkConfig((20, 20), (10, 10)) + ] + + augmented_samples = [] + + for sample in flip_augmented: + blueprint = sample.metadata.get("blueprint", {}) + + if not blueprint: + augmented_samples.append(sample) + continue + + # Add the full blueprint + augmented_samples.append(sample) + + # Generate subchunks for each config + for config in chunk_configs: + subchunks = generate_subchunks(blueprint, config) + + for i, chunk_blueprint in enumerate(subchunks): + # Create new sample with combined metadata + new_metadata = copy.deepcopy(sample.metadata) + new_metadata["blueprint"] = chunk_blueprint + new_metadata["augmentation_type"] = "combined" + new_metadata["subchunk_config"] = { + "chunk_size": config.chunk_size, + "step_size": config.step_size, + "chunk_index": i + } + + # Create unique ID + flip_part = sample.metadata.get("flip_suffix", "original") + chunk_part = f"chunk_{config.chunk_size[0]}x{config.chunk_size[1]}_{i}" + + new_sample = Sample( + input=sample.input, + target=sample.target, + metadata=new_metadata, + id=f"{sample.input}_{chunk_part}_{flip_part}", + files=sample.files + ) + print(new_sample.id) + + augmented_samples.append(new_sample) + + return MemoryDataset(samples=augmented_samples) + + +def augmented_blueprint_dataset_with_chunks() -> MemoryDataset: + """ + Create an augmented blueprint dataset with both rotations and subchunks. + """ + base_dataset = raw_blueprint_dataset() + base_dataset.samples = [base_dataset.samples[0]] + # Define chunk configurations + chunk_configs = [ + SubchunkConfig((10, 10), (5, 5), min_entities=5), + #SubchunkConfig((15, 15), (7, 7), min_entities=8), + SubchunkConfig((20, 20), (10, 10), min_entities=10) + ] + + return create_combined_augmented_dataset( + base_dataset, + include_flips=["none", "horizontal", "vertical", "both"], + chunk_configs=chunk_configs + ) \ No newline at end of file diff --git a/data/vqa/direction_utils.py b/data/vqa/direction_utils.py new file mode 100644 index 000000000..39810750f --- /dev/null +++ b/data/vqa/direction_utils.py @@ -0,0 +1,149 @@ +"""Direction utilities for VQA tasks.""" + +import enum +from typing import Union, Optional + +from data.vqa.blueprint_transforms import DirectionSystem + + +class Direction(enum.Enum): + """Direction enum matching Factorio's internal direction system.""" + + UP = NORTH = 0 + RIGHT = EAST = 2 + DOWN = SOUTH = 4 + LEFT = WEST = 6 + + @classmethod + def opposite(cls, direction: 'Direction') -> 'Direction': + """Get the opposite direction.""" + return cls((direction.value + 4) % 8) + + @classmethod + def next_clockwise(cls, direction: 'Direction') -> 'Direction': + """Get the next direction clockwise.""" + return cls((direction.value + 2) % 8) + + @classmethod + def next_counterclockwise(cls, direction: 'Direction') -> 'Direction': + """Get the next direction counterclockwise.""" + return cls((direction.value - 2) % 8) + + @classmethod + def to_factorio_direction(cls, direction: 'Direction') -> int: + """Convert to Factorio's numeric direction (0-3).""" + return direction.value // 2 + + @classmethod + def from_factorio_direction(cls, direction: int) -> 'Direction': + """Convert from Factorio's numeric direction (0-3) to enum.""" + return cls(direction * 2) + + @classmethod + def from_value(cls, v: Union[int, str], direction_system: DirectionSystem) -> Optional['Direction']: + """Convert a value (int or string) to Direction enum.""" + value = v + + if isinstance(value, int): + if direction_system == DirectionSystem.NEW_SYSTEM: + if v == 0: + return cls.NORTH + elif v == 4: + return cls.EAST + elif v == 8: + return cls.SOUTH + else: + return cls.WEST + elif value in [0, 2, 4, 6]: + return cls(value) + elif value in [0, 1, 2, 3]: + return cls.from_factorio_direction(value) + + elif isinstance(value, str): + # Handle string names + value_upper = value.upper() + for direction in cls: + if direction.name == value_upper: + return direction + return None + + def to_compass_string(self) -> str: + """Get lowercase compass direction string.""" + if self == Direction.NORTH: + return "north" + elif self == Direction.EAST: + return "east" + elif self == Direction.SOUTH: + return "south" + elif self == Direction.WEST: + return "west" + + def to_relative_string(self) -> str: + """Get relative direction string.""" + if self == Direction.UP: + return "up" + elif self == Direction.RIGHT: + return "right" + elif self == Direction.DOWN: + return "down" + elif self == Direction.LEFT: + return "left" + + +def convert_numeric_direction(direction_value: Union[int, float, str], direction_system) -> str: + """ + Convert numeric direction to compass string. + + Args: + direction_value: Numeric direction (0,2,4,6) or string + + Returns: + Compass direction string (north/east/south/west) + """ + if isinstance(direction_value, (int, float)): + direction = Direction.from_value(int(direction_value), direction_system) + if direction: + return direction.to_compass_string() + return str(direction_value) + + +def format_direction_in_text(text: str) -> str: + """ + Replace numeric directions in text with compass directions. + + Args: + text: Text containing direction references + + Returns: + Text with compass directions + """ + import re + + # Pattern to match direction references + patterns = [ + (r'\bdirection\s*=?\s*(\d)', 'direction_equals'), + (r'\bfacing\s+(\d)', 'facing'), + (r'\bdirection\s+(\d)', 'direction'), + ] + + result = text + for pattern, pattern_type in patterns: + matches = list(re.finditer(pattern, result, re.IGNORECASE)) + + # Process matches in reverse to preserve positions + for match in reversed(matches): + dir_value = int(match.group(1)) + direction = Direction.from_value(dir_value) + + if direction: + compass = direction.to_compass_string() + if pattern_type == 'direction_equals': + replacement = f'facing {compass}' + elif pattern_type == 'facing': + replacement = f'facing {compass}' + else: + replacement = f'facing {compass}' + + result = result[:match.start()] + replacement + result[match.end():] + + return result \ No newline at end of file diff --git a/data/vqa/hook.py b/data/vqa/hook.py new file mode 100644 index 000000000..cca6008a8 --- /dev/null +++ b/data/vqa/hook.py @@ -0,0 +1,603 @@ +# qa_hook.py +import json +import os +from datetime import datetime +from pathlib import Path +from typing import List, Dict, Any, Optional + +from inspect_ai.hooks import Hooks, SampleEnd, TaskStart, hooks, TaskEnd + +from inspect_ai.log import EvalConfig, EvalLog, EvalSample + + +class VQAPairsSerializer: + """Serializer for VQA pairs that collects and saves QA data to JSONL files.""" + + def __init__(self, output_dir: str = "./dataset"): + """ + Initialize the serializer. + + Args: + output_dir: Directory where JSONL files will be saved + """ + self.output_dir = Path(output_dir) + self.output_dir.mkdir(parents=True, exist_ok=True) + + def collect_qa_pairs(self, sample: EvalSample) -> List[Dict[str, Any]]: + """ + Collect all QA pairs from a sample's metadata. + + Args: + sample: The evaluation sample containing QA pairs in metadata + + Returns: + List of QA pair dictionaries with normalized structure + """ + qa_pairs = [] + metadata = sample.metadata + + # Collect from different task types + task_qa_fields = [ + # Basic tasks + ("basic_questions", self._normalize_basic_qa), + ("position_questions", self._normalize_position_qa), + ("counting_questions", self._normalize_counting_qa), + ("direction_questions", self._normalize_direction_qa), + + # Spatial reasoning tasks + ("spatial_questions", self._normalize_spatial_qa), + + # Spatial reasoning tasks + ("contrastive_alignment", self._contrastive_alignment), + + # State prediction tasks + ("state_questions", self._normalize_state_qa), + ("inventory_questions", self._normalize_inventory_qa), + + # Denoising tasks + ("qa_pairs", self._normalize_denoising_qa), + + # Action prediction tasks + ("next_action_questions", self._normalize_action_qa), + ("construction_order_questions", self._normalize_construction_qa), + + # Productivity planning tasks + ("throughput_questions", self._normalize_throughput_qa), + ("bottleneck_questions", self._normalize_bottleneck_qa), + ("optimization_questions", self._normalize_optimization_qa), + + # Terrain tasks + ("nearest_questions", self._normalize_nearest_qa), + ("nearest_buildable_questions", self._normalize_nearest_buildable_qa), + ("nearest_buildable_resource_questions", self._normalize_nearest_buildable_resource_qa), + ("tile_count_questions", self._normalize_tile_count_qa), + ("character_localisation_question", self._normalize_character_localisation_qa), + ] + + for field_name, normalizer in task_qa_fields: + if field_name in metadata: + field_data = metadata[field_name] + if isinstance(field_data, list): + for qa in field_data: + normalized = normalizer(qa, metadata) + if 'image_id' not in normalized.keys(): + normalized['image_id'] = metadata.get('image', '') + if normalized: + qa_pairs.append(normalized) + + return qa_pairs + + def _add_global_metadata(self, normalized: Dict[str, Any], metadata: Dict) -> None: + """ + Add global metadata (bounding box, blueprint center, rotation, etc.) to a normalized QA pair. + """ + + # Add bounding box if present + if "bounding_box" in metadata: + normalized["bounding_box"] = metadata["bounding_box"] + + # Add blueprint center if present + if "blueprint_center" in metadata: + normalized["blueprint_center"] = metadata["blueprint_center"] + + # Add rotation information if present + if "rotation" in metadata: + normalized["rotation"] = metadata["rotation"] + + if "rotation_degrees" in metadata: + normalized["rotation_degrees"] = metadata["rotation_degrees"] + + if "original_filename" in metadata: + normalized["original_filename"] = metadata["original_filename"] + + def _normalize_basic_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize basic QA pairs (entity name/position questions).""" + normalized = { + "task_type": "basic", + "question": qa.get("question", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "blueprint_file": metadata.get("filename", ""), + "entity_properties": qa.get("entity_properties", {}), + "position": qa.get("position", {}), + "question_type": qa.get("question_type", "open_ended"), + } + + # Add multiple choice options if present + if "options" in qa: + normalized["options"] = qa["options"] + normalized["answer_text"] = qa.get("answer_text", "") + + # Add global metadata + self._add_global_metadata(normalized, metadata) + return normalized + + def _normalize_position_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize position QA pairs.""" + normalized = { + "task_type": "position", + "question": qa.get("question", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "blueprint_file": metadata.get("filename", ""), + "entity": qa.get("entity", {}), + "context": qa.get("context", {}), + "question_type": qa.get("question_type", "open_ended"), + } + + # Add multiple choice options if present + if "options" in qa: + normalized["options"] = qa["options"] + normalized["answer_text"] = qa.get("answer_text", "") + + # Add global metadata + self._add_global_metadata(normalized, metadata) + return normalized + + def _normalize_counting_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize counting QA pairs.""" + normalized = { + "task_type": "counting", + "question": qa.get("question", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "blueprint_file": metadata.get("filename", ""), + "explanation": qa.get("explanation", ""), + "context": qa.get("context", {}), + "question_type": qa.get("question_type", "open_ended"), + } + + # Add multiple choice options if present + if "options" in qa: + normalized["options"] = qa["options"] + normalized["answer_text"] = qa.get("answer_text", "") + + # Add global metadata + self._add_global_metadata(normalized, metadata) + return normalized + + def _normalize_direction_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize direction QA pairs.""" + normalized = { + "task_type": "direction", + "question": qa.get("question", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "blueprint_file": metadata.get("filename", ""), + "entity": qa.get("entity", {}), + "direction_type": qa.get("direction_type", ""), + "direction_enum": qa.get("direction_enum", ""), + } + + # Add global metadata + self._add_global_metadata(normalized, metadata) + return normalized + + def _normalize_spatial_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize spatial reasoning QA pairs.""" + normalized = { + "task_type": "spatial_reasoning", + "question": qa.get("question", "") or qa.get("spatial_question", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "blueprint_file": metadata.get("filename", ""), + "metadata": qa.get("metadata", {}), + "nearby_entities": qa.get("nearby_entities", []), + } + + # Add global metadata + self._add_global_metadata(normalized, metadata) + return normalized + + def _contrastive_alignment(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + + normalized = { + "task_type": "contrastive_alignment", + "question": qa.get("question", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "blueprint_file": metadata.get("filename", ""), + } + + # Add global metadata + self._add_global_metadata(normalized, metadata) + return normalized + + def _normalize_state_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize state prediction QA pairs.""" + normalized = { + "task_type": "state_prediction", + "question": qa.get("question", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "blueprint_file": metadata.get("filename", ""), + "entity_type": qa.get("entity_type", ""), + } + + # Add global metadata + self._add_global_metadata(normalized, metadata) + return normalized + + def _normalize_inventory_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize inventory QA pairs.""" + normalized = { + "task_type": "inventory", + "question": qa.get("question", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "blueprint_file": metadata.get("filename", ""), + "item": qa.get("item", ""), + "quantity": qa.get("quantity", 0), + } + + # Add global metadata + self._add_global_metadata(normalized, metadata) + return normalized + + def _normalize_denoising_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize denoising QA pairs.""" + base = { + "task_type": "denoising", + "question": qa.get("question", "") or qa.get("spatial_question", ""), + "answer": qa.get("answer", ""), + "image_id": qa.get("image", "") or metadata.get("image", ""), + "blueprint_file": metadata.get("filename", ""), + "removed_entity": qa.get("removed_entity", {}), + "position": qa.get("position", {}), + } + + # Include validation results if present + if "validation_result" in qa: + base["validation_result"] = qa["validation_result"] + + # Include spatial context if present + if "nearby_entities" in qa: + base["nearby_entities"] = qa["nearby_entities"] + + # Add global metadata + self._add_global_metadata(base, metadata) + return base + + def _normalize_action_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize action prediction QA pairs.""" + normalized = { + "task_type": "action_prediction", + "question": qa.get("question_prompt", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "blueprint_file": metadata.get("filename", ""), + "previous_actions": [a.get("action", "") for a in qa.get("previous_actions", [])], + "split_point": qa.get("split_point", 0), + } + + # Add global metadata + self._add_global_metadata(normalized, metadata) + return normalized + + def _normalize_construction_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize construction order QA pairs.""" + normalized = { + "task_type": "construction_order", + "question": qa.get("question", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "blueprint_file": metadata.get("filename", ""), + "entity_names": qa.get("entity_names", []), + } + + # Add global metadata + self._add_global_metadata(normalized, metadata) + return normalized + + def _normalize_throughput_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize throughput QA pairs.""" + normalized = { + "task_type": "throughput", + "question": qa.get("question", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "blueprint_file": metadata.get("filename", ""), + "calculated_throughput": qa.get("calculated_throughput", 0), + } + + # Add global metadata + self._add_global_metadata(normalized, metadata) + return normalized + + def _normalize_bottleneck_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize bottleneck QA pairs.""" + normalized = { + "task_type": "bottleneck", + "question": qa.get("question", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "blueprint_file": metadata.get("filename", ""), + "analysis_type": qa.get("analysis_type", ""), + } + + # Add global metadata + self._add_global_metadata(normalized, metadata) + return normalized + + def _normalize_optimization_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize optimization QA pairs.""" + normalized = { + "task_type": "optimization", + "question": qa.get("question", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "blueprint_file": metadata.get("filename", ""), + "entity_counts": qa.get("entity_counts", {}), + "total_entities": qa.get("total_entities", 0), + } + + # Add global metadata + self._add_global_metadata(normalized, metadata) + return normalized + + # New terrain task normalizers + def _normalize_nearest_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize nearest resource QA pairs.""" + normalized = { + "task_type": "nearest_resource", + "question": qa.get("question", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "entity_properties": qa.get("entity_properties", ""), + "nearest": qa.get("nearest", {}), + "question_type": qa.get("question_type", "open_ended"), + "terrain_position": {"x": metadata.get("x", 0), "y": metadata.get("y", 0)}, + } + + # Add multiple choice options if present + if "options" in qa: + normalized["options"] = qa["options"] + + return normalized + + def _normalize_nearest_buildable_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize nearest buildable position QA pairs.""" + normalized = { + "task_type": "nearest_buildable", + "question": qa.get("question", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "prototype": qa.get("prototype", ""), + "building_box": qa.get("building_box", {}), + "center_position": qa.get("center_position", {}), + "buildable_area": qa.get("buildable_area", {}), + "question_type": qa.get("question_type", "open_ended"), + "terrain_position": {"x": metadata.get("x", 0), "y": metadata.get("y", 0)}, + } + + # Add multiple choice options if present + if "options" in qa: + normalized["options"] = qa["options"] + normalized["correct_index"] = qa.get("correct_index", -1) + + return normalized + + def _normalize_nearest_buildable_resource_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize nearest buildable resource-dependent QA pairs.""" + normalized = { + "task_type": "nearest_buildable_resource", + "question": qa.get("question", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "prototype": qa.get("prototype", ""), + "resource_type": qa.get("resource_type", ""), + "building_box": qa.get("building_box", {}), + "buildable_position": qa.get("buildable_position", {}), + "question_type": qa.get("question_type", "open_ended"), + "terrain_position": {"x": metadata.get("x", 0), "y": metadata.get("y", 0)}, + } + + # Add multiple choice options if present + if "options" in qa: + normalized["options"] = qa["options"] + normalized["correct_index"] = qa.get("correct_index", -1) + + return normalized + + def _normalize_tile_count_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize tile count QA pairs.""" + normalized = { + "task_type": "tile_count", + "question": qa.get("question", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "entity_properties": qa.get("entity_properties", ""), + "count": qa.get("count", 0), + "question_type": qa.get("question_type", "open_ended"), + "terrain_position": {"x": metadata.get("x", 0), "y": metadata.get("y", 0)}, + } + + # Add multiple choice options if present + if "options" in qa: + normalized["options"] = qa["options"] + + return normalized + + def _normalize_character_localisation_qa(self, qa: Dict, metadata: Dict) -> Dict[str, Any]: + """Normalize character localisation QA pairs.""" + normalized = { + "task_type": "character_localisation", + "question": qa.get("question", ""), + "answer": qa.get("answer", ""), + "image_id": metadata.get("image", ""), + "position": qa.get("position", {}), + "entity_properties": qa.get("entity_properties", {}), + "question_type": qa.get("question_type", "open_ended"), + "terrain_position": {"x": metadata.get("x", 0), "y": metadata.get("y", 0)}, + } + + return normalized + + def save_qa_pairs(self, qa_pairs: List[Dict[str, Any]], task_name: str, + timestamp: Optional[str] = None) -> Path: + """ + Save QA pairs to a JSONL file. + + Args: + qa_pairs: List of normalized QA pair dictionaries + task_name: Name of the task (used in filename) + timestamp: Optional timestamp string (defaults to current time) + + Returns: + Path to the saved JSONL file + """ + if timestamp is None: + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + + filename = f"{task_name}_{timestamp}.jsonl" + filepath = self.output_dir / filename + + if qa_pairs: + with open(filepath, 'w') as f: + for qa_pair in qa_pairs: + # Add metadata + qa_pair["timestamp"] = timestamp + qa_pair["task_name"] = task_name + + # Write as JSONL + f.write(json.dumps(qa_pair) + '\n') + + return filepath + + def save_from_eval_log(self, eval_log: EvalLog) -> Path: + """ + Extract and save all QA pairs from an evaluation log. + + Args: + eval_log: The evaluation log containing samples with QA pairs + + Returns: + Path to the saved JSONL file + """ + all_qa_pairs = [] + + for sample in eval_log.samples: + qa_pairs = self.collect_qa_pairs(sample) + all_qa_pairs.extend(qa_pairs) + + # Extract task name from eval metadata + task_name = eval_log.eval.task or "unknown_task" + timestamp = eval_log.eval.created or datetime.now().strftime("%Y%m%d_%H%M%S") + + return self.save_qa_pairs(all_qa_pairs, task_name, timestamp) + + def merge_jsonl_files(self, pattern: str = "*.jsonl", output_file: str = "merged_qa_pairs.jsonl") -> Path: + """ + Merge multiple JSONL files into a single file. + + Args: + pattern: Glob pattern to match JSONL files + output_file: Name of the merged output file + + Returns: + Path to the merged file + """ + merged_path = self.output_dir / output_file + + with open(merged_path, 'w') as outfile: + for jsonl_file in self.output_dir.glob(pattern): + if jsonl_file.name != output_file: # Don't read the output file + with open(jsonl_file, 'r') as infile: + for line in infile: + outfile.write(line) + + return merged_path + + def load_qa_pairs(self, filepath: Path) -> List[Dict[str, Any]]: + """ + Load QA pairs from a JSONL file. + + Args: + filepath: Path to the JSONL file + + Returns: + List of QA pair dictionaries + """ + qa_pairs = [] + with open(filepath, 'r') as f: + for line in f: + qa_pairs.append(json.loads(line)) + return qa_pairs + + def get_statistics(self, filepath: Path) -> Dict[str, Any]: + """ + Get statistics about QA pairs in a JSONL file. + + Args: + filepath: Path to the JSONL file + + Returns: + Dictionary with statistics + """ + qa_pairs = self.load_qa_pairs(filepath) + + task_types = {} + question_types = {} + + for qa in qa_pairs: + # Count task types + task_type = qa.get("task_type", "unknown") + task_types[task_type] = task_types.get(task_type, 0) + 1 + + # Count question types (open_ended vs multiple_choice) + question_type = qa.get("question_type", "unknown") + question_types[question_type] = question_types.get(question_type, 0) + 1 + + return { + "total_qa_pairs": len(qa_pairs), + "task_types": task_types, + "question_types": question_types, + "unique_images": len(set(qa.get("image_id", "") for qa in qa_pairs)), + "unique_blueprints": len(set(qa.get("blueprint_file", "") for qa in qa_pairs if qa.get("blueprint_file"))), + "terrain_positions": len(set( + f"{qa.get('terrain_position', {}).get('x', 0)},{qa.get('terrain_position', {}).get('y', 0)}" + for qa in qa_pairs if qa.get("terrain_position") + )), + } + + +@hooks( + name="vqa_pairs_hook", + description="Parses logs and outputs JSONL format." +) +class VQAPairsHook(Hooks): + """Hook that automatically serializes QA pairs after evaluation.""" + + def __init__(self, output_dir: str = "./../../dataset"): + self.serializer = VQAPairsSerializer(output_dir) + + async def on_task_end(self, task: TaskEnd): + """Called after evaluation completes.""" + log = task.log + filepath = self.serializer.save_from_eval_log(log) + stats = self.serializer.get_statistics(filepath) + + print(f"\nVQA Pairs saved to: {filepath}") + print(f"Statistics: {json.dumps(stats, indent=2)}") + + return log \ No newline at end of file diff --git a/data/vqa/image_utils.py b/data/vqa/image_utils.py new file mode 100644 index 000000000..6e51d30ae --- /dev/null +++ b/data/vqa/image_utils.py @@ -0,0 +1,277 @@ +"""Image utilities for VQA tasks - supports both blueprints and game maps.""" + +import hashlib +import os +from pathlib import Path +from typing import Dict, Any, Union, Optional +from datetime import datetime +from fle.commons.models.rendered_image import RenderedImage + + +def get_blueprint_name(blueprint: Dict[str, Any], metadata: Dict[str, Any]) -> str: + """ + Get a clean blueprint name for folder structure. + + Args: + blueprint: Blueprint dictionary + metadata: Metadata containing filename + + Returns: + Clean blueprint name suitable for folder name + """ + # Try to get label first, then fall back to filename + if 'label' in blueprint and blueprint['label']: + name = blueprint['label'] + else: + # Get filename without extension + filename = metadata.get("filename", "unknown") + name = Path(filename).stem + + # Clean the name for filesystem use + # Remove/replace problematic characters + clean_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in name) + + # Ensure it's not empty and not too long + if not clean_name or clean_name == "_": + clean_name = "unknown" + + # Limit length to prevent filesystem issues + if len(clean_name) > 50: + clean_name = clean_name[:50] + + return clean_name + + +def get_map_name(metadata: Dict[str, Any]) -> str: + """ + Get a clean name for game map folder structure. + + Args: + metadata: Metadata containing map information + + Returns: + Clean map name suitable for folder name + """ + # Try different naming strategies for maps + if 'map_name' in metadata and metadata['map_name']: + name = metadata['map_name'] + elif 'location' in metadata and metadata['location']: + name = f"map_{metadata['location']}" + elif 'position' in metadata: + pos = metadata['position'] + if isinstance(pos, dict) and 'x' in pos and 'y' in pos: + name = f"map_{int(pos['x'])}_{int(pos['y'])}" + else: + name = f"map_{str(pos).replace(',', '_').replace(' ', '')}" + elif 'x' in metadata and 'y' in metadata: + name = f"map_{int(metadata['x'])}_{int(metadata['y'])}" + else: + # Use timestamp as fallback + name = f"map_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + + # Clean the name for filesystem use + clean_name = "".join(c if c.isalnum() or c in "-_" else "_" for c in name) + + # Ensure it's not empty + if not clean_name or clean_name == "_": + clean_name = "map_unknown" + + # Limit length + if len(clean_name) > 50: + clean_name = clean_name[:50] + + return clean_name + + +def generate_variant_hash(content: Union[Dict[str, Any], None] = None, + modification_info: str = "", + metadata: Dict[str, Any] = None, + is_map: bool = False) -> str: + """ + Generate a hash representing this specific variant of the blueprint or map. + + Args: + content: Blueprint dictionary or None for maps + modification_info: Additional info about modifications (for denoising, etc.) + metadata: Metadata that may contain rotation info, position, etc. + is_map: Whether this is a game map render + + Returns: + Short hash string for this variant + """ + # Create a string representing this specific variant + variant_components = [] + + if is_map and metadata: + # For maps, use position, radius, layers, etc. + variant_components.extend([ + f"map_render", + str(metadata.get("position", "")), + str(metadata.get("radius", 64)), + str(metadata.get("layers", "all")), + str(metadata.get("include_status", False)), + str(metadata.get("timestamp", "")) + ]) + elif content: + # For blueprints + variant_components.append(str(content)) + + variant_components.append(modification_info) + + # Include rotation information if present + if metadata: + rotation = metadata.get("rotation", "") + rotation_degrees = metadata.get("rotation_degrees", "") + variant_components.extend([rotation, str(rotation_degrees)]) + + variant_string = "|".join(variant_components) + + # Generate a shorter, more readable hash + hash_object = hashlib.md5(variant_string.encode()) + return hash_object.hexdigest()[:12] # Use first 12 characters + + +def generate_image_path_and_id(content: Union[Dict[str, Any], None] = None, + metadata: Dict[str, Any] = None, + modification_info: str = "", + base_dir: str = "../../dataset/images", + is_map: bool = False) -> tuple[str, str]: + """ + Generate the folder structure image path and ID for blueprints or maps. + + Args: + content: Blueprint dictionary or None for maps + metadata: Metadata containing filename or map info + modification_info: Additional info for variants (denoising, etc.) + base_dir: Base directory for images + is_map: Whether this is a game map render + + Returns: + Tuple of (file_path, image_id) where: + - file_path: Full path where image should be saved + - image_id: ID to use in metadata (relative path from base_dir) + """ + if is_map: + name = get_map_name(metadata or {}) + # Add "maps" subdirectory to separate from blueprints + folder_path = Path(base_dir) / "maps"# / name + else: + if not content: + raise ValueError("Blueprint content required when is_map=False") + name = get_blueprint_name(content, metadata or {}) + folder_path = Path(base_dir) / name + + variant_hash = generate_variant_hash(content, modification_info, metadata, is_map) + + # Add rotation/flip prefix to filename if present + prefix = "" + if metadata: + if "flip_suffix" in metadata: + prefix = metadata["flip_suffix"] + "_" + elif is_map and "view_angle" in metadata: + prefix = f"angle_{metadata['view_angle']}_" + + # Create the image ID (relative path from base_dir for metadata) + if is_map: + image_id = f"maps/{name}_{prefix}{variant_hash}" + else: + image_id = f"{name}_{prefix}{variant_hash}" + + # Create the full file path + file_path = folder_path / f"{name}_{prefix}{variant_hash}.jpg" + + return str(file_path), image_id + + +def save_rendered_image(image: RenderedImage, + blueprint: Optional[Dict[str, Any]] = None, + metadata: Optional[Dict[str, Any]] = None, + modification_info: str = "", + base_dir: str = "../../dataset/images", + is_map: bool = False) -> str: + """ + Save a rendered image using the folder structure for blueprints or maps. + + Args: + image: RenderedImage to save + blueprint: Blueprint dictionary (None for maps) + metadata: Metadata containing filename or map info + modification_info: Additional info for variants (denoising, etc.) + base_dir: Base directory for images + is_map: Whether this is a game map render + + Returns: + Image ID for use in metadata + """ + # Validate inputs + if not is_map and blueprint is None: + raise ValueError("Blueprint required when is_map=False") + + if is_map and metadata is None: + # Create minimal metadata for map + metadata = {"timestamp": datetime.now().isoformat()} + + file_path, image_id = generate_image_path_and_id( + content=blueprint, + metadata=metadata or {}, + modification_info=modification_info, + base_dir=base_dir, + is_map=is_map + ) + + # Create directory if it doesn't exist + Path(file_path).parent.mkdir(parents=True, exist_ok=True) + + # Save the image + image.save(file_path) + + return image_id + + +def save_map_render(image: RenderedImage, + position: Optional[Dict[str, float]] = None, + radius: int = 64, + metadata: Optional[Dict[str, Any]] = None, + base_dir: str = "../../dataset/images") -> str: + """ + Convenience function specifically for saving game map renders. + + Args: + image: RenderedImage to save + position: Position dict with x,y coordinates + radius: Render radius + metadata: Additional metadata + base_dir: Base directory for images + + Returns: + Image ID for use in metadata + """ + # Build map-specific metadata + map_metadata = metadata or {} + map_metadata.update({ + "position": position, + "radius": radius, + "timestamp": datetime.now().isoformat() + }) + + return save_rendered_image( + image=image, + blueprint=None, + metadata=map_metadata, + modification_info=f"radius_{radius}", + base_dir=base_dir, + is_map=True + ) + + +def get_legacy_image_id(blueprint: Dict[str, Any]) -> str: + """ + Generate the old-style hash-based image ID for backwards compatibility. + + Args: + blueprint: Blueprint dictionary + + Returns: + Legacy hash-based image ID + """ + return str(hash(str(blueprint))) \ No newline at end of file diff --git a/data/vqa/position_utils.py b/data/vqa/position_utils.py new file mode 100644 index 000000000..29acde329 --- /dev/null +++ b/data/vqa/position_utils.py @@ -0,0 +1,96 @@ +"""Position utilities for VQA tasks.""" + +import re +from typing import Union, Tuple, Dict, Any + + +def format_position(x: Union[int, float], y: Union[int, float]) -> str: + """ + Format a position as Position(x={x}, y={y}). + + Args: + x: X coordinate + y: Y coordinate + + Returns: + Formatted position string + """ + return f"Position(x={x}, y={y})" + + +def format_position_from_dict(position: Dict[str, Union[int, float]]) -> str: + """ + Format a position dictionary as Position(x={x}, y={y}). + + Args: + position: Dictionary with 'x' and 'y' keys + + Returns: + Formatted position string + """ + x = position.get('x', 0) + y = position.get('y', 0) + return format_position(x, y) + + +def convert_coordinate_format_in_text(text: str) -> str: + """ + Convert coordinate references in text from (x, y) format to Position(x={x}, y={y}) format. + + Args: + text: Text containing coordinate references + + Returns: + Text with updated coordinate format + """ + # Pattern to match coordinates in (x, y) format + coordinate_pattern = r'\((-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)\)' + + def replace_coordinate(match): + x = match.group(1) + y = match.group(2) + return f"Position(x={x}, y={y})" + + return re.sub(coordinate_pattern, replace_coordinate, text) + + +def extract_position_from_text(text: str) -> Tuple[float, float]: + """ + Extract position coordinates from Position(x={x}, y={y}) format. + + Args: + text: Text containing position reference + + Returns: + Tuple of (x, y) coordinates, or (0, 0) if not found + """ + pattern = r'Position\(x=(-?\d+(?:\.\d+)?),\s*y=(-?\d+(?:\.\d+)?)\)' + match = re.search(pattern, text) + + if match: + x = float(match.group(1)) + y = float(match.group(2)) + return (x, y) + + return (0.0, 0.0) + + +def normalize_position_references_in_qa(qa_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Normalize position references in a QA pair to use Position(x={x}, y={y}) format. + + Args: + qa_data: QA pair dictionary containing 'question' and 'answer' keys + + Returns: + Updated QA pair with normalized position format + """ + updated_qa = qa_data.copy() + + if 'question' in updated_qa: + updated_qa['question'] = convert_coordinate_format_in_text(updated_qa['question']) + + if 'answer' in updated_qa: + updated_qa['answer'] = convert_coordinate_format_in_text(updated_qa['answer']) + + return updated_qa \ No newline at end of file diff --git a/data/vqa/requirements.txt b/data/vqa/requirements.txt new file mode 100644 index 000000000..d0ffc7c6c --- /dev/null +++ b/data/vqa/requirements.txt @@ -0,0 +1,2 @@ +git+https://github.com/UKGovernmentBEIS/inspect_ai.git +jinja2 \ No newline at end of file diff --git a/data/vqa/solvers.py b/data/vqa/solvers.py new file mode 100644 index 000000000..a373206ba --- /dev/null +++ b/data/vqa/solvers.py @@ -0,0 +1,244 @@ +# In solvers.py +import json +import random +import re + +from inspect_ai.model import ChatMessageUser +from inspect_ai.solver import Solver, solver, TaskState, Generate + +from fle.agents.data.screenshots_from_run import create_factorio_instance +from fle.commons.models.rendered_image import RenderedImage +from .templates import Templates + + +@solver +def generate_blueprint_title_and_purpose() -> Solver: + """Generate both title and purpose description for blueprints.""" + + async def solve(state: TaskState, generate: Generate) -> TaskState: + blueprint = state.metadata.get("blueprint", {}) + + # Generate prompt using Jinja2 template + prompt = Templates.blueprint_title_purpose(blueprint=blueprint) + + state.messages[-1] = ChatMessageUser(content=prompt) + + response = await generate(state) + + completion = response.output.completion + + pattern = r'```json\s*\n(.*?)\n```' + match = re.search(pattern, completion, re.DOTALL) + if match: + json_content = match.group(1) + data = json.loads(json_content) + title = data.get('title') + purpose = data.get('purpose') + + state.metadata["title"] = title + state.metadata["purpose"] = purpose + + return state + + return solve + + +@solver +def entity_removal_denoising(qa_pairs_per_blueprint: int = 5) -> Solver: + """ + Solver that: + 1. Loads a blueprint + 2. Generates multiple QA pairs by removing different entities + 3. Stores all QA pairs for the blueprint + + Args: + qa_pairs_per_blueprint: Number of QA pairs to generate per blueprint + """ + + async def solve(state: TaskState, generate: Generate) -> TaskState: + blueprint = state.metadata.get("blueprint", {}) + + # Initialize QA pairs list + qa_pairs = [] + + # Get entities from blueprint + entities = blueprint.get("entities", []) + if not entities: + state.metadata["error"] = "No entities found in blueprint" + state.metadata["qa_pairs"] = qa_pairs + return state + + # Generate specified number of QA pairs + num_pairs = min(qa_pairs_per_blueprint, len(entities)) + selected_indices = random.sample(range(len(entities)), num_pairs) + + for idx in selected_indices: + removed_entity = entities[idx].copy() + + # Create modified blueprint with entity removed + modified_blueprint = blueprint.copy() + modified_blueprint["entities"] = [e for i, e in enumerate(entities) if i != idx] + + # Store the modification details + position = removed_entity.get("position", {}) + entity_name = removed_entity.get("name", "unknown") + + # Generate a question about the missing entity using template + question_prompt = Templates.denoising_question( + position=position, + entity_name=entity_name + ) + + state.messages = [ChatMessageUser(content=question_prompt)] + question_response = await generate(state) + question = question_response.output.completion.strip() + + # Generate the answer + answer = entity_name + + # Create QA pair + qa_pair = { + "question": question, + "answer": answer, + "removed_entity": removed_entity, + "position": position, + "modified_blueprint": modified_blueprint + } + + qa_pairs.append(qa_pair) + + # Store all QA pairs in metadata + state.metadata["qa_pairs"] = qa_pairs + state.metadata["num_qa_pairs"] = len(qa_pairs) + + return state + + return solve + + +@solver +def validate_denoising_qa() -> Solver: + """ + Solver that validates if another model can answer the denoising questions correctly. + This should be run after entity_removal_denoising. + """ + + async def solve(state: TaskState, generate: Generate) -> TaskState: + qa_pairs = state.metadata.get("qa_pairs", []) + if not qa_pairs: + state.metadata["error"] = "No QA pairs found" + return state + + validated_pairs = [] + + for qa_pair in qa_pairs: + # Prepare validation prompt using template + validation_prompt = Templates.denoising_validation( + modified_blueprint=qa_pair['modified_blueprint'], + question=qa_pair['question'] + ) + + # Clear messages and ask the validation model + state.messages = [ChatMessageUser(content=validation_prompt)] + + validation_response = await generate(state) + predicted_answer = validation_response.output.completion.strip().lower() + + # Check if the answer is correct + correct_answer = qa_pair['answer'].lower() + is_correct = correct_answer in predicted_answer or predicted_answer in correct_answer + + # Add validation result to QA pair + validated_qa = qa_pair.copy() + validated_qa["validation_result"] = { + "predicted": predicted_answer, + "correct": correct_answer, + "is_correct": is_correct + } + + validated_pairs.append(validated_qa) + + state.metadata["qa_pairs"] = validated_pairs + state.metadata["validation_complete"] = True + + return state + + return solve + + +@solver +def generate_spatial_context_question() -> Solver: + """ + Alternative solver that generates more complex spatial reasoning questions + for each QA pair that was already generated. + """ + instance = create_factorio_instance() + async def solve(state: TaskState, generate: Generate) -> TaskState: + qa_pairs = state.metadata.get("qa_pairs", []) + if not qa_pairs: + # Run entity removal first if not done + removal_solver = entity_removal_denoising() + state = await removal_solver(state, generate) + qa_pairs = state.metadata.get("qa_pairs", []) + + spatial_qa_pairs = [] + + for qa_pair in qa_pairs: + removed_entity = qa_pair["removed_entity"] + modified_blueprint = qa_pair["modified_blueprint"] + entities = modified_blueprint.get("entities", []) + + # Find nearby entities for spatial context + removed_pos = removed_entity.get("position", {}) + rx, ry = removed_pos.get("x", 0), removed_pos.get("y", 0) + + nearby_entities = [] + for entity in entities: + pos = entity.get("position", {}) + ex, ey = pos.get("x", 0), pos.get("y", 0) + distance = abs(ex - rx) + abs(ey - ry) # Manhattan distance + if distance <= 5: # Within 5 tiles + nearby_entities.append({ + "entity": entity, + "distance": distance, + "relative_x": ex - rx, + "relative_y": ey - ry + }) + + # Sort by distance + nearby_entities.sort(key=lambda x: x["distance"]) + + # Generate spatial context question using template + context_prompt = Templates.spatial_context_question( + removed_entity=removed_entity, + removed_position={'x': rx, 'y': ry}, + nearby_entities=[{ + 'name': ne['entity'].get('name'), + 'relative_position': f"({ne['relative_x']}, {ne['relative_y']}) from missing entity" + } for ne in nearby_entities[:3]], + nearest_entity_name=nearby_entities[0]['entity'].get('name') if nearby_entities else 'nearest entity' + ) + + state.messages = [ChatMessageUser(content=context_prompt)] + question_response = await generate(state) + spatial_question = question_response.output.completion.strip() + + # Create enhanced QA pair with spatial question + spatial_qa = qa_pair.copy() + spatial_qa["spatial_question"] = spatial_question + spatial_qa["nearby_entities"] = nearby_entities[:3] # Keep top 3 nearest + blueprint = state.metadata.get("blueprint", {}) + image: RenderedImage = instance.namespace._render(blueprint=blueprint) + from data.vqa.image_utils import save_rendered_image + image_id = save_rendered_image(image, blueprint, state.metadata, "spatial_qa", "../../images") + spatial_qa["image"] = image_id + + spatial_qa_pairs.append(spatial_qa) + + # Update QA pairs with spatial questions + state.metadata["qa_pairs"] = spatial_qa_pairs + state.metadata["spatial_questions_added"] = True + + return state + + return solve \ No newline at end of file diff --git a/data/vqa/tasks.py b/data/vqa/tasks.py new file mode 100644 index 000000000..17fb1ce84 --- /dev/null +++ b/data/vqa/tasks.py @@ -0,0 +1,23 @@ +# Main tasks module - imports all task definitions from subdirectories +from inspect_ai import eval + +# Import all tasks from the task modules +from data.vqa.tasks import * +from data.vqa.hook import * +from data.vqa.tasks.spatial_reasoning.task import spatial_reasoning_sandbox_task, spatial_context_sandbox_task + +if __name__ == "__main__": + model = ["anthropic/claude-opus-4-20250514"] + + # Example: Run a denoising task + results = eval( + tasks=[spatial_reasoning_sandbox_task(questions_per_blueprint=20), + spatial_context_sandbox_task(qa_pairs_per_blueprint=20), + denoising_blueprint_task(qa_pairs_per_blueprint=20), + denoising_validation_task(qa_pairs_per_blueprint=20), + contrastive_alignment_task()], + model=model, + limit=1, + log_dir="./logs", + hooks=[VQAPairsHook()] + ) \ No newline at end of file diff --git a/data/vqa/tasks/__init__.py b/data/vqa/tasks/__init__.py new file mode 100644 index 000000000..982f55684 --- /dev/null +++ b/data/vqa/tasks/__init__.py @@ -0,0 +1,54 @@ +# Task modules for VQA system + +from .blueprints.basic.task import ( + entity_name_task, + position_task, + counting_task +) + +from .blueprints.spatial_reasoning.task import ( + generate_spatial_reasoning_with_code, + generate_spatial_context_with_code +) + + +from .blueprints.denoising_qa.task import ( + denoising_blueprint_task, + denoising_validation_task +) + +from .blueprints.action_prediction.task import ( + action_sequence_generation_task, + next_action_prediction_task, + construction_order_task, + comprehensive_action_task +) + +from .blueprints.contrastive_alignment.task import ( + contrastive_blueprint_labelling_task, +) + +__all__ = [ + # Basic tasks + "entity_name_task", + "position_task", + "counting_task", + + # Spatial reasoning tasks + "generate_spatial_reasoning_with_code", + "generate_spatial_context_with_code", + + # Denoising tasks + "denoising_blueprint_task", + "denoising_validation_task", + + # Action prediction tasks + "action_sequence_generation_task", + "next_action_prediction_task", + "construction_order_task", + "comprehensive_action_task", + + # Contrastive alignment tasks + "contrastive_blueprint_labelling_task", + +] \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/action_prediction/solver.py b/data/vqa/tasks/blueprints/action_prediction/solver.py new file mode 100644 index 000000000..b763b7558 --- /dev/null +++ b/data/vqa/tasks/blueprints/action_prediction/solver.py @@ -0,0 +1,180 @@ +import random +from inspect_ai.solver import Solver, solver, TaskState, Generate +from ....templates import Templates + + +@solver +def generate_action_sequence(max_actions: int = 10) -> Solver: + """ + Generate a sequence of construction actions from a blueprint. + + This solver takes a blueprint and converts it into a sequence of imperative + construction steps that could be used to build the blueprint. + + Args: + max_actions: Maximum number of actions to generate per blueprint + """ + + async def solve(state: TaskState, generate: Generate) -> TaskState: + blueprint = state.metadata.get("blueprint", {}) + entities = blueprint.get("entities", []) + + if not entities: + state.metadata["error"] = "No entities found in blueprint" + state.metadata["action_sequence"] = [] + return state + + # Sort entities by some construction order logic + # For simplicity, sort by position (left to right, top to bottom) + sorted_entities = sorted(entities, key=lambda e: ( + e.get("position", {}).get("y", 0), + e.get("position", {}).get("x", 0) + )) + + actions = [] + + for i, entity in enumerate(sorted_entities[:max_actions]): + entity_name = entity.get("name", "unknown") + position = entity.get("position", {}) + x, y = position.get("x", 0), position.get("y", 0) + + # Generate construction action + action_types = [ + f"place {entity_name} at ({x}, {y})", + f"build {entity_name} at position ({x}, {y})", + f"construct {entity_name} at coordinates ({x}, {y})", + f"install {entity_name} at location ({x}, {y})" + ] + + action = random.choice(action_types) + actions.append({ + "step": i + 1, + "action": action, + "entity": entity_name, + "position": position + }) + + state.metadata["action_sequence"] = actions + state.metadata["total_actions"] = len(actions) + + return state + + return solve + + +@solver +def generate_next_action_questions(num_questions: int = 3) -> Solver: + """ + Generate questions asking to predict the next action in a construction sequence. + + This solver uses the action sequence to create questions where N-1 actions + are shown and the model must predict the Nth action. + + Args: + num_questions: Number of next-action questions to generate + """ + + async def solve(state: TaskState, generate: Generate) -> TaskState: + action_sequence = state.metadata.get("action_sequence", []) + + if len(action_sequence) < 2: + state.metadata["error"] = "Not enough actions for next-action prediction" + state.metadata["next_action_questions"] = [] + return state + + next_action_questions = [] + + for _ in range(min(num_questions, len(action_sequence) - 1)): + # Choose a random point in the sequence to split + split_point = random.randint(1, len(action_sequence) - 1) + + previous_actions = action_sequence[:split_point] + next_action = action_sequence[split_point] + + # Create the question using template + previous_action_texts = [action["action"] for action in previous_actions] + + blueprint = state.metadata.get("blueprint", {}) + + # Generate the question prompt + prompt = Templates.action_prediction( + previous_actions=previous_action_texts, + blueprint=blueprint + ) + + answer = next_action["action"] + + next_action_questions.append({ + "previous_actions": previous_actions, + "next_action": next_action, + "question_prompt": prompt, + "answer": answer, + "split_point": split_point + }) + + state.metadata["next_action_questions"] = next_action_questions + return state + + return solve + + +@solver +def generate_construction_order_questions(num_questions: int = 2) -> Solver: + """ + Generate questions about the optimal construction order. + + Args: + num_questions: Number of construction order questions to generate + """ + + async def solve(state: TaskState, generate: Generate) -> TaskState: + blueprint = state.metadata.get("blueprint", {}) + entities = blueprint.get("entities", []) + + if len(entities) < 3: + state.metadata["error"] = "Not enough entities for construction order questions" + state.metadata["construction_order_questions"] = [] + return state + + construction_order_questions = [] + + for _ in range(num_questions): + # Select 3-4 random entities + selected_entities = random.sample(entities, min(4, len(entities))) + + entity_names = [e.get("name", "unknown") for e in selected_entities] + + question_types = [ + f"What is the optimal order to build these entities: {', '.join(entity_names)}?", + f"In what sequence should you construct: {', '.join(entity_names)}?", + f"Which entity should be built first among: {', '.join(entity_names)}?", + f"What is the recommended construction order for: {', '.join(entity_names)}?" + ] + + question = random.choice(question_types) + + # Simple heuristic for construction order (power -> production -> logistics) + priority_order = { + "electric-pole": 1, "power-line": 1, + "assembly-machine": 2, "furnace": 2, "electric-furnace": 2, + "transport-belt": 3, "inserter": 3, "underground-belt": 3, + "chest": 4, "pipe": 4 + } + + # Sort by priority + sorted_entities = sorted(selected_entities, key=lambda e: + priority_order.get(e.get("name", "unknown").split("-")[0], 5)) + + answer = ", ".join([e.get("name", "unknown") for e in sorted_entities]) + + construction_order_questions.append({ + "question": question, + "answer": answer, + "entities": selected_entities, + "entity_names": entity_names + }) + + state.metadata["construction_order_questions"] = construction_order_questions + return state + + return solve \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/action_prediction/task.py b/data/vqa/tasks/blueprints/action_prediction/task.py new file mode 100644 index 000000000..d8dc68232 --- /dev/null +++ b/data/vqa/tasks/blueprints/action_prediction/task.py @@ -0,0 +1,132 @@ +from inspect_ai import task, Task +from inspect_ai.solver import system_message + +from ....dataset import raw_blueprint_dataset +from .solver import generate_action_sequence, generate_next_action_questions, generate_construction_order_questions +from ....common_solvers import validate_qa_answerability, generate_direction_questions, normalize_position_format, attach_bounding_box + + +@task +def action_sequence_generation_task(max_actions: int = 10) -> Task: + """ + Generate construction action sequences from blueprints. + + This task converts blueprints into imperative construction steps, + creating a sequence of "place X at (y, z)" actions. + + Args: + max_actions: Maximum number of construction actions to generate per blueprint + """ + return Task( + dataset=raw_blueprint_dataset(), + solver=[ + system_message("""You are planning the construction sequence for a Factorio blueprint. + Convert the blueprint into a logical series of construction steps."""), + generate_action_sequence(max_actions=max_actions), + ], + scorer=None, # We're generating data, not scoring + ) + + +@task +def next_action_prediction_task(num_questions: int = 3) -> Task: + """ + Action prediction VQA task: Predict the next action in a construction sequence. + + This task shows N-1 construction actions and asks the model to predict + the Nth action. It tests understanding of construction logic and blueprints. + + Args: + num_questions: Number of next-action prediction questions per blueprint + """ + return Task( + dataset=raw_blueprint_dataset(), + solver=[ + system_message("""You are an expert at Factorio construction planning. + Given a sequence of construction actions, predict what the next logical + action should be based on the blueprint and construction principles."""), + attach_bounding_box(), + generate_action_sequence(max_actions=10), + generate_next_action_questions(num_questions=num_questions), + generate_direction_questions(), + normalize_position_format(), + validate_qa_answerability(), + ], + scorer=None, # We're generating data, not scoring + ) + + +@task +def construction_order_task(num_questions: int = 2) -> Task: + """ + Construction order VQA task: Determine optimal build order for entities. + + This task asks about the optimal order to construct multiple entities, + considering dependencies and efficiency. + + Args: + num_questions: Number of construction order questions per blueprint + """ + return Task( + dataset=raw_blueprint_dataset(), + solver=[ + system_message("""You are an expert at Factorio construction planning. + Determine the optimal order to build entities considering power requirements, + dependencies, and construction efficiency."""), + attach_bounding_box(), + generate_construction_order_questions(num_questions=num_questions), + generate_direction_questions(), + normalize_position_format(), + validate_qa_answerability(), + ], + scorer=None, # We're generating data, not scoring + ) + + +@task +def comprehensive_action_task(max_actions: int = 8, next_action_questions: int = 2, order_questions: int = 1) -> Task: + """ + Comprehensive action prediction task combining sequence generation and prediction. + + Args: + max_actions: Maximum construction actions to generate + next_action_questions: Number of next-action prediction questions + order_questions: Number of construction order questions + """ + return Task( + dataset=raw_blueprint_dataset(), + solver=[ + system_message("""You are an expert at Factorio construction and automation. + Plan construction sequences, predict next actions, and determine optimal + build orders for efficient factory construction."""), + attach_bounding_box(), + generate_action_sequence(max_actions=max_actions), + generate_next_action_questions(num_questions=next_action_questions), + generate_construction_order_questions(num_questions=order_questions), + generate_direction_questions(), + normalize_position_format(), + validate_qa_answerability(), + ], + scorer=None, # We're generating data, not scoring + ) + + + +# Main tasks module - imports all task definitions from subdirectories +from inspect_ai import eval + +# Import all tasks from the task modules +from data.vqa.tasks import * +from data.vqa.hook import * + +if __name__ == "__main__": + model = ["anthropic/claude-opus-4-20250514"] + dataset = comprehensive_action_task(subset="title") + # Example: Run a denoising task + results = eval( + tasks=[], + model=model, + limit=1, + log_dir="../../../logs", + hooks=[VQAPairsHook()] + ) \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/action_prediction/templates/action_prediction.jinja2 b/data/vqa/tasks/blueprints/action_prediction/templates/action_prediction.jinja2 new file mode 100644 index 000000000..768b279f9 --- /dev/null +++ b/data/vqa/tasks/blueprints/action_prediction/templates/action_prediction.jinja2 @@ -0,0 +1,11 @@ +Given the following sequence of actions to build a Factorio blueprint, predict the next action. + +Previous actions: +{% for action in previous_actions %} +{{ loop.index }}. {{ action }} +{% endfor %} + +Blueprint being constructed: +{{ blueprint | tojson(indent=2) }} + +What is the next action? Provide only the action command. \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/basic/logs/2025-07-26T19-17-53-07-00_comprehensive-basic-task_56Ta43MzXNhHAKKx38MKdQ.eval b/data/vqa/tasks/blueprints/basic/logs/2025-07-26T19-17-53-07-00_comprehensive-basic-task_56Ta43MzXNhHAKKx38MKdQ.eval new file mode 100644 index 000000000..6eb984cf5 Binary files /dev/null and b/data/vqa/tasks/blueprints/basic/logs/2025-07-26T19-17-53-07-00_comprehensive-basic-task_56Ta43MzXNhHAKKx38MKdQ.eval differ diff --git a/data/vqa/tasks/blueprints/basic/solver.py b/data/vqa/tasks/blueprints/basic/solver.py new file mode 100644 index 000000000..337539b29 --- /dev/null +++ b/data/vqa/tasks/blueprints/basic/solver.py @@ -0,0 +1,732 @@ +import json +import random +import re +from collections import defaultdict +from json import JSONDecodeError + +from inspect_ai.model import ChatMessageUser +from inspect_ai.solver import Solver, solver, TaskState, Generate + +from data.vqa.blueprint_transforms import detect_direction_system +from data.vqa.direction_utils import convert_numeric_direction +from data.vqa.position_utils import format_position +from fle.agents.data.screenshots_from_run import create_factorio_instance + + +@solver +def generate_entity_name_questions(questions_per_blueprint: int = 3, multiple_choice: bool = False) -> Solver: + """ + Generate questions about entity properties using a model to create diverse Q&A pairs. + + Args: + questions_per_blueprint: Number of questions to generate per blueprint + multiple_choice: If True, generate multiple choice questions with distractor options + """ + instance = create_factorio_instance() + + async def solve(state: TaskState, generate: Generate) -> TaskState: + blueprint = state.metadata.get("blueprint", {}) + entities = blueprint.get("entities", []) + direction_system = detect_direction_system(blueprint) + + if not entities: + state.metadata["error"] = "No entities found in blueprint" + state.metadata["basic_questions"] = [] + return state + + basic_questions = [] + + # Get all unique entity names for creating distractors + all_entity_names = list(set(entity.get("name", "unknown") for entity in entities)) + + # Sample entities for question generation + num_questions = min(questions_per_blueprint, len(entities)) + selected_entities = random.sample(entities, num_questions) + + for entity in selected_entities: + position = entity.get("position", {}) + entity_name = entity.get("name", "unknown") + x, y = position.get("x", 0), position.get("y", 0) + + # Extract all entity properties for the model to use + entity['entity_number'] = None + entity_properties = {k: v for k, v in entity.items() if v is not None} + + # Convert direction to compass if present + if "direction" in entity_properties: + dir_value = entity_properties["direction"] + compass_dir = convert_numeric_direction(dir_value, direction_system) + entity_properties["direction_compass"] = compass_dir + + if multiple_choice: + # Create prompt for multiple choice question + prompt = f"""Given this Factorio entity and its properties, generate a SPECIFIC and UNAMBIGUOUS multiple choice question about the entity. + +Entity Properties: +{entity_properties} + +All entity types in blueprint: {all_entity_names} + +IMPORTANT GUIDELINES: +1. Questions must be answerable from just looking at the blueprint image +2. Always use exact positions when referring to entities (e.g., "at Position(x={x}, y={y})") +3. Create 3 plausible distractor options that could appear in a Factorio blueprint +4. For entity name questions, use other entity types from the blueprint as distractors when possible +5. Make distractors realistic but clearly wrong when examining the blueprint + +Examples of GOOD multiple choice questions: +- "What entity is located at Position(x={x}, y={y})? + A) transport-belt + B) inserter + C) assembly-machine-2 + D) {entity_name}" + +- "What recipe is configured in the {entity_name} at Position(x={x}, y={y})? + A) copper-plate + B) iron-gear-wheel + C) electronic-circuit + D) [correct recipe]" + +The correct answer should be the option at: {random.choice(['A','B','C','D'])} + +Return your response in this exact JSON format: +```json +{{ + "question": "Your specific question here", + "options": {{ + "A": "First option", + "B": "Second option", + "C": "Third option", + "D": "Fourth option" + }}, + "correct_answer": "The letter of the correct option (A, B, C, or D)", + "answer_text": "The actual answer value" +}} +```""" + else: + # Original prompt for open-ended questions + prompt = f"""Given this Factorio entity and its properties, generate a SPECIFIC and UNAMBIGUOUS question and answer pair about the positioning of the entity. + +Entity Properties: +{entity_properties} + +IMPORTANT GUIDELINES: +1. Questions must be answerable from just looking at the blueprint image +2. Always use exact positions when referring to entities (e.g., "at Position(x={x}, y={y})") +3. Be specific - if there are multiple entities of the same type, specify which one +4. Avoid vague references like "the inserter" without position + +Examples of GOOD questions: +- "What entity is located at Position(x={x}, y={y})?" +- "What recipe is configured in the {entity_name} at Position(x={x}, y={y})?" +- "How many filters are set on the {entity_name} at Position(x={x}, y={y})?" +- "Is there a {entity_name} at Position(x={x}, y={y})?" + +Return your response in this exact JSON format: +```json +{{ + "question": "Your specific question here", + "answer": "The precise answer" +}} +```""" + + # Clear messages and generate Q&A pair + state.messages = [ChatMessageUser(content=prompt)] + response = await generate(state) + + try: + # Parse the JSON response + completion = response.output.completion + json_match = re.search(r'```json\s*\n(.*?)\n```', completion, re.DOTALL) + if json_match: + qa_data = json.loads(json_match.group(1)) + + if multiple_choice: + question = qa_data.get("question", f"What entity is at {format_position(x, y)}?") + options = qa_data.get("options", {}) + correct_answer = qa_data.get("correct_answer", "D") + answer_text = qa_data.get("answer_text", entity_name) + + # Ensure we have valid options + if not options or len(options) != 4: + # Fallback: create default options + distractors = [name for name in all_entity_names if name != entity_name][:3] + if len(distractors) < 3: + # Add some common Factorio entities as distractors + common_entities = ["transport-belt", "inserter", "assembly-machine-2", + "electric-mining-drill", "stone-furnace", "splitter"] + distractors.extend( + [e for e in common_entities if e != entity_name and e not in distractors])[:3] + + options = { + "A": distractors[0] if len(distractors) > 0 else "transport-belt", + "B": distractors[1] if len(distractors) > 1 else "inserter", + "C": distractors[2] if len(distractors) > 2 else "assembly-machine-2", + "D": entity_name + } + correct_answer = "D" + + # Format question with options + formatted_question = f"{question}\n" + for letter, option in sorted(options.items()): + formatted_question += f" {letter}) {option}\n" + + answer = correct_answer + question = formatted_question.rstrip() + else: + question = qa_data.get("question", f"What entity is at {format_position(x, y)}?") + answer = qa_data.get("answer", entity_name) + else: + # Fallback to default question format + if multiple_choice: + distractors = [name for name in all_entity_names if name != entity_name][:3] + question = f"What entity is located at position {format_position(x, y)}?\n" + question += f" A) {distractors[0] if distractors else 'transport-belt'}\n" + question += f" B) {distractors[1] if len(distractors) > 1 else 'inserter'}\n" + question += f" C) {distractors[2] if len(distractors) > 2 else 'assembly-machine-2'}\n" + question += f" D) {entity_name}" + answer = "D" + else: + question = f"What entity is located at position {format_position(x, y)}?" + answer = entity_name + + except (JSONDecodeError, AttributeError): + # Fallback to default question format if parsing fails + if multiple_choice: + distractors = [name for name in all_entity_names if name != entity_name][:3] + question = f"What entity is located at position {format_position(x, y)}?\n" + question += f" A) {distractors[0] if distractors else 'transport-belt'}\n" + question += f" B) {distractors[1] if len(distractors) > 1 else 'inserter'}\n" + question += f" C) {distractors[2] if len(distractors) > 2 else 'assembly-machine-2'}\n" + question += f" D) {entity_name}" + answer = "D" + else: + question = f"What entity is located at position {format_position(x, y)}?" + answer = entity_name + + qa_entry = { + "question": question, + "answer": answer, + "entity": entity, + "position": position, + "entity_properties": entity_properties, + "question_type": "multiple_choice" if multiple_choice else "open_ended" + } + + if multiple_choice and 'options' in locals(): + qa_entry["options"] = options + qa_entry["answer_text"] = answer_text if 'answer_text' in locals() else entity_name + + basic_questions.append(qa_entry) + + state.metadata["basic_questions"] = basic_questions + return state + + return solve + + +@solver +def generate_position_questions(questions_per_blueprint: int = 3, multiple_choice: bool = False) -> Solver: + """ + Generate questions asking for the position of entities using model-based generation. + + Args: + questions_per_blueprint: Number of questions to generate per blueprint + multiple_choice: If True, generate multiple choice questions with distractor positions + """ + instance = create_factorio_instance() + + async def solve(state: TaskState, generate: Generate) -> TaskState: + blueprint = state.metadata.get("blueprint", {}) + entities = blueprint.get("entities", []) + direction_system = detect_direction_system(blueprint) + + if not entities: + state.metadata["error"] = "No entities found in blueprint" + state.metadata["position_questions"] = [] + return state + + position_questions = [] + + # Group entities by name to handle multiple instances + entities_by_name = defaultdict(list) + for entity in entities: + entities_by_name[entity.get("name", "unknown")].append(entity) + + # Get all positions for creating distractors + all_positions = [(e.get("position", {}).get("x", 0), e.get("position", {}).get("y", 0)) for e in entities] + + # Sample entities for question generation + num_questions = min(questions_per_blueprint, len(entities)) + selected_entities = random.sample(entities, num_questions) + + for entity in selected_entities: + position = entity.get("position", {}) + entity_name = entity.get("name", "unknown") + x, y = position.get("x", 0), position.get("y", 0) + + # Count how many entities of this type exist + same_type_count = len(entities_by_name[entity_name]) + + # Get nearby entities for context + nearby_entities = [] + for other in entities: + if other != entity: + other_pos = other.get("position", {}) + ox, oy = other_pos.get("x", 0), other_pos.get("y", 0) + distance = abs(ox - x) + abs(oy - y) + if distance <= 5: # Within 5 tiles + nearby_entities.append({ + "name": other.get("name", "unknown"), + "position": {"x": ox, "y": oy}, + "distance": distance + }) + + # Sort by distance + nearby_entities.sort(key=lambda e: e["distance"]) + + if multiple_choice: + # Create prompt for multiple choice position question + prompt = f"""Given this Factorio entity and context, generate a SPECIFIC multiple choice question asking about its position. + +Entity: {entity_name} +Position: {format_position(x, y)} +Total {entity_name}s in blueprint: {same_type_count} +Nearby entities (within 5 tiles): {nearby_entities[:3] if nearby_entities else "None"} +All positions in blueprint: {all_positions[:10]} # Show sample of positions + +IMPORTANT GUIDELINES: +1. Create 3 distractor positions that are plausible but incorrect +2. Distractors should be actual positions from the blueprint or nearby positions +3. Make the question specific enough to have only one correct answer +4. If there are multiple entities of the same type, use specific identifiers + +Examples of GOOD multiple choice position questions: +{f'- "Where is the {entity_name} located?' if same_type_count == 1 else f'- "Where is the northernmost {entity_name} located?'} + A) Position(x=5, y=2) + B) Position(x=3, y=-1) + C) Position(x={x}, y={y}) + D) Position(x=0, y=4)" + +Return your response in this exact JSON format: +```json +{{ + "question": "Your specific position question here", + "options": {{ + "A": "Position(x=?, y=?)", + "B": "Position(x=?, y=?)", + "C": "Position(x=?, y=?)", + "D": "Position(x=?, y=?)" + }}, + "correct_answer": "The letter of the correct option (A, B, C, or D)", + "answer_text": "{format_position(x, y)}" +}} +```""" + else: + # Original prompt for open-ended questions + prompt = f"""Given this Factorio entity and context, generate a SPECIFIC question asking about its position. + +Entity: {entity_name} +Position: {format_position(x, y)} +Total {entity_name}s in blueprint: {same_type_count} +Nearby entities (within 5 tiles): {nearby_entities[:3] if nearby_entities else "None"} + +IMPORTANT GUIDELINES: +1. If there's only one {entity_name}, the question can be simple +2. If there are multiple, use specific identifiers: + - Relative positions (northernmost, southernmost, etc.) + - Distance from other entities with their exact positions + - Unique characteristics visible in the image +3. Always make the question answerable from just the visual image + +Return your response in this exact JSON format: +```json +{{ + "question": "Your specific position question here", + "answer": "{format_position(x, y)}" +}} +```""" + + # Generate Q&A pair + state.messages = [ChatMessageUser(content=prompt)] + response = await generate(state) + + try: + completion = response.output.completion + json_match = re.search(r'```json\s*\n(.*?)\n```', completion, re.DOTALL) + if json_match: + qa_data = json.loads(json_match.group(1)) + + if multiple_choice: + question = qa_data.get("question", f"Where is the {entity_name} located?") + options = qa_data.get("options", {}) + correct_answer = qa_data.get("correct_answer", "C") + answer_text = qa_data.get("answer_text", format_position(x, y)) + + # Ensure we have valid options + if not options or len(options) != 4: + # Create distractor positions + distractor_positions = [] + for ox, oy in all_positions: + if (ox, oy) != (x, y): + distractor_positions.append(format_position(ox, oy)) + + # If not enough real positions, create synthetic ones + if len(distractor_positions) < 3: + for i in range(3 - len(distractor_positions)): + offset_x = random.randint(-5, 5) + offset_y = random.randint(-5, 5) + if (x + offset_x, y + offset_y) != (x, y): + distractor_positions.append(format_position(x + offset_x, y + offset_y)) + + random.shuffle(distractor_positions) + options = { + "A": distractor_positions[0], + "B": distractor_positions[1], + "C": format_position(x, y), + "D": distractor_positions[2] + } + correct_answer = "C" + + # Format question with options + formatted_question = f"{question}\n" + for letter, option in sorted(options.items()): + formatted_question += f"{letter}) {option}\n" + + answer = correct_answer + question = formatted_question.rstrip() + else: + question = qa_data.get("question", f"Where is the {entity_name} located?") + answer = qa_data.get("answer", format_position(x, y)) + else: + if multiple_choice: + # Fallback multiple choice + distractor_positions = [] + for ox, oy in random.sample(all_positions, min(3, len(all_positions) - 1)): + if (ox, oy) != (x, y): + distractor_positions.append(format_position(ox, oy)) + + question = f"Where is the {entity_name} located?\n" + options_list = distractor_positions[:3] + options_list.append(format_position(x, y)) + random.shuffle(options_list) + + correct_idx = options_list.index(format_position(x, y)) + letters = ["A", "B", "C", "D"] + + for i, opt in enumerate(options_list): + question += f" {letters[i]}) {opt}\n" + + answer = letters[correct_idx] + question = question.rstrip() + else: + question = f"Where is the {entity_name} located?" + answer = format_position(x, y) + + except (json.JSONDecodeError, AttributeError): + if multiple_choice: + # Simple fallback + question = f"Where is the {entity_name} located?\n" + question += f" A) Position(x={x + 1}, y={y})\n" + question += f" B) Position(x={x}, y={y + 1})\n" + question += f" C) Position(x={x}, y={y})\n" + question += f" D) Position(x={x - 1}, y={y - 1})" + answer = "C" + else: + question = f"Where is the {entity_name} located?" + answer = format_position(x, y) + + qa_entry = { + "question": question, + "answer": answer, + "entity": entity, + "position": position, + "context": { + "same_type_count": same_type_count, + "nearby_entities": nearby_entities[:3] + }, + "question_type": "multiple_choice" if multiple_choice else "open_ended" + } + + if multiple_choice and 'options' in locals(): + qa_entry["options"] = options + qa_entry["answer_text"] = answer_text if 'answer_text' in locals() else format_position(x, y) + + position_questions.append(qa_entry) + + state.metadata["position_questions"] = position_questions + return state + + return solve + + +@solver +def generate_counting_questions(questions_per_blueprint: int = 2, multiple_choice: bool = False) -> Solver: + """ + Generate questions about counting entities using model-based generation. + + Args: + questions_per_blueprint: Number of counting questions to generate per blueprint + multiple_choice: If True, generate multiple choice questions with distractor counts + """ + + async def solve(state: TaskState, generate: Generate) -> TaskState: + blueprint = state.metadata.get("blueprint", {}) + entities = blueprint.get("entities", []) + direction_system = detect_direction_system(blueprint) + + if not entities: + state.metadata["error"] = "No entities found in blueprint" + state.metadata["counting_questions"] = [] + return state + + # Count entities by various properties + entity_counts = defaultdict(int) + entity_by_direction = defaultdict(lambda: defaultdict(int)) + entity_in_regions = defaultdict(lambda: defaultdict(int)) + connected_entities = defaultdict(int) + + for entity in entities: + entity_name = entity.get("name", "unknown") + entity_counts[entity_name] += 1 + + # Count by direction (convert to compass) + direction = entity.get("direction", 0) + compass_dir = convert_numeric_direction(direction, direction_system) + entity_by_direction[entity_name][compass_dir] += 1 + + # Count by region (quadrants) + pos = entity.get("position", {}) + x, y = pos.get("x", 0), pos.get("y", 0) + region = f"{'north' if y < 0 else 'south'}-{'west' if x < 0 else 'east'}" + entity_in_regions[entity_name][region] += 1 + + # Count connected entities + if entity.get("connections"): + connected_entities[entity_name] += 1 + + counting_questions = [] + + # Generate diverse counting questions + for i in range(questions_per_blueprint): + # Create comprehensive context for the model + context = { + "total_entities": len(entities), + "entity_types": list(entity_counts.keys()), + "entity_counts": dict(entity_counts), + "entities_by_direction": {k: dict(v) for k, v in entity_by_direction.items()}, + "entities_by_region": {k: dict(v) for k, v in entity_in_regions.items()}, + "connected_entity_counts": dict(connected_entities) + } + + if multiple_choice: + prompt = f"""Given this Factorio blueprint analysis, generate a multiple choice counting question. + +Blueprint Statistics: +- Total entities: {context['total_entities']} +- Entity types and counts: {context['entity_counts']} +- Entities by direction: {context['entities_by_direction']} +- Entities by region: {context['entities_by_region']} +- Connected entities: {context['connected_entity_counts']} + +Generate a creative counting question with 4 options. The distractor numbers should be plausible but wrong. + +Examples: +- "How many transport-belts are in this blueprint? + A) 12 + B) 15 + C) 18 + D) 21" + +- "Count the number of inserters facing north: + A) 2 + B) 4 + C) 6 + D) 8" + +GUIDELINES FOR DISTRACTORS: +1. Make them close to the correct answer (within ±50%) +2. Avoid obvious wrong answers like 0 or 1000 +3. Include common counting mistakes (off by one, double counting, etc.) + +The correct answer should be the option at: {random.choice(['A','B','C','D'])} + +Return your response in this exact JSON format: +```json +{{ + "question": "Your counting question here", + "options": {{ + "A": "number", + "B": "number", + "C": "number", + "D": "number" + }}, + "correct_answer": "The letter of the correct option (A, B, C, or D)", + "answer_text": "The numeric answer", + "explanation": "Brief explanation of what was counted" +}} +```""" + else: + prompt = f"""Given this Factorio blueprint analysis, generate a counting question and its answer. + +Blueprint Statistics: +- Total entities: {context['total_entities']} +- Entity types and counts: {context['entity_counts']} +- Entities by direction: {context['entities_by_direction']} +- Entities by region: {context['entities_by_region']} +- Connected entities: {context['connected_entity_counts']} + +Generate a creative counting question. Examples: +- "How many transport-belts are in this blueprint?" +- "Count the number of inserters facing north" +- "How many assembly machines are in the eastern half of the blueprint?" +- "What's the total number of connected entities?" +- "How many different types of entities are used?" +- "Count all entities that can move items" + +Think step by step. + +Return your response in this exact JSON format: +```json +{{ + "question": "Your counting question here", + "answer": "The numeric answer", + "explanation": "Brief explanation of what was counted" +}} +```""" + + # Generate Q&A pair + state.messages = [ChatMessageUser(content=prompt)] + response = await generate(state) + + try: + completion = response.output.completion + json_match = re.search(r'```json\s*\n(.*?)\n```', completion, re.DOTALL) + if json_match: + qa_data = json.loads(json_match.group(1)) + + if multiple_choice: + question = qa_data.get("question") + options = qa_data.get("options", {}) + correct_answer = qa_data.get("correct_answer") + answer_text = qa_data.get("answer_text") + explanation = qa_data.get("explanation", "") + + if question and correct_answer and options: + # Format question with options + formatted_question = f"{question}\n" + for letter, option in sorted(options.items()): + formatted_question += f" {letter}) {option}\n" + + counting_questions.append({ + "question": formatted_question.rstrip(), + "answer": correct_answer, + "answer_text": answer_text, + "options": options, + "explanation": explanation, + "context": context, + "question_type": "multiple_choice" + }) + else: + # Fallback with generated distractors + entity_name = random.choice(list(entity_counts.keys())) + correct_count = entity_counts[entity_name] + + # Generate plausible distractors + distractors = [] + distractors.append(max(1, correct_count - random.randint(1, 3))) + distractors.append(correct_count + random.randint(1, 3)) + distractors.append(max(1, int(correct_count * random.uniform(0.7, 0.9)))) + + options_list = distractors + [correct_count] + random.shuffle(options_list) + + options = { + "A": str(options_list[0]), + "B": str(options_list[1]), + "C": str(options_list[2]), + "D": str(options_list[3]) + } + + correct_idx = options_list.index(correct_count) + correct_answer = ["A", "B", "C", "D"][correct_idx] + + question = f"How many {entity_name}s are in this blueprint?\n" + for letter, count in sorted(options.items()): + question += f" {letter}) {count}\n" + + counting_questions.append({ + "question": question.rstrip(), + "answer": correct_answer, + "answer_text": str(correct_count), + "options": options, + "explanation": f"Count of {entity_name} entities", + "context": context, + "question_type": "multiple_choice" + }) + else: + question = qa_data.get("question") + answer = qa_data.get("answer") + explanation = qa_data.get("explanation", "") + + if question and answer: + counting_questions.append({ + "question": question, + "answer": answer, + "explanation": explanation, + "context": context, + "question_type": "open_ended" + }) + else: + # Fallback to basic counting + entity_name = random.choice(list(entity_counts.keys())) + counting_questions.append({ + "question": f"How many {entity_name}s are in this blueprint?", + "answer": str(entity_counts[entity_name]), + "explanation": f"Count of {entity_name} entities", + "context": context, + "question_type": "open_ended" + }) + + except (json.JSONDecodeError, AttributeError): + # Fallback to basic counting question + if entity_counts: + entity_name = random.choice(list(entity_counts.keys())) + + if multiple_choice: + correct_count = entity_counts[entity_name] + + # Simple distractor generation + options = { + "A": str(max(1, correct_count - 2)), + "B": str(correct_count + 1), + "C": str(correct_count), + "D": str(correct_count + 3) + } + + question = f"How many {entity_name}s are in this blueprint?\n" + for letter, count in sorted(options.items()): + question += f" {letter}) {count}\n" + + counting_questions.append({ + "question": question.rstrip(), + "answer": "C", + "answer_text": str(correct_count), + "options": options, + "explanation": f"Count of {entity_name} entities", + "context": context, + "question_type": "multiple_choice" + }) + else: + counting_questions.append({ + "question": f"How many {entity_name}s are in this blueprint?", + "answer": str(entity_counts[entity_name]), + "explanation": f"Count of {entity_name} entities", + "context": context, + "question_type": "open_ended" + }) + + state.metadata["counting_questions"] = counting_questions + return state + + return solve \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/basic/task.py b/data/vqa/tasks/blueprints/basic/task.py new file mode 100644 index 000000000..1e3d45cad --- /dev/null +++ b/data/vqa/tasks/blueprints/basic/task.py @@ -0,0 +1,230 @@ +from data.vqa.dataset import augmented_blueprint_dataset_with_chunks + +# task.py - Refactored into separate task files + +from inspect_ai import task, Task +from inspect_ai.solver import system_message + +from data.vqa.common_solvers import ( + validate_qa_answerability, + generate_direction_questions, + normalize_position_format, + attach_bounding_box, + render_blueprint_image +) +from data.vqa.dataset import augmented_blueprint_dataset_with_chunks +# Import all tasks from the task modules +from data.vqa.tasks import * +from data.vqa.tasks.blueprints.basic.solver import ( + generate_entity_name_questions, + generate_position_questions, + generate_counting_questions +) + + +# task.py - Refactored into separate task files +# Main tasks module - imports all task definitions from subdirectories + + +# ============= ENTITY NAME TASKS ============= + +@task +def entity_name_task(questions_per_blueprint: int = 10, multiple_choice: bool = False) -> Task: + """ + Entity name task with rotation augmentation. + + Args: + questions_per_blueprint: Number of questions to generate per blueprint + multiple_choice: If True, generate multiple choice questions + """ + return Task( + name="entity_name_task" + ("_mc" if multiple_choice else ""), + dataset=augmented_blueprint_dataset_with_chunks(), + solver=[ + system_message("""You are analyzing Factorio blueprints to identify entities. + Answer questions about what entities are located at specific positions. + The blueprints may be rotated."""), + attach_bounding_box(), + render_blueprint_image(), + generate_entity_name_questions( + questions_per_blueprint=questions_per_blueprint, + multiple_choice=multiple_choice + ), + normalize_position_format(), + validate_qa_answerability(), + ], + scorer=None, + ) + + +@task +def entity_name_mc_task(questions_per_blueprint: int = 10) -> Task: + """ + Entity name task with multiple choice questions. + Convenience function that calls entity_name_task with multiple_choice=True. + """ + return entity_name_task(questions_per_blueprint=questions_per_blueprint, multiple_choice=True) + + +# ============= POSITION TASKS ============= + +@task +def position_task(questions_per_blueprint: int = 10, multiple_choice: bool = False) -> Task: + """ + Position task with rotation augmentation. + + Args: + questions_per_blueprint: Number of questions to generate per blueprint + multiple_choice: If True, generate multiple choice questions + """ + return Task( + name="position_task" + ("_mc" if multiple_choice else ""), + dataset=augmented_blueprint_dataset_with_chunks(), + solver=[ + system_message("""You are analyzing Factorio blueprints to locate entities. + Answer questions about where specific entities are positioned. + The blueprints may be rotated."""), + attach_bounding_box(), + render_blueprint_image(), + generate_position_questions( + questions_per_blueprint=questions_per_blueprint, + multiple_choice=multiple_choice + ), + normalize_position_format(), + validate_qa_answerability(), + ], + scorer=None, + ) + + +@task +def position_mc_task(questions_per_blueprint: int = 10) -> Task: + """ + Position task with multiple choice questions. + Convenience function that calls position_task with multiple_choice=True. + """ + return position_task(questions_per_blueprint=questions_per_blueprint, multiple_choice=True) + + +# ============= COUNTING TASKS ============= + +@task +def counting_task(questions_per_blueprint: int = 10, multiple_choice: bool = False) -> Task: + """ + Counting task with rotation augmentation. + + Args: + questions_per_blueprint: Number of questions to generate per blueprint + multiple_choice: If True, generate multiple choice questions + """ + return Task( + name="counting_task" + ("_mc" if multiple_choice else ""), + dataset=augmented_blueprint_dataset_with_chunks(), + solver=[ + system_message("""You are analyzing Factorio blueprints to count entities. + Answer questions about how many entities of each type are present. + The blueprints may be rotated."""), + attach_bounding_box(), + render_blueprint_image(), + generate_counting_questions( + questions_per_blueprint=questions_per_blueprint, + multiple_choice=multiple_choice + ), + normalize_position_format(), + validate_qa_answerability(), + ], + scorer=None, + ) + + +@task +def counting_mc_task(questions_per_blueprint: int = 10) -> Task: + """ + Counting task with multiple choice questions. + Convenience function that calls counting_task with multiple_choice=True. + """ + return counting_task(questions_per_blueprint=questions_per_blueprint, multiple_choice=True) + + +# ============= DIRECTION TASKS ============= + +@task +def direction_task(questions_per_blueprint: int = 10, multiple_choice: bool = False) -> Task: + """ + Direction task with rotation augmentation. + + Args: + questions_per_blueprint: Number of questions to generate per blueprint + multiple_choice: If True, generate multiple choice questions + """ + # Note: You'll need to update generate_direction_questions in common_solvers + # to support multiple_choice parameter if you want this functionality + return Task( + name="direction_task" + ("_mc" if multiple_choice else ""), + dataset=augmented_blueprint_dataset_with_chunks(), + solver=[ + system_message("""You are analyzing Factorio blueprints to identify entity directions. + Answer questions about which direction entities are facing. + The blueprints may be rotated."""), + attach_bounding_box(), + render_blueprint_image(), + generate_direction_questions( + questions_per_blueprint=questions_per_blueprint, + # multiple_choice=multiple_choice # Uncomment when implemented + ), + normalize_position_format(), + validate_qa_answerability(), + ], + scorer=None, + ) + + +@task +def direction_mc_task(questions_per_blueprint: int = 10) -> Task: + """ + Direction task with multiple choice questions. + Currently returns regular direction task - update when MC support is added. + """ + # For now, return regular task until multiple choice is implemented for directions + return direction_task(questions_per_blueprint=questions_per_blueprint) + + + +# ============= USAGE EXAMPLES ============= + +if __name__ == "__main__": + from inspect_ai import eval + + model = ["anthropic/claude-sonnet-4-20250514"] + + # Example 1: Run open-ended questions + # open_ended_tasks = [ + # entity_name_task(questions_per_blueprint=10, multiple_choice=False), + # position_task(questions_per_blueprint=10, multiple_choice=False), + # counting_task(questions_per_blueprint=10, multiple_choice=False), + # direction_task(questions_per_blueprint=10), + # ] + + # Example 2: Run multiple choice questions + multiple_choice_tasks = [ + entity_name_mc_task(questions_per_blueprint=2), + position_mc_task(questions_per_blueprint=2), + counting_mc_task(questions_per_blueprint=10), + direction_mc_task(questions_per_blueprint=10), # When implemented + ] + + # Example 3: Mix and match + # mixed_tasks = [ + # entity_name_task(questions_per_blueprint=5, multiple_choice=False), + # entity_name_mc_task(questions_per_blueprint=5), + # position_mc_task(questions_per_blueprint=10), + # counting_task(questions_per_blueprint=10, multiple_choice=False), + # ] + + # Run evaluation + results = eval( + tasks=multiple_choice_tasks, # Choose which task set to run + model=model, + limit=2, + log_dir="../../../logs" + ) \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/basic/templates/entity_name_position.jinja2 b/data/vqa/tasks/blueprints/basic/templates/entity_name_position.jinja2 new file mode 100644 index 000000000..1ae2f1ee0 --- /dev/null +++ b/data/vqa/tasks/blueprints/basic/templates/entity_name_position.jinja2 @@ -0,0 +1,8 @@ +Analyze this Factorio blueprint and answer the question about entity names or positions. + +Blueprint: +{{ blueprint | tojson(indent=2) }} + +Question: {{ question }} + +Provide a clear, concise answer. \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/dataset.py b/data/vqa/tasks/blueprints/contrastive_alignment/dataset.py new file mode 100644 index 000000000..df536a6e4 --- /dev/null +++ b/data/vqa/tasks/blueprints/contrastive_alignment/dataset.py @@ -0,0 +1,27 @@ +import json + +from inspect_ai.dataset import MemoryDataset, Sample + +from data.vqa.utils import find_blueprints_dir + + +def raw_blueprint_dataset() -> MemoryDataset: + # Load blueprints from directory + blueprint_dir = find_blueprints_dir() + samples = [] + + for blueprint_path in blueprint_dir.glob("*.json"): + with open(blueprint_path, 'r') as f: + blueprint_json = f.read() + + blueprint = json.loads(blueprint_json) + sample = Sample( + input=blueprint['label'] if 'label' in blueprint else blueprint_path.name, + metadata={"filename": blueprint_path.name, "blueprint": blueprint}, + ) + samples.append(sample) + + # Create dataset + dataset = MemoryDataset(samples=samples) + return dataset + diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-29-00-07-00_contrastive-blueprint-labelling-task_ajb2MpGKuRSDC7ZZYSdioc.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-29-00-07-00_contrastive-blueprint-labelling-task_ajb2MpGKuRSDC7ZZYSdioc.eval new file mode 100644 index 000000000..51d5d0695 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-29-00-07-00_contrastive-blueprint-labelling-task_ajb2MpGKuRSDC7ZZYSdioc.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-31-29-07-00_contrastive-blueprint-labelling-task_Yw5hXUehUdb3pPwQ2qLFYC.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-31-29-07-00_contrastive-blueprint-labelling-task_Yw5hXUehUdb3pPwQ2qLFYC.eval new file mode 100644 index 000000000..c2e4ece7e Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-31-29-07-00_contrastive-blueprint-labelling-task_Yw5hXUehUdb3pPwQ2qLFYC.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-43-56-07-00_contrastive-blueprint-labelling-task_ArbNwDWiFMgh7dhZcKStGg.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-43-56-07-00_contrastive-blueprint-labelling-task_ArbNwDWiFMgh7dhZcKStGg.eval new file mode 100644 index 000000000..0b832b049 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-43-56-07-00_contrastive-blueprint-labelling-task_ArbNwDWiFMgh7dhZcKStGg.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-55-54-07-00_contrastive-blueprint-labelling-task_mUo4KxZUKTu3Q4ydrid7XL.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-55-54-07-00_contrastive-blueprint-labelling-task_mUo4KxZUKTu3Q4ydrid7XL.eval new file mode 100644 index 000000000..2180b19a9 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-55-54-07-00_contrastive-blueprint-labelling-task_mUo4KxZUKTu3Q4ydrid7XL.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-57-11-07-00_contrastive-blueprint-labelling-task_K4vjoU62hZEUQ7EeaQgsWp.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-57-11-07-00_contrastive-blueprint-labelling-task_K4vjoU62hZEUQ7EeaQgsWp.eval new file mode 100644 index 000000000..c9024713c Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-57-11-07-00_contrastive-blueprint-labelling-task_K4vjoU62hZEUQ7EeaQgsWp.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-58-29-07-00_contrastive-blueprint-labelling-task_aWBw8upQSWApwdNNyCJRsS.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-58-29-07-00_contrastive-blueprint-labelling-task_aWBw8upQSWApwdNNyCJRsS.eval new file mode 100644 index 000000000..d4fe0070a Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-58-29-07-00_contrastive-blueprint-labelling-task_aWBw8upQSWApwdNNyCJRsS.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-58-50-07-00_contrastive-blueprint-labelling-task_5NvZi3oHxF5ipvxrJ5CZFC.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-58-50-07-00_contrastive-blueprint-labelling-task_5NvZi3oHxF5ipvxrJ5CZFC.eval new file mode 100644 index 000000000..f45d34aee Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T15-58-50-07-00_contrastive-blueprint-labelling-task_5NvZi3oHxF5ipvxrJ5CZFC.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-00-18-07-00_contrastive-blueprint-labelling-task_JfhrhtF5Be2MQssuWw67zE.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-00-18-07-00_contrastive-blueprint-labelling-task_JfhrhtF5Be2MQssuWw67zE.eval new file mode 100644 index 000000000..ff7abb9bf Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-00-18-07-00_contrastive-blueprint-labelling-task_JfhrhtF5Be2MQssuWw67zE.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-02-09-07-00_contrastive-blueprint-labelling-task_Dw4ciMabb2rMCHjhYRQi7H.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-02-09-07-00_contrastive-blueprint-labelling-task_Dw4ciMabb2rMCHjhYRQi7H.eval new file mode 100644 index 000000000..27a421a0f Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-02-09-07-00_contrastive-blueprint-labelling-task_Dw4ciMabb2rMCHjhYRQi7H.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-04-38-07-00_contrastive-blueprint-labelling-task_Na4tpNgDLxbv3bqherDCk3.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-04-38-07-00_contrastive-blueprint-labelling-task_Na4tpNgDLxbv3bqherDCk3.eval new file mode 100644 index 000000000..792fda6eb Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-04-38-07-00_contrastive-blueprint-labelling-task_Na4tpNgDLxbv3bqherDCk3.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-06-18-07-00_contrastive-blueprint-labelling-task_RkvaUVmHFnSAo6su6ULU88.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-06-18-07-00_contrastive-blueprint-labelling-task_RkvaUVmHFnSAo6su6ULU88.eval new file mode 100644 index 000000000..b25f1dc5e Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-06-18-07-00_contrastive-blueprint-labelling-task_RkvaUVmHFnSAo6su6ULU88.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-08-37-07-00_contrastive-blueprint-labelling-task_PjJDcWfeopsNNHpTPvNvAB.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-08-37-07-00_contrastive-blueprint-labelling-task_PjJDcWfeopsNNHpTPvNvAB.eval new file mode 100644 index 000000000..0cba5693f Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-08-37-07-00_contrastive-blueprint-labelling-task_PjJDcWfeopsNNHpTPvNvAB.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-10-47-07-00_contrastive-blueprint-labelling-task_KVE7AEYf6Tit4ywF4C8iZY.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-10-47-07-00_contrastive-blueprint-labelling-task_KVE7AEYf6Tit4ywF4C8iZY.eval new file mode 100644 index 000000000..369ab2a8a Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-10-47-07-00_contrastive-blueprint-labelling-task_KVE7AEYf6Tit4ywF4C8iZY.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-12-24-07-00_contrastive-blueprint-labelling-task_PDZwwJE5jCGqzLhiN2zCPM.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-12-24-07-00_contrastive-blueprint-labelling-task_PDZwwJE5jCGqzLhiN2zCPM.eval new file mode 100644 index 000000000..c41277f74 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-12-24-07-00_contrastive-blueprint-labelling-task_PDZwwJE5jCGqzLhiN2zCPM.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-13-12-07-00_contrastive-blueprint-labelling-task_eZXnEEh9Vn3hnbiFoF88jR.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-13-12-07-00_contrastive-blueprint-labelling-task_eZXnEEh9Vn3hnbiFoF88jR.eval new file mode 100644 index 000000000..7976476df Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-13-12-07-00_contrastive-blueprint-labelling-task_eZXnEEh9Vn3hnbiFoF88jR.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-15-47-07-00_contrastive-blueprint-labelling-task_iWECTNAqj6Nza7sHMy5BqW.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-15-47-07-00_contrastive-blueprint-labelling-task_iWECTNAqj6Nza7sHMy5BqW.eval new file mode 100644 index 000000000..614ee97f7 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-15-47-07-00_contrastive-blueprint-labelling-task_iWECTNAqj6Nza7sHMy5BqW.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-17-57-07-00_contrastive-blueprint-labelling-task_Dex2pm7s2k2wXCcy6LfVQQ.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-17-57-07-00_contrastive-blueprint-labelling-task_Dex2pm7s2k2wXCcy6LfVQQ.eval new file mode 100644 index 000000000..1ab794d63 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-17-57-07-00_contrastive-blueprint-labelling-task_Dex2pm7s2k2wXCcy6LfVQQ.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-19-23-07-00_contrastive-blueprint-labelling-task_Xz3yBejbFFJqtj8gyQg8z9.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-19-23-07-00_contrastive-blueprint-labelling-task_Xz3yBejbFFJqtj8gyQg8z9.eval new file mode 100644 index 000000000..cb0edb18c Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-19-23-07-00_contrastive-blueprint-labelling-task_Xz3yBejbFFJqtj8gyQg8z9.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-21-20-07-00_contrastive-blueprint-labelling-task_kscXpzi7AvM6qCMZuLiWPf.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-21-20-07-00_contrastive-blueprint-labelling-task_kscXpzi7AvM6qCMZuLiWPf.eval new file mode 100644 index 000000000..4f06411b7 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-21-20-07-00_contrastive-blueprint-labelling-task_kscXpzi7AvM6qCMZuLiWPf.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-23-31-07-00_contrastive-blueprint-labelling-task_8mkFvNj3qoyJVRnzyBmrah.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-23-31-07-00_contrastive-blueprint-labelling-task_8mkFvNj3qoyJVRnzyBmrah.eval new file mode 100644 index 000000000..0222409fd Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-23-31-07-00_contrastive-blueprint-labelling-task_8mkFvNj3qoyJVRnzyBmrah.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-24-30-07-00_contrastive-blueprint-labelling-task_SvRE2Wrd3B5qTDEZY9SuYj.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-24-30-07-00_contrastive-blueprint-labelling-task_SvRE2Wrd3B5qTDEZY9SuYj.eval new file mode 100644 index 000000000..3cc4e67f3 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-24-30-07-00_contrastive-blueprint-labelling-task_SvRE2Wrd3B5qTDEZY9SuYj.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-25-34-07-00_contrastive-blueprint-labelling-task_BAiDjj2jHG5MzqhQQTaErN.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-25-34-07-00_contrastive-blueprint-labelling-task_BAiDjj2jHG5MzqhQQTaErN.eval new file mode 100644 index 000000000..7b893e668 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-25-34-07-00_contrastive-blueprint-labelling-task_BAiDjj2jHG5MzqhQQTaErN.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-28-11-07-00_contrastive-blueprint-labelling-task_SqfVxNGpxuCAzB3hHtSWY2.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-28-11-07-00_contrastive-blueprint-labelling-task_SqfVxNGpxuCAzB3hHtSWY2.eval new file mode 100644 index 000000000..a20805aee Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-28-11-07-00_contrastive-blueprint-labelling-task_SqfVxNGpxuCAzB3hHtSWY2.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-29-45-07-00_contrastive-blueprint-labelling-task_cFiYKn426gjgPnA2R7F5ub.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-29-45-07-00_contrastive-blueprint-labelling-task_cFiYKn426gjgPnA2R7F5ub.eval new file mode 100644 index 000000000..cb983d2ff Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-29-45-07-00_contrastive-blueprint-labelling-task_cFiYKn426gjgPnA2R7F5ub.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-31-22-07-00_contrastive-blueprint-labelling-task_RrZ5yxzSCFWTYt2HJfbGrL.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-31-22-07-00_contrastive-blueprint-labelling-task_RrZ5yxzSCFWTYt2HJfbGrL.eval new file mode 100644 index 000000000..f4d7fa120 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-31-22-07-00_contrastive-blueprint-labelling-task_RrZ5yxzSCFWTYt2HJfbGrL.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-38-21-07-00_contrastive-blueprint-labelling-task_5EG8jEHc9RNWFbiGHYd69u.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-38-21-07-00_contrastive-blueprint-labelling-task_5EG8jEHc9RNWFbiGHYd69u.eval new file mode 100644 index 000000000..ecab07d91 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-38-21-07-00_contrastive-blueprint-labelling-task_5EG8jEHc9RNWFbiGHYd69u.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-39-59-07-00_contrastive-blueprint-labelling-task_ZdAJn7pWeVtFpd8besvo5P.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-39-59-07-00_contrastive-blueprint-labelling-task_ZdAJn7pWeVtFpd8besvo5P.eval new file mode 100644 index 000000000..16375c733 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-39-59-07-00_contrastive-blueprint-labelling-task_ZdAJn7pWeVtFpd8besvo5P.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-43-30-07-00_contrastive-blueprint-labelling-task_k9KdXZPRbCHVpTwua9XyrR.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-43-30-07-00_contrastive-blueprint-labelling-task_k9KdXZPRbCHVpTwua9XyrR.eval new file mode 100644 index 000000000..a7cc2a449 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-43-30-07-00_contrastive-blueprint-labelling-task_k9KdXZPRbCHVpTwua9XyrR.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-45-30-07-00_contrastive-blueprint-labelling-task_kpvKAgaYzx5QW85pMsea7x.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-45-30-07-00_contrastive-blueprint-labelling-task_kpvKAgaYzx5QW85pMsea7x.eval new file mode 100644 index 000000000..97dbcbaad Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-45-30-07-00_contrastive-blueprint-labelling-task_kpvKAgaYzx5QW85pMsea7x.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-47-54-07-00_contrastive-blueprint-labelling-task_Uw2bzGYheiDXXeytAu64ub.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-47-54-07-00_contrastive-blueprint-labelling-task_Uw2bzGYheiDXXeytAu64ub.eval new file mode 100644 index 000000000..d71a45e35 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-47-54-07-00_contrastive-blueprint-labelling-task_Uw2bzGYheiDXXeytAu64ub.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-55-16-07-00_contrastive-blueprint-labelling-task_RVsM4txm5Jv3iiuTLdsB9W.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-55-16-07-00_contrastive-blueprint-labelling-task_RVsM4txm5Jv3iiuTLdsB9W.eval new file mode 100644 index 000000000..ce59951b2 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-55-16-07-00_contrastive-blueprint-labelling-task_RVsM4txm5Jv3iiuTLdsB9W.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-56-37-07-00_contrastive-blueprint-labelling-task_ezGvpX3bkdgTgPPFJCqDbR.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-56-37-07-00_contrastive-blueprint-labelling-task_ezGvpX3bkdgTgPPFJCqDbR.eval new file mode 100644 index 000000000..ba0267999 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T16-56-37-07-00_contrastive-blueprint-labelling-task_ezGvpX3bkdgTgPPFJCqDbR.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-01-24-07-00_contrastive-blueprint-labelling-task_QPwJy4Efp9XWVgDua5Gxi4.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-01-24-07-00_contrastive-blueprint-labelling-task_QPwJy4Efp9XWVgDua5Gxi4.eval new file mode 100644 index 000000000..c2c28b026 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-01-24-07-00_contrastive-blueprint-labelling-task_QPwJy4Efp9XWVgDua5Gxi4.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-03-07-07-00_contrastive-blueprint-labelling-task_VC3TQieMtrexLa3dqYYG4w.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-03-07-07-00_contrastive-blueprint-labelling-task_VC3TQieMtrexLa3dqYYG4w.eval new file mode 100644 index 000000000..037ab3e3e Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-03-07-07-00_contrastive-blueprint-labelling-task_VC3TQieMtrexLa3dqYYG4w.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-04-22-07-00_contrastive-blueprint-labelling-task_84pAzeSf4Lu7zQgkiEeLCf.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-04-22-07-00_contrastive-blueprint-labelling-task_84pAzeSf4Lu7zQgkiEeLCf.eval new file mode 100644 index 000000000..13d5c000f Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-04-22-07-00_contrastive-blueprint-labelling-task_84pAzeSf4Lu7zQgkiEeLCf.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-05-20-07-00_contrastive-blueprint-labelling-task_6C3k5vxg25kpc4Jh2H2NRb.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-05-20-07-00_contrastive-blueprint-labelling-task_6C3k5vxg25kpc4Jh2H2NRb.eval new file mode 100644 index 000000000..82be2c49b Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-05-20-07-00_contrastive-blueprint-labelling-task_6C3k5vxg25kpc4Jh2H2NRb.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-06-11-07-00_contrastive-blueprint-labelling-task_SVMyNYU8GnTbuRM4gqvb7H.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-06-11-07-00_contrastive-blueprint-labelling-task_SVMyNYU8GnTbuRM4gqvb7H.eval new file mode 100644 index 000000000..5a18269e7 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-06-11-07-00_contrastive-blueprint-labelling-task_SVMyNYU8GnTbuRM4gqvb7H.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-07-43-07-00_contrastive-blueprint-labelling-task_QgcKWSpQPbeXuLXPmXvgQM.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-07-43-07-00_contrastive-blueprint-labelling-task_QgcKWSpQPbeXuLXPmXvgQM.eval new file mode 100644 index 000000000..cd6d73e20 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-07-43-07-00_contrastive-blueprint-labelling-task_QgcKWSpQPbeXuLXPmXvgQM.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-08-59-07-00_contrastive-blueprint-labelling-task_ZzcyCGr4s26uDwp5TyrKaU.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-08-59-07-00_contrastive-blueprint-labelling-task_ZzcyCGr4s26uDwp5TyrKaU.eval new file mode 100644 index 000000000..45b13db43 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-08-59-07-00_contrastive-blueprint-labelling-task_ZzcyCGr4s26uDwp5TyrKaU.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-10-31-07-00_contrastive-blueprint-labelling-task_KL6Mh7EFoRSfJejMTaYQzj.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-10-31-07-00_contrastive-blueprint-labelling-task_KL6Mh7EFoRSfJejMTaYQzj.eval new file mode 100644 index 000000000..37dd92f9a Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-10-31-07-00_contrastive-blueprint-labelling-task_KL6Mh7EFoRSfJejMTaYQzj.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-12-05-07-00_contrastive-blueprint-labelling-task_dVMrLNGPBGizsPSneQDTG4.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-12-05-07-00_contrastive-blueprint-labelling-task_dVMrLNGPBGizsPSneQDTG4.eval new file mode 100644 index 000000000..e71163ff9 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-12-05-07-00_contrastive-blueprint-labelling-task_dVMrLNGPBGizsPSneQDTG4.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-13-44-07-00_contrastive-blueprint-labelling-task_o4sgEgMZri5Pvny97UQL6K.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-13-44-07-00_contrastive-blueprint-labelling-task_o4sgEgMZri5Pvny97UQL6K.eval new file mode 100644 index 000000000..dcf2ab388 Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-13-44-07-00_contrastive-blueprint-labelling-task_o4sgEgMZri5Pvny97UQL6K.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-14-49-07-00_contrastive-blueprint-labelling-task_KYEhH8qeshn2vAZnANGKmT.eval b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-14-49-07-00_contrastive-blueprint-labelling-task_KYEhH8qeshn2vAZnANGKmT.eval new file mode 100644 index 000000000..051ce40ef Binary files /dev/null and b/data/vqa/tasks/blueprints/contrastive_alignment/logs/2025-08-01T17-14-49-07-00_contrastive-blueprint-labelling-task_KYEhH8qeshn2vAZnANGKmT.eval differ diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/solver.py b/data/vqa/tasks/blueprints/contrastive_alignment/solver.py new file mode 100644 index 000000000..dc6639238 --- /dev/null +++ b/data/vqa/tasks/blueprints/contrastive_alignment/solver.py @@ -0,0 +1,148 @@ +import json +import re +from inspect_ai.model import ChatMessageUser +from inspect_ai.solver import Solver, solver, TaskState, Generate +from data.vqa.templates import Templates + + +@solver +def generate_blueprint_title_and_purpose(num_variations: int = 3) -> Solver: + """Generate multiple title and purpose descriptions for blueprints in a single LLM call. + + Args: + num_variations: Number of different title/purpose pairs to generate (default: 3) + """ + + async def solve(state: TaskState, generate: Generate) -> TaskState: + blueprint = state.metadata.get("blueprint", {}) + + # Generate prompt requesting multiple variations at once + blueprint_copy = blueprint.copy() + if "label" in blueprint_copy: + del blueprint_copy["label"] + + prompt = f"""Analyze this Factorio blueprint and generate {num_variations} different metadata variations. + +Blueprint: +{json.dumps(blueprint_copy, indent=2)} + +Generate {num_variations} different variations, each with: +1. A concise title (max 10 words) that describes what this blueprint builds +2. A purpose description (1-2 sentences) explaining what it does and how it's used + +Important guidelines: +- Each variation should emphasize different aspects of the blueprint +- Variation 1: Focus on the primary function and most obvious use case +- Variation 2: Highlight efficiency, automation, or technical aspects +- Variation 3: Emphasize scalability, integration, or advanced features +{f"- Additional variations: Consider alternative use cases, specialized applications, or unique benefits" if num_variations > 3 else ""} + +Make each title and purpose distinct while still being accurate. + +Format your response as JSON: +```json +{{ + "variations": [ + {{ + "title": "...", + "purpose": "..." + }}, + {{ + "title": "...", + "purpose": "..." + }}, + ... + ] +}} +```""" + + state.messages[-1] = ChatMessageUser(content=prompt) + response = await generate(state) + completion = response.output.completion + + pattern = r'```json\s*\n(.*?)\n```' + match = re.search(pattern, completion, re.DOTALL) + + all_titles = [] + all_purposes = [] + + if match: + json_content = match.group(1) + try: + data = json.loads(json_content) + variations = data.get('variations', []) + + for variation in variations: + all_titles.append(variation.get('title', '')) + all_purposes.append(variation.get('purpose', '')) + except json.JSONDecodeError as e: + print(f"Error parsing JSON: {e}") + # Fallback to empty lists + pass + + # Store all variations + state.metadata["titles"] = all_titles + state.metadata["purposes"] = all_purposes + + # Keep single title/purpose for backward compatibility + if all_titles: + state.metadata["title"] = all_titles[0] + state.metadata["purpose"] = all_purposes[0] + + return state + + return solve + + +@solver +def contrastive_matching(num_options: int = 4) -> Solver: + """Generate contrastive matching questions for blueprint identification.""" + + async def solve(state: TaskState, generate: Generate) -> TaskState: + blueprint = state.metadata.get("blueprint", {}) + + # Generate title and purpose for current blueprint if not already done + if "titles" not in state.metadata or "purposes" not in state.metadata: + title_purpose_solver = generate_blueprint_title_and_purpose() + state = await title_purpose_solver(state, generate) + + # Use the first variation for this matching question + correct_title = state.metadata.get("title", "Unknown Blueprint") + correct_purpose = state.metadata.get("purpose", "No description available") + + # Create options list (placeholder - in real implementation, would get from other blueprints) + options = [ + {"title": correct_title, "purpose": correct_purpose} + ] + + # Add dummy options for now (in real implementation, would sample from other blueprints) + dummy_options = [ + {"title": "Belt Balancer", "purpose": "Distributes items evenly across multiple belt lanes"}, + {"title": "Train Station", "purpose": "Automated loading and unloading point for trains"}, + {"title": "Power Plant", "purpose": "Generates electricity using steam engines and boilers"} + ] + + for i in range(min(num_options - 1, len(dummy_options))): + options.append(dummy_options[i]) + + # Shuffle options (keep track of correct answer) + import random + correct_index = 0 + random.shuffle(options) + + # Find new position of correct answer + for i, option in enumerate(options): + if option["title"] == correct_title: + correct_index = i + break + + # Generate matching prompt + prompt = Templates.contrastive_matching(options=options) + + state.messages = [ChatMessageUser(content=prompt)] + state.metadata["contrastive_options"] = options + state.metadata["correct_answer"] = correct_index + 1 # 1-indexed + + return state + + return solve \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/task.py b/data/vqa/tasks/blueprints/contrastive_alignment/task.py new file mode 100644 index 000000000..95127c486 --- /dev/null +++ b/data/vqa/tasks/blueprints/contrastive_alignment/task.py @@ -0,0 +1,215 @@ +import random +from typing import Literal + +from inspect_ai import task, Task +from inspect_ai.dataset import MemoryDataset, Sample +from inspect_ai.scorer import includes +from inspect_ai.solver import system_message + +from data.vqa.common_solvers import validate_qa_answerability, generate_direction_questions, normalize_position_format, \ + attach_bounding_box, render_blueprint_image +from data.vqa.dataset import augmented_blueprint_dataset +from data.vqa.tasks.blueprints.contrastive_alignment.solver import generate_blueprint_title_and_purpose +from fle.agents.data.screenshots_from_run import create_factorio_instance +from fle.commons.models.rendered_image import RenderedImage +from inspect_ai.solver import solver, TaskState, Generate + + +@task +def contrastive_blueprint_labelling_task(num_variations: int = 3) -> Task: + """ + For each blueprint, we run a solver to compute multiple variations of metadata: + 1. Descriptive labels + 2. Descriptive purposes + + Args: + num_variations: Number of title/purpose variations to generate per blueprint + """ + return Task( + dataset=augmented_blueprint_dataset(), + solver=[ + system_message("""You are an expert Factorio player analyzing blueprints. + Generate clear, concise titles and purpose descriptions that would help + other players understand what each blueprint does."""), + attach_bounding_box(), + # Use the efficient single-prompt version + generate_blueprint_title_and_purpose(num_variations=num_variations) + ], + scorer=[includes()] + ) + + +@solver +def passthrough_solver(): + async def solve(state: TaskState, generate: Generate) -> TaskState: + question = state.input + answer = state.target.text + alphabet = ['A', 'B', 'C', 'D', 'E'] + choices = [choice.value for choice in state.choices._choices] + random.shuffle(choices) + options = "\n".join([f"{alphabet[i]}) {choice}" for i, choice in enumerate(choices)]) + question = question + "\n" + options + + answer_index = choices.index(answer) + target = alphabet[answer_index] + state.metadata['contrastive_alignment'] = [{'answer': target, 'question': question}] + return state + + return solve + + +@task +def contrastive_alignment_task(subset: Literal['title', 'purpose'] = "title", limit=4, variants=1) -> Task: + """ + For each blueprint, we run a solver to compute the following metadata for it: + 1. A descriptive label + 2. A descriptive purpose + """ + dataset = contrastive_alignment_dataset( + subset=subset, + limit=limit, + num_variations=variants # This will create 3 different titles per blueprint + ) + return Task( + name=f"contrastive_alignment_{subset}", + dataset=dataset, + solver=[ + render_blueprint_image(), + passthrough_solver() + ], + scorer=[] + ) + + +def contrastive_alignment_dataset(*args, + subset: Literal['title', 'purpose'], + limit=10, + num_variations=3, + model="anthropic/claude-opus-4-20250514") -> MemoryDataset: + """ + Task that creates contrastive image-text alignment questions with multiple variations per blueprint. + Given a blueprint image, the model must select the correct title/purpose from multiple options. + + Args: + subset: Whether to use 'title' or 'purpose' for questions + limit: Number of blueprints to process + num_variations: Number of title/purpose variations to generate per blueprint + model: Model to use for generation + """ + instance = create_factorio_instance() + result = eval( + tasks=contrastive_blueprint_labelling_task(num_variations=num_variations), + limit=limit, + model=[model] + ) + + # Remove duplicates while preserving order + all_titles = [] + all_purposes = [] + for s in result[0].samples: + all_titles.append(s.metadata.get('titles', [])) + all_purposes.append(s.metadata.get('purposes', [])) + + + + samples = [] + for i, s in enumerate(result[0].samples): + # Get the variations for this blueprint + variations = s.metadata.get('titles' if subset == 'title' else 'purposes', []) + all_choices = [] + try: + while len(all_choices) < 3: + sample_index = random.randint(0, len(all_titles)) + if sample_index != i: + if subset == 'title': + all_choices.append(random.choice(all_titles[sample_index])) + else: + all_choices.append(random.choice(all_purposes[sample_index])) + except IndexError: + continue + + + # Create multiple samples per blueprint using different variations + for variation_idx, correct_answer in enumerate(variations): + # Skip if this variation is empty + if not correct_answer: + continue + + # Create distractor options from other blueprints' variations + distractors = [choice for choice in all_choices if choice != correct_answer] + + # Sample 3 distractors + if len(distractors) >= 3: + other_options = random.sample(distractors, 3) + else: + # If not enough distractors, use what we have and add dummy options + other_options = distractors.copy() + dummy_options = [ + "Belt Balancer System" if subset == 'title' else "Distributes items evenly across multiple belt lanes", + "Automated Train Station" if subset == 'title' else "Loading and unloading point for trains with circuit control", + "Steam Power Plant" if subset == 'title' else "Generates electricity using steam engines and boilers" + ] + while len(other_options) < 3 and dummy_options: + other_options.append(dummy_options.pop(0)) + + all_choices_for_question = [correct_answer] + other_options + random.shuffle(all_choices_for_question) + + try: + image: RenderedImage = instance.namespace._render(blueprint=s.metadata['blueprint']) + from data.vqa.image_utils import save_rendered_image + # Add variation index to image ID to make it unique + image_id = save_rendered_image( + image, + s.metadata['blueprint'], + {**s.metadata, 'variation_idx': variation_idx}, + f"contrastive_v{variation_idx}", + "../../images" + ) + files = {"image": image_id} + except Exception as e: + print(f"Error rendering blueprint: {e}") + continue + + input_text = ( + "What is the best title for this blueprint?" + if subset == 'title' + else "What is the purpose of this blueprint?" + ) + + sample = Sample( + choices=all_choices_for_question, + target=str(correct_answer), + input=input_text, + files=files, + metadata={ + **s.metadata, + 'variation_idx': variation_idx, + 'total_variations': len(variations) + } + ) + samples.append(sample) + + dataset = MemoryDataset(samples) + return dataset + + +# Main tasks module - imports all task definitions from subdirectories +from inspect_ai import eval + +# Import all tasks from the task modules +from data.vqa.tasks import * +from data.vqa.hook import * + +if __name__ == "__main__": + model = ["anthropic/claude-sonnet-4-20250514"] + + # Run evaluation + results = eval( + tasks=[contrastive_alignment_task(limit=3, subset='title'), + contrastive_alignment_task(limit=5, subset='purpose')], + model=model, + limit=3, + log_dir="./../logs", + hooks=[VQAPairsHook()] + ) \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/templates/blueprint_title_purpose.jinja2 b/data/vqa/tasks/blueprints/contrastive_alignment/templates/blueprint_title_purpose.jinja2 new file mode 100644 index 000000000..8fb3926e5 --- /dev/null +++ b/data/vqa/tasks/blueprints/contrastive_alignment/templates/blueprint_title_purpose.jinja2 @@ -0,0 +1,16 @@ +Analyze this Factorio blueprint and generate metadata. + +Blueprint: +{{ blueprint | tojson(indent=2) }} + +Generate: +1. A concise title (max 10 words) that describes what this blueprint builds +2. A purpose description (1-2 sentences) explaining what it does and how it's used + +Format your response as JSON: +```json +{ + "title": "...", + "purpose": "..." +} +``` \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/contrastive_alignment/templates/contrastive_matching.jinja2 b/data/vqa/tasks/blueprints/contrastive_alignment/templates/contrastive_matching.jinja2 new file mode 100644 index 000000000..9bdeb0664 --- /dev/null +++ b/data/vqa/tasks/blueprints/contrastive_alignment/templates/contrastive_matching.jinja2 @@ -0,0 +1,9 @@ +Match the blueprint image to its correct title and purpose from the options below. + +Options: +{% for i, option in enumerate(options) %} +{{ i+1 }}. Title: {{ option.title }} + Purpose: {{ option.purpose }} +{% endfor %} + +Which option best describes this blueprint? Respond with just the number. \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/denoising/solver.py b/data/vqa/tasks/blueprints/denoising/solver.py new file mode 100644 index 000000000..6167ae0ab --- /dev/null +++ b/data/vqa/tasks/blueprints/denoising/solver.py @@ -0,0 +1,84 @@ +import json +import random +from inspect_ai.model import ChatMessageUser +from inspect_ai.solver import Solver, solver, TaskState, Generate +from data.vqa.templates import Templates +from fle.agents.data.screenshots_from_run import create_factorio_instance +from fle.commons.models.rendered_image import RenderedImage + + +@solver +def entity_removal_denoising(qa_pairs_per_blueprint: int = 5) -> Solver: + """ + Solver that: + 1. Loads a blueprint + 2. Generates multiple QA pairs by removing different entities + 3. Stores all QA pairs for the blueprint + + Args: + qa_pairs_per_blueprint: Number of QA pairs to generate per blueprint + """ + + instance = create_factorio_instance() + + async def solve(state: TaskState, generate: Generate) -> TaskState: + blueprint = state.metadata.get("blueprint", {}) + + + # Initialize QA pairs list + qa_pairs = [] + + # Get entities from blueprint + entities = blueprint.get("entities", []) + if not entities: + state.metadata["error"] = "No entities found in blueprint" + state.metadata["qa_pairs"] = qa_pairs + return state + + # Generate specified number of QA pairs + num_pairs = min(qa_pairs_per_blueprint, len(entities)) + selected_indices = random.sample(range(len(entities)), num_pairs) + + for idx in selected_indices: + removed_entity = entities[idx].copy() + + # Create modified blueprint with entity removed + modified_blueprint = blueprint.copy() + modified_blueprint["entities"] = [e for i, e in enumerate(entities) if i != idx] + + # Store the modification details + position = removed_entity.get("position", {}) + entity_name = removed_entity.get("name", "unknown") + + # Generate a question about the missing entity using template + question = f"Name the missing entity at: Position(x={position['x']}, y={position['y']})" + + image: RenderedImage = instance.namespace._render(blueprint=modified_blueprint) + from data.vqa.image_utils import save_rendered_image + # Pass modification info to distinguish denoising variants + modification_info = f"denoising_removed_{removed_entity.get('name', 'unknown')}_{idx}" + image_id = save_rendered_image(image, modified_blueprint, state.metadata, modification_info) + id = image_id + + # Generate the answer + answer = entity_name + + # Create QA pair + qa_pair = { + "question": question, + "answer": answer, + "removed_entity": removed_entity, + "position": position, + "modified_blueprint": modified_blueprint, + "image": id + } + + qa_pairs.append(qa_pair) + + # Store all QA pairs in metadata + state.metadata["qa_pairs"] = qa_pairs + state.metadata["num_qa_pairs"] = len(qa_pairs) + + return state + + return solve \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/denoising/task.py b/data/vqa/tasks/blueprints/denoising/task.py new file mode 100644 index 000000000..7c5fbedbf --- /dev/null +++ b/data/vqa/tasks/blueprints/denoising/task.py @@ -0,0 +1,50 @@ +from inspect_ai import task, Task +from inspect_ai.solver import system_message + +from data.vqa.common_solvers import attach_bounding_box +from data.vqa.dataset import augmented_blueprint_dataset +from data.vqa.tasks.denoising.solver import entity_removal_denoising + + +@task +def simple_denoising_blueprint_task(qa_pairs_per_blueprint: int = 5) -> Task: + """ + Task that creates denoising QA pairs from blueprints. + + This task removes entities from blueprints and asks questions about what's missing. + It's useful for training models to understand blueprint completeness and entity relationships. + + Args: + qa_pairs_per_blueprint: Number of QA pairs to generate per blueprint (default: 5) + """ + return Task( + dataset=augmented_blueprint_dataset(), + solver=[ + system_message( + """You are an expert at analyzing Factorio blueprints and identifying missing components."""), + attach_bounding_box(), + entity_removal_denoising(qa_pairs_per_blueprint=qa_pairs_per_blueprint), + ], + scorer=None, # We're generating data, not scoring + ) + + +# Main tasks module - imports all task definitions from subdirectories +from inspect_ai import eval + +# Import all tasks from the task modules +from data.vqa.tasks import * +from data.vqa.hook import * + +if __name__ == "__main__": + model = ["anthropic/claude-sonnet-4-20250514"] + + # Example: Run a denoising task + results = eval( + tasks=simple_denoising_blueprint_task(qa_pairs_per_blueprint=10), + model=model, + limit=10, + log_dir="../../logs", + hooks=[VQAPairsHook()] + ) + diff --git a/data/vqa/tasks/blueprints/denoising/templates/question_generation.jinja2 b/data/vqa/tasks/blueprints/denoising/templates/question_generation.jinja2 new file mode 100644 index 000000000..35fdac1b3 --- /dev/null +++ b/data/vqa/tasks/blueprints/denoising/templates/question_generation.jinja2 @@ -0,0 +1,11 @@ +Given a modified Factorio blueprint where an entity has been removed, generate a clear, vague question asking about what's missing at position x={{ position.x }}, y={{ position.y }}. + +The removed entity was: {{ entity_name }} + +Generate a natural question that would help identify the missing entity. Examples: +- "What entity should be placed at position ({{ position.x }}, {{ position.y }}) to complete this blueprint?" +- "An entity is missing at x={{ position.x }}, y={{ position.y }}. What type of entity belongs there?" +- "This blueprint has a gap at coordinates ({{ position.x }}, {{ position.y }}). What should fill it?" +- "In what position would you place a new {{ entity_name }}?" + +Return only the question, nothing else. \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/denoising/templates/validation.jinja2 b/data/vqa/tasks/blueprints/denoising/templates/validation.jinja2 new file mode 100644 index 000000000..66bdb8b43 --- /dev/null +++ b/data/vqa/tasks/blueprints/denoising/templates/validation.jinja2 @@ -0,0 +1,8 @@ +You are analyzing a Factorio blueprint that has been modified - one entity has been removed. + +Modified Blueprint: +{{ modified_blueprint | tojson(indent=2) }} + +Question: {{ question }} + +Based on the blueprint structure and the question, what entity is missing? Respond with only the entity name. \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/denoising_qa/solver.py b/data/vqa/tasks/blueprints/denoising_qa/solver.py new file mode 100644 index 000000000..f066295b4 --- /dev/null +++ b/data/vqa/tasks/blueprints/denoising_qa/solver.py @@ -0,0 +1,144 @@ +import json +import random +from inspect_ai.model import ChatMessageUser +from inspect_ai.solver import Solver, solver, TaskState, Generate +from data.vqa.templates import Templates +from fle.agents.data.screenshots_from_run import create_factorio_instance +from fle.commons.models.rendered_image import RenderedImage + + +@solver +def entity_removal_denoising(qa_pairs_per_blueprint: int = 5) -> Solver: + """ + Solver that: + 1. Loads a blueprint + 2. Generates multiple QA pairs by removing different entities + 3. Stores all QA pairs for the blueprint + + Args: + qa_pairs_per_blueprint: Number of QA pairs to generate per blueprint + """ + + instance = create_factorio_instance() + + async def solve(state: TaskState, generate: Generate) -> TaskState: + blueprint = state.metadata.get("blueprint", {}) + + + # Initialize QA pairs list + qa_pairs = [] + + # Get entities from blueprint + entities = blueprint.get("entities", []) + if not entities: + state.metadata["error"] = "No entities found in blueprint" + state.metadata["qa_pairs"] = qa_pairs + return state + + # Generate specified number of QA pairs + num_pairs = min(qa_pairs_per_blueprint, len(entities)) + selected_indices = random.sample(range(len(entities)), num_pairs) + + for idx in selected_indices: + removed_entity = entities[idx].copy() + + # Create modified blueprint with entity removed + modified_blueprint = blueprint.copy() + modified_blueprint["entities"] = [e for i, e in enumerate(entities) if i != idx] + + # Store the modification details + position = removed_entity.get("position", {}) + entity_name = removed_entity.get("name", "unknown") + + # Generate a question about the missing entity using template + question_prompt = Templates.denoising_question( + position=position, + entity_name=entity_name + ) + + state.messages = [ChatMessageUser(content=question_prompt)] + question_response = await generate(state) + question = question_response.output.completion.strip("\"") + + if not question: + continue + + image: RenderedImage = instance.namespace._render(blueprint=modified_blueprint) + from data.vqa.image_utils import save_rendered_image + # Pass modification info to distinguish denoising variants + modification_info = f"denoising_removed_{removed_entity.get('name', 'unknown')}_{idx}" + image_id = save_rendered_image(image, modified_blueprint, state.metadata, modification_info) + id = image_id + + # Generate the answer + answer = entity_name + + # Create QA pair + qa_pair = { + "question": question, + "answer": answer, + "removed_entity": removed_entity, + "position": position, + "modified_blueprint": modified_blueprint, + "image": id + } + + qa_pairs.append(qa_pair) + + # Store all QA pairs in metadata + state.metadata["qa_pairs"] = qa_pairs + state.metadata["num_qa_pairs"] = len(qa_pairs) + + return state + + return solve + + +@solver +def validate_denoising_qa() -> Solver: + """ + Solver that validates if another model can answer the denoising questions correctly. + This should be run after entity_removal_denoising. + """ + + async def solve(state: TaskState, generate: Generate) -> TaskState: + qa_pairs = state.metadata.get("qa_pairs", []) + if not qa_pairs: + state.metadata["error"] = "No QA pairs found" + return state + + validated_pairs = [] + + for qa_pair in qa_pairs: + # Prepare validation prompt using template + validation_prompt = Templates.denoising_validation( + modified_blueprint=qa_pair['modified_blueprint'], + question=qa_pair['question'] + ) + + # Clear messages and ask the validation model + state.messages = [ChatMessageUser(content=validation_prompt)] + + validation_response = await generate(state) + predicted_answer = validation_response.output.completion.strip().lower() + + # Check if the answer is correct + correct_answer = qa_pair['answer'].lower() + is_correct = correct_answer in predicted_answer or predicted_answer in correct_answer + + # Add validation result to QA pair + validated_qa = qa_pair.copy() + validated_qa["validation_result"] = { + "predicted": predicted_answer, + "correct": correct_answer, + "is_correct": is_correct + } + + validated_pairs.append(validated_qa) + + state.metadata["qa_pairs"] = validated_pairs + state.metadata["validation_complete"] = True + + return state + + return solve \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/denoising_qa/task.py b/data/vqa/tasks/blueprints/denoising_qa/task.py new file mode 100644 index 000000000..534955f4b --- /dev/null +++ b/data/vqa/tasks/blueprints/denoising_qa/task.py @@ -0,0 +1,80 @@ +from inspect_ai import task, Task +from inspect_ai.solver import system_message + +from data.vqa.common_solvers import validate_qa_answerability, generate_direction_questions, normalize_position_format, \ + attach_bounding_box +from data.vqa.dataset import augmented_blueprint_dataset +from data.vqa.tasks.blueprints.denoising_qa.solver import entity_removal_denoising, validate_denoising_qa + + +@task +def denoising_blueprint_task(qa_pairs_per_blueprint: int = 5) -> Task: + """ + Task that creates denoising QA pairs from blueprints. + + This task removes entities from blueprints and asks questions about what's missing. + It's useful for training models to understand blueprint completeness and entity relationships. + + Args: + qa_pairs_per_blueprint: Number of QA pairs to generate per blueprint (default: 5) + """ + return Task( + dataset=augmented_blueprint_dataset(), + solver=[ + system_message( + """You are an expert at analyzing Factorio blueprints and identifying missing components."""), + attach_bounding_box(), + entity_removal_denoising(qa_pairs_per_blueprint=qa_pairs_per_blueprint), + generate_direction_questions(), + normalize_position_format(), + validate_qa_answerability(), + ], + scorer=None, # We're generating data, not scoring + ) + + +@task +def denoising_validation_task(qa_pairs_per_blueprint: int = 5) -> Task: + """ + Task that validates denoising QA pairs by testing if a model can answer them correctly. + + This task first generates denoising QA pairs, then validates them by having + a model attempt to answer the questions. + + Args: + qa_pairs_per_blueprint: Number of QA pairs to generate per blueprint (default: 5) + """ + return Task( + dataset=augmented_blueprint_dataset(), + solver=[ + system_message( + """You are an expert at analyzing Factorio blueprints and identifying missing components."""), + attach_bounding_box(), + entity_removal_denoising(qa_pairs_per_blueprint=qa_pairs_per_blueprint), + validate_denoising_qa(), + generate_direction_questions(), + normalize_position_format(), + validate_qa_answerability(), + ], + scorer=None, # Custom scorer would evaluate validation accuracy + ) + +# Main tasks module - imports all task definitions from subdirectories +from inspect_ai import eval + +# Import all tasks from the task modules +from data.vqa.tasks import * +from data.vqa.hook import * + +if __name__ == "__main__": + model = ["anthropic/claude-sonnet-4-20250514"] + + # Example: Run a denoising task + results = eval( + tasks=denoising_blueprint_task(qa_pairs_per_blueprint=10), + model=model, + limit=10, + log_dir="../../logs", + hooks=[VQAPairsHook()] + ) + diff --git a/data/vqa/tasks/blueprints/denoising_qa/templates/question_generation.jinja2 b/data/vqa/tasks/blueprints/denoising_qa/templates/question_generation.jinja2 new file mode 100644 index 000000000..35fdac1b3 --- /dev/null +++ b/data/vqa/tasks/blueprints/denoising_qa/templates/question_generation.jinja2 @@ -0,0 +1,11 @@ +Given a modified Factorio blueprint where an entity has been removed, generate a clear, vague question asking about what's missing at position x={{ position.x }}, y={{ position.y }}. + +The removed entity was: {{ entity_name }} + +Generate a natural question that would help identify the missing entity. Examples: +- "What entity should be placed at position ({{ position.x }}, {{ position.y }}) to complete this blueprint?" +- "An entity is missing at x={{ position.x }}, y={{ position.y }}. What type of entity belongs there?" +- "This blueprint has a gap at coordinates ({{ position.x }}, {{ position.y }}). What should fill it?" +- "In what position would you place a new {{ entity_name }}?" + +Return only the question, nothing else. \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/denoising_qa/templates/validation.jinja2 b/data/vqa/tasks/blueprints/denoising_qa/templates/validation.jinja2 new file mode 100644 index 000000000..66bdb8b43 --- /dev/null +++ b/data/vqa/tasks/blueprints/denoising_qa/templates/validation.jinja2 @@ -0,0 +1,8 @@ +You are analyzing a Factorio blueprint that has been modified - one entity has been removed. + +Modified Blueprint: +{{ modified_blueprint | tojson(indent=2) }} + +Question: {{ question }} + +Based on the blueprint structure and the question, what entity is missing? Respond with only the entity name. \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/spatial_reasoning/.compose.yaml b/data/vqa/tasks/blueprints/spatial_reasoning/.compose.yaml new file mode 100644 index 000000000..e8173b5ba --- /dev/null +++ b/data/vqa/tasks/blueprints/spatial_reasoning/.compose.yaml @@ -0,0 +1,9 @@ +# inspect auto-generated docker compose file +# (will be removed when task is complete) +services: + default: + image: "aisiuk/inspect-tool-support" + command: "tail -f /dev/null" + init: true + network_mode: none + stop_grace_period: 1s diff --git a/data/vqa/tasks/blueprints/spatial_reasoning/solver.py b/data/vqa/tasks/blueprints/spatial_reasoning/solver.py new file mode 100644 index 000000000..f6c165128 --- /dev/null +++ b/data/vqa/tasks/blueprints/spatial_reasoning/solver.py @@ -0,0 +1,271 @@ +import json + +from inspect_ai.model import ChatMessageUser, ChatMessageTool +from inspect_ai.solver import Solver, solver, TaskState, Generate +from inspect_ai.tool import tool, ToolError +from inspect_ai.util import sandbox + +from fle.agents.data.screenshots_from_run import create_factorio_instance +from fle.commons.models.rendered_image import RenderedImage + + +@tool +def analyze_blueprint() -> str: + """ + Analyze a Factorio blueprint using Python code to generate spatial reasoning QA pairs. + + Args: + code: Python code that analyzes the blueprint and generates qa_pairs + + Returns: + JSON string containing the generated QA pairs + """ + + async def execute(code: str) -> str: + # Write the Python code to a file + await sandbox().write_file("/tmp/analyze.py", code) + + # Execute the code + result = await sandbox().exec(["python3", "/tmp/analyze.py"]) + + if result.success: + return result.stdout + else: + raise ToolError(f"Python execution failed: {result.stderr}") + + return execute + + +@solver +def generate_spatial_reasoning_with_code(questions_per_blueprint: int = 3) -> Solver: + """ + Generate spatial reasoning questions using Python code written by the agent. + """ + instance = create_factorio_instance() + async def solve(state: TaskState, generate: Generate) -> TaskState: + blueprint = state.metadata.get("blueprint", {}) + entities = blueprint.get("entities", []) + + image: RenderedImage = instance.namespace._render(blueprint=blueprint) + from data.vqa.image_utils import save_rendered_image + image_id = save_rendered_image(image, blueprint, state.metadata, "spatial_reasoning") + state.metadata["image"] = image_id + + if len(entities) < 2: + state.metadata["error"] = "Not enough entities for spatial reasoning" + state.metadata["spatial_questions"] = [] + return state + + # First, write the blueprint data to a file that the code can import + blueprint_data = f"blueprint = {json.dumps(blueprint, indent=2)}" + await sandbox().write_file("/tmp/blueprint_data.py", blueprint_data) + + # Create the prompt + prompt = f"""I need you to analyze a Factorio blueprint and generate {questions_per_blueprint} spatial reasoning QA pairs. + +The blueprint has {len(entities)} entities. I've saved the blueprint data to `/tmp/blueprint_data.py`. + +Write Python code that: +1. Imports the blueprint data using: `from blueprint_data import blueprint` +2. Analyzes spatial relationships between entities +3. Generates diverse spatial reasoning questions +4. Prints the qa_pairs as JSON + +Your code should generate questions about: +- Distances between entities (Manhattan, Euclidean) +- Relative directions (north/south/east/west) +- Spatial patterns (lines, grids, clusters) +- Nearest/farthest entities +- Entities within a certain radius + +The output should be a JSON list of QA pairs, each with: +- 'question': The spatial reasoning question +- 'answer': The correct answer +- 'metadata': Additional context about the spatial relationship + +Example code structure: +```python +import json +import random +import math +from blueprint_data import blueprint + +entities = blueprint.get('entities', []) +qa_pairs = [] + +# Generate distance questions +for _ in range(2): + if len(entities) >= 2: + e1, e2 = random.sample(entities, 2) + x1, y1 = e1['position']['x'], e1['position']['y'] + x2, y2 = e2['position']['x'], e2['position']['y'] + + manhattan = abs(x2 - x1) + abs(y2 - y1) + + qa_pairs.append({{ + 'question': f"What is the Manhattan distance between the {{e1['name']}} at ({{x1}}, {{y1}}) and the {{e2['name']}} at ({{x2}}, {{y2}})?", + 'answer': str(manhattan), + 'metadata': {{ + 'type': 'distance', + 'entities': [e1['name'], e2['name']], + 'positions': [(x1, y1), (x2, y2)] + }} + }}) + +# Add more question types... + +print(json.dumps(qa_pairs, indent=2)) +``` + +Use the analyze_blueprint tool to execute your code.""" + + state.messages = [ChatMessageUser(content=prompt)] + + # Let the agent generate and execute code + state = await generate(state) + + # Extract results from tool calls + qa_pairs = [] + for tool_call in reversed(state.messages): + if isinstance(tool_call, ChatMessageTool): + try: + qa_pairs = json.loads(tool_call.content) + break + except json.JSONDecodeError: + continue + + state.metadata["spatial_questions"] = qa_pairs + state.metadata["generation_method"] = "sandbox_code" + + return state + + return solve + + +@solver +def generate_spatial_context_with_code() -> Solver: + """ + Generate spatial context questions for denoising scenarios using sandbox Python execution. + """ + instance = create_factorio_instance() + async def solve(state: TaskState, generate: Generate) -> TaskState: + + qa_pairs = state.metadata.get("qa_pairs", []) + if not qa_pairs: + state.metadata["error"] = "No denoising QA pairs found" + return state + + core_qa_pairs = [] + images = [] + for pair in qa_pairs: + core_pair = {} + for key, value in pair.items(): + if key != "image": + core_pair[key] = value + else: + images.append(value) + core_qa_pairs.append(core_pair) + + # Write the QA pairs data to a file + qa_data = f"qa_pairs = {json.dumps(core_qa_pairs, indent=2)}" + await sandbox().write_file("/tmp/qa_pairs_data.py", qa_data) + + prompt = f"""I need you to enhance {len(qa_pairs)} denoising QA pairs with spatial context analysis. + +The QA pairs data has been saved to `/tmp/qa_pairs_data.py`. Each pair contains: +- 'removed_entity': The entity that was removed +- 'modified_blueprint': The blueprint after removal +- 'position': Where the entity was removed + +Write Python code that: +1. Imports the data: `from qa_pairs_data import qa_pairs` +2. For each QA pair, analyzes nearby entities in the modified blueprint +3. Generates spatial context questions about what's missing +4. Creates enhanced QA pairs with spatial reasoning + +Generate questions like: +- "What entity is missing 2 tiles north of the [entity_name] at position ([x], [y])?" +- "An entity was removed between two [entity_type]. What was it?" +- "What's missing from the center of the 3x3 grid?" + +Output format should be a JSON list of enhanced QA pairs with: +- All original fields +- 'spatial_question': A context-aware question +- 'nearby_entities': List of nearby entities with distances and directions + +Only print the final output to stdout, and nothing else. + +Example approach: +```python +import json +from qa_pairs_data import qa_pairs + +def get_direction(from_pos, to_pos): + dx = to_pos['x'] - from_pos['x'] + dy = to_pos['y'] - from_pos['y'] + + if abs(dx) > abs(dy): + return 'east' if dx > 0 else 'west' + else: + return 'south' if dy > 0 else 'north' + +enhanced_pairs = [] + +for qa in qa_pairs: + removed_pos = qa['position'] + entities = qa['modified_blueprint']['entities'] + + # Find nearby entities + nearby = [] + for entity in entities: + pos = entity['position'] + dist = abs(pos['x'] - removed_pos['x']) + abs(pos['y'] - removed_pos['y']) + if dist <= 5: + nearby.append({{ + 'name': entity['name'], + 'distance': dist, + 'direction': get_direction(removed_pos, pos) + }}) + + nearby.sort(key=lambda x: x['distance']) + + # Create spatial question + if nearby: + nearest = nearby[0] + spatial_q = f"What entity is missing {{nearest['distance']}} tiles {{nearest['direction']}} of the {{nearest['name']}}?" + else: + spatial_q = f"What entity was at position ({{removed_pos['x']}}, {{removed_pos['y']}})?" + + enhanced = qa.copy() + enhanced['spatial_question'] = spatial_q + enhanced['nearby_entities'] = nearby[:3] + enhanced_pairs.append(enhanced) + +print(json.dumps(enhanced_pairs, indent=2)) +```""" + + state.messages = [ChatMessageUser(content=prompt)] + + # Let the agent generate and execute code + state = await generate(state) + + # Extract results from tool calls + for tool_call in reversed(state.messages): + if isinstance(tool_call, ChatMessageTool): + try: + enhanced_pairs = json.loads(tool_call.content) + state.metadata["qa_pairs"] = enhanced_pairs + state.metadata["spatial_context_added"] = True + break + except json.JSONDecodeError: + continue + + blueprint = state.metadata.get("blueprint", {}) + image: RenderedImage = instance.namespace._render(blueprint=blueprint) + from data.vqa.image_utils import save_rendered_image + image_id = save_rendered_image(image, blueprint, state.metadata, "spatial_context") + state.metadata["image"] = image_id + + return state + + return solve \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/spatial_reasoning/task.py b/data/vqa/tasks/blueprints/spatial_reasoning/task.py new file mode 100644 index 000000000..8661be46e --- /dev/null +++ b/data/vqa/tasks/blueprints/spatial_reasoning/task.py @@ -0,0 +1,150 @@ +from importlib.metadata import metadata + +from inspect_ai import task, Task +from inspect_ai.solver import system_message + +from data.vqa.dataset import raw_blueprint_dataset +from data.vqa.tasks.blueprints.spatial_reasoning.solver import ( + generate_spatial_reasoning_with_code, + generate_spatial_context_with_code +) +from inspect_ai.tool import bash, python +from data.vqa.tasks.blueprints.denoising_qa.solver import entity_removal_denoising +from inspect_ai.solver import use_tools + +from data.vqa.common_solvers import validate_qa_answerability, generate_direction_questions, normalize_position_format, attach_bounding_box + +from data.vqa.hook import VQAPairsHook + + +@task +def spatial_reasoning_sandbox_task(questions_per_blueprint: int = 3) -> Task: + """ + Spatial reasoning task using sandboxed Python execution. + + The agent writes Python code to analyze blueprints and generate + diverse spatial reasoning questions. This allows for more complex + analysis including pattern detection, clustering, and path finding. + + Args: + questions_per_blueprint: Number of questions to generate per blueprint + """ + return Task( + dataset=raw_blueprint_dataset(), + solver=[ + use_tools([bash(), python()]), + system_message("""You are an expert at spatial analysis in Factorio blueprints. + You can write Python code to analyze entity positions, calculate distances, + identify patterns, and generate creative spatial reasoning questions. + + Focus on: + - Distance calculations (Manhattan, Euclidean) + - Directional relationships (north/south/east/west) + - Spatial patterns (lines, grids, clusters) + - Relative positions and proximity + - Path finding and connectivity + - Symmetry and alignment analysis + + Your code has access to the blueprint data and can use standard Python + libraries for calculations."""), + attach_bounding_box(), + generate_spatial_reasoning_with_code(questions_per_blueprint=questions_per_blueprint), + generate_direction_questions(), + normalize_position_format(), + validate_qa_answerability(), + ], + sandbox='docker', # Use local Python sandbox + scorer=None, + ) + + +@task +def spatial_context_sandbox_task(qa_pairs_per_blueprint: int = 5) -> Task: + """ + Spatial context denoising task using sandboxed Python execution. + + The agent writes Python code to analyze spatial relationships + around removed entities and generate context-aware questions. + This enables sophisticated pattern analysis and spatial reasoning. + + Args: + qa_pairs_per_blueprint: Number of QA pairs to generate + """ + return Task( + dataset=raw_blueprint_dataset(), + solver=[ + use_tools([bash(), python()]), + system_message("""You are an expert at spatial context analysis in Factorio. + Write Python code to analyze spatial relationships around missing entities + and generate questions that use spatial context to identify what's missing. + + Consider: + - Nearby entity positions and types + - Patterns that would be broken by the missing entity + - Functional relationships (e.g., inserters need adjacent targets) + - Symmetry and alignment in the layout + - Connection patterns (belts, pipes, power) + - Production flow and logistics + + Your code should identify sophisticated spatial patterns and generate + questions that require understanding these relationships."""), + attach_bounding_box(), + entity_removal_denoising(qa_pairs_per_blueprint=qa_pairs_per_blueprint), + generate_spatial_context_with_code(), + generate_direction_questions(), + normalize_position_format(), + #validate_qa_answerability(), + ], + sandbox='docker', + scorer=None, + ) + + +""" +Example of using the spatial reasoning sandbox tasks +""" +from inspect_ai import eval + +if __name__ == "__main__": + # Example 1: Basic spatial reasoning with code generation + print("Running spatial reasoning with Python code generation...") + + model = ["anthropic/claude-opus-4-20250514"] + + results = eval( + tasks=spatial_reasoning_sandbox_task(questions_per_blueprint=20), + #model=["anthropic/claude-opus-4-20250514"], # or any other model + model=model, + limit=2, + log_dir="../../logs", + hooks=[VQAPairsHook()] + ) + + # Print some generated questions + for sample in results[0].samples: + if "spatial_questions" in sample.metadata: + print("\nGenerated spatial questions:") + for qa in sample.metadata["spatial_questions"][:2]: + print(f"Q: {qa['question']}") + print(f"A: {qa['answer']}") + print() + #if 'metadata' in qa: + # print(f"Metadata: {qa['metadata']}") + + # Example 2: Spatial context denoising with code + print("\n\nRunning spatial context denoising with code generation...") + + results2 = eval( + tasks=spatial_context_sandbox_task(qa_pairs_per_blueprint=20), + model=model, + limit=2, + log_dir="../../logs", + hooks=[VQAPairsHook()] + ) + + # Print + for sample in results2[0].samples: + queries = sample.metadata['spatial_questions'] if 'spatial_questions' in sample.metadata else [] + qa_pairs = sample.metadata['qa_pairs'] if 'qa_pairs' in sample.metadata else [] + combined = qa_pairs + queries + pass \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/spatial_reasoning/templates/spatial_context_question.jinja2 b/data/vqa/tasks/blueprints/spatial_reasoning/templates/spatial_context_question.jinja2 new file mode 100644 index 000000000..3914a7a68 --- /dev/null +++ b/data/vqa/tasks/blueprints/spatial_reasoning/templates/spatial_context_question.jinja2 @@ -0,0 +1,13 @@ +Generate a spatial reasoning question about a missing entity in a Factorio blueprint. + +The removed entity was: {{ removed_entity.name }} at position ({{ removed_position.x }}, {{ removed_position.y }}) + +Nearby entities: +{{ nearby_entities | tojson(indent=2) }} + +Create a question that uses spatial relationships to identify what's missing. Examples: +- "What entity is missing 2 tiles north of the {{ nearest_entity_name }}?" +- "An entity has been removed between two transport belts. What should be there?" +- "What's missing at the position that would connect the nearby inserters?" + +Return only the question. \ No newline at end of file diff --git a/data/vqa/tasks/blueprints/spatial_reasoning/templates/spatial_question.jinja2 b/data/vqa/tasks/blueprints/spatial_reasoning/templates/spatial_question.jinja2 new file mode 100644 index 000000000..580ebff9d --- /dev/null +++ b/data/vqa/tasks/blueprints/spatial_reasoning/templates/spatial_question.jinja2 @@ -0,0 +1,8 @@ +Analyze this Factorio blueprint and answer the spatial reasoning question. + +Blueprint: +{{ blueprint | tojson(indent=2) }} + +Question: {{ question }} + +Think step by step about the spatial relationships and provide your answer. \ No newline at end of file diff --git a/data/vqa/tasks/terrain/character_localisation/solver.py b/data/vqa/tasks/terrain/character_localisation/solver.py new file mode 100644 index 000000000..4168a62bd --- /dev/null +++ b/data/vqa/tasks/terrain/character_localisation/solver.py @@ -0,0 +1,50 @@ +import random + +from inspect_ai.solver import Solver, solver, TaskState, Generate + + +@solver +def character_localisation_question(multiple_choice: bool = False) -> Solver: + async def solve(state: TaskState, generate: Generate) -> TaskState: + + renderer = state.metadata['renderer'] + + characters = list(filter(lambda x:x.name == 'character', renderer.entities)) + + if len(characters) == 1: + if multiple_choice: + if len(renderer.entities) >= 3: + options = [ entity.position for i, entity in enumerate(random.sample(renderer.entities, k=3))] + else: + options = [ entity.position for entity in renderer.entities ] + + if characters[0].position not in options: + options.append( characters[0].position ) + + random.shuffle(options) + correct_index = str(options.index(characters[0].position) + 1) + option_string = "\n".join([f"{i+1}) Position({str(option)})" for i, option in enumerate(options)]) + question = f"What is the position of your character?\n{option_string}\nOnly provide the correct number." + qa_entry = { + "question": question, + "answer": str(correct_index), + "position": characters[0].position, + "entity_properties": characters[0], + "question_type": "multiple_choice" + } + + else: + qa_entry = { + "question": "What is the position of your character?", + "answer": f"Position(x={characters[0].position.x}, y={characters[0].position.y})", + "position": characters[0].position, + "entity_properties": characters[0], + "question_type": "open_ended" + } + + state.metadata["character_localisation_question"] = [ + qa_entry + ] + return state + + return solve \ No newline at end of file diff --git a/data/vqa/tasks/terrain/dataset.py b/data/vqa/tasks/terrain/dataset.py new file mode 100644 index 000000000..647ab35b6 --- /dev/null +++ b/data/vqa/tasks/terrain/dataset.py @@ -0,0 +1,230 @@ +from inspect_ai.dataset import Dataset, Sample, MemoryDataset +from typing import List, Tuple +import math + + +def generate_spiral_positions(max_radius: int = 50, step: int = 1) -> List[Tuple[int, int]]: + """ + Generate positions in a spiral pattern starting from origin. + + Args: + max_radius: Maximum distance from origin to generate + step: Step size between positions + + Returns: + List of (x, y) positions in spiral order + """ + positions = [] + x, y = 0, 0 + dx, dy = 0, -step + + positions.append((x, y)) + + while max(abs(x), abs(y)) < max_radius: + # Check if we need to turn + if x == y or (x < 0 and x == -y) or (x > 0 and x == 1 - y): + # Turn 90 degrees clockwise + dx, dy = -dy, dx + + # Move to next position + x, y = x + dx, y + dy + positions.append((x, y)) + + return positions + + +def generate_concentric_spiral_positions(max_radius: int = 50) -> List[Tuple[int, int]]: + """ + Generate positions in concentric squares expanding from origin. + This creates a more predictable pattern than a true spiral. + + Args: + max_radius: Maximum distance from origin + + Returns: + List of (x, y) positions in concentric order + """ + positions = [(0, 0)] # Start at origin + + for radius in range(1, max_radius + 1): + # Top edge (left to right) + for x in range(-radius, radius + 1): + positions.append((x, -radius)) + + # Right edge (top to bottom, excluding corners) + for y in range(-radius + 1, radius): + positions.append((radius, y)) + + # Bottom edge (right to left) + for x in range(radius, -radius - 1, -1): + positions.append((x, radius)) + + # Left edge (bottom to top, excluding corners) + for y in range(radius - 1, -radius, -1): + positions.append((-radius, y)) + + return positions + + +def generate_true_spiral_positions(max_positions: int = 10000, spacing: float = 1.0) -> List[Tuple[int, int]]: + """ + Generate positions following an Archimedean spiral. + + Args: + max_positions: Maximum number of positions to generate + spacing: Distance between spiral arms + + Returns: + List of (x, y) positions in spiral order + """ + positions = [] + seen = set() + + theta = 0 + while len(positions) < max_positions: + # Archimedean spiral: r = a + b * theta + r = spacing * theta / (2 * math.pi) + + # Convert to Cartesian coordinates + x = int(round(r * math.cos(theta))) + y = int(round(r * math.sin(theta))) + + # Only add unique positions + if (x, y) not in seen: + positions.append((x, y)) + seen.add((x, y)) + + # Increment angle + theta += 0.1 + + # Break if we're too far from origin + if r > 100: + break + + return positions + + +def raw_position_dataset(pattern: str = "concentric", limit: int = None) -> MemoryDataset: + """ + Generate position dataset with various patterns. + + Args: + pattern: One of "concentric", "spiral", "true_spiral", or "grid" + limit: Maximum number of samples (None for all) + + Returns: + MemoryDataset with position samples + """ + samples = [] + + if pattern == "grid": + # Original grid pattern + positions = [(x, y) for x in range(-50, 51) for y in range(-50, 51)] + elif pattern == "concentric": + # Concentric squares expanding from origin + positions = generate_concentric_spiral_positions(max_radius=50) + elif pattern == "spiral": + # Simple spiral pattern + positions = generate_spiral_positions(max_radius=50) + elif pattern == "true_spiral": + # Archimedean spiral + positions = generate_true_spiral_positions(max_positions=10000) + else: + raise ValueError(f"Unknown pattern: {pattern}") + + # Apply limit if specified + if limit: + positions = positions[:limit] + + # Create samples + for x, y in positions: + sample = Sample( + input=f"Position(x={x}, y={y})", + metadata={"x": x, "y": y}, + ) + samples.append(sample) + + dataset = MemoryDataset(samples=samples) + return dataset + + +def raw_position_dataset_with_priority(max_radius: int = 50, + inner_radius_priority: int = 10) -> MemoryDataset: + """ + Generate position dataset with priority given to positions near origin. + + Args: + max_radius: Maximum distance from origin + inner_radius_priority: Positions within this radius are added first + + Returns: + MemoryDataset with position samples + """ + samples = [] + + # First add all positions within priority radius + priority_positions = [] + regular_positions = [] + + for x in range(-max_radius, max_radius + 1): + for y in range(-max_radius, max_radius + 1): + distance = math.sqrt(x * x + y * y) + if distance <= inner_radius_priority: + priority_positions.append((x, y, distance)) + elif distance <= max_radius: + regular_positions.append((x, y, distance)) + + # Sort priority positions by distance from origin + priority_positions.sort(key=lambda p: p[2]) + + # Sort regular positions by distance from origin + regular_positions.sort(key=lambda p: p[2]) + + # Create samples - priority positions first + for x, y, _ in priority_positions: + sample = Sample( + input=f"Position(x={x}, y={y})", + metadata={"x": x, "y": y, "distance_from_origin": math.sqrt(x * x + y * y)}, + ) + samples.append(sample) + + # Then regular positions + for x, y, _ in regular_positions: + sample = Sample( + input=f"Position(x={x}, y={y})", + metadata={"x": x, "y": y, "distance_from_origin": math.sqrt(x * x + y * y)}, + ) + samples.append(sample) + + dataset = MemoryDataset(samples=samples) + return dataset + + +# Update your terrain/dataset.py to use this pattern +def terrain_position_dataset() -> MemoryDataset: + """ + Generate terrain positions in a concentric spiral pattern. + This ensures we explore from the origin outward, which is more + efficient for finding resources and buildable areas. + """ + return raw_position_dataset(pattern="concentric", limit=None) + + +# Example usage in your task +if __name__ == "__main__": + # Test different patterns + print("Concentric pattern (first 20 positions):") + dataset = raw_position_dataset(pattern="concentric", limit=20) + for i, sample in enumerate(dataset.samples[:20]): + print(f"{i}: x={sample.metadata['x']}, y={sample.metadata['y']}") + + print("\nSpiral pattern (first 20 positions):") + dataset = raw_position_dataset(pattern="spiral", limit=20) + for i, sample in enumerate(dataset.samples[:20]): + print(f"{i}: x={sample.metadata['x']}, y={sample.metadata['y']}") + + print("\nPriority-based pattern (first 20 positions):") + dataset = raw_position_dataset_with_priority(max_radius=50, inner_radius_priority=5) + for i, sample in enumerate(dataset.samples[:20]): + dist = sample.metadata.get('distance_from_origin', 0) + print(f"{i}: x={sample.metadata['x']}, y={sample.metadata['y']}, dist={dist:.2f}") \ No newline at end of file diff --git a/data/vqa/tasks/terrain/nearest/README.md b/data/vqa/tasks/terrain/nearest/README.md new file mode 100644 index 000000000..4cd8eaad1 --- /dev/null +++ b/data/vqa/tasks/terrain/nearest/README.md @@ -0,0 +1,7 @@ +We are disposing of `nearest`. + +We should train the visual model to do this automatically. + +This task should do the following: +1. Given a player position +2. Predict the nearest entity / resource / tree / water etc \ No newline at end of file diff --git a/data/vqa/tasks/terrain/nearest/solver.py b/data/vqa/tasks/terrain/nearest/solver.py new file mode 100644 index 000000000..cb54ff903 --- /dev/null +++ b/data/vqa/tasks/terrain/nearest/solver.py @@ -0,0 +1,79 @@ +import copy +import random +import re + +import numpy as np +from inspect_ai.solver import Solver, solver, TaskState, Generate + +from fle.env import Resource + + +@solver +def nearest_questions(multiple_choice: bool = True) -> Solver: + + async def solve(state: TaskState, generate: Generate) -> TaskState: + + instance = state.metadata['instance'] + renderer = state.metadata['renderer'] + + state.metadata["nearest_questions"] = [] + + bag = [Resource.IronOre, + Resource.Water, + Resource.Stone, + Resource.CrudeOil, + Resource.CopperOre, + Resource.Coal, + Resource.Wood] + + nearests = [] + for b in bag: + choice = b + try: + nearest = instance.namespace.nearest(choice) + nearests.append((choice, nearest)) + except Exception as e: + continue + + for choice, nearest in nearests: + + choice_name, choice_entity = choice + if not multiple_choice: + question = f"What is the position of the nearest {choice_name} to you?" + answer = f"Position({str(nearest)})" + + qa_entry = { + "question": question, + "answer": answer, + "entity_properties": choice_name, + "nearest": nearest, + "question_type": "open_ended" + } + state.metadata["nearest_questions"].append(qa_entry) + else: + other_options = random.sample([p for _, p in nearests], 3) + alphabet = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k'] + other_options.append(nearest) + random.shuffle(other_options) + option_string = "\n".join([f"{alphabet[i]}) Position({option})" for i, option in enumerate(other_options)]) + + question = (f"What is the position of the nearest {choice_name} to you?\n" + f"Provide the correct letter and nothing else.\n" + f"{option_string}") + + answer = str(alphabet[other_options.index(nearest)]) + + qa_entry = { + "question": question, + "answer": answer, + "entity_properties": choice_name, + "nearest": nearest, + "options": other_options, + "question_type": "multiple_choice" + } + state.metadata["nearest_questions"].append(qa_entry) + pass + + return state + + return solve \ No newline at end of file diff --git a/data/vqa/tasks/terrain/nearest_buildable/README.md b/data/vqa/tasks/terrain/nearest_buildable/README.md new file mode 100644 index 000000000..6361eaf06 --- /dev/null +++ b/data/vqa/tasks/terrain/nearest_buildable/README.md @@ -0,0 +1,8 @@ +We are disposing of `nearest_buildable`. + +We should train the visual model to do this automatically. + +This task should do the following: +1. Given an entity that we want to place +2. Predict the nearest place that this entity can be built. + diff --git a/data/vqa/tasks/terrain/nearest_buildable/solver.py b/data/vqa/tasks/terrain/nearest_buildable/solver.py new file mode 100644 index 000000000..c5060eabe --- /dev/null +++ b/data/vqa/tasks/terrain/nearest_buildable/solver.py @@ -0,0 +1,341 @@ +import random +from typing import List, Dict, Any +from inspect_ai.solver import Solver, solver, TaskState, Generate +from fle.env import Position, BuildingBox +from fle.env.game_types import Prototype + +# Common prototypes to test for building placement +BUILDABLE_PROTOTYPES = [ + # Basic structures + Prototype.WoodenChest, + Prototype.IronChest, + Prototype.SteelChest, + + # Production buildings + Prototype.AssemblingMachine1, + Prototype.AssemblingMachine2, + Prototype.StoneFurnace, + Prototype.SteelFurnace, + Prototype.ElectricFurnace, + + # Mining + Prototype.BurnerMiningDrill, + Prototype.ElectricMiningDrill, + + # Power + Prototype.SteamEngine, + Prototype.SolarPanel, + Prototype.Accumulator, + Prototype.Boiler, + + # Logistics + Prototype.TransportBelt, + Prototype.FastTransportBelt, + Prototype.Inserter, + Prototype.LongHandedInserter, + Prototype.FastInserter, + + # Defense + Prototype.GunTurret, + Prototype.StoneWall, + + # Fluid handling + Prototype.Pipe, + Prototype.StorageTank, + Prototype.OffshorePump, + Prototype.Pump, + + # Advanced + Prototype.Lab, + Prototype.ChemicalPlant, + Prototype.OilRefinery, + Prototype.RocketSilo, +] + + +@solver +def nearest_buildable_questions( + questions_per_position: int = 5, + multiple_choice: bool = True, + prototype_subset: List[Prototype] = None +) -> Solver: + """ + Generate questions about nearest buildable positions for various prototypes. + + Args: + questions_per_position: Number of questions to generate per terrain position + multiple_choice: If True, generate multiple choice questions + prototype_subset: Specific prototypes to test, if None uses default list + """ + + async def solve(state: TaskState, generate: Generate) -> TaskState: + instance = state.metadata.get('instance') + renderer = state.metadata.get('renderer') + + if not instance: + state.metadata["error"] = "No instance found" + state.metadata["nearest_buildable_questions"] = [] + return state + + characters = list(filter(lambda x: x.name == 'character', renderer.entities)) + player_position = None + if len(characters) == 1: + player_position = characters[0].position + else: + return state + + # Use provided prototypes or default list + prototypes_to_test = prototype_subset or BUILDABLE_PROTOTYPES + + # Sample prototypes for this position + num_questions = min(questions_per_position, len(prototypes_to_test)) + selected_prototypes = random.sample(prototypes_to_test, num_questions) + + nearest_buildable_questions = [] + + for prototype in selected_prototypes: + try: + # Get the prototype's dimensions + width = prototype.WIDTH + height = prototype.HEIGHT + + # Create building box + building_box = BuildingBox(width=width, height=height) + + # Get current player position as center + player_pos = player_position + center_pos = Position(x=player_pos.x, y=player_pos.y) + + # Find nearest buildable position + buildable_area = instance.namespace.nearest_buildable( + entity=prototype, + building_box=building_box, + center_position=center_pos + ) + + # Extract the center position of buildable area + nearest_pos = buildable_area.center + + if not multiple_choice: + # Open-ended question + question = f"What is the position of the nearest place you can build a {prototype.value[0]}?" + answer = f"Position(x={nearest_pos.x}, y={nearest_pos.y})" + + qa_entry = { + "question": question, + "answer": answer, + "prototype": prototype.value[0], + "building_box": {"width": width, "height": height}, + "center_position": {"x": center_pos.x, "y": center_pos.y}, + "buildable_area": { + "center": {"x": nearest_pos.x, "y": nearest_pos.y}, + "left_top": {"x": buildable_area.left_top.x, "y": buildable_area.left_top.y}, + "right_bottom": {"x": buildable_area.right_bottom.x, "y": buildable_area.right_bottom.y} + }, + "question_type": "open_ended" + } + else: + # Multiple choice question + # Generate distractor positions + distractors = [] + + # Add some offset positions as distractors + offsets = [ + (-5, -5), (5, 5), (-10, 0), (0, 10), + (-7, 3), (3, -7), (8, -2), (-2, 8) + ] + + for offset_x, offset_y in random.sample(offsets, 3): + distractor_pos = Position( + x=center_pos.x + offset_x, + y=center_pos.y + offset_y + ) + distractors.append(distractor_pos) + + # Create options list with correct answer + options = distractors + [nearest_pos] + random.shuffle(options) + + # Create alphabet labels + alphabet = ['a', 'b', 'c', 'd'] + + # Format options string + option_strings = [] + for i, pos in enumerate(options): + option_strings.append(f"{alphabet[i]}) Position(x={pos.x}, y={pos.y})") + + options_text = "\n".join(option_strings) + + # Find correct answer letter + correct_index = options.index(nearest_pos) + correct_letter = alphabet[correct_index] + + question = ( + f"What is the position of the nearest place you can build a {prototype.value[0]}?\n" + f"Provide the correct letter and nothing else.\n" + f"{options_text}" + ) + + qa_entry = { + "question": question, + "answer": correct_letter, + "prototype": prototype.value[0], + "building_box": {"width": width, "height": height}, + "center_position": {"x": center_pos.x, "y": center_pos.y}, + "buildable_area": { + "center": {"x": nearest_pos.x, "y": nearest_pos.y}, + "left_top": {"x": buildable_area.left_top.x, "y": buildable_area.left_top.y}, + "right_bottom": {"x": buildable_area.right_bottom.x, "y": buildable_area.right_bottom.y} + }, + "options": [{"x": pos.x, "y": pos.y} for pos in options], + "correct_index": correct_index, + "question_type": "multiple_choice" + } + + nearest_buildable_questions.append(qa_entry) + + except Exception as e: + # Log error but continue with other prototypes + print(f"Error finding buildable position for {prototype.value[0]}: {e}") + continue + + state.metadata["nearest_buildable_questions"] = nearest_buildable_questions + return state + + return solve + + +@solver +def nearest_buildable_with_resources_questions( + questions_per_position: int = 3, + multiple_choice: bool = True +) -> Solver: + """ + Generate questions about nearest buildable positions for resource-dependent entities + like mining drills that need to be placed on resource patches. + """ + + # Prototypes that need resources + RESOURCE_DEPENDENT_PROTOTYPES = [ + (Prototype.BurnerMiningDrill, ["iron-ore", "copper-ore", "coal", "stone"]), + (Prototype.ElectricMiningDrill, ["iron-ore", "copper-ore", "coal", "stone"]), + (Prototype.PumpJack, ["crude-oil"]), + (Prototype.OffshorePump, ["water"]) + ] + + async def solve(state: TaskState, generate: Generate) -> TaskState: + instance = state.metadata.get('instance') + renderer = state.metadata.get('renderer') + + if not instance: + state.metadata["error"] = "No instance found" + state.metadata["nearest_buildable_resource_questions"] = [] + return state + + questions = [] + + characters = list(filter(lambda x: x.name == 'character', renderer.entities)) + player_position = None + if len(characters) == 1: + player_position = characters[0].position + else: + return state + + # Sample resource-dependent prototypes + num_questions = min(questions_per_position, len(RESOURCE_DEPENDENT_PROTOTYPES)) + selected_items = random.sample(RESOURCE_DEPENDENT_PROTOTYPES, num_questions) + + for prototype, valid_resources in selected_items: + try: + width = prototype.WIDTH + height = prototype.HEIGHT + building_box = BuildingBox(width=width, height=height) + + # Get current position + player_pos = player_position #instance.namespace.get_player().position + center_pos = Position(x=player_pos.x, y=player_pos.y) + + # Find nearest buildable position (will consider resource requirements) + buildable_area = instance.namespace.nearest_buildable( + entity=prototype, + building_box=building_box, + center_position=center_pos + ) + + nearest_pos = buildable_area.center + + # Determine which resource this would be on + resource_type = "a resource patch" + if prototype == Prototype.PumpJack: + resource_type = "crude oil" + elif prototype == Prototype.OffshorePump: + resource_type = "water" + else: + resource_type = "ore" + + if not multiple_choice: + question = ( + f"What is the position of the nearest {resource_type} where I can build a {prototype.value[0]}?" + ) + answer = f"Position(x={nearest_pos.x}, y={nearest_pos.y})" + + qa_entry = { + "question": question, + "answer": answer, + "prototype": prototype.value[0], + "resource_type": resource_type, + "building_box": {"width": width, "height": height}, + "buildable_position": {"x": nearest_pos.x, "y": nearest_pos.y}, + "question_type": "open_ended" + } + else: + # Generate distractors + distractors = [] + offsets = [(-8, -8), (10, 0), (0, -10), (7, 7), (-5, 5), (5, -5)] + + for offset_x, offset_y in random.sample(offsets, 3): + distractor = Position( + x=center_pos.x + offset_x, + y=center_pos.y + offset_y + ) + distractors.append(distractor) + + options = distractors + [nearest_pos] + random.shuffle(options) + + alphabet = ['a', 'b', 'c', 'd'] + option_strings = [ + f"{alphabet[i]}) Position(x={pos.x}, y={pos.y})" + for i, pos in enumerate(options) + ] + + correct_index = options.index(nearest_pos) + + question = ( + f"What is the position of the nearest {resource_type} where you can build a {prototype.value[0]}?\n" + f"Provide the correct letter and nothing else.\n" + f"{'\n'.join(option_strings)}" + ) + + qa_entry = { + "question": question, + "answer": alphabet[correct_index], + "prototype": prototype.value[0], + "resource_type": resource_type, + "building_box": {"width": width, "height": height}, + "buildable_position": {"x": nearest_pos.x, "y": nearest_pos.y}, + "options": [{"x": pos.x, "y": pos.y} for pos in options], + "correct_index": correct_index, + "question_type": "multiple_choice" + } + + questions.append(qa_entry) + + except Exception as e: + print(f"Error with {prototype.value[0]}: {e}") + continue + + state.metadata["nearest_buildable_resource_questions"] = questions + return state + + return solve \ No newline at end of file diff --git a/data/vqa/tasks/terrain/solver.py b/data/vqa/tasks/terrain/solver.py new file mode 100644 index 000000000..2394632c6 --- /dev/null +++ b/data/vqa/tasks/terrain/solver.py @@ -0,0 +1,76 @@ +import random +from asyncio import sleep + +from inspect_ai.solver import Solver, solver, TaskState, Generate + +from data.vqa.image_utils import save_rendered_image +from fle.agents.data.screenshots_from_run import create_factorio_instance +from fle.env import Position, Resource, Prototype + + +@solver +def render_terrain() -> Solver: + + instance = create_factorio_instance() + + async def solve(state: TaskState, generate: Generate) -> TaskState: + x,y = state.metadata['x'], state.metadata['y'] + step = 32 + request = f'/c game.surfaces[0].request_to_generate_chunks({{{x*step}, {y*step}}}, 16)' + instance.rcon_client.send_command(request) + instance.rcon_client.send_command(f'/c game.player.surface.force_generate_chunk_requests()') + instance.namespace.move_to(Position(x=x*step, y=y*step)) + + nearest = None + attempt = 0 + + # We move between map features. + bag = [Resource.IronOre, + Resource.Water, + Resource.Stone, + Resource.CrudeOil, + Resource.CopperOre, + Resource.Coal, + Resource.Wood] + + while nearest is None and bag: + choice = random.choice(bag) + try: + nearest = instance.namespace.nearest(choice) + instance.namespace.move_to(nearest) + print("nearest:", nearest) + except Exception as e: + attempt += 1 + bag.remove(choice) + continue + + visible_radius = 32 # The actual visible area we want to render + + # For now, use the visible radius directly since max_render_radius centers at (0,0) in normalized space + # TODO: Update renderer to support centering the trim area at player position + image, renderer = instance.namespace._render(radius=visible_radius, + position=nearest, + return_renderer=True, + max_render_radius=32) + + # Add the actual position coordinates to metadata for image naming + if nearest: + state.metadata['position'] = {'x': int(nearest.x), 'y': int(nearest.y)} + else: + # Fallback to original position if no resource was found + state.metadata['position'] = {'x': int(x * step), 'y': int(y * step)} + + image_id = save_rendered_image(image, metadata=state.metadata, is_map=True) + entities = instance.namespace.get_entities(radius=visible_radius, position=nearest) + + # Move back + instance.namespace.move_to(Position(x=x * step, y=y * step)) + + state.metadata['image'] = image_id + state.metadata['renderer'] = renderer + state.metadata['entities'] = entities + state.metadata['instance'] = instance + + return state + + return solve \ No newline at end of file diff --git a/data/vqa/tasks/terrain/task.py b/data/vqa/tasks/terrain/task.py new file mode 100644 index 000000000..40f855ace --- /dev/null +++ b/data/vqa/tasks/terrain/task.py @@ -0,0 +1,133 @@ +# task.py - Updated terrain task with nearest_buildable questions + +from inspect_ai import task, Task +from inspect_ai.solver import system_message + +from data.vqa.common_solvers import ( + normalize_position_format, + attach_bounding_box +) +from data.vqa.tasks.terrain.character_localisation.solver import character_localisation_question +from data.vqa.tasks.terrain.dataset import raw_position_dataset +from data.vqa.tasks.terrain.nearest.solver import nearest_questions +from data.vqa.tasks.terrain.nearest_buildable.solver import ( + nearest_buildable_questions, + nearest_buildable_with_resources_questions +) +from data.vqa.tasks.terrain.solver import render_terrain +from data.vqa.tasks.terrain.tile_count.solver import tile_count_questions + + +@task +def terrain_task( + include_nearest: bool = True, + include_buildable: bool = True, + include_resource_buildable: bool = True, + include_tile_count: bool = False, + include_character_loc: bool = True, + multiple_choice: bool = True +) -> Task: + """ + Terrain analysis task including nearest buildable positions. + + Args: + include_nearest: Include nearest resource questions + include_buildable: Include nearest buildable position questions + include_resource_buildable: Include resource-dependent buildable questions + include_tile_count: Include tile counting questions + include_character_loc: Include character localization questions + multiple_choice: If True, generate multiple choice questions + """ + + solvers = [ + system_message("""You are analyzing Factorio terrain to answer questions about + resources, buildable positions, and entity placement. + Consider terrain features, obstacles, and resource availability."""), + attach_bounding_box(), + render_terrain(), + ] + + # Add selected question types + if include_nearest: + solvers.append(nearest_questions(multiple_choice=multiple_choice)) + + if include_buildable: + solvers.append(nearest_buildable_questions( + questions_per_position=5, + multiple_choice=multiple_choice + )) + + if include_resource_buildable: + solvers.append(nearest_buildable_with_resources_questions( + questions_per_position=3, + multiple_choice=multiple_choice + )) + + if include_tile_count: + solvers.append(tile_count_questions(multiple_choice=multiple_choice)) + + if include_character_loc: + solvers.append(character_localisation_question(multiple_choice=multiple_choice)) + + return Task( + name="terrain_task" + ("_mc" if multiple_choice else ""), + dataset=raw_position_dataset(pattern="concentric"), + solver=solvers, + scorer=None, + ) + + +@task +def nearest_buildable_task(multiple_choice: bool = True) -> Task: + """ + Task focused only on nearest buildable position questions. + """ + return Task( + name="nearest_buildable_task", + dataset=raw_position_dataset(pattern="concentric"), + solver=[ + system_message("""You are analyzing Factorio terrain to find valid building positions. + Consider space requirements, terrain obstacles, and resource coverage."""), + attach_bounding_box(), + render_terrain(), + nearest_buildable_questions( + questions_per_position=8, + multiple_choice=multiple_choice + ), + nearest_buildable_with_resources_questions( + questions_per_position=4, + multiple_choice=multiple_choice + ) + ], + scorer=None, + ) + + +if __name__ == "__main__": + from inspect_ai import eval + from data.vqa.hook import VQAPairsHook + + model = ["anthropic/claude-sonnet-4-20250514"] + + # Example 1: Run comprehensive terrain task + results = eval( + tasks=terrain_task( + include_nearest=True, + include_buildable=True, + include_resource_buildable=True, + multiple_choice=True + ), + model=model, + limit=40, + log_dir="../../logs/", + hooks=[VQAPairsHook()] + ) + + # Example 2: Run focused nearest buildable task + # results = eval( + # tasks=nearest_buildable_task(multiple_choice=False), + # model=model, + # limit=5, + # log_dir="../../logs/", + # hooks=[VQAPairsHook()] + # ) \ No newline at end of file diff --git a/data/vqa/tasks/terrain/tile_count/solver.py b/data/vqa/tasks/terrain/tile_count/solver.py new file mode 100644 index 000000000..112adcc38 --- /dev/null +++ b/data/vqa/tasks/terrain/tile_count/solver.py @@ -0,0 +1,77 @@ +import copy +import random +import re + +import numpy as np +from inspect_ai.solver import Solver, solver, TaskState, Generate + + +@solver +def tile_count_questions(multiple_choice: bool = True) -> Solver: + async def solve(state: TaskState, generate: Generate) -> TaskState: + + renderer = state.metadata['renderer'] + + counts = {} + for entity in renderer.entities: + name = entity.name.replace('-', ' ') + name = re.sub('\d+', '', name) # Remove variants + + if 'water' in name or 'cliff' in name: + name += '-tile' + if name.endswith('big'): + name = name[:-3] + name = 'big '+name + + name = name.strip() + if name not in counts: + counts[name] = 0 + counts[name] += 1 + + for entity in renderer.water_tiles: + if entity['name'] not in counts: + counts[entity['name']] = 0 + counts[entity['name']] += 1 + + multiple_choice_bands = [0, 1, 2, 4, 8, 16, 32, 64, 128] + + state.metadata["tile_count_questions"] = [] + + for key, value in counts.items(): + if multiple_choice: + band = None + for band in multiple_choice_bands: + if value > band: + continue + break + + removed_multiple_choice_bands = copy.deepcopy(multiple_choice_bands) + removed_multiple_choice_bands.remove(band) + alphabet = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k'] + other_options = random.sample(removed_multiple_choice_bands, 3) + other_options.append(band) + random.shuffle(other_options) + option_string = "\n".join([f"{alphabet[i]}){option}" for i, option in enumerate(other_options)]) + question = f"How many {key}s do you see?\n{option_string}\nProvide the letter of the best match and nothing else." + + qa_entry = { + "question": question, + "answer": str(alphabet[other_options.index(band)]), + "entity_properties": key, + "count": value, + "options": other_options, + "question_type": "multiple_choice" + } + state.metadata["tile_count_questions"].append(qa_entry) + else: + qa_entry = { + "question": f"How many {key}s do you see?", + "answer": str(value), + "entity_properties": key, + "question_type": "open_ended" + } + state.metadata["tile_count_questions"].append(qa_entry) + + return state + + return solve \ No newline at end of file diff --git a/data/vqa/templates.py b/data/vqa/templates.py new file mode 100644 index 000000000..3de31e8c4 --- /dev/null +++ b/data/vqa/templates.py @@ -0,0 +1,108 @@ +from pathlib import Path +from jinja2 import Environment, FileSystemLoader +from typing import Dict, Any + +class TemplateManager: + """Manages Jinja2 templates for VQA tasks.""" + + def __init__(self, base_path: Path = None): + if base_path is None: + base_path = Path(__file__).parent / "tasks" + + self.base_path = base_path + self.environments = {} + self._init_environments() + + def _init_environments(self): + """Initialize Jinja2 environments for each task type.""" + task_dirs = [d for d in self.base_path.iterdir() if d.is_dir()] + + for task_dir in task_dirs: + templates_dir = task_dir / "templates" + if templates_dir.exists(): + env = Environment( + loader=FileSystemLoader(str(templates_dir)), + trim_blocks=True, + lstrip_blocks=True + ) + self.environments[task_dir.name] = env + + def render(self, task_type: str, template_name: str, **kwargs) -> str: + """Render a template with the given parameters.""" + if task_type not in self.environments: + raise ValueError(f"Unknown task type: {task_type}") + + env = self.environments[task_type] + template = env.get_template(f"{template_name}.jinja2") + return template.render(**kwargs) + + def get_available_tasks(self) -> list: + """Get list of available task types.""" + return list(self.environments.keys()) + + def get_available_templates(self, task_type: str) -> list: + """Get list of available templates for a task type.""" + if task_type not in self.environments: + return [] + + templates_dir = self.base_path / task_type / "templates" + return [f.stem for f in templates_dir.glob("*.jinja2")] + +# Global template manager instance +template_manager = TemplateManager() + +# Convenience functions for backward compatibility +def render_template(task_type: str, template_name: str, **kwargs) -> str: + """Render a template using the global template manager.""" + return template_manager.render(task_type, template_name, **kwargs) + +# Template shortcuts for each task type +class Templates: + """Template shortcuts for easy access.""" + + @staticmethod + def blueprint_title_purpose(blueprint: Dict[str, Any]) -> str: + return render_template("contrastive_alignment", "blueprint_title_purpose", blueprint=blueprint) + + @staticmethod + def contrastive_matching(options: list) -> str: + return render_template("contrastive_alignment", "contrastive_matching", options=options) + + @staticmethod + def denoising_question(position: Dict[str, Any], entity_name: str) -> str: + return render_template("denoising", "question_generation", position=position, entity_name=entity_name) + + @staticmethod + def denoising_validation(modified_blueprint: Dict[str, Any], question: str) -> str: + return render_template("denoising", "validation", modified_blueprint=modified_blueprint, question=question) + + @staticmethod + def spatial_context_question(removed_entity: Dict[str, Any], removed_position: Dict[str, Any], + nearby_entities: list, nearest_entity_name: str) -> str: + return render_template("spatial_reasoning", "spatial_context_question", + removed_entity=removed_entity, removed_position=removed_position, + nearby_entities=nearby_entities, nearest_entity_name=nearest_entity_name) + + @staticmethod + def spatial_question(blueprint: Dict[str, Any], question: str) -> str: + return render_template("spatial_reasoning", "spatial_question", blueprint=blueprint, question=question) + + @staticmethod + def entity_name_position(blueprint: Dict[str, Any], question: str) -> str: + return render_template("basic", "entity_name_position", blueprint=blueprint, question=question) + + @staticmethod + def state_prediction(factory_state: Dict[str, Any], question: str) -> str: + return render_template("state_prediction", "state_prediction", factory_state=factory_state, question=question) + + @staticmethod + def action_prediction(previous_actions: list, blueprint: Dict[str, Any]) -> str: + return render_template("action_prediction", "action_prediction", + previous_actions=previous_actions, blueprint=blueprint) + + @staticmethod + def productivity_planning(factory_state: Dict[str, Any], entity1_name: str, entity1_pos: Dict[str, Any], + entity2_name: str, entity2_pos: Dict[str, Any]) -> str: + return render_template("productivity_planning", "productivity_planning", + factory_state=factory_state, entity1_name=entity1_name, entity1_pos=entity1_pos, + entity2_name=entity2_name, entity2_pos=entity2_pos) \ No newline at end of file diff --git a/data/vqa/utils.py b/data/vqa/utils.py new file mode 100644 index 000000000..606625806 --- /dev/null +++ b/data/vqa/utils.py @@ -0,0 +1,15 @@ +from pathlib import Path + + +def find_blueprints_dir() -> Path: + """Walk up the directory tree until we find .fle directory.""" + current = Path.cwd() + + while current != current.parent: + fle_dir = current / ".fle" + if fle_dir.exists() and fle_dir.is_dir(): + return fle_dir / "blueprints" + current = current.parent + + # Fallback - return the path even if it doesn't exist + return Path.cwd() / ".fle" / "blueprints" \ No newline at end of file diff --git a/fle/agents/data/blueprints_to_policies/blueprint_analyzer.py b/fle/agents/data/blueprints_to_policies/blueprint_analyzer.py index f6ba6d856..f843c7d28 100644 --- a/fle/agents/data/blueprints_to_policies/blueprint_analyzer.py +++ b/fle/agents/data/blueprints_to_policies/blueprint_analyzer.py @@ -7,7 +7,7 @@ from fle.env import EntityGroup from fle.env import FactorioInstance from fle.env.game_types import prototype_by_name -from data.blueprints_to_policies.models.blueprint_entity import BlueprintEntity +from fle.agents.data.blueprints_to_policies.models.blueprint_entity import BlueprintEntity class BlueprintAnalyzer: @@ -174,9 +174,9 @@ def generate_program(self) -> str: vertical_patterns, horizontal_patterns, singles = self.find_patterns() miners = [e for e in self.entities if "mining-drill" in e.name] if miners: - origin_calc = f"game.nearest_buildable({self._name_to_prototype_string(miners[0].name)}, bounding_box=miner_box)" + origin_calc = f"game.nearest_buildable({self._name_to_prototype_string(miners[0].name)}, center_position=Position(**{self.entities[0].position}),building_box=miner_box)" else: - origin_calc = f"game.nearest_buildable({self._name_to_prototype_string(self.entities[0].name)}, bounding_box=miner_box)" + origin_calc = f"game.nearest_buildable({self._name_to_prototype_string(self.entities[0].name)}, center_position=Position(**{self.entities[0].position}), building_box=miner_box)" lines = [ "# Calculate bounding box", @@ -184,10 +184,18 @@ def generate_program(self) -> str: " x=0,", " y=0", ")", + "left_bottom = Position(", + " x=0,", + f" y={self.max_y - self.min_y}", + ")", "right_bottom = Position(", f" x={self.max_x - self.min_x},", f" y={self.max_y - self.min_y}", ")", + "right_top = Position(", + f" x={self.max_x - self.min_x},", + f" y=0", + ")", "center = Position(", " x=(left_top.x + right_bottom.x) / 2,", " y=(left_top.y + right_bottom.y) / 2", @@ -196,7 +204,8 @@ def generate_program(self) -> str: "miner_box = BoundingBox(", " left_top=left_top,", " right_bottom=right_bottom,", - " center=center", + " left_bottom=left_bottom,", + " right_top=right_top" ")", "", "# Find valid position using nearest_buildable", @@ -304,7 +313,7 @@ def analyze_blueprint(blueprint_json: str) -> str: return analyzer.generate_program(), analyzer.get_inventory() -execution_dir = os.path.dirname(os.path.realpath(__file__)) + "/blueprints/misc/" +execution_dir = os.path.dirname(os.path.realpath(__file__)) + "/blueprints/other/" filename = "1a. Mining" # Early Mining" # iterate over all json files in the directory @@ -343,7 +352,7 @@ def analyze_blueprint(blueprint_json: str) -> str: continue print(code) - game_entities = instance.get_entities() + game_entities = instance.namespace.get_entities() try: analyzer.verify_placement(game_entities) except AssertionError as e: diff --git a/fle/agents/data/blueprints_to_policies/blueprint_refactor.py b/fle/agents/data/blueprints_to_policies/blueprint_refactor.py index 4219bdbed..a149de831 100644 --- a/fle/agents/data/blueprints_to_policies/blueprint_refactor.py +++ b/fle/agents/data/blueprints_to_policies/blueprint_refactor.py @@ -14,8 +14,8 @@ import pandas as pd from tenacity import retry, stop_after_attempt, wait_exponential -from cluster.remote.cluster_ips import get_public_ips -from data.blueprints_to_policies.processing_state import ProcessingState +from fle.cluster.remote.cluster_ips import get_public_ips +from fle.agents.data.blueprints_to_policies.processing_state import ProcessingState from fle.env import Position from fle.env import FactorioInstance from blueprint_analyzer import BlueprintAnalyzer diff --git a/fle/agents/data/blueprints_to_policies/models/blueprint_entity.py b/fle/agents/data/blueprints_to_policies/models/blueprint_entity.py index 0893ee82b..71a7f5d04 100644 --- a/fle/agents/data/blueprints_to_policies/models/blueprint_entity.py +++ b/fle/agents/data/blueprints_to_policies/models/blueprint_entity.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Any @dataclass @@ -17,3 +17,8 @@ class BlueprintEntity: control_behavior: Dict = None connections: Dict = None filter: Dict = None + filters: List[Dict] = None + filter_mode: Any = None + use_filters: Any = None + recipe_quality: Dict = None + bar: Dict = None \ No newline at end of file diff --git a/fle/agents/data/blueprints_to_policies/parse_blueprints.py b/fle/agents/data/blueprints_to_policies/parse_blueprints.py index af1b32fa3..ac1d2b96a 100644 --- a/fle/agents/data/blueprints_to_policies/parse_blueprints.py +++ b/fle/agents/data/blueprints_to_policies/parse_blueprints.py @@ -5,7 +5,7 @@ import matplotlib.pyplot as plt from dataclasses import dataclass, field -from game_types import Prototype +from fle.env import Prototype @dataclass(frozen=True) # Make the class immutable and hashable diff --git a/fle/agents/data/run_trace.py b/fle/agents/data/run_trace.py index 6d3bf9942..40e419224 100644 --- a/fle/agents/data/run_trace.py +++ b/fle/agents/data/run_trace.py @@ -34,7 +34,7 @@ def game_instance(): bounding_box=200, tcp_port=27000, cache_scripts=False, - fast=False, + fast=True, inventory={}, ) instance.speed(20) diff --git a/fle/agents/data/screenshots_from_run.py b/fle/agents/data/screenshots_from_run.py index 0eed835f7..ca1f12cb6 100644 --- a/fle/agents/data/screenshots_from_run.py +++ b/fle/agents/data/screenshots_from_run.py @@ -7,7 +7,7 @@ from data.screenshots_to_mp4 import png_to_mp4 from fle.env import FactorioInstance from fle.commons.models.program import Program -from fle.cluster.local.cluster_ips import get_local_container_ips +from fle.commons.cluster_ips import get_local_container_ips load_dotenv() diff --git a/fle/agents/data/sprites/basis_image_resolver.py b/fle/agents/data/sprites/basis_image_resolver.py new file mode 100644 index 000000000..3a26bea02 --- /dev/null +++ b/fle/agents/data/sprites/basis_image_resolver.py @@ -0,0 +1,396 @@ +#!/usr/bin/env python3 +""" +Enhanced Image Resolver that handles .basis files for Factorio sprites +""" + +import os +import json +import subprocess +import tempfile +import shutil +from pathlib import Path +from PIL import Image +from typing import Optional, Dict, Any, Tuple + + +class BasisImageResolver: + """Image resolver that handles both PNG and .basis files""" + + def __init__(self, data_dir: str, cache_dir: str = None): + """ + Initialize the image resolver + + Args: + data_dir: Path to the data/rendering directory + cache_dir: Directory for cached PNG files (default: data_dir/cache) + """ + self.data_dir = Path(data_dir) + self.base_graphics_dir = self.data_dir / "__base__" / "graphics" + + if cache_dir is None: + self.cache_dir = self.data_dir / "cache" + else: + self.cache_dir = Path(cache_dir) + + self.cache_dir.mkdir(exist_ok=True) + self.image_cache = {} + + # Load entity data for sprite lookups + self.entity_data = self._load_entity_data() + + def _load_entity_data(self) -> Dict[str, Any]: + """Load data.json for entity information""" + data_file = self.data_dir / "data.json" + if data_file.exists(): + with open(data_file, 'r') as f: + return json.load(f) + return {} + + def _find_sprite_file(self, sprite_name: str) -> Optional[Path]: + """ + Find the sprite file for a given name, checking various locations + + Args: + sprite_name: Name of the sprite (e.g., 'stone-furnace', 'boiler_north') + + Returns: + Path to the sprite file (.basis or .png), or None if not found + """ + # Clean up sprite name + clean_name = sprite_name.replace("_shadow", "") + is_shadow = "_shadow" in sprite_name + + # Handle different sprite naming patterns + search_patterns = [] + + # Entity-specific sprites (e.g., boiler_north -> boiler-N-idle) + if "_" in clean_name: + parts = clean_name.split("_") + entity_name = parts[0] + direction = parts[1] if len(parts) > 1 else "" + + # Direction mapping + dir_map = { + "north": "N", + "east": "E", + "south": "S", + "west": "W", + "up": "N", + "right": "E", + "down": "S", + "left": "W" + } + + dir_letter = dir_map.get(direction, direction.upper()[:1]) + + # Common entity sprite patterns + if is_shadow: + search_patterns.extend([ + f"entity/{entity_name}/{entity_name}-{dir_letter}-shadow", + f"entity/{entity_name}/hr-{entity_name}-{dir_letter}-shadow", + ]) + else: + search_patterns.extend([ + f"entity/{entity_name}/{entity_name}-{dir_letter}-idle", + f"entity/{entity_name}/{entity_name}-{dir_letter}", + f"entity/{entity_name}/hr-{entity_name}-{dir_letter}-idle", + f"entity/{entity_name}/hr-{entity_name}-{dir_letter}", + ]) + + # Icon sprites (e.g., icon_stone-furnace) + if clean_name.startswith("icon_"): + icon_name = clean_name[5:] # Remove 'icon_' prefix + search_patterns.extend([ + f"icons/{icon_name}", + f"icons/hr-{icon_name}", + ]) + + # Pipe sprites + if clean_name.startswith("pipe_"): + pipe_type = clean_name[5:] # Remove 'pipe_' prefix + search_patterns.extend([ + f"entity/pipe/{pipe_type}", + f"entity/pipe/hr-{pipe_type}", + f"entity/pipe-covers/{pipe_type}", + f"entity/pipe-covers/hr-{pipe_type}", + ]) + + # Heat pipe sprites + if "heat-pipe" in clean_name: + heat_pipe_type = clean_name.replace("heat-pipe_", "") + search_patterns.extend([ + f"entity/heat-pipe/{heat_pipe_type}", + f"entity/heat-pipe/hr-{heat_pipe_type}", + ]) + + # Transport belt sprites + if any(belt in clean_name for belt in ["transport-belt", "fast-transport-belt", "express-transport-belt"]): + belt_parts = clean_name.split("_") + if len(belt_parts) >= 2: + belt_name = belt_parts[0] + belt_type = belt_parts[1] if len(belt_parts) > 1 else "" + search_patterns.extend([ + f"entity/{belt_name}/{belt_name}", + f"entity/{belt_name}/hr-{belt_name}", + f"entity/{belt_name}/{belt_type}", + f"entity/{belt_name}/hr-{belt_type}", + ]) + + # Generic entity pattern + search_patterns.extend([ + f"entity/{clean_name}/{clean_name}", + f"entity/{clean_name}/hr-{clean_name}", + f"icons/{clean_name}", + f"icons/hr-{clean_name}", + ]) + + # Check each pattern for .basis and .png files + for pattern in search_patterns: + for ext in [".basis", ".png"]: + file_path = self.base_graphics_dir / (pattern + ext) + if file_path.exists(): + return file_path + + return None + + def _get_cached_png_path(self, sprite_name: str) -> Path: + """Get the path where the cached PNG should be stored""" + return self.cache_dir / f"{sprite_name}.png" + + def _transcode_basis_to_png(self, basis_path: Path, output_path: Path) -> bool: + """ + Transcode a .basis file to PNG using basisu + + Args: + basis_path: Path to the .basis file + output_path: Path where PNG should be saved + + Returns: + True if successful, False otherwise + """ + try: + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Run basisu transcoder + cmd = ["basisu", "-unpack", str(basis_path)] + result = subprocess.run( + cmd, + cwd=temp_path, + capture_output=True, + text=True + ) + + if result.returncode != 0: + print(f"basisu failed for {basis_path.name}: {result.stderr}") + return False + + # Find the generated PNG + possible_patterns = [ + "*_unpacked_rgba_BC7_RGBA_0_0000.png", + "*_unpacked_rgba_BC3_RGBA_0_0000.png", + "*_unpacked_rgba_ETC2_RGBA_0_0000.png", + "*_unpacked_rgba_*_0_0000.png", + ] + + generated_png = None + for pattern in possible_patterns: + matches = list(temp_path.glob(pattern)) + if matches: + generated_png = matches[0] + break + + if not generated_png or not generated_png.exists(): + print(f"No suitable PNG generated from {basis_path}") + return False + + # Copy to output location + shutil.copy2(generated_png, output_path) + return True + + except Exception as e: + print(f"Error transcoding {basis_path}: {e}") + return False + + def __call__(self, sprite_name: str, shadow: bool = False) -> Optional[Image.Image]: + """ + Resolve sprite by name, handling .basis transcoding if needed + + Args: + sprite_name: Name of the sprite + shadow: Whether this is a shadow sprite + + Returns: + PIL Image object, or None if not found + """ + # Adjust name for shadow + if shadow and not sprite_name.endswith("_shadow"): + sprite_name = f"{sprite_name}_shadow" + + # Check memory cache first + if sprite_name in self.image_cache: + return self.image_cache[sprite_name] + + # Check file cache + cached_png = self._get_cached_png_path(sprite_name) + if cached_png.exists(): + try: + image = Image.open(cached_png).convert("RGBA") + self.image_cache[sprite_name] = image + return image + except Exception as e: + print(f"Error loading cached sprite {sprite_name}: {e}") + + # Find the sprite file + sprite_path = self._find_sprite_file(sprite_name) + if not sprite_path: + print(f"Sprite not found: {sprite_name}") + return None + + # Handle based on file type + if sprite_path.suffix == ".png": + try: + image = Image.open(sprite_path).convert("RGBA") + self.image_cache[sprite_name] = image + return image + except Exception as e: + print(f"Error loading PNG {sprite_path}: {e}") + return None + + elif sprite_path.suffix == ".basis": + # Transcode to PNG + if self._transcode_basis_to_png(sprite_path, cached_png): + try: + image = Image.open(cached_png).convert("RGBA") + self.image_cache[sprite_name] = image + return image + except Exception as e: + print(f"Error loading transcoded sprite {sprite_name}: {e}") + + return None + + def preload_entity_sprites(self, entity_names: list): + """Preload sprites for a list of entities""" + print("Preloading entity sprites...") + for entity_name in entity_names: + # Try common variations + variations = [ + entity_name, + f"{entity_name}-north", + f"{entity_name}-east", + f"{entity_name}-south", + f"{entity_name}-west", + f"icon_{entity_name}", + ] + + for variant in variations: + sprite = self(variant, False) + if sprite: + print(f" ✓ {variant}") + # Also try shadow + shadow = self(variant, True) + if shadow: + print(f" ✓ {variant}_shadow") + + def _find_sprite_file_tree_extension(self, sprite_name: str) -> Optional[Path]: + """ + Extension to handle tree sprite patterns. + Add this logic to the existing _find_sprite_file method. + """ + search_patterns = [] + + # Tree sprites (e.g., tree-01-a-full, tree-01-a-full-shadow) + if sprite_name.startswith("tree-"): + parts = sprite_name.split("-") + if len(parts) >= 4: + tree_type = parts[1] # 01, 02, etc. + variation = parts[2] # a, b, c, etc. + state = "-".join(parts[3:]) # full, medium, minimal, trunk_only, or full-shadow + + # Handle shadow sprites + if state.endswith("-shadow"): + base_state = state[:-7] # Remove -shadow + # Shadows are in the tree type folder + search_patterns.extend([ + f"tree/{tree_type}/tree-{tree_type}-{variation}-{base_state}-shadow", + f"tree/{tree_type}/hr-tree-{tree_type}-{variation}-{base_state}-shadow", + f"tree/{tree_type}/tree-{tree_type}-{variation}-shadow", # Fallback + f"tree/{tree_type}/hr-tree-{tree_type}-{variation}-shadow", + ]) + else: + # Regular tree sprites + search_patterns.extend([ + f"tree/{tree_type}/tree-{tree_type}-{variation}-{state}", + f"tree/{tree_type}/hr-tree-{tree_type}-{variation}-{state}", + ]) + + # Dead tree sprites (e.g., dead-tree-desert-01) + elif "dead-tree" in sprite_name or "dry-tree" in sprite_name or "dead-dry-hairy-tree" in sprite_name: + # These are stored in their specific folders + tree_type = "-".join(sprite_name.split("-")[:-1]) # Everything except the number + variant_num = sprite_name.split("-")[-1] if sprite_name[-1].isdigit() else "00" + + search_patterns.extend([ + f"tree/{tree_type}/{sprite_name}", + f"tree/{tree_type}/hr-{sprite_name}", + ]) + + # Dead grey trunk sprites + elif "dead-grey-trunk" in sprite_name: + search_patterns.extend([ + f"tree/dead-grey-trunk/{sprite_name}", + f"tree/dead-grey-trunk/hr-{sprite_name}", + ]) + + # Dry hairy tree sprites + elif "dry-hairy-tree" in sprite_name: + search_patterns.extend([ + f"tree/dry-hairy-tree/{sprite_name}", + f"tree/dry-hairy-tree/hr-{sprite_name}", + ]) + + # Resource sprites (e.g., coal_1_8, iron-ore_3_5) + elif "_" in sprite_name and any(resource in sprite_name for resource in + ["coal", "copper-ore", "iron-ore", "stone", "uranium-ore", "crude-oil"]): + parts = sprite_name.split("_") + if len(parts) == 3: + resource_name = parts[0] + variant = parts[1] + volume = parts[2] + + # Resources are in the resources folder, not base graphics + resource_patterns = [ + f"../../resources/{resource_name}/{resource_name}_{variant}_{volume}", + f"../../resources/{resource_name}/hr-{resource_name}_{variant}_{volume}", + ] + + # Check resource patterns with data_dir as base + for pattern in resource_patterns: + for ext in [".png"]: # Resources are typically PNG after extraction + file_path = self.data_dir / pattern.lstrip("../../") / ext + if file_path.exists(): + return file_path + + return search_patterns + + # Also add this helper method to handle the new sprite locations + def _check_sprite_locations_extended(self, search_patterns: list) -> Optional[Path]: + """ + Check additional locations for sprites beyond base graphics. + This handles sprites that might be in the extracted spritemaps directory. + """ + # First check the standard locations + for pattern in search_patterns: + for ext in [".png"]: # Extracted sprites are PNG + # Check in the spritemaps directory (where extracted sprites are saved) + if hasattr(self, 'spritemaps_dir'): + file_path = self.spritemaps_dir / (pattern + ext) + if file_path.exists(): + return file_path + + # Check relative to data directory + file_path = self.data_dir / (pattern + ext) + if file_path.exists(): + return file_path + + return None \ No newline at end of file diff --git a/fle/agents/data/sprites/download.py b/fle/agents/data/sprites/download.py new file mode 100644 index 000000000..3c83515c6 --- /dev/null +++ b/fle/agents/data/sprites/download.py @@ -0,0 +1,439 @@ +#!/usr/bin/env python3 +""" +Optimized sprite downloader with parallel downloads and compression support +""" + +import os +import shutil +import tarfile +import zipfile +from pathlib import Path +from typing import Optional, List +import concurrent.futures +from functools import partial +import requests +from tqdm import tqdm +from huggingface_hub import hf_hub_download, list_repo_files, snapshot_download +import threading + +from fle.agents.data.sprites.extractors.alerts import AlertSpriteExtractor +from fle.agents.data.sprites.extractors.icons import IconSpriteExtractor + + +class OptimizedSpriteDownloader: + def __init__(self, repo_id: str = "Noddybear/fle_images", num_workers: int = 10): + self.repo_id = repo_id + self.num_workers = num_workers + self.download_lock = threading.Lock() + self.progress_lock = threading.Lock() + self.completed_files = 0 + self.total_files = 0 + + def download_file_parallel(self, file_path: str, output_path: Path, pbar: tqdm) -> Optional[Path]: + """Download a single file with progress tracking""" + try: + # Download to cache + local_file = hf_hub_download( + repo_id=self.repo_id, + filename=file_path, + repo_type="dataset", + cache_dir=output_path / ".cache", + force_filename=file_path # Keep original structure + ) + + # Copy to final location + rel_path = Path(file_path) + dest_path = output_path / rel_path + dest_path.parent.mkdir(parents=True, exist_ok=True) + + # Use hard link if possible (instant, no copy) + try: + os.link(local_file, dest_path) + except (OSError, AttributeError): + # Fall back to copy if hard link fails + shutil.copy2(local_file, dest_path) + + # Update progress + with self.progress_lock: + self.completed_files += 1 + pbar.update(1) + + return dest_path + + except Exception as e: + print(f"\nError downloading {file_path}: {e}") + with self.progress_lock: + pbar.update(1) + return None + + +def download_sprites_from_hf( + repo_id: str = "Noddybear/fle_images", + output_dir: str = ".fle/spritemaps", + force: bool = False, + num_workers: int = 10, + use_snapshot: bool = True, + archive_name: Optional[str] = None +) -> bool: + """ + Optimized sprite download with multiple strategies + + Args: + repo_id: Hugging Face dataset repository ID + output_dir: Directory to save sprites + force: Force re-download even if files exist + num_workers: Number of parallel download workers + use_snapshot: Use snapshot_download for faster bulk download + archive_name: If sprites are in a single archive file, specify its name + + Returns: + True if successful, False otherwise + """ + output_path = Path(output_dir) + + # Check if already downloaded + if output_path.exists() and not force: + if any(output_path.iterdir()): + print(f"Sprites already exist in {output_path}. Use --force to re-download.") + return True + + output_path.mkdir(parents=True, exist_ok=True) + + try: + # Strategy 1: Check if sprites are in a single archive + if archive_name or check_for_archive(repo_id): + return download_archive_strategy(repo_id, output_path, archive_name) + + # Strategy 2: Use snapshot_download for bulk download (fastest for many files) + if use_snapshot: + return download_snapshot_strategy(repo_id, output_path) + + # Strategy 3: Parallel individual downloads + return download_parallel_strategy(repo_id, output_path, num_workers) + + except Exception as e: + print(f"Error downloading sprites: {e}") + return False + + +def check_for_archive(repo_id: str) -> Optional[str]: + """Check if repository contains an archive file with all sprites""" + try: + files = list_repo_files(repo_id, repo_type="dataset") + + # Look for common archive formats + archive_extensions = ['.tar.gz', '.tar.bz2', '.tar', '.zip', '.7z'] + archives = [f for f in files if any(f.endswith(ext) for ext in archive_extensions)] + + # Look for files that might contain sprites + sprite_archives = [ + a for a in archives + if any(keyword in a.lower() for keyword in ['sprite', 'image', 'all', 'complete']) + ] + + if sprite_archives: + # Return the largest archive (likely the complete set) + return max(sprite_archives, key=lambda x: len(x)) + + # If there's only one archive, it's probably what we want + if len(archives) == 1: + return archives[0] + + except Exception: + pass + + return None + + +def download_archive_strategy(repo_id: str, output_path: Path, archive_name: Optional[str]) -> bool: + """Download and extract archive file (fastest method)""" + print("Using archive download strategy...") + + try: + if not archive_name: + archive_name = check_for_archive(repo_id) + if not archive_name: + print("No archive found, falling back to parallel downloads") + return False + + print(f"Downloading archive: {archive_name}") + + # Download the archive + archive_path = hf_hub_download( + repo_id=repo_id, + filename=archive_name, + repo_type="dataset", + cache_dir=output_path / ".cache" + ) + + # Extract based on file type + print("Extracting sprites...") + + if archive_name.endswith('.zip'): + with zipfile.ZipFile(archive_path, 'r') as zf: + # Extract with progress bar + members = zf.namelist() + with tqdm(total=len(members), desc="Extracting") as pbar: + for member in members: + zf.extract(member, output_path) + pbar.update(1) + + elif archive_name.endswith(('.tar.gz', '.tar.bz2', '.tar')): + mode = 'r:gz' if archive_name.endswith('.gz') else 'r:bz2' if archive_name.endswith('.bz2') else 'r' + with tarfile.open(archive_path, mode) as tf: + # Extract with progress bar + members = tf.getmembers() + with tqdm(total=len(members), desc="Extracting") as pbar: + for member in members: + tf.extract(member, output_path) + pbar.update(1) + else: + print(f"Unsupported archive format: {archive_name}") + return False + + # Clean up cache + cache_dir = output_path / ".cache" + if cache_dir.exists(): + shutil.rmtree(cache_dir) + + print(f"Successfully extracted sprites to {output_path}") + return True + + except Exception as e: + print(f"Error with archive strategy: {e}") + return False + + +def download_snapshot_strategy(repo_id: str, output_path: Path) -> bool: + """Use HF snapshot_download for efficient bulk download""" + print("Using snapshot download strategy (recommended for many files)...") + + try: + # snapshot_download is optimized for downloading entire repos + snapshot_path = snapshot_download( + repo_id=repo_id, + repo_type="dataset", + cache_dir=output_path / ".cache", + local_dir=output_path, + local_dir_use_symlinks=False, # Copy files instead of symlinks + ignore_patterns=["*.md", "*.txt", ".git*"], # Skip non-image files + ) + + # Clean up cache + cache_dir = output_path / ".cache" + if cache_dir.exists(): + shutil.rmtree(cache_dir) + + print(f"Successfully downloaded sprites to {output_path}") + return True + + except Exception as e: + print(f"Error with snapshot strategy: {e}") + return False + + +def download_parallel_strategy(repo_id: str, output_path: Path, num_workers: int) -> bool: + """Parallel download of individual files""" + print(f"Using parallel download strategy with {num_workers} workers...") + + try: + downloader = OptimizedSpriteDownloader(repo_id, num_workers) + + # List all files + files = list_repo_files(repo_id, repo_type="dataset") + image_files = [f for f in files if f.endswith(('.png', '.jpg', '.jpeg'))] + + if not image_files: + print("No image files found in the repository.") + return False + + downloader.total_files = len(image_files) + print(f"Found {len(image_files)} sprite files to download.") + + # Create progress bar + with tqdm(total=len(image_files), desc="Downloading sprites") as pbar: + # Use ThreadPoolExecutor for parallel downloads + with concurrent.futures.ThreadPoolExecutor(max_workers=num_workers) as executor: + # Create partial function with fixed arguments + download_func = partial( + downloader.download_file_parallel, + output_path=output_path, + pbar=pbar + ) + + # Submit all downloads + futures = { + executor.submit(download_func, file_path): file_path + for file_path in image_files + } + + # Wait for completion + for future in concurrent.futures.as_completed(futures): + result = future.result() + # Result handling is done in download_file_parallel + + # Clean up cache + cache_dir = output_path / ".cache" + if cache_dir.exists(): + shutil.rmtree(cache_dir) + + print(f"Successfully downloaded {downloader.completed_files}/{downloader.total_files} sprites") + return downloader.completed_files > 0 + + except Exception as e: + print(f"Error with parallel strategy: {e}") + return False + + +def create_sprite_archive( + input_dir: str = ".fle/sprites", + output_file: str = "fle_sprites.tar.gz", + compression: str = "gz" +) -> bool: + """ + Create a compressed archive of sprites for faster distribution + + Args: + input_dir: Directory containing sprites + output_file: Output archive filename + compression: Compression type ('gz', 'bz2', 'xz', or None) + """ + input_path = Path(input_dir) + + if not input_path.exists(): + print(f"Input directory {input_path} does not exist.") + return False + + print(f"Creating archive {output_file}...") + + try: + mode = f'w:{compression}' if compression else 'w' + + with tarfile.open(output_file, mode) as tar: + # Get all files to archive + files = list(input_path.rglob("*")) + files = [f for f in files if f.is_file()] + + with tqdm(total=len(files), desc="Archiving") as pbar: + for file_path in files: + # Add file with relative path + arcname = file_path.relative_to(input_path) + tar.add(file_path, arcname=arcname) + pbar.update(1) + + # Get file size + size_mb = os.path.getsize(output_file) / (1024 * 1024) + print(f"Created archive: {output_file} ({size_mb:.1f} MB)") + return True + + except Exception as e: + print(f"Error creating archive: {e}") + return False + +def generate_sprites( + input_dir: str = ".fle/spritemaps", + output_dir: str = ".fle/sprites" +): + """ + Generate individual sprites from spritemaps + + Args: + input_dir: Directory containing downloaded spritemaps + output_dir: Directory to save extracted sprites + data_path: Path to data.json file (optional) + """ + # Import here to avoid circular imports + import sys + from pathlib import Path + + # Add the sprites directory to Python path + sprites_module_path = Path(__file__).parent.parent / "data" / "sprites" + if sprites_module_path.exists(): + sys.path.insert(0, str(sprites_module_path)) + + try: + from fle.agents.data.sprites.extractors.entities import EntitySpritesheetExtractor + from fle.agents.data.sprites.extractors.resources import ResourceSpriteExtractor + from fle.agents.data.sprites.extractors.terrain import TerrainSpriteExtractor + from fle.agents.data.sprites.extractors.trees import TreeSpriteExtractor + except ImportError: + print("Error: Could not import extractor modules.") + print("Make sure the extractor modules are in the correct location.") + return False + + input_path = Path(input_dir) + output_path = Path(output_dir) + + if not input_path.exists(): + print(f"Input directory {input_path} does not exist. Run 'fle sprites download' first.") + return False + + print(f"Generating sprites from {input_path} to {output_path}...") + + # Create output directory + output_path.mkdir(parents=True, exist_ok=True) + + try: + # Check if we have a data.json in the input directory + if (input_path / "data.json").exists(): + # Run entity extraction + entities = EntitySpritesheetExtractor(str(input_path), str(output_path)) + entities.extract_all() + + # Check for other resources + base_graphics = input_path / "__base__" / "graphics" + + if base_graphics.exists(): + resources_path = base_graphics / "resources" + if resources_path.exists(): + resources = ResourceSpriteExtractor(str(resources_path), str(output_path)) + resources.extract_all_resources() + resources.create_all_icons() + + trees = TreeSpriteExtractor(str(resources_path), str(output_path)) + trees.extract_all_trees() + + terrain_path = base_graphics / "terrain" + if terrain_path.exists(): + terrain = TerrainSpriteExtractor(str(terrain_path), str(output_path)) + terrain.extract_all_resources() + terrain.create_all_icons() + + icons_path = base_graphics / "icons" + if icons_path.exists(): + icon = IconSpriteExtractor(str(icons_path), str(output_path)) + icon.extract_all_icons() + + alerts_path = icons_path / "alerts" + if alerts_path.exists(): + icon = AlertSpriteExtractor(str(alerts_path), str(output_path)) + icon.extract_all_alerts() + + else: + # Fallback: Just copy PNG files from spritemaps + print("No __base__/graphics structure found, copying PNG files directly...") + + png_files = list(input_path.rglob("*.png")) + + if not png_files: + print("No PNG files found in spritemaps directory.") + return False + + from tqdm import tqdm + import shutil + + for png_file in tqdm(png_files, desc="Copying sprites"): + # Maintain relative path structure + rel_path = png_file.relative_to(input_path) + dest_path = output_path / rel_path + dest_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(png_file, dest_path) + + print(f"Successfully generated sprites in {output_path}") + return True + + except Exception as e: + print(f"Error generating sprites: {e}") + import traceback + traceback.print_exc() + return False \ No newline at end of file diff --git a/fle/agents/data/sprites/extractors/alerts.py b/fle/agents/data/sprites/extractors/alerts.py new file mode 100644 index 000000000..905bd4a65 --- /dev/null +++ b/fle/agents/data/sprites/extractors/alerts.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +""" +Alert sprite extractor for Factorio alert/warning icons +Handles individual alert icon files and renames them consistently +""" + +import shutil +from pathlib import Path +from PIL import Image +from typing import Dict, List, Optional, Tuple + + +class AlertSpriteExtractor: + """Extract and process alert/warning sprites""" + + def __init__(self, alerts_path: str, output_dir: str = "images"): + self.alerts_path = Path(alerts_path) + self.output_dir = Path(output_dir) + self.output_dir.mkdir(exist_ok=True, parents=True) + + # Mapping of original filenames to standardized alert names + self.alert_mappings = { + 'warning-icon.png': 'alert-warning', + 'danger-icon.png': 'alert-danger', + 'destroyed-icon.png': 'alert-destroyed', + 'electricity-icon-red.png': 'alert-no-electricity', + 'electricity-icon-unplugged.png': 'alert-disconnected', + 'fluid-icon-red.png': 'alert-no-fluid', + 'fuel-icon-red.png': 'alert-no-fuel', + 'ammo-icon-red.png': 'alert-no-ammo', + 'too-far-from-roboport-icon.png': 'alert-no-roboport-coverage', + 'no-building-material-icon.png': 'alert-no-building-materials', + 'no-storage-space-icon.png': 'alert-no-storage', + 'not-enough-repair-packs-icon.png': 'alert-no-repair-packs', + 'not-enough-construction-robots-icon.png': 'alert-no-construction-robots', + 'recharge-icon.png': 'alert-recharge-needed', + 'logistic-delivery.png': 'alert-logistic-delivery' + } + + def extract_alert_icon(self, original_filename: str, alert_name: str): + """ + Extract and save an alert icon with standardized naming + + Args: + original_filename: Original filename in the alerts directory + alert_name: Standardized alert name for output + """ + input_path = self.alerts_path / original_filename + + if not input_path.exists(): + print(f"Alert icon not found: {input_path}") + return + + try: + # Load the icon + icon = Image.open(input_path).convert('RGBA') + + # Save with standardized name + output_filename = f"{alert_name}.png" + output_path = self.output_dir / output_filename + + icon.save(output_path) + print(f"Saved alert icon: {output_path} (size: {icon.width}x{icon.height})") + + # Also create a copy with 'icon_' prefix for consistency with other extractors + icon_output_path = self.output_dir / f"icon_{alert_name}.png" + icon.save(icon_output_path) + print(f"Saved icon variant: {icon_output_path}") + + except Exception as e: + print(f"Error processing {original_filename}: {e}") + + def extract_all_alerts(self): + """Extract all alert sprites from the alerts directory""" + print("=== Extracting Alert Sprites ===") + + if not self.alerts_path.exists(): + print(f"Alerts directory not found: {self.alerts_path}") + return + + # Process mapped alerts + processed_count = 0 + for original_name, alert_name in self.alert_mappings.items(): + print(f"\nProcessing alert: {original_name} -> {alert_name}") + self.extract_alert_icon(original_name, alert_name) + processed_count += 1 + + # Check for any unmapped alert files + unmapped_files = [] + for file_path in self.alerts_path.glob("*.png"): + if file_path.name not in self.alert_mappings: + unmapped_files.append(file_path.name) + + if unmapped_files: + print(f"\nWarning: Found {len(unmapped_files)} unmapped alert files:") + for filename in unmapped_files: + print(f" - {filename}") + # Process unmapped files with a generic naming scheme + base_name = filename.replace('.png', '').replace('-icon', '') + alert_name = f"alert-{base_name}" + print(f" Processing as: {alert_name}") + self.extract_alert_icon(filename, alert_name) + processed_count += 1 + + print(f"\n=== Extracted {processed_count} alert sprites ===") + + def create_alert_composite(self, output_filename: str = "alert-composite.png"): + """ + Create a composite image showing all alert icons in a grid + Useful for documentation or overview purposes + """ + print("\n=== Creating Alert Composite ===") + + # Collect all extracted alert images + alert_files = list(self.output_dir.glob("alert-*.png")) + + if not alert_files: + print("No alert files found to create composite") + return + + # Load all icons and find max dimensions + icons = [] + max_width = 0 + max_height = 0 + + for file_path in sorted(alert_files): + if 'composite' in file_path.name or file_path.name.startswith('icon_'): + continue + + try: + icon = Image.open(file_path).convert('RGBA') + icons.append((file_path.name, icon)) + max_width = max(max_width, icon.width) + max_height = max(max_height, icon.height) + except Exception as e: + print(f"Error loading {file_path}: {e}") + + if not icons: + print("No valid icons found for composite") + return + + # Calculate grid dimensions + grid_cols = min(8, len(icons)) # Max 8 columns + grid_rows = (len(icons) + grid_cols - 1) // grid_cols + + # Add padding between icons + padding = 10 + cell_width = max_width + padding * 2 + cell_height = max_height + padding * 2 + + # Create composite image + composite_width = grid_cols * cell_width + composite_height = grid_rows * cell_height + composite = Image.new('RGBA', (composite_width, composite_height), (0, 0, 0, 0)) + + # Place icons in grid + for idx, (filename, icon) in enumerate(icons): + col = idx % grid_cols + row = idx // grid_cols + + # Center icon in cell + x = col * cell_width + padding + (max_width - icon.width) // 2 + y = row * cell_height + padding + (max_height - icon.height) // 2 + + composite.paste(icon, (x, y), icon) + + # Save composite + composite_path = self.output_dir / output_filename + composite.save(composite_path) + print(f"Saved alert composite: {composite_path} (size: {composite_width}x{composite_height})") + print(f"Grid: {grid_cols}x{grid_rows}, {len(icons)} icons") + + def generate_alert_categories(self): + """ + Organize alerts by category and create category-specific composites + """ + categories = { + 'resource': ['no-electricity', 'no-fluid', 'no-fuel', 'no-ammo', 'no-building-materials'], + 'robot': ['no-roboport-coverage', 'no-construction-robots', 'no-repair-packs', 'recharge-needed'], + 'status': ['warning', 'danger', 'destroyed', 'disconnected'], + 'logistics': ['no-storage', 'logistic-delivery'] + } + + print("\n=== Organizing Alerts by Category ===") + + for category_name, alert_types in categories.items(): + print(f"\nCategory: {category_name}") + + # Create category subdirectory + category_dir = self.output_dir / 'alerts' / category_name + category_dir.mkdir(exist_ok=True, parents=True) + + # Copy relevant alerts to category directory + copied_count = 0 + for alert_type in alert_types: + source_path = self.output_dir / f"alert-{alert_type}.png" + if source_path.exists(): + dest_path = category_dir / f"alert-{alert_type}.png" + shutil.copy2(source_path, dest_path) + print(f" - Copied: alert-{alert_type}.png") + copied_count += 1 + else: + print(f" - Warning: alert-{alert_type}.png not found") + + print(f" Total: {copied_count} alerts in {category_name} category") + + def create_alert_mapping_json(self): + """ + Create a JSON mapping file for alert icons + Useful for game integration + """ + import json + + mapping = { + 'alerts': {}, + 'categories': { + 'resource': ['no-electricity', 'no-fluid', 'no-fuel', 'no-ammo', 'no-building-materials'], + 'robot': ['no-roboport-coverage', 'no-construction-robots', 'no-repair-packs', 'recharge-needed'], + 'status': ['warning', 'danger', 'destroyed', 'disconnected'], + 'logistics': ['no-storage', 'logistic-delivery'] + } + } + + # Build alert mappings + for original, standardized in self.alert_mappings.items(): + alert_key = standardized.replace('alert-', '') + mapping['alerts'][alert_key] = { + 'filename': f"{standardized}.png", + 'icon_filename': f"icon_{standardized}.png", + 'original': original + } + + # Save mapping + mapping_path = self.output_dir / "alert_mapping.json" + with open(mapping_path, 'w') as f: + json.dump(mapping, f, indent=2, sort_keys=True) + + print(f"\nCreated alert mapping: {mapping_path}") + print(f"Total alerts mapped: {len(mapping['alerts'])}") + + +def main(): + """Main entry point for alert extraction""" + import sys + + # Default paths - adjust as needed + alerts_path = ".fle/spritemaps/__base__/graphics/icons/alerts" + output_dir = ".fle/sprites" + + # Allow command line overrides + if len(sys.argv) > 1: + alerts_path = sys.argv[1] + if len(sys.argv) > 2: + output_dir = sys.argv[2] + + print(f"Alerts path: {alerts_path}") + print(f"Output path: {output_dir}") + + # Create extractor and run + extractor = AlertSpriteExtractor(alerts_path, output_dir) + + # Extract all alerts + extractor.extract_all_alerts() + + # Create composite image + extractor.create_alert_composite() + + # Organize by categories + extractor.generate_alert_categories() + + # Create mapping file + extractor.create_alert_mapping_json() + + print("\n=== Alert Extraction Complete ===") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/fle/agents/data/sprites/extractors/character.py b/fle/agents/data/sprites/extractors/character.py new file mode 100644 index 000000000..6acf6827c --- /dev/null +++ b/fle/agents/data/sprites/extractors/character.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +""" +Character sprite extractor for Factorio character animations +Extracts individual character sprites from spritemaps with varying dimensions +Saves as: name_{variant}_{direction}.png where variant=column, direction=row +""" + +import os +from pathlib import Path +from PIL import Image +from typing import Dict, List, Optional, Tuple + + +class CharacterSpriteExtractor: + """Extract character sprites from spritemaps""" + + def __init__(self, character_path: str, output_dir: str = "images"): + self.character_path = Path(character_path) + self.output_dir = Path(output_dir) / "character" + self.output_dir.mkdir(exist_ok=True, parents=True) + + # Create hr subdirectory + self.output_dir_hr = Path(output_dir + "-hr") / "character" + self.output_dir_hr.mkdir(exist_ok=True, parents=True) + + # Define the dimensions for each sprite sheet type + # Format: (columns, rows) + self.sprite_dimensions = { + 'level1_dead': (2, 1), + 'level2addon_dead': (2, 1), + 'level3addon_dead': (2, 1), + 'level1_idle': (22, 8), + 'level2addon_idle': (22, 8), + 'level3addon_idle': (22, 8), + 'level1_idle_gun': (22, 8), + 'level2addon_idle_gun': (22, 8), + 'level3addon_idle_gun': (22, 8), + 'level1_mining_tool': (13, 8), + 'level2addon_mining_tool': (13, 8), + 'level3addon_mining_tool': (13, 8), + 'level1_running': (22, 8), + 'level2addon_running': (22, 8), + 'level3addon_running': (22, 8), + 'level1_running_gun': (22, 18), + 'level2addon_running_gun': (22, 18), + 'level3addon_running_gun': (22, 18), + 'level1_running_mask': (22, 18), + 'level2addon_running_mask': (22, 18), + 'level3addon_running_mask': (22, 18), + 'level1_running_gun_mask': (22, 18), + 'level2addon_running_gun_mask': (22, 18), + 'level3addon_running_gun_mask': (22, 18), + 'level1_running_shadow': (10, 7), + 'level2addon_running_shadow': (10, 7), + 'level3addon_running_shadow': (10, 7), + 'level1_running_gun_shadow': (10, 7), + 'level2addon_running_gun_shadow': (10, 7), + 'level3addon_running_gun_shadow': (10, 7), + } + + # Track detected sprite sizes for each type + self.detected_sprite_sizes = {} + + def get_sprite_dimensions(self, sprite_name: str) -> Tuple[int, int]: + """ + Get the expected dimensions for a sprite sheet. + + Args: + sprite_name: Name of the sprite sheet (without hr- prefix and .png) + + Returns: + Tuple of (columns, rows) + """ + # Remove hr- prefix if present + clean_name = sprite_name.replace('hr-', '') + + # Check direct match first + if clean_name in self.sprite_dimensions: + return self.sprite_dimensions[clean_name] + + # Check for base name without suffixes like -1, -2 + base_name = clean_name + if base_name.endswith('-1') or base_name.endswith('-2'): + base_name = base_name[:-2] + + if base_name in self.sprite_dimensions: + return self.sprite_dimensions[base_name] + + # Handle mask/shadow variants + for suffix in ['_mask', '_shadow', '_shadow-1', '_shadow-2']: + if base_name.endswith(suffix): + check_name = base_name.replace(suffix, '') + # For shadows/masks, check if we have a running variant + if 'running' in check_name and check_name in self.sprite_dimensions: + return self.sprite_dimensions[check_name] + # Otherwise use the base sprite dimensions + if check_name in self.sprite_dimensions: + return self.sprite_dimensions[check_name] + + # Default fallback for standard sprites + if 'running' in clean_name and ('gun' in clean_name or 'mask' in clean_name): + return (22, 18) # Running with gun has more frames + elif 'running' in clean_name and 'shadow' in clean_name: + return (10, 7) # Running shadows have fewer frames + elif 'dead' in clean_name: + return (2, 1) # Dead sprites are minimal + elif 'mining' in clean_name: + return (13, 8) # Mining has fewer directions + else: + return (22, 8) # Default for most sprites + + def extract_sprites_from_sheet(self, sheet_path: Path, output_prefix: str): + """ + Extract all sprites from a character sprite sheet. + Saves as: name_{variant}_{direction}.png + + Args: + sheet_path: Path to the sprite sheet + output_prefix: Prefix for output files (e.g., "level1_idle") + """ + if not sheet_path.exists(): + print(f"Sprite sheet not found: {sheet_path}") + return + + try: + sheet = Image.open(sheet_path).convert('RGBA') + + # Determine if this is an HR sprite + is_hr = output_prefix.startswith('hr-') + + # Get expected dimensions for this sprite type + sprite_name = sheet_path.stem # filename without extension + cols, rows = self.get_sprite_dimensions(sprite_name) + + # Calculate individual sprite dimensions + sprite_width = sheet.width // cols + sprite_height = sheet.height // rows + + # Store detected size + self.detected_sprite_sizes[sprite_name] = { + 'sprite_size': (sprite_width, sprite_height), + 'grid_size': (cols, rows), + 'sheet_size': (sheet.width, sheet.height) + } + + print(f" {sprite_name}: {cols}x{rows} grid, sprite size {sprite_width}x{sprite_height}") + + # Extract each sprite + extracted_count = 0 + for row in range(rows): + for col in range(cols): + # Calculate sprite position + x = col * sprite_width + y = row * sprite_height + + # Extract sprite + sprite = sheet.crop(( + x, y, + x + sprite_width, + y + sprite_height + )) + + # Create filename with variant_direction format + # Remove hr- prefix from output name if present + clean_prefix = output_prefix.replace('hr-', '') + output_name = f"{clean_prefix}_{col}_{row}.png" + + # Save to appropriate directory + if is_hr: + output_path = self.output_dir_hr / output_name + else: + output_path = self.output_dir / output_name + + sprite.save(output_path) + extracted_count += 1 + + print(f" Extracted {extracted_count} sprites to {'hr' if is_hr else 'normal'} directory") + + except Exception as e: + print(f"Error processing {sheet_path}: {e}") + import traceback + traceback.print_exc() + + def extract_all_character_sprites(self): + """Extract all character sprites from the character directory""" + print("=== Extracting Character Sprites ===") + + if not self.character_path.exists(): + print(f"Character directory not found: {self.character_path}") + return + + # Process all PNG files in the character directory + png_files = list(self.character_path.glob("*.png")) + + # Group files by type for reporting + file_groups = { + 'idle': [], + 'idle_gun': [], + 'running': [], + 'running_gun': [], + 'mining_tool': [], + 'dead': [], + 'masks': [], + 'shadows': [], + 'other': [] + } + + # Categorize files + for file_path in png_files: + filename = file_path.name + base_name = file_path.stem.replace('hr-', '') + + if '_mask' in filename: + file_groups['masks'].append(file_path) + elif '_shadow' in filename: + file_groups['shadows'].append(file_path) + elif 'dead' in filename: + file_groups['dead'].append(file_path) + elif 'mining_tool' in filename: + file_groups['mining_tool'].append(file_path) + elif 'running_gun' in filename: + file_groups['running_gun'].append(file_path) + elif 'running' in filename: + file_groups['running'].append(file_path) + elif 'idle_gun' in filename: + file_groups['idle_gun'].append(file_path) + elif 'idle' in filename: + file_groups['idle'].append(file_path) + else: + file_groups['other'].append(file_path) + + # Process each group + for group_name, files in file_groups.items(): + if files: + print(f"\nProcessing {group_name} sprites ({len(files)} files)...") + for file_path in sorted(files): + output_prefix = file_path.stem # Keep the full name including hr- prefix + self.extract_sprites_from_sheet(file_path, output_prefix) + + def create_character_mapping(self): + """ + Create a JSON mapping file for character sprites. + Includes the varying dimensions for different sprite types. + """ + import json + + # Direction mappings vary by sprite type + standard_directions = { + 0: 0, # North + 1: 1, # North-East + 2: 2, # East + 3: 3, # South-East + 4: 4, # South + 5: 5, # South-West + 6: 6, # West + 7: 7 # North-West + } + + # Mining tool has fewer directions (no diagonals) + mining_directions = { + 0: 0, # North + 2: 3, # East (maps to column 3) + 4: 6, # South (maps to column 6) + 6: 9, # West (maps to column 9) + } + + # Dead has only 2 directions + dead_directions = { + 0: 0, # North/South + 2: 1, # East/West + 4: 0, # South (same as North) + 6: 1, # West (same as East) + } + + mapping = { + 'sprite_dimensions': self.sprite_dimensions, + 'detected_sizes': self.detected_sprite_sizes, + 'direction_mappings': { + 'standard': standard_directions, + 'mining': mining_directions, + 'dead': dead_directions + }, + 'sprite_types': { + 'idle': { + 'sheets': ['level1_idle', 'level2addon_idle', 'level3addon_idle'], + 'grid': (22, 8), + 'directions': 'standard' + }, + 'idle_gun': { + 'sheets': ['level1_idle_gun', 'level2addon_idle_gun', 'level3addon_idle_gun'], + 'grid': (22, 8), + 'directions': 'standard' + }, + 'running': { + 'sheets': ['level1_running', 'level2addon_running', 'level3addon_running'], + 'grid': (22, 8), + 'directions': 'standard' + }, + 'running_gun': { + 'sheets': ['level1_running_gun', 'level2addon_running_gun', 'level3addon_running_gun'], + 'grid': (22, 18), + 'directions': 'standard' + }, + 'mining_tool': { + 'sheets': ['level1_mining_tool', 'level2addon_mining_tool', 'level3addon_mining_tool'], + 'grid': (13, 8), + 'directions': 'mining' + }, + 'dead': { + 'sheets': ['level1_dead', 'level2addon_dead', 'level3addon_dead'], + 'grid': (2, 1), + 'directions': 'dead' + } + }, + 'naming_format': 'name_{variant}_{direction}.png where variant=column, direction=row' + } + + # Save mapping in both directories + for output_dir in [self.output_dir, self.output_dir_hr]: + mapping_path = output_dir / "character_mapping.json" + with open(mapping_path, 'w') as f: + json.dump(mapping, f, indent=2) + print(f"Created character mapping: {mapping_path}") + + def extract_single_sprites(self): + """ + Extract single sprites that aren't in sheets (like footprints). + """ + single_sprites = ['footprints', 'character-reflection'] + + print("\nExtracting single sprites...") + for sprite_name in single_sprites: + for prefix in ['', 'hr-']: + filename = f"{prefix}{sprite_name}.png" + file_path = self.character_path / filename + + if file_path.exists(): + try: + sprite = Image.open(file_path).convert('RGBA') + + # Save to appropriate directory + if prefix == 'hr-': + output_path = self.output_dir_hr / f"{sprite_name}.png" + else: + output_path = self.output_dir / f"{sprite_name}.png" + + sprite.save(output_path) + print(f" Copied {filename} to {'hr' if prefix else 'normal'} directory") + except Exception as e: + print(f" Error copying {filename}: {e}") + + +def main(): + """Main entry point for character extraction""" + import sys + + # Default paths + character_path = "/Users/jackhopkins/PycharmProjects/PaperclipMaximiser/.fle/spritemaps/__base__/graphics/entity/character" + output_dir = "/Users/jackhopkins/PycharmProjects/PaperclipMaximiser/.fle/sprites" + + # Allow command line overrides + if len(sys.argv) > 1: + character_path = sys.argv[1] + if len(sys.argv) > 2: + output_dir = sys.argv[2] + + print(f"Character path: {character_path}") + print(f"Output path: {output_dir}") + + # Create extractor and run + extractor = CharacterSpriteExtractor(character_path, output_dir) + + # Extract all sprites + extractor.extract_all_character_sprites() + + # Extract single sprites + extractor.extract_single_sprites() + + # Create mapping file + extractor.create_character_mapping() + + print("\n=== Character Extraction Complete ===") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/fle/agents/data/sprites/extractors/decoratives.py b/fle/agents/data/sprites/extractors/decoratives.py new file mode 100644 index 000000000..15f72563f --- /dev/null +++ b/fle/agents/data/sprites/extractors/decoratives.py @@ -0,0 +1,67 @@ + +#!/usr/bin/env python3 +""" +Extended sprite extractor for Factorio decoratives +""" + +import json +import os +import shutil +from pathlib import Path +from PIL import Image +from typing import Dict, List, Optional, Tuple, Any + + +class DecorativeSpriteExtractor: + """Extract and process resource sprites and tree layers""" + + def __init__(self, decoratives_path: str, output_dir: str = "images"): + self.decoratives_path = Path(decoratives_path) + self.output_dir = Path(output_dir) + self.output_dir.mkdir(exist_ok=True, parents=True) + + self.output_dir_hr = Path(output_dir+"-hr") + self.output_dir_hr.mkdir(exist_ok=True, parents=True) + + def extract_decorative_sprite(self, decorative_name: str): + """Extract individual sprites from a resource sprite sheet""" + decorative_dir = self.decoratives_path / decorative_name + + if not decorative_dir.exists(): + print(f"Decorative directory not found: {decorative_dir}") + return + + + # Process both normal and high-res versions + for variant in range(99): + for prefix in ['', 'hr-']: + sprite_path = decorative_dir / f"{prefix}{decorative_name}-{str(variant).zfill(2)}.png" + + if not sprite_path.exists(): + continue + + try: + sprite = Image.open(sprite_path).convert('RGBA') + if prefix == 'hr-': + output_path = self.output_dir_hr / f"{decorative_name}_{variant}.png" + else: + output_path = self.output_dir / f"{decorative_name}_{variant}.png" + + sprite.save(output_path) + print(f"Saved: {output_path}") + + except Exception as e: + print(f"Error processing {sprite}: {e}") + + def extract_all_decoratives(self): + """Extract all resource sprites""" + print("=== Extracting Decorative Sprites ===") + + # Extract sprite sheets for mineral resources + for _, decorative_names, _ in os.walk(self.decoratives_path): + for decorative_name in decorative_names: + print(f"\nProcessing decoratives: {decorative_names}") + self.extract_decorative_sprite(decorative_name) + + print("\n=== Extracting Tree Sprites ===") + diff --git a/fle/agents/data/sprites/extractors/entities.py b/fle/agents/data/sprites/extractors/entities.py new file mode 100644 index 000000000..8d7fabf41 --- /dev/null +++ b/fle/agents/data/sprites/extractors/entities.py @@ -0,0 +1,1407 @@ +#!/usr/bin/env python3 +""" +Python port of spritesheet.js for extracting Factorio sprites (https://github.com/BlooperDB/BPRenderer) +Handles complex sprite extraction including multi-layer sprites, rotations, and combinations +""" + +import json +import math +import shutil +import subprocess +import tempfile +from pathlib import Path +from PIL import Image, ImageDraw,ImageChops +from typing import Dict, List, Optional, Tuple, Any + +TILE_PX = 32 + +class EntitySpritesheetExtractor: + """Extract and process Factorio sprites from game data""" + + def __init__(self, data_path: str, output_dir: str = "images"): + self.data_path = Path(data_path) + self.output_dir = Path(output_dir) + self.output_dir.mkdir(exist_ok=True, parents=True) + + # Create cache directory for transcoded basis files + self.cache_dir = self.data_path / "cache" + self.cache_dir.mkdir(exist_ok=True) + + # Load game data + with open(self.data_path / "data.json", 'r') as f: + full_data = json.load(f) + # Handle both 'raw' and 'entities' formats + # if 'items' in full_data: + # self.data = full_data['items'] + if 'entities' in full_data: + self.data = full_data['entities'] + else: + self.data = full_data + + self.directions = ['north', 'east', 'south', 'west'] + + + def get_file(self, path: str) -> Image.Image: + """Load image file from path""" + # Remove ALL __ prefixes (global replace) to match JavaScript + clean_path = path + while "__" in clean_path: + clean_path = clean_path.replace("__", "") + + # If path starts with "base/", we need to map it to "__base__/" + if clean_path.startswith("base/"): + file_path = self.data_path / "__base__" / clean_path[5:] # Remove "base/" + else: + file_path = self.data_path / clean_path + + # First, check if the file exists as-is (could be .png or .basis) + if file_path.exists(): + if file_path.suffix == '.basis': + return self._load_basis_file(file_path) + else: + return Image.open(file_path).convert('RGBA') + + # If no extension, try .basis first, then .png + if file_path.suffix == '': + basis_path = file_path.with_suffix('.basis') + if basis_path.exists(): + return self._load_basis_file(basis_path) + + png_path = file_path.with_suffix('.png') + if png_path.exists(): + return Image.open(png_path).convert('RGBA') + + # If the original path has .png extension but doesn't exist, try .basis + if file_path.suffix == '.png' and not file_path.exists(): + basis_path = file_path.with_suffix('.basis') + if basis_path.exists(): + return self._load_basis_file(basis_path) + + raise FileNotFoundError(f"Image not found: {file_path} (tried .basis and .png)") + + def _load_basis_file(self, basis_path: Path) -> Image.Image: + """Load a basis file, transcoding if necessary""" + # Check cache first + cache_key = str(basis_path).replace('/', '_').replace('.basis', '') + cached_png = self.cache_dir / f"{cache_key}.png" + + if not cached_png.exists(): + # Transcode basis to PNG + if not self._transcode_basis_to_png(basis_path, cached_png): + raise FileNotFoundError(f"Failed to transcode: {basis_path}") + + return Image.open(cached_png).convert('RGBA') + + def _transcode_basis_to_png(self, basis_path: Path, output_path: Path) -> bool: + """ + Transcode a .basis file to PNG using basisu + + Args: + basis_path: Path to the .basis file + output_path: Path where PNG should be saved + + Returns: + True if successful, False otherwise + """ + try: + # Create temporary directory for basisu output + with tempfile.TemporaryDirectory() as temp_dir: + temp_path = Path(temp_dir) + + # Run basisu transcoder + cmd = ["basisu", "-unpack", str(basis_path)] + result = subprocess.run( + cmd, + cwd=temp_path, + capture_output=True, + text=True + ) + + if result.returncode != 0: + print(f"basisu failed for {basis_path.name}: {result.stderr}") + return False + + # Find the generated RGBA PNG (best quality) + possible_patterns = [ + "*_unpacked_rgba_BC7_RGBA_0_0000.png", + "*_unpacked_rgba_BC3_RGBA_0_0000.png", + "*_unpacked_rgba_ETC2_RGBA_0_0000.png", + "*_unpacked_rgba_*_0_0000.png", + ] + + generated_png = None + for pattern in possible_patterns: + matches = list(temp_path.glob(pattern)) + if matches: + generated_png = matches[0] + break + + if not generated_png or not generated_png.exists(): + print(f"No suitable PNG generated from {basis_path}") + return False + + # Copy to output location + shutil.copy2(generated_png, output_path) + return True + + except Exception as e: + print(f"Error transcoding {basis_path}: {e}") + return False + + def save_canvas(self, path: str, image: Image.Image): + """Save image to file""" + output_path = self.output_dir / path + output_path.parent.mkdir(exist_ok=True, parents=True) + image.save(output_path) + print(f"Saved: {output_path}") + + def combine_canvas(self, first: Image.Image, second: Image.Image) -> Image.Image: + """Combine two images, centering both""" + width = max(first.width, second.width) + height = max(first.height, second.height) + + result = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + + # Center first image - use floor division to match JavaScript Math.floor + x1 = (width - first.width) // 2 + y1 = (height - first.height) // 2 + result.paste(first, (x1, y1), first) + + # Center second image - use floor division to match JavaScript Math.floor + x2 = (width - second.width) // 2 + y2 = (height - second.height) // 2 + result.paste(second, (x2, y2), second) + + return result + + def rotate_canvas(self, image: Image.Image, degrees: float) -> Image.Image: + """Rotate image by degrees""" + # PIL rotates counter-clockwise, canvas rotates clockwise, so negate + return image.rotate(-degrees, expand=False, fillcolor=(0, 0, 0, 0)) + + def extend_canvas(self, image: Image.Image, up: int = 0, right: int = 0, + down: int = 0, left: int = 0) -> Image.Image: + """Extend canvas in specified directions""" + new_width = image.width + right + left + new_height = image.height + up + down + + result = Image.new('RGBA', (new_width, new_height), (0, 0, 0, 0)) + result.paste(image, (left, up), image) + + return result + + def crop_image(self, image: Image.Image, x: int, y: int, + width: int, height: int) -> Image.Image: + """Crop image to specified rectangle""" + return image.crop((x, y, x + width, y + height)) + + def process_picture(self, picture: Dict, x_offset: int = 0, y_offset: int = 0, + width_x: Optional[int] = None, height_y: Optional[int] = None) -> Optional[Image.Image]: + """Process a picture definition from game data""" + if picture is None: + return None + + # Skip runtime tint and filenames (animated) + if picture.get('apply_runtime_tint') is not None or picture.get('filenames') is not None: + return None + + if 'filename' not in picture: + return None + + try: + image = self.get_file(picture['filename']) + except FileNotFoundError: + print(f"Warning: Could not load {picture['filename']}") + return None + + # Get dimensions + width = width_x or picture.get('width', image.width) + height = height_y or picture.get('height', image.height) + + # Calculate center position + center_x = width / 2 + center_y = height / 2 + + # Apply shift if specified - match JavaScript calculation exactly + if 'shift' in picture: + center_x = round(abs((picture['shift'][0] - width / 64) * 32)) + center_y = round(abs((picture['shift'][1] - height / 64) * 32)) + + # Calculate canvas size + canvas_width = width + abs(width / 2 - center_x) + canvas_height = height + abs(height / 2 - center_y) + + # Create result canvas + result = Image.new('RGBA', (int(canvas_width), int(canvas_height)), (0, 0, 0, 0)) + + # Calculate position to draw at + delta_x = round(canvas_width / 2) - center_x + delta_y = round(canvas_height / 2) - center_y + + # Extract the specified region from source image + src_x = x_offset or picture.get('x', 0) + src_y = y_offset or picture.get('y', 0) + + if src_x + width <= image.width and src_y + height <= image.height: + cropped = image.crop((src_x, src_y, src_x + width, src_y + height)) + result.paste(cropped, (int(delta_x), int(delta_y)), cropped) + + return result + + def extract_from_picture(self, name: str, picture: Any, suffix: str = ""): + """Extract sprites from picture definition""" + if picture is None: + print(f"Skipping (no data): {name} {suffix}") + return + + if isinstance(picture, dict): + if 'filename' in picture: + # Single sprite + result_name = name + suffix + if picture.get('draw_as_shadow'): + result_name += "_shadow" + + canvas = self.process_picture(picture) + if canvas: + self.save_canvas(f"{result_name}.png", canvas) + + elif 'north' in picture: + # Directional sprites + for direction in self.directions: + if direction in picture: + self.extract_from_picture(name, picture[direction], + suffix + "_" + direction) + + elif 'layers' in picture: + # Layered sprites + layers = picture['layers'] + if len(layers) == 2 and len(layers) > 1 and layers[1].get('draw_as_shadow'): + # Main + shadow + self.extract_from_picture(name, layers[0], suffix) + self.extract_from_picture(name, layers[1], suffix) + else: + # Multiple layers + for i, layer in enumerate(layers): + self.extract_from_picture(name, layer, suffix + f"_{i}") + + else: + # Other structure - check for sheet + for key, value in picture.items(): + if key == "sheet": + self.extract_from_picture(name, value, suffix) + else: + self.extract_from_picture(name, value, suffix + f"_{key}") + + # Entity-specific extraction methods + def transport_belt(self, entity: str, data: Dict): + """Extract transport belt sprites""" + animations = data.get('animations') + if not animations: + animations = data.get('belt_animation_set', {}).get('animation_set', []) + + # Horizontal + img = self.process_picture(animations) + if img: + self.save_canvas(f"{entity}_horizontal.png", img) + + # Vertical + img = self.process_picture(animations, 0, animations.get('height', 0)) + if img: + self.save_canvas(f"{entity}_vertical.png", img) + + # Bend right + img = self.process_picture(animations, 0, 8 * animations.get('height', 0)) + if img: + self.save_canvas(f"{entity}_bend_right.png", img) + + # Bend left + img = self.process_picture(animations, 0, 9 * animations.get('height', 0)) + if img: + self.save_canvas(f"{entity}_bend_left.png", img) + + def underground_belt(self, entity: str, data: Dict): + """Extract underground belt sprites""" + structure = data.get('structure', {}) + + # Get belt animations from the correct location + belt_animation = data.get('belt_animation_set', {}).get('animation_set') + if not belt_animation: + print(f"Warning: No belt animation found for {entity}") + return + + # For underground belts, we need to extract horizontal and vertical sections + # from the main animation sheet + if not structure: + print(f"Warning: No structure found for {entity}") + return + + out_sprites = structure.get('direction_out', {}) + in_sprites = structure.get('direction_in', {}) + + # The belt animation contains all directions in one sheet + # We need to process it similarly to transport_belt to get horizontal and vertical + + # First, let's get the belt animations for different orientations + belt_h = self.process_picture(belt_animation) # Horizontal belt + belt_v = self.process_picture(belt_animation, 0, belt_animation.get('height', 0)) # Vertical belt + + if not belt_h or not belt_v: + print(f"Warning: Could not process belt animations for {entity}") + return + + # Output sprites + if out_sprites.get('sheet'): + sheet = out_sprites['sheet'] + + # Down + belt = self.process_picture(belt_animation, 0, belt_animation.get('height', 0) + 40, 40, 20) + if belt: + belt = self.rotate_canvas(belt, 180) + belt = self.extend_canvas(belt, 20, 0, 0, 1) + out_img = self.process_picture(sheet) + if out_img: + combined = self.combine_canvas(belt, out_img) + self.save_canvas(f"{entity}_out_down.png", combined) + + # Left + out_img = self.process_picture(sheet, sheet.get('width', 0), 0) + if belt_h and out_img: + combined = self.combine_canvas(belt_h, out_img) + self.save_canvas(f"{entity}_out_left.png", combined) + + # Up + belt = self.extend_canvas(belt_v, 0, 0, 0, 1) + out_img = self.process_picture(sheet, 2 * sheet.get('width', 0), 0) + if belt and out_img: + combined = self.combine_canvas(belt, out_img) + self.save_canvas(f"{entity}_out_up.png", combined) + + # Right + belt = self.process_picture(belt_animation, 20, 0, 20, 40) + if belt: + belt = self.extend_canvas(belt, 0, 0, 0, 21) + out_img = self.process_picture(sheet, 3 * sheet.get('width', 0), 0) + if out_img: + combined = self.combine_canvas(belt, out_img) + self.save_canvas(f"{entity}_out_right.png", combined) + + # Input sprites + if in_sprites.get('sheet'): + sheet = in_sprites['sheet'] + sheet_h = sheet.get('height', 0) + + # Up + belt = self.process_picture(belt_animation, 0, belt_animation.get('height', 0) + 60, 40, 20) + if belt: + belt = self.extend_canvas(belt, 20, 0, 0, 1) + in_img = self.process_picture(sheet, 0, sheet_h) + if in_img: + combined = self.combine_canvas(belt, in_img) + self.save_canvas(f"{entity}_in_up.png", combined) + + # Right + belt = self.process_picture(belt_animation, 0, 0, 19, 40) + if belt: + belt = self.extend_canvas(belt, 0, 20, 0, 0) + in_img = self.process_picture(sheet, sheet.get('width', 0), sheet_h) + if in_img: + combined = self.combine_canvas(belt, in_img) + self.save_canvas(f"{entity}_in_right.png", combined) + + # Down + belt = self.process_picture(belt_animation, 0, belt_animation.get('height', 0)) + if belt: + belt = self.rotate_canvas(belt, 180) + belt = self.extend_canvas(belt, 0, 0, 0, 1) + in_img = self.process_picture(sheet, 2 * sheet.get('width', 0), sheet_h) + if in_img: + combined = self.combine_canvas(belt, in_img) + self.save_canvas(f"{entity}_in_down.png", combined) + + # Left + belt = self.process_picture(belt_animation, 0, 0, 20, 40) + if belt: + belt = self.rotate_canvas(belt, 180) + belt = self.extend_canvas(belt, 0, 0, 0, 20) + in_img = self.process_picture(sheet, 3 * sheet.get('width', 0), sheet_h) + if in_img: + combined = self.combine_canvas(belt, in_img) + self.save_canvas(f"{entity}_in_left.png", combined) + + def lab(self, entity: str, data: Dict) -> None: + """ + Render the Factorio lab. + + Saves: + _off.png – single static sprite + _on_XX.png – 33‑frame animation, zero‑based + + Needs: + - data["off_animation"]["layers"] + - data["on_animation"]["layers"] + """ + + # ------------------------------------------------------------------ + # 1. Little helpers + # ------------------------------------------------------------------ + def slice_frame(layer: Dict, frame: int) -> Image.Image | None: + """ + Return PIL.Image for *layer* at *frame* (handles HR fallback). + """ + sheet = self.process_picture(layer) + if sheet is None: + return None + + w, h = layer["width"], layer["height"] + fc = layer.get("frame_count", 1) + ll = layer.get("line_length", fc) + + # When frame_count == 1 but repeat_count > 1 (integration/shadow) + # we still slice 0 – factorio repeats that single tile. + local_f = 0 if fc == 1 else frame % fc + col = local_f % ll + row = local_f // ll + x0, y0 = col * w, row * h + return sheet.crop((x0, y0, x0 + w, y0 + h)) + + def shift_img(img: Image.Image, shift_xy: list[float]) -> Image.Image: + """ + Add transparent borders so *img* is offset by `shift`. + Positive shift.x → right, positive shift.y → down (Factorio style). + """ + dx = int(round(shift_xy[0] * TILE_PX)) + dy = int(round(shift_xy[1] * TILE_PX)) + + left, right = (dx, 0) if dx > 0 else (0, -dx) + top, bottom = (dy, 0) if dy > 0 else (0, -dy) + return self.extend_canvas(img, left, top, right, bottom) + + def composite(base: Image.Image | None, top: Image.Image, mode: str) -> Image.Image: + """ + Composite *top* onto *base* using *mode*. + """ + if base is None: + return top + + if mode == "additive": + # Pillow's add clips at 255 → faithful enough + return ImageChops.add(base, top) + else: + return self.combine_canvas(base, top) + + # ------------------------------------------------------------------ + # 2. Convenience to render one complete frame + # ------------------------------------------------------------------ + def render_frame(layers: list[Dict], frame_idx: int) -> Image.Image | None: + base = None + shadows: list[Image.Image] = [] + + # 2a. paint normal + additive layers immediately + for lyr in layers: + img = slice_frame(lyr, frame_idx) + if img is None: + continue + + img = shift_img(img, lyr.get("shift", [0, 0])) + + if lyr.get("draw_as_shadow"): + shadows.append(img) # postpone until after everything else + continue + + blend = "additive" if lyr.get("blend_mode") == "additive" or lyr.get("draw_as_light") else "normal" + base = composite(base, img, blend) + + # 2b. shadows go on top, normal blend + for sh in shadows: + base = composite(base, sh, "normal") + + return base + + # ------------------------------------------------------------------ + # 3. OFF sprite (single frame) + # ------------------------------------------------------------------ + off_layers = data.get("off_animation", {}).get("layers", []) + if off_layers: + off = render_frame(off_layers, 0) + if off: + self.save_canvas(f"{entity}_off.png", off) + + # ------------------------------------------------------------------ + # 4. ON animation (33 frames) + # ------------------------------------------------------------------ + on_layers = data.get("on_animation", {}).get("layers", []) + if on_layers: + frame_count = on_layers[0].get("frame_count", 1) + for f in range(frame_count): + frame_img = render_frame(on_layers, f) + if frame_img: + self.save_canvas(f"{entity}_on_{f:02d}.png", frame_img) + + def splitter(self, entity: str, data: Dict): + """ + Render the four static splitter sprites (N/E/S/W) from the new + 1.1‑style prototype definition. + + Required keys inside *data*: + - structure: {north|east|south|west: {...}} + - belt_animation_set.animation_set: {...} + Optional: + - structure_patch: {east|west: {...}} # only E/W use patches + """ + # ------------------------------------------------------------------ + # 1. Collect the three sprite sources we need + # ------------------------------------------------------------------ + structure = data.get("structure", {}) + if not structure: + return + + anim_set = ( + data.get("belt_animation_set", {}) + .get("animation_set") + ) + if not anim_set: + return + + patch = data.get("structure_patch", {}) # may be empty + + # ------------------------------------------------------------------ + # 2. Helpers for slicing the belt sprite sheet and applying patches + # ------------------------------------------------------------------ + def belt_frame(direction: int, frame: int = 0): + """ + Crop a *single* belt tile from the big 64×64 (HR:128×128) sheet. + *direction* is the sprite‑row (0 = north, 2 = east, 4 = south, 6 = west) + *frame* is the animation frame column (we render frame 0 for static) + """ + sheet = self.process_picture(anim_set) + if not sheet: + return None + + w, h = anim_set["width"], anim_set["height"] + line_len = anim_set.get("line_length", anim_set["frame_count"]) + + col = frame % line_len + row = direction + x0, y0 = col * w, row * h + return sheet.crop((x0, y0, x0 + w, y0 + h)) + + def add_patch(canvas, direction: str): + """ + Overlay the (optional) top patch used by east/west splitters. + """ + p = patch.get(direction) + # Ignore the canonical 1×1 empty sprite + if p and not p["filename"].endswith("empty.png"): + patch_img = self.process_picture(p) + if patch_img: + canvas = self.combine_canvas(canvas, patch_img) + return canvas + + # Pre‑slice static belt tiles once; reuse them for both belts + belt_v = belt_frame(0) # vertical (north‑facing) + belt_h = belt_frame(2) # horizontal (east‑facing) + if not belt_v or not belt_h: + return + + # ------------------------------------------------------------------ + # 3. NORTH + # ------------------------------------------------------------------ + if "north" in structure: + belt1 = self.extend_canvas(belt_v.copy(), 0, 30) # belt entering + belt2 = self.extend_canvas(belt_v.copy(), 0, 0, 0, 30) # belt leaving + belts = self.combine_canvas(belt1, belt2) + + struct = self.process_picture(structure["north"]) + if struct: + combined = self.combine_canvas(belts, struct) + self.save_canvas(f"{entity}_north.png", combined) + + # ------------------------------------------------------------------ + # 4. EAST + # ------------------------------------------------------------------ + if "east" in structure: + belt1 = self.extend_canvas(belt_h.copy(), 34) # belt entering + belt2 = self.extend_canvas(belt_h.copy(), 0, 0, 34) # belt leaving + belts = self.combine_canvas(belt1, belt2) + + struct = self.process_picture(structure["east"]) + if struct: + combined = self.combine_canvas(belts, struct) + combined = add_patch(combined, "east") + self.save_canvas(f"{entity}_east.png", combined) + + # ------------------------------------------------------------------ + # 5. SOUTH (rotate belts 180°) + # ------------------------------------------------------------------ + if "south" in structure: + belt1 = self.rotate_canvas(belt_v.copy(), 180) + belt2 = self.rotate_canvas(belt_v.copy(), 180) + belt1 = self.extend_canvas(belt1, 0, 32) + belt2 = self.extend_canvas(belt2, 0, 0, 0, 32) + belts = self.combine_canvas(belt1, belt2) + + struct = self.process_picture(structure["south"]) + if struct: + combined = self.combine_canvas(belts, struct) + self.save_canvas(f"{entity}_south.png", combined) + + # ------------------------------------------------------------------ + # 6. WEST (rotate belts 180° + optional patch) + # ------------------------------------------------------------------ + if "west" in structure: + belt1 = self.rotate_canvas(belt_h.copy(), 180) + belt2 = self.rotate_canvas(belt_h.copy(), 180) + belt1 = self.extend_canvas(belt1, 34) + belt2 = self.extend_canvas(belt2, 0, 0, 34) + belts = self.combine_canvas(belt1, belt2) + + struct = self.process_picture(structure["west"]) + if struct: + combined = self.combine_canvas(belts, struct) + combined = add_patch(combined, "west") + self.save_canvas(f"{entity}_west.png", combined) + + def pipe_to_ground(self, entity: str, data: Dict): + """Extract pipe-to-ground sprites - similar to underground belt""" + # Just use standard extraction for pipe-to-ground + if 'pictures' in data: + self.extract_from_picture(entity, data['pictures']) + elif 'picture' in data: + self.extract_from_picture(entity, data['picture']) + + def inserter(self, entity: str, data: Dict): + """Extract inserter sprites""" + platform = data.get('platform_picture', {}).get('sheet') + hand_open = data.get('hand_open_picture') + hand_base = data.get('hand_base_picture') + + if not all([platform, hand_open, hand_base]): + return + + # North + plat = self.process_picture(platform) + hand = self.process_picture(hand_open) + if plat and hand: + hand = self.extend_canvas(hand, 0, 0, 40, 2) + combined = self.combine_canvas(plat, hand) + self.save_canvas(f"{entity}_north.png", combined) + + # East + plat = self.process_picture(platform, 3 * platform['width'], 0) + if plat and hand_base and hand_open: + base = self.process_picture(hand_base) + hand = self.process_picture(hand_open) + if base and hand: + base = self.extend_canvas(base, 15, 15, 15, 15) + base = self.rotate_canvas(base, 35) + base = self.extend_canvas(base, 0, 0, 20, 10) + + hand = self.extend_canvas(hand, 15, 15, 15, 15) + hand = self.rotate_canvas(hand, 145) + hand = self.extend_canvas(hand, 0, 0, 15, 45) + + hands = self.combine_canvas(base, hand) + combined = self.combine_canvas(plat, hands) + self.save_canvas(f"{entity}_east.png", combined) + + # South + plat = self.process_picture(platform, 2 * platform['width'], 0) + hand = self.process_picture(hand_open) + if plat and hand: + hand = self.rotate_canvas(hand, 180) + hand = self.extend_canvas(hand, 32, 0, 0, 2) + combined = self.combine_canvas(plat, hand) + self.save_canvas(f"{entity}_south.png", combined) + + # West + plat = self.process_picture(platform, 1 * platform['width'], 0) + if plat and hand_base and hand_open: + base = self.process_picture(hand_base) + hand = self.process_picture(hand_open) + if base and hand: + base = self.extend_canvas(base, 15, 15, 15, 15) + base = self.rotate_canvas(base, -35) + base = self.extend_canvas(base, 0, 15, 20, 0) + + hand = self.extend_canvas(hand, 15, 15, 15, 15) + hand = self.rotate_canvas(hand, -145) + hand = self.extend_canvas(hand, 0, 50, 15, 0) + + hands = self.combine_canvas(base, hand) + combined = self.combine_canvas(plat, hands) + self.save_canvas(f"{entity}_west.png", combined) + + def long_handed_inserter(self, entity: str, data: Dict): + """Extract long-handed inserter sprites""" + platform = data.get('platform_picture', {}).get('sheet') + hand_open = data.get('hand_open_picture') + hand_base = data.get('hand_base_picture') + + if not all([platform, hand_open, hand_base]): + return + + # North + plat = self.process_picture(platform) + hand = self.process_picture(hand_open) + base = self.process_picture(hand_base) + if plat and hand and base: + hand = self.extend_canvas(hand, 0, 0, 90, 2) + base = self.extend_canvas(base, 0, 0, 30, 3) + hands = self.combine_canvas(hand, base) + combined = self.combine_canvas(plat, hands) + self.save_canvas(f"{entity}_north.png", combined) + + # East + plat = self.process_picture(platform, 3 * platform['width'], 0) + if plat and hand_base and hand_open: + base = self.process_picture(hand_base) + hand = self.process_picture(hand_open) + if base and hand: + base = self.extend_canvas(base, 15, 15, 15, 15) + base = self.rotate_canvas(base, 75) + base = self.extend_canvas(base, 0, 0, 20, 20) + + hand = self.extend_canvas(hand, 15, 15, 15, 15) + hand = self.rotate_canvas(hand, 115) + hand = self.extend_canvas(hand, 0, 0, 15, 85) + + hands = self.combine_canvas(base, hand) + combined = self.combine_canvas(plat, hands) + self.save_canvas(f"{entity}_east.png", combined) + + # South + plat = self.process_picture(platform, 2 * platform['width'], 0) + hand = self.process_picture(hand_open) + base = self.process_picture(hand_base) + if plat and hand and base: + hand = self.rotate_canvas(hand, 180) + hand = self.extend_canvas(hand, 85, 0, 0, 2) + base = self.rotate_canvas(base, 180) + base = self.extend_canvas(base, 25, 0, 0, 3) + hands = self.combine_canvas(hand, base) + combined = self.combine_canvas(plat, hands) + self.save_canvas(f"{entity}_south.png", combined) + + # West + plat = self.process_picture(platform, 1 * platform['width'], 0) + if plat and hand_base and hand_open: + base = self.process_picture(hand_base) + hand = self.process_picture(hand_open) + if base and hand: + base = self.extend_canvas(base, 15, 15, 15, 15) + base = self.rotate_canvas(base, -75) + base = self.extend_canvas(base, 0, 15, 20, 0) + + hand = self.extend_canvas(hand, 15, 15, 15, 15) + hand = self.rotate_canvas(hand, -115) + hand = self.extend_canvas(hand, 0, 85, 15, 0) + + hands = self.combine_canvas(base, hand) + combined = self.combine_canvas(plat, hands) + self.save_canvas(f"{entity}_west.png", combined) + + def combinator_displays(self): + """Extract combinator display symbols""" + grid = [ + ["empty", "plus", "minus", "multiply", "divide", "modulo"], + ["power", "left_shift", "right_shift", "and", "or", "xor"], + ["gt", "lt", "eq", "neq", "lte", "gte"] + ] + + width = 15 + height = 11 + + try: + image = self.get_file("__base__/graphics/entity/combinator/combinator-displays.png") + except FileNotFoundError: + print("Warning: Could not find combinator displays") + return + + for y in range(len(grid)): + for x in range(len(grid[y])): + cropped = self.crop_image(image, x * width, y * height, width, height) + self.save_canvas(f"display_{grid[y][x]}.png", cropped) + + def roboport(self, entity: str, data: Dict): + """Extract roboport sprites""" + base = self.process_picture(data.get('base')) + base_patch = self.process_picture(data.get('base_patch')) + door_up = self.process_picture(data.get('door_animation_up')) + door_down = self.process_picture(data.get('door_animation_down')) + base_anim = self.process_picture(data.get('base_animation')) + + result = base + if base_patch: + result = self.combine_canvas(result, base_patch) if result else base_patch + if door_up: + result = self.combine_canvas(result, door_up) if result else door_up + if door_down: + result = self.combine_canvas(result, door_down) if result else door_down + if base_anim: + result = self.combine_canvas(result, base_anim) if result else base_anim + + if result: + self.save_canvas(f"{entity}.png", result) + + def heat_pipe(self, entity: str, data: Dict): + """Extract heat pipe sprites""" + sprites = data.get('connection_sprites', {}) + + sprite_names = [ + 'single', 'straight_horizontal', 'ending_right', 'corner_right_up', + 't_left', 't_down', 'ending_up', 't_right', 't_up', 'ending_left', + 'ending_down', 'straight_vertical', 'corner_right_down', 'cross', + 'corner_left_down', 'corner_left_up' + ] + + for sprite_name in sprite_names: + if sprite_name in sprites and sprites[sprite_name]: + img = self.process_picture(sprites[sprite_name][0]) + if img: + self.save_canvas(f"{entity}_{sprite_name}.png", img) + + def stone_wall(self, entity: str, data: Dict): + """Extract stone wall sprites""" + pics = data.get('pictures', {}) + + # Regular sprites + if 'single' in pics and 'layers' in pics['single']: + img = self.process_picture(pics['single']['layers'][0]) + if img: + self.save_canvas(f"{entity}_single.png", img) + img = self.process_picture(pics['single']['layers'][1]) + if img: + self.save_canvas(f"{entity}_single_shadow.png", img) + + # Other wall types + wall_types = [ + ('straight_horizontal', 0), + ('ending_right', None), + ('t_up', None), + ('ending_left', None), + ('straight_vertical', 0), + ('corner_right_down', None), + ('corner_left_down', None) + ] + + for wall_type, index in wall_types: + if wall_type in pics: + pic_data = pics[wall_type] + if index is not None and isinstance(pic_data, list): + pic_data = pic_data[index] + + if 'layers' in pic_data: + img = self.process_picture(pic_data['layers'][0]) + if img: + self.save_canvas(f"{entity}_{wall_type}.png", img) + img = self.process_picture(pic_data['layers'][1]) + if img: + self.save_canvas(f"{entity}_{wall_type}_shadow.png", img) + + def assembling_machine(self, entity: str, data: Dict): + """Extract assembling machine sprites""" + if 'animation' in data and 'layers' in data['animation']: + img = self.process_picture(data['animation']['layers'][0]) + if img: + self.save_canvas(f"{entity}.png", img) + if len(data['animation']['layers']) > 1: + img = self.process_picture(data['animation']['layers'][1]) + if img: + self.save_canvas(f"{entity}_shadow.png", img) + + # Pipe connections - try to load directly + for direction in ['N', 'E', 'S', 'W']: + try: + img = self.get_file(f"__base__/graphics/entity/{entity}/{entity}-pipe-{direction}.png") + if direction == 'N': + img = self.extend_canvas(img, 0, 0, 100, 5) + elif direction == 'E': + img = self.extend_canvas(img, 0, 0, 0, 80) + elif direction == 'S': + img = self.extend_canvas(img, 70, 0, 0, 0) + elif direction == 'W': + img = self.extend_canvas(img, 0, 77, 0, 0) + self.save_canvas(f"{entity}_pipe_{direction.lower()}orth.png" if direction == 'N' else + f"{entity}_pipe_{direction.lower()}ast.png" if direction == 'E' else + f"{entity}_pipe_{direction.lower()}outh.png" if direction == 'S' else + f"{entity}_pipe_{direction.lower()}est.png", img) + except: + pass + + def rocket_silo(self, entity: str, data: Dict): + """Extract rocket silo sprites""" + door_back = self.process_picture(data.get('door_back_sprite')) + base_day = self.process_picture(data.get('base_day_sprite')) + + # Door front needs special handling + door_front = None + if 'door_front_sprite' in data and 'filename' in data['door_front_sprite']: + try: + door_front = self.get_file(data['door_front_sprite']['filename']) + door_front = self.extend_canvas(door_front, 130, 0, 0, 0) + except: + pass + + result = base_day + if door_back: + result = self.combine_canvas(door_back, result) if result else door_back + if door_front: + result = self.combine_canvas(result, door_front) if result else door_front + + if result: + self.save_canvas(f"{entity}.png", result) + + shadow = self.process_picture(data.get('shadow_sprite')) + if shadow: + self.save_canvas(f"{entity}_shadow.png", shadow) + + def nuclear_reactor(self, entity: str, data: Dict): + """Extract nuclear reactor sprites""" + lower = self.process_picture(data.get('lower_layer_picture')) + upper = None + + if 'picture' in data and 'layers' in data['picture']: + upper = self.process_picture(data['picture']['layers'][0]) + + if lower and upper: + result = self.combine_canvas(lower, upper) + self.save_canvas(f"{entity}.png", result) + elif lower: + self.save_canvas(f"{entity}.png", lower) + elif upper: + self.save_canvas(f"{entity}.png", upper) + + if 'picture' in data and 'layers' in data['picture'] and len(data['picture']['layers']) > 1: + shadow = self.process_picture(data['picture']['layers'][1]) + if shadow: + self.save_canvas(f"{entity}_shadow.png", shadow) + + def storage_tank(self, entity: str, data: Dict): + """Extract storage tank sprites""" + if 'pictures' in data and 'picture' in data['pictures'] and 'sheet' in data['pictures']['picture']: + sheet = data['pictures']['picture']['sheet'] + img = self.process_picture(sheet) + if img: + self.save_canvas(f"{entity}_north.png", img) + img = self.process_picture(sheet, sheet.get('width', 0), 0) + if img: + self.save_canvas(f"{entity}_east.png", img) + + def beacon(self, entity: str, data: Dict): + """Extract beacon sprites""" + base = self.process_picture(data.get('base_picture')) + animation = self.process_picture(data.get('animation')) + + if base and animation: + result = self.combine_canvas(base, animation) + self.save_canvas(f"{entity}.png", result) + elif base: + self.save_canvas(f"{entity}.png", base) + elif animation: + self.save_canvas(f"{entity}.png", animation) + + def centrifuge(self, entity: str, data: Dict): + """Extract centrifuge sprites""" + if 'idle_animation' in data and 'layers' in data['idle_animation']: + layers = data['idle_animation']['layers'] + + # Main sprite + if len(layers) >= 5: + layer0 = self.process_picture(layers[0]) + layer2 = self.process_picture(layers[2]) + layer4 = self.process_picture(layers[4]) + + result = layer0 + if layer2: + result = self.combine_canvas(result, layer2) if result else layer2 + if layer4: + result = self.combine_canvas(result, layer4) if result else layer4 + + if result: + self.save_canvas(f"{entity}.png", result) + + # Shadow sprite + if len(layers) >= 6: + layer1 = self.process_picture(layers[1]) + layer3 = self.process_picture(layers[3]) + layer5 = self.process_picture(layers[5]) + + result = layer1 + if layer3: + result = self.combine_canvas(result, layer3) if result else layer3 + if layer5: + result = self.combine_canvas(result, layer5) if result else layer5 + + if result: + self.save_canvas(f"{entity}_shadow.png", result) + + def flamethrower_turret(self, entity: str, data: Dict): + """Extract flamethrower turret sprites""" + pipe_pics = data.get('fluid_box', {}).get('pipe_picture', {}) + base_pics = data.get('base_picture', {}) + folded_anim = data.get('folded_animation', {}) + + # Process each direction + for direction in self.directions: + if direction in base_pics and direction in folded_anim: + base_layers = base_pics[direction].get('layers', []) + folded_layers = folded_anim[direction].get('layers', []) + + if base_layers and folded_layers: + # Main sprite + base = self.process_picture(base_layers[0]) + folded = self.process_picture(folded_layers[0]) + + # Add pipes based on direction + if direction == 'north': + pipe_e = self.process_picture(pipe_pics.get('east')) + pipe_w = self.process_picture(pipe_pics.get('west')) + if pipe_e: + pipe_e = self.extend_canvas(pipe_e, 64, 0, 0, 32) + if pipe_w: + pipe_w = self.extend_canvas(pipe_w, 64, 32, 0, 0) + elif direction == 'east': + pipe_n = self.process_picture(pipe_pics.get('north')) + pipe_s = self.process_picture(pipe_pics.get('south')) + if pipe_n: + pipe_n = self.extend_canvas(pipe_n, 0, 64, 32, 0) + if pipe_s: + pipe_s = self.extend_canvas(pipe_s, 32, 64, 0, 0) + elif direction == 'south': + pipe_e = self.process_picture(pipe_pics.get('east')) + pipe_w = self.process_picture(pipe_pics.get('west')) + if pipe_e: + pipe_e = self.extend_canvas(pipe_e, 0, 0, 64, 32) + if pipe_w: + pipe_w = self.extend_canvas(pipe_w, 0, 32, 64, 0) + elif direction == 'west': + pipe_n = self.process_picture(pipe_pics.get('north')) + pipe_s = self.process_picture(pipe_pics.get('south')) + if pipe_n: + pipe_n = self.extend_canvas(pipe_n, 0, 0, 32, 64) + if pipe_s: + pipe_s = self.extend_canvas(pipe_s, 32, 0, 0, 64) + + # Combine all elements + result = base + if folded: + result = self.combine_canvas(result, folded) if result else folded + + if direction in ['north', 'south']: + if 'pipe_e' in locals() and pipe_e: + result = self.combine_canvas(pipe_e, result) if result else pipe_e + if 'pipe_w' in locals() and pipe_w: + result = self.combine_canvas(pipe_w, result) if result else pipe_w + else: + if 'pipe_n' in locals() and pipe_n: + result = self.combine_canvas(pipe_n, result) if result else pipe_n + if 'pipe_s' in locals() and pipe_s: + result = self.combine_canvas(pipe_s, result) if result else pipe_s + + if result: + self.save_canvas(f"{entity}_{direction}.png", result) + + # Shadow + if len(base_layers) > 2 and len(folded_layers) > 2: + base_shadow = self.process_picture(base_layers[2]) + folded_shadow = self.process_picture(folded_layers[2]) + + shadow = base_shadow + if folded_shadow: + shadow = self.combine_canvas(shadow, folded_shadow) if shadow else folded_shadow + + if shadow: + self.save_canvas(f"{entity}_{direction}_shadow.png", shadow) + + def normal_turret(self, entity: str, data: Dict): + """Extract normal turret sprites""" + base = None + folded = None + shadow = None + + if 'base_picture' in data and 'layers' in data['base_picture']: + base = self.process_picture(data['base_picture']['layers'][0]) + + if 'folded_animation' in data and 'layers' in data['folded_animation']: + folded = self.process_picture(data['folded_animation']['layers'][0]) + # Try to find shadow in different positions + if len(data['folded_animation']['layers']) > 1: + shadow = self.process_picture(data['folded_animation']['layers'][1]) + if not shadow and len(data['folded_animation']['layers']) > 2: + shadow = self.process_picture(data['folded_animation']['layers'][2]) + + result = base + if folded: + result = self.combine_canvas(result, folded) if result else folded + + if result: + self.save_canvas(f"{entity}.png", result) + + if shadow: + self.save_canvas(f"{entity}_shadow.png", shadow) + + def pumpjack(self, entity: str, data: Dict): + """Extract pumpjack sprites""" + base_sheet = data.get('base_picture', {}).get('sheet') + animations = data.get('animations', {}).get('north') + + if base_sheet and animations: + for i, direction in enumerate(self.directions): + base = self.process_picture(base_sheet, i * base_sheet.get('width', 0), 0) + anim = self.process_picture(animations) + + if base and anim: + result = self.combine_canvas(base, anim) + self.save_canvas(f"{entity}_{direction}.png", result) + + def straight_rail(self, entity: str, data: Dict): + """Extract straight rail sprites""" + pics = data.get('pictures', {}) + + # Horizontal rails + h_rail = pics.get('straight_rail_horizontal', {}) + for i, component in enumerate(['stone_path_background', 'stone_path', 'ties', 'backplates', 'metals']): + if component in h_rail and 'sheet' in h_rail[component]: + img = self.process_picture(h_rail[component]['sheet']) + if img: + self.save_canvas(f"{entity}_horizontal_pass_{i + 1}.png", img) + + # Vertical rails + v_rail = pics.get('straight_rail_vertical', {}) + for i, component in enumerate(['stone_path_background', 'stone_path', 'ties', 'backplates', 'metals']): + if component in v_rail and 'sheet' in v_rail[component]: + img = self.process_picture(v_rail[component]['sheet']) + if img: + self.save_canvas(f"{entity}_vertical_pass_{i + 1}.png", img) + + # Diagonal rails + for diagonal in ['diagonal_left_bottom', 'diagonal_right_bottom', 'diagonal_left_top', 'diagonal_right_top']: + d_rail = pics.get(f'straight_rail_{diagonal}', {}) + for i, component in enumerate(['stone_path_background', 'stone_path', 'ties', 'backplates', 'metals']): + if component in d_rail and 'sheet' in d_rail[component]: + img = self.process_picture(d_rail[component]['sheet']) + if img: + self.save_canvas(f"{entity}_{diagonal}_pass_{i + 1}.png", img) + + def curved_rail(self, entity: str, data: Dict): + """Extract curved rail sprites""" + pics = data.get('pictures', {}) + + # All curved rail variants + variants = [ + 'vertical_left_top', 'vertical_left_bottom', 'vertical_right_top', 'vertical_right_bottom', + 'horizontal_left_top', 'horizontal_left_bottom', 'horizontal_right_top', 'horizontal_right_bottom' + ] + + for variant in variants: + rail = pics.get(f'curved_rail_{variant}', {}) + for i, component in enumerate(['stone_path_background', 'stone_path', 'ties', 'backplates', 'metals']): + if component in rail and 'sheet' in rail[component]: + img = self.process_picture(rail[component]['sheet']) + if img: + self.save_canvas(f"{entity}_{variant}_pass_{i + 1}.png", img) + + def rail_signal(self, entity: str, data: Dict): + """Extract rail signal sprites""" + animation = data.get('animation') + rail_piece = data.get('rail_piece') + + if animation: + for i in range(8): + img = self.process_picture(animation, 0, i * animation.get('height', 0)) + if img: + self.save_canvas(f"{entity}_{i}.png", img) + + if rail_piece: + for i in range(8): + img = self.process_picture(rail_piece, i * rail_piece.get('width', 0), 0) + if img: + self.save_canvas(f"{entity}_rail_{i}.png", img) + + def rail_chain_signal(self, entity: str, data: Dict): + """Extract rail chain signal sprites""" + anim = data.get('animation') + rail = data.get('rail_piece') + + # Different orientations with extensions + extensions = [ + (0, 0, 0, 64), # 0 + (64, 0, 0, 64), # 1 + (64, 0, 0, 0), # 2 + (64, 64, 0, 0), # 3 + (0, 128, 0, 0), # 4 + (0, 64, 64, 0), # 5 + (0, 0, 128, 0), # 6 + (0, 0, 64, 64) # 7 + ] + + for i, (up, right, down, left) in enumerate(extensions): + if anim: + img = self.process_picture(anim, 0, i * anim.get('height', 0)) + if img: + img = self.extend_canvas(img, up, right, down, left) + self.save_canvas(f"{entity}_{i}.png", img) + + if rail: + img = self.process_picture(rail, i * rail.get('width', 0), 0) + if img: + img = self.extend_canvas(img, up, right, down, left) + self.save_canvas(f"{entity}_rail_{i}.png", img) + + def extract_entity(self, entity_name: str): + """Extract sprites for a specific entity""" + # Special handlers + special_handlers = { + "curved-rail": self.curved_rail, + "straight-rail": self.straight_rail, + "beacon": self.beacon, + "centrifuge": self.centrifuge, + "pumpjack": self.pumpjack, + "rocket-silo": self.rocket_silo, + "underground-belt": self.underground_belt, + "fast-underground-belt": self.underground_belt, + "express-underground-belt": self.underground_belt, + "transport-belt": self.transport_belt, + "fast-transport-belt": self.transport_belt, + "express-transport-belt": self.transport_belt, + "splitter": self.splitter, + "fast-splitter": self.splitter, + "express-splitter": self.splitter, + "inserter": self.inserter, + "stack-inserter": self.inserter, + "filter-inserter": self.inserter, + "burner-inserter": self.inserter, + "fast-inserter": self.inserter, + "stack-filter-inserter": self.inserter, + "long-handed-inserter": self.long_handed_inserter, + "roboport": self.roboport, + "heat-pipe": self.heat_pipe, + "stone-wall": self.stone_wall, + "nuclear-reactor": self.nuclear_reactor, + "assembling-machine-2": self.assembling_machine, + "assembling-machine-3": self.assembling_machine, + "storage-tank": self.storage_tank, + "flamethrower-turret": self.flamethrower_turret, + "laser-turret": self.normal_turret, + "gun-turret": self.normal_turret, + "rail-signal": self.rail_signal, + "rail-chain-signal": self.rail_chain_signal, + "pipe-to-ground": self.pipe_to_ground # Added missing handler + } + + # Find entity data + entity_data = None + # for key, category in self.data.items(): + # if isinstance(category, dict) and entity_name in category: + # entity_data = category[entity_name] + # break + entity_data = self.data[entity_name] + + if not entity_data: + print(f"Entity not found: {entity_name}") + return + + # Use special handler if available + if entity_name in special_handlers: + special_handlers[entity_name](entity_name, entity_data) + return + + # Generic extraction + sprite_properties = [ + 'picture', 'pictures', 'idle_animation', 'animation', + 'animations', 'structure', 'off_animation', + 'vertical_animation', 'horizontal_animation', + 'picture_off', 'power_on_animation', 'sprite', + 'sprites', 'connection_sprites' + ] + + extracted = False + for prop in sprite_properties: + if prop in entity_data: + if prop == 'vertical_animation' and 'horizontal_animation' in entity_data: + self.extract_from_picture(entity_name, entity_data['vertical_animation'], "_vertical") + self.extract_from_picture(entity_name, entity_data['horizontal_animation'], "_horizontal") + else: + self.extract_from_picture(entity_name, entity_data[prop]) + extracted = True + break + + if not extracted: + print(f"TODO: {entity_name}") + else: + pass + + def extract_all(self): + """Extract all entities""" + # Complete skip categories list to match JavaScript + skip_categories = [ + 'technology', 'item-subgroup', 'tutorial', 'simple-entity', + 'unit', 'simple-entity-with-force', 'rail-remnants', 'item-group', + 'particle', 'car', 'font', 'character-corpse', 'cargo-wagon', + 'ammo-category', 'ambient-sound', 'smoke', 'tree', 'corpse' + ] + + # for category_name, category_data in self.data.items(): + # if category_name in skip_categories or category_name.endswith('achievement'): + # continue + # + # if not isinstance(category_data, dict): + # continue + + for entity_name, entity_data in self.data.items(): + if not isinstance(entity_data, dict): + continue + + # Skip hidden entities + flags = entity_data.get('flags', []) + if flags and 'hidden' in flags: + continue + + # Extract icon + if 'icon' in entity_data: + try: + icon = self.get_file(entity_data['icon']) + self.save_canvas(f"icon_{entity_name}.png", icon) + except Exception: + pass + + # Skip recipes and items for sprite extraction + # if category_name in ['recipe', 'item']: + # continue + + # Check flags more strictly to match JavaScript + if flags and ('player-creation' not in flags or 'placeable-off-grid' in flags): + continue + + print(f"Processing {entity_name}...") + try: + self.extract_entity(entity_name) + except Exception as e: + print(f"Error processing {entity_name}: {e}") + # Don't re-raise, continue processing + + # Extract combinator displays + self.combinator_displays() + + +def main(): + """Main entry point""" + import sys + + # if len(sys.argv) < 2: + # print("Usage: python spritesheet_extractor.py [output_dir]") + # print("\nExample:") + # print(" python spritesheet_extractor.py data/rendering images") + # sys.exit(1) + # + # data_path = sys.argv[1] + # output_dir = sys.argv[2] if len(sys.argv) > 2 else "images" + data_path = "/Users/jackhopkins/PycharmProjects/PaperclipMaximiser/data/rendering" + output_dir = "/data/sprites/spritemaps" + + extractor = EntitySpritesheetExtractor(data_path, output_dir) + extractor.extract_all() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/fle/agents/data/sprites/extractors/icons.py b/fle/agents/data/sprites/extractors/icons.py new file mode 100644 index 000000000..0b8b6a47c --- /dev/null +++ b/fle/agents/data/sprites/extractors/icons.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +""" +Icon sprite extractor for Factorio icons +Handles the layout where each successive icon is 50% the size of the previous +""" + +import os +from pathlib import Path +from PIL import Image +from typing import Dict, List, Optional, Tuple + + +class IconSpriteExtractor: + """Extract icon sprites from halving-size layout spritesheets""" + + def __init__(self, icons_path: str, output_dir: str = "images"): + self.icons_path = Path(icons_path) + self.output_dir = Path(output_dir) + self.output_dir.mkdir(exist_ok=True, parents=True) + + self.output_dir_hr = Path(output_dir + "-hr") + self.output_dir_hr.mkdir(exist_ok=True, parents=True) + + def extract_icon_from_spritesheet(self, icon_name: str): + """ + Extract the primary (leftmost) icon from a halving-size layout spritesheet + + The layout is: + - Left pane: Primary icon (full height) + - Right pane upper half: Second icon (50% size) + - Right pane upper-right quarter: Third icon (25% size) + - And so on... + """ + icon_file = self.icons_path / f"{icon_name}.png" + icon_file_hr = self.icons_path / f"hr-{icon_name}.png" + + # Process both normal and high-res versions + for is_hr, file_path in [(False, icon_file), (True, icon_file_hr)]: + if not file_path.exists(): + continue + + try: + spritesheet = Image.open(file_path).convert('RGBA') + + # The primary icon width can be determined by finding where + # the halving pattern starts in the right side + primary_width = self._find_primary_icon_width(spritesheet) + + if primary_width is None: + # Fallback: assume the primary icon is square (width = height) + primary_width = spritesheet.height + + # Extract the leftmost sprite (primary icon) + primary_icon = spritesheet.crop((0, 0, primary_width, spritesheet.height)) + + # Save the icon with 'icon_' prefix + output_name = f"{'hr-' if is_hr else ''}icon_{icon_name}.png" + if is_hr: + output_path = self.output_dir_hr / output_name + else: + output_path = self.output_dir / output_name + + primary_icon.save(output_path) + print(f"Saved icon: {output_path} (size: {primary_icon.width}x{primary_icon.height})") + + except Exception as e: + print(f"Error processing {file_path}: {e}") + + def _find_primary_icon_width(self, image: Image.Image) -> Optional[int]: + """ + Find the width of the primary icon by analyzing the image structure + """ + width, height = image.size + + # Method 1: Look for a clear vertical division + division = self._find_vertical_division(image) + if division is not None: + return division + + # Method 2: Check if the image follows the halving pattern + # If total width = height + height/2, then primary width = height + if abs(width - (height + height / 2)) < 5: # Allow small tolerance + return height + + # Method 3: Check for common icon sizes (powers of 2) + common_sizes = [16, 32, 64, 128, 256, 512] + for size in common_sizes: + if height == size: + # Check if remaining width could contain the halving pattern + remaining = width - size + if remaining >= size / 2: + return size + + return None + + def _find_vertical_division(self, image: Image.Image) -> Optional[int]: + """ + Try to find a vertical division line in the image + """ + width, height = image.size + pixels = image.load() + + # Look for vertical lines that might be divisions + # Start from expected positions based on square primary icon + candidates = [height, int(height * 0.75), int(height * 1.25)] + + for x in candidates: + if x < 10 or x >= width - 10: + continue + + # Check if this x-coordinate forms a clear division + is_division = True + transparent_count = 0 + + for y in range(0, height, 5): # Sample every 5th pixel + pixel = pixels[x, y] + + # Count transparent pixels + if len(pixel) == 4 and pixel[3] < 10: + transparent_count += 1 + + # If most pixels in this column are transparent, it's likely a division + if transparent_count > height / 10: + return x + + return None + + def extract_icon_from_single_file(self, icon_name: str): + """ + Extract icon from a single file (not a spritesheet) + Just copy it with the icon_ prefix + """ + for prefix in ['', 'hr-']: + input_file = self.icons_path / f"{prefix}{icon_name}.png" + + if not input_file.exists(): + continue + + try: + # Just copy the file with icon_ prefix + output_name = f"{prefix}icon_{icon_name}.png" + if prefix == 'hr-': + output_path = self.output_dir_hr / output_name + else: + output_path = self.output_dir / output_name + + # Copy the image + image = Image.open(input_file).convert('RGBA') + image.save(output_path) + print(f"Saved icon: {output_path} (size: {image.width}x{image.height})") + + except Exception as e: + print(f"Error processing {input_file}: {e}") + + def is_likely_spritesheet(self, file_path: Path) -> bool: + """ + Determine if an icon file is likely a spritesheet based on its dimensions + """ + try: + image = Image.open(file_path) + width, height = image.size + + # A spritesheet would be wider than tall + if width <= height: + return False + + # Check if width follows the pattern: height + height/2 + height/4 + ... + # The minimum spritesheet would be height + height/2 = 1.5 * height + if width >= height * 1.4: + return True + + return False + + except Exception as e: + print(f"Error checking {file_path}: {e}") + return False + + def extract_all_icons(self): + """Extract all icon sprites from the icons directory""" + print("=== Extracting Icon Sprites ===") + + if not self.icons_path.exists(): + print(f"Icons directory not found: {self.icons_path}") + return + + # Find all unique icon names (without hr- prefix and .png extension) + icon_files = {} + + for file_path in self.icons_path.glob("*.png"): + # Skip if it's not a PNG file + if not file_path.suffix.lower() == '.png': + continue + + # Get base name without hr- prefix + base_name = file_path.stem + is_hr = base_name.startswith('hr-') + if is_hr: + base_name = base_name[3:] # Remove 'hr-' prefix + + if base_name not in icon_files: + icon_files[base_name] = {'normal': None, 'hr': None} + + if is_hr: + icon_files[base_name]['hr'] = file_path + else: + icon_files[base_name]['normal'] = file_path + + print(f"Found {len(icon_files)} unique icons to process") + + # Process each icon + for icon_name, files in sorted(icon_files.items()): + print(f"\nProcessing icon: {icon_name}") + + # Check if any version is a spritesheet + is_spritesheet = False + + if files['normal'] and self.is_likely_spritesheet(files['normal']): + is_spritesheet = True + elif files['hr'] and self.is_likely_spritesheet(files['hr']): + is_spritesheet = True + + if is_spritesheet: + print(f" Detected as spritesheet") + self.extract_icon_from_spritesheet(icon_name) + else: + print(f" Detected as single icon") + self.extract_icon_from_single_file(icon_name) + + def create_icon_mappings(self): + """ + Create a mapping file that lists all extracted icons + Useful for referencing icons in the game + """ + mapping = {} + + # Scan output directory for all icon files + for file_path in self.output_dir.glob("icon_*.png"): + icon_name = file_path.stem.replace('icon_', '') + mapping[icon_name] = { + 'normal': str(file_path.name), + 'hr': None + } + + # Check for HR version + hr_path = self.output_dir_hr / f"hr-icon_{icon_name}.png" + if hr_path.exists(): + mapping[icon_name]['hr'] = str(hr_path.name) + + # Save mapping as JSON + import json + mapping_path = self.output_dir / "icon_mapping.json" + with open(mapping_path, 'w') as f: + json.dump(mapping, f, indent=2, sort_keys=True) + + print(f"\nCreated icon mapping: {mapping_path}") + print(f"Total icons extracted: {len(mapping)}") + + def debug_spritesheet_layout(self, icon_name: str): + """ + Debug function to analyze and visualize the layout of a spritesheet + """ + icon_file = self.icons_path / f"{icon_name}.png" + + if not icon_file.exists(): + print(f"Icon file not found: {icon_file}") + return + + try: + image = Image.open(icon_file) + width, height = image.size + + print(f"\nAnalyzing spritesheet: {icon_name}") + print(f" Total dimensions: {width}x{height}") + print(f" Aspect ratio: {width / height:.2f}") + + # Check various possible primary icon widths + print("\nPossible primary icon widths:") + + # Square primary icon + if width >= height: + print(f" - Square icon: {height}x{height}") + remaining = width - height + print(f" Remaining width: {remaining}") + + # Check if remaining follows halving pattern + expected_remaining = height / 2 + height / 4 + height / 8 + print(f" Expected remaining for halving pattern: {expected_remaining:.1f}") + + # Check common sizes + for size in [16, 32, 64, 128, 256]: + if height == size: + print(f" - If primary is {size}x{size}:") + print(f" Remaining: {width - size}") + print(f" Could fit: {(width - size) / (size / 2):.1f} half-size icons") + + except Exception as e: + print(f"Error analyzing {icon_file}: {e}") \ No newline at end of file diff --git a/fle/agents/data/sprites/extractors/resources.py b/fle/agents/data/sprites/extractors/resources.py new file mode 100644 index 000000000..f4f28f18c --- /dev/null +++ b/fle/agents/data/sprites/extractors/resources.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +""" +Extended sprite extractor for Factorio resources and trees +Handles both sprite sheet extraction for resources and layer merging for trees +""" + +import json +import os +import shutil +from pathlib import Path +from PIL import Image +from typing import Dict, List, Optional, Tuple, Any + + +class ResourceSpriteExtractor: + """Extract and process resource sprites and tree layers""" + + def __init__(self, resources_path: str, output_dir: str = "images"): + self.resources_path = Path(resources_path) + self.output_dir = Path(output_dir) + self.output_dir.mkdir(exist_ok=True, parents=True) + + self.output_dir_hr = Path(output_dir+"-hr") + self.output_dir_hr.mkdir(exist_ok=True, parents=True) + + # Resource sprite sheet configurations + self.resource_configs = { + 'coal': {'columns': 8, 'rows': 8, 'width': 64, 'height': 64}, + 'copper-ore': {'columns': 8, 'rows': 8, 'width': 64, 'height': 64}, + 'iron-ore': {'columns': 8, 'rows': 8, 'width': 64, 'height': 64}, + 'stone': {'columns': 8, 'rows': 8, 'width': 64, 'height': 64}, + 'uranium-ore': {'columns': 8, 'rows': 8, 'width': 64, 'height': 64}, + 'crude-oil': {'columns': 4, 'rows': 1, 'width': 74, 'height': 64}, # Oil is different + } + + def extract_resource_sprites(self, resource_name: str): + """Extract individual sprites from a resource sprite sheet""" + resource_dir = self.resources_path / resource_name + + if not resource_dir.exists(): + print(f"Resource directory not found: {resource_dir}") + return + + config = self.resource_configs.get(resource_name, { + 'columns': 8, 'rows': 8, 'width': 64, 'height': 64 + }) + + # Process both normal and high-res versions + for prefix in ['', 'hr-']: + sprite_sheet_path = resource_dir / f"{prefix}{resource_name}.png" + + if not sprite_sheet_path.exists(): + continue + + try: + sprite_sheet = Image.open(sprite_sheet_path).convert('RGBA') + + # Extract each sprite + for row in range(config['rows']): + for col in range(config['columns']): + # Calculate position in sprite sheet + x = col * config['width'] + y = row * config['height'] + + # Extract sprite + sprite = sprite_sheet.crop(( + x, y, + x + config['width'], + y + config['height'] + )) + + # Save with naming convention: resource_variant_volume + # Row 0 = full volume (8), Row 7 = minimal volume (1) + volume = config['rows'] - row + variant = col + 1 + + output_name = f"{prefix}{resource_name}_{variant}_{volume}.png" + + if prefix == 'hr-': + output_path = self.output_dir_hr / output_name + else: + output_path = self.output_dir / output_name + + sprite.save(output_path) + print(f"Saved: {output_path}") + + # Also handle glow sprites for uranium + if resource_name == 'uranium-ore': + glow_path = resource_dir / f"{prefix}uranium-ore-glow.png" + if glow_path.exists(): + glow_sheet = Image.open(glow_path).convert('RGBA') + + for row in range(config['rows']): + for col in range(config['columns']): + x = col * config['width'] + y = row * config['height'] + + glow_sprite = glow_sheet.crop(( + x, y, + x + config['width'], + y + config['height'] + )) + + volume = config['rows'] - row + variant = col + 1 + + output_name = f"{prefix}uranium-ore-glow_{variant}_{volume}.png" + + if prefix == 'hr-': + output_path = self.output_dir_hr / output_name + else: + output_path = self.output_dir / output_name + + glow_sprite.save(output_path) + print(f"Saved glow: {output_path}") + + except Exception as e: + print(f"Error processing {sprite_sheet_path}: {e}") + + def extract_all_resources(self): + """Extract all resource sprites""" + print("=== Extracting Resource Sprites ===") + + # Extract sprite sheets for mineral resources + for resource_name in self.resource_configs.keys(): + print(f"\nProcessing resource: {resource_name}") + self.extract_resource_sprites(resource_name) + + print("\n=== Extracting Tree Sprites ===") + + def create_resource_icon(self, resource_name: str): + """Create a representative icon for each resource type using the first full variant""" + try: + # Use the first variant at full volume as the icon + source_path = self.output_dir / f"{resource_name}_1_8.png" + if source_path.exists(): + icon_path = self.output_dir / f"icon_{resource_name}.png" + shutil.copy2(source_path, icon_path) + print(f"Created icon: {icon_path}") + + # Also create HR version if available + hr_source_path = self.output_dir / f"hr-{resource_name}_1_8.png" + if hr_source_path.exists(): + hr_icon_path = self.output_dir / f"icon_hr-{resource_name}.png" + shutil.copy2(hr_source_path, hr_icon_path) + print(f"Created HR icon: {hr_icon_path}") + + except Exception as e: + print(f"Error creating icon for {resource_name}: {e}") + + def create_all_icons(self): + """Create icons for all resources""" + print("\n=== Creating Resource Icons ===") + for resource_name in self.resource_configs.keys(): + self.create_resource_icon(resource_name) \ No newline at end of file diff --git a/fle/agents/data/sprites/extractors/terrain.py b/fle/agents/data/sprites/extractors/terrain.py new file mode 100644 index 000000000..356e8aaee --- /dev/null +++ b/fle/agents/data/sprites/extractors/terrain.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +""" +Extended sprite extractor for Factorio resources and trees +Handles both sprite sheet extraction for resources and layer merging for trees +""" + +import json +import os +import shutil +from pathlib import Path +from PIL import Image +from typing import Dict, List, Optional, Tuple, Any + + +class TerrainSpriteExtractor: + """Extract and process resource sprites and tree layers""" + + def __init__(self, resources_path: str, output_dir: str = "images"): + self.resources_path = Path(resources_path) + self.output_dir = Path(output_dir) + self.output_dir.mkdir(exist_ok=True, parents=True) + self.output_dir_hr = Path(output_dir+"-hr") + self.output_dir_hr.mkdir(exist_ok=True, parents=True) + + # Resource sprite sheet configurations + self.resource_configs = { + 'water': {'columns': 8, 'rows': 1, 'width': 32, 'height': 32}, + 'water-mud': {'columns': 8, 'rows': 1, 'width': 32, 'height': 32}, + 'water-green': {'columns': 8, 'rows': 1, 'width': 32, 'height': 32}, + 'water-shallow': {'columns': 8, 'rows': 1, 'width': 32, 'height': 32}, + 'deepwater': {'columns': 8, 'rows': 1, 'width': 32, 'height': 32}, + 'cliff-inner': {'columns': 8, 'rows': 2, 'width': 128, 'height': 256}, + 'cliff-outer': {'columns': 8, 'rows': 2, 'width': 128, 'height': 256}, + 'cliff-entrance': {'columns': 4, 'rows': 4, 'width': 128, 'height': 128}, + 'cliff-sides': {'columns': 8, 'rows': 4, 'width': 128, 'height': 128}, + } + + def extract_resource_sprites(self, resource_name: str): + """Extract individual sprites from a resource sprite sheet""" + resource_dir = self.resources_path / resource_name + + if not resource_dir.exists(): + print(f"Resource directory not found: {resource_dir}") + return + + config = self.resource_configs.get(resource_name, { + 'columns': 8, 'rows': 8, 'width': 64, 'height': 64 + }) + + # Process both normal and high-res versions + for prefix in ['', 'hr-']: + sprite_sheet_path = resource_dir / f"{prefix}{resource_name}.png" + + if not sprite_sheet_path.exists(): + sprite_sheet_path = resource_dir / f"{prefix}{resource_name}1.png" + if not sprite_sheet_path.exists(): + continue + + try: + sprite_sheet = Image.open(sprite_sheet_path).convert('RGBA') + + # Extract each sprite + for row in range(config['rows']): + for col in range(config['columns']): + # Calculate position in sprite sheet + x = col * config['width'] + y = row * config['height'] + + # Extract sprite + sprite = sprite_sheet.crop(( + x, y, + x + config['width'], + y + config['height'] + )) + + # Save with naming convention: resource_variant_volume + # Row 0 = full volume (8), Row 7 = minimal volume (1) + volume = config['rows'] - row + variant = col + 1 + + output_name = f"{prefix}{resource_name}_{variant}_{volume}.png" + if prefix == 'hr-': + output_path = self.output_dir_hr / output_name + else: + output_path = self.output_dir / output_name + sprite.save(output_path) + print(f"Saved: {output_path}") + + # Also handle glow sprites for uranium + if resource_name == 'uranium-ore': + glow_path = resource_dir / f"{prefix}uranium-ore-glow.png" + if glow_path.exists(): + glow_sheet = Image.open(glow_path).convert('RGBA') + + for row in range(config['rows']): + for col in range(config['columns']): + x = col * config['width'] + y = row * config['height'] + + glow_sprite = glow_sheet.crop(( + x, y, + x + config['width'], + y + config['height'] + )) + + volume = config['rows'] - row + variant = col + 1 + + output_name = f"{prefix}uranium-ore-glow_{variant}_{volume}.png" + if prefix == 'hr-': + output_path = self.output_dir_hr / output_name + else: + output_path = self.output_dir / output_name + glow_sprite.save(output_path) + print(f"Saved glow: {output_path}") + + except Exception as e: + print(f"Error processing {sprite_sheet_path}: {e}") + + def extract_all_resources(self): + """Extract all resource sprites""" + print("=== Extracting Resource Sprites ===") + + # Extract sprite sheets for mineral resources + for resource_name in self.resource_configs.keys(): + print(f"\nProcessing resource: {resource_name}") + self.extract_resource_sprites(resource_name) + + print("\n=== Extracting Tree Sprites ===") + + def create_resource_icon(self, resource_name: str): + """Create a representative icon for each resource type using the first full variant""" + try: + # Use the first variant at full volume as the icon + source_path = self.output_dir / f"{resource_name}_1_8.png" + if source_path.exists(): + icon_path = self.output_dir / f"icon_{resource_name}.png" + shutil.copy2(source_path, icon_path) + print(f"Created icon: {icon_path}") + + # Also create HR version if available + hr_source_path = self.output_dir / f"hr-{resource_name}_1_8.png" + if hr_source_path.exists(): + hr_icon_path = self.output_dir / f"icon_hr-{resource_name}.png" + shutil.copy2(hr_source_path, hr_icon_path) + print(f"Created HR icon: {hr_icon_path}") + + except Exception as e: + print(f"Error creating icon for {resource_name}: {e}") + + def create_all_icons(self): + """Create icons for all resources""" + print("\n=== Creating Resource Icons ===") + for resource_name in self.resource_configs.keys(): + self.create_resource_icon(resource_name) diff --git a/fle/agents/data/sprites/extractors/trees.py b/fle/agents/data/sprites/extractors/trees.py new file mode 100644 index 000000000..38442dd61 --- /dev/null +++ b/fle/agents/data/sprites/extractors/trees.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +""" +Tree sprite extractor that handles composite sprites with foliage states +and shadow quadriptychs +""" + +import shutil +from pathlib import Path +from typing import Optional + +from PIL import Image + + +class TreeSpriteExtractor: + """Extract tree sprites from composite images showing different foliage states""" + + def __init__(self, resources_path: str, output_dir: str = "images"): + self.resources_path = Path(resources_path) + self.output_dir = Path(output_dir) + self.output_dir.mkdir(exist_ok=True, parents=True) + self.output_dir_hr = Path(output_dir + '-hr') + self.output_dir_hr.mkdir(exist_ok=True, parents=True) + + def extract_tree_states(self, tree_type: str): + """Extract different foliage states from tree composites""" + tree_dir = self.resources_path / 'tree' / tree_type + + if not tree_dir.exists(): + print(f"Tree directory not found: {tree_dir}") + return + + # Group files by variation and resolution + tree_files = {} + + for file_path in tree_dir.glob("*.png"): + filename = file_path.name + + # Parse filename to extract variation and layer type + parts = filename.replace('hr-', '').replace('.png', '').split('-') + + if len(parts) >= 4: + tree_num = parts[1] # 01, 02, etc. + variation = parts[2] # a, b, c, etc. + layer_type = parts[3] # trunk, leaves, shadow, normal, stump + + is_hr = filename.startswith('hr-') + key = (tree_num, variation, is_hr) + + if key not in tree_files: + tree_files[key] = {} + + tree_files[key][layer_type] = file_path + + # Process each tree variation + for (tree_num, variation, is_hr), layers in tree_files.items(): + try: + prefix = 'hr-' if is_hr else '' + + # Process leaves if available + if 'leaves' in layers: + leaves_img = Image.open(layers['leaves']).convert('RGBA') + aspect_ratio = leaves_img.width / leaves_img.height + + # Check if leaves is a triptych (3x wide) + if aspect_ratio > 2: + print(f"Processing triptych leaves for tree {tree_num}-{variation}") + + # Load trunk if available (trunk is always single sprite) + trunk_img = None + if 'trunk' in layers: + trunk_img = Image.open(layers['trunk']).convert('RGBA') + + extraction_successful = self._process_triptych_leaves(leaves_img, trunk_img, tree_num, + variation, prefix) + + # Skip shadows and stumps if extraction failed + if extraction_successful is False: + continue + else: + # Single leaves image + print(f"Processing single leaves for tree {tree_num}-{variation}") + if 'trunk' in layers: + trunk_img = Image.open(layers['trunk']).convert('RGBA') + self._process_single_leaves(leaves_img, trunk_img, tree_num, variation, prefix) + else: + # Just save the leaves as full state + output_name = f"{prefix}tree-{tree_num}-{variation}-full.png" + if prefix == 'hr-': + output_path = self.output_dir_hr / output_name + else: + output_path = self.output_dir / output_name + leaves_img.save(output_path) + print(f"Saved tree state: {output_path}") + + elif 'trunk' in layers: + # Trunk only, no leaves + trunk_img = Image.open(layers['trunk']).convert('RGBA') + output_name = f"{prefix}tree-{tree_num}-{variation}-trunk_only.png" + output_path = self.output_dir / output_name + trunk_img.save(output_path) + print(f"Saved trunk-only tree: {output_path}") + + # Extract shadow quadriptych if available + if 'shadow' in layers: + self._extract_shadow_states(layers['shadow'], tree_num, variation, prefix) + + # Save stump if available + if 'stump' in layers: + stump = Image.open(layers['stump']).convert('RGBA') + output_name = f"{prefix}tree-{tree_num}-{variation}-stump.png" + if prefix == 'hr-': + output_path = self.output_dir_hr / output_name + else: + output_path = self.output_dir / output_name + stump.save(output_path) + print(f"Saved stump: {output_path}") + + except Exception as e: + print(f"Error processing tree {tree_num}-{variation}: {e}") + + def _process_triptych_leaves(self, leaves_triptych: Image.Image, trunk_img: Optional[Image.Image], + tree_num: str, variation: str, prefix: str): + """Process leaves that are in triptych format""" + try: + # Calculate dimensions for individual sprites + triptych_width = leaves_triptych.width + triptych_height = leaves_triptych.height + sprite_width = triptych_width // 3 + sprite_height = triptych_height + + print(f" Triptych dimensions: {triptych_width}x{triptych_height}") + print(f" Individual sprite dimensions: {sprite_width}x{sprite_height}") + + # Extract each foliage state + foliage_states = ['full', 'medium', 'minimal'] + + # Determine final composite dimensions based on trunk and single sprite + if trunk_img: + composite_width = max(trunk_img.width, sprite_width) + composite_height = max(trunk_img.height, sprite_height) + else: + composite_width = sprite_width + composite_height = sprite_height + + # Store results temporarily + temp_results = {} + extraction_failed = False + + for i, state in enumerate(foliage_states): + # Calculate region boundaries + left = i * sprite_width + upper = 0 + right = left + sprite_width + lower = sprite_height + + print(f" Extracting {state} from region: ({left}, {upper}, {right}, {lower})") + + # Use crop with explicit box tuple + box = (left, upper, right, lower) + sprite_region = leaves_triptych.crop(box) + + # Force a copy to ensure we have a separate image + sprite_img = sprite_region.copy() + + # Verify the extraction worked + if sprite_img.width == triptych_width: + print(f" ERROR: Sprite still has triptych width! Trying alternative method...") + # Alternative: Create new image and paste the region + sprite_img = Image.new('RGBA', (sprite_width, sprite_height), (0, 0, 0, 0)) + # Use the region as a paste source + sprite_img.paste(leaves_triptych, (-left, 0)) + + print(f" Extracted {state} sprite: {sprite_img.width}x{sprite_img.height}") + + # Double-check the extraction really worked + if sprite_img.width >= triptych_width * 0.9: # Allow 10% margin + print( + f" CRITICAL ERROR: {state} sprite width {sprite_img.width} is too close to triptych width {triptych_width}") + extraction_failed = True + break + + if trunk_img: + # Create final composite + final_composite = Image.new('RGBA', (composite_width, composite_height), (0, 0, 0, 0)) + + # Center trunk + trunk_x = (composite_width - trunk_img.width) // 2 + trunk_y = (composite_height - trunk_img.height) // 2 + final_composite.paste(trunk_img, (trunk_x, trunk_y), trunk_img) + + # Center foliage on top + foliage_x = (composite_width - sprite_width) // 2 + foliage_y = (composite_height - sprite_height) // 2 + final_composite.paste(sprite_img, (foliage_x, foliage_y), sprite_img) + + result = final_composite + else: + result = sprite_img + + # Store temporarily + output_name = f"{prefix}tree-{tree_num}-{variation}-{state}.png" + temp_results[state] = (output_name, result) + + # Final check on the composite + if result.width >= triptych_width * 0.9: + print( + f" CRITICAL ERROR: Final composite for {state} has width {result.width}, too close to triptych width {triptych_width}") + extraction_failed = True + break + + # Only save if extraction was successful + if not extraction_failed: + print(f" All extractions successful, saving files...") + + # Save all sprites + for state, (output_name, img) in temp_results.items(): + if prefix == 'hr-': + output_path = self.output_dir_hr / output_name + else: + output_path = self.output_dir / output_name + img.save(output_path) + print(f" Saved tree state: {output_path} (size: {img.width}x{img.height})") + + # Add trunk-only state if we have trunk + if trunk_img: + # Create image with same dimensions as other sprites + trunk_only = Image.new('RGBA', (composite_width, composite_height), (0, 0, 0, 0)) + trunk_x = (composite_width - trunk_img.width) // 2 + trunk_y = (composite_height - trunk_img.height) // 2 + trunk_only.paste(trunk_img, (trunk_x, trunk_y), trunk_img) + + output_name = f"{prefix}tree-{tree_num}-{variation}-trunk_only.png" + if prefix == 'hr-': + output_path = self.output_dir_hr / output_name + else: + output_path = self.output_dir / output_name + trunk_only.save(output_path) + print(f" Saved trunk-only state: {output_path} (size: {trunk_only.width}x{trunk_only.height})") + else: + print(f" WARNING: Skipping tree {tree_num}-{variation} due to extraction failure") + # Don't process shadows or stumps for failed extractions + return + + except Exception as e: + print(f"Error processing triptych leaves: {e}") + import traceback + traceback.print_exc() + return + + def _process_single_leaves(self, leaves_img: Image.Image, trunk_img: Image.Image, + tree_num: str, variation: str, prefix: str): + """Process single leaves image with trunk""" + try: + # Create full state (trunk + leaves) + width = max(trunk_img.width, leaves_img.width) + height = max(trunk_img.height, leaves_img.height) + + full_state = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + + # Center trunk + trunk_x = (width - trunk_img.width) // 2 + trunk_y = (height - trunk_img.height) // 2 + full_state.paste(trunk_img, (trunk_x, trunk_y), trunk_img) + + # Center leaves + leaves_x = (width - leaves_img.width) // 2 + leaves_y = (height - leaves_img.height) // 2 + full_state.paste(leaves_img, (leaves_x, leaves_y), leaves_img) + + output_name = f"{prefix}tree-{tree_num}-{variation}-full.png" + if prefix == 'hr-': + output_path = self.output_dir_hr / output_name + else: + output_path = self.output_dir / output_name + full_state.save(output_path) + print(f" Saved tree state: {output_path}") + + # Create trunk-only state + trunk_only = Image.new('RGBA', (width, height), (0, 0, 0, 0)) + trunk_only.paste(trunk_img, (trunk_x, trunk_y), trunk_img) + + output_name = f"{prefix}tree-{tree_num}-{variation}-trunk_only.png" + if prefix == 'hr-': + output_path = self.output_dir_hr / output_name + else: + output_path = self.output_dir / output_name + trunk_only.save(output_path) + print(f" Saved trunk-only state: {output_path}") + + except Exception as e: + print(f"Error processing single leaves: {e}") + + def _extract_shadow_states(self, shadow_path: Path, tree_num: str, variation: str, prefix: str): + """Extract shadow states from quadriptych (4 states, rotated 90° clockwise)""" + try: + shadow_quad = Image.open(shadow_path).convert('RGBA') + + quad_width = shadow_quad.width + quad_height = shadow_quad.height + shadow_width = quad_width // 4 + shadow_height = quad_height + + shadow_states = ['full', 'medium', 'minimal', 'trunk_only'] + + for i, state in enumerate(shadow_states): + # Extract shadow state + x_offset = i * shadow_width + shadow = shadow_quad.crop((x_offset, 0, x_offset + shadow_width, shadow_height)) + + output_name = f"{prefix}tree-{tree_num}-{variation}-{state}-shadow.png" + + if prefix == 'hr-': + output_path = self.output_dir_hr / output_name + else: + output_path = self.output_dir / output_name + + shadow.save(output_path) + print(f" Saved shadow state: {output_path}") + + except Exception as e: + print(f"Error extracting shadow states: {e}") + + def extract_all_trees(self): + """Extract all tree types""" + print("=== Extracting Tree Sprites ===") + + tree_dir = self.resources_path / 'tree' + if tree_dir.exists(): + for tree_type_dir in tree_dir.iterdir(): + if tree_type_dir.is_dir(): + tree_type = tree_type_dir.name + print(f"\nProcessing tree type: {tree_type}") + + # Dead trees are handled differently + if 'dead' in tree_type or 'dry' in tree_type: + self._extract_dead_trees(tree_type) + else: + self.extract_tree_states(tree_type) + + def _extract_dead_trees(self, tree_type: str): + """Extract dead tree sprites (these are typically single files)""" + tree_dir = self.resources_path / 'tree' / tree_type + + if not tree_dir.exists(): + print(f"Dead tree directory not found: {tree_dir}") + return + + # Dead trees are usually single sprites, just copy them + for file_path in tree_dir.glob("*.png"): + try: + if 'hr-' in file_path.name: + output_path = self.output_dir_hr / file_path.name + else: + output_path = self.output_dir / file_path.name + shutil.copy2(file_path, output_path) + print(f" Copied dead tree: {output_path}") + except Exception as e: + print(f"Error processing {file_path}: {e}") \ No newline at end of file diff --git a/fle/agents/data/sprites/generate_sprites.py b/fle/agents/data/sprites/generate_sprites.py new file mode 100644 index 000000000..20e60f631 --- /dev/null +++ b/fle/agents/data/sprites/generate_sprites.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +import os +import sys +from pathlib import Path + +from fle.agents.data.sprites.extractors.character import CharacterSpriteExtractor +from fle.agents.data.sprites.extractors.decoratives import DecorativeSpriteExtractor +from fle.agents.data.sprites.extractors.icons import IconSpriteExtractor +from fle.agents.data.sprites.extractors.alerts import AlertSpriteExtractor + +# Add the parent directory to Python path so imports work +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from extractors.entities import EntitySpritesheetExtractor +from extractors.resources import ResourceSpriteExtractor +from extractors.terrain import TerrainSpriteExtractor +from extractors.trees import TreeSpriteExtractor + + +def main(): + """Main entry point""" + # Use relative paths or environment variables + base = Path.cwd() # Current working directory + + # Check if we're in the right directory structure + if base.name == 'sprites' or base.name == 'data': + project_root = base.parent.parent.parent.parent.parent + if base.name == 'sprites': + project_root = base.parent.parent.parent.parent + else: + project_root = base.parent.parent.parent.parent.parent + + # Set up paths relative to project root + base_input_path = project_root / ".fle" / "spritemaps" / "__base__" / "graphics" + resources_path = base_input_path / "resources" + terrain_path = base_input_path / "terrain" + decoratives_path = base_input_path / "decorative" + icons_path = base_input_path / "icons" + alerts_path = icons_path / "alerts" + + entities_path = project_root / ".fle" / "spritemaps" + output_dir = project_root / ".fle" / "sprites" + + # Create output directory if it doesn't exist + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"Project root: {project_root}") + print(f"Input path: {entities_path}") + print(f"Output path: {output_dir}") + + # Check if input directories exist + if not entities_path.exists(): + print(f"Error: Input directory does not exist: {entities_path}") + print("Run 'fle sprites download' first to download the spritemaps.") + return + + # Extract icons + if icons_path.exists(): + print("\n=== Extracting Icon Sprites ===") + icon = IconSpriteExtractor(str(icons_path), str(output_dir)) + icon.extract_all_icons() + else: + print(f"Warning: Icon path not found: {icons_path}") + + # Extract alerts + if alerts_path.exists(): + print("\n=== Extracting Alert Sprites ===") + alerts = AlertSpriteExtractor(str(alerts_path), str(output_dir)) + alerts.extract_all_alerts() + else: + print(f"Warning: Alerts path not found: {alerts_path}") + + # Extract decoratives + if decoratives_path.exists(): + print("\n=== Extracting Decorative Sprites ===") + decoratives = DecorativeSpriteExtractor(str(decoratives_path), str(output_dir)) + decoratives.extract_all_decoratives() + else: + print(f"Warning: Decoratives path not found: {decoratives_path}") + + # Extract entities + if (entities_path / "data.json").exists(): + print("\n=== Extracting Entity Sprites ===") + entities = EntitySpritesheetExtractor(str(entities_path), str(output_dir)) + entities.extract_all() + else: + print("Warning: data.json not found, skipping entity extraction") + + # Extract resources + if resources_path.exists(): + print("\n=== Extracting Resource Sprites ===") + resources = ResourceSpriteExtractor(str(resources_path), str(output_dir)) + resources.extract_all_resources() + resources.create_all_icons() + else: + print(f"Warning: Resources path not found: {resources_path}") + + # Extract trees + if resources_path.exists(): + print("\n=== Extracting Tree Sprites ===") + trees = TreeSpriteExtractor(str(resources_path), str(output_dir)) + trees.extract_all_trees() + + # Extract terrain + if terrain_path.exists(): + print("\n=== Extracting Terrain Sprites ===") + terrain = TerrainSpriteExtractor(str(terrain_path), str(output_dir)) + terrain.extract_all_resources() + terrain.create_all_icons() + else: + print(f"Warning: Terrain path not found: {terrain_path}") + + character_path = base_input_path.parent / "character" # Assuming character folder is at same level as __base__ + if character_path.exists(): + print("\n=== Extracting Character Sprites ===") + character = CharacterSpriteExtractor(str(character_path), str(output_dir)) + character.extract_all_character_sprites() + character.extract_single_sprites() + character.create_character_mapping() + else: + print(f"Warning: Character path not found: {character_path}") + + print("\n=== Extraction Complete ===") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/fle/agents/data/sprites/run.py b/fle/agents/data/sprites/run.py new file mode 100644 index 000000000..4f86c7a17 --- /dev/null +++ b/fle/agents/data/sprites/run.py @@ -0,0 +1,138 @@ +import json +from pathlib import Path + +from fle.env.tools.admin.render.renderer import load_game_data, ImageResolver, parse_blueprint, Renderer + + +def render_blueprints_from_directory(blueprints_dir: str, output_dir: str = None, show_images: bool = True): + """ + Render all JSON blueprint files from a directory + + Args: + blueprints_dir: Directory containing JSON blueprint files + output_dir: Optional directory to save rendered images (default: blueprints_dir/rendered) + show_images: Whether to display images on screen (default: True) + """ + blueprints_path = Path(blueprints_dir) + + if not blueprints_path.exists(): + print(f"Error: Blueprint directory not found: {blueprints_dir}") + return + + # Set up output directory + if output_dir is None: + output_path = blueprints_path / "rendered" + else: + output_path = Path(output_dir) + + output_path.mkdir(exist_ok=True) + + # Set up paths for resources + sprites_dir = Path(".fle/sprites") + + # Try to import the enhanced resolver + try: + from basis_image_resolver import BasisImageResolver + print("Using BasisImageResolver for .basis file support") + #base = "/Users/jackhopkins/PycharmProjects/PaperclipMaximiser/data/rendering" + # Load game data + #game_data, game_recipes = load_game_data(f"{base}/data.json") + image_resolver = ImageResolver(".fle/sprites") + except ImportError: + print("BasisImageResolver not found, using simple PNG resolver") + print("Place PNG files in 'images' directory") + # Fallback to simple resolver + #game_data, game_recipes = load_game_data("data.json") + image_resolver = ImageResolver(str(sprites_dir)) + + # Find all JSON files + json_files = list(blueprints_path.glob("*.json")) + + if not json_files: + print(f"No JSON files found in {blueprints_dir}") + return + + print(f"Found {len(json_files)} blueprint files to render") + + # Process each blueprint + successful = 0 + failed = 0 + + for json_file in json_files[:3]: + try: + print(f"\nProcessing: {json_file.name}") + + # Load the JSON file + with open(json_file, 'r') as f: + blueprint_data = json.load(f) + + # Handle different blueprint formats + if 'blueprint' in blueprint_data: + blueprint_content = blueprint_data['blueprint'] + elif 'entities' in blueprint_data: + blueprint_content = blueprint_data + else: + # Try to parse as blueprint string + if isinstance(blueprint_data, str): + parsed = parse_blueprint(blueprint_data) + blueprint_content = parsed.get('blueprint', parsed) + else: + print(f" Warning: Unknown blueprint format in {json_file.name}") + failed += 1 + continue + + # Create blueprint object + blueprint = Renderer(sprites_dir, entities=blueprint_content["entities"],) + + # Calculate render size + size = blueprint.get_size() + if size['width'] == 0 or size['height'] == 0: + print(f" Warning: Blueprint has no entities to render") + failed += 1 + continue + + scaling = 32 + width = min((size['width'] + 2) * scaling, 2048) # Cap max width + height = min((size['height'] + 2) * scaling, 2048) # Cap max height + + # Render the blueprint + image = blueprint.render(width, height, image_resolver) + + # Save the image + output_filename = json_file.stem + ".png" + output_file = output_path / output_filename + image.save(output_file) + print(f" Rendered to: {output_file} ({width}x{height})") + + # Display the image if requested + if show_images: + image.show() + + successful += 1 + + except Exception as e: + print(f" Error processing {json_file.name}: {str(e)}") + failed += 1 + + print(f"\n=== Summary ===") + print(f"Successfully rendered: {successful}") + print(f"Failed: {failed}") + print(f"Output directory: {output_path}") + + +def main(): + """Main function to render all blueprints in the specified directory""" + blueprints_dir = "/Users/jackhopkins/PycharmProjects/PaperclipMaximiser/fle/agents/data/blueprints_to_policies/blueprints/other" + + + # Render all blueprints + # Set show_images=False if you don't want them to pop up on screen + render_blueprints_from_directory( + blueprints_dir=blueprints_dir, + output_dir=None, # Will create 'rendered' subdirectory + show_images=True # Set to False to just save without displaying + ) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/fle/agents/data/sprites/sample_blueprint.json b/fle/agents/data/sprites/sample_blueprint.json new file mode 100644 index 000000000..590072b85 --- /dev/null +++ b/fle/agents/data/sprites/sample_blueprint.json @@ -0,0 +1 @@ +{"entities": [{"name": "tree-01", "position": {"x": -5.29296875, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": -4.390625, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": -3.48828125, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": -2.5859375, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": -1.68359375, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": -0.78125, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 0.12109375, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 1.0234375, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 1.92578125, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 2.828125, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 3.73046875, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 4.6328125, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 5.53515625, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 6.4375, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 7.33984375, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 8.2421875, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 9.14453125, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 10.046875, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 10.94921875, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 11.8515625, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 12.75390625, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 13.65625, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 14.55859375, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 15.4609375, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 16.36328125, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 17.265625, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 18.16796875, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 19.0703125, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 19.97265625, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 20.875, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 21.77734375, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 22.6796875, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 23.58203125, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 24.484375, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 25.38671875, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 26.2890625, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 27.19140625, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 28.09375, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 28.99609375, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 29.8984375, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 30.80078125, "y": -18.68359375}, "direction": 0}, {"name": "tree-01", "position": {"x": 31.63671875, "y": -18.58203125}, "direction": 0}, {"name": "tree-01", "position": {"x": 34.34375, "y": -18.58203125}, "direction": 0}, {"name": "tree-01", "position": {"x": 32.5390625, "y": -18.58203125}, "direction": 0}, {"name": "tree-01", "position": {"x": 33.44140625, "y": -18.58203125}, "direction": 0}, {"name": "small-electric-pole", "position": {"x": 0.5, "y": -9.5}, "direction": 0}, {"name": "small-electric-pole", "position": {"x": 4.5, "y": -9.5}, "direction": 0}, {"name": "small-electric-pole", "position": {"x": 8.5, "y": -9.5}, "direction": 0}, {"name": "small-electric-pole", "position": {"x": 12.5, "y": -9.5}, "direction": 0}, {"name": "character", "position": {"x": 14.5, "y": -7.5}, "direction": 0}, {"name": "coal", "position": {"x": 15.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 15.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 16.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 17.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 16.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 17.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 18.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 19.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 18.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 19.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 20.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 21.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 20.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 21.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 22.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 23.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 22.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 23.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 24.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 25.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 24.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 25.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 26.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 27.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 26.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 27.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 28.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 29.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 28.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 29.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 30.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 31.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 30.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 31.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 32.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 33.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 32.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 33.5, "y": -3.5}, "direction": 0}, {"name": "coal", "position": {"x": 34.5, "y": -2.5}, "direction": 0}, {"name": "coal", "position": {"x": 34.5, "y": -3.5}, "direction": 0}, {"name": "transport-belt", "position": {"x": 1.5, "y": -1.5}, "direction": 2}, {"name": "transport-belt", "position": {"x": 0.5, "y": -1.5}, "direction": 2}, {"name": "transport-belt", "position": {"x": 3.5, "y": -0.5}, "direction": 4}, {"name": "transport-belt", "position": {"x": 2.5, "y": -0.5}, "direction": 2}, {"name": "transport-belt", "position": {"x": 2.5, "y": -1.5}, "direction": 4}, {"name": "coal", "position": {"x": 15.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 15.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 17.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 16.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 17.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 16.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 19.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 18.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 19.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 18.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 21.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 20.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 21.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 20.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 23.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 22.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 23.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 22.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 25.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 24.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 25.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 24.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 27.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 26.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 27.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 26.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 29.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 28.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 29.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 28.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 31.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 30.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 31.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 30.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 33.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 32.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 33.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 32.5, "y": -0.5}, "direction": 0}, {"name": "coal", "position": {"x": 34.5, "y": -1.5}, "direction": 0}, {"name": "coal", "position": {"x": 34.5, "y": -0.5}, "direction": 0}, {"name": "iron-chest", "position": {"x": 0.5, "y": 0.5}, "direction": 0}, {"name": "transport-belt", "position": {"x": 3.5, "y": 0.5}, "direction": 2}, {"name": "transport-belt", "position": {"x": 5.5, "y": 1.5}, "direction": 4}, {"name": "transport-belt", "position": {"x": 4.5, "y": 1.5}, "direction": 2}, {"name": "transport-belt", "position": {"x": 4.5, "y": 0.5}, "direction": 4}, {"name": "coal", "position": {"x": 15.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 15.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 16.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 17.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 17.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 16.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 18.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 19.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 19.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 18.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 20.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 21.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 20.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 21.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 22.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 23.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 22.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 23.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 24.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 25.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 24.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 25.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 26.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 27.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 26.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 27.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 28.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 29.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 28.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 29.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 30.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 31.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 30.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 31.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 32.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 33.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 32.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 33.5, "y": 1.5}, "direction": 0}, {"name": "coal", "position": {"x": 34.5, "y": 0.5}, "direction": 0}, {"name": "coal", "position": {"x": 34.5, "y": 1.5}, "direction": 0}, {"name": "transport-belt", "position": {"x": 0.5, "y": 2.5}, "direction": 0}, {"name": "transport-belt", "position": {"x": 0.5, "y": 3.5}, "direction": 0}, {"name": "transport-belt", "position": {"x": 1.5, "y": 3.5}, "direction": 6}, {"name": "transport-belt", "position": {"x": 5.5, "y": 2.5}, "direction": 2}, {"name": "transport-belt", "position": {"x": 7.5, "y": 3.5}, "direction": 4}, {"name": "transport-belt", "position": {"x": 6.5, "y": 3.5}, "direction": 2}, {"name": "transport-belt", "position": {"x": 6.5, "y": 2.5}, "direction": 4}, {"name": "coal", "position": {"x": 15.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 15.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 17.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 17.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 16.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 16.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 18.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 19.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 18.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 19.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 20.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 21.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 20.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 21.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 23.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 22.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 22.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 23.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 25.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 24.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 24.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 25.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 27.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 26.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 26.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 27.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 29.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 29.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 28.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 28.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 30.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 31.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 31.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 30.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 32.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 32.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 33.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 33.5, "y": 3.5}, "direction": 0}, {"name": "coal", "position": {"x": 34.5, "y": 2.5}, "direction": 0}, {"name": "coal", "position": {"x": 34.5, "y": 3.5}, "direction": 0}, {"name": "transport-belt", "position": {"x": 1.5, "y": 4.5}, "direction": 0}, {"name": "transport-belt", "position": {"x": 2.5, "y": 4.5}, "direction": 6}, {"name": "transport-belt", "position": {"x": 2.5, "y": 5.5}, "direction": 0}, {"name": "transport-belt", "position": {"x": 3.5, "y": 5.5}, "direction": 6}, {"name": "transport-belt", "position": {"x": 7.5, "y": 4.5}, "direction": 2}, {"name": "underground-belt", "position": {"x": 9.5, "y": 5.5}, "direction": 2, "type": "input"}, {"name": "transport-belt", "position": {"x": 8.5, "y": 5.5}, "direction": 2}, {"name": "transport-belt", "position": {"x": 8.5, "y": 4.5}, "direction": 4}, {"name": "underground-belt", "position": {"x": 13.5, "y": 5.5}, "direction": 2, "type": "output"}, {"name": "transport-belt", "position": {"x": 14.5, "y": 5.5}, "direction": 2}, {"name": "transport-belt", "position": {"x": 15.5, "y": 5.5}, "direction": 2}, {"name": "coal", "position": {"x": 15.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 15.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 17.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 17.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 16.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 16.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 19.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 19.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 18.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 18.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 20.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 20.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 21.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 21.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 23.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 23.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 22.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 22.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 25.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 24.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 24.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 25.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 27.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 27.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 26.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 26.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 28.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 29.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 29.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 28.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 30.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 30.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 31.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 31.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 32.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 32.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 33.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 33.5, "y": 5.5}, "direction": 0}, {"name": "coal", "position": {"x": 34.5, "y": 4.5}, "direction": 0}, {"name": "coal", "position": {"x": 34.5, "y": 5.5}, "direction": 0}, {"name": "transport-belt", "position": {"x": 3.5, "y": 6.5}, "direction": 0}, {"name": "transport-belt", "position": {"x": 4.5, "y": 6.5}, "direction": 6}, {"name": "transport-belt", "position": {"x": 4.5, "y": 7.5}, "direction": 6}, {"name": "underground-belt", "position": {"x": 5.5, "y": 7.5}, "direction": 6, "type": "output"}, {"name": "underground-belt", "position": {"x": 7.5, "y": 7.5}, "direction": 6, "type": "input"}, {"name": "underground-belt", "position": {"x": 8.5, "y": 7.5}, "direction": 6, "type": "output"}, {"name": "underground-belt", "position": {"x": 12.5, "y": 7.5}, "direction": 6, "type": "input"}, {"name": "transport-belt", "position": {"x": 13.5, "y": 7.5}, "direction": 6}, {"name": "coal", "position": {"x": 15.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 15.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 17.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 17.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 16.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 16.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 19.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 19.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 18.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 18.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 20.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 21.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 20.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 21.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 23.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 23.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 22.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 22.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 24.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 24.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 25.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 25.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 27.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 27.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 26.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 26.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 29.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 29.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 28.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 28.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 30.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 30.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 31.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 31.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 32.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 32.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 33.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 33.5, "y": 7.5}, "direction": 0}, {"name": "coal", "position": {"x": 34.5, "y": 6.5}, "direction": 0}, {"name": "coal", "position": {"x": 34.5, "y": 7.5}, "direction": 0}, {"name": "transport-belt", "position": {"x": 13.5, "y": 8.5}, "direction": 0}, {"name": "transport-belt", "position": {"x": 14.5, "y": 8.5}, "direction": 6}, {"name": "transport-belt", "position": {"x": 14.5, "y": 9.5}, "direction": 0}, {"name": "transport-belt", "position": {"x": 15.5, "y": 9.5}, "direction": 6}, {"name": "coal", "position": {"x": 15.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 15.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 17.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 17.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 16.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 16.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 19.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 19.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 18.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 18.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 20.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 20.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 21.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 21.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 22.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 22.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 23.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 23.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 24.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 24.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 25.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 25.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 27.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 26.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 26.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 27.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 29.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 28.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 28.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 29.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 30.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 30.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 31.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 31.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 32.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 32.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 33.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 33.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 34.5, "y": 8.5}, "direction": 0}, {"name": "coal", "position": {"x": 34.5, "y": 9.5}, "direction": 0}, {"name": "coal", "position": {"x": 15.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 15.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 17.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 17.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 16.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 16.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 18.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 18.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 19.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 19.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 20.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 20.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 21.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 21.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 23.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 22.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 22.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 23.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 25.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 25.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 24.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 24.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 27.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 27.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 26.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 26.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 29.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 29.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 28.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 28.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 30.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 30.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 31.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 31.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 32.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 32.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 33.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 33.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 34.5, "y": 10.5}, "direction": 0}, {"name": "coal", "position": {"x": 34.5, "y": 11.5}, "direction": 0}, {"name": "coal", "position": {"x": 15.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 17.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 16.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 18.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 19.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 20.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 21.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 22.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 23.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 25.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 24.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 27.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 26.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 29.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 28.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 30.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 31.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 32.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 33.5, "y": 12.5}, "direction": 0}, {"name": "coal", "position": {"x": 34.5, "y": 12.5}, "direction": 0}], "water_tiles": [], "resources": [{"name": "coal", "position": {"x": 15.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 15.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 16.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 17.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 16.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 17.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 18.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 19.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 18.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 19.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 20.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 21.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 20.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 21.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 22.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 23.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 22.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 23.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 24.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 25.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 24.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 25.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 26.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 27.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 26.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 27.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 28.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 29.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 28.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 29.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 30.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 31.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 30.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 31.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 32.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 33.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 32.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 33.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 34.5, "y": -2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 34.5, "y": -3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 15.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 15.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 17.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 16.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 17.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 16.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 19.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 18.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 19.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 18.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 21.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 20.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 21.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 20.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 23.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 22.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 23.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 22.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 25.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 24.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 25.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 24.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 27.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 26.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 27.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 26.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 29.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 28.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 29.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 28.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 31.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 30.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 31.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 30.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 33.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 32.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 33.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 32.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 34.5, "y": -1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 34.5, "y": -0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 15.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 15.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 16.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 17.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 17.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 16.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 18.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 19.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 19.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 18.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 20.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 21.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 20.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 21.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 22.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 23.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 22.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 23.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 24.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 25.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 24.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 25.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 26.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 27.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 26.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 27.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 28.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 29.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 28.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 29.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 30.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 31.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 30.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 31.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 32.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 33.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 32.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 33.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 34.5, "y": 0.5}, "amount": 10000}, {"name": "coal", "position": {"x": 34.5, "y": 1.5}, "amount": 10000}, {"name": "coal", "position": {"x": 15.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 15.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 17.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 17.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 16.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 16.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 18.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 19.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 18.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 19.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 20.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 21.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 20.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 21.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 23.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 22.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 22.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 23.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 25.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 24.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 24.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 25.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 27.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 26.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 26.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 27.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 29.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 29.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 28.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 28.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 30.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 31.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 31.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 30.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 32.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 32.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 33.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 33.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 34.5, "y": 2.5}, "amount": 10000}, {"name": "coal", "position": {"x": 34.5, "y": 3.5}, "amount": 10000}, {"name": "coal", "position": {"x": 15.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 15.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 17.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 17.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 16.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 16.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 19.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 19.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 18.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 18.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 20.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 20.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 21.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 21.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 23.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 23.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 22.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 22.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 25.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 24.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 24.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 25.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 27.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 27.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 26.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 26.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 28.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 29.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 29.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 28.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 30.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 30.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 31.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 31.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 32.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 32.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 33.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 33.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 34.5, "y": 4.5}, "amount": 10000}, {"name": "coal", "position": {"x": 34.5, "y": 5.5}, "amount": 10000}, {"name": "coal", "position": {"x": 15.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 15.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 17.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 17.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 16.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 16.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 19.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 19.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 18.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 18.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 20.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 21.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 20.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 21.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 23.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 23.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 22.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 22.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 24.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 24.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 25.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 25.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 27.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 27.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 26.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 26.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 29.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 29.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 28.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 28.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 30.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 30.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 31.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 31.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 32.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 32.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 33.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 33.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 34.5, "y": 6.5}, "amount": 10000}, {"name": "coal", "position": {"x": 34.5, "y": 7.5}, "amount": 10000}, {"name": "coal", "position": {"x": 15.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 15.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 17.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 17.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 16.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 16.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 19.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 19.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 18.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 18.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 20.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 20.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 21.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 21.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 22.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 22.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 23.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 23.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 24.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 24.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 25.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 25.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 27.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 26.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 26.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 27.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 29.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 28.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 28.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 29.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 30.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 30.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 31.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 31.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 32.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 32.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 33.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 33.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 34.5, "y": 8.5}, "amount": 10000}, {"name": "coal", "position": {"x": 34.5, "y": 9.5}, "amount": 10000}, {"name": "coal", "position": {"x": 15.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 15.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 17.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 17.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 16.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 16.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 18.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 18.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 19.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 19.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 20.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 20.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 21.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 21.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 23.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 22.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 22.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 23.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 25.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 25.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 24.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 24.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 27.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 27.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 26.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 26.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 29.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 29.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 28.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 28.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 30.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 30.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 31.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 31.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 32.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 32.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 33.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 33.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 34.5, "y": 10.5}, "amount": 10000}, {"name": "coal", "position": {"x": 34.5, "y": 11.5}, "amount": 10000}, {"name": "coal", "position": {"x": 15.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 17.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 16.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 18.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 19.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 20.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 21.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 22.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 23.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 25.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 24.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 27.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 26.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 29.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 28.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 30.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 31.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 32.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 33.5, "y": 12.5}, "amount": 10000}, {"name": "coal", "position": {"x": 34.5, "y": 12.5}, "amount": 10000}]} \ No newline at end of file diff --git a/fle/agents/data/sprites/sample_blueprint.txt b/fle/agents/data/sprites/sample_blueprint.txt new file mode 100644 index 000000000..e69de29bb diff --git a/fle/agents/data/sprites/upload.py b/fle/agents/data/sprites/upload.py new file mode 100644 index 000000000..043a4909a --- /dev/null +++ b/fle/agents/data/sprites/upload.py @@ -0,0 +1,10 @@ +import os + +from huggingface_hub import HfApi + +api = HfApi(token=os.getenv("HF_TOKEN")) +api.upload_large_folder( + folder_path="/Users/jackhopkins/PycharmProjects/PaperclipMaximiser/.fle/spritemaps", + repo_id="Noddybear/fle_images", + repo_type="dataset", +) diff --git a/fle/agents/llm/api_factory.py b/fle/agents/llm/api_factory.py index b9d2b9a82..9a6df3f49 100644 --- a/fle/agents/llm/api_factory.py +++ b/fle/agents/llm/api_factory.py @@ -255,7 +255,7 @@ async def acall(self, *args, **kwargs): stream=False, ) - elif "o1-mini" in model_to_use or "o3-mini" in model_to_use: + elif "o1-mini" in model_to_use or "o3-mini" in model_to_use or "o3" in model_to_use: if has_images: raise ValueError( "Claude o1-mini and o3-mini models do not support image inputs." @@ -281,6 +281,8 @@ async def acall(self, *args, **kwargs): model = "o3-mini" elif "o1-mini" in model: model = "o1-mini" + elif "o3" in model: + model = "o3-2025-04-16" response = await client.chat.completions.create( *args, diff --git a/fle/cluster/local/create_docker_compose_config.py b/fle/cluster/local/create_docker_compose_config.py index 2a85f4af7..3b868edb6 100644 --- a/fle/cluster/local/create_docker_compose_config.py +++ b/fle/cluster/local/create_docker_compose_config.py @@ -36,16 +36,16 @@ def generate_compose_config(num_instances: int, map: str) -> Dict[str, Any]: "source": "../scenarios/open_world", "target": "/opt/factorio/scenarios/open_world", }, - { - "type": "bind", - "source": "~/Applications/Factorio.app/Contents/Resources/mods", - "target": "/opt/factorio/mods", - }, - { - "type": "bind", - "source": "../../data/_screenshots", - "target": "/opt/factorio/script-output", - }, + # { + # "type": "bind", + # "source": "~/Applications/Factorio.app/Contents/Resources/mods", + # "target": "/opt/factorio/mods", + # }, + # { + # "type": "bind", + # "source": "../../data/_screenshots", + # "target": "/opt/factorio/script-output", + # }, ], "ports": [ f"{base_udp_port + i}:{base_udp_port}/udp", @@ -96,7 +96,7 @@ def setup_docker_compose(num_instances: int, map: str): if __name__ == "__main__": - num_instances = 33 + num_instances = 8 map = "open_world" # or default_lab_scenario if len(sys.argv) != 2: print("Usage: python create_docker_compose_config.py ") diff --git a/fle/cluster/local/docker-compose-1-example.yml b/fle/cluster/local/docker-compose-1-example.yml index 5604aadc4..72495a14e 100644 --- a/fle/cluster/local/docker-compose-1-example.yml +++ b/fle/cluster/local/docker-compose-1-example.yml @@ -35,9 +35,3 @@ services: - source: ../scenarios/open_world target: /opt/factorio/scenarios/open_world type: bind - - source: ~/Applications/Factorio.app/Contents/Resources/mods - target: /opt/factorio/mods - type: bind - - source: ../../data/_screenshots - target: /opt/factorio/script-output - type: bind diff --git a/fle/cluster/local/docker-compose-8.yml b/fle/cluster/local/docker-compose-8.yml new file mode 100644 index 000000000..ffa2cac8e --- /dev/null +++ b/fle/cluster/local/docker-compose-8.yml @@ -0,0 +1,289 @@ +services: + factorio_0: + command: /opt/factorio/bin/x64/factorio --start-server-load-scenario open_world + --port 34197 --server-settings /opt/factorio/config/server-settings.json --map-gen-settings + /opt/factorio/config/map-gen-settings.json --map-settings /opt/factorio/config/map-settings.json + --server-banlist /opt/factorio/config/server-banlist.json --rcon-port 27015 + --rcon-password "factorio" --server-whitelist /opt/factorio/config/server-whitelist.json + --use-server-whitelist --server-adminlist /opt/factorio/config/server-adminlist.json + --mod-directory /opt/factorio/mods --map-gen-seed 44340 + deploy: + resources: + limits: + cpus: '1' + memory: 1024m + entrypoint: [] + environment: + - SAVES=/opt/factorio/saves + - CONFIG=/opt/factorio/config + - MODS=/opt/factorio/mods + - SCENARIOS=/opt/factorio/scenarios + - PORT=34197 + - RCON_PORT=27015 + image: factorio + platform: linux/amd64 + ports: + - 34197:34197/udp + - 27000:27015/tcp + pull_policy: never + restart: unless-stopped + user: factorio + volumes: + - source: ../scenarios/default_lab_scenario + target: /opt/factorio/scenarios/default_lab_scenario + type: bind + - source: ../scenarios/open_world + target: /opt/factorio/scenarios/open_world + type: bind + factorio_1: + command: /opt/factorio/bin/x64/factorio --start-server-load-scenario open_world + --port 34197 --server-settings /opt/factorio/config/server-settings.json --map-gen-settings + /opt/factorio/config/map-gen-settings.json --map-settings /opt/factorio/config/map-settings.json + --server-banlist /opt/factorio/config/server-banlist.json --rcon-port 27015 + --rcon-password "factorio" --server-whitelist /opt/factorio/config/server-whitelist.json + --use-server-whitelist --server-adminlist /opt/factorio/config/server-adminlist.json + --mod-directory /opt/factorio/mods --map-gen-seed 44340 + deploy: + resources: + limits: + cpus: '1' + memory: 1024m + entrypoint: [] + environment: + - SAVES=/opt/factorio/saves + - CONFIG=/opt/factorio/config + - MODS=/opt/factorio/mods + - SCENARIOS=/opt/factorio/scenarios + - PORT=34197 + - RCON_PORT=27015 + image: factorio + platform: linux/amd64 + ports: + - 34198:34197/udp + - 27001:27015/tcp + pull_policy: never + restart: unless-stopped + user: factorio + volumes: + - source: ../scenarios/default_lab_scenario + target: /opt/factorio/scenarios/default_lab_scenario + type: bind + - source: ../scenarios/open_world + target: /opt/factorio/scenarios/open_world + type: bind + factorio_2: + command: /opt/factorio/bin/x64/factorio --start-server-load-scenario open_world + --port 34197 --server-settings /opt/factorio/config/server-settings.json --map-gen-settings + /opt/factorio/config/map-gen-settings.json --map-settings /opt/factorio/config/map-settings.json + --server-banlist /opt/factorio/config/server-banlist.json --rcon-port 27015 + --rcon-password "factorio" --server-whitelist /opt/factorio/config/server-whitelist.json + --use-server-whitelist --server-adminlist /opt/factorio/config/server-adminlist.json + --mod-directory /opt/factorio/mods --map-gen-seed 44340 + deploy: + resources: + limits: + cpus: '1' + memory: 1024m + entrypoint: [] + environment: + - SAVES=/opt/factorio/saves + - CONFIG=/opt/factorio/config + - MODS=/opt/factorio/mods + - SCENARIOS=/opt/factorio/scenarios + - PORT=34197 + - RCON_PORT=27015 + image: factorio + platform: linux/amd64 + ports: + - 34199:34197/udp + - 27002:27015/tcp + pull_policy: never + restart: unless-stopped + user: factorio + volumes: + - source: ../scenarios/default_lab_scenario + target: /opt/factorio/scenarios/default_lab_scenario + type: bind + - source: ../scenarios/open_world + target: /opt/factorio/scenarios/open_world + type: bind + factorio_3: + command: /opt/factorio/bin/x64/factorio --start-server-load-scenario open_world + --port 34197 --server-settings /opt/factorio/config/server-settings.json --map-gen-settings + /opt/factorio/config/map-gen-settings.json --map-settings /opt/factorio/config/map-settings.json + --server-banlist /opt/factorio/config/server-banlist.json --rcon-port 27015 + --rcon-password "factorio" --server-whitelist /opt/factorio/config/server-whitelist.json + --use-server-whitelist --server-adminlist /opt/factorio/config/server-adminlist.json + --mod-directory /opt/factorio/mods --map-gen-seed 44340 + deploy: + resources: + limits: + cpus: '1' + memory: 1024m + entrypoint: [] + environment: + - SAVES=/opt/factorio/saves + - CONFIG=/opt/factorio/config + - MODS=/opt/factorio/mods + - SCENARIOS=/opt/factorio/scenarios + - PORT=34197 + - RCON_PORT=27015 + image: factorio + platform: linux/amd64 + ports: + - 34200:34197/udp + - 27003:27015/tcp + pull_policy: never + restart: unless-stopped + user: factorio + volumes: + - source: ../scenarios/default_lab_scenario + target: /opt/factorio/scenarios/default_lab_scenario + type: bind + - source: ../scenarios/open_world + target: /opt/factorio/scenarios/open_world + type: bind + factorio_4: + command: /opt/factorio/bin/x64/factorio --start-server-load-scenario open_world + --port 34197 --server-settings /opt/factorio/config/server-settings.json --map-gen-settings + /opt/factorio/config/map-gen-settings.json --map-settings /opt/factorio/config/map-settings.json + --server-banlist /opt/factorio/config/server-banlist.json --rcon-port 27015 + --rcon-password "factorio" --server-whitelist /opt/factorio/config/server-whitelist.json + --use-server-whitelist --server-adminlist /opt/factorio/config/server-adminlist.json + --mod-directory /opt/factorio/mods --map-gen-seed 44340 + deploy: + resources: + limits: + cpus: '1' + memory: 1024m + entrypoint: [] + environment: + - SAVES=/opt/factorio/saves + - CONFIG=/opt/factorio/config + - MODS=/opt/factorio/mods + - SCENARIOS=/opt/factorio/scenarios + - PORT=34197 + - RCON_PORT=27015 + image: factorio + platform: linux/amd64 + ports: + - 34201:34197/udp + - 27004:27015/tcp + pull_policy: never + restart: unless-stopped + user: factorio + volumes: + - source: ../scenarios/default_lab_scenario + target: /opt/factorio/scenarios/default_lab_scenario + type: bind + - source: ../scenarios/open_world + target: /opt/factorio/scenarios/open_world + type: bind + factorio_5: + command: /opt/factorio/bin/x64/factorio --start-server-load-scenario open_world + --port 34197 --server-settings /opt/factorio/config/server-settings.json --map-gen-settings + /opt/factorio/config/map-gen-settings.json --map-settings /opt/factorio/config/map-settings.json + --server-banlist /opt/factorio/config/server-banlist.json --rcon-port 27015 + --rcon-password "factorio" --server-whitelist /opt/factorio/config/server-whitelist.json + --use-server-whitelist --server-adminlist /opt/factorio/config/server-adminlist.json + --mod-directory /opt/factorio/mods --map-gen-seed 44340 + deploy: + resources: + limits: + cpus: '1' + memory: 1024m + entrypoint: [] + environment: + - SAVES=/opt/factorio/saves + - CONFIG=/opt/factorio/config + - MODS=/opt/factorio/mods + - SCENARIOS=/opt/factorio/scenarios + - PORT=34197 + - RCON_PORT=27015 + image: factorio + platform: linux/amd64 + ports: + - 34202:34197/udp + - 27005:27015/tcp + pull_policy: never + restart: unless-stopped + user: factorio + volumes: + - source: ../scenarios/default_lab_scenario + target: /opt/factorio/scenarios/default_lab_scenario + type: bind + - source: ../scenarios/open_world + target: /opt/factorio/scenarios/open_world + type: bind + factorio_6: + command: /opt/factorio/bin/x64/factorio --start-server-load-scenario open_world + --port 34197 --server-settings /opt/factorio/config/server-settings.json --map-gen-settings + /opt/factorio/config/map-gen-settings.json --map-settings /opt/factorio/config/map-settings.json + --server-banlist /opt/factorio/config/server-banlist.json --rcon-port 27015 + --rcon-password "factorio" --server-whitelist /opt/factorio/config/server-whitelist.json + --use-server-whitelist --server-adminlist /opt/factorio/config/server-adminlist.json + --mod-directory /opt/factorio/mods --map-gen-seed 44340 + deploy: + resources: + limits: + cpus: '1' + memory: 1024m + entrypoint: [] + environment: + - SAVES=/opt/factorio/saves + - CONFIG=/opt/factorio/config + - MODS=/opt/factorio/mods + - SCENARIOS=/opt/factorio/scenarios + - PORT=34197 + - RCON_PORT=27015 + image: factorio + platform: linux/amd64 + ports: + - 34203:34197/udp + - 27006:27015/tcp + pull_policy: never + restart: unless-stopped + user: factorio + volumes: + - source: ../scenarios/default_lab_scenario + target: /opt/factorio/scenarios/default_lab_scenario + type: bind + - source: ../scenarios/open_world + target: /opt/factorio/scenarios/open_world + type: bind + factorio_7: + command: /opt/factorio/bin/x64/factorio --start-server-load-scenario open_world + --port 34197 --server-settings /opt/factorio/config/server-settings.json --map-gen-settings + /opt/factorio/config/map-gen-settings.json --map-settings /opt/factorio/config/map-settings.json + --server-banlist /opt/factorio/config/server-banlist.json --rcon-port 27015 + --rcon-password "factorio" --server-whitelist /opt/factorio/config/server-whitelist.json + --use-server-whitelist --server-adminlist /opt/factorio/config/server-adminlist.json + --mod-directory /opt/factorio/mods --map-gen-seed 44340 + deploy: + resources: + limits: + cpus: '1' + memory: 1024m + entrypoint: [] + environment: + - SAVES=/opt/factorio/saves + - CONFIG=/opt/factorio/config + - MODS=/opt/factorio/mods + - SCENARIOS=/opt/factorio/scenarios + - PORT=34197 + - RCON_PORT=27015 + image: factorio + platform: linux/amd64 + ports: + - 34204:34197/udp + - 27007:27015/tcp + pull_policy: never + restart: unless-stopped + user: factorio + volumes: + - source: ../scenarios/default_lab_scenario + target: /opt/factorio/scenarios/default_lab_scenario + type: bind + - source: ../scenarios/open_world + target: /opt/factorio/scenarios/open_world + type: bind diff --git a/fle/env/tools/admin/render/rendered_image.py b/fle/commons/models/rendered_image.py similarity index 90% rename from fle/env/tools/admin/render/rendered_image.py rename to fle/commons/models/rendered_image.py index f7963d884..7c379cf4c 100644 --- a/fle/env/tools/admin/render/rendered_image.py +++ b/fle/commons/models/rendered_image.py @@ -1,5 +1,6 @@ import base64 import io + from PIL import Image @@ -9,9 +10,9 @@ class RenderedImage: def __init__(self, image: Image.Image): self.image = image - def show(self): + def show(self, *args, **kwargs): """Display the image (works in IDEs)""" - self.image.show() + self.image.show(*args, **kwargs) def save(self, path: str): """Save the image to a file""" diff --git a/fle/configs/gym_run_config.json b/fle/configs/gym_run_config.json new file mode 100644 index 000000000..af2387748 --- /dev/null +++ b/fle/configs/gym_run_config.json @@ -0,0 +1,6 @@ +[ + { + "env_id": "iron_plate_throughput_unbounded_steps_show_steps_false", + "model": "claude-3-5-sonnet-latest" + } +] \ No newline at end of file diff --git a/fle/env/entities.py b/fle/env/entities.py index cda514032..96a844ac4 100644 --- a/fle/env/entities.py +++ b/fle/env/entities.py @@ -2,7 +2,8 @@ from typing import Tuple, Any, Union, Dict, Literal from typing import List, Optional from enum import Enum, IntFlag -from pydantic import ConfigDict, BaseModel, model_validator +from pydantic import ConfigDict, BaseModel, model_validator, model_serializer + class Layer(IntFlag): @@ -160,12 +161,36 @@ def keys(self): def values(self): return self.__dict__.values() + def __add__(self, other): + if not isinstance(other, Inventory): + return NotImplemented + + result = Inventory(**self.__dict__) + + for key, value in other.items(): + if key in result: + result[key] = result[key] + value + else: + result[key] = value + + return result + + @model_serializer + def serialize_model(self): + return {k: v for k, v in self.__dict__.items() + if not k.startswith('_')} + class Direction(Enum): UP = NORTH = 0 RIGHT = EAST = 2 DOWN = SOUTH = 4 LEFT = WEST = 6 + # + # UPRIGHT = NORTHEAST = 8 + # DOWNRIGHT = SOUTHEAST = 10 + # DOWNLEFT = SOUTHWEST = 12 + # UPLEFT = NORTHWEST = 14 def __repr__(self): return f"Direction.{self.name}" @@ -417,8 +442,9 @@ class BurnerType(BaseModel): class EntityCore(BaseModel): # id: Optional[str] = None + model_config = ConfigDict(extra='allow') name: str - direction: Direction + direction: Direction = Direction.NORTH position: Position def __repr__(self): @@ -495,7 +521,6 @@ def height(cls): class StaticEntity(Entity): """A static (non-moving) entity in the game.""" - neighbours: Optional[Union[Dict, List[EntityCore]]] = [] @@ -521,7 +546,8 @@ class TransportBelt(Entity): input_position: Position output_position: Position - inventory: Inventory = Inventory() + #inventory: Inventory = Inventory() + inventory: Dict[Literal['left', 'right'], Inventory] = {'left': {}, 'right': {}} is_terminus: bool = False is_source: bool = False _height: float = 1 diff --git a/fle/env/instance.py b/fle/env/instance.py index 6e7e8feb8..3fff7bfbb 100644 --- a/fle/env/instance.py +++ b/fle/env/instance.py @@ -94,7 +94,7 @@ class FactorioInstance: def __init__( self, address=None, - fast=False, + fast=True, tcp_port=27000, inventory=None, cache_scripts=True, @@ -127,23 +127,30 @@ def __init__( self.pre_tool_hooks = {} self.post_tool_hooks = {} - # Load the python controllers that correspond to the Lua scripts - self.setup_tools(self.lua_script_manager) - if inventory is None: inventory = {} self.initial_inventory = inventory - self.initialise(fast) self.initial_score = 0 + self.initialise(fast) + + # Load the python controllers that correspond to the Lua scripts + self.setup_tools(self.lua_script_manager) + + + try: - self.first_namespace.score() + _, goal = self.first_namespace.score() + if not goal: + raise Exception("No goal") except Exception: # Invalidate cache if there is an error + self.rcon_client, self.address = self.connect_to_server(address, tcp_port) self.lua_script_manager = LuaScriptManager(self.rcon_client, False) self.script_dict = { **self.lua_script_manager.lib_scripts, **self.lua_script_manager.tool_scripts, } + self.setup_tools(self.lua_script_manager) self.initialise(fast) @@ -226,7 +233,7 @@ def reset(self, game_state: Optional[GameState] = None): # Clear renderings self.begin_transaction() - self.add_command("/sc rendering.clear()", raw=True) + self.add_command("/c rendering.clear()", raw=True) self.execute_transaction() def set_inventory(self, inventory: Dict[str, Any], agent_idx: int = 0): @@ -240,14 +247,14 @@ def set_inventory(self, inventory: Dict[str, Any], agent_idx: int = 0): inventory_items_json = json.dumps(inventory_items) player_idx = agent_idx + 1 self.add_command( - f"/sc global.actions.initialise_inventory({player_idx}, '{inventory_items_json}')", + f"/c global.actions.initialise_inventory({player_idx}, '{inventory_items_json}')", raw=True, ) self.execute_transaction() def speed(self, speed): - self.rcon_client.send_command(f"/sc game.speed = {speed}") + self.rcon_client.send_command(f"/c game.speed = {speed}") self._speed = speed def get_speed(self): @@ -663,7 +670,7 @@ def _reset(self, inventories: List[Dict[str, Any]]): self._reset_static_achievement_counters() self._reset_elapsed_ticks() - def _execute_transaction(self) -> Dict[str, Any]: + def _execute_transaction(self, measured=True) -> Dict[str, Any]: start = timer() rcon_commands = {} for idx, (command, parameters, is_raw) in enumerate( @@ -673,7 +680,7 @@ def _execute_transaction(self) -> Dict[str, Any]: rcon_commands[f"{idx}_{command}"] = command else: script = self._get_command( - command, parameters=parameters, measured=False + command, parameters=parameters, measured=measured ) rcon_commands[f"{idx}_{command}"] = script @@ -714,6 +721,7 @@ def initialise(self, fast=True): # Create characters for all agents self._create_agent_game_characters() + init_scripts = [ "initialise", "clear_entities", @@ -731,9 +739,14 @@ def initialise(self, fast=True): for script_name in init_scripts: self.lua_script_manager.load_init_into_game(script_name) + inventories = [self.initial_inventory] * self.num_agents self._reset(inventories) - self.first_namespace._clear_collision_boxes() + try: + self.first_namespace._clear_collision_boxes() + except AttributeError: + print("Could not clear collision boxes") + return def _create_agent_game_characters(self): """Create Factorio characters for all agents in the game.""" diff --git a/fle/env/lua_manager.py b/fle/env/lua_manager.py index 4cc0f4a72..ab751fe05 100644 --- a/fle/env/lua_manager.py +++ b/fle/env/lua_manager.py @@ -87,8 +87,11 @@ def load_tool_into_game(self, name): raise Exception(f"Syntax error in: {script_name}: {error}") print(f"{self.rcon_client.port}: Loading action {script_name} into game") - self.rcon_client.send_command("/sc " + script) - pass + response = self.rcon_client.send_command("/sc " + script) + + if response and 'error' in response.lower(): + raise Exception(response) + def load_init_into_game(self, name): if name not in self.lib_scripts: @@ -103,7 +106,12 @@ def load_init_into_game(self, name): return self.update_game_checksum(self.rcon_client, name, checksum) - self.rcon_client.send_command("/sc " + script) + response = self.rcon_client.send_command("/c " + script) + + if response and 'error' in response.lower(): + raise Exception(response) + + pass def calculate_checksum(self, content: str) -> str: return hashlib.md5(content.encode()).hexdigest() diff --git a/fle/env/mods/initialise.lua b/fle/env/mods/initialise.lua index 9d401830d..4382dce4e 100644 --- a/fle/env/mods/initialise.lua +++ b/fle/env/mods/initialise.lua @@ -17,7 +17,7 @@ end -- Note: The debug_rendering.lua library will be loaded separately by the LuaScriptManager ---local player = game.players[arg1] +local player = game.players[1] player.surface.always_day=true --game.players[1].character_collision_mask = "not-colliding-with-itself" player.force.character_build_distance_bonus = 100 diff --git a/fle/env/mods/serialize.lua b/fle/env/mods/serialize.lua index 3934682da..dedab24ca 100644 --- a/fle/env/mods/serialize.lua +++ b/fle/env/mods/serialize.lua @@ -1061,12 +1061,17 @@ global.utils.serialize_entity = function(entity) serialized.is_terminus = #entity.belt_neighbours["outputs"] == 0 serialized.is_source = #entity.belt_neighbours["inputs"] == 0 + serialized.inventory['left'] = {} + serialized.inventory['right'] = {} + -- Merge contents from both belt lines for item_name, count in pairs(line1_contents) do - serialized.inventory[item_name] = (serialized.inventory[item_name] or 0) + count + --serialized.inventory[item_name] = (serialized.inventory[item_name] or 0) + count + serialized.inventory['left'][item_name] = (serialized.inventory[item_name] or 0) + count end for item_name, count in pairs(line2_contents) do - serialized.inventory[item_name] = (serialized.inventory[item_name] or 0) + count + --serialized.inventory[item_name] = (serialized.inventory[item_name] or 0) + count + serialized.inventory['right'][item_name] = (serialized.inventory[item_name] or 0) + count end -- Add warning if belt is full diff --git a/fle/env/tools/admin/render/client.py b/fle/env/tools/admin/render/client.py index 33af49052..691330a4b 100644 --- a/fle/env/tools/admin/render/client.py +++ b/fle/env/tools/admin/render/client.py @@ -1,199 +1,209 @@ -from typing import Optional, Dict -import math - -from fle.env import BoundingBox, Position, BeltGroup, PipeGroup, ElectricityGroup, Layer -from fle.env.tools.admin.render.rendered_image import RenderedImage +from typing import Dict, Optional, Union, List, Tuple +from fle.commons.models.rendered_image import RenderedImage +from fle.env import Position, Layer +from fle.env.tools import Tool +from fle.env.tools.admin.render.constants import DEFAULT_SCALING +from fle.env.tools.admin.render.decoder import Decoder +from fle.env.tools.admin.render.image_resolver import ImageResolver from fle.env.tools.admin.render.renderer import Renderer from fle.env.tools.agent.get_entities.client import GetEntities -from fle.env.tools import Tool - -MAX_TILES = ( - 20 # Don't parameterise this, as the agent could break if it chooses a huge grid. -) +from fle.env.tools.admin.render.profiler import profiler, profile_method class Render(Tool): - """Render tool for visualizing Factorio entities""" - - def __init__(self, connection, game_state): - super().__init__(connection, game_state) - self.renderer = Renderer() - self.get_entities = GetEntities(connection, game_state) - - def __call__( - self, - position: Optional[Position] = None, - bounding_box: Optional[BoundingBox] = None, - style: Optional[Dict] = None, - layers: Optional[Layer] = Layer.ALL, - zoom: float = 1.0, - ) -> RenderedImage: + def __init__(self, *args): + super().__init__(*args) + self.image_resolver = ImageResolver(".fle/sprites") + self.decoder = Decoder() + self.get_entities = GetEntities(*args) + + @profile_method(include_args=True) + def _get_map_entities(self, include_status, radius, compression_level): + # Execute the Lua function with compression level + try: + result, _, elapsed = self.execute( + self.player_index, + include_status, + radius, + compression_level, + return_elapsed=True + ) + + + # Decode the optimized format if necessary + decoded_result = self._decode_optimized_format(result) + + return decoded_result + except Exception as e: + result, _, elapsed = self.execute( + self.player_index, + include_status, + radius, + compression_level, + return_elapsed=True + ) + pass + + @profile_method(include_args=True) + def __call__(self, + include_status: bool = False, + radius: int = 64, + position: Optional[Position] = None, + layers: Layer = Layer.ALL, + compression_level: str = 'binary', + blueprint: Union[str, List[Dict]] = None, + return_renderer=False, + max_render_radius: Optional[float] = None) -> Union[RenderedImage, Tuple[RenderedImage, Renderer]]: """ - Render entities around a position or within a bounding box. + Returns information about all entities, tiles, and resources within the specified radius of the player. Args: - position: Center position for rendering (defaults to player position if None) - radius: Radius around position to render (default: 10) - bounding_box: Specific area to render (overrides position and radius) - style: Optional custom style configuration - max_tiles: Maximum number of tiles to render on each side of the position (default: 50) - layers: Layer flags to specify which elements to render - zoom: Zoom factor for rendering (default: 1.0) - values > 1 zoom in (fewer tiles visible), - values < 1 zoom out (more tiles visible) + include_status: Whether to include status information for entities (optional) + radius: Search radius around the player (default: 50) + position: Center position for the search (optional, defaults to player position) + layers: Which layers to include in the render + compression_level: Compression level to use ('none', 'standard', 'binary', 'maximum') + - 'none': No compression, raw data + - 'standard': Run-length encoding for water, patch-based for resources (default) + - 'binary': Binary encoding with base64 transport + - 'maximum': Same as binary, reserved for future improvements + blueprint: Either a Base64 encoded blueprint, or a decoded blueprint + return_renderer: Whether to return the renderer, which contains the entities that were renderered Returns: - RenderedImage: An image object that can be displayed or saved + RenderedImage containing the visual representation of the area """ - radius: int = MAX_TILES - max_tiles: int = MAX_TILES + assert isinstance(include_status, bool), "Include status must be boolean" + assert isinstance(radius, (int, float)), "Radius must be a number" - # Apply minimum and maximum bounds to prevent issues with very small or very large zoom values - MIN_TILES = 2 # Prevent zooming in too much (minimum tiles to show) - MAX_ZOOM_TILES = 100 # Prevent zooming out too much (maximum tiles to show) + if not blueprint: + # Create renderer with decoded data + renderer = self.get_renderer_from_map(include_status, radius, compression_level, max_render_radius) + else: + renderer = self.get_renderer_from_blueprint(blueprint) - # Cap the maximum resolution/dimensions of the final image - MAX_IMAGE_RESOLUTION = 4000 # Maximum pixels in either dimension - MAX_TOTAL_TILES = ( - 8000 # Maximum total tiles (width * height) to prevent memory issues - ) - # Apply style if provided - custom_style = {} - if style: - custom_style.update(style) - - # Create new renderer with the custom style if any - if custom_style: - self.renderer = Renderer(custom_style) - - # Apply zoom by adjusting max_tiles (number of tiles displayed) - if zoom != 1.0: - # Adjust the number of tiles displayed based on zoom - # Zoom in (> 1.0) = fewer tiles displayed (divide by zoom) - # Zoom out (< 1.0) = more tiles displayed (divide by zoom) - max_tiles = int(MAX_TILES / zoom) - radius = max_tiles # Update radius as well to keep consistent - - # Apply bounds to ensure reasonable limits - max_tiles = max(MIN_TILES, min(max_tiles, MAX_ZOOM_TILES)) - radius = max_tiles # Match radius to max_tiles - - # Cap max_tiles to ensure the total image size doesn't exceed maximum resolution - # Calculate estimated pixels per tile including margins - estimated_pixels_per_tile = self.renderer.config.style["cell_size"] - - # Calculate maximum tiles in each dimension based on MAX_IMAGE_RESOLUTION - max_tiles_per_dimension = MAX_IMAGE_RESOLUTION // estimated_pixels_per_tile - - # Ensure max_tiles doesn't exceed the resolution limit - max_tiles = min(max_tiles, max_tiles_per_dimension) - radius = min(radius, max_tiles_per_dimension) - - # Ensure total tiles (width * height) doesn't exceed MAX_TOTAL_TILES - # A square of max_tiles*2 x max_tiles*2 would have 4*max_tiles^2 total tiles - # We want this to be <= MAX_TOTAL_TILES - max_tiles_from_total = int(math.sqrt(MAX_TOTAL_TILES / 4)) - max_tiles = min(max_tiles, max_tiles_from_total) - radius = min(radius, max_tiles_from_total) - - if position is None and bounding_box is None: - # Get player position from game state - position = Position(0, 0) # Default fallback - player_data = self.game_state.get("player", {}) - if "position" in player_data: - position = Position( - player_data["position"]["x"], player_data["position"]["y"] - ) - - # Ensure radius doesn't exceed max_tiles - radius = min(radius, max_tiles) - - # Set up area to query - if bounding_box: - # Clip bounding box to max_tiles if needed - if position: - # Ensure the box doesn't exceed max_tiles from center_pos - left = max(bounding_box.left_top.x, position.x - max_tiles) - right = min(bounding_box.right_bottom.x, position.x + max_tiles) - top = max(bounding_box.left_top.y, position.y - max_tiles) - bottom = min(bounding_box.right_bottom.y, position.y + max_tiles) - - # Create a new clipped bounding box - bounding_box = BoundingBox( - left_top=Position(left, top), - right_bottom=Position(right, bottom), - left_bottom=Position(left, bottom), - right_top=Position(right, top), - ) - - # Get entities within bounding box - response, _ = self.execute( - self.player_index, - "bounding_box", - bounding_box.left_top.x, - bounding_box.left_top.y, - bounding_box.right_bottom.x, - bounding_box.right_bottom.y, - ) + # Calculate render size + size = renderer.get_size() + if size['width'] == 0 or size['height'] == 0: + raise Exception("Nothing to render.") + + width = size['width'] * DEFAULT_SCALING + height = size['height'] * DEFAULT_SCALING + + # Render the blueprint + image = renderer.render(width, height, self.image_resolver) + + if return_renderer: + return RenderedImage(image), renderer + else: + return RenderedImage(image) + + def get_renderer_from_blueprint(self, blueprint): + if isinstance(blueprint, str): + raise NotImplementedError() + # entities = blueprint['entities'] + # renderer = Renderer( + # entities=entities + # ) else: - # Ensure radius is within the max_tiles limit - radius = min(radius, max_tiles) + if not 'entities' in blueprint: + raise ValueError("Blueprint passed with no entities") - # Get water, resources, trees and rocks within radius of position - response, _ = self.execute( - self.player_index, "radius", position.x, position.y, radius + entities = blueprint['entities'] + renderer = Renderer( + entities=entities ) + return renderer + + def get_renderer_from_map(self, + include_status: bool = False, + radius: int = 64, + compression_level: str = 'binary', + max_render_radius: Optional[float] = None, + ) -> Renderer: + + result = self._get_map_entities(include_status, radius, compression_level) + + #ent = self.get_entities(radius=radius) + #if ent: + # pass + + # Parse the Lua dictionaries + entities = self.parse_lua_dict(result['entities']) + + character_position = [c['position'] for c in list(filter(lambda x:x['name']=='character', entities))] + + #ent.extend(entities) + water_tiles = result['water_tiles'] + - # Get entities within radius of position - entities = self.get_entities(position=position, radius=radius) - - base_entities = [] - - for entity in entities: - if isinstance(entity, BeltGroup): - base_entities.extend(entity.belts) - elif isinstance(entity, PipeGroup): - base_entities.extend(entity.pipes) - elif isinstance(entity, ElectricityGroup): - base_entities.extend(entity.poles) - else: - base_entities.append(entity) - - # Extract data from the response - water_tiles = list(response.get("water_tiles", {}).values()) - resource_entities = list(response.get("resources", {}).values()) - trees = list(response.get("trees", {}).values()) - rocks = list(response.get("rocks", {}).values()) - electricity_networks = list( - response.get("electricity_networks", {}).values() - ) # Extract electricity networks - - # Render the entities with all additional elements - img = self.renderer.render_entities( - base_entities, - center_pos=position, - bounding_box=bounding_box, + resources = result['resources'] + + # Create renderer with decoded data + renderer = Renderer( + entities=entities, water_tiles=water_tiles, - resource_entities=resource_entities, - trees=trees, - rocks=rocks, - electricity_networks=electricity_networks, # Pass electricity networks to renderer - max_tiles=max_tiles, - layers=layers, + resources=resources, + max_render_radius=max_render_radius ) + return renderer + + def _decode_optimized_format(self, result: Dict) -> Dict: + """ + Decode the optimized format based on the version. + + Args: + result: The raw result from the Lua execution - return RenderedImage(img) - - def _process_nested_dict(self, nested_dict): - """Helper method to process nested dictionaries""" - if isinstance(nested_dict, dict): - if all(isinstance(key, int) for key in nested_dict.keys()): - return [ - self._process_nested_dict(value) for value in nested_dict.values() - ] - else: - return { - key: self._process_nested_dict(value) - for key, value in nested_dict.items() - } - return nested_dict + Returns: + Dictionary with decoded entities, water_tiles, and resources + """ + meta = result.get('meta', {}) + format_version = meta.get('format', 'v1') + + if format_version == 'v2-binary': + # Handle binary compressed format + entities = result.get('entities', []) + + # Decode binary water data + water_tiles = [] + if 'water_binary' in result: + water_binary = self.decoder.decode_base64_urlsafe(result['water_binary']) + water_runs = self.decoder.decode_water_binary(water_binary) + water_tiles = self.decoder.decode_water_runs(water_runs) + + # Decode binary resource data + resources = [] + if 'resources_binary' in result: + resources_binary = self.decoder.decode_base64_urlsafe(result['resources_binary']) + resource_patches = self.decoder.decode_resources_binary(resources_binary) + resources = self.decoder.decode_resource_patches(resource_patches) + + return { + 'entities': entities, + 'water_tiles': water_tiles, + 'resources': resources + } + elif format_version == 'v2': + # Handle optimized format + entities = result.get('entities', []) + water_runs = result.get('water', []) + resource_patches = result.get('resources', {}) + + # Decode compressed data + water_tiles = self.decoder.decode_water_runs(water_runs) + resources = self.decoder.decode_resource_patches(resource_patches) + + return { + 'entities': entities, + 'water_tiles': water_tiles, + 'resources': resources + } + else: + # Handle legacy format + return { + 'entities': result.get('entities', []), + 'water_tiles': result.get('water_tiles', []), + 'resources': result.get('resources', []) + } \ No newline at end of file diff --git a/fle/env/tools/admin/render/constants.py b/fle/env/tools/admin/render/constants.py new file mode 100644 index 000000000..d89621953 --- /dev/null +++ b/fle/env/tools/admin/render/constants.py @@ -0,0 +1,149 @@ +"""Constants for the rendering system.""" + +from typing import Dict, Final + +# Direction mappings +DIRECTIONS: Final[Dict[int, str]] = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" +} + +RELATIVE_DIRECTIONS: Final[Dict[int, str]] = { + 0: "up", + 2: "right", + 4: "down", + 6: "left" +} + +# Direction constants +NORTH: Final[int] = 0 +EAST: Final[int] = 2 +SOUTH: Final[int] = 4 +WEST: Final[int] = 6 + +VERTICAL: Final[list[int]] = [NORTH, SOUTH] +HORIZONTAL: Final[list[int]] = [EAST, WEST] + +# Combinator operation mappings +COMBINATOR_TO_NORMAL: Final[Dict[str, str]] = { + "+": "plus", + "-": "minus", + "*": "multiply", + "/": "divide", + "%": "modulo", + "^": "power", + "<<": "left_shift", + ">>": "right_shift", + "&": "and", + "and": "and", + "AND": "and", + "|": "or", + "or": "or", + "OR": "or", + "xor": "xor", + "XOR": "xor", + ">": "gt", + "<": "lt", + "=": "eq", + "!=": "neq", + "≠": "neq", + ">=": "gte", + "≥": "gte", + "<=": "lte", + "≤": "lte" +} + +# Rendering constants +DEFAULT_SCALING: Final[int] = 32 +GRID_LINE_WIDTH: Final[int] = 2 +GRID_LINE_WIDTH_THIN: Final[int] = 1 +GRID_LINE_WIDTH_MEDIUM: Final[int] = 2 +GRID_LINE_WIDTH_THICK: Final[int] = 3 +BACKGROUND_COLOR: Final[str] = '#282828' +GRID_COLOR: Final[str] = '#3c3c3c' +GRID_COLOR_THIN: Final[str] = '#3c3c3c' +GRID_COLOR_MEDIUM: Final[str] = '#4a4a4a' +GRID_COLOR_THICK: Final[str] = '#5a5a5a' + +# Resource constants +DEFAULT_MAX_RESOURCE_AMOUNT: Final[int] = 10000 +MIN_RESOURCE_VOLUME: Final[int] = 1 +MAX_RESOURCE_VOLUME: Final[int] = 8 +DEFAULT_RESOURCE_VARIANTS: Final[int] = 8 +DEFAULT_ROCK_VARIANTS: Final[int] = 20 +OIL_RESOURCE_VARIANTS: Final[int] = 4 + +# Tree constants +TREE_VARIATIONS: Final[list[str]] = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l'] +TREE_FOLIAGE_STATES: Final[list[str]] = ['full', 'medium', 'minimal', 'trunk_only'] +TREE_FILES_PER_VARIATION: Final[int] = 5 + +# Renderer mappings +RENDERERS: Final[Dict[str, str]] = { + "transport-belt": "transport-belt", + "fast-transport-belt": "transport-belt", + "express-transport-belt": "transport-belt", + "underground-belt": "underground-belt", + "fast-underground-belt": "underground-belt", + "express-underground-belt": "underground-belt", + "splitter": "splitter", + "fast-splitter": "splitter", + "express-splitter": "splitter", + "pipe": "pipe", + "pipe-to-ground": "pipe-to-ground", + "stack-inserter": "inserter", + "long-handed-inserter": "inserter", + "fast-inserter": "inserter", + "inserter": "inserter", + "filter-inserter": "inserter", + "stack-filter-inserter": "inserter", + "burner-inserter": "inserter", + "assembling-machine-1": "assembling-machine", + "assembling-machine-2": "assembling-machine", + "assembling-machine-3": "assembling-machine", + "chemical-plant": "chemical-plant", + "storage-tank": "storage-tank", + "oil-refinery": "oil-refinery", + "decider-combinator": "decider-combinator", + "arithmetic-combinator": "arithmetic-combinator", + "pump": "pump", + "heat-pipe": "heat-pipe", + "stone-wall": "stone-wall", + "gate": "gate", + "boiler": "boiler", + "heat-exchanger": "heat-exchanger", + "steam-engine": "steam-engine", + "steam-turbine": "steam-turbine", + "constant-combinator": "constant-combinator", + "electric-mining-drill": "electric-mining-drill", + "offshore-pump": "offshore-pump", + "burner-mining-drill": "burner-mining-drill", + "flamethrower-turret": "flamethrower-turret", + "straight-rail": "straight-rail", + "curved-rail": "curved-rail", + "rail-signal": "rail-signal", + "rail-chain-signal": "rail-signal", + "tree-01": "tree", + "tree-02": "tree", + "tree-03": "tree", + "tree-04": "tree", + "tree-05": "tree", + "tree-06": "tree", + "tree-07": "tree", + "tree-08": "tree", + "tree-09": "tree", + "dead-tree-desert": "tree", + "dead-dry-hairy-tree": "tree", + "dead-grey-trunk": "tree", + "dry-hairy-tree": "tree", + "dry-tree": "tree", + "cliff": "cliff", + "cliff-inner": "cliff", + "cliff-outer": "cliff", + "cliff-entrance": "cliff", + "cliff-sides": "cliff", + "character": "character" + #"lab": "lab" +} \ No newline at end of file diff --git a/fle/env/tools/admin/render/decoder.py b/fle/env/tools/admin/render/decoder.py new file mode 100644 index 000000000..7ea7e453d --- /dev/null +++ b/fle/env/tools/admin/render/decoder.py @@ -0,0 +1,323 @@ +import struct +import zlib +import base64 +from typing import Dict, List, Any, Tuple +from collections import defaultdict + + +class Decoder: + """Compression utilities for Factorio data transmission for rendering.""" + + # Tile type mappings for binary encoding + TILE_TYPES = { + 'water': 1, + 'deepwater': 2, + 'water-green': 3, + 'water-mud': 4, + 'water-shallow': 5 + } + + TILE_TYPES_REVERSE = {v: k for k, v in TILE_TYPES.items()} + + # Resource type mappings + RESOURCE_TYPES = { + 'iron-ore': 1, + 'copper-ore': 2, + 'coal': 3, + 'stone': 4, + 'uranium-ore': 5, + 'crude-oil': 6 + } + + RESOURCE_TYPES_REVERSE = {v: k for k, v in RESOURCE_TYPES.items()} + + @classmethod + def encode_water_binary(cls, water_runs: List[Dict]) -> bytes: + """ + Encode water runs into a compact binary format. + + Format per run: [type:u8][x:i16][y:i16][length:u8] + Total: 6 bytes per run vs ~50 bytes JSON + """ + data = bytearray() + + for run in water_runs: + tile_type = cls.TILE_TYPES.get(run['t'], 1) + x = run['x'] + y = run['y'] + length = min(run['l'], 255) # Cap at 255 for single byte + + # Pack as: unsigned byte, signed short, signed short, unsigned byte + data.extend(struct.pack('!BhhB', tile_type, x, y, length)) + + return bytes(data) + + @classmethod + def decode_water_runs(cls, water_runs: List[Dict]) -> List[Dict]: + """ + Decode run-length encoded water tiles back to individual tiles. + + Args: + water_runs: List of water runs with format: + - t: tile type (water, deepwater, etc.) + - x: starting x coordinate + - y: y coordinate + - l: length of the run + + Returns: + List of individual water tiles in original format + """ + tiles = [] + for run in water_runs: + tile_type = run.get('t', 'water') + start_x = run.get('x', 0) + y = run.get('y', 0) + length = run.get('l', 1) + + # Expand the run into individual tiles + for x in range(start_x, start_x + length): + tiles.append({ + 'x': x, + 'y': y, + 'name': tile_type + }) + + return tiles + + + + @classmethod + def decode_water_binary(cls, data: bytes) -> List[Dict]: + """Decode binary water data back to run format.""" + runs = [] + offset = 0 + + while offset < len(data): + tile_type, x, y, length = struct.unpack_from('!BhhB', data, offset) + runs.append({ + 't': cls.TILE_TYPES_REVERSE.get(tile_type, 'water'), + 'x': x, + 'y': y, + 'l': length + }) + offset += 6 + + return runs + + @classmethod + def encode_resources_binary(cls, resource_patches: Dict[str, List[Dict]]) -> bytes: + """ + Encode resource patches into binary format. + + Format: + [resource_type:u8][patch_count:u16] + For each patch: + [center_x:i16][center_y:i16][entity_count:u16] + For each entity: + [dx:i8][dy:i8][amount:u32] + """ + data = bytearray() + + for resource_name, patches in resource_patches.items(): + resource_type = cls.RESOURCE_TYPES.get(resource_name, 0) + if resource_type == 0: + continue + + # Write resource type and patch count + data.extend(struct.pack('!BH', resource_type, len(patches))) + + for patch in patches: + center = patch['c'] + entities = patch['e'] + + # Write patch header + data.extend(struct.pack('!hhH', center[0], center[1], len(entities))) + + # Write entities + for entity in entities: + dx = max(-128, min(127, entity[0])) # Clamp to signed byte range + dy = max(-128, min(127, entity[1])) + amount = entity[2] + data.extend(struct.pack('!bbI', dx, dy, amount)) + + return bytes(data) + + @classmethod + def decode_resources_binary(cls, data: bytes) -> Dict[str, List[Dict]]: + """Decode binary resource data back to patch format.""" + resources = {} + offset = 0 + + while offset < len(data): + # Read resource type and patch count + resource_type, patch_count = struct.unpack_from('!BH', data, offset) + offset += 3 + + resource_name = cls.RESOURCE_TYPES_REVERSE.get(resource_type, 'unknown') + patches = [] + + for _ in range(patch_count): + # Read patch header + center_x, center_y, entity_count = struct.unpack_from('!hhH', data, offset) + offset += 6 + + entities = [] + for _ in range(entity_count): + dx, dy, amount = struct.unpack_from('!bbI', data, offset) + offset += 6 + entities.append([dx, dy, amount]) + + patches.append({ + 'c': [center_x, center_y], + 'e': entities + }) + + resources[resource_name] = patches + + return resources + + @classmethod + def decode_resource_patches(cls, resource_patches: Dict[str, List[Dict]]) -> List[Dict]: + """ + Decode patch-based resources back to individual resource entities. + + Args: + resource_patches: Dictionary mapping resource types to patches. + Each patch has: + - c: center position [x, y] + - e: list of entities as [dx, dy, amount] relative to center + + Returns: + List of individual resource entities in original format + """ + resources = [] + + for resource_type, patches in resource_patches.items(): + for patch in patches: + center = patch.get('c', [0, 0]) + entities = patch.get('e', []) + + # Convert relative positions to absolute + for entity in entities: + if len(entity) >= 3: + dx, dy, amount = entity[0], entity[1], entity[2] + resources.append({ + 'name': resource_type, + 'position': { + 'x': center[0] + dx, + 'y': center[1] + dy + }, + 'amount': amount + }) + + return resources + + @classmethod + def decode_base64_urlsafe(cls, data: str) -> bytes: + """ + Decode URL-safe Base64 (using - and _ instead of + and /) + + Args: + data: URL-safe Base64 encoded string + + Returns: + Decoded bytes + """ + # Replace URL-safe characters with standard Base64 characters + standard_b64 = data.replace('-', '+').replace('_', '/') + return base64.b64decode(standard_b64) + + @staticmethod + def compress_data(data: Dict) -> str: + """ + Compress the entire data structure using zlib and base64. + + Returns: + Base64 encoded compressed data + """ + import json + json_str = json.dumps(data, separators=(',', ':')) + compressed = zlib.compress(json_str.encode('utf-8'), level=9) + return base64.b64encode(compressed).decode('ascii') + + @staticmethod + def decompress_data(compressed_str: str) -> Dict: + """ + Decompress base64 encoded zlib compressed data. + + Returns: + Original data dictionary + """ + import json + compressed = base64.b64decode(compressed_str.encode('ascii')) + json_str = zlib.decompress(compressed).decode('utf-8') + return json.loads(json_str) + + @staticmethod + def create_spatial_index(entities: List[Dict], chunk_size: int = 32) -> Dict[Tuple[int, int], List[Dict]]: + """ + Create a spatial index for entities to enable efficient spatial queries. + + Args: + entities: List of entities with position data + chunk_size: Size of each spatial chunk + + Returns: + Dictionary mapping chunk coordinates to entities in that chunk + """ + chunks = defaultdict(list) + + for entity in entities: + pos = entity.get('position', {}) + x = pos.get('x', 0) + y = pos.get('y', 0) + + chunk_x = int(x // chunk_size) + chunk_y = int(y // chunk_size) + + chunks[(chunk_x, chunk_y)].append(entity) + + return dict(chunks) + + @staticmethod + def delta_encode_positions(positions: List[Tuple[int, int]]) -> Dict: + """ + Delta encode a list of positions for more efficient storage. + + Args: + positions: List of (x, y) tuples + + Returns: + Dictionary with start position and list of deltas + """ + if not positions: + return {'start': [0, 0], 'deltas': []} + + start = positions[0] + deltas = [] + + for i in range(1, len(positions)): + dx = positions[i][0] - positions[i - 1][0] + dy = positions[i][1] - positions[i - 1][1] + deltas.append([dx, dy]) + + return { + 'start': list(start), + 'deltas': deltas + } + + @staticmethod + def delta_decode_positions(encoded: Dict) -> List[Tuple[int, int]]: + """Decode delta encoded positions.""" + start = encoded['start'] + deltas = encoded['deltas'] + + positions = [(start[0], start[1])] + x, y = start[0], start[1] + + for dx, dy in deltas: + x += dx + y += dy + positions.append((x, y)) + + return positions \ No newline at end of file diff --git a/fle/env/tools/admin/render/entity_grid.py b/fle/env/tools/admin/render/entity_grid.py new file mode 100644 index 000000000..d2b48c1b1 --- /dev/null +++ b/fle/env/tools/admin/render/entity_grid.py @@ -0,0 +1,51 @@ +"""Entity grid view for relative position lookups.""" + +from typing import Dict, Optional + + +class EntityGridView: + """View into the entity grid for relative lookups.""" + + def __init__(self, grid: Dict, center_x: float, center_y: float, available_trees: Optional[Dict] = None): + """Initialize grid view with center position. + + Args: + grid: Entity position grid + center_x: X coordinate of center position + center_y: Y coordinate of center position + available_trees: Optional dict of available tree sprites + """ + self.grid = grid + self.center_x = center_x + self.center_y = center_y + self.available_trees = available_trees or {} + + def get_relative(self, relative_x: float, relative_y: float) -> Optional[Dict]: + """Get entity at relative position from center. + + Args: + relative_x: X offset from center + relative_y: Y offset from center + + Returns: + Entity dict if found, None otherwise + """ + x = self.center_x + relative_x + y = self.center_y + relative_y + + if x not in self.grid: + return None + val = self.grid[x].get(y) + if val is None: + return None + return val.model_dump() + + def set_center(self, center_x: float, center_y: float) -> None: + """Update center position. + + Args: + center_x: New X coordinate for center + center_y: New Y coordinate for center + """ + self.center_x = center_x + self.center_y = center_y \ No newline at end of file diff --git a/fle/env/tools/admin/render/image_resolver.py b/fle/env/tools/admin/render/image_resolver.py new file mode 100644 index 000000000..6ac59838e --- /dev/null +++ b/fle/env/tools/admin/render/image_resolver.py @@ -0,0 +1,56 @@ +"""Image resolution and caching functionality.""" + +from pathlib import Path +from typing import Optional, Dict +from PIL import Image + +from .utils import find_fle_sprites_dir +from .profiler import profiler, profile_method + + +class ImageResolver: + """Resolve image paths and load images (simple PNG-based resolver).""" + + def __init__(self, images_dir: str = ".fle/sprites"): + """Initialize image resolver. + + Args: + images_dir: Directory containing sprite images + """ + self.images_dir = find_fle_sprites_dir() + self.cache: Dict[str, Optional[Image.Image]] = {} + + @profile_method(include_args=True) + def __call__(self, name: str, shadow: bool = False) -> Optional[Image.Image]: + """Load and cache an image. + + Args: + name: Name of the sprite (without extension) + shadow: Whether to load shadow variant + + Returns: + PIL Image if found, None otherwise + """ + filename = f"{name}_shadow" if shadow else name + + if filename in self.cache and self.cache[filename]: + profiler.increment_counter('image_cache_hits') + return self.cache[filename] + + profiler.increment_counter('image_cache_misses') + path = self.images_dir / f"{filename}.png" + if not path.exists(): + self.cache[filename] = None + profiler.increment_counter('image_not_found') + return None + + try: + with profiler.timer('image_load_from_disk'): + image = Image.open(path).convert('RGBA') + self.cache[filename] = image + profiler.increment_counter('images_loaded') + return image + except Exception: + self.cache[filename] = None + profiler.increment_counter('image_load_errors') + return None \ No newline at end of file diff --git a/fle/env/tools/admin/render/performance_tools.py b/fle/env/tools/admin/render/performance_tools.py new file mode 100644 index 000000000..5ae33cfe8 --- /dev/null +++ b/fle/env/tools/admin/render/performance_tools.py @@ -0,0 +1,323 @@ +"""Performance analysis and reporting tools for the rendering system.""" + +import time +from pathlib import Path +from typing import Dict, List, Optional, Any +from collections import defaultdict +import json + +from .profiler import profiler + + +class PerformanceAnalyzer: + """Analyze and report on rendering performance.""" + + def __init__(self): + """Initialize performance analyzer.""" + self.profiler = profiler + + def start_session(self, session_name: str = None): + """Start a new profiling session. + + Args: + session_name: Optional name for the session + """ + self.profiler.clear() + self.profiler.enable() + self.session_name = session_name or f"session_{int(time.time())}" + self.session_start = time.time() + + def end_session(self): + """End the current profiling session.""" + self.session_end = time.time() + + def analyze_bottlenecks(self, min_time: float = 0.001) -> Dict[str, Any]: + """Analyze performance bottlenecks. + + Args: + min_time: Minimum time threshold for considering operations + + Returns: + Dictionary containing bottleneck analysis + """ + stats = self.profiler.get_all_stats() + + # Filter operations by minimum time + significant_ops = { + op: data for op, data in stats.items() + if data['total'] >= min_time + } + + # Sort by total time + sorted_ops = sorted( + significant_ops.items(), + key=lambda x: x[1]['total'], + reverse=True + ) + + # Categorize operations + categories = { + 'rendering': [], + 'image_loading': [], + 'entity_processing': [], + 'other': [] + } + + for op_name, data in sorted_ops: + if any(keyword in op_name.lower() for keyword in ['render', 'draw', 'paste']): + categories['rendering'].append((op_name, data)) + elif any(keyword in op_name.lower() for keyword in ['image', 'resolver', 'load']): + categories['image_loading'].append((op_name, data)) + elif any(keyword in op_name.lower() for keyword in ['entity', 'grid', 'manager']): + categories['entity_processing'].append((op_name, data)) + else: + categories['other'].append((op_name, data)) + + return { + 'total_operations': len(stats), + 'significant_operations': len(significant_ops), + 'categories': categories, + 'top_bottlenecks': sorted_ops[:10] + } + + def get_image_cache_stats(self) -> Dict[str, Any]: + """Get image cache performance statistics.""" + stats = self.profiler.get_all_stats() + counters = self.profiler.get_counters() + + image_ops = { + op: data for op, data in stats.items() + if 'ImageResolver' in op or 'image_resolver' in op + } + + total_image_calls = sum(data['count'] for data in image_ops.values()) + total_image_time = sum(data['total'] for data in image_ops.values()) + + cache_hits = counters.get('image_cache_hits', 0) + cache_misses = counters.get('image_cache_misses', 0) + + hit_rate = cache_hits / (cache_hits + cache_misses) if (cache_hits + cache_misses) > 0 else 0 + + return { + 'total_image_calls': total_image_calls, + 'total_image_time': total_image_time, + 'avg_time_per_call': total_image_time / total_image_calls if total_image_calls > 0 else 0, + 'cache_hits': cache_hits, + 'cache_misses': cache_misses, + 'cache_hit_rate': hit_rate + } + + def get_entity_rendering_stats(self) -> Dict[str, Any]: + """Get entity rendering performance statistics.""" + stats = self.profiler.get_all_stats() + + entity_render_ops = { + op: data for op, data in stats.items() + if any(keyword in op for keyword in ['_render_', 'render_']) + } + + # Group by render type + render_types = defaultdict(lambda: {'count': 0, 'total_time': 0, 'operations': []}) + + for op_name, data in entity_render_ops.items(): + if '_render_entities' in op_name: + render_type = 'entities' + elif '_render_trees' in op_name: + render_type = 'trees' + elif '_render_resources' in op_name: + render_type = 'resources' + elif '_render_shadows' in op_name: + render_type = 'shadows' + elif '_render_rails' in op_name: + render_type = 'rails' + elif '_render_inventories' in op_name: + render_type = 'inventories' + else: + render_type = 'other' + + render_types[render_type]['count'] += data['count'] + render_types[render_type]['total_time'] += data['total'] + render_types[render_type]['operations'].append((op_name, data)) + + return dict(render_types) + + def generate_performance_report(self, + output_file: Optional[Path] = None, + include_raw_data: bool = False) -> str: + """Generate a comprehensive performance report. + + Args: + output_file: Optional file to save the report + include_raw_data: Whether to include raw timing data + + Returns: + Report as a string + """ + lines = ["=" * 80, "RENDERING PERFORMANCE REPORT", "=" * 80, ""] + + # Session info + if hasattr(self, 'session_start') and hasattr(self, 'session_end'): + session_duration = self.session_end - self.session_start + lines.extend([ + f"Session: {getattr(self, 'session_name', 'Unknown')}", + f"Duration: {session_duration:.3f}s", + "" + ]) + + # Overall statistics + stats = self.profiler.get_all_stats() + counters = self.profiler.get_counters() + + total_operations = sum(data['count'] for data in stats.values()) + total_time = sum(data['total'] for data in stats.values()) + + lines.extend([ + "OVERALL STATISTICS:", + f"Total operations: {total_operations:,}", + f"Total time: {total_time:.3f}s", + f"Average time per operation: {total_time/total_operations*1000:.2f}ms" if total_operations > 0 else "N/A", + "" + ]) + + # Bottleneck analysis + bottlenecks = self.analyze_bottlenecks() + lines.extend([ + "TOP PERFORMANCE BOTTLENECKS:", + f"{'Operation':<50} {'Count':<8} {'Total(s)':<10} {'Avg(ms)':<10} {'%Time':<8}", + "-" * 96 + ]) + + for op_name, data in bottlenecks['top_bottlenecks']: + percent_time = (data['total'] / total_time * 100) if total_time > 0 else 0 + lines.append( + f"{op_name:<50} {data['count']:<8} {data['total']:<10.3f} " + f"{data['avg']*1000:<10.2f} {percent_time:<8.1f}%" + ) + + lines.append("") + + # Category breakdown + lines.extend(["PERFORMANCE BY CATEGORY:", ""]) + for category, ops in bottlenecks['categories'].items(): + if ops: + category_time = sum(data['total'] for _, data in ops) + category_percent = (category_time / total_time * 100) if total_time > 0 else 0 + lines.extend([ + f"{category.upper()}: {category_time:.3f}s ({category_percent:.1f}%)", + f" Operations: {len(ops)}" + ]) + + # Show top 3 operations in this category + for i, (op_name, data) in enumerate(ops[:3]): + lines.append(f" {i+1}. {op_name}: {data['total']:.3f}s ({data['count']} calls)") + + lines.append("") + + # Image cache statistics + cache_stats = self.get_image_cache_stats() + lines.extend([ + "IMAGE CACHE PERFORMANCE:", + f"Total image calls: {cache_stats['total_image_calls']:,}", + f"Total image time: {cache_stats['total_image_time']:.3f}s", + f"Average time per call: {cache_stats['avg_time_per_call']*1000:.2f}ms", + f"Cache hit rate: {cache_stats['cache_hit_rate']*100:.1f}%", + "" + ]) + + # Entity rendering breakdown + entity_stats = self.get_entity_rendering_stats() + lines.extend(["ENTITY RENDERING BREAKDOWN:", ""]) + for render_type, data in entity_stats.items(): + if data['total_time'] > 0: + lines.extend([ + f"{render_type.upper()}:", + f" Total time: {data['total_time']:.3f}s", + f" Operations: {data['count']:,}", + f" Avg per operation: {data['total_time']/data['count']*1000:.2f}ms" if data['count'] > 0 else " N/A", + "" + ]) + + # Counter data + if counters: + lines.extend(["COUNTERS:", ""]) + for counter, value in sorted(counters.items()): + lines.append(f"{counter}: {value:,}") + lines.append("") + + # Raw data if requested + if include_raw_data: + lines.extend([ + "RAW TIMING DATA:", + "=" * 50, + self.profiler.get_report() + ]) + + report = "\n".join(lines) + + # Save to file if requested + if output_file: + with open(output_file, 'w') as f: + f.write(report) + lines.extend([ + "", + f"Report saved to: {output_file}" + ]) + + return report + + def export_data_for_analysis(self, output_file: Path): + """Export performance data in JSON format for further analysis. + + Args: + output_file: Path to save the JSON data + """ + data = { + 'session_info': { + 'name': getattr(self, 'session_name', 'Unknown'), + 'start_time': getattr(self, 'session_start', None), + 'end_time': getattr(self, 'session_end', None), + 'duration': getattr(self, 'session_end', 0) - getattr(self, 'session_start', 0) + }, + 'statistics': self.profiler.get_all_stats(), + 'counters': self.profiler.get_counters(), + 'bottlenecks': self.analyze_bottlenecks(), + 'image_cache_stats': self.get_image_cache_stats(), + 'entity_rendering_stats': self.get_entity_rendering_stats() + } + + with open(output_file, 'w') as f: + json.dump(data, f, indent=2) + + +# Convenience functions for easy use +def start_profiling(session_name: str = None) -> PerformanceAnalyzer: + """Start a profiling session. + + Args: + session_name: Optional name for the session + + Returns: + PerformanceAnalyzer instance + """ + analyzer = PerformanceAnalyzer() + analyzer.start_session(session_name) + return analyzer + + +def stop_profiling_and_report(analyzer: PerformanceAnalyzer, + output_file: Optional[Path] = None) -> str: + """Stop profiling and generate a report. + + Args: + analyzer: PerformanceAnalyzer instance from start_profiling() + output_file: Optional file to save the report + + Returns: + Performance report as string + """ + analyzer.end_session() + return analyzer.generate_performance_report(output_file) + + +# Global instance for convenience +performance_analyzer = PerformanceAnalyzer() \ No newline at end of file diff --git a/fle/env/tools/admin/render/profiler.py b/fle/env/tools/admin/render/profiler.py new file mode 100644 index 000000000..06ea5acf9 --- /dev/null +++ b/fle/env/tools/admin/render/profiler.py @@ -0,0 +1,246 @@ +"""Performance profiling utilities for the rendering system.""" + +import time +import functools +import json +from typing import Dict, List, Optional, Any, Callable +from collections import defaultdict, deque +from pathlib import Path +import threading +from contextlib import contextmanager + + +class PerformanceProfiler: + """Centralized performance profiler for the rendering system.""" + + def __init__(self, max_samples: int = 1000): + """Initialize the profiler. + + Args: + max_samples: Maximum number of samples to keep per metric + """ + self._data = defaultdict(lambda: deque(maxlen=max_samples)) + self._counters = defaultdict(int) + self._active_timers = {} + self._thread_local = threading.local() + self._enabled = True + + def enable(self): + """Enable profiling.""" + self._enabled = True + + def disable(self): + """Disable profiling.""" + self._enabled = False + + def is_enabled(self) -> bool: + """Check if profiling is enabled.""" + return self._enabled + + @contextmanager + def timer(self, operation: str, metadata: Optional[Dict] = None): + """Context manager for timing operations. + + Args: + operation: Name of the operation being timed + metadata: Additional metadata to include with the timing + """ + if not self._enabled: + yield + return + + start_time = time.perf_counter() + try: + yield + finally: + elapsed = time.perf_counter() - start_time + self.record_timing(operation, elapsed, metadata) + + def record_timing(self, operation: str, elapsed: float, metadata: Optional[Dict] = None): + """Record a timing measurement. + + Args: + operation: Name of the operation + elapsed: Time elapsed in seconds + metadata: Additional metadata + """ + if not self._enabled: + return + + entry = { + 'timestamp': time.time(), + 'elapsed': elapsed, + 'metadata': metadata or {} + } + self._data[operation].append(entry) + + def increment_counter(self, counter: str, value: int = 1): + """Increment a counter. + + Args: + counter: Name of the counter + value: Value to increment by + """ + if not self._enabled: + return + + self._counters[counter] += value + + def get_stats(self, operation: str) -> Dict[str, Any]: + """Get statistics for an operation. + + Args: + operation: Name of the operation + + Returns: + Dictionary containing timing statistics + """ + if operation not in self._data or not self._data[operation]: + return {'count': 0, 'total': 0, 'avg': 0, 'min': 0, 'max': 0} + + timings = [entry['elapsed'] for entry in self._data[operation]] + return { + 'count': len(timings), + 'total': sum(timings), + 'avg': sum(timings) / len(timings), + 'min': min(timings), + 'max': max(timings), + 'recent_avg': sum(timings[-10:]) / min(len(timings), 10) + } + + def get_all_stats(self) -> Dict[str, Dict[str, Any]]: + """Get statistics for all operations.""" + stats = {} + for operation in self._data.keys(): + stats[operation] = self.get_stats(operation) + return stats + + def get_counters(self) -> Dict[str, int]: + """Get all counter values.""" + return dict(self._counters) + + def get_report(self) -> str: + """Generate a performance report.""" + lines = ["=== Performance Report ===", ""] + + # Timing statistics + lines.append("Timing Statistics:") + stats = self.get_all_stats() + if stats: + lines.append(f"{'Operation':<30} {'Count':<8} {'Total(s)':<10} {'Avg(ms)':<10} {'Min(ms)':<10} {'Max(ms)':<10}") + lines.append("-" * 88) + + # Sort by total time descending + sorted_ops = sorted(stats.items(), key=lambda x: x[1]['total'], reverse=True) + for operation, data in sorted_ops: + lines.append(f"{operation:<30} {data['count']:<8} {data['total']:<10.3f} " + f"{data['avg']*1000:<10.2f} {data['min']*1000:<10.2f} {data['max']*1000:<10.2f}") + else: + lines.append("No timing data collected") + + lines.append("") + + # Counters + lines.append("Counters:") + counters = self.get_counters() + if counters: + for counter, value in sorted(counters.items()): + lines.append(f"{counter}: {value}") + else: + lines.append("No counter data collected") + + return "\n".join(lines) + + def save_report(self, filepath: Path): + """Save performance report to file. + + Args: + filepath: Path to save the report + """ + with open(filepath, 'w') as f: + f.write(self.get_report()) + + def export_data(self, filepath: Path): + """Export raw profiling data to JSON. + + Args: + filepath: Path to save the data + """ + data = { + 'timings': {k: list(v) for k, v in self._data.items()}, + 'counters': dict(self._counters) + } + with open(filepath, 'w') as f: + json.dump(data, f, indent=2) + + def clear(self): + """Clear all profiling data.""" + self._data.clear() + self._counters.clear() + + +# Global profiler instance +profiler = PerformanceProfiler() + + +def profile_function(operation_name: str = None, include_args: bool = False): + """Decorator to profile function execution time. + + Args: + operation_name: Custom name for the operation (defaults to function name) + include_args: Whether to include function arguments in metadata + """ + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(*args, **kwargs): + if not profiler.is_enabled(): + return func(*args, **kwargs) + + op_name = operation_name or f"{func.__module__}.{func.__name__}" + metadata = {} + + if include_args: + # Include basic info about arguments + metadata['arg_count'] = len(args) + metadata['kwarg_count'] = len(kwargs) + + # Include specific argument info for key functions + if 'entity' in kwargs: + entity = kwargs['entity'] + if isinstance(entity, dict): + metadata['entity_name'] = entity.get('name', 'unknown') + elif hasattr(entity, 'name'): + metadata['entity_name'] = entity.name + + with profiler.timer(op_name, metadata): + return func(*args, **kwargs) + + return wrapper + return decorator + + +def profile_method(operation_name: str = None, include_args: bool = False): + """Decorator to profile method execution time. + + Args: + operation_name: Custom name for the operation (defaults to class.method) + include_args: Whether to include method arguments in metadata + """ + def decorator(func: Callable) -> Callable: + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + if not profiler.is_enabled(): + return func(self, *args, **kwargs) + + op_name = operation_name or f"{self.__class__.__name__}.{func.__name__}" + metadata = {} + + if include_args: + metadata['arg_count'] = len(args) + metadata['kwarg_count'] = len(kwargs) + + with profiler.timer(op_name, metadata): + return func(self, *args, **kwargs) + + return wrapper + return decorator \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderer.py b/fle/env/tools/admin/render/renderer.py index 703cabc4a..ed00438d1 100644 --- a/fle/env/tools/admin/render/renderer.py +++ b/fle/env/tools/admin/render/renderer.py @@ -1,306 +1,946 @@ -from PIL import Image, ImageDraw, ImageFont -from typing import List, Dict, Optional - -from fle.env.entities import Entity, Position, BoundingBox, Layer, EntityStatus -from fle.env.tools.admin.render.layers.connection_layers_renderer import ( - ConnectionsLayerRenderer, -) -from fle.env.tools.admin.render.layers.marker_layers_renderer import ( - MarkersLayerRenderer, -) -from fle.env.tools.admin.render.layers.resource_layer_renderer import ( - ResourcesLayerRenderer, -) -from fle.env.tools.admin.render.utils.electricity_renderer import ( - ElectricityLayerRenderer, -) -from fle.env.tools.admin.render.utils.render_config import RenderConfig -from fle.env.tools.admin.render.utils.entity_categoriser import EntityCategoriser -from fle.env.tools.admin.render.utils.colour_manager import ColourManager -from fle.env.tools.admin.render.utils.shape_renderer import ShapeRenderer -from fle.env.tools.admin.render.utils.legend_renderer import LegendRenderer -from fle.env.tools.admin.render.utils.connection_renderer import ConnectionRenderer -from fle.env.tools.admin.render.utils.image_calculator import ImageCalculator - - -# Import layer renderers -from fle.env.tools.admin.render.layers.grid_layer_renderer import GridLayerRenderer -from fle.env.tools.admin.render.layers.water_layer_renderer import WaterLayerRenderer -from fle.env.tools.admin.render.layers.natural_layer_renderer import ( - NaturalLayerRenderer, -) -from fle.env.tools.admin.render.layers.entities_layer_renderer import ( - EntitiesLayerRenderer, +#!/usr/bin/env python3 +""" +Factorio Blueprint Renderer - Extended with Resource Support +Renders Factorio blueprints including resource patches +""" +import copy +import json +import math +from pathlib import Path +from PIL import Image, ImageDraw +from typing import Dict, List, Optional, Union, Any + +from fle.env import Entity, EntityCore, UndergroundBelt, EntityStatus +from fle.env.tools.admin.render.constants import BACKGROUND_COLOR, GRID_LINE_WIDTH, GRID_COLOR, DEFAULT_ROCK_VARIANTS, \ + OIL_RESOURCE_VARIANTS, RENDERERS, DEFAULT_SCALING, DEFAULT_RESOURCE_VARIANTS, \ + GRID_LINE_WIDTH_THIN, GRID_LINE_WIDTH_MEDIUM, GRID_LINE_WIDTH_THICK, \ + GRID_COLOR_THIN, GRID_COLOR_MEDIUM, GRID_COLOR_THICK +from fle.env.tools.admin.render.utils import ( + entities_to_grid, resources_to_grid, get_resource_variant, + get_resource_volume, is_tree_entity, find_fle_sprites_dir, + is_rock_entity, flatten_entities ) +from fle.env.tools.admin.render.entity_grid import EntityGridView +from fle.env.tools.admin.render.image_resolver import ImageResolver +from fle.env.tools.admin.render.renderer_manager import renderer_manager +from fle.env.tools.admin.render.renderers.tree import build_available_trees_index, get_tree_variant +from fle.env.tools.admin.render.profiler import profiler, profile_method -class Renderer: - """ - Main renderer class for Factorio entities that composes all rendering components - """ - - def __init__(self, style: Optional[Dict] = None): - """Initialize renderer with optional custom style""" - # Core components - self.config = RenderConfig(style) - self.categorizer = EntityCategoriser() - self.color_manager = ColourManager(self.config, self.categorizer) - self.shape_renderer = ShapeRenderer(self.config) - self.connection_renderer = ConnectionRenderer(self.color_manager) - self.legend_renderer = LegendRenderer( - self.config, self.color_manager, self.categorizer, self.shape_renderer - ) - self.image_calculator = ImageCalculator(self.config) - - # Initialize layer renderers - self.layer_renderers = { - Layer.GRID: GridLayerRenderer(self.config), - Layer.WATER: WaterLayerRenderer(self.config), - Layer.RESOURCES: ResourcesLayerRenderer(self.config), - Layer.NATURAL: NaturalLayerRenderer(self.config), - Layer.ENTITIES: EntitiesLayerRenderer( - self.config, self.categorizer, self.color_manager, self.shape_renderer - ), - Layer.CONNECTIONS: ConnectionsLayerRenderer( - self.config, self.color_manager, self.connection_renderer - ), - Layer.PLAYER | Layer.ORIGIN: MarkersLayerRenderer( - self.config, self.shape_renderer - ), - Layer.ELECTRICITY: ElectricityLayerRenderer( - self.config - ), # Add the new electricity layer renderer - } - def render_entities( - self, - entities: List[Entity], - center_pos: Optional[Position] = None, - bounding_box: Optional[BoundingBox] = None, - water_tiles: Optional[List[Dict]] = None, - resource_entities: Optional[List[Dict]] = None, - trees: Optional[List[Dict]] = None, - rocks: Optional[List[Dict]] = None, - electricity_networks: Optional[List[Dict]] = None, - max_tiles: int = 20, - layers: Layer = Layer.ALL, - ) -> Image.Image: - """ - Render a list of Factorio entities to an image + + +class Renderer: + """Factorio Blueprint representation.""" + + @profile_method(include_args=True) + def __init__(self, + entities: Union[List[Dict], List[Entity]] = [], + resources: List[Dict] = [], + water_tiles: List[Dict] = [], + sprites_dir: Optional[Path] = None, + max_render_radius: Optional[float] = None, + center_on_player: bool = True): + """Initialize renderer with blueprint data. Args: entities: List of entities to render - center_pos: Optional center position (e.g. player position) - bounding_box: Optional bounding box to constrain the render area - water_tiles: Optional list of water tiles to render - resource_entities: Optional list of resource entities to render - trees: Optional list of trees to render - rocks: Optional list of rocks to render - electricity_networks: Optional list of electricity network data to render - max_tiles: Maximum number of tiles on each side of the center position - layers: Layer flags to specify which elements to render - - Returns: - PIL Image containing the rendered map + resources: List of resources to render + water_tiles: List of water tiles to render + sprites_dir: Optional directory path for sprite files + max_render_radius: Optional maximum radius to render (for trimming captured area) + center_on_player: Whether to center the rendering on the player position """ - # Track resources and natural elements present in the map for the legend - resources_present = set() - natural_elements_present = set() - - # Track entity statuses present in the map - statuses_present = set() - - # Track electricity networks and their colors - network_colors = {} - - # Add water if water tiles are present and water layer is enabled - if Layer.WATER in layers and water_tiles and len(water_tiles) > 0: - resources_present.add("water") - - # Add resources from resource_entities if resources layer is enabled - if Layer.RESOURCES in layers and resource_entities: - for resource in resource_entities: - if "name" in resource: - resources_present.add(resource["name"]) - - # Track trees and rocks if their respective layers are enabled - if Layer.TREES in layers and trees and len(trees) > 0: - natural_elements_present.add("tree") - - if Layer.ROCKS in layers and rocks and len(rocks) > 0: - natural_elements_present.add("rock") - - # Track electricity networks if that layer is enabled - if Layer.ELECTRICITY in layers and electricity_networks: - # Create a renderer to get network colors if needed - electricity_renderer = self.layer_renderers.get(Layer.ELECTRICITY) - if electricity_renderer: - # Collect all network IDs - network_ids = set() - for network in electricity_networks: - if "network_id" in network: - network_ids.add(network["network_id"]) - - # Generate colors for each network - electricity_renderer._assign_network_colors(network_ids, network_colors) - - # Assign colors to entities - self.color_manager.assign_entity_colors(entities) - - # Calculate boundaries for rendering, making sure max_tiles is passed - boundaries = self.image_calculator.calculate_boundaries( - entities, center_pos, bounding_box, max_tiles=max_tiles - ) - - # Filter entities that are outside the boundaries - filtered_entities = [] - for entity in entities: + self.icons = [] + self.max_render_radius = max_render_radius + self.center_on_player = center_on_player + + flattened_entities = list(flatten_entities(entities)) + + # Find player position if centering on player + self.player_position = None + if center_on_player: + for entity in flattened_entities: + if isinstance(entity, dict) and entity.get('name') == 'character': + pos = entity.get('position', {}) + self.player_position = {'x': pos.get('x', 0), 'y': pos.get('y', 0)} + break + elif hasattr(entity, 'name') and entity.name == 'character': + self.player_position = {'x': entity.position.x, 'y': entity.position.y} + break + + # Determine normalization offset + if self.player_position and center_on_player: + # Center on player position + self.offset_x = self.player_position['x'] + self.offset_y = self.player_position['y'] + else: + # Original behavior: normalize to minimum coordinates + min_x, min_y = self._find_min_coordinates(flattened_entities, resources, water_tiles) + self.offset_x = min_x + self.offset_y = min_y + + # Normalize all coordinates + self.entities = self._normalize_positions(flattened_entities) + self.resources = self._normalize_positions(resources) + self.water_tiles = self._normalize_positions(water_tiles) + + self.entity_grid = entities_to_grid(self.entities) + self.resource_grid = resources_to_grid(self.resources) + + self.sprites_dir = self._resolve_sprites_dir(sprites_dir) + self.available_trees = build_available_trees_index(self.sprites_dir) + self.tree_variants = self._precompute_tree_variants() + self._sort_entities_for_rendering() + + @profile_method() + def get_size(self) -> Dict: + """Calculate blueprint bounds including resources and trees.""" + + if self.max_render_radius is not None: + # When using max_render_radius, create a square centered on (0,0) + # (which is the player position after normalization) + return { + 'minX': -self.max_render_radius, + 'minY': -self.max_render_radius, + 'maxX': self.max_render_radius, + 'maxY': self.max_render_radius, + 'width': math.ceil(self.max_render_radius * 2), + 'height': math.ceil(self.max_render_radius * 2) + } + + # Original behavior for when max_render_radius is not specified + bounds = self._calculate_bounds() + + # Calculate actual content dimensions + content_width = bounds['max_width'] - bounds['min_width'] + content_height = bounds['max_height'] - bounds['min_height'] + + # Make dimensions square by using the maximum dimension + max_dimension = max(content_width, content_height) + + # Calculate how much to expand on each direction + width_diff = max_dimension - content_width + height_diff = max_dimension - content_height + + # Expand bounds to create a square area + adjusted_min_x = bounds['min_width'] - width_diff / 2 + adjusted_max_x = bounds['max_width'] + width_diff / 2 + adjusted_min_y = bounds['min_height'] - height_diff / 2 + adjusted_max_y = bounds['max_height'] + height_diff / 2 + + return { + 'minX': adjusted_min_x, + 'minY': adjusted_min_y, + 'maxX': adjusted_max_x, + 'maxY': adjusted_max_y, + 'width': math.ceil(max_dimension), + 'height': math.ceil(max_dimension) + } + + def _calculate_bounds(self) -> Dict: + """Calculate the bounding box for all entities and resources.""" + min_width = min_height = float('inf') + max_width = max_height = float('-inf') + + # Check entities + for entity in self.entities: pos = entity.position - # Check if entity is within boundaries - if ( - pos.x >= boundaries["min_x"] - and pos.x <= boundaries["max_x"] - and pos.y >= boundaries["min_y"] - and pos.y <= boundaries["max_y"] - ): - filtered_entities.append(entity) - - # Track entity status if the status indicator is enabled - if ( - self.config.style["status_indicator_enabled"] - and entity.status != EntityStatus.NORMAL - ): - statuses_present.add(entity.status) - - # Update the entity list - entities = filtered_entities - - # Always position the legend to the right of the grid - self.config.style["legend_position"] = "right_top" - - # Calculate legend dimensions - legend_dimensions = None - if self.config.style["legend_enabled"] and ( - self.color_manager.entity_colors - or resources_present - or natural_elements_present - or statuses_present - or network_colors - ): - # Create a temporary image to calculate legend dimensions properly - # Use constant base cell size for legend calculations to ensure consistent legend sizing regardless of zoom - BASE_CELL_SIZE = 20 # Base cell size - this ensures legend remains readable at all zoom levels - - tmp_width = int( - (boundaries["max_x"] - boundaries["min_x"]) * BASE_CELL_SIZE - + 2 * self.config.style["margin"] - ) - tmp_height = int( - (boundaries["max_y"] - boundaries["min_y"]) * BASE_CELL_SIZE - + 2 * self.config.style["margin"] - ) - - legend_dimensions = self.legend_renderer.calculate_legend_dimensions( - tmp_width, - tmp_height, - resources_present, - natural_elements_present, - statuses_present, - network_colors, - ) - - # Calculate final image dimensions - dimensions = self.image_calculator.calculate_image_dimensions(legend_dimensions) - img_width = dimensions["img_width"] - img_height = dimensions["img_height"] - - # Create image and drawing context - img = Image.new( - "RGBA", (img_width, img_height), self.config.style["background_color"] - ) - draw = ImageDraw.Draw(img) + size = renderer_manager.get_entity_size(entity) + min_width = min(min_width, pos.x - size[0] / 2) + min_height = min(min_height, pos.y - size[1] / 2) + max_width = max(max_width, pos.x + size[0] / 2) + max_height = max(max_height, pos.y + size[1] / 2) + + # Check resources (they are 1x1) + for resource in self.resources: + pos = resource['position'] + min_width = min(min_width, pos['x'] - 0.5) + min_height = min(min_height, pos['y'] - 0.5) + max_width = max(max_width, pos['x'] + 0.5) + max_height = max(max_height, pos['y'] + 0.5) + + # Check water tiles (they are 1x1) + for water_tile in self.water_tiles: + pos = water_tile + min_width = min(min_width, pos['x'] - 0.5) + min_height = min(min_height, pos['y'] - 0.5) + max_width = max(max_width, pos['x'] + 0.5) + max_height = max(max_height, pos['y'] + 0.5) + + # If we have no content, default to a reasonable area around origin + if min_width == float('inf'): + min_width = -10 + max_width = 10 + min_height = -10 + max_height = 10 + + return { + 'min_width': min_width, + 'min_height': min_height, + 'max_width': max_width, + 'max_height': max_height + } - # Get coordinate conversion function - game_to_img = self.image_calculator.get_game_to_image_coordinate_function() - - # Load fonts for text rendering - one for the map and one for the legend - font = self._load_font() - legend_font = self._load_legend_font() - - # Define the render order - certain layers should be rendered before others - render_order = [ - Layer.WATER, # Water tiles (background) - Layer.GRID, # Grid lines - Layer.RESOURCES, # Resource patches - Layer.ROCKS, # Rocks - Layer.TREES, # Trees - Layer.ELECTRICITY, # Electricity networks - Layer.ENTITIES, # Player-built entities - Layer.CONNECTIONS, # Underground connections - Layer.ORIGIN, # Origin marker (0,0) - Layer.PLAYER, # Player position marker + @profile_method() + def _resolve_sprites_dir(self, sprites_dir: Optional[Path]) -> Path: + """Resolve sprites directory location.""" + if sprites_dir is not None: + return sprites_dir + + possible_dirs = [ + Path(".fle/sprites"), + Path("sprites"), + Path("images"), ] - - # Common kwargs for all layer renderers - render_kwargs = { - "entities": entities, - "water_tiles": water_tiles, - "resource_entities": resource_entities, - "trees": trees, - "rocks": rocks, - "electricity_networks": electricity_networks, - "center_pos": center_pos, - "font": font, - "layers": layers, + + for dir_path in possible_dirs: + if dir_path.exists(): + return dir_path + + return find_fle_sprites_dir() + + @profile_method() + def _render_alert_overlays(self, img: Image.Image, entities, size: Dict, scaling: float, image_resolver) -> None: + """Render alert overlays for entities with non-normal status.""" + + # Status to alert icon mapping + status_alert_mapping = { + EntityStatus.NO_POWER: 'alert-no-electricity', + EntityStatus.LOW_POWER: 'alert-no-electricity', + EntityStatus.NO_FUEL: 'alert-no-fuel', + EntityStatus.EMPTY: 'alert-warning', + EntityStatus.NOT_PLUGGED_IN_ELECTRIC_NETWORK: 'alert-disconnected', + EntityStatus.CHARGING: 'alert-recharge-needed', + EntityStatus.DISCHARGING: 'alert-recharge-needed', + EntityStatus.FULLY_CHARGED: None, # No alert for fully charged + EntityStatus.NO_RECIPE: 'alert-no-building-materials', + EntityStatus.NO_INGREDIENTS: 'alert-no-building-materials', + EntityStatus.NOT_CONNECTED: 'alert-disconnected', + EntityStatus.NO_INPUT_FLUID: 'alert-no-fluid', + EntityStatus.NO_RESEARCH_IN_PROGRESS: 'alert-warning', + EntityStatus.NO_MINABLE_RESOURCES: 'alert-warning', + EntityStatus.LOW_INPUT_FLUID: 'alert-no-fluid', + EntityStatus.FLUID_INGREDIENT_SHORTAGE: 'alert-no-fluid', + EntityStatus.FULL_OUTPUT: 'alert-no-storage', + EntityStatus.FULL_BURNT_RESULT_OUTPUT: 'alert-no-storage', + EntityStatus.ITEM_INGREDIENT_SHORTAGE: 'alert-no-building-materials', + EntityStatus.MISSING_REQUIRED_FLUID: 'alert-no-fluid', + EntityStatus.MISSING_SCIENCE_PACKS: 'alert-no-building-materials', + EntityStatus.WAITING_FOR_SOURCE_ITEMS: 'alert-logistic-delivery', + EntityStatus.WAITING_FOR_SPACE_IN_DESTINATION: 'alert-no-storage', + EntityStatus.PREPARING_ROCKET_FOR_LAUNCH: 'alert-warning', + EntityStatus.WAITING_TO_LAUNCH_ROCKET: 'alert-warning', + EntityStatus.LAUNCHING_ROCKET: 'alert-warning', + EntityStatus.NO_AMMO: 'alert-no-ammo', + EntityStatus.LOW_TEMPERATURE: 'alert-warning', + EntityStatus.NOT_CONNECTED_TO_RAIL: 'alert-disconnected', } - # Render each layer in order if it's enabled - for layer_type in render_order: - if layer_type in layers: - # Find the appropriate renderer - for renderer_key, renderer in self.layer_renderers.items(): - if layer_type in renderer_key: - renderer.render(draw, game_to_img, boundaries, **render_kwargs) - break + for entity in entities: + # Handle both Entity objects and dicts + if hasattr(entity, 'status'): + status = entity.status + entity_dict = entity.model_dump() if hasattr(entity, 'model_dump') else entity.__dict__ + elif isinstance(entity, dict) and 'status' in entity: + status = entity['status'] + if isinstance(status, str): + status = EntityStatus.from_string(status) + entity_dict = entity + else: + continue # Skip entities without status + + # Not being plugged in takes precedence over no power. + if status == EntityStatus.NO_POWER: + if hasattr(entity, 'electrical_id'): + if not entity.electrical_id: + status = EntityStatus.NOT_PLUGGED_IN_ELECTRIC_NETWORK + elif isinstance(entity, dict) and 'electrical_id' in entity: + if not entity['electrical_id']: + status = EntityStatus.NOT_PLUGGED_IN_ELECTRIC_NETWORK + + + # Skip if status is NORMAL or WORKING + if status in (EntityStatus.NORMAL, EntityStatus.WORKING): + continue + + # Get the appropriate alert icon + alert_name = status_alert_mapping.get(status, 'alert-warning') + if not alert_name: + continue + + # Load the alert icon + alert_icon = image_resolver(alert_name, False) + if not alert_icon: + # Try with icon_ prefix as fallback + alert_icon = image_resolver(f"icon_{alert_name}", False) + if not alert_icon: + profiler.increment_counter('alert_icon_not_found') + continue + + # Get entity position and size + pos = entity_dict.get('position', {}) + if hasattr(pos, 'x'): + x, y = pos.x, pos.y + else: + x = pos.get('x', 0) + y = pos.get('y', 0) + + if hasattr(entity, 'tile_dimensions'): + entity_size = (entity.tile_dimensions.tile_width, entity.tile_dimensions.tile_height) + else: + entity_size = renderer_manager.get_entity_size(entity) + + # Scale the alert icon to 2x its original size + scale_factor = 0.5 if entity_size[0] + entity_size[1] > 2 else 0.25 + new_width = int(alert_icon.width * scale_factor) + new_height = int(alert_icon.height * scale_factor) + alert_icon = alert_icon.resize((new_width, new_height), Image.Resampling.LANCZOS) + + # Convert alert icon to RGBA if it isn't already + if alert_icon.mode != 'RGBA': + alert_icon = alert_icon.convert('RGBA') + + # Apply 50% alpha by modifying the alpha channel + pixels = alert_icon.load() + for y_pixel in range(new_height): + for x_pixel in range(new_width): + r, g, b, a = pixels[x_pixel, y_pixel] + # Reduce alpha to 50% of its original value + pixels[x_pixel, y_pixel] = (r, g, b, int(a * 0.75)) + + # Calculate position for alert overlay + # Place alert in top-right corner of entity + relative_x = x + abs(size['minX']) + relative_y = y + abs(size['minY']) + + # Calculate pixel position + # Offset to place icon in top-right of entity + # Adjust offset for the larger icon size + icon_offset_x = 0#(entity_size[0] / 2) * scaling - new_width * 0.6 + icon_offset_y = 0#-(entity_size[1] / 2) * scaling - new_height * 0.2 + + start_x = int((relative_x * scaling + scaling / 2) - new_width / 2 + icon_offset_x) + start_y = int((relative_y * scaling + scaling / 2) - new_height / 2 + icon_offset_y) + + # Paste the alert icon with alpha channel + img.paste(alert_icon, (start_x, start_y), alert_icon) + + profiler.increment_counter('alert_overlays_rendered') + + @profile_method() + def _precompute_tree_variants(self) -> Dict: + """Pre-calculate tree variants for sorting.""" + tree_variants = {} + + for e in self.entities: + entity = e.model_dump() if not isinstance(e, dict) else e + + if not is_tree_entity(entity['name']): + continue + + x = entity['position']['x'] + y = entity['position']['y'] + tree_type = entity['name'].split('-')[-1] if '-' in entity['name'] else '01' + + if 'dead' not in entity['name'] and 'dry' not in entity['name']: + variation, _ = get_tree_variant(x, y, tree_type, self.available_trees) + tree_variants[id(entity)] = variation + else: + tree_variants[id(entity)] = 'z' # Sort after all regular trees + + return tree_variants + + @profile_method() + def _sort_entities_for_rendering(self) -> None: + """Sort entities for proper rendering order.""" + + self.entities.sort(key=lambda e: ( + not is_tree_entity(e.name), # Trees first + -ord(self.tree_variants.get(id(e), 'a')) if is_tree_entity(e.name) else 0, + not e.name.endswith('inserter'), + e.position.y, + e.position.x + )) + + # @profile_method() + # def get_size(self) -> Dict: + # """Calculate blueprint bounds including resources and trees.""" + # bounds = self._calculate_bounds() + # + # # Calculate actual content dimensions (not including origin distance) + # content_width = bounds['max_width'] - bounds['min_width'] + # content_height = bounds['max_height'] - bounds['min_height'] + # + # # Make dimensions square by using the minimum + # min_dimension = min(content_width, content_height) + # + # # Calculate how much to crop from each direction + # width_diff = content_width - min_dimension + # height_diff = content_height - min_dimension + # + # # Crop bounds to create a square area + # # Split the difference evenly on both sides + # adjusted_min_x = bounds['min_width'] + width_diff / 2 + # adjusted_max_x = bounds['max_width'] - width_diff / 2 + # adjusted_min_y = bounds['min_height'] + height_diff / 2 + # adjusted_max_y = bounds['max_height'] - height_diff / 2 + # + # return { + # 'minX': adjusted_min_x, + # 'minY': adjusted_min_y, + # 'maxX': adjusted_max_x, + # 'maxY': adjusted_max_y, + # 'width': math.ceil(min_dimension), + # 'height': math.ceil(min_dimension) + # } + + def _get_position(self, item: Any) -> Optional[Dict[str, float]]: + """Extract position from an item, handling both dict and object formats. + + Returns position as dict with 'x' and 'y' keys, or None if no position found. + """ + # Handle water tiles which have x,y directly + if isinstance(item, dict) and 'x' in item and 'y' in item and 'position' not in item: + return {'x': item['x'], 'y': item['y']} + + # Handle items with position attribute/key + if hasattr(item, 'position'): + pos = item.position + if hasattr(pos, 'x') and hasattr(pos, 'y'): + return {'x': pos.x, 'y': pos.y} + elif isinstance(pos, dict): + return pos + elif isinstance(item, dict) and 'position' in item: + return item['position'] + + return None + + def _set_position(self, item: Any, x: float, y: float) -> Any: + """Set position on an item, handling both dict and object formats. + + Returns a copy of the item with updated position. + """ + item_copy = copy.deepcopy(item) + + # Handle water tiles which have x,y directly + if isinstance(item_copy, dict) and 'x' in item_copy and 'y' in item_copy and 'position' not in item_copy: + item_copy['x'] = x + item_copy['y'] = y + # Handle items with position attribute + elif hasattr(item_copy, 'position'): + if hasattr(item_copy.position, 'x') and hasattr(item_copy.position, 'y'): + item_copy.position.x = x + item_copy.position.y = y + elif isinstance(item_copy.position, dict): + item_copy.position['x'] = x + item_copy.position['y'] = y + # Handle dict items with position key + elif isinstance(item_copy, dict) and 'position' in item_copy: + item_copy['position']['x'] = x + item_copy['position']['y'] = y + + return item_copy + + def _find_min_coordinates(self, entities: List[Any], resources: List[Any], water_tiles: List[Any]) -> tuple[ + float, float]: + """Find minimum x and y coordinates across all items.""" + min_x, min_y = float('inf'), float('inf') + + # Check all items for minimum coordinates + all_items = list(entities) + list(resources) + list(water_tiles) + + for item in all_items: + pos = self._get_position(item) + if pos: + min_x = min(min_x, pos['x']) + min_y = min(min_y, pos['y']) + + # If no positions found, default to 0,0 + if min_x == float('inf'): + min_x = 0 + if min_y == float('inf'): + min_y = 0 + + return min_x, min_y + + def _normalize_positions(self, items: List[Any]) -> List[Any]: + """Normalize positions of all items by subtracting minimum coordinates.""" + normalized = [] + + for item in items: + pos = self._get_position(item) + if pos: + # Create normalized copy with adjusted position + normalized_item = self._set_position( + item, + pos['x'] - self.offset_x, + pos['y'] - self.offset_y + ) + normalized.append(normalized_item) + else: + # No position to normalize, keep as is + normalized.append(copy.deepcopy(item)) + + return normalized + + # def _calculate_bounds(self) -> Dict: + # """Calculate the bounding box for all entities and resources.""" + # min_width = min_height = 0 + # max_width = max_height = 0 + # + # # Check entities + # for entity in self.entities: + # pos = entity.position + # size = renderer_manager.get_entity_size(entity) + # min_width = min(min_width, pos.x - size[0] / 2) + # min_height = min(min_height, pos.y - size[1] / 2) + # max_width = max(max_width, pos.x + size[0] / 2) + # max_height = max(max_height, pos.y + size[1] / 2) + # + # # Check resources (they are 1x1) + # for resource in self.resources: + # pos = resource['position'] + # min_width = min(min_width, pos['x'] - 0.5) + # min_height = min(min_height, pos['y'] - 0.5) + # max_width = max(max_width, pos['x'] + 0.5) + # max_height = max(max_height, pos['y'] + 0.5) + # + # # Check water tiles (they are 1x1) + # for water_tile in self.water_tiles: + # pos = water_tile + # min_width = min(min_width, pos['x'] - 0.5) + # min_height = min(min_height, pos['y'] - 0.5) + # max_width = max(max_width, pos['x'] + 0.5) + # max_height = max(max_height, pos['y'] + 0.5) + # + # # If max_render_radius is specified, limit the bounds + # if self.max_render_radius is not None: + # # Assume we're centered at (0, 0) after normalization + # min_width = max(min_width, -self.max_render_radius) + # min_height = max(min_height, -self.max_render_radius) + # max_width = min(max_width, self.max_render_radius) + # max_height = min(max_height, self.max_render_radius) + # + # return { + # 'min_width': min_width, + # 'min_height': min_height, + # 'max_width': max_width, + # 'max_height': max_height + # } + + @profile_method(include_args=True) + def render(self, width: int, height: int, image_resolver) -> Image.Image: + """Render blueprint to image. + + Args: + width: Output image width + height: Output image height + image_resolver: Function to resolve sprite images + + Returns: + Rendered PIL Image + """ + size = self.get_size() + scaling = min(width / size['width'], height / size['height']) + + img = self._create_base_image(width, height) + self._draw_grid(img, size, scaling, width, height) + + # Separate entities for proper rendering order + tree_entities = [e.model_dump() if not isinstance(e, dict) else e for e in self.entities if is_tree_entity(e.name)] + rock_entities = [e.model_dump() if not isinstance(e, dict) else e for e in self.entities if is_rock_entity(e.name)] + player_entities = [e for e in self.entities if not is_tree_entity(e.name) and not is_rock_entity(e.name)] + + # Expand consolidate underground belts into pairs + player_entities = self._disintegrate_underground_belts(player_entities) + + grid_view = EntityGridView(self.entity_grid, 0, 0, self.available_trees) + + # Record entity counts for profiling + profiler.increment_counter('total_entities', len(self.entities)) + profiler.increment_counter('tree_entities', len(tree_entities)) + profiler.increment_counter('rock_entities', len(rock_entities)) + profiler.increment_counter('player_entities', len(player_entities)) + profiler.increment_counter('resources', len(self.resources)) + profiler.increment_counter('water_tiles', len(self.water_tiles)) + + # Render in order: water -> resources -> tree shadows -> trees -> entity shadows -> rails -> entities + self._render_water_tiles(img, size, scaling, image_resolver) + self._render_resources(img, size, scaling, image_resolver) + self._render_tree_shadows(img, tree_entities, size, scaling, grid_view, image_resolver) + self._render_trees(img, tree_entities, size, scaling, grid_view, image_resolver) + self._render_decoratives(img, rock_entities, size, scaling, image_resolver) + + self._render_entity_shadows(img, player_entities, size, scaling, grid_view, image_resolver) + self._render_rails(img, player_entities, size, scaling, image_resolver) + self._render_entities(img, player_entities, size, scaling, grid_view, image_resolver) + + # This is needed for belts + self._render_visible_inventories(img, player_entities, size, scaling, grid_view, image_resolver) + + # This should be last so alerts appear on top of everything + self._render_alert_overlays(img, player_entities, size, scaling, image_resolver) + return img - # Draw the legend with resources, natural elements, statuses, and electricity networks - # Use the legend_font for consistent readability regardless of zoom - self.legend_renderer.draw_combined_legend( - draw, - img_width, - img_height, - legend_font, - resources_present, - natural_elements_present, - statuses_present, - network_colors, - ) + def _disintegrate_underground_belts(self, player_entities): + entities = [] + for entity in player_entities: + if isinstance(entity, UndergroundBelt): + # input + entities.append(entity) + # output + output = copy.deepcopy(entity) + output.is_input = False + output.position = output.output_position + entities.append(output) + else: + entities.append(entity) + return entities + + @profile_method() + def _create_base_image(self, width: int, height: int) -> Image.Image: + """Create base image with background color.""" + return Image.new('RGB', (width, height), BACKGROUND_COLOR) + + @profile_method() + def _draw_grid(self, img: Image.Image, size: Dict, scaling: float, width: int, height: int) -> None: + """Draw grid lines on the image with different thicknesses based on game positions.""" + draw = ImageDraw.Draw(img) - return img + # Get the original game space offset that was used for normalization + # Round the offset to ensure we're aligned with integer game coordinates + game_offset_x = round(self.offset_x) + game_offset_y = round(self.offset_y) + + # Calculate the visible range in actual game coordinates + # We want to draw lines at integer game positions only + min_game_x = int(math.floor(size['minX'] + game_offset_x)) + max_game_x = int(math.ceil(size['maxX'] + game_offset_x)) + min_game_y = int(math.floor(size['minY'] + game_offset_y)) + max_game_y = int(math.ceil(size['maxY'] + game_offset_y)) + + # Draw vertical lines at integer game positions + for game_x in range(min_game_x, max_game_x + 1): + # Convert game position back to normalized coordinate for pixel calculation + norm_x = game_x - game_offset_x + + # Calculate pixel position + pixel_x = (norm_x - size['minX']) * scaling + + # Skip if line is outside visible area + if pixel_x < -5 or pixel_x > width + 5: + continue + + # Determine line properties based on game coordinate + if game_x % 10 == 0: + line_width = GRID_LINE_WIDTH_THICK + line_color = GRID_COLOR_THICK + elif game_x % 5 == 0: + line_width = GRID_LINE_WIDTH_MEDIUM + line_color = GRID_COLOR_MEDIUM + else: + line_width = GRID_LINE_WIDTH_THIN + line_color = GRID_COLOR_THIN + + # Draw line precisely at the integer position + x_center = int(pixel_x) + half_width = line_width // 2 + x_start = x_center - half_width + x_end = x_center + half_width + + # Ensure odd-width lines are symmetric + if line_width % 2 == 1: + x_end += 1 + + # Clip to image bounds + x_start = max(0, x_start) + x_end = min(width, x_end) + + if x_end > x_start: + draw.rectangle([x_start, 0, x_end, height], fill=line_color) + + # Draw horizontal lines at integer game positions + for game_y in range(min_game_y, max_game_y + 1): + # Convert game position back to normalized coordinate for pixel calculation + norm_y = game_y - game_offset_y + + # Calculate pixel position + pixel_y = (norm_y - size['minY']) * scaling + + # Skip if line is outside visible area + if pixel_y < -5 or pixel_y > height + 5: + continue + + # Determine line properties based on game coordinate + if game_y % 10 == 0: + line_width = GRID_LINE_WIDTH_THICK + line_color = GRID_COLOR_THICK + elif game_y % 5 == 0: + line_width = GRID_LINE_WIDTH_MEDIUM + line_color = GRID_COLOR_MEDIUM + else: + line_width = GRID_LINE_WIDTH_THIN + line_color = GRID_COLOR_THIN + + # Draw line precisely at the integer position + y_center = int(pixel_y) + half_width = line_width // 2 + y_start = y_center - half_width + y_end = y_center + half_width + + # Ensure odd-width lines are symmetric + if line_width % 2 == 1: + y_end += 1 + + # Clip to image bounds + y_start = max(0, y_start) + y_end = min(height, y_end) + + if y_end > y_start: + draw.rectangle([0, y_start, width, y_end], fill=line_color) + + @profile_method() + def _render_resources(self, img: Image.Image, size: Dict, scaling: float, image_resolver) -> None: + """Render resource patches.""" + for resource in self.resources: + pos = resource['position'] + relative_x = pos['x'] + abs(size['minX']) + relative_y = pos['y'] + abs(size['minY']) + + if resource['name'] == 'crude-oil': + volume = 1 + variant = get_resource_variant(pos['x'], pos['y'], max_variants=OIL_RESOURCE_VARIANTS) + else: + volume = get_resource_volume(resource.get('amount', 10000)) + variant = get_resource_variant(pos['x'], pos['y']) + + sprite_name = f"{resource['name']}_{variant}_{volume}" + image = image_resolver(sprite_name, False) + + if image: + self._paste_image(img, image, relative_x, relative_y, scaling) + + def _render_decoratives(self, img: Image.Image, decoratives: List[Dict], size: Dict, scaling: float, image_resolver) -> None: + """Render decoratives.""" + for decorative in decoratives: + pos = decorative['position'] + relative_x = pos['x'] + abs(size['minX']) + relative_y = pos['y'] + abs(size['minY']) + + variant = get_resource_variant(pos['x'], pos['y'], max_variants=DEFAULT_ROCK_VARIANTS) + + sprite_name = f"{decorative['name']}_{variant}" + image = image_resolver(sprite_name, False) + + if image: + self._paste_image(img, image, relative_x, relative_y, scaling) + else: + while not image and variant < DEFAULT_ROCK_VARIANTS: + variant = variant + 1 + sprite_name = f"{decorative['name']}_{variant}" + image = image_resolver(sprite_name, False) + if image: + self._paste_image(img, image, relative_x, relative_y, scaling) + break + + @profile_method() + def _render_water_tiles(self, img: Image.Image, size: Dict, scaling: float, image_resolver) -> None: + """Render water tiles.""" + for water in self.water_tiles: + pos = water + + relative_x = pos['x'] + abs(size['minX']) + 0.5 + relative_y = pos['y'] + abs(size['minY']) + 0.5 + + volume = 1 + variant = get_resource_variant(pos['x'], pos['y'], max_variants=DEFAULT_RESOURCE_VARIANTS) + + sprite_name = f"{water['name']}_{variant}_{volume}" + image = image_resolver(sprite_name, False) + + if image: + self._paste_image(img, image, relative_x, relative_y, scaling) + + @profile_method() + def _render_tree_shadows(self, + img: Image.Image, + tree_entities, + size: Dict, + scaling: float, + grid_view, + image_resolver) -> None: + + """Render tree shadows.""" + for tree in tree_entities: + pos = tree['position'] + relative_x = pos['x'] + abs(size['minX']) + relative_y = pos['y'] + abs(size['minY']) + + grid_view.set_center(pos['x'], pos['y']) + renderer = renderer_manager.get_renderer(tree['name']) + + if renderer and hasattr(renderer, 'render_shadow'): + shadow_image = renderer.render_shadow(tree, grid_view, image_resolver) + if shadow_image: + shadow_offset_x = 32 # We need to offset tree shadows + shadow_offset_y = 32 # We need to offset tree shadows + self._paste_image(img, shadow_image, relative_x, relative_y, scaling, + shadow_offset_x, shadow_offset_y) + + @profile_method() + def _render_trees(self, img: Image.Image, tree_entities, size: Dict, scaling: float, grid_view, image_resolver) -> None: + """Render trees.""" + for tree in tree_entities: + pos = tree['position'] + relative_x = pos['x'] + abs(size['minX']) + relative_y = pos['y'] + abs(size['minY']) + + grid_view.set_center(pos['x'], pos['y']) + renderer = renderer_manager.get_renderer(tree['name']) + + if renderer and hasattr(renderer, 'render'): + tree_image = renderer.render(tree, grid_view, image_resolver) + if tree_image: + self._paste_image(img, tree_image, relative_x, relative_y, scaling) + + @profile_method() + def _render_entity_shadows(self, img: Image.Image, non_tree_entities, size: Dict, scaling: float, grid_view, image_resolver) -> None: + """Render entity shadows.""" + for entity in non_tree_entities: + entity = entity.model_dump() if hasattr(entity, 'model_dump') else entity + pos = entity['position'] + relative_x = pos['x'] + abs(size['minX']) + relative_y = pos['y'] + abs(size['minY']) + + grid_view.set_center(pos['x'], pos['y']) + image = None + + if entity['name'] in RENDERERS: + renderer = renderer_manager.get_renderer(entity['name']) + if renderer and hasattr(renderer, 'render_shadow'): + + if 'direction' in entity: + entity['direction'] = int(entity['direction'].value) + image = renderer.render_shadow(entity, grid_view, image_resolver) + else: + image = image_resolver(entity['name'], True) + + if image: + # Apply shadow offset for character entities + if entity['name'] == 'character': + shadow_offset_x = 32 # Character shadows need less offset than trees + shadow_offset_y = 20 + self._paste_image(img, image, relative_x, relative_y, scaling, + shadow_offset_x, shadow_offset_y) + else: + self._paste_image(img, image, relative_x, relative_y, scaling) + + @profile_method() + def _render_visible_inventories(self, img: Image.Image, entities, size: Dict, scaling: float, grid_view, image_resolver) -> None: + """Render entity shadows.""" + for entity in entities: + entity = entity.model_dump() if hasattr(entity, 'model_dump') else entity + pos = entity['position'] + relative_x = pos['x'] + abs(size['minX']) + relative_y = pos['y'] + abs(size['minY']) + + grid_view.set_center(pos['x'], pos['y']) + image = None + + if entity['name'] in RENDERERS: + renderer = renderer_manager.get_renderer(entity['name']) + if renderer and hasattr(renderer, 'render_inventory'): + image = renderer.render_inventory(entity, grid_view, image_resolver) + + if image: + self._paste_image(img, image, relative_x, relative_y, scaling) + + @profile_method() + def _render_rails(self, img: Image.Image, non_tree_entities, size: Dict, scaling: float, image_resolver) -> None: + """Render rail entities with multiple passes.""" + passes = [1, 2, 3, 3.5, 4, 5] + + for pass_num in passes: + for entity in non_tree_entities: + entity = entity.model_dump() if hasattr(entity, 'model_dump') else entity + if entity['name'] not in ['straight-rail', 'curved-rail', 'rail-signal', 'rail-chain-signal']: + continue + + pos = entity['position'] + relative_x = pos['x'] + abs(size['minX']) + relative_y = pos['y'] + abs(size['minY']) + direction = entity.get('direction', 0) + image = None + + if entity.name == 'straight-rail': + if direction in [0, 4]: + image = image_resolver(f"{entity.name}_vertical_pass_{int(pass_num)}", False) + elif direction in [2, 6]: + image = image_resolver(f"{entity.name}_horizontal_pass_{int(pass_num)}", False) + + if image: + self._paste_image(img, image, relative_x, relative_y, scaling) + + @profile_method() + def _render_entities(self, img: Image.Image, non_tree_entities, size: Dict, scaling: float, grid_view, image_resolver) -> None: + """Render non-rail entities.""" + for entity in non_tree_entities: + if entity.name in ['straight-rail', 'curved-rail']: + continue - def _load_font(self) -> ImageFont.ImageFont: - """Load a font for text rendering with fallbacks""" - try: - font = ImageFont.truetype("arial.ttf", size=10) - except IOError: - try: - # Try another common font on different systems - font = ImageFont.truetype("DejaVuSans.ttf", size=10) - except IOError: - # Fallback to default font - font = ImageFont.load_default() - return font - - def _load_legend_font(self) -> ImageFont.ImageFont: - """Load a font specifically for the legend with a consistent size""" - legend_font_size = self.config.style.get("legend_font_size", 10) - try: - font = ImageFont.truetype("arial.ttf", size=legend_font_size) - except IOError: - try: - # Try another common font on different systems - font = ImageFont.truetype("DejaVuSans.ttf", size=legend_font_size) - except IOError: - # Fallback to default font - font = ImageFont.load_default() - return font + pos = entity.position + relative_x = pos.x + abs(size['minX']) + relative_y = pos.y + abs(size['minY']) + + grid_view.set_center(pos.x, pos.y) + image = None + + if entity.name in RENDERERS: + renderer = renderer_manager.get_renderer(entity.name) + if renderer and hasattr(renderer, 'render'): + entity_dict = entity.model_dump() + if 'direction' in entity_dict: + entity_dict['direction'] = int(entity_dict['direction'].value) + image = renderer.render(entity_dict, grid_view, image_resolver) + else: + image = image_resolver(entity.name, False) + + if image: + self._paste_image(img, image, relative_x, relative_y, scaling) + + @profile_method() + def _paste_image(self, img: Image.Image, + sprite: Image.Image, + relative_x: float, + relative_y: float, + scaling: float, + offset_x: int = 0, + offset_y: int = 0) -> None: + """Paste a sprite image onto the main image at the specified position.""" + start_x = int((relative_x * scaling + scaling / 2) - sprite.width / 2) + offset_x + start_y = int((relative_y * scaling + scaling / 2) - sprite.height / 2) + offset_y + mask = sprite if sprite.mode == 'RGBA' else None + img.paste(sprite, (start_x, start_y), mask) + + +def main(): + """Example usage""" + sprites_dir = Path("../.fle/sprites") + image_resolver = ImageResolver(str(sprites_dir)) + + with open("/Users/jackhopkins/PycharmProjects/PaperclipMaximiser/fle/agents/data/sprites/sample_blueprint.json", "r") as f: + blueprint_json = json.loads(f.read().strip()) + + blueprint = Renderer(blueprint_json, sprites_dir) + size = blueprint.get_size() + width = size['width'] * DEFAULT_SCALING + height = size['height'] * DEFAULT_SCALING + + image = blueprint.render(width, height, image_resolver) + image.show() + print(f"Blueprint rendered ({width}x{height})") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderer_manager.py b/fle/env/tools/admin/render/renderer_manager.py new file mode 100644 index 000000000..2605373c6 --- /dev/null +++ b/fle/env/tools/admin/render/renderer_manager.py @@ -0,0 +1,72 @@ +"""Renderer management and caching.""" + +from typing import Dict, Optional, Tuple, Any, Union + +from fle.env import EntityCore +from .constants import RENDERERS +from .profiler import profiler, profile_method + + +class RendererManager: + """Manages renderer modules and caching.""" + + def __init__(self): + """Initialize renderer manager.""" + self._renderer_cache: Dict[str, Any] = {} + + @profile_method(include_args=True) + def get_renderer(self, entity_name: str) -> Optional[Any]: + """Get renderer module for entity. + + Args: + entity_name: Name of the entity + + Returns: + Renderer module if available, None otherwise + """ + renderer_name = RENDERERS.get(entity_name) + if not renderer_name: + return None + + if renderer_name not in self._renderer_cache: + self._load_renderer(renderer_name) + + return self._renderer_cache.get(renderer_name) + + def _load_renderer(self, renderer_name: str) -> None: + """Load renderer module dynamically. + + Args: + renderer_name: Name of the renderer to load + """ + try: + module_name = renderer_name.replace("-", "_") + module = __import__(f'fle.env.tools.admin.render.renderers.{module_name}', fromlist=['']) + self._renderer_cache[renderer_name] = module + except ImportError as e: + print(f"Warning: Could not import renderer for {renderer_name}: {e}") + self._renderer_cache[renderer_name] = None + + def get_entity_size(self, entity: Union[Dict, EntityCore]) -> Tuple[float, float]: + """Get entity size. + + Args: + entity: Entity dictionary + + Returns: + Tuple of (width, height) in tiles + """ + if isinstance(entity, dict): + renderer = self.get_renderer(entity['name']) + else: + renderer = self.get_renderer(entity.name) + + if renderer and hasattr(renderer, 'get_size'): + _entity = entity.model_dump() + return renderer.get_size(_entity) + + return (1.0, 1.0) + + +# Global renderer manager instance +renderer_manager = RendererManager() \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/__init__.py b/fle/env/tools/admin/render/renderers/__init__.py new file mode 100644 index 000000000..53aa1b342 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/__init__.py @@ -0,0 +1,31 @@ +# renderers/__init__.py +""" +Base renderer functionality for entity rendering +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + + +class BaseRenderer: + """Base class for entity renderers""" + + @staticmethod + def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render entity""" + raise NotImplementedError + + @staticmethod + def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render entity shadow""" + return None + + @staticmethod + def get_key(entity: Dict, grid) -> str: + """Get cache key for entity state""" + return str(entity.get('direction', 0)) + + @staticmethod + def get_size(entity: Dict) -> Tuple[float, float]: + """Get entity size in tiles""" + return (1, 1) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/arithmetic_combinator.py b/fle/env/tools/admin/render/renderers/arithmetic_combinator.py new file mode 100644 index 000000000..a83f57814 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/arithmetic_combinator.py @@ -0,0 +1,96 @@ +# renderers/arithmetic_combinator.py +""" +Arithmetic combinator renderer with display +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + +from fle.env import EntityCore + +DIRECTIONS = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" +} + +COMBINATOR_TO_NORMAL = { + None: "empty", + "+": "plus", + "-": "minus", + "*": "multiply", + "/": "divide", + "%": "modulo", + "^": "power", + "<<": "left_shift", + ">>": "right_shift", + "&": "and", + "and": "and", + "AND": "and", + "|": "or", + "or": "or", + "OR": "or", + "xor": "xor", + "XOR": "xor" +} + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render arithmetic combinator with display""" + #entity = ent.model_dump() + direction = entity.get('direction', 0) + base = image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}") + + if base is None: + return None + + # Check for control behavior + control = entity.get('control_behavior', {}) + conditions = control.get('arithmetic_conditions', {}) + operation = conditions.get('operation') + + if operation is None: + return base + + # Create a copy to modify + result = base.copy() + display_name = COMBINATOR_TO_NORMAL.get(operation, 'empty') + icon = image_resolver(f"display_{display_name}") + + if icon: + # Position based on direction + if direction in [0, 4]: + x, y = 36, 22 + else: + x, y = 36, 20 + + result.paste(icon, (x, y), icon if icon.mode == 'RGBA' else None) + + return result + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Arithmetic combinators have no shadows""" + return None + + +def get_key(entity: Dict, grid) -> str: + """Get cache key including operation""" + direction = entity.get('direction', 0) + control = entity.get('control_behavior', {}) + conditions = control.get('arithmetic_conditions', {}) + operation = conditions.get('operation', '') + + if operation: + return f"{direction}_{operation}" + return str(direction) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Get size based on direction""" + direction = entity.get('direction', 0) + if direction in [2, 6]: # East/West + return (2, 1) + else: # North/South + return (1, 2) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/assembling_machine.py b/fle/env/tools/admin/render/renderers/assembling_machine.py new file mode 100644 index 000000000..fde1231ff --- /dev/null +++ b/fle/env/tools/admin/render/renderers/assembling_machine.py @@ -0,0 +1,69 @@ +# renderers/assembling_machine.py +""" +Assembling machine renderer with recipe icons +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image, ImageDraw + +from fle.env import EntityCore + +DIRECTIONS = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" +} + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render assembling machine with recipe icon""" + base_image = image_resolver(entity['name']) + if base_image is None: + return None + + # If no recipe, return base image + if 'recipe' not in entity: + return base_image + + # Create a copy to modify + result = base_image.copy() + + # Try to get recipe icon + icon = image_resolver(f"icon_{entity['recipe']}") + if icon: + # Draw dark circle background + draw = ImageDraw.Draw(result) + center_x = result.width // 2 + center_y = result.height // 2 - 10 + radius = 23 + + draw.ellipse( + [center_x - radius, center_y - radius, + center_x + radius, center_y + radius], + fill=(0, 0, 0, 166) + ) + + # Paste icon + icon_x = center_x - icon.width // 2 + icon_y = center_y - icon.height // 2 + result.paste(icon, (icon_x, icon_y), icon if icon.mode == 'RGBA' else None) + + return result + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render shadow""" + return image_resolver(entity['name'], True) + + +def get_key(entity: Dict, grid) -> str: + """Get cache key including recipe""" + recipe = entity.get('recipe', '') + direction = entity.get('direction', 0) + return f"{recipe}_{direction}" + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Assembling machine is 3x3""" + return (3, 3) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/boiler.py b/fle/env/tools/admin/render/renderers/boiler.py new file mode 100644 index 000000000..d4078a642 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/boiler.py @@ -0,0 +1,40 @@ +# renderers/boiler.py +""" +Boiler renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + +DIRECTIONS = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" +} + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render boiler""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}") + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render shadow""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}", True) + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Get boiler size based on direction""" + direction = entity.get('direction', 0) + if direction in [2, 6]: # East/West + return (2, 3) + else: # North/South + return (3, 2) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/burner_mining_drill.py b/fle/env/tools/admin/render/renderers/burner_mining_drill.py new file mode 100644 index 000000000..f6c8d45e4 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/burner_mining_drill.py @@ -0,0 +1,36 @@ +# renderers/burner_mining_drill.py +""" +Burner mining drill renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + +DIRECTIONS = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" +} + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render burner mining drill""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}") + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render shadow""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}", True) + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Burner mining drill is 2x2""" + return (2, 2) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/character.py b/fle/env/tools/admin/render/renderers/character.py new file mode 100644 index 000000000..e9102cf09 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/character.py @@ -0,0 +1,326 @@ +# renderers/character.py +""" +Character renderer for Factorio player characters +Uses sprites with naming format: name_{variant}_{direction}.png +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image +import os + +# Default player colors (can be customized) +DEFAULT_PLAYER_COLOR = (255, 165, 0) # Orange + +# Sprite sheet configurations +SPRITE_CONFIGS = { + 'idle': { + 'grid': (22, 8), + 'directions': 'standard' + }, + 'idle_gun': { + 'grid': (22, 8), + 'directions': 'standard' + }, + 'running': { + 'grid': (22, 8), + 'directions': 'standard' + }, + 'running_gun': { + 'grid': (22, 18), + 'directions': 'standard' + }, + 'mining': { + 'grid': (13, 8), + 'directions': 'mining' + }, + 'dead': { + 'grid': (2, 1), + 'directions': 'dead' + } +} + +# Direction mappings for different sprite types +DIRECTION_MAPPINGS = { + 'standard': { + 0: 0, # North + 1: 1, # North-East + 2: 2, # East + 3: 3, # South-East + 4: 4, # South + 5: 5, # South-West + 6: 6, # West + 7: 7 # North-West + }, + 'mining': { + 0: 0, # North + 1: 0, # NE -> North + 2: 3, # East + 3: 3, # SE -> East + 4: 6, # South + 5: 6, # SW -> South + 6: 9, # West + 7: 9 # NW -> West + }, + 'dead': { + 0: 0, # North/South + 1: 1, # NE -> East/West + 2: 1, # East/West + 3: 1, # SE -> East/West + 4: 0, # South -> North/South + 5: 0, # SW -> North/South + 6: 1, # West -> East/West + 7: 1 # NW -> East/West + } +} + + +def get_sprite_config(state: str, has_gun: bool = False) -> Dict: + """Get the sprite configuration for a given state.""" + if state == 'idle': + return SPRITE_CONFIGS['idle_gun' if has_gun else 'idle'] + elif state == 'running': + return SPRITE_CONFIGS['running_gun' if has_gun else 'running'] + elif state == 'mining': + return SPRITE_CONFIGS['mining'] + elif state == 'dead': + return SPRITE_CONFIGS['dead'] + else: + return SPRITE_CONFIGS['idle'] + + +def apply_color_to_mask(mask: Image.Image, color: Tuple[int, int, int]) -> Image.Image: + """Apply color tinting to a mask image. + + Args: + mask: The mask image (grayscale) + color: RGB color tuple to apply + + Returns: + Colored mask image + """ + # Convert mask to RGBA if not already + if mask.mode != 'RGBA': + mask = mask.convert('RGBA') + + # Create a colored overlay + colored = Image.new('RGBA', mask.size, tuple(color + [255])) + + # Use the mask's alpha channel to blend + result = Image.new('RGBA', mask.size, (0, 0, 0, 0)) + + # Apply color based on mask brightness + pixels = mask.load() + result_pixels = result.load() + + for y in range(mask.height): + for x in range(mask.width): + r, g, b, a = pixels[x, y] + # Use the brightness of the mask pixel to determine opacity + brightness = (r + g + b) // 3 + if brightness > 0: + result_pixels[x, y] = ( + int(color[0] * brightness / 255), + int(color[1] * brightness / 255), + int(color[2] * brightness / 255), + a + ) + + return result + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render character based on state and direction.""" + # Get character properties + direction = entity.get('direction', 0) + state = entity.get('state', 'idle') # idle, running, mining, dead + level = entity.get('level', 1) # armor level: 1, 2, or 3 + has_gun = entity.get('has_gun', False) + player_color = entity.get('color', DEFAULT_PLAYER_COLOR) + animation_frame = entity.get('animation_frame', 0) + + # Get sprite configuration + config = get_sprite_config(state, has_gun) + cols, rows = config['grid'] + direction_mapping = DIRECTION_MAPPINGS[config['directions']] + + # Determine sprite sheet names based on state + if state == 'idle': + if has_gun: + base_name = f"level{level}_idle_gun" + mask_name = f"level{level}_idle_gun_mask" + else: + base_name = f"level{level}_idle" + mask_name = f"level{level}_idle_mask" + elif state == 'running': + if has_gun: + base_name = f"level{level}_running_gun" + mask_name = f"level{level}_running_gun_mask" + else: + base_name = f"level{level}_running" + mask_name = f"level{level}_running_mask" + elif state == 'mining': + base_name = f"level{level}_mining_tool" + mask_name = f"level{level}_mining_tool_mask" + elif state == 'dead': + base_name = f"level{level}_dead" + mask_name = f"level{level}_dead_mask" + else: + # Default to idle + base_name = f"level{level}_idle" + mask_name = f"level{level}_idle_mask" + + # Handle armor addons for levels 2 and 3 + if level > 1: + base_name = base_name.replace(f"level{level}", f"level{level}addon") + mask_name = mask_name.replace(f"level{level}", f"level{level}addon") + + # Calculate variant (column) and direction (row) based on the mapping + variant = direction_mapping.get(direction, 0) + direction_row = min(animation_frame, rows - 1) + + # Build sprite filename using variant_direction format + sprite_filename = f"{base_name}_{variant}_{direction_row}" + mask_filename = f"{mask_name}_{variant}_{direction_row}" + + # Try to load the base sprite + base_sprite = image_resolver(f"character/{sprite_filename}", False) + + if not base_sprite: + # Fallback: try without the character/ prefix + base_sprite = image_resolver(sprite_filename, False) + + if not base_sprite: + return None + + # Try to load the mask + mask_sprite = image_resolver(f"character/{mask_filename}", False) + if not mask_sprite: + mask_sprite = image_resolver(mask_filename, False) + + # If we have a mask, apply the player color + if mask_sprite: + colored_mask = apply_color_to_mask(mask_sprite, player_color) + + # Composite the colored mask over the base sprite + result = Image.new('RGBA', base_sprite.size, (0, 0, 0, 0)) + result.paste(base_sprite, (0, 0), base_sprite) + result.paste(colored_mask, (9, 0), colored_mask) # There is an offset with the mask + + return result + + return base_sprite + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render character shadow.""" + # Get character properties + direction = entity.get('direction', 0) + state = entity.get('state', 'idle') + level = entity.get('level', 1) + has_gun = entity.get('has_gun', False) + animation_frame = entity.get('animation_frame', 0) + + # Shadow sprites have different dimensions + shadow_config = { + 'idle': (22, 8), + 'idle_gun': (22, 8), + 'running': (10, 7), # Running shadows are smaller + 'running_gun': (10, 7), + 'mining': (13, 8), + 'dead': (2, 1) + } + + # Determine shadow sprite name + if state == 'idle': + if has_gun: + shadow_base = f"level{level}_idle_gun_shadow" + cols, rows = shadow_config['idle_gun'] + else: + shadow_base = f"level{level}_idle_shadow" + cols, rows = shadow_config['idle'] + elif state == 'running': + if has_gun: + shadow_base = f"level{level}_running_gun_shadow" + cols, rows = shadow_config['running_gun'] + else: + shadow_base = f"level{level}_running_shadow" + cols, rows = shadow_config['running'] + elif state == 'mining': + shadow_base = f"level{level}_mining_tool_shadow" + cols, rows = shadow_config['mining'] + elif state == 'dead': + shadow_base = f"level{level}_dead_shadow" + cols, rows = shadow_config['dead'] + else: + shadow_base = f"level{level}_idle_shadow" + cols, rows = shadow_config['idle'] + + # Handle armor addons + if level > 1: + shadow_base = shadow_base.replace(f"level{level}", f"level{level}addon") + + # Get appropriate direction mapping + if state == 'mining': + direction_mapping = DIRECTION_MAPPINGS['mining'] + elif state == 'dead': + direction_mapping = DIRECTION_MAPPINGS['dead'] + else: + # Running shadows have fewer columns, so we need to map directions differently + if state == 'running': + # Map 8 directions to fewer columns for running shadows + direction_mapping = { + 0: 0, # North + 1: 1, # NE + 2: 2, # East + 3: 3, # SE + 4: 4, # South + 5: 5, # SW + 6: 6, # West + 7: 7, # NW + } + # Adjust for actual available columns + if cols < 8: + direction_mapping = {k: min(v, cols - 1) for k, v in direction_mapping.items()} + else: + direction_mapping = DIRECTION_MAPPINGS['standard'] + + # Calculate variant and direction + variant = direction_mapping.get(direction, 0) + direction_row = min(animation_frame, rows - 1) + + # Try to load shadow sprite with variant_direction format + shadow_filename = f"{shadow_base}_{variant}_{direction_row}" + shadow_sprite = image_resolver(f"character/{shadow_filename}", False) + + if not shadow_sprite: + shadow_sprite = image_resolver(shadow_filename, False) + + # Some shadows might be in separate files with -1, -2 suffixes + if not shadow_sprite: + # Try with -1 suffix + shadow_base_1 = shadow_base.replace('_shadow', '_shadow-1') + shadow_filename = f"{shadow_base_1}_{variant}_{direction_row}.png" + shadow_sprite = image_resolver(f"character/{shadow_filename}", False) + + if not shadow_sprite: + shadow_sprite = image_resolver(shadow_filename, False) + + return shadow_sprite + + +def get_key(entity: Dict, grid) -> str: + """Get cache key for character.""" + direction = entity.get('direction', 0) + state = entity.get('state', 'idle') + level = entity.get('level', 1) + has_gun = entity.get('has_gun', False) + animation_frame = entity.get('animation_frame', 0) + color = entity.get('color', DEFAULT_PLAYER_COLOR) + + color_str = f"{color[0]}_{color[1]}_{color[2]}" + return f"{direction}_{state}_{level}_{has_gun}_{animation_frame}_{color_str}" + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Character is effectively 1x1 for positioning.""" + return (1, 1) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/chemical_plant.py b/fle/env/tools/admin/render/renderers/chemical_plant.py new file mode 100644 index 000000000..c51534025 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/chemical_plant.py @@ -0,0 +1,68 @@ +# renderers/chemical_plant.py +""" +Chemical plant renderer with recipe icons +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image, ImageDraw + +DIRECTIONS = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" +} + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render chemical plant with recipe icon""" + direction = entity.get('direction', 0) + base = image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}") + + if base is None: + return None + + if 'recipe' not in entity: + return base + + # Create a copy to modify + result = base.copy() + icon = image_resolver(f"icon_{entity['recipe']}") + + if icon: + # Draw dark circle background + draw = ImageDraw.Draw(result) + center_x = result.width // 2 + center_y = result.height // 2 - 10 + radius = 23 + + draw.ellipse( + [center_x - radius, center_y - radius, + center_x + radius, center_y + radius], + fill=(0, 0, 0, 166) + ) + + # Paste icon + icon_x = center_x - icon.width // 2 + icon_y = center_y - icon.height // 2 + result.paste(icon, (icon_x, icon_y), icon if icon.mode == 'RGBA' else None) + + return result + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render shadow""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}", True) + + +def get_key(entity: Dict, grid) -> str: + """Get cache key including recipe""" + recipe = entity.get('recipe', '') + direction = entity.get('direction', 0) + return f"{recipe}_{direction}" + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Chemical plant is 3x3""" + return (3, 3) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/cliff.py b/fle/env/tools/admin/render/renderers/cliff.py new file mode 100644 index 000000000..70e894021 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/cliff.py @@ -0,0 +1,178 @@ +# renderers/cliff.py +""" +Cliff renderer using orientation data from server +""" +import random +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render cliff based on orientation from server""" + orientation = get_orientation(entity) + cliff_type = determine_cliff_type_from_orientation(orientation) + + sprite_name = get_cliff_sprite_name(cliff_type, orientation) + return image_resolver(sprite_name) + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render cliff shadow""" + orientation = get_orientation(entity) + cliff_type = determine_cliff_type_from_orientation(orientation) + + sprite_name = get_cliff_sprite_name(cliff_type, orientation) + return image_resolver(sprite_name, True) + + +def get_orientation(entity: Dict) -> str: + """Extract orientation from entity data""" + # Handle quoted string from Lua + orientation = entity.get('cliff_orientation', '').strip('"') + + if not orientation: + # Fallback to direction field if available + if 'direction' in entity: + direction = str(entity['direction']).strip('"') + if direction and '-to-' in direction: + orientation = direction + + return orientation or 'west-to-east' + + +def determine_cliff_type_from_orientation(orientation: str) -> str: + """ + Determine cliff type based on orientation pattern. + + In Factorio: + - cliff-sides: Standard straight cliff pieces and basic corners + - cliff-outer: Convex (outward) corners + - cliff-inner: Concave (inward) corners + - cliff-entrance: End pieces and special transitions + """ + + # Parse orientation + from_dir, to_dir = parse_orientation(orientation) + + # Terminal pieces (one end is "none") -> entrance + if from_dir == "none" or to_dir == "none": + return 'cliff-entrance' + + # Analyze the turn angle to determine corner type + direction_order = ['north', 'east', 'south', 'west'] + + if from_dir in direction_order and to_dir in direction_order: + from_idx = direction_order.index(from_dir) + to_idx = direction_order.index(to_dir) + + # Calculate turn direction and angle + turn = (to_idx - from_idx) % 4 + + if turn == 0: + # Same direction - shouldn't happen + return 'cliff-sides' + elif turn == 2: + # Opposite directions - straight cliff + return 'cliff-sides' + elif turn == 1: + # 90-degree right turn - outer corner + return 'cliff-outer' + elif turn == 3: + # 90-degree left turn (270 right) - inner corner + return 'cliff-inner' + + # Default to sides for any unhandled cases + return 'cliff-sides' + + +def parse_orientation(orientation: str) -> Tuple[str, str]: + """Parse orientation string into from and to directions""" + parts = orientation.split('-to-') + if len(parts) == 2: + return parts[0], parts[1] + return 'west', 'east' + + +def get_cliff_sprite_name(cliff_type: str, orientation: str) -> str: + """ + Map cliff orientation to sprite name based on the cliff type. + + Each cliff type has different sprite organization: + - cliff-sides: 8x4 grid - main cliff pieces + - cliff-inner: 8x2 grid - inner corners + - cliff-outer: 8x2 grid - outer corners + - cliff-entrance: 4x4 grid - terminals and special pieces + """ + + if cliff_type == 'cliff-entrance': + # cliff-entrance uses a 4x4 layout + orientation_map = { + # Terminal pieces (where cliffs end/start) + 'none-to-east': (1,4), #1-2 -> 4 + 'west-to-none': (3,4), #3-4 -> 4 + 'none-to-south': (1, 1), #1-2 -> 1 + 'north-to-none': (3, 1), #3-4 -> 1 + + 'none-to-west': (1, 2), #1-2 -> 2 + 'east-to-none': (3, 2), #3-4 -> 2 + 'none-to-north': (1, 3), #1,2 -> 3 + 'south-to-none': (3, 3), #3,4 -> 3 + } + row, col = orientation_map.get(orientation, (1,1)) + return f"{cliff_type}_{row}_{col}" + + elif cliff_type == 'cliff-outer': + # Outer corners - 90 degree right turns + orientation_map = { + 'west-to-north': 2, + 'north-to-east': 1, + 'east-to-south': 2, + 'south-to-west': 1, + } + + elif cliff_type == 'cliff-inner': + # Inner corners - 90 degree left turns + orientation_map = { + 'west-to-south': 2, + 'south-to-east': 1, + 'east-to-north': 2, + 'north-to-west': 1, + } + + else: # cliff-sides + # Main cliff pieces and basic transitions + orientation_map = { + 'north-to-south': 1, # Horizontal cliff facing down + 'west-to-east': 2, # Vertical cliff facing right + 'east-to-west': 4, # Vertical cliff facing left + 'south-to-north': 3, # Horizontal cliff facing up + } + + # Get the mapping with fallback + row = orientation_map.get(orientation, 1) + + # # Ensure we stay within bounds for each cliff type + # if cliff_type == 'cliff-inner' and row > 2: + # row = 2 + # elif cliff_type == 'cliff-outer' and row > 2: + # row = 2 + # elif cliff_type == 'cliff-entrance' and (col > 4 or row > 4): + # col = min(col, 4) + # row = min(row, 4) + # elif cliff_type == 'cliff-sides' and row > 4: + # row = 4 + + variant = random.choice([1,2,3,4]) + return f"{cliff_type}_{variant}_{row}" + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + orientation = get_orientation(entity) + cliff_type = determine_cliff_type_from_orientation(orientation) + return f"{cliff_type}_{orientation}" + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Get cliff size""" + return (2, 2) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/constant_combinator.py b/fle/env/tools/admin/render/renderers/constant_combinator.py new file mode 100644 index 000000000..8e90e029a --- /dev/null +++ b/fle/env/tools/admin/render/renderers/constant_combinator.py @@ -0,0 +1,35 @@ +# renderers/constant_combinator.py +""" +Constant combinator renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + +DIRECTIONS = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" +} + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render constant combinator""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}") + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Constant combinators have no shadows""" + return None + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Constant combinator is 1x1""" + return (1, 1) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/curved_rail.py b/fle/env/tools/admin/render/renderers/curved_rail.py new file mode 100644 index 000000000..2dcdb1b4d --- /dev/null +++ b/fle/env/tools/admin/render/renderers/curved_rail.py @@ -0,0 +1,31 @@ +# renderers/curved_rail.py +""" +Curved rail renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Curved rail rendering is handled in main render loop""" + return None + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Rails have no shadows""" + return None + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Get curved rail size based on direction""" + direction = entity.get('direction', 0) + if direction in [0, 1, 4, 5]: + return (5, 9) + else: + return (9, 4.5) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/decider_combinator.py b/fle/env/tools/admin/render/renderers/decider_combinator.py new file mode 100644 index 000000000..9fe650834 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/decider_combinator.py @@ -0,0 +1,102 @@ +# renderers/decider_combinator.py +""" +Decider combinator renderer with display +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + +DIRECTIONS = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" +} + +COMBINATOR_TO_NORMAL = { + None: "empty", + "+": "plus", + "-": "minus", + "*": "multiply", + "/": "divide", + "%": "modulo", + "^": "power", + "<<": "left_shift", + ">>": "right_shift", + "&": "and", + "and": "and", + "AND": "and", + "|": "or", + "or": "or", + "OR": "or", + "xor": "xor", + "XOR": "xor", + ">": "gt", + "<": "lt", + "=": "eq", + "!=": "neq", + "≠": "neq", + ">=": "gte", + "≥": "gte", + "<=": "lte", + "≤": "lte" +} + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render decider combinator with display""" + direction = entity.get('direction', 0) + base = image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}") + + if base is None: + return None + + # Check for control behavior + control = entity.get('control_behavior', {}) + conditions = control.get('decider_conditions', {}) + comparator = conditions.get('comparator') + + if comparator is None: + return base + + # Create a copy to modify + result = base.copy() + display_name = COMBINATOR_TO_NORMAL.get(comparator, 'empty') + icon = image_resolver(f"display_{display_name}") + + if icon: + # Position based on direction + if direction in [0, 4]: + x, y = 36, 22 + else: + x, y = 36, 18 + + result.paste(icon, (x, y), icon if icon.mode == 'RGBA' else None) + + return result + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Decider combinators have no shadows""" + return None + + +def get_key(entity: Dict, grid) -> str: + """Get cache key including comparator""" + direction = entity.get('direction', 0) + control = entity.get('control_behavior', {}) + conditions = control.get('decider_conditions', {}) + comparator = conditions.get('comparator', '') + + if comparator: + return f"{direction}_{comparator}" + return str(direction) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Get size based on direction""" + direction = entity.get('direction', 0) + if direction in [2, 6]: # East/West + return (2, 1) + else: # North/South + return (1, 2) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/electric_mining_drill.py b/fle/env/tools/admin/render/renderers/electric_mining_drill.py new file mode 100644 index 000000000..22e317a54 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/electric_mining_drill.py @@ -0,0 +1,35 @@ +# renderers/electric_mining_drill.py +""" +Electric mining drill renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + +DIRECTIONS = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" +} + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render electric mining drill""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}") + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Electric mining drills have no shadows""" + return None + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Electric mining drill is 3x3""" + return (3, 3) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/flamethrower_turret.py b/fle/env/tools/admin/render/renderers/flamethrower_turret.py new file mode 100644 index 000000000..00f5c76f2 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/flamethrower_turret.py @@ -0,0 +1,40 @@ +# renderers/flamethrower_turret.py +""" +Flamethrower turret renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + +DIRECTIONS = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" +} + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render flamethrower turret""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}") + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render shadow""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}", True) + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Get flamethrower turret size based on direction""" + direction = entity.get('direction', 0) + if direction in [2, 6]: # East/West + return (3, 2) + else: # North/South + return (2, 3) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/gate.py b/fle/env/tools/admin/render/renderers/gate.py new file mode 100644 index 000000000..08a35e342 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/gate.py @@ -0,0 +1,31 @@ +# renderers/gate.py +""" +Gate renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render gate""" + direction = entity.get('direction', 0) + orientation = 'vertical' if direction == 0 else 'horizontal' + return image_resolver(f"{entity['name']}_{orientation}") + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render shadow""" + direction = entity.get('direction', 0) + orientation = 'vertical' if direction == 0 else 'horizontal' + return image_resolver(f"{entity['name']}_{orientation}", True) + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Gate is 1x1""" + return (1, 1) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/heat_exchanger.py b/fle/env/tools/admin/render/renderers/heat_exchanger.py new file mode 100644 index 000000000..ed9d7173e --- /dev/null +++ b/fle/env/tools/admin/render/renderers/heat_exchanger.py @@ -0,0 +1,40 @@ +# renderers/heat_exchanger.py +""" +Heat exchanger renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + +DIRECTIONS = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" +} + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render heat exchanger""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}") + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render shadow""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}", True) + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Get heat exchanger size based on direction""" + direction = entity.get('direction', 0) + if direction in [2, 6]: # East/West + return (2, 3) + else: # North/South + return (3, 2) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/heat_pipe.py b/fle/env/tools/admin/render/renderers/heat_pipe.py new file mode 100644 index 000000000..d307d1ae0 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/heat_pipe.py @@ -0,0 +1,125 @@ +# renderers/heat_pipe.py +""" +Heat pipe renderer with connection logic +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render heat pipe based on connections""" + around = get_around(entity, grid) + count = sum(around) + + image_name = None + + if count == 0: + image_name = "heat-pipe_single" + elif count == 1: + if around[0] == 1: + image_name = "heat-pipe_ending_up" + elif around[1] == 1: + image_name = "heat-pipe_ending_right" + elif around[2] == 1: + image_name = "heat-pipe_ending_down" + else: + image_name = "heat-pipe_ending_left" + elif count == 2: + if around[0] == 1: + if around[1] == 1: + image_name = "heat-pipe_corner_right_up" + elif around[2] == 1: + image_name = "heat-pipe_straight_vertical" + elif around[3] == 1: + image_name = "heat-pipe_corner_left_up" + elif around[1] == 1: + if around[2] == 1: + image_name = "heat-pipe_corner_right_down" + elif around[3] == 1: + image_name = "heat-pipe_straight_horizontal" + else: + image_name = "heat-pipe_corner_left_down" + elif count == 3: + if around[0] == 0: + image_name = "heat-pipe_t_down" + elif around[1] == 0: + image_name = "heat-pipe_t_left" + elif around[2] == 0: + image_name = "heat-pipe_t_up" + elif around[3] == 0: + image_name = "heat-pipe_t_right" + else: + image_name = "heat-pipe_cross" + + return image_resolver(image_name) + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Heat pipes have no shadows""" + return None + + +def get_key(entity: Dict, grid) -> str: + """Get cache key based on connections""" + around = get_around(entity, grid) + return '_'.join(map(str, around)) + + +def get_around(entity: Dict, grid) -> list: + """Check surrounding heat connections""" + return [ + # North + is_heat_pipe(grid.get_relative(0, -1)) or + is_entity_in_direction(grid.get_relative(0, -1.5), "heat-exchanger", 0) or + is_entity(grid.get_relative(-2, -3), "nuclear-reactor") or + is_entity(grid.get_relative(0, -3), "nuclear-reactor") or + is_entity(grid.get_relative(2, -3), "nuclear-reactor"), + + # East + is_heat_pipe(grid.get_relative(1, 0)) or + is_entity_in_direction(grid.get_relative(1.5, 0), "heat-exchanger", 2) or + is_entity(grid.get_relative(3, -2), "nuclear-reactor") or + is_entity(grid.get_relative(3, 0), "nuclear-reactor") or + is_entity(grid.get_relative(3, 2), "nuclear-reactor"), + + # South + is_heat_pipe(grid.get_relative(0, 1)) or + is_entity_in_direction(grid.get_relative(0, 1.5), "heat-exchanger", 4) or + is_entity(grid.get_relative(-2, 3), "nuclear-reactor") or + is_entity(grid.get_relative(0, 3), "nuclear-reactor") or + is_entity(grid.get_relative(2, 3), "nuclear-reactor"), + + # West + is_heat_pipe(grid.get_relative(-1, 0)) or + is_entity_in_direction(grid.get_relative(-1.5, 0), "heat-exchanger", 6) or + is_entity(grid.get_relative(-3, -2), "nuclear-reactor") or + is_entity(grid.get_relative(-3, 0), "nuclear-reactor") or + is_entity(grid.get_relative(-3, 2), "nuclear-reactor") + ] + + +def is_heat_pipe(entity: Optional[Dict]) -> int: + """Check if entity is heat pipe""" + if entity is None: + return 0 + return 1 if entity['name'] == 'heat-pipe' else 0 + + +def is_entity(entity: Optional[Dict], target: str) -> int: + """Check if entity matches target""" + if entity is None: + return 0 + return 1 if entity['name'] == target else 0 + + +def is_entity_in_direction(entity: Optional[Dict], target: str, direction: int) -> int: + """Check if entity matches target and direction""" + if entity is None: + return 0 + return 1 if entity['name'] == target and entity.get('direction', 0) == direction else 0 + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Heat pipe is 1x1""" + return (1, 1) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/inserter.py b/fle/env/tools/admin/render/renderers/inserter.py new file mode 100644 index 000000000..acd70d2ae --- /dev/null +++ b/fle/env/tools/admin/render/renderers/inserter.py @@ -0,0 +1,33 @@ +# renderers/inserter.py +""" +Inserter renderer +""" +from enum import Enum +from typing import Dict, Tuple, Optional, Callable, Union +from PIL import Image + +from fle.env import EntityCore +from ..constants import DIRECTIONS + + +def render(entity: Union[Dict], grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render inserter""" + direction = entity.get('direction', 0) + if not isinstance(direction, int): + direction = direction.value + return image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}") + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Inserters have no shadows""" + return None + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Inserter is 1x1""" + return (1, 1) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/lab.py b/fle/env/tools/admin/render/renderers/lab.py new file mode 100644 index 000000000..63e078249 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/lab.py @@ -0,0 +1,30 @@ +# renderers/lab.py +""" +Lab renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render heat exchanger""" + + return image_resolver(f"{entity['name']}_0") + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render shadow""" + + return image_resolver(f"{entity['name']}_2_shadow", True) + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Get heat exchanger size based on direction""" + return (3,3) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/offshore_pump.py b/fle/env/tools/admin/render/renderers/offshore_pump.py new file mode 100644 index 000000000..fcdd69ca8 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/offshore_pump.py @@ -0,0 +1,35 @@ +# renderers/offshore_pump.py +""" +Offshore pump renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + +DIRECTIONS = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" +} + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render offshore pump""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}") + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Offshore pumps have no shadows""" + return None + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Offshore pump is 2x2""" + return (2, 2) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/oil_refinery.py b/fle/env/tools/admin/render/renderers/oil_refinery.py new file mode 100644 index 000000000..41274c31e --- /dev/null +++ b/fle/env/tools/admin/render/renderers/oil_refinery.py @@ -0,0 +1,68 @@ +# renderers/oil_refinery.py +""" +Oil refinery renderer with recipe icons +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image, ImageDraw + +DIRECTIONS = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" +} + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render oil refinery with recipe icon""" + direction = entity.get('direction', 0) + base = image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}") + + if base is None: + return None + + if 'recipe' not in entity: + return base + + # Create a copy to modify + result = base.copy() + icon = image_resolver(f"icon_{entity['recipe']}") + + if icon: + # Draw dark circle background + draw = ImageDraw.Draw(result) + center_x = result.width // 2 + center_y = result.height // 2 - 10 + radius = 23 + + draw.ellipse( + [center_x - radius, center_y - radius, + center_x + radius, center_y + radius], + fill=(0, 0, 0, 166) + ) + + # Paste icon + icon_x = center_x - icon.width // 2 + icon_y = center_y - icon.height // 2 + result.paste(icon, (icon_x, icon_y), icon if icon.mode == 'RGBA' else None) + + return result + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render shadow""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}", True) + + +def get_key(entity: Dict, grid) -> str: + """Get cache key including recipe""" + recipe = entity.get('recipe', '') + direction = entity.get('direction', 0) + return f"{recipe}_{direction}" + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Oil refinery is 5x5""" + return (5, 5) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/pipe.py b/fle/env/tools/admin/render/renderers/pipe.py new file mode 100644 index 000000000..dea88268d --- /dev/null +++ b/fle/env/tools/admin/render/renderers/pipe.py @@ -0,0 +1,118 @@ +# renderers/pipe.py +""" +Pipe renderer with connection logic +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render pipe based on connections""" + around = get_around(entity, grid) + count = sum(around) + + image_name = None + + if count == 0: + image_name = "pipe_straight_horizontal" + elif count == 1: + if around[0] == 1: + image_name = "pipe_ending_up" + elif around[1] == 1: + image_name = "pipe_ending_right" + elif around[2] == 1: + image_name = "pipe_ending_down" + else: + image_name = "pipe_ending_left" + elif count == 2: + if around[0] == 1: + if around[1] == 1: + image_name = "pipe_corner_up_right" + elif around[2] == 1: + image_name = "pipe_straight_vertical" + elif around[3] == 1: + image_name = "pipe_corner_up_left" + elif around[1] == 1: + if around[2] == 1: + image_name = "pipe_corner_down_right" + elif around[3] == 1: + image_name = "pipe_straight_horizontal" + else: + image_name = "pipe_corner_down_left" + elif count == 3: + if around[0] == 0: + image_name = "pipe_t_down" + elif around[1] == 0: + image_name = "pipe_t_left" + elif around[2] == 0: + image_name = "pipe_t_up" + elif around[3] == 0: + image_name = "pipe_t_right" + else: + image_name = "pipe_cross" + + return image_resolver(image_name) + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Pipes have no shadows""" + return None + + +def get_key(entity: Dict, grid) -> str: + """Get cache key based on connections""" + around = get_around(entity, grid) + return '_'.join(map(str, around)) + + +def get_around(entity: Dict, grid) -> list: + """Check surrounding pipe connections""" + # Simplified version - would need full connection logic + return [ + # North + is_pipe(grid.get_relative(0, -1), 4) or + is_entity_in_direction(grid.get_relative(0, -1), "offshore-pump", 0), + + # East + is_pipe(grid.get_relative(1, 0), 6) or + is_entity_in_direction(grid.get_relative(1, 0), "offshore-pump", 2), + + # South + is_pipe(grid.get_relative(0, 1), 0) or + is_entity_in_direction(grid.get_relative(0, 1), "offshore-pump", 4), + + # West + is_pipe(grid.get_relative(-1, 0), 2) or + is_entity_in_direction(grid.get_relative(-1, 0), "offshore-pump", 6) + ] + + +def is_pipe(entity: Optional[Dict], direction: int) -> int: + """Check if entity is pipe or pipe-to-ground""" + if entity is None: + return 0 + + if entity['name'] == 'pipe': + return 1 + elif entity['name'] == 'pipe-to-ground': + if entity.get('direction', 0) == direction: + return 1 + + return 0 + + +def is_entity_in_direction(entity: Optional[Dict], target: str, direction: int) -> int: + """Check if entity matches target and direction""" + if entity is None: + return 0 + + if entity['name'] == target and entity.get('direction', 0) == direction: + return 1 + + return 0 + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Pipe is 1x1""" + return (1, 1) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/pipe_to_ground.py b/fle/env/tools/admin/render/renderers/pipe_to_ground.py new file mode 100644 index 000000000..6d8aada9b --- /dev/null +++ b/fle/env/tools/admin/render/renderers/pipe_to_ground.py @@ -0,0 +1,35 @@ +# renderers/pipe_to_ground.py +""" +Pipe to ground renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + +RELATIVE_DIRECTIONS = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" +} + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render pipe to ground""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{RELATIVE_DIRECTIONS[direction]}") + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Pipe to ground has no shadow""" + return None + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Pipe to ground is 1x1""" + return (1, 1) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/pump.py b/fle/env/tools/admin/render/renderers/pump.py new file mode 100644 index 000000000..6c7b15792 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/pump.py @@ -0,0 +1,39 @@ +# renderers/pump.py +""" +Pump renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + +DIRECTIONS = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" +} + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render pump""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}") + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Pumps have no shadows""" + return None + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Get pump size based on direction""" + direction = entity.get('direction', 0) + if direction in [2, 6]: # East/West + return (2, 1) + else: # North/South + return (1, 2) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/rail_signal.py b/fle/env/tools/admin/render/renderers/rail_signal.py new file mode 100644 index 000000000..e2af1f42f --- /dev/null +++ b/fle/env/tools/admin/render/renderers/rail_signal.py @@ -0,0 +1,28 @@ +# renderers/rail_signal.py +""" +Rail signal and rail chain signal renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render rail signal""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{direction}") + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Rail signals have no shadows""" + return None + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Rail signal is 1x1""" + return (1, 1) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/splitter.py b/fle/env/tools/admin/render/renderers/splitter.py new file mode 100644 index 000000000..ca3d8de26 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/splitter.py @@ -0,0 +1,39 @@ +# renderers/splitter.py +""" +Splitter renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + +DIRECTIONS = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" +} + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render splitter""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}") + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Splitters have no shadows""" + return None + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Get splitter size based on direction""" + direction = entity.get('direction', 0) + if direction in [2, 6]: # East/West + return (1, 2) + else: # North/South + return (2, 1) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/steam_engine.py b/fle/env/tools/admin/render/renderers/steam_engine.py new file mode 100644 index 000000000..68d038db4 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/steam_engine.py @@ -0,0 +1,35 @@ +# renderers/steam_engine.py +""" +Steam engine renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render steam engine""" + direction = entity.get('direction', 0) + orientation = 'vertical' if direction == 0 else 'horizontal' + return image_resolver(f"{entity['name']}_{orientation}") + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render shadow""" + direction = entity.get('direction', 0) + orientation = 'vertical' if direction == 0 else 'horizontal' + return image_resolver(f"{entity['name']}_{orientation}", True) + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Get steam engine size based on direction""" + direction = entity.get('direction', 0) + if direction in [2, 6]: # East/West (horizontal) + return (5, 3) + else: # North/South (vertical) + return (3, 5) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/steam_turbine.py b/fle/env/tools/admin/render/renderers/steam_turbine.py new file mode 100644 index 000000000..eb39d700c --- /dev/null +++ b/fle/env/tools/admin/render/renderers/steam_turbine.py @@ -0,0 +1,35 @@ +# renderers/steam_turbine.py +""" +Steam turbine renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render steam turbine""" + direction = entity.get('direction', 0) + orientation = 'vertical' if direction == 0 else 'horizontal' + return image_resolver(f"{entity['name']}_{orientation}") + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render shadow""" + direction = entity.get('direction', 0) + orientation = 'vertical' if direction == 0 else 'horizontal' + return image_resolver(f"{entity['name']}_{orientation}", True) + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Get steam turbine size based on direction""" + direction = entity.get('direction', 0) + if direction in [2, 6]: # East/West (horizontal) + return (5, 3) + else: # North/South (vertical) + return (3, 5) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/stone_wall.py b/fle/env/tools/admin/render/renderers/stone_wall.py new file mode 100644 index 000000000..9f6d0ff1e --- /dev/null +++ b/fle/env/tools/admin/render/renderers/stone_wall.py @@ -0,0 +1,107 @@ +# renderers/stone_wall.py +""" +Stone wall renderer with connection logic +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render stone wall based on connections""" + return image_resolver(get_name(entity, grid)) + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render shadow""" + return image_resolver(get_name(entity, grid), True) + + +def get_name(entity: Dict, grid) -> str: + """Get wall sprite name based on connections""" + around = get_around(entity, grid) + count = sum(around) + + if count == 0: + return "stone-wall_single" + elif count == 1: + if around[0] == 1: + return "stone-wall_single" + elif around[1] == 1: + return "stone-wall_ending_right" + elif around[2] == 1: + return "stone-wall_straight_vertical" + else: + return "stone-wall_ending_left" + elif count == 2: + if around[0] == 1: + if around[1] == 1: + return "stone-wall_ending_right" + elif around[2] == 1: + return "stone-wall_straight_vertical" + elif around[3] == 1: + return "stone-wall_ending_left" + elif around[1] == 1: + if around[2] == 1: + return "stone-wall_corner_right_down" + elif around[3] == 1: + return "stone-wall_straight_horizontal" + else: + return "stone-wall_corner_left_down" + elif count == 3: + if around[0] == 0: + return "stone-wall_t_up" + elif around[1] == 0: + return "stone-wall_corner_left_down" + elif around[2] == 0: + return "stone-wall_straight_horizontal" + elif around[3] == 0: + return "stone-wall_corner_right_down" + else: + return "stone-wall_t_up" + + +def get_key(entity: Dict, grid) -> str: + """Get cache key based on connections""" + around = get_around(entity, grid) + return '_'.join(map(str, around)) + + +def get_around(entity: Dict, grid) -> list: + """Check surrounding wall connections""" + return [ + # North + is_stone_wall(grid.get_relative(0, -1)) or + is_gate(grid.get_relative(0, -1), 0), + + # East + is_stone_wall(grid.get_relative(1, 0)) or + is_gate(grid.get_relative(1, 0), 2), + + # South + is_stone_wall(grid.get_relative(0, 1)) or + is_gate(grid.get_relative(0, 1), 0), + + # West + is_stone_wall(grid.get_relative(-1, 0)) or + is_gate(grid.get_relative(-1, 0), 2) + ] + + +def is_stone_wall(entity: Optional[Dict]) -> int: + """Check if entity is stone wall""" + if entity is None: + return 0 + return 1 if entity['name'] == 'stone-wall' else 0 + + +def is_gate(entity: Optional[Dict], direction: int) -> int: + """Check if entity is gate with direction""" + if entity is None: + return 0 + return 1 if entity['name'] == 'gate' and entity.get('direction', 0) == direction else 0 + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Stone wall is 1x1""" + return (1, 1) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/storage_tank.py b/fle/env/tools/admin/render/renderers/storage_tank.py new file mode 100644 index 000000000..e2fb69291 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/storage_tank.py @@ -0,0 +1,35 @@ +# renderers/storage_tank.py +""" +Storage tank renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + +DIRECTIONS = { + 0: "north", + 2: "east", + 4: "south", + 6: "west" +} + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render storage tank""" + direction = entity.get('direction', 0) + return image_resolver(f"{entity['name']}_{DIRECTIONS[direction]}") + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Storage tanks have no shadows""" + return None + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Storage tank is 3x3""" + return (3, 3) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/straight_rail.py b/fle/env/tools/admin/render/renderers/straight_rail.py new file mode 100644 index 000000000..18aa64d73 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/straight_rail.py @@ -0,0 +1,27 @@ +# renderers/straight_rail.py +""" +Straight rail renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Straight rail rendering is handled in main render loop""" + return None + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Rails have no shadows""" + return None + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return str(entity.get('direction', 0)) + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Straight rail is 3x3""" + return (3, 3) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/transport_belt.py b/fle/env/tools/admin/render/renderers/transport_belt.py new file mode 100644 index 000000000..a58422dc1 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/transport_belt.py @@ -0,0 +1,754 @@ +# renderers/transport_belt.py +""" +Transport belt renderer +""" +import random +from typing import Dict, Tuple, Optional, Callable + +from PIL import Image + +from fle.env import EntityCore, Entity +from ..constants import NORTH, SOUTH, EAST, WEST, VERTICAL, HORIZONTAL, DEFAULT_SCALING +from ..profiler import profiler, profile_function + + + +@profile_function("transport_belt.render", include_args=True) +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render transport belt""" + + around = get_around(entity, grid) + count = sum(around) + direction = entity.get('direction', 0) + if not isinstance(direction, int): + direction = direction.value + degree_offset = 90 + + image = None + + + if count in [0, 2, 3]: + if direction in VERTICAL: + image = image_resolver(f"{entity['name']}_vertical") + degree_offset = -90 + else: + image = image_resolver(f"{entity['name']}_horizontal") + elif count == 1: + if around[0] == 1: # South + if direction in VERTICAL: + image = image_resolver(f"{entity['name']}_vertical") + degree_offset = -90 + elif direction == EAST: + image = image_resolver(f"{entity['name']}_bend_left") + degree_offset = 180 + elif direction == WEST: + image = image_resolver(f"{entity['name']}_bend_right") + degree_offset = 90 + elif around[1] == 1: # West + if direction in HORIZONTAL: + image = image_resolver(f"{entity['name']}_horizontal") + elif direction == NORTH: + image = image_resolver(f"{entity['name']}_bend_right") + degree_offset = 90 + elif direction == SOUTH: + image = image_resolver(f"{entity['name']}_bend_left") + degree_offset = -180 # Add this back + elif around[2] == 1: # North + if direction in VERTICAL: + image = image_resolver(f"{entity['name']}_vertical") + degree_offset = -90 + elif direction == EAST: + image = image_resolver(f"{entity['name']}_bend_right") + degree_offset = 90 + elif direction == WEST: + image = image_resolver(f"{entity['name']}_bend_left") + degree_offset = 180 + elif around[3] == 1: # East + if direction in HORIZONTAL: + image = image_resolver(f"{entity['name']}_horizontal") + elif direction == NORTH: + image = image_resolver(f"{entity['name']}_bend_left") + degree_offset = -180 + elif direction == SOUTH: + image = image_resolver(f"{entity['name']}_bend_right") # Changed from bend_right + degree_offset = 90 + # Keep default degree_offset = 90 + + if image is None: + return None + + # Rotate image based on direction + rotation = (direction * 45) - degree_offset + if rotation != 0: + image = image.rotate(-rotation, expand=True) + + return image + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Transport belts have no shadows""" + return None + + +@profile_function("transport_belt.render_inventory", include_args=True) +def render_inventory(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Transport belts display their contents on them""" + inventory = entity.get('inventory', {}) + if not inventory or (not inventory.get('left') and not inventory.get('right')): + return None + + # Get belt direction + direction = entity.get('direction', 0) + if not isinstance(direction, int): + direction = direction.value + + # Import required modules + from PIL import Image + import math + + # Determine belt type and rotation using the same logic as render() + from ..constants import VERTICAL, HORIZONTAL, EAST, WEST, NORTH, SOUTH + + around = get_around(entity, grid) + count = sum(around) + degree_offset = 90 + belt_type = 'straight' # Default + + # Determine belt configuration + if count == 1: + if around[0] == 1: # South + if direction == EAST: + belt_type = 'bend_left' + degree_offset = 180 + elif direction == WEST: + belt_type = 'bend_right' + degree_offset = 90 + elif direction in VERTICAL: + belt_type = 'vertical' + degree_offset = -90 + elif around[1] == 1: # West + if direction == NORTH: + belt_type = 'bend_right' + degree_offset = 90 + elif direction == SOUTH: + belt_type = 'bend_left' + degree_offset = -180 + else: + belt_type = 'horizontal' + elif around[2] == 1: # North + if direction == EAST: + belt_type = 'bend_right' + degree_offset = 90 + elif direction == WEST: + belt_type = 'bend_left' + degree_offset = 180 + elif direction in VERTICAL: + belt_type = 'vertical' + degree_offset = -90 + elif around[3] == 1: # East + if direction == NORTH: + belt_type = 'bend_left' + degree_offset = -180 + elif direction == SOUTH: + belt_type = 'bend_right' + degree_offset = 90 + else: + belt_type = 'horizontal' + else: # count in [0, 2, 3] + if direction in VERTICAL: + belt_type = 'vertical' + degree_offset = -90 + else: + belt_type = 'horizontal' + + # Calculate final rotation + rotation = (direction * 45) - degree_offset + + # Create overlay (64x64 to match sprite size) + overlay = Image.new('RGBA', (64, 64), (0, 0, 0, 0)) + + # Item configuration + item_size = 16 # Larger items since we have more space + max_items_per_lane = 4 + + # Center offset - belt content is centered in 64x64 sprite + center = 32 # Center of the 64x64 image + belt_half_width = 16 # Roughly half of the 32x32 belt content + + def place_items_on_lane(items_dict, is_left_lane): + """Place items on a specific lane""" + if not items_dict: + return + + item_name = list(items_dict.keys())[0] + item_count = min(items_dict[item_name], max_items_per_lane) + + choice = random.choice([1,2,3]) + item_icon = image_resolver(f"icon_{item_name}-{choice}", False) + if not item_icon: + item_icon = image_resolver(f"icon_{item_name}", False) + if not item_icon: + return + + item_icon = item_icon.resize((item_size, item_size), Image.Resampling.LANCZOS) + + # Define item positions based on belt type + # All positions are in the "canonical" orientation (before rotation) + positions = [] + spacing = 8 + + if belt_type in ('horizontal', 'vertical'): + for i in range(item_count): + offset = -12 + (i * spacing) + if direction in VERTICAL: + # This belt will be rotated to vertical + # For a south-facing belt (direction 4), rotation is 270 degrees + # This means our "left" needs to be on the bottom to end up on the left after rotation + if direction == SOUTH: # Going down + if is_left_lane: + x = center + offset + y = center + 6 # Bottom becomes left after 270° rotation + else: + x = center + offset + y = center - 6 # Top becomes right after 270° rotation + else: # NORTH - Going up + if is_left_lane: + x = center + offset + y = center - 6 # Top becomes left after 90° rotation + else: + x = center + offset + y = center + 6 # Bottom becomes right after 90° rotation + else: + # Horizontal belts - standard layout + if is_left_lane: + x = center + offset + y = center - 6 # Top lane + else: + x = center + offset + y = center + 6 # Bottom lane + positions.append((x, y)) + + elif belt_type == 'bend_left': + # Actually curves from bottom to RIGHT (naming is confusing!) + # Left lane is outer curve, right lane is inner curve + for i in range(item_count): + t = (i + 0.5) / max_items_per_lane # 0 to 1 along curve + + if is_left_lane: + # Outer curve - larger radius (this is working correctly) + angle = t * math.pi / 2 # 0 to 90 degrees + radius = 18 + center_x, center_y = center - 10, center + 10 # Curve center on left + + # Calculate position on arc (curving right) + x = center_x + radius * math.cos(angle) + y = center_y + radius * math.sin(angle) + else: + # Inner curve - needs 90 degree rotation + # Start from right side and curve up instead of starting from bottom + angle = t * math.pi / 2 # 0 to 90 degrees + radius = 10 + center_x, center_y = center - 10, center + 10 + + # Rotate the arc by 90 degrees (start from right, go up) + x = center_x + radius * math.sin(angle) + y = center_y + radius * math.cos(angle) + + positions.append((int(x), int(y))) + + elif belt_type == 'bend_right': + # Actually curves from bottom to LEFT (naming is confusing!) + # Left lane is inner curve, right lane is outer curve + for i in range(item_count): + t = (i + 0.5) / max_items_per_lane + + if is_left_lane: + # Inner curve - needs 90 degree rotation + # Start from left side and curve up instead of starting from bottom + angle = t * math.pi / 2 + radius = 10 + center_x, center_y = center + 10, center + 10 + + # Rotate the arc by 90 degrees (start from left, go up) + x = center_x - radius * math.sin(angle) + y = center_y - radius * math.cos(angle) + else: + # Outer curve - larger radius (this is working correctly) + angle = t * math.pi / 2 + radius = 18 + center_x, center_y = center + 10, center + 10 + + # Calculate position on arc (curving left) + x = center_x - radius * math.cos(angle) + y = center_y - radius * math.sin(angle) + + positions.append((int(x), int(y))) + + # Place items at calculated positions + for x, y in positions: + # Center the item icon at position + paste_x = x - item_size // 2 + paste_y = y - item_size // 2 + + overlay.paste(item_icon, (paste_x, paste_y), item_icon if item_icon.mode == 'RGBA' else None) + + # Process both lanes + place_items_on_lane(inventory.get('left', {}), True) + place_items_on_lane(inventory.get('right', {}), False) + + # Apply rotation to match belt sprite + if rotation != 0: + overlay = overlay.rotate(-rotation, expand=False) # Keep 64x64 size + + return overlay + +def render_inventory2(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Transport belts display their contents on them""" + inventory = entity.get('inventory', {}) + if not inventory: + return None + + # Get belt direction + direction = entity.get('direction', 0) + if not isinstance(direction, int): + direction = direction.value + + # Import required modules + from PIL import Image, ImageDraw + import math + + # Create a transparent image to overlay items on + # Start with a larger canvas to handle rotation + canvas_size = 64 + overlay = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0)) + + # Determine belt type using the same logic as render() + from ..constants import VERTICAL, HORIZONTAL, EAST, WEST, NORTH, SOUTH + + # Get surrounding connections + around = get_around(entity, grid) + count = sum(around) + + # Determine belt configuration and rotation + degree_offset = 90 + is_bend = False + bend_type = None + + if count == 1: + if around[0] == 1: # South + if direction == EAST: + is_bend = True + bend_type = 'left' + degree_offset = 180 + elif direction == WEST: + is_bend = True + bend_type = 'right' + degree_offset = 90 + elif direction in VERTICAL: + degree_offset = -90 + elif around[1] == 1: # West + if direction == NORTH: + is_bend = True + bend_type = 'right' + degree_offset = 90 + elif direction == SOUTH: + is_bend = True + bend_type = 'left' + degree_offset = -180 + elif around[2] == 1: # North + if direction == EAST: + is_bend = True + bend_type = 'right' + degree_offset = 90 + elif direction == WEST: + is_bend = True + bend_type = 'left' + degree_offset = 180 + elif direction in VERTICAL: + degree_offset = -90 + elif around[3] == 1: # East + if direction == NORTH: + is_bend = True + bend_type = 'left' + degree_offset = -180 + elif direction == SOUTH: + is_bend = True + bend_type = 'right' + degree_offset = 90 + elif count in [0, 2, 3]: + if direction in VERTICAL: + degree_offset = -90 + + # Calculate the rotation that will be applied to match the belt sprite + rotation = (direction * 45) - degree_offset + + # Belt items are roughly 16x16 pixels + item_size = 16 + center = canvas_size // 2 + + def place_items_on_path(items_dict, is_left_lane): + """Place items along the belt path""" + if not items_dict: + return + + item_name = list(items_dict.keys())[0] + item_count = min(items_dict[item_name], 4) + + item_icon = image_resolver(f"icon_{item_name}", False) + if not item_icon: + return + + item_icon = item_icon.resize((item_size, item_size), Image.Resampling.LANCZOS) + + for i in range(item_count): + if is_bend: + # Place items along the curve + # All curves start from bottom and turn 90 degrees + t = (i + 0.5) / 4.0 # Parameter from 0 to 1, offset to center items + + # Determine if this lane is on the inside or outside of the curve + is_inside = (bend_type == 'left' and is_left_lane) or (bend_type == 'right' and not is_left_lane) + + # Lane offset from center line + lane_offset = 6 + + if bend_type == 'left': + # Left turn: bottom to left + # Use a simple quarter circle arc + angle = t * math.pi / 2 # 0 to 90 degrees + + # Center line of the belt follows this path + center_x = center - 6 * math.sin(angle) + center_y = center - 6 * (1 - math.cos(angle)) + + # Offset perpendicular to the curve for lanes + # Normal vector at this point on the curve + normal_x = -math.cos(angle) + normal_y = -math.sin(angle) + + if not is_inside: + x = center_x - normal_x * lane_offset + y = center_y - normal_y * lane_offset + else: + x = center_x + normal_x * lane_offset + y = center_y + normal_y * lane_offset + + else: + # Right turn: bottom to right + angle = t * math.pi / 2 # 0 to 90 degrees + + # Center line of the belt + center_x = center + 6 * math.sin(angle) + center_y = center - 6 * (1 - math.cos(angle)) + + # Normal vector (pointing outward from curve) + normal_x = math.cos(angle) + normal_y = math.sin(angle) + + if not is_inside: + x = center_x - normal_x * lane_offset + y = center_y - normal_y * lane_offset + else: + x = center_x + normal_x * lane_offset + y = center_y - normal_y * lane_offset + + else: + # Straight belt - always draw horizontally, rotation handles orientation + spacing = 8 + offset = -12 + (i * spacing) + + # For straight belts, we need to consider how rotation will affect lanes + # The rotation will transform our coordinate system + # For belts that will be rotated to vertical, we need to adjust + + if direction in VERTICAL: + # This belt will be rotated to vertical + # For a south-facing belt (direction 4), rotation is 270 degrees + # This means our "left" needs to be on the bottom to end up on the left after rotation + if direction == SOUTH: # Going down + if is_left_lane: + x = center + offset + y = center + 6 # Bottom becomes left after 270° rotation + else: + x = center + offset + y = center - 6 # Top becomes right after 270° rotation + else: # NORTH - Going up + if is_left_lane: + x = center + offset + y = center - 6 # Top becomes left after 90° rotation + else: + x = center + offset + y = center + 6 # Bottom becomes right after 90° rotation + else: + # Horizontal belts - standard layout + if is_left_lane: + x = center + offset + y = center - 6 # Top lane + else: + x = center + offset + y = center + 6 # Bottom lane + + # Place the item + x_pos = int(x - item_size / 2) + y_pos = int(y - item_size / 2) + overlay.paste(item_icon, (x_pos, y_pos), item_icon if item_icon.mode == 'RGBA' else None) + + # Process both lanes + place_items_on_path(inventory.get('left', {}), True) + place_items_on_path(inventory.get('right', {}), False) + + # Apply the same rotation as the belt sprite + if rotation != 0: + overlay = overlay.rotate(-rotation, expand=True) + + # Crop to final size (32x32) centered + final_size = 32 + if overlay.size[0] > final_size or overlay.size[1] > final_size: + left = (overlay.width - final_size) // 2 + top = (overlay.height - final_size) // 2 + right = left + final_size + bottom = top + final_size + overlay = overlay.crop((left, top, right, bottom)) + + # Return the overlay if we added any items + if inventory.get('left') or inventory.get('right'): + return overlay + + return None + + +# def render_inventory(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: +# """Transport belts display their contents on them""" +# inventory = entity.get('inventory', {}) +# if not inventory: +# return None +# +# # Get belt direction +# direction = entity.get('direction', 0) +# if not isinstance(direction, int): +# direction = direction.value +# +# # Import required modules +# from PIL import Image, ImageDraw +# import math +# +# # Create a transparent image to overlay items on +# # Start with a larger canvas to handle rotation +# canvas_size = 64 +# overlay = Image.new('RGBA', (canvas_size, canvas_size), (0, 0, 0, 0)) +# +# # Determine belt type using the same logic as render() +# from ..constants import VERTICAL, HORIZONTAL, EAST, WEST, NORTH, SOUTH +# +# # Get surrounding connections +# around = get_around(entity, grid) +# count = sum(around) +# +# # Determine belt configuration and rotation +# degree_offset = 90 +# is_bend = False +# bend_type = None +# +# if count == 1: +# if around[0] == 1: # South +# if direction == EAST: +# is_bend = True +# bend_type = 'left' +# degree_offset = 180 +# elif direction == WEST: +# is_bend = True +# bend_type = 'right' +# degree_offset = 90 +# elif direction in VERTICAL: +# degree_offset = -90 +# elif around[1] == 1: # West +# if direction == NORTH: +# is_bend = True +# bend_type = 'right' +# degree_offset = 90 +# elif direction == SOUTH: +# is_bend = True +# bend_type = 'left' +# degree_offset = -180 +# elif around[2] == 1: # North +# if direction == EAST: +# is_bend = True +# bend_type = 'right' +# degree_offset = 90 +# elif direction == WEST: +# is_bend = True +# bend_type = 'left' +# degree_offset = 180 +# elif direction in VERTICAL: +# degree_offset = -90 +# elif around[3] == 1: # East +# if direction == NORTH: +# is_bend = True +# bend_type = 'left' +# degree_offset = -180 +# elif direction == SOUTH: +# is_bend = True +# bend_type = 'right' +# degree_offset = 90 +# elif count in [0, 2, 3]: +# if direction in VERTICAL: +# degree_offset = -90 +# +# # Calculate the rotation that will be applied to match the belt sprite +# rotation = (direction * 45) - degree_offset +# +# # Belt items are roughly 16x16 pixels (smaller to fit on belt) +# item_size = 16 +# center = canvas_size // 2 +# +# def place_items_on_path(items_dict, lane_offset, is_left_lane): +# """Place items along the belt path""" +# if not items_dict: +# return +# +# item_name = list(items_dict.keys())[0] +# item_count = min(items_dict[item_name], 4) +# +# item_icon = image_resolver(f"icon_{item_name}", False) +# if not item_icon: +# return +# +# item_icon = item_icon.resize((item_size, item_size), Image.Resampling.LANCZOS) +# +# for i in range(item_count): +# if is_bend: +# # For bends, place items along a curve +# # The curve follows the belt's bend before rotation +# t = (i + 0.5) / 4.0 # Parameter along curve (0 to 1) +# +# # Base curve in unrotated space +# if bend_type == 'left': +# # Left bend: straight to curved left +# angle = t * math.pi / 2 # 0 to 90 degrees +# radius = 16 +# x = center + radius * math.sin(angle) + lane_offset * math.cos(angle) +# y = center + radius * (1 - math.cos(angle)) + lane_offset * math.sin(angle) +# else: +# # Right bend: straight to curved right +# angle = t * math.pi / 2 # 0 to 90 degrees +# radius = 16 +# x = center + radius * math.sin(angle) + lane_offset * math.cos(angle) +# y = center - radius * (1 - math.cos(angle)) - lane_offset * math.sin(angle) +# else: +# # Straight belt - items in a line +# # Place items along the belt before rotation +# spacing = 8 +# if direction in VERTICAL or (count in [0, 2, 3] and direction in VERTICAL): +# # Vertical belt (before rotation) +# x = center + lane_offset +# y = center - 12 + (i * spacing) +# else: +# # Horizontal belt (before rotation) +# x = center - 12 + (i * spacing) +# y = center + lane_offset +# +# # Center the item icon +# x_pos = int(x - item_size / 2) +# y_pos = int(y - item_size / 2) +# +# overlay.paste(item_icon, (x_pos, y_pos), item_icon if item_icon.mode == 'RGBA' else None) +# +# # Process left and right lanes +# # Lane offsets are perpendicular to belt direction +# lane_spacing = 6 +# place_items_on_path(inventory.get('left', {}), -lane_spacing, True) +# place_items_on_path(inventory.get('right', {}), lane_spacing, False) +# +# # Apply the same rotation as the belt sprite +# if rotation != 0: +# overlay = overlay.rotate(-rotation, expand=True) +# +# # Crop to final size (32x32) centered +# final_size = 32 +# if overlay.size[0] > final_size or overlay.size[1] > final_size: +# # Calculate crop box to center the result +# left = (overlay.width - final_size) // 2 +# top = (overlay.height - final_size) // 2 +# right = left + final_size +# bottom = top + final_size +# overlay = overlay.crop((left, top, right, bottom)) +# +# # Return the overlay if we added any items +# if inventory.get('left') or inventory.get('right'): +# return overlay +# +# return None + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + around = get_around(entity, grid) + return f"{entity.get('direction', 0)}_{'_'.join(map(str, around))}" + + +# TODO: I think the semantics are wrong here @jack +@profile_function("transport_belt.get_around") +def get_around(entity: Dict, grid) -> list: + """Check surrounding connections""" + return [ + is_transport_belt(grid.get_relative(0, -1), SOUTH) or + is_splitter(grid.get_relative(0.5, -1), SOUTH) or + is_splitter(grid.get_relative(-0.5, -1), SOUTH), + + is_transport_belt(grid.get_relative(1, 0), WEST) or + is_splitter(grid.get_relative(1, 0.5), WEST) or + is_splitter(grid.get_relative(1, -0.5), WEST), + + is_transport_belt(grid.get_relative(0, 1), NORTH) or + is_splitter(grid.get_relative(0.5, 1), NORTH) or + is_splitter(grid.get_relative(-0.5, 1), NORTH), + + is_transport_belt(grid.get_relative(-1, 0), EAST) or + is_splitter(grid.get_relative(-1, 0.5), EAST) or + is_splitter(grid.get_relative(-1, -0.5), EAST) + ] + + +def is_transport_belt(entity: Optional[Dict], direction: int) -> int: + """Check if entity is transport belt facing direction""" + if entity is None: + return 0 + + belt_types = ['transport-belt', 'fast-transport-belt', 'express-transport-belt'] + underground_types = ['underground-belt', 'fast-underground-belt', 'express-underground-belt'] + + if entity['name'] in belt_types: + if entity['direction'] == direction or ( + entity['direction'].value == direction if not isinstance(entity['direction'], int) else 0 + ): + return 1 + + if entity['name'] in underground_types: + if entity['type'] == 'output': + if entity['direction'] == direction or ( + entity['direction'].value == direction if not isinstance(entity['direction'], int) else 0 + ): + return 1 + + return 0 + + +def is_splitter(entity: Optional[Dict], direction: int) -> int: + """Check if entity is splitter facing direction""" + if entity is None: + return 0 + + splitter_types = ['splitter', 'fast-splitter', 'express-splitter'] + + if entity['name'] in splitter_types: + if entity['direction'] == direction or ( + entity['direction'].value == direction if not isinstance(entity['direction'], int) else 0 + ): + return 1 + + return 0 + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Transport belt is 1x1""" + return (1, 1) \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/tree.py b/fle/env/tools/admin/render/renderers/tree.py new file mode 100644 index 000000000..8cb5f7518 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/tree.py @@ -0,0 +1,228 @@ +# renderers/tree.py +""" +Tree renderer for various tree types including dead trees +""" + +import re +from typing import Dict, Tuple, Optional, Callable, Set +from PIL import Image + +from ..constants import TREE_VARIATIONS, TREE_FILES_PER_VARIATION + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render tree based on type and position""" + tree_name = entity['name'] + x = entity['position']['x'] + y = entity['position']['y'] + + # Get available trees from grid (passed from main renderer) + available_trees = getattr(grid, 'available_trees', {}) + + # Handle dead trees differently (they don't have variations) + if 'dead' in tree_name or 'dry' in tree_name: + # Dead trees use numbered sprites + tree_parts = tree_name.split('-') + if len(tree_parts) >= 3: + # e.g., "dead-tree-desert" -> use position to pick 00-09 + num_variants = 10 # Adjust based on actual dead tree variants + variant_num = abs(int(x + y * 7)) % num_variants + sprite_name = f"{tree_name}-{variant_num:02d}" + else: + sprite_name = tree_name + else: + # Regular trees with foliage states + # Extract tree type number from name (e.g., "tree-01" -> "01") + tree_type = tree_name.split('-')[-1] if '-' in tree_name else '01' + + variation, foliage_state = get_tree_variant(x, y, tree_type, available_trees) + + sprite_name = f"tree-{tree_type}-{variation}-{foliage_state}" + + return image_resolver(sprite_name, False) + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render tree shadow""" + tree_name = entity['name'] + x = entity['position']['x'] + y = entity['position']['y'] + + # Get available trees from grid (passed from main renderer) + available_trees = getattr(grid, 'available_trees', {}) + + # Dead trees might have different shadow naming + if 'dead' in tree_name or 'dry' in tree_name: + tree_parts = tree_name.split('-') + if len(tree_parts) >= 3: + num_variants = 10 + variant_num = abs(int(x + y * 7)) % num_variants + shadow_name = f"{tree_name}-{variant_num:02d}_shadow" + else: + shadow_name = f"{tree_name}_shadow" + else: + # Regular trees + tree_type = tree_name.split('-')[-1] if '-' in tree_name else '01' + variation, foliage_state = get_tree_variant(x, y, tree_type, available_trees) + + # Regular trees append -shadow to the full sprite name + shadow_name = f"tree-{tree_type}-{variation}-{foliage_state}-shadow" + + return image_resolver(shadow_name, False) + + +def get_key(entity: Dict, grid) -> str: + """Get cache key for tree based on position""" + x = entity['position']['x'] + y = entity['position']['y'] + + # Use position to generate consistent key + return f"{entity['name']}_{x}_{y}" + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Trees are typically larger than 1x1""" + # Most trees are effectively 3x3 or 4x4 in terms of collision + # But for rendering purposes, we use 1x1 positioning + return (1, 1) + + +def get_tree_variant(x: float, y: float, tree_type: str, available_trees: Dict[str, Set[str]]) -> Tuple[str, str]: + """ + Calculate tree variant and foliage state based on position. + Returns (variation_letter, foliage_state) + + Args: + x, y: Position coordinates + tree_type: Type of tree (e.g., '03', '04', '05') + available_trees: Dict mapping tree_type to set of available variation-state combinations + e.g., {'03': {'a-full', 'a-medium', 'a-minimal', 'b-full', ...}} + """ + # Use position-based hash for deterministic randomness + seed = int((x * 113 + y * 157) * (x - y) * 31 + x * y * 73 + hash(tree_type) * 17) + + # Add fine-grained position sensitivity + position_hash = int((x * 1000) % 97 + (y * 1000) % 89 + ((x + y) * 1000) % 83) + seed = seed * 29 + position_hash + + # Use constants for tree variations + variations = TREE_VARIATIONS + + # Get available variations for this tree type + available_for_type = available_trees.get(tree_type, set()) + + # Extract unique variations that are available (have any states) + valid_variations = [] + for var in variations: + # Check if this variation has any states available + has_any_state = any(f"{var}-" in combo for combo in available_for_type) + if has_any_state: + valid_variations.append(var) + + # If no valid variations, use default + if not valid_variations: + valid_variations = ['a'] # Default fallback + + # Select variation using deterministic randomness + variation_index = seed % len(valid_variations) + variation = valid_variations[variation_index] + + # Foliage states with weighted probability (more full trees) + foliage_weights = [ + ('full', 70), # 70% chance + ('medium', 25), # 25% chance + ('minimal', 5), # 5% chance + ('trunk_only', 0) # 0% chance (adjusted from original) + ] + + # Calculate weighted random choice + total_weight = sum(w for _, w in foliage_weights) + choice = (seed // len(variations)) % total_weight + + cumulative = 0 + foliage_state = 'full' + for state, weight in foliage_weights: + cumulative += weight + if choice < cumulative: + foliage_state = state + break + + # Verify the selected combination exists + if f"{variation}-{foliage_state}" not in available_for_type: + # Fall back to any available state for this variation + for state in ['full', 'medium', 'minimal', 'trunk_only']: + if f"{variation}-{state}" in available_for_type: + foliage_state = state + break + else: + # If still no valid state, pick the first available state for this variation + for combo in available_for_type: + if combo.startswith(f"{variation}-"): + foliage_state = combo.split('-', 1)[1] + break + + return variation, foliage_state + + +def is_tree_entity(entity_name: str) -> bool: + """Check if an entity is a tree""" + return (entity_name.startswith('tree-') or + 'dead-tree' in entity_name or + 'dry-tree' in entity_name or + 'dead-grey-trunk' in entity_name) + + +def build_available_trees_index(sprites_dir) -> Dict[str, Set[str]]: + """ + Build an index of available tree sprites. + + Returns a dict mapping tree type to available variation-state combinations. + Only includes variations that have exactly 10 files (complete set). + e.g., {'03': {'a-full', 'a-medium', 'a-minimal', 'b-full', ...}} + """ + from pathlib import Path + + # First pass: count files for each tree type and variation + file_counts = {} + all_files = {} + + # Pattern to match tree sprite files + # Matches: tree-03-a-full.png, hr-tree-03-a-full.png, etc. + tree_pattern = re.compile(r'^(?:hr-)?tree-(\d+)-([a-l])-(\w+)\.png$') + + sprites_dir = Path(sprites_dir) + for sprite_file in sprites_dir.glob('*.png'): + match = tree_pattern.match(sprite_file.name) + if match: + tree_type = match.group(1) + variation = match.group(2) + state = match.group(3) + + # Skip shadow files in our count + if state.endswith('-shadow'): + continue + + # Count files per tree type and variation + key = (tree_type, variation) + if key not in file_counts: + file_counts[key] = 0 + all_files[key] = [] + + file_counts[key] += 1 + all_files[key].append((state, sprite_file.name)) + + # Second pass: only include variations with exactly the expected number of files + available_trees = {} + + for (tree_type, variation), count in file_counts.items(): + if count == TREE_FILES_PER_VARIATION: # Only complete sets + if tree_type not in available_trees: + available_trees[tree_type] = set() + + # Add all states for this variation + for state, _ in all_files[(tree_type, variation)]: + available_trees[tree_type].add(f"{variation}-{state}") + else: + print(f"Skipping tree-{tree_type}-{variation}: has {count} files instead of {TREE_FILES_PER_VARIATION}") + + return available_trees \ No newline at end of file diff --git a/fle/env/tools/admin/render/renderers/underground_belt.py b/fle/env/tools/admin/render/renderers/underground_belt.py new file mode 100644 index 000000000..4f4059417 --- /dev/null +++ b/fle/env/tools/admin/render/renderers/underground_belt.py @@ -0,0 +1,44 @@ +# renderers/underground_belt.py +""" +Underground belt renderer +""" + +from typing import Dict, Tuple, Optional, Callable +from PIL import Image + +from fle.env.tools.admin.render.constants import DEFAULT_SCALING + +RELATIVE_DIRECTIONS = { + 0: "up", + 2: "right", + 4: "down", + 6: "left" +} + + +def render(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Render underground belt""" + belt_type = entity.get('type', 'input') + + if not belt_type: + belt_type = 'input' if entity.get('is_input') == True else 'output' + + direction = entity.get('direction', 0) + prefix = 'in' if belt_type == 'input' else 'out' + return image_resolver(f"{entity['name']}_{prefix}_{RELATIVE_DIRECTIONS[direction]}") + + + +def render_shadow(entity: Dict, grid, image_resolver: Callable) -> Optional[Image.Image]: + """Underground belts have no shadows""" + return None + + +def get_key(entity: Dict, grid) -> str: + """Get cache key""" + return f"{entity.get('direction', 0)}_{entity.get('type', 'input')}" + + +def get_size(entity: Dict) -> Tuple[float, float]: + """Underground belt is 1x1""" + return (1, 1) \ No newline at end of file diff --git a/fle/env/tools/admin/render/server.lua b/fle/env/tools/admin/render/server.lua index 0f7481627..b3b2f16d4 100644 --- a/fle/env/tools/admin/render/server.lua +++ b/fle/env/tools/admin/render/server.lua @@ -1,157 +1,585 @@ -global.actions.render = function(player_index, method, arg1, arg2, arg3, arg4) - local player = global.agent_characters[player_index] - local position, bounding_box, radius - - if method == "bounding_box" then - -- If using bounding box method - bounding_box = { - left_top = {x = tonumber(arg1), y = tonumber(arg2)}, - right_bottom = {x = tonumber(arg3), y = tonumber(arg4)} - } - elseif method == "radius" then - -- If using radius method - local pos_x = tonumber(arg1) - local pos_y = tonumber(arg2) - radius = tonumber(arg3) or 40 - - position = {x = pos_x, y = pos_y} - bounding_box = { - left_top = {x = position.x - radius, y = position.y - radius}, - right_bottom = {x = position.x + radius, y = position.y + radius} - } - else - return "\"Error: Invalid method\"" +-- Add this function to analyze cliff neighborhoods +function analyze_cliff_orientation(entity, surface) + if not entity or not entity.valid then + return "west-to-east" end - -- Get all entities - local area = { - {bounding_box.left_top.x, bounding_box.left_top.y}, - {bounding_box.right_bottom.x, bounding_box.right_bottom.y} + local pos = entity.position + + -- Check for cliff neighbors in 8 directions + local neighbors = {} + local directions = { + {name = "n", dx = 0, dy = -1}, + {name = "ne", dx = 1, dy = -1}, + {name = "e", dx = 1, dy = 0}, + {name = "se", dx = 1, dy = 1}, + {name = "s", dx = 0, dy = 1}, + {name = "sw", dx = -1, dy = 1}, + {name = "w", dx = -1, dy = 0}, + {name = "nw", dx = -1, dy = -1} } - -- Get water tiles - local water_tiles = {} - for x = math.floor(area[1][1]), math.ceil(area[2][1]) do - for y = math.floor(area[1][2]), math.ceil(area[2][2]) do - local tile = player.surface.get_tile(x, y) - if tile and tile.valid and tile.name and (tile.name:find("water") or tile.name == "deepwater" or tile.name == "water") then - table.insert(water_tiles, { - x = tile.position.x, - y = tile.position.y, - name = "\""..tile.name.."\"" - }) + -- Track which positions we've already confirmed as having cliffs + local checked_positions = {} + + -- Store the entity's unit number for comparison + local current_unit_number = entity.valid and entity.unit_number or nil + + for _, dir in ipairs(directions) do + local check_pos = {x = pos.x + dir.dx*2, y = pos.y + dir.dy*2} + local pos_key = check_pos.x .. "," .. check_pos.y + + -- Skip if we've already checked this position + if not checked_positions[pos_key] then + local area = { + left_top = {x = check_pos.x - 0.1, y = check_pos.y - 0.1}, + right_bottom = {x = check_pos.x + 0.1, y = check_pos.y + 0.1} + } + + -- Use pcall to safely find entities + local find_success, found_cliffs = pcall(function() + return surface.find_entities_filtered{ + area = area, + type = "cliff" + } + end) + + if find_success and found_cliffs then + -- Check if any of the found cliffs are NOT the current entity + local has_neighbor = false + for _, cliff in ipairs(found_cliffs) do + -- Use pcall to safely check validity and unit_number + local check_success, is_different = pcall(function() + return cliff.valid and cliff.unit_number ~= current_unit_number + end) + + if check_success and is_different then + has_neighbor = true + break + end + end + + neighbors[dir.name] = has_neighbor + checked_positions[pos_key] = has_neighbor + else + neighbors[dir.name] = false + checked_positions[pos_key] = false end + else + -- Use the cached result + neighbors[dir.name] = checked_positions[pos_key] end end - -- Get resource entities - local resource_types = {"iron-ore", "copper-ore", "coal", "stone", "uranium-ore", "crude-oil"} - local resources = {} + -- Count neighbors + local neighbor_count = 0 + for _, has_neighbor in pairs(neighbors) do + if has_neighbor then + neighbor_count = neighbor_count + 1 + end + end - for _, resource_type in ipairs(resource_types) do - local resource_entities = player.surface.find_entities_filtered{ - area = area, - name = resource_type + -- Determine orientation based on neighbor pattern + local orientation = "west-to-east" -- default + + -- End pieces (1 neighbor) + if neighbor_count == 1 then + if neighbors.n then + orientation = "south-to-none" + elseif neighbors.s then + orientation = "north-to-none" + elseif neighbors.e then + orientation = "west-to-none" + elseif neighbors.w then + orientation = "east-to-none" + elseif neighbors.ne then + orientation = "south-to-none" + elseif neighbors.se then + orientation = "north-to-none" + elseif neighbors.sw then + orientation = "north-to-none" + elseif neighbors.nw then + orientation = "south-to-none" + end + -- Straight pieces (2 neighbors on opposite sides) + elseif neighbor_count == 2 then + if neighbors.n and neighbors.s then + orientation = "west-to-east" + elseif neighbors.e and neighbors.w then + orientation = "north-to-south" + -- Corner pieces (2 neighbors at 90 degrees) + elseif neighbors.n and neighbors.e then + orientation = "south-to-west" + elseif neighbors.e and neighbors.s then + orientation = "west-to-north" + elseif neighbors.s and neighbors.w then + orientation = "north-to-east" + elseif neighbors.w and neighbors.n then + orientation = "east-to-south" + -- Diagonal connections + elseif neighbors.ne and neighbors.sw then + orientation = "north-to-south" + elseif neighbors.nw and neighbors.se then + orientation = "west-to-east" + end + -- Complex pieces (3+ neighbors) + elseif neighbor_count >= 3 then + -- T-junctions + if not neighbors.n and neighbors.e and neighbors.s and neighbors.w then + orientation = "east-to-west" + elseif neighbors.n and not neighbors.e and neighbors.s and neighbors.w then + orientation = "north-to-south" + elseif neighbors.n and neighbors.e and not neighbors.s and neighbors.w then + orientation = "west-to-east" + elseif neighbors.n and neighbors.e and neighbors.s and not neighbors.w then + orientation = "south-to-north" + -- Inner corners (3 neighbors forming an L) + elseif neighbors.n and neighbors.e and neighbors.ne then + orientation = "west-to-south" + elseif neighbors.e and neighbors.s and neighbors.se then + orientation = "north-to-west" + elseif neighbors.s and neighbors.w and neighbors.sw then + orientation = "east-to-north" + elseif neighbors.w and neighbors.n and neighbors.nw then + orientation = "south-to-east" + end + end + + return orientation +end + +-- Modify the entity processing section in global.actions.render +global.actions.render = function(player_index, include_status, radius, compression_level) + local player = global.agent_characters[player_index] + if not player then + return nil, "Player not found" + end + + compression_level = compression_level or "standard" + + local surface = player.surface + local player_position = player.position + local MARGIN = 0 + -- Define search area around player + local area = { + left_top = { + x = player_position.x - radius - MARGIN, + y = player_position.y - radius - MARGIN + }, + right_bottom = { + x = player_position.x + radius - MARGIN, + y = player_position.y + radius - MARGIN } + } - for _, entity in ipairs(resource_entities) do - local resource_data = { + -- ENTITIES - Keep as is, they're already relatively efficient + local entities = surface.find_entities_filtered({ area=area, force='neutral' }) + local entity_data = {} + local characters = surface.find_entities_filtered({area=area, name='character'}) + for _, entity in pairs(characters) do + table.insert(entities, entity) + end + -- Define resource types to exclude from entities + local resource_names = { + ["iron-ore"] = true, + ["copper-ore"] = true, + ["coal"] = true, + ["stone"] = true, + ["uranium-ore"] = true, + ["crude-oil"] = true + } + + for _, entity in pairs(entities) do + if entity.valid then + -- Collect all data in one protected call + local data = { name = "\""..entity.name.."\"", position = { x = entity.position.x, y = entity.position.y }, - amount = entity.amount + direction = entity.direction or 0, + orientation = entity.orientation or 0 } - table.insert(resources, resource_data) + -- Handle special entity types + if entity.type == 'underground-belt' then + if entity.belt_to_ground_type then + data.type = entity.belt_to_ground_type + end + end + + -- Enhanced cliff handling with validity check + if entity.type == 'cliff' and entity.valid then + if entity.cliff_orientation then + data.cliff_orientation = "\""..entity.cliff_orientation.."\"" + else + local inferred_orientation = analyze_cliff_orientation(entity, surface) + data.cliff_orientation = "\""..inferred_orientation.."\"" + data.cliff_inferred = true + end + end + + -- Handle character entities + if entity.type == 'character' then + -- Add character-specific data + data.player_index = entity.player and entity.player.index or nil + + -- Get character state + if entity.walking_state and entity.walking_state.walking then + data.state = "\"running\"" + data.animation_frame = entity.walking_state.walking and + math.floor((game.tick % 140) / 20) or 0 -- 7 frames for running + elseif entity.mining_state and entity.mining_state.mining then + data.state = "\"mining\"" + data.animation_frame = math.floor((game.tick % 80) / 10) -- 8 frames for mining + else + data.state = "\"idle\"" + data.animation_frame = 0 + end + + -- Get armor level (1, 2, or 3 based on equipment) + data.level = 1 -- Default + if entity.get_inventory then + local armor_inventory = entity.get_inventory(defines.inventory.character_armor) + if armor_inventory and armor_inventory.valid then + local armor = armor_inventory[1] + if armor and armor.valid_for_read then + if armor.name == "power-armor-mk2" then + data.level = 3 + elseif armor.name == "power-armor" or armor.name == "modular-armor" then + data.level = 2 + end + end + end + end + + -- Check if character has a gun + data.has_gun = false + if entity.get_inventory then + local gun_inventory = entity.get_inventory(defines.inventory.character_guns) + if gun_inventory and gun_inventory.valid then + for i = 1, #gun_inventory do + if gun_inventory[i].valid_for_read then + data.has_gun = true + break + end + end + end + end + + -- Get player color if available + if entity.player then + local color = entity.player.color + data.color = { + math.floor(color.r * 255), + math.floor(color.g * 255), + math.floor(color.b * 255) + } + else + -- Default orange for non-player characters + data.color = {255, 165, 0} + end + end + + if include_status and entity.status and entity.valid then + data.status = entity.status + end + + -- Add the entity to the list + table.insert(entity_data, data) end end - -- Get trees - local trees = {} - local tree_entities = player.surface.find_entities_filtered{ + -- Also explicitly add all characters in the area (in case we missed any) + local characters = surface.find_entities_filtered({ area = area, - type = "tree" - } + type = "character" + }) - for _, tree in ipairs(tree_entities) do - local tree_data = { - name = "\""..tree.name.."\"", - position = { - x = tree.position.x, - y = tree.position.y - } - } + -- Create a lookup to avoid duplicates + local entity_positions = {} + for _, data in ipairs(entity_data) do + local key = data.position.x .. "," .. data.position.y + entity_positions[key] = true + end + + -- Add any characters we might have missed + for _, character in pairs(characters) do + if character.valid then + local key = character.position.x .. "," .. character.position.y + if not entity_positions[key] then + -- Add character with full data (same as above) + local data = { + name = "\"character\"", + position = { + x = character.position.x, + y = character.position.y + }, + direction = character.direction or 0, + orientation = character.orientation or 0, + player_index = character.player and character.player.index or nil, + state = "\""..character.state.."\"", --"\"idle\"", + animation_frame = 0, + level = 1, + has_gun = false, + color = {255, 165, 0} + } + table.insert(entity_data, data) + end + end + end + + -- WATER TILES - Optimized using run-length encoding + local water_runs = {} + local min_x = math.floor(area.left_top.x - MARGIN) + local max_x = math.ceil(area.right_bottom.x + MARGIN) + local min_y = math.floor(area.left_top.y - MARGIN) + local max_y = math.ceil(area.right_bottom.y + MARGIN) + + -- Scan row by row for water runs + for y = min_y, max_y do + local current_type = nil + local run_start = nil + + for x = min_x, max_x + 1 do -- +1 to close final run + local tile = (x <= max_x) and surface.get_tile(x, y) or nil + local is_water = tile and tile.valid and (tile.name:find("water") or tile.name == "deepwater" or tile.name == "water") + local tile_type = is_water and tile.name or nil - -- Add tree size if available - if tree.prototype and tree.prototype.tree_color_count then - tree_data.size = tree.prototype.tree_color_count + if tile_type ~= current_type then + -- Close previous run if it was water + if current_type then + table.insert(water_runs, { + t = current_type, -- Short key names + x = run_start, + y = y, + l = x - run_start -- length + }) + end + + -- Start new run if water + if tile_type then + current_type = tile_type + run_start = x + else + current_type = nil + end + end end + end + + -- RESOURCES - Optimized by grouping into patches + local resource_types = {"iron-ore", "copper-ore", "coal", "stone", "uranium-ore", "crude-oil"} + local resources = {} + + for _, resource_type in ipairs(resource_types) do + local resource_entities = surface.find_entities_filtered{ + area = area, + name = resource_type + } - table.insert(trees, tree_data) + if #resource_entities > 0 then + -- For dense patches, store as relative positions + local patches = {} + local processed = {} + + -- Simple clustering - group resources within 3 tiles of each other + for i, entity in ipairs(resource_entities) do + if not processed[i] then + local patch = { + c = { -- center + math.floor(entity.position.x), + math.floor(entity.position.y) + }, + e = {{0, 0, entity.amount}} -- entities as [dx, dy, amount] + } + processed[i] = true + + -- Find nearby resources + for j = i + 1, #resource_entities do + if not processed[j] then + local other = resource_entities[j] + local dx = other.position.x - patch.c[1] + local dy = other.position.y - patch.c[2] + + if math.abs(dx) <= 3 and math.abs(dy) <= 3 then + table.insert(patch.e, {dx, dy, other.amount}) + processed[j] = true + end + end + end + + table.insert(patches, patch) + end + end + + if #patches > 0 then + resources[resource_type] = patches + end + end end - -- Get rocks - local rocks = {} - local rock_entities = player.surface.find_entities_filtered{ - area = area, - type = "simple-entity" - } + -- Handle binary compression if requested + if compression_level == "binary" or compression_level == "maximum" then + -- Convert water runs to binary format + local water_binary = encode_water_binary(water_runs) + local resources_binary = encode_resources_binary(resources) - for _, rock in ipairs(rock_entities) do - -- Check if it's actually a rock (naming convention for rocks typically includes "rock" or "stone") - if rock.name and (rock.name:find("rock") or rock.name:find("stone")) then - local rock_data = { - name = "\""..rock.name.."\"", - position = { - x = rock.position.x, - y = rock.position.y - } + return { + entities = entity_data, + water_binary = "\""..water_binary.."\"", -- URL-safe Base64 encoded binary data + resources_binary = "\""..resources_binary.."\"", -- URL-safe Base64 encoded binary data + -- Include metadata for decoding + meta = { + area = area, + format = "\"v2-binary\"" + } + } + else + -- Standard v2 format + return { + entities = entity_data, + water = water_runs, + resources = resources, + -- Include metadata for decoding + meta = { + area = area, + format = "v2" } + } + end +end +-- Binary packing functions (since string.pack isn't available in Factorio's Lua 5.2) +function pack_uint8(n) + return string.char(bit32.band(n, 0xFF)) +end - table.insert(rocks, rock_data) - end +function pack_int16(n) + -- Convert to signed representation if needed + if n < 0 then + n = 65536 + n end + return string.char( + bit32.band(bit32.rshift(n, 8), 0xFF), + bit32.band(n, 0xFF) + ) +end - -- Get electricity network information - local electricity_networks = {} - local electric_poles = player.surface.find_entities_filtered{ - area = area, - type = {"electric-pole", "power-switch"} +function pack_uint16(n) + return string.char( + bit32.band(bit32.rshift(n, 8), 0xFF), + bit32.band(n, 0xFF) + ) +end + +function pack_uint32(n) + return string.char( + bit32.band(bit32.rshift(n, 24), 0xFF), + bit32.band(bit32.rshift(n, 16), 0xFF), + bit32.band(bit32.rshift(n, 8), 0xFF), + bit32.band(n, 0xFF) + ) +end + +function pack_int8(n) + -- Convert to unsigned representation + if n < 0 then + n = 256 + n + end + return string.char(bit32.band(n, 0xFF)) +end + +-- Binary encoding functions +function encode_water_binary(water_runs) + local TILE_TYPES = { + ['water'] = 1, + ['deepwater'] = 2, + ['water-green'] = 3, + ['water-mud'] = 4, + ['water-shallow'] = 5 } - -- Process electric poles to get network information - for _, pole in ipairs(electric_poles) do - if pole.valid and pole.electric_network_id then - local network_id = pole.electric_network_id - local supply_area = pole.prototype.supply_area_distance or 0 + local data = {} - -- Get the area this pole covers - local pole_data = { - position = { - x = pole.position.x, - y = pole.position.y - }, - network_id = network_id, - supply_area = supply_area, - name = "\""..pole.name.."\"" - } + for _, run in ipairs(water_runs) do + local tile_type = TILE_TYPES[run.t] or 1 + local x = run.x + local y = run.y + local length = math.min(run.l, 255) -- Cap at 255 for single byte - table.insert(electricity_networks, pole_data) - end + -- Pack as: type(u8), x(i16), y(i16), length(u8) + table.insert(data, pack_uint8(tile_type)) + table.insert(data, pack_int16(x)) + table.insert(data, pack_int16(y)) + table.insert(data, pack_uint8(length)) end - -- Combine all data for response - local render_data = { - water_tiles = water_tiles, - resources = resources, - trees = trees, - rocks = rocks, - electricity_networks = electricity_networks, - bounding_box = bounding_box, - position = position + -- Concatenate all binary data and base64 encode + local binary_data = table.concat(data) + return base64_encode(binary_data) +end + +function encode_resources_binary(resource_patches) + local RESOURCE_TYPES = { + ['iron-ore'] = 1, + ['copper-ore'] = 2, + ['coal'] = 3, + ['stone'] = 4, + ['uranium-ore'] = 5, + ['crude-oil'] = 6, + ['tree-01'] = 7 } - return dump(render_data) + local data = {} + + for resource_name, patches in pairs(resource_patches) do + local resource_type = RESOURCE_TYPES[resource_name] or 0 + if resource_type > 0 then + -- Write resource type and patch count + table.insert(data, pack_uint8(resource_type)) + table.insert(data, pack_uint16(#patches)) + + for _, patch in ipairs(patches) do + local center = patch.c + local entities = patch.e + + -- Write patch header: center_x(i16), center_y(i16), entity_count(u16) + table.insert(data, pack_int16(center[1])) + table.insert(data, pack_int16(center[2])) + table.insert(data, pack_uint16(#entities)) + + -- Write entities + for _, entity in ipairs(entities) do + local dx = math.max(-128, math.min(127, entity[1])) -- Clamp to signed byte range + local dy = math.max(-128, math.min(127, entity[2])) + local amount = entity[3] + + -- Pack as: dx(i8), dy(i8), amount(u32) + table.insert(data, pack_int8(dx)) + table.insert(data, pack_int8(dy)) + table.insert(data, pack_uint32(amount)) + end + end + end + end + + local binary_data = table.concat(data) + return base64_encode(binary_data) end + +-- Base64 encoding function (URL-safe variant to avoid RCON issues) +function base64_encode(data) + -- Use - and _ instead of + and / to avoid RCON command interpretation + local b='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' + return ((data:gsub('.', function(x) + local r,b='',x:byte() + for i=8,1,-1 do r=r..(b%2^i-b%2^(i-1)>0 and '1' or '0') end + return r; + end)..'0000'):gsub('%d%d%d?%d?%d?%d?', function(x) + if (#x < 6) then return '' end + local c=0 + for i=1,6 do c=c+(x:sub(i,i)=='1' and 2^(6-i) or 0) end + return b:sub(c+1,c+1) + end)..({ '', '==', '=' })[#data%3+1]) +end \ No newline at end of file diff --git a/fle/env/tools/admin/render/utils.py b/fle/env/tools/admin/render/utils.py new file mode 100644 index 000000000..844d60495 --- /dev/null +++ b/fle/env/tools/admin/render/utils.py @@ -0,0 +1,206 @@ +"""Utility functions for the rendering system.""" + +import json +import base64 +import zlib +import math +from pathlib import Path +from typing import Dict, List, Tuple, Optional, Any, Union + +from fle.env import Entity, EntityGroup, WallGroup, BeltGroup, PipeGroup, ElectricityGroup, EntityCore +from .constants import ( + DEFAULT_MAX_RESOURCE_AMOUNT, MIN_RESOURCE_VOLUME, MAX_RESOURCE_VOLUME, + DEFAULT_RESOURCE_VARIANTS, OIL_RESOURCE_VARIANTS +) + +def flatten_entities(entities: List[Union[Dict, Entity, EntityGroup]]) -> List[Union[Entity, EntityCore]]: + # Sometimes directions are 0-12 + max_direction = 0 + for entity in entities: + if isinstance(entity, dict): + if not 'direction' in entity: + entity['direction'] = 0 + direction = entity['direction'] if 'direction' in entity else 0 + if direction > max_direction: + max_direction = direction + + + for entity in entities: + if isinstance(entity, dict): + try: + #Sigh. Some blueprints are 0-12. + #if 'belt' in entity['name']: + entity['direction'] = entity['direction'] / 2 if max_direction > 6 else entity['direction'] + + yield EntityCore(**entity) + except Exception as e: + pass + elif isinstance(entity, EntityGroup): + e_list = [] + if isinstance(entity, WallGroup): + e_list = entity.entities + elif isinstance(entity, BeltGroup): + e_list = entity.belts + elif isinstance(entity, PipeGroup): + e_list = entity.pipes + elif isinstance(entity, ElectricityGroup): + e_list = entity.poles + + for e in e_list: + yield e + else: + yield entity + +def entities_to_grid(entities: List[Union[Dict, Entity]]) -> Dict: + """Convert entity list to position grid.""" + grid = {} + for entity in entities: + if isinstance(entity, dict): + x = entity['position']['x'] + y = entity['position']['y'] + if x not in grid: + grid[x] = {} + grid[x][y] = entity + elif isinstance(entity, EntityGroup): + e_list = [] + if isinstance(entity, WallGroup): + e_list = entity.entities + elif isinstance(entity, BeltGroup): + e_list = entity.belts + elif isinstance(entity, PipeGroup): + e_list = entity.pipes + elif isinstance(entity, ElectricityGroup): + e_list = entity.poles + + for e in e_list: + if e.position.x not in grid: + grid[e.position.x] = {} + grid[e.position.x][e.position.y] = entity + + elif isinstance(entity, EntityCore): + x = entity.position.x + y = entity.position.y + if x not in grid: + grid[x] = {} + grid[x][y] = entity + + return grid + + +def resources_to_grid(resources: List[Dict]) -> Dict: + """Convert resource list to position grid.""" + grid = {} + for resource in resources: + x = resource['position']['x'] + y = resource['position']['y'] + if x not in grid: + grid[x] = {} + grid[x][y] = resource + return grid + + +def get_resource_variant(x: float, y: float, max_variants: int = DEFAULT_RESOURCE_VARIANTS) -> int: + """ + Calculate resource variant based on position using a hash-like function. + Returns a variant number from 1 to max_variants. + """ + hash_value = int(x * 7 + y * 13) % max_variants + return hash_value + 1 # Variants are 1-indexed + + +def get_resource_volume(amount: int, max_amount: int = DEFAULT_MAX_RESOURCE_AMOUNT) -> int: + """ + Calculate resource volume level (1-8) based on amount. + 8 = full, 1 = nearly empty + """ + if amount <= 0: + return MIN_RESOURCE_VOLUME + + percentage = min(amount / max_amount, 1.0) + volume = max(MIN_RESOURCE_VOLUME, min(MAX_RESOURCE_VOLUME, int(percentage * MAX_RESOURCE_VOLUME))) + return volume + + +def is_entity(entity: Optional[Dict], target: str) -> bool: + """Check if entity matches target name.""" + if entity is None: + return False + return entity.get('name') == target + + +def is_entity_in_direction(entity: Optional[Dict], target: str, direction: int) -> bool: + """Check if entity matches target name and direction.""" + if not is_entity(entity, target): + return False + return entity.get('direction', 0) == direction + + +def recipe_has_fluids(recipe: Dict) -> bool: + """Check if recipe has fluid ingredients.""" + ingredients = recipe.get('ingredients') or recipe.get('normal', {}).get('ingredients', []) + return any(ing.get('type') == 'fluid' for ing in ingredients) + + +def is_tree_entity(entity_name: str) -> bool: + """Check if an entity is a tree.""" + return ( + entity_name.startswith('tree-') or + 'dead-tree' in entity_name or + 'dry-tree' in entity_name or + 'dead-grey-trunk' in entity_name + ) + +def is_rock_entity(entity_name: str) -> bool: + return 'rock-' in entity_name + + +def parse_blueprint(blueprint_string: str) -> Dict: + """Parse blueprint string to JSON.""" + decoded = base64.b64decode(blueprint_string[1:]) + unzipped = zlib.decompress(decoded) + return json.loads(unzipped) + + +def load_game_data(data_path: str) -> Tuple[Dict, Dict]: + """Load game data from JSON file.""" + with open(data_path, 'r') as f: + data = json.load(f) + + parsed = {} + recipes = {} + + skip_categories = [ + 'technology', 'item-subgroup', 'tutorial', 'simple-entity', + 'unit', 'simple-entity-with-force', 'rail-remnants', 'item-group', + 'particle', 'car', 'font', 'character-corpse', 'cargo-wagon', + 'ammo-category', 'ambient-sound', 'smoke', 'tree', 'corpse' + ] + + for category, items in data.items(): + if category in skip_categories or category.endswith('achievement'): + continue + + try: + for entity_name, entity_data in items.items(): + if category == 'recipe': + recipes[entity_name] = entity_data + else: + parsed[entity_name] = entity_data + except AttributeError: + pass + + return parsed, recipes + + +def find_fle_sprites_dir() -> Path: + """Walk up the directory tree until we find .fle directory.""" + current = Path.cwd() + + while current != current.parent: + fle_dir = current / ".fle" + if fle_dir.exists() and fle_dir.is_dir(): + return fle_dir / "sprites" + current = current.parent + + # Fallback - return the path even if it doesn't exist + return Path.cwd() / ".fle" / "sprites" \ No newline at end of file diff --git a/fle/env/tools/admin/render/agent.md b/fle/env/tools/admin/render_simple/agent.md similarity index 100% rename from fle/env/tools/admin/render/agent.md rename to fle/env/tools/admin/render_simple/agent.md diff --git a/fle/env/tools/admin/render_simple/client.py b/fle/env/tools/admin/render_simple/client.py new file mode 100644 index 000000000..75e96bb75 --- /dev/null +++ b/fle/env/tools/admin/render_simple/client.py @@ -0,0 +1,199 @@ +from typing import Optional, Dict +import math + +from fle.env import BoundingBox, Position, BeltGroup, PipeGroup, ElectricityGroup, Layer +from fle.commons.models.rendered_image import RenderedImage +from fle.env.tools.admin.render_simple.renderer import Renderer +from fle.env.tools.agent.get_entities.client import GetEntities +from fle.env.tools import Tool + +MAX_TILES = ( + 20 # Don't parameterise this, as the agent could break if it chooses a huge grid. +) + + +class RenderSimple(Tool): + """Render tool for visualizing Factorio entities""" + + def __init__(self, connection, game_state): + super().__init__(connection, game_state) + self.renderer = Renderer() + self.get_entities = GetEntities(connection, game_state) + + def __call__( + self, + position: Optional[Position] = None, + bounding_box: Optional[BoundingBox] = None, + style: Optional[Dict] = None, + layers: Optional[Layer] = Layer.ALL, + zoom: float = 1.0, + ) -> RenderedImage: + """ + Render entities around a position or within a bounding box. + + Args: + position: Center position for rendering (defaults to player position if None) + radius: Radius around position to render (default: 10) + bounding_box: Specific area to render (overrides position and radius) + style: Optional custom style configuration + max_tiles: Maximum number of tiles to render on each side of the position (default: 50) + layers: Layer flags to specify which elements to render + zoom: Zoom factor for rendering (default: 1.0) - values > 1 zoom in (fewer tiles visible), + values < 1 zoom out (more tiles visible) + + Returns: + RenderedImage: An image object that can be displayed or saved + """ + radius: int = MAX_TILES + max_tiles: int = MAX_TILES + + # Apply minimum and maximum bounds to prevent issues with very small or very large zoom values + MIN_TILES = 2 # Prevent zooming in too much (minimum tiles to show) + MAX_ZOOM_TILES = 100 # Prevent zooming out too much (maximum tiles to show) + + # Cap the maximum resolution/dimensions of the final image + MAX_IMAGE_RESOLUTION = 4000 # Maximum pixels in either dimension + MAX_TOTAL_TILES = ( + 8000 # Maximum total tiles (width * height) to prevent memory issues + ) + + # Apply style if provided + custom_style = {} + if style: + custom_style.update(style) + + # Create new renderer with the custom style if any + if custom_style: + self.renderer = Renderer(custom_style) + + # Apply zoom by adjusting max_tiles (number of tiles displayed) + if zoom != 1.0: + # Adjust the number of tiles displayed based on zoom + # Zoom in (> 1.0) = fewer tiles displayed (divide by zoom) + # Zoom out (< 1.0) = more tiles displayed (divide by zoom) + max_tiles = int(MAX_TILES / zoom) + radius = max_tiles # Update radius as well to keep consistent + + # Apply bounds to ensure reasonable limits + max_tiles = max(MIN_TILES, min(max_tiles, MAX_ZOOM_TILES)) + radius = max_tiles # Match radius to max_tiles + + # Cap max_tiles to ensure the total image size doesn't exceed maximum resolution + # Calculate estimated pixels per tile including margins + estimated_pixels_per_tile = self.renderer.config.style["cell_size"] + + # Calculate maximum tiles in each dimension based on MAX_IMAGE_RESOLUTION + max_tiles_per_dimension = MAX_IMAGE_RESOLUTION // estimated_pixels_per_tile + + # Ensure max_tiles doesn't exceed the resolution limit + max_tiles = min(max_tiles, max_tiles_per_dimension) + radius = min(radius, max_tiles_per_dimension) + + # Ensure total tiles (width * height) doesn't exceed MAX_TOTAL_TILES + # A square of max_tiles*2 x max_tiles*2 would have 4*max_tiles^2 total tiles + # We want this to be <= MAX_TOTAL_TILES + max_tiles_from_total = int(math.sqrt(MAX_TOTAL_TILES / 4)) + max_tiles = min(max_tiles, max_tiles_from_total) + radius = min(radius, max_tiles_from_total) + + if position is None and bounding_box is None: + # Get player position from game state + position = Position(0, 0) # Default fallback + player_data = self.game_state.get("player", {}) + if "position" in player_data: + position = Position( + player_data["position"]["x"], player_data["position"]["y"] + ) + + # Ensure radius doesn't exceed max_tiles + radius = min(radius, max_tiles) + + # Set up area to query + if bounding_box: + # Clip bounding box to max_tiles if needed + if position: + # Ensure the box doesn't exceed max_tiles from center_pos + left = max(bounding_box.left_top.x, position.x - max_tiles) + right = min(bounding_box.right_bottom.x, position.x + max_tiles) + top = max(bounding_box.left_top.y, position.y - max_tiles) + bottom = min(bounding_box.right_bottom.y, position.y + max_tiles) + + # Create a new clipped bounding box + bounding_box = BoundingBox( + left_top=Position(left, top), + right_bottom=Position(right, bottom), + left_bottom=Position(left, bottom), + right_top=Position(right, top), + ) + + # Get entities within bounding box + response, _ = self.execute( + self.player_index, + "bounding_box", + bounding_box.left_top.x, + bounding_box.left_top.y, + bounding_box.right_bottom.x, + bounding_box.right_bottom.y, + ) + else: + # Ensure radius is within the max_tiles limit + radius = min(radius, max_tiles) + + # Get water, resources, trees and rocks within radius of position + response, _ = self.execute( + self.player_index, "radius", position.x, position.y, radius + ) + + # Get entities within radius of position + entities = self.get_entities(position=position, radius=radius) + + base_entities = [] + + for entity in entities: + if isinstance(entity, BeltGroup): + base_entities.extend(entity.belts) + elif isinstance(entity, PipeGroup): + base_entities.extend(entity.pipes) + elif isinstance(entity, ElectricityGroup): + base_entities.extend(entity.poles) + else: + base_entities.append(entity) + + # Extract data from the response + water_tiles = list(response.get("water_tiles", {}).values()) + resource_entities = list(response.get("resources", {}).values()) + trees = list(response.get("trees", {}).values()) + rocks = list(response.get("rocks", {}).values()) + electricity_networks = list( + response.get("electricity_networks", {}).values() + ) # Extract electricity networks + + # Render the entities with all additional elements + img = self.renderer.render_entities( + base_entities, + center_pos=position, + bounding_box=bounding_box, + water_tiles=water_tiles, + resource_entities=resource_entities, + trees=trees, + rocks=rocks, + electricity_networks=electricity_networks, # Pass electricity networks to renderer + max_tiles=max_tiles, + layers=layers, + ) + + return RenderedImage(img) + + def _process_nested_dict(self, nested_dict): + """Helper method to process nested dictionaries""" + if isinstance(nested_dict, dict): + if all(isinstance(key, int) for key in nested_dict.keys()): + return [ + self._process_nested_dict(value) for value in nested_dict.values() + ] + else: + return { + key: self._process_nested_dict(value) + for key, value in nested_dict.items() + } + return nested_dict diff --git a/fle/env/tools/admin/render/layers/connection_layers_renderer.py b/fle/env/tools/admin/render_simple/layers/connection_layers_renderer.py similarity index 98% rename from fle/env/tools/admin/render/layers/connection_layers_renderer.py rename to fle/env/tools/admin/render_simple/layers/connection_layers_renderer.py index 60843f021..125bc421e 100644 --- a/fle/env/tools/admin/render/layers/connection_layers_renderer.py +++ b/fle/env/tools/admin/render_simple/layers/connection_layers_renderer.py @@ -2,7 +2,7 @@ from PIL import ImageDraw from fle.env.entities import Entity, UndergroundBelt, Pipe -from fle.env.tools.admin.render.layers.layer_renderer import LayerRenderer +from fle.env.tools.admin.render_simple.layers.layer_renderer import LayerRenderer class ConnectionsLayerRenderer(LayerRenderer): diff --git a/fle/env/tools/admin/render/layers/entities_layer_renderer.py b/fle/env/tools/admin/render_simple/layers/entities_layer_renderer.py similarity index 94% rename from fle/env/tools/admin/render/layers/entities_layer_renderer.py rename to fle/env/tools/admin/render_simple/layers/entities_layer_renderer.py index ff7dc3230..6b38e523b 100644 --- a/fle/env/tools/admin/render/layers/entities_layer_renderer.py +++ b/fle/env/tools/admin/render_simple/layers/entities_layer_renderer.py @@ -2,7 +2,7 @@ from PIL import ImageDraw from fle.env.entities import Direction, EntityStatus -from fle.env.tools.admin.render.layers.layer_renderer import LayerRenderer +from fle.env.tools.admin.render_simple.layers.layer_renderer import LayerRenderer class EntitiesLayerRenderer(LayerRenderer): @@ -59,7 +59,7 @@ def render( category = self.categorizer.get_entity_category(entity) shape_type = self.config.get_category_shape(category) - # Draw entity shape + # Draw entity sprite/shape self.shape_renderer.draw_shape( draw, x1, @@ -68,6 +68,7 @@ def render( y2, shape_type, entity_color, + #entity_name=getattr(entity, 'name', None), direction=entity.direction if hasattr(entity, "direction") else None, ) diff --git a/fle/env/tools/admin/render/layers/grid_layer_renderer.py b/fle/env/tools/admin/render_simple/layers/grid_layer_renderer.py similarity index 95% rename from fle/env/tools/admin/render/layers/grid_layer_renderer.py rename to fle/env/tools/admin/render_simple/layers/grid_layer_renderer.py index 5cadfca17..3ce1d468e 100644 --- a/fle/env/tools/admin/render/layers/grid_layer_renderer.py +++ b/fle/env/tools/admin/render_simple/layers/grid_layer_renderer.py @@ -1,7 +1,7 @@ from typing import Dict, Callable from PIL import ImageDraw -from fle.env.tools.admin.render.layers.layer_renderer import LayerRenderer +from fle.env.tools.admin.render_simple.layers.layer_renderer import LayerRenderer class GridLayerRenderer(LayerRenderer): diff --git a/fle/env/tools/admin/render/layers/layer_renderer.py b/fle/env/tools/admin/render_simple/layers/layer_renderer.py similarity index 93% rename from fle/env/tools/admin/render/layers/layer_renderer.py rename to fle/env/tools/admin/render_simple/layers/layer_renderer.py index a86f7c271..93e7d8c5b 100644 --- a/fle/env/tools/admin/render/layers/layer_renderer.py +++ b/fle/env/tools/admin/render_simple/layers/layer_renderer.py @@ -2,7 +2,7 @@ from typing import Dict, Callable from PIL import ImageDraw -from fle.env.tools.admin.render.utils.render_config import RenderConfig +from fle.env.tools.admin.render_simple.utils.render_config import RenderConfig class LayerRenderer(ABC): diff --git a/fle/env/tools/admin/render/layers/marker_layers_renderer.py b/fle/env/tools/admin/render_simple/layers/marker_layers_renderer.py similarity index 96% rename from fle/env/tools/admin/render/layers/marker_layers_renderer.py rename to fle/env/tools/admin/render_simple/layers/marker_layers_renderer.py index b5a3f92a9..c4cda710e 100644 --- a/fle/env/tools/admin/render/layers/marker_layers_renderer.py +++ b/fle/env/tools/admin/render_simple/layers/marker_layers_renderer.py @@ -2,7 +2,7 @@ from PIL import ImageDraw from fle.env.entities import Position, Layer -from fle.env.tools.admin.render.layers.layer_renderer import LayerRenderer +from fle.env.tools.admin.render_simple.layers.layer_renderer import LayerRenderer class MarkersLayerRenderer(LayerRenderer): diff --git a/fle/env/tools/admin/render/layers/natural_layer_renderer.py b/fle/env/tools/admin/render_simple/layers/natural_layer_renderer.py similarity index 99% rename from fle/env/tools/admin/render/layers/natural_layer_renderer.py rename to fle/env/tools/admin/render_simple/layers/natural_layer_renderer.py index 3683eeca4..a748f37e7 100644 --- a/fle/env/tools/admin/render/layers/natural_layer_renderer.py +++ b/fle/env/tools/admin/render_simple/layers/natural_layer_renderer.py @@ -4,7 +4,7 @@ from PIL import ImageDraw from fle.env.entities import Layer -from fle.env.tools.admin.render.layers.layer_renderer import LayerRenderer +from fle.env.tools.admin.render_simple.layers.layer_renderer import LayerRenderer class NaturalLayerRenderer(LayerRenderer): diff --git a/fle/env/tools/admin/render/layers/resource_layer_renderer.py b/fle/env/tools/admin/render_simple/layers/resource_layer_renderer.py similarity index 98% rename from fle/env/tools/admin/render/layers/resource_layer_renderer.py rename to fle/env/tools/admin/render_simple/layers/resource_layer_renderer.py index 8f2d9b64c..1331a9b75 100644 --- a/fle/env/tools/admin/render/layers/resource_layer_renderer.py +++ b/fle/env/tools/admin/render_simple/layers/resource_layer_renderer.py @@ -2,7 +2,7 @@ from typing import Dict, Callable from PIL import ImageDraw -from fle.env.tools.admin.render.layers.layer_renderer import LayerRenderer +from fle.env.tools.admin.render_simple.layers.layer_renderer import LayerRenderer class ResourcesLayerRenderer(LayerRenderer): diff --git a/fle/env/tools/admin/render/layers/water_layer_renderer.py b/fle/env/tools/admin/render_simple/layers/water_layer_renderer.py similarity index 98% rename from fle/env/tools/admin/render/layers/water_layer_renderer.py rename to fle/env/tools/admin/render_simple/layers/water_layer_renderer.py index 05debc8a9..e15e36068 100644 --- a/fle/env/tools/admin/render/layers/water_layer_renderer.py +++ b/fle/env/tools/admin/render_simple/layers/water_layer_renderer.py @@ -1,7 +1,7 @@ from typing import Dict, Callable from PIL import ImageDraw -from fle.env.tools.admin.render.layers.layer_renderer import LayerRenderer +from fle.env.tools.admin.render_simple.layers.layer_renderer import LayerRenderer class WaterLayerRenderer(LayerRenderer): diff --git a/fle/env/tools/admin/render_simple/renderer.py b/fle/env/tools/admin/render_simple/renderer.py new file mode 100644 index 000000000..fc4536841 --- /dev/null +++ b/fle/env/tools/admin/render_simple/renderer.py @@ -0,0 +1,307 @@ +from PIL import Image, ImageDraw, ImageFont +from typing import List, Dict, Optional + +from fle.env.entities import Entity, Position, BoundingBox, Layer, EntityStatus +from fle.env.tools.admin.render_simple.layers.connection_layers_renderer import ( + ConnectionsLayerRenderer, +) +from fle.env.tools.admin.render_simple.layers.marker_layers_renderer import ( + MarkersLayerRenderer, +) +from fle.env.tools.admin.render_simple.layers.resource_layer_renderer import ( + ResourcesLayerRenderer, +) +from fle.env.tools.admin.render_simple.utils.electricity_renderer import ( + ElectricityLayerRenderer, +) +from fle.env.tools.admin.render_simple.utils.render_config import RenderConfig +from fle.env.tools.admin.render_simple.utils.entity_categoriser import EntityCategoriser +from fle.env.tools.admin.render_simple.utils.colour_manager import ColourManager +from fle.env.tools.admin.render_simple.utils.shape_renderer import ShapeRenderer +from fle.env.tools.admin.render_simple.utils.legend_renderer import LegendRenderer +from fle.env.tools.admin.render_simple.utils.connection_renderer import ConnectionRenderer +from fle.env.tools.admin.render_simple.utils.image_calculator import ImageCalculator + + +# Import layer renderers +from fle.env.tools.admin.render_simple.layers.grid_layer_renderer import GridLayerRenderer +from fle.env.tools.admin.render_simple.layers.water_layer_renderer import WaterLayerRenderer +from fle.env.tools.admin.render_simple.layers.natural_layer_renderer import ( + NaturalLayerRenderer, +) +from fle.env.tools.admin.render_simple.layers.entities_layer_renderer import ( + EntitiesLayerRenderer, +) + + +class Renderer: + """ + Main renderer class for Factorio entities that composes all rendering components + """ + + def __init__(self, style: Optional[Dict] = None): + """Initialize renderer with optional custom style""" + # Core components + self.config = RenderConfig(style) + self.categorizer = EntityCategoriser() + self.color_manager = ColourManager(self.config, self.categorizer) + self.shape_renderer = ShapeRenderer(self.config) + + self.connection_renderer = ConnectionRenderer(self.color_manager) + self.legend_renderer = LegendRenderer( + self.config, self.color_manager, self.categorizer, self.shape_renderer + ) + self.image_calculator = ImageCalculator(self.config) + + # Initialize layer renderers + self.layer_renderers = { + Layer.GRID: GridLayerRenderer(self.config), + Layer.WATER: WaterLayerRenderer(self.config), + Layer.RESOURCES: ResourcesLayerRenderer(self.config), + Layer.NATURAL: NaturalLayerRenderer(self.config), + Layer.ENTITIES: EntitiesLayerRenderer( + self.config, self.categorizer, self.color_manager, self.shape_renderer + ), + Layer.CONNECTIONS: ConnectionsLayerRenderer( + self.config, self.color_manager, self.connection_renderer + ), + Layer.PLAYER | Layer.ORIGIN: MarkersLayerRenderer( + self.config, self.shape_renderer + ), + Layer.ELECTRICITY: ElectricityLayerRenderer( + self.config + ), # Add the new electricity layer renderer + } + + def render_entities( + self, + entities: List[Entity], + center_pos: Optional[Position] = None, + bounding_box: Optional[BoundingBox] = None, + water_tiles: Optional[List[Dict]] = None, + resource_entities: Optional[List[Dict]] = None, + trees: Optional[List[Dict]] = None, + rocks: Optional[List[Dict]] = None, + electricity_networks: Optional[List[Dict]] = None, + max_tiles: int = 20, + layers: Layer = Layer.ALL, + ) -> Image.Image: + """ + Render a list of Factorio entities to an image + + Args: + entities: List of entities to render + center_pos: Optional center position (e.g. player position) + bounding_box: Optional bounding box to constrain the render area + water_tiles: Optional list of water tiles to render + resource_entities: Optional list of resource entities to render + trees: Optional list of trees to render + rocks: Optional list of rocks to render + electricity_networks: Optional list of electricity network data to render + max_tiles: Maximum number of tiles on each side of the center position + layers: Layer flags to specify which elements to render + + Returns: + PIL Image containing the rendered map + """ + # Track resources and natural elements present in the map for the legend + resources_present = set() + natural_elements_present = set() + + # Track entity statuses present in the map + statuses_present = set() + + # Track electricity networks and their colors + network_colors = {} + + # Add water if water tiles are present and water layer is enabled + if Layer.WATER in layers and water_tiles and len(water_tiles) > 0: + resources_present.add("water") + + # Add resources from resource_entities if resources layer is enabled + if Layer.RESOURCES in layers and resource_entities: + for resource in resource_entities: + if "name" in resource: + resources_present.add(resource["name"]) + + # Track trees and rocks if their respective layers are enabled + if Layer.TREES in layers and trees and len(trees) > 0: + natural_elements_present.add("tree") + + if Layer.ROCKS in layers and rocks and len(rocks) > 0: + natural_elements_present.add("rock") + + # Track electricity networks if that layer is enabled + if Layer.ELECTRICITY in layers and electricity_networks: + # Create a renderer to get network colors if needed + electricity_renderer = self.layer_renderers.get(Layer.ELECTRICITY) + if electricity_renderer: + # Collect all network IDs + network_ids = set() + for network in electricity_networks: + if "network_id" in network: + network_ids.add(network["network_id"]) + + # Generate colors for each network + electricity_renderer._assign_network_colors(network_ids, network_colors) + + # Assign colors to entities + self.color_manager.assign_entity_colors(entities) + + # Calculate boundaries for rendering, making sure max_tiles is passed + boundaries = self.image_calculator.calculate_boundaries( + entities, center_pos, bounding_box, max_tiles=max_tiles + ) + + # Filter entities that are outside the boundaries + filtered_entities = [] + for entity in entities: + pos = entity.position + # Check if entity is within boundaries + if ( + pos.x >= boundaries["min_x"] + and pos.x <= boundaries["max_x"] + and pos.y >= boundaries["min_y"] + and pos.y <= boundaries["max_y"] + ): + filtered_entities.append(entity) + + # Track entity status if the status indicator is enabled + if ( + self.config.style["status_indicator_enabled"] + and entity.status != EntityStatus.NORMAL + ): + statuses_present.add(entity.status) + + # Update the entity list + entities = filtered_entities + + # Always position the legend to the right of the grid + self.config.style["legend_position"] = "right_top" + + # Calculate legend dimensions + legend_dimensions = None + if self.config.style["legend_enabled"] and ( + self.color_manager.entity_colors + or resources_present + or natural_elements_present + or statuses_present + or network_colors + ): + # Create a temporary image to calculate legend dimensions properly + # Use constant base cell size for legend calculations to ensure consistent legend sizing regardless of zoom + BASE_CELL_SIZE = 20 # Base cell size - this ensures legend remains readable at all zoom levels + + tmp_width = int( + (boundaries["max_x"] - boundaries["min_x"]) * BASE_CELL_SIZE + + 2 * self.config.style["margin"] + ) + tmp_height = int( + (boundaries["max_y"] - boundaries["min_y"]) * BASE_CELL_SIZE + + 2 * self.config.style["margin"] + ) + + legend_dimensions = self.legend_renderer.calculate_legend_dimensions( + tmp_width, + tmp_height, + resources_present, + natural_elements_present, + statuses_present, + network_colors, + ) + + # Calculate final image dimensions + dimensions = self.image_calculator.calculate_image_dimensions(legend_dimensions) + img_width = dimensions["img_width"] + img_height = dimensions["img_height"] + + # Create image and drawing context + img = Image.new( + "RGBA", (img_width, img_height), self.config.style["background_color"] + ) + draw = ImageDraw.Draw(img) + + # Get coordinate conversion function + game_to_img = self.image_calculator.get_game_to_image_coordinate_function() + + # Load fonts for text rendering - one for the map and one for the legend + font = self._load_font() + legend_font = self._load_legend_font() + + # Define the render order - certain layers should be rendered before others + render_order = [ + Layer.WATER, # Water tiles (background) + Layer.GRID, # Grid lines + Layer.RESOURCES, # Resource patches + Layer.ROCKS, # Rocks + Layer.TREES, # Trees + Layer.ELECTRICITY, # Electricity networks + Layer.ENTITIES, # Player-built entities + Layer.CONNECTIONS, # Underground connections + Layer.ORIGIN, # Origin marker (0,0) + Layer.PLAYER, # Player position marker + ] + + # Common kwargs for all layer renderers + render_kwargs = { + "entities": entities, + "water_tiles": water_tiles, + "resource_entities": resource_entities, + "trees": trees, + "rocks": rocks, + "electricity_networks": electricity_networks, + "center_pos": center_pos, + "font": font, + "layers": layers, + } + + # Render each layer in order if it's enabled + for layer_type in render_order: + if layer_type in layers: + # Find the appropriate renderer + for renderer_key, renderer in self.layer_renderers.items(): + if layer_type in renderer_key: + renderer.render(draw, game_to_img, boundaries, **render_kwargs) + break + + # Draw the legend with resources, natural elements, statuses, and electricity networks + # Use the legend_font for consistent readability regardless of zoom + self.legend_renderer.draw_combined_legend( + draw, + img_width, + img_height, + legend_font, + resources_present, + natural_elements_present, + statuses_present, + network_colors, + ) + + return img + + def _load_font(self) -> ImageFont.ImageFont: + """Load a font for text rendering with fallbacks""" + try: + font = ImageFont.truetype("arial.ttf", size=10) + except IOError: + try: + # Try another common font on different systems + font = ImageFont.truetype("DejaVuSans.ttf", size=10) + except IOError: + # Fallback to default font + font = ImageFont.load_default() + return font + + def _load_legend_font(self) -> ImageFont.ImageFont: + """Load a font specifically for the legend with a consistent size""" + legend_font_size = self.config.style.get("legend_font_size", 10) + try: + font = ImageFont.truetype("arial.ttf", size=legend_font_size) + except IOError: + try: + # Try another common font on different systems + font = ImageFont.truetype("DejaVuSans.ttf", size=legend_font_size) + except IOError: + # Fallback to default font + font = ImageFont.load_default() + return font diff --git a/fle/env/tools/admin/render_simple/server.lua b/fle/env/tools/admin/render_simple/server.lua new file mode 100644 index 000000000..ac59fa4ea --- /dev/null +++ b/fle/env/tools/admin/render_simple/server.lua @@ -0,0 +1,157 @@ +global.actions.render_simple = function(player_index, method, arg1, arg2, arg3, arg4) + local player = global.agent_characters[player_index] + local position, bounding_box, radius + + if method == "bounding_box" then + -- If using bounding box method + bounding_box = { + left_top = {x = tonumber(arg1), y = tonumber(arg2)}, + right_bottom = {x = tonumber(arg3), y = tonumber(arg4)} + } + elseif method == "radius" then + -- If using radius method + local pos_x = tonumber(arg1) + local pos_y = tonumber(arg2) + radius = tonumber(arg3) or 40 + + position = {x = pos_x, y = pos_y} + bounding_box = { + left_top = {x = position.x - radius, y = position.y - radius}, + right_bottom = {x = position.x + radius, y = position.y + radius} + } + else + return "\"Error: Invalid method\"" + end + + -- Get all entities + local area = { + {bounding_box.left_top.x, bounding_box.left_top.y}, + {bounding_box.right_bottom.x, bounding_box.right_bottom.y} + } + + -- Get water tiles + local water_tiles = {} + for x = math.floor(area[1][1]), math.ceil(area[2][1]) do + for y = math.floor(area[1][2]), math.ceil(area[2][2]) do + local tile = player.surface.get_tile(x, y) + if tile and tile.valid and tile.name and (tile.name:find("water") or tile.name == "deepwater" or tile.name == "water") then + table.insert(water_tiles, { + x = tile.position.x, + y = tile.position.y, + name = "\""..tile.name.."\"" + }) + end + end + end + + -- Get resource entities + local resource_types = {"iron-ore", "copper-ore", "coal", "stone", "uranium-ore", "crude-oil"} + local resources = {} + + for _, resource_type in ipairs(resource_types) do + local resource_entities = player.surface.find_entities_filtered{ + area = area, + name = resource_type + } + + for _, entity in ipairs(resource_entities) do + local resource_data = { + name = "\""..entity.name.."\"", + position = { + x = entity.position.x, + y = entity.position.y + }, + amount = entity.amount + } + + table.insert(resources, resource_data) + end + end + + -- Get trees + local trees = {} + local tree_entities = player.surface.find_entities_filtered{ + area = area, + type = "tree" + } + + for _, tree in ipairs(tree_entities) do + local tree_data = { + name = "\""..tree.name.."\"", + position = { + x = tree.position.x, + y = tree.position.y + } + } + + -- Add tree size if available + if tree.prototype and tree.prototype.tree_color_count then + tree_data.size = tree.prototype.tree_color_count + end + + table.insert(trees, tree_data) + end + + -- Get rocks + local rocks = {} + local rock_entities = player.surface.find_entities_filtered{ + area = area, + type = "simple-entity" + } + + for _, rock in ipairs(rock_entities) do + -- Check if it's actually a rock (naming convention for rocks typically includes "rock" or "stone") + if rock.name and (rock.name:find("rock") or rock.name:find("stone")) then + local rock_data = { + name = "\""..rock.name.."\"", + position = { + x = rock.position.x, + y = rock.position.y + } + } + + table.insert(rocks, rock_data) + end + end + + -- Get electricity network information + local electricity_networks = {} + local electric_poles = player.surface.find_entities_filtered{ + area = area, + type = {"electric-pole", "power-switch"} + } + + -- Process electric poles to get network information + for _, pole in ipairs(electric_poles) do + if pole.valid and pole.electric_network_id then + local network_id = pole.electric_network_id + local supply_area = pole.prototype.supply_area_distance or 0 + + -- Get the area this pole covers + local pole_data = { + position = { + x = pole.position.x, + y = pole.position.y + }, + network_id = network_id, + supply_area = supply_area, + name = "\""..pole.name.."\"" + } + + table.insert(electricity_networks, pole_data) + end + end + + -- Combine all data for response + local render_data = { + water_tiles = water_tiles, + resources = resources, + trees = trees, + rocks = rocks, + electricity_networks = electricity_networks, + bounding_box = bounding_box, + position = position + } + + return dump(render_data) +end diff --git a/fle/env/tools/admin/render/utils/colour_manager.py b/fle/env/tools/admin/render_simple/utils/colour_manager.py similarity index 97% rename from fle/env/tools/admin/render/utils/colour_manager.py rename to fle/env/tools/admin/render_simple/utils/colour_manager.py index 5c4062f85..44e63713e 100644 --- a/fle/env/tools/admin/render/utils/colour_manager.py +++ b/fle/env/tools/admin/render_simple/utils/colour_manager.py @@ -4,8 +4,8 @@ from fle.env.entities import Entity, EntityStatus from fle.env.game_types import prototype_by_name -from fle.env.tools.admin.render.utils.render_config import RenderConfig -from fle.env.tools.admin.render.utils.entity_categoriser import EntityCategoriser +from fle.env.tools.admin.render_simple.utils.render_config import RenderConfig +from fle.env.tools.admin.render_simple.utils.entity_categoriser import EntityCategoriser class ColourManager: diff --git a/fle/env/tools/admin/render/utils/connection_renderer.py b/fle/env/tools/admin/render_simple/utils/connection_renderer.py similarity index 98% rename from fle/env/tools/admin/render/utils/connection_renderer.py rename to fle/env/tools/admin/render_simple/utils/connection_renderer.py index 78af526dc..376831c38 100644 --- a/fle/env/tools/admin/render/utils/connection_renderer.py +++ b/fle/env/tools/admin/render_simple/utils/connection_renderer.py @@ -3,7 +3,7 @@ from PIL import ImageDraw from fle.env import UndergroundBelt, Pipe -from fle.env.tools.admin.render.utils.colour_manager import ColourManager +from fle.env.tools.admin.render_simple.utils.colour_manager import ColourManager class ConnectionRenderer: diff --git a/fle/env/tools/admin/render/utils/electricity_renderer.py b/fle/env/tools/admin/render_simple/utils/electricity_renderer.py similarity index 98% rename from fle/env/tools/admin/render/utils/electricity_renderer.py rename to fle/env/tools/admin/render_simple/utils/electricity_renderer.py index 797266c09..6422b32b2 100644 --- a/fle/env/tools/admin/render/utils/electricity_renderer.py +++ b/fle/env/tools/admin/render_simple/utils/electricity_renderer.py @@ -3,7 +3,7 @@ from PIL import ImageDraw from fle.env.entities import Layer -from fle.env.tools.admin.render.layers.layer_renderer import LayerRenderer +from fle.env.tools.admin.render_simple.layers.layer_renderer import LayerRenderer class ElectricityLayerRenderer(LayerRenderer): diff --git a/fle/env/tools/admin/render/utils/entity_categoriser.py b/fle/env/tools/admin/render_simple/utils/entity_categoriser.py similarity index 100% rename from fle/env/tools/admin/render/utils/entity_categoriser.py rename to fle/env/tools/admin/render_simple/utils/entity_categoriser.py diff --git a/fle/env/tools/admin/render/utils/image_calculator.py b/fle/env/tools/admin/render_simple/utils/image_calculator.py similarity index 98% rename from fle/env/tools/admin/render/utils/image_calculator.py rename to fle/env/tools/admin/render_simple/utils/image_calculator.py index 81ed84e47..801aa7d4b 100644 --- a/fle/env/tools/admin/render/utils/image_calculator.py +++ b/fle/env/tools/admin/render_simple/utils/image_calculator.py @@ -1,7 +1,7 @@ from typing import List, Optional, Dict, Callable from fle.env.entities import Entity, Position, BoundingBox, Direction -from fle.env.tools.admin.render.utils.render_config import RenderConfig +from fle.env.tools.admin.render_simple.utils.render_config import RenderConfig class ImageCalculator: diff --git a/fle/env/tools/admin/render/utils/legend_renderer.py b/fle/env/tools/admin/render_simple/utils/legend_renderer.py similarity index 99% rename from fle/env/tools/admin/render/utils/legend_renderer.py rename to fle/env/tools/admin/render_simple/utils/legend_renderer.py index cba32e177..398ebb4fe 100644 --- a/fle/env/tools/admin/render/utils/legend_renderer.py +++ b/fle/env/tools/admin/render_simple/utils/legend_renderer.py @@ -3,10 +3,10 @@ import math from fle.env.entities import EntityStatus -from fle.env.tools.admin.render.utils.render_config import RenderConfig -from fle.env.tools.admin.render.utils.colour_manager import ColourManager -from fle.env.tools.admin.render.utils.entity_categoriser import EntityCategoriser -from fle.env.tools.admin.render.utils.shape_renderer import ShapeRenderer +from fle.env.tools.admin.render_simple.utils.render_config import RenderConfig +from fle.env.tools.admin.render_simple.utils.colour_manager import ColourManager +from fle.env.tools.admin.render_simple.utils.entity_categoriser import EntityCategoriser +from fle.env.tools.admin.render_simple.utils.shape_renderer import ShapeRenderer class LegendRenderer: diff --git a/fle/env/tools/admin/render/utils/natural_renderer.py b/fle/env/tools/admin/render_simple/utils/natural_renderer.py similarity index 99% rename from fle/env/tools/admin/render/utils/natural_renderer.py rename to fle/env/tools/admin/render_simple/utils/natural_renderer.py index 526477a45..f6308a0d1 100644 --- a/fle/env/tools/admin/render/utils/natural_renderer.py +++ b/fle/env/tools/admin/render_simple/utils/natural_renderer.py @@ -3,7 +3,7 @@ import math import random -from fle.env.tools.admin.render.utils.render_config import RenderConfig +from fle.env.tools.admin.render_simple.utils.render_config import RenderConfig class NaturalRenderer: diff --git a/fle/env/tools/admin/render/utils/render_config.py b/fle/env/tools/admin/render_simple/utils/render_config.py similarity index 100% rename from fle/env/tools/admin/render/utils/render_config.py rename to fle/env/tools/admin/render_simple/utils/render_config.py diff --git a/fle/env/tools/admin/render/utils/shape_renderer.py b/fle/env/tools/admin/render_simple/utils/shape_renderer.py similarity index 99% rename from fle/env/tools/admin/render/utils/shape_renderer.py rename to fle/env/tools/admin/render_simple/utils/shape_renderer.py index 068900a01..e09d61d2a 100644 --- a/fle/env/tools/admin/render/utils/shape_renderer.py +++ b/fle/env/tools/admin/render_simple/utils/shape_renderer.py @@ -3,7 +3,7 @@ from PIL import ImageDraw from fle.env.entities import Direction, EntityStatus -from fle.env.tools.admin.render.utils.render_config import RenderConfig +from fle.env.tools.admin.render_simple.utils.render_config import RenderConfig class ShapeRenderer: diff --git a/fle/env/tools/admin/render/utils/tile_renderer.py b/fle/env/tools/admin/render_simple/utils/tile_renderer.py similarity index 99% rename from fle/env/tools/admin/render/utils/tile_renderer.py rename to fle/env/tools/admin/render_simple/utils/tile_renderer.py index ba7ae2d15..06bfaaf9b 100644 --- a/fle/env/tools/admin/render/utils/tile_renderer.py +++ b/fle/env/tools/admin/render_simple/utils/tile_renderer.py @@ -2,7 +2,7 @@ from PIL import ImageDraw import math -from fle.env.tools.admin.render.utils.render_config import RenderConfig +from fle.env.tools.admin.render_simple.utils.render_config import RenderConfig class TileRenderer: diff --git a/fle/env/tools/agent/connect_entities/client.py b/fle/env/tools/agent/connect_entities/client.py index a462ee2f7..58746f954 100644 --- a/fle/env/tools/agent/connect_entities/client.py +++ b/fle/env/tools/agent/connect_entities/client.py @@ -269,7 +269,7 @@ def _connect_pair_of_waypoints( ) exception_message = str(last_exception) if exception_message == "nil,": - exception_message = "Failed to connect entitites. Please reposition entities or clear potential blockages" + exception_message = "Failed to connect entities. Please reposition entities or clear potential blockages" raise Exception( f"Failed to connect {set([type.name for type in connection_types])} from {source_error_message_addition} to {target_error_message_addition}. " diff --git a/fle/env/tools/agent/connect_entities/groupable_entities.py b/fle/env/tools/agent/connect_entities/groupable_entities.py index 7e680abde..c8ce87a84 100644 --- a/fle/env/tools/agent/connect_entities/groupable_entities.py +++ b/fle/env/tools/agent/connect_entities/groupable_entities.py @@ -48,12 +48,24 @@ def _construct_group( if ( hasattr(entity, "inventory") and entity.inventory ): # Check if inventory exists and is not empty - entity_inventory = entity.inventory - for item, value in entity_inventory.items(): - current_value = inventory.get( - item, 0 - ) # Get current value or 0 if not exists - inventory[item] = current_value + value # Add new value + + if 'left' in entity.inventory.keys() or 'right' in entity.inventory.keys(): + for inv in ['left', 'right']: + if not inv in entity.inventory: + continue + entity_inventory = entity.inventory[inv] + for item, value in entity_inventory.items(): + current_value = inventory.get( + item, 0 + ) # Get current value or 0 if not exists + inventory[item] = current_value + value # Add new value + else: + entity_inventory = entity.inventory + for item, value in entity_inventory.items(): + current_value = inventory.get( + item, 0 + ) # Get current value or 0 if not exists + inventory[item] = current_value + value # Add new value if any(entity.warnings and entity.warnings[0] == "full" for entity in entities): status = EntityStatus.FULL_OUTPUT @@ -152,6 +164,28 @@ def process_group(group): # Create new underground belt representing the whole section try: + if 'left' in entrance.inventory: + if 'left' in exit.inventory: + left_inventory = entrance.inventory['left'] + exit.inventory['left'] + else: + left_inventory = entrance.inventory['left'] + else: + if 'left' in exit.inventory: + left_inventory = exit.inventory['left'] + else: + left_inventory = Inventory() + + if 'right' in entrance.inventory: + if 'right' in exit.inventory: + right_inventory = exit.inventory['right'] + exit.inventory['right'] + else: + right_inventory = exit.inventory['right'] + else: + if 'right' in exit.inventory: + right_inventory = exit.inventory['right'] + else: + right_inventory = Inventory() + consolidated = UndergroundBelt( name=entrance.name, id=entrance.id, @@ -169,15 +203,19 @@ def process_group(group): status=entrance.status, prototype=entrance.prototype, health=min(entrance.health, exit.health), - inventory=Inventory( - **{ - **entrance.inventory.__dict__, - **exit.inventory.__dict__, - } - ), + inventory={ + 'left': left_inventory, + 'right': right_inventory + } + # inventory=Inventory( + # **{ + # **entrance.inventory.__dict__, + # **exit.inventory.__dict__, + # } + # ), ) new_belts.append(consolidated) - except Exception: + except Exception as e: pass # Mark both entrance and exit for removal diff --git a/fle/env/tools/agent/get_entities/client.py b/fle/env/tools/agent/get_entities/client.py index 034c32f51..72c39d747 100644 --- a/fle/env/tools/agent/get_entities/client.py +++ b/fle/env/tools/agent/get_entities/client.py @@ -1,7 +1,10 @@ from time import sleep from typing import List, Set, Union +from memoization import cached + from fle.env.entities import Position, Entity, EntityGroup from fle.env.game_types import Prototype +from fle.env.tools.admin.render.profiler import profile_method from fle.env.tools.agent.connect_entities.groupable_entities import ( agglomerate_groupable_entities, ) @@ -12,19 +15,22 @@ class GetEntities(Tool): def __init__(self, connection, game_state): super().__init__(connection, game_state) + @cached(max_size=16, ttl=1) def __call__( self, entities: Union[Set[Prototype], Prototype] = set(), position: Position = None, - radius: float = 1000, + radius: float = 1000 ) -> List[Union[Entity, EntityGroup]]: """ Get entities within a radius of a given position. :param entities: Set of entity prototypes to filter by. If empty, all entities are returned. :param position: Position to search around. Can be a Position object or "player" for player's position. :param radius: Radius to search within. + :param player_only: If True, only player entities are returned, otherwise terrain features too. :return: Found entities """ + try: if not isinstance(position, Position) and position is not None: raise ValueError("The second argument must be a Position object") @@ -32,7 +38,7 @@ def __call__( if not isinstance(entities, Set): entities = set([entities]) - # Serialize entity_names as a string + # Serialize entity_names as a string entity_names = ( "[" + ",".join([f'"{entity.value[0]}"' for entity in entities]) + "]" if entities @@ -97,6 +103,16 @@ def __call__( } try: + if "inventory" in entity_data: + if isinstance(entity_data["inventory"], list): + for inv in entity_data["inventory"]: + entity_data['inventory'] += inv + else: + inventory_data = { + k: v for k, v in entity_data['inventory'].items() if v or isinstance(v, int) + } + entity_data['inventory'] = inventory_data + entity = metaclass(**entity_data) entities_list.append(entity) except Exception as e1: diff --git a/fle/env/tools/agent/get_entities/server.lua b/fle/env/tools/agent/get_entities/server.lua index 0b1440d8e..e8ea99796 100644 --- a/fle/env/tools/agent/get_entities/server.lua +++ b/fle/env/tools/agent/get_entities/server.lua @@ -20,6 +20,7 @@ global.actions.get_entities = function(player_index, radius, entity_names_json, end local entities + if #entity_names > 0 then entities = player.surface.find_entities_filtered{area = area, force = player.force, filter=filter} else diff --git a/fle/env/tools/agent/score/client.py b/fle/env/tools/agent/score/client.py index 9e2f23c61..6c58ac841 100644 --- a/fle/env/tools/agent/score/client.py +++ b/fle/env/tools/agent/score/client.py @@ -21,6 +21,9 @@ def __call__(self, *args, **kwargs): if isinstance(response, str): raise Exception("Could not get player score", response) + if not 'player' in response: + response["player"] = 0 + return response["player"], goal diff --git a/fle/env/tools/controller.py b/fle/env/tools/controller.py index 5f5f6cd75..e826392d6 100644 --- a/fle/env/tools/controller.py +++ b/fle/env/tools/controller.py @@ -1,6 +1,6 @@ import time from timeit import default_timer as timer -from typing import List, Tuple, Dict, Any +from typing import List, Tuple, Dict, Any, Union from slpp import slpp as lua, ParseError @@ -86,6 +86,8 @@ def clean_value(value): return cleaned_response def parse_lua_dict(self, d): + if isinstance(d, int): + return d if all(isinstance(k, int) for k in d.keys()): # Convert to list if all keys are numeric return [self.parse_lua_dict(d[k]) for k in sorted(d.keys())] @@ -94,6 +96,8 @@ def parse_lua_dict(self, d): new_dict = {} last_key = None + if isinstance(d, int): + pass for key in d.keys(): if isinstance(key, int): if last_key is not None and isinstance(d[key], str): @@ -131,17 +135,25 @@ def _get_command(self, command, parameters=[], measured=True): script = command return script - def execute(self, *args) -> Tuple[Dict, Any]: + def execute(self, *args, return_elapsed=False) -> Union[Tuple[Dict, Any], Tuple[Dict, Any, float]]: try: start = time.time() parameters = [lua.encode(arg) for arg in args] invocation = f"pcall(global.actions.{self.name}{(', ' if parameters else '') + ','.join(parameters)})" wrapped = f"{COMMAND} a, b = {invocation}; rcon.print(dump({{a=a, b=b}}))" lua_response = self.connection.rcon_client.send_command(wrapped) + elapsed = time.time() - start - parsed, elapsed = _lua2python(invocation, lua_response, start=start) + if isinstance(lua_response, str) and "error" in lua_response.lower(): + return lua_response, -1 + + parsed, _ = _lua2python(invocation, lua_response, start=0) if parsed is None: - return {}, lua_response # elapsed + result = {}, lua_response # elapsed + if return_elapsed: + return *result, elapsed + else: + return result if not parsed.get("a") and "b" in parsed and isinstance(parsed["b"], str): if parsed["b"] == "string": @@ -151,47 +163,52 @@ def execute(self, *args) -> Tuple[Dict, Any]: .replace('"', "") .strip() ) - return error, lua_response # elapsed - return parsed["b"], lua_response # elapsed + result = error, lua_response # elapsed + else: + result = parsed["b"], lua_response # elapsed + else: + result = parsed.get("b", {}), lua_response # elapsed - return parsed.get("b", {}), lua_response # elapsed + if return_elapsed: + return *result, elapsed + return result except Exception: return {}, -1 - def execute2(self, *args) -> Tuple[Dict, Any]: - lua_response = "" - try: - start = time.time() - parameters = [lua.encode(arg) for arg in args] - invocation = f"pcall(global.actions.{self.name}{(', ' if parameters else '') + ','.join(parameters)})" - wrapped = f"{COMMAND} a, b = {invocation}; rcon.print(dump({{a=a, b=b}}))" - lua_response = self.connection.rcon_client.send_command(wrapped) - parsed, elapsed = _lua2python(invocation, lua_response, start=start) - if not parsed["a"] and "b" in parsed and isinstance(parsed["b"], str): - parts = lua_response.split('["b"] = ') - parts[1] = f"{parts[1][:-2]}" if parts[1][-1] == "}" else parts[1] - parsed["b"] = parts[1].replace("!!", '"') - if "b" not in parsed: - return {}, elapsed - except ParseError as e: - # If a non-string gets passed back from the Lua script, it will raise a ParseError - # Split by `["b"] = ` and take the second part, which is the returned value - try: - parts = lua_response.split('["b"] = ') - return parts[1][:-2], -1 - except IndexError: - return e.args[0], -1 - return lua_response, -1 - except TypeError: - return lua_response, -1 - except Exception: - return lua_response, -1 - return parsed["b"], elapsed + # def execute2(self, *args) -> Tuple[Dict, Any]: + # lua_response = "" + # try: + # start = time.time() + # parameters = [lua.encode(arg) for arg in args] + # invocation = f"pcall(global.actions.{self.name}{(', ' if parameters else '') + ','.join(parameters)})" + # wrapped = f"{COMMAND} a, b = {invocation}; rcon.print(dump({{a=a, b=b}}))" + # lua_response = self.connection.rcon_client.send_command(wrapped) + # parsed, elapsed = _lua2python(invocation, lua_response, start=start) + # if not parsed["a"] and "b" in parsed and isinstance(parsed["b"], str): + # parts = lua_response.split('["b"] = ') + # parts[1] = f"{parts[1][:-2]}" if parts[1][-1] == "}" else parts[1] + # parsed["b"] = parts[1].replace("!!", '"') + # if "b" not in parsed: + # return {}, elapsed + # except ParseError as e: + # # If a non-string gets passed back from the Lua script, it will raise a ParseError + # # Split by `["b"] = ` and take the second part, which is the returned value + # try: + # parts = lua_response.split('["b"] = ') + # return parts[1][:-2], -1 + # except IndexError: + # return e.args[0], -1 + # return lua_response, -1 + # except TypeError: + # return lua_response, -1 + # except Exception: + # return lua_response, -1 + # return parsed["b"], elapsed def send(self, command, *parameters, trace=False) -> List[str]: start = timer() script = self._get_command(command, parameters=list(parameters), measured=False) - lua_response = self.connection.send_command(script) + lua_response = self.connection.rcon_client.send_command(script) # print(lua_response) return _lua2python(command, lua_response, start=start) diff --git a/fle/eval/algorithms/independent/gym_run_config.json b/fle/eval/algorithms/independent/gym_run_config.json index 173777af1..af2387748 100644 --- a/fle/eval/algorithms/independent/gym_run_config.json +++ b/fle/eval/algorithms/independent/gym_run_config.json @@ -1,6 +1,6 @@ [ { - "env_id": "Factorio-iron_ore_throughput_16-v0", + "env_id": "iron_plate_throughput_unbounded_steps_show_steps_false", "model": "claude-3-5-sonnet-latest" } ] \ No newline at end of file diff --git a/fle/eval/algorithms/independent/rebuttals_run_config.json b/fle/eval/algorithms/independent/rebuttals_run_config.json new file mode 100644 index 000000000..ef7887fca --- /dev/null +++ b/fle/eval/algorithms/independent/rebuttals_run_config.json @@ -0,0 +1,6 @@ +[ + { + "env_id": "electronic_circuit_throughput", + "model": "o3-2025-04-16" + } +] \ No newline at end of file diff --git a/fle/eval/algorithms/independent/trajectory_runner.py b/fle/eval/algorithms/independent/trajectory_runner.py index efba0976b..c415801ca 100644 --- a/fle/eval/algorithms/independent/trajectory_runner.py +++ b/fle/eval/algorithms/independent/trajectory_runner.py @@ -315,6 +315,7 @@ async def run(self): instance_namespace_before_program = ( self.evaluator.instance.namespaces[agent_idx] ) + ( evaluated_program, task_verification_response, diff --git a/fle/eval/tasks/unbounded_throughput_task.py b/fle/eval/tasks/unbounded_throughput_task.py index 8e4fd0aae..be8d28cd7 100644 --- a/fle/eval/tasks/unbounded_throughput_task.py +++ b/fle/eval/tasks/unbounded_throughput_task.py @@ -73,12 +73,14 @@ def verify( max_achievements = achievements else: break + + current_step = step_statistics["current_step_id"] if "current_step_id" in step_statistics else 0 return TaskResponse( success=False, meta={ "achievements": max_achievements, "nr_of_steps_left": self.trajectory_length - - step_statistics["current_step_id"] + - current_step - 1, }, ) diff --git a/fle/run.py b/fle/run.py index 4d0578ab5..dd4b87116 100644 --- a/fle/run.py +++ b/fle/run.py @@ -72,6 +72,59 @@ def fle_eval(args, env): print(f"Error: {e}", file=sys.stderr) sys.exit(1) +def fle_sprites(args): + """Handle sprite commands""" + try: + from fle.agents.data.sprites.download import download_sprites_from_hf, generate_sprites + except ImportError as e: + print(f"Error: Could not import sprite modules. Make sure dependencies are installed. {str(e)}", file=sys.stderr) + sys.exit(1) + + if args.sprites_command == "download": + # Download sprites from Hugging Face + success = download_sprites_from_hf( + repo_id=args.repo, + output_dir=args.output, + force=args.force + ) + if not success: + sys.exit(1) + + elif args.sprites_command == "generate": + # Generate individual sprites from spritemaps + success = generate_sprites( + input_dir=args.input, + output_dir=args.output, + ) + if not success: + sys.exit(1) + + elif args.sprites_command == "all": + # Do both download and generate + print("Downloading and generating sprites...") + + # Download first + download_dir = ".fle/spritemaps" + success = download_sprites_from_hf( + repo_id=args.repo, + output_dir=download_dir, + force=args.force + ) + if not success: + sys.exit(1) + + # Then generate + success = generate_sprites( + input_dir=download_dir, + output_dir=args.output + ) + if not success: + sys.exit(1) + + else: + print(f"Unknown sprites command: {args.sprites_command}", file=sys.stderr) + sys.exit(1) + def main(): parser = argparse.ArgumentParser( @@ -82,6 +135,7 @@ def main(): Examples: fle eval --config configs/gym_run_config.json fle cluster [start|stop|restart|help] [-n N] [-s SCENARIO] + fle sprites [download|generate|all] """, ) subparsers = parser.add_subparsers(dest="command") @@ -106,6 +160,75 @@ def main(): ) parser_eval = subparsers.add_parser("eval", help="Run experiment") parser_eval.add_argument("--config", required=True, help="Path to run config JSON") + + # Sprites command + parser_sprites = subparsers.add_parser("sprites", help="Manage Factorio sprites") + sprites_subparsers = parser_sprites.add_subparsers(dest="sprites_command", help="Sprites subcommands") + + # Sprites download + parser_download = sprites_subparsers.add_parser( + "download", help="Download sprites from Hugging Face" + ) + parser_download.add_argument( + "--repo", + default="Noddybear/fle_images", + help="Hugging Face dataset repository ID (default: Noddybear/fle_images)" + ) + parser_download.add_argument( + "--output", + default=".fle/spritemaps", + help="Output directory for downloaded sprites (default: .fle/spritemaps)" + ) + parser_download.add_argument( + "--force", + action="store_true", + help="Force re-download even if sprites exist" + ) + + # Sprites generate + parser_generate = sprites_subparsers.add_parser( + "generate", help="Generate individual sprites from spritemaps" + ) + parser_generate.add_argument( + "--input", + default=".fle/spritemaps", + help="Input directory containing spritemaps (default: .fle/spritemaps)" + ) + parser_generate.add_argument( + "--output", + default=".fle/sprites", + help="Output directory for generated sprites (default: .fle/sprites)" + ) + parser_generate.add_argument( + "--data", + default=".fle/spritemaps/data.json", + help="Path to data.json file for advanced extraction" + ) + + # Sprites all (download + generate) + parser_all = sprites_subparsers.add_parser( + "all", help="Download and generate sprites in one command" + ) + parser_all.add_argument( + "--repo", + default="Noddybear/fle_images", + help="Hugging Face dataset repository ID (default: Noddybear/fle_images)" + ) + parser_all.add_argument( + "--output", + default=".fle/sprites", + help="Output directory for generated sprites (default: .fle/sprites)" + ) + parser_all.add_argument( + "--data", + help="Path to data.json file for advanced extraction" + ) + parser_all.add_argument( + "--force", + action="store_true", + help="Force re-download even if sprites exist" + ) + args = parser.parse_args() env = True if args.command: @@ -114,10 +237,20 @@ def main(): fle_cluster(args) elif args.command == "eval": fle_eval(args, env) + elif args.command == "sprites": + fle_sprites(args) else: parser.print_help() sys.exit(1) if __name__ == "__main__": - main() + parser = argparse.ArgumentParser( + prog="fle", + description="Factorio Learning Environment CLI", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--config", default="eval/algorithms/independent/rebuttals_run_config.json", help="Path to run config JSON") + args = parser.parse_args() + fle_eval(args, True) + #main() diff --git a/pyproject.toml b/pyproject.toml index eb9df8d44..079e89148 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ dependencies = [ "aiohttp>=3.8.0", "uvicorn>=0.15.0", "pytest>=8.4.1", + "memoization>=0.4.0" ] [project.optional-dependencies] @@ -63,6 +64,8 @@ dev = [ "isort>=5.12.0", "rich>=14.0.0", "questionary>=2.1.0", + "huggingface-hub>=0.19.0", + "tqdm>=4.65.0" ] agents = [ "anthropic>=0.49.0", @@ -106,6 +109,10 @@ env = [ "fastapi>=0.68.0", ] +data = [ + "inspect-ai>=0.3.80", +] + [project.scripts] fle = "fle.run:main" @@ -141,3 +148,11 @@ publish-url = "https://upload.pypi.org/legacy/" name = "testpypi" url = "https://test.pypi.org/legacy/" publish-url = "https://test.pypi.org/legacy/" + +[tool.uv.sources] +factorio-learning-environment = { workspace = true } + +[dependency-groups] +dev = [ + "factorio-learning-environment", +] diff --git a/test_vqa_system.py b/test_vqa_system.py new file mode 100644 index 000000000..935352a6e --- /dev/null +++ b/test_vqa_system.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +""" +Test script for the Factorio Blueprint VQA system. +""" + +import asyncio +import sys +from pathlib import Path +import pytest +# Add the project root to Python path +sys.path.insert(0, str(Path(__file__).parent)) + +from vqa_dataset import ( + BlueprintLoader, + QuestionGenerator, + VQADatasetPipeline, + FactorioBlueprintAnalyzer +) + +@pytest.fixture() +def game(instance): + instance.initial_inventory = { + "iron-chest": 1, + "small-electric-pole": 20, + "iron-plate": 10, + "assembling-machine-1": 1, + "pipe-to-ground": 10, + "pipe": 30, + "transport-belt": 50, + "underground-belt": 30, + 'splitter': 1, + 'lab': 1 + } + instance.reset() + yield instance.namespace + instance.reset() + + +async def test_blueprint_loading(): + """Test blueprint loading functionality.""" + print("Testing blueprint loading...") + + blueprints_dir = "fle/agents/data/blueprints_to_policies/blueprints" + loader = BlueprintLoader(blueprints_dir) + + # Load blueprints from example directory + blueprints = loader.load_all_blueprints(['example']) + print(f"Loaded {len(blueprints)} blueprints from example directory") + + if blueprints: + # Test one blueprint + blueprint_name, blueprint = list(blueprints.items())[0] + print(f"Sample blueprint: {blueprint_name}") + print(f" Total entities: {blueprint.get_total_entity_count()}") + print(f" Entity types: {blueprint.get_unique_entity_types()}") + print(f" Dimensions: {blueprint.get_dimensions()}") + + # Test statistics + stats = loader.get_blueprint_statistics(blueprints) + print(f"Blueprint statistics: {stats}") + + return True + else: + print("No blueprints loaded!") + return False + + +def test_question_generation(): + """Test question generation functionality.""" + print("\nTesting question generation...") + + blueprints_dir = "fle/agents/data/blueprints_to_policies/blueprints" + loader = BlueprintLoader(blueprints_dir) + generator = QuestionGenerator() + + # Load a few blueprints + blueprints = loader.load_all_blueprints(['example']) + blueprints = loader.filter_blueprints_by_complexity(blueprints, max_entities=50) + + if not blueprints: + print("No blueprints available for testing!") + return False + + # Generate questions + questions = generator.generate_questions_batch( + blueprints, + num_questions_per_blueprint=3 + ) + + print(f"Generated {len(questions)} questions") + + # Show sample questions + print("Sample questions:") + for i, q in enumerate(questions[:5]): + print(f"{i+1}. Q: {q.question}") + print(f" A: {q.answer}") + print(f" Type: {q.question_type}") + print() + + # Test statistics + stats = generator.get_question_statistics(questions) + print(f"Question statistics: {stats}") + + return True + + +def test_blueprint_analyzer(): + """Test the blueprint analyzer functionality.""" + print("\nTesting blueprint analyzer...") + + analyzer = FactorioBlueprintAnalyzer() + + try: + results = analyzer.run_evaluation( + "fle/agents/data/blueprints_to_policies/blueprints", + output_file="test_analysis_results.json", + max_blueprints=3 + ) + + print(f"Analysis completed for {results['total_blueprints']} blueprints") + + if results['blueprints']: + sample = results['blueprints'][0] + print(f"Sample analysis for '{sample['blueprint_name']}':") + print(f" Total entities: {sample['total_entities']}") + print(f" Entity types: {len(sample['unique_entity_types'])}") + print(f" Questions generated: {len(sample['questions_and_answers'])}") + + return True + + except Exception as e: + print(f"Analyzer test failed: {e}") + return False + + +async def test_pipeline_without_rendering(): + """Test the complete pipeline without rendering (faster).""" + print("\nTesting complete pipeline (without rendering)...") + + try: + pipeline = VQADatasetPipeline( + blueprints_dir="fle/agents/data/blueprints_to_policies/blueprints", + output_dir="test_output" + ) + + results = await pipeline.run_pipeline( + blueprint_subdirs=['example'], + max_blueprints=5, + max_entities=100, + questions_per_blueprint=4, + render_images=False # Skip rendering for faster testing + ) + + print("Pipeline test completed successfully!") + print(f"Processed {results['blueprints_count']} blueprints") + print(f"Generated {results['questions_count']} questions") + print(f"Output directory: {results['output_directory']}") + + return True + + except Exception as e: + print(f"Pipeline test failed: {e}") + import traceback + traceback.print_exc() + return False + + +async def test_small_pipeline_with_rendering(): + """Test pipeline with rendering on a very small dataset.""" + print("\nTesting pipeline with rendering (small dataset)...") + + try: + pipeline = VQADatasetPipeline( + blueprints_dir="fle/agents/data/blueprints_to_policies/blueprints", + output_dir="test_output_with_images" + ) + + results = await pipeline.run_pipeline( + blueprint_subdirs=['example'], + max_blueprints=2, # Very small for testing + max_entities=50, # Small blueprints only + questions_per_blueprint=2, + render_images=True + ) + + print("Pipeline with rendering completed successfully!") + print(f"Processed {results['blueprints_count']} blueprints") + print(f"Generated {results['questions_count']} questions") + print(f"Rendered {results['rendered_images_count']} images") + + return True + + except Exception as e: + print(f"Pipeline with rendering failed: {e}") + import traceback + traceback.print_exc() + return False + + +async def main(): + """Run all tests.""" + print("Starting VQA System Tests") + print("=" * 50) + + tests = [ + ("Blueprint Loading", test_blueprint_loading()), + ("Question Generation", test_question_generation()), + ("Blueprint Analyzer", test_blueprint_analyzer()), + ("Pipeline (No Rendering)", test_pipeline_without_rendering()), + ("Pipeline (With Rendering)", test_small_pipeline_with_rendering()), + ] + + results = [] + for test_name, test_coro in tests: + print(f"\n{'='*20} {test_name} {'='*20}") + try: + if asyncio.iscoroutine(test_coro): + result = await test_coro + else: + result = test_coro + results.append((test_name, result)) + print(f"✓ {test_name}: {'PASSED' if result else 'FAILED'}") + except Exception as e: + print(f"✗ {test_name}: FAILED with error: {e}") + results.append((test_name, False)) + + # Summary + print(f"\n{'='*50}") + print("TEST SUMMARY") + print("=" * 50) + + passed = sum(1 for _, result in results if result) + total = len(results) + + for test_name, result in results: + status = "PASSED" if result else "FAILED" + print(f"{test_name}: {status}") + + print(f"\nOverall: {passed}/{total} tests passed") + + if passed == total: + print("🎉 All tests passed! The VQA system is working correctly.") + return 0 + else: + print("❌ Some tests failed. Please check the errors above.") + return 1 + + +if __name__ == "__main__": + sys.exit(asyncio.run(main())) \ No newline at end of file diff --git a/tests/actions/performance_reports/detailed_report.txt b/tests/actions/performance_reports/detailed_report.txt new file mode 100644 index 000000000..eda5bf1d6 --- /dev/null +++ b/tests/actions/performance_reports/detailed_report.txt @@ -0,0 +1,134 @@ +================================================================================ +RENDERING PERFORMANCE REPORT +================================================================================ + +Session: renderer_test +Duration: 2.569s + +OVERALL STATISTICS: +Total operations: 3,132 +Total time: 0.946s +Average time per operation: 0.30ms + +TOP PERFORMANCE BOTTLENECKS: +Operation Count Total(s) Avg(ms) %Time +------------------------------------------------------------------------------------------------ +Render.__call__ 1 0.439 438.81 46.4 % +Renderer.render 1 0.127 126.73 13.4 % +Renderer.__init__ 1 0.093 93.12 9.8 % +Render._get_map_entities 1 0.084 83.88 8.9 % +Renderer._render_resources 1 0.041 41.02 4.3 % +image_load_from_disk 69 0.027 0.39 2.8 % +ImageResolver.__call__ 1000 0.022 0.02 2.3 % +Renderer._render_trees 1 0.020 19.70 2.1 % +Renderer._render_tree_shadows 1 0.018 18.26 1.9 % +Renderer._render_water_tiles 1 0.012 11.78 1.2 % + +PERFORMANCE BY CATEGORY: + +RENDERING: 0.895s (94.7%) + Operations: 21 + 1. Render.__call__: 0.439s (1 calls) + 2. Renderer.render: 0.127s (1 calls) + 3. Renderer.__init__: 0.093s (1 calls) + +IMAGE_LOADING: 0.048s (5.1%) + Operations: 2 + 1. image_load_from_disk: 0.027s (69 calls) + 2. ImageResolver.__call__: 0.022s (1000 calls) + +OTHER: 0.001s (0.1%) + Operations: 1 + 1. transport_belt.get_around: 0.001s (84 calls) + +IMAGE CACHE PERFORMANCE: +Total image calls: 1,000 +Total image time: 0.022s +Average time per call: 0.02ms +Cache hit rate: 94.7% + +ENTITY RENDERING BREAKDOWN: + +RESOURCES: + Total time: 0.041s + Operations: 1 + Avg per operation: 41.02ms + +OTHER: + Total time: 0.049s + Operations: 49 + Avg per operation: 0.99ms + +TREES: + Total time: 0.020s + Operations: 1 + Avg per operation: 19.70ms + +RAILS: + Total time: 0.001s + Operations: 1 + Avg per operation: 1.23ms + +ENTITIES: + Total time: 0.011s + Operations: 1 + Avg per operation: 10.72ms + +COUNTERS: + +alert_overlays_rendered: 3 +image_cache_hits: 1,223 +image_cache_misses: 69 +images_loaded: 69 +player_entities: 56 +resources: 616 +rock_entities: 0 +total_entities: 209 +tree_entities: 153 +water_tiles: 264 + +RAW TIMING DATA: +================================================== +=== Performance Report === + +Timing Statistics: +Operation Count Total(s) Avg(ms) Min(ms) Max(ms) +---------------------------------------------------------------------------------------- +Render.__call__ 1 0.439 438.81 438.81 438.81 +Renderer.render 1 0.127 126.73 126.73 126.73 +Renderer.__init__ 1 0.093 93.12 93.12 93.12 +Render._get_map_entities 1 0.084 83.88 83.88 83.88 +Renderer._render_resources 1 0.041 41.02 41.02 41.02 +image_load_from_disk 69 0.027 0.39 0.13 9.34 +ImageResolver.__call__ 1000 0.022 0.02 0.00 1.10 +Renderer._render_trees 1 0.020 19.70 19.70 19.70 +Renderer._render_tree_shadows 1 0.018 18.26 18.26 18.26 +Renderer._render_water_tiles 1 0.012 11.78 11.78 11.78 +Renderer.get_size 2 0.011 5.66 4.83 6.50 +Renderer._render_entities 1 0.011 10.72 10.72 10.72 +Renderer._render_visible_inventories 1 0.009 8.56 8.56 8.56 +Renderer._paste_image 1000 0.007 0.01 0.00 0.06 +transport_belt.render_inventory 44 0.005 0.12 0.00 0.69 +Renderer._create_base_image 1 0.004 4.02 4.02 4.02 +transport_belt.render 44 0.004 0.09 0.05 0.40 +Renderer._render_entity_shadows 1 0.003 2.62 2.62 2.62 +Renderer._render_alert_overlays 1 0.002 2.31 2.31 2.31 +Renderer._precompute_tree_variants 1 0.002 2.25 2.25 2.25 +RendererManager.get_renderer 871 0.002 0.00 0.00 0.48 +transport_belt.get_around 84 0.001 0.02 0.01 0.06 +Renderer._render_rails 1 0.001 1.23 1.23 1.23 +Renderer._draw_grid 1 0.001 1.08 1.08 1.08 +Renderer._resolve_sprites_dir 1 0.001 0.80 0.80 0.80 +Renderer._sort_entities_for_rendering 1 0.000 0.08 0.08 0.08 + +Counters: +alert_overlays_rendered: 3 +image_cache_hits: 1223 +image_cache_misses: 69 +images_loaded: 69 +player_entities: 56 +resources: 616 +rock_entities: 0 +total_entities: 209 +tree_entities: 153 +water_tiles: 264 \ No newline at end of file diff --git a/tests/actions/test_render.py b/tests/actions/test_render.py index 1b0e3b6e7..34befc8a0 100644 --- a/tests/actions/test_render.py +++ b/tests/actions/test_render.py @@ -1,7 +1,14 @@ +import json +from pathlib import Path +from time import sleep + import pytest +from data.vqa.blueprint_transforms import flip_blueprint, FlipType +from fle.env import Direction from fle.env.entities import Position, Layer from fle.env.game_types import Prototype +from fle.env.tools.admin.render.performance_tools import start_profiling, stop_profiling_and_report @pytest.fixture() @@ -12,25 +19,209 @@ def game(instance): "iron-plate": 10, "assembling-machine-1": 1, "pipe-to-ground": 10, + 'burner-inserter': 1, "pipe": 30, - "transport-belt": 50, + "transport-belt": 80, "underground-belt": 30, + 'splitter': 4, + 'lab': 1, + 'coal': 10, + 'iron-ore': 200, + 'gun-turret': 2 } instance.reset() yield instance.namespace instance.reset() +def test_blueprint_render(game): + #image = game._render(position=Position(x=0, y=5), layers=Layer.ALL) + #image.show() + + # Find all JSON files + blueprints_path = Path("/Users/jackhopkins/PycharmProjects/PaperclipMaximiser/.fle/blueprints") + json_files = list(blueprints_path.glob("*.json")) + + # Load the JSON file + for json_file in json_files: + with open(json_file, 'r') as f: + blueprint_data = json.load(f) + + if 'label' in blueprint_data and blueprint_data['label'] == '11_10_balancer_red_blue': + #game._render(blueprint=rotate_blueprint(blueprint_data, rotation=Rotation.EAST)).show(title="east") + #game._render(blueprint=rotate_blueprint(blueprint_data, rotation=Rotation.WEST)).show(title="west") + + game._render(blueprint=blueprint_data).show(title="north") + + h_flipped = flip_blueprint(blueprint_data, FlipType.VERTICAL) + game._render(blueprint=h_flipped).show(title="north") + + pass + pass + +def test_blueprint_render2(game): + #image = game._render(position=Position(x=0, y=5), layers=Layer.ALL) + #image.show() + + # Find all JSON files + blueprints_path = Path("/Users/jackhopkins/PycharmProjects/PaperclipMaximiser/.fle/blueprints") + json_files = list(blueprints_path.glob("*.json")) + + # Load the JSON file + with open(json_files[0], 'r') as f: + blueprint_data = json.load(f) + + if blueprint_data['label'] == '11_10_balancer_red_blue': + image = game._render(blueprint=blueprint_data) + image.show() + pass def test_basic_render(game): - game.place_entity(Prototype.IronChest, position=Position(x=0, y=0)) + # Start profiling + analyzer = start_profiling("renderer_test") + + game.reset() + + chest = game.place_entity(Prototype.IronChest, position=Position(x=0, y=0)) + + game.insert_item(Prototype.IronOre, chest, 200) + + entity = game.place_entity(Prototype.BurnerInserter, position=chest.position.above()) + game.insert_item(Prototype.Coal, entity, 10) + + game.place_entity(Prototype.Splitter, direction=Direction.UP, position=Position(x=0, y=-5)) + game.place_entity(Prototype.Splitter, direction=Direction.LEFT, position=Position(x=-5, y=0)) + game.place_entity(Prototype.Splitter, direction=Direction.DOWN, position=Position(x=0, y=5)) + game.place_entity(Prototype.Splitter, direction=Direction.RIGHT, position=Position(x=5, y=0)) + + game.place_entity(Prototype.Lab, position=Position(x=10, y=0)) + + game.place_entity(Prototype.GunTurret, position=Position(x=8, y=0)) + game.connect_entities( Position(x=0, y=-2), Position(x=15, y=5), - {Prototype.Pipe, Prototype.UndergroundPipe}, + Position(x=5, y=8), + Position(x=15, y=15), + Prototype.TransportBelt ) + # game.connect_entities( + # Position(x=0, y=-2), + # Position(x=15, y=5), + # {Prototype.TransportBelt, Prototype.UndergroundBelt}, + # ) + + # game.connect_entities( + # Position(x=15, y=9), + # Position(x=0, y=2), + # {Prototype.TransportBelt, Prototype.UndergroundBelt}, + # ) + game.connect_entities( Position(x=0, y=-10), Position(x=15, y=-10), {Prototype.SmallElectricPole} ) + + #observation = game._observe_all(radius=20) + #json_observation = json.dumps(observation) + #sleep(2) + image = game._render(position=Position(x=0, y=5), layers=Layer.ALL) + + # Generate comprehensive report + report = stop_profiling_and_report(analyzer) + print(report) + + # Also save detailed analysis + output_dir = Path("./performance_reports") + output_dir.mkdir(exist_ok=True) + + detailed_report = analyzer.generate_performance_report( + output_file=output_dir / "detailed_report.txt", + include_raw_data=True + ) + + image.show() + + # image2 = game._render_simple(position=Position(x=0, y=5), layers=Layer.ALL) + # image2.show() + pass + + +def test_cliff_orientations(game): + game.reset() + + # Clear existing cliffs + game.instance.rcon_client.send_command( + "/sc " + "for _, cliff in pairs(game.surfaces[1].find_entities_filtered{type='cliff'}) do " + "cliff.destroy() " + "end" + ) + + # Create all cliff orientations in a grid pattern + game.instance.rcon_client.send_command( + "/sc " + "-- All possible cliff orientations\n" + "local orientations = {" + " 'west-to-east', 'north-to-south', 'east-to-west', 'south-to-north'," + " 'west-to-north', 'north-to-east', 'east-to-south', 'south-to-west'," + " 'west-to-south', 'north-to-west', 'east-to-north', 'south-to-east'," + " 'west-to-none', 'none-to-east', 'east-to-none', 'none-to-west'," + " 'north-to-none', 'none-to-south', 'south-to-none', 'none-to-north'" + "} " + + "-- Place each orientation in a grid\n" + "for i, orientation in ipairs(orientations) do " + " local row = math.floor((i-1) / 5) " + " local col = (i-1) % 5 " + " local x = col * 6 - 15 " + " local y = row * 6 - 10 " + " game.surfaces[1].create_entity{" + " name='cliff', " + " position={x=x, y=y}, " + " cliff_orientation=orientation" + " } " + " -- Add label (using flying text for visualization)\n" + " game.surfaces[1].create_entity{" + " name='flying-text', " + " position={x=x, y=y-2}, " + " text=orientation" + " } " + "end" + ) + + # Create test patterns for each cliff type + game.instance.rcon_client.send_command( + "/sc " + "-- Straight line (cliff-sides)\n" + "for i=-3,3 do " + " game.surfaces[1].create_entity{name='cliff', position={x=i*2, y=20}, cliff_orientation='west-to-east'} " + "end " + + "-- L-shaped outer corner (cliff-outer)\n" + "game.surfaces[1].create_entity{name='cliff', position={x=-20, y=20}, cliff_orientation='west-to-north'} " + "game.surfaces[1].create_entity{name='cliff', position={x=-18, y=20}, cliff_orientation='west-to-east'} " + "game.surfaces[1].create_entity{name='cliff', position={x=-20, y=22}, cliff_orientation='north-to-south'} " + + "-- L-shaped inner corner (cliff-inner)\n" + "game.surfaces[1].create_entity{name='cliff', position={x=20, y=20}, cliff_orientation='north-to-south'} " + "game.surfaces[1].create_entity{name='cliff', position={x=20, y=22}, cliff_orientation='west-to-south'} " + "game.surfaces[1].create_entity{name='cliff', position={x=22, y=22}, cliff_orientation='west-to-east'} " + + "-- Terminal pieces (cliff-entrance)\n" + "game.surfaces[1].create_entity{name='cliff', position={x=-10, y=30}, cliff_orientation='west-to-none'} " + "game.surfaces[1].create_entity{name='cliff', position={x=-8, y=30}, cliff_orientation='west-to-east'} " + "game.surfaces[1].create_entity{name='cliff', position={x=-6, y=30}, cliff_orientation='none-to-east'} " + + "-- T-junction pattern\n" + "for i=-2,2 do " + " game.surfaces[1].create_entity{name='cliff', position={x=i*2, y=35}, cliff_orientation='west-to-east'} " + "end " + "for i=1,3 do " + " game.surfaces[1].create_entity{name='cliff', position={x=0, y=35+i*2}, cliff_orientation='north-to-south'} " + "end" + ) + + image = game._render(position=Position(x=0, y=10), radius=40, layers=Layer.ALL) + image.show() \ No newline at end of file diff --git a/tests/functional/test_electricity_unit.py b/tests/functional/test_electricity_unit.py index 2de409925..145bfd5b3 100644 --- a/tests/functional/test_electricity_unit.py +++ b/tests/functional/test_electricity_unit.py @@ -83,7 +83,7 @@ def test_create_offshore_pump_to_steam_engine(game): assert steam_engine.direction.value == Direction.opposite(boiler.direction).value - image = game._render(Position(x=5, y=0), zoom=5) + image = game._render()#, zoom=5) image.show() pass diff --git a/tests/render/__init__.py b/tests/render/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/render/test_cliffs.py b/tests/render/test_cliffs.py new file mode 100644 index 000000000..abc3cce06 --- /dev/null +++ b/tests/render/test_cliffs.py @@ -0,0 +1,307 @@ +import pytest +from fle.env.entities import Position, Layer +from fle.env.game_types import Prototype + + +@pytest.fixture() +def game(instance): + instance.initial_inventory = { + "iron-chest": 1, + "small-electric-pole": 20, + "iron-plate": 10, + "assembling-machine-1": 1, + "pipe-to-ground": 10, + "pipe": 30, + "transport-belt": 50, + "underground-belt": 30, + 'splitter': 1, + 'lab': 1 + } + instance.reset() + yield instance.namespace + instance.reset() + + +@pytest.fixture() +def clear_terrain(game): + """Clear cliffs and rocks before each test""" + game.instance.rcon_client.send_command( + "/sc " + "for _, cliff in pairs(game.surfaces[1].find_entities_filtered{type='cliff'}) do " + "cliff.destroy() " + "end " + "for _, rock in pairs(game.surfaces[1].find_entities_filtered{type='simple-entity'}) do " + "if rock.name:find('rock') then rock.destroy() end " + "end" + ) + return game + + +def test_cliff_straight_lines(clear_terrain): + """Test straight cliff formations (cliff-sides)""" + game = clear_terrain + + # Create horizontal cliff line + game.instance.rcon_client.send_command( + "/sc " + "for i=-5,5 do " + "game.surfaces[1].create_entity{" + "name='cliff', " + "position={x=i*2, y=0}, " + "cliff_orientation='west-to-east'} " + "end" + ) + + # Create vertical cliff line + game.instance.rcon_client.send_command( + "/sc " + "for i=-5,5 do " + "game.surfaces[1].create_entity{" + "name='cliff', " + "position={x=0, y=i*2}, " + "cliff_orientation='north-to-south'} " + "end" + ) + + image = game._render(position=Position(x=0, y=0), radius=15, layers=Layer.ALL) + image.show() # Uncomment to view + assert image is not None + + +def test_cliff_outer_corners(clear_terrain): + """Test outer corner cliff formations (cliff-outer)""" + game = clear_terrain + + # Create L-shaped outer corners for all 4 orientations + game.instance.rcon_client.send_command( + "/sc " + "-- Bottom-left outer corner\n" + "game.surfaces[1].create_entity{name='cliff', position={x=-10, y=0}, cliff_orientation='west-to-north'} " + "game.surfaces[1].create_entity{name='cliff', position={x=-8, y=0}, cliff_orientation='west-to-east'} " + "game.surfaces[1].create_entity{name='cliff', position={x=-10, y=2}, cliff_orientation='north-to-south'} " + + "-- Bottom-right outer corner\n" + "game.surfaces[1].create_entity{name='cliff', position={x=10, y=0}, cliff_orientation='north-to-east'} " + "game.surfaces[1].create_entity{name='cliff', position={x=8, y=0}, cliff_orientation='east-to-west'} " + "game.surfaces[1].create_entity{name='cliff', position={x=10, y=2}, cliff_orientation='north-to-south'} " + + "-- Top-right outer corner\n" + "game.surfaces[1].create_entity{name='cliff', position={x=10, y=10}, cliff_orientation='east-to-south'} " + "game.surfaces[1].create_entity{name='cliff', position={x=8, y=10}, cliff_orientation='east-to-west'} " + "game.surfaces[1].create_entity{name='cliff', position={x=10, y=8}, cliff_orientation='south-to-north'} " + + "-- Top-left outer corner\n" + "game.surfaces[1].create_entity{name='cliff', position={x=-10, y=10}, cliff_orientation='south-to-west'} " + "game.surfaces[1].create_entity{name='cliff', position={x=-8, y=10}, cliff_orientation='west-to-east'} " + "game.surfaces[1].create_entity{name='cliff', position={x=-10, y=8}, cliff_orientation='south-to-north'} " + ) + + image = game._render(position=Position(x=0, y=5), radius=15, layers=Layer.ALL) + image.show() + assert image is not None + + +def test_cliff_inner_corners(clear_terrain): + """Test inner corner cliff formations (cliff-inner)""" + game = clear_terrain + + # Create inner corners + game.instance.rcon_client.send_command( + "/sc " + "-- Create a box with inner corners\n" + "-- Top edge\n" + "for i=-3,3 do " + " if i ~= 0 then " + " game.surfaces[1].create_entity{name='cliff', position={x=i*2, y=-6}, cliff_orientation='west-to-east'} " + " end " + "end " + "-- Bottom edge\n" + "for i=-3,3 do " + " if i ~= 0 then " + " game.surfaces[1].create_entity{name='cliff', position={x=i*2, y=6}, cliff_orientation='west-to-east'} " + " end " + "end " + "-- Left edge\n" + "for i=-2,2 do " + " if i ~= 0 then " + " game.surfaces[1].create_entity{name='cliff', position={x=-6, y=i*2}, cliff_orientation='north-to-south'} " + " end " + "end " + "-- Right edge\n" + "for i=-2,2 do " + " if i ~= 0 then " + " game.surfaces[1].create_entity{name='cliff', position={x=6, y=i*2}, cliff_orientation='north-to-south'} " + " end " + "end " + "-- Inner corners\n" + "game.surfaces[1].create_entity{name='cliff', position={x=-6, y=-6}, cliff_orientation='west-to-south'} " + "game.surfaces[1].create_entity{name='cliff', position={x=6, y=-6}, cliff_orientation='south-to-east'} " + "game.surfaces[1].create_entity{name='cliff', position={x=6, y=6}, cliff_orientation='east-to-north'} " + "game.surfaces[1].create_entity{name='cliff', position={x=-6, y=6}, cliff_orientation='north-to-west'} " + ) + + image = game._render(position=Position(x=0, y=0), radius=10, layers=Layer.ALL) + image.show() + assert image is not None + + +def test_cliff_terminals(clear_terrain): + """Test terminal cliff pieces (cliff-entrance)""" + game = clear_terrain + + # Create all terminal orientations + game.instance.rcon_client.send_command( + "/sc " + "-- Terminals ending in each direction\n" + "game.surfaces[1].create_entity{name='cliff', position={x=-6, y=0}, cliff_orientation='west-to-none'} " + + "game.surfaces[1].create_entity{name='cliff', position={x=6, y=0}, cliff_orientation='east-to-none'} " + + "game.surfaces[1].create_entity{name='cliff', position={x=0, y=-6}, cliff_orientation='north-to-none'} " + + "game.surfaces[1].create_entity{name='cliff', position={x=0, y=6}, cliff_orientation='south-to-none'} " + + "-- Terminals starting from each direction\n" + "game.surfaces[1].create_entity{name='cliff', position={x=-10, y=10}, cliff_orientation='none-to-east'} " + + "game.surfaces[1].create_entity{name='cliff', position={x=10, y=10}, cliff_orientation='none-to-west'} " + + "game.surfaces[1].create_entity{name='cliff', position={x=-10, y=-10}, cliff_orientation='none-to-south'} " + + "game.surfaces[1].create_entity{name='cliff', position={x=10, y=-10}, cliff_orientation='none-to-north'} " + ) + + image = game._render(position=Position(x=0, y=0), radius=15, layers=Layer.ALL) + image.show() + assert image is not None + + +def test_cliff_t_junctions(clear_terrain): + """Test T-junction cliff formations""" + game = clear_terrain + + # Create T-junctions in all 4 orientations + game.instance.rcon_client.send_command( + "/sc " + "-- T-junction pointing up\n" + "for i=-2,2 do " + " game.surfaces[1].create_entity{name='cliff', position={x=i*2, y=0}, cliff_orientation='west-to-east'} " + "end " + "for i=1,3 do " + " game.surfaces[1].create_entity{name='cliff', position={x=0, y=-i*2}, cliff_orientation='north-to-south'} " + "end " + + "-- T-junction pointing down\n" + "for i=-2,2 do " + " game.surfaces[1].create_entity{name='cliff', position={x=i*2, y=10}, cliff_orientation='west-to-east'} " + "end " + "for i=1,3 do " + " game.surfaces[1].create_entity{name='cliff', position={x=0, y=10+i*2}, cliff_orientation='north-to-south'} " + "end " + + "-- T-junction pointing right\n" + "for i=-2,2 do " + " game.surfaces[1].create_entity{name='cliff', position={x=-10, y=i*2}, cliff_orientation='north-to-south'} " + "end " + "for i=1,3 do " + " game.surfaces[1].create_entity{name='cliff', position={x=-10+i*2, y=0}, cliff_orientation='west-to-east'} " + "end " + + "-- T-junction pointing left\n" + "for i=-2,2 do " + " game.surfaces[1].create_entity{name='cliff', position={x=10, y=i*2}, cliff_orientation='north-to-south'} " + "end " + "for i=1,3 do " + " game.surfaces[1].create_entity{name='cliff', position={x=10-i*2, y=0}, cliff_orientation='west-to-east'} " + "end " + ) + + image = game._render(position=Position(x=0, y=5), radius=20, layers=Layer.ALL) + image.show() + assert image is not None + + +def test_cliff_all_orientations_grid(clear_terrain): + """Test all 20 cliff orientations in a grid layout""" + game = clear_terrain + + game.instance.rcon_client.send_command( + "/sc " + "local orientations = {" + " 'west-to-east', 'north-to-south', 'east-to-west', 'south-to-north'," + " 'west-to-north', 'north-to-east', 'east-to-south', 'south-to-west'," + " 'west-to-south', 'north-to-west', 'east-to-north', 'south-to-east'," + " 'west-to-none', 'none-to-east', 'east-to-none', 'none-to-west'," + " 'north-to-none', 'none-to-south', 'south-to-none', 'none-to-north'" + "} " + "for i, orientation in ipairs(orientations) do " + " local row = math.floor((i-1) / 5) " + " local col = (i-1) % 5 " + " local x = col * 4 - 8 " + " local y = row * 4 - 6 " + " game.surfaces[1].create_entity{" + " name='cliff', " + " position={x=x, y=y}, " + " cliff_orientation=orientation" + " } " + "end" + ) + + image = game._render(position=Position(x=0, y=0), radius=12, layers=Layer.ALL) + # image.show() + assert image is not None + + +def test_entities_with_cliffs(clear_terrain): + """Test entity placement alongside cliffs""" + game = clear_terrain + + # Create some cliffs + game.instance.rcon_client.send_command( + "/sc " + "for i=-3,3 do " + " game.surfaces[1].create_entity{name='cliff', position={x=i*2, y=-10}, cliff_orientation='west-to-east'} " + "end" + ) + + # Place entities + game.place_entity(Prototype.IronChest, position=Position(x=0, y=0)) + game.place_entity(Prototype.Splitter, position=Position(x=5, y=0)) + game.place_entity(Prototype.Lab, position=Position(x=10, y=0)) + + # Create transport belt connections + game.connect_entities( + Position(x=0, y=-2), + Position(x=15, y=5), + {Prototype.TransportBelt, Prototype.UndergroundBelt}, + ) + + game.connect_entities( + Position(x=0, y=-5), + Position(x=15, y=-5), + {Prototype.SmallElectricPole} + ) + + image = game._render(position=Position(x=5, y=0), radius=20, layers=Layer.ALL) + # image.show() + assert image is not None + + +def test_rocks_and_decoratives(clear_terrain): + """Test rock placement as decoratives""" + game = clear_terrain + + game.instance.rcon_client.send_command( + "/sc " + "local rock_types = {'rock-huge', 'rock-big', 'sand-rock-big'} " + "for i=1,10 do " + " local rock = rock_types[math.random(#rock_types)] " + " local x = math.random(-10, 10) " + " local y = math.random(-10, 10) " + " game.surfaces[1].create_entity{name=rock, position={x=x, y=y}} " + "end" + ) + + image = game._render(position=Position(x=0, y=0), radius=15, layers=Layer.ALL) + # image.show() + assert image is not None \ No newline at end of file