commit 6aeae4fdfd739447eae4f95737a1114f3a9cce55 Author: tcsenpai Date: Sun Mar 30 22:46:27 2025 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..09eca21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules +logs +bun.lockb \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ca8d12e --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 tcsenpai (https://github.com/tcsenpai) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b57eab2 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# Fluffles Simulator + +## Overview + +Fluffles Simulator is an evolutionary simulation that models a population of small creatures called "Fluffles" in a dynamic ecosystem. The simulation demonstrates natural selection, genetic inheritance, and emergent behaviors as Fluffles compete for resources, reproduce, and adapt to their environment. + +## Features + +- **Genetic Evolution**: Fluffles inherit traits from their parents with slight mutations, allowing populations to evolve over time. +- **Complex Behaviors**: Fluffles make decisions based on their needs, genetic traits, and environmental conditions. +- **Life Cycle**: Fluffles are born, mature, reproduce, and eventually die of old age or other causes. +- **Predation**: Hungry or aggressive Fluffles may attack others to survive. +- **Environmental Challenges**: Natural disasters like earthquakes, droughts, disease, and cold snaps create evolutionary pressure. +- **Terrain Variety**: The world contains grass (food), water, and rocks, each affecting movement and survival. +- **Population Dynamics**: Overcrowding creates pressure that affects reproduction and resource availability. + +## Genetic Traits + +Fluffles have various genetic traits that influence their behavior and survival: + +- **Size**: Affects energy consumption and appearance +- **Speed**: Determines movement efficiency and hunting/escape success +- **Metabolism**: Controls energy usage and hunger rate +- **Intelligence**: Affects decision-making quality +- **Aggression**: Influences tendency to attack other Fluffles +- **Friendship**: Counteracts aggression and promotes social behavior +- **Social Behavior**: Determines desire to be near other Fluffles +- **Reproductive Urge**: Affects frequency of mating attempts +- **Lifespan**: Determines maximum age +- **Maturity Age**: Sets when a Fluffles can reproduce +- **Fur Color**: Visual trait with no survival impact + +## Controls + +- **Left/Right Arrows**: Select previous/next animal +- **H**: Toggle help screen +- **+/-**: Increase/decrease simulation speed +- **Q** or **Esc**: Quit the simulation + +## Visual Indicators + +- **F, f**: Adult Fluffles (different sizes) +- **•**: Baby Fluffles (not mature yet) +- **♥**: Fluffles that are mating +- **♣, ♠, ·**: Grass (different densities) +- **≈**: Water +- **▲**: Rocks/Mountains + +## Life Cycle + +1. Fluffles are born as babies (•) +2. They mature around age 25 (varies by genetics) +3. They can mate only when mature (takes 4 turns) +4. Their lifespan is around 100 (varies by genetics) +5. Population pressure affects reproduction success + +## Technical Details + +The simulation is built with TypeScript and uses the Blessed library for terminal-based UI. It implements: + +- A genetic system with inheritance and mutation +- A tile-based world with different terrain types +- A decision-making system based on needs and traits +- Environmental challenges and disasters +- Detailed logging and statistics + +**NOTE:** This has been coded using Claude 3.7 Sonnet as a guide and is a fun project to play around with. Don't take it too seriously. + +## Running the Simulation + +To run the simulation: + +1. Make sure you have Node.js and Bun installed +2. Clone the repository +3. Run `bun install` to install dependencies +4. Run `bun start` to start the simulation + +Logs are saved to the `logs` directory for later analysis. + +## Development + +The codebase is organized into several key components: + +- **Animal**: Base class for all creatures +- **Fluffles**: Implementation of the specific animal type +- **DNA**: Handles genetic traits, inheritance and mutation +- **World**: Manages the terrain, resources, and population +- **Simulation**: Controls the overall simulation flow +- **Renderer**: Handles the terminal-based UI +- **Logger**: Records events for analysis + +## License + +This project is licensed under the MIT License. See the LICENSE file for details. diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..5a8e1f5 --- /dev/null +++ b/bun.lock @@ -0,0 +1,143 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "animal-simulator", + "dependencies": { + "blessed": "^0.1.81", + "blessed-contrib": "^4.11.0", + "chalk": "^5.3.0", + }, + "devDependencies": { + "bun-types": "latest", + }, + }, + }, + "packages": { + "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], + + "@types/node": ["@types/node@22.13.14", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-Zs/Ollc1SJ8nKUAgc7ivOEdIBM8JAKgrqqUYi2J997JuKO7/tpQC+WCetQ1sypiKCQWHdvdg9wBNpUPEWZae7w=="], + + "@types/ws": ["@types/ws@8.18.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw=="], + + "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], + + "ansi-escapes": ["ansi-escapes@6.2.1", "", {}, "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig=="], + + "ansi-regex": ["ansi-regex@2.1.1", "", {}, "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA=="], + + "ansi-styles": ["ansi-styles@2.2.1", "", {}, "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA=="], + + "ansi-term": ["ansi-term@0.0.2", "", { "dependencies": { "x256": ">=0.0.1" } }, "sha512-jLnGE+n8uAjksTJxiWZf/kcUmXq+cRWSl550B9NmQ8YiqaTM+lILcSe5dHdp8QkJPhaOghDjnMKwyYSMjosgAA=="], + + "ansicolors": ["ansicolors@0.3.2", "", {}, "sha512-QXu7BPrP29VllRxH8GwB7x5iX5qWKAAMLqKQGWTeLWVlNHNOpVMJ91dsxQAIWXpjuW5wqvxu3Jd/nRjrJ+0pqg=="], + + "blessed": ["blessed@0.1.81", "", { "bin": { "blessed": "./bin/tput.js" } }, "sha512-LoF5gae+hlmfORcG1M5+5XZi4LBmvlXTzwJWzUlPryN/SJdSflZvROM2TwkT0GMpq7oqT48NRd4GS7BiVBc5OQ=="], + + "blessed-contrib": ["blessed-contrib@4.11.0", "", { "dependencies": { "ansi-term": ">=0.0.2", "chalk": "^1.1.0", "drawille-canvas-blessed-contrib": ">=0.1.3", "lodash": "~>=4.17.21", "map-canvas": ">=0.1.5", "marked": "^4.0.12", "marked-terminal": "^5.1.1", "memory-streams": "^0.1.0", "memorystream": "^0.3.1", "picture-tuber": "^1.0.1", "sparkline": "^0.1.1", "strip-ansi": "^3.0.0", "term-canvas": "0.0.5", "x256": ">=0.0.1" } }, "sha512-P00Xji3xPp53+FdU9f74WpvnOAn/SS0CKLy4vLAf5Ps7FGDOTY711ruJPZb3/7dpFuP+4i7f4a/ZTZdLlKG9WA=="], + + "bresenham": ["bresenham@0.0.3", "", {}, "sha512-wbMxoJJM1p3+6G7xEFXYNCJ30h2qkwmVxebkbwIl4OcnWtno5R3UT9VuYLfStlVNAQCmRjkGwjPFdfaPd4iNXw=="], + + "buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="], + + "bun-types": ["bun-types@1.2.7", "", { "dependencies": { "@types/node": "*", "@types/ws": "*" } }, "sha512-P4hHhk7kjF99acXqKvltyuMQ2kf/rzIw3ylEDpCxDS9Xa0X0Yp/gJu/vDCucmWpiur5qJ0lwB2bWzOXa2GlHqA=="], + + "cardinal": ["cardinal@2.1.1", "", { "dependencies": { "ansicolors": "~0.3.2", "redeyed": "~2.1.0" }, "bin": { "cdl": "./bin/cdl.js" } }, "sha512-JSr5eOgoEymtYHBjNWyjrMqet9Am2miJhlfKNdqLp6zoeAh0KN5dRAcxlecj5mAJrmQomgiOBj35xHLrFjqBpw=="], + + "chalk": ["chalk@5.4.1", "", {}, "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w=="], + + "charm": ["charm@0.1.2", "", {}, "sha512-syedaZ9cPe7r3hoQA9twWYKu5AIyCswN5+szkmPBe9ccdLrj4bYaCnLVPTLd2kgVRc7+zoX4tyPgRnFKCj5YjQ=="], + + "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "drawille-blessed-contrib": ["drawille-blessed-contrib@1.0.0", "", {}, "sha512-WnHMgf5en/hVOsFhxLI8ZX0qTJmerOsVjIMQmn4cR1eI8nLGu+L7w5ENbul+lZ6w827A3JakCuernES5xbHLzQ=="], + + "drawille-canvas-blessed-contrib": ["drawille-canvas-blessed-contrib@0.1.3", "", { "dependencies": { "ansi-term": ">=0.0.2", "bresenham": "0.0.3", "drawille-blessed-contrib": ">=0.0.1", "gl-matrix": "^2.1.0", "x256": ">=0.0.1" } }, "sha512-bdDvVJOxlrEoPLifGDPaxIzFh3cD7QH05ePoQ4fwnqfi08ZSxzEhOUpI5Z0/SQMlWgcCQOEtuw0zrwezacXglw=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "escape-string-regexp": ["escape-string-regexp@1.0.5", "", {}, "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "event-stream": ["event-stream@0.9.8", "", { "dependencies": { "optimist": "0.2" } }, "sha512-o5h0Mp1bkoR6B0i7pTCAzRy+VzdsRWH997KQD4Psb0EOPoKEIiaRx/EsOdUl7p1Ktjw7aIWvweI/OY1R9XrlUg=="], + + "gl-matrix": ["gl-matrix@2.8.1", "", {}, "sha512-0YCjVpE3pS5XWlN3J4X7AiAx65+nqAI54LndtVFnQZB6G/FVLkZH8y8V6R3cIoOQR4pUdfwQGd1iwyoXHJ4Qfw=="], + + "has-ansi": ["has-ansi@2.0.0", "", { "dependencies": { "ansi-regex": "^2.0.0" } }, "sha512-C8vBJ8DwUCx19vhm7urhTuUsr4/IyP6l4VzNQDv+ryHQObW3TTTp9yB68WpYgRe2bbaGuZ/se74IqFeVnMnLZg=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "here": ["here@0.0.2", "", {}, "sha512-U7VYImCTcPoY27TSmzoiFsmWLEqQFaYNdpsPb9K0dXJhE6kufUqycaz51oR09CW85dDU9iWyy7At8M+p7hb3NQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "isarray": ["isarray@0.0.1", "", {}, "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ=="], + + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "map-canvas": ["map-canvas@0.1.5", "", { "dependencies": { "drawille-canvas-blessed-contrib": ">=0.0.1", "xml2js": "^0.4.5" } }, "sha512-f7M3sOuL9+up0NCOZbb1rQpWDLZwR/ftCiNbyscjl9LUUEwrRaoumH4sz6swgs58lF21DQ0hsYOCw5C6Zz7hbg=="], + + "marked": ["marked@4.3.0", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A=="], + + "marked-terminal": ["marked-terminal@5.2.0", "", { "dependencies": { "ansi-escapes": "^6.2.0", "cardinal": "^2.1.1", "chalk": "^5.2.0", "cli-table3": "^0.6.3", "node-emoji": "^1.11.0", "supports-hyperlinks": "^2.3.0" }, "peerDependencies": { "marked": "^1.0.0 || ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0" } }, "sha512-Piv6yNwAQXGFjZSaiNljyNFw7jKDdGrw70FSbtxEyldLsyeuV5ZHm/1wW++kWbrOF1VPnUgYOhB2oLL0ZpnekA=="], + + "memory-streams": ["memory-streams@0.1.3", "", { "dependencies": { "readable-stream": "~1.0.2" } }, "sha512-qVQ/CjkMyMInPaaRMrwWNDvf6boRZXaT/DbQeMYcCWuXPEBf1v8qChOc9OlEVQp2uOvRXa1Qu30fLmKhY6NipA=="], + + "memorystream": ["memorystream@0.3.1", "", {}, "sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw=="], + + "node-emoji": ["node-emoji@1.11.0", "", { "dependencies": { "lodash": "^4.17.21" } }, "sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A=="], + + "nopt": ["nopt@2.1.2", "", { "dependencies": { "abbrev": "1" }, "bin": { "nopt": "./bin/nopt.js" } }, "sha512-x8vXm7BZ2jE1Txrxh/hO74HTuYZQEbo8edoRcANgdZ4+PCV+pbjd/xdummkmjjC7LU5EjPzlu8zEq/oxWylnKA=="], + + "optimist": ["optimist@0.3.7", "", { "dependencies": { "wordwrap": "~0.0.2" } }, "sha512-TCx0dXQzVtSCg2OgY/bO9hjM9cV4XYx09TVK+s3+FhkjT6LovsLe+pPMzpWf+6yXK/hUizs2gUoTw3jHM0VaTQ=="], + + "picture-tuber": ["picture-tuber@1.0.2", "", { "dependencies": { "buffers": "~0.1.1", "charm": "~0.1.0", "event-stream": "~0.9.8", "optimist": "~0.3.4", "png-js": "~0.1.0", "x256": "~0.0.1" }, "bin": { "picture-tube": "bin/tube.js" } }, "sha512-49/xq+wzbwDeI32aPvwQJldM8pr7dKDRuR76IjztrkmiCkAQDaWFJzkmfVqCHmt/iFoPFhHmI9L0oKhthrTOQw=="], + + "png-js": ["png-js@0.1.1", "", {}, "sha512-NTtk2SyfjBm+xYl2/VZJBhFnTQ4kU5qWC7VC4/iGbrgiU4FuB4xC+74erxADYJIqZICOR1HCvRA7EBHkpjTg9g=="], + + "readable-stream": ["readable-stream@1.0.34", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.1", "isarray": "0.0.1", "string_decoder": "~0.10.x" } }, "sha512-ok1qVCJuRkNmvebYikljxJA/UEsKwLl2nI1OmaqAu4/UE+h0wKCHok4XkL/gvi39OacXvw59RJUOFUkDib2rHg=="], + + "redeyed": ["redeyed@2.1.1", "", { "dependencies": { "esprima": "~4.0.0" } }, "sha512-FNpGGo1DycYAdnrKFxCMmKYgo/mILAqtRYbkdQD8Ep/Hk2PQ5+aEAEx+IU713RTDmuBaH0c8P5ZozurNu5ObRQ=="], + + "sax": ["sax@1.4.1", "", {}, "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg=="], + + "sparkline": ["sparkline@0.1.2", "", { "dependencies": { "here": "0.0.2", "nopt": "~2.1.2" }, "bin": { "sparkline": "bin/sparkline" } }, "sha512-t//aVOiWt9fi/e22ea1vXVWBDX+gp18y+Ch9sKqmHl828bRfvP2VtfTJVEcgWFBQHd0yDPNQRiHdqzCvbcYSDA=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@0.10.31", "", {}, "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ=="], + + "strip-ansi": ["strip-ansi@3.0.1", "", { "dependencies": { "ansi-regex": "^2.0.0" } }, "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg=="], + + "supports-color": ["supports-color@2.0.0", "", {}, "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g=="], + + "supports-hyperlinks": ["supports-hyperlinks@2.3.0", "", { "dependencies": { "has-flag": "^4.0.0", "supports-color": "^7.0.0" } }, "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA=="], + + "term-canvas": ["term-canvas@0.0.5", "", {}, "sha512-eZ3rIWi5yLnKiUcsW8P79fKyooaLmyLWAGqBhFspqMxRNUiB4GmHHk5AzQ4LxvFbJILaXqQZLwbbATLOhCFwkw=="], + + "undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="], + + "wordwrap": ["wordwrap@0.0.3", "", {}, "sha512-1tMA907+V4QmxV7dbRvb4/8MaRALK6q9Abid3ndMYnbyo8piisCmeONVqVSXqQA3KaP4SLt5b7ud6E2sqP8TFw=="], + + "x256": ["x256@0.0.2", "", {}, "sha512-ZsIH+sheoF8YG9YG+QKEEIdtqpHRA9FYuD7MqhfyB1kayXU43RUNBFSxBEnF8ywSUxdg+8no4+bPr5qLbyxKgA=="], + + "xml2js": ["xml2js@0.4.23", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug=="], + + "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + + "blessed-contrib/chalk": ["chalk@1.1.3", "", { "dependencies": { "ansi-styles": "^2.2.1", "escape-string-regexp": "^1.0.2", "has-ansi": "^2.0.0", "strip-ansi": "^3.0.0", "supports-color": "^2.0.0" } }, "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A=="], + + "event-stream/optimist": ["optimist@0.2.8", "", { "dependencies": { "wordwrap": ">=0.0.1 <0.1.0" } }, "sha512-Wy7E3cQDpqsTIFyW7m22hSevyTLxw850ahYv7FWsw4G6MIKVTZ8NSA95KBrQ95a4SMsMr1UGUUnwEFKhVaSzIg=="], + + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "supports-hyperlinks/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + } +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..05b3ed7 --- /dev/null +++ b/index.ts @@ -0,0 +1,23 @@ +import { Simulation } from './src/simulation'; + +// Create and start the simulation with a size that fits most terminals +const simulation = new Simulation(30, 15); +simulation.initialize(5); // Start with fewer fluffles for clarity + +// Display startup message +console.log("Starting Fluffles Simulator..."); +console.log("Press 'q' to quit"); + +simulation.start(); + +// Handle process termination +process.on('SIGINT', () => { + console.log('Shutting down simulation...'); + simulation.stop(); + process.exit(0); +}); + +process.on('exit', () => { + // Ensure simulation is stopped properly + simulation.stop(); +}); \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..7aa43bb --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "animal-simulator", + "module": "index.ts", + "type": "module", + "scripts": { + "start": "bun run index.ts", + "dev": "bun --watch run index.ts" + }, + "devDependencies": { + "bun-types": "latest" + }, + "dependencies": { + "blessed": "^0.1.81", + "blessed-contrib": "^4.11.0", + "chalk": "^5.3.0" + } +} \ No newline at end of file diff --git a/src/animal.ts b/src/animal.ts new file mode 100644 index 0000000..231f931 --- /dev/null +++ b/src/animal.ts @@ -0,0 +1,324 @@ +import { DNA, Gene } from './dna'; +import { Position, World } from './world'; +import { Logger } from './logger'; + +export interface AnimalStats { + health: number; + energy: number; + age: number; + hunger: number; +} + +export abstract class Animal { + protected id: string; + protected dna: DNA; + protected position: Position; + protected stats: AnimalStats; + protected world: World; + protected logger: Logger; + + constructor(dna: DNA, position: Position, world: World, logger: Logger) { + this.id = crypto.randomUUID(); + this.dna = dna; + this.position = position; + this.world = world; + this.logger = logger; + + // Initialize stats based on DNA + this.stats = { + health: 100, + energy: 100, + age: 0, + hunger: 0 + }; + + this.logger.log(`Animal ${this.id} was born at position (${position.x}, ${position.y})`); + } + + getId(): string { + return this.id; + } + + getDNA(): DNA { + return this.dna; + } + + getPosition(): Position { + return {...this.position}; + } + + getStats(): AnimalStats { + return {...this.stats}; + } + + setPosition(position: Position): void { + this.position = position; + } + + // Update the animal's state for one time step + update(): void { + // Increment age + this.stats.age += 1; + + // Increase hunger + this.stats.hunger = Math.min(100, this.stats.hunger + 1); + + // Decrease energy slightly + this.stats.energy = Math.max(0, this.stats.energy - 0.5); + + // Check for death conditions + if (this.stats.health <= 0 || this.stats.hunger >= 100) { + this.die(); + return; + } + + // Check for old age death + const lifespanGene = this.dna.getGene('lifespan'); + const baseLifespan = 100; + const lifespanModifier = lifespanGene ? lifespanGene.value * 50 : 0; // 0 to +50 range + const maxAge = baseLifespan + lifespanModifier; + + // Chance of death increases with age + if (this.stats.age > maxAge * 0.7) { + const deathChance = (this.stats.age - (maxAge * 0.7)) / (maxAge * 0.3); + if (Math.random() < deathChance * 0.1) { + this.logger.log(`Animal ${this.id.substring(0, 6)} died of old age at ${this.stats.age}`); + this.die(); + return; + } + } + + // If still alive, perform actions + this.act(); + } + + protected calculateEnergyConsumption(): number { + // Base energy consumption + let consumption = 1; + + // Size affects energy consumption + const sizeGene = this.dna.getGene('size'); + if (sizeGene) { + consumption += sizeGene.value * 2; // Larger animals consume more energy + } + + // Activity level affects energy consumption + const activityGene = this.dna.getGene('activity'); + if (activityGene) { + consumption += activityGene.value * 1.5; + } + + return consumption; + } + + protected abstract act(): void; + + protected move(direction: 'north' | 'east' | 'south' | 'west'): void { + const newPosition = {...this.position}; + + switch (direction) { + case 'north': + newPosition.y = Math.max(0, newPosition.y - 1); + break; + case 'east': + newPosition.x = Math.min(this.world.getWidth() - 1, newPosition.x + 1); + break; + case 'south': + newPosition.y = Math.min(this.world.getHeight() - 1, newPosition.y + 1); + break; + case 'west': + newPosition.x = Math.max(0, newPosition.x - 1); + break; + } + + // Check if the new position is valid + if (this.world.isPositionValid(newPosition)) { + this.position = newPosition; + this.stats.energy -= 1; // Moving costs energy + + // Only log movements occasionally to reduce spam + if (Math.random() < 0.05) { + this.logger.log(`Animal ${this.id.substring(0, 6)} moved to (${this.position.x}, ${this.position.y})`); + } + } + } + + protected eat(foodValue: number): void { + this.stats.hunger = Math.max(0, this.stats.hunger - foodValue); + this.stats.energy = Math.min(100, this.stats.energy + foodValue / 2); + + // Only log eating occasionally + if (Math.random() < 0.2) { + this.logger.log(`Animal ${this.id.substring(0, 6)} ate food with value ${foodValue}`); + } + } + + protected die(): void { + this.world.removeAnimal(this.id); + this.logger.log(`Animal ${this.id} has died at age ${this.stats.age}`); + } + + // Method for reproduction + reproduce(partner: Animal): Animal | null { + // Check if both animals are mature + if (!this.isAdult() || !partner.isAdult()) { + return null; + } + + // Check if both animals have enough energy to reproduce + if (this.stats.energy < 50 || partner.getStats().energy < 50) { + return null; + } + + // Get population pressure from the world + const populationPressure = this.world.getPopulationPressure(); + + // Make reproduction harder when population is high + const reproductiveUrgeGene = this.dna.getGene('reproductiveUrge'); + const reproductiveUrge = reproductiveUrgeGene ? reproductiveUrgeGene.value : 0.5; + + // Calculate success chance based on population pressure and reproductive urge + const successChance = Math.max(0.1, reproductiveUrge - (populationPressure * 0.08)); + + if (Math.random() > successChance) { + // Failed reproduction attempt due to environmental factors + this.stats.energy -= 10; // Still costs some energy to try + return null; + } + + // Combine DNA + const childDNA = DNA.combine(this.dna, partner.getDNA()); + + // Possibly mutate + const mutatedDNA = childDNA.mutate(); + + // Create a new animal of the same type + const childPosition = { + x: Math.floor((this.position.x + partner.getPosition().x) / 2), + y: Math.floor((this.position.y + partner.getPosition().y) / 2) + }; + + // Consume energy for reproduction + this.stats.energy -= 30; + + this.logger.log(`Animal ${this.id.substring(0, 6)} reproduced with ${partner.getId().substring(0, 6)}`); + + // The specific animal type will be created by the derived class + const offspring = this.createOffspring(mutatedDNA, childPosition); + + // Check if the world accepted the new animal + if (!this.world.addAnimal(offspring)) { + this.logger.log(`Offspring couldn't survive due to overpopulation`); + return null; + } + + return offspring; + } + + protected abstract createOffspring(dna: DNA, position: Position): Animal; + + // Method for rendering the animal + abstract render(): string; + + applyEnvironmentalStress(pressureLevel: number): void { + // Higher pressure means more competition for resources + if (pressureLevel > 7) { + // Severe pressure + this.stats.hunger += 0.5; // Animals get hungrier faster + this.stats.energy -= 0.3; // Animals tire more quickly + + // Chance of health decline due to overcrowding + if (Math.random() < 0.05) { + this.stats.health -= 1; + if (Math.random() < 0.1) { + this.logger.log(`Animal ${this.id.substring(0, 6)} suffering from overcrowding`); + } + } + } else if (pressureLevel > 5) { + // Moderate pressure + this.stats.hunger += 0.3; + this.stats.energy -= 0.1; + } + } + + protected isAdult(): boolean { + // Check if the animal has reached maturity + const lifespanGene = this.dna.getGene('lifespan'); + const maturityGene = this.dna.getGene('maturityAge'); + + // Default maturity age is 25, but can be modified by genes + const baseMaturityAge = 25; + const maturityModifier = maturityGene ? maturityGene.value * 20 - 10 : 0; // -10 to +10 range + const maturityAge = Math.max(15, baseMaturityAge + maturityModifier); + + return this.stats.age >= maturityAge; + } + + protected attack(target: Animal): boolean { + // Check if we have enough energy to attack + if (this.stats.energy < 20) { + return false; + } + + // Get relevant genes + const aggressionGene = this.dna.getGene('aggression'); + const speedGene = this.dna.getGene('speed'); + const targetSpeedGene = target.getDNA().getGene('speed'); + + // Calculate attack success chance based on genes + const aggression = aggressionGene ? aggressionGene.value : 0.3; + const speed = speedGene ? speedGene.value : 0.5; + const targetSpeed = targetSpeedGene ? targetSpeedGene.value : 0.5; + + // Speed difference affects chance to catch prey + const speedAdvantage = Math.max(0, speed - targetSpeed); + + // Calculate success chance + const successChance = 0.3 + (aggression * 0.4) + (speedAdvantage * 0.3); + + // Attacking costs energy + this.stats.energy -= 15; + + if (Math.random() < successChance) { + // Attack succeeded + this.logger.log(`Animal ${this.id.substring(0, 6)} successfully attacked ${target.getId().substring(0, 6)}`); + + // Gain energy and reduce hunger from the kill + const energyGain = 30 + Math.floor(Math.random() * 20); + this.stats.energy = Math.min(100, this.stats.energy + energyGain); + this.stats.hunger = Math.max(0, this.stats.hunger - 40); + + // Target dies + target.die(); + return true; + } else { + // Attack failed + if (Math.random() < 0.3) { + this.logger.log(`Animal ${this.id.substring(0, 6)} failed to catch ${target.getId().substring(0, 6)}`); + } + return false; + } + } + + protected shouldBeAggressive(): boolean { + // Get relevant genes + const aggressionGene = this.dna.getGene('aggression'); + const friendshipGene = this.dna.getGene('friendship'); + + // Calculate base aggression + const aggression = aggressionGene ? aggressionGene.value : 0.3; + const friendship = friendshipGene ? friendshipGene.value : 0.5; + + // Low energy increases aggression + const energyFactor = Math.max(0, 1 - (this.stats.energy / 100)); + + // Hunger increases aggression - this is now a stronger factor + const hungerFactor = this.stats.hunger / 100; + + // Calculate overall aggression tendency + // Hunger now has a higher weight (0.4) compared to energy (0.2) + const aggressionTendency = (aggression * 0.3) + (energyFactor * 0.2) + (hungerFactor * 0.4) - (friendship * 0.3); + + return Math.random() < aggressionTendency; + } +} \ No newline at end of file diff --git a/src/animals/fluffles.ts b/src/animals/fluffles.ts new file mode 100644 index 0000000..86a252f --- /dev/null +++ b/src/animals/fluffles.ts @@ -0,0 +1,400 @@ +import { Animal } from '../animal'; +import { DNA } from '../dna'; +import { Position, World } from '../world'; +import { Logger } from '../logger'; + +export class Fluffles extends Animal { + private matingPartner: string | null = null; + private matingProgress: number = 0; + private readonly MATING_DURATION = 4; + + constructor(dna: DNA, position: Position, world: World, logger: Logger) { + super(dna, position, world, logger); + + // If no DNA is provided, initialize with standard genes + if (dna.getAllGenes().length === 0) { + // Create a new DNA with fluffles-specific tweaks to standard values + const flufflesDNA = DNA.createStandard({ + size: 0.3, // Fluffles are small + speed: 0.7, // Fluffles are fast + metabolism: 0.7, // Fluffles have high metabolism + vision: 0.6, // Fluffles have good vision + intelligence: 0.4, // Fluffles are moderately intelligent + aggression: 0.2, // Fluffles are not aggressive + socialBehavior: 0.6, // Fluffles are somewhat social + reproductiveUrge: 0.8, // Fluffles reproduce quickly + maturityAge: 0.4 // Fluffles mature relatively quickly + }); + + // Copy all genes to this animal's DNA + flufflesDNA.getAllGenes().forEach(gene => { + this.dna.addGene(gene); + }); + } + } + + protected act(): void { + // If currently mating, continue the mating process + if (this.matingPartner !== null) { + this.continueMating(); + return; + } + + // Get relevant genes that affect behavior + const intelligenceGene = this.dna.getGene('intelligence'); + const aggressionGene = this.dna.getGene('aggression'); + const socialGene = this.dna.getGene('socialBehavior'); + const reproductiveUrgeGene = this.dna.getGene('reproductiveUrge'); + + // Intelligence affects decision making + const intelligence = intelligenceGene ? intelligenceGene.value : 0.3; + + // Get population pressure + const populationPressure = this.world.getPopulationPressure(); + + // Check if we should be aggressive (more likely when hungry or low energy) + const shouldAttack = this.shouldBeAggressive(); + + // Make smarter decisions based on intelligence + const randomFactor = Math.random(); + + // Higher intelligence means more likely to make optimal decisions + if (randomFactor < intelligence) { + // Make the best decision based on current needs + if (this.stats.hunger > 70) { + // Very hungry, prioritize food + if (shouldAttack && (this.stats.hunger > 80 || this.stats.energy < 50)) { + // Try hunting instead of foraging when very hungry + this.huntOthers(); + } else { + this.searchForFood(); + } + } + else if (this.stats.energy < 30) { + // Low energy, rest + this.rest(); + } + else if (this.stats.hunger > 40) { + // Somewhat hungry, look for food + if (shouldAttack && (this.stats.hunger > 60 || this.stats.energy < 50)) { + // Try hunting instead of foraging + this.huntOthers(); + } else { + this.searchForFood(); + } + } + else if (reproductiveUrgeGene && + reproductiveUrgeGene.value > 0.6 && + this.stats.energy > 70 && + populationPressure < 7 && + this.isAdult()) { // Only seek mates when mature and population isn't too high + // High reproductive urge and enough energy, look for mates + this.searchForMates(); + } + else if (socialGene && socialGene.value > 0.7 && populationPressure < 8 && !shouldAttack) { + // Highly social, seek other fluffles (unless very overcrowded or aggressive) + this.seekCompany(); + } + else if (shouldAttack && this.stats.energy < 50) { + // Become aggressive when energy is low + this.huntOthers(); + } + else { + // Otherwise, explore + this.wander(); + } + } else { + // Less intelligent animals make more random choices + const choice = Math.random(); + + if (shouldAttack && this.stats.energy < 50 && choice < 0.4) { + this.huntOthers(); + } else if (choice < 0.4) { + this.searchForFood(); + } else if (choice < 0.6) { + this.rest(); + } else { + this.wander(); + } + } + } + + // New method to handle the mating process + private continueMating(): void { + // Check if partner still exists + const partner = this.world.getAnimal(this.matingPartner!); + if (!partner) { + // Partner disappeared, cancel mating + this.matingPartner = null; + this.matingProgress = 0; + return; + } + + // Increment mating progress + this.matingProgress++; + + // Check if mating is complete + if (this.matingProgress >= this.MATING_DURATION) { + // Attempt reproduction + const offspring = this.reproduce(partner); + + // Reset mating state + this.matingPartner = null; + this.matingProgress = 0; + + if (offspring) { + this.logger.log(`Fluffles ${this.id.substring(0, 6)} and ${partner.getId().substring(0, 6)} successfully produced offspring!`); + } + } else { + // Stay close to partner during mating + const partnerPos = partner.getPosition(); + if (Math.abs(partnerPos.x - this.position.x) + Math.abs(partnerPos.y - this.position.y) > 1) { + // Move toward partner if separated + this.moveToward(partnerPos); + } + } + } + + // Update the searchForMates method to initiate mating + private searchForMates(): void { + const nearbyAnimals = this.world.getAnimalsInRadius(this.position, 2); + + for (const animal of nearbyAnimals) { + if (animal.getId() !== this.id && animal instanceof Fluffles) { + // Check if the other animal is mature + if (!animal.isAdult()) continue; + + const pos = animal.getPosition(); + const distance = Math.abs(pos.x - this.position.x) + Math.abs(pos.y - this.position.y); + + if (distance <= 1) { + // Adjacent animal, initiate mating + this.startMating(animal.getId()); + return; + } else if (distance <= 2) { + // Nearby animal, move toward it + this.moveToward(pos); + return; + } + } + } + + // If no potential mates found, just wander + this.wander(); + } + + // New method to start the mating process + private startMating(partnerId: string): void { + // Only start if not already mating + if (this.matingPartner === null) { + this.matingPartner = partnerId; + this.matingProgress = 0; + + if (Math.random() < 0.3) { + this.logger.log(`Fluffles ${this.id.substring(0, 6)} started mating with ${partnerId.substring(0, 6)}`); + } + } + } + + protected createOffspring(dna: DNA, position: Position): Animal { + return new Fluffles(dna, position, this.world, this.logger); + } + + render(): string { + // Render the fluffles based on its fur color and other attributes + const furColorGene = this.dna.getGene('furColor'); + const sizeGene = this.dna.getGene('size'); + const energyLevel = this.stats.energy; + + // Different symbols based on size + let symbol = 'f'; + if (sizeGene) { + if (sizeGene.value > 0.7) symbol = 'F'; + else if (sizeGene.value < 0.3) symbol = '°'; + } + + // If currently mating, use a special symbol + if (this.matingPartner !== null) { + symbol = '♥'; + } + + // If not mature yet, use a baby symbol + if (!this.isAdult()) { + symbol = '•'; + } + + // Color based on fur color gene + let color = 'white'; + if (furColorGene) { + if (furColorGene.value > 0.8) color = 'red'; + else if (furColorGene.value > 0.6) color = 'yellow'; + else if (furColorGene.value > 0.4) color = 'magenta'; + else if (furColorGene.value > 0.2) color = 'blue'; + else color = 'white'; + } + + // Add brightness based on energy level + let brightness = ''; + if (energyLevel < 30) brightness = '-fg'; + else brightness = '-fg'; + + return `{${color}${brightness}}${symbol}{/${color}${brightness}}`; + } + + private wander(): void { + // Simple random movement + const directions = ['north', 'east', 'south', 'west'] as const; + const randomDirection = directions[Math.floor(Math.random() * directions.length)]; + this.move(randomDirection); + } + + private searchForFood(): void { + // Look for food in adjacent tiles + const adjacentPositions = [ + { x: this.position.x, y: this.position.y - 1 }, // North + { x: this.position.x + 1, y: this.position.y }, // East + { x: this.position.x, y: this.position.y + 1 }, // South + { x: this.position.x - 1, y: this.position.y } // West + ]; + + // Check each adjacent position for food + let bestFoodPosition = null; + let bestFoodValue = 0; + + for (const pos of adjacentPositions) { + if (this.world.isPositionValid(pos)) { + const tile = this.world.getTile(pos); + if (tile && tile.type === 'grass' && tile.foodValue > bestFoodValue) { + bestFoodValue = tile.foodValue; + bestFoodPosition = pos; + } + } + } + + if (bestFoodPosition && bestFoodValue > 0) { + // Move toward the food + if (bestFoodPosition.y < this.position.y) { + this.move('north'); + } else if (bestFoodPosition.x > this.position.x) { + this.move('east'); + } else if (bestFoodPosition.y > this.position.y) { + this.move('south'); + } else if (bestFoodPosition.x < this.position.x) { + this.move('west'); + } + + // Eat if we're on a grass tile + const currentTile = this.world.getTile(this.position); + if (currentTile && currentTile.type === 'grass' && currentTile.foodValue > 0) { + const foodEaten = this.world.consumeFood(this.position, Math.min(5, currentTile.foodValue)); + this.eat(foodEaten); + } + } else { + // No food found, just wander + this.wander(); + } + } + + private rest(): void { + // Stay in place and recover energy + this.stats.energy = Math.min(100, this.stats.energy + 10); + + if (Math.random() < 0.1) { + this.logger.log(`Fluffles ${this.id.substring(0, 6)} is resting and recovering energy`); + } + } + + private seekCompany(): void { + const nearbyAnimals = this.world.getAnimalsInRadius(this.position, 3); + + if (nearbyAnimals.length > 0) { + // Find the closest fluffles + let closestFluffles = null; + let closestDistance = Infinity; + + for (const animal of nearbyAnimals) { + if (animal.getId() !== this.id) { // Not self + const pos = animal.getPosition(); + const distance = Math.abs(pos.x - this.position.x) + Math.abs(pos.y - this.position.y); + + if (distance < closestDistance) { + closestFluffles = animal; + closestDistance = distance; + } + } + } + + if (closestFluffles) { + // Move toward the closest fluffles + const targetPos = closestFluffles.getPosition(); + this.moveToward(targetPos); + return; + } + } + + // If no fluffles found, just wander + this.wander(); + } + + // Helper method to move toward a target position + private moveToward(targetPos: Position): void { + // Determine which direction gets us closer to the target + const dx = targetPos.x - this.position.x; + const dy = targetPos.y - this.position.y; + + if (Math.abs(dx) > Math.abs(dy)) { + // Move horizontally + if (dx > 0) { + this.move('east'); + } else { + this.move('west'); + } + } else { + // Move vertically + if (dy > 0) { + this.move('south'); + } else { + this.move('north'); + } + } + } + + // Add a new method for hunting other animals + private huntOthers(): void { + const nearbyAnimals = this.world.getAnimalsInRadius(this.position, 2); + + // Filter out self + const potentialTargets = nearbyAnimals.filter(animal => animal.getId() !== this.id); + + if (potentialTargets.length > 0) { + // Find the closest target + let closestTarget = null; + let closestDistance = Infinity; + + for (const animal of potentialTargets) { + const pos = animal.getPosition(); + const distance = Math.abs(pos.x - this.position.x) + Math.abs(pos.y - this.position.y); + + if (distance < closestDistance) { + closestTarget = animal; + closestDistance = distance; + } + } + + if (closestTarget) { + if (closestDistance <= 1) { + // Adjacent animal, attack it + this.attack(closestTarget); + } else { + // Move toward the target + const targetPos = closestTarget.getPosition(); + this.moveToward(targetPos); + } + return; + } + } + + // If no targets found, just wander + this.wander(); + } +} \ No newline at end of file diff --git a/src/animals/rabbit.ts b/src/animals/rabbit.ts new file mode 100644 index 0000000..fedf038 --- /dev/null +++ b/src/animals/rabbit.ts @@ -0,0 +1,260 @@ +import { Animal } from '../animal'; +import { DNA } from '../dna'; +import { Position, World } from '../world'; +import { Logger } from '../logger'; + +export class Rabbit extends Animal { + constructor(dna: DNA, position: Position, world: World, logger: Logger) { + super(dna, position, world, logger); + + // If no DNA is provided, initialize with standard genes + if (dna.getAllGenes().length === 0) { + // Create a new DNA with rabbit-specific tweaks to standard values + const rabbitDNA = DNA.createStandard({ + size: 0.3, // Rabbits are small + speed: 0.7, // Rabbits are fast + metabolism: 0.7, // Rabbits have high metabolism + vision: 0.6, // Rabbits have good vision + intelligence: 0.4, // Rabbits are moderately intelligent + aggression: 0.2, // Rabbits are not aggressive + socialBehavior: 0.6, // Rabbits are somewhat social + reproductiveUrge: 0.8 // Rabbits reproduce quickly + }); + + // Copy all genes to this animal's DNA + rabbitDNA.getAllGenes().forEach(gene => { + this.dna.addGene(gene); + }); + } + } + + protected act(): void { + // Get relevant genes that affect behavior + const intelligenceGene = this.dna.getGene('intelligence'); + const aggressionGene = this.dna.getGene('aggression'); + const socialGene = this.dna.getGene('socialBehavior'); + const reproductiveUrgeGene = this.dna.getGene('reproductiveUrge'); + + // Intelligence affects decision making + const intelligence = intelligenceGene ? intelligenceGene.value : 0.3; + + // Get population pressure + const populationPressure = this.world.getPopulationPressure(); + + // Make smarter decisions based on intelligence + const randomFactor = Math.random(); + + // Higher intelligence means more likely to make optimal decisions + if (randomFactor < intelligence) { + // Make the best decision based on current needs + if (this.stats.hunger > 70) { + // Very hungry, prioritize food + this.searchForFood(); + } + else if (this.stats.energy < 30) { + // Low energy, rest + this.rest(); + } + else if (this.stats.hunger > 40) { + // Somewhat hungry, look for food + this.searchForFood(); + } + else if (reproductiveUrgeGene && + reproductiveUrgeGene.value > 0.6 && + this.stats.energy > 70 && + populationPressure < 7) { // Only seek mates when population isn't too high + // High reproductive urge and enough energy, look for mates + this.searchForMates(); + } + else if (socialGene && socialGene.value > 0.7 && populationPressure < 8) { + // Highly social, seek other rabbits (unless very overcrowded) + this.seekCompany(); + } + else { + // Otherwise, explore + this.wander(); + } + } else { + // Less intelligent animals make more random choices + const choice = Math.random(); + + if (choice < 0.4) { + this.searchForFood(); + } else if (choice < 0.6) { + this.rest(); + } else { + this.wander(); + } + } + } + + private searchForFood(): void { + // Check current position for food + const currentTile = this.world.getTile(this.position); + if (currentTile && currentTile.foodValue > 0) { + const consumed = this.world.consumeFood(this.position, 5); + this.eat(consumed); + return; + } + + // Look in adjacent tiles for food + const directions: ('north' | 'east' | 'south' | 'west')[] = ['north', 'east', 'south', 'west']; + + // Shuffle directions for randomness + for (let i = directions.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [directions[i], directions[j]] = [directions[j], directions[i]]; + } + + // Check each direction + for (const direction of directions) { + const newPos = {...this.position}; + + switch (direction) { + case 'north': newPos.y = Math.max(0, newPos.y - 1); break; + case 'east': newPos.x = Math.min(this.world.getWidth() - 1, newPos.x + 1); break; + case 'south': newPos.y = Math.min(this.world.getHeight() - 1, newPos.y + 1); break; + case 'west': newPos.x = Math.max(0, newPos.x - 1); break; + } + + const tile = this.world.getTile(newPos); + if (tile && tile.foodValue > 0) { + this.move(direction); + return; + } + } + + // If no food found in adjacent tiles, just wander + this.wander(); + } + + private rest(): void { + // Resting recovers energy but increases hunger + this.stats.energy = Math.min(100, this.stats.energy + 5); + this.logger.log(`Rabbit ${this.id} is resting. Energy: ${this.stats.energy}`); + } + + private wander(): void { + // Move in a random direction + const directions: ('north' | 'east' | 'south' | 'west')[] = ['north', 'east', 'south', 'west']; + const randomDirection = directions[Math.floor(Math.random() * directions.length)]; + + this.move(randomDirection); + } + + protected createOffspring(dna: DNA, position: Position): Animal { + return new Rabbit(dna, position, this.world, this.logger); + } + + render(): string { + // Render the rabbit based on its fur color and other attributes + const furColorGene = this.dna.getGene('furColor'); + const sizeGene = this.dna.getGene('size'); + const energyLevel = this.stats.energy; + + // Different symbols based on size + let symbol = 'r'; + if (sizeGene) { + if (sizeGene.value > 0.7) symbol = 'R'; + else if (sizeGene.value < 0.3) symbol = '°'; + } + + // Color based on fur color gene + let color = 'white'; + if (furColorGene) { + if (furColorGene.value > 0.8) color = 'red'; + else if (furColorGene.value > 0.6) color = 'yellow'; + else if (furColorGene.value > 0.4) color = 'magenta'; + else if (furColorGene.value > 0.2) color = 'blue'; + else color = 'white'; + } + + // Add brightness based on energy level + let brightness = ''; + if (energyLevel < 30) brightness = '-fg'; + else brightness = '-fg'; + + return `{${color}${brightness}}${symbol}{/${color}${brightness}}`; + } + + // New method to seek company of other rabbits + private seekCompany(): void { + const nearbyAnimals = this.world.getAnimalsInRadius(this.position, 3); + + if (nearbyAnimals.length > 0) { + // Find the closest rabbit + let closestRabbit = null; + let closestDistance = Infinity; + + for (const animal of nearbyAnimals) { + if (animal.getId() !== this.id) { // Not self + const pos = animal.getPosition(); + const distance = Math.abs(pos.x - this.position.x) + Math.abs(pos.y - this.position.y); + + if (distance < closestDistance) { + closestRabbit = animal; + closestDistance = distance; + } + } + } + + if (closestRabbit) { + // Move toward the closest rabbit + const targetPos = closestRabbit.getPosition(); + this.moveToward(targetPos); + return; + } + } + + // If no rabbits found, just wander + this.wander(); + } + + // New method to search for potential mates + private searchForMates(): void { + const nearbyAnimals = this.world.getAnimalsInRadius(this.position, 2); + + for (const animal of nearbyAnimals) { + if (animal.getId() !== this.id) { // Not self + const pos = animal.getPosition(); + const distance = Math.abs(pos.x - this.position.x) + Math.abs(pos.y - this.position.y); + + if (distance <= 1) { + // Adjacent animal, try to reproduce + this.reproduce(animal); + return; + } else if (distance <= 2) { + // Nearby animal, move toward it + this.moveToward(pos); + return; + } + } + } + + // If no potential mates found, just wander + this.wander(); + } + + // Helper method to move toward a target position + private moveToward(targetPos: Position): void { + // Determine which direction gets us closer to the target + const dx = targetPos.x - this.position.x; + const dy = targetPos.y - this.position.y; + + if (Math.abs(dx) > Math.abs(dy)) { + // Move horizontally + if (dx > 0) { + this.move('east'); + } else { + this.move('west'); + } + } else { + // Move vertically + if (dy > 0) { + this.move('south'); + } else { + this.move('north'); + } + } + } +} \ No newline at end of file diff --git a/src/dna.ts b/src/dna.ts new file mode 100644 index 0000000..4f7e195 --- /dev/null +++ b/src/dna.ts @@ -0,0 +1,234 @@ +export interface Gene { + name: string; + value: number; + mutationRate: number; +} + +export interface GeneDefinition { + name: string; + defaultValue: number; + defaultMutationRate: number; + description: string; + minValue?: number; + maxValue?: number; +} + +export class DNA { + private genes: Map; + + constructor(initialGenes?: Gene[]) { + this.genes = new Map(); + + if (initialGenes) { + initialGenes.forEach(gene => { + this.genes.set(gene.name, gene); + }); + } + } + + addGene(gene: Gene): void { + this.genes.set(gene.name, gene); + } + + getGene(name: string): Gene | undefined { + return this.genes.get(name); + } + + getAllGenes(): Gene[] { + return Array.from(this.genes.values()); + } + + mutate(): DNA { + const newDNA = new DNA(); + + this.genes.forEach((gene, name) => { + const shouldMutate = Math.random() < gene.mutationRate; + + if (shouldMutate) { + // Create a mutation by adjusting the value slightly + const mutationAmount = (Math.random() * 2 - 1) * 0.1; // -10% to +10% + const newValue = Math.max(0, Math.min(1, gene.value + mutationAmount)); + + newDNA.addGene({ + name, + value: newValue, + mutationRate: gene.mutationRate + }); + } else { + // Copy the gene as is + newDNA.addGene({...gene}); + } + }); + + return newDNA; + } + + // For creating offspring DNA by combining two parent DNAs + static combine(dna1: DNA, dna2: DNA): DNA { + const combinedDNA = new DNA(); + + // Get all unique gene names from both parents + const allGeneNames = new Set([ + ...dna1.genes.keys(), + ...dna2.genes.keys() + ]); + + allGeneNames.forEach(name => { + const gene1 = dna1.getGene(name); + const gene2 = dna2.getGene(name); + + if (gene1 && gene2) { + // If both parents have the gene, randomly select one or average them + const inheritanceType = Math.random(); + + if (inheritanceType < 0.4) { + // 40% chance to inherit from first parent + combinedDNA.addGene({...gene1}); + } else if (inheritanceType < 0.8) { + // 40% chance to inherit from second parent + combinedDNA.addGene({...gene2}); + } else { + // 20% chance to blend the genes + combinedDNA.addGene({ + name, + value: (gene1.value + gene2.value) / 2, + mutationRate: (gene1.mutationRate + gene2.mutationRate) / 2 + }); + } + } else if (gene1) { + // Only first parent has the gene + combinedDNA.addGene({...gene1}); + } else if (gene2) { + // Only second parent has the gene + combinedDNA.addGene({...gene2}); + } + }); + + return combinedDNA; + } + + // Standard gene definitions for animals + static STANDARD_GENES: GeneDefinition[] = [ + { + name: 'size', + defaultValue: 0.5, + defaultMutationRate: 0.05, + description: 'Physical size of the animal', + minValue: 0.1, + maxValue: 1.0 + }, + { + name: 'speed', + defaultValue: 0.5, + defaultMutationRate: 0.1, + description: 'Movement speed and agility', + minValue: 0.1, + maxValue: 1.0 + }, + { + name: 'metabolism', + defaultValue: 0.5, + defaultMutationRate: 0.05, + description: 'Rate of energy consumption', + minValue: 0.1, + maxValue: 1.0 + }, + { + name: 'vision', + defaultValue: 0.5, + defaultMutationRate: 0.08, + description: 'Ability to detect food and threats at a distance', + minValue: 0.1, + maxValue: 1.0 + }, + { + name: 'intelligence', + defaultValue: 0.3, + defaultMutationRate: 0.03, + description: 'Problem-solving and decision-making ability', + minValue: 0.1, + maxValue: 1.0 + }, + { + name: 'aggression', + defaultValue: 0.3, + defaultMutationRate: 0.1, + description: 'Tendency to fight rather than flee', + minValue: 0, + maxValue: 1.0 + }, + { + name: 'socialBehavior', + defaultValue: 0.5, + defaultMutationRate: 0.07, + description: 'Tendency to group with others', + minValue: 0, + maxValue: 1.0 + }, + { + name: 'furColor', + defaultValue: 0.5, + defaultMutationRate: 0.15, + description: 'Color of fur/skin', + minValue: 0, + maxValue: 1.0 + }, + { + name: 'reproductiveUrge', + defaultValue: 0.5, + defaultMutationRate: 0.1, + description: 'Desire to reproduce when conditions are right', + minValue: 0.1, + maxValue: 1.0 + }, + { + name: 'lifespan', + defaultValue: 0.5, + defaultMutationRate: 0.05, + description: 'Natural maximum age', + minValue: 0.2, + maxValue: 1.0 + }, + { + name: 'maturityAge', + defaultValue: 0.5, + defaultMutationRate: 0.05, + description: 'Age at which the animal reaches maturity', + minValue: 0.2, + maxValue: 0.8 + }, + { + name: 'friendship', + defaultValue: 0.5, + defaultMutationRate: 0.1, + description: 'Tendency to be friendly rather than aggressive', + minValue: 0, + maxValue: 1.0 + } + ]; + + // Add a static method to create a DNA with standard genes + static createStandard(customValues?: Record): DNA { + const dna = new DNA(); + + DNA.STANDARD_GENES.forEach(geneDef => { + const value = customValues && customValues[geneDef.name] !== undefined + ? customValues[geneDef.name] + : geneDef.defaultValue; + + dna.addGene({ + name: geneDef.name, + value: value, + mutationRate: geneDef.defaultMutationRate + }); + }); + + return dna; + } + + // Add a method to get a gene's description + getGeneDescription(name: string): string | undefined { + const geneDef = DNA.STANDARD_GENES.find(g => g.name === name); + return geneDef?.description; + } +} \ No newline at end of file diff --git a/src/logger.ts b/src/logger.ts new file mode 100644 index 0000000..b412052 --- /dev/null +++ b/src/logger.ts @@ -0,0 +1,80 @@ +import * as fs from 'fs'; +import * as path from 'path'; + +export class Logger { + private logs: string[] = []; + private logCallback: ((message: string) => void) | null = null; + private consoleOutput: boolean; + private fileOutput: boolean; + private logFile: fs.WriteStream | null = null; + + constructor(consoleOutput: boolean = true, fileOutput: boolean = true) { + this.consoleOutput = consoleOutput; + this.fileOutput = fileOutput; + + if (fileOutput) { + this.setupLogFile(); + } + } + + private setupLogFile(): void { + // Create logs directory if it doesn't exist + const logsDir = path.join(process.cwd(), 'logs'); + if (!fs.existsSync(logsDir)) { + fs.mkdirSync(logsDir); + } + + // Create a timestamped log file + const timestamp = new Date().toISOString().replace(/:/g, '-').replace(/\..+/, ''); + const logFilePath = path.join(logsDir, `simulation-${timestamp}.log`); + + this.logFile = fs.createWriteStream(logFilePath, { flags: 'a' }); + this.log(`Log file created at ${logFilePath}`); + } + + log(message: string): void { + const timestamp = new Date().toISOString(); + const formattedMessage = `[${timestamp}] ${message}`; + + this.logs.push(formattedMessage); + + if (this.consoleOutput) { + console.log(formattedMessage); + } + + if (this.fileOutput && this.logFile) { + this.logFile.write(formattedMessage + '\n'); + } + + if (this.logCallback) { + this.logCallback(formattedMessage); + } + } + + setLogCallback(callback: (message: string) => void): void { + this.logCallback = callback; + + // Send the last few logs to the callback to populate the log view + const lastLogs = this.getLastLogs(10); + lastLogs.forEach(log => callback(log)); + } + + getLogs(): string[] { + return [...this.logs]; + } + + getLastLogs(count: number): string[] { + return this.logs.slice(-count); + } + + clearLogs(): void { + this.logs = []; + } + + close(): void { + if (this.logFile) { + this.logFile.end(); + this.logFile = null; + } + } +} \ No newline at end of file diff --git a/src/renderer.ts b/src/renderer.ts new file mode 100644 index 0000000..150c0f2 --- /dev/null +++ b/src/renderer.ts @@ -0,0 +1,424 @@ +import { World } from './world'; +import { Logger } from './logger'; +import * as blessed from 'blessed'; +import * as contrib from 'blessed-contrib'; +import { Animal } from './animal'; + +export class Renderer { + private screen: blessed.Widgets.Screen; + private worldBox: blessed.Widgets.BoxElement; + private worldInfoBox: blessed.Widgets.BoxElement; + private logBox: blessed.Widgets.Log; + private statsBox: blessed.Widgets.BoxElement; + private helpBox: blessed.Widgets.BoxElement; + private world: World; + private logger: Logger; + private selectedAnimalIndex: number = 0; + private showHelp: boolean = false; + private disasterInfo: { active: boolean, type: string, duration: number, intensity: number } = { + active: false, + type: '', + duration: 0, + intensity: 0 + }; + + constructor(world: World, logger: Logger) { + this.world = world; + this.logger = logger; + + // Initialize blessed screen + this.screen = blessed.screen({ + smartCSR: true, + title: 'Animal Simulator', + fullUnicode: true + }); + + // Create layout + this.worldBox = blessed.box({ + top: 0, + left: 0, + width: '70%', + height: '60%', // Reduced height to make room for world info + content: '', + tags: true, + label: ' World ', + border: { + type: 'line' + }, + style: { + fg: 'white', + border: { + fg: 'cyan' + } + } + }); + + // Add a separate world info box + this.worldInfoBox = blessed.box({ + top: '60%', + left: 0, + width: '70%', + height: '10%', // Small box for world info and legend + content: '', + tags: true, + label: ' World Info ', + border: { + type: 'line' + }, + style: { + fg: 'white', + border: { + fg: 'green' + } + } + }); + + // Use a Log widget instead of a Box for better log handling + this.logBox = blessed.log({ + top: '70%', + left: 0, + width: '100%', + height: '30%', + tags: true, + label: ' Animal Logs ', + border: { + type: 'line' + }, + style: { + fg: 'white', + border: { + fg: 'cyan' + } + }, + scrollable: true, + scrollbar: { + ch: '│', + track: { + bg: 'black' + }, + style: { + inverse: true + } + }, + mouse: true, + alwaysScroll: true + }); + + this.statsBox = blessed.box({ + top: 0, + left: '70%', + width: '30%', + height: '70%', + content: '', + tags: true, + label: ' Statistics ', + border: { + type: 'line' + }, + style: { + fg: 'white', + border: { + fg: 'cyan' + } + }, + scrollable: true, + scrollbar: { + ch: '│', + track: { + bg: 'black' + }, + style: { + inverse: true + } + }, + mouse: true, + alwaysScroll: true + }); + + // Create help box (initially hidden) + this.helpBox = blessed.box({ + top: 'center', + left: 'center', + width: '80%', + height: '80%', + content: this.getHelpContent(), + tags: true, + label: ' Help ', + border: { + type: 'line' + }, + style: { + fg: 'white', + border: { + fg: 'yellow' + } + }, + hidden: true, + scrollable: true, + scrollbar: { + ch: '│', + track: { + bg: 'black' + }, + style: { + inverse: true + } + }, + mouse: true, + alwaysScroll: true, + keys: true, + vi: true + }); + + // Add components to screen + this.screen.append(this.worldBox); + this.screen.append(this.worldInfoBox); // Add the new box + this.screen.append(this.logBox); + this.screen.append(this.statsBox); + this.screen.append(this.helpBox); + + // Set up key handlers + this.setupKeyHandlers(); + + // Register with logger to receive log events + logger.setLogCallback((message) => { + this.logBox.log(message); + this.screen.render(); + }); + + // Render the screen + this.screen.render(); + } + + private setupKeyHandlers(): void { + // Quit on Escape, q, or Control-C + this.screen.key(['escape', 'q', 'C-c'], () => process.exit(0)); + + // Toggle help screen with 'h' + this.screen.key('h', () => { + this.showHelp = !this.showHelp; + this.helpBox.hidden = !this.showHelp; + this.screen.render(); + }); + + // Animal selection with arrow keys + this.screen.key('right', () => { + const animals = this.world.getAllAnimals(); + if (animals.length > 0) { + this.selectedAnimalIndex = (this.selectedAnimalIndex + 1) % animals.length; + this.render(); + } + }); + + this.screen.key('left', () => { + const animals = this.world.getAllAnimals(); + if (animals.length > 0) { + this.selectedAnimalIndex = (this.selectedAnimalIndex - 1 + animals.length) % animals.length; + this.render(); + } + }); + + // Speed controls + this.screen.key('+', () => { + this.logger.log("Simulation speed increased"); + this.screen.emit('speed', 0.75); // Emit custom event for speed change + }); + + this.screen.key('-', () => { + this.logger.log("Simulation speed decreased"); + this.screen.emit('speed', 1.25); // Emit custom event for speed change + }); + + // Add pause/unpause key + this.screen.key('p', () => { + this.screen.emit('pause'); // Emit custom event for pause toggle + this.logger.log("Simulation pause toggled"); + }); + } + + onSpeedChange(callback: (factor: number) => void): void { + this.screen.on('speed', callback); + } + + onPauseToggle(callback: () => void): void { + this.screen.on('pause', callback); + } + + private getHelpContent(): string { + return `{bold}{green-fg}Animal Simulator Help{/green-fg}{/bold} + +{yellow-fg}Navigation Controls:{/yellow-fg} +• {bold}Left/Right Arrows{/bold}: Select previous/next animal +• {bold}H{/bold}: Toggle this help screen +• {bold}Q{/bold} or {bold}Esc{/bold}: Quit the simulation + +{yellow-fg}Simulation Controls:{/yellow-fg} +• {bold}P{/bold}: Pause/Unpause simulation +• {bold}+{/bold}: Increase simulation speed +• {bold}−{/bold}: Decrease simulation speed + +{yellow-fg}World Symbols:{/yellow-fg} +• {bold}F, f, °{/bold}: Adult Fluffles (different sizes) +• {bold}•{/bold}: Baby Fluffles (not mature yet) +• {bold}♥{/bold}: Fluffles that are mating +• {bold}♣, ♠, ·{/bold}: Grass (different densities) +• {bold}≈{/bold}: Water +• {bold}▲{/bold}: Rocks/Mountains + +{cyan-fg}Life Cycle:{/cyan-fg} +• Fluffles are born as babies (•) +• They mature around age 25 (varies by genetics) +• They can mate only when mature (takes 4 turns) +• Their lifespan is around 100 (varies by genetics) +• Population pressure affects reproduction success + +{cyan-fg}Tip:{/cyan-fg} Watch how animals with different DNA behave differently!`; + } + + updateDisasterInfo(active: boolean, type: string, duration: number, intensity: number): void { + this.disasterInfo.active = active; + this.disasterInfo.type = type; + this.disasterInfo.duration = duration; + this.disasterInfo.intensity = intensity; + } + + render(): void { + // Render world with improved visuals and highlight selected animal + const worldGrid = this.world.render(); + const animals = this.world.getAllAnimals(); + + // Make sure the selected animal index is valid + if (this.selectedAnimalIndex >= animals.length && animals.length > 0) { + this.selectedAnimalIndex = animals.length - 1; + } + + // Highlight the selected animal if any + if (animals.length > 0) { + const selectedAnimal = animals[this.selectedAnimalIndex]; + if (selectedAnimal) { // Make sure the animal exists + const pos = selectedAnimal.getPosition(); + + // Add a highlight marker around the selected animal + if (this.world.isPositionValid(pos)) { + worldGrid[pos.y][pos.x] = `{inverse}${worldGrid[pos.y][pos.x]}{/inverse}`; + } + } + } + + // Remove legend from world content and put it in the world info box + let worldContent = ''; + for (const row of worldGrid) { + worldContent += row.join('') + '\n'; + } + + this.worldBox.setContent(worldContent); + + // Create world info content with legend and terrain stats + let worldInfoContent = '{bold}Legend:{/bold} '; + worldInfoContent += '{green-fg}♣{/green-fg}=Grass '; + worldInfoContent += '{blue-fg}≈{/blue-fg}=Water '; + worldInfoContent += '{white-fg}▲{/white-fg}=Rock '; + worldInfoContent += '{magenta-fg}F{/magenta-fg}=Adult '; + worldInfoContent += '{cyan-fg}•{/cyan-fg}=Baby '; + worldInfoContent += '{red-fg}♥{/red-fg}=Mating '; + worldInfoContent += '{yellow-fg}⚔{/yellow-fg}=Fighting\n'; + + // Add terrain statistics + const terrainStats = this.world.getTerrainStats(); + worldInfoContent += `{bold}Terrain:{/bold} `; + worldInfoContent += `Grass: ${terrainStats.grass} tiles | `; + worldInfoContent += `Water: ${terrainStats.water} tiles | `; + worldInfoContent += `Rock: ${terrainStats.rock} tiles | `; + worldInfoContent += `Avg. Food: ${terrainStats.averageFood.toFixed(1)}`; + + // Add disaster info if active + if (this.disasterInfo.active) { + worldInfoContent += `\n{bold}{red-fg}DISASTER:{/red-fg}{/bold} ${this.disasterInfo.type} `; + worldInfoContent += `(${Math.round(this.disasterInfo.intensity * 100)}% intensity, `; + worldInfoContent += `${this.disasterInfo.duration} ticks remaining)`; + } + + this.worldInfoBox.setContent(worldInfoContent); + + // Render stats with improved formatting + let statsContent = '{bold}Animal Simulator Stats{/bold}\n\n'; + + const populationPressure = this.world.getPopulationPressure(); + const maxPopulation = this.world.getMaxPopulation(); + + // Show population status with color coding + let populationColor = 'green'; + if (populationPressure > 7) populationColor = 'red'; + else if (populationPressure > 5) populationColor = 'yellow'; + + statsContent += `{yellow-fg}Total Animals:{/yellow-fg} ${animals.length}/${maxPopulation}\n`; + statsContent += `{yellow-fg}Population Pressure:{/yellow-fg} ${this.createProgressBar(populationPressure, 10, true)}\n\n`; + + if (animals.length > 0 && this.selectedAnimalIndex < animals.length) { + const animal = animals[this.selectedAnimalIndex]; + const stats = animal.getStats(); + const pos = animal.getPosition(); + + statsContent += `{green-fg}Selected Animal:{/green-fg} ${animal.getId().substring(0, 8)}...\n`; + statsContent += `{green-fg}Animal ${this.selectedAnimalIndex + 1} of ${animals.length}{/green-fg}\n`; + statsContent += `{cyan-fg}Position:{/cyan-fg} (${pos.x}, ${pos.y})\n`; + statsContent += `{cyan-fg}Health:{/cyan-fg} ${this.createProgressBar(stats.health, 100)}\n`; + statsContent += `{cyan-fg}Energy:{/cyan-fg} ${this.createProgressBar(stats.energy, 100)}\n`; + statsContent += `{cyan-fg}Age:{/cyan-fg} ${stats.age}\n`; + statsContent += `{cyan-fg}Hunger:{/cyan-fg} ${this.createProgressBar(stats.hunger, 100, true)}\n\n`; + + statsContent += '{bold}DNA:{/bold}\n'; + const dna = animal.getDNA(); + dna.getAllGenes().forEach(gene => { + statsContent += `{magenta-fg}${gene.name}:{/magenta-fg} ${gene.value.toFixed(2)}\n`; + }); + + // Add controls reminder + statsContent += '\n{bold}Controls:{/bold} ← → to select animals | H for help'; + } else if (animals.length === 0) { + // No animals left + statsContent += '{red-fg}No animals alive in the simulation!{/red-fg}\n'; + statsContent += 'The population has gone extinct.\n\n'; + statsContent += 'Press Q to quit or restart the simulation.'; + } + + // Add disaster info to stats if active + if (this.disasterInfo.active) { + statsContent += '\n{bold}{red-fg}DISASTER ACTIVE{/red-fg}{/bold}\n'; + statsContent += `{red-fg}Type:{/red-fg} ${this.disasterInfo.type}\n`; + statsContent += `{red-fg}Intensity:{/red-fg} ${Math.round(this.disasterInfo.intensity * 100)}%\n`; + statsContent += `{red-fg}Remaining:{/red-fg} ${this.disasterInfo.duration} ticks\n`; + } + + // Add key commands reminder at the bottom of the stats box + statsContent += '\n\n{bold}Key Commands:{/bold}\n'; + statsContent += '← → : Select animal | H: Help | +/-: Speed | Q: Quit'; + + this.statsBox.setContent(statsContent); + + // Render the screen + this.screen.render(); + } + + private createProgressBar(value: number, max: number, inverse: boolean = false): string { + const width = 15; + const filledWidth = Math.round((value / max) * width); + const emptyWidth = width - filledWidth; + + let color = 'green'; + if (inverse) { + // For inverse bars like hunger, red means high (bad) + if (value > 70) color = 'red'; + else if (value > 40) color = 'yellow'; + } else { + // For normal bars like health, red means low (bad) + if (value < 30) color = 'red'; + else if (value < 60) color = 'yellow'; + } + + const filled = `{${color}-fg}${'█'.repeat(filledWidth)}{/${color}-fg}`; + const empty = '░'.repeat(emptyWidth); + + return `[${filled}${empty}] ${value}/${max}`; + } +} \ No newline at end of file diff --git a/src/simulation.ts b/src/simulation.ts new file mode 100644 index 0000000..23a9950 --- /dev/null +++ b/src/simulation.ts @@ -0,0 +1,356 @@ +import { World } from './world'; +import { Logger } from './logger'; +import { Renderer } from './renderer'; +import { DNA } from './dna'; +import { Fluffles } from './animals/fluffles'; + +export class Simulation { + private world: World; + private logger: Logger; + private renderer: Renderer; + private running: boolean = false; + private tickInterval: number = 800; // milliseconds + private timer: NodeJS.Timeout | null = null; + private disasterActive: boolean = false; + private disasterType: string = ''; + private disasterDuration: number = 0; + private disasterIntensity: number = 0; + private paused: boolean = false; + + constructor(worldWidth: number, worldHeight: number) { + // Create logger with console output disabled but file output enabled + this.logger = new Logger(false, true); + this.world = new World(worldWidth, worldHeight, this.logger); + this.renderer = new Renderer(this.world, this.logger); + + // Slower tick rate for better visualization + this.tickInterval = 800; // milliseconds + + // Connect speed controls + this.renderer.onSpeedChange((factor: number) => { + this.tickInterval = Math.max(100, Math.min(2000, this.tickInterval * factor)); + this.logger.log(`Simulation speed changed to ${Math.round(1000/this.tickInterval)} ticks/second`); + + // Restart the timer if running + if (this.running && this.timer) { + clearInterval(this.timer); + this.timer = setInterval(() => { + this.tick(); + }, this.tickInterval); + } + }); + + // Connect disaster info to renderer + this.renderer.updateDisasterInfo(this.disasterActive, this.disasterType, this.disasterDuration, this.disasterIntensity); + + // Connect pause toggle + this.renderer.onPauseToggle(() => { + this.paused = !this.paused; + this.logger.log(`Simulation ${this.paused ? 'paused' : 'resumed'}`); + }); + + this.logger.log('Simulation initialized'); + } + + initialize(numFluffles: number): void { + // Limit initial population to the world's capacity + const maxPopulation = this.world.getMaxPopulation(); + const actualFluffles = Math.min(numFluffles, maxPopulation); + + if (actualFluffles < numFluffles) { + this.logger.log(`Limiting initial population to ${actualFluffles} (world capacity)`); + } + + // Add initial animals + for (let i = 0; i < actualFluffles; i++) { + const dna = new DNA(); + const position = { + x: Math.floor(Math.random() * this.world.getWidth()), + y: Math.floor(Math.random() * this.world.getHeight()) + }; + + const fluffles = new Fluffles(dna, position, this.world, this.logger); + this.world.addAnimal(fluffles); + } + + this.logger.log(`Added ${actualFluffles} fluffles to the world`); + } + + start(): void { + if (this.running) return; + + this.running = true; + this.logger.log('Simulation started'); + + // Start the simulation loop + this.timer = setInterval(() => { + this.tick(); + }, this.tickInterval); + } + + stop(): void { + if (!this.running) return; + + this.running = false; + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + + this.logger.log('Simulation stopped'); + this.logger.close(); + } + + tick(): void { + // Skip updates if paused + if (this.paused) { + // Still render to show the paused state + this.renderer.render(); + return; + } + + // Update the world + this.world.update(); + + // Check for reproduction opportunities + this.checkReproduction(); + + // Handle ongoing disasters + if (this.disasterActive) { + this.updateDisaster(); + } else { + // Occasionally introduce environmental challenges or disasters + if (Math.random() < 0.005) { // 0.5% chance per tick + this.introduceChallenge(); + } + + // Even more rarely, trigger a major disaster + if (Math.random() < 0.001) { // 0.1% chance per tick + this.triggerDisaster(); + } + } + + // Render the current state + this.renderer.render(); + } + + private checkReproduction(): void { + // This is now handled by the Fluffles class's mating behavior + // We don't need to check for reproduction here anymore + } + + private introduceChallenge(): void { + const challengeType = Math.random(); + + if (challengeType < 0.3) { + // Food shortage + this.logger.log("Environmental event: Food shortage affecting the ecosystem"); + this.reduceWorldFood(0.3); // Reduce food by 30% + } else if (challengeType < 0.6) { + // Disease + this.logger.log("Environmental event: Disease spreading among animals"); + this.applyDisease(); + } else { + // Harsh weather + this.logger.log("Environmental event: Harsh weather conditions"); + this.applyHarshWeather(); + } + } + + private reduceWorldFood(percentage: number): void { + // Implementation to reduce food in the world + for (let y = 0; y < this.world.getHeight(); y++) { + for (let x = 0; x < this.world.getWidth(); x++) { + const position = { x, y }; + const tile = this.world.getTile(position); + if (tile && tile.type === 'grass') { + const reduction = tile.foodValue * percentage; + this.world.consumeFood(position, reduction); + } + } + } + } + + private applyDisease(): void { + // Implementation to apply disease effects to some animals + const animals = this.world.getAllAnimals(); + const affectedCount = Math.ceil(animals.length * 0.2); // Affect 20% of population + + for (let i = 0; i < affectedCount; i++) { + const randomIndex = Math.floor(Math.random() * animals.length); + const animal = animals[randomIndex]; + + // Apply health reduction + const stats = animal.getStats(); + stats.health -= 20; + + if (Math.random() < 0.3) { + this.logger.log(`Animal ${animal.getId().substring(0, 6)} affected by disease`); + } + } + } + + private applyHarshWeather(): void { + // Implementation to apply harsh weather effects + const animals = this.world.getAllAnimals(); + + animals.forEach(animal => { + // Harsh weather affects energy levels + const stats = animal.getStats(); + stats.energy = Math.max(10, stats.energy - 10); + }); + } + + setTickInterval(interval: number): void { + this.tickInterval = interval; + + // Restart the timer if running + if (this.running && this.timer) { + clearInterval(this.timer); + this.timer = setInterval(() => { + this.tick(); + }, this.tickInterval); + } + } + + // Add a method to limit log frequency + private shouldLog(animalId: string): boolean { + // Only log about 10% of animal actions to reduce log spam + return Math.random() < 0.1; + } + + // Add a method to trigger major disasters + private triggerDisaster(): void { + const disasterTypes = ['earthquake', 'drought', 'disease', 'coldSnap']; + const disasterType = disasterTypes[Math.floor(Math.random() * disasterTypes.length)]; + + // Set disaster parameters + this.disasterActive = true; + this.disasterType = disasterType; + this.disasterDuration = 20 + Math.floor(Math.random() * 30); // 20-50 ticks + this.disasterIntensity = 0.5 + (Math.random() * 0.5); // 0.5-1.0 intensity + + this.logger.log(`MAJOR DISASTER: A ${disasterType} has struck the world! (Intensity: ${Math.round(this.disasterIntensity * 100)}%)`); + + // Apply initial disaster effects + this.applyDisasterEffects(); + + // Update renderer with new disaster info + this.renderer.updateDisasterInfo(this.disasterActive, this.disasterType, this.disasterDuration, this.disasterIntensity); + } + + // Add a method to update ongoing disasters + private updateDisaster(): void { + // Continue applying effects + this.applyDisasterEffects(); + + // Decrease duration + this.disasterDuration--; + + // Check if disaster has ended + if (this.disasterDuration <= 0) { + this.logger.log(`The ${this.disasterType} has ended. The world begins to recover.`); + this.disasterActive = false; + this.disasterType = ''; + } + + // Update renderer with current disaster info + this.renderer.updateDisasterInfo(this.disasterActive, this.disasterType, this.disasterDuration, this.disasterIntensity); + } + + // Add a method to apply disaster effects + private applyDisasterEffects(): void { + const animals = this.world.getAllAnimals(); + + switch (this.disasterType) { + case 'earthquake': + // Earthquakes damage terrain and injure animals + if (Math.random() < 0.3) { + // Damage random terrain + for (let i = 0; i < 5; i++) { + const x = Math.floor(Math.random() * this.world.getWidth()); + const y = Math.floor(Math.random() * this.world.getHeight()); + const position = { x, y }; + + const tile = this.world.getTile(position); + if (tile && tile.type === 'grass') { + // Reduce food value + this.world.consumeFood(position, tile.foodValue * this.disasterIntensity); + } + } + } + + // Injure random animals + animals.forEach(animal => { + if (Math.random() < this.disasterIntensity * 0.2) { + const stats = animal.getStats(); + stats.health -= Math.floor(20 * this.disasterIntensity); + + if (Math.random() < 0.1) { + this.logger.log(`Animal ${animal.getId().substring(0, 6)} was injured by the earthquake`); + } + } + }); + break; + + case 'drought': + // Droughts reduce food everywhere + if (this.disasterDuration % 5 === 0) { + for (let y = 0; y < this.world.getHeight(); y++) { + for (let x = 0; x < this.world.getWidth(); x++) { + const position = { x, y }; + const tile = this.world.getTile(position); + if (tile && tile.type === 'grass') { + // Reduce food value + this.world.consumeFood(position, tile.foodValue * 0.1 * this.disasterIntensity); + } + } + } + + if (Math.random() < 0.3) { + this.logger.log("The drought continues to wither the vegetation..."); + } + } + + // Animals get thirstier + animals.forEach(animal => { + const stats = animal.getStats(); + stats.hunger += this.disasterIntensity * 2; + }); + break; + + case 'disease': + // Disease spreads among animals + animals.forEach(animal => { + if (Math.random() < this.disasterIntensity * 0.15) { + const stats = animal.getStats(); + stats.health -= Math.floor(10 * this.disasterIntensity); + + if (Math.random() < 0.05) { + this.logger.log(`Animal ${animal.getId().substring(0, 6)} is suffering from the disease`); + } + } + }); + break; + + case 'coldSnap': + // Cold snap reduces energy and can kill animals + animals.forEach(animal => { + const stats = animal.getStats(); + + // Energy drains faster in cold + stats.energy -= this.disasterIntensity * 2; + + // Very cold can cause health damage + if (Math.random() < this.disasterIntensity * 0.1) { + stats.health -= Math.floor(5 * this.disasterIntensity); + + if (Math.random() < 0.05) { + this.logger.log(`Animal ${animal.getId().substring(0, 6)} is suffering from the cold`); + } + } + }); + break; + } + } +} \ No newline at end of file diff --git a/src/world.ts b/src/world.ts new file mode 100644 index 0000000..98cf18a --- /dev/null +++ b/src/world.ts @@ -0,0 +1,251 @@ +import { Animal } from './animal'; +import { Logger } from './logger'; + +export interface Position { + x: number; + y: number; +} + +export interface Tile { + type: 'grass' | 'water' | 'rock'; + foodValue: number; +} + +export class World { + private width: number; + private height: number; + private grid: Tile[][]; + private animals: Map; + private logger: Logger; + private maxPopulation: number; + private populationPressure: number = 0; // Tracks environmental stress + + constructor(width: number, height: number, logger: Logger) { + this.width = width; + this.height = height; + this.animals = new Map(); + this.logger = logger; + + // Set maximum population based on world size + // A reasonable limit is about 30% of the total tiles + this.maxPopulation = Math.floor(width * height * 0.3); + + // Initialize the grid + this.grid = []; + for (let y = 0; y < height; y++) { + const row: Tile[] = []; + for (let x = 0; x < width; x++) { + // Random tile type + const rand = Math.random(); + let type: Tile['type'] = 'grass'; + let foodValue = 0; + + if (rand < 0.7) { + type = 'grass'; + foodValue = 10; + } else if (rand < 0.9) { + type = 'water'; + foodValue = 5; + } else { + type = 'rock'; + foodValue = 0; + } + + row.push({ type, foodValue }); + } + this.grid.push(row); + } + + this.logger.log(`Created world with dimensions ${width}x${height}`); + } + + getWidth(): number { + return this.width; + } + + getHeight(): number { + return this.height; + } + + getTile(position: Position): Tile | null { + if (this.isPositionValid(position)) { + return this.grid[position.y][position.x]; + } + return null; + } + + isPositionValid(position: Position): boolean { + return position.x >= 0 && position.x < this.width && + position.y >= 0 && position.y < this.height; + } + + addAnimal(animal: Animal): boolean { + // Check if we're at population capacity + if (this.animals.size >= this.maxPopulation) { + // Only allow adding if it's a special case (like initial setup) + if (this.populationPressure < 5) { + this.populationPressure++; + this.logger.log(`Warning: Population approaching maximum capacity (${this.animals.size}/${this.maxPopulation})`); + } + return false; + } + + this.animals.set(animal.getId(), animal); + + // Calculate population pressure (0-10 scale) + this.populationPressure = Math.min(10, Math.floor((this.animals.size / this.maxPopulation) * 10)); + + if (this.animals.size % 5 === 0) { + this.logger.log(`Population now at ${this.animals.size} animals (${Math.round((this.animals.size / this.maxPopulation) * 100)}% of capacity)`); + } + + return true; + } + + removeAnimal(id: string): void { + this.animals.delete(id); + this.logger.log(`Removed animal ${id} from the world`); + } + + getAnimal(id: string): Animal | undefined { + return this.animals.get(id); + } + + getAllAnimals(): Animal[] { + return Array.from(this.animals.values()); + } + + update(): void { + // Update all animals + this.animals.forEach(animal => { + // Apply population pressure effects + if (this.populationPressure > 5) { + // High population pressure makes food scarcer + animal.applyEnvironmentalStress(this.populationPressure); + } + + animal.update(); + }); + + // Regrow food in grass tiles, but slower when overpopulated + const regrowthRate = Math.max(0.01, 0.1 - (this.populationPressure * 0.01)); + + for (let y = 0; y < this.height; y++) { + for (let x = 0; x < this.width; x++) { + const tile = this.grid[y][x]; + if (tile.type === 'grass' && tile.foodValue < 10) { + tile.foodValue += regrowthRate; + } + } + } + } + + consumeFood(position: Position, amount: number): number { + const tile = this.getTile(position); + if (!tile) return 0; + + const consumed = Math.min(tile.foodValue, amount); + tile.foodValue -= consumed; + return consumed; + } + + // Get a string representation of the world for rendering + render(): string[][] { + const representation: string[][] = []; + + // Initialize with terrain + for (let y = 0; y < this.height; y++) { + const row: string[] = []; + for (let x = 0; x < this.width; x++) { + const tile = this.grid[y][x]; + switch (tile.type) { + case 'grass': + // Show different grass density based on food value with colors + if (tile.foodValue > 7) { + row.push('{green-fg}♣{/green-fg}'); // Lush grass + } else if (tile.foodValue > 3) { + row.push('{green-fg}♠{/green-fg}'); // Medium grass + } else { + row.push('{green-fg}·{/green-fg}'); // Sparse grass + } + break; + case 'water': + row.push('{blue-fg}≈{/blue-fg}'); // Water + break; + case 'rock': + row.push('{white-fg}▲{/white-fg}'); // Rock/mountain + break; + } + } + representation.push(row); + } + + // Add animals with colors based on their attributes + this.animals.forEach(animal => { + const pos = animal.getPosition(); + if (this.isPositionValid(pos)) { + representation[pos.y][pos.x] = animal.render(); + } + }); + + return representation; + } + + getAnimalsInRadius(center: Position, radius: number): Animal[] { + const result: Animal[] = []; + + this.animals.forEach(animal => { + const pos = animal.getPosition(); + const distance = Math.abs(pos.x - center.x) + Math.abs(pos.y - center.y); + + if (distance <= radius) { + result.push(animal); + } + }); + + return result; + } + + getPopulationPressure(): number { + return this.populationPressure; + } + + getMaxPopulation(): number { + return this.maxPopulation; + } + + getTerrainStats(): { grass: number, water: number, rock: number, averageFood: number } { + let grassCount = 0; + let waterCount = 0; + let rockCount = 0; + let totalFood = 0; + + for (let y = 0; y < this.height; y++) { + for (let x = 0; x < this.width; x++) { + const tile = this.grid[y][x]; + + switch (tile.type) { + case 'grass': + grassCount++; + totalFood += tile.foodValue; + break; + case 'water': + waterCount++; + break; + case 'rock': + rockCount++; + break; + } + } + } + + const averageFood = grassCount > 0 ? totalFood / grassCount : 0; + + return { + grass: grassCount, + water: waterCount, + rock: rockCount, + averageFood + }; + } +} \ No newline at end of file