Repository: ViciousSquid/Dosidicus Branch: 2.6.3.0_LatestVersion Commit: f0790c639bd1 Files: 190 Total size: 3.0 MB Directory structure: gitextract_di5h6smx/ ├── .dockerignore ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ └── submit_squid.yml │ └── workflows/ │ ├── build-nuitka.yml │ └── process_submission.yml ├── CONTRIBUTING.md ├── Dockerfile ├── Docs/ │ ├── Cognitive Sandbox Manifesto - Artificial Life and Transparent Neural Systems.md │ ├── README.md │ ├── brain-tool/ │ │ ├── Brain-Designer.md │ │ ├── Brain-Trainer-Headless.md │ │ ├── Decisions-Tab.md │ │ ├── Learning-Tab.md │ │ ├── Memory-Tab.md │ │ ├── Network-Tab.md │ │ ├── Neuron-Laboratory.md │ │ └── Personality-Tab.md │ ├── engine/ │ │ ├── Data-Flow-Summary.md │ │ ├── Decision-Engine.md │ │ ├── Engine-Overview.md │ │ ├── Multiplayer.md │ │ ├── Plugin-Hooks.md │ │ ├── Plugin-System.md │ │ ├── Save-File-Format.md │ │ └── config.ini.md │ ├── extras/ │ │ ├── Achievements.md │ │ ├── Decoration-Window.md │ │ ├── Easter-Eggs.md │ │ ├── SaveViewer.md │ │ └── UUID.md │ ├── getting-started/ │ │ ├── Care-Guide.md │ │ ├── Changelog.md │ │ ├── Example-Squids.md │ │ └── Home.md │ ├── neural-network/ │ │ ├── AI-Accelerator-Support.md │ │ ├── Experience-Buffer.md │ │ ├── Hebbian-Learning.md │ │ ├── Neurogenesis.md │ │ ├── Personality.md │ │ ├── STDP.md │ │ ├── Technical-Overview.md │ │ └── Vision-System.md │ └── source-reference/ │ ├── brain_neuron_hooks.py.md │ ├── brain_neuron_outputs.py.md │ ├── brain_render_worker.py.md │ ├── brain_tool.py.md │ ├── brain_widget.py.md │ ├── brain_worker.py.md │ ├── custom_brain_loader.py.md │ ├── designer_window.py.md │ ├── main.py.md │ ├── memory_manager.py.md │ ├── neurogenesis_show.py.md │ ├── squid.py.md │ ├── tamagotchi_logic.py.md │ └── vision_worker.py.md ├── LICENSE ├── README.md ├── config.ini ├── custom_brains/ │ ├── Bathtub.json │ ├── Change_colour_when_see_food.json │ ├── Dense_connections.json │ ├── Feed-Forward-Hidden-Layer.json │ ├── Feeling-Blue.json │ ├── Grasshopper.json │ ├── Healthy_Interests.json │ ├── L'insomniaque.json │ ├── Minimal.json │ ├── Plant-Seeker.json │ └── readme.md ├── docker-compose.yml ├── example_squids/ │ └── readme.md ├── extras/ │ ├── SaveViewer.html │ ├── SquidBreeder.html │ ├── StepTrainer.html │ └── brain_2_keras.py ├── headless/ │ ├── HeadlessLauncher.jsx │ ├── README.md │ ├── headless_launcher.html │ └── headless_trainer.py ├── images/ │ └── decoration/ │ └── DecorationsFolder ├── linux_setup.sh ├── main.py ├── plugins/ │ ├── achievements/ │ │ ├── __init__.py │ │ ├── achievements_data.py │ │ ├── display_scaling.py │ │ └── main.py │ ├── multiplayer/ │ │ ├── __init__.py │ │ ├── main.py │ │ ├── mp_constants.py │ │ ├── mp_network_node.py │ │ ├── mp_plugin_logic.py │ │ ├── multiplayer_config_dialog.py │ │ ├── multiplayer_events.py │ │ ├── multiplayer_status_widget.py │ │ ├── network_utilities.py │ │ ├── packet_validator.py │ │ ├── plugin.txt │ │ ├── remote_entity_manager.py │ │ ├── squid_multiplayer_autopilot.py │ │ └── status_bar_component.py │ ├── readme.md │ ├── stdp/ │ │ ├── __init__.py │ │ ├── main.py │ │ ├── stdp_control_panel.py │ │ └── stdp_core.py │ └── whitelist.txt ├── requirements.txt ├── src/ │ ├── __init__.py │ ├── animation_styles.py │ ├── brain_about_tab.py │ ├── brain_base_tab.py │ ├── brain_constants.py │ ├── brain_decisions_tab.py │ ├── brain_designer.py │ ├── brain_designer_launcher.py │ ├── brain_dialogs.py │ ├── brain_learning_tab.py │ ├── brain_memory_tab.py │ ├── brain_network_tab.py │ ├── brain_network_tab_banners.py │ ├── brain_neuron_hooks.py │ ├── brain_neuron_outputs.py │ ├── brain_personality_tab.py │ ├── brain_render_worker.py │ ├── brain_state_bridge.py │ ├── brain_statistics_tab.py │ ├── brain_tool.py │ ├── brain_tooltips.py │ ├── brain_ui_utils.py │ ├── brain_utils.py │ ├── brain_widget.py │ ├── brain_worker.py │ ├── certificate.py │ ├── compute_backend.py │ ├── config_manager.py │ ├── custom_brain_loader.py │ ├── decision_engine.py │ ├── decoration_stats.json │ ├── designer_canvas.py │ ├── designer_canvas_utils.py │ ├── designer_constants.py │ ├── designer_core.py │ ├── designer_dialogs.py │ ├── designer_logging.py │ ├── designer_network_generator.py │ ├── designer_outputs_panel.py │ ├── designer_panels.py │ ├── designer_sensor_discovery.py │ ├── designer_templates.py │ ├── designer_window.py │ ├── display_scaling.py │ ├── hidden_imports.txt │ ├── image_cache.py │ ├── interactions.py │ ├── interactions2.py │ ├── laboratory.py │ ├── learning.py │ ├── localisation.py │ ├── main.py │ ├── memory_manager.py │ ├── mental_states.py │ ├── network_adapter.py │ ├── network_protocol.py │ ├── neurogenesis.py │ ├── neurogenesis_show.py │ ├── personality.py │ ├── personality_traits.py │ ├── plugin_manager.py │ ├── plugin_manager_dialog.py │ ├── preferences.py │ ├── save_manager.py │ ├── splash_screen.py │ ├── squid.py │ ├── squid_facts.py │ ├── squid_statistics.py │ ├── statistics_window.py │ ├── tamagotchi_logic.py │ ├── task_manager.py │ ├── tutorial.py │ ├── ui.py │ ├── vision.py │ └── vision_worker.py ├── translations/ │ ├── de.py │ ├── en.py │ ├── es.py │ ├── fr.py │ ├── ja.py │ ├── ml.py │ └── zh.py └── version ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .git .github __pycache__/ *.pyc *.pyo *.pyd *.swp *.swo *.tmp logs/ saves/ .env .env.* *.zip *.tar *.tar.gz *.7z .vscode/ .idea/ .DS_Store Thumbs.db ================================================ FILE: .github/ISSUE_TEMPLATE/submit_squid.yml ================================================ name: 🦑 Submit a Squid Save description: Share your evolved squid! (Must be a .zip containing uuid.txt) labels: ["squid-submission"] body: - type: input id: name attributes: label: Squid Nickname placeholder: Squid validations: required: true - type: input id: save_url attributes: label: Save File (.zip) description: Drag and drop your ZIP here, then copy the link. validations: required: true ================================================ FILE: .github/workflows/build-nuitka.yml ================================================ name: Build (Nuitka) on: workflow_dispatch: jobs: build: strategy: matrix: os: [windows-latest, ubuntu-latest, macos-latest] runs-on: ${{ matrix.os }} defaults: run: shell: bash steps: - name: Checkout repo uses: actions/checkout@v4 - name: Setup Python uses: actions/setup-python@v5 with: python-version: "3.11" cache: pip - name: Install dependencies run: | python -m pip install --upgrade pip pip install nuitka pyqt5 numpy - name: Install macOS icon dependency if: runner.os == 'macOS' run: pip install imageio # Standalone mode produces a folder of files rather than a single # self-extracting binary. This avoids Defender false positives on Windows # which flag onefile/packed executables as suspicious by default. # Defender is also disabled during build to prevent it quarantining # Nuitka's intermediate files mid-compile. - name: Disable Windows Defender during build if: runner.os == 'Windows' shell: pwsh run: | Set-MpPreference -DisableRealtimeMonitoring $true Add-MpPreference -ExclusionPath "${{ github.workspace }}" - name: Build executable run: | if [[ "${{ runner.os }}" == "Windows" ]]; then ICON_FLAG="--windows-icon-from-ico=images/icons/squidblack.ico" elif [[ "${{ runner.os }}" == "Linux" ]]; then ICON_FLAG="--linux-icon=images/icons/squidblack.png" else ICON_FLAG="--macos-app-icon=images/icons/squidblack.png" fi python -m nuitka \ --mode=standalone \ --windows-console-mode=force \ --assume-yes-for-downloads \ --enable-plugin=pyqt5 \ --disable-plugin=options-nanny \ --output-dir=build \ --output-filename=Dosidicus \ --include-package=src \ --include-package=plugins \ --include-module=uuid \ --include-module=src.brain_designer \ --include-module=src.brain_designer_launcher \ --include-module=src.brain_state_bridge \ --include-module=src.designer_canvas \ --include-module=src.designer_canvas_utils \ --include-module=src.designer_constants \ --include-module=src.designer_core \ --include-module=src.designer_dialogs \ --include-module=src.designer_logging \ --include-module=src.designer_network_generator \ --include-module=src.designer_outputs_panel \ --include-module=src.designer_panels \ --include-module=src.designer_sensor_discovery \ --include-module=src.designer_templates \ --include-module=src.designer_window \ $ICON_FLAG \ main.py - name: Show build output run: find build -maxdepth 2 | sort - name: Create release directory run: mkdir release - name: Copy repository structure run: | for item in *; do case "$item" in release|dist|build|__pycache__|*.spec) ;; *) cp -r "$item" release/ 2>/dev/null || true ;; esac done - name: Remove python entrypoint run: rm -f release/main.py || true - name: Insert compiled executable run: | if [[ "${{ runner.os }}" == "macOS" ]]; then APP=$(find build -maxdepth 1 -name "*.app" | head -1) if [ -n "$APP" ]; then cp -r "$APP" release/Dosidicus.app else cp -r build/main.dist/. release/ fi else cp -r build/main.dist/. release/ fi # Ad-hoc signing suppresses some Gatekeeper warnings on macOS. # This is free but NOT a substitute for full notarization ($99/yr Apple # Developer account). Users on macOS will still need to right-click -> # Open -> Open Anyway the first time. Document this in your README. - name: Ad-hoc sign for macOS if: runner.os == 'macOS' run: | APP=$(find release -maxdepth 1 -name "*.app" | head -1) if [ -n "$APP" ]; then codesign --deep --force --sign - "$APP" else codesign --deep --force --sign - release/Dosidicus fi # ─── Smoke tests ──────────────────────────────────────────────────────────── # Launch the binary, capture stdout, and look for the startup print from # MainWindow.__init__: "📄 Applied language from config:" # This fires after Qt initialises and all imports resolve, confirming the # build is not silently broken. - name: Smoke test (Linux) if: runner.os == 'Linux' run: | QT_QPA_PLATFORM=offscreen ./release/Dosidicus > smoke.log 2>&1 & PID=$! echo "Started PID $PID" # Poll for the expected startup line, up to 30s for i in $(seq 1 30); do sleep 1 if grep -q "Applied language from config" smoke.log 2>/dev/null; then echo "✅ Startup confirmed at ${i}s" echo "--- stdout/stderr ---" cat smoke.log kill $PID 2>/dev/null || true exit 0 fi # Bail early if the process already died if ! kill -0 $PID 2>/dev/null; then echo "❌ Process exited before printing startup line" echo "--- stdout/stderr ---" cat smoke.log exit 1 fi done echo "❌ Startup line not seen after 30s" echo "--- stdout/stderr ---" cat smoke.log kill $PID 2>/dev/null || true exit 1 - name: Smoke test (macOS) if: runner.os == 'macOS' run: | APP=$(find release -maxdepth 1 -name "*.app" | head -1) if [ -n "$APP" ]; then BIN=$(find "$APP" -type f -perm +111 -name "Dosidicus" | head -1) else BIN=release/Dosidicus fi "$BIN" > smoke.log 2>&1 & PID=$! echo "Started PID $PID" for i in $(seq 1 30); do sleep 1 if grep -q "Applied language from config" smoke.log 2>/dev/null; then echo "✅ Startup confirmed at ${i}s" echo "--- stdout/stderr ---" cat smoke.log kill $PID 2>/dev/null || true exit 0 fi if ! kill -0 $PID 2>/dev/null; then echo "❌ Process exited before printing startup line" echo "--- stdout/stderr ---" cat smoke.log exit 1 fi done echo "❌ Startup line not seen after 30s" echo "--- stdout/stderr ---" cat smoke.log kill $PID 2>/dev/null || true exit 1 # ──────────────────────────────────────────────────────────────────────────── # Zip may strip execute permissions - restore them before archiving - name: Mark Linux binary executable if: runner.os == 'Linux' run: chmod +x release/Dosidicus - name: Create zip run: | OS_NAME=$(echo "${{ runner.os }}" | tr '[:upper:]' '[:lower:]') python -c "import shutil; shutil.make_archive('Dosidicus${{ github.ref_name }}-${OS_NAME}', 'zip', 'release')" - name: Upload artifact uses: actions/upload-artifact@v4 with: name: nuitka-${{ runner.os }} path: Dosidicus${{ github.ref_name }}-*.zip ================================================ FILE: .github/workflows/process_submission.yml ================================================ name: Squid Gallery Bot on: issues: types: [opened] jobs: add-to-gallery: if: contains(github.event.issue.labels.*.name, 'squid-submission') runs-on: ubuntu-latest permissions: contents: write issues: write steps: - name: Checkout Code uses: actions/checkout@v4 - name: Process Squid Save env: ISSUE_BODY: ${{ github.event.issue.body }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} ISSUE_NUMBER: ${{ github.event.issue.number }} run: | # 1. Extract URL and Nickname FILE_URL=$(echo "$ISSUE_BODY" | grep -o 'https://github.com/[^)]*' | head -n 1) NICKNAME=$(echo "$ISSUE_BODY" | grep -A 1 "Squid Nickname" | tail -n 1 | xargs | tr -d ' ') mkdir -p gallery curl -L $FILE_URL -o "temp_squid.zip" # 2. Verify ZIP and extract UUID if unzip -l temp_squid.zip | grep -q "uuid.txt"; then UUID=$(unzip -p temp_squid.zip uuid.txt | head -n 1 | xargs) FINAL_NAME="${NICKNAME}_${UUID}.zip" mv temp_squid.zip "gallery/${FINAL_NAME}" # 3. Commit to repo git config user.name "SquidBot" git config user.email "bot@github.com" git add gallery/ git commit -m "New Squid verified: ${NICKNAME} (${UUID})" git push gh issue comment $ISSUE_NUMBER --body "✅ **Squid Verified!** $NICKNAME has been added to the gallery. UUID: \`$UUID\`" gh issue close $ISSUE_NUMBER else gh issue comment $ISSUE_NUMBER --body "❌ **Validation Failed:** This ZIP does not contain a \`uuid.txt\`. Please check your save file and try again." exit 1 fi ================================================ FILE: CONTRIBUTING.md ================================================ ## Contributing to Dosidicus 🦑 Dosidicus isn't just a repository; it’s an open-ended experiment in transparent artificial life. We are building a world where cognition isn't a black box, but a visible, hackable structure. Whether you are a neuro-enthusiast, a Python wizard, or a digital explorer, there is a place for you in the sandbox. ### 🧠 1. Evolve the Core (src/) The STRINg engine is the beating heart of the project. We’ve avoided heavy frameworks like TensorFlow to keep things "bare metal" and interpretable. The Challenge: Optimize neural algorithms, refine Hebbian plasticity logic, or enhance the real-time UI. Goal: Make the simulation faster and the neural dynamics more expressive. ### 🧩 2. Architect New Realities (plugins/) Dosidicus is designed to be modular. Our plugin system allows you to inject new logic without touching the core engine. The Challenge: Create a new plugin folder with a plugin.txt descriptor. Ideas: Environmental stressors, advanced "Achievement" triggers, or even a Twitch-integrated "Chat-controlled Evolution." ### 🧬 3. Map the Connectome (custom_brains/) Help us build a library of diverse neural starting points. By contributing JSON templates to custom_brains/, you define the "DNA" that users experiment with. The Challenge: Design a unique neural layout—perhaps a highly recurrent "memory loop" or a sensory-heavy "predator" build. ### 📂 4. Share Your Specimens (example_squids/) In this sandbox, Structure + Experience = Behavior. No two squids are identical. The Challenge: Export your .json save files and share them. Did your squid develop a strange obsession with a specific corner of the map? Did it grow a massive neural cluster after a specific training session? Action: Submit your most interesting "trained" squids as examples for others to study. ### 🌍 5. Bridge the Language Gap (translations/) We want "visible minds" to be accessible to everyone. The Challenge: Add a new language file (e.g., fr.py, jp.py) to the translations/ directory. Help us ensure that concepts like Neurogenesis and Activation Patterns translate clearly across cultures. ### 🛠️ 6. Documentation & Educational Outreach If a newcomer can’t understand how to "read" a squid’s mind, the transparency is lost. The Challenge: Improve docstrings, write "Field Guides" for new users, or record a video of a specific behavioral emergence. Goal: Turn complex neuroscience concepts into playable tutorials. ### 🚀 How to Get Started Check the "Ahead" Forks: See what the community is already building in the Network Graph. Open an Issue: Have a wild idea for a new neuron type? Let's discuss it first! Submit a PR: We welcome all PRs, from single-typo fixes to massive engine overhauls. Every commit is a mutation. Every contribution helps the system grow. ================================================ FILE: Dockerfile ================================================ # syntax=docker/dockerfile:1 FROM python:3.11-slim AS base ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 WORKDIR /app # Install core dependencies needed by ALL targets RUN python -m pip install --no-cache-dir numpy>=1.21 # ------------------------------ # Headless trainer target # ------------------------------ FROM base AS headless # Only copy what is needed for headless training COPY headless/ ./headless/ COPY custom_brains/ ./custom_brains/ ENTRYPOINT ["python", "headless/headless_trainer.py"] CMD ["--ticks", "10000", "--output", "trained_brain.json"] # ------------------------------ # GUI target (requires X11) # ------------------------------ FROM base AS gui # Install system libraries for GUI support RUN apt-get update && apt-get install -y --no-install-recommends \ libgl1 \ libgl1-mesa-dri \ libglib2.0-0 \ libx11-6 \ libx11-xcb1 \ libxkbcommon-x11-0 \ libxkbcommon0 \ libxext6 \ libxrender1 \ libxrandr2 \ libxfixes3 \ libxdamage1 \ libxi6 \ libxcomposite1 \ libxcursor1 \ libxss1 \ libxtst6 \ libfontconfig1 \ libfreetype6 \ libnss3 \ libdbus-1-3 \ libxcb1 \ libxcb-icccm4 \ libxcb-image0 \ libxcb-keysyms1 \ libxcb-randr0 \ libxcb-render0 \ libxcb-render-util0 \ libxcb-shape0 \ libxcb-shm0 \ libxcb-sync1 \ libxcb-xfixes0 \ libxcb-xinerama0 \ libxcb-xkb1 \ libxcb-cursor0 \ libxshmfence1 \ libwayland-client0 \ libwayland-cursor0 \ libwayland-egl1 \ && rm -rf /var/lib/apt/lists/* # Install GUI-specific Python dependencies RUN python -m pip install --no-cache-dir PyQt5>=5.15 # Copy the entire project for the GUI COPY . . ENV QT_X11_NO_MITSHM=1 ENTRYPOINT ["python", "main.py"] ================================================ FILE: Docs/Cognitive Sandbox Manifesto - Artificial Life and Transparent Neural Systems.md ================================================ ## 1. Why This Exists Most artificial intelligence today is powerful, fast, and opaque: Large neural networks are trained on vast datasets producing remarkable outputs. However the learning process itself is hidden inside millions or billions of parameters. * You cannot watch a mind form - you can only inspect the result. Dosidicus was created from a different question: * What if cognition could be SEEN emerging? Not as a metaphor or as a visualisation layered on top, but at the level of individual neurons. ## 2. The Cognitive Sandbox Dosidicus is a cognitive sandbox. A sandbox is not a finished product. It is a contained environment where systems interact, evolve, and reveal behaviour. Each squid: * Is born with a randomly wired neural architecture * Starts with 8 neurons * Learns through Hebbian dynamics * Grows new structure through neurogenesis * Forms memories * Develops behavioural tendencies No two squids are identical. Every save file becomes a record of accumulated experience. The sandbox does not prescribe intelligence, it allows structure to form through interaction. ## 3. Transparency as a Design Principle Most AI systems are black boxes. Dosidicus rejects opacity as default. Every neuron is: * Visible * Inspectable * Directly stimulatable * Modifiable You can see activation values and observe connection strengths change. You can influence structural growth. This is not industrial-scale AI, it is intentionally small-scale and transparent. The goal is not performance but **visibility**. Transparency transforms AI from a service into an object of study. ## 4. Artificial Life Dosidicus is not an attempt at AGI. It is a constrained, evolving, micro-organism simulation which exists in a narrow world: * Hunger * Interaction * Stimulus * Memory * Simple drives Yet within these constraints, patterns emerge. Artificial life is not about scale. It is about process. A small system that adapts and accumulates experience over time can evoke something that feels alive — even when it is mechanistic. This boundary between mechanism and perceived life is intentional. ## 5. Attachment to Visible Minds Humans bond with simple systems. We bond with: * Tamagotchi * Virtual pets * Pixel creatures * Even malfunctioning robots Dosidicus introduces a twist: You can see the internal cause of behaviour. If your squid develops an aversion to something, you can trace the neural path that led there. This changes the psychology of attachment. Instead of caring for a scripted creature you are shaping a developing structure. * Responsibility becomes more concrete. * Every interaction leaves a trace. ## 6. Retro as Constraint / computational meta-art Dosidicus uses minimal animation. Two tentacle frames. Hand-drawn sprites. Deliberately simple presentation. It is a design constraint. Complex graphics distract from internal complexity. The squid is art. The brain is system. Together, they create **computational meta-art**: a drawn creature whose behaviour emerges from real learning dynamics. ## 7. STRINg: The Simulation Engine Under the hood runs a [custom engine](https://github.com/ViciousSquid/Dosidicus/wiki/Engine-overview): [STRINg](https://github.com/ViciousSquid/Dosidicus/wiki/Engine-overview) `S`imulated `T`amagotchi `R`eactions via `I`nferencing and `N`eurogenesis Built from scratch using NumPy. * No TensorFlow. * No PyTorch. #### Core properties: * Explicit neuron-level simulation * Hebbian plasticity * Structural growth (neurogenesis) * Dual memory system (short-term and long-term) * Headless training capability * Plugin extensibility STRINg is optimised for interpretability not scale. It treats neural networks not as static architectures, but as evolving structures. ## 8. Educational Intent Dosidicus functions as: * A learning tool for understanding neural dynamics * A demonstration of Hebbian learning * A sandbox for artificial life experimentation * A way to visualise structural plasticity * A gateway into neuroscience concepts through play Instead of teaching neural networks as equations alone, it allows learners to raise one. Understanding becomes experiential. ## 9. Individuality Through Random Birth Every squid begins differently. * Initial wiring is randomised. * Early experiences alter trajectory. * Small differences amplify over time. This models a core biological principle: _**Structure + experience = behaviour.**_ The system does not claim biological realism. It demonstrates structural sensitivity. No two digital lives are identical. ## 10. What This Is Not * Not Skynet. * Not a productivity AI. * Not a chatbot. * Not a pretrained monolith. * Not an optimised inference engine. It is a visible micro-mind. Contained. Hackable. Inspectable. Evolving. ================================================ FILE: Docs/README.md ================================================ _"What if a Tamagotchi had a neural network and could learn stuff?"_ — [Gigazine](https://gigazine.net/gsc_news/en/20250505-dosidicus-electronicae/)

AI GPL-2.0 Translations Buy Me A Coffee

# _Dosidicus electronicus_ 🦑 _A transparent neural sandbox disguised as a digital pet squid with a neural network you can **see thinking**_ Micro neural engine for small autonomous agents that learn via Hebbian dynamics and grow new structure - Part **educational neuro tool**, part **sim game**, part **fever dream** - [Build-your-own neural network ](https://github.com/ViciousSquid/Dosidicus/wiki/Brain-Designer) - learn neuroscience by raising a squid that **might develop irrational fears** - Custom [simulation engine](https://github.com/ViciousSquid/Dosidicus/wiki/Engine-overview) using Numpy - **No Tensorflow or PyTorch** - Most AI is a **black box**; Dosidicus is **transparent** - every neuron is visible, stimulatable, understandable. - Starts with 8 neurons — grows via **neurogenesis** and rewires using **Hebbian learning**. - Includes `achievements` with **50** to collect! - ----------------------------- --- ### Getting Started | Page | Description | |------|-------------| | [Home](getting-started/Home.md) | Project overview | | [Care Guide](getting-started/Care-Guide.md) | How to look after your squid | | [Example Squids](getting-started/Example-Squids.md) | Pre-made squid configurations | | [Changelog](getting-started/Changelog.md) | Version history | --- ### STRINg Simulation Engine | Page | Description | |------|-------------| | [Engine Overview](engine/Engine-Overview.md) | High-level architecture | | [Decision Engine](engine/Decision-Engine.md) | How the squid makes choices | | [Data Flow Summary](engine/Data-Flow-Summary.md) | How data moves through the system | | [Plugin System](engine/Plugin-System.md) | Extending Dosidicus with plugins | | [Plugin Hooks](engine/Plugin-Hooks.md) | Available hook points | | [Save File Format](engine/Save-File-Format.md) | Structure of `.squid` save files | | [config.ini](engine/config.ini.md) | Configuration reference | | [Multiplayer](engine/Multiplayer.md) | Multiplayer support | --- ### Neural Network | Page | Description | |------|-------------| | [Technical Overview](neural-network/Technical-Overview.md) | Neural network architecture | | [Hebbian Learning](neural-network/Hebbian-Learning.md) | Weight update algorithm | | [STDP](neural-network/STDP.md) | Spike-Timing-Dependent Plasticity | | [Neurogenesis](neural-network/Neurogenesis.md) | Creating new neurons at runtime | | [Experience Buffer](neural-network/Experience-Buffer.md) | Short/long-term memory experiences | | [Vision System](neural-network/Vision-System.md) | Food detection via vision cone | | [Personality](neural-network/Personality.md) | The 7 personality types | | [AI Accelerator Support](neural-network/AI-Accelerator-Support.md) | Hardware acceleration options | --- ### Brain Tool | Page | Description | |------|-------------| | [Brain Designer](brain-tool/Brain-Designer.md) | GUI for designing custom brains | | [Brain Trainer (Headless)](brain-tool/Brain-Trainer-Headless.md) | CLI training without a GUI | | [Network Tab](brain-tool/Network-Tab.md) | Visualising the neuron network | | [Learning Tab](brain-tool/Learning-Tab.md) | Monitoring learning in real time | | [Memory Tab](brain-tool/Memory-Tab.md) | Inspecting memory contents | | [Decisions Tab](brain-tool/Decisions-Tab.md) | Watching decision-making live | | [Personality Tab](brain-tool/Personality-Tab.md) | Adjusting personality traits | | [Neuron Laboratory](brain-tool/Neuron-Laboratory.md) | Experimenting with individual neurons | --- ### Source Reference Documentation for individual source files: | File | File | |------|------| | [main.py](source-reference/main.py.md) | [brain_tool.py](source-reference/brain_tool.py.md) | | [squid.py](source-reference/squid.py.md) | [brain_widget.py](source-reference/brain_widget.py.md) | | [tamagotchi_logic.py](source-reference/tamagotchi_logic.py.md) | [brain_worker.py](source-reference/brain_worker.py.md) | | [memory_manager.py](source-reference/memory_manager.py.md) | [brain_render_worker.py](source-reference/brain_render_worker.py.md) | | [vision_worker.py](source-reference/vision_worker.py.md) | [brain_neuron_hooks.py](source-reference/brain_neuron_hooks.py.md) | | [custom_brain_loader.py](source-reference/custom_brain_loader.py.md) | [brain_neuron_outputs.py](source-reference/brain_neuron_outputs.py.md) | | [designer_window.py](source-reference/designer_window.py.md) | [neurogenesis_show.py](source-reference/neurogenesis_show.py.md) | --- ### Extras | Page | Description | |------|-------------| | [Achievements](extras/Achievements.md) | Unlockable achievements | | [Easter Eggs](extras/Easter-Eggs.md) | Hidden features | | [Decoration Window](extras/Decoration-Window.md) | Customising the environment | | [SaveViewer](extras/SaveViewer.md) | Browser-based save file inspector | | [UUID](extras/UUID.md) | Squid identity system | ================================================ FILE: Docs/brain-tool/Brain-Designer.md ================================================ ### Design your own (GPL!) squid brain! Add new **neurons** / **layers** - see how they affect the squid's ability to process his environment Click the brain button in the bottom right of the [network tab](../brain-tool/Network-Tab.md) to swich into Designer mode FROM LEFT TO RIGHT: 1. -- Fast-Generate a random network with no prompt 2. -- Generate a random network with controls 3. -- Add a new Custom/Input Neuron 4. -- Export the brain to a .json file 5. -- Clear all connections 6. -- Quick-generate a network from template -------------------------------------- If designer mode is launched from within the simulation (default behaviour): * The running brain will be automatically imported into the designer. * The purple '**Switch to Game**' button passes the brain from the designer to the running brain tool ``` Some buttons will not be available if the designer is launched standalone (main.py -designer) ``` --------------------------------- #### CONTROLS: * to add a neuron * Mouse wheel on the canvas to zoom in/out, hold right mouse button to drag * Click on a neuron and hold to drag a connection line - release on another neuron * Select a link and use the mouse wheel to increase/decrease the connection weight (range: -1.00 to +1.00) * negative weights are inhibitory (red), positive weights are excitory (green). * Press **DEL** to delete a connection link * **SPACE** to change the direction of a connecting line (indicated with arrows) * Stronger weights are indicated with thicker lines. * **Ctrl-click-and drag** to reposition any neuron. * Use the Layers tab to create more complicated brains with additional layers * The Sensors tab shows available INPUT neurons * The Output tab lists Output bindings for bridging neuron activations to game behaviours: Choose a **source neuron**, a **trigger**, and **behaviour** for that trigger EXAMPLES: ``` IF .. can_see_food .. THEN .. change colour IF .. happiness = <30 .. THEN .. pick up a rock IF .. anxiety = >60 .. THEN .. seek plant ``` These can be combined to create reactive behaviours (simulated _biological drives/**urges**_) ------------------------ ### `Generate sparse network` The 'generate sparse network' button creates biologically-inspired neural networks using the core 8 neurons. Each generated brain is unique and randomised. ================================================ FILE: Docs/brain-tool/Brain-Trainer-Headless.md ================================================ A headless training tool is included in the `headless` folder. This can be used to train brains in a time-accelerated environment * **Headless Operation**: No GUI required, runs purely on CPU * **Accelerated Time**: Runs 25,000-35,000+ ticks per second (vs ~1 tick/second in real-time) * **Neurogenesis**: Automatic creation of new neurons based on stress/novelty/reward triggers * **Hebbian Learning**: Continuous weight updates based on co-activation * **Training Scenarios**: Predefined scenarios for different training goals * **Export Trained Brains**: Save trained brains back to JSON for use in the main game Documentation for this tool can be found here: https://github.com/ViciousSquid/Dosidicus/blob/v2.6.1.0__b1218_LatestVersion/headless/README_headless_trainer.md #### WORK IN PROGRESS, BUGGY, WORK IN PROGRESS, BUGGY, WORK IN PROGRESS, BUGGY #### `headless_launcher.html` is a user-friendly launcher for this tool: drag and drop or Browse for a brain and then select how to train it and for how long ================================================ FILE: Docs/brain-tool/Decisions-Tab.md ================================================ ![image](https://github.com/user-attachments/assets/a6b74e77-98db-4e97-b03e-a067b0814c77)

The Decisions Tab provides a fascinating, step-by-step visualization of your squid's thought process. It breaks down how the squid goes from assessing his environment and internal needs to making a final, actionable decision. This view is updated every time the squid makes a new choice.

Decisions reflected here are made by the [Decision Engine](../engine/Decision-Engine.md) which is driven by the [Neural Network](../neural-network/Technical-Overview.md)

Interface Elements


================================================ FILE: Docs/brain-tool/Learning-Tab.md ================================================ #### `Hebbian learning` is a principle grounded in the functioning of biological neural networks

The Learning Tab is your window into understanding how your squid learns. It focuses on the Hebbian learning principle: "neurons that fire together, wire together." This tab visualizes **neuron pairs** that were selected for learning (happens every 30 seconds by default) The connection strength between them (weight) was either **strengthened** or **weakened**, meaning they became more or less associated with each other. For example: if Hunger and Satisfaction both happened to be firing at the same time (perhaps the squid just had a positive eating experience) these two neurons would have their connection strengthened when the counter reaches zero Over time the same useful connections will keep strengthening and strong pathways will develop. This is how the squid 'learns' favourable behaviours ---------------------------- #### Further Reading: External links * https://medium.com/@reutdayan1/hebbian-learning-biologically-plausible-alternative-to-backpropagation-6ee0a24deb00 * https://informatics.ed.ac.uk/sites/default/files/2024-03/Qiuye%20Zhang%20Lovelace%20Colloquium%20Poster.pdf * https://en.wikipedia.org/wiki/Hebbian_theory * https://www.youtube.com/watch?v=TvTQQO5yTa4 ================================================ FILE: Docs/brain-tool/Memory-Tab.md ================================================

The Memory Tab allows you to explore the memories your squid has formed. It separates memories into short-term (recent events) and long-term (consolidated, important events), giving you insight into what has recently affected your squid and what it has learned to remember.

Interface Elements


================================================ FILE: Docs/brain-tool/Network-Tab.md ================================================ The Network Tab provides a live, visual representation of the overall structure and health of the neural network in real-time. - Each neural network is unique and randomly generated (with rules) when the squid is hatched - Red lines represent Excitory (positive) connections between neurons - Green lines represent Inhibitory (negative) connections - The thicker the line, the stronger the connection

Interface Elements

The big button with the brain on it opens the [Brain Designer](../brain-tool/Brain-Designer.md) which allows you to build and edit custom brains and behaviours ================================================ FILE: Docs/brain-tool/Neuron-Laboratory.md ================================================ ### **Double click on any neuron** to view the Neuron Laboratory It can be used to inspect individual neurons. The 'Edit Sandbox' tab allows direct adjustment of the live brain state. This window also displays the Neurogenesis Experience buffer and neural patterns (last tab) that determine the squid's learned responses. ----------------------------- ### Unlocking the 'sandbox' tab allows for full manual control of neuron values: ================================================ FILE: Docs/brain-tool/Personality-Tab.md ================================================ ![image](https://github.com/user-attachments/assets/fb73ae47-efae-4127-85c7-b2da6236e9c7)

The Personality Tab details the innate character of your squid, which is randomly assigned at the beginning of a new game. -------------- This [personality](../neural-network/Personality.md) has a significant and constant influence on its behaviour, decision-making, and needs.

Interface Elements


================================================ FILE: Docs/engine/Data-Flow-Summary.md ================================================ ## Data Flow Summary ### [Main Loop](../source-reference/main.py.md) **Game Loop** → [`TamagotchiLogic`](../source-reference/tamagotchi_logic.py.md) feeds stats → `BrainWidget.update_brain_state()` --- ### Central Hub: [BrainWidget](../source-reference/brain_widget.py.md) | Component | Description | |-----------|-------------| | `state` dict | Neuron activations | | `weights` dict | Connection strengths | | Coordinates | All subsystems | --- ### Worker Threads | Worker | Responsibility | Output | |--------|----------------|--------| | [**BrainWorker**](../source-reference/brain_worker.py.md) | Hebbian learning, Neurogenesis | Signals → BrainWidget | | [**BrainRenderWorker**](../source-reference/brain_render_worker.py.md) | Offscreen painting | QImage → paintEvent | | [**NeuronOutputMonitor**](../source-reference/brain_neuron_outputs.py.md) | Threshold checks | Hooks → Squid behaviors | --- ### Signal Flow ``` BrainWorker ──────────┐ │ ▼ BrainWidget ──────▶ Squid ▲ │ BrainRenderWorker ────┘ ``` --- ### Complete Pipeline 1. **Input Stage** - [`BrainNeuronHooks`](../source-reference/brain_neuron_hooks.py.md) converts game events → neuron activations - Sensors: `can_see_food`, `plant_proximity`, `is_fleeing`, etc. 2. **Processing Stage** - [`BrainWidget`](../source-reference/brain_widget.py.md) updates state dictionary - [`BrainWorker`](../source-reference/brain_worker.py.md) performs Hebbian learning (weight updates) - [`BrainWorker`](../source-reference/brain_worker.py.md) checks [neurogenesis](../neural-network/Neurogenesis.md) triggers 3. **Output Stage** - [`NeuronOutputMonitor`](../source-reference/brain_neuron_outputs.py.md) checks activation thresholds - Fires output hooks → game behaviours - Actions: `flee`, `seek_food`, `sleep`, `change_colour`, etc. 4. **Rendering Stage** - [`BrainRenderWorker`](../source-reference/brain_render_worker.py.md) receives state snapshot - Renders to offscreen QImage - Main thread blits cached image ================================================ FILE: Docs/engine/Decision-Engine.md ================================================ #### view source: _[decision_engine.py](https://github.com/ViciousSquid/Dosidicus/blob/2.6.1.2_LatestVersion/src/decision_engine.py)_ _version 2.6.1.2_ ## Overview ``` exploration of emergent behavioural complexity via dynamic, biologically-inspired neural architecture rather than a static state machine. ``` The **Decision Engine** is the core action-selection system for Dosidicus. It is responsible for selecting and executing behaviour based on the squid’s *current neural state*, *physiological drives*, *memory influences*, and *personality modifiers*. Unlike traditional game AI systems (finite-state machines, behaviour trees, or rule stacks), the Decision Engine is **neural-first**: it does not directly reason about the world. Instead, *all perception and context must flow through the brain*. In practical terms, this means behaviour is not scripted. It **emerges** from continuous internal signals competing for expression. The engine is designed to be: * Explainable (full decision traces are recorded) * Extensible (new drives, memories, or actions integrate naturally) * Compatible with future learning systems (dopamine, reinforcement, plasticity) --- ## Design Philosophy ### Neural-First Authority The Decision Engine treats the brain as the **single source of truth**. It does not perform manual world queries (e.g. checking for food, scanning objects) to *decide* what to do. Instead, it consumes: * Perceptual neuron outputs (via [`BrainNeuronHooks`](../source-reference/brain_neuron_hooks.py.md)) * Internal state neurons (hunger, anxiety, curiosity, etc.) * Learned and persistent neural values Direct world interaction is limited to *execution*, not *decision-making*. ### Continuous Competition Actions are not triggered by rules. Instead, all candidate actions receive **weights** derived from internal signals. These weights compete, and the strongest wins. Small differences matter, enabling hesitation, oscillation, and personality-driven variance. ### Modulation, Not Commands Memory and personality do not issue instructions. They *bias* behaviour by scaling weights. This ensures: * Memories influence but do not dominate * Personalities remain relevant in all contexts * New behaviours automatically inherit modulation --- ## Decision Pipeline The decision process is executed in six structured stages. --- ### 1. Perceptual & Brain State Construction All perceptual input is retrieved via [`BrainNeuronHooks`](../source-reference/brain_neuron_hooks.py.md): * Temporal sensors are decayed each tick * No manual perception checks are allowed The full brain state is constructed from: * Core neurons * Learned neurons * Perceptual inputs (merged defensively) This combined state represents the squid’s *entire subjective reality* at the moment of decision. --- ### 2. Memory Influence Active memories are retrieved from the memory manager and converted into **multiplicative biases** on specific actions. Examples: * Positive food memories bias eating * Object interaction memories bias play and throwing * Startle memories suppress exploration and increase comfort-seeking Memory effects are: * Directional (positive or negative bias) * Non-deterministic * Stackable This models habits, preferences, and learned aversions rather than explicit recall. --- ### 3. Physiological Urgency (Nonlinear Drives) Physiological needs generate **exponential urgency curves**: * Hunger amplifies eating * Sleepiness amplifies sleeping Nonlinear scaling ensures that high-need states *crowd out* other motivations rather than simply increasing priority linearly. #### Reflex Overrides Certain extreme states bypass competition entirely: * Exhaustion → forced sleep * Active sleep → no decision * Extreme external stimulus → startle response These represent **reflex arcs**, not cognitive decisions. --- ### 4. Base Action Weight Construction Each candidate action receives a base weight derived from the brain state. Actions include: * Exploring * Eating * Approaching plants (comfort-seeking) * Playing * Throwing objects * Sleeping * Fleeing Weights are influenced by: * Drives (hunger, curiosity, satisfaction) * Threat and anxiety * Perceptual confidence (e.g. food visibility) * Contextual suppressors (illness, external stimuli) This stage defines *what the squid wants* before learning, memory, or personality intervene. --- ### 5. Memory & Personality Modulation #### Memory Modifiers Memory multipliers are applied to relevant actions, biasing selection without enforcing outcomes. #### Personality Modifiers [Personalities](../neural-network/Personality.md) act as **gain controls**: * **Adventurous**: boosts exploration and play * **Timid**: suppresses exploration, amplifies comfort-seeking * **Greedy**: amplifies eating * **Lazy**: suppresses energetic actions * **Energetic**: boosts play and exploration Personality does not define behaviour — it shapes *how strongly* drives express themselves. #### Anxiety Coupling High anxiety further amplifies comfort-seeking behaviour, creating feedback between affect and action selection. --- ### 6. Stochastic Selection & Confidence After all modifiers: * Small stochastic noise is applied to prevent determinism * The highest-weighted action is selected #### Confidence Metric Decision confidence is computed as the relative margin between the top two competing actions. This signal can be used for: * Animation blending * UI visualization * Learning-rate modulation * Behavioural hesitation --- ## Execution Phase Once an action is selected, it is executed via `_execute_neural_decision`. Key principles: * Execution respects neural intent * World scanning is minimized * Fallback behaviours preserve personality flavour Execution returns a *descriptive outcome string*, not just an action label, enabling rich UI feedback. --- ## Decision Tracing & Visualization Each decision produces a full trace containing: * Raw perceptual inputs * Brain state snapshot * Base action weights * Memory influences * Urgency multipliers * Personality modifiers * Final adjusted weights * Selected action * Confidence score This trace is exposed to the Brain Tool UI for inspection and debugging. --- ## What the Decision Engine Is (and Is Not) ### It Is: * A neural-modulated action selection system * Continuous and explainable * Designed for emergent behaviour * Compatible with learning extensions ### It Is Not: * A finite-state machine * A behaviour tree * A planner or lookahead system * A reinforcement learner (yet) --- ## Future Extensions The Decision Engine is intentionally structured to support: * Dopaminergic reinforcement signals * Action-value learning * Noise modulation by arousal or confidence * Fully neural affordance perception The hardest architectural work — unified perception, continuous competition, and traceability — is already in place. --- ## Summary The Decision Engine forms the behavioural core of Dosidicus. By enforcing neural authority, continuous competition, and modulation-based influence, it produces behaviour that is adaptive, interpretable, and personality-consistent — without relying on brittle scripts or hard-coded modes. It is not merely a controller, but a foundation for a growing cognitive system. ================================================ FILE: Docs/engine/Engine-Overview.md ================================================ ## `S`imulated `T`amagotchi `R`eactions via `I`nferencing and `N`eurogenesis `(STRINg)` ### simulation engine overview: The architecture of Dosidicus is a "Bottom-Up" sensory system where raw environmental data is distilled into neural inputs, which are then filtered through the squid's [personality](https://github.com/ViciousSquid/Dosidicus/wiki/Personality) to produce behaviour. Built from scratch using NumPy. - No TensorFlow. - No PyTorch. ### Core properties: * Explicit neuron-level simulation * Hebbian plasticity * Structural growth (neurogenesis) * Dual memory system (short-term and long-term) * Headless training capability * Plugin extensibility * STRINg is optimised for interpretability not scale. It treats neural networks not as static architectures, but as evolving structures. --------------------- - Network grows via **[neurogenesis](https://github.com/ViciousSquid/Dosidicus/wiki/Neurogenesis)** and self-trains via **[Hebbian learning](https://github.com/ViciousSquid/Dosidicus/wiki/Hebbian-learning)** - Automatic **pruning** of redundant neurons and weights (can be turned off) - [Experience buffer](https://github.com/ViciousSquid/Dosidicus/wiki/Experience-Buffer) records and encodes learned experiences - [decision_engine](https://github.com/ViciousSquid/Dosidicus/wiki/Decision-Engine) uses neural data to make decisions ------------------ * Beta (and optional) support for [AI accelerators via ONNX Runtime](https://github.com/ViciousSquid/Dosidicus/wiki/AI-accelerator-support) * _Experimental and a work in progress_ * _Probably not the best way to do this!_ 😃 --------------------------------- ## Read Next: [Data flow Summary](https://github.com/ViciousSquid/Dosidicus/wiki/Data-Flow-Summary) overview #### Further engine studies: - [Decision Engine](https://github.com/ViciousSquid/Dosidicus/wiki/Decision-Engine) - [Brain Widget](https://github.com/ViciousSquid/Dosidicus/wiki/brain_widget.py) External links * https://medium.com/@reutdayan1/hebbian-learning-biologically-plausible-alternative-to-backpropagation-6ee0a24deb00 * https://informatics.ed.ac.uk/sites/default/files/2024-03/Qiuye%20Zhang%20Lovelace%20Colloquium%20Poster.pdf * https://en.wikipedia.org/wiki/Hebbian_theory * https://www.youtube.com/watch?v=TvTQQO5yTa4 ================================================ FILE: Docs/engine/Multiplayer.md ================================================ **Basic Multiplayer functionality** has been implemented as a plugin (`plugins/multiplayer`) enabled via the Plugin manager _source code: https://github.com/ViciousSquid/Dosidicus/tree/2.5.0.0_latest_release/plugins/multiplayer_ When Multiplayer is enabled: * the client will search for and automatically connect to other running networked clients on LAN * Squids can **leave their tanks** via an edge and appear in **other tanks** where they may attempt to steal rocks/decorations and bring them back home as trophies * Squids will automatically return home after a random duration (between 2 and 5 minutes) * Supports UDP (default) or TCP/IP (experimental, untested) via included control panel (Plugins>Multiplayer menu) * 'Dashboard' is included to see connected clients * Experimental work in progress, please report issues ================================================ FILE: Docs/engine/Plugin-Hooks.md ================================================ The following hooks are available for [plugins](../engine/Plugin-System.md) to subscribe to: ### Lifecycle hooks `on_startup` -called when the application launches `on_shutdown` -called when the application shuts down `on_new_game` -called when a new game is started `on_save_game` -called when a game is saved `on_load_game` -called when a game is loaded ### Simulation hooks `pre_update` -called immediately BEFORE the update of a frame (once per 1000 milliseconds) `post_update` -called immediately AFTER the update of a frame `on_speed_change` -called when the game speed is changed ### Squid state hooks `on_squid_state_change` -called when any of the squid's states change `on_hunger_change` -called every time the value of HUNGER changes `on_happiness_change` -called every time the value of HAPPINESS changes `on_cleanliness_change` -called every time the value of CLEANLINESS changes `on_sleepiness_change` -called every time the value of SLEEPINESS changes `on_satisfaction_change` -called every time the value of SATISFACTION changes `on_anxiety_change` -called every time the value of ANXIETY changes `on_curiosity_change` -called every time the value of ANXIETY changes ### Action hooks `on_feed` -called when the squid is fed via Actions>Feed `on_clean` -called when the tank is cleaned via Actions>Clean `on_medicine` -called when squid is administered medicine via Actions>Medicine `on_sleep` -squid calls this when going to sleep `on_wake` -squid calls this when waking up `on_startle` -squid calls this upon being startled ### Interaction hooks `on_rock_pickup` -called when squid picks up a rock `on_rock_throw` -called when squid throws a rock `on_decoration_interaction` -called when squid interacts with a decoration item such as rock or plant `on_ink_cloud` -squid calls this if he is sufficiently startled to produce an ink cloud (rare) ### Neural/memory hooks `on_neurogenesis` -called when a new neuron is created via neurogenesis `on_memory_created` -called when a new memory is created `on_memory_to_long_term` -called when a short term memory is transferred to long term memory ### UI hooks `on_menu_creation` -custom mod submenu (used with `register_menu_actions` [see below]) `on_message_display` -Displays a UI message ### Custom menu action hooks `register_menu_actions` -Creates a submenu underneath the 'Plugins' menu ### Designer Hooks register_neuron_handler(name, handler, plugin_name, metadata) - plugins call this to register custom neurons ================================================ FILE: Docs/engine/Plugin-System.md ================================================ ### Hooks The application can be extended via **HOOKS** which are made available by the simulation engine when certain events occur * Available hooks are documented here: ../engine/Plugin-Hooks.md Plugins can subscribe to these hooks by calling the following methods in `src/plugin_manager.py` : ### `register_hook`, `subscribe_to_hook`, `trigger_hook`
Show full methods ```python def register_hook(self, hook_name: str) -> None: """ Register a new hook that plugins can subscribe to. """ if hook_name not in self.hooks: self.hooks[hook_name] = [] self.logger.debug(f"Registered hook: {hook_name}") ``` ----------------- ```python def subscribe_to_hook(self, hook_name: str, plugin_name: str, callback: Callable) -> bool: """ Subscribe a plugin's callback to a specific hook. """ if hook_name not in self.hooks: self.logger.warning(f"Plugin {plugin_name} tried to subscribe to non-existent hook: {hook_name}") return False self.hooks[hook_name].append({ "plugin": plugin_name, "callback": callback }) self.logger.debug(f"Plugin {plugin_name} subscribed to hook: {hook_name}") return True ``` -------------------- ```python def trigger_hook(self, hook_name, **kwargs): """ Trigger a hook, calling all subscribed plugin callbacks. """ if hook_name not in self.hooks: self.logger.warning(f"Attempted to trigger non-existent hook: {hook_name}") return [] results = [] for subscriber in self.hooks[hook_name]: plugin_name = subscriber["plugin"] # Only trigger hooks for enabled plugins if plugin_name.lower() not in self.enabled_plugins: continue try: callback = subscriber["callback"] result = callback(**kwargs) results.append(result) except Exception as e: self.logger.error(f"Error in plugin {plugin_name} for hook {hook_name}: {str(e)}", exc_info=True) return results ```
-------------------------- ### A Quick Guide to Creating a Plugin #### A really good starting place would be to look at [multiplayer main.py](https://github.com/ViciousSquid/Dosidicus/blob/2.4.5.1_latest_release/plugins/multiplayer/main.py) and [achievements main.py](https://github.com/ViciousSquid/Dosidicus/blob/2.4.5.1_latest_release/plugins/achievements/main.py) and then refer back to this guide This guide will walk you through the essential steps to create a basic plugin that correctly initializes and registers with the system's `PluginManager` _(src/plugin_manager.py)_ ``` The plugin manager expects: * A folder in plugins/ directory (e.g., plugins/pluginname/) * A main.py file inside that folder * Module-level constants: PLUGIN_NAME, PLUGIN_VERSION, PLUGIN_AUTHOR, PLUGIN_DESCRIPTION, PLUGIN_REQUIRES * An initialize(plugin_manager) function that: 1. Creates the plugin instance 1. Registers it with the plugin manager via plugin_manager.plugins[name] = {..., 'instance': instance} 1. Returns `True` on success ``` ### Step 1: Create the Plugin Folder Structure First, create a new folder for your plugin inside the plugins/ directory. The name of this folder will be your plugin's unique identifier (its key), so choose a simple, lowercase name. ``` Dosidicus/ ├── plugins/ │ ├── my_new_plugin/ <-- Your new plugin folder │ │ └── main.py <-- Your plugin's entry point │ ├── auto_care/ │ └── multiplayer/ └── src/ └── ... ``` ### Step 2: Define Plugin Metadata You must define Module-level constants that the PluginManager uses to identify and load your plugin. * `PLUGIN_NAME`: (Required) The display name of your plugin. ### **MUST **BE LOWERCASE! * `PLUGIN_DESCRIPTION`: A brief description of what your plugin does. * `PLUGIN_AUTHOR`: Your name or alias. * `PLUGIN_VERSION`: The version of your plugin. ```python # plugins/my_new_plugin/main.py # --- Plugin Metadata --- # PLUGIN_NAME must be lowercase PLUGIN_NAME = "my new plugin" PLUGIN_DESCRIPTION = "A simple plugin that demonstrates the basics." PLUGIN_AUTHOR = "Seymour Butts" PLUGIN_VERSION = "1.0" ``` ### Step 3: Create the Main Plugin Class It's best practice to encapsulate your plugin's logic within a class. This class will hold the state and functionality of your plugin, such as methods for enabling, disabling, and handling events (hooks). ```python # plugins/my_new_plugin/main.py class MyPlugin: def __init__(self, plugin_manager, plugin_key): self.plugin_manager = plugin_manager self.plugin_key = plugin_key # e.g., "my_new_plugin" self.is_enabled = False # Subscribe to a system event (hook) self.plugin_manager.subscribe_to_hook( "on_startup", self.plugin_key, self.on_app_startup ) def enable(self): """Called when the user enables the plugin.""" print(f"[{self.plugin_key}] Plugin Enabled!") self.is_enabled = True return True # Return True on success def disable(self): """Called when the user disables the plugin.""" print(f"[{self.plugin_key}] Plugin Disabled!") self.is_enabled = False return True # Return True on success def on_app_startup(self, **kwargs): """Callback for the on_startup hook.""" if self.is_enabled: print(f"[{self.plugin_key}] Application has started up!") ``` ### Step 4: Implement the initialize Function The PluginManager requires a global function named initialize in your main.py. This function's job is to: 1. Get the plugin's unique key (the folder name). 2. Create an instance of your main plugin class. 3. Register the instance with the PluginManager. This is the crucial step that connects your plugin to the main application. ```python # plugins/my_new_plugin/main.py import os # (Metadata and Class definition from above) # ... def initialize(plugin_manager_instance): """ This function is called by the PluginManager to initialize the plugin. """ # Get the plugin's unique key from its directory name # os.path.basename(os.path.dirname(__file__)) will return "my_new_plugin" plugin_key = os.path.basename(os.path.dirname(__file__)) try: # Create an instance of your main plugin class plugin_instance = MyPlugin(plugin_manager_instance, plugin_key) # Register the plugin instance with the manager # This makes the manager aware of the plugin and its instance plugin_manager_instance.plugins[plugin_key] = { 'instance': plugin_instance, 'is_enabled_by_default': False # ALWAYS FALSE NEVER CHANGE THIS } print(f"[{plugin_key}] Plugin initialized successfully.") return True # IMPORTANT: Return True on successful initialization except Exception as e: print(f"[{plugin_key}] Failed to initialize: {e}") return False # Return False on failure ``` With these steps, you have a complete, well-structured plugin that the system can discover, load, and run. The user can then enable and disable it manually through the application's plugin management UI. ================================================ FILE: Docs/engine/Save-File-Format.md ================================================ Save and load functionality is handled by the [SaveManager](https://github.com/ViciousSquid/Dosidicus/blob/2.4.4_stable/src/save_manager.py) class. It uses a structured approach that packages all game data into a single, portable file.

Save File Location and Types

The SaveManager creates and manages files within a `saves` directory in the application's root folder. It maintains two distinct save slots:

Save File Structure

When `save_game()` is called, it bundles data into the following internal JSON files within the zip archive: ------------------------------------------ [SaveViewer.html](../extras/SaveViewer.md) is available for easy viewing and comparisons of save files. This tool can also convert old v1 saved games (pre 2.4.5.0) to the new v2 format (2.5.0.0+) ================================================ FILE: Docs/engine/config.ini.md ================================================ The [config.ini](https://github.com/ViciousSquid/Dosidicus/blob/2.4.4_stable/config.ini) file allows for the fine-tuning of various game mechanics without needing to alter the source code. It is structured into sections, each controlling a different aspect of the application's behavior. ``` NOTE: As of 2.6.1.0 there is a dedicated preferences window that exposes every setting. it can be accessed via the View menu (View>Preferences) Manual editing of the Config file still works but is not recommended ```

[General]

`language`: = `en` (Default) - Can be `de`, `en`, `es`, `fr`, `ja`, `ml` or `zh`

[Debug]

`multiplayer_debug` Show debug messages when multiplayer is enabled (Defaults to False) [Developer feature]

[RockInteractions]

This section controls the logic for how the squid interacts with rock items.

[Neurogenesis]

This is the main section for controlling the creation of new neurons.
[Neurogenesis.Novelty], [Neurogenesis.Stress], [Neurogenesis.Reward]
These subsections control the specific triggers for creating new neurons. Each has the following parameters:
[Neurogenesis.NeuronProperties]
This section defines the default properties for newly created neurons.
[Neurogenesis.Appearance] & [Neurogenesis.VisualEffects]
These sections control the visual feedback for neurogenesis.
================================================ FILE: Docs/extras/Achievements.md ================================================ Achievements are implemented via the [achievements plugin](https://github.com/ViciousSquid/Dosidicus/tree/2.5.0.0_latest_release/plugins/achievements) (Enabled by default) # Achievements List ### **Total Achievements:** **50** (41 visible, 9 secret) Worth 1125 max possible points --- ## 🍽️ Feeding Category (5) | Icon | Name | Condition | Points | Tier | |------|------|-----------|--------|------| | 🍽️ | First Bite | Feed the squid for the first time | 10 | 1 | | 🥄 | Regular Meals | Feed the squid 10 times | 15 | 1 | | 🍴 | Dedicated Caretaker | Feed the squid 50 times | 25 | 2 | | 👨‍🍳 | Master Chef | Feed the squid 100 times | 50 | 3 | | 🌟 | Culinary Legend | Feed the squid 500 times | 100 | 4 | --- ## 🧠 Neurogenesis Category (6) | Icon | Name | Condition | Points | Tier | |------|------|-----------|--------|------| | 🧠 | Brain Spark | Create the first neurogenesis neuron | 20 | 1 | | 🔮 | Neural Network | Create 10 neurons through neurogenesis | 30 | 2 | | 💫 | Expanding Mind | Create 50 neurons through neurogenesis | 50 | 3 | | 🌌 | Cerebral Powerhouse | Create 100 neurons through neurogenesis | 75 | 4 | | ⚡ | Strengthened Synapse | Level up a neuron for the first time | 15 | 1 | | 🌠 | Peak Performance | Level a neuron to maximum strength | 40 | 3 | --- ## 😴 Sleep Category (3) | Icon | Name | Condition | Points | Tier | |------|------|-----------|--------|------| | 😴 | Sweet Dreams | The squid wakes from its first sleep | 10 | 1 | | 🛏️ | Well Rested | The squid has slept 10 times | 20 | 2 | | 💭 | Deep Dreamer | Squid entered REM sleep | 25 | 2 | --- ## 📅 Milestones Category (7) | Icon | Name | Condition | Points | Tier | |------|------|-----------|--------|------| | ⏰ | One Hour Old | Squid reached 1 hour old | 15 | 1 | | 📅 | Growing Up | Squid reached 10 hours old | 30 | 2 | | 🎂 | One Day Wonder | Squid survived for 24 hours | 50 | 3 | | 🏅 | Week Veteran | Squid has lived for one week | 100 | 4 | | 🎖️ | Month Veteran | Squid has lived for one month | 150 | 5 | | 😄 | Pure Bliss | Reach 100% happiness | 20 | 2 | | ⚖️ | Perfect Balance | All stats above 80% simultaneously | 40 | 3 | --- ## 🧹 Cleaning Category (3) | Icon | Name | Condition | Points | Tier | |------|------|-----------|--------|------| | 🧼 | First Scrub | Clean the tank for the first time | 10 | 1 | | ✨ | Spotless Environment | Clean the tank 25 times | 25 | 2 | | 🧹 | Germaphobe | Keep cleanliness above 90% for 1 hour straight | 30 | 2 | --- ## 💊 Health Category (3) | Icon | Name | Condition | Points | Tier | |------|------|-----------|--------|------| | 💊 | First Aid | Give medicine for the first time | 10 | 1 | | 🩺 | Doctor Squid | Give medicine 10 times | 20 | 2 | | 💪 | Comeback Kid | Recover from critically low health (<20%) to full | 40 | 3 | --- ## 🎯 Interaction Category (14) ### Rocks | Icon | Name | Condition | Points | Tier | |------|------|-----------|--------|------| | 🪨 | Rock Collector | Pick up a rock for the first time | 10 | 1 | | ⛰️ | Stone Gatherer | Pick up 10 rocks | 15 | 1 | | 🏔️ | Boulder Hoarder | Pick up 50 rocks | 30 | 2 | | 🎯 | Skipping Stones | Throw a rock for the first time | 10 | 1 | | 🚀 | Rock Launcher | Throw 25 rocks | 20 | 2 | | 💨 | Catapult Master | Throw 100 rocks | 40 | 3 | ### Decorations & Plants | Icon | Name | Condition | Points | Tier | |------|------|-----------|--------|------| | 🪴 | Interior Decorator | Push a decoration for the first time | 10 | 1 | | 🏠 | Furniture Mover | Push decorations 10 times | 15 | 1 | | 🎨 | Feng Shui Master | Push decorations 50 times | 30 | 2 | | 🌱 | Green Thumb | Interact with a plant for the first time | 10 | 1 | | 🌿 | Garden Explorer | Interact with plants 10 times | 15 | 1 | | 🌳 | Botanist | Interact with plants 50 times | 30 | 2 | ### General Interaction | Icon | Name | Condition | Points | Tier | |------|------|-----------|--------|------| | 🔍 | Curious Inspector | Investigate 25 different objects | 25 | 2 | | 🕵️ | Master Detective | Investigate 100 different objects | 50 | 3 | --- ## 💩 Exploration Category (1) | Icon | Name | Condition | Points | Tier | |------|------|-----------|--------|------| | 💩 | Mischief Maker | Squid threw a poop for the first time | 10 | 1 | --- ## 🖤 Ink Category (2) | Icon | Name | Condition | Points | Tier | |------|------|-----------|--------|------| | 🖤 | Smoke Screen | Squid releases ink cloud for the first time | 15 | 1 | | 🌫️ | Ink Master | Release 20 ink clouds | 25 | 2 | --- ## 💾 Memory Category (3) | Icon | Name | Condition | Points | Tier | |------|------|-----------|--------|------| | 💾 | First Memory | Form the first memory | 15 | 1 | | 🗄️ | Long Term Thinking | Promote a memory to long-term storage | 25 | 2 | | 📚 | Photographic Memory | Have 50 memories stored | 40 | 3 | --- ## 😊 Emotional Category (4) | Icon | Name | Condition | Points | Tier | |------|------|-----------|--------|------| | 🤔 | Curious George | Curiosity reaches 100% | 15 | 1 | | 🧘 | Zen Master | Keep anxiety below 10% for 30 minutes | 30 | 2 | | 😱 | Startled! | Startle the squid for the first time | 10 | 1 | | 😰 | Nervous Wreck | Anxiety reaches 100% | 15 | 2 | --- ## 🔬 Meta Category (3) | Icon | Name | Condition | Points | Tier | |------|------|-----------|--------|------| | 🔬 | Brain Surgeon | Open the brain visualization tool | 10 | 1 | | ⏩ | Speed Demon | Run simulation at max speed for 10 minutes | 15 | 2 | | 🏆 | Completionist | Unlock 30 other achievements | 100 | 4 | --- ## 📊 Tier System The tier system uses 5 levels with different colors: | Tier | Name | Color | Example Achievements | |------|------|-------|----------------------| | 1 | Bronze | #CD7F32 | First Bite, Brain Spark, Sweet Dreams | | 2 | Silver | #C0C0C0 | Regular Meals, Neural Network, Pure Bliss | | 3 | Gold | #FFD700 | Master Chef, Peak Performance, Perfect Balance | | 4 | Platinum | #E5E4E2 | Culinary Legend, Cerebral Powerhouse, Completionist | | 5 | Diamond | #B9F2FF | Month Veteran | --- ## Summary Statistics - **Total Achievements:** 50 - **Visible Achievements:** 41 - **Hidden Achievements:** 9 - **Total Points Available:** 1,125 - **Most Common Tier:** Tier 1 (Bronze) - **Most Common Category:** Interaction (14 achievements) ================================================ FILE: Docs/extras/Decoration-Window.md ================================================ ![image](https://github.com/user-attachments/assets/bed7c083-80d3-4ecd-8375-dd6c8746d3d4) # Press `D` to open the decorations window * ## or use the `View > Decorations` menu ------------------------ The Decorations Window is your catalog for all the items you can use to furnish your squid's environment. Placing decorations is not just for cosmetic appeal; each item has a unique effect on the squid's mood and well-being, influencing his stats and behavior. This feature allows you to customize the tank and actively manage your squid's environment.

How to Use the Decorations Window

  1. Opening the Window: To open the catalog, navigate to the menu bar at the top of the main application window, click on View> Decorations (`Or simply press T`)
  2. Adding an Item: To place a decoration, click and hold the desired item from the Decorations Window, drag it over to the main tank area, and release to drop it.

Interacting with Placed Decorations

Once an item has been dropped into the tank, you can manipulate it directly:

Decoration Effects

Every decoration has hidden properties defined in [decoration_stats.json](https://github.com/ViciousSquid/Dosidicus/blob/2.4.4_stable/src/decoration_stats.json) - when the squid interacts with or is near a decoration, these properties can influence his statistics, such as happiness, anxiety, and curiosity. Experiment with different items to see how they affect your squid's mood and development! ================================================ FILE: Docs/extras/Easter-Eggs.md ================================================ On the About tab of the Brain Window, a colour can be selected for the squid. This colour will be added to your save file and will persist across play sessions -------------- **ml** (Millennial) is an available language (along with 7 others) -------------- Resizing the play window will startle the squid ================================================ FILE: Docs/extras/SaveViewer.md ================================================ #### SaveViewer.html * Quickly and easily open save files and view the contents. * `Network` tab visualises entire network * No need to open the game ================================================ FILE: Docs/extras/UUID.md ================================================ When a squid is created at the start of a new game he is assigned a unique fingerprint (also the name of his save file) It looks like this: ```example SquidSignature ab148370-6cd7-4d7e-a3d2-e33b68c4b615 ``` This currently serves no purpose but in future it will be used for any/all/none of the following: * DNA / Genetic code * Encoding personality traits/behaviours * Use as node name in multiplayer * seed to always generate the exact same squid * Verification (used as a 'magic number' or checksum) ================================================ FILE: Docs/getting-started/Care-Guide.md ================================================

Care Guide: Nurturing Body and Mind

Caring for your squid is a unique experience that goes beyond simple pet simulation. You are not only responsible for its physical well-being but also for the growth and development of its simulated brain. This guide will walk you through the essentials of caring for your squid's core needs and enriching its environment to foster a healthy, intelligent, and happy companion.

Part 1: Managing Your Squid's Core Needs

Your squid has several fundamental needs that you must monitor and manage. These are handled primarily through the Actions menu in the main application window.

Part 2: Enriching Your Squid's Mind & Stimulating the Brain

A healthy squid needs more than just food and a clean tank; it needs mental stimulation. Your actions directly influence the development of its neural network through learning (strengthening connections) and neurogenesis (creating new neurons).

Fostering Learning (Strengthening Connections)

Your squid's brain learns by associating events. When two of its neurons are active at the same time, the connection between them gets stronger. You can encourage this process through consistent care.

Example: When your squid is hungry, its hunger neuron is highly active. When you feed it, its satisfaction neuron becomes active. The brain sees these two events happen together and strengthens the connection between them. Over time, the squid "learns" that eating leads to satisfaction.

Stimulating Neurogenesis (Creating New Neurons)

Neurogenesis is the birth of new neurons and is the key to enriching your squid's mind. You can trigger this process by providing specific types of stimulation that correspond to the three neurogenesis pathways.

Cater to Your Squid's Personality

Remember that every squid has a unique personality that affects its needs. Check the Personality Tab in the Brain Tool to understand your squid's specific traits and get tailored advice.

Example: A Timid squid's anxiety will decrease significantly when it is near plants, making them an essential decoration for its well-being. In contrast, an Adventurous squid will thrive on a constantly changing environment with new decorations to investigate, which will be your primary method for providing novelty.

================================================ FILE: Docs/getting-started/Changelog.md ================================================ ### version 2.6.1.2 `23 Feb 2026` * Optional [hardware AI accelerator support via ONNX Runtime](../neural-network/AI-Accelerator-Support.md) - _Experimental, disabled by default_ * NEW: [brain_to_keras.py](https://github.com/ViciousSquid/Dosidicus/blob/2.6.1.2_LatestVersion/extras/brain_to_keras.py) (from the dev branch) in the `extras` folder - _attempts to convert a Dosidicus brain.json to Keras v3 (experimental)_ * Improved Brain Tool short-term and long-term memory tabs: **more varied and verbose memories** * **Random Humboldt squid facts** can occasionally appear in status bar * FIXED: BrainTool Hebbian timers weren't in sync [(20)](https://github.com/ViciousSquid/Dosidicus/issues/20) * FIXED squid now properly goes to sleep when sleepiness=max ------------------------- ### version 2.6.1.1 `20 Feb 2026` ### Milestone 2 * Added `linux_setup.sh` * Code optimisations & bug fixes ------------------------- ### version 2.6.1.0 `21 Jan 2026` #### build 1219 * Translation files for 7 languages: _English_, _French_, _Spanish_, _German_, _Chinese_, _Japanese_, _Millennial_ * [Stable release for Windows](https://github.com/ViciousSquid/Dosidicus/releases/tag/v2.6.1.0) `18 Dec 2025` #### build 1218 **Milestone 2** Release * Integrated Designer into Brain Tool * Added ability to create custom neurons * NEW: [Headless brain trainer](https://github.com/ViciousSquid/Dosidicus/blob/v2.6.1.0__b1218_LatestVersion/headless/README_headless_trainer.md) with accelerated time epochs * NEW: Global preferences window * Added an additional 4 custom brains * French and Spanish Translations (_does not currently include Designer_) ------------------- ### version 2.6.0.3 `11 Dec 2025` * Added FEED, CLEAN, MEDICINE buttons to UI * Added 5 example [custom brains](https://github.com/ViciousSquid/Dosidicus/tree/2.6.0.2_latest_release/custom_brains) * Neuron/font sizes and other UI elements now configurable via config.ini * FIXED: missing `update_score` method in `StatisticsWindow` * [Brain Designer](../brain-tool/Brain-Designer.md) can now import current running brain from Brain Tool * NEW: Neuron output bindings can be used to create simple IF THEN behaviours for the squid ------------------- ### version 2.6.0.2 `8 Dec 2025` * NEW: [Brain Designer](../brain-tool/Brain-Designer.md) - create your own custom squid brains! * NEW: 5 custom brain templates that can be edited however you like [[dir](https://github.com/ViciousSquid/Dosidicus/tree/2.6.0.1_latest_release/custom_brains)] * NEW: [Example squid](../getting-started/Example-Squids.md) **Miroslav** ------------------- ### version 2.5.0.0 `3 Dec 2025` * New [Save Viewer](../extras/SaveViewer.md) * Added 8 additional plant decorations & associated stats * Brain Network tab now has buttons for Experience Buffer and Neuron Laboratory * Fixed a bug where the brain state was not being restored properly from save * Added [showman wrapper](../source-reference/neurogenesis_show.py.md) for Neurogenesis * Code refactoring and removal of legacy cruft (pre version 2.4.X) * Feature-complete stable code-base ------------------- ### version 2.4.5.1 _patch_ `25 Nov 2025` * [Engine](../engine/Engine-Overview.md) update - Neurogenesis and hebbian calculations now in own thread so UI remains responsive * **New**: Improved [multiplayer](../engine/Multiplayer.md) plugin!! * **New**: [Achievements](../extras/Achievements.md) (50 to collect) * **New**: Full interactive tutorial when starting a new game * Every squid is born with a `uuid` that stays with him his entire life * Added [Neuron Laboratory](../brain-tool/Neuron-Laboratory.md) via the View menu or by double clicking any neuron ------------------- ### version 2.4.5.0 `15 Nov 2025` * Improved [plugin manager](../engine/Plugin-System.md) * Massively overhauled and [improved](../source-reference/neurogenesis_show.py.md) neurogenesis * Track statistics such as squid age, distance travelled, total foods eaten, etc * Improved load/save mechanism (backward compatible with v2.3 saves and earlier) * Hebbian now trains on 2 active neuron pairs at once * Redesigned Brain Tool [learning tab](../brain-tool/Learning-Tab.md) * Global counters now respect game speed * Arcade-style (High-Score) system * Animations throughout the UI ------------------- ### version 2.4.4.1 `04 Sept 2025` * Code cleanup and stability improvements * WIP: Track statistics such as squid age, distance travelled, total foods eaten, etc * ADDED: Small chance of squid creating an ink cloud when startled * ADDED: Experimental [multiplayer](../engine/Multiplayer.md) plugin ------------------- ### version 2.4.3 Milestone 1 **Initial stable release** ### **AUTHOR GOT A TATTOO** to celebrate 1 year of this project! Buy Me A Coffee ================================================ FILE: Docs/getting-started/Example-Squids.md ================================================ ``` The example_squids folder contains saved brains that have already accumulated some experiences: ``` ------------------------- ### [b071f720_593e_471f_8412_8ee84172a1b0.zip](https://github.com/ViciousSquid/Dosidicus/blob/2.6.0.0_latest_release/example_squids/b071f720_593e_471f_8412_8ee84172a1b0.zip) #### Squid name: Miroslav | Age: 5 | Number of neurons: 12 * Young squid with default brain type (7 core neurons) * ${\color{red}stress}$ neurons have developed in response to being extremely hungry in the past * ${\color{yellow}novelty}$ neurons have developed - positive experiences related to investigation/exploration * many connections (111!) - strong brain, highly responsive, quick to learn (high plasticity) * `novelty_object_investigation` has extremely high multiplier of 14 suggesting episodes of manic excitement ------------------------- ================================================ FILE: Docs/getting-started/Home.md ================================================ ### _A transparent neural sandbox disguised as a digital pet_ A micro neural engine for small autonomous agents that learn via Hebbian dynamics and grow new structure when exposed to novelty. ---------------------------------- ## [Manifesto](https://github.com/ViciousSquid/Dosidicus/wiki/Cognitive-Sandbox-Manifesto-%7C-Artificial-Life-and-Transparent-Neural-Systems) | [Wiki](https://github.com/ViciousSquid/Dosidicus/wiki) | [Changelog](https://github.com/ViciousSquid/Dosidicus/wiki/changelog) ---------------------------- ## Getting Started New to Dosidicus? Start here to understand how to interact with your squid. * **[Care Guide / Getting Started](../getting-started/Care-Guide.md)** * **[Personalities](../neural-network/Personality.md)** — How different squid types behave. * **[Decoration Window](../extras/Decoration-Window.md)** — Managing the squid's environment. --- ### Biological Autonomy * **[Vision System](../neural-network/Vision-System.md)** — Realistic foraging and food detection. * **[Hebbian Learning](../neural-network/Hebbian-Learning.md)** — The algorithm behind 30-second learning cycles. * **[Neurogenesis](../neural-network/Neurogenesis.md)** — How the squid creates new neurons based on environment. * **[Decision Engine](../engine/Decision-Engine.md)** — Making choices based on hunger, sleep, and memory. ### Engine Architecture & Logic * **[Engine Overview](../engine/Engine-Overview.md)** — High-level system architecture. * **[main.py](../source-reference/main.py.md)** — The main simulation loop. * **[tamagotchi_logic.py](../source-reference/tamagotchi_logic.py.md)** — Core needs and health management. * **[squid.py](../source-reference/squid.py.md)** — The physical squid class. * **[Memory System](https://github.com/ViciousSquid/Dosidicus/blob/main/Docs/Memory%20System.md)** & **[memory_manager.py](../source-reference/memory_manager.py.md)** — Managing experiences. --- ## Tools & Configuration Fine-tune the simulation and monitor the squid's neural activity. ### [The Brain Tool](../brain-tool/Network-Tab.md) * **[Network Tab](../brain-tool/Network-Tab.md)** | **[Learning Tab](../brain-tool/Learning-Tab.md)** * **[Memory Tab](../brain-tool/Memory-Tab.md)** | **[Decisions Tab](../brain-tool/Decisions-Tab.md)** * **[Personality Tab](../neural-network/Personality.md-tab)** ### System Settings * **[config.ini](../engine/config.ini.md)** — Adjusting simulation parameters. * **[Save File Format](../engine/Save-File-Format.md)** — Structure of persisted data. * **[Plugin System](../engine/Plugin-System.md)** — Extending the engine's capabilities. ================================================ FILE: Docs/neural-network/AI-Accelerator-Support.md ================================================ #### New in v2.6.1.2 (experimental) [compute_backend.py](https://github.com/ViciousSquid/Dosidicus/blob/2.6.1.2_onnx/src/compute_backend.py) facilitates hardware acceleration for neural calculations by changing one line: in `config.ini`: ``` [Compute] backend = numpy ``` Options: - `numpy` - default, no extra dependencies - `onnx` - enables hardware AI accelerator support via ONNX Runtime auto-selects `DirectML`, `OpenVINO`, `QNN`, or falls back to `numpy` if no runtime is present. --------------------------------- requires package to be installed (refer to the following list:) #### Recommended packages by platform: - **Windows** | _NVIDIA + AMD + Intel GPU + NPU (DirectML)_ | `pip install onnxruntime-directml` - **Windows** | _NVIDIA only (maximum CUDA performance)_ | `pip install onnxruntime-gpu` - **Windows** | _Qualcomm 8CX / SQX / Snapdragon (NPU)_ | `pip install onnxruntime-qnn` - **macOS** | _Apple Silicon and Intel Macs_ | `pip install onnxruntime` - **Linux** | _NVIDIA GPUs_ | `pip install onnxruntime-gpu` - **Linux** | _AMD GPUs_ | Use the ROCm/MIGraphX build (see [ONNX Runtime docs](https://onnxruntime.ai/docs/execution-providers/MIGraphX-ExecutionProvider.html)) ``` If no package is installed, the default `numpy` will be used (neural calculations performed on CPU) Any failure conditions will print to the console and we fall back to default `numpy` Detection should happen automatically. ONNX SUPPORT IS NEW AND EXPERIMENTAL ``` ------------------------------- The Brain Tool Network Tab displays which backend is being used in (top right) ================================================ FILE: Docs/neural-network/Experience-Buffer.md ================================================ The Experience Buffer is a core component in the system's learning and [neurogenesis](../neural-network/Neurogenesis.md) (neuron creation) module that functions as a memory for recent, significant events. It maintains a time-ordered log of the squid's context and uses this data to identify recurring patterns, which helps the system decide when and how to create new, functionally specialized neurons. #### How the Experience Buffer Works The experience buffer is technically implemented as the `ExperienceBuffer` class, which operates in two main ways: maintaining a rolling log and tracking pattern recurrence. 1. Rolling Log (deque): The buffer uses a deque (double-ended queue) to store a fixed, limited number of recent experiences (default maximum is 50 experiences). When a new experience is added, the oldest one is automatically discarded, ensuring the buffer only contains the latest, most relevant context. 2. Pattern Tracking: When an experience is added, it is processed to generate three levels of **pattern signatures**, which are counted to track how often similar events occur: * **Specific Pattern**: The most detailed signature, identifying a precise combination of `trigger`, `outcome`, and primary motivational neuron state. * **Parent Pattern**: A broader pattern used for hierarchical grouping. * **Core Pattern**: A minimal pattern used for "fuzzy matching" or identifying basic event categories. The system analyses these counts to determine if a situation is a novel event or a recurring pattern, which heavily influences whether a new neuron is created, or if an existing neuron is strengthened. ---------------- #### Example Experiences (ExperienceContext) Each recorded experience is captured as an ExperienceContext object, which is a snapshot of the squid's state and environment at the moment a significant event (the trigger) occurs. * `trigger_type` - The general category of the event: `novelty`, `stress`, or `reward`. * `outcome` - The result of the experience: `positive`, `negative`, or `neutral`. * `active_neurons` A dictionary of all current neuron activations (e.g., {`hunger`: 20, `anxiety`: 85}). * `recent_actions` A list of recent actions taken by the squid (e.g., [`approach_plant`, `hide`]). * `environmental_state` Key external facts at the time (e.g., {`food_count`: 0, `has_rock`: True}). ================================================ FILE: Docs/neural-network/Hebbian-Learning.md ================================================ The neural network in Dosidicus does not use traditional backpropagation for training. Instead, it employs a form of Hebbian learning, a biologically-inspired principle summarized as "neurons that fire together, wire together." This method allows the network to learn associations and patterns organically based on the squid's concurrent states, without requiring a separate training phase. The entire process is managed within the perform_hebbian_learning and update\_connection methods.

1. The Learning Cycle

Learning is not continuous but occurs in discrete cycles to ensure stability and reduce computational load.

2. The Core Learning Process

The perform_hebbian_learning method executes a precise sequence of steps to update the network's weights.

  1. Identify Active Neurons: The system first scans all neurons in the brain. Any neuron whose activation value is above the active_threshold defined in the configuration is considered "active" for this learning cycle. System-level neurons (e.g., is_eating, direction) are excluded from this process.
  2. Random Pair Sampling: If fewer than two neurons are active, the learning cycle is aborted. If there are enough active neurons, the system does not update all possible pairs. Instead, it randomly samples a small number of pairs (e.g., two) from the pool of active neurons. This stochastic approach introduces variability and prevents the network from over-stabilizing into rigid patterns.
  3. Update Connection Weight: For each selected pair, the update_connection method is called. This is where the core weight calculation happens:

3. Visual Feedback

The learning process is tied directly to the application's user interface to provide clear, real-time feedback.

================================================ FILE: Docs/neural-network/Neurogenesis.md ================================================ `neurogenesis.py` is the brains’ stem-cell layer: it decides when, why, and how new neurons appear, makes sure they are immediately functional, keeps the network within size & specialization limits, and cleans up the least useful ones—all through a single, auditable pipeline. #### Unified neuron creation * `create_neuron()` – the only public entry-point used by the UI, BrainWorker, save-load, etc. * `create_functional_neuron()` – internal helper that always produces a FunctionalNeuron. * All neurons are converted into FunctionalNeuron objects #### Context-aware experience tracking * `ExperienceContext` – a snapshot of why the neuron is being made (trigger type, brain state, environment, outcome, recent actions). * `ExperienceBuffer` – rolling FIFO buffer (≈ 50 experiences) that counts how often each specific / parent / core pattern recurs; used to decide when a neuron should actually be spawned. * `NeurogenesisTriggerSystem` – state-delta detection (novelty spikes, stress surges, reward rebounds). #### Functional specialization & wiring * Every neuron gets a specialization string (feeding_satisfaction, filth_avoidance, object_investigation, …) derived from the context. * `get_functional_connections()` – returns a weighted connection list to existing neurons so the new cell is immediately useful instead of random. * `_make_reciprocal_connections()` – guarantees that any outgoing connection ≥ 0.2 gets a matching incoming link so the new neuron can activate/be activated. #### Placement & visuals * `_calculate_functional_position()` – places the neuron near the neurons it will influence, not at random. * `_set_neuron_appearance()` – shape (diamond / square / triangle) and color palette encode type and specialization so users can “read” the brain at a glance. #### Soft & hard limits * Per-type caps (max_per_type) – e.g. max 3 stress, 5 novelty, 4 reward. * Per-specialization caps (max_per_specialization) – prevents 20 identical “hunger_stress_response” clones. * Global neuron cap (max_neurons) – total network size ceiling. * Cooldown – minimum seconds between any creation event. * Pattern-recurrence thresholds – neuron spawns only after a pattern has repeated 2–5× (depending on specificity). #### Strengthening instead of duplication If a cap is hit, the system boosts an existing neuron (strength_multiplier, utility_score) rather than creating a redundant one. #### Pruning & housekeeping * `intelligent_pruning()` – removes the lowest-utility neuron that is > 5 min old, considering activation recency, uniqueness of specialization, and total synaptic weight. * `_rebuild_new_neurons_details_for_lab()` – guarantees the Laboratory “newest neurogenesis neurons” card always has origin data. #### State integration & runtime updates * `update_neuron_activations()` – every tick, functional neurons compute their value from incoming weights; stress neurons collectively suppress anxiety (bi-directional feedback). * Emits pulse animations for weights ≥ 0.15 (can be disabled). #### Persistence & save/load * `to_dict()` / `from_dict()` – serializes the entire `ExperienceBuffer`, every `FunctionalNeuron`, counters, and creation history. * `ensure_all_neurons_functional()` – on load, converts any legacy neurons discovered in [brain_widget](../source-reference/brain_widget.py.md) into FunctionalNeuron instances so the system stays unified. #### Achievement hooks `set_achievement_callbacks()` – lets the Achievements module receive “neuron created” and “neuron leveled” events for trophies. ================================================ FILE: Docs/neural-network/Personality.md ================================================ Personality affects squid needs and behaviour. A random personality is assigned every time a new squid is born, and could be thought of as a sort of "difficulty level" with 'Lazy' being the easiest and 'Stubborn' being hardest. There are seven different squid personalities that affect their needs and how they behave: * `Timid`: Higher chance of becoming anxious * `Adventurous`: Increased curiosity and exploration * `Lazy`: Slower movement and energy consumption * `Energetic`: Faster movement and higher activity levels * `Introvert`: Prefers solitude and quiet environments * `Greedy`: More focused on food and resources * `Stubborn`: Fussy and difficult One of these is randomly chosen at launch A personality type can be forced at launch using the `-p` flag followed by the personality name above (example: `main.py -p lazy`) Each personality type presents unique challenges and requirements for the player to manage. Understanding and accommodating the specific needs and behaviors of each personality type is crucial for the player's success in caring for the squid and maintaining its well-being. Here's a description of the different personality types and their corresponding behaviors in the game: ### `Timid` Personality: * Tendency to become anxious, especially when not near plants. * Moves slowly and prefers quiet, solitary environments. * Curiosity level is lower than other personalities. * Has a higher chance of becoming startled by decorations or other environmental factors. ### `Adventurous` Personality: * Curious and exploratory, with a higher chance of entering the "curious" state. * Moves faster and is more active compared to other personalities. * Curiosity level is higher, leading to increased exploration and interaction with the environment. ### `Lazy` Personality: * Moves and consumes energy at a slower pace. * Takes more time to fulfill their needs, such as eating and sleeping. * May be less responsive to environmental changes or stimuli. ### `Energetic` Personality: * Moves and acts at a faster pace. * Tends to have higher activity levels and may expend energy more quickly. * May be more prone to restlessness or agitation. ### `Introvert` Personality: * Prefers solitary environments and is content when alone. * May become unhappy or anxious when forced to interact with the environment or decorations. * Curiosity level is balanced, not too high or too low. ### `Greedy` Personality: * Highly focused on food and resources, becoming anxious and hungry when those needs are not met. * Curiosity level may be higher, leading to more exploration, but this is primarily driven by the desire for food. * May become more aggressive or assertive in obtaining food or resources. ### `Stubborn` Personality: * Only eats its favorite food (sushi), often refusing to consume any other type of food (cheese). * Displays the message "Fussy squid does not like that type of food!" when presented with non-favorite food. * Has a chance of refusing to sleep when its sleepiness is high, instead moving randomly. * Moves slowly and stubbornly when not actively searching for its favorite food. * Prioritizes sushi over cheese in its vision cone when searching for food. ================================================ FILE: Docs/neural-network/STDP.md ================================================ ### Spike‐Timing‐Dependent Plasticity (STDP) _As of version 2.6.2.0_ this page has moved to the Wiki [[HERE]](https://github.com/ViciousSquid/Dosidicus/wiki/Spike%E2%80%90Timing%E2%80%90Dependent-Plasticity-(STDP)) ================================================ FILE: Docs/neural-network/Technical-Overview.md ================================================

Neural Network Technical Overview

image

The system's neural network is a unique, single-layer, fully-connected network architecture that dynamically grows through a process of neurogenesis. Traditional backpropagation is not used for learning, instead relying on a pure Hebbian model (../neural-network/Hebbian-Learning.md) ...

1. Core Architecture

2. Learning Mechanism: Hebbian Learning

The network updates its connection weights using a Hebbian learning rule, which follows the principle "neurons that fire together, wire together."

3. Dynamic Architecture: Neurogenesis

The network's most advanced feature is its ability to create new neurons, a process called neurogenesis. This allows the brain's architecture to grow and adapt based on the squid's experiences.

4. Network Stability and Pruning

To manage the complexity of a dynamically growing network, the system employs pruning mechanisms to remove inefficient or irrelevant components. This feature is critical for long-term network health and is enabled by default.

================================================ FILE: Docs/neural-network/Vision-System.md ================================================

The squid's ability to "see" and react to his environment is not based on simple proximity but on a simulated line-of-sight mechanic. This system is composed of two main parts: the View Cone, which is an attribute of the squid itself and represents his field of view, and the Vision Window (from vision.py), which is a debug tool that provides a first-person visualization of what the squid is currently perceiving. ![image](https://github.com/user-attachments/assets/8bbb87ac-fef9-4093-bb22-a921ff0bc23f)

1. The View Cone: The Mechanism of Sight

The core of the vision system is the "View Cone." This is not a visual effect but an invisible geometric shape (specifically, a QPolygonF) that is mathematically projected from the squid's current position and orientation.

2. The Vision Window (vision.py)

![image](https://github.com/user-attachments/assets/2d733297-acda-42d9-9a96-7591f1c3de12)

The Vision Window is a powerful debugging tool that renders the output of the View Cone, allowing you to see the world from the squid's perspective.

How It Works

The update_vision() method is the heart of this window and performs the following steps on each refresh:

  1. Clear the Scene: It first clears its own display to ensure no leftover artifacts from the previous frame.
  2. Get the View Cone: It fetches the squid's current View Cone polygon from the main game logic.
  3. Draw the Cone: It draws a visual representation of the cone shape itself within its window, so you can see the precise boundaries of the squid's perception.
  4. Render Seen Objects: It then performs the same intersection logic as the main decision engine. For every object whose bounding box intersects with the View Cone, it creates a copy of that object's pixmap and draws it inside the Vision Window. It also draws the bounding box of the seen object for clarity.
  5. ================================================ FILE: Docs/source-reference/brain_neuron_hooks.py.md ================================================ #### brain_neuron_hooks.py The **sensory input** system that bridges game events to neuron activations. It maintains a registry of handler functions that calculate activation values for input neurons based on the current game state. This is where environmental awareness enters the neural network—when the squid sees food, gets startled, or detects a nearby plant, these hooks translate those game conditions into numerical activations that propagate through the network. #### Handler Registry: * Maps neuron names to calculation functions * Built-in handlers for: `external_stimulus`, `can_see_food`, `plant_proximity`, `threat_level`, `is_eating`, `is_sleeping`, `is_fleeing`, `is_startled`, `pursuing_food`, `is_sick` #### Plugin Integration: * `register_handler(name, callable)` — allows plugins to add custom input neurons * `unregister_handler(name)` — removes custom handlers (built-ins protected) * Merges plugin handlers with built-in ones at runtime #### Event Tracking: * Maintains `event_tracker` dictionary for temporal calculations * Tracks window resizes, object spawns, user interactions with decay over time * `update_decay()` — decays event intensities each simulation tick #### Key Method: * `get_input_neuron_values()` — returns current activation values for all registered input sensors ================================================ FILE: Docs/source-reference/brain_neuron_outputs.py.md ================================================ #### brain_neuron_outputs.py **output system** that bridges neuron activations to game behaviours (bindings) When neurons fire above configurable thresholds, this system triggers corresponding game actions (hooks) like fleeing, seeking food, or changing colour. It completes the sensorimotor loop—inputs flow in through hooks, propagate through the network, and outputs emerge here to drive the squid's behaviour. #### NeuronOutputBinding Dataclass: * Binds a neuron to an output hook with threshold, trigger mode, and cooldown * Trigger modes: `THRESHOLD_RISING`, `THRESHOLD_FALLING`, `THRESHOLD_ABOVE`, `THRESHOLD_BELOW`, `ON_CHANGE` Serializable to/from dict for save/load support #### Standard Output Hooks: * Movement: `flee`, `seek_food`, `seek_plant`, `approach_rock`, `wander` * Actions: `throw_rock`, `pick_up_rock`, `ink_cloud`, `eat`, `change_color` * State Changes: `sleep`, `wake`, `startle`, `calm` * Stat Modifications: `boost_happiness`, `boost_curiosity`, `reduce_anxiety` #### NeuronOutputMonitor Class: * `monitor(activations)` — checks all bindings against current values, fires those meeting conditions * Respects cooldown timers to prevent rapid-fire triggering * Integrates with plugin system's hook dispatcher * Includes floating `NeuronLogWindow` for debugging fired outputs ================================================ FILE: Docs/source-reference/brain_render_worker.py.md ================================================ _Not to be confused with [brain_worker.py](../source-reference/brain_worker.py.md)_ #### brain_render_worker.py (938 lines) An offscreen rendering engine running in its own QThread that paints the entire brain visualization to a QImage buffer. The main thread simply blits this cached image during paintEvent, dramatically improving UI responsiveness. The worker receives immutable state snapshots and handles all the complex drawing logic—connections with animated pulses, neurons with various shapes, localized labels, and visual effects for learning events. #### RenderState Dataclass: * Immutable snapshot containing positions, states, colors, weights, and animation parameters * Created on main thread via `create_render_state_from_widget()` helper * Includes pre-calculated localized neuron labels to avoid i18n lookups during render #### Rendering Pipeline: `request_render(state)` — throttled to 10 FPS * Draws and animates neurons and connections ================================================ FILE: Docs/source-reference/brain_tool.py.md ================================================ #### Responsibilities * Builds the multi-tab window (Network, Learning, Memory, Decisions, Statistics, …). * Hosts the [BrainWidget](../source-reference/brain_widget.py.md) in the “Network” tab. * Exposes buttons, sliders, tables to stimulate neurons, export data, change learning rate, force a learning cycle, etc. * Persists / loads the whole brain state (weights, positions, neurogenesis history) to JSON. * Owns the [BrainWorker](../source-reference/brain_worker.py.md) thread instance (wraps the one inside BrainWidget) and restarts it if it crashes. * Bridges between the squid logic ([tamagotchi_logic](../source-reference/tamagotchi_logic.py.md)) and the brain widget: – every few seconds it copies the squid’s current `hunger`, `happiness`, `anxiety`… into the widget’s state so the network mirrors the squid. – when the network learns new weights, the squid can query them for [decision-making](../engine/Decision-Engine.md). #### Key internal objects * `self.brain_widget` – canvas widget ([brain_widget.py](../source-reference/brain_widget.py.md)) * `self.tabs` – QTabWidget with all the inspector tabs * `self.config_manager` – central place for thresholds, intervals, colours, etc. * `self.tamagotchi_logic` – reference to the actual pet simulation (runs in the main game loop) ================================================ FILE: Docs/source-reference/brain_widget.py.md ================================================ ### brain_widget.py This is the main neural network visualization and coordination hub. It serves as the central controller that owns the authoritative brain state, coordinates background worker threads, and integrates all the subsystems that make the neural network function. Everything flows through this widget—stat updates from the game, rendering requests, learning signals, and neurogenesis events all converge here before being dispatched to the appropriate handlers. #### Core State Management: * Maintains the authoritative `state` dictionary with all neuron activations (hunger, happiness, anxiety, etc.) * Manages `weights` dictionary for connection strengths between neurons * Tracks `neuron_positions` for [visualization](../brain-tool/Network-Tab.md) layout #### Worker Coordination: * Receives an external [`BrainWorker`](../source-reference/brain_worker.py.md) via `set_brain_worker()` (avoids duplicate thread creation) * Owns a [`BrainRenderWorker`](../source-reference/brain_render_worker.py.md) for offscreen rendering * Coordinates signal/slot connections between workers and UI #### Subsystems Integrated: * [`EnhancedNeurogenesis`](../neural-network/Neurogenesis.md) for dynamic neuron creation * [`ExperienceBuffer`](../neural-network/Experience-Buffer.md) for tracking learning experiences * `EnhancedBrainTooltips` for hover information * Theming with animation styles (Vibrant, Subtle, etc.) * Brain state bridge for [designer](../brain-tool/Brain-Designer.md) synchronization #### Key Methods: * `update_brain_state(stats_dict)` — main entry point for stat updates from the game * `set_brain_worker()` — accepts external [worker](../source-reference/brain_worker.py.md) instance * `export_brain_state_for_designer()` — syncs state with the Brain Designer tool ================================================ FILE: Docs/source-reference/brain_worker.py.md ================================================ _Not to be confused with [brain_render_worker.py](../source-reference/brain_render_worker.py.md)_ #### brain_worker.py A background QThread dedicated to handling computationally expensive brain logic that would otherwise block the UI. It operates on cached snapshots of brain state, processes tasks from a thread-safe queue, and emits results back to the main thread via signals. This separation keeps the simulation responsive even during complex Hebbian learning calculations or neurogenesis evaluations. #### Task Queue System: * Uses thread-safe `Queue` for task dispatch * Processes three task types: [`neurogenesis`](../neural-network/Neurogenesis.md), [`hebbian`](../neural-network/Hebbian-Learning.md), `state_update` * Supports pause/resume for game state changes #### Neurogenesis Checks: * Evaluates stress/novelty/reward triggers against configurable thresholds * Emits `neurogenesis_result` signal with creation recommendations * Handles emergency stress neuron creation when anxiety exceeds 90 #### Hebbian Learning: * Selects top-k neuron pairs based on co-activation scores * Includes anti-loop mechanisms (randomization, cooldown penalties on recently-used pairs) * Can create new connections between previously unconnected co-active neurons * Emits weight updates back to main thread for application #### State Decay Processing: * Applies temporal decay toward baseline for non-input neurons * Adds small random noise for organic feel * Propagates connection effects through the network ================================================ FILE: Docs/source-reference/custom_brain_loader.py.md ================================================ #### custom_brain_loader.py The brain architecture import/export system that allows custom neural network designs from the Brain Designer tool to be loaded into the live simulation. It handles parsing various brain file formats, applying the architecture to the running BrainWidget, and integrating with the save/load system so custom brains persist across game sessions. This is what makes the Brain Designer's output actually playable. #### Global State Tracking: * Tracks currently loaded custom brain name, definition, and source file path * `has_custom_brain()` / `get_custom_brain_name()` — API for querying current state #### BrainLoader Class: * `show_dialog()` — opens brain selection UI * `_parse(raw_data)` — normalizes different brain file formats into consistent structure * `_apply(parsed_brain)` — applies positions, weights, and output bindings to live BrainWidget * `reset_positions_to_default()` — restores original layout while preserving network structure #### Save/Load Integration: * `get_custom_brain_save_data()` — packages custom brain definition for game saves * `restore_custom_brain_from_save()` — restores custom brain when loading a save * `validate_custom_brain_save()` — checks if a save file's custom brain can be loaded * `show_custom_brain_load_warning()` — warns user when loading saves with custom brains #### BrainSelectDialog: * File browser UI for .json brain files in the brains folder * Shows metadata preview (neuron count, connection count, description) * Supports browsing to external files or opening the brains folder ================================================ FILE: Docs/source-reference/designer_window.py.md ================================================ #### designer_window.py This is the primary application shell for the neural network Designer. #### Core Responsibilities: * **Application Orchestration**: Manages the main window, menus, toolbars, and the splitter layout containing the canvas and property panels. * **State Management**: Holds the active `BrainDesign` instance and coordinates "Undo/Redo" style refreshes across all sub-panels. * **Live Game Bridge**: If the game is running, it allows the user to "Sync from Game" (import the current brain) or "Push to Game" (export the design to the live squid). * **Network Generation**: Provides entry points for "Chaos Mode" or preset-based automated network generation using the `SparseNetworkGenerator`. #### Key Functions: * `setup_ui()` - Initializes the `BrainCanvas` and the vertical tabbed panels (Layers, Sensors, Properties, Connections, Outputs). * `push_to_game()` - Converts the visual design into a format the game understands and sends it via the `brain_state_bridge`. * `instant_random_generate()` - Triggers an instant "Chaos" shuffle of neuron positions and randomizes connections. * `load_from_brain_widget_state()` - Allows the editor to be opened mid-game by importing the current state of the squid's brain. #### Key internal objects: `ScrollingTicker`: A specialized UI widget that scrolls rich-text help messages and shortcuts. `BrainDesignerWindow`: The `QMainWindow` class that coordinates the canvas, side panels, and the bridge to the live game. ================================================ FILE: Docs/source-reference/main.py.md ================================================ ### The Simulation Loop (main.py) The simulation loop is driven by a QTimer from the PyQt5 framework, which "ticks" at a consistent rate. Each tick represents one frame of the simulation. The loop has two key responsibilities: **State Update**: On each tick, it calls the `update_game_state()` method. This function is responsible for everything that changes over time without direct user input. This includes: * Decrementing the squid's needs (hunger, cleanliness, etc.). * Calling the neural network to get the squid's next autonomous action. * Executing the squid's chosen action (e.g., moving, interacting with an object). * Updating animations. **Rendering**: After the state has been updated, the loop tells the GUI to repaint itself, ensuring that the user always sees the most current state of the simulation. ================================================ FILE: Docs/source-reference/memory_manager.py.md ================================================ `MemoryManager` is designed to simulate a simple cognitive memory system with two main components: #### Data Persistence and Initialization * File Storage: Memory is persisted in two JSON files within a _memory directory: `ShortTerm.json` and `LongTerm.json`. * `__init__` Method: Initializes memory paths, loads existing memory using _load_and_convert_timestamps, and sets operational limits: * `self.short_term_limit` = 50: Maximum number of items in short-term memory (STM). * `self.short_term_duration` = 300 (5 minutes): The lifespan for a short-term memory item before it's considered for cleanup or transfer. * `_load_and_convert_timestamps`: A crucial helper method that handles loading memory from JSON. It's robust, attempting to convert string-based _ISO 8601_ timestamps (used for storage) into floating-point Unix timestamps (used for internal operations) to enable easy time-based comparisons. * `save_memory`: The counterpart to the loading function. It takes the in-memory list and converts the internal float timestamps back to _ISO 8601_ strings before saving to the JSON file with indentation (indent=4). ------------------------------- #### Short-Term Memory (STM) Management STM is dynamic, time-limited, and its items have calculated metrics to determine their longevity. `add_short_term_memory`: Adds a new memory item. If an item with the same category and key already exists, it reinforces the memory by increasing its importance by 0.5 and updating its timestamp. It checks for immediate promotion: if importance reaches 3.0, it calls `transfer_to_long_term_memory`. It enforces the `self.short_term_limit` by dropping the oldest memory (`pop(0)`) when the limit is exceeded (FIFO - First In, First Out). `get_short_term_memory`: Retrieves a memory by category and key. It verifies the memory is still within the `self.short_term_duration`. A successful access increases the memory's `access_count`. `cleanup_short_term_memory`: Removes expired memories (older than `self.short_term_duration`). If the list is still over the limit, it prunes based on a combined score of importance and access_count. `get_active_memories_data`: Retrieves and formats memories that are still valid, sorting them by a combination of importance and access_count in descending order. --------------------- #### Long-Term Memory (LTM) Management LTM is for permanent or reinforced memories and is primarily concerned with deduplication. `add_long_term_memory`: Adds a memory. It checks for duplicates based on category and key. If a memory already exists, it is not duplicated; instead, its timestamp is updated to reflect the reinforcement. `get_all_long_term_memories`: Retrieves all LTM items, with an optional filter by category. --------------- #### Transfer and Review Logic This section defines the "learning" or "consolidation" process where temporary memories become permanent. * `review_and_transfer_memories`: The core decay and promotion loop. It iterates through expired STM items. * If an expired item meets the criteria in `should_transfer_to_long_term`, it's promoted using `transfer_to_long_term_memory`. * Otherwise, the expired memory is simply removed from the STM. * `periodic_memory_management`: A simple rate limiter that calls review_and_transfer_memories only if 30 seconds have passed since the last cleanup. * `should_transfer_to_long_term`: Defines the promotion criteria: 1. importance >= 7 OR 1. access_count >= 3 OR 1. (importance >= 5 AND access_count >= 2) * `transfer_to_long_term_memory`: Moves a memory from STM to LTM (using `add_long_term_memory` to handle LTM deduplication) and then removes it from the STM. ------------------- #### Utility and Formatting * `clear_short_term_memory` / `clear_all_memories`: Provides methods to reset one or both memory stores. * `update_memory_importance`: Allows external logic to manually adjust the importance of an STM item. * `format_memory`: A presentation method that takes a memory dictionary and returns an HTML-formatted string, color-coding the memory's interaction type (Positive/Negative/Neutral) based on its raw_value or category/key. ================================================ FILE: Docs/source-reference/neurogenesis_show.py.md ================================================ `neurogenesis_show.py` is the **“showman” layer** that wraps the real `EnhancedNeurogenesis` engine and adds **player-facing spectacle** without touching any of the underlying biology, caps, or cooldown logic. The wrapper is enabled by default (via config.ini) and is meant to make neurogenesis more fun/game-like versus biologically accurate. Disable the wrapper and neurogenesis will behave in a **more scientifically accurate way** (better suited for actual biological/neuro experiments) #### 1. Guarantee the player *sees* a neuron - If the real engine **would** create a neuron, this wrapper **always lets it spawn** (respects the same caps/cooldowns). - If the real engine **would NOT** create one, the showman can still **force an extra “dramatic” neuron** when something cool happens—**but only if showmanship is enabled in config**. #### 2. Detect “cool moments” `NeurogenesisEvent` enum + `_detect_dramatic_moment()` Monitors every experience context for **visually obvious milestones**: | Event | Typical Threshold | |---|---| | `ANXIETY_SPIKE` | anxiety ≥ 70 | | `CURIOSITY_EXPLOSION` | curiosity ≥ 75 | | `HUNGER_SATISFIED` | reward trigger + eating action | | `MAX_HAPPINESS` | happiness ≥ 85 | | `SPOTLESS_TANK` | cleanliness ≥ 90 | | `FIRST_DECORATION_PUSH` | “decoration” or “push” in recent actions | These are **intentionally permissive**—the goal is “player smiles,” not “perfect biology.” #### 3. Manual event triggers (achievement integration) `trigger_event(NeurogenesisEvent.FOO)` Lets **external systems** (achievements, tutorials, Easter-egg code) **request** a showman neuron. Returns `True` if one was actually spawned (respects cooldown & config flag). #### 4. Cosmetic upgrades - **_rename_for_drama()** – replaces auto-generated names like `stress_anxiety_regulation_3` with cinematic ones: `trauma`, `wonder_trigger`, `satisfaction_burst`, `discovery_rush`, … - **_burst_color()** – gives the newborn neuron a **10-second gold/crimson/mint “super-pulse”** instead of the normal 5-second blink. - **_migrate_neuron()** – atomically renames **every dictionary key, animation tracker, visible set, weight tuple, etc.** so the new pretty name is safe everywhere. #### 5. Achievement callbacks `set_callbacks(on_dramatic_neuron=…, on_event_triggered=…)` Fires when: - a showman neuron is actually spawned, or - any `NeurogenesisEvent` is recorded #### 6. Config-aware passthrough - Reads `[Neurogenesis] showmanship = True/False` **each time** (hot-switchable). - When disabled the class becomes a **transparent proxy**: every call falls straight through to the real engine with zero overhead. - Public API surface **mirrors** `EnhancedNeurogenesis` (`create_neuron`, `should_create_neuron`, `get_global_cooldown_remaining`, …) so the rest of the codebase never knows a wrapper exists. #### 7. Bookkeeping - `last_showman_creation` – 15-second **cosmetic cooldown** (independent of the real engine’s 60-second biological cooldown). - `_triggered_events` set – prevents **exact duplicate** “first” events within the same session. - `reset_events()` – clears history for **new game** or **load save**. ### TL;DR `neurogenesis_show.py` is the **marketing department** of the neuron factory: it **sprinkles confetti** (dramatic names, longer pulses, extra neurons on cool moments) while **never overruling** the real biologist downstairs—unless the player explicitly turns off the show, in which case it **vanishes silently**. ================================================ FILE: Docs/source-reference/squid.py.md ================================================ `squid.py` file defines the Squid class, integrating various functionalities to simulate a living creature. It handles its own visual rendering and animation, calculates its movement, makes decisions based on its internal states and personality, and interacts with objects like food and decorations. #### Core Responsibilities: * **Physical Simulation**: Handles movement, animation frames, collision with boundaries, and "Inking" behaviors. * **Internal State**: Tracks "Needs" (Hunger, Happiness, etc.) and "Goal Neurons" (Satisfaction, Anxiety, Curiosity). * **Interaction Engine**: Manages picking up/throwing rocks, eating food, and reacting to other squids in a multiplayer environment. * **Perception**: Acts as the primary interface for the VisionWorker, translating raw geometric vision data into behavioral triggers. #### Key Functions: * `update_view_direction()` - Makes the squid "scan" the tank; includes a "Hunger Bias" that forces it to look toward food. * `eat()` - Processes food consumption, applies stat changes, and starts the "Poop Timer". * `check_boundary_exit()` - Detects if the squid has swum off-screen to transition to a neighbour's tank in multiplayer mode. * `startle_awake()` - Handles the logic for a rude awakening, including anxiety spikes and potential ink cloud creation. #### Key internal objects: * `self.mental_state_manager` – boolean flags + cooldowns * `self.memory_manager` – short-term & long-term memory lists * `self._decision_engine` – Q-learning / weight-based action picker * `self.statistics` – personal lifetime counters (age, food eaten, rocks thrown…) ================================================ FILE: Docs/source-reference/tamagotchi_logic.py.md ================================================ View source: [tamagotchi_logic.py](https://github.com/ViciousSquid/Dosidicus/blob/2.6.1.2_LatestVersion/src/tamagotchi_logic.py) version 2.6.1.2 A **god-object** serving as the central game logic controller. Its primary purpose is to manage the core simulation loop, handle the squid's behaviour and needs, facilitate interactions with the environment, and integrate various game systems, including the neural network, memory, and save/load functionalities. #### Responsibilities * Owns the live pet simulation loop (`hunger`, `happiness`, `sickness`, `sleep`, etc.). * Spawns and manages world objects: food, poop, decorations, rocks. * Runs every-frame update (movement, collisions, timers, cooldowns). * Handles user actions: feed, clean, medicine, speed changes, window resize. * Coordinates save / load of the entire game state (squid, memories, decorations, brain). * Hosts the [plugin](../engine/Plugin-System.md) system (achievements, multiplayer, etc.) and fires hooks. * Owns statistics & scoring (distance swam, food eaten, startles, ink clouds…). * Bridges pet ← → brain: 1. – copies squid stats into the neural network so the network mirrors the pet. 2. – reads learned weights back from the network to influence future decisions. #### Key internal objects * `self.squid` – the pet instance ([squid.py](../source-reference/squid.py.md)) * `self.brain_window` – the debug UI ([brain_tool.py](../source-reference/brain_tool.py.md)) * `self.food_items` / `poop_items` / `rock_items` – lists of QGraphicsItems * `self.neurogenesis_triggers` – counters that tell the brain when to grow new neurons * `self.plugin_manager` – loads & runs plugins (multiplayer, achievements, …) ---------------------------------- * `TamagotchiLogic.__init__()` constructs the Squid and keeps a reference. * Squid receives a back-reference (`self.tamagotchi_logic`) so it can: 1. – ask for nearby decorations / food 2. – tell the logic when it threw a rock (for RL reward) 3. – trigger plugin hooks #### Data flow every frame: * `TamagotchiLogic.update_simulation`() * → calls `squid.move_squid`() * → copies `squid.hunger` / `happiness` / `anxiety` … into a dict * → sends dict to `brain_window.update_brain`() (neural network) * Neural network learns, may create new neurons, returns updated weights. * `TamagotchiLogic` reads those weights and/or calls `squid.make_decision`() which uses them. * Save / load * `TamagotchiLogic.save_game`() asks `squid.save_state`() for the pet slice, then bundles it with brain data, memories, decorations, achievements. On load the reverse happens; afterwards `sync_state_from_squid`() is called so the squid remains the single source of truth for core stats. #### Plugin hooks: Any time a Squid property changes (via its setters) it fires tamagotchi_logic.plugin_manager.trigger_hook("on_hunger_change", …) so plugins (achievements, multiplayer, etc.) can react. ================================================ FILE: Docs/source-reference/vision_worker.py.md ================================================ #### vision_worker.py This is the computational engine of the squid's perception. It runs as a background thread (QThread) to ensure that geometric calculations do not "freeze" the main game animations. #### Core Responsibilities: * **Asynchronous Calculation**: Performs vision cone and proximity checks at a fixed frequency (20Hz) **independently of the main game loop**. * **Vision Cone Logic**: Determines if objects (food, rocks, plants) are within the squid's 80-degree field of view by calculating the angular difference between the squid's gaze and the object's position. * **Proximity Sensing**: Calculates "tactile" proximity to plants. Unlike the vision cone, this uses bounding-box edge distances to detect if the squid is touching or near a plant (0–100 range). * **Change Detection**: It monitors the environment and only "alerts" the squid via signals when something meaningful changes (e.g., food just appeared in view). #### Key Functions: * `_calculate_visibility()` - The math core; checks every scene object against the vision cone and calculates distances. * `update_squid_state()` - Receives the squid's current X/Y and gaze angle from the main thread. * `update_scene_objects()` - Receives a lightweight list of all items currently in the tank. * `_check_and_emit_changes()` - Fires signals like `food_visibility_changed` or `plant_proximity_changed` only when state shifts. #### Key internal objects * `SquidVisionState`: A data class containing a snapshot of the squid's position, size, and gaze angle. * `SceneObject`: A lightweight representation of a world object (food, plant, rock) used for visibility checks. * `VisionResult`: A container for the output of a vision cycle, listing visible items and proximity values. * `VisionWorker`: The QThread subclass that constantly calculates what is inside the vision cone. ---------------------- #### "Producer-Consumer" model: 1. **Input**: [`Squid.py`](../source-reference/squid.py.md) periodically sends its position to the `VisionWorker`. 2. **Processing**: `VisionWorker` (in the background) identifies what is visible and sends a `VisionResult` back to the Squid. 3. **Behaviour**: The Squid uses that result to decide if it should chase food or feel calm near a plant. 4. **Display**: If the VisionWindow is open, it reads those same results to display the "Visible Objects" list to the user. * **Note:** The VisionWorker includes a "Circuit Breaker" logic to prevent the squid from chasing "ghost food" that was just eaten but hasn't been cleared from the background thread's cache yet. ================================================ FILE: LICENSE ================================================ GNU GENERAL PUBLIC LICENSE Version 2, June 1991 Copyright (C) 1989, 1991 Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The licenses for most software are designed to take away your freedom to share and change it. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change free software--to make sure the software is free for all its users. This General Public License applies to most of the Free Software Foundation's software and to any other program whose authors commit to using it. (Some other Free Software Foundation software is covered by the GNU Lesser General Public License instead.) You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for this service if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs; and that you know you can do these things. To protect your rights, we need to make restrictions that forbid anyone to deny you these rights or to ask you to surrender the rights. These restrictions translate to certain responsibilities for you if you distribute copies of the software, or if you modify it. For example, if you distribute copies of such a program, whether gratis or for a fee, you must give the recipients all the rights that you have. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights. We protect your rights with two steps: (1) copyright the software, and (2) offer you this license which gives you legal permission to copy, distribute and/or modify the software. Also, for each author's protection and ours, we want to make certain that everyone understands that there is no warranty for this free software. If the software is modified by someone else and passed on, we want its recipients to know that what they have is not the original, so that any problems introduced by others will not reflect on the original authors' reputations. Finally, any free program is threatened constantly by software patents. We wish to avoid the danger that redistributors of a free program will individually obtain patent licenses, in effect making the program proprietary. To prevent this, we have made it clear that any patent must be licensed for everyone's free use or not licensed at all. The precise terms and conditions for copying, distribution and modification follow. GNU GENERAL PUBLIC LICENSE TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 0. This License applies to any program or other work which contains a notice placed by the copyright holder saying it may be distributed under the terms of this General Public License. The "Program", below, refers to any such program or work, and a "work based on the Program" means either the Program or any derivative work under copyright law: that is to say, a work containing the Program or a portion of it, either verbatim or with modifications and/or translated into another language. (Hereinafter, translation is included without limitation in the term "modification".) Each licensee is addressed as "you". Activities other than copying, distribution and modification are not covered by this License; they are outside its scope. The act of running the Program is not restricted, and the output from the Program is covered only if its contents constitute a work based on the Program (independent of having been made by running the Program). Whether that is true depends on what the Program does. 1. You may copy and distribute verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice and disclaimer of warranty; keep intact all the notices that refer to this License and to the absence of any warranty; and give any other recipients of the Program a copy of this License along with the Program. You may charge a fee for the physical act of transferring a copy, and you may at your option offer warranty protection in exchange for a fee. 2. You may modify your copy or copies of the Program or any portion of it, thus forming a work based on the Program, and copy and distribute such modifications or work under the terms of Section 1 above, provided that you also meet all of these conditions: a) You must cause the modified files to carry prominent notices stating that you changed the files and the date of any change. b) You must cause any work that you distribute or publish, that in whole or in part contains or is derived from the Program or any part thereof, to be licensed as a whole at no charge to all third parties under the terms of this License. c) If the modified program normally reads commands interactively when run, you must cause it, when started running for such interactive use in the most ordinary way, to print or display an announcement including an appropriate copyright notice and a notice that there is no warranty (or else, saying that you provide a warranty) and that users may redistribute the program under these conditions, and telling the user how to view a copy of this License. (Exception: if the Program itself is interactive but does not normally print such an announcement, your work based on the Program is not required to print an announcement.) These requirements apply to the modified work as a whole. If identifiable sections of that work are not derived from the Program, and can be reasonably considered independent and separate works in themselves, then this License, and its terms, do not apply to those sections when you distribute them as separate works. But when you distribute the same sections as part of a whole which is a work based on the Program, the distribution of the whole must be on the terms of this License, whose permissions for other licensees extend to the entire whole, and thus to each and every part regardless of who wrote it. Thus, it is not the intent of this section to claim rights or contest your rights to work written entirely by you; rather, the intent is to exercise the right to control the distribution of derivative or collective works based on the Program. In addition, mere aggregation of another work not based on the Program with the Program (or with a work based on the Program) on a volume of a storage or distribution medium does not bring the other work under the scope of this License. 3. You may copy and distribute the Program (or a work based on it, under Section 2) in object code or executable form under the terms of Sections 1 and 2 above provided that you also do one of the following: a) Accompany it with the complete corresponding machine-readable source code, which must be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, b) Accompany it with a written offer, valid for at least three years, to give any third party, for a charge no more than your cost of physically performing source distribution, a complete machine-readable copy of the corresponding source code, to be distributed under the terms of Sections 1 and 2 above on a medium customarily used for software interchange; or, c) Accompany it with the information you received as to the offer to distribute corresponding source code. (This alternative is allowed only for noncommercial distribution and only if you received the program in object code or executable form with such an offer, in accord with Subsection b above.) The source code for a work means the preferred form of the work for making modifications to it. For an executable work, complete source code means all the source code for all modules it contains, plus any associated interface definition files, plus the scripts used to control compilation and installation of the executable. However, as a special exception, the source code distributed need not include anything that is normally distributed (in either source or binary form) with the major components (compiler, kernel, and so on) of the operating system on which the executable runs, unless that component itself accompanies the executable. If distribution of executable or object code is made by offering access to copy from a designated place, then offering equivalent access to copy the source code from the same place counts as distribution of the source code, even though third parties are not compelled to copy the source along with the object code. 4. You may not copy, modify, sublicense, or distribute the Program except as expressly provided under this License. Any attempt otherwise to copy, modify, sublicense or distribute the Program is void, and will automatically terminate your rights under this License. However, parties who have received copies, or rights, from you under this License will not have their licenses terminated so long as such parties remain in full compliance. 5. You are not required to accept this License, since you have not signed it. However, nothing else grants you permission to modify or distribute the Program or its derivative works. These actions are prohibited by law if you do not accept this License. Therefore, by modifying or distributing the Program (or any work based on the Program), you indicate your acceptance of this License to do so, and all its terms and conditions for copying, distributing or modifying the Program or works based on it. 6. Each time you redistribute the Program (or any work based on the Program), the recipient automatically receives a license from the original licensor to copy, distribute or modify the Program subject to these terms and conditions. You may not impose any further restrictions on the recipients' exercise of the rights granted herein. You are not responsible for enforcing compliance by third parties to this License. 7. If, as a consequence of a court judgment or allegation of patent infringement or for any other reason (not limited to patent issues), conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot distribute so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not distribute the Program at all. For example, if a patent license would not permit royalty-free redistribution of the Program by all those who receive copies directly or indirectly through you, then the only way you could satisfy both it and this License would be to refrain entirely from distribution of the Program. If any portion of this section is held invalid or unenforceable under any particular circumstance, the balance of the section is intended to apply and the section as a whole is intended to apply in other circumstances. It is not the purpose of this section to induce you to infringe any patents or other property right claims or to contest validity of any such claims; this section has the sole purpose of protecting the integrity of the free software distribution system, which is implemented by public license practices. Many people have made generous contributions to the wide range of software distributed through that system in reliance on consistent application of that system; it is up to the author/donor to decide if he or she is willing to distribute software through any other system and a licensee cannot impose that choice. This section is intended to make thoroughly clear what is believed to be a consequence of the rest of this License. 8. If the distribution and/or use of the Program is restricted in certain countries either by patents or by copyrighted interfaces, the original copyright holder who places the Program under this License may add an explicit geographical distribution limitation excluding those countries, so that distribution is permitted only in or among countries not thus excluded. In such case, this License incorporates the limitation as if written in the body of this License. 9. The Free Software Foundation may publish revised and/or new versions of the General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies a version number of this License which applies to it and "any later version", you have the option of following the terms and conditions either of that version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of this License, you may choose any version ever published by the Free Software Foundation. 10. If you wish to incorporate parts of the Program into other free programs whose distribution conditions are different, write to the author to ask for permission. For software which is copyrighted by the Free Software Foundation, write to the Free Software Foundation; we sometimes make exceptions for this. Our decision will be guided by the two goals of preserving the free status of all derivatives of our free software and of promoting the sharing and reuse of software generally. NO WARRANTY 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively convey the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. Also add information on how to contact you by electronic and paper mail. If the program is interactive, make it output a short notice like this when it starts in an interactive mode: Gnomovision version 69, Copyright (C) year name of author Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, the commands you use may be called something other than `show w' and `show c'; they could even be mouse-clicks or menu items--whatever suits your program. You should also get your employer (if you work as a programmer) or your school, if any, to sign a "copyright disclaimer" for the program, if necessary. Here is a sample; alter the names: Yoyodyne, Inc., hereby disclaims all copyright interest in the program `Gnomovision' (which makes passes at compilers) written by James Hacker. , 1 April 1989 Ty Coon, President of Vice This General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. ================================================ FILE: README.md ================================================ _"What if a Tamagotchi had a neural network and could learn stuff?"_ - [Gigazine](https://gigazine.net/gsc_news/en/20250505-dosidicus-electronicae/) , [Hackaday](https://hackaday.com/2025/04/26/digital-squids-behavior-shaped-by-neural-network/)

    AI GPL-2.0 Translations Buy Me A Coffee

    # _Dosidicus electronicus_ _A transparent cognitive sandbox disguised as a digital pet squid with a neural network you can **see thinking**_ - Part **educational neuro tool**, part **sim game**, part **fever dream** - A unique intersection of 1990s retro-gaming aesthetic and modern computational neuroscience. - [Build-your-own neural network](https://github.com/ViciousSquid/Dosidicus/wiki/Brain-Designer) - learn how an NN works by **raising one as a pet** ### Compiled binaries for Windows, Mac and Linux: [see Releases](https://github.com/ViciousSquid/Dosidicus/releases) page ```bash curl -sSL https://raw.githubusercontent.com/ViciousSquid/Dosidicus/2.6.2.0_LatestVersion/linux_setup.sh | bash ``` image --- ## [Manifesto](https://github.com/ViciousSquid/Dosidicus/wiki/Cognitive-Sandbox-Manifesto-%7C-Artificial-Life-and-Transparent-Neural-Systems) | [Wiki](https://github.com/ViciousSquid/Dosidicus/wiki) | [Changelog](https://github.com/ViciousSquid/Dosidicus/wiki/changelog) --- ## **Myth & Mechanism** Dosidicus is a digital squid born with a randomly wired brain. Feed him, stimulate neurons, watch him learn. - He starts with 8 neurons. - He grows new structure via **neurogenesis** and rewires using **Hebbian learning** - He forms memories. - He develops quirks. Every squid is different. Every save file is a cognitive history. #### Under the hood runs [**STRINg** simulation engine](https://github.com/ViciousSquid/Dosidicus/wiki/Engine-overview): * Built from scratch in NumPy * No TensorFlow. No PyTorch. No NEAT. * Fully visible neuron activations * Structural growth over time * Dual memory system * Headless training mode * Most AI is a black box: Dosidicus lets you see the mind forming - every neuron is visible, stimulatable, understandable. The squid serves as a digital pioneer in our quest to understand the mechanisms of thought and the evolution of autonomy in a synthetic world. Want the full conceptual philosophy behind Dosidicus? Read the [Cognitive Sandbox Manifesto](https://github.com/ViciousSquid/Dosidicus/wiki/Cognitive-Sandbox-Manifesto-%7C-Artificial-Life-and-Transparent-Neural-Systems) --- ## Share Your Squid No two squids are wired the same. Early interactions permanently alter their structure. Tiny differences amplify. Habits form. Fears emerge. Personalities drift. Your squid's brain is a cognitive history - shaped by you. So share it. - Export save files and let others explore your squid's neural structure. - Post screenshots of strange activation patterns and unexpected growth. - Show bizarre learned behaviors (Why is yours afraid of poop?) - Compare cognitive histories and trace how experience shaped structure. - Did yours grow 40 neurons? - Did it develop a persistent avoidance loop? - Did you accidentally create a neurotic reward spiral? Every squid is an experiment. --- ## Docker Two targets are provided: `headless` (CLI trainer) and `gui` (PyQt5 app with X11). Headless (recommended for containers): ```bash docker build -t dosidicus:headless --target headless . docker run --rm -v ${PWD}/headless_output:/app/output dosidicus:headless --ticks 10000 --output /app/output/trained_brain.json ``` GUI (Linux host with X11 or WSLg): ```bash docker build -t dosidicus:gui --target gui . docker run --rm \ -e DISPLAY=$DISPLAY \ -e QT_X11_NO_MITSHM=1 \ -v /tmp/.X11-unix:/tmp/.X11-unix:rw \ -v ${PWD}/saves:/app/saves \ -v ${PWD}/logs:/app/logs \ dosidicus:gui ``` Compose: ```bash docker compose up --build docker compose --profile gui up --build ``` WSLg note: If the GUI fails to start with a Qt platform plugin error, try: ```bash export QT_QPA_PLATFORM=wayland docker compose --profile gui up --build ``` Note: On Windows without WSLg, you will need an X server and a valid `DISPLAY` value to run the GUI container. Note: Attempting to build the Docker container on Windows ARM64 will fail because there is no pyqt5 wheel [[32]](https://github.com/ViciousSquid/Dosidicus/pull/32) - Use the prebuilt binary from [releases](https://github.com/ViciousSquid/Dosidicus/releases/tag/v2.6.2.0) instead Troubleshooting (quick): - If `DISPLAY` is empty in WSL: WSLg is not active. Use WSLg or run an X server on Windows. - If Docker errors mention `docker_engine`/pipe not found: start Docker Desktop and ensure WSL integration is enabled. - If GUI still exits with Qt plugin errors: rebuild the image (`docker compose --profile gui build --no-cache`) and retry. --- ## Project Overview - 41,636 lines, one developer, 28 months, GPL 2.0 license - **Dependencies:** - Python ^3.9 - PyQt5 ^5.15 (GUI framework) - numpy ^1.21 (neural network computations) - **OPTIONAL** onnxruntime or onnxruntime-directml ([more info](https://github.com/ViciousSquid/Dosidicus/wiki/AI-accelerator-support)) - **Core Structure:** Modular codebase in `src/` including brain designer, decision engine, learning algorithms, personality traits, memory management, UI components, and interaction systems. Entry point via `main.py`. ### Key Project Components - **Plugin System:** Extensible architecture with built-in plugins for achievements (tracking milestones) and multiplayer (networked interactions). - **Save System:** Persistent saves in `saves/` for pet states, autosaves, and achievement logs. - **Headless Mode:** Standalone training and simulation in `headless/` for GUI-less operation, ideal for background training or server environments (experimental) - **Custom Brains:** Library of pre-configured neural networks in `custom_brains/` (e.g., "Plant-Seeker", "Insomniac") for quick behavior setup. - **Memory Management:** Dual memory system (`_memory/`) with long-term and short-term storage for learning persistence. - **Examples and Tools:** Example squids, configuration files (`config.ini`), and version tracking. --- ### A year ago I got a **tattoo of this project** to celebrate its first development milestone! --- ![Visitors](https://api.visitorbadge.io/api/visitors?path=ViciousSquid&label=UNIQUE%20VISITORS&countColor=%2326313f&style=flat) ================================================ FILE: config.ini ================================================ [General] language = en [Debug] multiplayer_debug = False [Compute] backend = numpy [Display] neuron_label_font_size = 8 neuron_radius = 14 connection_line_width = 1.5 button_font_size = 16 button_width = 140 button_height = 50 button_spacing = 20 [RockInteractions] pickup_probability = 0.7 throw_probability = 0.4 min_carry_duration = 4.0 max_carry_duration = 10.0 cooldown_after_throw = 10.0 happiness_boost = 9 satisfaction_boost = 11 anxiety_reduction = 9 memory_decay_rate = 0.98 max_rock_memories = 10 [Neurogenesis] enabled = True showmanship = True pruning_enabled = True cooldown = 60.0 per_type_cooldown = 30.0 max_novelty_neurons = 5 pattern_threshold = 3 experience_buffer_size = 50 min_utility_for_keep = 0.2 max_neurons = 128 initial_neuron_count = 7 max_hebbian_pairs = 2 [Neurogenesis.Novelty] enabled = True threshold = 3.0 decay_rate = 0.85 max_counter = 10.0 min_curiosity = 0.3 adventurous_modifier = 1.2 timid_modifier = 0.8 [Neurogenesis.Stress] enabled = True threshold = 2.0 decay_rate = 0.85 max_counter = 10.0 min_anxiety = 0.4 timid_modifier = 1.5 energetic_modifier = 0.7 [Neurogenesis.Reward] enabled = True threshold = 2.5 decay_rate = 0.85 max_counter = 8.0 min_satisfaction = 0.5 boost_multiplier = 1.1 [Neurogenesis.SpecialisationCaps] novelty_object_investigation = 8 [Neurogenesis.NeuronProperties] base_activation = 0.5 position_variance = 75 default_connections = True connection_strength = 0.35 reciprocal_strength = 0.15 randomize_start_positions = True canvas_padding = 60 centering_force = 0.02 force_bounds = True [Neurogenesis.VisualEffects] highlight_duration = 5.0 highlight_radius = 40 pulse_effect = True pulse_speed = 0.5 animation_style = pattern_1 [Neurogenesis.Appearance] novelty_color = 255,255,150 stress_color = 255,150,150 reward_color = 150,255,150 novelty_shape = triangle stress_shape = square reward_shape = circle [Hebbian] learning_interval = 30 base_learning_rate = 0.12 weight_decay = 0.01 min_weight = -1.0 max_weight = 1.0 max_hebbian_pairs = 2 [LinkBlink] interval_min = 8.0 interval_max = 20.0 blink_duration = 2.0 [Animation] style = vibrant [Designer] designer_min_neuron_distance = 90 designer_max_neuron_distance = 200 [Facts] enabled = True interval_minutes = 6 display_seconds = 12 [Sleep] recovery_per_second = 28.0 happiness_boost = 0.45 satisfaction_boost = 0.35 sink_speed = 0.58 bottom_padding = 25 bob_amount = 1.2 ================================================ FILE: custom_brains/Bathtub.json ================================================ { "version": "2.0", "format": "dosidicus", "metadata": { "name": "Bathtub", "description": "", "author": "Rufus Pearce", "version": "1.0", "created": "10-12-25", "modified": "" }, "neurons": { "hunger": { "position": [ 127.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "happiness": { "position": [ 392.0, 63.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "cleanliness": { "position": [ 627.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "sleepiness": { "position": [ 840.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "satisfaction": { "position": [ 271.0, 380.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "anxiety": { "position": [ 491.0, 389.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "curiosity": { "position": [ 701.0, 386.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "can_see_food": { "position": [ 260.0, 151.0 ], "type": "sensor", "is_binary": true, "is_core": false, "is_sensor": true, "activation": 0.0 } }, "connections": { "sleepiness->anxiety": 0.125, "hunger->satisfaction": -0.344, "cleanliness->happiness": 0.296, "sleepiness->happiness": -0.355, "can_see_food->hunger": 0.25, "happiness->satisfaction": 0.164, "anxiety->curiosity": -0.375, "sleepiness->curiosity": -0.17, "satisfaction->hunger": -0.064, "curiosity->happiness": 0.299, "satisfaction->anxiety": -0.34, "can_see_food->curiosity": 0.191, "anxiety->happiness": -0.418, "hunger->happiness": -0.189, "happiness->anxiety": -0.228, "anxiety->satisfaction": -0.13, "satisfaction->happiness": 0.364, "anxiety->sleepiness": 0.096 }, "state": { "hunger": 50.0, "happiness": 50.0, "cleanliness": 50.0, "sleepiness": 50.0, "satisfaction": 50.0, "anxiety": 50.0, "curiosity": 50.0, "can_see_food": false }, "excluded_neurons": [], "neuron_positions": { "hunger": [ 127.0, 81.0 ], "happiness": [ 392.0, 63.0 ], "cleanliness": [ 627.0, 81.0 ], "sleepiness": [ 840.0, 81.0 ], "satisfaction": [ 271.0, 380.0 ], "anxiety": [ 491.0, 389.0 ], "curiosity": [ 701.0, 386.0 ], "can_see_food": [ 260.0, 151.0 ] }, "weights": { "sleepiness|anxiety": 0.125, "hunger|satisfaction": -0.344, "cleanliness|happiness": 0.296, "sleepiness|happiness": -0.355, "can_see_food|hunger": 0.25, "happiness|satisfaction": 0.164, "anxiety|curiosity": -0.375, "sleepiness|curiosity": -0.17, "satisfaction|hunger": -0.064, "curiosity|happiness": 0.299, "satisfaction|anxiety": -0.34, "can_see_food|curiosity": 0.191, "anxiety|happiness": -0.418, "hunger|happiness": -0.189, "happiness|anxiety": -0.228, "anxiety|satisfaction": -0.13, "satisfaction|happiness": 0.364, "anxiety|sleepiness": 0.096 }, "layer_structure": [], "neuron_details": { "hunger": { "name": "hunger", "neuron_type": "core", "position": [ 127.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "happiness": { "name": "happiness", "neuron_type": "core", "position": [ 392.0, 63.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "cleanliness": { "name": "cleanliness", "neuron_type": "core", "position": [ 627.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "sleepiness": { "name": "sleepiness", "neuron_type": "core", "position": [ 840.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "satisfaction": { "name": "satisfaction", "neuron_type": "core", "position": [ 271.0, 380.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "anxiety": { "name": "anxiety", "neuron_type": "core", "position": [ 491.0, 389.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "curiosity": { "name": "curiosity", "neuron_type": "core", "position": [ 701.0, 386.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "can_see_food": { "name": "can_see_food", "neuron_type": "sensor", "position": [ 260.0, 151.0 ], "layer_index": 0, "color": [ 100, 180, 100 ], "description": "Required neuron", "is_binary": true, "category": "required" } }, "sensors_used": [ "can_see_food" ], "required_complete": true } ================================================ FILE: custom_brains/Change_colour_when_see_food.json ================================================ { "version": "2.0", "format": "dosidicus", "metadata": { "name": "Change_colour_when_see_food", "description": "Green when food is visible / red when not", "author": "Rufus Pearce", "version": "1.0", "created": "", "modified": "" }, "neurons": { "can_see_food": { "position": [ 50.0, 200.0 ], "type": "sensor", "is_binary": true, "is_core": false, "is_sensor": true, "activation": 0.0 }, "hunger": { "position": [ 127.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "happiness": { "position": [ 361.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "cleanliness": { "position": [ 627.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "sleepiness": { "position": [ 840.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "satisfaction": { "position": [ 271.0, 380.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "anxiety": { "position": [ 491.0, 389.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "curiosity": { "position": [ 701.0, 386.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "novelty_object_investigation": { "position": [ 515.2635423006693, 185.04889429904628 ], "type": "hidden", "is_binary": false, "is_core": false, "is_sensor": false, "activation": 0.0 } }, "connections": { "cleanliness->happiness": 0.141, "satisfaction->happiness": 0.37, "can_see_food->satisfaction": 0.24, "can_see_food->hunger": 0.29, "curiosity->satisfaction": 0.212, "satisfaction->curiosity": 0.36463690000000004, "novelty_object_investigation->hunger": -0.7280000000000001, "novelty_object_investigation->happiness": 0.5152000000000035, "novelty_object_investigation->satisfaction": 0.8, "novelty_object_investigation->curiosity": 0.7, "novelty_object_investigation->anxiety": -0.4, "hunger->novelty_object_investigation": -0.7280000000000001, "happiness->novelty_object_investigation": 0.5152000000000035, "satisfaction->novelty_object_investigation": 0.98308, "curiosity->novelty_object_investigation": 0.7, "anxiety->novelty_object_investigation": -0.4, "can_see_food->can_see_food": -0.8, "novelty_object_investigation->novelty_object_investigation": -0.8 }, "state": { "can_see_food": false, "hunger": 50.0, "happiness": 50.0, "cleanliness": 50.0, "sleepiness": 50.0, "satisfaction": 50.0, "anxiety": 50.0, "curiosity": 50.0, "novelty_object_investigation": 0.0 }, "neuron_shapes": { "can_see_food": "square", "hunger": "circle", "happiness": "circle", "cleanliness": "circle", "sleepiness": "circle", "satisfaction": "circle", "anxiety": "circle", "curiosity": "circle", "novelty_object_investigation": "diamond" }, "excluded_neurons": [], "output_bindings": [ { "neuron_name": "can_see_food", "output_hook": "neuron_output_change_color", "threshold": 90.0, "trigger_mode": "above", "cooldown": 1.0, "enabled": true, "hook_params": { "red": 170, "green": 255, "blue": 127 } }, { "neuron_name": "can_see_food", "output_hook": "neuron_output_change_color", "threshold": 40.0, "trigger_mode": "below", "cooldown": 1.0, "enabled": true, "hook_params": { "red": 255, "green": 255, "blue": 255 } } ], "neuron_positions": { "can_see_food": [ 50.0, 200.0 ], "hunger": [ 127.0, 81.0 ], "happiness": [ 361.0, 81.0 ], "cleanliness": [ 627.0, 81.0 ], "sleepiness": [ 840.0, 81.0 ], "satisfaction": [ 271.0, 380.0 ], "anxiety": [ 491.0, 389.0 ], "curiosity": [ 701.0, 386.0 ], "novelty_object_investigation": [ 515.2635423006693, 185.04889429904628 ] }, "weights": { "cleanliness|happiness": 0.141, "satisfaction|happiness": 0.37, "can_see_food|satisfaction": 0.24, "can_see_food|hunger": 0.29, "curiosity|satisfaction": 0.212, "satisfaction|curiosity": 0.36463690000000004, "novelty_object_investigation|hunger": -0.7280000000000001, "novelty_object_investigation|happiness": 0.5152000000000035, "novelty_object_investigation|satisfaction": 0.8, "novelty_object_investigation|curiosity": 0.7, "novelty_object_investigation|anxiety": -0.4, "hunger|novelty_object_investigation": -0.7280000000000001, "happiness|novelty_object_investigation": 0.5152000000000035, "satisfaction|novelty_object_investigation": 0.98308, "curiosity|novelty_object_investigation": 0.7, "anxiety|novelty_object_investigation": -0.4, "can_see_food|can_see_food": -0.8, "novelty_object_investigation|novelty_object_investigation": -0.8 }, "layer_structure": [], "neuron_details": { "can_see_food": { "name": "can_see_food", "neuron_type": "sensor", "position": [ 50.0, 200.0 ], "layer_index": 0, "color": [ 100, 180, 100 ], "description": "", "is_binary": true, "category": "required", "shape": "square" }, "hunger": { "name": "hunger", "neuron_type": "core", "position": [ 127.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "", "is_binary": false, "category": "core", "shape": "circle" }, "happiness": { "name": "happiness", "neuron_type": "core", "position": [ 361.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "", "is_binary": false, "category": "core", "shape": "circle" }, "cleanliness": { "name": "cleanliness", "neuron_type": "core", "position": [ 627.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "", "is_binary": false, "category": "core", "shape": "circle" }, "sleepiness": { "name": "sleepiness", "neuron_type": "core", "position": [ 840.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "", "is_binary": false, "category": "core", "shape": "circle" }, "satisfaction": { "name": "satisfaction", "neuron_type": "core", "position": [ 271.0, 380.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "", "is_binary": false, "category": "core", "shape": "circle" }, "anxiety": { "name": "anxiety", "neuron_type": "core", "position": [ 491.0, 389.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "", "is_binary": false, "category": "core", "shape": "circle" }, "curiosity": { "name": "curiosity", "neuron_type": "core", "position": [ 701.0, 386.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "", "is_binary": false, "category": "core", "shape": "circle" }, "novelty_object_investigation": { "name": "novelty_object_investigation", "neuron_type": "hidden", "position": [ 515.2635423006693, 185.04889429904628 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "", "is_binary": false, "category": "custom", "shape": "diamond" } }, "sensors_used": [ "can_see_food" ], "required_complete": true } ================================================ FILE: custom_brains/Dense_connections.json ================================================ { "version": "2.0", "format": "dosidicus", "metadata": { "name": "Dense connections", "description": "Many many young connections", "author": "Rufus Pearce", "version": "1.0", "created": "22-12-25", "modified": "" }, "neurons": { "hunger": { "position": [ 95.0, 46.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "happiness": { "position": [ 361.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "cleanliness": { "position": [ 627.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "sleepiness": { "position": [ 840.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "satisfaction": { "position": [ 271.0, 380.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "anxiety": { "position": [ 491.0, 389.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "curiosity": { "position": [ 701.0, 386.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "can_see_food": { "position": [ 50.0, 200.0 ], "type": "sensor", "is_binary": true, "is_core": false, "is_sensor": true, "is_custom": false, "activation": 0.0 } }, "connections": { "happiness->anxiety": -0.2, "can_see_food->curiosity": 0.27, "sleepiness->curiosity": -0.309, "can_see_food->anxiety": -0.137, "hunger->can_see_food": -0.035, "satisfaction->anxiety": -0.216, "happiness->satisfaction": 0.32, "cleanliness->satisfaction": 0.035, "hunger->anxiety": 0.333, "hunger->curiosity": 0.204, "happiness->curiosity": 0.227, "happiness->can_see_food": -0.297, "cleanliness->anxiety": -0.141, "satisfaction->curiosity": 0.187, "cleanliness->happiness": 0.391, "anxiety->can_see_food": 0.206, "anxiety->happiness": -0.229, "satisfaction->cleanliness": -0.207, "curiosity->hunger": -0.142, "curiosity->happiness": 0.082, "sleepiness->anxiety": -0.025, "can_see_food->hunger": 0.347, "curiosity->sleepiness": 0.084, "anxiety->curiosity": -0.309, "anxiety->satisfaction": 0.257, "happiness->cleanliness": -0.187, "satisfaction->happiness": -0.313, "curiosity->satisfaction": -0.173, "hunger->satisfaction": -0.42, "anxiety->sleepiness": 0.197, "anxiety->hunger": -0.239, "happiness->sleepiness": -0.025, "hunger->happiness": -0.131, "curiosity->can_see_food": -0.256, "sleepiness->satisfaction": -0.229, "anxiety->cleanliness": 0.156, "satisfaction->hunger": 0.046, "sleepiness->happiness": -0.246, "can_see_food->happiness": 0.232, "curiosity->anxiety": -0.037, "satisfaction->sleepiness": 0.066 }, "state": { "hunger": 50.0, "happiness": 50.0, "cleanliness": 50.0, "sleepiness": 50.0, "satisfaction": 50.0, "anxiety": 50.0, "curiosity": 50.0, "can_see_food": false }, "neuron_shapes": { "hunger": "circle", "happiness": "circle", "cleanliness": "circle", "sleepiness": "circle", "satisfaction": "circle", "anxiety": "circle", "curiosity": "circle", "can_see_food": "square" }, "excluded_neurons": [], "output_bindings": [], "neuron_positions": { "hunger": [ 95.0, 46.0 ], "happiness": [ 361.0, 81.0 ], "cleanliness": [ 627.0, 81.0 ], "sleepiness": [ 840.0, 81.0 ], "satisfaction": [ 271.0, 380.0 ], "anxiety": [ 491.0, 389.0 ], "curiosity": [ 701.0, 386.0 ], "can_see_food": [ 50.0, 200.0 ] }, "weights": { "happiness|anxiety": -0.2, "can_see_food|curiosity": 0.27, "sleepiness|curiosity": -0.309, "can_see_food|anxiety": -0.137, "hunger|can_see_food": -0.035, "satisfaction|anxiety": -0.216, "happiness|satisfaction": 0.32, "cleanliness|satisfaction": 0.035, "hunger|anxiety": 0.333, "hunger|curiosity": 0.204, "happiness|curiosity": 0.227, "happiness|can_see_food": -0.297, "cleanliness|anxiety": -0.141, "satisfaction|curiosity": 0.187, "cleanliness|happiness": 0.391, "anxiety|can_see_food": 0.206, "anxiety|happiness": -0.229, "satisfaction|cleanliness": -0.207, "curiosity|hunger": -0.142, "curiosity|happiness": 0.082, "sleepiness|anxiety": -0.025, "can_see_food|hunger": 0.347, "curiosity|sleepiness": 0.084, "anxiety|curiosity": -0.309, "anxiety|satisfaction": 0.257, "happiness|cleanliness": -0.187, "satisfaction|happiness": -0.313, "curiosity|satisfaction": -0.173, "hunger|satisfaction": -0.42, "anxiety|sleepiness": 0.197, "anxiety|hunger": -0.239, "happiness|sleepiness": -0.025, "hunger|happiness": -0.131, "curiosity|can_see_food": -0.256, "sleepiness|satisfaction": -0.229, "anxiety|cleanliness": 0.156, "satisfaction|hunger": 0.046, "sleepiness|happiness": -0.246, "can_see_food|happiness": 0.232, "curiosity|anxiety": -0.037, "satisfaction|sleepiness": 0.066 }, "layer_structure": [], "neuron_details": { "hunger": { "name": "hunger", "neuron_type": "core", "position": [ 95.0, 46.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false, "bias": 0.0 }, "happiness": { "name": "happiness", "neuron_type": "core", "position": [ 361.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false, "bias": 0.0 }, "cleanliness": { "name": "cleanliness", "neuron_type": "core", "position": [ 627.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false, "bias": 0.0 }, "sleepiness": { "name": "sleepiness", "neuron_type": "core", "position": [ 840.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false, "bias": 0.0 }, "satisfaction": { "name": "satisfaction", "neuron_type": "core", "position": [ 271.0, 380.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false, "bias": 0.0 }, "anxiety": { "name": "anxiety", "neuron_type": "core", "position": [ 491.0, 389.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false, "bias": 0.0 }, "curiosity": { "name": "curiosity", "neuron_type": "core", "position": [ 701.0, 386.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false, "bias": 0.0 }, "can_see_food": { "name": "can_see_food", "neuron_type": "sensor", "position": [ 50.0, 200.0 ], "layer_index": 0, "color": [ 100, 180, 100 ], "description": "Required neuron", "is_binary": true, "category": "required", "shape": "square", "is_custom": false, "bias": 0.0 } }, "sensors_used": [ "can_see_food" ], "custom_neurons": [], "required_complete": true } ================================================ FILE: custom_brains/Feed-Forward-Hidden-Layer.json ================================================ { "version": "2.0", "format": "dosidicus", "metadata": { "name": "Feed-Forward-Hidden-Layer", "description": "Hidden layer of 4 neurons, improves learning", "author": "Rufus Pearce", "version": "1.1", "created": "18-12-25", "modified": "22-12-25" }, "neurons": { "hunger": { "position": [ 40.0, 50 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "happiness": { "position": [ 220.0, 50 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "cleanliness": { "position": [ 400.0, 50 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "sleepiness": { "position": [ 580.0, 50 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "satisfaction": { "position": [ 220.0, 350 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "anxiety": { "position": [ 400.0, 350 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "curiosity": { "position": [ 580.0, 350 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "can_see_food": { "position": [ 760.0, 50 ], "type": "sensor", "is_binary": true, "is_core": false, "is_sensor": true, "is_custom": false, "activation": 0.0 }, "hidden0_0": { "position": [ 130.0, 200.0 ], "type": "custom", "is_binary": false, "is_core": false, "is_sensor": false, "is_custom": true, "activation": 0.0 }, "hidden0_1": { "position": [ 310.0, 200.0 ], "type": "custom", "is_binary": false, "is_core": false, "is_sensor": false, "is_custom": true, "activation": 0.0 }, "hidden0_2": { "position": [ 490.0, 200.0 ], "type": "custom", "is_binary": false, "is_core": false, "is_sensor": false, "is_custom": true, "activation": 0.0 }, "hidden0_3": { "position": [ 670.0, 200.0 ], "type": "custom", "is_binary": false, "is_core": false, "is_sensor": false, "is_custom": true, "activation": 0.0 } }, "connections": { "hunger->hidden0_0": 0.15527318685678937, "hunger->hidden0_1": -0.32662200356313464, "hunger->hidden0_2": 0.3690305534442575, "hunger->hidden0_3": 0.08771704005552605, "happiness->hidden0_0": 0.43563670388631415, "happiness->hidden0_1": 0.3747222471549809, "happiness->hidden0_2": 0.21675935283830716, "happiness->hidden0_3": 0.30553842478041715, "cleanliness->hidden0_0": -0.45817893703227386, "cleanliness->hidden0_1": -0.3924017649498124, "cleanliness->hidden0_2": 0.34017744816296347, "cleanliness->hidden0_3": 0.22903537539505892, "sleepiness->hidden0_0": 0.42053087299722425, "sleepiness->hidden0_1": -0.11093503092117496, "sleepiness->hidden0_2": 0.0022831860748842026, "sleepiness->hidden0_3": -0.46929846779909523, "can_see_food->hidden0_0": 0.49996147166880156, "can_see_food->hidden0_1": 0.12164356013940125, "can_see_food->hidden0_2": 0.3932867930701862, "can_see_food->hidden0_3": -0.2591350891409684, "hidden0_0->satisfaction": 0.3553682697768865, "hidden0_0->anxiety": 0.4303336060210178, "hidden0_0->curiosity": -0.10878219621490581, "hidden0_1->satisfaction": -0.17268262288844005, "hidden0_1->anxiety": -0.21608691390489854, "hidden0_1->curiosity": -0.42810633830239697, "hidden0_2->satisfaction": -0.30226978830452644, "hidden0_2->anxiety": 0.2120823591662554, "hidden0_2->curiosity": -0.3888682945493871, "hidden0_3->satisfaction": -0.4596086232970492, "hidden0_3->anxiety": -0.3153435843511939, "hidden0_3->curiosity": 0.14797491080390412 }, "state": { "hunger": 50.0, "happiness": 50.0, "cleanliness": 50.0, "sleepiness": 50.0, "satisfaction": 50.0, "anxiety": 50.0, "curiosity": 50.0, "can_see_food": false, "hidden0_0": 0.0, "hidden0_1": 0.0, "hidden0_2": 0.0, "hidden0_3": 0.0 }, "neuron_shapes": { "hunger": "circle", "happiness": "circle", "cleanliness": "circle", "sleepiness": "circle", "satisfaction": "circle", "anxiety": "circle", "curiosity": "circle", "can_see_food": "square", "hidden0_0": "pentagon", "hidden0_1": "pentagon", "hidden0_2": "pentagon", "hidden0_3": "pentagon" }, "excluded_neurons": [], "output_bindings": [], "neuron_positions": { "hunger": [ 40.0, 50 ], "happiness": [ 220.0, 50 ], "cleanliness": [ 400.0, 50 ], "sleepiness": [ 580.0, 50 ], "satisfaction": [ 220.0, 350 ], "anxiety": [ 400.0, 350 ], "curiosity": [ 580.0, 350 ], "can_see_food": [ 760.0, 50 ], "hidden0_0": [ 130.0, 200.0 ], "hidden0_1": [ 310.0, 200.0 ], "hidden0_2": [ 490.0, 200.0 ], "hidden0_3": [ 670.0, 200.0 ] }, "weights": { "hunger|hidden0_0": 0.15527318685678937, "hunger|hidden0_1": -0.32662200356313464, "hunger|hidden0_2": 0.3690305534442575, "hunger|hidden0_3": 0.08771704005552605, "happiness|hidden0_0": 0.43563670388631415, "happiness|hidden0_1": 0.3747222471549809, "happiness|hidden0_2": 0.21675935283830716, "happiness|hidden0_3": 0.30553842478041715, "cleanliness|hidden0_0": -0.45817893703227386, "cleanliness|hidden0_1": -0.3924017649498124, "cleanliness|hidden0_2": 0.34017744816296347, "cleanliness|hidden0_3": 0.22903537539505892, "sleepiness|hidden0_0": 0.42053087299722425, "sleepiness|hidden0_1": -0.11093503092117496, "sleepiness|hidden0_2": 0.0022831860748842026, "sleepiness|hidden0_3": -0.46929846779909523, "can_see_food|hidden0_0": 0.49996147166880156, "can_see_food|hidden0_1": 0.12164356013940125, "can_see_food|hidden0_2": 0.3932867930701862, "can_see_food|hidden0_3": -0.2591350891409684, "hidden0_0|satisfaction": 0.3553682697768865, "hidden0_0|anxiety": 0.4303336060210178, "hidden0_0|curiosity": -0.10878219621490581, "hidden0_1|satisfaction": -0.17268262288844005, "hidden0_1|anxiety": -0.21608691390489854, "hidden0_1|curiosity": -0.42810633830239697, "hidden0_2|satisfaction": -0.30226978830452644, "hidden0_2|anxiety": 0.2120823591662554, "hidden0_2|curiosity": -0.3888682945493871, "hidden0_3|satisfaction": -0.4596086232970492, "hidden0_3|anxiety": -0.3153435843511939, "hidden0_3|curiosity": 0.14797491080390412 }, "layer_structure": [], "neuron_details": { "hunger": { "name": "hunger", "neuron_type": "core", "position": [ 40.0, 50 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false }, "happiness": { "name": "happiness", "neuron_type": "core", "position": [ 220.0, 50 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false }, "cleanliness": { "name": "cleanliness", "neuron_type": "core", "position": [ 400.0, 50 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false }, "sleepiness": { "name": "sleepiness", "neuron_type": "core", "position": [ 580.0, 50 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false }, "satisfaction": { "name": "satisfaction", "neuron_type": "core", "position": [ 220.0, 350 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false }, "anxiety": { "name": "anxiety", "neuron_type": "core", "position": [ 400.0, 350 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false }, "curiosity": { "name": "curiosity", "neuron_type": "core", "position": [ 580.0, 350 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false }, "can_see_food": { "name": "can_see_food", "neuron_type": "sensor", "position": [ 760.0, 50 ], "layer_index": 0, "color": [ 100, 180, 100 ], "description": "Required neuron", "is_binary": true, "category": "required", "shape": "square", "is_custom": false }, "hidden0_0": { "name": "hidden0_0", "neuron_type": "custom", "position": [ 130.0, 200.0 ], "layer_index": 0, "color": [ 147, 112, 219 ], "description": "", "is_binary": false, "category": "custom", "shape": "pentagon", "is_custom": true }, "hidden0_1": { "name": "hidden0_1", "neuron_type": "custom", "position": [ 310.0, 200.0 ], "layer_index": 0, "color": [ 147, 112, 219 ], "description": "", "is_binary": false, "category": "custom", "shape": "pentagon", "is_custom": true }, "hidden0_2": { "name": "hidden0_2", "neuron_type": "custom", "position": [ 490.0, 200.0 ], "layer_index": 0, "color": [ 147, 112, 219 ], "description": "", "is_binary": false, "category": "custom", "shape": "pentagon", "is_custom": true }, "hidden0_3": { "name": "hidden0_3", "neuron_type": "custom", "position": [ 670.0, 200.0 ], "layer_index": 0, "color": [ 147, 112, 219 ], "description": "", "is_binary": false, "category": "custom", "shape": "pentagon", "is_custom": true } }, "sensors_used": [ "can_see_food" ], "custom_neurons": [ "hidden0_0", "hidden0_1", "hidden0_2", "hidden0_3" ], "required_complete": true } ================================================ FILE: custom_brains/Feeling-Blue.json ================================================ { "version": "2.0", "format": "dosidicus", "metadata": { "name": "Feeling-Blue", "description": "Turn actually blue when depressed", "author": "Rufus Pearce", "version": "1.0", "created": "16-12-25", "modified": "22-12-25" }, "neurons": { "can_see_food": { "position": [ 378.0, 456.0 ], "type": "sensor", "is_binary": true, "is_core": false, "is_sensor": true, "activation": 0.0 }, "hunger": { "position": [ 200.0, 64.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "happiness": { "position": [ 370.0, 224.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "cleanliness": { "position": [ 452.0, 68.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "sleepiness": { "position": [ 739.0, -6.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "satisfaction": { "position": [ 177.0, 269.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "anxiety": { "position": [ 746.0, 134.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "curiosity": { "position": [ 726.0, 405.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "connector_rescue": { "position": [ 601.96, 241.90666666666667 ], "type": "connector", "is_binary": false, "is_core": false, "is_sensor": false, "activation": 0.0 } }, "connections": { "can_see_food->hunger": 0.2, "can_see_food->happiness": 0.5, "can_see_food->satisfaction": -0.017445686087134815, "can_see_food->curiosity": 0.06631607984264454, "hunger->cleanliness": -0.43801581951295265, "hunger->sleepiness": -0.5012994136772835, "happiness->sleepiness": 0.566154703734282, "cleanliness->sleepiness": 0.8100292263454689, "satisfaction->curiosity": 0.00986623355092231, "anxiety->connector_rescue": 0.5723832132411578, "curiosity->connector_rescue": -0.4078549395278624, "connector_rescue->hunger": -0.42918421260854756, "cleanliness->anxiety": -0.05 }, "state": { "can_see_food": false, "hunger": 50.0, "happiness": 50.0, "cleanliness": 50.0, "sleepiness": 50.0, "satisfaction": 50.0, "anxiety": 50.0, "curiosity": 50.0, "connector_rescue": 0.0 }, "neuron_shapes": { "can_see_food": "square", "hunger": "circle", "happiness": "circle", "cleanliness": "circle", "sleepiness": "circle", "satisfaction": "circle", "anxiety": "circle", "curiosity": "circle", "connector_rescue": "hexagon" }, "excluded_neurons": [], "output_bindings": [ { "neuron_name": "happiness", "output_hook": "neuron_output_change_color", "threshold": 30.0, "trigger_mode": "falling", "cooldown": 1.0, "enabled": true, "hook_params": { "red": 129, "green": 228, "blue": 255 } } ], "neuron_positions": { "can_see_food": [ 378.0, 456.0 ], "hunger": [ 200.0, 64.0 ], "happiness": [ 370.0, 224.0 ], "cleanliness": [ 452.0, 68.0 ], "sleepiness": [ 739.0, -6.0 ], "satisfaction": [ 177.0, 269.0 ], "anxiety": [ 746.0, 134.0 ], "curiosity": [ 726.0, 405.0 ], "connector_rescue": [ 601.96, 241.90666666666667 ] }, "weights": { "can_see_food|hunger": 0.2, "can_see_food|happiness": 0.5, "can_see_food|satisfaction": -0.017445686087134815, "can_see_food|curiosity": 0.06631607984264454, "hunger|cleanliness": -0.43801581951295265, "hunger|sleepiness": -0.5012994136772835, "happiness|sleepiness": 0.566154703734282, "cleanliness|sleepiness": 0.8100292263454689, "satisfaction|curiosity": 0.00986623355092231, "anxiety|connector_rescue": 0.5723832132411578, "curiosity|connector_rescue": -0.4078549395278624, "connector_rescue|hunger": -0.42918421260854756, "cleanliness|anxiety": -0.05 }, "layer_structure": [], "neuron_details": { "can_see_food": { "name": "can_see_food", "neuron_type": "sensor", "position": [ 378.0, 456.0 ], "layer_index": 0, "color": [ 100, 180, 100 ], "description": "", "is_binary": true, "category": "required", "shape": "square" }, "hunger": { "name": "hunger", "neuron_type": "core", "position": [ 200.0, 64.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "", "is_binary": false, "category": "core", "shape": "circle" }, "happiness": { "name": "happiness", "neuron_type": "core", "position": [ 370.0, 224.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "", "is_binary": false, "category": "core", "shape": "circle" }, "cleanliness": { "name": "cleanliness", "neuron_type": "core", "position": [ 452.0, 68.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "", "is_binary": false, "category": "core", "shape": "circle" }, "sleepiness": { "name": "sleepiness", "neuron_type": "core", "position": [ 739.0, -6.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "", "is_binary": false, "category": "core", "shape": "circle" }, "satisfaction": { "name": "satisfaction", "neuron_type": "core", "position": [ 177.0, 269.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "", "is_binary": false, "category": "core", "shape": "circle" }, "anxiety": { "name": "anxiety", "neuron_type": "core", "position": [ 746.0, 134.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "", "is_binary": false, "category": "core", "shape": "circle" }, "curiosity": { "name": "curiosity", "neuron_type": "core", "position": [ 726.0, 405.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "", "is_binary": false, "category": "core", "shape": "circle" }, "connector_rescue": { "name": "connector_rescue", "neuron_type": "connector", "position": [ 601.96, 241.90666666666667 ], "layer_index": 0, "color": [ 0, 0, 0 ], "description": "", "is_binary": false, "category": "custom", "shape": "hexagon" } }, "sensors_used": [ "can_see_food" ], "required_complete": true } ================================================ FILE: custom_brains/Grasshopper.json ================================================ { "version": "2.0", "format": "dosidicus", "metadata": { "name": "Grasshopper", "description": "", "author": "Rufus Pearce", "version": "1.0", "created": "10-12-25", "modified": "" }, "neurons": { "hunger": { "position": [ 127.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "happiness": { "position": [ 361.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "cleanliness": { "position": [ 627.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "sleepiness": { "position": [ 840.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "satisfaction": { "position": [ 271.0, 380.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "anxiety": { "position": [ 491.0, 389.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "curiosity": { "position": [ 701.0, 386.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "can_see_food": { "position": [ 50.0, 200.0 ], "type": "sensor", "is_binary": true, "is_core": false, "is_sensor": true, "activation": 0.0 } }, "connections": { "hunger->anxiety": 0.142, "satisfaction->anxiety": -0.295, "hunger->curiosity": -0.026, "happiness->satisfaction": 0.087, "sleepiness->satisfaction": -0.158, "happiness->curiosity": 0.198, "sleepiness->anxiety": 0.163, "anxiety->curiosity": -0.393, "sleepiness->curiosity": -0.358, "can_see_food->hunger": 0.354, "curiosity->satisfaction": 0.098, "can_see_food->happiness": 0.301, "hunger->satisfaction": -0.191, "hunger->happiness": -0.296, "satisfaction->happiness": 0.185, "cleanliness->anxiety": -0.151, "anxiety->happiness": -0.202 }, "state": { "hunger": 50.0, "happiness": 50.0, "cleanliness": 50.0, "sleepiness": 50.0, "satisfaction": 50.0, "anxiety": 50.0, "curiosity": 50.0, "can_see_food": false }, "excluded_neurons": [], "neuron_positions": { "hunger": [ 127.0, 81.0 ], "happiness": [ 361.0, 81.0 ], "cleanliness": [ 627.0, 81.0 ], "sleepiness": [ 840.0, 81.0 ], "satisfaction": [ 271.0, 380.0 ], "anxiety": [ 491.0, 389.0 ], "curiosity": [ 701.0, 386.0 ], "can_see_food": [ 50.0, 200.0 ] }, "weights": { "hunger|anxiety": 0.142, "satisfaction|anxiety": -0.295, "hunger|curiosity": -0.026, "happiness|satisfaction": 0.087, "sleepiness|satisfaction": -0.158, "happiness|curiosity": 0.198, "sleepiness|anxiety": 0.163, "anxiety|curiosity": -0.393, "sleepiness|curiosity": -0.358, "can_see_food|hunger": 0.354, "curiosity|satisfaction": 0.098, "can_see_food|happiness": 0.301, "hunger|satisfaction": -0.191, "hunger|happiness": -0.296, "satisfaction|happiness": 0.185, "cleanliness|anxiety": -0.151, "anxiety|happiness": -0.202 }, "layer_structure": [], "neuron_details": { "hunger": { "name": "hunger", "neuron_type": "core", "position": [ 127.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "happiness": { "name": "happiness", "neuron_type": "core", "position": [ 361.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "cleanliness": { "name": "cleanliness", "neuron_type": "core", "position": [ 627.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "sleepiness": { "name": "sleepiness", "neuron_type": "core", "position": [ 840.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "satisfaction": { "name": "satisfaction", "neuron_type": "core", "position": [ 271.0, 380.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "anxiety": { "name": "anxiety", "neuron_type": "core", "position": [ 491.0, 389.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "curiosity": { "name": "curiosity", "neuron_type": "core", "position": [ 701.0, 386.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "can_see_food": { "name": "can_see_food", "neuron_type": "sensor", "position": [ 50.0, 200.0 ], "layer_index": 0, "color": [ 100, 180, 100 ], "description": "Required neuron", "is_binary": true, "category": "required" } }, "sensors_used": [ "can_see_food" ], "required_complete": true } ================================================ FILE: custom_brains/Healthy_Interests.json ================================================ { "version": "2.0", "format": "dosidicus", "metadata": { "name": "Healthy interests", "description": "Nice strong reciprocal basic layout", "author": "Rufus Pearce", "version": "1.0", "created": "22-12-25", "modified": "" }, "neurons": { "hunger": { "position": [ 95.0, 46.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "happiness": { "position": [ 361.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "cleanliness": { "position": [ 627.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "sleepiness": { "position": [ 840.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "satisfaction": { "position": [ 271.0, 380.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "anxiety": { "position": [ 491.0, 389.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "curiosity": { "position": [ 701.0, 386.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "is_custom": false, "activation": 50.0 }, "can_see_food": { "position": [ 50.0, 200.0 ], "type": "sensor", "is_binary": true, "is_core": false, "is_sensor": true, "is_custom": false, "activation": 0.0 } }, "connections": { "sleepiness->anxiety": 0.212, "satisfaction->anxiety": -0.302, "can_see_food->hunger": 0.443, "anxiety->sleepiness": -1.0, "satisfaction->happiness": 0.7000000000000001, "anxiety->satisfaction": -0.183, "hunger->happiness": -0.181, "can_see_food->happiness": 0.359 }, "state": { "hunger": 50.0, "happiness": 50.0, "cleanliness": 50.0, "sleepiness": 50.0, "satisfaction": 50.0, "anxiety": 50.0, "curiosity": 50.0, "can_see_food": false }, "neuron_shapes": { "hunger": "circle", "happiness": "circle", "cleanliness": "circle", "sleepiness": "circle", "satisfaction": "circle", "anxiety": "circle", "curiosity": "circle", "can_see_food": "square" }, "excluded_neurons": [], "output_bindings": [], "neuron_positions": { "hunger": [ 95.0, 46.0 ], "happiness": [ 361.0, 81.0 ], "cleanliness": [ 627.0, 81.0 ], "sleepiness": [ 840.0, 81.0 ], "satisfaction": [ 271.0, 380.0 ], "anxiety": [ 491.0, 389.0 ], "curiosity": [ 701.0, 386.0 ], "can_see_food": [ 50.0, 200.0 ] }, "weights": { "sleepiness|anxiety": 0.212, "satisfaction|anxiety": -0.302, "can_see_food|hunger": 0.443, "anxiety|sleepiness": -1.0, "satisfaction|happiness": 0.7000000000000001, "anxiety|satisfaction": -0.183, "hunger|happiness": -0.181, "can_see_food|happiness": 0.359 }, "layer_structure": [], "neuron_details": { "hunger": { "name": "hunger", "neuron_type": "core", "position": [ 95.0, 46.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false, "bias": 0.0 }, "happiness": { "name": "happiness", "neuron_type": "core", "position": [ 361.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false, "bias": 0.0 }, "cleanliness": { "name": "cleanliness", "neuron_type": "core", "position": [ 627.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false, "bias": 0.0 }, "sleepiness": { "name": "sleepiness", "neuron_type": "core", "position": [ 840.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false, "bias": 0.0 }, "satisfaction": { "name": "satisfaction", "neuron_type": "core", "position": [ 271.0, 380.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false, "bias": 0.0 }, "anxiety": { "name": "anxiety", "neuron_type": "core", "position": [ 491.0, 389.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false, "bias": 0.0 }, "curiosity": { "name": "curiosity", "neuron_type": "core", "position": [ 701.0, 386.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core", "shape": "circle", "is_custom": false, "bias": 0.0 }, "can_see_food": { "name": "can_see_food", "neuron_type": "sensor", "position": [ 50.0, 200.0 ], "layer_index": 0, "color": [ 100, 180, 100 ], "description": "Required neuron", "is_binary": true, "category": "required", "shape": "square", "is_custom": false, "bias": 0.0 } }, "sensors_used": [ "can_see_food" ], "custom_neurons": [], "required_complete": true } ================================================ FILE: custom_brains/L'insomniaque.json ================================================ { "version": "2.0", "format": "dosidicus", "metadata": { "name": "L'insomniaque", "description": "", "author": "Rufus Pearce", "version": "1.0", "created": "10-12-25", "modified": "" }, "neurons": { "hunger": { "position": [ 127.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "happiness": { "position": [ 361.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "cleanliness": { "position": [ 627.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "sleepiness": { "position": [ 840.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "satisfaction": { "position": [ 271.0, 380.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "anxiety": { "position": [ 491.0, 389.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "curiosity": { "position": [ 701.0, 386.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "can_see_food": { "position": [ 50.0, 200.0 ], "type": "sensor", "is_binary": true, "is_core": false, "is_sensor": true, "activation": 0.0 } }, "connections": { "anxiety->sleepiness": -0.95, "curiosity->sleepiness": -0.8, "hunger->sleepiness": -0.5, "can_see_food->sleepiness": -0.5, "sleepiness->anxiety": 0.2, "happiness->satisfaction": 0.10000000000000007, "satisfaction->happiness": 0.10000000000000007 }, "state": { "hunger": 50.0, "happiness": 50.0, "cleanliness": 50.0, "sleepiness": 50.0, "satisfaction": 50.0, "anxiety": 50.0, "curiosity": 50.0, "can_see_food": false }, "excluded_neurons": [], "neuron_positions": { "hunger": [ 127.0, 81.0 ], "happiness": [ 361.0, 81.0 ], "cleanliness": [ 627.0, 81.0 ], "sleepiness": [ 840.0, 81.0 ], "satisfaction": [ 271.0, 380.0 ], "anxiety": [ 491.0, 389.0 ], "curiosity": [ 701.0, 386.0 ], "can_see_food": [ 50.0, 200.0 ] }, "weights": { "anxiety|sleepiness": -0.95, "curiosity|sleepiness": -0.8, "hunger|sleepiness": -0.5, "can_see_food|sleepiness": -0.5, "sleepiness|anxiety": 0.2, "happiness|satisfaction": 0.10000000000000007, "satisfaction|happiness": 0.10000000000000007 }, "layer_structure": [ { "name": "Sensors", "layer_type": "input", "y_position": 150, "color": [ 200, 200, 220, 80 ] }, { "name": "Racing Mind", "layer_type": "hidden", "y_position": 200, "color": [ 200, 200, 220, 80 ] }, { "name": "State", "layer_type": "output", "y_position": 350, "color": [ 200, 200, 220, 80 ] } ], "neuron_details": { "hunger": { "name": "hunger", "neuron_type": "core", "position": [ 127.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "happiness": { "name": "happiness", "neuron_type": "core", "position": [ 361.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "cleanliness": { "name": "cleanliness", "neuron_type": "core", "position": [ 627.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "sleepiness": { "name": "sleepiness", "neuron_type": "core", "position": [ 840.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "satisfaction": { "name": "satisfaction", "neuron_type": "core", "position": [ 271.0, 380.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "anxiety": { "name": "anxiety", "neuron_type": "core", "position": [ 491.0, 389.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "curiosity": { "name": "curiosity", "neuron_type": "core", "position": [ 701.0, 386.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "can_see_food": { "name": "can_see_food", "neuron_type": "sensor", "position": [ 50.0, 200.0 ], "layer_index": 0, "color": [ 100, 180, 100 ], "description": "Required neuron", "is_binary": true, "category": "required" } }, "sensors_used": [ "can_see_food" ], "required_complete": true } ================================================ FILE: custom_brains/Minimal.json ================================================ { "version": "2.0", "format": "dosidicus", "metadata": { "name": "Minimal", "description": "", "author": "Rufus Pearce", "version": "1.0", "created": "10-12-25", "modified": "" }, "neurons": { "hunger": { "position": [ 127.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "happiness": { "position": [ 392.0, 63.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "cleanliness": { "position": [ 627.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "sleepiness": { "position": [ 840.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "satisfaction": { "position": [ 271.0, 380.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "anxiety": { "position": [ 491.0, 389.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "curiosity": { "position": [ 701.0, 386.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "can_see_food": { "position": [ 260.0, 151.0 ], "type": "sensor", "is_binary": true, "is_core": false, "is_sensor": true, "activation": 0.0 } }, "connections": { "can_see_food->satisfaction": 0.13, "sleepiness->anxiety": 0.184, "sleepiness->happiness": -0.063, "satisfaction->happiness": 0.288, "hunger->happiness": -0.151, "cleanliness->happiness": 0.404, "happiness->satisfaction": 0.307, "satisfaction->curiosity": 0.202, "anxiety->curiosity": -0.305 }, "state": { "hunger": 50.0, "happiness": 50.0, "cleanliness": 50.0, "sleepiness": 50.0, "satisfaction": 50.0, "anxiety": 50.0, "curiosity": 50.0, "can_see_food": false }, "excluded_neurons": [], "neuron_positions": { "hunger": [ 127.0, 81.0 ], "happiness": [ 392.0, 63.0 ], "cleanliness": [ 627.0, 81.0 ], "sleepiness": [ 840.0, 81.0 ], "satisfaction": [ 271.0, 380.0 ], "anxiety": [ 491.0, 389.0 ], "curiosity": [ 701.0, 386.0 ], "can_see_food": [ 260.0, 151.0 ] }, "weights": { "can_see_food|satisfaction": 0.13, "sleepiness|anxiety": 0.184, "sleepiness|happiness": -0.063, "satisfaction|happiness": 0.288, "hunger|happiness": -0.151, "cleanliness|happiness": 0.404, "happiness|satisfaction": 0.307, "satisfaction|curiosity": 0.202, "anxiety|curiosity": -0.305 }, "layer_structure": [], "neuron_details": { "hunger": { "name": "hunger", "neuron_type": "core", "position": [ 127.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "happiness": { "name": "happiness", "neuron_type": "core", "position": [ 392.0, 63.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "cleanliness": { "name": "cleanliness", "neuron_type": "core", "position": [ 627.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "sleepiness": { "name": "sleepiness", "neuron_type": "core", "position": [ 840.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "satisfaction": { "name": "satisfaction", "neuron_type": "core", "position": [ 271.0, 380.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "anxiety": { "name": "anxiety", "neuron_type": "core", "position": [ 491.0, 389.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "curiosity": { "name": "curiosity", "neuron_type": "core", "position": [ 701.0, 386.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "can_see_food": { "name": "can_see_food", "neuron_type": "sensor", "position": [ 260.0, 151.0 ], "layer_index": 0, "color": [ 100, 180, 100 ], "description": "Required neuron", "is_binary": true, "category": "required" } }, "sensors_used": [ "can_see_food" ], "required_complete": true } ================================================ FILE: custom_brains/Plant-Seeker.json ================================================ { "version": "2.0", "format": "dosidicus", "metadata": { "name": "Untitled", "description": "", "author": "", "version": "1.0", "created": "", "modified": "" }, "neurons": { "hunger": { "position": [ 127.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "happiness": { "position": [ 361.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "cleanliness": { "position": [ 627.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "sleepiness": { "position": [ 840.0, 81.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "satisfaction": { "position": [ 271.0, 380.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "anxiety": { "position": [ 491.0, 389.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "curiosity": { "position": [ 701.0, 386.0 ], "type": "core", "is_binary": false, "is_core": true, "is_sensor": false, "activation": 50.0 }, "can_see_food": { "position": [ 50.0, 200.0 ], "type": "sensor", "is_binary": true, "is_core": false, "is_sensor": true, "activation": 0.0 }, "plant_proximity": { "position": [ 890.0, 264.0 ], "type": "sensor", "is_binary": false, "is_core": false, "is_sensor": true, "activation": 0.0 } }, "connections": { "hunger->satisfaction": -0.092, "anxiety->curiosity": -0.344, "satisfaction->anxiety": -0.098, "hunger->happiness": -0.19, "sleepiness->satisfaction": -0.201, "anxiety->happiness": -0.059, "anxiety->satisfaction": -0.15, "satisfaction->hunger": -0.185, "can_see_food->curiosity": 0.114, "sleepiness->curiosity": -0.243, "satisfaction->happiness": 0.397, "hunger->anxiety": 0.348, "happiness->curiosity": 0.088, "can_see_food->hunger": 0.041, "curiosity->happiness": 0.247, "sleepiness->anxiety": 0.18, "cleanliness->anxiety": -0.137, "cleanliness->happiness": 0.2, "plant_proximity->happiness": 0.2, "plant_proximity->anxiety": -0.3, "can_see_food->happiness": 0.25000000000000006 }, "state": { "hunger": 50.0, "happiness": 50.0, "cleanliness": 50.0, "sleepiness": 50.0, "satisfaction": 50.0, "anxiety": 50.0, "curiosity": 50.0, "can_see_food": false, "plant_proximity": 0.0 }, "excluded_neurons": [], "output_bindings": [], "neuron_positions": { "hunger": [ 127.0, 81.0 ], "happiness": [ 361.0, 81.0 ], "cleanliness": [ 627.0, 81.0 ], "sleepiness": [ 840.0, 81.0 ], "satisfaction": [ 271.0, 380.0 ], "anxiety": [ 491.0, 389.0 ], "curiosity": [ 701.0, 386.0 ], "can_see_food": [ 50.0, 200.0 ], "plant_proximity": [ 890.0, 264.0 ] }, "weights": { "hunger|satisfaction": -0.092, "anxiety|curiosity": -0.344, "satisfaction|anxiety": -0.098, "hunger|happiness": -0.19, "sleepiness|satisfaction": -0.201, "anxiety|happiness": -0.059, "anxiety|satisfaction": -0.15, "satisfaction|hunger": -0.185, "can_see_food|curiosity": 0.114, "sleepiness|curiosity": -0.243, "satisfaction|happiness": 0.397, "hunger|anxiety": 0.348, "happiness|curiosity": 0.088, "can_see_food|hunger": 0.041, "curiosity|happiness": 0.247, "sleepiness|anxiety": 0.18, "cleanliness|anxiety": -0.137, "cleanliness|happiness": 0.2, "plant_proximity|happiness": 0.2, "plant_proximity|anxiety": -0.3, "can_see_food|happiness": 0.25000000000000006 }, "layer_structure": [], "neuron_details": { "hunger": { "name": "hunger", "neuron_type": "core", "position": [ 127.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "happiness": { "name": "happiness", "neuron_type": "core", "position": [ 361.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "cleanliness": { "name": "cleanliness", "neuron_type": "core", "position": [ 627.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "sleepiness": { "name": "sleepiness", "neuron_type": "core", "position": [ 840.0, 81.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "satisfaction": { "name": "satisfaction", "neuron_type": "core", "position": [ 271.0, 380.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "anxiety": { "name": "anxiety", "neuron_type": "core", "position": [ 491.0, 389.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "curiosity": { "name": "curiosity", "neuron_type": "core", "position": [ 701.0, 386.0 ], "layer_index": 0, "color": [ 150, 150, 220 ], "description": "Required neuron", "is_binary": false, "category": "core" }, "can_see_food": { "name": "can_see_food", "neuron_type": "sensor", "position": [ 50.0, 200.0 ], "layer_index": 0, "color": [ 100, 180, 100 ], "description": "Required neuron", "is_binary": true, "category": "required" }, "plant_proximity": { "name": "plant_proximity", "neuron_type": "sensor", "position": [ 890.0, 264.0 ], "layer_index": 0, "color": [ 150, 200, 220 ], "description": "", "is_binary": false, "category": "sensor" } }, "sensors_used": [ "can_see_food", "plant_proximity" ], "required_complete": true } ================================================ FILE: custom_brains/readme.md ================================================ # Custom brains: Included in this folder: ## "Dense connections" (The Reactive Brain) This brain is characterized by high volatility and direct emotional feedback loops. Emotional Interdependence: Almost every core drive is wired directly to another. For example, cleanliness has a strong positive weight toward happiness (0.391), meaning this squid finds significant joy in being clean. The "Anxiety" Hub: anxiety is extremely "loud" in this brain. hunger significantly drives anxiety (0.333), but interestingly, satisfaction helps suppress it (-0.216). This squid likely feels "hangry" very easily but calms down quickly once it's content. High-Friction Curiosity: anxiety has a strong inhibitory link to curiosity (-0.309). This squid is likely "Timid"; when it gets nervous, it completely stops exploring. Cognitive Style: Because there are no hidden layers, this brain doesn't "think" before it acts. It is purely reflexive. If it sees food, that signal hits curiosity (0.27) and happiness (0.232) instantly. ------------------------------------- ## "Feed-Forward-Hidden-Layer" (The Analytical Brain) This version (v1.1) represents a significant leap in "cognitive" complexity by introducing a hidden processing layer (hidden0_0 through hidden0_3). The "Filter" Layer: Information doesn't just jump from a feeling to an action. It passes through four "pentagon" neurons first. This allows the squid to balance multiple conflicting needs (like being both hungry and sleepy) before deciding how it feels. ### Structured Influence: - `Hidden0_0` (The Positivity Driver): This neuron is strongly activated by happiness (0.435) and can_see_food (0.499). It then heavily drives satisfaction and anxiety. It seems to be the "Excitement" processor. - `Hidden0_3` (The Inhibitor): This neuron is strongly suppressed by sleepiness (-0.469) and can_see_food (-0.259). When it is active, it heavily suppresses satisfaction (-0.459). - Better Learning Potential: By using hidden layers, this brain can learn non-linear relationships. It can "understand" that seeing food is good when hungry, but maybe less important when it's exhausted. - Cognitive Style: This is a "contemplative" brain. It’s better at prioritizing and likely shows more stable, less erratic behavior than the "Dense Connections" model. ------------------------ ## Plant-Seeker: This is a more complex specialized brain. It uses a unique plant_proximity sensor to drive its internal state. It has a very strong link where hunger spikes anxiety (0.348), creating a squid that is highly motivated to find those plants when its stomach is empty. ------------------- ## Change_colour_when_see_food: This squid has a very high novelty_object_investigation drive. Interestingly, investigating novelty significantly reduces its hunger (-0.728), suggesting that for this child, curiosity and discovery are almost as fulfilling as eating. This squid has an output binding (set in the designer) that makes it change colour when it can see food -------------------- ## Feeling-Blue: This structure is a study in depression. It features a fascinating connector_rescue neuron. It is wired so that cleanliness and happiness both have powerful positive influences on sleepiness (0.81 and 0.566 respectively), creating a squid that likely retreats into sleep when it feels good—or perhaps uses sleep as a primary emotional regulator. ------------------ ## Grasshopper: A high-anxiety model. Its anxiety has a very strong inhibitory effect on curiosity (-0.393), meaning this squid likely "freezes" or stops exploring the moment it feels stressed. ---------------- ## Healthy Interests: This is one of your most balanced "offspring." It features strong reciprocal links, like satisfaction driving happiness (0.7), creating a stable positive feedback loop that helps the squid maintain a "healthy" mental state. ------------------ ## L'insomniaque: This squid is physically incapable of rest. We have wired almost every drive—anxiety (-0.95), curiosity (-0.8), and even hunger (-0.5)—to inhibit sleepiness. This is a squid that stays awake as long as it has any internal stimulation. ----------------- ## Bathtub: This model seems focused on the joy of hygiene. It has a notable positive weight from cleanliness to happiness (0.296), making it a squid that specifically finds its "zen" in being clean. --------------- ## Minimal: This is the "blank slate" child. With very few active connections, it represents the base species before the environment and neurogenesis begin their work. It is the perfect starting point for watching how a brain grows from nothing. ================================================ FILE: docker-compose.yml ================================================ services: headless: build: context: . target: headless volumes: - ./headless_output:/app/output command: ["--ticks", "10000", "--output", "/app/output/trained_brain.json"] gui: build: context: . target: gui environment: - DISPLAY=${DISPLAY} - WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-wayland-0} - XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/mnt/wslg/runtime-dir} - QT_X11_NO_MITSHM=1 volumes: - /tmp/.X11-unix:/tmp/.X11-unix:rw - /mnt/wslg:/mnt/wslg - ./saves:/app/saves - ./logs:/app/logs profiles: - gui ================================================ FILE: example_squids/readme.md ================================================ ### `Example_squids` folder put these example squids in the `saves` folder and launch the simulation ONLY ONE squid can be in the save folder at a time Please see this wiki article: https://github.com/ViciousSquid/Dosidicus/wiki/Example-squids ================================================ FILE: extras/SaveViewer.html ================================================ Dosidicus Save Viewer

    Save Viewer v2.1.3

    No file loaded
    🦑

    Dosidicus Save Viewer v2.1.3

    https://github.com/ViciousSquid/Dosidicus

    Use the pink button to load a squid

    ================================================ FILE: extras/SquidBreeder.html ================================================ Squid Breeder

    🧬 Squid Breeder

    Squid A

    Squid B

    Offspring

    Inherited From:


    Neuron Type:


    View Mode:


    Mutation:
    Rate Strength
    ================================================ FILE: extras/StepTrainer.html ================================================ Step Trainer

    🧠 Step Trainer

    Hebbian plasticity | Spike‑timing emulation | Autonomous neuron growth

    🧬 Active Network

    Ticks: 0
    ⚡ Neurogenesis monitor ready. Output bindings will appear here.

    🎮 Manual Inputs

    Load a brain to see sliders

    🧬 Neurogenesis Triggers

    Novelty counter

    0.0
    threshold 3.0

    Stress counter

    0.0
    threshold 2.0

    Reward counter

    0.0
    threshold 2.5
    🧪 New neurons:

    No output bindings
    💡 Neurogenesis: counters increase when core emotions exceed thresholds. New neurons are placed randomly across the canvas, avoiding overlap.

    📈 Behaviour Analytics

    Dominant Archetype

    Neurons (total)

    0

    Active Outputs

    0
    📊 Archetype distribution (last 50 steps)
    ================================================ FILE: extras/brain_2_keras.py ================================================ import json import numpy as np import os import argparse # Try to import keras, handle if not installed try: import keras from keras import ops except ImportError: print("Error: Keras v3 is required. Please install it via 'pip install keras'") exit(1) class HybridActivation(keras.layers.Layer): """ Applies different activation functions to different slices of the input tensor. This is necessary because the Brain Designer allows per-neuron activation settings. """ def __init__(self, activation_map, units, **kwargs): """ activation_map: dict mapping activation name ('relu', 'sigmoid') to list of indices units: total number of neurons """ super().__init__(**kwargs) self.activation_map = activation_map self.units = units # Create boolean masks for each activation type for vectorized application self.masks = {} for act_name, indices in activation_map.items(): mask = np.zeros(units, dtype="float32") mask[indices] = 1.0 self.masks[act_name] = mask def call(self, inputs): output = ops.zeros_like(inputs) for act_name, mask in self.masks.items(): # Convert mask to tensor mask_tensor = ops.convert_to_tensor(mask) # Apply standard keras activations if act_name == 'relu': activated = keras.activations.relu(inputs) elif act_name == 'sigmoid': activated = keras.activations.sigmoid(inputs) elif act_name == 'tanh': activated = keras.activations.tanh(inputs) elif act_name == 'linear': activated = inputs else: # Default to linear if unknown activated = inputs # Add masked contribution to output output = output + (activated * mask_tensor) return output def get_config(self): config = super().get_config() config.update({ "activation_map": self.activation_map, "units": self.units }) return config class BrainCell(keras.layers.Layer): """ A Custom RNN Cell that mimics the exact topology of the JSON brain design. """ def __init__(self, input_units, state_units, input_weights_init, recurrent_weights_init, activation_map, output_indices, **kwargs): super().__init__(**kwargs) self.input_units = input_units self.state_units = state_units self.state_size = state_units self.output_size = len(output_indices) # We only output specific neurons self.output_indices = output_indices # Initializers (passed as numpy arrays from the converter) self.w_in_init = input_weights_init self.w_rec_init = recurrent_weights_init self.activation_map = activation_map def build(self, input_shape): # Input Kernel (Input Neurons -> State Neurons) self.kernel = self.add_weight( shape=(self.input_units, self.state_units), initializer=keras.initializers.Constant(self.w_in_init), name="input_kernel", trainable=True # Set to False to freeze the design structure exactly ) # Recurrent Kernel (State Neurons -> State Neurons) self.recurrent_kernel = self.add_weight( shape=(self.state_units, self.state_units), initializer=keras.initializers.Constant(self.w_rec_init), name="recurrent_kernel", trainable=True ) self.bias = self.add_weight( shape=(self.state_units,), initializer="zeros", name="bias" ) self.hybrid_activation = HybridActivation(self.activation_map, self.state_units) self.built = True def call(self, inputs, states): prev_output = states[0] # Standard RNN math: h' = Act(Wx + Uh + b) # inputs shape: (batch, input_units) # prev_output shape: (batch, state_units) h_in = ops.matmul(inputs, self.kernel) h_rec = ops.matmul(prev_output, self.recurrent_kernel) total_input = h_in + h_rec + self.bias # Apply per-neuron activations output = self.hybrid_activation(total_input) return output, [output] def get_config(self): config = super().get_config() # Note: We don't serialize large numpy arrays in config typically, # but for reconstruction within this tool it's handled via the loader. # For saving/loading the Keras model strictly, rely on model.save() # which handles weight serialization automatically. config.update({ "input_units": self.input_units, "state_units": self.state_units, "output_indices": self.output_indices, "activation_map": self.activation_map, # We cannot serialize numpy arrays directly in config for JSON safety # If rebuilding from config, standard initializers would be used # unless we implement custom logic. }) return config def load_brain_json(filepath): """Parses the Brain Designer JSON file.""" with open(filepath, 'r') as f: data = json.load(f) return data def parse_design(data): """ Analyzes the JSON data to build matrices. Returns: input_ids, state_ids, output_ids, W_in, W_rec, act_map """ # 1. Identify Neurons neurons = data.get('neurons', {}) if isinstance(neurons, list): # Handle array format # Convert list to dict map temp = {} for n in neurons: temp[n['name']] = n neurons = temp # Sort into Input (Sensors) vs State (Everything else) # Note: In BrainDesigner logic, sensors are just neurons with 0 inputs typically, # but strictly speaking, they are the interface to the outside world. input_names = [] state_names = [] # Keras needs fixed indices name_to_idx = {} # Logic to distinguish inputs: # Look for 'sensor' type or specific names in standard inputs known_inputs = [ 'external_stimulus', 'plant_proximity', 'threat_level', 'pursuing_food', 'is_sick', 'is_fleeing', 'is_eating', 'is_sleeping', 'is_startled', 'can_see_food' ] for name, props in neurons.items(): n_type = props.get('type', props.get('neuron_type', 'hidden')).lower() # Determine if it's an input source or a stateful neuron if n_type == 'sensor' or name in known_inputs: input_names.append(name) else: state_names.append(name) # Sort for determinism input_names.sort() state_names.sort() input_map = {name: i for i, name in enumerate(input_names)} state_map = {name: i for i, name in enumerate(state_names)} print(f"Parsed {len(input_names)} inputs and {len(state_names)} state neurons.") # 2. Build Matrices n_in = len(input_names) n_state = len(state_names) W_in = np.zeros((n_in, n_state), dtype="float32") W_rec = np.zeros((n_state, n_state), dtype="float32") # Helper to find where a name belongs def get_loc(name): if name in state_map: return ('state', state_map[name]) if name in input_map: return ('input', input_map[name]) return (None, -1) # Parse Connections connections = data.get('connections', []) # Normalize connection format (list of dicts vs dict of strings) conn_list = [] if isinstance(connections, dict): for k, w in connections.items(): if '->' in k: s, t = k.split('->') elif '|' in k: s, t = k.split('|') else: continue conn_list.append((s, t, w)) else: for c in connections: conn_list.append((c['source'], c['target'], c['weight'])) for source, target, weight in conn_list: # We only care about connections going TO a state neuron. # Connections TO an input neuron are ignored (inputs are forced). target_type, t_idx = get_loc(target) if target_type != 'state': continue source_type, s_idx = get_loc(source) if source_type == 'input': W_in[s_idx, t_idx] = weight elif source_type == 'state': W_rec[s_idx, t_idx] = weight # 3. Activation Mapping # Group state indices by activation function act_map = { 'relu': [], 'sigmoid': [], 'tanh': [], 'linear': [] } for name in state_names: idx = state_map[name] props = neurons[name] # Default logic based on type if explicit activation not found act = props.get('activation') if not act: n_type = props.get('type', props.get('neuron_type', 'hidden')).lower() if n_type == 'output': act = 'sigmoid' elif n_type == 'core': act = 'relu' elif props.get('is_binary'): act = 'step' # approximate step with sigmoid*100 or tanh else: act = 'relu' if act == 'step': act = 'sigmoid' # Keras doesn't have hard step usually, mapping to sigmoid if act not in act_map: act_map[act] = [] act_map[act].append(idx) # 4. Output Indices # By default, treat 'output' type or 'core' type as model outputs output_indices = [] for name in state_names: n_type = neurons[name].get('type', neurons[name].get('neuron_type', '')).lower() if n_type in ['output', 'core']: output_indices.append(state_map[name]) return n_in, n_state, W_in, W_rec, act_map, output_indices, input_names, state_names def convert_to_keras_model(json_path, output_path): print(f"Loading {json_path}...") data = load_brain_json(json_path) # Parse topology n_in, n_state, W_in, W_rec, act_map, out_idx, in_names, st_names = parse_design(data) # Create the custom cell # Note: We must pass weights as list to `Constant` initializer or load them after. # Here we pass them to the constructor to initialize the layers. cell = BrainCell( input_units=n_in, state_units=n_state, input_weights_init=W_in, recurrent_weights_init=W_rec, activation_map=act_map, output_indices=out_idx ) # Create the RNN Layer # return_sequences=True gives output at every time step rnn_layer = keras.layers.RNN(cell, return_sequences=True, name="brain_rnn") # Define Input # Shape: (None, input_features) -> Timesteps is flexible inputs = keras.Input(shape=(None, n_in), name="sensor_inputs") # Get RNN outputs (this returns the full state vector at every step) whole_state_sequence = rnn_layer(inputs) # If we want to filter only specific "Output" neurons: # We use a Lambda layer or slicing. Keras slicing layer is cleaner. if out_idx: # Sort indices to ensure deterministic output order out_idx.sort() def filter_outputs(x): return x[:, :, out_idx] # Batch, Time, Indices # Note: Lambda layers can be tricky for saving/loading without custom objects. # But `ops.take` or slicing is fine. final_output = keras.layers.Lambda( lambda x: ops.take(x, indices=out_idx, axis=-1), name="filter_outputs" )(whole_state_sequence) else: final_output = whole_state_sequence model = keras.Model(inputs=inputs, outputs=final_output, name="DosidicusBrain") # Compile just to finalize model.compile(loss="mse", optimizer="adam") model.summary() # Save print(f"Saving Keras model to {output_path}...") model.save(output_path) print("Done.") # Print input mapping for the user print("\n--- Input Mapping (Index: Name) ---") for i, name in enumerate(in_names): print(f"{i}: {name}") if __name__ == "__main__": parser = argparse.ArgumentParser(description="Convert Dosidicus Brain JSON to Keras v3 Model") parser.add_argument("input", help="Path to input JSON file") parser.add_argument("output", help="Path to output .keras file") args = parser.parse_args() if not os.path.exists(args.input): print(f"Error: Input file '{args.input}' not found.") else: convert_to_keras_model(args.input, args.output) ================================================ FILE: headless/HeadlessLauncher.jsx ================================================ import { useState, useEffect } from 'react'; const scenarios = { none: { name: '⚙️ Custom', desc: 'Use manual settings without predefined events', ticks: null }, balanced: { name: '⚖️ Balanced', desc: 'Standard training with normal event rates', ticks: 10000 }, stress_test: { name: '😰 Stress Test', desc: 'High anxiety to develop resilience neurons', ticks: 5000 }, reward_rich: { name: '🎁 Reward Rich', desc: 'Frequent positive outcomes for reward pathways', ticks: 8000 }, novelty_exploration: { name: '🔍 Novelty', desc: 'High curiosity with new object encounters', ticks: 6000 }, endurance: { name: '🏃 Endurance', desc: 'Long-duration training session', ticks: 50000 }, }; export default function HeadlessLauncher() { const [brainFile, setBrainFile] = useState(''); const [outputFile, setOutputFile] = useState(''); const [ticks, setTicks] = useState(10000); const [progress, setProgress] = useState(500); const [learningRate, setLearningRate] = useState(''); const [maxNeurons, setMaxNeurons] = useState(''); const [neurogenesis, setNeurogenesis] = useState(true); const [quietMode, setQuietMode] = useState(false); const [scenario, setScenario] = useState('none'); const [copied, setCopied] = useState(''); const selectScenario = (key) => { setScenario(key); if (scenarios[key].ticks) { setTicks(scenarios[key].ticks); } }; const getCommand = () => { let cmd = 'python headless_trainer.py'; if (brainFile) cmd += ` --brain "${brainFile}"`; if (scenario !== 'none') cmd += ` --scenario ${scenario}`; cmd += ` --ticks ${ticks}`; if (outputFile) cmd += ` --output "${outputFile}"`; if (progress !== 500) cmd += ` --progress ${progress}`; if (learningRate) cmd += ` --learning-rate ${learningRate}`; if (maxNeurons) cmd += ` --max-neurons ${maxNeurons}`; if (!neurogenesis) cmd += ` --neurogenesis False`; if (quietMode) cmd += ` --quiet`; return cmd; }; const copyToClipboard = (text, label) => { navigator.clipboard.writeText(text); setCopied(label); setTimeout(() => setCopied(''), 2000); }; const estTime = (ticks / 30000).toFixed(1); const estNeuronsMin = Math.floor(ticks / 2000); const estNeuronsMax = Math.min(Math.floor(ticks / 500), 100); const estHebbian = Math.floor(ticks / 30); return (
    {/* Header */}

    🦑 Dosidicus-2 Headless Trainer

    Configure and launch accelerated brain training

    {/* Brain Config */}

    Brain Configuration

    setBrainFile(e.target.value)} placeholder="path/to/brain.json" className="w-full bg-slate-900 border border-slate-600 rounded px-3 py-2 text-sm focus:border-rose-400 focus:outline-none" />
    setOutputFile(e.target.value)} placeholder="trained_brain.json" className="w-full bg-slate-900 border border-slate-600 rounded px-3 py-2 text-sm focus:border-rose-400 focus:outline-none" />
    {/* Training Params */}

    Training Parameters

    setTicks(parseInt(e.target.value) || 1000)} min={100} step={1000} className="w-full bg-slate-900 border border-slate-600 rounded px-3 py-2 text-sm focus:border-rose-400 focus:outline-none" />
    setProgress(parseInt(e.target.value) || 0)} min={0} step={100} className="w-full bg-slate-900 border border-slate-600 rounded px-3 py-2 text-sm focus:border-rose-400 focus:outline-none" />
    {estTime}s
    Est. Time
    ~{estNeuronsMin}-{estNeuronsMax}
    New Neurons
    ~{estHebbian}
    Hebbian Updates
    {/* Scenarios */}

    Training Scenario

    {Object.entries(scenarios).map(([key, info]) => ( ))}
    {/* Advanced */}

    Advanced Options

    setLearningRate(e.target.value)} placeholder="0.1" step={0.01} className="w-full bg-slate-900 border border-slate-600 rounded px-3 py-2 text-sm focus:border-rose-400 focus:outline-none" />
    setMaxNeurons(e.target.value)} placeholder="100" className="w-full bg-slate-900 border border-slate-600 rounded px-3 py-2 text-sm focus:border-rose-400 focus:outline-none" />
    {/* Command Output */}

    Generated Command

    python{' '} headless_trainer.py {brainFile && ( <> --brain "{brainFile}" )} {scenario !== 'none' && ( <> --scenario {scenario} )} <> --ticks {ticks} {outputFile && ( <> --output "{outputFile}" )} {progress !== 500 && ( <> --progress {progress} )} {learningRate && ( <> --learning-rate {learningRate} )} {maxNeurons && ( <> --max-neurons {maxNeurons} )} {!neurogenesis && ( <> --neurogenesis False )} {quietMode && ( <> --quiet )}
    Dosidicus-2 Headless Trainer Launcher
    ); } ================================================ FILE: headless/README.md ================================================ ## Headless Brain Trainer A standalone training system for Dosidicus neural network brains that runs without GUI overhead, enabling fast accelerated training of custom brain architectures. ## Features - **Headless Operation**: No GUI required, runs purely on CPU - **Accelerated Time**: Runs 25,000-35,000+ ticks per second (vs ~1 tick/second in real-time) - **Custom Brain Loading**: Load brain architectures from JSON files - **Neurogenesis**: Automatic creation of new neurons based on stress/novelty/reward triggers - **Hebbian Learning**: Continuous weight updates based on co-activation - **Training Scenarios**: Predefined scenarios for different training goals - **Export Trained Brains**: Save trained brains back to JSON for use in the main game ## Installation **No additional dependencies required** beyond Python 3.6+. The trainer is self-contained. ```bash # Make executable (optional) chmod +x headless_trainer.py ``` ## Quick Start ```bash # Train with default brain for 10,000 ticks python headless_trainer.py --ticks 10000 --output my_trained_brain.json # Train a custom brain python headless_trainer.py --brain my_custom_brain.json --ticks 20000 --output trained.json # Use a predefined scenario python headless_trainer.py --brain my_brain.json --scenario stress_test --output stress_trained.json # List available scenarios python headless_trainer.py --list-scenarios ``` ## Command Line Options | Option | Short | Description | |--------|-------|-------------| | `--brain FILE` | `-b` | Path to brain JSON file to load | | `--output FILE` | `-o` | Path to save trained brain (auto-generated if not specified) | | `--ticks N` | `-t` | Number of simulation ticks (default: 10000) | | `--scenario NAME` | `-s` | Use a predefined training scenario | | `--list-scenarios` | | List available training scenarios | | `--progress N` | `-p` | Progress report interval (default: 500, 0=quiet) | | `--quiet` | `-q` | Minimal output | | `--learning-rate F` | | Override Hebbian learning rate | | `--neurogenesis BOOL` | | Enable/disable neurogenesis | | `--max-neurons N` | | Maximum neurons allowed | ## Training Scenarios ### `balanced` Standard balanced training with normal event rates. Good for general-purpose brain development. ### `stress_test` High anxiety/stress conditions to develop resilience neurons. Includes: - Reduced food availability - Increased startle events - Scripted high-anxiety events ### `reward_rich` Frequent positive outcomes to develop reward pathways. Includes: - High food spawn rate - Reduced negative events - Scripted feeding events ### `novelty_exploration` High curiosity environment with new objects. Good for developing exploration behaviors. ### `endurance` Long-duration (50,000 ticks) training with varied conditions. ## Brain JSON Format Brains are stored as JSON files with the following structure: ```json { "metadata": { "name": "My Brain", "description": "Description of the brain" }, "positions": { "hunger": {"x": 127, "y": 81, "is_custom": false}, "custom_neuron": {"x": 450, "y": 250, "is_custom": true} }, "weights": { "hunger,satisfaction": -0.3, "happiness,satisfaction": 0.4 }, "neuron_shapes": { "custom_neuron": "pentagon" }, "output_bindings": [] } ``` ### Core Neurons (cannot be removed) - `hunger`, `happiness`, `cleanliness`, `sleepiness` - `satisfaction`, `anxiety`, `curiosity` ### Input Sensors - `can_see_food`, `plant_proximity`, `external_stimulus` - `is_eating`, `is_sleeping`, `is_sick`, `is_fleeing`, `is_startled`, `pursuing_food` ### Custom Neurons Any neuron not in the core/sensor lists is treated as a custom neuron and will participate in Hebbian learning with boosted rates. ## Example Workflow ### 1. Create a custom brain in the Brain Designer Use the [Brain Designer](https://github.com/ViciousSquid/Dosidicus/wiki/Brain-Designer) to create your architecture, then export it as JSON. ### 2. Train the brain headlessly ```bash # Quick training python headless_trainer.py -b my_design.json -t 50000 -o trained_v1.json # Or with a scenario python headless_trainer.py -b my_design.json -s stress_test -o stress_trained.json ``` ### 3. Load the trained brain back into Dosidicus Use the "Load Brain" button in the [Network tab](https://github.com/ViciousSquid/Dosidicus/wiki/Network-Tab) to load your trained brain. ## Training Tips 1. **Start with balanced training** to establish baseline connections 2. **Use stress_test** if you want resilience neurons for anxiety handling 3. **Use reward_rich** if you want strong satisfaction/happiness pathways 4. **Long training (50k+ ticks)** allows more sophisticated weight patterns to develop 5. **Multiple training rounds** with different scenarios can create well-rounded brains ## Performance On modern hardware, expect: - ~25,000-35,000 ticks/second - A 50,000 tick training session completes in ~2 seconds - A 1,000,000 tick session completes in ~30 seconds ## Integration with Dosidicus-2 The trained brain JSON files are fully compatible with: - Brain Designer (import/export) - Main game's "Load Brain" feature - Save game custom brain storage ## License Part of Dosidicus-2 project. [GPL-2.0 license](https://github.com/ViciousSquid/Dosidicus/blob/v2.6.1.0__b1218_LatestVersion/LICENSE) ================================================ FILE: headless/headless_launcher.html ================================================ Dosidicus-2 Headless Trainer Launcher

    🦑 Dosidicus-2 Headless Trainer

    Configure and launch accelerated brain training sessions

    Brain Configuration

    Leave empty to use default brain architecture
    Auto-generated if not specified
    📂 Drop brain.json here or click to browse

    Training Parameters

    Number of simulation steps (≈30k ticks/sec)
    Report frequency (0 = quiet mode)
    0.3s
    Estimated Time
    ~5-20
    New Neurons
    ~334
    Hebbian Updates

    Training Scenario

    Select a predefined scenario or use custom settings

    ⚙️ Custom

    Use manual settings without predefined events

    ⚖️ Balanced

    Standard training with normal event rates

    10,000 ticks

    😰 Stress Test

    High anxiety to develop resilience neurons

    5,000 ticks

    🎁 Reward Rich

    Frequent positive outcomes for reward pathways

    8,000 ticks

    🔍 Novelty

    High curiosity with new object encounters

    6,000 ticks

    🏃 Endurance

    Long-duration training session

    50,000 ticks

    Advanced Options

    Hebbian learning rate (default: 0.1)
    Maximum neurons allowed (default: 100)

    Generated Command

    python headless_trainer.py --ticks 10000

    Quick Reference

    Performance: ~25,000-35,000 ticks/second on modern hardware

    Neurogenesis: Creates stress/novelty/reward neurons based on squid state

    Hebbian Learning: Updates connection weights every 30 ticks

    Output: Compatible with Brain Designer and main game "Load Brain" feature

    ================================================ FILE: headless/headless_trainer.py ================================================ #!/usr/bin/env python3 """ Dosidicus-2 Headless Brain Trainer Allows training custom neural network brains without GUI overhead. Supports accelerated time, custom brain loading, and state persistence. Usage: python headless_trainer.py --brain custom_brain.json --ticks 10000 --output trained_brain.json python headless_trainer.py --brain custom_brain.json --duration 3600 --speed 100 python headless_trainer.py --list-scenarios python headless_trainer.py --brain my_brain.json --scenario stress_test Author: Headless training system for Dosidicus-2 """ import argparse import json import math import os import random import sys import time from collections import deque from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import Dict, List, Optional, Set, Tuple, Any from heapq import nlargest # ============================================================================ # CONSTANTS (from brain_worker.py and brain_constants.py) # ============================================================================ PURE_INPUTS = { "can_see_food", "is_eating", "is_sleeping", "is_sick", "pursuing_food", "is_fleeing", "is_startled", "external_stimulus", "plant_proximity" } CORE_NEURONS = { "hunger", "happiness", "cleanliness", "sleepiness", "satisfaction", "anxiety", "curiosity" } INPUT_SENSORS = { "can_see_food", "is_eating", "is_sleeping", "is_sick", "pursuing_food", "is_fleeing", "is_startled", "external_stimulus", "plant_proximity" } class Personality(Enum): """Squid personality types""" TIMID = "timid" ADVENTUROUS = "adventurous" GREEDY = "greedy" STUBBORN = "stubborn" ENERGETIC = "energetic" # ============================================================================ # CONFIGURATION # ============================================================================ @dataclass class TrainingConfig: """Configuration for headless training""" # Learning parameters learning_rate: float = 0.1 weight_decay: float = 0.01 max_hebbian_pairs: int = 2 hebbian_interval: int = 30 # ticks between hebbian updates # Neurogenesis parameters neurogenesis_enabled: bool = True neurogenesis_cooldown: int = 60 # ticks max_neurons: int = 100 novelty_threshold: float = 3.0 stress_threshold: float = 1.2 reward_threshold: float = 3.5 # Simulation parameters decay_rate: float = 0.95 noise_range: float = 0.5 # Scenarios food_spawn_chance: float = 0.02 poop_spawn_chance: float = 0.01 startle_chance: float = 0.005 @classmethod def from_dict(cls, data: Dict) -> 'TrainingConfig': return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}) @dataclass class TrainingScenario: """Predefined training scenarios""" name: str description: str duration_ticks: int config_overrides: Dict = field(default_factory=dict) events: List[Dict] = field(default_factory=list) TRAINING_SCENARIOS = { "balanced": TrainingScenario( name="Balanced Training", description="Standard balanced training with normal event rates", duration_ticks=10000, config_overrides={}, events=[] ), "stress_test": TrainingScenario( name="Stress Test", description="High anxiety/stress conditions to develop resilience neurons", duration_ticks=5000, config_overrides={ "startle_chance": 0.02, "food_spawn_chance": 0.005, }, events=[ {"tick": 100, "type": "set_state", "state": {"anxiety": 80}}, {"tick": 500, "type": "startle"}, {"tick": 1000, "type": "set_state", "state": {"hunger": 90}}, ] ), "reward_rich": TrainingScenario( name="Reward-Rich Environment", description="Frequent positive outcomes to develop reward pathways", duration_ticks=8000, config_overrides={ "food_spawn_chance": 0.08, "poop_spawn_chance": 0.001, }, events=[ {"tick": 200, "type": "feed"}, {"tick": 600, "type": "feed"}, {"tick": 1200, "type": "feed"}, ] ), "novelty_exploration": TrainingScenario( name="Novelty Exploration", description="High curiosity environment with new objects", duration_ticks=6000, config_overrides={ "startle_chance": 0.001, }, events=[ {"tick": 100, "type": "set_state", "state": {"curiosity": 85}}, {"tick": 300, "type": "new_object"}, {"tick": 800, "type": "new_object"}, {"tick": 1500, "type": "new_object"}, ] ), "endurance": TrainingScenario( name="Endurance Training", description="Long-duration training with varied conditions", duration_ticks=50000, config_overrides={}, events=[] ), } # ============================================================================ # HEADLESS SQUID (minimal state tracking) # ============================================================================ class HeadlessSquid: """Minimal squid implementation for headless training""" def __init__(self, personality: Personality = None): self.personality = personality or random.choice(list(Personality)) # Core stats (0-100) self.hunger = 50.0 self.happiness = 50.0 self.cleanliness = 80.0 self.sleepiness = 20.0 self.health = 100.0 # Goal neurons self.satisfaction = 50.0 self.anxiety = 10.0 self.curiosity = 55.0 # Boolean states self.is_sick = False self.is_sleeping = False self.is_eating = False self.is_fleeing = False self.is_startled = False self.pursuing_food = False # Position (normalized 0-1) self.x = 0.5 self.y = 0.5 self.direction = "right" # Status self.status = "roaming" # Tracking self.food_visible = False self.plant_nearby = False self.time_since_ate = 0 self.time_since_slept = 0 def get_state_dict(self) -> Dict[str, Any]: """Get current state as dictionary for brain updates""" return { "hunger": self.hunger, "happiness": self.happiness, "cleanliness": self.cleanliness, "sleepiness": self.sleepiness, "satisfaction": self.satisfaction, "anxiety": self.anxiety, "curiosity": self.curiosity, "is_sick": self.is_sick, "is_sleeping": self.is_sleeping, "is_eating": self.is_eating, "pursuing_food": self.pursuing_food, "is_fleeing": self.is_fleeing, "is_startled": self.is_startled, "can_see_food": 100.0 if self.food_visible else 0.0, "plant_proximity": 100.0 if self.plant_nearby else 0.0, "external_stimulus": 0.0, "direction": self.direction, "position": (self.x, self.y), "personality": self.personality.value, "status": self.status, } def update(self, dt: float = 1.0): """Update squid state for one tick""" # Natural stat changes self.hunger = min(100, self.hunger + 0.1 * dt) self.sleepiness = min(100, self.sleepiness + 0.05 * dt) self.cleanliness = max(0, self.cleanliness - 0.02 * dt) # Update time trackers self.time_since_ate += dt if not self.is_sleeping: self.time_since_slept += dt # Personality effects if self.personality == Personality.GREEDY: self.hunger = min(100, self.hunger + 0.05 * dt) elif self.personality == Personality.ENERGETIC: self.sleepiness = max(0, self.sleepiness - 0.02 * dt) elif self.personality == Personality.TIMID: if self.plant_nearby: self.anxiety = max(0, self.anxiety - 0.1 * dt) # Anxiety from needs if self.hunger > 70: self.anxiety = min(100, self.anxiety + 0.1 * dt) if self.cleanliness < 30: self.anxiety = min(100, self.anxiety + 0.05 * dt) # Satisfaction from good states if self.hunger < 30 and self.cleanliness > 70: self.satisfaction = min(100, self.satisfaction + 0.1 * dt) else: self.satisfaction = max(0, self.satisfaction - 0.02 * dt) # Happiness dynamics if self.anxiety > 50: self.happiness = max(0, self.happiness - 0.1 * dt) elif self.satisfaction > 60: self.happiness = min(100, self.happiness + 0.05 * dt) # Curiosity wanders self.curiosity += random.uniform(-0.5, 0.5) * dt self.curiosity = max(0, min(100, self.curiosity)) # Clear temporary states if self.is_startled: if random.random() < 0.1: self.is_startled = False self.is_fleeing = False if self.is_eating: if random.random() < 0.2: self.is_eating = False self.pursuing_food = False # Sleep behavior if self.sleepiness > 90 and not self.is_sleeping: self.is_sleeping = True self.status = "sleeping" elif self.is_sleeping and self.sleepiness < 20: self.is_sleeping = False self.time_since_slept = 0 self.status = "roaming" # Movement simulation if not self.is_sleeping and not self.is_eating: self.x += random.uniform(-0.02, 0.02) self.y += random.uniform(-0.02, 0.02) self.x = max(0, min(1, self.x)) self.y = max(0, min(1, self.y)) def feed(self): """Feed the squid""" self.hunger = max(0, self.hunger - 30) self.happiness = min(100, self.happiness + 10) self.satisfaction = min(100, self.satisfaction + 15) self.is_eating = True self.time_since_ate = 0 self.status = "eating" def startle(self): """Startle the squid""" self.is_startled = True self.is_fleeing = True self.anxiety = min(100, self.anxiety + 20) self.happiness = max(0, self.happiness - 10) self.status = "fleeing!" def clean(self): """Clean the environment""" self.cleanliness = min(100, self.cleanliness + 40) self.anxiety = max(0, self.anxiety - 5) # ============================================================================ # HEADLESS BRAIN # ============================================================================ class HeadlessBrain: """ Neural network brain without GUI dependencies. Handles state updates, Hebbian learning, and neurogenesis. """ def __init__(self, config: TrainingConfig = None): self.config = config or TrainingConfig() # Neural state self.state: Dict[str, float] = {} self.weights: Dict[Tuple[str, str], float] = {} self.positions: Dict[str, Tuple[float, float]] = {} self.neuron_shapes: Dict[str, str] = {} # Custom neurons tracking self.custom_neurons: Set[str] = set() self.new_neurons: Set[str] = set() self.connector_neurons: Set[str] = set() # Learning tracking self.last_hebbian_pairs: List[Tuple[str, str]] = [] self.weight_history: List[Dict] = [] # Neurogenesis tracking self.neurogenesis_data = { 'new_neurons': [], 'last_neuron_time': 0, 'neurons_created': 0, 'functional_neurons': {}, } self.last_neurogenesis_tick = 0 self.stress_neuron_count = 0 self.novelty_neuron_count = 0 self.reward_neuron_count = 0 # Output bindings (for behaviors) self.output_bindings: List[Dict] = [] # Initialize default state self._initialize_default_state() def _initialize_default_state(self): """Initialize with default neuron structure""" default_positions = { "can_see_food": (50, 200), "hunger": (127, 81), "happiness": (361, 81), "cleanliness": (627, 81), "sleepiness": (840, 81), "satisfaction": (271, 380), "anxiety": (491, 389), "curiosity": (701, 386), } for name, pos in default_positions.items(): self.positions[name] = pos if name in CORE_NEURONS: self.state[name] = 50.0 elif name in INPUT_SENSORS: self.state[name] = 0.0 else: self.state[name] = 50.0 # Default connections default_connections = [ ("hunger", "satisfaction", -0.3), ("happiness", "satisfaction", 0.4), ("anxiety", "satisfaction", -0.35), ("cleanliness", "happiness", 0.2), ("sleepiness", "happiness", -0.15), ("curiosity", "happiness", 0.25), ] for src, dst, weight in default_connections: self.weights[(src, dst)] = weight def load_brain(self, brain_data: Dict) -> bool: """Load a brain from dictionary (JSON structure)""" try: # Clear existing self.state.clear() self.weights.clear() self.positions.clear() self.custom_neurons.clear() # Load positions/neurons — accept both 'positions' (headless export) # and 'neurons' (Designer / game format). positions_raw = brain_data.get('positions', brain_data.get('neurons', {})) for name, pos in positions_raw.items(): if isinstance(pos, dict): # Designer format: {'position': [x, y], 'is_custom': bool} # Headless export format: {'x': float, 'y': float, 'is_custom': bool} if 'position' in pos: p = pos['position'] self.positions[name] = (float(p[0]), float(p[1])) elif 'x' in pos and 'y' in pos: self.positions[name] = (float(pos['x']), float(pos['y'])) else: self.positions[name] = (0.0, 0.0) if pos.get('is_custom', False): self.custom_neurons.add(name) elif isinstance(pos, (list, tuple)) and len(pos) >= 2: self.positions[name] = (float(pos[0]), float(pos[1])) else: # Fallback: unknown shape — park at origin rather than # storing a raw unsupported value. self.positions[name] = (0.0, 0.0) # Initialize state if name in CORE_NEURONS: self.state[name] = 50.0 elif name in INPUT_SENSORS: self.state[name] = 0.0 else: self.state[name] = 50.0 self.custom_neurons.add(name) # Load weights/connections # Accept list format (Designer) and dict format (headless export). weights_data = brain_data.get('weights', brain_data.get('connections', {})) if isinstance(weights_data, dict): for key, weight in weights_data.items(): # Handle string keys like "hunger,satisfaction" or "hunger->satisfaction" if isinstance(key, str): if '->' in key: parts = key.split('->') else: parts = key.replace('(', '').replace(')', '').replace("'", "").split(',') if len(parts) >= 2: src = parts[0].strip() dst = parts[1].strip() self.weights[(src, dst)] = float(weight) else: self.weights[tuple(key)] = float(weight) elif isinstance(weights_data, list): for conn in weights_data: if isinstance(conn, dict): src = conn.get('source', conn.get('from', '')) dst = conn.get('target', conn.get('to', '')) w = conn.get('weight', 0.5) if src and dst: self.weights[(src, dst)] = float(w) elif isinstance(conn, (list, tuple)) and len(conn) >= 2: self.weights[(conn[0], conn[1])] = float(conn[2]) if len(conn) > 2 else 0.5 # Load shapes self.neuron_shapes = brain_data.get('neuron_shapes', {}) # Load output bindings self.output_bindings = brain_data.get('output_bindings', []) # Load neurogenesis data if present if 'neurogenesis_data' in brain_data: self.neurogenesis_data.update(brain_data['neurogenesis_data']) print(f"✓ Loaded brain: {len(self.positions)} neurons, {len(self.weights)} connections") return True except Exception as e: print(f"✗ Error loading brain: {e}") return False def load_brain_file(self, filepath: str) -> bool: """Load brain from JSON file""" try: with open(filepath, 'r') as f: brain_data = json.load(f) return self.load_brain(brain_data) except Exception as e: print(f"✗ Error loading brain file: {e}") return False def export_brain(self) -> Dict: """ Export brain in the Designer/game format understood by custom_brain_loader.BrainLoader._parse() (Format 2: neurons dict + connections list). This ensures trained brains can be loaded straight back into the game without any manual conversion. """ # neurons dict — position as list, is_custom flag neurons = {} for name, pos in self.positions.items(): neurons[name] = { 'position': [pos[0], pos[1]], 'is_custom': name in self.custom_neurons, } # connections as a list of {source, target, weight} dicts # (BrainLoader._parse() Format 2 expects this exact structure) connections = [] for (src, dst), w in self.weights.items(): connections.append({'source': src, 'target': dst, 'weight': float(w)}) return { 'metadata': { 'exported_at': time.strftime('%Y-%m-%d %H:%M:%S'), 'exported_by': 'headless_trainer', 'neuron_count': len(self.positions), 'connection_count': len(self.weights), 'custom_neuron_count': len(self.custom_neurons), }, # ── game-loader-compatible keys ────────────────────────────────── 'neurons': neurons, # _parse() checks for 'neurons' key 'connections': connections, # _parse() Format 2: list of dicts # ──────────────────────────────────────────────────────────────── 'neuron_shapes': dict(self.neuron_shapes), 'output_bindings': self.output_bindings, 'state': {k: v for k, v in self.state.items()}, 'neurogenesis_data': { 'neurons_created': self.neurogenesis_data.get('neurons_created', 0), 'stress_neurons': self.stress_neuron_count, 'novelty_neurons': self.novelty_neuron_count, 'reward_neurons': self.reward_neuron_count, }, } def save_brain(self, filepath: str) -> bool: """Save brain to JSON file""" try: brain_data = self.export_brain() with open(filepath, 'w') as f: json.dump(brain_data, f, indent=2) print(f"✓ Saved brain to {filepath}") return True except Exception as e: print(f"✗ Error saving brain: {e}") return False def update_state(self, squid_state: Dict[str, Any]): """Update brain state from squid state""" for key, value in squid_state.items(): if key in self.positions or key in self.state: if isinstance(value, bool): self.state[key] = 100.0 if value else 0.0 elif isinstance(value, (int, float)): self.state[key] = float(value) def propagate(self): """Propagate activations through connections""" updates = {} for (src, dst), weight in self.weights.items(): if src in self.state and dst in self.state: if dst in PURE_INPUTS: continue src_val = self.state[src] effect = src_val * weight * 0.1 updates[dst] = updates.get(dst, 0) + effect # Apply updates with decay for neuron, delta in updates.items(): if neuron in self.state and neuron not in PURE_INPUTS: old_val = self.state[neuron] new_val = old_val * self.config.decay_rate + delta new_val += random.uniform(-self.config.noise_range, self.config.noise_range) self.state[neuron] = max(-100, min(100, new_val)) def perform_hebbian_learning(self) -> List[Tuple[str, str]]: """Perform Hebbian learning update""" # Get learning candidates excluded = set(PURE_INPUTS) | self.connector_neurons candidates = [n for n in self.positions.keys() if n not in excluded] if len(candidates) < 2: return [] # Score pairs scored_pairs = [] for i, n1 in enumerate(candidates): for n2 in candidates[i+1:]: v1 = self._get_neuron_value(self.state.get(n1, 50)) v2 = self._get_neuron_value(self.state.get(n2, 50)) score = v1 + v2 + random.uniform(0, 40) # Penalize recent pairs pair_key = tuple(sorted((n1, n2))) if pair_key in self.last_hebbian_pairs: score -= 500 # Boost custom neurons if n1 in self.custom_neurons or n2 in self.custom_neurons: score += 15 scored_pairs.append((score, n1, n2, v1, v2)) # Select top pairs top_pairs = nlargest(self.config.max_hebbian_pairs, scored_pairs) self.last_hebbian_pairs = [tuple(sorted((n1, n2))) for _, n1, n2, _, _ in top_pairs] updated_pairs = [] for _, n1, n2, v1, v2 in top_pairs: pair = (n1, n2) reverse_pair = (n2, n1) # Find existing connection if pair in self.weights: use_pair = pair elif reverse_pair in self.weights: use_pair = reverse_pair else: use_pair = pair self.weights[use_pair] = 0.0 old_w = self.weights[use_pair] # Calculate learning rate with boosts lr = self.config.learning_rate if n1 in self.new_neurons or n2 in self.new_neurons: lr *= 2.0 if n1 in self.custom_neurons or n2 in self.custom_neurons: lr *= 1.5 # Hebbian update delta = lr * (v1 / 100.0) * (v2 / 100.0) new_w = old_w + delta - (old_w * self.config.weight_decay) new_w = max(-1.0, min(1.0, new_w)) self.weights[use_pair] = new_w updated_pairs.append(use_pair) # Track history self.weight_history.append({ 'pair': use_pair, 'old': old_w, 'new': new_w, 'tick': time.time() }) return updated_pairs def check_neurogenesis(self, squid_state: Dict, tick: int) -> Optional[str]: """Check if neurogenesis should occur""" if not self.config.neurogenesis_enabled: return None if tick - self.last_neurogenesis_tick < self.config.neurogenesis_cooldown: return None if len(self.positions) >= self.config.max_neurons: return None # Check triggers anxiety = squid_state.get('anxiety', 50) curiosity = squid_state.get('curiosity', 50) happiness = squid_state.get('happiness', 50) satisfaction = squid_state.get('satisfaction', 50) trigger_type = None # Stress trigger if anxiety > 70 or (anxiety > 50 and squid_state.get('is_fleeing', False)): if self.stress_neuron_count < 5: # Cap stress neurons trigger_type = 'stress' # Novelty trigger elif curiosity > 75: trigger_type = 'novelty' # Reward trigger elif happiness > 80 and satisfaction > 70: trigger_type = 'reward' if trigger_type: neuron_name = self._create_neuron(trigger_type, squid_state) self.last_neurogenesis_tick = tick return neuron_name return None def _create_neuron(self, neuron_type: str, context: Dict) -> str: """Create a new neuron""" # Generate unique name count = self.neurogenesis_data.get('neurons_created', 0) + 1 self.neurogenesis_data['neurons_created'] = count neuron_name = f"{neuron_type}_{count}" # Find position (spread from existing neurons) existing_positions = list(self.positions.values()) if existing_positions: avg_x = sum(p[0] for p in existing_positions) / len(existing_positions) avg_y = sum(p[1] for p in existing_positions) / len(existing_positions) # Add some randomness new_x = avg_x + random.uniform(-100, 100) new_y = avg_y + random.uniform(-100, 100) else: new_x = random.uniform(100, 800) new_y = random.uniform(100, 400) self.positions[neuron_name] = (new_x, new_y) self.state[neuron_name] = 50.0 self.custom_neurons.add(neuron_name) self.new_neurons.add(neuron_name) # Set shape based on type shape_map = {'stress': 'hexagon', 'novelty': 'triangle', 'reward': 'circle'} self.neuron_shapes[neuron_name] = shape_map.get(neuron_type, 'circle') # Create connections based on type if neuron_type == 'stress': self.stress_neuron_count += 1 self.weights[(neuron_name, 'anxiety')] = -0.3 self.weights[('anxiety', neuron_name)] = 0.4 elif neuron_type == 'novelty': self.novelty_neuron_count += 1 self.weights[(neuron_name, 'curiosity')] = 0.3 self.weights[('curiosity', neuron_name)] = 0.3 elif neuron_type == 'reward': self.reward_neuron_count += 1 self.weights[(neuron_name, 'satisfaction')] = 0.35 self.weights[(neuron_name, 'happiness')] = 0.25 self.neurogenesis_data['new_neurons'].append(neuron_name) return neuron_name def _get_neuron_value(self, val) -> float: """Convert value to float for calculations""" if isinstance(val, bool): return 100.0 if val else 0.0 if isinstance(val, (int, float)): return float(val) return 0.0 def get_statistics(self) -> Dict: """Get brain statistics""" return { 'total_neurons': len(self.positions), 'total_connections': len(self.weights), 'custom_neurons': len(self.custom_neurons), 'stress_neurons': self.stress_neuron_count, 'novelty_neurons': self.novelty_neuron_count, 'reward_neurons': self.reward_neuron_count, 'weight_updates': len(self.weight_history), 'avg_weight': sum(self.weights.values()) / len(self.weights) if self.weights else 0, } # ============================================================================ # HEADLESS SIMULATION # ============================================================================ class HeadlessSimulation: """ Headless simulation runner for brain training. Runs the simulation loop without GUI, with accelerated time. """ def __init__(self, config: TrainingConfig = None): self.config = config or TrainingConfig() self.brain = HeadlessBrain(config) self.squid = HeadlessSquid() # Simulation state self.tick = 0 self.running = False # Environment self.food_items: List[Dict] = [] self.poop_items: List[Dict] = [] # Statistics self.stats = { 'ticks_completed': 0, 'neurons_created': 0, 'hebbian_updates': 0, 'food_eaten': 0, 'startles': 0, 'peak_anxiety': 0, 'peak_happiness': 0, } # Event queue (for scenarios) self.event_queue: List[Dict] = [] # Progress callback self.progress_callback = None def load_brain(self, filepath: str) -> bool: """Load a brain from file""" return self.brain.load_brain_file(filepath) def load_scenario(self, scenario_name: str) -> bool: """Load a predefined training scenario""" if scenario_name not in TRAINING_SCENARIOS: print(f"✗ Unknown scenario: {scenario_name}") print(f" Available: {list(TRAINING_SCENARIOS.keys())}") return False scenario = TRAINING_SCENARIOS[scenario_name] print(f"✓ Loading scenario: {scenario.name}") print(f" Description: {scenario.description}") print(f" Duration: {scenario.duration_ticks} ticks") # Apply config overrides for key, value in scenario.config_overrides.items(): if hasattr(self.config, key): setattr(self.config, key, value) # Queue events self.event_queue = list(scenario.events) return True def run(self, ticks: int = 1000, progress_interval: int = 100) -> Dict: """ Run the simulation for specified number of ticks. Args: ticks: Number of simulation ticks to run progress_interval: How often to report progress Returns: Dictionary with final statistics """ self.running = True start_time = time.time() print(f"\n🚀 Starting headless training: {ticks} ticks") print(f" Brain: {len(self.brain.positions)} neurons, {len(self.brain.weights)} connections") print() for self.tick in range(ticks): if not self.running: break # Process queued events self._process_events() # Simulation step self._simulation_step() # Progress report if progress_interval > 0 and (self.tick + 1) % progress_interval == 0: elapsed = time.time() - start_time tps = (self.tick + 1) / elapsed if elapsed > 0 else 0 self._report_progress(tps) # Final statistics elapsed = time.time() - start_time self.stats['ticks_completed'] = self.tick + 1 self.stats['elapsed_seconds'] = elapsed self.stats['ticks_per_second'] = (self.tick + 1) / elapsed if elapsed > 0 else 0 print(f"\n✓ Training complete!") print(f" Ticks: {self.stats['ticks_completed']}") print(f" Time: {elapsed:.2f}s ({self.stats['ticks_per_second']:.1f} ticks/sec)") print(f" Neurons created: {self.stats['neurons_created']}") print(f" Hebbian updates: {self.stats['hebbian_updates']}") brain_stats = self.brain.get_statistics() print(f"\n Brain Statistics:") print(f" Total neurons: {brain_stats['total_neurons']}") print(f" Connections: {brain_stats['total_connections']}") print(f" Custom neurons: {brain_stats['custom_neurons']}") return {**self.stats, **brain_stats} def _simulation_step(self): """Execute one simulation tick""" # 1. Update squid state self.squid.update() # 2. Environment events self._update_environment() # 3. Update brain with squid state squid_state = self.squid.get_state_dict() self.brain.update_state(squid_state) # 4. Propagate brain activations self.brain.propagate() # 5. Hebbian learning (periodic) if self.tick % self.config.hebbian_interval == 0: updated = self.brain.perform_hebbian_learning() if updated: self.stats['hebbian_updates'] += len(updated) # 6. Neurogenesis check new_neuron = self.brain.check_neurogenesis(squid_state, self.tick) if new_neuron: self.stats['neurons_created'] += 1 print(f" 🧠 Tick {self.tick}: Created neuron '{new_neuron}'") # 7. Track statistics self.stats['peak_anxiety'] = max(self.stats['peak_anxiety'], self.squid.anxiety) self.stats['peak_happiness'] = max(self.stats['peak_happiness'], self.squid.happiness) def _update_environment(self): """Update environment (spawn/despawn items, random events)""" # Food spawning if random.random() < self.config.food_spawn_chance: self.food_items.append({'x': random.random(), 'y': random.random()}) self.squid.food_visible = True # Food consumption if self.squid.food_visible and random.random() < 0.1: self.squid.feed() self.stats['food_eaten'] += 1 if self.food_items: self.food_items.pop(0) self.squid.food_visible = len(self.food_items) > 0 # Poop spawning if random.random() < self.config.poop_spawn_chance: self.poop_items.append({'x': random.random(), 'y': random.random()}) self.squid.cleanliness = max(0, self.squid.cleanliness - 5) # Random startle if random.random() < self.config.startle_chance: self.squid.startle() self.stats['startles'] += 1 # Plant proximity (random for now) self.squid.plant_nearby = random.random() < 0.2 def _process_events(self): """Process queued scenario events""" events_to_remove = [] for event in self.event_queue: if event.get('tick', 0) == self.tick: self._execute_event(event) events_to_remove.append(event) for event in events_to_remove: self.event_queue.remove(event) def _execute_event(self, event: Dict): """Execute a scenario event""" event_type = event.get('type', '') if event_type == 'set_state': state = event.get('state', {}) for key, value in state.items(): if hasattr(self.squid, key): setattr(self.squid, key, value) print(f" 📋 Tick {self.tick}: Set state {state}") elif event_type == 'feed': self.squid.feed() self.stats['food_eaten'] += 1 print(f" 🍕 Tick {self.tick}: Fed squid") elif event_type == 'startle': self.squid.startle() self.stats['startles'] += 1 print(f" ⚡ Tick {self.tick}: Startled squid") elif event_type == 'clean': self.squid.clean() print(f" 🧹 Tick {self.tick}: Cleaned environment") elif event_type == 'new_object': self.squid.curiosity = min(100, self.squid.curiosity + 20) print(f" 🆕 Tick {self.tick}: New object encountered") def _report_progress(self, tps: float): """Report training progress""" stats = self.brain.get_statistics() squid = self.squid progress_pct = (self.tick + 1) / max(1, self.stats.get('target_ticks', self.tick + 1)) * 100 print(f" [{self.tick+1:6d}] {tps:6.1f} t/s | " f"Neurons: {stats['total_neurons']:2d} | " f"H:{squid.hunger:5.1f} A:{squid.anxiety:5.1f} S:{squid.satisfaction:5.1f} | " f"Created: {self.stats['neurons_created']}") def stop(self): """Stop the simulation""" self.running = False # ============================================================================ # COMMAND LINE INTERFACE # ============================================================================ def main(): parser = argparse.ArgumentParser( description='Dosidicus-2 Headless Brain Trainer', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: Train a brain for 10000 ticks: python headless_trainer.py --brain my_brain.json --ticks 10000 --output trained.json Run a stress test scenario: python headless_trainer.py --brain my_brain.json --scenario stress_test --output stress_trained.json List available scenarios: python headless_trainer.py --list-scenarios Quick training with default brain: python headless_trainer.py --ticks 5000 --output quick_trained.json """ ) parser.add_argument('--brain', '-b', type=str, help='Path to brain JSON file to load') parser.add_argument('--output', '-o', type=str, help='Path to save trained brain') parser.add_argument('--ticks', '-t', type=int, default=10000, help='Number of ticks to train (default: 10000)') parser.add_argument('--scenario', '-s', type=str, help='Training scenario to use') parser.add_argument('--list-scenarios', action='store_true', help='List available training scenarios') parser.add_argument('--progress', '-p', type=int, default=500, help='Progress report interval (default: 500)') parser.add_argument('--quiet', '-q', action='store_true', help='Minimal output') # Config overrides parser.add_argument('--learning-rate', type=float, help='Hebbian learning rate') parser.add_argument('--neurogenesis', type=bool, help='Enable neurogenesis') parser.add_argument('--max-neurons', type=int, help='Maximum neurons allowed') args = parser.parse_args() # List scenarios if args.list_scenarios: print("\nAvailable Training Scenarios:") print("-" * 60) for name, scenario in TRAINING_SCENARIOS.items(): print(f"\n {name}:") print(f" {scenario.description}") print(f" Duration: {scenario.duration_ticks} ticks") if scenario.config_overrides: print(f" Config overrides: {scenario.config_overrides}") print() return # Build config config = TrainingConfig() if args.learning_rate: config.learning_rate = args.learning_rate if args.neurogenesis is not None: config.neurogenesis_enabled = args.neurogenesis if args.max_neurons: config.max_neurons = args.max_neurons # Create simulation sim = HeadlessSimulation(config) # Load brain if args.brain: if not os.path.exists(args.brain): print(f"✗ Brain file not found: {args.brain}") sys.exit(1) if not sim.load_brain(args.brain): sys.exit(1) else: print("ℹ Using default brain architecture") # Load scenario ticks = args.ticks if args.scenario: if not sim.load_scenario(args.scenario): sys.exit(1) # Use scenario duration if longer scenario = TRAINING_SCENARIOS.get(args.scenario) if scenario and scenario.duration_ticks > ticks: ticks = scenario.duration_ticks # Run training progress = 0 if args.quiet else args.progress sim.stats['target_ticks'] = ticks try: stats = sim.run(ticks=ticks, progress_interval=progress) except KeyboardInterrupt: print("\n\n⚠ Training interrupted by user") sim.stop() stats = sim.stats # Save output if args.output: sim.brain.save_brain(args.output) else: # Default output name timestamp = time.strftime('%Y%m%d_%H%M%S') output_name = f"trained_brain_{timestamp}.json" sim.brain.save_brain(output_name) print("\n✓ Done!") if __name__ == '__main__': main() ================================================ FILE: images/decoration/DecorationsFolder ================================================ ================================================ FILE: linux_setup.sh ================================================ #!/usr/bin/env bash # ============================================================= # Dosidicus - Linux Setup & Launch Script # https://github.com/ViciousSquid/Dosidicus # ============================================================= # # If you hit a Qt platform error like `could not load the Qt platform plugin "xcb", run the following: # sudo apt install libxcb-xinerama0 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xkb1 libxkbcommon-x11-0 set -e REPO_URL="https://github.com/ViciousSquid/Dosidicus.git" # Use the current directory where the script lives INSTALL_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" VENV_DIR="$INSTALL_DIR/.venv" # ── Colours ────────────────────────────────────────────────── RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' info() { echo -e "${CYAN}▸ $*${NC}"; } success() { echo -e "${GREEN}✔ $*${NC}"; } warn() { echo -e "${YELLOW}⚠ $*${NC}"; } error() { echo -e "${RED}✖ $*${NC}"; exit 1; } echo -e "${BOLD}" echo " DOSIDICUS by ViciousSquid" echo -e "${NC}" echo -e " ${BOLD}A cognitive sandbox for raising digital squids${NC}" echo " ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" # ── 1. Check Python ─────────────────────────────────────────── info "Checking Python version..." if ! command -v python3 &>/dev/null; then error "python3 not found. Install it with: sudo apt install python3" fi PY_VER=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') PY_MAJOR=$(echo "$PY_VER" | cut -d. -f1) PY_MINOR=$(echo "$PY_VER" | cut -d. -f2) if [[ "$PY_MAJOR" -lt 3 || ("$PY_MAJOR" -eq 3 && "$PY_MINOR" -lt 9) ]]; then error "Python 3.9+ required. Found: $PY_VER" fi success "Python $PY_VER found" # ── 2. Check / install system deps ─────────────────────────── info "Checking system dependencies..." MISSING_PKGS=() for pkg in git python3-venv python3-dev; do if ! dpkg -s "$pkg" &>/dev/null 2>&1; then MISSING_PKGS+=("$pkg") fi done # PyQt5 often needs these on Linux for pkg in libxcb-xinerama0 libxcb-cursor0 libgl1 libxcb-icccm4 libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 libxcb-xkb1 libxkbcommon-x11-0; do if ! dpkg -s "$pkg" &>/dev/null 2>&1; then MISSING_PKGS+=("$pkg") fi done if [[ ${#MISSING_PKGS[@]} -gt 0 ]]; then warn "Missing packages: ${MISSING_PKGS[*]}" echo -e " Installing with sudo..." sudo apt-get update -qq sudo apt-get install -y -qq "${MISSING_PKGS[@]}" success "System packages installed" else success "All system packages present" fi # ── 3. Clone or update repo ─────────────────────────────────── # If we are already inside a git repo, just pull. if [[ -d "$INSTALL_DIR/.git" ]]; then info "Repo already exists at $INSTALL_DIR — pulling latest..." git -C "$INSTALL_DIR" pull --ff-only success "Updated to latest" elif [[ ! -f "$INSTALL_DIR/main.py" ]]; then info "Cloning Dosidicus into $INSTALL_DIR..." git clone "$REPO_URL" "$INSTALL_DIR" success "Cloned successfully" fi cd "$INSTALL_DIR" # ── 4. Create virtualenv ────────────────────────────────────── if [[ ! -d "$VENV_DIR" ]]; then info "Creating Python virtual environment..." python3 -m venv "$VENV_DIR" success "Virtual environment created" else success "Virtual environment already exists" fi # Activate # shellcheck disable=SC1091 source "$VENV_DIR/bin/activate" # ── 5. Install Python dependencies ─────────────────────────── info "Installing Python dependencies..." pip install --upgrade pip --quiet if [[ -f "requirements.txt" ]]; then pip install -r requirements.txt --quiet else pip install --quiet "PyQt5>=5.15" "numpy>=1.21" fi # Optional but recommended for PyQt5 pip install --quiet "pyqt5-sip" 2>/dev/null || true success "Python packages installed" # ── 6. Create launcher script ───────────────────────────────── LAUNCHER="$INSTALL_DIR/run.sh" cat > "$LAUNCHER" << LAUNCH #!/usr/bin/env bash DIR="\$(cd "\$(dirname "\${BASH_SOURCE[0]}")" && pwd)" source "\$DIR/.venv/bin/activate" # Fix for Qt/XCB issues on some Linux setups export QT_QPA_PLATFORM="\${QT_QPA_PLATFORM:-xcb}" # Crucial: This ensures main.py can find the 'src' folder export PYTHONPATH="\$DIR:\$PYTHONPATH" cd "\$DIR" exec python3 main.py "\$@" LAUNCH chmod +x "$LAUNCHER" success "Launcher script created: $LAUNCHER" # ── 7. Create desktop shortcut (optional) ──────────────────── DESKTOP_FILE="$HOME/.local/share/applications/dosidicus.desktop" mkdir -p "$(dirname "$DESKTOP_FILE")" cat > "$DESKTOP_FILE" << DESKTOP [Desktop Entry] Name=Dosidicus Comment=A cognitive sandbox for raising digital squids Exec=$LAUNCHER Icon=$INSTALL_DIR/images/icon.png Terminal=false Type=Application Categories=Game;Simulation; DESKTOP success "Desktop shortcut created" # ── 8. Done! ────────────────────────────────────────────────── echo "" echo -e "${GREEN}${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo -e " Setup complete!" echo -e "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" echo "" echo -e " ${BOLD}To launch Dosidicus:${NC}" echo -e " ${CYAN}$LAUNCHER${NC}" echo "" echo -e " ${BOLD}Or from any terminal:${NC}" echo -e " ${CYAN}cd $INSTALL_DIR && source .venv/bin/activate && python3 main.py${NC}" echo "" echo -e " ${BOLD}Headless/training mode:${NC}" echo -e " ${CYAN}cd $INSTALL_DIR && source .venv/bin/activate && python3 headless/headless_trainer.py${NC}" echo "" # ── 9. Offer to launch now ──────────────────────────────────── read -rp " Launch Dosidicus now? [y/N] " REPLY if [[ "$REPLY" =~ ^[Yy]$ ]]; then info "Launching..." exec "$LAUNCHER" fi ================================================ FILE: main.py ================================================ # Dosidicus - a digital pet with a neural network | 2.1.2.0 March 2026 # main.py Entrypoint from PyQt5 import QtWidgets, QtCore, QtGui import sys import os # BOOTSTRAP: Add the current directory and src directory to sys.path # This ensures "from src.ui import Ui" works even if launched from elsewhere. BASE_DIR = os.path.dirname(os.path.abspath(__file__)) if BASE_DIR not in sys.path: sys.path.insert(0, BASE_DIR) import time import sys import json import os import shutil import traceback import multiprocessing import logging from PyQt5 import QtWidgets, QtCore import random import argparse from src.ui import Ui from src.tamagotchi_logic import TamagotchiLogic from src.squid import Squid, Personality from src.splash_screen import SplashScreen from src.save_manager import SaveManager from src.brain_tool import SquidBrainWindow from src.display_scaling import DisplayScaling from src.learning import LearningConfig from src.plugin_manager import PluginManager from src.brain_worker import BrainWorker from src.config_manager import ConfigManager from src.localisation import Localisation def launch_brain_designer_process(): """Entry point for Brain Designer in a separate process""" from src.brain_designer_launcher import launch_brain_designer_process as _launch _launch() def setup_logging_configuration(): """Initialize logging configuration""" os.environ['QT_LOGGING_RULES'] = '*.debug=false;qt.qpa.*=false;qt.style.*=false' os.makedirs('logs', exist_ok=True) logging.basicConfig( filename='logs/dosidicus_log.txt', level=logging.ERROR, format='%(asctime)s - %(levelname)s - %(message)s' ) def perform_cleanup_and_exit(): """Recursively delete __pycache__ and logs directories.""" print("🧹 Cleaning environment...") root_dir = os.path.dirname(os.path.abspath(__file__)) deleted_count = 0 for root, dirs, files in os.walk(root_dir, topdown=True): # Filter and remove specific directories # We iterate over a copy of dirs so we can modify the original list safely for name in list(dirs): if name in ['__pycache__', 'logs']: path = os.path.join(root, name) try: shutil.rmtree(path) print(f" Deleted: {path}") dirs.remove(name) # Prevent os.walk from trying to enter this dir deleted_count += 1 except Exception as e: print(f" ❌ Failed to delete {path}: {e}") print(f"✨ Cleanup complete. Removed {deleted_count} directories.") def global_exception_handler(exctype, value, tb): """Global exception handler to log unhandled exceptions""" error_message = ''.join(traceback.format_exception(exctype, value, tb)) logging.error("Unhandled exception:\n%s", error_message) QtWidgets.QMessageBox.critical(None, "Error", "An unexpected error occurred. Please check dosidicus_log.txt for details.") class TeeStream: """Duplicate output to both console and file""" def __init__(self, original_stream, file_stream): self.original_stream = original_stream self.file_stream = file_stream def write(self, data): self.original_stream.write(data) self.file_stream.write(data) self.file_stream.flush() def flush(self): self.original_stream.flush() self.file_stream.flush() class TimedMessageBox(QtWidgets.QDialog): """A message box that auto-closes after a timeout with a default choice""" def __init__(self, parent, title, message, timeout_seconds=5): super().__init__(parent) self.setWindowTitle(title) self.timeout_seconds = timeout_seconds self.remaining_seconds = timeout_seconds self.result_value = QtWidgets.QMessageBox.No # Default to No self.loc = Localisation.instance() # Setup UI layout = QtWidgets.QVBoxLayout() self.message_label = QtWidgets.QLabel(message) self.message_label.setStyleSheet(f"font-size: {DisplayScaling.font_size(13)}pt;") layout.addWidget(self.message_label) # Auto-decline message self.timer_label = QtWidgets.QLabel(self.loc.get("auto_decline", seconds=self.remaining_seconds)) self.timer_label.setStyleSheet(f"color: gray; font-size: {DisplayScaling.font_size(11)}pt;") layout.addWidget(self.timer_label) # Buttons self.button_box = QtWidgets.QDialogButtonBox( QtWidgets.QDialogButtonBox.Yes | QtWidgets.QDialogButtonBox.No ) # Localise buttons manually since we use a custom dict system self.button_box.button(QtWidgets.QDialogButtonBox.Yes).setText(self.loc.get("yes")) self.button_box.button(QtWidgets.QDialogButtonBox.No).setText(self.loc.get("no")) self.button_box.accepted.connect(self.accept_yes) self.button_box.rejected.connect(self.reject_no) layout.addWidget(self.button_box) self.setLayout(layout) # Setup timer self.timer = QtCore.QTimer(self) self.timer.timeout.connect(self.update_countdown) self.timer.start(1000) # Update every second def update_countdown(self): """Update the countdown and auto-close when time runs out""" self.remaining_seconds -= 1 self.timer_label.setText(self.loc.get("auto_decline", seconds=self.remaining_seconds)) if self.remaining_seconds <= 0: self.timer.stop() self.reject_no() # Auto-close with No def accept_yes(self): """User clicked Yes""" self.timer.stop() self.result_value = QtWidgets.QMessageBox.Yes self.accept() def reject_no(self): """User clicked No or timeout occurred""" self.timer.stop() self.result_value = QtWidgets.QMessageBox.No self.reject() def get_result(self): """Get the result after dialog closes""" return self.result_value class MainWindow(QtWidgets.QMainWindow): def __init__(self, specified_personality=None, debug_mode=False, neuro_cooldown=None): super().__init__() # Apply configured language from config.ini config_manager = ConfigManager() language = config_manager.get_language() Localisation.instance().set_language(language) print(f"📄 Applied language from config: {language}") # Initialize configuration self.config = LearningConfig() if neuro_cooldown is not None: self.config.neurogenesis['cooldown'] = neuro_cooldown # Add initialization tracking flag self._initialization_complete = False # Set up debugging self.debug_mode = debug_mode if self.debug_mode: self.setup_logging() # Initialize UI first logging.debug("Initializing UI") self.user_interface = Ui(self, debug_mode=self.debug_mode) # Initialize SquidBrainWindow with config logging.debug("Initializing SquidBrainWindow") self.brain_window = SquidBrainWindow(None, self.debug_mode, self.config) # Store the original window reference to prevent garbage collection self._brain_window_ref = self.brain_window # Explicitly force creation of all tab contents QtCore.QTimer.singleShot(100, self.preload_brain_window_tabs) # Continue with normal initialization self.brain_window.set_tamagotchi_logic(None) # Placeholder to ensure initialization self.user_interface.squid_brain_window = self.brain_window # Initialize plugin manager after UI and brain window logging.debug("Initializing PluginManager") self.plugin_manager = PluginManager() print(f"> Plugin manager initialized: {self.plugin_manager}") self.specified_personality = specified_personality self.neuro_cooldown = neuro_cooldown self.squid = None # Check for existing save data self.save_manager = SaveManager("saves") # Track whether we want to show tutorial self.show_tutorial = False # ===== PERFORMANCE FIX: Single BrainWorker managed by brain_tool ===== # Don't create another worker here - SquidBrainWindow creates and shares it # Access via self.brain_window.brain_worker if needed self.brain_worker = None print("ℹ️ BrainWorker managed by SquidBrainWindow") # Initialize the game logging.debug("Initializing game") self.initialize_game() # Now that tamagotchi_logic is created, set it in plugin_manager and brain_window logging.debug("Setting tamagotchi_logic references") self.plugin_manager.tamagotchi_logic = self.tamagotchi_logic self.tamagotchi_logic.plugin_manager = self.plugin_manager self.brain_window.set_tamagotchi_logic(self.tamagotchi_logic) # New in 2.4.5.0 : Create a unique personality starter neuron squid = self.tamagotchi_logic.squid brain_widget = self.brain_window.brain_widget if (squid and squid.personality and brain_widget and hasattr(brain_widget, 'enhanced_neurogenesis')): if not squid._has_personality_starter_neuron(): neuron = brain_widget.enhanced_neurogenesis.create_personality_starter_neuron( squid.personality.value, brain_widget.state ) if neuron: print(f"🧬 Personality starter neuron created: {neuron}") # Load and initialize plugins after core components logging.debug("Loading plugins") plugin_results = self.plugin_manager.load_all_plugins() # Setup plugins with tamagotchi_logic reference for plugin_name, plugin_data in self.plugin_manager.plugins.items(): instance = plugin_data.get('instance') if instance and hasattr(instance, 'setup') and not plugin_data.get('is_setup', False): try: instance.setup(self.plugin_manager, self.tamagotchi_logic) plugin_data['is_setup'] = True except Exception as e: print(f"Error setting up plugin {plugin_name}: {e}") # CRITICAL FIX: Re-load achievement data since plugin instances were replaced # during load_all_plugins(), discarding any data loaded earlier if self.save_manager.save_exists(): save_data = self.save_manager.load_game() if save_data and 'achievements' in save_data: self._restore_achievements_data(save_data['achievements']) # Update status bar with plugin information if hasattr(self.user_interface, 'status_bar'): self.user_interface.status_bar.update_plugins_status(self.plugin_manager) # Connect signals self.user_interface.new_game_action.triggered.connect(self.start_new_game) self.user_interface.load_action.triggered.connect(self.load_game) self.user_interface.save_action.triggered.connect(self.save_game) self.user_interface.decorations_action.triggered.connect(self.user_interface.toggle_decoration_window) # Initialize plugin menu - do this AFTER loading plugins self.user_interface.apply_plugin_menu_registrations(self.plugin_manager) # Position window 300 pixels to the left of default position desktop = QtWidgets.QApplication.desktop() screen_rect = desktop.screenGeometry() window_rect = self.geometry() center_x = screen_rect.center().x() window_x = center_x - (window_rect.width() // 2) # Default centered X position # Move 300 pixels to the left self.move(window_x - 300, self.y()) if self.debug_mode: print(f"DEBUG MODE ENABLED: Console output is being logged to console.txt") self.setup_facts_timer() def preload_brain_window_tabs(self): """Force creation of all tab contents to prevent crashes during tutorial""" print("Pre-loading brain window tabs...") if not hasattr(self, 'brain_window') or not self.brain_window: print("⚠️ Brain window not initialized, cannot preload") return try: # Force the window to process events and initialize all tabs if hasattr(self.brain_window, 'tabs'): # Visit each tab to ensure it's loaded tab_count = self.brain_window.tabs.count() # Initialize tabs array to prevent garbage collection if not hasattr(self, '_preloaded_tabs'): self._preloaded_tabs = [] # Remember if window was visible before we started was_visible = self.brain_window.isVisible() #print(f"📋 Brain window was_visible before preload: {was_visible}") # Temporarily show the window off-screen to force loading original_pos = self.brain_window.pos() self.brain_window.move(-10000, -10000) # Move off-screen self.brain_window.show() # Force each tab to be displayed at least once for i in range(tab_count): self.brain_window.tabs.setCurrentIndex(i) QtWidgets.QApplication.processEvents() # Get and store references to tab widgets widget = self.brain_window.tabs.widget(i) if widget: self._preloaded_tabs.append(widget) #print(f" ✓ Preloaded tab {i}: {self.brain_window.tabs.tabText(i)}") # Return to first tab (Network/Brain tab) self.brain_window.tabs.setCurrentIndex(0) QtWidgets.QApplication.processEvents() #print(f"📋 Reset to first tab: {self.brain_window.tabs.tabText(0)}") # Restore original position self.brain_window.move(original_pos) # Only hide if it wasn't visible before (don't hide if user is viewing it) if not was_visible: self.brain_window.hide() print("📋 Brain window hidden after preload (was not visible before)") else: print("📋 Brain window kept visible after preload (was visible before)") print(f"✅ Successfully preloaded {len(self._preloaded_tabs)} tabs") except Exception as e: print(f"❌ Error preloading tabs: {e}") import traceback traceback.print_exc() def setup_logging(self): """Set up console logging to file""" if not hasattr(sys, '_original_stdout'): sys._original_stdout = sys.stdout sys._original_stderr = sys.stderr console_log = open('console.txt', 'w', encoding='utf-8') sys.stdout = TeeStream(sys._original_stdout, console_log) sys.stderr = TeeStream(sys._original_stderr, console_log) def setup_facts_timer(self): """Rare ocean-blue Humboldt squid facts every 5 minutes""" config_manager = ConfigManager() if not config_manager.get_facts_enabled(): return self.fact_timer = QtCore.QTimer(self) self.fact_timer.timeout.connect(self.show_random_squid_fact) self.fact_timer.start(config_manager.get_fact_interval_ms()) def show_random_squid_fact(self): """Show one short Humboldt squid fact – big, bright blue, always visible""" try: from src.squid_facts import get_random_fact fact = get_random_fact() if not fact: return msg = f"🌊 Humboldt Fact: {fact}" # Preferred: status bar (works everywhere, supports color) if hasattr(self.user_interface, 'status_bar') and self.user_interface.status_bar: colored_msg = f'{msg}' self.user_interface.status_bar.showMessage(colored_msg, 8000) # 8 seconds else: # Fallback: plain message self.user_interface.show_message(msg) print(f"[Facts] DISPLAYED: {fact[:80]}...") # helpful console confirmation except Exception as e: print(f"[Facts] Error showing fact: {e}") def initialize_game(self): if hasattr(self.save_manager, 'cleanup_duplicate_saves'): self.save_manager.cleanup_duplicate_saves() if self.save_manager.save_exists() and self.specified_personality is None: print("\x1b[32mExisting save data found and will be loaded\x1b[0m") self.squid = Squid(self.user_interface, None, None) self.tamagotchi_logic = TamagotchiLogic(self.user_interface, self.squid, self.brain_window) self.squid.tamagotchi_logic = self.tamagotchi_logic self.user_interface.tamagotchi_logic = self.tamagotchi_logic self.brain_window.tamagotchi_logic = self.tamagotchi_logic if hasattr(self.brain_window, 'set_tamagotchi_logic'): self.brain_window.set_tamagotchi_logic(self.tamagotchi_logic) self.load_game() if hasattr(self.tamagotchi_logic, 'statistics_window'): self.tamagotchi_logic.statistics_window.update_statistics() brain_widget = self.brain_window.brain_widget for name in brain_widget.original_neurons: brain_widget.visible_neurons.add(name) if hasattr(brain_widget, 'neurogenesis_data'): for name in brain_widget.neurogenesis_data.get('new_neurons_details', {}): brain_widget.visible_neurons.add(name) core = brain_widget.original_neurons for idx, name in enumerate(core): QtCore.QTimer.singleShot(idx * 500, lambda n=name: brain_widget.reveal_neuron(n)) self.brain_window.show() self.user_interface.brain_action.setChecked(True) else: print("\x1b[92m-------------- STARTING A NEW SIMULATION --------------\x1b[0m") self.create_new_game(self.specified_personality) self.tamagotchi_logic = TamagotchiLogic(self.user_interface, self.squid, self.brain_window) self.squid.tamagotchi_logic = self.tamagotchi_logic self.user_interface.tamagotchi_logic = self.tamagotchi_logic self.brain_window.tamagotchi_logic = self.tamagotchi_logic if hasattr(self.brain_window, 'set_tamagotchi_logic'): self.brain_window.set_tamagotchi_logic(self.tamagotchi_logic) if not self.save_manager.save_exists(): QtCore.QTimer.singleShot(500, self.delayed_tutorial_check) self._initialization_complete = True def delayed_tutorial_check(self): """Check if the user wants to see the tutorial after UI is responsive""" # Process pending events to ensure UI is responsive QtWidgets.QApplication.processEvents() # Now check tutorial preference self.check_tutorial_preference() # If tutorial was chosen, schedule it for later if self.show_tutorial: # We'll show tutorial when the game starts pass else: # Just open initial windows if no tutorial QtCore.QTimer.singleShot(500, self.open_initial_windows) def create_new_game(self, specified_personality=None): """Create a new game instance""" # Delete any existing save to ensure clean start if self.save_manager.save_exists(): self.save_manager.delete_save() # Choose personality randomly if not specified if specified_personality is None: personality = random.choice(list(Personality)) else: personality = specified_personality # Create new squid with chosen personality self.squid = Squid( user_interface=self.user_interface, tamagotchi_logic=None, personality=personality, neuro_cooldown=self.neuro_cooldown ) print(f" ") print(f">> Generated squid personality: {self.squid.personality.value}") print(f" ") if self.neuro_cooldown: print(f"\x1b[43m Neurogenesis cooldown:\033[0m {self.neuro_cooldown}") self.squid.memory_manager.clear_all_memories() self.show_splash_screen() def check_tutorial_preference(self): """Show a dialog asking if the user wants to see the tutorial with 5-second timeout""" # Don't ask about tutorial if save data exists if self.save_manager.save_exists(): self.show_tutorial = False return # Show timed dialog dialog = TimedMessageBox( self, Localisation.instance().get("startup"), Localisation.instance().get("show_tutorial_q"), timeout_seconds=5 ) dialog.exec_() # Set flag based on user's choice (defaults to No if timeout) self.show_tutorial = (dialog.get_result() == QtWidgets.QMessageBox.Yes) def position_and_show_decoration_window(self): """Position the decoration window in the bottom right and show it""" if hasattr(self.user_interface, 'decoration_window') and self.user_interface.decoration_window: # Get screen geometry screen_geometry = QtWidgets.QApplication.desktop().availableGeometry() # Position window in bottom right decoration_window = self.user_interface.decoration_window decoration_window.move( screen_geometry.right() - decoration_window.width() - 20, screen_geometry.bottom() - decoration_window.height() - 20 ) decoration_window.show() self.user_interface.decorations_action.setChecked(True) def start_new_game(self): """Start a new game, deleting any existing save""" # First, ask for confirmation with a timed dialog confirm_dialog = TimedMessageBox( self, "Confirm New Game", "Are you sure you want to start a new game? This will delete all current progress and save data.", timeout_seconds=10 ) confirm_dialog.exec_() # If user declined or let it timeout, abort if confirm_dialog.get_result() != QtWidgets.QMessageBox.Yes: print("New game cancelled by user") return print("Starting new game...") # Ask about tutorial tutorial_dialog = TimedMessageBox( self, Localisation.instance().get("tutorial_title"), Localisation.instance().get("tutorial_query"), timeout_seconds=5 ) tutorial_dialog.exec_() self.show_tutorial = (tutorial_dialog.get_result() == QtWidgets.QMessageBox.Yes) # Stop current simulation if running if hasattr(self, 'tamagotchi_logic'): self.tamagotchi_logic.stop() # Stop autosave timer if it exists if hasattr(self.tamagotchi_logic, 'autosave_timer'): self.tamagotchi_logic.autosave_timer.stop() # Delete all save files (both autosave and manual save) if self.save_manager.save_exists(): self.save_manager.delete_save(is_autosave=True) # Delete autosave self.save_manager.delete_save(is_autosave=False) # Delete manual save print("All save files deleted") # Clear memory files memory_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '_memory') if os.path.exists(memory_dir): import shutil shutil.rmtree(memory_dir) print("Memory directory cleared") # Clear all neurons and state from brain window if hasattr(self, 'brain_window') and hasattr(self.brain_window, 'brain_widget'): brain_widget = self.brain_window.brain_widget # Clear visible neurons brain_widget.visible_neurons = set() # Clear neurogenesis data if hasattr(brain_widget, 'neurogenesis_data'): brain_widget.neurogenesis_data = { 'new_neurons': [], 'new_neurons_details': {}, 'new_synapses': [] } # Clear enhanced neurogenesis tracking if hasattr(brain_widget, 'enhanced_neurogenesis'): brain_widget.enhanced_neurogenesis.reset_state() # Reset brain widget state if hasattr(brain_widget, 'state'): brain_widget.state = brain_widget.create_initial_state() # Clear hebbian learning state if hasattr(brain_widget, 'hebbian'): brain_widget.hebbian.reset() print("Brain state cleared") # Clear all decorations and items from the scene if hasattr(self, 'user_interface') and hasattr(self.user_interface, 'scene'): # Remove all items except the background (if it exists) items_to_remove = [] background_item = getattr(self.user_interface, 'background', None) for item in self.user_interface.scene.items(): # Keep the background (if it exists) and remove everything else if background_item is None or item != background_item: items_to_remove.append(item) for item in items_to_remove: self.user_interface.scene.removeItem(item) # Clear decoration tracking if hasattr(self.user_interface, 'awarded_decorations'): self.user_interface.awarded_decorations = set() print("Scene cleared") # Create new game (creates squid but not tamagotchi_logic) self.create_new_game(self.specified_personality) # Create TamagotchiLogic self.tamagotchi_logic = TamagotchiLogic(self.user_interface, self.squid, self.brain_window) # Update references self.squid.tamagotchi_logic = self.tamagotchi_logic self.user_interface.tamagotchi_logic = self.tamagotchi_logic self.brain_window.tamagotchi_logic = self.tamagotchi_logic if hasattr(self.brain_window, 'set_tamagotchi_logic'): self.brain_window.set_tamagotchi_logic(self.tamagotchi_logic) self.plugin_manager.tamagotchi_logic = self.tamagotchi_logic self.tamagotchi_logic.plugin_manager = self.plugin_manager # Create personality starter neuron if needed squid = self.tamagotchi_logic.squid brain_widget = self.brain_window.brain_widget if (squid and squid.personality and brain_widget and hasattr(brain_widget, 'enhanced_neurogenesis')): if not squid._has_personality_starter_neuron(): neuron = brain_widget.enhanced_neurogenesis.create_personality_starter_neuron( squid.personality.value, brain_widget.state ) if neuron: print(f"🧬 Personality starter neuron created: {neuron}") # Reload plugins to ensure they get the new tamagotchi_logic self.plugin_manager.reload_all_plugins() print("New game created successfully!") def load_game(self): """Delegate to tamagotchi_logic""" self.tamagotchi_logic.load_game() def save_game(self): """Delegate to tamagotchi_logic""" if self.squid and self.tamagotchi_logic: self.tamagotchi_logic.save_game() def _restore_achievements_data(self, achievements_data): """Restore achievement data to the achievements plugin after plugin reload. This is needed because plugin instances get replaced during initialization, discarding any previously loaded save data. """ if not achievements_data: return try: if 'achievements' in self.plugin_manager.plugins: plugin_info = self.plugin_manager.plugins['achievements'] instance = plugin_info.get('instance') if instance and hasattr(instance, 'load_save_data'): instance.load_save_data(achievements_data) unlocked_count = len(achievements_data.get('unlocked', {})) print(f"✓ Restored {unlocked_count} achievements") except Exception as e: print(f"[Warning] Could not restore achievements: {e}") def closeEvent(self, event): """Handle window close event""" # Save game before closing if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: self.save_game() # Stop the tamagotchi logic if it has a stop method if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: if hasattr(self.tamagotchi_logic, 'stop'): self.tamagotchi_logic.stop() # Stop the timer if it exists elif hasattr(self.tamagotchi_logic, 'timer') and self.tamagotchi_logic.timer: self.tamagotchi_logic.timer.stop() # Clean up brain state bridge for designer sync if hasattr(self, 'brain_window') and self.brain_window: if hasattr(self.brain_window, 'brain_widget') and self.brain_window.brain_widget: if hasattr(self.brain_window.brain_widget, 'cleanup_brain_bridge'): self.brain_window.brain_widget.cleanup_brain_bridge() # Close brain window if hasattr(self, 'brain_window') and self.brain_window: self.brain_window.close() event.accept() def show_splash_screen(self): """Display splash screen animation with synchronized neuron reveal""" self.splash = SplashScreen(self) self.splash.finished.connect(self.start_simulation) self.splash.finished.connect(lambda: self.tamagotchi_logic.statistics_window.award(1000)) self.splash.second_frame.connect(self.show_hatching_notification) # NEW: award 1000 points the instant the splash ends self.splash.finished.connect( lambda: self.tamagotchi_logic.statistics_window.award(1000) ) # After splash ends, wait 3 s then show the normal feeding hint self.splash.finished.connect(lambda: QtCore.QTimer.singleShot(3000, self.show_feeding_hint)) # Check if this is a brand new game (no save exists) is_new_game = not self.save_manager.save_exists() print(f"🎮 show_splash_screen: is_new_game={is_new_game}, save_exists={self.save_manager.save_exists()}") if is_new_game: # Ensure brain widget starts empty if hasattr(self.brain_window, 'brain_widget') and hasattr(self.brain_window.brain_widget, 'visible_neurons'): self.brain_window.brain_widget.visible_neurons = set() # Show brain window first self.brain_window.show() self.user_interface.brain_action.setChecked(True) # Force immediate processing to ensure brain window is painted QtWidgets.QApplication.processEvents() # Give the brain window time to fully render (longer delay) QtCore.QTimer.singleShot(1500, lambda: self._start_splash_with_reveals()) else: # For loaded games, show brain window with all neurons visible if hasattr(self.brain_window, 'brain_widget') and hasattr(self.brain_window.brain_widget, 'visible_neurons'): brain_widget = self.brain_window.brain_widget # Add all core neurons to visible set for neuron_name in brain_widget.original_neurons: brain_widget.visible_neurons.add(neuron_name) # Also add any neurogenesis neurons that exist if hasattr(brain_widget, 'neurogenesis_data') and 'new_neurons_details' in brain_widget.neurogenesis_data: for neuron_name in brain_widget.neurogenesis_data['new_neurons_details'].keys(): brain_widget.visible_neurons.add(neuron_name) # Show brain window immediately for loaded games self.brain_window.show() self.user_interface.brain_action.setChecked(True) # Force immediate processing to ensure brain window is painted QtWidgets.QApplication.processEvents() # Show splash normally (no animated reveals needed for loaded games) self.splash.show() QtCore.QTimer.singleShot(1000, self.splash.start_animation) def show_feeding_hint(self): """Use the same strip as every other message.""" self.user_interface.show_message("Press D to open the Decorations window") def _start_splash_with_reveals(self): """Start splash screen with neuron reveal synchronization (called after brain window is ready)""" print(" 🥚 A squid is hatching...") # Connect frame changes to neuron reveals self.splash.frame_changed.connect(self._reveal_neuron_for_frame) # Show and start the splash screen animation self.splash.show() QtCore.QTimer.singleShot(500, self.splash.start_animation) # Small delay for splash to show def _reveal_neuron_for_frame(self, frame_index): """Reveal core neurons in sequence with animation frames""" if not hasattr(self.brain_window, 'brain_widget'): return brain_widget = self.brain_window.brain_widget core_neurons = brain_widget.original_neurons # Distribution: 1-2 neurons per frame. Now revised for 8 core neurons (indices 0-7) reveal_map = { 0: [0], # First frame 1: [1], # Second frame 2: [2], # Third frame 3: [3], # Fourth frame 4: [4, 5], # Fifth frame 5: [6, 7] # Sixth frame } # Reveal mapped neurons for this frame for neuron_idx in reveal_map.get(frame_index, []): if neuron_idx < len(core_neurons): neuron_name = core_neurons[neuron_idx] brain_widget.reveal_neuron(neuron_name) #print(f"🧠 Revealed neuron: {neuron_name} (frame {frame_index})") def show_hatching_notification(self): """Display hatching message""" self.user_interface.show_message("Squid is hatching!") def start_simulation(self): """Begin the simulation - brain window is already visible for new games""" self.cleanup_duplicate_squids() self.tamagotchi_logic.set_simulation_speed(1) self.tamagotchi_logic.start_autosave() # Get brain widget reference brain_widget = self.brain_window.brain_widget # Show tutorial if enabled if self.show_tutorial: QtCore.QTimer.singleShot(1000, self.user_interface.show_tutorial_overlay) else: # === FIX START: Manual cleanup if tutorial is skipped === if hasattr(brain_widget, 'is_tutorial_mode'): # Set the flag to False, which allows connections to draw brain_widget.is_tutorial_mode = False # OPTIONAL: If setting the flag doesn't immediately refresh the links, # you may need to force a repaint. If the links are set to show, # a simple repaint will usually draw them once the block is gone. brain_widget.update() # Force repaint # === FIX END === # Only open decoration window automatically (brain window already visible for new games) QtCore.QTimer.singleShot(500, self.position_and_show_decoration_window) def show_tutorial_overlay(self): """Delegate to UI layer and ensure no duplicates remain""" # First do one more duplicate cleanup self.cleanup_duplicate_squids() # Then show the tutorial via the UI if hasattr(self, 'user_interface') and self.user_interface: self.user_interface.show_tutorial_overlay() def open_initial_windows(self): """Open brain window and decorations window""" # Open brain window if hasattr(self, 'brain_window'): self.brain_window.show() self.user_interface.brain_action.setChecked(True) # Open decorations window if hasattr(self.user_interface, 'decoration_window'): self.position_and_show_decoration_window() self.user_interface.decorations_action.setChecked(True) def cleanup_duplicate_squids(self): """Remove any duplicate squid items from the scene""" if not hasattr(self, 'user_interface') or not self.user_interface: return if not hasattr(self, 'squid') or not self.squid: return try: # Get the reference to our genuine squid item main_squid_item = self.squid.squid_item # Get all items in the scene all_items = self.user_interface.scene.items() # Track how many items we find and remove found_count = 0 # Look for graphics items that could be duplicate squids for item in all_items: # Skip our genuine squid item if item == main_squid_item: continue # Only check QGraphicsPixmapItems if isinstance(item, QtWidgets.QGraphicsPixmapItem): # Check if it has the same pixmap dimensions as our squid if (hasattr(item, 'pixmap') and item.pixmap() and main_squid_item.pixmap() and item.pixmap().width() == main_squid_item.pixmap().width() and item.pixmap().height() == main_squid_item.pixmap().height()): print(f"Found potential duplicate squid item - removing") self.user_interface.scene.removeItem(item) found_count += 1 if found_count > 0: print(f"Cleaned up {found_count} duplicate squid items") # Force scene update self.user_interface.scene.update() except Exception as e: print(f"Error during cleanup: {str(e)}") def initialize_multiplayer_manually(self): """Manually initialize multiplayer plugin if needed""" try: # Import the plugin module directly import sys import os plugin_path = os.path.join(os.path.dirname(__file__), 'plugins', 'multiplayer') if plugin_path not in sys.path: sys.path.insert(0, plugin_path) import main as multiplayer_main # Create plugin instance multiplayer_plugin = multiplayer_main.MultiplayerPlugin() # Find it in plugin_manager and add the instance for plugin_name, plugin_data in self.plugin_manager.plugins.items(): if plugin_name.lower() == "multiplayer": plugin_data['instance'] = multiplayer_plugin print(f"Manually added multiplayer plugin instance to {plugin_name}") # Initialize the plugin if hasattr(multiplayer_plugin, 'setup'): multiplayer_plugin.setup(self.plugin_manager) # Register menu actions if hasattr(multiplayer_plugin, 'register_menu_actions'): multiplayer_plugin.register_menu_actions() break # Force the UI to refresh plugin menu self.user_interface.setup_plugin_menu(self.plugin_manager) #print("Manual multiplayer initialization complete") return True except Exception as e: print(f"Error in manual multiplayer initialization: {e}") import traceback traceback.print_exc() return False def main(): """Main entry point""" # CRITICAL for PyInstaller + multiprocessing on Windows multiprocessing.freeze_support() sys.excepthook = global_exception_handler parser = argparse.ArgumentParser(description="Dosidicus digital squid with a neural network") parser.add_argument('-p', '--personality', type=str, choices=[p.value for p in Personality], help='Specify squid personality') parser.add_argument('-d', '--debug', action='store_true', help='Enable debug mode with console logging') parser.add_argument('-nc', '--neurocooldown', type=int, help='Set neurogenesis cooldown in seconds') parser.add_argument('-c', '--clean', action='store_true', help='Clean __pycache__ and logs folders before starting') parser.add_argument('-designer', '--designer', action='store_true', help='Launch Brain Designer standalone') args = parser.parse_args() # Perform cleanup if requested before logging setup if args.clean: perform_cleanup_and_exit() # Launch designer if flag is set if args.designer: print("Launching Brain Designer standalone...") try: # Import and run designer's main function try: from src import brain_designer except ImportError: import brain_designer # brain_designer.main() will parse sys.argv and handle -d and -c flags automatically brain_designer.main() except ImportError as e: print(f"Error: Could not import brain_designer module: {e}") sys.exit(1) except Exception as e: print(f"Error launching designer: {e}") sys.exit(1) return # Exit after designer closes # Initialize logging (replaces previous global setup) setup_logging_configuration() print(f" Personality: {args.personality}") print(f" Debug mode: {args.debug}") print(f" Cooldown {args.neurocooldown or 'will be loaded from config'}") app = QtWidgets.QApplication(sys.argv) try: personality = Personality(args.personality) if args.personality else None main_window = MainWindow(personality, args.debug, args.neurocooldown) main_window.show() sys.exit(app.exec_()) except Exception as e: logging.exception("Fatal error in main") QtWidgets.QMessageBox.critical(None, "Error", f"Critical error: {str(e)}\nSee dosidicus_log.txt for details.") if __name__ == '__main__': main() ================================================ FILE: plugins/achievements/__init__.py ================================================ ================================================ FILE: plugins/achievements/achievements_data.py ================================================ # File: achievements_data.py # All achievement definitions for the Achievements Plugin # Separated from main.py for cleaner organization from dataclasses import dataclass, asdict from typing import Dict from enum import Enum # ============================================================================= # ENUMS AND DATA CLASSES # ============================================================================= class AchievementCategory(Enum): FEEDING = "feeding" NEUROGENESIS = "neurogenesis" SLEEP = "sleep" MILESTONES = "milestones" EXPLORATION = "exploration" CLEANING = "cleaning" HEALTH = "health" INTERACTION = "interaction" INK = "ink" MEMORY = "memory" EMOTIONAL = "emotional" SECRET = "secret" META = "meta" @dataclass class Achievement: id: str name: str # Now stores the localisation KEY description: str # Now stores the localisation KEY icon: str = "🏆" category: str = "milestones" hidden: bool = False points: int = 10 tier: int = 1 target_count: int = 1 def to_dict(self) -> dict: return asdict(self) @dataclass class UnlockedAchievement: achievement_id: str unlocked_at: str progress: int = 0 notified: bool = False def to_dict(self) -> dict: return asdict(self) @classmethod def from_dict(cls, data: dict) -> 'UnlockedAchievement': return cls(**data) # Tier colors for UI TIER_COLORS = { 1: "#CD7F32", # Bronze 2: "#C0C0C0", # Silver 3: "#FFD700", # Gold 4: "#E5E4E2", # Platinum 5: "#B9F2FF", # Diamond } # ============================================================================= # ALL ACHIEVEMENT DEFINITIONS (50+ achievements) # ============================================================================= ACHIEVEMENT_DEFINITIONS: Dict[str, Achievement] = { # ========================================================================= # FEEDING CATEGORY (5) # ========================================================================= "first_feeding": Achievement( id="first_feeding", name="ach_first_feeding_name", description="ach_first_feeding_desc", icon="🍽️", category="feeding", points=10, tier=1, ), "fed_10_times": Achievement( id="fed_10_times", name="ach_fed_10_times_name", description="ach_fed_10_times_desc", icon="🥄", category="feeding", points=15, tier=1, target_count=10, ), "fed_50_times": Achievement( id="fed_50_times", name="ach_fed_50_times_name", description="ach_fed_50_times_desc", icon="🍴", category="feeding", points=25, tier=2, target_count=50, ), "fed_100_times": Achievement( id="fed_100_times", name="ach_fed_100_times_name", description="ach_fed_100_times_desc", icon="👨‍🍳", category="feeding", points=50, tier=3, target_count=100, ), "fed_500_times": Achievement( id="fed_500_times", name="ach_fed_500_times_name", description="ach_fed_500_times_desc", icon="🌟", category="feeding", points=100, tier=4, target_count=500, hidden=True, ), # ========================================================================= # NEUROGENESIS CATEGORY (6) # ========================================================================= "first_neuron": Achievement( id="first_neuron", name="ach_first_neuron_name", description="ach_first_neuron_desc", icon="🧠", category="neurogenesis", points=20, tier=1, ), "neurons_10": Achievement( id="neurons_10", name="ach_neurons_10_name", description="ach_neurons_10_desc", icon="🔮", category="neurogenesis", points=30, tier=2, target_count=10, ), "neurons_50": Achievement( id="neurons_50", name="ach_neurons_50_name", description="ach_neurons_50_desc", icon="💫", category="neurogenesis", points=50, tier=3, target_count=50, ), "neurons_100": Achievement( id="neurons_100", name="ach_neurons_100_name", description="ach_neurons_100_desc", icon="🌌", category="neurogenesis", points=75, tier=4, target_count=100, hidden=True, ), "first_neuron_levelup": Achievement( id="first_neuron_levelup", name="ach_first_neuron_levelup_name", description="ach_first_neuron_levelup_desc", icon="⚡", category="neurogenesis", points=15, tier=1, ), "neuron_max_level": Achievement( id="neuron_max_level", name="ach_neuron_max_level_name", description="ach_neuron_max_level_desc", icon="🌠", category="neurogenesis", points=40, tier=3, ), # ========================================================================= # SLEEP CATEGORY (3) # ========================================================================= "first_sleep": Achievement( id="first_sleep", name="ach_first_sleep_name", description="ach_first_sleep_desc", icon="😴", category="sleep", points=10, tier=1, ), "slept_10_times": Achievement( id="slept_10_times", name="ach_slept_10_times_name", description="ach_slept_10_times_desc", icon="🛏️", category="sleep", points=20, tier=2, target_count=10, ), "dream_state": Achievement( id="dream_state", name="ach_dream_state_name", description="ach_dream_state_desc", icon="💭", category="sleep", points=25, tier=2, hidden=True, ), # ========================================================================= # MILESTONES CATEGORY (6) # ========================================================================= "age_1_hour": Achievement( id="age_1_hour", name="ach_age_1_hour_name", description="ach_age_1_hour_desc", icon="⏰", category="milestones", points=15, tier=1, ), "age_10_hours": Achievement( id="age_10_hours", name="ach_age_10_hours_name", description="ach_age_10_hours_desc", icon="📅", category="milestones", points=30, tier=2, ), "age_24_hours": Achievement( id="age_24_hours", name="ach_age_24_hours_name", description="ach_age_24_hours_desc", icon="🎂", category="milestones", points=50, tier=3, ), "age_1_week": Achievement( id="age_1_week", name="ach_age_1_week_name", description="ach_age_1_week_desc", icon="🏅", category="milestones", points=100, tier=4, hidden=True, ), "age_1_month": Achievement( id="age_1_month", name="ach_age_1_month_name", description="ach_age_1_month_desc", icon="🎖️", category="milestones", points=150, tier=5, hidden=True, ), "happiness_100": Achievement( id="happiness_100", name="ach_happiness_100_name", description="ach_happiness_100_desc", icon="😄", category="milestones", points=20, tier=2, ), "all_stats_high": Achievement( id="all_stats_high", name="ach_all_stats_high_name", description="ach_all_stats_high_desc", icon="⚖️", category="milestones", points=40, tier=3, ), # ========================================================================= # CLEANING CATEGORY (3) # ========================================================================= "first_clean": Achievement( id="first_clean", name="ach_first_clean_name", description="ach_first_clean_desc", icon="🧼", category="cleaning", points=10, tier=1, ), "cleaned_25_times": Achievement( id="cleaned_25_times", name="ach_cleaned_25_times_name", description="ach_cleaned_25_times_desc", icon="✨", category="cleaning", points=25, tier=2, target_count=25, ), "germaphobe": Achievement( id="germaphobe", name="ach_germaphobe_name", description="ach_germaphobe_desc", icon="🧹", category="cleaning", points=30, tier=2, ), # ========================================================================= # HEALTH CATEGORY (3) # ========================================================================= "first_medicine": Achievement( id="first_medicine", name="ach_first_medicine_name", description="ach_first_medicine_desc", icon="💊", category="health", points=10, tier=1, ), "medicine_10_times": Achievement( id="medicine_10_times", name="ach_medicine_10_times_name", description="ach_medicine_10_times_desc", icon="🩺", category="health", points=20, tier=2, target_count=10, ), "comeback_kid": Achievement( id="comeback_kid", name="ach_comeback_kid_name", description="ach_comeback_kid_desc", icon="💪", category="health", points=40, tier=3, hidden=True, ), # ========================================================================= # INTERACTION - ROCKS (6) # ========================================================================= "first_rock_pickup": Achievement( id="first_rock_pickup", name="ach_first_rock_pickup_name", description="ach_first_rock_pickup_desc", icon="🪨", category="interaction", points=10, tier=1, ), "rocks_picked_10": Achievement( id="rocks_picked_10", name="ach_rocks_picked_10_name", description="ach_rocks_picked_10_desc", icon="⛰️", category="interaction", points=15, tier=1, target_count=10, ), "rocks_picked_50": Achievement( id="rocks_picked_50", name="ach_rocks_picked_50_name", description="ach_rocks_picked_50_desc", icon="🏔️", category="interaction", points=30, tier=2, target_count=50, ), "first_rock_throw": Achievement( id="first_rock_throw", name="ach_first_rock_throw_name", description="ach_first_rock_throw_desc", icon="🎯", category="interaction", points=10, tier=1, ), "rocks_thrown_25": Achievement( id="rocks_thrown_25", name="ach_rocks_thrown_25_name", description="ach_rocks_thrown_25_desc", icon="🚀", category="interaction", points=20, tier=2, target_count=25, ), "rocks_thrown_100": Achievement( id="rocks_thrown_100", name="ach_rocks_thrown_100_name", description="ach_rocks_thrown_100_desc", icon="💨", category="interaction", points=40, tier=3, target_count=100, hidden=True, ), # ========================================================================= # INTERACTION - PLANTS & DECORATIONS (8) # ========================================================================= "first_decoration_push": Achievement( id="first_decoration_push", name="ach_first_decoration_push_name", description="ach_first_decoration_push_desc", icon="🪴", category="interaction", points=10, tier=1, ), "decorations_pushed_10": Achievement( id="decorations_pushed_10", name="ach_decorations_pushed_10_name", description="ach_decorations_pushed_10_desc", icon="🏠", category="interaction", points=15, tier=1, target_count=10, ), "decorations_pushed_50": Achievement( id="decorations_pushed_50", name="ach_decorations_pushed_50_name", description="ach_decorations_pushed_50_desc", icon="🎨", category="interaction", points=30, tier=2, target_count=50, ), "first_plant_interact": Achievement( id="first_plant_interact", name="ach_first_plant_interact_name", description="ach_first_plant_interact_desc", icon="🌱", category="interaction", points=10, tier=1, ), "plants_interacted_10": Achievement( id="plants_interacted_10", name="ach_plants_interacted_10_name", description="ach_plants_interacted_10_desc", icon="🌿", category="interaction", points=15, tier=1, target_count=10, ), "plants_interacted_50": Achievement( id="plants_interacted_50", name="ach_plants_interacted_50_name", description="ach_plants_interacted_50_desc", icon="🌳", category="interaction", points=30, tier=2, target_count=50, ), "objects_investigated_25": Achievement( id="objects_investigated_25", name="ach_objects_investigated_25_name", description="ach_objects_investigated_25_desc", icon="🔍", category="interaction", points=25, tier=2, target_count=25, ), "objects_investigated_100": Achievement( id="objects_investigated_100", name="ach_objects_investigated_100_name", description="ach_objects_investigated_100_desc", icon="🕵️", category="interaction", points=50, tier=3, target_count=100, ), # ========================================================================= # EXPLORATION - POOP (1) # ========================================================================= "first_poop_throw": Achievement( id="first_poop_throw", name="ach_first_poop_throw_name", description="ach_first_poop_throw_desc", icon="💩", category="exploration", points=10, tier=1, ), # ========================================================================= # INK CATEGORY (2) # ========================================================================= "first_ink_cloud": Achievement( id="first_ink_cloud", name="ach_first_ink_cloud_name", description="ach_first_ink_cloud_desc", icon="🖤", category="ink", points=15, tier=1, ), "ink_clouds_20": Achievement( id="ink_clouds_20", name="ach_ink_clouds_20_name", description="ach_ink_clouds_20_desc", icon="🌫️", category="ink", points=25, tier=2, target_count=20, ), # ========================================================================= # MEMORY CATEGORY (3) # ========================================================================= "first_memory": Achievement( id="first_memory", name="ach_first_memory_name", description="ach_first_memory_desc", icon="💾", category="memory", points=15, tier=1, ), "memory_long_term": Achievement( id="memory_long_term", name="ach_memory_long_term_name", description="ach_memory_long_term_desc", icon="🗄️", category="memory", points=25, tier=2, ), "memories_50": Achievement( id="memories_50", name="ach_memories_50_name", description="ach_memories_50_desc", icon="📚", category="memory", points=40, tier=3, target_count=50, ), # ========================================================================= # EMOTIONAL CATEGORY (4) # ========================================================================= "curiosity_100": Achievement( id="curiosity_100", name="ach_curiosity_100_name", description="ach_curiosity_100_desc", icon="🤔", category="emotional", points=15, tier=1, ), "zen_master": Achievement( id="zen_master", name="ach_zen_master_name", description="ach_zen_master_desc", icon="🧘", category="emotional", points=30, tier=2, ), "first_startle": Achievement( id="first_startle", name="ach_first_startle_name", description="ach_first_startle_desc", icon="😱", category="emotional", points=10, tier=1, ), "nervous_wreck": Achievement( id="nervous_wreck", name="ach_nervous_wreck_name", description="ach_nervous_wreck_desc", icon="😰", category="emotional", points=15, tier=2, hidden=True, ), # ========================================================================= # SECRET CATEGORY (3) # ========================================================================= "night_owl": Achievement( id="night_owl", name="ach_night_owl_name", description="ach_night_owl_desc", icon="🦉", category="secret", points=15, tier=2, hidden=True, ), "early_bird": Achievement( id="early_bird", name="ach_early_bird_name", description="ach_early_bird_desc", icon="🐦", category="secret", points=15, tier=2, hidden=True, ), "weekend_warrior": Achievement( id="weekend_warrior", name="ach_weekend_warrior_name", description="ach_weekend_warrior_desc", icon="🗓️", category="secret", points=20, tier=2, hidden=True, ), # ========================================================================= # META CATEGORY (2) # ========================================================================= "brain_surgeon": Achievement( id="brain_surgeon", name="ach_brain_surgeon_name", description="ach_brain_surgeon_desc", icon="🔬", category="meta", points=10, tier=1, ), "speed_demon": Achievement( id="speed_demon", name="ach_speed_demon_name", description="ach_speed_demon_desc", icon="⏩", category="meta", points=15, tier=2, ), "completionist": Achievement( id="completionist", name="ach_completionist_name", description="ach_completionist_desc", icon="🏆", category="meta", points=100, tier=4, hidden=True, ), } # ============================================================================= # HELPER FUNCTIONS # ============================================================================= def get_achievement(achievement_id: str) -> Achievement | None: """Get an achievement by ID""" return ACHIEVEMENT_DEFINITIONS.get(achievement_id) def get_achievements_by_category(category: str) -> Dict[str, Achievement]: """Get all achievements in a specific category""" return { aid: ach for aid, ach in ACHIEVEMENT_DEFINITIONS.items() if ach.category == category } def get_visible_achievements() -> Dict[str, Achievement]: """Get all non-hidden achievements""" return { aid: ach for aid, ach in ACHIEVEMENT_DEFINITIONS.items() if not ach.hidden } def get_total_points() -> int: """Get total possible points from all achievements""" return sum(ach.points for ach in ACHIEVEMENT_DEFINITIONS.values()) def get_achievement_count() -> int: """Get total number of achievements""" return len(ACHIEVEMENT_DEFINITIONS) ================================================ FILE: plugins/achievements/display_scaling.py ================================================ """ Portable copy of DisplayScaling plugins. Keep this file in the same folder as main.py """ import re class DisplayScaling: """ Utility class that computes a single scale-factor from the current screen resolution versus the design resolution (2880 × 1920). All UI measurements are then multiplied by this factor. """ DESIGN_WIDTH = 2880 DESIGN_HEIGHT = 1920 _scale_factor = 1.0 @classmethod def initialize(cls, current_width: int, current_height: int) -> None: """Call once after we know the real screen size.""" width_ratio = current_width / cls.DESIGN_WIDTH height_ratio = current_height / cls.DESIGN_HEIGHT base_scale = min(width_ratio, height_ratio) # Slightly smaller UI on 1080p screens if current_width <= 1920 and current_height <= 1080: cls._scale_factor = base_scale * 0.85 else: cls._scale_factor = base_scale @classmethod def scale(cls, value: int | float) -> int: """Scale an integer or float pixel value.""" return int(value * cls._scale_factor) @classmethod def font_size(cls, size: int) -> int: """Return a font size guaranteed to be at least 8 pt.""" return max(8, cls.scale(size)) @classmethod def get_scale_factor(cls) -> float: return cls._scale_factor @classmethod def scale_css(cls, css_string: str) -> str: """Replace font-size:Xpx with scaled value inside a CSS snippet.""" pattern = r'font-size:\s*(\d+)px' def _repl(m: re.Match) -> str: return f"font-size:{cls.font_size(int(m.group(1)))}px" return re.sub(pattern, _repl, css_string) ================================================ FILE: plugins/achievements/main.py ================================================ import os import json import time import logging from datetime import datetime from typing import Dict, List, Any, Optional from pathlib import Path from PyQt5 import QtCore, QtWidgets, QtGui # Import the local copy of DisplayScaling from .display_scaling import DisplayScaling as _DS # Increase the *nominal* sizes by ~1.4× (tweak multiplier to taste) _FONT_BOOST = 1.4 def _scale_size(pt: int) -> int: """Return a bigger base size before DisplayScaling does its job.""" return int(pt * _FONT_BOOST) # Monkey-patch the local DisplayScaling.font_size so every caller # automatically gets the boosted value. _DS.font_size = lambda pt: max(8, _DS.scale(_scale_size(pt))) # Re-export the (now patched) class under its original name so UI code can see it DisplayScaling = _DS # Import achievement definitions from separate file from .achievements_data import ( Achievement, UnlockedAchievement, AchievementCategory, ACHIEVEMENT_DEFINITIONS, TIER_COLORS, get_achievement, ) # Import localisation from src import localisation # Assign the core translation function 't' from the imported module _t = localisation.loc # ============================================================================= # PLUGIN METADATA - Required by PluginManager # ============================================================================= PLUGIN_NAME = "achievements" PLUGIN_VERSION = "2.1.0" # Version bump for localisation support PLUGIN_AUTHOR = "ViciousSquid" PLUGIN_DESCRIPTION = "Track milestones and unlock achievements as your squid grows" PLUGIN_REQUIRES = [] # No dependencies # Default language setting (can be changed via main menu or config) LANGUAGE = "en" # Options: "en", "es", "fr" localisation.CURRENT_LANGUAGE = LANGUAGE # ============================================================================= # UI COMPONENTS # ============================================================================= class AchievementNotification(QtWidgets.QWidget): """Toast notification for achievement unlocks - LARGER VERSION with description""" def __init__(self, achievement: Achievement, parent=None): super().__init__(parent) self.achievement = achievement self._setup_ui() self._setup_animation() def _setup_ui(self): self.setWindowFlags( QtCore.Qt.FramelessWindowHint | QtCore.Qt.WindowStaysOnTopHint | QtCore.Qt.Tool ) self.setAttribute(QtCore.Qt.WA_TranslucentBackground) # LARGER toast to fit description - increased height significantly self.setFixedSize(DisplayScaling.scale(520), DisplayScaling.scale(160)) container = QtWidgets.QFrame(self) container.setFixedSize(DisplayScaling.scale(490), DisplayScaling.scale(145)) container.move(DisplayScaling.scale(15), DisplayScaling.scale(7)) # Simple dark rectangle background tier_color = TIER_COLORS.get(self.achievement.tier, "#CD7F32") container.setStyleSheet(""" QFrame { background: rgb(30, 30, 35); border: none; border-radius: 8px; } """) layout = QtWidgets.QHBoxLayout(container) layout.setContentsMargins( DisplayScaling.scale(16), DisplayScaling.scale(12), DisplayScaling.scale(16), DisplayScaling.scale(12) ) layout.setSpacing(DisplayScaling.scale(14)) # Icon - larger icon_label = QtWidgets.QLabel(self.achievement.icon) icon_label.setStyleSheet(f""" font-size: {DisplayScaling.font_size(52)}px; background: transparent; color: white; """) icon_label.setFixedWidth(DisplayScaling.scale(80)) icon_label.setAlignment(QtCore.Qt.AlignCenter) layout.addWidget(icon_label) # Text section text_layout = QtWidgets.QVBoxLayout() text_layout.setSpacing(DisplayScaling.scale(4)) # Header "Achievement Unlocked!" header = QtWidgets.QLabel(_t("ui_achievement_unlocked")) header.setStyleSheet(f""" color: {tier_color}; font-size: {DisplayScaling.font_size(14)}px; font-weight: bold; background: transparent; """) text_layout.addWidget(header) # Achievement name - Translated name_label = QtWidgets.QLabel(_t(self.achievement.name)) name_label.setStyleSheet(f""" color: white; font-size: {DisplayScaling.font_size(22)}px; font-weight: bold; background: transparent; """) text_layout.addWidget(name_label) # Achievement DESCRIPTION - Translated desc_label = QtWidgets.QLabel(_t(self.achievement.description)) desc_label.setStyleSheet(f""" color: #cccccc; font-size: {DisplayScaling.font_size(13)}px; background: transparent; """) desc_label.setWordWrap(True) desc_label.setMaximumWidth(DisplayScaling.scale(320)) text_layout.addWidget(desc_label) # Points earned points_label = QtWidgets.QLabel(f"+{self.achievement.points} {_t('ui_points_gained')}") points_label.setStyleSheet(f""" color: {tier_color}; font-size: {DisplayScaling.font_size(12)}px; font-weight: bold; background: transparent; """) text_layout.addWidget(points_label) layout.addLayout(text_layout) layout.addStretch() def _setup_animation(self): self.opacity_effect = QtWidgets.QGraphicsOpacityEffect(self) self.setGraphicsEffect(self.opacity_effect) self.fade_in = QtCore.QPropertyAnimation(self.opacity_effect, b"opacity") self.fade_in.setDuration(300) self.fade_in.setStartValue(0) self.fade_in.setEndValue(1) self.fade_out = QtCore.QPropertyAnimation(self.opacity_effect, b"opacity") self.fade_out.setDuration(500) self.fade_out.setStartValue(1) self.fade_out.setEndValue(0) self.fade_out.finished.connect(self.close) self.display_timer = QtCore.QTimer(self) self.display_timer.setSingleShot(True) self.display_timer.timeout.connect(self.fade_out.start) def show_notification(self, duration_ms=4000): self.show() self.fade_in.start() self.display_timer.start(duration_ms) class AchievementsWindow(QtWidgets.QDialog): """Window displaying all achievements""" def __init__(self, plugin: 'AchievementsPlugin', parent=None): super().__init__(parent) self.plugin = plugin self.setWindowTitle(f"🏆 {PLUGIN_NAME}") self.setMinimumSize(DisplayScaling.scale(550), DisplayScaling.scale(650)) self._setup_ui() def _setup_ui(self): layout = QtWidgets.QVBoxLayout(self) # Header header = QtWidgets.QFrame() header.setStyleSheet(""" QFrame { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #2c3e50, stop:1 #3498db); border-radius: 8px; padding: 10px; } """) header_layout = QtWidgets.QHBoxLayout(header) total_points = self.plugin.get_total_points() total_unlocked = len(self.plugin.unlocked) total_available = len([a for a in ACHIEVEMENT_DEFINITIONS.values() if not a.hidden]) points_label = QtWidgets.QLabel(f"🏆 {total_points} {_t('ui_points')}") points_label.setStyleSheet(f"color: gold; " f"font-size: {DisplayScaling.font_size(18)}px; " f"font-weight: bold;") header_layout.addWidget(points_label) header_layout.addStretch() progress_label = QtWidgets.QLabel(f"📊 {total_unlocked}/{total_available} {_t('ui_unlocked')}") progress_label.setStyleSheet(f"color: white; " f"font-size: {DisplayScaling.font_size(14)}px;") header_layout.addWidget(progress_label) layout.addWidget(header) # Tabs tabs = QtWidgets.QTabWidget() tabs.addTab(self._create_list(None), _t("ui_all")) for cat in AchievementCategory: cat_achievements = [a for a in ACHIEVEMENT_DEFINITIONS.values() if a.category == cat.value] if cat_achievements: # Only add tab if category has achievements # Use translated category name tabs.addTab(self._create_list(cat.value), _t(f"cat_{cat.value}")) layout.addWidget(tabs) def _create_list(self, category_filter: Optional[str]) -> QtWidgets.QScrollArea: scroll = QtWidgets.QScrollArea() scroll.setWidgetResizable(True) container = QtWidgets.QWidget() layout = QtWidgets.QVBoxLayout(container) layout.setSpacing(DisplayScaling.scale(8)) unlocked_ids = set(self.plugin.unlocked.keys()) for ach_id, ach in ACHIEVEMENT_DEFINITIONS.items(): if category_filter and ach.category != category_filter: continue is_unlocked = ach_id in unlocked_ids if ach.hidden and not is_unlocked: continue card = self._create_card(ach, is_unlocked, self.plugin.progress.get(ach_id, 0)) layout.addWidget(card) layout.addStretch() scroll.setWidget(container) return scroll def _create_card(self, ach: Achievement, unlocked: bool, progress: int) -> QtWidgets.QFrame: card = QtWidgets.QFrame() # 🌟 NEW Lighter Colors 🌟 # Use the tier color for unlocked, or a light gray for locked. border_color = TIER_COLORS.get(ach.tier, "#CD7F32") if unlocked else "#AAA" # Use a very light, semi-transparent background for both. bg = "rgba(255, 255, 255, 230)" if unlocked else "rgba(230, 230, 235, 200)" card.setStyleSheet(f"QFrame {{ background: {bg}; border: 2px solid {border_color}; border-radius: 8px; }}") layout = QtWidgets.QHBoxLayout(card) # Icon styling - remains largely the same, but will appear clearer on the lighter background. icon = QtWidgets.QLabel(ach.icon if unlocked else "🔒") icon.setStyleSheet(f"font-size: {DisplayScaling.font_size(28)}px;") icon.setFixedWidth(DisplayScaling.scale(50)) layout.addWidget(icon) info = QtWidgets.QVBoxLayout() # 🌟 NEW Name Text Color 🌟 # Black for unlocked, Dark Gray for locked (easier to read on light BG) name_color = '#000' if unlocked else '#555' # Translate the name key display_name = _t(ach.name) if unlocked or not ach.hidden else "???" name = QtWidgets.QLabel(display_name) name.setStyleSheet(f"color: {name_color}; " f"font-size: {DisplayScaling.font_size(14)}px; " f"font-weight: bold;") info.addWidget(name) # 🌟 NEW Description Text Color 🌟 # Dark Gray for unlocked, Medium Gray for locked (easier to read on light BG) desc_color = '#333' if unlocked else '#777' # Translate the description key display_desc = _t(ach.description) if unlocked or not ach.hidden else _t("ui_hidden") desc = QtWidgets.QLabel(display_desc) desc.setStyleSheet(f"color: {desc_color}; " f"font-size: {DisplayScaling.font_size(11)}px;") desc.setWordWrap(True) info.addWidget(desc) # Progress bar logic remains the same if ach.target_count > 1 and not unlocked: pbar = QtWidgets.QProgressBar() pbar.setMaximum(ach.target_count) pbar.setValue(min(progress, ach.target_count)) pbar.setFormat(f"{progress}/{ach.target_count}") pbar.setFixedHeight(DisplayScaling.scale(16)) info.addWidget(pbar) layout.addLayout(info, 1) # Points color remains the tier color, which will stand out well. pts = QtWidgets.QLabel(f"+{ach.points}") pts.setStyleSheet(f"color: {TIER_COLORS.get(ach.tier, '#CD7F32')}; " f"font-size: {DisplayScaling.font_size(12)}px; " f"font-weight: bold;") layout.addWidget(pts) return card # ============================================================================= # MAIN PLUGIN CLASS # ============================================================================= class AchievementsPlugin: """Main achievements plugin class""" def __init__(self): self.logger = None self.plugin_manager = None self.tamagotchi_logic = None self.squid = None self.unlocked: Dict[str, UnlockedAchievement] = {} self.progress: Dict[str, int] = {} self.statistics: Dict[str, int] = {} # Timers self.age_check_timer: Optional[QtCore.QTimer] = None self.stat_check_timer: Optional[QtCore.QTimer] = None self.notification_timer: Optional[QtCore.QTimer] = None self.notification_queue: List[Achievement] = [] self.current_notification: Optional[AchievementNotification] = None # Tracking for timed achievements self.cleanliness_high_since: Optional[float] = None # For germaphobe self.anxiety_low_since: Optional[float] = None # For zen_master self.max_speed_since: Optional[float] = None # For speed_demon self.health_was_critical: bool = False # For comeback_kid self.weekend_saturday: bool = False # For weekend_warrior self.weekend_sunday: bool = False self.parent_window: Optional[QtWidgets.QMainWindow] = None self.is_setup = False self.debug_mode = False self._original_methods: Dict[str, Any] = {} # ---------------------------------------------------------- # Write unlock to text file only # ---------------------------------------------------------- def _log_unlock_to_text_file(self, ach: Achievement) -> None: """Append 'ID | long-date | HHMMSS | name' to achievements_log.txt""" try: log_path = Path(self._get_save_path()).with_name("achievements_log.txt") time_stamp = datetime.now().strftime("%A, %B %d, %Y @ %H%M%S") # Translate name for log file using current language translated_name = _t(ach.name) line = f"{ach.id} | {time_stamp} | {translated_name}\n" with log_path.open("a", encoding="utf-8") as fh: fh.write(line) except Exception as e: if self.logger: self.logger.warning(f"Could not write achievement log: {e}") def setup(self, plugin_manager, tamagotchi_logic) -> bool: """Called by PluginManager when enabling the plugin""" self.plugin_manager = plugin_manager self.tamagotchi_logic = tamagotchi_logic # Setup logger if hasattr(plugin_manager, 'logger'): self.logger = plugin_manager.logger.getChild(PLUGIN_NAME) else: self.logger = logging.getLogger(f"{PLUGIN_NAME}_Plugin") if not self.logger.handlers: handler = logging.StreamHandler() handler.setFormatter(logging.Formatter('%(levelname)s:%(name)s: %(message)s')) self.logger.addHandler(handler) self.logger.setLevel(logging.INFO) self.logger.info(f"Setting up {PLUGIN_NAME}...") # Get squid reference if tamagotchi_logic and hasattr(tamagotchi_logic, 'squid'): self.squid = tamagotchi_logic.squid self.logger.info(f"Got squid reference: {self.squid}") # Get parent window if tamagotchi_logic and hasattr(tamagotchi_logic, 'user_interface'): ui = tamagotchi_logic.user_interface if hasattr(ui, 'window'): self.parent_window = ui.window elif isinstance(ui, QtWidgets.QMainWindow): self.parent_window = ui self.debug_mode = getattr(tamagotchi_logic, 'debug_mode', False) # Setup timers self._setup_timers() # Subscribe to plugin manager hooks self._subscribe_to_hooks() # Also try direct method hooks as backup self._install_hooks() self.is_setup = True self.logger.info(f"{PLUGIN_NAME} setup complete. {len(self.unlocked)} achievements loaded.") return True def _subscribe_to_hooks(self): """Subscribe to plugin manager hooks for event tracking""" if not self.plugin_manager: if self.logger: self.logger.warning("No plugin_manager, cannot subscribe to hooks") return hook_subscriptions = [ # Original hooks ("on_feed", self._hook_on_feed), ("on_wake", self._hook_on_wake), ("on_sleep", self._hook_on_sleep), ("on_neurogenesis", self._hook_on_neurogenesis), # New hooks for expanded achievements ("on_clean", self._hook_on_clean), ("on_medicine", self._hook_on_medicine), ("on_rock_pickup", self._hook_on_rock_pickup), ("on_rock_throw", self._hook_on_rock_throw), ("on_decoration_interaction", self._hook_on_decoration_interaction), ("on_ink_cloud", self._hook_on_ink_cloud), ("on_startle", self._hook_on_startle), ("on_memory_created", self._hook_on_memory_created), ("on_memory_to_long_term", self._hook_on_memory_to_long_term), ("on_curiosity_change", self._hook_on_curiosity_change), ("on_anxiety_change", self._hook_on_anxiety_change), ("on_speed_change", self._hook_on_speed_change), ] for hook_name, callback in hook_subscriptions: try: if hasattr(self.plugin_manager, 'subscribe_to_hook'): result = self.plugin_manager.subscribe_to_hook(hook_name, PLUGIN_NAME, callback) if self.logger: self.logger.debug(f"Subscribed to hook '{hook_name}': {result}") except Exception as e: if self.logger: self.logger.warning(f"Could not subscribe to hook '{hook_name}': {e}") # ---------------------------------------------------------- # Hook Callbacks # ---------------------------------------------------------- def _hook_on_feed(self, **kwargs): self.on_squid_fed() def _hook_on_wake(self, **kwargs): self.on_squid_woke() def _hook_on_sleep(self, **kwargs): pass # Sleep achievement triggers on wake def _hook_on_neurogenesis(self, **kwargs): self.on_neuron_created() def _hook_on_clean(self, **kwargs): self.on_tank_cleaned() def _hook_on_medicine(self, **kwargs): self.on_medicine_given() def _hook_on_rock_pickup(self, **kwargs): self.on_rock_picked_up() def _hook_on_rock_throw(self, **kwargs): self.on_rock_thrown() def _hook_on_decoration_interaction(self, **kwargs): decoration = kwargs.get('decoration') interaction_type = kwargs.get('type', 'push') self.on_decoration_interacted(decoration, interaction_type) def _hook_on_ink_cloud(self, **kwargs): self.on_ink_cloud_released() def _hook_on_startle(self, **kwargs): self.on_squid_startled() def _hook_on_memory_created(self, **kwargs): self.on_memory_formed() def _hook_on_memory_to_long_term(self, **kwargs): self.on_memory_promoted() def _hook_on_curiosity_change(self, **kwargs): new_value = kwargs.get('new_value', 0) if new_value >= 100: self.unlock_achievement("curiosity_100") def _hook_on_anxiety_change(self, **kwargs): new_value = kwargs.get('new_value', 0) if new_value >= 100: self.unlock_achievement("nervous_wreck") # Track for zen_master if new_value < 10: if self.anxiety_low_since is None: self.anxiety_low_since = time.time() else: self.anxiety_low_since = None def _hook_on_speed_change(self, **kwargs): speed = kwargs.get('speed', 1) max_speed = kwargs.get('max_speed', 4) if speed >= max_speed: if self.max_speed_since is None: self.max_speed_since = time.time() else: self.max_speed_since = None # ---------------------------------------------------------- # Event Handlers # ---------------------------------------------------------- def on_squid_fed(self): self._increment_stat("times_fed") count = self.statistics.get("times_fed", 0) if count == 1: self.unlock_achievement("first_feeding") if count >= 10: self.unlock_achievement("fed_10_times") if count >= 50: self.unlock_achievement("fed_50_times") if count >= 100: self.unlock_achievement("fed_100_times") if count >= 500: self.unlock_achievement("fed_500_times") for aid in ["fed_10_times", "fed_50_times", "fed_100_times", "fed_500_times"]: self._update_progress(aid, count) def on_squid_woke(self): self._increment_stat("times_slept") count = self.statistics.get("times_slept", 0) if count == 1: self.unlock_achievement("first_sleep") if count >= 10: self.unlock_achievement("slept_10_times") self._update_progress("slept_10_times", count) def on_neuron_created(self): self._increment_stat("neurons_created") count = self.statistics.get("neurons_created", 0) if count == 1: self.unlock_achievement("first_neuron") if count >= 10: self.unlock_achievement("neurons_10") if count >= 50: self.unlock_achievement("neurons_50") if count >= 100: self.unlock_achievement("neurons_100") self._update_progress("neurons_10", count) self._update_progress("neurons_50", count) self._update_progress("neurons_100", count) def on_neuron_leveled(self): self._increment_stat("neurons_leveled") if self.statistics.get("neurons_leveled", 0) == 1: self.unlock_achievement("first_neuron_levelup") def on_poop_thrown(self): self._increment_stat("poops_thrown") if self.statistics.get("poops_thrown", 0) == 1: self.unlock_achievement("first_poop_throw") def on_tank_cleaned(self): self._increment_stat("times_cleaned") count = self.statistics.get("times_cleaned", 0) if count == 1: self.unlock_achievement("first_clean") if count >= 25: self.unlock_achievement("cleaned_25_times") self._update_progress("cleaned_25_times", count) def on_medicine_given(self): self._increment_stat("times_medicated") count = self.statistics.get("times_medicated", 0) if count == 1: self.unlock_achievement("first_medicine") if count >= 10: self.unlock_achievement("medicine_10_times") self._update_progress("medicine_10_times", count) # Check for comeback_kid - track if health was critical before medicine if self.health_was_critical and self.squid: health = getattr(self.squid, 'health', 100) if health >= 100: self.unlock_achievement("comeback_kid") self.health_was_critical = False def on_rock_picked_up(self): self._increment_stat("rocks_picked") count = self.statistics.get("rocks_picked", 0) if count == 1: self.unlock_achievement("first_rock_pickup") if count >= 10: self.unlock_achievement("rocks_picked_10") if count >= 50: self.unlock_achievement("rocks_picked_50") self._update_progress("rocks_picked_10", count) self._update_progress("rocks_picked_50", count) # Also count as object investigated self._on_object_investigated() def on_rock_thrown(self): self._increment_stat("rocks_thrown") count = self.statistics.get("rocks_thrown", 0) if count == 1: self.unlock_achievement("first_rock_throw") if count >= 25: self.unlock_achievement("rocks_thrown_25") if count >= 100: self.unlock_achievement("rocks_thrown_100") self._update_progress("rocks_thrown_25", count) self._update_progress("rocks_thrown_100", count) def on_decoration_interacted(self, decoration=None, interaction_type='push'): """Handle decoration interactions (push, investigate, etc.)""" # Track push interactions if interaction_type == 'push': self._increment_stat("decorations_pushed") count = self.statistics.get("decorations_pushed", 0) if count == 1: self.unlock_achievement("first_decoration_push") if count >= 10: self.unlock_achievement("decorations_pushed_10") if count >= 50: self.unlock_achievement("decorations_pushed_50") self._update_progress("decorations_pushed_10", count) self._update_progress("decorations_pushed_50", count) # Track plant-specific interactions if decoration and hasattr(decoration, 'category'): if decoration.category == 'plant': self._increment_stat("plants_interacted") count = self.statistics.get("plants_interacted", 0) if count == 1: self.unlock_achievement("first_plant_interact") if count >= 10: self.unlock_achievement("plants_interacted_10") if count >= 50: self.unlock_achievement("plants_interacted_50") self._update_progress("plants_interacted_10", count) self._update_progress("plants_interacted_50", count) # Count as object investigated self._on_object_investigated() def _on_object_investigated(self): """Track unique object investigations""" self._increment_stat("objects_investigated") count = self.statistics.get("objects_investigated", 0) if count >= 25: self.unlock_achievement("objects_investigated_25") if count >= 100: self.unlock_achievement("objects_investigated_100") self._update_progress("objects_investigated_25", count) self._update_progress("objects_investigated_100", count) def on_ink_cloud_released(self): self._increment_stat("ink_clouds") count = self.statistics.get("ink_clouds", 0) if count == 1: self.unlock_achievement("first_ink_cloud") if count >= 20: self.unlock_achievement("ink_clouds_20") self._update_progress("ink_clouds_20", count) def on_squid_startled(self): self._increment_stat("times_startled") if self.statistics.get("times_startled", 0) == 1: self.unlock_achievement("first_startle") def on_memory_formed(self): self._increment_stat("memories_formed") count = self.statistics.get("memories_formed", 0) if count == 1: self.unlock_achievement("first_memory") if count >= 50: self.unlock_achievement("memories_50") self._update_progress("memories_50", count) def on_memory_promoted(self): self._increment_stat("memories_promoted") if self.statistics.get("memories_promoted", 0) == 1: self.unlock_achievement("memory_long_term") def on_brain_tool_opened(self): """Called when brain visualization is opened""" if "brain_surgeon" not in self.unlocked: self.unlock_achievement("brain_surgeon") # ---------------------------------------------------------- # Periodic Checks # ---------------------------------------------------------- def _check_age_achievements(self): if not self.squid: return age_hours = 0 if hasattr(self.squid, 'birth_time'): age_hours = (time.time() - self.squid.birth_time) / 3600 elif hasattr(self.squid, 'age_hours'): age_hours = self.squid.age_hours if age_hours >= 1: self.unlock_achievement("age_1_hour") if age_hours >= 10: self.unlock_achievement("age_10_hours") if age_hours >= 24: self.unlock_achievement("age_24_hours") if age_hours >= 168: # 1 week self.unlock_achievement("age_1_week") if age_hours >= 720: # 30 days self.unlock_achievement("age_1_month") # Time-of-day achievements hour = datetime.now().hour if 0 <= hour < 4: self.unlock_achievement("night_owl") if 5 <= hour < 7: self.unlock_achievement("early_bird") # Weekend warrior day = datetime.now().weekday() if day == 5: # Saturday self.weekend_saturday = True elif day == 6: # Sunday self.weekend_sunday = True if self.weekend_saturday and self.weekend_sunday: self.unlock_achievement("weekend_warrior") def _check_stat_achievements(self): if not self.squid: return # Happiness if hasattr(self.squid, 'happiness') and self.squid.happiness >= 100: self.unlock_achievement("happiness_100") # All stats high stats = ['happiness', 'hunger', 'energy', 'health'] all_high = all(getattr(self.squid, s, 0) >= 80 for s in stats if hasattr(self.squid, s)) if all_high: self.unlock_achievement("all_stats_high") # Check health for comeback_kid tracking if hasattr(self.squid, 'health'): if self.squid.health < 20: self.health_was_critical = True elif self.squid.health >= 100 and self.health_was_critical: self.unlock_achievement("comeback_kid") self.health_was_critical = False # Cleanliness tracking for germaphobe if hasattr(self.squid, 'cleanliness'): if self.squid.cleanliness >= 90: if self.cleanliness_high_since is None: self.cleanliness_high_since = time.time() elif time.time() - self.cleanliness_high_since >= 3600: # 1 hour self.unlock_achievement("germaphobe") else: self.cleanliness_high_since = None # Anxiety tracking for zen_master if hasattr(self.squid, 'anxiety'): if self.squid.anxiety < 10: if self.anxiety_low_since is None: self.anxiety_low_since = time.time() elif time.time() - self.anxiety_low_since >= 1800: # 30 minutes self.unlock_achievement("zen_master") else: self.anxiety_low_since = None # Speed demon check if self.max_speed_since is not None: if time.time() - self.max_speed_since >= 600: # 10 minutes self.unlock_achievement("speed_demon") # Completionist check unlocked_count = len(self.unlocked) if "completionist" not in self.unlocked and unlocked_count >= 30: self.unlock_achievement("completionist") # ---------------------------------------------------------- # Core Methods # ---------------------------------------------------------- def unlock_achievement(self, achievement_id: str, silent: bool = False) -> bool: if achievement_id in self.unlocked: return False if achievement_id not in ACHIEVEMENT_DEFINITIONS: return False ach = ACHIEVEMENT_DEFINITIONS[achievement_id] if ach.target_count > 1 and self.progress.get(achievement_id, 0) < ach.target_count: return False self.unlocked[achievement_id] = UnlockedAchievement( achievement_id=achievement_id, unlocked_at=datetime.now().isoformat(), progress=ach.target_count, notified=silent ) # Log with translated name for console/logger (optional, or keep English) if self.logger: # Using English key here might be safer for logs, or translated: self.logger.info(f"🏆 Unlocked: {_t(ach.name)}") self._log_unlock_to_text_file(ach) if not silent: self._queue_notification(ach) if self.tamagotchi_logic and hasattr(self.tamagotchi_logic, 'show_message'): self.tamagotchi_logic.show_message(f"🏆") return True def manually_trigger(self, event_name: str): handlers = { "fed": self.on_squid_fed, "woke": self.on_squid_woke, "neuron_created": self.on_neuron_created, "neuron_leveled": self.on_neuron_leveled, "poop_thrown": self.on_poop_thrown, "cleaned": self.on_tank_cleaned, "medicine": self.on_medicine_given, "rock_pickup": self.on_rock_picked_up, "rock_throw": self.on_rock_thrown, "ink_cloud": self.on_ink_cloud_released, "startle": self.on_squid_startled, "memory_formed": self.on_memory_formed, "memory_promoted": self.on_memory_promoted, "brain_opened": self.on_brain_tool_opened, "neuron_max_level": lambda: self.unlock_achievement("neuron_max_level"), "dream_state": lambda: self.unlock_achievement("dream_state"), } if event_name in handlers: handlers[event_name]() else: self.unlock_achievement(event_name) def get_total_points(self) -> int: return sum( ACHIEVEMENT_DEFINITIONS[a.achievement_id].points for a in self.unlocked.values() if a.achievement_id in ACHIEVEMENT_DEFINITIONS ) def _increment_stat(self, name: str, amount: int = 1): self.statistics[name] = self.statistics.get(name, 0) + amount def _update_progress(self, achievement_id: str, value: int): self.progress[achievement_id] = value # ---------------------------------------------------------- # Notifications # ---------------------------------------------------------- def _queue_notification(self, achievement: Achievement): self.notification_queue.append(achievement) if not self.notification_timer.isActive(): self._show_next_notification() def _show_next_notification(self): if self.current_notification: self.current_notification.close() self.current_notification = None if not self.notification_queue: self.notification_timer.stop() return ach = self.notification_queue.pop(0) self.current_notification = AchievementNotification(ach, self.parent_window) if self.parent_window: geo = self.parent_window.geometry() x = geo.x() + DisplayScaling.scale(20) y = geo.y() + DisplayScaling.scale(20) self.current_notification.move(x, y) self.current_notification.show_notification() if self.notification_queue: self.notification_timer.start(4500) # Slightly longer for reading description # ---------------------------------------------------------- # Timers & Hooks Setup # ---------------------------------------------------------- def _setup_timers(self): self.age_check_timer = QtCore.QTimer() self.age_check_timer.timeout.connect(self._check_age_achievements) self.stat_check_timer = QtCore.QTimer() self.stat_check_timer.timeout.connect(self._check_stat_achievements) self.notification_timer = QtCore.QTimer() self.notification_timer.timeout.connect(self._show_next_notification) def _install_hooks(self): """Hook into game events via method wrapping""" if self.logger: self.logger.info(f"Installing hooks... squid={self.squid is not None}") if not self.squid: if self.logger: self.logger.warning("Cannot install hooks: squid is None") return hooks_installed = [] try: # Hook squid.eat if hasattr(self.squid, 'eat') and 'eat' not in self._original_methods: self._original_methods['eat'] = self.squid.eat original_eat = self._original_methods['eat'] plugin_self = self def hooked_eat(*args, **kwargs): result = original_eat(*args, **kwargs) try: plugin_self.on_squid_fed() except Exception as e: if plugin_self.logger: plugin_self.logger.error(f"Error in on_squid_fed: {e}") return result self.squid.eat = hooked_eat hooks_installed.append('eat') # Hook squid.wake_up if hasattr(self.squid, 'wake_up') and 'wake_up' not in self._original_methods: self._original_methods['wake_up'] = self.squid.wake_up original_wake = self._original_methods['wake_up'] plugin_self = self def hooked_wake(*args, **kwargs): result = original_wake(*args, **kwargs) try: plugin_self.on_squid_woke() except Exception as e: if plugin_self.logger: plugin_self.logger.error(f"Error in on_squid_woke: {e}") return result self.squid.wake_up = hooked_wake hooks_installed.append('wake_up') # Hook neurogenesis if self.tamagotchi_logic and hasattr(self.tamagotchi_logic, 'neurogenesis_system'): ng = self.tamagotchi_logic.neurogenesis_system if ng and hasattr(ng, 'create_neuron') and 'create_neuron' not in self._original_methods: self._original_methods['create_neuron'] = ng.create_neuron original_create = self._original_methods['create_neuron'] plugin_self = self def hooked_create(*args, **kwargs): result = original_create(*args, **kwargs) if result: try: plugin_self.on_neuron_created() except Exception as e: if plugin_self.logger: plugin_self.logger.error(f"Error in on_neuron_created: {e}") return result ng.create_neuron = hooked_create hooks_installed.append('create_neuron') # Hook poop throwing if self.tamagotchi_logic and hasattr(self.tamagotchi_logic, 'poop_manager'): pm = self.tamagotchi_logic.poop_manager if pm and hasattr(pm, 'throw_poop') and 'throw_poop' not in self._original_methods: self._original_methods['throw_poop'] = pm.throw_poop original_throw = self._original_methods['throw_poop'] plugin_self = self def hooked_throw(*args, **kwargs): result = original_throw(*args, **kwargs) if result: try: plugin_self.on_poop_thrown() except Exception as e: if plugin_self.logger: plugin_self.logger.error(f"Error in on_poop_thrown: {e}") return result pm.throw_poop = hooked_throw hooks_installed.append('throw_poop') # Hook push_decoration on squid if hasattr(self.squid, 'push_decoration') and 'push_decoration' not in self._original_methods: self._original_methods['push_decoration'] = self.squid.push_decoration original_push = self._original_methods['push_decoration'] plugin_self = self def hooked_push(decoration, direction): result = original_push(decoration, direction) try: plugin_self.on_decoration_interacted(decoration, 'push') except Exception as e: if plugin_self.logger: plugin_self.logger.error(f"Error in on_decoration_interacted: {e}") return result self.squid.push_decoration = hooked_push hooks_installed.append('push_decoration') if self.logger: self.logger.info(f"Hooks installed: {hooks_installed}") except Exception as e: if self.logger: self.logger.error(f"Error installing hooks: {e}", exc_info=True) def _uninstall_hooks(self): try: if 'eat' in self._original_methods and self.squid: self.squid.eat = self._original_methods['eat'] if 'wake_up' in self._original_methods and self.squid: self.squid.wake_up = self._original_methods['wake_up'] if 'push_decoration' in self._original_methods and self.squid: self.squid.push_decoration = self._original_methods['push_decoration'] except: pass self._original_methods.clear() # ---------------------------------------------------------- # Enable / Disable / Shutdown # ---------------------------------------------------------- def enable(self) -> bool: if self.logger: self.logger.info(f"{PLUGIN_NAME} enable() called. is_setup={self.is_setup}") if not self.age_check_timer: self._setup_timers() if self.tamagotchi_logic and hasattr(self.tamagotchi_logic, 'squid'): if self.squid != self.tamagotchi_logic.squid: self.squid = self.tamagotchi_logic.squid self._install_hooks() if self.logger: self.logger.info(f"Re-acquired squid reference and reinstalled hooks") if self.age_check_timer and not self.age_check_timer.isActive(): self.age_check_timer.start(60000) if self.stat_check_timer and not self.stat_check_timer.isActive(): self.stat_check_timer.start(5000) if self.logger: self.logger.info(f"{PLUGIN_NAME} enabled successfully") return True def disable(self): if self.logger: self.logger.info(f"{PLUGIN_NAME} disable() called") if self.age_check_timer: self.age_check_timer.stop() if self.stat_check_timer: self.stat_check_timer.stop() if self.notification_timer: self.notification_timer.stop() if self.logger: self.logger.info(f"{PLUGIN_NAME} disabled") def shutdown(self): self.disable() self._uninstall_hooks() # ---------------------------------------------------------- # Persistence # ---------------------------------------------------------- def _get_save_path(self) -> str: os.makedirs("saves", exist_ok=True) return os.path.join("saves", "achievements.json") def get_save_data(self) -> dict: return { "unlocked": {k: v.to_dict() for k, v in self.unlocked.items()}, "progress": self.progress, "statistics": self.statistics, "tracking": { "weekend_saturday": self.weekend_saturday, "weekend_sunday": self.weekend_sunday, } } def load_save_data(self, data: dict): if not data: return for aid, adata in data.get("unlocked", {}).items(): self.unlocked[aid] = UnlockedAchievement.from_dict(adata) self.progress = data.get("progress", {}) self.statistics = data.get("statistics", {}) tracking = data.get("tracking", {}) self.weekend_saturday = tracking.get("weekend_saturday", False) self.weekend_sunday = tracking.get("weekend_sunday", False) # ---------------------------------------------------------- # UI # ---------------------------------------------------------- def show_achievements_window(self): window = AchievementsWindow(self, self.parent_window) window.exec_() def register_menu_actions(self, main_window: QtWidgets.QMainWindow, menu: QtWidgets.QMenu): action = QtWidgets.QAction(f"🏆 {PLUGIN_NAME}...", main_window) action.triggered.connect(self.show_achievements_window) menu.addAction(action) # ============================================================================= # INITIALIZE FUNCTION - Required by PluginManager # ============================================================================= def initialize(plugin_manager) -> bool: plugin_key = PLUGIN_NAME.lower() if plugin_key in plugin_manager.plugins: if hasattr(plugin_manager, 'logger'): plugin_manager.logger.warning(f"{PLUGIN_NAME} is already registered. Skipping re-registration.") return True # Already initialized, no error try: instance = AchievementsPlugin() plugin_manager.plugins[plugin_key] = { 'name': plugin_key, 'original_name': PLUGIN_NAME, 'version': PLUGIN_VERSION, 'author': PLUGIN_AUTHOR, 'description': PLUGIN_DESCRIPTION, 'requires': PLUGIN_REQUIRES, 'instance': instance, 'is_setup': False, } if hasattr(plugin_manager, 'logger'): plugin_manager.logger.info(f"{PLUGIN_NAME} v{PLUGIN_VERSION} initialized") return True except Exception as e: if hasattr(plugin_manager, 'logger'): plugin_manager.logger.error(f"Failed to initialize {PLUGIN_NAME}: {e}") return False ================================================ FILE: plugins/multiplayer/__init__.py ================================================ ================================================ FILE: plugins/multiplayer/main.py ================================================ # File: main.py (Plugin Entry Point) import os import sys import traceback # --- Plugin Metadata --- # These constants describe the plugin to the system and users. PLUGIN_NAME = "multiplayer" PLUGIN_VERSION = "1.2.0" PLUGIN_AUTHOR = "ViciousSquid" PLUGIN_DESCRIPTION = "Enables network sync for squids and objects (Experimental)" PLUGIN_REQUIRES = [] # Names of other plugins this one depends on # --- Python Path Setup --- # Adjust these paths if your project structure is different. # This setup helps Python find your 'src' directory and other plugin modules. try: current_file_dir = os.path.dirname(os.path.abspath(__file__)) # Assuming 'main.py' is in 'project_root/plugins/your_plugin_name/' project_root_candidate_one_up = os.path.abspath(os.path.join(current_file_dir, '..', '..')) # plugins folder is one up, project root is two up project_root_candidate_two_up = os.path.abspath(os.path.join(current_file_dir, '..', '..', '..')) # If nested deeper project_root = None # Check if 'src' directory exists at the determined project root level if os.path.isdir(os.path.join(project_root_candidate_one_up, 'src')): project_root = project_root_candidate_one_up elif os.path.isdir(os.path.join(project_root_candidate_two_up, 'src')): # Fallback for deeper nesting project_root = project_root_candidate_two_up else: # Using print here as logger might not be available/configured at this early stage of module loading print(f"Multiplayer Plugin (main.py) Warning: 'src' directory not reliably found from {current_file_dir}. Imports might fail.") # Default to a common structure if unsure (e.g., plugin is in 'project_root/plugins/plugin_name/') project_root = project_root_candidate_one_up if project_root and project_root not in sys.path: sys.path.insert(0, project_root) # Optional: print(f"Multiplayer Plugin (main.py): Added '{project_root}' to sys.path for src imports.") if current_file_dir not in sys.path: # Add current plugin directory to sys.path (for relative imports if run directly) sys.path.insert(0, current_file_dir) except Exception as e: print(f"Multiplayer Plugin (main.py): Error setting up sys.path: {e}") # --- End Python Path Setup --- # Import after sys.path modifications try: from src.tamagotchi_logic import TamagotchiLogic except ImportError: # This print is important for diagnosing issues if the main application structure isn't found print("Multiplayer Plugin (main.py) CRITICAL IMPORT ERROR: TamagotchiLogic could not be imported. Ensure 'src' is in sys.path and contains tamagotchi_logic.py.") TamagotchiLogic = None # Define as None if import fails, plugin should handle this gracefully. # Import plugin metadata constants (defined centrally) from . import mp_constants # Use this to access PLUGIN_NAME, etc. # Import the main plugin class from .mp_plugin_logic import MultiplayerPlugin # --- Plugin Registration Function --- def initialize(plugin_manager_instance): # plugin_manager_instance is the actual PluginManager object """ Initializes the Multiplayer plugin and registers it with the plugin manager. This function is called by the plugin system. """ try: # Create an instance of the main plugin class plugin_instance = MultiplayerPlugin() # This is MultiplayerPlugin from mp_plugin_logic.py # --- Set plugin_manager on the instance --- # Explicitly set the plugin_manager on the plugin instance here. # This ensures it's available to the plugin instance's methods like enable() and particularly setup(). # Assumes MultiplayerPlugin.__init__ defines self.plugin_manager = None plugin_instance.plugin_manager = plugin_manager_instance # Define a unique key for the plugin (e.g., based on its name from constants) plugin_key = mp_constants.PLUGIN_NAME.lower().replace(" ", "_") # Example: "multiplayer" # Register the plugin with the plugin manager # The plugin manager will use this information to manage the plugin plugin_manager_instance.plugins[plugin_key] = { 'instance': plugin_instance, # The actual plugin object 'name': mp_constants.PLUGIN_NAME, # Display name of the plugin 'version': mp_constants.PLUGIN_VERSION,# Version number 'author': mp_constants.PLUGIN_AUTHOR, # Author(s) 'description': mp_constants.PLUGIN_DESCRIPTION, # Brief description 'requires': mp_constants.PLUGIN_REQUIRES, # List of dependencies (other plugin names) 'is_setup': False, # Plugin's own setup method will set this to True 'is_enabled_by_default': False # This should ALWAYS ALWAYS be set to 'False' or bad things will happen } # The plugin manager should ideally pass itself to the plugin instance, # which we now do above. # The MultiplayerPlugin.enable() method relies on self.plugin_manager being set. # This print uses the global print function, as an instance logger isn't set up for this main.py scope. print(f"{mp_constants.PLUGIN_NAME} (Version: {mp_constants.PLUGIN_VERSION} by {mp_constants.PLUGIN_AUTHOR}) has been registered with the plugin manager.") return True except Exception as e: # Use global print for errors at this very early stage if a logger isn't available/reliable print(f"Error during {mp_constants.PLUGIN_NAME} plugin initialization (in plugins/multiplayer/main.py): {e}") traceback.print_exc() # Print full traceback for diagnosing initialization errors return False # --- End of Plugin Registration --- ================================================ FILE: plugins/multiplayer/mp_constants.py ================================================ # File: mp_constants.py # --- Plugin Metadata --- # These constants describe the plugin to the system and users. PLUGIN_NAME = "Multiplayer" PLUGIN_VERSION = "1.2.0" PLUGIN_AUTHOR = "ViciousSquid" PLUGIN_DESCRIPTION = "Enables network sync for squids and objects (Experimental)" PLUGIN_REQUIRES = [] # Names of other plugins this one depends on # --- Network Configuration --- # These define the network parameters for multicast communication. MULTICAST_GROUP = '224.3.29.71' # IP address for the multicast group MULTICAST_PORT = 10000 # Port number for multicast communication SYNC_INTERVAL = 1.0 # Default seconds between game state sync broadcasts MAX_PACKET_SIZE = 1472 # Maximum UDP packet size, to prevent fragmentation USE_TCP = False # default – restored from ini TCP_IP_LIST = [] # will be ['192.168.1.50','192.168.1.51',…] TCP_PORT = 5008 # --- Visual Settings (Defaults) --- # These are default visual parameters. The MultiplayerPlugin instance may override these # based on runtime configuration (e.g., from a settings dialog). REMOTE_SQUID_OPACITY = 1.0 # Default opacity for remote squids (0.0 to 1.0) SHOW_REMOTE_LABELS = True # Default setting for showing labels on remote entities SHOW_CONNECTION_LINES = True # Default setting for showing lines connecting to remote squids ================================================ FILE: plugins/multiplayer/mp_network_node.py ================================================ # File: mp_network_node.py import uuid import time import socket import queue import zlib import json import traceback import threading import logging from .mp_constants import MULTICAST_GROUP, MULTICAST_PORT, MAX_PACKET_SIZE class NetworkNode: def __init__(self, node_id=None, logger=None): """ Represents a networked node in the multiplayer system. Args: node_id (str, optional): Unique identifier for this node. Generated if not provided. logger (logging.Logger, optional): Logger instance to use. A default one is created if not provided. """ try: # Attempt to use NetworkUtilities if available from plugins.multiplayer.network_utilities import NetworkUtilities self.node_id = node_id or NetworkUtilities.generate_node_id() self.utils = NetworkUtilities except ImportError: self.node_id = node_id or f"squid_{uuid.uuid4().hex[:8]}" self.utils = None # Mark utils as unavailable self.socket = None self.initialized = False # Socket structure initialized (IP_ADD_MEMBERSHIP etc.) self.is_connected = False # Socket bound and ready for I/O self._is_listening_active = False # Flag to control the listening loop self.listener_thread = None # Thread object for the listening loop self.last_connection_attempt = 0 self.connection_retry_interval = 5.0 # seconds self.auto_reconnect = True # Flag to control auto-reconnect attempts self.use_compression = True # Flag to control message compression self.incoming_queue = queue.Queue(maxsize=500) # Bounded: if drain stops, old packets are dropped rather than eating RAM self.queue_lock = threading.Lock() # Used with incoming_message_queue in one of the versions, ensure consistency # Deduplication: on a single machine every multicast packet is received once # per membership (once per interface + the 0.0.0.0 catch-all), so the same # message can arrive 2-3× in the queue. We track (node_id, timestamp) tuples # and silently drop duplicates for self._dedup_ttl seconds. self._seen_message_ids: dict = {} # {(node_id, timestamp): time_first_seen} self._dedup_ttl: float = 10.0 # seconds before a seen-key is expired self.known_nodes = {} # Stores info about other detected nodes self.last_sync_time = 0 # Timestamp of the last sync operation self.debug_mode = False # Controlled by MultiplayerPlugin # Logger must be set up before _get_local_ip() which uses self.logger if logger is not None: self.logger = logger else: _logger_name = f"{__name__}.NetworkNode.{self.node_id[:4]}" self.logger = logging.getLogger(_logger_name) if not self.logger.handlers: # Avoid adding multiple handlers if logger is passed around handler = logging.StreamHandler() formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') handler.setFormatter(formatter) self.logger.addHandler(handler) # Initial level, can be updated by MultiplayerPlugin if debug_mode changes self.logger.setLevel(logging.DEBUG if self.debug_mode else logging.INFO) self.local_ip = self._get_local_ip() self.initialize_socket_structure() # Initialize socket when a NetworkNode is created def _get_local_ip(self): """ Detects the best local LAN IP for multicast by enumerating all interfaces and ranking them. Prefers 192.168.x.x (typical home/office LAN) over 172.16.x.x, then 10.x.x.x -- avoiding VPN/virtual adapters that may have captured the default route and share the same IP across machines. Set MULTICAST_BIND_IP in mp_constants.py to a specific IP to override. """ # Allow manual override via config from . import mp_constants as _mc override = getattr(_mc, 'MULTICAST_BIND_IP', '').strip() if override and override != '0.0.0.0': self.logger.info(f"[IP] Using manually configured MULTICAST_BIND_IP: {override}") return override candidates = [] # Strategy 1: enumerate all IPs via getaddrinfo on hostname try: hostname = socket.gethostname() for info in socket.getaddrinfo(hostname, None, socket.AF_INET): ip = info[4][0] if ip not in candidates: candidates.append(ip) except Exception: pass # Strategy 2: also grab the default-route IP the OS prefers try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: s.settimeout(0.1) s.connect(("8.8.8.8", 80)) default_ip = s.getsockname()[0] if default_ip not in candidates: candidates.append(default_ip) except Exception: pass self.logger.info(f"[IP] All detected IPs on this machine: {candidates}") def score(ip): """Higher score = more likely a real LAN interface.""" if ip.startswith('127.') or ip.startswith('169.254.'): return -1 # loopback / link-local: skip if ip.startswith('192.168.'): return 3 # classic home/office LAN -- highest priority if ip.startswith('172.'): parts = ip.split('.') try: if 16 <= int(parts[1]) <= 31: return 2 # RFC1918 172.16-31 range except (IndexError, ValueError): pass if ip.startswith('10.'): return 1 # Could be LAN or VPN; lower priority return 0 scored = [(score(ip), ip) for ip in candidates if score(ip) >= 0] scored.sort(key=lambda x: x[0], reverse=True) self.logger.info(f"[IP] Scored candidates (higher=better): {scored}") if scored: chosen = scored[0][1] if len(scored) > 1 and scored[0][0] == scored[1][0]: all_ips = [ip for _, ip in scored] self.logger.warning( f"[IP] Multiple equal-score IPs: {all_ips}. Using {chosen}. " f"If wrong (e.g. a VPN IP), set MULTICAST_BIND_IP in mp_constants.py to your real LAN IP." ) else: self.logger.info(f"[IP] Selected local IP for multicast: {chosen}") return chosen self.logger.warning("[IP] Could not detect a suitable LAN IP. Falling back to 127.0.0.1.") return '127.0.0.1' def _get_all_send_ips(self): """ Returns all local IPv4 addresses that are valid for multicast sending, scored so the best LAN IPs come first. Always includes 0.0.0.0 (default interface) as a fallback at the end so same-machine loopback always works even if NIC enumeration misses something. """ def score(ip): if ip.startswith('127.') or ip.startswith('169.254.'): return -1 if ip.startswith('192.168.'): return 3 if ip.startswith('172.'): try: if 16 <= int(ip.split('.')[1]) <= 31: return 2 except (IndexError, ValueError): pass if ip.startswith('10.'): return 1 return 0 candidates = set() try: for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET): candidates.add(info[4][0]) except Exception: pass try: with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as s: s.settimeout(0.1) s.connect(("8.8.8.8", 80)) candidates.add(s.getsockname()[0]) except Exception: pass scored = sorted( [(score(ip), ip) for ip in candidates if score(ip) >= 0], key=lambda x: x[0], reverse=True ) result = [ip for _, ip in scored] # Always ensure 0.0.0.0 is last — it lets the OS pick the default route # interface, which covers same-machine loopback if nothing else does. if '0.0.0.0' not in result: result.append('0.0.0.0') self.logger.info(f"[MCAST] Send interfaces: {result}") return result def initialize_socket_structure(self): """Initializes the socket, sets options, binds, and joins the multicast group.""" if self.is_connected and self.socket: # Check if already properly set up self.logger.info("Socket structure already initialized and connected.") return True try: if self.socket: # If socket exists but not connected, close it first try: self.socket.close() except Exception: pass # Ignore errors on close if already closed self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # SO_REUSEPORT allows multiple processes to bind to the same port, useful for testing on one machine if hasattr(socket, "SO_REUSEPORT"): try: self.socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) except OSError as e: if self.debug_mode: self.logger.debug(f"SO_REUSEPORT not supported or error setting it: {e}") try: # Enable loopback so packets are delivered to other sockets on the same host. # Value 1 = enabled (was incorrectly set to 0, which disabled same-machine peer detection). self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, 1) if self.debug_mode: self.logger.debug("Multicast loopback enabled.") except socket.error as e_loop: self.logger.warning(f"Could not enable multicast loopback: {e_loop}. May impact same-machine testing.") self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, 2) # Time-to-live for multicast packets # All instances MUST bind to the same port (MULTICAST_PORT) so they all # receive packets sent to that port. The old port-increment fallback was # broken: if instance 2 bumped to 10001, it sent to 10000 but listened on # 10001, so it never heard anything. SO_REUSEADDR (set above) allows # multiple processes to share a UDP multicast port on Windows. try: self.socket.bind(('', MULTICAST_PORT)) self.logger.info(f"Socket bound successfully to port {MULTICAST_PORT}.") except OSError as bind_error: self.logger.error( f"Could not bind to port {MULTICAST_PORT}: {bind_error}. " f"Ensure SO_REUSEADDR is supported and no non-multicast process has exclusively claimed this port." ) self.is_connected = False self.initialized = False return False # Join the multicast group on every valid local interface so we receive # packets whether they arrive from the loopback (same-machine peers) or # from the physical LAN adapter (remote peers). self._multicast_send_ips = self._get_all_send_ips() joined_count = 0 for iface_ip in self._multicast_send_ips: try: mreq = socket.inet_aton(MULTICAST_GROUP) + socket.inet_aton(iface_ip) self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) joined_count += 1 self.logger.info(f"[MCAST] Joined multicast group on interface {iface_ip}") except OSError as e_join: self.logger.warning(f"[MCAST] Could not join group on {iface_ip}: {e_join}") # Always also join via 0.0.0.0 as a catch-all safety net try: mreq_any = socket.inet_aton(MULTICAST_GROUP) + socket.inet_aton("0.0.0.0") self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq_any) self.logger.info(f"[MCAST] Also joined via 0.0.0.0 (catch-all). Total explicit joins: {joined_count}") except OSError: pass # Already joined on all interfaces - OS treats 0.0.0.0 as duplicate, harmless self.socket.settimeout(0.1) # Non-blocking recvfrom self.is_connected = True # Socket is bound and ready self.initialized = True # Full structure (options, group) is set up self.last_connection_attempt = time.time() self.logger.info(f"Socket structure initialized on {self.local_ip} (listening on all interfaces, port {MULTICAST_PORT}) for multicast group {MULTICAST_GROUP}.") return True except Exception as e: self.logger.error(f"Error initializing socket structure: {e}", exc_info=self.debug_mode) if self.socket: try: self.socket.close() except: pass self.socket = None self.is_connected = False self.initialized = False return False def _listen_for_multicast(self): """Dedicated thread function to listen for incoming multicast packets.""" self.logger.info(f"Listener thread started for node {self.node_id} on IP {self.local_ip}.") while self._is_listening_active: # Loop controlled by flag if not self.is_connected or not self.socket: self.logger.warning("Listening loop: Socket not connected or available. Attempting to reconnect...") if not self.try_reconnect(): # This will call initialize_socket_structure self.logger.error("Listening loop: Reconnect failed. Pausing before retry.") time.sleep(self.connection_retry_interval) continue # Retry connection try: raw_data, addr = self.socket.recvfrom(MAX_PACKET_SIZE) if raw_data: # Use the thread-safe queue for passing data to the main thread. # put_nowait raises queue.Full (never blocks) so the listener # thread can't be stalled if the main thread stops draining. try: self.incoming_queue.put_nowait({'raw_data': raw_data, 'addr': addr}) except queue.Full: # Queue is full – main thread has stopped draining. Drop # the oldest packet to make room (FIFO discard) rather than # losing the newest one entirely. try: self.incoming_queue.get_nowait() except queue.Empty: pass try: self.incoming_queue.put_nowait({'raw_data': raw_data, 'addr': addr}) except queue.Full: pass # Give up on this packet – main thread is very stuck except socket.timeout: continue # Normal behavior for non-blocking socket, allows checking _is_listening_active except OSError as e: # Handle socket closed or other OS errors if self._is_listening_active: # Log only if we weren't expecting closure self.logger.error(f"Socket OS error in listener thread: {e}", exc_info=True) self.is_connected = False # Assume connection is lost if OS error occurs # No break here, rely on try_reconnect in the next iteration if _is_listening_active is still true except Exception as e: # Catch any other unexpected errors if self._is_listening_active: self.logger.error(f"Unexpected error in listener thread: {e}", exc_info=True) time.sleep(0.1) # Brief pause before retrying or exiting based on flag self.logger.info(f"Listener thread stopped for node {self.node_id}.") def is_listening(self): """Checks if the listener thread is active and alive.""" return self._is_listening_active and self.listener_thread is not None and self.listener_thread.is_alive() def watchdog_check(self) -> bool: """ Call this periodically (e.g. from the plugin's connection_timer) to detect and recover a silently-dead listener thread. A thread can die silently when an unexpected exception escapes the while-loop (shouldn't happen given the broad except clause, but OS signal delivery or interpreter shutdown edge cases can still kill it). Returns True if everything is healthy, False if a restart was attempted. """ if not self._is_listening_active: return True # We deliberately stopped – not a fault if self.listener_thread is None or not self.listener_thread.is_alive(): self.logger.error( f"Watchdog: Listener thread for node {self.node_id} has died unexpectedly! " f"Attempting automatic restart." ) self._is_listening_active = False # Reset flag so start_listening doesn't bail early self.listener_thread = None restarted = self.start_listening() if restarted: self.logger.info("Watchdog: Listener thread restarted successfully.") else: self.logger.error("Watchdog: Listener thread restart FAILED.") return False # Signal that a recovery was needed return True # Thread is alive and well def start_listening(self): """Starts the multicast listener thread if not already running.""" if self.is_listening(): self.logger.info("Attempted to start listening, but already listening.") return True # Already doing its job # Ensure socket is initialized and connected before starting to listen if not self.initialized or not self.is_connected: self.logger.warning("Socket not initialized or connected. Attempting to initialize before listening.") if not self.initialize_socket_structure(): self.logger.error("Failed to initialize socket structure. Cannot start listening.") return False self.logger.info("Starting network listener thread...") try: self._is_listening_active = True # Set flag before starting thread self.listener_thread = threading.Thread(target=self._listen_for_multicast, daemon=True) self.listener_thread.setName(f"MPNodeListener-{self.node_id[:4]}") # Helpful for debugging threads self.listener_thread.start() self.logger.info("Listener thread started successfully.") return True except Exception as e: self.logger.error(f"Failed to start listener thread: {e}", exc_info=self.debug_mode) self._is_listening_active = False # Reset flag on error return False def stop_listening(self): """Stops the multicast listener thread.""" if not self._is_listening_active and (self.listener_thread is None or not self.listener_thread.is_alive()): # If the flag is already false and thread is gone or never started. self.logger.info("Listener already stopped or was never started.") return self.logger.info("Stopping listener thread...") self._is_listening_active = False # Signal the loop to terminate if self.listener_thread and self.listener_thread.is_alive(): self.listener_thread.join(timeout=2.0) # Wait for the thread to finish if self.listener_thread.is_alive(): # This might happen if socket.recvfrom() is stuck, though timeout should prevent it. self.logger.warning("Listener thread did not stop in time (join timeout).") else: self.logger.info("Listener thread joined successfully.") else: self.logger.info("Listener thread was not active or did not exist at explicit stop.") self.listener_thread = None # Clear the thread object def try_reconnect(self): """Attempts to re-establish the socket connection and restart listening if needed.""" if self.is_listening(): # If already listening, assume connection is fine return True current_time = time.time() if current_time - self.last_connection_attempt < self.connection_retry_interval: # Avoid rapid reconnection attempts return False self.logger.info("Attempting to reconnect and restart listener...") self.last_connection_attempt = current_time self.stop_listening() # Ensure any old listener is stopped if self.initialize_socket_structure(): # Re-initialize socket (binds, joins group) return self.start_listening() # Start listening again else: self.logger.error("Reconnect failed: Could not re-initialize socket structure.") return False def send_message(self, message_type: str, payload: dict): """Sends a single message to the multicast group.""" if not self.is_connected: # Check if socket is ready self.logger.warning(f"Cannot send '{message_type}', socket not connected.") if self.auto_reconnect and not self.try_reconnect(): # Attempt to reconnect if enabled self.logger.error(f"Send failed for '{message_type}': Reconnect attempt failed.") return False elif not self.is_connected: # If still not connected after attempt self.logger.error(f"Send failed for '{message_type}': Still not connected after reconnect check.") return False # Construct the full message with metadata message_data = { 'node_id': self.node_id, # Sender's ID 'timestamp': time.time(), # Time of sending 'type': message_type, # Type of message (e.g., "squid_exit") 'payload': payload # The actual data payload } try: data_to_send: bytes serialized_message = json.dumps(message_data).encode('utf-8') if self.use_compression: # Use NetworkUtilities for compression if available, else direct zlib if self.utils and hasattr(self.utils, 'compress_message'): # Assuming compress_message takes the dict and returns bytes data_to_send = self.utils.compress_message(message_data) else: # Fallback to direct zlib if NetworkUtilities or method missing data_to_send = zlib.compress(serialized_message) if self.debug_mode and message_type.upper() in ["SQUID_EXIT", "SQUID_RETURN"]: self.logger.debug(f"DEBUG_COMPRESS (send): Type: {message_type}. Original: {len(serialized_message)}, Compressed: {len(data_to_send)}") else: data_to_send = serialized_message if len(data_to_send) > MAX_PACKET_SIZE: self.logger.warning(f"Message '{message_type}' size ({len(data_to_send)}) exceeds MAX_PACKET_SIZE. May fail or be fragmented (UDP handles this, but can be less reliable).") if not self.socket: self.logger.error(f"Cannot send '{message_type}', socket is None.") return False # Send on every valid local interface so that: # - same-machine peers receive it via IP_MULTICAST_LOOP loopback # - LAN peers receive it via the physical NIC send_ips = getattr(self, '_multicast_send_ips', None) or ['0.0.0.0'] sent_ok = False for iface_ip in send_ips: try: iface_bytes = socket.inet_aton(iface_ip) self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, iface_bytes) self.socket.sendto(data_to_send, (MULTICAST_GROUP, MULTICAST_PORT)) sent_ok = True except OSError as e_send: self.logger.warning(f"[MCAST] Send on interface {iface_ip} failed: {e_send}") if self.debug_mode and message_type not in ['object_sync', 'squid_move', 'heartbeat']: self.logger.debug(f"Sent '{message_type}' ({len(data_to_send)} bytes) on {len(send_ips)} interface(s).") return sent_ok except socket.error as sock_err: # Specific socket errors self.logger.error(f"Socket error sending message '{message_type}': {sock_err}") self.is_connected = False # Assume connection is broken self.stop_listening() # Stop listener as connection is likely bad except Exception as e: # Other errors (JSON encoding, compression etc.) self.logger.error(f"Error sending message '{message_type}': {e}", exc_info=self.debug_mode) return False def send_message_batch(self, messages: list): """Sends a batch of messages in a single packet.""" # Connection check similar to send_message if not self.is_connected: self.logger.warning("Cannot send batch, socket not connected.") if self.auto_reconnect and not self.try_reconnect(): self.logger.error("Send batch failed: Reconnect attempt failed.") return False elif not self.is_connected: self.logger.error("Send batch failed: Still not connected after reconnect check.") return False # Structure for batch message batch_data = { 'node_id': self.node_id, 'timestamp': time.time(), 'batch': True, # Indicates this packet contains multiple messages 'messages': [{'type': msg_type, 'payload': payload} for msg_type, payload in messages] } try: data_to_send: bytes serialized_batch = json.dumps(batch_data).encode('utf-8') if self.use_compression: if self.utils and hasattr(self.utils, 'compress_message'): data_to_send = self.utils.compress_message(batch_data) else: data_to_send = zlib.compress(serialized_batch) else: data_to_send = serialized_batch if len(data_to_send) > MAX_PACKET_SIZE: self.logger.warning(f"Batch message size ({len(data_to_send)}) exceeds MAX_PACKET_SIZE. Transmission may fail.") if not self.socket: self.logger.error("Cannot send batch, socket is None.") return False send_ips = getattr(self, '_multicast_send_ips', None) or ['0.0.0.0'] sent_ok = False for iface_ip in send_ips: try: self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_IF, socket.inet_aton(iface_ip)) self.socket.sendto(data_to_send, (MULTICAST_GROUP, MULTICAST_PORT)) sent_ok = True except OSError as e_send: self.logger.warning(f"[MCAST] Batch send on interface {iface_ip} failed: {e_send}") if self.debug_mode: self.logger.debug(f"Sent batch ({len(data_to_send)} bytes, {len(messages)} msgs) on {len(send_ips)} interface(s).") return sent_ok except socket.error as sock_err: self.logger.error(f"Socket error sending message batch: {sock_err}") self.is_connected = False self.stop_listening() except Exception as e: self.logger.error(f"Error sending message batch: {e}", exc_info=self.debug_mode) return False def receive_messages(self): """ Processes all currently queued raw datagrams from the listener thread. This should be called by the main application thread. Returns: list: A list of (message_dict, address_tuple) for successfully decoded messages. """ if not self.is_connected and not self.initialized : # If socket isn't even set up # This case might occur if receive_messages is called before successful initialization # or after a critical failure. # self.logger.debug("receive_messages called but socket not initialized/connected.") return [] received_messages_this_call = [] # Process all items currently in the queue while not self.incoming_queue.empty(): try: item = self.incoming_queue.get_nowait() # Get item from queue raw_data = item['raw_data'] addr = item['addr'] except queue.Empty: # Should not happen with while not empty(), but as safeguard break except Exception as e_q: # Should not happen for basic queue ops self.logger.error(f"Error getting item from incoming_queue: {e_q}") continue # Peek at sender_node_id from raw data if possible (for debug log context) temp_node_id_peek = "unknown_at_raw_recv" try: # This peeking is best-effort for logging, might fail if data is not as expected peek_data_bytes = raw_data if self.use_compression: # Try decompressing a copy for peeking try: peek_data_bytes = zlib.decompress(raw_data) except zlib.error: pass # If not zlib compressed, peek_data_bytes remains raw_data j_peek = json.loads(peek_data_bytes.decode('utf-8', errors='ignore')) temp_node_id_peek = j_peek.get('node_id', 'peek_decode_fail') except: # Broad except as peeking can fail in many ways temp_node_id_peek = 'peek_failed_entirely' # Log raw reception if it's not from self (based on peek) if temp_node_id_peek != self.node_id: if self.debug_mode: print(f"DEBUG_RAW_RECEIVE (Node {self.node_id}) from {addr} (Peeked Sender: {temp_node_id_peek}). Size: {len(raw_data)}. Data[:60]: {raw_data[:60]}") message_dict = None decoded_successfully = False # Try decoding (with or without compression) try: data_for_json_decode = raw_data if self.use_compression: try: if self.utils and hasattr(self.utils, 'decompress_message'): # Assumes decompress_message returns a dict or raises error message_dict = self.utils.decompress_message(raw_data) else: # Fallback to direct zlib + json data_for_json_decode = zlib.decompress(raw_data) message_dict = json.loads(data_for_json_decode.decode('utf-8')) decoded_successfully = True except (zlib.error, TypeError) as e_zlib: # TypeError if utils.decompress_message fails unexpectedly # If zlib fails, it might be an uncompressed message. Try decoding raw_data as JSON. if self.debug_mode: self.logger.debug(f"Zlib decompression failed from {addr} (Sender: {temp_node_id_peek}): {e_zlib}. Trying as uncompressed JSON.") # data_for_json_decode remains raw_data message_dict = json.loads(raw_data.decode('utf-8')) decoded_successfully = True # If this line is reached, uncompressed JSON was successful else: # Not using compression, just decode JSON message_dict = json.loads(raw_data.decode('utf-8')) decoded_successfully = True except (json.JSONDecodeError, UnicodeDecodeError) as e_decode: if self.debug_mode: self.logger.warning(f"Failed to decode JSON/UTF-8 from {addr} (Sender: {temp_node_id_peek}). Error: {e_decode}. Data: {raw_data[:80]}") continue # Skip this malformed packet except Exception as e_general_decode: # Catch-all for other unexpected decoding issues if self.debug_mode: self.logger.error(f"General error decoding packet from {addr} (Sender: {temp_node_id_peek}): {e_general_decode}", exc_info=True) continue if not decoded_successfully or not isinstance(message_dict, dict) or 'node_id' not in message_dict: if self.debug_mode: self.logger.debug(f"Invalid or incomplete message structure after all decode attempts from {addr} (Sender: {temp_node_id_peek}): {message_dict}") continue final_sender_node_id = message_dict.get('node_id') # Critical filter: Ignore messages from self if final_sender_node_id == self.node_id: continue # ── Deduplication ──────────────────────────────────────────────────────── # On a single machine every multicast send is received once per membership # (once per NIC interface + the 0.0.0.0 catch-all join), so the same # logical message can arrive 2-3× in the queue. We key on # (sender_node_id, timestamp) – every outgoing envelope already stamps a # float timestamp – and silently drop copies seen within _dedup_ttl seconds. _now = time.time() # Prune expired entries so the dict stays small self._seen_message_ids = { k: v for k, v in self._seen_message_ids.items() if _now - v < self._dedup_ttl } msg_dedup_key = (final_sender_node_id, message_dict.get('timestamp')) if msg_dedup_key in self._seen_message_ids: if self.debug_mode: self.logger.debug( f"Dropping duplicate message key={msg_dedup_key} from {addr}" ) continue self._seen_message_ids[msg_dedup_key] = _now # ───────────────────────────────────────────────────────────────────────── # Log decoded message details if self.debug_mode: payload_keys_str = list(message_dict.get('payload', {}).keys()) if isinstance(message_dict.get('payload'), dict) else 'Payload_Not_Dict' print(f"DEBUG_DECODED (Node {self.node_id}) from {addr}: Type '{message_dict.get('type', 'N/A')}', From Node '{final_sender_node_id}', PayloadKeys: {payload_keys_str}") if message_dict.get('type') == 'squid_exit': # Specific debug for SQUID_EXIT payload print(f"DEBUG_SQUID_EXIT_PAYLOAD_RECEIVED: {message_dict.get('payload')}") # Update known_nodes (this is a simplified version, a more robust presence system might be needed) # The payload of interest for squid's last known state might be deeper, e.g., message_dict['payload']['payload'] for SQUID_EXIT squid_info_for_known_nodes = message_dict.get('payload', {}) if message_dict.get('type') == 'squid_exit' and isinstance(squid_info_for_known_nodes.get('payload'), dict): squid_info_for_known_nodes = squid_info_for_known_nodes.get('payload') self.known_nodes[final_sender_node_id] = (addr[0], time.time(), squid_info_for_known_nodes) # Add the fully processed message and its original address to the list for the caller received_messages_this_call.append((message_dict, addr)) return received_messages_this_call def process_messages(self, plugin_manager_ref): """ Retrieves messages from the internal queue (filled by receive_messages via listener thread) and triggers hooks in the PluginManager. This method is intended to be called by the main application thread. """ messages_to_process_from_queue = [] while not self.incoming_queue.empty(): # Drain the queue try: # Item from queue is expected to be {'raw_data': ..., 'addr': ...} from _listen_for_multicast # NO, item from queue should be (decoded_message_dict, addr) if receive_messages puts decoded ones. # Let's clarify: _listen_for_multicast puts raw data. # receive_messages (called by this process_messages or similar) decodes them. # This process_messages should be working with DECODED messages. # The current structure has receive_messages called by process_messages. # So, call receive_messages first to get decoded messages. decoded_messages_and_addrs = self.receive_messages() # This call processes the queue internally. for message_data, addr in decoded_messages_and_addrs: # Now message_data is a decoded dict if not isinstance(message_data, dict) or 'type' not in message_data or 'node_id' not in message_data: if self.debug_mode: self.logger.debug(f"process_messages: Discarding malformed message: {message_data}") continue # Redundant self-check, receive_messages should have handled this. # if message_data['node_id'] == self.node_id: # continue message_type = message_data.get('type', 'unknown_message') hook_name = f"on_network_{message_type}" # Convention for hook names if self.debug_mode: print(f"DEBUG_STEP_2A: NetworkNode {self.node_id} attempting to trigger hook: '{hook_name}' for msg type '{message_type}' from node {message_data['node_id']}") # Trigger hook via PluginManager if hasattr(plugin_manager_ref, 'trigger_hook'): plugin_manager_ref.trigger_hook( hook_name, node=self, # Pass this NetworkNode instance message=message_data, # Pass the decoded message dictionary addr=addr # Pass the original address tuple ) # Fallback if PluginManager has a different direct processing method (less common for hook systems) elif hasattr(plugin_manager_ref, '_process_network_message'): plugin_manager_ref._process_network_message(message_data, addr) else: # Log if no way to dispatch the message if self.debug_mode: self.logger.warning(f"Plugin manager has no trigger_hook or _process_network_message method for hook {hook_name}") break # process_messages should ideally process one batch from receive_messages at a time. except Exception as e: # Catch any errors during the processing loop self.logger.error(f"Error in process_messages loop: {e}", exc_info=self.debug_mode) break # Exit loop on error to avoid continuous failure on same bad data def close(self): """Cleans up the network node, stops listening, and closes the socket.""" self.logger.info(f"Closing network node {self.node_id}...") self.auto_reconnect = False # Prevent any further reconnect attempts during closure self.stop_listening() # Signal listener thread to stop and wait for it if self.socket: socket_was_initialized_and_connected = self.initialized and self.is_connected self.is_connected = False # Mark as not connected self.initialized = False # Mark as not initialized # Attempt to leave multicast group if socket was properly set up if socket_was_initialized_and_connected and self.local_ip: try: # Use "0.0.0.0" for imr_interface when leaving, consistent with joining mreq_leave_struct = socket.inet_aton(MULTICAST_GROUP) + socket.inet_aton("0.0.0.0") self.socket.setsockopt(socket.IPPROTO_IP, socket.IP_DROP_MEMBERSHIP, mreq_leave_struct) self.logger.info("Left multicast group.") except socket.error as e_mcast_leave: if self.debug_mode: self.logger.debug(f"Socket error leaving multicast group (may be normal if already disconnected): {e_mcast_leave}") except AttributeError: # Can happen if local_ip was problematic if self.debug_mode: self.logger.debug("AttributeError leaving multicast group (IP likely invalid during shutdown).") except Exception as e_general_leave: # Catch any other unexpected errors if self.debug_mode: self.logger.error(f"Unexpected error leaving multicast group: {e_general_leave}", exc_info=True) try: self.socket.close() self.logger.info("Socket closed.") except Exception as e_sock_close: if self.debug_mode: self.logger.debug(f"Error closing socket (may already be closed): {e_sock_close}") self.socket = None # Clear socket reference self.logger.info(f"Network node {self.node_id} closed.") ================================================ FILE: plugins/multiplayer/mp_plugin_logic.py ================================================ # File: mp_plugin_logic.py import os # import sys # sys.path is handled in main.py import inspect # For _find_tamagotchi_logic import json import uuid import time # import socket # Network operations are in NetworkNode import threading import queue # For plugin's own internal queuing if different from NetworkNode's import random import traceback # For logging exceptions import math from typing import Dict, List, Any, Tuple from PyQt5 import QtCore, QtGui, QtWidgets # --- Local Imports --- import logging # Added for logger from . import mp_constants # Access constants like mp_constants.PLUGIN_NAME from .mp_network_node import NetworkNode from .remote_entity_manager import RemoteEntityManager # Ensure this is imported if type hinting or direct use from .squid_multiplayer_autopilot import RemoteSquidController # Ensure this for autopilot logic # TamagotchiLogic is imported in main.py and should be in sys.path # If type hinting is needed here and main.py's import might not be seen by linters: try: from src.tamagotchi_logic import TamagotchiLogic except ImportError: TamagotchiLogic = None class MultiplayerPlugin: def __init__(self): # --- Initialize logger attribute --- self.logger = None # --- Locks for thread safety --- self.network_lock = threading.RLock() self.remote_squids_lock = threading.RLock() self.remote_objects_lock = threading.RLock() # --- Core Components --- self.network_node: NetworkNode | None = None self.plugin_manager = None # Set by plugin system or during setup self.tamagotchi_logic: TamagotchiLogic | None = None # Set during setup # --- Timers and Threads --- self.sync_thread: threading.Thread | None = None self.message_process_timer: QtCore.QTimer | None = None self.controller_update_timer: QtCore.QTimer | None = None self.controller_creation_timer: QtCore.QTimer | None = None self.cleanup_timer_basic: QtCore.QTimer | None = None self.connection_timer_basic: QtCore.QTimer | None = None # --- State and Data --- self.remote_squids: Dict[str, Dict[str, Any]] = {} self.remote_objects: Dict[str, Dict[str, Any]] = {} self.remote_squid_controllers: Dict[str, Any] = {} # Should be RemoteSquidController self.pending_controller_creations: List[Dict[str, Any]] = [] self.connection_lines: Dict[str, QtWidgets.QGraphicsLineItem] = {} self.last_message_times: Dict[str, float] = {} # --- Configuration --- self.MULTICAST_GROUP = mp_constants.MULTICAST_GROUP self.MULTICAST_PORT = mp_constants.MULTICAST_PORT self.SYNC_INTERVAL = mp_constants.SYNC_INTERVAL # MODIFIED for testing: Force full opacity self.REMOTE_SQUID_OPACITY = 1.0 # Was mp_constants.REMOTE_SQUID_OPACITY self.SHOW_REMOTE_LABELS = mp_constants.SHOW_REMOTE_LABELS self.SHOW_CONNECTION_LINES = mp_constants.SHOW_CONNECTION_LINES # --- UI Elements --- self.config_dialog: QtWidgets.QDialog | None = None self.status_widget: Any | None = None # Should be MultiplayerStatusWidget self.status_bar: Any | None = None # --- Flags --- self.is_setup = False # 1. Try to get the global flag from config.ini try: self.debug_mode = self.plugin_manager.config_manager.get_debug_flag("multiplayer_debug", fallback=False) except Exception: # 2. Any problem (no ConfigManager, no key, etc.) → stay quiet self.debug_mode = False # 3. Push the value to every sub-component that cares if self.network_node: self.network_node.debug_mode = self.debug_mode if getattr(self, 'entity_manager', None): self.entity_manager.debug_mode = self.debug_mode self.last_controller_update = time.time() self.entity_manager: RemoteEntityManager | None = None # Added type hint self.config_manager = None # Placeholder, ensure this is set if used (e.g. in handle_squid_exit_message print) def _initialize_remote_entity_manager(self): """ Initializes the RemoteEntityManager instance. This method encapsulates the logic for creating and configuring the RemoteEntityManager. """ if not self.logger: # This case should ideally not happen if logger is set up in __init__ or early setup print("MPPluginLogic ERRA: Logger not available for _initialize_remote_entity_manager") self.entity_manager = None return if self.tamagotchi_logic and \ hasattr(self.tamagotchi_logic, 'user_interface') and \ self.tamagotchi_logic.user_interface and \ hasattr(self.tamagotchi_logic.user_interface, 'image_cache'): # Check for image_cache ui = self.tamagotchi_logic.user_interface try: self.entity_manager = RemoteEntityManager( scene=ui.scene, window_width=ui.window_width, window_height=ui.window_height, #image_cache=ui.image_cache, # Pass the image_cache instance debug_mode=self.debug_mode, # self.debug_mode should be set in MpPluginLogic logger=self.logger.getChild("RemoteEntityManager") # Pass a child logger ) self.logger.info("RemoteEntityManager initialized successfully.") except ImportError: # Should not happen if imports are correct at file top self.logger.error("RemoteEntityManager import failed during initialization. Visuals for remote entities will be basic or non-functional.", exc_info=True) self.entity_manager = None # self.initialize_remote_representation() # Call your fallback if RemoteEntityManager fails except Exception as e_rem: self.logger.error(f"Error initializing RemoteEntityManager: {e_rem}", exc_info=True) self.entity_manager = None # self.initialize_remote_representation() # Call your fallback else: self.logger.warning("User interface, TamagotchiLogic, or ImageCache not available for RemoteEntityManager setup. Remote visuals may be limited or non-functional.") self.entity_manager = None # self.initialize_remote_representation() # Call your fallback def debug_autopilot_status(self): """Debug the status of all autopilot controllers for remote squids.""" if not self.logger: # Safeguard print("Multiplayer ERRA: Logger not initialized in debug_autopilot_status") return if not hasattr(self, 'remote_squid_controllers') or not self.remote_squid_controllers: self.logger.debug("No remote squid controllers are currently active.") return self.logger.debug(f"\n=== AUTOPILOT DEBUG ({len(self.remote_squid_controllers)} controllers) ===") for node_id, controller in self.remote_squid_controllers.items(): squid_name = node_id[-6:] self.logger.debug(f"Squid {squid_name}:") self.logger.debug(f" State: {getattr(controller, 'state', 'N/A')}") squid_data = getattr(controller, 'squid_data', {}) pos_x = squid_data.get('x', 0.0) pos_y = squid_data.get('y', 0.0) self.logger.debug(f" Position: ({pos_x:.1f}, {pos_y:.1f})") self.logger.debug(f" Direction: {squid_data.get('direction', 'N/A')}") self.logger.debug(f" Home Dir: {getattr(controller, 'home_direction', 'N/A')}") time_away = getattr(controller, 'time_away', 0.0) max_time = getattr(controller, 'max_time_away', 0.0) self.logger.debug(f" Time Away: {time_away:.1f}s / {max_time:.1f}s") food_count = getattr(controller, 'food_eaten_count', 0) rock_count = getattr(controller, 'rock_interaction_count', 0) self.logger.debug(f" Activities: {food_count} food, {rock_count} rocks") target_obj = getattr(controller, 'target_object', None) self.logger.debug(f" Target: {'Yes (' + type(target_obj).__name__ + ')' if target_obj else 'No'}") self.logger.debug("=====================================\n") def enable(self): self.logger.info("Attempting to enable Multiplayer...") # --- NEW: Debug log all key objects --- self.logger.debug(f"[ENABLE] status_widget: {self.status_widget}") self.logger.debug(f"[ENABLE] entity_manager: {self.entity_manager}") self.logger.debug(f"[ENABLE] network_node: {self.network_node}") self.logger.debug(f"[ENABLE] tamagotchi_logic: {self.tamagotchi_logic}") self.logger.debug(f"[ENABLE] config_manager: {getattr(self, 'config_manager', None)}") # --- END DEBUG LOG --- # Plugin already set up? if not self.is_setup: self.logger.info("Multiplayer plugin is not set up. Calling setup()...") # Assuming self.plugin_manager and self.tamagotchi_logic_ref are available if not self.setup(self.plugin_manager, self.tamagotchi_logic_ref): # Pass necessary args self.logger.error("Multiplayer setup failed during enable(). Cannot enable.") return False else: self.logger.info("Multiplayer is already marked as set up. Re-enabling components.") # --- BEGIN NEW/MODIFIED SECTION --- # Ensure network node is ready and listening if self.network_node: # Ensure the socket structure is initialized (it should be by NetworkNode.__init__ or a previous setup) # but a re-check or re-init if disconnected can be robust. if not self.network_node.is_connected: self.logger.info("NetworkNode socket not connected, attempting to initialize in enable()...") if not self.network_node.initialize_socket_structure(): self.logger.error("Failed to initialize NetworkNode socket in enable(). Cannot proceed with enabling multiplayer.") # Potentially set self.enabled = False or similar state management return False # Or handle error appropriately # Explicitly start the listener thread if it's not already active if not self.network_node.is_listening(): self.logger.info("NetworkNode listener not active, starting it explicitly in enable()...") if not self.network_node.start_listening(): self.logger.error("Failed to start NetworkNode listener in enable(). Multiplayer might not receive messages.") # Decide if this is a fatal error for enabling or just a warning # For now, let's treat it as potentially non-fatal but log an error. # Depending on requirements, you might return False here. else: self.logger.info(">>>>>> NetworkNode listener started successfully!") else: self.logger.info("NetworkNode listener was already active.") else: self.logger.error("NetworkNode not found after setup in enable(). Cannot enable multiplayer fully.") # Potentially set self.enabled = False return False # This is likely a critical failure # --- END NEW/MODIFIED SECTION --- # Resume original enable logic: # For example, re-initialize UI components, timers, etc. # Ensure any components that were disabled are re-enabled. # Re-initialize or ensure timers are running (if they were stopped in disable) if self.message_process_timer: if not self.message_process_timer.isActive(): self.message_process_timer.start(50) self.logger.info("Message processing timer restarted.") else: self.logger.warning("message_process_timer is None in enable(). Skipping.") if hasattr(self, 'sync_timer') and self.sync_timer: if not self.sync_timer.isActive(): self.logger.info("Sync timer not active, starting/restarting it.") self.start_sync_timer() else: self.logger.warning("sync_timer not found or is None. Skipping.") # Update status widget if applicable if self.status_widget: try: self.status_widget.update_status("Enabled", True) current_ip = self.network_node.local_ip if self.network_node else "N/A" if hasattr(self.status_widget, 'set_ip_address'): self.status_widget.set_ip_address(current_ip) else: self.logger.warning("status_widget has no set_ip_address method.") except Exception as e: self.logger.error(f"Error updating status_widget in enable(): {e}", exc_info=True) else: self.logger.warning("status_widget is None in enable(). Skipping UI update.") self.enabled = True # Mark as enabled self.logger.info("Multiplayer enabled successfully.") return True def disable(self): """Disables the multiplayer plugin and cleans up resources.""" if not self.logger: # Should be set by now print("Multiplayer ERRA: Logger not set in disable()") # Fallback to print if logger is broken return self.logger.info(f"Disabling {mp_constants.PLUGIN_NAME}...") if self.network_node and self.network_node.is_connected: self.network_node.send_message( 'player_leave', {'node_id': self.network_node.node_id, 'reason': 'plugin_disabled'} ) self.cleanup() if self.status_widget: self.status_widget.hide() if self.status_bar: if hasattr(self.status_bar, 'update_network_status'): self.status_bar.update_network_status(False) if hasattr(self.status_bar, 'update_peers_count'): self.status_bar.update_peers_count(0) self.logger.info(f"{mp_constants.PLUGIN_NAME} disabled.") def setup(self, plugin_manager_instance, tamagotchi_logic_instance): """ Sets up the multiplayer plugin. Called when the plugin is first loaded or enabled. Args: plugin_manager_instance: A reference to the main plugin manager. tamagotchi_logic_instance: A reference to the core tamagotchi logic. """ self.plugin_manager = plugin_manager_instance # --- BEGIN LOGGER INITIALIZATION --- if hasattr(self.plugin_manager, 'logger') and self.plugin_manager.logger is not None: self.logger = self.plugin_manager.logger.getChild(mp_constants.PLUGIN_NAME) # Get a child logger is good practice else: logger_name = f"{mp_constants.PLUGIN_NAME}_Plugin" self.logger = logging.getLogger(logger_name) if not self.logger.hasHandlers(): handler = logging.StreamHandler() formatter = logging.Formatter('%(asctime)s - %(name)s - [%(levelname)s] - %(message)s') handler.setFormatter(formatter) self.logger.addHandler(handler) # Initial level, might be changed by debug_mode later self.logger.setLevel(logging.INFO) self.logger.warning( "PluginManager did not provide a valid logger. Using fallback logger." ) self.logger.info(f"Initializing setup for {mp_constants.PLUGIN_NAME}...") # --- END LOGGER INITIALIZATION --- # Attempt to get config_manager from plugin_manager if available (for the print in handle_squid_exit) if hasattr(plugin_manager_instance, 'config_manager'): self.config_manager = plugin_manager_instance.config_manager elif hasattr(tamagotchi_logic_instance, 'config_manager'): # Check on tamagotchi_logic as well self.config_manager = tamagotchi_logic_instance.config_manager else: self.logger.warning("ConfigManager not found for debug print in handle_squid_exit_message.") # Create a dummy if needed for the print to not error out, or handle it in the print class DummyConfigManager: def get_node_id(self): return "UnknownNodeID_Setup" # Differentiate if needed if not hasattr(self, 'config_manager') or self.config_manager is None : # Set only if not already set self.config_manager = DummyConfigManager() self.tamagotchi_logic = tamagotchi_logic_instance if not TamagotchiLogic: # Class itself self.logger.critical("TamagotchiLogic module was not loaded (import failed). Cannot complete setup.") return False if self.tamagotchi_logic is None: # Instance self.logger.warning("TamagotchiLogic instance was not directly passed or was None. Attempting to find it via PluginManager.") if hasattr(self.plugin_manager, 'core_game_logic'): self.tamagotchi_logic = self.plugin_manager.core_game_logic elif hasattr(self.plugin_manager, 'tamagotchi_logic'): # Common attribute name self.tamagotchi_logic = self.plugin_manager.tamagotchi_logic else: # Fallback deep search self.tamagotchi_logic = self._find_tamagotchi_logic(self.plugin_manager) if not self.tamagotchi_logic: self.logger.critical("TamagotchiLogic instance not found. Plugin functionality will be severely limited.") # return False # Decided to proceed with limited functionality if UI parts are missing else: self.debug_mode = getattr(self.tamagotchi_logic, 'debug_mode', False) if self.logger and hasattr(self.logger, 'setLevel'): # Make sure logger has setLevel self.logger.setLevel(logging.DEBUG if self.debug_mode else logging.INFO) self.logger.info(f"TamagotchiLogic instance found. Debug mode: {self.debug_mode}") node_id_val = f"squid_{uuid.uuid4().hex[:6]}" self.network_node = NetworkNode(node_id_val, logger=self.logger) self.network_node.debug_mode = self.debug_mode # Pass debug mode to network node if self.tamagotchi_logic: # Ensure tamagotchi_logic exists before setting attribute setattr(self.tamagotchi_logic, 'multiplayer_network_node', self.network_node) if not self.message_process_timer: self.message_process_timer = QtCore.QTimer() self.message_process_timer.timeout.connect(self._process_network_node_queue) self.message_process_timer.start(50) # Process queue every 50ms if not self.controller_update_timer: self.controller_update_timer = QtCore.QTimer() self.controller_update_timer.timeout.connect(self.update_remote_controllers) self.controller_update_timer.start(50) # Update controllers every 50ms if not self.controller_creation_timer: self._setup_controller_creation_timer() # For deferred controller creation self._register_hooks() # Register message handlers # Clear previous state self.remote_squids.clear() self.remote_objects.clear() self.connection_lines.clear() self.remote_squid_controllers.clear() self.last_controller_update = time.time() # === MODIFIED RemoteEntityManager Instantiation Block START === if self.tamagotchi_logic and \ hasattr(self.tamagotchi_logic, 'user_interface') and \ self.tamagotchi_logic.user_interface: ui = self.tamagotchi_logic.user_interface # This is the GameWindow instance # Removed check for ui.image_cache as it's no longer passed or needed by RemoteEntityManager try: # RemoteEntityManager should be imported at the top of mp_plugin_logic.py self.entity_manager = RemoteEntityManager( scene=ui.scene, window_width=ui.window_width, window_height=ui.window_height, # image_cache=ui.image_cache, # <<< THIS LINE IS REMOVED debug_mode=self.debug_mode, # Correct: Pass the debug_mode boolean logger=self.logger.getChild("RemoteEntityManager") # Good practice for logger ) self.logger.info("RemoteEntityManager initialized.") except ImportError: # Should be caught if RemoteEntityManager isn't imported self.logger.error("RemoteEntityManager class import failed. Visuals for remote entities will be basic or non-functional.", exc_info=True) self.entity_manager = None self.initialize_remote_representation() # Your fallback except Exception as e_rem: self.logger.error(f"Error initializing RemoteEntityManager: {e_rem}", exc_info=True) self.entity_manager = None self.initialize_remote_representation() # Your fallback else: self.logger.warning("User interface or TamagotchiLogic not available for RemoteEntityManager setup. Remote visuals may be limited.") self.entity_manager = None self.initialize_remote_representation() # Your fallback # === MODIFIED RemoteEntityManager Instantiation Block END === self.initialize_status_ui() # Initialize status widget or bar if self.tamagotchi_logic and hasattr(self.tamagotchi_logic, 'show_message') and self.network_node: self.tamagotchi_logic.show_message(f"Multiplayer active! Node ID: {self.network_node.node_id}") node_ip = self.network_node.local_ip if self.network_node else "N/A" node_port = self.MULTICAST_PORT # Use the constant self.logger.info(f"Setup complete. Node: {node_id_val} on IP: {node_ip}. Listening for multicast on port: {node_port}") self.is_setup = True return True def _process_network_node_queue(self, **kwargs): """Called by a QTimer to process messages from the NetworkNode's incoming_queue.""" if not self.logger: return if self.network_node and self.plugin_manager: try: self.network_node.process_messages(self.plugin_manager) except Exception as e: if self.debug_mode: self.logger.error(f"Error in _process_network_node_queue: {e}", exc_info=True) def setup_minimal_network(self): """(Helper) Creates a basic network interface if one is required but not found.""" if not self.logger: return self.logger.info("Setting up minimal network interface...") class MinimalNetworkInterface: def create_socket(self, socket_type='udp'): import socket return socket.socket(socket.AF_INET, socket.SOCK_DGRAM) if self.plugin_manager: self.plugin_manager.plugins['network_interface'] = { 'instance': MinimalNetworkInterface(), 'name': 'Minimal Network Interface', 'version': '0.1' } self.logger.info("Minimal network interface registered.") def update_remote_squid_image(self, remote_squid_display_data: Dict, direction: str): """Updates the visual image of a remote squid based on its direction.""" if not self.logger: return False visual_item = remote_squid_display_data.get('visual') if not visual_item or not isinstance(visual_item, QtWidgets.QGraphicsPixmapItem): return False try: base_image_path = "images" squid_image_file = f"{direction.lower()}1.png" full_image_path = os.path.join(base_image_path, squid_image_file) squid_pixmap = QtGui.QPixmap(full_image_path) if squid_pixmap.isNull(): fallback_path = os.path.join(base_image_path, "right1.png") # Default fallback squid_pixmap = QtGui.QPixmap(fallback_path) if squid_pixmap.isNull() and self.debug_mode: self.logger.warning(f"Could not load squid image '{full_image_path}' or fallback '{fallback_path}'.") # Create a colored square as an ultimate fallback if even "right1.png" fails squid_pixmap = QtGui.QPixmap(60,40) # Default size squid_color = remote_squid_display_data.get('data',{}).get('color',(128,128,128)) squid_pixmap.fill(QtGui.QColor(*squid_color)) visual_item.setPixmap(squid_pixmap) return True except Exception as e: if self.debug_mode: self.logger.error(f"Error updating remote squid image for direction '{direction}': {e}", exc_info=True) return False def handle_squid_interaction(self, local_squid, remote_node_id, remote_squid_data): """Handles interactions between the local squid and a detected remote squid.""" if not self.logger: return if not local_squid or not remote_squid_data or not self.tamagotchi_logic: return local_pos = (local_squid.squid_x, local_squid.squid_y) remote_pos = (remote_squid_data.get('x',0.0), remote_squid_data.get('y',0.0)) distance = math.hypot(local_pos[0] - remote_pos[0], local_pos[1] - remote_pos[1]) interaction_distance_threshold = 80 # Example threshold if distance < interaction_distance_threshold: if hasattr(local_squid, 'memory_manager') and hasattr(local_squid.memory_manager, 'add_short_term_memory'): local_squid.memory_manager.add_short_term_memory( category='social', event_type='squid_meeting', description=f"Met squid {remote_node_id[-6:]} from another tank.", importance=5 ) self.attempt_gift_exchange(local_squid, remote_node_id) def attempt_gift_exchange(self, local_squid, remote_node_id: str): """Allows squids to exchange a random decoration item if conditions are met.""" if not self.logger: return False if random.random() > 0.15: return False # 15% chance if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'user_interface'): return False ui = self.tamagotchi_logic.user_interface local_decorations = [ item for item in ui.scene.items() if isinstance(item, QtWidgets.QGraphicsPixmapItem) and getattr(item, 'category', '') == 'decoration' and item.isVisible() and not getattr(item, 'is_foreign', False) and # Not already from remote not getattr(item, 'is_gift_from_remote', False) # Not a gift they received ] if not local_decorations: return False gift_to_send_away = random.choice(local_decorations) # Simulate receiving a gift (create a new decoration) received_gift_item = self.create_gift_decoration(remote_node_id) if received_gift_item: # If a gift was successfully created for the local scene # Make the local squid's chosen decoration disappear (sent away) gift_to_send_away.setVisible(False) # Optionally, remove it from the scene after a delay or permanently QtCore.QTimer.singleShot(15000, lambda item=gift_to_send_away: self._remove_gifted_item_from_scene(item)) if hasattr(local_squid, 'memory_manager'): local_squid.memory_manager.add_short_term_memory( 'social', 'decoration_exchange', f"Exchanged decorations with squid {remote_node_id[-6:]}!", importance=7 ) if hasattr(self.tamagotchi_logic, 'show_message'): self.tamagotchi_logic.show_message(f"🎁 Your squid exchanged gifts with {remote_node_id[-6:]}!") return True return False def _remove_gifted_item_from_scene(self, item_to_remove): """Safely removes an item from the scene if it's still present.""" if not self.logger: return if item_to_remove and self.tamagotchi_logic and hasattr(self.tamagotchi_logic, 'user_interface'): scene = self.tamagotchi_logic.user_interface.scene if item_to_remove in scene.items(): scene.removeItem(item_to_remove) if self.debug_mode: self.logger.debug(f"Removed gifted item '{getattr(item_to_remove,'filename','N/A')}' from scene.") def create_stolen_rocks(self, local_squid, num_rocks: int, entry_position: tuple): """Creates rock items in the local scene, representing rocks 'stolen' by the local squid from a remote tank.""" if not self.logger: return if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'user_interface') or num_rocks <= 0: return ui = self.tamagotchi_logic.user_interface scene = ui.scene rock_image_files = [] search_paths = [os.path.join("images", "decoration"), "images"] # Common paths for rocks for path in search_paths: if os.path.exists(path): for filename in os.listdir(path): if 'rock' in filename.lower() and filename.lower().endswith(('.png', '.jpg')): rock_image_files.append(os.path.join(path, filename)) if not rock_image_files: # Fallback if no specific rocks found rock_image_files.append(os.path.join("images", "rock.png")) # Assume a default rock image entry_x, entry_y = entry_position for i in range(num_rocks): try: chosen_rock_file = random.choice(rock_image_files) # Scatter rocks around the entry point angle_offset = random.uniform(-math.pi / 4, math.pi / 4) angle = (i * (2 * math.pi / num_rocks)) + angle_offset dist = random.uniform(60, 100) # Distance from entry point rock_x = entry_x + dist * math.cos(angle) rock_y = entry_y + dist * math.sin(angle) rock_pixmap = QtGui.QPixmap(chosen_rock_file) if rock_pixmap.isNull(): continue # Skip if image fails to load rock_graphics_item = None if hasattr(ui, 'ResizablePixmapItem'): # Check if custom item class exists rock_graphics_item = ui.ResizablePixmapItem(rock_pixmap, chosen_rock_file) else: rock_graphics_item = QtWidgets.QGraphicsPixmapItem(rock_pixmap) setattr(rock_graphics_item, 'filename', chosen_rock_file) # Store filename if not ResizablePixmapItem setattr(rock_graphics_item, 'category', 'rock') setattr(rock_graphics_item, 'can_be_picked_up', True) setattr(rock_graphics_item, 'is_stolen_from_remote', True) setattr(rock_graphics_item, 'is_foreign', True) # Mark as foreign for tinting rock_graphics_item.setPos(rock_x, rock_y) scene.addItem(rock_graphics_item) self.apply_foreign_object_tint(rock_graphics_item) # Apply a visual tint # Simple fade-in for stolen rocks (no complex animation for static testing) rock_graphics_item.setOpacity(0.2) # Start slightly transparent # Removed QPropertyAnimation for opacity for static testing rock_graphics_item.setOpacity(1.0) # Make fully visible immediately except Exception as e: if self.debug_mode: self.logger.error(f"Error creating stolen rock visuals: {e}", exc_info=True) if hasattr(local_squid, 'memory_manager'): local_squid.memory_manager.add_short_term_memory( 'achievement', 'rock_heist', f"Brought back {num_rocks} rocks from an adventure!", importance=8 ) def apply_foreign_object_tint(self, q_graphics_item: QtWidgets.QGraphicsPixmapItem): """Applies a visual tint to indicate an object is from a remote instance.""" if not self.logger: return if not isinstance(q_graphics_item, QtWidgets.QGraphicsPixmapItem): return existing_effect = q_graphics_item.graphicsEffect() if isinstance(existing_effect, QtWidgets.QGraphicsColorizeEffect): # Update existing effect if it's already the right type existing_effect.setColor(QtGui.QColor(255, 120, 120, 200)) # Tint color (e.g., reddish) existing_effect.setStrength(0.3) # Tint strength else: # Create and apply new effect colorize_effect = QtWidgets.QGraphicsColorizeEffect() colorize_effect.setColor(QtGui.QColor(255, 120, 120, 200)) colorize_effect.setStrength(0.3) q_graphics_item.setGraphicsEffect(colorize_effect) setattr(q_graphics_item, 'is_foreign', True) # Ensure flag is set def show_network_dashboard(self): """Displays a dialog with detailed network status and peer information.""" if not self.logger: return if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'user_interface') or not self.network_node: self.logger.warning("Cannot show network dashboard - UI or NetworkNode missing.") return parent_window = self.tamagotchi_logic.user_interface.window dashboard_dialog = QtWidgets.QDialog(parent_window) dashboard_dialog.setWindowTitle("Multiplayer Network Dashboard") dashboard_dialog.setMinimumSize(550, 450) main_layout = QtWidgets.QVBoxLayout(dashboard_dialog) # Connection Info Group conn_info_group = QtWidgets.QGroupBox("My Connection") conn_info_form = QtWidgets.QFormLayout(conn_info_group) node_id_label = QtWidgets.QLabel(self.network_node.node_id) ip_label = QtWidgets.QLabel(self.network_node.local_ip) status_val_label = QtWidgets.QLabel() # Will be updated conn_info_form.addRow("Node ID:", node_id_label) conn_info_form.addRow("Local IP:", ip_label) conn_info_form.addRow("Status:", status_val_label) main_layout.addWidget(conn_info_group) # Peers Group peers_group = QtWidgets.QGroupBox("Detected Peers") peers_layout = QtWidgets.QVBoxLayout(peers_group) peers_table_widget = QtWidgets.QTableWidget() peers_table_widget.setColumnCount(4) peers_table_widget.setHorizontalHeaderLabels(["Node ID", "IP Address", "Last Seen", "Status"]) peers_table_widget.horizontalHeader().setStretchLastSection(True) peers_table_widget.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) peers_table_widget.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) peers_layout.addWidget(peers_table_widget) main_layout.addWidget(peers_group) # Network Stats Group (Conceptual) stats_group = QtWidgets.QGroupBox("Network Statistics (Conceptual)") stats_form = QtWidgets.QFormLayout(stats_group) stats_form.addRow("Messages Sent (Total):", QtWidgets.QLabel(str(getattr(self.network_node, 'total_sent_count', 'N/A')))) stats_form.addRow("Messages Received (Total):", QtWidgets.QLabel(str(getattr(self.network_node, 'total_received_count', 'N/A')))) main_layout.addWidget(stats_group) def refresh_dashboard_data(): # Update connection status is_connected = self.network_node.is_connected status_val_label.setText("Connected" if is_connected else "Disconnected") status_val_label.setStyleSheet("color: green; font-weight: bold;" if is_connected else "color: red; font-weight: bold;") # Update peers table peers_table_widget.setRowCount(0) # Clear table if self.network_node: # Check if network_node still exists for row, (node_id, (ip, last_seen, _)) in enumerate(self.network_node.known_nodes.items()): peers_table_widget.insertRow(row) peers_table_widget.setItem(row, 0, QtWidgets.QTableWidgetItem(node_id)) peers_table_widget.setItem(row, 1, QtWidgets.QTableWidgetItem(ip)) time_delta_secs = time.time() - last_seen time_ago_str = f"{int(time_delta_secs)}s ago" peers_table_widget.setItem(row, 2, QtWidgets.QTableWidgetItem(time_ago_str)) peer_status_str = "Active" if time_delta_secs < 20 else "Inactive" # Example threshold status_cell_item = QtWidgets.QTableWidgetItem(peer_status_str) status_cell_item.setForeground(QtGui.QBrush(QtGui.QColor("green" if peer_status_str == "Active" else "gray"))) peers_table_widget.setItem(row, 3, status_cell_item) peers_table_widget.resizeColumnsToContents() refresh_dashboard_data() # Initial population # Buttons button_box = QtWidgets.QDialogButtonBox() refresh_btn = button_box.addButton("Refresh", QtWidgets.QDialogButtonBox.ActionRole) close_btn = button_box.addButton(QtWidgets.QDialogButtonBox.Close) refresh_btn.clicked.connect(refresh_dashboard_data) close_btn.clicked.connect(dashboard_dialog.accept) main_layout.addWidget(button_box) dashboard_dialog.exec_() def initialize_status_ui(self): """Initializes UI components for displaying multiplayer status.""" if not self.logger: return try: if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'user_interface'): self.logger.warning("Status UI cannot be initialized - user_interface not found.") return ui = self.tamagotchi_logic.user_interface try: from plugins.multiplayer.multiplayer_status_widget import MultiplayerStatusWidget if not hasattr(ui, '_mp_status_widget_instance_'): # Create if not exists ui._mp_status_widget_instance_ = MultiplayerStatusWidget(ui.window) # Position the widget (example: top-right corner) ui._mp_status_widget_instance_.move( ui.window.width() - ui._mp_status_widget_instance_.width() - 15, 15 ) ui._mp_status_widget_instance_.hide() # Initially hidden self.status_widget = ui._mp_status_widget_instance_ if self.network_node and hasattr(self.status_widget, 'set_network_node_reference'): self.status_widget.set_network_node_reference(self.network_node) self.logger.info("Dedicated status widget initialized.") except ImportError: self.logger.info("MultiplayerStatusWidget not found. Will attempt fallback status bar integration.") self.initialize_status_bar() # Fallback except Exception as e_msw: self.logger.error(f"Error initializing MultiplayerStatusWidget: {e_msw}. Using fallback.", exc_info=True) self.initialize_status_bar() # Fallback except Exception as e: self.logger.error(f"Could not initialize status UI: {e}", exc_info=True) def initialize_status_bar(self): # Added stub, assuming it was missing """Fallback to initialize status bar component if dedicated widget fails.""" if not self.logger: return self.logger.info("Attempting to initialize fallback status bar component.") # Implementation for status_bar initialization would go here # For now, just log that it's a fallback if self.tamagotchi_logic and hasattr(self.tamagotchi_logic, 'user_interface') and \ hasattr(self.tamagotchi_logic.user_interface, 'statusBar'): # Example access self.status_bar = self.tamagotchi_logic.user_interface.statusBar() # Example: Add a permanent widget or label to the status bar # if self.status_bar and not hasattr(self, '_mp_status_label_basic'): # self._mp_status_label_basic = QtWidgets.QLabel("MP: Disconnected") # self.status_bar.addPermanentWidget(self._mp_status_label_basic) self.logger.info("Fallback status bar component obtained.") else: self.logger.warning("Fallback status bar component could not be obtained.") def _find_tamagotchi_logic(self, search_object, depth=0, visited_ids=None): """Recursively searches for an attribute named 'tamagotchi_logic'.""" if not self.logger: return None # Should not happen if called after setup if visited_ids is None: visited_ids = set() if id(search_object) in visited_ids or depth > 6: # Prevent deep recursion / cycles return None visited_ids.add(id(search_object)) # Direct check: Is the search_object itself the TamagotchiLogic instance? if TamagotchiLogic and isinstance(search_object, TamagotchiLogic): return search_object # Check for a 'tamagotchi_logic' attribute specifically if hasattr(search_object, 'tamagotchi_logic'): tl_attr = getattr(search_object, 'tamagotchi_logic') if TamagotchiLogic and isinstance(tl_attr, TamagotchiLogic): return tl_attr # Iterate through attributes if it's not a basic type or module try: for attr_name in dir(search_object): if attr_name.startswith('_'): continue # Skip private/magic attributes try: attr_value = getattr(search_object, attr_name) # Avoid recursing into very common or problematic types if attr_value is None or isinstance(attr_value, (int, str, bool, float, list, dict, set, tuple, bytes)): continue if inspect.ismodule(attr_value) or inspect.isbuiltin(attr_value) or inspect.isroutine(attr_value): continue # Be careful with Qt parent traversals to avoid excessive depth or cycles if isinstance(attr_value, (QtWidgets.QWidget, QtCore.QObject)): if depth > 2 and attr_name in ['parent', 'parentWidget', 'parentItem']: # Limit depth for parent attributes continue found_logic = self._find_tamagotchi_logic(attr_value, depth + 1, visited_ids) if found_logic: return found_logic except (AttributeError, RecursionError, TypeError, ReferenceError): # Catch errors during getattr or recursion continue except (RecursionError, TypeError, ReferenceError): # Catch errors from dir() or initial checks pass # Could happen with problematic objects return None def _animate_remote_squid_entry(self, squid_graphics_item, status_text_item, entry_direction_str): """MODIFIED: Animates (or rather, just shows) the visual entry of a remote squid.""" if not self.logger: return if not squid_graphics_item: return # Directly set opacity to full (or the plugin's setting) squid_graphics_item.setOpacity(self.REMOTE_SQUID_OPACITY) # REMOTE_SQUID_OPACITY is 1.0 for testing squid_graphics_item.setScale(1.0) # Ensure normal scale if squid_graphics_item.graphicsEffect(): # Remove any prior effect squid_graphics_item.setGraphicsEffect(None) if status_text_item: status_text_item.setOpacity(1.0) # Make status text fully visible if status_text_item.graphicsEffect(): # Remove any prior effect status_text_item.setGraphicsEffect(None) if self.debug_mode: self.logger.debug(f"Static display for remote squid entry: {entry_direction_str}") def get_opposite_direction(self, direction_str: str) -> str: """Returns the opposite of a given cardinal direction string.""" opposites = {'left': 'right', 'right': 'left', 'up': 'down', 'down': 'up'} return opposites.get(direction_str.lower(), 'right') # Default if unknown def create_entry_effect(self, center_x: float, center_y: float, direction_str: str = ""): """Creates a visual effect at the point where a remote squid enters the scene. MODIFIED: This effect will be static or very simple for testing.""" if not self.logger: return if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'user_interface'): return scene = self.tamagotchi_logic.user_interface.scene # Simplified static indicator (e.g., a small, briefly visible circle or text) # For testing, we can even skip this visual flourish if the goal is just to see the squid. if self.debug_mode: self.logger.debug(f"Skipping dynamic entry effect for static testing at ({center_x},{center_y}).") # If you still want a minimal static cue: # arrival_text_str = "New Visitor" # arrival_text_item = scene.addText(arrival_text_str) # arrival_font = QtGui.QFont("Arial", 10, QtGui.QFont.Bold) # arrival_text_item.setFont(arrival_font) # text_metrics = QtGui.QFontMetrics(arrival_font) # text_rect = text_metrics.boundingRect(arrival_text_str) # arrival_text_item.setDefaultTextColor(QtGui.QColor(255, 215, 0)) # Gold # arrival_text_item.setPos(center_x - text_rect.width() / 2, center_y - 60) # Position above entry # arrival_text_item.setZValue(100) # arrival_text_item.setVisible(True) # # Timer to remove it after a short period if desired, or let it persist. # QtCore.QTimer.singleShot(3000, lambda: scene.removeItem(arrival_text_item) if arrival_text_item.scene() else None) def _setup_controller_immediately(self, node_id: str, squid_initial_data: Dict): """Creates and initializes a RemoteSquidController.""" if not self.logger: return try: # from plugins.multiplayer.squid_multiplayer_autopilot import RemoteSquidController # Already at top pass # Ensure it's imported except ImportError: if self.debug_mode: self.logger.error("RemoteSquidController module not found. Remote squids will not be autonomous.") return if node_id in self.remote_squid_controllers: if self.debug_mode: self.logger.debug(f"Controller for squid {node_id[-6:]} already exists. Updating its data.") # Ensure squid_data includes x,y if updating position via controller self.remote_squid_controllers[node_id].squid_data.update(squid_initial_data) # Potentially trigger a state re-evaluation in the controller if data changed significantly # self.remote_squid_controllers[node_id].evaluate_state() # If such a method exists return if self.debug_mode: self.logger.info(f"Creating autopilot controller for remote squid {node_id[-6:]}.") try: controller_instance = RemoteSquidController( squid_data=squid_initial_data, # This now contains x,y from entry_details if available scene=self.tamagotchi_logic.user_interface.scene, plugin_instance=self, # Pass self (MultiplayerPlugin instance) debug_mode=self.debug_mode, remote_entity_manager=self.entity_manager # Pass the entity manager ) self.remote_squid_controllers[node_id] = controller_instance if self.debug_mode: self.logger.info(f"Controller for {node_id[-6:]} created. Initial state: {getattr(controller_instance, 'state', 'N/A')}") except Exception as e_create: self.logger.error(f"Failed to create RemoteSquidController for {node_id[-6:]}: {e_create}", exc_info=True) return # Ensure the controller update timer is running if self.controller_update_timer and not self.controller_update_timer.isActive(): self.controller_update_timer.start() if self.debug_mode: self.logger.debug("Restarted controller update timer.") def handle_squid_exit_message(self, node: Any, message: Dict, addr: tuple): """ We just received a 'squid_exit' multicast. Draw the giant exit-arrow and then create / update the remote squid visual. """ # ----- basic sanity / logging ----- node_id_for_print = (getattr(self, 'config_manager', None) and getattr(self.config_manager, 'get_node_id', None) and self.config_manager.get_node_id()) or \ (self.network_node and self.network_node.node_id) or "UnknownNode_HandleExit" if self.debug_mode: print(f"DEBUG_STEP_1: Node {node_id_for_print} - handle_squid_exit_message CALLED. " f"Message type: {message.get('type')}") if not self.logger: print("MPPluginLogic ERRA: Logger not available in handle_squid_exit_message") return False try: self.logger.info(f"MY NODE ID {self.network_node.node_id if self.network_node else 'Unknown'} - " f"Received squid_exit message: {message} from {addr}") exit_payload_outer = message.get('payload', {}) exit_payload_inner = exit_payload_outer.get('payload') # actual exit_data if not exit_payload_inner: self.logger.warning("squid_exit message missing nested 'payload' key.") return False source_node_id = exit_payload_inner.get('node_id') if not source_node_id: self.logger.warning("squid_exit inner payload missing 'node_id'.") return False # ignore our own broadcast if self.network_node and source_node_id == self.network_node.node_id: self.logger.debug(f"Ignoring own squid_exit broadcast for {source_node_id}.") return False # ========== NEW: SHOW THE BIG EXIT ARROW ========== exit_dir = exit_payload_inner.get('direction', 'right') if self.debug_mode: self.logger.debug(f"Showing exit arrow for direction: {exit_dir}") self._show_exit_arrow(exit_dir) # =================================================== # create / update the remote squid visual if self.entity_manager: update_success = self.entity_manager.update_remote_squid( source_node_id, exit_payload_inner, is_new_arrival=True ) if update_success and source_node_id not in self.remote_squid_controllers: self.logger.info(f"Creating new autopilot for remote squid {source_node_id}") entry_details = self.entity_manager.get_last_calculated_entry_details(source_node_id) initial_data = exit_payload_inner.copy() if entry_details: initial_data['x'], initial_data['y'] = entry_details['entry_pos'] initial_data['entry_direction_on_this_screen'] = entry_details['entry_direction'] autopilot_controller = RemoteSquidController( squid_data=initial_data, scene=self.tamagotchi_logic.user_interface.scene, plugin_instance=self, debug_mode=self.debug_mode, remote_entity_manager=self.entity_manager ) self.remote_squid_controllers[source_node_id] = autopilot_controller self.logger.info(f"Autopilot for {source_node_id} created.") else: self.logger.warning(f"Failed to create/update remote squid {source_node_id} in entity_manager.") else: self.logger.error("Remote entity manager (self.entity_manager) not found!") return True except Exception as e: self.logger.error(f"Error in handle_squid_exit_message: {e}", exc_info=True) return False def _setup_controller_creation_timer(self): """(Fallback) Sets up a QTimer to process pending controller creations.""" if not self.logger: return if self.controller_creation_timer and self.controller_creation_timer.isActive(): return # Already running if not self.controller_creation_timer: self.controller_creation_timer = QtCore.QTimer() self.controller_creation_timer.timeout.connect(self._process_pending_controller_creations) self.controller_creation_timer.start(300) # Check every 300ms if self.debug_mode: self.logger.debug("Fallback controller creation timer started.") def _process_pending_controller_creations(self): """(Fallback) Processes remote squids waiting for controllers.""" if not self.logger: return if not hasattr(self, 'pending_controller_creations') or not self.pending_controller_creations: return items_to_process = list(self.pending_controller_creations) # Copy for safe iteration self.pending_controller_creations.clear() # Clear original list for creation_task in items_to_process: node_id = creation_task.get('node_id') squid_data = creation_task.get('squid_data') if not node_id or not squid_data: continue if node_id not in self.remote_squid_controllers: if self.debug_mode: self.logger.debug(f"Fallback: Creating controller for squid {node_id[-6:]}.") self._setup_controller_immediately(node_id, squid_data) elif self.debug_mode: self.logger.debug(f"Fallback: Controller for {node_id[-6:]} already exists, skipping duplicate creation.") def update_remote_controllers(self): """Called by a QTimer to update RemoteSquidController instances.""" if not self.logger: return # ---- TRACE ---- if self.debug_mode: print(f"TRACE ++++ MP_PLUGIN_LOGIC: update_remote_controllers METHOD ENTERED. " f"Num controllers: {len(self.remote_squid_controllers)} ++++") self.logger.info("DEBUG_MPL_UPDATE: At start of update_remote_controllers. " "Current controllers: %s. Dict size: %s", list(self.remote_squid_controllers.keys()), len(self.remote_squid_controllers)) # ----------------- if not hasattr(self, 'remote_squid_controllers') or not self.remote_squid_controllers: return current_time = time.time() delta_time = current_time - self.last_controller_update if delta_time <= 0.001: return self.last_controller_update = current_time for node_id, controller in list(self.remote_squid_controllers.items()): try: if self.debug_mode: print(f"++++ MP_PLUGIN_LOGIC: Attempting to call update() for controller {node_id[-4:]} ++++") controller.update(delta_time) except Exception as e: self.logger.error(f"++++ MP_PLUGIN_LOGIC: Error updating controller for {node_id[-6:]}: {e}", exc_info=True) def calculate_entry_position(self, entry_side_direction: str) -> tuple: """Calculates X,Y coordinates for a squid entering this local screen.""" if not self.logger: return (100,100) # Default fallback if not self.tamagotchi_logic or not self.tamagotchi_logic.user_interface: return (100, 100) # Fallback if UI not available window_w = self.tamagotchi_logic.user_interface.window_width window_h = self.tamagotchi_logic.user_interface.window_height margin = 70 # How far from the edge it should appear if entry_side_direction == 'left': return (margin, window_h / 2) elif entry_side_direction == 'right':return (window_w - margin, window_h / 2) elif entry_side_direction == 'up': return (window_w / 2, margin) elif entry_side_direction == 'down': return (window_w / 2, window_h - margin) return (window_w / 2, window_h / 2) # Default to center if direction unknown def apply_remote_experiences(self, local_squid, activity_summary: Dict): """Applies summarized experiences from a remote journey to the local squid.""" if not self.logger: return if not local_squid or not activity_summary: return food_eaten = activity_summary.get('food_eaten', 0) rocks_interacted = activity_summary.get('rock_interactions', 0) rocks_brought_back = activity_summary.get('rocks_stolen', 0) time_away_seconds = activity_summary.get('time_away', 0) time_str = f"{int(time_away_seconds/60)}m {int(time_away_seconds%60)}s" journey_desc = f"Returned from a {time_str} journey to another tank. " if hasattr(local_squid, 'memory_manager'): mm = local_squid.memory_manager if food_eaten > 0: journey_desc += f"Ate {food_eaten} snacks there. " local_squid.hunger = max(0, local_squid.hunger - 10 * food_eaten) # Reduce hunger mm.add_short_term_memory('travel', 'ate_on_trip', f"Found {food_eaten} yummy treats on my trip!", 5) if rocks_interacted > 0: journey_desc += f"Played with {rocks_interacted} interesting rocks. " local_squid.happiness = min(100, local_squid.happiness + 3 * rocks_interacted) # Increase happiness mm.add_short_term_memory('travel', 'played_on_trip', f"Played with {rocks_interacted} cool rocks elsewhere!", 4) mm.add_short_term_memory('travel', 'completed_journey', journey_desc, importance=7) if food_eaten > 1 or rocks_interacted > 3 or rocks_brought_back > 0: mm.add_short_term_memory('emotion', 'happy_return', "It's great to be back home after an exciting adventure!", 6) else: mm.add_short_term_memory('emotion', 'calm_return', "Returned home. It was a quiet trip.", 3) if hasattr(local_squid, 'curiosity'): # Reduce curiosity after travel local_squid.curiosity = max(0, local_squid.curiosity - 25) def create_exit_effect(self, center_x: float, center_y: float, direction_str: str = ""): """Creates a visual effect when a local squid exits the screen. MODIFIED: This effect will be static or very simple for testing.""" if not self.logger: return if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'user_interface'): return # scene = self.tamagotchi_logic.user_interface.scene # Scene not used if no visual effects # Simplified static indicator (e.g., a small, briefly visible circle or text) if self.debug_mode: self.logger.debug(f"Skipping dynamic exit effect for static testing at ({center_x},{center_y}).") from PyQt5 import QtCore, QtGui, QtWidgets def _show_exit_arrow(self, exit_direction: str): """ Draw a big arrow flush with the screen edge the squid just left through. exit_direction : 'left' | 'right' | 'up' | 'down' """ if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'user_interface'): return ui = self.tamagotchi_logic.user_interface scene = ui.scene w, h = ui.window_width, ui.window_height colour = QtGui.QColor(255, 200, 0, 200) # juicy orange pen = QtGui.QPen(colour, 16) pen.setCapStyle(QtCore.Qt.RoundCap) pen.setJoinStyle(QtCore.Qt.RoundJoin) # ---------- geometry ---------- shaft_len = min(w, h) * 0.55 # long shaft head_len = shaft_len * 0.30 # arrow-head shaft_wid = 24 # visual thickness if exit_direction == 'left': # leaving LEFT edge → arrow points LEFT shaft = QtCore.QLineF(shaft_len, h/2, 0, h/2) head1 = QtCore.QLineF(head_len, h/2 - head_len*0.6, 0, h/2) head2 = QtCore.QLineF(head_len, h/2 + head_len*0.6, 0, h/2) elif exit_direction == 'right': # leaving RIGHT edge → arrow points RIGHT shaft = QtCore.QLineF(w - shaft_len, h/2, w, h/2) head1 = QtCore.QLineF(w - head_len, h/2 - head_len*0.6, w, h/2) head2 = QtCore.QLineF(w - head_len, h/2 + head_len*0.6, w, h/2) elif exit_direction == 'up': # leaving TOP edge → arrow points UP shaft = QtCore.QLineF(w/2, shaft_len, w/2, 0) head1 = QtCore.QLineF(w/2 - head_len*0.6, head_len, w/2, 0) head2 = QtCore.QLineF(w/2 + head_len*0.6, head_len, w/2, 0) else: # leaving BOTTOM edge → arrow points DOWN shaft = QtCore.QLineF(w/2, h - shaft_len, w/2, h) head1 = QtCore.QLineF(w/2 - head_len*0.6, h - head_len, w/2, h) head2 = QtCore.QLineF(w/2 + head_len*0.6, h - head_len, w/2, h) # ---------- draw ---------- group = QtWidgets.QGraphicsItemGroup() group.setZValue(1000) # on top of everything for line in (shaft, head1, head2): group.addToGroup(scene.addLine(line, pen)) scene.addItem(group) # ---------- fade & self-destruct ---------- def fade(): opacity = group.opacity() if opacity > 0.01: group.setOpacity(max(0, opacity - 0.04)) else: scene.removeItem(group) fade_timer.stop() fade_timer = QtCore.QTimer() fade_timer.timeout.connect(fade) fade_timer.start(40) # ~25 fps → gone in ~2 s def handle_squid_return(self, node: NetworkNode, message: Dict, addr: tuple): """Handles a 'squid_return' message for the player's own squid.""" if not self.logger: return try: return_payload = message.get('payload', {}) returning_node_id = return_payload.get('node_id') if not self.network_node or returning_node_id != self.network_node.node_id: if self.debug_mode: expected_id = self.network_node.node_id if self.network_node else "N/A" self.logger.debug(f"Squid_return message ignored. Expected node '{expected_id}', got '{returning_node_id}'.") return local_squid = self.tamagotchi_logic.squid if not local_squid or not local_squid.squid_item: if self.debug_mode: self.logger.debug("Local squid or its visual item not found for return.") return activity_summary = return_payload.get('activity_summary', {}) entry_side = return_payload.get('return_direction', 'left') entry_coords = self.calculate_entry_position(entry_side) local_squid.squid_x, local_squid.squid_y = entry_coords[0], entry_coords[1] local_squid.squid_item.setPos(local_squid.squid_x, local_squid.squid_y) local_squid.squid_item.setVisible(True) local_squid.squid_item.setOpacity(1.0) if local_squid.squid_item.graphicsEffect(): local_squid.squid_item.graphicsEffect().setEnabled(False) local_squid.squid_item.setGraphicsEffect(None) self.apply_remote_experiences(local_squid, activity_summary) # --- MODIFICATION FOR DETAILED ITEM RECREATION --- carried_items_details_list = activity_summary.get('carried_items_details', []) num_items_brought_back = len(carried_items_details_list) # Or use 'rocks_stolen' if it reliably matches the list length if num_items_brought_back > 0: self.logger.info(f"Local squid returned carrying {num_items_brought_back} item(s). Details: {carried_items_details_list}") # We'll create/modify this method in the next step: self.recreate_carried_items_in_tank(local_squid, carried_items_details_list, entry_coords) if hasattr(self.tamagotchi_logic, 'show_message'): self.tamagotchi_logic.show_message(f"Your squid returned with {num_items_brought_back} item(s) from its adventure!") else: # ... (existing message for no items stolen) ... if hasattr(self.tamagotchi_logic, 'show_message'): journey_time_sec = activity_summary.get('time_away', 0) time_str = f"{int(journey_time_sec/60)}m {int(journey_time_sec%60)}s" self.tamagotchi_logic.show_message(f"Welcome back! Your squid explored for {time_str}.") local_squid.can_move = True if hasattr(local_squid, 'is_transitioning'): local_squid.is_transitioning = False local_squid.status = "just returned home" if self.debug_mode: self.logger.debug(f"Local squid '{getattr(local_squid,'name','')}' returned to {entry_coords} from {entry_side}. Brought back {num_items_brought_back} items.") except Exception as e: self.logger.error(f"Handling local squid's return failed: {e}", exc_info=True) def recreate_carried_items_in_tank(self, local_squid, carried_items_data_list: List[Dict], entry_position: tuple): """ Recreates items in the local scene based on detailed data brought back by the squid. The first item is given to the squid to carry; subsequent items are placed in the tank. """ if not self.logger: print("MPPluginLogic ERRA: Logger not available in recreate_carried_items_in_tank") return if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'user_interface') or not carried_items_data_list: self.logger.warning("Cannot recreate carried items: TamagotchiLogic, UI, or item data missing.") return ui = self.tamagotchi_logic.user_interface scene = ui.scene entry_x, entry_y = entry_position self.logger.info(f"Recreating {len(carried_items_data_list)} item(s) brought back by squid {local_squid.name if hasattr(local_squid, 'name') else 'N/A'}.") item_successfully_given_to_squid_to_carry = False for i, item_data in enumerate(carried_items_data_list): try: original_filename = item_data.get('original_filename') original_category = item_data.get('original_category', 'decoration') # Default if not specified item_scale = item_data.get('scale', 1.0) # Use original_zValue if provided, otherwise default (e.g. for decorations) # Add a small positive bias if it's going to be carried to appear above squid if at same base Z item_z_value = item_data.get('zValue', 0) if not original_filename: self.logger.warning(f"Skipping item recreation: 'original_filename' missing in item data: {item_data}") continue # --- Asset Path Resolution --- # This logic attempts to find the image based on the original_filename. # It assumes original_filename might be a base name or include a subfolder like "decoration/". possible_paths = [ os.path.join("images", original_filename), os.path.join("images", "decoration", os.path.basename(original_filename)), os.path.join("images", "items", os.path.basename(original_filename)), os.path.join("images", "food", os.path.basename(original_filename)), # If food can be carried os.path.join("images", "rocks", os.path.basename(original_filename)), # If rocks are in subfolder original_filename # If original_filename was already a relative path like "images/foo.png" ] item_image_path = None for p_path in possible_paths: # Normalize path for consistent checking normalized_path = os.path.normpath(p_path) if os.path.exists(normalized_path): item_image_path = normalized_path break if not item_image_path: self.logger.warning(f"Could not find local image asset for '{original_filename}'. Attempting fallback to default rock/item.") # Fallback to a generic rock image if specific image not found item_image_path = os.path.join("images", "rock.png") # Default fallback if not os.path.exists(item_image_path): self.logger.error(f"Default fallback image 'images/rock.png' also not found. Cannot recreate item.") continue # Skip this item original_category = 'rock' # Override category if using fallback rock self.logger.info(f"Attempting to recreate item {i+1}: Path='{item_image_path}', Category='{original_category}', Scale={item_scale:.2f}") pixmap = QtGui.QPixmap(item_image_path) if pixmap.isNull(): self.logger.error(f"Failed to load QPixmap for item image: '{item_image_path}'") continue # --- Item Creation --- created_item_visual = None if hasattr(ui, 'ResizablePixmapItem'): created_item_visual = ui.ResizablePixmapItem(pixmap, item_image_path) else: created_item_visual = QtWidgets.QGraphicsPixmapItem(pixmap) setattr(created_item_visual, 'filename', item_image_path) setattr(created_item_visual, 'category', original_category) created_item_visual.setScale(item_scale) # ZValue for carried item will be handled by squid logic to be on top # ZValue for placed items will use item_z_value # setattr(created_item_visual, 'original_zValue', item_z_value) # Store for later placement setattr(created_item_visual, 'is_stolen_from_remote', True) setattr(created_item_visual, 'is_foreign', True) self.apply_foreign_object_tint(created_item_visual) # Add to scene so it's managed by Qt, positions will be updated scene.addItem(created_item_visual) if not item_successfully_given_to_squid_to_carry and hasattr(local_squid, 'start_carrying_item'): # The first successfully created item is given to the squid to carry created_item_visual.setZValue(local_squid.squid_item.zValue() + 0.1) # Ensure it's visually above squid slightly local_squid.start_carrying_item(created_item_visual) item_successfully_given_to_squid_to_carry = True self.logger.info(f"Squid '{local_squid.name}' is now carrying '{os.path.basename(item_image_path)}'.") else: # Subsequent items, or if squid can't carry, place them in the tank if not hasattr(local_squid, 'start_carrying_item'): self.logger.warning(f"Squid object does not have 'start_carrying_item' method. Placing item '{os.path.basename(item_image_path)}' near entry.") else: self.logger.info(f"Placing additional item '{os.path.basename(item_image_path)}' directly in tank.") created_item_visual.setZValue(item_z_value) # Use its original/default Z for placed items # Scatter these additional items around the entry point angle_offset = random.uniform(-math.pi / 2, math.pi / 2) # Use 'i' to ensure different angle for each subsequent item angle = (i * (math.pi / max(1, len(carried_items_data_list) -1 ))) + angle_offset dist_from_entry = random.uniform(80, 150) # Slightly further for placed items item_x = entry_x + dist_from_entry * math.cos(angle) item_y = entry_y + dist_from_entry * math.sin(angle) item_w = pixmap.width() * item_scale item_h = pixmap.height() * item_scale # Basic boundary check item_x = max(item_w/2, min(item_x, ui.window_width - item_w/2)) item_y = max(item_h/2, min(item_y, ui.window_height - item_h/2)) created_item_visual.setPos(item_x - item_w/2, item_y - item_h/2) created_item_visual.setOpacity(1.0) # Ensure visible # Add memory for each successfully recreated item if hasattr(local_squid, 'memory_manager'): local_squid.memory_manager.add_short_term_memory( 'achievement', f'brought_back_{original_category}', f"Brought back a {os.path.basename(item_image_path)} from an adventure!", importance=6 ) except Exception as e: self.logger.error(f"Error processing and recreating a carried item from data '{item_data}': {e}", exc_info=True) def _create_arrival_animation(self, graphics_item: QtWidgets.QGraphicsPixmapItem): """MODIFIED: Creates a simple fade-in animation (now static) for newly arrived remote items.""" if not self.logger: return if not graphics_item: return try: # For static testing, just ensure opacity and scale are correct target_opacity = self.REMOTE_SQUID_OPACITY # Should be 1.0 if hasattr(graphics_item, 'is_remote_clone') and getattr(graphics_item, 'is_remote_clone'): # Cloned objects might have a slightly different base opacity if desired, but for squid, full. pass # target_opacity *= 0.75 (example if clones were different) graphics_item.setOpacity(target_opacity) graphics_item.setScale(1.0) # Ensure normal scale if graphics_item.graphicsEffect(): # Remove any prior opacity effect graphics_item.setGraphicsEffect(None) except Exception as e: if self.debug_mode: self.logger.warning(f"Simple static arrival display error: {e}") if graphics_item: graphics_item.setOpacity(self.REMOTE_SQUID_OPACITY) # Fallback def _reset_remote_squid_style(self, node_id_or_item): """Resets the visual style of a remote squid to default (static, full opacity).""" if not self.logger: return node_id = None squid_display_data = None if isinstance(node_id_or_item, str): # If node_id is passed node_id = node_id_or_item squid_display_data = self.remote_squids.get(node_id) elif isinstance(node_id_or_item, QtWidgets.QGraphicsPixmapItem): # If visual item is passed for nid, s_data in self.remote_squids.items(): if s_data.get('visual') == node_id_or_item: node_id = nid squid_display_data = s_data break if not squid_display_data: return # Squid not found visual_item = squid_display_data.get('visual') status_text_item = squid_display_data.get('status_text') id_text_item = squid_display_data.get('id_text') if visual_item: visual_item.setZValue(5) # Default Z-order for remote squids visual_item.setOpacity(self.REMOTE_SQUID_OPACITY) # Ensure full opacity visual_item.setScale(1.0) # Ensure normal scale if visual_item.graphicsEffect(): # Remove any effects (like shadow or previous opacity effects) visual_item.setGraphicsEffect(None) # Reset status text style if status_text_item: current_status_from_data = squid_display_data.get('data', {}).get('status', 'visiting') status_text_item.setPlainText(current_status_from_data) status_text_item.setDefaultTextColor(QtGui.QColor(200, 200, 200, 220)) # Default color status_text_item.setFont(QtGui.QFont("Arial", 9)) # Default font status_text_item.setZValue(6) # Above squid # Reset ID text style (usually less dynamic) if id_text_item: id_text_item.setDefaultTextColor(QtGui.QColor(200, 200, 200, 180)) id_text_item.setFont(QtGui.QFont("Arial", 8)) id_text_item.setZValue(6) def register_menu_actions(self, main_ui_window: QtWidgets.QMainWindow, target_menu: QtWidgets.QMenu): """Registers menu actions related to the multiplayer plugin.""" if not self.logger: return about_action = QtWidgets.QAction(f"About {mp_constants.PLUGIN_NAME}...", main_ui_window) about_action.triggered.connect(self.show_about_dialog) target_menu.addAction(about_action) config_action = QtWidgets.QAction("Network Settings...", main_ui_window) config_action.triggered.connect(self.show_config_dialog) target_menu.addAction(config_action) dashboard_action = QtWidgets.QAction("Network Dashboard...", main_ui_window) dashboard_action.triggered.connect(self.show_network_dashboard) target_menu.addAction(dashboard_action) target_menu.addSeparator() refresh_connections_action = QtWidgets.QAction("Refresh Connections", main_ui_window) refresh_connections_action.triggered.connect(self.refresh_connections) target_menu.addAction(refresh_connections_action) # Toggle for connection lines self.mp_menu_toggle_connection_lines_action = QtWidgets.QAction("Show Connection Lines", main_ui_window) self.mp_menu_toggle_connection_lines_action.setCheckable(True) self.mp_menu_toggle_connection_lines_action.setChecked(self.SHOW_CONNECTION_LINES) self.mp_menu_toggle_connection_lines_action.triggered.connect(self.toggle_connection_lines) target_menu.addAction(self.mp_menu_toggle_connection_lines_action) if self.debug_mode: # Debug specific actions target_menu.addSeparator() debug_autopilot_action = QtWidgets.QAction("Debug Autopilot Status", main_ui_window) debug_autopilot_action.triggered.connect(self.debug_autopilot_status) target_menu.addAction(debug_autopilot_action) def update_menu_states(self): """Updates the state of checkable menu items.""" if hasattr(self, 'mp_menu_toggle_connection_lines_action'): self.mp_menu_toggle_connection_lines_action.setChecked(self.SHOW_CONNECTION_LINES) def show_about_dialog(self): """Displays an 'About' dialog.""" if not self.logger: return parent_window = None if self.tamagotchi_logic and hasattr(self.tamagotchi_logic, 'user_interface'): parent_window = self.tamagotchi_logic.user_interface.window node_id_str = getattr(self.network_node, 'node_id', "N/A") if self.network_node else "N/A" ip_str = getattr(self.network_node, 'local_ip', "N/A") if self.network_node else "N/A" status_str = "Connected" if self.network_node and self.network_node.is_connected else "Disconnected" about_text_content = ( f"

    {mp_constants.PLUGIN_NAME}

    " f"

    Version: {mp_constants.PLUGIN_VERSION}
    " f"Author: {mp_constants.PLUGIN_AUTHOR}

    " f"

    {mp_constants.PLUGIN_DESCRIPTION}


    " f"

    Node ID: {node_id_str}
    " f"Local IP: {ip_str}
    " f"Status: {status_str}

    " ) QtWidgets.QMessageBox.about(parent_window, f"About {mp_constants.PLUGIN_NAME}", about_text_content) def show_config_dialog(self): """Displays the configuration dialog for multiplayer settings.""" if not self.logger: return try: from .multiplayer_config_dialog import MultiplayerConfigDialog except ImportError: self.logger.error("MultiplayerConfigDialog class/file not found.") parent_win = self.tamagotchi_logic.user_interface.window if self.tamagotchi_logic and hasattr(self.tamagotchi_logic, 'user_interface') else None QtWidgets.QMessageBox.critical(parent_win, "Configuration Error", "The multiplayer settings dialog could not be loaded.") return parent_window = None if self.tamagotchi_logic and hasattr(self.tamagotchi_logic, 'user_interface'): parent_window = self.tamagotchi_logic.user_interface.window current_settings = { 'multicast_group': self.MULTICAST_GROUP, 'port': self.MULTICAST_PORT, 'sync_interval': self.SYNC_INTERVAL, 'remote_opacity': self.REMOTE_SQUID_OPACITY, 'show_labels': self.SHOW_REMOTE_LABELS, 'show_connections': self.SHOW_CONNECTION_LINES, 'debug_mode': self.debug_mode, 'auto_reconnect': self.network_node.auto_reconnect if self.network_node else True, 'use_compression': self.network_node.use_compression if self.network_node else True } if not self.config_dialog or not self.config_dialog.isVisible(): # Create new or reuse self.config_dialog = MultiplayerConfigDialog( plugin_instance=self, parent=parent_window, initial_settings=current_settings ) else: self.config_dialog.load_settings(current_settings) # Update existing dialog with current settings self.config_dialog.exec_() # Show as modal dialog def toggle_connection_lines(self, checked_state: bool): """Toggles the visibility of connection lines.""" if not self.logger: return self.SHOW_CONNECTION_LINES = checked_state # This will be handled by entity_manager's update_settings or fallback update_connection_lines if getattr(self, 'entity_manager', None): self.entity_manager.update_settings(show_connections=self.SHOW_CONNECTION_LINES) else: # Fallback if no entity manager if hasattr(self.tamagotchi_logic, 'user_interface') and self.tamagotchi_logic.user_interface: scene = self.tamagotchi_logic.user_interface.scene for line_item in self.connection_lines.values(): if line_item in scene.items(): # Check if item is still in scene line_item.setVisible(self.SHOW_CONNECTION_LINES) if not self.SHOW_CONNECTION_LINES: # If hiding, remove them for node_id_key in list(self.connection_lines.keys()): line_to_remove = self.connection_lines.pop(node_id_key) if line_to_remove in scene.items(): scene.removeItem(line_to_remove) self.update_connection_lines() # Trigger an update to draw/remove lines if hasattr(self.tamagotchi_logic, 'show_message'): self.tamagotchi_logic.show_message(f"Connection lines to remote squids {'shown' if checked_state else 'hidden'}.") def refresh_connections(self): """Manually triggers a network presence announcement.""" if not self.logger: return if not self.network_node: msg = "Multiplayer: Network component not initialized. Cannot refresh." if hasattr(self.tamagotchi_logic, 'show_message'): self.tamagotchi_logic.show_message(msg) else: self.logger.warning(msg) return if not self.network_node.is_connected: if self.debug_mode: self.logger.debug("Attempting to reconnect before refresh...") self.network_node.try_reconnect() # Attempt reconnect if not connected message_to_show = "" if self.network_node.is_connected: self.network_node.send_message( 'heartbeat', {'node_id': self.network_node.node_id, 'status': 'active_refresh_request'} ) message_to_show = "Multiplayer: Sent network heartbeat. Checking for peers..." else: message_to_show = "Multiplayer: Network disconnected. Could not send heartbeat." if hasattr(self.tamagotchi_logic, 'show_message'): self.tamagotchi_logic.show_message(message_to_show) elif self.debug_mode or not self.network_node.is_connected: # Log if no UI message or on error self.logger.info(message_to_show) # Update UI status current_peers_count = len(self.network_node.known_nodes if self.network_node else {}) if self.status_widget: self.status_widget.update_peers(self.network_node.known_nodes if self.network_node else {}) self.status_widget.add_activity(f"Connections refreshed. {current_peers_count} peers currently detected.") elif self.status_bar: # Fallback to basic status bar if hasattr(self.status_bar, 'update_peers_count'): self.status_bar.update_peers_count(current_peers_count) if hasattr(self.status_bar, 'showMessage'): self.status_bar.showMessage(f"Refreshed. {current_peers_count} peers.", 3000) def initialize_remote_representation(self): """(Fallback) Initializes basic timers for managing remote entity visuals if entity_manager is not used.""" if not self.logger: return if self.entity_manager: # If entity_manager is active, it handles these. if self.debug_mode: self.logger.debug("RemoteEntityManager is active, skipping fallback timers.") return if not self.cleanup_timer_basic: self.cleanup_timer_basic = QtCore.QTimer() self.cleanup_timer_basic.timeout.connect(self.cleanup_stale_nodes) # Fallback cleanup self.cleanup_timer_basic.start(7500) # Check every 7.5s if not self.connection_timer_basic: self.connection_timer_basic = QtCore.QTimer() self.connection_timer_basic.timeout.connect(self.update_connection_lines) # Fallback line drawing self.connection_timer_basic.start(1200) # Update lines every 1.2s def cleanup_stale_nodes(self): """(Fallback) Removes visuals of remote nodes that haven't sent updates, used if entity_manager is None.""" if not self.logger: return if self.entity_manager: return # entity_manager handles its own cleanup if not self.network_node: return current_time = time.time() stale_threshold_seconds = 45.0 # Example: 45 seconds timeout nodes_to_remove_ids = [] # Check known_nodes from NetworkNode for node_id, (_, last_seen_time, _) in list(self.network_node.known_nodes.items()): if current_time - last_seen_time > stale_threshold_seconds: nodes_to_remove_ids.append(node_id) for node_id_to_remove in nodes_to_remove_ids: if self.debug_mode: self.logger.debug(f"Basic Cleanup: Node {node_id_to_remove[-6:]} timed out. Removing.") if node_id_to_remove in self.network_node.known_nodes: del self.network_node.known_nodes[node_id_to_remove] # Remove visual representation using this plugin's direct management self.remove_remote_squid(node_id_to_remove) # This uses self.remote_squids if node_id_to_remove in self.remote_squid_controllers: # Also remove controller del self.remote_squid_controllers[node_id_to_remove] # Update UI status if self.network_node: # Check again as it might be cleaned up peers_now = self.network_node.known_nodes if self.network_node else {} if self.status_widget: self.status_widget.update_peers(peers_now) elif self.status_bar and hasattr(self.status_bar, 'update_peers_count'): self.status_bar.update_peers_count(len(peers_now)) def update_connection_lines(self): """(Fallback) Updates visual lines connecting local squid to remote squids if entity_manager is None.""" if not self.logger: return if self.entity_manager: return # entity_manager handles its own connection lines if not self.SHOW_CONNECTION_LINES or not self.tamagotchi_logic or \ not self.tamagotchi_logic.squid or not self.tamagotchi_logic.user_interface or \ not self.tamagotchi_logic.squid.squid_item: # Ensure all parts exist # Clear existing lines if not showing or prerequisites missing if hasattr(self.tamagotchi_logic, 'user_interface') and self.tamagotchi_logic.user_interface: scene = self.tamagotchi_logic.user_interface.scene for node_id_key in list(self.connection_lines.keys()): line_to_remove = self.connection_lines.pop(node_id_key) if line_to_remove in scene.items(): scene.removeItem(line_to_remove) return ui = self.tamagotchi_logic.user_interface scene = ui.scene local_squid_visual = self.tamagotchi_logic.squid.squid_item local_rect = local_squid_visual.boundingRect() local_center_pos = local_squid_visual.pos() + local_rect.center() # Center of local squid active_remote_node_ids = set() for node_id, remote_squid_info in self.remote_squids.items(): # Iterate this plugin's remote_squids remote_visual = remote_squid_info.get('visual') if not remote_visual or not remote_visual.isVisible() or remote_visual not in scene.items(): continue # Skip if no visual or not in scene active_remote_node_ids.add(node_id) remote_rect = remote_visual.boundingRect() remote_center_pos = remote_visual.pos() + remote_rect.center() # Center of remote squid line_color_data = remote_squid_info.get('data', {}).get('color', (100, 100, 255)) # Use squid's color try: pen_color = QtGui.QColor(*line_color_data, 120) # Add alpha except TypeError: pen_color = QtGui.QColor(100,100,255,120) # Default color pen = QtGui.QPen(pen_color) pen.setWidth(2) pen.setStyle(QtCore.Qt.DashLine) # Dashed line style if node_id in self.connection_lines: # Update existing line line = self.connection_lines[node_id] if line not in scene.items(): scene.addItem(line) # Re-add if removed somehow line.setLine(local_center_pos.x(), local_center_pos.y(), remote_center_pos.x(), remote_center_pos.y()) line.setPen(pen) line.setVisible(True) else: # Create new line line = QtWidgets.QGraphicsLineItem( local_center_pos.x(), local_center_pos.y(), remote_center_pos.x(), remote_center_pos.y() ) line.setPen(pen) line.setZValue(-10) # Draw behind other items scene.addItem(line) self.connection_lines[node_id] = line # Remove lines for squids that are no longer active/present for node_id_key in list(self.connection_lines.keys()): if node_id_key not in active_remote_node_ids: line_to_remove = self.connection_lines.pop(node_id_key) if line_to_remove in scene.items(): scene.removeItem(line_to_remove) def _register_hooks(self): """Registers handlers for network message types with the plugin manager.""" if not self.logger: return if not self.plugin_manager: self.logger.error("Cannot register hooks, plugin_manager is not set.") return hook_handlers = { # The hook name generated by NetworkNode is "on_network_squid_exit" # The subscribe_to_hook call should match this. "on_network_squid_exit": self.handle_squid_exit_message, "on_network_squid_move": self.handle_squid_move, "on_network_object_sync": self.handle_object_sync, "on_network_rock_throw": self.handle_rock_throw, "on_network_heartbeat": self.handle_heartbeat, "on_network_state_update": self.handle_state_update, # Generic state update # Add new 'squid_return' handler "on_network_squid_return": self.handle_squid_return } for hook_name_to_register, handler_method_to_call in hook_handlers.items(): if hook_name_to_register == "on_network_squid_exit": node_id_for_print_hook = "UnknownNode_HookReg" if self.network_node: node_id_for_print_hook = self.network_node.node_id print(f"DEBUG_STEP_2B: MultiplayerPluginLogic {node_id_for_print_hook} is subscribing '{handler_method_to_call.__name__}' to hook: '{hook_name_to_register}'") self.plugin_manager.register_hook(hook_name_to_register) self.plugin_manager.subscribe_to_hook( hook_name_to_register, mp_constants.PLUGIN_NAME, handler_method_to_call ) # This hook is for the QTimer-based processing of the network queue, not direct network messages. # If _process_network_node_queue is solely called by its QTimer, this pre_update might be redundant. # However, it doesn't hurt to have it as a fallback or for other potential uses. self.plugin_manager.register_hook("pre_update") self.plugin_manager.subscribe_to_hook("pre_update", mp_constants.PLUGIN_NAME, self._process_network_node_queue) if self.debug_mode: self.logger.debug("Network message hooks and pre_update hook registered.") def pre_update(self, *args, **kwargs): """Called by game's main update loop if subscribed to 'pre_update' hook.""" # Current design uses QTimer for _process_network_node_queue. # This method can be used for other periodic tasks if needed. pass def start_sync_timer(self): """Starts a daemon thread for periodic game state synchronization.""" if not self.logger: return if self.sync_thread and self.sync_thread.is_alive(): if self.debug_mode: self.logger.debug("Sync thread already running.") return def game_state_sync_loop(): while True: if not self.is_setup: # Check if plugin is still supposed to be running if self.debug_mode: self.logger.debug("SyncLoop: Plugin not setup or disabled, loop exiting.") break try: if self.network_node and self.network_node.is_connected and \ self.tamagotchi_logic and self.tamagotchi_logic.squid: # Dynamic sync interval based on local squid activity and peer count is_local_squid_moving = getattr(self.tamagotchi_logic.squid, 'is_moving', False) sync_delay_seconds = 0.3 if is_local_squid_moving else self.SYNC_INTERVAL # More frequent if moving num_peers = len(getattr(self.network_node, 'known_nodes', {})) if num_peers > 8: sync_delay_seconds *= 1.5 # Reduce load with many peers elif num_peers > 15: sync_delay_seconds *= 2.0 sync_delay_seconds = max(0.2, min(sync_delay_seconds, 3.0)) # Clamp interval self.sync_game_state() # Send current state time.sleep(sync_delay_seconds) else: # If not connected or prerequisites missing, wait longer before retrying time.sleep(2.5) except ReferenceError: # Can happen during interpreter shutdown if self.debug_mode: self.logger.debug("SyncLoop: ReferenceError (likely app shutting down), loop exiting.") break except Exception as e_sync: if self.debug_mode: self.logger.error(f"Error in game_state_sync_loop: {e_sync}", exc_info=True) time.sleep(3.0) # Wait a bit longer after an error self.sync_thread = threading.Thread(target=game_state_sync_loop, daemon=True) self.sync_thread.start() if self.debug_mode: self.logger.info("Game state synchronization thread started.") def sync_game_state(self): """Collects and sends current local game state.""" if not self.logger: return if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'squid') or \ not self.network_node or not self.network_node.is_connected: return # Prerequisites not met try: squid_current_state = self._get_squid_state() objects_current_state = self._get_objects_state() # Get state of syncable objects sync_payload = { 'squid': squid_current_state, 'objects': objects_current_state, 'node_info': {'id': self.network_node.node_id, 'ip': self.network_node.local_ip} } self.network_node.send_message('object_sync', sync_payload) # Use 'object_sync' for combined state # Send heartbeat periodically as well time_now = time.time() if time_now - self.last_message_times.get('heartbeat_sent', 0) > 8.0: # Every 8 seconds heartbeat_payload = { 'node_id': self.network_node.node_id, 'status': 'active', 'squid_pos': (squid_current_state['x'], squid_current_state['y']) # Include position in heartbeat } self.network_node.send_message('heartbeat', heartbeat_payload) self.last_message_times['heartbeat_sent'] = time_now except Exception as e: self.logger.error(f"ERROR during sync_game_state: {e}", exc_info=True) def _get_squid_state(self) -> Dict: """Compiles and returns a dictionary of the local squid's current state.""" if not self.logger: return {} if not self.tamagotchi_logic or not self.tamagotchi_logic.squid or not self.network_node: return {} # Not enough info to build state squid = self.tamagotchi_logic.squid view_direction_rad = self.get_actual_view_direction(squid) # Get view cone direction # --- Logging for sent direction --- if self.debug_mode: self.logger.debug(f"Sending squid state: NodeID={self.network_node.node_id}, Direction={squid.squid_direction}, X={squid.squid_x:.1f}, Y={squid.squid_y:.1f}") return { 'x': squid.squid_x, 'y': squid.squid_y, 'direction': squid.squid_direction, # General movement/logic direction 'image_direction_key': squid.squid_direction, # Explicit key for visual rendering direction 'looking_direction': view_direction_rad, # For view cone 'view_cone_angle': getattr(squid, 'view_cone_angle_rad', math.radians(60)), 'hunger': squid.hunger, 'happiness': squid.happiness, 'status': getattr(squid, 'status', "idle"), # Current action/status 'carrying_rock': getattr(squid, 'carrying_rock', False), 'is_sleeping': getattr(squid, 'is_sleeping', False), 'color': self.get_squid_color(), # Get consistent color based on node ID 'node_id': self.network_node.node_id, # Include node_id for identification 'view_cone_visible': getattr(squid, 'view_cone_visible', False), # Is view cone active 'squid_width': getattr(squid, 'squid_width', 60), # For rendering remote squid 'squid_height': getattr(squid, 'squid_height', 40) # For rendering remote squid } def get_actual_view_direction(self, squid_instance) -> float: """Determines the squid's current viewing direction in radians.""" if hasattr(squid_instance, 'current_view_angle_radians'): # If a dynamic view angle exists return squid_instance.current_view_angle_radians # Fallback to movement direction if no specific view angle direction_to_radians_map = { 'right': 0.0, 'left': math.pi, 'up': 1.5 * math.pi, # -math.pi/2 or 3*math.pi/2 'down': 0.5 * math.pi # math.pi/2 } return direction_to_radians_map.get(getattr(squid_instance, 'squid_direction', 'right'), 0.0) def get_squid_color(self) -> tuple: """Generates a persistent color (R,G,B) for the local squid based on its node ID.""" if not hasattr(self, '_local_squid_color_cache'): # Cache to avoid recalculation node_id_str = "default_node" # Fallback if self.network_node and self.network_node.node_id: node_id_str = self.network_node.node_id # Simple hash-like function to generate color from node_id hash_value = 0 for char_code in node_id_str.encode('utf-8'): # Use byte values of chars hash_value = (hash_value * 37 + char_code) & 0xFFFFFF # Keep it within 24-bit color range r = (hash_value >> 16) & 0xFF g = (hash_value >> 8) & 0xFF b = hash_value & 0xFF # Ensure colors are reasonably bright and distinct r = max(80, min(r, 220)) g = max(80, min(g, 220)) b = max(80, min(b, 220)) self._local_squid_color_cache = (r, g, b) return self._local_squid_color_cache def _get_objects_state(self) -> List[Dict]: """ Collects and returns a list of syncable game object states. MODIFIED: Returns an empty list to prevent general mirroring of local items. Items will now only transfer between instances via the explicit "stealing/carrying" mechanic. """ if not self.logger: # This check is good practice, though an empty list is returned anyway. print("MPPluginLogic ERRA: Logger not available in _get_objects_state") return [] # To stop broadcasting general local items like rocks, food, decorations, etc., # simply return an empty list. The 'object_sync' message will still send squid state # and any other essential global state, but not these common environmental items. if self.debug_mode: self.logger.debug("_get_objects_state: Intentionally returning empty list to prevent general item mirroring.") return [] # Return an empty list (do not sync decoration items) def _determine_object_type(self, scene_item: QtWidgets.QGraphicsItem) -> str: """Determines a string type for a scene item based on attributes or filename.""" # Prioritize explicit 'category' or 'object_type' attributes if hasattr(scene_item, 'category') and isinstance(getattr(scene_item, 'category'), str): return getattr(scene_item, 'category') if hasattr(scene_item, 'object_type') and isinstance(getattr(scene_item, 'object_type'), str): return getattr(scene_item, 'object_type') # Fallback to filename analysis if it's a QGraphicsPixmapItem with a filename if isinstance(scene_item, QtWidgets.QGraphicsPixmapItem) and hasattr(scene_item, 'filename'): filename_lower = getattr(scene_item, 'filename', '').lower() if not filename_lower: return 'unknown_pixmap' # If filename is empty if 'rock' in filename_lower: return 'rock' if any(food_kw in filename_lower for food_kw in ['food', 'sushi', 'apple', 'cheese', 'berry']): return 'food' if 'poop' in filename_lower: return 'poop' # Check common decoration paths or keywords if os.path.join("images", "decoration") in filename_lower.replace("\\", "/") or \ any(kw in filename_lower for kw in ['decor', 'plant', 'toy', 'shell', 'coral', 'starfish', 'gem']): return 'decoration' return 'generic_item' # Default if type cannot be determined def handle_object_sync(self, node: NetworkNode, message: Dict, addr: tuple): """Handles incoming 'object_sync' messages from remote peers.""" if not self.logger: return if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'user_interface'): return try: sync_payload = message.get('payload', {}) remote_squid_state = sync_payload.get('squid', {}) # Data about the sender's squid remote_objects_list = sync_payload.get('objects', []) # List of objects from sender source_node_info = sync_payload.get('node_info', {}) sender_node_id = source_node_info.get('id') or remote_squid_state.get('node_id') # Get sender's ID if not sender_node_id: if self.debug_mode: self.logger.warning("Received object_sync with no identifiable sender node_id.") return if self.network_node and sender_node_id == self.network_node.node_id: return # Ignore own sync messages # Update the visual of the remote squid based on their state if remote_squid_state: # If entity_manager exists, it will handle the update. Otherwise, basic update. if self.entity_manager: self.entity_manager.update_remote_squid(sender_node_id, remote_squid_state, is_new_arrival=False) else: self.update_remote_squid(sender_node_id, remote_squid_state, is_new_arrival=False) # Fallback # Process remote objects if remote_objects_list: active_cloned_ids_for_this_sender = set() for remote_obj_data in remote_objects_list: if not all(k in remote_obj_data for k in ['id', 'type', 'x', 'y', 'filename']): if self.debug_mode: self.logger.debug(f"Skipping incomplete remote object data from {sender_node_id}: {remote_obj_data.get('id', 'No ID')}") continue original_id_from_sender = remote_obj_data['id'] # Create a unique ID for the clone in this instance's scene clone_id = f"clone_{sender_node_id}_{original_id_from_sender}" active_cloned_ids_for_this_sender.add(clone_id) # Process (create or update) the visual clone of the remote object self.process_remote_object(remote_obj_data, sender_node_id, clone_id) # Cleanup: Remove clones of objects that are no longer in the sender's sync list with self.remote_objects_lock: # Ensure thread safety for self.remote_objects ids_to_remove = [ obj_id for obj_id, obj_info in self.remote_objects.items() if obj_info.get('source_node') == sender_node_id and obj_id not in active_cloned_ids_for_this_sender ] for obj_id_to_remove in ids_to_remove: self.remove_remote_object(obj_id_to_remove) # Method to remove visual and from dict # Optional: Trigger local squid's reaction to seeing a remote squid if self.tamagotchi_logic.squid and hasattr(self.tamagotchi_logic.squid, 'process_squid_detection') and remote_squid_state: # Pass remote_squid_state as remote_squid_props for position-based fleeing self.tamagotchi_logic.squid.process_squid_detection( remote_node_id=sender_node_id, is_visible=True, remote_squid_props=remote_squid_state ) except Exception as e: if self.debug_mode: self.logger.error(f"Handling object_sync from {addr} failed: {e}", exc_info=True) def process_remote_object(self, remote_obj_data: Dict, source_node_id: str, clone_id: str): """ Creates or updates a visual clone of a remote object in the local scene. MODIFIED: Selectively ignores common environmental items to prevent general mirroring. """ if not self.logger: print("MPPluginLogic ERRA: Logger not available in process_remote_object") # Basic fallback print return # --- NEW: SELECTIVE PROCESSING TO PREVENT MIRRORING OF COMMON LOCAL ITEMS --- # The 'type' field in remote_obj_data is set by _determine_object_type # from _get_objects_state on the sender's side. item_type_from_remote = remote_obj_data.get('type', 'unknown').lower() # Define types that should NOT be mirrored through general sync. # These items should be local to each tank unless explicitly carried over. types_to_ignore_for_mirroring = ['rock', 'urchin', 'food', 'poop', 'decoration'] if item_type_from_remote in types_to_ignore_for_mirroring: # If a mirrored clone of this type of item already exists in our scene # (e.g., from before this filtering logic was active, or if the other client is still sending), # we should remove it to enforce the non-mirroring rule. if clone_id in self.remote_objects: # self.remote_objects tracks visual clones this instance created if self.debug_mode: self.logger.debug(f"process_remote_object: Removing existing undesired mirrored clone '{clone_id}' (type: '{item_type_from_remote}') from node {source_node_id}.") self.remove_remote_object(clone_id) # This method should handle removal from scene and self.remote_objects if self.debug_mode: self.logger.debug(f"process_remote_object: Ignoring general sync for remote object type '{item_type_from_remote}' (Original ID: {remote_obj_data.get('id', 'N/A')}, Clone ID: {clone_id}) from node {source_node_id}. This item type should only transfer via explicit stealing/carrying.") return # Stop further processing for this item, thus preventing its mirroring. # --- END NEW SELECTIVE PROCESSING --- # Original continuation of the method for item types that ARE allowed to be mirrored, # or for other types of shared objects if you have any. if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'user_interface'): self.logger.warning("process_remote_object: TamagotchiLogic or UI not available, cannot process item further.") return scene = self.tamagotchi_logic.user_interface.scene # The rest of this method is the original logic for creating/updating clones. # It will now only apply to items NOT filtered out by the block above. # For example, if you had a special shared item type like 'portal' or 'shared_toy' # that you *did* want mirrored, its processing would continue here. base_filename = os.path.basename(remote_obj_data.get('filename', 'unknown_sync_item.png')) # Asset path resolution (same as before) resolved_filename = os.path.join("images", base_filename) if not os.path.exists(resolved_filename): for subdir in ["decoration", "items", "food", "rocks"]: # Add other relevant subdirs if needed path_attempt = os.path.join("images", subdir, base_filename) if os.path.exists(path_attempt): resolved_filename = path_attempt break else: if self.debug_mode: self.logger.warning(f"process_remote_object: Image for allowed sync item '{base_filename}' (type: '{item_type_from_remote}') not found locally for clone '{clone_id}'. Skipping visual.") return # Thread safety for self.remote_objects dictionary with self.remote_objects_lock: if clone_id in self.remote_objects: # If clone already exists, update it existing_clone_info = self.remote_objects[clone_id] visual_item = existing_clone_info['visual'] # Update existing visual item's properties visual_item.setPos(remote_obj_data['x'], remote_obj_data['y']) visual_item.setScale(remote_obj_data.get('scale', 1.0)) visual_item.setZValue(remote_obj_data.get('zValue', -5)) # Default Z for background items visual_item.setVisible(not remote_obj_data.get('is_being_carried', False)) # Hide if carried by remote squid existing_clone_info['last_update'] = time.time() existing_clone_info['data'] = remote_obj_data # Store latest data # Ensure tint is applied if it's meant to be foreign (clones always are) if not getattr(visual_item, 'is_foreign', False): # Should always be true for clones self.apply_foreign_object_tint(visual_item) if self.debug_mode: self.logger.debug(f"process_remote_object: Updated mirrored clone '{clone_id}' (type: '{item_type_from_remote}') from {source_node_id}.") else: # New clone for an allowed item type, create it if remote_obj_data.get('is_being_carried', False): # Don't create visual if it's being carried by the remote squid if self.debug_mode: self.logger.debug(f"process_remote_object: Item '{clone_id}' (type: '{item_type_from_remote}') is being carried remotely. Not creating visual clone yet.") return try: pixmap = QtGui.QPixmap(resolved_filename) if pixmap.isNull(): if self.debug_mode: self.logger.error(f"process_remote_object: Failed to load QPixmap for allowed remote object '{resolved_filename}'.") return cloned_visual = QtWidgets.QGraphicsPixmapItem(pixmap) cloned_visual.setPos(remote_obj_data['x'], remote_obj_data['y']) cloned_visual.setScale(remote_obj_data.get('scale', 1.0)) # Cloned objects are typically less prominent than remote squids themselves cloned_visual.setOpacity(self.REMOTE_SQUID_OPACITY * 0.65) # Example: Slightly more transparent cloned_visual.setZValue(remote_obj_data.get('zValue', -5)) # Default Z for background items setattr(cloned_visual, 'filename', resolved_filename) setattr(cloned_visual, 'is_remote_clone', True) # Mark as a clone from a remote instance setattr(cloned_visual, 'original_id_from_sender', remote_obj_data['id']) self.apply_foreign_object_tint(cloned_visual) # Apply visual tint scene.addItem(cloned_visual) self.remote_objects[clone_id] = { 'visual': cloned_visual, 'type': item_type_from_remote, # Store the determined type 'source_node': source_node_id, 'last_update': time.time(), 'data': remote_obj_data # Store all received data } if self.debug_mode: self.logger.debug(f"process_remote_object: Created new mirrored clone '{clone_id}' (type: '{item_type_from_remote}') from {source_node_id}.") except Exception as e_create_clone: if self.debug_mode: self.logger.error(f"process_remote_object: Creating visual clone for allowed item '{clone_id}' failed: {e_create_clone}", exc_info=True) def handle_heartbeat(self, node: NetworkNode, message: Dict, addr: tuple): """Handles heartbeat messages from other peers.""" if not self.logger: return if not self.network_node: return # This instance's network node must exist sender_node_id = message.get('payload', {}).get('node_id') # Heartbeat payload contains sender's ID if not sender_node_id or sender_node_id == self.network_node.node_id: return # Ignore own or invalid heartbeats # Update UI (status widget or bar) with known peers if self.status_widget: self.status_widget.update_peers(self.network_node.known_nodes) # known_nodes is updated by NetworkNode if sender_node_id not in self.remote_squids: # If this is the first sign of this peer self.status_widget.add_activity(f"Peer {sender_node_id[-6:]} detected via heartbeat.") elif self.status_bar and hasattr(self.status_bar, 'update_peers_count'): self.status_bar.update_peers_count(len(self.network_node.known_nodes)) heartbeat_payload = message.get('payload', {}) squid_pos_data = heartbeat_payload.get('squid_pos') # Heartbeat might include basic position # If this peer is new and sent position, create a basic placeholder visual if squid_pos_data and sender_node_id not in self.remote_squids: if self.debug_mode: self.logger.debug(f"Creating placeholder for {sender_node_id[-6:]} from heartbeat.") placeholder_squid_data = { 'x': squid_pos_data[0], 'y': squid_pos_data[1], 'direction': 'right', # Default direction 'color': (150, 150, 150), 'node_id': sender_node_id, 'status': 'detected_via_heartbeat', 'squid_width': 60, 'squid_height': 40 # Default dimensions } # Use entity_manager if available, otherwise fallback if self.entity_manager: self.entity_manager.update_remote_squid(sender_node_id, placeholder_squid_data, is_new_arrival=True) else: self.update_remote_squid(sender_node_id, placeholder_squid_data, is_new_arrival=True) def update_remote_squid(self, remote_node_id: str, squid_data_dict: Dict, is_new_arrival=False, high_visibility=False): """Updates or creates the visual representation of a remote squid. This method now primarily defers to self.entity_manager if available.""" if not self.logger: return False if self.entity_manager: # entity_manager.update_remote_squid will handle all visual creation and updates # Pass is_new_arrival along, high_visibility is implicitly handled by static display now success = self.entity_manager.update_remote_squid(remote_node_id, squid_data_dict, is_new_arrival) if success and is_new_arrival: # If entity_manager handled it, log for clarity, autopilot logic is in handle_squid_exit. if self.debug_mode: self.logger.debug(f"RemoteEntityManager handled update/creation for {remote_node_id}.") elif not success: if self.debug_mode: self.logger.warning(f"RemoteEntityManager failed to update/create {remote_node_id}.") return success # --- Fallback logic if entity_manager is NOT available (original basic logic) --- self.logger.warning("entity_manager NOT found. Using fallback remote squid update logic.") if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'user_interface'): return False if not squid_data_dict or not all(key in squid_data_dict for key in ['x', 'y', 'direction']): if self.debug_mode: self.logger.warning(f"Fallback: Insufficient data for remote squid {remote_node_id}.") return False scene = self.tamagotchi_logic.user_interface.scene with self.remote_squids_lock: # Ensure lock for fallback access too existing_squid_display = self.remote_squids.get(remote_node_id) if existing_squid_display: visual = existing_squid_display.get('visual') id_text = existing_squid_display.get('id_text') status_text = existing_squid_display.get('status_text') if visual: visual.setPos(squid_data_dict['x'], squid_data_dict['y']) visual.setOpacity(self.REMOTE_SQUID_OPACITY) # Fallback ensure opacity visual.setScale(1.0) # Fallback ensure scale self.update_remote_squid_image(existing_squid_display, squid_data_dict['direction']) new_status_str = "ARRIVING" if is_new_arrival else squid_data_dict.get('status', 'active') if id_text: id_text.setPos(squid_data_dict['x'], squid_data_dict['y'] - 50) # Adjust as needed if status_text: status_text.setPlainText(new_status_str) status_text.setPos(squid_data_dict['x'], squid_data_dict['y'] - 35) # Adjust # Static text styling, no animation-dependent changes status_text.setDefaultTextColor(QtGui.QColor(200,200,200,230)) status_text.setFont(QtGui.QFont("Arial", 9)) if is_new_arrival: # Slight emphasis for new arrivals' status status_text.setDefaultTextColor(QtGui.QColor(255, 223, 0)) # Gold status_text.setFont(QtGui.QFont("Arial", 10, QtGui.QFont.Bold)) existing_squid_display['data'] = squid_data_dict existing_squid_display['last_update'] = time.time() else: # New squid, fallback creation try: initial_direction = squid_data_dict.get('direction', 'right') # Attempt to load image, fallback to placeholder base_image_path = "images" squid_image_file = f"{initial_direction.lower()}1.png" full_image_path = os.path.join(base_image_path, squid_image_file) squid_pixmap = QtGui.QPixmap(full_image_path) if squid_pixmap.isNull(): self.logger.warning(f"Fallback: Image {full_image_path} not found. Using color placeholder.") squid_width = squid_data_dict.get('squid_width', 60) squid_height = squid_data_dict.get('squid_height', 40) squid_pixmap = QtGui.QPixmap(int(squid_width), int(squid_height)) squid_color_tuple = squid_data_dict.get('color', (100,150,255)) # Default color squid_pixmap.fill(QtGui.QColor(*squid_color_tuple)) visual = QtWidgets.QGraphicsPixmapItem(squid_pixmap) visual.setPos(squid_data_dict['x'], squid_data_dict['y']) visual.setOpacity(self.REMOTE_SQUID_OPACITY) # Full opacity (1.0) visual.setScale(1.0) # Normal scale visual.setZValue(5) # Default Z order scene.addItem(visual) # ID Text display_id_str = f"{remote_node_id[-6:]}" # Show last 6 chars of ID id_text = scene.addText(display_id_str) # Basic text item id_text.setPos(squid_data_dict['x'], squid_data_dict['y'] - 50) # Position above visual id_text.setFont(QtGui.QFont("Arial", 8)) id_text.setDefaultTextColor(QtGui.QColor(200,200,200,180)) # Semi-transparent white id_text.setZValue(6) # Above squid visual id_text.setVisible(self.SHOW_REMOTE_LABELS) # Status Text status_str = "ARRIVING" if is_new_arrival else squid_data_dict.get('status', 'active') status_text = scene.addText(status_str) status_text.setPos(squid_data_dict['x'], squid_data_dict['y'] - 35) # Position above visual status_text.setFont(QtGui.QFont("Arial", 9)) status_text.setDefaultTextColor(QtGui.QColor(200,200,200,230)) if is_new_arrival: # Emphasize for new arrivals status_text.setDefaultTextColor(QtGui.QColor(255, 215, 0)) # Gold status_text.setFont(QtGui.QFont("Arial", 10, QtGui.QFont.Bold)) status_text.setZValue(6) # Above squid visual status_text.setVisible(self.SHOW_REMOTE_LABELS) new_squid_display_data = { 'visual': visual, 'id_text': id_text, 'status_text': status_text, 'view_cone': None, 'last_update': time.time(), 'data': squid_data_dict } self.remote_squids[remote_node_id] = new_squid_display_data # update_remote_squid_image is implicitly handled by direct pixmap load or placeholder if self.debug_mode: self.logger.debug(f"Fallback: Created static remote squid visual for {remote_node_id}.") except Exception as e_create_squid_fb: self.logger.error(f"Fallback: Creating remote squid visual for {remote_node_id} failed: {e_create_squid_fb}", exc_info=True) if remote_node_id in self.remote_squids: del self.remote_squids[remote_node_id] # Cleanup partial creation return False return True def _create_enhanced_arrival_animation(self, squid_visual_item: QtWidgets.QGraphicsPixmapItem, at_x: float, at_y: float): """MODIFIED: Creates a more prominent visual animation (now static) for newly arriving remote squids.""" if not self.logger: return if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'user_interface'): return # scene = self.tamagotchi_logic.user_interface.scene # Scene not used if no visual effects if squid_visual_item: squid_visual_item.setOpacity(self.REMOTE_SQUID_OPACITY) # Ensure full visibility (1.0) squid_visual_item.setScale(1.0) # Ensure normal scale if squid_visual_item.graphicsEffect(): # Remove any prior effect squid_visual_item.setGraphicsEffect(None) if self.debug_mode: self.logger.debug(f"Static display for enhanced arrival at ({at_x}, {at_y}).") def handle_remote_squid_return(self, remote_node_id: str, controller: Any): # Type hint for controller if available """Initiates the process for a remote squid (controlled by autopilot) to return to its home instance.""" if not self.logger: return if self.debug_mode: self.logger.debug(f"Remote squid {remote_node_id[-6:]} is being returned home by its controller.") activity_summary_data = controller.get_summary() # Get summary of activities from controller home_direction_for_exit = controller.home_direction # Direction it should exit this screen # If entity_manager is handling visuals, tell it to start the removal process if self.entity_manager and hasattr(self.entity_manager, 'initiate_squid_departure_animation'): self.entity_manager.initiate_squid_departure_animation( remote_node_id, lambda: self.complete_remote_squid_return(remote_node_id, activity_summary_data, home_direction_for_exit) ) if hasattr(self.tamagotchi_logic, 'show_message'): self.tamagotchi_logic.show_message(f"👋 Visitor squid {remote_node_id[-6:]} is heading back home!") return # Fallback: Basic immediate removal or simple fade if no entity_manager animation remote_squid_display_info = self.remote_squids.get(remote_node_id) if not remote_squid_display_info or not remote_squid_display_info.get('visual'): if self.debug_mode: self.logger.warning(f"Visual for returning remote squid {remote_node_id[-6:]} not found. Completing return directly.") self.complete_remote_squid_return(remote_node_id, activity_summary_data, home_direction_for_exit) return visual_item = remote_squid_display_info['visual'] status_text = remote_squid_display_info.get('status_text') if status_text: # Update status text status_text.setPlainText("RETURNING HOME...") status_text.setDefaultTextColor(QtGui.QColor(255, 165, 0)) # Orange status_text.setFont(QtGui.QFont("Arial", 10, QtGui.QFont.Bold)) # For static testing, remove immediately after sending message self.logger.info(f"Static removal for remote squid {remote_node_id[-6:]} returning home.") self.complete_remote_squid_return(remote_node_id, activity_summary_data, home_direction_for_exit) if hasattr(self.tamagotchi_logic, 'show_message'): self.tamagotchi_logic.show_message(f"👋 Visitor squid {remote_node_id[-6:]} is heading back home!") def complete_remote_squid_return(self, remote_node_id: str, activity_summary: Dict, exit_direction: str): """Finalizes the return of a remote squid: sends message and removes local visuals.""" if not self.logger: return try: # Send 'squid_return' message to the network so the original instance knows its squid is back if self.network_node and self.network_node.is_connected: return_message_payload = { 'node_id': remote_node_id, # ID of the squid that is returning 'activity_summary': activity_summary, # What it did on this instance 'return_direction': exit_direction # How it should re-enter its home screen } self.network_node.send_message('squid_return', return_message_payload) if self.debug_mode: rocks = activity_summary.get('rocks_stolen',0) self.logger.info(f"Sent 'squid_return' for {remote_node_id[-6:]} (summary: {rocks} rocks). Exit dir: {exit_direction}") # Remove the remote squid's visual representation from this instance if self.entity_manager: self.entity_manager.remove_remote_squid(remote_node_id) else: # Fallback self.remove_remote_squid(remote_node_id) # This plugin's method for basic removal # Remove its controller if remote_node_id in self.remote_squid_controllers: del self.remote_squid_controllers[remote_node_id] if self.debug_mode: self.logger.info(f"Removed controller for returned remote squid {remote_node_id[-6:]}.") except Exception as e: self.logger.error(f"Completing remote squid return for {remote_node_id[-6:]} failed: {e}", exc_info=True) def update_remote_view_cone(self, remote_node_id: str, remote_squid_data: Dict): """Updates the visual representation of a remote squid's view cone. This is the mp_plugin_logic's own implementation, potentially a fallback if entity_manager is not used or if this plugin needs to draw cones for controllers it manages directly.""" if not self.logger: return # If entity_manager is present and handles view cones, defer to it. if self.entity_manager and hasattr(self.entity_manager, 'update_remote_view_cone'): # Ensure entity_manager's update_remote_view_cone is compatible or adapt the call # This assumes entity_manager.update_remote_view_cone(node_id, data) signature self.entity_manager.update_remote_view_cone(remote_node_id, remote_squid_data) return # --- Fallback or direct implementation if no entity_manager for this --- if not self.SHOW_REMOTE_LABELS: # View cones are often tied to label visibility if remote_node_id in self.remote_squids and self.remote_squids[remote_node_id].get('view_cone'): self._remove_view_cone_for_squid(remote_node_id) # Helper to remove existing cone return if remote_node_id not in self.remote_squids or not self.tamagotchi_logic or \ not hasattr(self.tamagotchi_logic, 'user_interface'): return scene = self.tamagotchi_logic.user_interface.scene squid_display_info = self.remote_squids[remote_node_id] # This plugin's own remote_squids dict # Remove existing cone if any self._remove_view_cone_for_squid(remote_node_id) if not remote_squid_data.get('view_cone_visible', False): # If cone should not be visible return # Get squid's visual item from this plugin's tracking (if fallback) visual_item = squid_display_info.get('visual') if not visual_item: if self.debug_mode: self.logger.warning(f"Fallback: No visual item for {remote_node_id} to draw view cone.") return # Use visual item's current position and size for cone origin squid_center_x = visual_item.pos().x() + visual_item.boundingRect().width() / 2 squid_center_y = visual_item.pos().y() + visual_item.boundingRect().height() / 2 looking_direction_rad = remote_squid_data.get('looking_direction', 0.0) # In radians view_cone_angle_rad = remote_squid_data.get('view_cone_angle', math.radians(50)) # Default cone angle cone_half_angle = view_cone_angle_rad / 2.0 cone_length = 150 # Length of the cone # Calculate points of the cone triangle point1_origin = QtCore.QPointF(squid_center_x, squid_center_y) point2_edge1 = QtCore.QPointF( squid_center_x + cone_length * math.cos(looking_direction_rad - cone_half_angle), squid_center_y + cone_length * math.sin(looking_direction_rad - cone_half_angle) ) point3_edge2 = QtCore.QPointF( squid_center_x + cone_length * math.cos(looking_direction_rad + cone_half_angle), squid_center_y + cone_length * math.sin(looking_direction_rad + cone_half_angle) ) cone_polygon = QtGui.QPolygonF([point1_origin, point2_edge1, point3_edge2]) new_cone_item = QtWidgets.QGraphicsPolygonItem(cone_polygon) squid_color = remote_squid_data.get('color', (150, 150, 255)) # Use squid's color for cone try: cone_q_color = QtGui.QColor(*squid_color) except TypeError: cone_q_color = QtGui.QColor(150,150,255) # Fallback color new_cone_item.setPen(QtGui.QPen(QtGui.QColor(cone_q_color.red(), cone_q_color.green(), cone_q_color.blue(), 0))) # Transparent border new_cone_item.setBrush(QtGui.QBrush(QtGui.QColor(cone_q_color.red(), cone_q_color.green(), cone_q_color.blue(), 25))) # Semi-transparent fill new_cone_item.setZValue(visual_item.zValue() - 1) # Draw behind squid scene.addItem(new_cone_item) squid_display_info['view_cone'] = new_cone_item # Store reference def _remove_view_cone_for_squid(self, remote_node_id: str): """Safely removes a view cone for a specific remote squid (used by fallback logic).""" if not self.logger: return # This check is for this plugin's remote_squids dictionary if remote_node_id in self.remote_squids and self.tamagotchi_logic and hasattr(self.tamagotchi_logic.user_interface, 'scene'): squid_display_info = self.remote_squids[remote_node_id] cone_item = squid_display_info.get('view_cone') if cone_item and cone_item.scene(): # Check if it has a scene before removing self.tamagotchi_logic.user_interface.scene.removeItem(cone_item) squid_display_info['view_cone'] = None # Clear reference def create_gift_decoration(self, from_remote_node_id: str) -> QtWidgets.QGraphicsPixmapItem | None: """Creates a new decoration item representing a received gift.""" if not self.logger: return None if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'user_interface'): return None ui = self.tamagotchi_logic.user_interface scene = ui.scene available_decoration_images = [] decoration_image_dirs = [os.path.join("images", "decoration"), "images"] # Search paths for img_dir in decoration_image_dirs: if os.path.exists(img_dir): for filename in os.listdir(img_dir): if filename.lower().endswith(('.png', '.jpg', '.gif')) and \ any(kw in filename.lower() for kw in ['decor', 'plant', 'toy', 'shell', 'coral', 'starfish', 'gem']): # Keywords for decorations available_decoration_images.append(os.path.join(img_dir, filename)) if not available_decoration_images: # Fallback if no specific decorations found default_gift_img = os.path.join("images", "plant.png") # Example default if not os.path.exists(default_gift_img): if self.debug_mode: self.logger.warning("Default gift image 'plant.png' not found. Cannot create gift.") return None available_decoration_images.append(default_gift_img) chosen_gift_image_path = random.choice(available_decoration_images) try: gift_pixmap = QtGui.QPixmap(chosen_gift_image_path) if gift_pixmap.isNull(): if self.debug_mode: self.logger.error(f"Failed to load gift image '{chosen_gift_image_path}'.") return None gift_item = None if hasattr(ui, 'ResizablePixmapItem'): # Use custom item if available gift_item = ui.ResizablePixmapItem(gift_pixmap, chosen_gift_image_path) else: # Use standard QGraphicsPixmapItem gift_item = QtWidgets.QGraphicsPixmapItem(gift_pixmap) setattr(gift_item, 'filename', chosen_gift_image_path) # Store filename setattr(gift_item, 'category', 'decoration') setattr(gift_item, 'is_gift_from_remote', True) # Mark as a received gift setattr(gift_item, 'received_from_node', from_remote_node_id) # Store sender gift_item.setToolTip(f"A surprise gift from tank {from_remote_node_id[-6:]}!") # Position the gift randomly but within bounds item_width = gift_pixmap.width() * gift_item.scale() # Account for scale item_height = gift_pixmap.height() * gift_item.scale() max_placement_x = ui.window_width - item_width - 30 # 30px margin max_placement_y = ui.window_height - item_height - 30 gift_pos_x = random.uniform(30, max(30, max_placement_x)) gift_pos_y = random.uniform(30, max(30, max_placement_y)) gift_item.setPos(gift_pos_x, gift_pos_y) self.apply_foreign_object_tint(gift_item) # Apply tint to show it's from remote scene.addItem(gift_item) # Static display for gift, no complex animation gift_item.setOpacity(0.0) # Start transparent # MODIFIED: For static testing, make it immediately visible gift_item.setOpacity(1.0) # Optional: Add a temporary "🎁 Gift!" text label above it gift_indicator_label = scene.addText("🎁 Gift!") label_font = QtGui.QFont("Arial", 10, QtGui.QFont.Bold) gift_indicator_label.setFont(label_font) gift_indicator_label.setDefaultTextColor(QtGui.QColor(255, 100, 100)) # Bright color label_x = gift_pos_x + (item_width / 2) - (gift_indicator_label.boundingRect().width() / 2) label_y = gift_pos_y - gift_indicator_label.boundingRect().height() - 5 # Above gift gift_indicator_label.setPos(label_x, label_y) gift_indicator_label.setZValue(gift_item.zValue() + 1) # Ensure label is on top # Make label disappear after a few seconds QtCore.QTimer.singleShot(4000, lambda item=gift_indicator_label: item.scene().removeItem(item) if item.scene() else None) return gift_item except Exception as e_gift: if self.debug_mode: self.logger.error(f"Error creating gift decoration: {e_gift}", exc_info=True) return None def remove_remote_squid(self, node_id_to_remove: str): """Removes visual components of a specific remote squid (used by fallback or direct management).""" if not self.logger: return if node_id_to_remove not in self.remote_squids: return # Not in this plugin's tracking if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'user_interface'): return scene = self.tamagotchi_logic.user_interface.scene with self.remote_squids_lock: # Thread safety for self.remote_squids squid_display_elements = self.remote_squids.pop(node_id_to_remove, None) if squid_display_elements: visual_keys = ['visual', 'view_cone', 'id_text', 'status_text'] for key in visual_keys: item_to_remove = squid_display_elements.get(key) if item_to_remove and item_to_remove.scene(): # Check if item has a scene scene.removeItem(item_to_remove) # Remove associated connection line if managed by this plugin directly if node_id_to_remove in self.connection_lines: line = self.connection_lines.pop(node_id_to_remove) if line.scene(): scene.removeItem(line) if self.debug_mode: self.logger.debug(f"Fallback: Removed all visuals for remote squid {node_id_to_remove[-6:]}.") # Update UI status if network_node is still valid if self.network_node: if self.status_widget: self.status_widget.update_peers(self.network_node.known_nodes if self.network_node else {}) elif self.status_bar and hasattr(self.status_bar, 'update_peers_count'): self.status_bar.update_peers_count(len(self.network_node.known_nodes if self.network_node else {})) def remove_remote_object(self, full_clone_id: str): """Removes a specific cloned remote object (used by fallback or direct management).""" if not self.logger: return if full_clone_id not in self.remote_objects: return # Not in this plugin's tracking if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'user_interface'): return scene = self.tamagotchi_logic.user_interface.scene with self.remote_objects_lock: # Thread safety for self.remote_objects object_clone_info = self.remote_objects.pop(full_clone_id, None) if object_clone_info: visual_item = object_clone_info.get('visual') if visual_item and visual_item.scene(): # Check if item has a scene scene.removeItem(visual_item) if self.debug_mode: self.logger.debug(f"Fallback: Removed remote object clone: {full_clone_id}.") def throw_rock_network(self, rock_graphics_item: QtWidgets.QGraphicsPixmapItem, direction_thrown: str): """Broadcasts a 'rock_throw' event when the local squid throws a rock.""" if not self.logger: return if not self.network_node or not self.network_node.is_connected or not rock_graphics_item: return # Prerequisites not met try: rock_filename = getattr(rock_graphics_item, 'filename', "default_rock.png") # Get filename initial_pos = rock_graphics_item.pos() # Position when thrown rock_throw_payload = { 'rock_data': { 'filename': rock_filename, 'direction': direction_thrown, 'initial_pos_x': initial_pos.x(), 'initial_pos_y': initial_pos.y(), 'scale': rock_graphics_item.scale() if hasattr(rock_graphics_item, 'scale') else 1.0, } } self.network_node.send_message('rock_throw', rock_throw_payload) # Send message if self.debug_mode: self.logger.debug(f"Broadcasted local rock throw: {os.path.basename(rock_filename)} towards {direction_thrown}.") except Exception as e_throw: if self.debug_mode: self.logger.error(f"Broadcasting rock throw failed: {e_throw}", exc_info=True) def cleanup(self): """Cleans up all resources used by the multiplayer plugin.""" if self.logger is None: emergency_logger = logging.getLogger(f"{mp_constants.PLUGIN_NAME}_CleanupEmergency") if not emergency_logger.hasHandlers(): emergency_logger.addHandler(logging.StreamHandler()) emergency_logger.setLevel(logging.INFO) self.logger = emergency_logger self.logger.warning("Logger was None at the start of cleanup. Using emergency logger.") self.logger.info(f"Initiating {mp_constants.PLUGIN_NAME} cleanup...") self.is_setup = False # Mark as not setup to stop background threads/timers # Stop all QTimers timers_to_manage = [ 'message_process_timer', 'controller_update_timer', 'controller_creation_timer', 'cleanup_timer_basic', 'connection_timer_basic' ] for timer_attr_name in timers_to_manage: timer_instance = getattr(self, timer_attr_name, None) if timer_instance and isinstance(timer_instance, QtCore.QTimer) and timer_instance.isActive(): timer_instance.stop() if self.debug_mode: self.logger.debug(f"Stopped timer '{timer_attr_name}'.") setattr(self, timer_attr_name, None) # Clear reference # Sync thread is a daemon, will exit with app. Signal it to stop if it checks is_setup. if self.sync_thread and self.sync_thread.is_alive(): if self.debug_mode: self.logger.info("Sync thread was active during cleanup. As a daemon, it will exit with app or when its loop condition (is_setup) fails.") self.sync_thread = None # Clear reference # NetworkNode cleanup if self.network_node: nn_ref = self.network_node # Temporary reference for cleanup self.network_node = None # Nullify early to prevent re-use during its own shutdown if nn_ref.is_connected: # Send leave message if connected try: nn_ref.send_message( 'player_leave', {'node_id': nn_ref.node_id, 'reason': 'plugin_unloaded_or_disabled'} ) except Exception as e_leave: # Socket might already be closed if self.debug_mode: self.logger.error(f"Error sending player_leave message (socket may be closed): {e_leave}", exc_info=False) # No exc_info if expected # Close socket and leave multicast group if nn_ref.socket: try: if nn_ref.is_connected and hasattr(nn_ref, 'local_ip') and nn_ref.local_ip and \ hasattr(mp_constants, 'MULTICAST_GROUP') and mp_constants.MULTICAST_GROUP: import socket as sock_module # Local import for cleanup mreq_leave = sock_module.inet_aton(mp_constants.MULTICAST_GROUP) + sock_module.inet_aton(nn_ref.local_ip) nn_ref.socket.setsockopt(sock_module.IPPROTO_IP, sock_module.IP_DROP_MEMBERSHIP, mreq_leave) except AttributeError as e_attr: if self.debug_mode: self.logger.warning(f"Attribute error during multicast group leave: {e_attr}") except Exception as e_mcast_leave: if self.debug_mode: self.logger.warning(f"Error leaving multicast group: {e_mcast_leave}", exc_info=True) finally: try: nn_ref.socket.close() except Exception: pass # Ignore errors on closing already closed socket nn_ref.is_connected = False nn_ref.socket = None # Cleanup visuals if entity_manager is not handling it or as a final sweep if self.entity_manager: self.entity_manager.cleanup_all() # Tell entity_manager to clean its own entities else: # Fallback: this plugin cleans its own directly managed visuals if self.tamagotchi_logic and hasattr(self.tamagotchi_logic, 'user_interface'): with self.remote_squids_lock: for node_id_key in list(self.remote_squids.keys()): self.remove_remote_squid(node_id_key) with self.remote_objects_lock: for clone_id_key in list(self.remote_objects.keys()): self.remove_remote_object(clone_id_key) self.remote_squids.clear() self.remote_objects.clear() self.connection_lines.clear() # Should be empty if lines removed correctly self.remote_squid_controllers.clear() # Update UI status if self.status_widget: if hasattr(self.status_widget, 'update_connection_status'): self.status_widget.update_connection_status(False) if hasattr(self.status_widget, 'update_peers'): self.status_widget.update_peers({}) if hasattr(self.status_widget, 'add_activity'): self.status_widget.add_activity(f"{mp_constants.PLUGIN_NAME} has been shut down.") elif self.status_bar: # Fallback if hasattr(self.status_bar, 'update_network_status'): self.status_bar.update_network_status(False) if hasattr(self.status_bar, 'update_peers_count'): self.status_bar.update_peers_count(0) if hasattr(self.status_bar, 'showMessage'): self.status_bar.showMessage(f"{mp_constants.PLUGIN_NAME} plugin shut down.", 5000) self.logger.info(f"{mp_constants.PLUGIN_NAME} plugin cleanup process completed.") def handle_squid_move(self, node: NetworkNode, message: Dict, addr: tuple): """Handles discrete 'squid_move' messages (less frequent than full sync).""" if not self.logger: return payload = message.get('payload', {}) sender_node_id = payload.get('node_id') # Assume payload contains node_id if not sender_node_id: sender_node_id = message.get('node_id') # Fallback if NetworkNode added it to top level if not sender_node_id: if self.debug_mode: self.logger.warning("squid_move message missing sender_node_id.") return if self.network_node and sender_node_id == self.network_node.node_id: return # Ignore own move messages # Defer to entity_manager if available for visual updates if self.entity_manager: # Ensure payload matches what entity_manager.update_remote_squid expects # It needs x, y, direction, and other relevant fields from _get_squid_state self.entity_manager.update_remote_squid(sender_node_id, payload, is_new_arrival=False) elif sender_node_id in self.remote_squids: # Fallback basic update current_display_data = self.remote_squids[sender_node_id] visual = current_display_data.get('visual') if visual and all(k in payload for k in ['x', 'y', 'direction']): visual.setPos(payload['x'], payload['y']) self.update_remote_squid_image(current_display_data, payload['direction']) # Update image based on direction # Update stored data for this squid if 'data' in current_display_data: current_display_data['data'].update(payload) # Merge new data current_display_data['last_update'] = time.time() def handle_rock_throw(self, node: NetworkNode, message: Dict, addr: tuple): """Handles 'rock_throw' messages from remote players, creating a visual effect.""" if not self.logger: return if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'user_interface'): return scene = self.tamagotchi_logic.user_interface.scene payload_outer = message.get('payload', {}) # Payload from NetworkNode (contains original sender's payload) rock_throw_data = payload_outer.get('rock_data', {}) # Actual rock data # Get sender_node_id (NetworkNode should add this based on sender's address if not in payload) sender_node_id = payload_outer.get('node_id') or message.get('node_id') if not rock_throw_data or not sender_node_id: if self.debug_mode: self.logger.warning(f"Incomplete rock_throw message: data={rock_throw_data}, sender={sender_node_id}") return if self.network_node and sender_node_id == self.network_node.node_id: return # Ignore own rock throws if self.debug_mode: self.logger.debug(f"Received rock_throw from {sender_node_id[-6:]}, data: {rock_throw_data}") # Create visual for the thrown rock rock_filename = rock_throw_data.get('filename', os.path.join("images","rock.png")) # Default rock image try: pixmap = QtGui.QPixmap(rock_filename) if pixmap.isNull(): # Fallback if specified image fails pixmap = QtGui.QPixmap(os.path.join("images","rock.png")) thrown_rock_item = QtWidgets.QGraphicsPixmapItem(pixmap) initial_x = rock_throw_data.get('initial_pos_x', scene.width()/2) # Default to center initial_y = rock_throw_data.get('initial_pos_y', scene.height()/2) thrown_rock_item.setPos(initial_x, initial_y) thrown_rock_item.setScale(rock_throw_data.get('scale', 0.8)) # Use scale from payload thrown_rock_item.setZValue(20) # High Z-value to be visible self.apply_foreign_object_tint(thrown_rock_item) # Mark as from remote scene.addItem(thrown_rock_item) # Static display for testing - no animation # The rock will just appear and then be removed if it goes off-screen or by other logic. # For a simple effect, you could make it disappear after a short time. if self.debug_mode: self.logger.debug(f"Static remote rock '{os.path.basename(rock_filename)}' displayed from {sender_node_id[-6:]}.") QtCore.QTimer.singleShot(1500, lambda item=thrown_rock_item: item.scene().removeItem(item) if item.scene() else None) except Exception as e_rock_throw_vis: if self.debug_mode: self.logger.error(f"Error visualizing remote rock throw: {e_rock_throw_vis}", exc_info=True) def handle_state_update(self, node: NetworkNode, message: Dict, addr: tuple): """Handles generic 'state_update' messages. Could be used for various game events.""" if not self.logger: return payload = message.get('payload', {}) # Sender ID might be in top-level message from NetworkNode or within payload sender_node_id = message.get('node_id') or payload.get('node_id') if self.debug_mode: self.logger.debug(f"Received generic 'state_update' from {sender_node_id[-6:] if sender_node_id else 'Unknown'}. Payload: {payload}") # Example: If state_update contains specific info about a remote squid's special action # if sender_node_id and 'special_action' in payload: # action_type = payload['special_action'] # if self.entity_manager and hasattr(self.entity_manager, 'trigger_remote_squid_special_effect'): # self.entity_manager.trigger_remote_squid_special_effect(sender_node_id, action_type, payload) # elif self.debug_mode: # self.logger.info(f"Remote squid {sender_node_id[-6:]} performed special action: {action_type}") # Add more specific logic here based on the content and purpose of 'state_update' messages. ================================================ FILE: plugins/multiplayer/multiplayer_config_dialog.py ================================================ # multiplayer_config_dialog.py from PyQt5 import QtCore, QtGui, QtWidgets from plugins.multiplayer import mp_constants import os class MultiplayerConfigDialog(QtWidgets.QDialog): """ Modal dialog that lets the user change multiplayer network settings. New: checkbox to switch to TCP/IP list + line-edit for addresses. """ def __init__(self, plugin_instance, parent=None, initial_settings=None): super().__init__(parent) self.plugin = plugin_instance self.setWindowTitle("Multiplayer Network Settings") self.setModal(True) self.resize(500, 400) self._build_ui(initial_settings or {}) # ------------------------------------------------------------------ # UI construction # ------------------------------------------------------------------ def _build_ui(self, cfg): main_layout = QtWidgets.QVBoxLayout(self) # ----- multicast group / port ----- grp_multicast = QtWidgets.QGroupBox("Multicast (UDP)") form = QtWidgets.QFormLayout(grp_multicast) self.mc_group_edit = QtWidgets.QLineEdit(cfg.get("multicast_group", mp_constants.MULTICAST_GROUP)) self.mc_port_spin = QtWidgets.QSpinBox() self.mc_port_spin.setRange(1024, 65535) self.mc_port_spin.setValue(cfg.get("port", mp_constants.MULTICAST_PORT)) form.addRow("Group:", self.mc_group_edit) form.addRow("Port:", self.mc_port_spin) main_layout.addWidget(grp_multicast) # ----- TCP/IP list ----- grp_tcp = QtWidgets.QGroupBox("TCP/IP Peer List") vbox = QtWidgets.QVBoxLayout(grp_tcp) self.use_tcp_check = QtWidgets.QCheckBox("Use TCP/IP list instead of multicast") self.use_tcp_check.setToolTip("Check this to connect to specific IPs across routers / Internet.") self.tcp_ip_edit = QtWidgets.QLineEdit(cfg.get("tcp_ip_list", "")) self.tcp_ip_edit.setPlaceholderText("e.g. 192.168.1.50,203.0.113.12,example.com") self.tcp_ip_edit.setEnabled(False) self.use_tcp_check.toggled.connect(lambda on: self.tcp_ip_edit.setEnabled(on)) self.use_tcp_check.setChecked(cfg.get("use_tcp", False)) vbox.addWidget(self.use_tcp_check) vbox.addWidget(self.tcp_ip_edit) main_layout.addWidget(grp_tcp) # ----- other existing sliders ----- self.opacity_slider = self._new_slider(cfg.get("remote_opacity", 1.0), 0.2, 1.0, 0.05, "Remote opacity") self.sync_spin = QtWidgets.QSpinBox() self.sync_spin.setRange(1, 10) self.sync_spin.setValue(int(cfg.get("sync_interval", 2))) form2 = QtWidgets.QFormLayout() form2.addRow("Remote squid opacity:", self.opacity_slider) form2.addRow("Sync interval (s):", self.sync_spin) main_layout.addLayout(form2) # ----- OK / Cancel ----- buttons = QtWidgets.QDialogButtonBox(QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel) buttons.accepted.connect(self._on_ok) buttons.rejected.connect(self.reject) main_layout.addWidget(buttons) # ------------------------------------------------------------------ # helpers # ------------------------------------------------------------------ def _new_slider(self, val, minv, maxv, step, label): slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) slider.setRange(int(minv / step), int(maxv / step)) slider.setValue(int(val / step)) slider.setTickPosition(QtWidgets.QSlider.TicksBelow) slider.setTickInterval(1) slider.setSingleStep(1) return slider def _on_ok(self): self.new_settings = { "multicast_group": self.mc_group_edit.text().strip(), "port": self.mc_port_spin.value(), "remote_opacity": self.opacity_slider.value() * 0.05, "sync_interval": self.sync_spin.value(), "use_tcp": self.use_tcp_check.isChecked(), "tcp_ip_list": self.tcp_ip_edit.text().strip() } self.accept() # ------------------------------------------------------------------ # let caller re-load values if dialog is re-opened # ------------------------------------------------------------------ def load_settings(self, cfg): self.mc_group_edit.setText(cfg.get("multicast_group", mp_constants.MULTICAST_GROUP)) self.mc_port_spin.setValue(cfg.get("port", mp_constants.MULTICAST_PORT)) self.opacity_slider.setValue(int(cfg.get("remote_opacity", 1.0) / 0.05)) self.sync_spin.setValue(int(cfg.get("sync_interval", 2))) self.use_tcp_check.setChecked(cfg.get("use_tcp", False)) self.tcp_ip_edit.setText(cfg.get("tcp_ip_list", "")) ================================================ FILE: plugins/multiplayer/multiplayer_events.py ================================================ from PyQt5 import QtCore from typing import Dict, Any, List, Optional, Callable class MultiplayerEventDispatcher(QtCore.QObject): """Dispatches multiplayer events to registered handlers""" # Define signals for various event types squid_joined = QtCore.pyqtSignal(str, dict) # node_id, squid_data squid_left = QtCore.pyqtSignal(str, str) # node_id, reason squid_moved = QtCore.pyqtSignal(str, dict) # node_id, position_data squid_action = QtCore.pyqtSignal(str, str, dict) # node_id, action_type, action_data object_synced = QtCore.pyqtSignal(str, list) # node_id, objects_data rock_thrown = QtCore.pyqtSignal(str, dict) # node_id, rock_data squid_exited = QtCore.pyqtSignal(str, str, dict) # node_id, direction, exit_data squid_arrived = QtCore.pyqtSignal(str, dict) # node_id, arrival_data def __init__(self, parent=None): super().__init__(parent) self.handlers = {} self.debug_mode = False def register_handler(self, event_type: str, handler: Callable): """Register a handler for a specific event type""" if event_type not in self.handlers: self.handlers[event_type] = [] self.handlers[event_type].append(handler) # Connect to corresponding signal if it exists signal_map = { 'squid_joined': self.squid_joined, 'squid_left': self.squid_left, 'squid_moved': self.squid_moved, 'squid_action': self.squid_action, 'object_synced': self.object_synced, 'rock_thrown': self.rock_thrown, 'squid_exited': self.squid_exited, 'squid_arrived': self.squid_arrived } if event_type in signal_map: signal_map[event_type].connect(handler) def dispatch_event(self, event_type: str, *args, **kwargs): """Dispatch an event to all registered handlers""" if self.debug_mode: print(f"Dispatching event: {event_type}") # Emit the corresponding signal if it exists signal_map = { 'squid_joined': self.squid_joined, 'squid_left': self.squid_left, 'squid_moved': self.squid_moved, 'squid_action': self.squid_action, 'object_synced': self.object_synced, 'rock_thrown': self.rock_thrown, 'squid_exited': self.squid_exited, 'squid_arrived': self.squid_arrived } if event_type in signal_map: signal = signal_map[event_type] signal.emit(*args) # Call direct handlers if event_type in self.handlers: for handler in self.handlers[event_type]: try: handler(*args, **kwargs) except Exception as e: print(f"Error in event handler for {event_type}: {e}") ================================================ FILE: plugins/multiplayer/multiplayer_status_widget.py ================================================ from PyQt5 import QtCore, QtGui, QtWidgets import time class MultiplayerStatusWidget(QtWidgets.QWidget): def __init__(self, plugin_manager=None, parent=None): # MODIFIED: Added plugin_manager super().__init__(parent) self.plugin_manager = plugin_manager # MODIFIED: Store plugin_manager self.setObjectName("MultiplayerStatusWidget") self.setAttribute(QtCore.Qt.WA_TranslucentBackground) # Keep track of peers and connection status self.connection_active = False self.node_id = "Unknown" # Initial node_id self.local_ip = "N/A" # Initial local_ip self.peers = [] self.last_activity = {} # Setup UI self.setup_ui() # Update timer self.update_timer = QtCore.QTimer(self) self.update_timer.timeout.connect(self.update_display) self.update_timer.start(1000) # Update every second def setup_ui(self): layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(5, 5, 5, 5) frame = QtWidgets.QFrame(self) frame.setStyleSheet(""" QFrame { background-color: rgba(0, 0, 0, 170); border-radius: 12px; color: white; border: 1px solid rgba(255, 255, 255, 100); } """) frame_layout = QtWidgets.QVBoxLayout(frame) title_label = QtWidgets.QLabel("🌐 Multiplayer") title_label.setStyleSheet("color: #FFFFFF; font-weight: bold; font-size: 14px;") title_label.setAlignment(QtCore.Qt.AlignCenter) frame_layout.addWidget(title_label) status_layout = QtWidgets.QHBoxLayout() self.status_icon = QtWidgets.QLabel("⚠️") status_layout.addWidget(self.status_icon) self.status_label = QtWidgets.QLabel("Disconnected") self.status_label.setStyleSheet("color: #FF6666; font-weight: bold;") status_layout.addWidget(self.status_label) # Node ID and IP display label self.node_id_label = QtWidgets.QLabel(f"Node ID: {self.node_id} IP: {self.local_ip}") # MODIFIED: Shows both self.node_id_label.setStyleSheet("color: #DDDDDD; margin-left: 10px;") status_layout.addWidget(self.node_id_label) status_layout.addStretch() frame_layout.addLayout(status_layout) self.activity_log = QtWidgets.QListWidget() self.activity_log.setMaximumHeight(120) self.activity_log.setStyleSheet(""" QListWidget { background-color: rgba(0, 0, 0, 100); border: 1px solid #444444; border-radius: 5px; color: white; } QListWidget::item { padding: 2px; } """) frame_layout.addWidget(self.activity_log) self.peers_label = QtWidgets.QLabel("Connected Peers: 0") self.peers_label.setStyleSheet("color: #DDDDDD; margin-top: 5px;") frame_layout.addWidget(self.peers_label) self.peers_list = QtWidgets.QListWidget() self.peers_list.setMaximumHeight(80) self.peers_list.setStyleSheet(""" QListWidget { background-color: rgba(0, 0, 0, 80); border: 1px solid #333333; border-radius: 5px; color: white; } QListWidget::item { padding: 2px; } """) frame_layout.addWidget(self.peers_list) toggle_button = QtWidgets.QPushButton("▼") toggle_button.setMaximumWidth(30) toggle_button.clicked.connect(self.toggle_expanded) frame_layout.addWidget(toggle_button, alignment=QtCore.Qt.AlignRight) layout.addWidget(frame) self.is_expanded = True self.activity_log.setVisible(self.is_expanded) self.peers_label.setVisible(self.is_expanded) self.peers_list.setVisible(self.is_expanded) def add_activity(self, message): timestamp = QtCore.QTime.currentTime().toString("hh:mm:ss") item = QtWidgets.QListWidgetItem(f"{timestamp}: {message}") self.activity_log.insertItem(0, item) if self.activity_log.count() > 50: self.activity_log.takeItem(self.activity_log.count() - 1) def toggle_expanded(self): self.is_expanded = not self.is_expanded self.activity_log.setVisible(self.is_expanded) self.peers_label.setVisible(self.is_expanded) self.peers_list.setVisible(self.is_expanded) sender = self.sender() if isinstance(sender, QtWidgets.QPushButton): sender.setText("▼" if self.is_expanded else "▲") if self.is_expanded: self.parentWidget().adjustSize() self.setMaximumHeight(10000) else: self.parentWidget().adjustSize() min_height = self.layout().itemAt(0).widget().layout().itemAt(0).widget().sizeHint().height() min_height += self.layout().itemAt(0).widget().layout().itemAt(1).layout().sizeHint().height() min_height += self.layout().itemAt(0).widget().layout().itemAt(4).widget().sizeHint().height() min_height += self.layout().itemAt(0).widget().layout().contentsMargins().top() + self.layout().itemAt(0).widget().layout().contentsMargins().bottom() min_height += self.layout().contentsMargins().top() + self.layout().contentsMargins().bottom() self.setMaximumHeight(min_height + 20) def update_connection_status(self, is_connected, node_id=None): self.connection_active = is_connected if node_id: # If a node_id is provided, update it self.node_id = node_id if is_connected: self.status_label.setText("Connected") self.status_label.setStyleSheet("color: #66FF66; font-weight: bold;") self.status_icon.setText("✔️") else: self.status_label.setText("Disconnected") self.status_label.setStyleSheet("color: #FF6666; font-weight: bold;") self.status_icon.setText("⚠️") # Update the node_id_label with current node_id and local_ip self.node_id_label.setText(f"Node ID: {self.node_id} IP: {self.local_ip}") def update_peers(self, peers_data): self.peers = [] if not hasattr(self, 'peers_list'): return self.peers_list.clear() current_time = time.time() for node_id, (ip, last_seen, _) in peers_data.items(): status = "Active" if current_time - last_seen < 10 else "Inactive" self.peers.append({ 'node_id': node_id, 'ip': ip, 'last_seen': last_seen, 'status': status }) item_text = f"{node_id[-6:]} ({ip})" item = QtWidgets.QListWidgetItem(item_text) if status == "Active": item.setForeground(QtGui.QBrush(QtGui.QColor(100, 255, 100))) else: item.setForeground(QtGui.QBrush(QtGui.QColor(150, 150, 150))) self.peers_list.addItem(item) active_count = sum(1 for p in self.peers if p['status'] == "Active") if hasattr(self, 'peers_label'): self.peers_label.setText(f"Connected Peers: {active_count}") def update_display(self): if self.peers: current_time = time.time() update_needed = False for peer in self.peers: old_status = peer['status'] if isinstance(peer.get('last_seen'), (int, float)): peer['status'] = "Active" if current_time - peer['last_seen'] < 10 else "Inactive" else: peer['status'] = "Unknown" if old_status != peer['status']: update_needed = True if update_needed: self.refresh_peers_list() def refresh_peers_list(self): if not hasattr(self, 'peers_list') or not hasattr(self, 'peers_label'): return self.peers_list.clear() for peer in self.peers: item_text = f"{peer.get('node_id', 'N/A')[-6:]} ({peer.get('ip', 'N/A')})" item = QtWidgets.QListWidgetItem(item_text) if peer.get('status') == "Active": item.setForeground(QtGui.QBrush(QtGui.QColor(100, 255, 100))) else: item.setForeground(QtGui.QBrush(QtGui.QColor(150, 150, 150))) self.peers_list.addItem(item) active_count = sum(1 for p in self.peers if p.get('status') == "Active") self.peers_label.setText(f"Connected Peers: {active_count}") # --- ADDED/MODIFIED METHODS TO MATCH mp_plugin_logic.py EXPECTATIONS --- def update_status(self, status_text, is_enabled): """ Called by mp_plugin_logic.py to update the general enabled/disabled status. It maps to the widget's more specific 'update_connection_status'. Note: This version does not receive node_id directly. Node ID is managed by calls to update_connection_status (potentially by mp_plugin_logic.py if it calls that) or set initially. """ # Update connection status (visuals like "Connected"/"Disconnected" and icon) # It uses the currently stored self.node_id. self.update_connection_status(is_connected=is_enabled, node_id=self.node_id) # Logging through plugin_manager if available if self.plugin_manager and hasattr(self.plugin_manager, 'logger'): self.plugin_manager.logger.debug( f"MultiplayerStatusWidget: 'update_status' called. Status Text='{status_text}', IsEnabled={is_enabled}" ) else: # Fallback print for debugging if logger is not available print(f"DEBUG: MultiplayerStatusWidget: 'update_status' called. Status Text='{status_text}', IsEnabled={is_enabled}") def set_ip_address(self, ip_address_text): """ Called by mp_plugin_logic.py to set the local IP address display. """ self.local_ip = ip_address_text if ip_address_text else "N/A" # Update the display to show the new IP along with the current node_id self.node_id_label.setText(f"Node ID: {self.node_id} IP: {self.local_ip}") if self.plugin_manager and hasattr(self.plugin_manager, 'logger'): self.plugin_manager.logger.debug( f"MultiplayerStatusWidget: 'set_ip_address' called. IP='{self.local_ip}'" ) else: print(f"DEBUG: MultiplayerStatusWidget: 'set_ip_address' called. IP='{self.local_ip}'") def update_icon(self, is_enabled): """ Placeholder to prevent AttributeError if called. Actual icon update is handled by update_connection_status. """ # The actual icon (self.status_icon) is updated in self.update_connection_status. # This method is here to satisfy any external calls that might have been based on earlier designs. pass # If direct control over the icon from this method signature is needed later, # you can add logic here, e.g.: # if is_enabled: # self.status_icon.setText("✔️") # else: # self.status_icon.setText("⚠️") ================================================ FILE: plugins/multiplayer/network_utilities.py ================================================ # In plugins/multiplayer/network_utilities.py import json import zlib import socket # Make sure socket is imported if get_local_ip uses it import time # Make sure time is imported if is_node_active uses it import uuid # Make sure uuid is imported if generate_node_id uses it from typing import Dict, Any, Union, List, Tuple # Ensure all used types are imported # It's good practice to have a logger for utilities if they are complex # import logging # logger = logging.getLogger(__name__) class NetworkUtilities: """ A collection of static utility methods for network operations including message compression, decompression, and node ID generation. """ @staticmethod def get_local_ip() -> str: """ Attempts to discover the local IP address of the machine. Fallback to '127.0.0.1' if discovery fails. """ try: # Create a temporary socket to connect to an external server (doesn't send data) temp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) temp_socket.settimeout(0.5) # Prevent long blocking # Google's public DNS server is a common choice for this temp_socket.connect(('8.8.8.8', 80)) local_ip = temp_socket.getsockname()[0] temp_socket.close() return local_ip except socket.error: # Catch socket-specific errors # Fallback if the above method fails (e.g., no network, firewall) try: # Get hostname and resolve it hostname = socket.gethostname() local_ip = socket.gethostbyname(hostname) return local_ip except socket.gaierror: # getaddrinfo error return '127.0.0.1' # Ultimate fallback except Exception: # Catch any other unexpected errors return '127.0.0.1' @staticmethod def compress_message(message: Dict[str, Any]) -> bytes: """Compress a message dictionary to bytes using JSON and zlib.""" is_squid_exit_message = message.get('type') == 'squid_exit' # Optional: More detailed logging for all messages for debugging structure # print("--- Debug: Attempting to compress message (NetworkUtilities) ---") # for key, value in message.items(): # print(f" Key: {key}, Type: {type(value)}") # if isinstance(value, dict): # for sub_key, sub_value in value.items(): # print(f" SubKey: {sub_key}, SubType: {type(sub_value)}") # print("--- End of debug statements for message content (NetworkUtilities) ---") if is_squid_exit_message: # Using json.dumps for pretty printing complex nested structures for the log try: message_for_log = json.dumps(message, indent=2) except TypeError: # Handle non-serializable items if any for logging message_for_log = str(message) # Fallback to string representation print(f"DEBUG_COMPRESS: Compressing SQUID_EXIT. Full message data: {message_for_log}") serialized_msg = None # Initialize to handle potential early error try: serialized_msg = json.dumps(message).encode('utf-8') if is_squid_exit_message: print(f"DEBUG_COMPRESS: SQUID_EXIT serialized size: {len(serialized_msg)}") compressed_msg = zlib.compress(serialized_msg) if is_squid_exit_message: compression_ratio = len(compressed_msg) / len(serialized_msg) if len(serialized_msg) > 0 else 0 print(f"DEBUG_COMPRESS: SQUID_EXIT compressed size: {len(compressed_msg)}. Compression ratio: {compression_ratio:.2f}") return compressed_msg except TypeError as te: error_detail = f"TypeError during JSON serialization for SQUID_EXIT: {te}. Message keys: {list(message.keys())}" if is_squid_exit_message else f"TypeError during JSON serialization: {te}. Message keys: {list(message.keys())}" print(f"DEBUG_COMPRESS_ERROR: {error_detail}") # Fallback: return an error message, still as bytes return json.dumps({"error": "json_type_error", "details": str(te), "original_type": message.get('type')}).encode('utf-8') except zlib.error as ze: error_detail = f"zlib compression error for SQUID_EXIT: {ze}. Sending uncompressed." if is_squid_exit_message else f"zlib compression error: {ze}. Sending uncompressed." print(f"DEBUG_COMPRESS_ERROR: {error_detail}") if serialized_msg: # If serialization succeeded before zlib error return serialized_msg else: # Should not happen if TypeError is caught, but as a safeguard return json.dumps({"error": "zlib_error_and_serialization_failed", "details": str(ze), "original_type": message.get('type')}).encode('utf-8') except Exception as e: error_detail = f"General error compressing SQUID_EXIT: {e}" if is_squid_exit_message else f"General error compressing message: {e}" print(f"DEBUG_COMPRESS_ERROR: {error_detail}") return json.dumps({"error": "compression_failure", "details": str(e), "original_type": message.get('type')}).encode('utf-8') @staticmethod def decompress_message(compressed_msg: bytes) -> Union[Dict[str, Any], None]: """Decompress bytes to a message dictionary using zlib and JSON.""" if not compressed_msg: print("DEBUG_DECOMPRESS_ERROR: Received empty message for decompression.") return None # Crude check on raw/compressed bytes to see if it *might* be a squid_exit message for targeted logging # This check is heuristic and might not always be accurate before decompression. is_potentially_squid_exit = b'"type": "squid_exit"' in compressed_msg or \ b'squid_exit' in compressed_msg # More generic check if is_potentially_squid_exit: print(f"DEBUG_DECOMPRESS: Potential SQUID_EXIT raw data received (first 100 bytes): {compressed_msg[:100]}") decompressed_data_str = None message_dict = None try: # Attempt zlib decompression first try: decompressed_bytes = zlib.decompress(compressed_msg) if is_potentially_squid_exit: print(f"DEBUG_DECOMPRESS: SQUID_EXIT (potential) successfully zlib decompressed. Decompressed size: {len(decompressed_bytes)}") decompressed_data_str = decompressed_bytes.decode('utf-8') message_dict = json.loads(decompressed_data_str) except zlib.error as ze_decompress: # If zlib fails, assume it's uncompressed JSON if is_potentially_squid_exit: print(f"DEBUG_DECOMPRESS: zlib.error for SQUID_EXIT (potential) ('{ze_decompress}'). Assuming uncompressed JSON.") decompressed_data_str = compressed_msg.decode('utf-8') # Use original msg as string message_dict = json.loads(decompressed_data_str) except UnicodeDecodeError as ude: # Catch if decode after zlib fails print(f"DEBUG_DECOMPRESS_ERROR: UnicodeDecodeError after zlib success (or if it was uncompressed non-UTF8). Details: {ude}. Data (first 100 bytes of error source): {decompressed_bytes[:100] if 'decompressed_bytes' in locals() else compressed_msg[:100]}") return {"error": "unicode_decode_error_post_zlib", "details": str(ude)} # After successful JSON load, confirm and log if it's a squid_exit if isinstance(message_dict, dict) and message_dict.get('type') == 'squid_exit': # Using json.dumps for pretty printing complex nested structures for the log try: message_for_log = json.dumps(message_dict, indent=2) except TypeError: message_for_log = str(message_dict) # Fallback print(f"DEBUG_DECOMPRESS: Successfully decoded SQUID_EXIT message: {message_for_log}") return message_dict except UnicodeDecodeError as ude_uncompressed: # If decode of presumed uncompressed fails print(f"DEBUG_DECOMPRESS_ERROR: UnicodeDecodeError (assuming uncompressed). Details: {ude_uncompressed}. Raw data (first 100 bytes): {compressed_msg[:100]}") return {"error": "unicode_decode_error_uncompressed", "details": str(ude_uncompressed)} except json.JSONDecodeError as jde: # The data fed to json.loads here is `decompressed_data_str` print(f"DEBUG_DECOMPRESS_ERROR: JSONDecodeError. Details: {jde}. Data fed to json.loads (first 100 chars): {decompressed_data_str[:100] if decompressed_data_str else 'N/A'}") return {"error": "json_decode_error", "details": str(jde)} except Exception as e: # Catch-all for other unexpected errors print(f"DEBUG_DECOMPRESS_ERROR: General Exception during decompression. Details: {e}. Raw data (first 100 bytes): {compressed_msg[:100]}") return {"error": "general_decompression_failure", "details": str(e)} @staticmethod def generate_node_id(prefix: str = "squid") -> str: """Generate a unique node identifier with a given prefix.""" # Generate a UUID and take a portion of its hex representation for brevity return f"{prefix}_{uuid.uuid4().hex[:8]}" @staticmethod def is_node_active(last_seen_time: float, threshold: float = 30.0) -> bool: # Increased threshold """ Check if a node is considered active based on its last seen time. Args: last_seen_time: The timestamp (unix epoch float) when the node was last heard from. threshold: The number of seconds without contact after which a node is considered inactive. Returns: True if the node is active, False otherwise. """ if last_seen_time is None: return False # Never seen return (time.time() - last_seen_time) < threshold # Example of a BinaryProtocol class if it were part of this file: # class BinaryProtocol: # @staticmethod # def pack_data(*args) -> bytes: # # Example: Implement packing logic using struct or similar # # This is a placeholder and would need a proper specification # packed_bytes = b'' # for arg in args: # if isinstance(arg, int): # packed_bytes += arg.to_bytes(4, 'big', signed=True) # elif isinstance(arg, float): # # A more robust solution would use struct.pack # packed_bytes += str(arg).encode('utf-8').ljust(16, b'\0') # Simplistic # elif isinstance(arg, str): # packed_bytes += arg.encode('utf-8').ljust(32, b'\0') # Simplistic # return packed_bytes # @staticmethod # def unpack_data(data: bytes) -> tuple: # # Example: Implement unpacking logic # # This is a placeholder # # Assuming a fixed format like: int (4B), float_str (16B), str (32B) # num = int.from_bytes(data[0:4], 'big', signed=True) # float_val_str = data[4:20].decode('utf-8').strip('\0') # str_val = data[20:52].decode('utf-8').strip('\0') # return num, float(float_val_str), str_val ================================================ FILE: plugins/multiplayer/packet_validator.py ================================================ import re import json import os import time from typing import Dict, Any, Optional, List, Tuple class PacketValidator: """Utility class to validate network packets for security and integrity""" @staticmethod def validate_message(message: Dict[str, Any]) -> Tuple[bool, Optional[str]]: """ Validate a message for required fields and proper structure Args: message: The message to validate Returns: (is_valid, error_message) """ # Check for required fields required_fields = ['node_id', 'timestamp', 'type', 'payload'] for field in required_fields: if field not in message: return False, f"Missing required field: {field}" # Validate node_id format (alphanumeric) if not isinstance(message['node_id'], str) or not re.match(r'^[a-zA-Z0-9_-]+$', message['node_id']): return False, "Invalid node_id format" # Check timestamp (should be within 1 hour of current time to prevent replay attacks) current_time = time.time() msg_time = message['timestamp'] if not isinstance(msg_time, (int, float)) or abs(current_time - msg_time) > 3600: return False, "Invalid timestamp" # Validate message type valid_types = [ 'heartbeat', 'squid_move', 'squid_action', 'object_sync', 'rock_throw', 'player_join', 'player_leave', 'state_update', 'squid_exit', 'new_squid_arrival' ] if message['type'] not in valid_types: return False, f"Unknown message type: {message['type']}" # Validate payload is a dictionary if not isinstance(message['payload'], dict): return False, "Payload must be a dictionary" # Type-specific validation if message['type'] == 'squid_exit': return PacketValidator.validate_squid_exit(message['payload']) elif message['type'] == 'object_sync': return PacketValidator.validate_object_sync(message['payload']) # Default to valid for types without specific validation return True, None @staticmethod def validate_squid_exit(payload: Dict[str, Any]) -> Tuple[bool, Optional[str]]: """Validate squid exit payload""" # Check for nested payload structure if 'payload' not in payload: return False, "Missing nested payload in squid_exit message" exit_data = payload['payload'] # Check required fields required_fields = ['node_id', 'direction', 'position', 'color'] for field in required_fields: if field not in exit_data: return False, f"Missing required field in squid_exit: {field}" # Validate direction valid_directions = ['left', 'right', 'up', 'down'] if exit_data['direction'] not in valid_directions: return False, f"Invalid exit direction: {exit_data['direction']}" # Validate position is a dictionary with x,y if not isinstance(exit_data['position'], dict) or not all(k in exit_data['position'] for k in ['x', 'y']): return False, "Invalid position format" # Validate color is a tuple or list color = exit_data['color'] if not isinstance(color, (list, tuple)) or len(color) < 3 or not all(isinstance(c, int) for c in color[:3]): return False, "Invalid color format" return True, None @staticmethod def validate_object_sync(payload: Dict[str, Any]) -> Tuple[bool, Optional[str]]: """Validate object sync payload""" # Check for squid data if 'squid' not in payload: return False, "Missing squid data in object_sync" # Check for objects array if 'objects' not in payload: return False, "Missing objects array in object_sync" if not isinstance(payload['objects'], list): return False, "Objects must be an array" # Validate squid data has required fields squid = payload['squid'] required_squid_fields = ['x', 'y', 'direction'] for field in required_squid_fields: if field not in squid: return False, f"Missing required squid field: {field}" # Validate node_info if present if 'node_info' in payload: node_info = payload['node_info'] if not isinstance(node_info, dict) or 'id' not in node_info: return False, "Invalid node_info format" return True, None @staticmethod def sanitize_object_data(objects: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Sanitize object data to ensure no malicious content""" sanitized = [] for obj in objects: # Check if required fields exist if not all(k in obj for k in ['id', 'type', 'x', 'y']): continue # Sanitize filename to prevent directory traversal if 'filename' in obj: filename = obj['filename'] # Remove any path navigation filename = re.sub(r'\.\./', '', filename) filename = re.sub(r'\.\.\\', '', filename) # Use only the basename import os filename = os.path.basename(filename) obj['filename'] = filename # Ensure numeric values are valid obj['x'] = float(obj['x']) if isinstance(obj['x'], (int, float)) else 0 obj['y'] = float(obj['y']) if isinstance(obj['y'], (int, float)) else 0 if 'scale' in obj: obj['scale'] = float(obj['scale']) if isinstance(obj['scale'], (int, float)) else 1.0 # Limit to valid values obj['scale'] = max(0.1, min(5.0, obj['scale'])) # Reasonable scale limits sanitized.append(obj) return sanitized ================================================ FILE: plugins/multiplayer/plugin.txt ================================================ NAME=Multiplayer VERSION=1.10 AUTHOR=ViciousSquid DESCRIPTION=Enables network sync for squids and objects (Experimental) REQUIRES=network_interface ================================================ FILE: plugins/multiplayer/remote_entity_manager.py ================================================ from PyQt5 import QtCore, QtGui, QtWidgets import os import time import math from typing import Dict, Any, Optional, List import logging import base64 # AnimatableGraphicsItem class class AnimatableGraphicsItem(QtWidgets.QGraphicsPixmapItem, QtCore.QObject): def __init__(self, pixmap=None, parent=None): QtWidgets.QGraphicsPixmapItem.__init__(self, pixmap, parent) QtCore.QObject.__init__(self) self._scale = 1.0 @QtCore.pyqtProperty(float) def scale_factor(self): return self._scale @scale_factor.setter def scale_factor(self, value): self._scale = value self.setScale(value) # ObjectPool class class ObjectPool: def __init__(self, factory_func, initial_size=10): self.factory = factory_func self.available = [] self.in_use = set() for _ in range(initial_size): self.available.append(self.factory()) def acquire(self): obj = self.available.pop() if self.available else self.factory() self.in_use.add(obj) return obj def release(self, obj): if obj in self.in_use: self.in_use.remove(obj) self.available.append(obj) def clear(self): for item_list in [self.available, self.in_use]: for item in list(item_list): # Iterate copy if modifying list if isinstance(item, QtWidgets.QGraphicsItem) and item.scene(): item.scene().removeItem(item) if item_list is self.in_use and item in self.in_use: # If clearing in_use, ensure removed self.in_use.remove(item) self.available.clear() # self.in_use should be cleared by loop above if items are released, # but direct clear if items are not released back to pool by external logic. self.in_use.clear() class RemoteEntityManager: def __init__(self, scene, window_width, window_height, debug_mode=False, logger=None): self.scene = scene self.window_width = window_width self.window_height = window_height self.debug_mode = debug_mode self.IMAGE_DIMENSIONS = { "left1.png": (253, 147), "left2.png": (253, 147), "right1.png": (253, 147), "right2.png": (253, 147), "up1.png": (177, 238), "up2.png": (177, 238), } self.DEFAULT_IMAGE_DIMENSION = (253, 147) if logger: self.logger = logger else: self.logger = logging.getLogger(__name__ + ".RemoteEntityManager") if not self.logger.hasHandlers(): handler = logging.StreamHandler() formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') handler.setFormatter(formatter) self.logger.addHandler(handler) self.logger.setLevel(logging.DEBUG if self.debug_mode else logging.INFO) self.remote_squids = {} self.remote_objects = {} self.connection_lines = {} self._last_calculated_entry_details = {} self.remote_opacity = 1.0 self.show_labels = True self.show_connections = True self.text_pool = ObjectPool(lambda: QtWidgets.QGraphicsTextItem(""), initial_size=20) self.script_dir = os.path.dirname(os.path.abspath(__file__)) self.project_root = os.path.join(self.script_dir, '..', '..') self.images_folder_root_path = os.path.join(self.project_root, 'images') self.position_update_timer = QtCore.QTimer() self.position_update_timer.timeout.connect(self._update_visuals_once_per_second) self.MOVEMENT_INTERVAL_MS = 1000 # Update once per second self.MAX_PIXELS_PER_JUMP = 90.0 self.position_update_timer.start(self.MOVEMENT_INTERVAL_MS) def _get_image_file_name_and_direction(self, payload_direction_key: Optional[str], payload_animation_frame: Any, entry_direction_on_this_screen: Optional[str] = None, current_image_name_for_fallback_dir: str = "right1.png") -> tuple[str, str]: base_direction = "right" if payload_direction_key: base_direction = payload_direction_key.lower().strip() else: if self.debug_mode: self.logger.warning(f"_get_image_file_name_and_direction: payload_direction_key is missing. Defaulting to '{base_direction}'. Fallback hint: {current_image_name_for_fallback_dir}") facing_direction = base_direction if entry_direction_on_this_screen: original_facing_for_log = facing_direction if entry_direction_on_this_screen == "left": facing_direction = "right" elif entry_direction_on_this_screen == "right": facing_direction = "left" elif entry_direction_on_this_screen == "bottom": # Enters from bottom, should face up facing_direction = "up" elif entry_direction_on_this_screen == "top": # Enters from top, should face down facing_direction = "down" # Directly use "down" as it's valid since 'down1.png' exists if self.debug_mode and original_facing_for_log != facing_direction: self.logger.debug(f"_get_image_file_name_and_direction: Arrival adjustment. Entry: {entry_direction_on_this_screen}. Original Payload Facing: {original_facing_for_log}. New Visual Facing: {facing_direction}") # Valid sprite directions, now including "down" as per your confirmation valid_sprite_directions = ["left", "right", "up", "down"] if facing_direction not in valid_sprite_directions: if self.debug_mode: self.logger.warning(f"_get_image_file_name_and_direction: Attempted facing_direction '{facing_direction}' is not in {valid_sprite_directions}. Defaulting to 'right'.") facing_direction = "right" try: frame = int(payload_animation_frame) if frame not in [1, 2]: # Assuming animation frames are 1 and 2 if self.debug_mode and payload_animation_frame not in [1,2]: self.logger.warning(f"_get_image_file_name_and_direction: Invalid animation frame '{payload_animation_frame}'. Defaulting to 1.") frame = 1 except (ValueError, TypeError): if self.debug_mode: self.logger.warning(f"_get_image_file_name_and_direction: Animation frame '{payload_animation_frame}' is not a valid integer. Defaulting to 1.") frame = 1 image_file_name = f"{facing_direction}{frame}.png" if self.debug_mode: self.logger.debug(f"_get_image_file_name_and_direction: Args(PayloadDirKey='{payload_direction_key}', EntryDir='{entry_direction_on_this_screen}', AnimFrame='{payload_animation_frame}') -> Result(BaseDir='{base_direction}', FinalFacing='{facing_direction}', Image='{image_file_name}')") return image_file_name, facing_direction def _get_scaled_pixmap(self, image_file_name: str) -> tuple[QtGui.QPixmap, tuple[int, int]]: target_width, target_height = self.IMAGE_DIMENSIONS.get(image_file_name, self.DEFAULT_IMAGE_DIMENSION) if (target_width, target_height) == self.DEFAULT_IMAGE_DIMENSION and image_file_name not in self.IMAGE_DIMENSIONS: if self.debug_mode: self.logger.warning(f"Image file '{image_file_name}' not in IMAGE_DIMENSIONS. Using default: {self.DEFAULT_IMAGE_DIMENSION}") image_path = os.path.join(self.images_folder_root_path, image_file_name) pixmap = QtGui.QPixmap(image_path) if pixmap.isNull(): if self.debug_mode: self.logger.error(f"Image {image_path} not found. Fallback gray pixmap {target_width}x{target_height}.") fb_pixmap = QtGui.QPixmap(target_width, target_height); fb_pixmap.fill(QtCore.Qt.gray) return fb_pixmap, (target_width, target_height) return pixmap.scaled(target_width, target_height, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation), (target_width, target_height) def _update_dependent_items_position(self, remote_squid_info, new_visual_x, new_visual_y): if remote_squid_info.get('status_text'): remote_squid_info['status_text'].setPos(new_visual_x, new_visual_y - 30) if remote_squid_info.get('id_text'): remote_squid_info['id_text'].setPos(new_visual_x, new_visual_y - 45) def _update_visuals_once_per_second(self): for node_id, remote_squid_info in list(self.remote_squids.items()): visual_item = remote_squid_info.get('visual') if not visual_item: continue target_x = remote_squid_info.get('network_target_x') target_y = remote_squid_info.get('network_target_y') if target_x is None or target_y is None: continue current_pos = visual_item.pos() target_pos = QtCore.QPointF(target_x, target_y) if current_pos == target_pos: continue vector_to_target = target_pos - current_pos distance_to_target = math.sqrt(vector_to_target.x()**2 + vector_to_target.y()**2) new_pos: QtCore.QPointF if distance_to_target <= self.MAX_PIXELS_PER_JUMP: new_pos = target_pos else: normalized_x = vector_to_target.x() / distance_to_target normalized_y = vector_to_target.y() / distance_to_target new_pos = QtCore.QPointF( current_pos.x() + normalized_x * self.MAX_PIXELS_PER_JUMP, current_pos.y() + normalized_y * self.MAX_PIXELS_PER_JUMP ) visual_item.setPos(new_pos) self._update_dependent_items_position(remote_squid_info, new_pos.x(), new_pos.y()) # No debug log here by default to avoid spam, enable if needed def _handle_new_squid_arrival(self, node_id, squid_data_payload, entry_x, entry_y, entry_direction_on_this_screen): if self.debug_mode: self.logger.debug(f"_handle_new_squid_arrival: NodeID='{node_id}', EntryPos=({entry_x:.1f},{entry_y:.1f}), EntryDir='{entry_direction_on_this_screen}'") self.logger.debug(f"Payload for new arrival '{node_id}': {squid_data_payload}") payload_dir_key = squid_data_payload.get('image_direction_key', 'right') payload_anim_frame = squid_data_payload.get('current_animation_frame', 1) squid_image_name, determined_facing_direction = self._get_image_file_name_and_direction( payload_dir_key, payload_anim_frame, entry_direction_on_this_screen=entry_direction_on_this_screen ) scaled_pixmap, (current_w, current_h) = self._get_scaled_pixmap(squid_image_name) if self.debug_mode: self.logger.debug(f"_handle_new_squid_arrival '{node_id}': Image selected='{squid_image_name}', Determined Facing='{determined_facing_direction}', Size=({current_w}x{current_h})") remote_visual = AnimatableGraphicsItem(scaled_pixmap) remote_visual.setPos(entry_x, entry_y) remote_visual.setZValue(5) remote_visual.setOpacity(self.remote_opacity) remote_visual.setScale(1.0) self.scene.addItem(remote_visual) id_text = self.text_pool.acquire() if id_text.scene() != self.scene: if id_text.scene(): id_text.scene().removeItem(id_text) self.scene.addItem(id_text) id_text.setPlainText(f"Remote ({node_id[-4:]})") id_text.setDefaultTextColor(QtGui.QColor(200,200,200,200)) id_text.setFont(QtGui.QFont("Arial", 8)) id_text.setZValue(6) id_text.setVisible(self.show_labels) status_text = self.text_pool.acquire() if status_text.scene() != self.scene: if status_text.scene(): status_text.scene().removeItem(status_text) self.scene.addItem(status_text) status_text.setPlainText("ENTERING...") status_text.setDefaultTextColor(QtGui.QColor(255,255,0)) status_text.setFont(QtGui.QFont("Arial", 10, QtGui.QFont.Bold)) status_text.setZValue(6) status_text.setVisible(self.show_labels) self._update_dependent_items_position({'id_text': id_text, 'status_text': status_text}, entry_x, entry_y) self.remote_squids[node_id] = { 'visual': remote_visual, 'id_text': id_text, 'status_text': status_text, 'view_cone': None, 'last_update': time.time(), 'data': squid_data_payload.copy(), 'current_display_dimensions': (current_w, current_h), 'current_image_name': squid_image_name, 'was_arrival_text': True, 'network_target_x': entry_x, 'network_target_y': entry_y } if self.debug_mode: self.logger.info(f"REMOTE_ENTITY_MANAGER: Created NEW remote squid '{node_id}' at ({entry_x:.1f}, {entry_y:.1f}). Image: '{squid_image_name}', Size: {current_w}x{current_h}") def _handle_re_arriving_squid(self, node_id, squid_data_payload, remote_squid_info, entry_x, entry_y, entry_direction_on_this_screen): if self.debug_mode: self.logger.debug(f"_handle_re_arriving_squid: NodeID='{node_id}', EntryPos=({entry_x:.1f},{entry_y:.1f}), EntryDir='{entry_direction_on_this_screen}'") self.logger.debug(f"Payload for re-arriving '{node_id}': {squid_data_payload}") visual_item = remote_squid_info['visual'] visual_item.setPos(entry_x, entry_y) visual_item.setOpacity(self.remote_opacity) visual_item.setVisible(True) visual_item.setScale(1.0) payload_dir_key = squid_data_payload.get('image_direction_key', 'right') payload_anim_frame = squid_data_payload.get('current_animation_frame', 1) current_img_name_fallback = remote_squid_info.get('current_image_name', 'right1.png') new_squid_image_name, determined_facing_direction = self._get_image_file_name_and_direction( payload_dir_key, payload_anim_frame, entry_direction_on_this_screen=entry_direction_on_this_screen, current_image_name_for_fallback_dir=current_img_name_fallback ) scaled_pixmap, (current_w, current_h) = self._get_scaled_pixmap(new_squid_image_name) visual_item.setPixmap(scaled_pixmap) if self.debug_mode: self.logger.debug(f"_handle_re_arriving_squid '{node_id}': Image selected='{new_squid_image_name}', Determined Facing='{determined_facing_direction}', Size=({current_w}x{current_h})") remote_squid_info['current_display_dimensions'] = (current_w, current_h) remote_squid_info['current_image_name'] = new_squid_image_name remote_squid_info['network_target_x'] = entry_x remote_squid_info['network_target_y'] = entry_y status_item = remote_squid_info.get('status_text') if status_item: status_item.setPlainText("ENTERING...") status_item.setDefaultTextColor(QtGui.QColor(255,255,0)) status_item.setFont(QtGui.QFont("Arial", 10, QtGui.QFont.Bold)) status_item.setVisible(self.show_labels) id_item = remote_squid_info.get('id_text') if id_item: id_item.setVisible(self.show_labels) self._update_dependent_items_position(remote_squid_info, entry_x, entry_y) remote_squid_info['was_arrival_text'] = True if self.debug_mode: self.logger.info(f"REMOTE_ENTITY_MANAGER: Re-initialized RE-ARRIVING squid '{node_id}' at ({entry_x:.1f}, {entry_y:.1f}). Image: '{new_squid_image_name}', Size: {current_w}x{current_h}") def _handle_existing_squid_update(self, node_id, squid_data_payload, remote_squid_info): if self.debug_mode: log_payload = { 'x': squid_data_payload.get('x'), 'y': squid_data_payload.get('y'), 'image_direction_key': squid_data_payload.get('image_direction_key'), 'current_animation_frame': squid_data_payload.get('current_animation_frame'), 'status': squid_data_payload.get('status'), 'view_cone_visible': squid_data_payload.get('view_cone_visible') } self.logger.debug(f"_handle_existing_squid_update: NodeID='{node_id}'. Payload essentials: {log_payload}") network_x = squid_data_payload.get('x') network_y = squid_data_payload.get('y') if network_x is not None and network_y is not None: remote_squid_info['network_target_x'] = network_x remote_squid_info['network_target_y'] = network_y else: if self.debug_mode: self.logger.warning(f"_handle_existing_squid_update '{node_id}': Update missing x or y coordinates. Target position not updated.") visual_item = remote_squid_info.get('visual') if not visual_item: if self.debug_mode: self.logger.error(f"_handle_existing_squid_update '{node_id}': Visual item not found! Cannot update.") return False new_status_from_payload = squid_data_payload.get('status', remote_squid_info.get('data',{}).get('status','visiting')) status_text_item = remote_squid_info.get('status_text') if status_text_item: if status_text_item.toPlainText().upper() != new_status_from_payload.upper() or remote_squid_info.get('was_arrival_text', False): # Case-insensitive compare for current text status_text_item.setPlainText(new_status_from_payload.upper()) if remote_squid_info.get('was_arrival_text', False) or "ENTERING" in status_text_item.toPlainText(): status_text_item.setDefaultTextColor(QtGui.QColor(200,200,200,230)) status_text_item.setFont(QtGui.QFont("Arial", 10, QtGui.QFont.Normal)) remote_squid_info['was_arrival_text'] = False status_text_item.setVisible(self.show_labels) if 'view_cone_visible' in squid_data_payload: if squid_data_payload['view_cone_visible']: self.update_remote_view_cone(node_id, squid_data_payload) elif remote_squid_info.get('view_cone') and remote_squid_info['view_cone'].scene(): self.scene.removeItem(remote_squid_info['view_cone']) remote_squid_info['view_cone'] = None payload_dir_key = squid_data_payload.get('image_direction_key') payload_anim_frame = squid_data_payload.get('current_animation_frame', 1) current_img_name_fallback = remote_squid_info.get('current_image_name', 'right1.png') potential_new_image_name, determined_facing_direction = self._get_image_file_name_and_direction( payload_dir_key, payload_anim_frame, entry_direction_on_this_screen=None, current_image_name_for_fallback_dir=current_img_name_fallback ) if potential_new_image_name != remote_squid_info.get('current_image_name'): scaled_pixmap, (current_w, current_h) = self._get_scaled_pixmap(potential_new_image_name) visual_item.setPixmap(scaled_pixmap) remote_squid_info['current_display_dimensions'] = (current_w, current_h) remote_squid_info['current_image_name'] = potential_new_image_name if self.debug_mode: self.logger.debug(f"_handle_existing_squid_update '{node_id}': Image CHANGED to '{potential_new_image_name}', New Facing='{determined_facing_direction}', Size=({current_w}x{current_h})") return True def update_remote_squid(self, node_id, squid_data_payload, is_new_arrival=False): if self.debug_mode: arrival_status = "NEW ARRIVAL" if is_new_arrival else "EXISTING SQUID UPDATE" self.logger.debug(f"update_remote_squid CALLED: NodeID='{node_id}', Status='{arrival_status}'") # Avoid logging full payload if too verbose, log key parts or hash if necessary # For now, logging essential keys to understand context: log_payload_essentials = { 'x': squid_data_payload.get('x'), 'y': squid_data_payload.get('y'), 'image_direction_key': squid_data_payload.get('image_direction_key'), 'status': squid_data_payload.get('status'), 'node_id_in_payload': squid_data_payload.get('node_id') # verify it matches argument node_id } self.logger.debug(f"Payload essentials for '{node_id}': {log_payload_essentials}") if not squid_data_payload: if self.debug_mode: self.logger.warning(f"No data for remote squid {node_id}") return False try: if is_new_arrival: entry_x, entry_y, entry_dir = self.calculate_entry_position(squid_data_payload) if node_id in self.remote_squids: self._handle_re_arriving_squid(node_id, squid_data_payload, self.remote_squids[node_id], entry_x, entry_y, entry_dir) else: self._handle_new_squid_arrival(node_id, squid_data_payload, entry_x, entry_y, entry_dir) if node_id in self.remote_squids: self._create_arrival_animation(self.remote_squids[node_id]['visual']) if squid_data_payload.get('view_cone_visible', False): self.update_remote_view_cone(node_id, squid_data_payload) elif node_id in self.remote_squids: self._handle_existing_squid_update(node_id, squid_data_payload, self.remote_squids[node_id]) else: if self.debug_mode: self.logger.warning(f"Update for unknown {node_id} (not new arrival). Ignoring.") return False if node_id in self.remote_squids: self.remote_squids[node_id]['data'].update(squid_data_payload) self.remote_squids[node_id]['last_update'] = time.time() return True elif is_new_arrival and node_id not in self.remote_squids: # Should have been added if self.debug_mode: self.logger.error(f"New arrival {node_id} not added to remote_squids.") return False return False except Exception as e: self.logger.error(f"Error update_remote_squid for {node_id}: {e}", exc_info=True) if node_id in self.remote_squids and self.remote_squids[node_id].get('visual') and \ not self.remote_squids[node_id]['visual'].scene(): self.logger.info(f"Cleanup partially processed squid {node_id} after error.") self.remove_remote_squid(node_id) return False def calculate_entry_position(self, exit_data: dict) -> tuple[float, float, str]: original_exit_direction = exit_data.get('direction') original_exit_pos_x = exit_data.get('position', {}).get('x', 0) original_exit_pos_y = exit_data.get('position', {}).get('y', 0) squid_width_payload = int(exit_data.get('squid_width', 50)) squid_height_payload = int(exit_data.get('squid_height', 50)) current_window_width = self.window_width; current_window_height = self.window_height entry_x, entry_y = 0.0, 0.0; entry_direction_on_this_screen = "unknown" if original_exit_direction == 'right': entry_x = -squid_width_payload*0.8; entry_y = original_exit_pos_y; entry_direction_on_this_screen = "left" elif original_exit_direction == 'left': entry_x = current_window_width - squid_width_payload*0.2; entry_y = original_exit_pos_y; entry_direction_on_this_screen = "right" elif original_exit_direction == 'down': entry_y = -squid_height_payload*0.8; entry_x = original_exit_pos_x; entry_direction_on_this_screen = "top" elif original_exit_direction == 'up': entry_y = current_window_height - squid_height_payload*0.2; entry_x = original_exit_pos_x; entry_direction_on_this_screen = "bottom" else: entry_x = current_window_width/2 - squid_width_payload/2; entry_y = current_window_height/2 - squid_height_payload/2; entry_direction_on_this_screen = "center_fallback" if original_exit_direction in ['right','left']: entry_y = max(0, min(entry_y, current_window_height - squid_height_payload)) if original_exit_direction in ['up','down']: entry_x = max(0, min(entry_x, current_window_width - squid_width_payload)) node_id = exit_data.get('node_id') if node_id: self._last_calculated_entry_details[node_id] = {'entry_pos': (entry_x, entry_y), 'entry_direction': entry_direction_on_this_screen} return entry_x, entry_y, entry_direction_on_this_screen def get_last_calculated_entry_details(self, node_id: str) -> dict | None: return self._last_calculated_entry_details.get(node_id) def update_settings(self, opacity=None, show_labels=None, show_connections=None): if opacity is not None: self.remote_opacity = opacity current_target_opacity = self.remote_opacity for squid_data in self.remote_squids.values(): if squid_data.get('visual'): squid_data['visual'].setOpacity(current_target_opacity) if show_labels is not None: self.show_labels = show_labels for squid_data in self.remote_squids.values(): if squid_data.get('id_text'): squid_data['id_text'].setVisible(show_labels) if squid_data.get('status_text'): squid_data['status_text'].setVisible(show_labels) if show_connections is not None: self.show_connections = show_connections for line in self.connection_lines.values(): # Values, not items() for direct line objects if line.scene(): line.setVisible(show_connections) def update_remote_view_cone(self, node_id, squid_data): if node_id not in self.remote_squids: return remote_squid_info = self.remote_squids[node_id]; visual_item = remote_squid_info.get('visual') if not visual_item: return if remote_squid_info.get('view_cone') and remote_squid_info['view_cone'].scene(): self.scene.removeItem(remote_squid_info['view_cone']) remote_squid_info['view_cone'] = None if not squid_data.get('view_cone_visible', False): return squid_visual_pos = visual_item.pos() # Uses current visual position display_dims = remote_squid_info.get('current_display_dimensions') if not display_dims: pixmap = visual_item.pixmap(); display_dims = (pixmap.width(), pixmap.height()) if not pixmap.isNull() else self.DEFAULT_IMAGE_DIMENSION current_w, current_h = display_dims; item_scale = visual_item.scale() squid_center_x = squid_visual_pos.x()+(current_w/2*item_scale); squid_center_y = squid_visual_pos.y()+(current_h/2*item_scale) looking_direction_rad=squid_data.get('looking_direction',0.0); view_cone_angle_rad=squid_data.get('view_cone_angle',math.radians(50)) cone_length=squid_data.get('view_cone_length',150); cone_half_angle=view_cone_angle_rad/2.0 p1=QtCore.QPointF(squid_center_x,squid_center_y) p2=QtCore.QPointF(squid_center_x+cone_length*math.cos(looking_direction_rad-cone_half_angle),squid_center_y+cone_length*math.sin(looking_direction_rad-cone_half_angle)) p3=QtCore.QPointF(squid_center_x+cone_length*math.cos(looking_direction_rad+cone_half_angle),squid_center_y+cone_length*math.sin(looking_direction_rad+cone_half_angle)) cone_poly=QtGui.QPolygonF([p1,p2,p3]); cone_item=QtWidgets.QGraphicsPolygonItem(cone_poly) color_tuple=squid_data.get('color',(150,150,255)); q_color=QtGui.QColor(*color_tuple) if isinstance(color_tuple,tuple) else QtGui.QColor(150,150,255) cone_item.setPen(QtGui.QPen(QtGui.QColor(q_color.red(),q_color.green(),q_color.blue(),0))) cone_item.setBrush(QtGui.QBrush(QtGui.QColor(q_color.red(),q_color.green(),q_color.blue(),25))) cone_item.setZValue(visual_item.zValue()-1); self.scene.addItem(cone_item); remote_squid_info['view_cone']=cone_item def _create_arrival_animation(self, visual_item): if hasattr(visual_item, 'setOpacity'): visual_item.setOpacity(self.remote_opacity) if hasattr(visual_item, 'setScale'): visual_item.setScale(1.0) def _reset_remote_squid_style(self, visual_item_or_node_id): # Full method node_id=None; squid_display_data=None if isinstance(visual_item_or_node_id,str): node_id=visual_item_or_node_id; squid_display_data=self.remote_squids.get(node_id) elif isinstance(visual_item_or_node_id,QtWidgets.QGraphicsPixmapItem): for nid,s_data in self.remote_squids.items(): if s_data.get('visual')==visual_item_or_node_id: node_id=nid; squid_display_data=s_data; break if not squid_display_data: return visual_item=squid_display_data.get('visual'); status_text_item=squid_display_data.get('status_text') if visual_item: visual_item.setZValue(5); visual_item.setOpacity(self.remote_opacity); visual_item.setScale(1.0); visual_item.setGraphicsEffect(None) if status_text_item: current_status=squid_display_data.get('data',{}).get('status','visiting').upper() if squid_display_data.get('was_arrival_text',False) and current_status not in ["ENTERING...","ARRIVING...",squid_display_data.get('data',{}).get('status','visiting').upper()]: status_text_item.setDefaultTextColor(QtGui.QColor(200,200,200,230)); status_text_item.setFont(QtGui.QFont("Arial",10,QtGui.QFont.Normal)) status_text_item.setPlainText(squid_display_data.get('data',{}).get('status','visiting')); squid_display_data['was_arrival_text']=False status_text_item.setZValue(visual_item.zValue()+1 if visual_item else 6) def remove_remote_squid(self, node_id): # Full method if node_id not in self.remote_squids: return squid_data=self.remote_squids.pop(node_id) for key in ['visual','view_cone','id_text','status_text']: item=squid_data.get(key) if item and item.scene():item.scene().removeItem(item) if key in ['id_text','status_text'] and hasattr(self,'text_pool') and item in self.text_pool.in_use:self.text_pool.release(item) if node_id in self.connection_lines: line=self.connection_lines.pop(node_id) if line.scene():line.scene().removeItem(line) if self.debug_mode:self.logger.info(f"Removed remote squid {node_id}.") def cleanup_stale_entities(self, timeout=20.0): # Full method now=time.time() stale_squids=[nid for nid,data in self.remote_squids.items() if now-data.get('last_update',0)>timeout] for nid in stale_squids:self.remove_remote_squid(nid) stale_objs=[oid for oid,data in self.remote_objects.items() if now-data.get('last_update',0)>timeout] for oid in stale_objs:self.remove_remote_object(oid) if stale_squids or stale_objs and self.debug_mode:self.logger.debug(f"Stale cleanup: Removed {len(stale_squids)} squids, {len(stale_objs)} objects.") return len(stale_squids),len(stale_objs) def remove_remote_object(self, obj_id): # Full method if obj_id not in self.remote_objects:return obj_data=self.remote_objects.pop(obj_id) visual=obj_data.get('visual') if visual and visual.scene():visual.scene().removeItem(visual) if self.debug_mode:self.logger.debug(f"Removed remote object {obj_id}") def cleanup_all(self): # Full method for nid in list(self.remote_squids.keys()):self.remove_remote_squid(nid) for oid in list(self.remote_objects.keys()):self.remove_remote_object(oid) for line_id in list(self.connection_lines.keys()): line=self.connection_lines.pop(line_id) if line.scene():self.scene().removeItem(line) if hasattr(self,'text_pool'):self.text_pool.clear() if self.debug_mode:self.logger.info("RemoteEntityManager: All entities cleaned up.") def update_connection_lines(self, local_squid_pos_tuple): # Full method if not self.show_connections: for node_id,line in list(self.connection_lines.items()): if line.scene():self.scene.removeItem(line); del self.connection_lines[node_id] return if not local_squid_pos_tuple or len(local_squid_pos_tuple)!=2:return lx,ly=local_squid_pos_tuple; active_nodes=set() for node_id,squid_info in self.remote_squids.items(): visual=squid_info.get('visual') if not visual or not visual.isVisible() or not visual.scene():continue active_nodes.add(node_id); r_pos=visual.pos() dims=squid_info.get('current_display_dimensions') if not dims:pixmap=visual.pixmap();dims=(pixmap.width(),pixmap.height()) if not pixmap.isNull() else self.DEFAULT_IMAGE_DIMENSION w,h=dims; scale=visual.scale() rx=r_pos.x()+(w/2*scale); ry=r_pos.y()+(h/2*scale) color_tuple=squid_info.get('data',{}).get('color',(100,100,255)); q_color=QtGui.QColor(*color_tuple) if isinstance(color_tuple,tuple) else QtGui.QColor(100,100,255) if node_id in self.connection_lines: line=self.connection_lines[node_id] if not line.scene():self.scene.addItem(line) line.setLine(lx,ly,rx,ry);pen=line.pen();pen.setColor(QtGui.QColor(q_color.red(),q_color.green(),q_color.blue(),100));line.setPen(pen);line.setVisible(True) else: line=QtWidgets.QGraphicsLineItem(lx,ly,rx,ry) pen=QtGui.QPen(QtGui.QColor(q_color.red(),q_color.green(),q_color.blue(),100));pen.setWidth(1);pen.setStyle(QtCore.Qt.SolidLine) line.setPen(pen);line.setZValue(-10);line.setVisible(True);self.scene.addItem(line);self.connection_lines[node_id]=line for node_id in list(self.connection_lines.keys()): if node_id not in active_nodes: if self.connection_lines[node_id].scene():self.scene.removeItem(self.connection_lines[node_id]);del self.connection_lines[node_id] ================================================ FILE: plugins/multiplayer/squid_multiplayer_autopilot.py ================================================ import random import math import sys import time import os import threading from PyQt5 import QtCore, QtGui, QtWidgets class RemoteSquidController: """Controls behavior of squids away from their home instance""" def __init__(self, squid_data, scene, plugin_instance=None, debug_mode=False, remote_entity_manager=None): self.squid_data = squid_data.copy() # Ensure it's a copy self.scene = scene self.plugin_instance = plugin_instance self.debug_mode = debug_mode self.remote_entity_manager = remote_entity_manager # Store node_id for convenience and consistent use self.node_id = self.squid_data.get('node_id', 'UnknownRemoteNode') self.short_node_id = self.node_id[-4:] # For concise console logs if needed # Unique log file per remote squid instance self.log_file_name = f"autopilot_decisions_remote_{self.node_id}.txt" # Clear/initialize the log file for this session if self.debug_mode: try: with open(self.log_file_name, 'w', encoding='utf-8') as f: f.write(f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] Log for remote squid {self.node_id} (controller instance) started.\n") except Exception as e: print(f"[AutoPilotSetup] Error clearing/creating log file {self.log_file_name}: {e}") self.entry_time = squid_data.get('entry_time', time.time()) self.entry_position = squid_data.get('entry_position', None) # Tuple (x,y) if provided self.window_width = squid_data.get('window_width', 1280) # Provided by host context self.window_height = squid_data.get('window_height', 900) # Provided by host context entry_dir_on_this_screen = squid_data.get('entry_direction_on_this_screen') if entry_dir_on_this_screen: opposite_map = { 'left': 'right', 'right': 'left', 'up': 'down', 'down': 'up', 'top': 'down', 'bottom': 'up', # Aliases for clarity 'center_fallback': random.choice(['left', 'right', 'up', 'down']) # Should not happen if entry_dir valid } self.home_direction = opposite_map.get(entry_dir_on_this_screen.lower(), random.choice(['left', 'right', 'up', 'down'])) else: # Fallback if entry_direction_on_this_screen is not provided in squid_data # This will be called again in return_home if still None, using current position. self.home_direction = squid_data.get('home_direction') # Use if provided directly if not self.home_direction: # If still None, defer final determination to when return_home is called. # For __init__, it's okay if it's None here, as determine_home_direction() will be called. self._log_decision(f"__init__: home_direction not determined yet (entry_direction_on_this_screen missing). Will determine later.") self.state = "exploring" self.squid_data['status'] = "exploring" # Autopilot sets its own status self.target_object = None self.time_away = 0 self.max_time_away = random.randint(60, 180) # e.g. 1-3 minutes self.food_eaten_count = 0 self.rock_interaction_count = 0 # Counts interactions with any stealable item self.distance_traveled = 0 self.rocks_stolen = 0 # Will reflect len(self.carried_items_data) self.max_rocks_to_steal = random.randint(1, 3) # Max items the squid will try to carry self.stealing_phase = False # True when actively trying to "steal" (interaction part) self.carried_items_data = [] # Stores detailed data of items being "physically" carried self.move_speed = 4.5 self.direction_change_prob = 0.15 self.next_decision_time = 0 self.decision_interval = 0.5 # Time between major decision evaluations in seconds self.last_update_time = time.time() # Initial console print for immediate feedback (uses short_node_id) print(f"[AutoPilot __init__ {self.short_node_id}] Initialized. State: {self.state}, Status: {self.squid_data['status']}") print(f"[AutoPilot __init__ {self.short_node_id}] Max Time: {self.max_time_away}s. Max Carry: {self.max_rocks_to_steal}. Home Dir: {self.home_direction if self.home_direction else 'TBD'}") # Initial log to the dedicated file self._log_decision(f"Controller Initialized. Start State: {self.state}, Start Status: {self.squid_data['status']}, Max Time: {self.max_time_away:.1f}s, Max Carry: {self.max_rocks_to_steal}, Home Dir: {self.home_direction if self.home_direction else 'To be determined'}, Speed: {self.move_speed}, DirChangeProb: {self.direction_change_prob}") def _log_decision(self, decision_text: str): if not self.debug_mode: return timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) log_entry = f"[{timestamp}] [SquidID: {self.node_id}] {decision_text}\n" try: with open(self.log_file_name, 'a', encoding='utf-8') as f: f.write(log_entry) except Exception as e: print(f"[AutoPilotDecisionFileError] Could not write to {self.log_file_name} for SquidID {self.node_id}: {e}") print(f"[AutoPilotDecisionFallbackLog] {log_entry.strip()}") def _capture_item_properties(self, game_item_object) -> dict | None: if not game_item_object or not self.is_object_valid(game_item_object): self._log_decision(f"CaptureItemAttempt: FAILED - Target item is invalid or None ('{getattr(game_item_object, 'filename', game_item_object)}').") return None item_pos = self.get_object_position(game_item_object) item_filename = getattr(game_item_object, 'filename', 'unknown_item.png') item_category = getattr(game_item_object, 'category', 'unknown') item_scale = game_item_object.scale() if hasattr(game_item_object, 'scale') else 1.0 item_z_value = game_item_object.zValue() if hasattr(game_item_object, 'zValue') else 0.0 # item_rotation = game_item_object.rotation() if hasattr(game_item_object, 'rotation') else 0.0 properties = { 'original_filename': item_filename, 'original_category': item_category, 'original_x': item_pos[0], 'original_y': item_pos[1], 'scale': item_scale, 'zValue': item_z_value, # 'rotation': item_rotation, } base_name = os.path.basename(item_filename) if isinstance(item_filename, str) else "unknown_item_name" self._log_decision(f"CaptureItemSuccess: Captured properties for '{base_name}': Scale {properties['scale']:.2f}, Category '{properties['original_category']}'.") return properties def update(self, delta_time=None): current_time_autopilot = time.time() if delta_time is None: delta_time = current_time_autopilot - self.last_update_time self.last_update_time = current_time_autopilot # Ensure delta_time is non-negative and reasonable delta_time = max(0, delta_time) if delta_time > 1.0: # Cap delta_time to prevent huge jumps if there was a long pause self._log_decision(f"Warning: Large delta_time detected: {delta_time:.2f}s. Capping to 1.0s for this update.") delta_time = 1.0 self.time_away += delta_time self._log_decision(f"Update Cycle Begin. State='{self.state}', Status='{self.squid_data.get('status', 'N/A')}', TimeAway={self.time_away:.1f}/{self.max_time_away:.1f}s, NextDecisionAt={self.next_decision_time:.3f}, DeltaT={delta_time:.3f}") if current_time_autopilot < self.next_decision_time: self.move_in_direction(self.squid_data['direction']) if self.remote_entity_manager: self.remote_entity_manager.update_remote_squid(self.node_id, self.squid_data, is_new_arrival=False) self._log_decision(f"Update Cycle: Holding decision. Moving {self.squid_data['direction']}. Pos: ({self.squid_data['x']:.1f}, {self.squid_data['y']:.1f})") return self._log_decision(f"Update Cycle: Making new decision. Old state: '{self.state}', Old status: '{self.squid_data.get('status', 'N/A')}'") self.next_decision_time = current_time_autopilot + self.decision_interval if self.time_away > self.max_time_away and self.state != "returning" and self.state != "exited": old_state_before_timeout = self.state self.state = "returning" self.squid_data['status'] = "returning home (timeout)" self._log_decision(f"Update Cycle: Max time away ({self.max_time_away:.1f}s) EXCEEDED. Forcing state change: {old_state_before_timeout} -> {self.state}.") # State machine if self.state == "exploring": self.explore() elif self.state == "feeding": self.seek_food() elif self.state == "interacting": self.interact_with_object() elif self.state == "returning": self.return_home() elif self.state == "exited": self._log_decision("Update Cycle: State is 'exited'. No further action.") return # After state logic, update visuals if not exited if self.remote_entity_manager and self.state != "exited": self.remote_entity_manager.update_remote_squid(self.node_id, self.squid_data, is_new_arrival=False) self._log_decision(f"Update Cycle End. New State='{self.state}', New Status='{self.squid_data.get('status', 'N/A')}'") def explore(self): self._log_decision(f"Explore State: Current direction: {self.squid_data.get('direction')}. Time away: {self.time_away:.1f}s.") if self.squid_data.get('status') != "exploring": # Ensure status matches state self.squid_data['status'] = "exploring" self._log_decision(f"Explore: (Status corrected to 'exploring')") # This check is now also in update(), but keeping it here as a safeguard for explore's logic if self.time_away > self.max_time_away: old_state = self.state self.state = "returning" self.squid_data['status'] = "returning home (explore timeout)" self._log_decision(f"Explore: Max time away ({self.max_time_away:.1f}s) reached. State change: {old_state} -> {self.state}.") return if random.random() < self.direction_change_prob: old_direction = self.squid_data.get('direction', 'N/A') new_direction = random.choice(['left', 'right', 'up', 'down']) if new_direction == old_direction: # Try to pick a different one choices = list(set(['left', 'right', 'up', 'down']) - {old_direction}) new_direction = random.choice(choices) if choices else new_direction self.squid_data['direction'] = new_direction self._log_decision(f"Explore: Random direction change {old_direction} -> {new_direction} (Prob: {self.direction_change_prob:.2f}).") else: self._log_decision(f"Explore: No random direction change (Prob: {self.direction_change_prob:.2f}). Sticking to {self.squid_data.get('direction')}.") self.move_in_direction(self.squid_data.get('direction', 'right')) # Move first food_check_prob = 0.20 steal_check_prob = 0.30 if random.random() < food_check_prob: food = self.find_nearby_food() # This logs internally now if food: self.target_object = food old_state = self.state self.state = "feeding" self.squid_data['status'] = "heading to food" target_name = os.path.basename(getattr(self.target_object, 'filename', 'UnknownFood')) self._log_decision(f"Explore: Spotted food '{target_name}'. State change: {old_state} -> {self.state}.") return # else: find_nearby_food logs if nothing found if self.state == "exploring" and random.random() < steal_check_prob: if len(self.carried_items_data) < self.max_rocks_to_steal: stealable_item = self.find_nearby_stealable_item() # This logs internally now if stealable_item: self.target_object = stealable_item old_state = self.state self.state = "interacting" self.squid_data['status'] = "checking item" item_type = getattr(self.target_object, 'category', 'item') item_name = os.path.basename(getattr(self.target_object, 'filename', f'Unknown{item_type.capitalize()}')) self._log_decision(f"Explore: Spotted stealable {item_type} '{item_name}'. State change: {old_state} -> {self.state}.") return else: # Log if wanted to steal but couldn't due to carry limit if len(self.carried_items_data) >= self.max_rocks_to_steal: self._log_decision(f"Explore: Considered stealing item, but already carrying max ({len(self.carried_items_data)}/{self.max_rocks_to_steal}).") if self.state == "exploring": # If no other action taken self._log_decision(f"Explore: No new targets. Continuing exploration in direction {self.squid_data.get('direction')}.") def seek_food(self): if not self.target_object or not self.is_object_valid(self.target_object): old_target_name = os.path.basename(getattr(self.target_object, 'filename', 'previous food target')) old_state = self.state self.state = "exploring" self.squid_data['status'] = "exploring" self._log_decision(f"SeekFood: Lost or invalid food target '{old_target_name}'. State change: {old_state} -> {self.state}.") self.target_object = None return if self.squid_data.get('status') != "heading to food": self.squid_data['status'] = "heading to food" self._log_decision(f"SeekFood: (Status corrected to 'heading to food')") target_pos = self.get_object_position(self.target_object) self.move_toward(target_pos[0], target_pos[1]) squid_pos = (self.squid_data['x'], self.squid_data['y']) distance = self.distance_between(squid_pos, target_pos) food_name = os.path.basename(getattr(self.target_object, 'filename', 'UnknownFood')) self._log_decision(f"SeekFood: Moving towards '{food_name}' at ({target_pos[0]:.1f}, {target_pos[1]:.1f}). Distance: {distance:.1f}.") if distance < 50: self.eat_food(self.target_object) # This method already logs "Action: Eating food" self.food_eaten_count += 1 old_state = self.state self.state = "exploring" self.squid_data['status'] = "exploring" # Reset status after eating self._log_decision(f"SeekFood: Successfully ate food '{food_name}'. Food count: {self.food_eaten_count}. State change: {old_state} -> {self.state}.") self.target_object = None def interact_with_object(self): if not self.target_object or not self.is_object_valid(self.target_object): old_target_name = os.path.basename(getattr(self.target_object, 'filename', 'previous item target')) old_state = self.state self.state = "exploring" self.squid_data['status'] = "exploring" self._log_decision(f"Interact: Lost or invalid item target '{old_target_name}'. State change: {old_state} -> {self.state}.") self.target_object = None self.stealing_phase = False return current_status = self.squid_data.get('status', '') if current_status != "checking item" and not self.stealing_phase: # Only set if not already in a carrying/stealing related status self.squid_data['status'] = "checking item" self._log_decision(f"Interact: (Status set to 'checking item')") target_pos = self.get_object_position(self.target_object) self.move_toward(target_pos[0], target_pos[1]) squid_pos = (self.squid_data['x'], self.squid_data['y']) distance = self.distance_between(squid_pos, target_pos) item_name_for_log = os.path.basename(getattr(self.target_object, 'filename', 'UnknownItem')) self._log_decision(f"Interact: Moving towards '{item_name_for_log}' at ({target_pos[0]:.1f}, {target_pos[1]:.1f}). Distance: {distance:.1f}.") if distance < 50: self.rock_interaction_count += 1 attempt_steal_chance = 0.4 if (self.is_stealable_target(self.target_object) and len(self.carried_items_data) < self.max_rocks_to_steal and random.random() < attempt_steal_chance): item_data_to_carry = self._capture_item_properties(self.target_object) if item_data_to_carry: self.carried_items_data.append(item_data_to_carry) self.rocks_stolen = len(self.carried_items_data) self.squid_data['carrying_rock'] = True self.stealing_phase = True item_type_stolen = item_data_to_carry.get('original_category', 'item') item_name_stolen = os.path.basename(item_data_to_carry.get('original_filename', f'UnknownItem')) self.squid_data['status'] = f"carrying {item_type_stolen.lower()}" self._log_decision(f"Interact: SUCCEEDED steal of {item_type_stolen} '{item_name_stolen}'. Status: {self.squid_data['status']}. Carrying {self.rocks_stolen}/{self.max_rocks_to_steal}.") if self.remote_entity_manager and hasattr(self.remote_entity_manager, 'hide_item_temporarily'): self.remote_entity_manager.hide_item_temporarily(self.target_object) if self.rocks_stolen >= self.max_rocks_to_steal: old_state = self.state self.state = "returning" self.squid_data['status'] = "returning home" # Set status for returning self._log_decision(f"Interact: Met carrying quota ({self.rocks_stolen}/{self.max_rocks_to_steal}). State change: {old_state} -> {self.state}.") self.target_object = None self.stealing_phase = False # Done with stealing for now return else: self._log_decision(f"Interact: Attempted steal but FAILED to capture properties for target '{item_name_for_log}'.") else: reason = "" if not self.is_stealable_target(self.target_object): reason = "target not stealable type" elif len(self.carried_items_data) >= self.max_rocks_to_steal: reason = "carrying quota met" else: reason = f"failed {attempt_steal_chance*100:.0f}% steal chance" self._log_decision(f"Interact: Interacted with '{item_name_for_log}'. Did not steal (Reason: {reason}). Total interactions: {self.rock_interaction_count}.") old_state = self.state self.target_object = None self.state = "exploring" self.squid_data['status'] = "exploring" # Reset status self.stealing_phase = False # Reset stealing phase self._log_decision(f"Interact: Interaction logic complete for '{item_name_for_log}'. State change: {old_state} -> {self.state}.") def return_home(self): if self.squid_data.get('status') != "returning home" and not self.squid_data.get('status', '').startswith("returning home"): # Check variants self.squid_data['status'] = "returning home" self._log_decision(f"ReturnHome: (Status set to 'returning home')") if not self.home_direction: # Should have been set by __init__ or explore timeout self.determine_home_direction() # Recalculate if somehow lost self._log_decision(f"ReturnHome: home_direction was None, re-determined: {self.home_direction}.") self.move_in_direction(self.home_direction) # This method now logs boundary hits and turns self._log_decision(f"ReturnHome: Moving towards {self.home_direction}. Position: ({self.squid_data['x']:.1f}, {self.squid_data['y']:.1f}).") if self.is_at_boundary(self.home_direction): summary = self.get_summary() self._log_decision(f"ReturnHome: Reached home boundary ({self.home_direction}). Exiting. Summary: Ate {summary['food_eaten']}, Interacted {summary['rock_interactions']}, Stole {summary['rocks_stolen']} items.") if self.plugin_instance and hasattr(self.plugin_instance, 'handle_remote_squid_return'): self.plugin_instance.handle_remote_squid_return(self.node_id, self) else: self._log_decision(f"ReturnHome: CRITICAL - plugin_instance or handle_remote_squid_return method missing for {self.node_id}.") self.state = "exited" self.squid_data['status'] = "exited" # Final status for this controller instance self._log_decision(f"ReturnHome: State set to 'exited'.") def move_in_direction(self, direction): speed = self.move_speed prev_x, prev_y = self.squid_data['x'], self.squid_data['y'] squid_width = self.squid_data.get('squid_width', 50) squid_height = self.squid_data.get('squid_height', 50) win_width = self.get_window_width() win_height = self.get_window_height() new_x, new_y = self.squid_data['x'], self.squid_data['y'] original_direction = str(direction) # Keep a copy for logging current_effective_direction = str(direction) # What direction it will actually end up going if current_effective_direction == 'left': new_x -= speed elif current_effective_direction == 'right': new_x += speed elif current_effective_direction == 'up': new_y -= speed elif current_effective_direction == 'down': new_y += speed boundary_hit_log_message = "" # Horizontal boundary check if new_x <= 0: new_x = 0 current_effective_direction = 'right' boundary_hit_log_message = f"Hit left boundary (was going {original_direction}), turning right." elif new_x + squid_width >= win_width: new_x = win_width - squid_width current_effective_direction = 'left' boundary_hit_log_message = f"Hit right boundary (was going {original_direction}), turning left." # Vertical boundary check (can override horizontal turn if cornered) if new_y <= 0: new_y = 0 # If it also hit a side, the horizontal turn takes precedence for the new 'direction' # but we still log the vertical hit. if not boundary_hit_log_message: current_effective_direction = 'down' # Only change if not already turning from side boundary_hit_log_message += (" " if boundary_hit_log_message else "") + f"Hit top boundary (was going {original_direction}), ensuring not moving further up." elif new_y + squid_height >= win_height: new_y = win_height - squid_height if not boundary_hit_log_message: current_effective_direction = 'up' boundary_hit_log_message += (" " if boundary_hit_log_message else "") + f"Hit bottom boundary (was going {original_direction}), ensuring not moving further down." if boundary_hit_log_message and boundary_hit_log_message != "No boundary hit.": # Log if a boundary was actually hit self._log_decision(f"Move: {boundary_hit_log_message} New effective direction: {current_effective_direction}. Pos: ({new_x:.1f},{new_y:.1f})") self.squid_data['x'] = new_x self.squid_data['y'] = new_y self.squid_data['direction'] = current_effective_direction if current_effective_direction in ['left', 'right', 'up', 'down']: self.squid_data['image_direction_key'] = current_effective_direction moved_dist = math.sqrt((self.squid_data['x'] - prev_x)**2 + (self.squid_data['y'] - prev_y)**2) self.distance_traveled += moved_dist def move_toward(self, target_x, target_y): current_x = self.squid_data['x'] + self.squid_data.get('squid_width', 50) / 2 current_y = self.squid_data['y'] + self.squid_data.get('squid_height', 50) / 2 dx, dy = target_x - current_x, target_y - current_y chosen_direction = self.squid_data.get('direction', 'right') if abs(dx) > self.move_speed / 2 or abs(dy) > self.move_speed / 2: if abs(dx) > abs(dy) * 1.2: # Prioritize horizontal if significantly greater chosen_direction = 'right' if dx > 0 else 'left' elif abs(dy) > abs(dx) * 1.2: # Prioritize vertical if significantly greater chosen_direction = 'down' if dy > 0 else 'up' else: # Diagonal-ish: pick dominant or maintain current if aligned if abs(dx) > abs(dy): chosen_direction = 'right' if dx > 0 else 'left' else: chosen_direction = 'down' if dy > 0 else 'up' if chosen_direction != self.squid_data.get('direction'): self._log_decision(f"MoveToward: Target ({target_x:.0f},{target_y:.0f}), Current ({current_x:.0f},{current_y:.0f}). Direction changed to {chosen_direction}.") self.move_in_direction(chosen_direction) def find_nearby_food(self): self._log_decision(f"FIND_NEARBY_FOOD: Entered method for {self.node_id}.") food_items = self.get_food_items_from_scene() if not food_items: self._log_decision(f"FIND_NEARBY_FOOD: No food items from get_food_items_from_scene for {self.node_id}.") return None self._log_decision(f"FIND_NEARBY_FOOD: Found {len(food_items)} potential food items for {self.node_id}.") squid_pos = (self.squid_data['x'] + self.squid_data.get('squid_width',0)/2, self.squid_data['y'] + self.squid_data.get('squid_height',0)/2) closest_food, min_dist = None, float('inf') for i, food in enumerate(food_items): self._log_decision(f"FIND_NEARBY_FOOD: Processing item {i} for {self.node_id}. Filename: {getattr(food, 'filename', 'N/A')}") food_center_pos = None center_x = 0.0 center_y = 0.0 try: if food and self.is_object_valid(food): self._log_decision(f"FIND_NEARBY_FOOD: Item {i} - Food item IS valid.") item_rect_local = food.boundingRect() item_pos_scene = food.pos() # Get QPointF pos_x_val = item_pos_scene.x() # Known to be reliable rect_center_obj = item_rect_local.center() rect_center_x_val = rect_center_obj.x() # Known to be reliable center_x = pos_x_val + rect_center_x_val pos_y_val = None # Prioritize reading the stored Python attribute if hasattr(food, 'has_current_y_for_autopilot') and food.has_current_y_for_autopilot: pos_y_val = food.current_y_for_autopilot self._log_decision(f"FIND_NEARBY_FOOD: Item {i} - Used food.current_y_for_autopilot: {pos_y_val}") else: self._log_decision(f"FIND_NEARBY_FOOD: Item {i} - food.current_y_for_autopilot not found/set. Attempting item_pos_scene.y()") if item_pos_scene is not None: try: pos_y_val = item_pos_scene.y() self._log_decision(f"FIND_NEARBY_FOOD: Item {i} - item_pos_scene.y() succeeded: {pos_y_val}") except Exception as e_pos_y: self._log_decision(f"FIND_NEARBY_FOOD: Item {i} - !!! EXCEPTION during item_pos_scene.y() !!! Type: {type(e_pos_y).__name__}, Msg: {str(e_pos_y)}. Using fallback 0.0.") pos_y_val = 0.0 # Fallback if direct access fails else: self._log_decision(f"FIND_NEARBY_FOOD: Item {i} - item_pos_scene was None. Using fallback 0.0 for pos_y_val.") pos_y_val = 0.0 rect_center_y_val = rect_center_obj.y() # Known to be reliable center_y = float(pos_y_val if pos_y_val is not None else 0.0) + \ float(rect_center_y_val if rect_center_y_val is not None else 0.0) food_center_pos = (center_x, center_y) self._log_decision(f"FIND_NEARBY_FOOD: Item {i} - Calculated center: {food_center_pos}") else: self._log_decision(f"FIND_NEARBY_FOOD: Item {i} - Food item NOT valid.") except Exception as e_outer_inline: self._log_decision(f"FIND_NEARBY_FOOD: Item {i} - *** Outer Exception for item {i}: {type(e_outer_inline).__name__}: {e_outer_inline} *** food_center_pos set to None.") food_center_pos = None # No 'raise' here to allow autopilot to continue if food_center_pos is None: self._log_decision(f"FIND_NEARBY_FOOD: Item {i} - food_center_pos is None after logic block. Skipping.") continue dist = self.distance_between(squid_pos, food_center_pos) self._log_decision(f"FIND_NEARBY_FOOD: Item {i} - Distance to food ({getattr(food, 'filename', 'N/A')} at {food_center_pos}): {dist:.1f}") if dist < min_dist: min_dist = dist closest_food = food detection_radius = 300 chosen_food = closest_food if closest_food and min_dist < detection_radius else None if chosen_food: self._log_decision(f"FindFood: Target acquired for {self.node_id}: {os.path.basename(getattr(chosen_food, 'filename', 'N/A'))} at distance {min_dist:.1f}.") elif food_items: min_dist_str = f"{min_dist:.1f}" if min_dist != float('inf') else "N/A" self._log_decision(f"FindFood: Food items detected for {self.node_id} ({len(food_items)}), but none close/suitable (min_dist: {min_dist_str}, detection_radius: {detection_radius}).") else: self._log_decision(f"FindFood: No food items found in scene for {self.node_id} (list was empty or all invalid).") return chosen_food def find_nearby_stealable_item(self): items = self.get_stealable_items_from_scene() # This now logs periodically if not items: return None squid_pos = (self.squid_data['x'] + self.squid_data.get('squid_width',0)/2, self.squid_data['y'] + self.squid_data.get('squid_height',0)/2) closest_item, min_dist = None, float('inf') for item_obj in items: # is_object_valid should have been called by get_stealable_items_from_scene implicitly item_center_pos = self.get_object_center_position(item_obj) if item_center_pos is None: continue dist = self.distance_between(squid_pos, item_center_pos) if dist < min_dist: min_dist = dist closest_item = item_obj detection_radius = 200 chosen_item = closest_item if closest_item and min_dist < detection_radius else None if chosen_item: self._log_decision(f"FindStealable: Target acquired: {os.path.basename(getattr(chosen_item, 'filename', 'N/A'))} at distance {min_dist:.1f}.") elif items: min_dist_str = f"{min_dist:.1f}" if min_dist != float('inf') else "N/A" self._log_decision(f"FindStealable: Stealable items detected ({len(items)}), but none close/suitable (min_dist: {min_dist_str}, detection_radius: {detection_radius}).") return chosen_item def get_food_items_from_scene(self): food_items = [] if not self.scene: if self.debug_mode: self._log_decision("get_food_items: Scene not available.") return food_items items_checked_count = 0 scene_items_list = list(self.scene.items()) for item in scene_items_list: items_checked_count += 1 try: if not self.is_object_valid(item): # Use the enhanced is_object_valid continue is_food = False item_category = str(getattr(item, 'category', '')).lower() item_filename = str(getattr(item, 'filename', '')).lower() if item_category == 'food': is_food = True elif any(ft_keyword in item_filename for ft_keyword in ['food', 'sushi', 'cheese']): is_food = True if is_food: food_items.append(item) except Exception as e: if self.debug_mode: self._log_decision(f"get_food_items: Error checking item - {type(item)}: {e}") if self.debug_mode and (random.random() < 0.05 or not food_items): log_food_names = [os.path.basename(getattr(f, 'filename', 'N/A')) for f in food_items] self._log_decision(f"get_food_items: Checked {items_checked_count} scene items. Found {len(food_items)} food: [{', '.join(log_food_names)}].") return food_items def get_stealable_items_from_scene(self): stealable_items = [] if not self.scene: if self.debug_mode: self._log_decision("get_stealable_items: Scene not available.") return stealable_items items_checked_count = 0 scene_items_list = list(self.scene.items()) for item_obj in scene_items_list: items_checked_count +=1 try: if not self.is_object_valid(item_obj): # Use the enhanced is_object_valid continue # Crucially, do not attempt to steal items that are already clones from other remote players # This check is now also part of is_stealable_target, but good to have consistency if getattr(item_obj, 'is_remote_clone', False): continue item_category = str(getattr(item_obj, 'category', '')).lower() item_filename = str(getattr(item_obj, 'filename', '')).lower() is_rock = item_category == 'rock' or ('rock' in item_filename) is_urchin = item_category == 'urchin' or ('urchin' in item_filename) if (is_rock or is_urchin): stealable_items.append(item_obj) except Exception as e: if self.debug_mode: self._log_decision(f"get_stealable_items: Error checking item - {type(item_obj)}: {e}") if self.debug_mode and (random.random() < 0.05 or not stealable_items): log_item_names = [os.path.basename(getattr(s, 'filename', 'N/A')) for s in stealable_items] self._log_decision(f"get_stealable_items: Checked {items_checked_count} scene items. Found {len(stealable_items)} stealable: [{', '.join(log_item_names)}].") return stealable_items def is_in_vision_range(self, item): if not item or not self.is_object_valid(item): return False squid_center_pos = (self.squid_data['x'] + self.squid_data.get('squid_width',0)/2, self.squid_data['y'] + self.squid_data.get('squid_height',0)/2) obj_center_pos = self.get_object_center_position(item) if obj_center_pos is None: return False return self.distance_between(squid_center_pos, obj_center_pos) < 800 # Generic large range def animate_movement(self, squid_data, remote_visual): if self.debug_mode: self._log_decision(f"Animate_movement called (currently advisory). Pos: ({squid_data.get('x',0):.1f}, {squid_data.get('y',0):.1f}) Dir: {squid_data.get('direction')}") def eat_food(self, food_item): food_name = os.path.basename(getattr(food_item, 'filename', 'UnknownFood')) self._log_decision(f"Action: Eating food '{food_name}'. Current hunger: {self.squid_data.get('hunger', 50):.1f}.") self.squid_data['hunger'] = max(0, self.squid_data.get('hunger', 50) - 25) # More significant hunger reduction self.squid_data['happiness'] = min(100, self.squid_data.get('happiness', 50) + 15) if self.remote_entity_manager and hasattr(self.remote_entity_manager, 'remove_item_from_scene'): self.remote_entity_manager.remove_item_from_scene(food_item) self._log_decision(f"Signaled RemoteEntityManager to remove eaten food '{food_name}'. New hunger: {self.squid_data['hunger']:.1f}") else: self._log_decision(f"EatFood: Could not signal for removal of food '{food_name}'.") def interact_with_rock(self, rock_item): # Legacy, use interact_with_object item_name = os.path.basename(getattr(rock_item, 'filename', 'UnknownItem')) self._log_decision(f"Action: Legacy interact_with_rock called for '{item_name}'.") self.squid_data['happiness'] = min(100, self.squid_data.get('happiness', 50) + 5) def is_stealable_target(self, item_obj): if not self.is_object_valid(item_obj): return False if getattr(item_obj, 'is_remote_clone', False): # Should not steal clones self._log_decision(f"is_stealable_target: Item '{getattr(item_obj, 'filename', 'N/A')}' is a remote clone. Cannot steal.") return False item_category = str(getattr(item_obj, 'category', '')).lower() item_filename = str(getattr(item_obj, 'filename', '')).lower() is_rock = item_category == 'rock' or ('rock' in item_filename) is_urchin = item_category == 'urchin' or ('urchin' in item_filename) # Example of another stealable # Add more conditions for stealable items if needed # e.g. is_plant = item_category == 'plant' or ('plant' in item_filename) can_steal = is_rock or is_urchin # or is_plant etc. if can_steal and self.debug_mode: self._log_decision(f"is_stealable_target: Item '{item_filename}' (cat: {item_category}) IS stealable.") elif not can_steal and self.debug_mode: self._log_decision(f"is_stealable_target: Item '{item_filename}' (cat: {item_category}) is NOT stealable.") return can_steal def is_object_valid(self, obj): if obj is None: if self.debug_mode: self._log_decision("is_object_valid: FAILED - Object is None.") return False # Ensure obj is a QGraphicsItem before calling QGraphicsItem-specific methods if not isinstance(obj, QtWidgets.QGraphicsItem): if self.debug_mode: self._log_decision(f"is_object_valid: FAILED - Object '{type(obj)}' is not a QGraphicsItem.") return False has_scene_attr = hasattr(obj, 'scene') obj_scene_instance = None if has_scene_attr and callable(obj.scene): try: obj_scene_instance = obj.scene() # Call with no arguments as per PyQt5 except TypeError as e: # Catch if called incorrectly (e.g. if API differs unexpectedly) if self.debug_mode: self._log_decision(f"is_object_valid: FAILED - TypeError calling obj.scene() for {getattr(obj, 'filename', type(obj))}: {e}") return False except Exception as e_scene: # Catch other potential errors during scene() call if self.debug_mode: self._log_decision(f"is_object_valid: FAILED - Exception calling obj.scene() for {getattr(obj, 'filename', type(obj))}: {e_scene}") return False else: # If no scene attribute or not callable if self.debug_mode: self._log_decision(f"is_object_valid: FAILED - Object {getattr(obj, 'filename', type(obj))} has no callable 'scene' attribute.") # Depending on logic, this might be an invalid object, or an object not yet added to a scene. # For now, if it's supposed to be in *our* scene, this is invalid. return False is_in_correct_scene = obj_scene_instance is self.scene # Direct comparison with the controller's scene is_visible_attr = hasattr(obj, 'isVisible') is_currently_visible = obj.isVisible() if is_visible_attr and callable(obj.isVisible) else True valid = is_in_correct_scene and is_currently_visible if not valid and self.debug_mode: filename_info = getattr(obj, 'filename', str(type(obj))) reasons = [] if not is_in_correct_scene: reason_scene = "Scene mismatch/None" if obj_scene_instance: # If we got a scene instance but it's not self.scene reason_scene += f" (ItemSceneID: {id(obj_scene_instance)}, AutopilotSceneID: {id(self.scene)})" elif has_scene_attr and callable(obj.scene): # If scene() was callable but returned None reason_scene += f" (Item's scene() returned None, AutopilotSceneID: {id(self.scene) if self.scene else 'None'})" else: # If obj.scene wasn't even callable or present reason_scene += f" (Item has no valid scene, AutopilotSceneID: {id(self.scene) if self.scene else 'None'})" reasons.append(reason_scene) if not is_currently_visible: reasons.append("Not visible") self._log_decision(f"is_object_valid: Item '{filename_info}' FAILED validation. Reasons: {'; '.join(reasons) if reasons else 'Unknown (Logic Error in Validation)'}. Object Valid: {valid}") return valid def get_object_position(self, obj): # Gets top-left if obj and hasattr(obj, 'pos') and callable(obj.pos): pos_qpointf = obj.pos() return (pos_qpointf.x(), pos_qpointf.y()) if self.debug_mode: self._log_decision(f"Warning: get_object_position failed for object: {getattr(obj, 'filename', type(obj))}") return (self.squid_data.get('x',0), self.squid_data.get('y',0)) # Fallback def get_object_center_position(self, obj): if obj and self.is_object_valid(obj): # Ensure it's valid before getting rect try: # QGraphicsPixmapItem.boundingRect() is in item's local coordinates. # We need its sceneBoundingRect for global center, or combine pos() with boundingRect().center() item_rect_local = obj.boundingRect() # Local bounds item_pos_scene = obj.pos() # Top-left in scene center_x = item_pos_scene.x() + item_rect_local.center().x() center_y = item_pos_scene.y() + item_rect_local.center().y() return (center_x, center_y) except Exception as e: if self.debug_mode: self._log_decision(f"Error getting center for {getattr(obj, 'filename', type(obj))}: {e}") return None def distance_between(self, pos1, pos2): try: return math.sqrt((pos1[0] - pos2[0])**2 + (pos1[1] - pos2[1])**2) except TypeError: # If pos1 or pos2 is None or not subscriptable self._log_decision(f"Error in distance_between: Invalid input. Pos1: {pos1}, Pos2: {pos2}") return float('inf') def get_window_width(self): if self.remote_entity_manager and hasattr(self.remote_entity_manager, 'window_width'): return self.remote_entity_manager.window_width elif self.scene and hasattr(self.scene, 'sceneRect') and self.scene.sceneRect(): return self.scene.sceneRect().width() return self.window_width # Fallback to initial value def get_window_height(self): if self.remote_entity_manager and hasattr(self.remote_entity_manager, 'window_height'): return self.remote_entity_manager.window_height elif self.scene and hasattr(self.scene, 'sceneRect') and self.scene.sceneRect(): return self.scene.sceneRect().height() return self.window_height # Fallback to initial value def is_at_boundary(self, direction_moving_towards: str): x, y = self.squid_data['x'], self.squid_data['y'] squid_w = self.squid_data.get('squid_width', 50) squid_h = self.squid_data.get('squid_height', 50) # Threshold for being "at" the boundary to trigger exit # Should be small enough that it's definitely at edge, but not so small it overshoots. boundary_exit_threshold = self.move_speed * 1.5 # Approx 1.5 move steps from edge win_width = self.get_window_width() win_height = self.get_window_height() if direction_moving_towards == 'left': return x <= boundary_exit_threshold elif direction_moving_towards == 'right': return x + squid_w >= win_width - boundary_exit_threshold elif direction_moving_towards == 'up': return y <= boundary_exit_threshold elif direction_moving_towards == 'down': return y + squid_h >= win_height - boundary_exit_threshold return False def determine_home_direction(self): # This method determines the "exit" direction from the current client's perspective # to get "home" (back to its original instance). entry_dir_on_this_screen = self.squid_data.get('entry_direction_on_this_screen') opposite_map = {'left': 'right', 'right': 'left', 'up': 'down', 'down': 'up', 'top': 'down', 'bottom': 'up'} if entry_dir_on_this_screen and entry_dir_on_this_screen.lower() in opposite_map: self.home_direction = opposite_map[entry_dir_on_this_screen.lower()] self._log_decision(f"DetermineHomeDir: Determined home direction '{self.home_direction}' as opposite of entry_direction '{entry_dir_on_this_screen}'.") else: # Fallback: if entry direction was unclear, choose the closest edge as the exit. # This is less ideal as it might not be the true "opposite" of how it entered. x, y = self.squid_data.get('x', self.get_window_width()/2), self.squid_data.get('y', self.get_window_height()/2) width, height = self.get_window_width(), self.get_window_height() distances_to_edge = { 'left': x, 'right': width - (x + self.squid_data.get('squid_width', 50)), 'up': y, 'down': height - (y + self.squid_data.get('squid_height', 50)) } # Choose the edge it is currently closest to as its "home" direction. self.home_direction = min(distances_to_edge, key=distances_to_edge.get) self._log_decision(f"DetermineHomeDir: Fallback - entry_direction unclear. Closest edge chosen as home_direction: '{self.home_direction}'. Distances: {distances_to_edge}") def get_summary(self): actual_items_carried_count = len(self.carried_items_data) if self.rocks_stolen != actual_items_carried_count: # Ensure consistency self._log_decision(f"Summary: Correcting 'rocks_stolen' from {self.rocks_stolen} to actual carried count {actual_items_carried_count}.") self.rocks_stolen = actual_items_carried_count summary_data = { 'time_away': round(self.time_away, 2), 'food_eaten': self.food_eaten_count, 'rock_interactions': self.rock_interaction_count, # Total interactions with stealable types 'rocks_stolen': self.rocks_stolen, # Count of items successfully "stolen" and data captured 'carried_items_details': list(self.carried_items_data), # Ensure it's a list copy 'distance_traveled': round(self.distance_traveled, 2), 'final_state_on_this_client': self.state, 'node_id': self.node_id # ID of the squid this controller is for } self._log_decision(f"GetSummary: Generating summary - Food: {summary_data['food_eaten']}, Interactions: {summary_data['rock_interactions']}, Stolen: {summary_data['rocks_stolen']}, Items: {len(summary_data['carried_items_details'])}") return summary_data ================================================ FILE: plugins/multiplayer/status_bar_component.py ================================================ from PyQt5 import QtCore, QtGui, QtWidgets class StatusBarComponent: def __init__(self, main_window): self.main_window = main_window # Create the status bar if it doesn't exist if not main_window.statusBar(): self.status_bar = QtWidgets.QStatusBar(main_window) main_window.setStatusBar(self.status_bar) else: self.status_bar = main_window.statusBar() # Create status indicators self.create_indicators() # Message queue for rotating messages self.message_queue = [] self.message_timer = QtCore.QTimer() self.message_timer.timeout.connect(self.rotate_messages) self.message_timer.start(5000) # Rotate messages every 5 seconds def create_indicators(self): """Create permanent status indicators""" # Plugins indicator self.plugins_label = QtWidgets.QLabel("Plugins: None") self.plugins_label.setStyleSheet("padding: 0 10px;") self.status_bar.addPermanentWidget(self.plugins_label) # Network status indicator self.network_label = QtWidgets.QLabel("Network: Disconnected") self.network_label.setStyleSheet("padding: 0 10px;") self.status_bar.addPermanentWidget(self.network_label) # Peers indicator self.peers_label = QtWidgets.QLabel("Peers: 0") self.peers_label.setStyleSheet("padding: 0 10px;") self.status_bar.addPermanentWidget(self.peers_label) def update_plugins_status(self, plugin_manager): """Update the plugins status indicator""" if not plugin_manager: self.plugins_label.setText("Plugins: None") return enabled_plugins = plugin_manager.get_enabled_plugins() if not enabled_plugins: self.plugins_label.setText("Plugins: None") self.plugins_label.setStyleSheet("padding: 0 10px; color: gray;") else: plugin_count = len(enabled_plugins) self.plugins_label.setText(f"Plugins: {plugin_count} active") self.plugins_label.setStyleSheet("padding: 0 10px; color: green;") # Create tooltip with plugin names tooltip = "Active plugins:\n" + "\n".join(f"• {p}" for p in enabled_plugins) self.plugins_label.setToolTip(tooltip) def update_network_status(self, connected, node_id=None): """Update the network status indicator""" if connected: self.network_label.setText(f"Network: Connected") self.network_label.setStyleSheet("padding: 0 10px; color: green;") if node_id: self.network_label.setToolTip(f"Connected as {node_id}") else: self.network_label.setText("Network: Disconnected") self.network_label.setStyleSheet("padding: 0 10px; color: gray;") self.network_label.setToolTip("Network functionality is disconnected") def update_peers_count(self, count): """Update the peers count indicator""" self.peers_label.setText(f"Peers: {count}") if count > 0: self.peers_label.setStyleSheet("padding: 0 10px; color: green;") else: self.peers_label.setStyleSheet("padding: 0 10px; color: gray;") def add_message(self, message, duration=5000): """Add a temporary message to the status bar""" self.status_bar.showMessage(message, duration) def add_to_message_queue(self, message): """Add a message to the rotation queue""" if message not in self.message_queue: self.message_queue.append(message) def rotate_messages(self): """Rotate through queued messages""" if not self.message_queue: return # Show the next message message = self.message_queue.pop(0) self.status_bar.showMessage(message, 4500) # Show for slightly less than rotation time # Add the message back to the end of the queue self.message_queue.append(message) ================================================ FILE: plugins/readme.md ================================================ #### [Achievements](https://github.com/ViciousSquid/Dosidicus/wiki/Achievements) #### [Multiplayer](https://github.com/ViciousSquid/Dosidicus/wiki/Multiplayer) #### [STDP](https://github.com/ViciousSquid/Dosidicus/wiki/Spike%E2%80%90Timing%E2%80%90Dependent-Plasticity-(STDP)) ------------------------- #### [Plugin system overview](https://github.com/ViciousSquid/Dosidicus/wiki/Plugin-system) ================================================ FILE: plugins/stdp/__init__.py ================================================ # STDP Plugin Package ================================================ FILE: plugins/stdp/main.py ================================================ """ STDP Plugin for Dosidicus Augments the existing Hebbian learning system with Spike-Timing-Dependent Plasticity (STDP). Connections strengthen when the pre-synaptic neuron fires BEFORE the post-synaptic neuron (causal), and weaken when the order is reversed (acausal). This teaches the squid cause-and-effect rather than mere correlation. Integration method: monkey-patches BrainWorker._perform_hebbian_learning on the live instance, so it slots in without touching any core files. Reward signals are applied on feed / clean / medicine events via the standard hook system, using STDP eligibility traces as the third learning factor. """ import sys import os import time import logging import traceback from heapq import nlargest from collections import deque from typing import Optional, Dict # --------------------------------------------------------------------------- # Ensure the project root is importable (mirrors multiplayer plugin pattern) # --------------------------------------------------------------------------- try: _current_dir = os.path.dirname(os.path.abspath(__file__)) _project_root = os.path.abspath(os.path.join(_current_dir, '..', '..')) if _project_root not in sys.path: sys.path.insert(0, _project_root) except Exception as _e: print(f"STDP Plugin: sys.path setup warning: {_e}") from PyQt5 import QtCore, QtWidgets try: from display_scaling import DisplayScaling except ImportError: class DisplayScaling: @classmethod def font_size(cls, size): return size @classmethod def scale_css(cls, css): return css # Import STDP core from this package try: from .stdp_core import STDPLearner, STDPConfig except ImportError: from stdp_core import STDPLearner, STDPConfig # --------------------------------------------------------------------------- # Plugin metadata # --------------------------------------------------------------------------- PLUGIN_NAME = "STDP" PLUGIN_VERSION = "1.0.0" PLUGIN_AUTHOR = "ViciousSquid" PLUGIN_DESCRIPTION = "Spike-Timing-Dependent Plasticity – adds causal learning on top of Hebbian" PLUGIN_REQUIRES = [] # Neurons that receive external input – never modified by learning _PURE_INPUTS = { "can_see_food", "is_eating", "is_sleeping", "is_sick", "pursuing_food", "is_fleeing", "is_startled", "external_stimulus", "plant_proximity", } # =========================================================================== # Plugin class # =========================================================================== class STDPPlugin: """ Integrates STDP learning into Dosidicus without modifying core files. Lifecycle --------- setup() – called by PluginManager once tamagotchi_logic is ready cleanup() – called on disable / app exit """ def __init__(self): self.logger: Optional[logging.Logger] = None self.plugin_manager = None self.tamagotchi_logic = None # References resolved during setup() self._brain_worker = None # BrainWorker QThread instance self._brain_widget = None # BrainWidget QWidget instance # The STDP learning engine self.stdp_learner: Optional[STDPLearner] = None # Saved reference so we can restore on cleanup self._original_hebbian_fn = None # Timer for periodic spike sampling (main thread) self._spike_timer: Optional[QtCore.QTimer] = None # Periodic cleanup timer self._cleanup_timer: Optional[QtCore.QTimer] = None # UI banner widget injected into the learning tab self._ui_banner: Optional[QtWidgets.QWidget] = None self._banner_stats_label: Optional[QtWidgets.QLabel] = None self._banner_update_timer: Optional[QtCore.QTimer] = None # Control panel (opened on demand from the Plugins menu) self._panel = None self.is_setup = False self.enabled = False # Thread-safe queue: worker thread pushes (pair, weight_change, meta), # main-thread spike timer drains it into the learning tab. self._pending_tab_updates: deque = deque() # Configurable parameters (can be adjusted at runtime) self.config = STDPConfig( tau_plus=0.15, tau_minus=0.15, A_plus=0.08, A_minus=0.05, time_window=0.5, spike_threshold=60.0, spike_rising_threshold=8.0, refractory_period=0.08, burst_bonus=1.5, stdp_weight=0.4, # 40 % STDP, 60 % Hebbian eligibility_decay=0.95, eligibility_window=2.0, ) # ----------------------------------------------------------------------- # Setup / teardown # ----------------------------------------------------------------------- def setup(self, plugin_manager, tamagotchi_logic) -> bool: """Called by PluginManager after core components are ready.""" self.plugin_manager = plugin_manager self.tamagotchi_logic = tamagotchi_logic # Logger if hasattr(plugin_manager, 'logger'): self.logger = plugin_manager.logger.getChild(PLUGIN_NAME) else: self.logger = logging.getLogger(PLUGIN_NAME) if not self.logger.handlers: h = logging.StreamHandler() h.setFormatter(logging.Formatter('%(levelname)s:%(name)s: %(message)s')) self.logger.addHandler(h) self.logger.setLevel(logging.INFO) self.logger.info(f"Setting up {PLUGIN_NAME} v{PLUGIN_VERSION}...") # Build the learner self.stdp_learner = STDPLearner(self.config) # Resolve brain_worker and brain_widget if not self._resolve_brain_references(): self.logger.error("Could not resolve BrainWorker / BrainWidget – setup aborted.") return False # Patch the worker self._install_hebbian_patch() # Spike-sampling timer (runs on main thread, safe to touch brain_widget) self._spike_timer = QtCore.QTimer() self._spike_timer.timeout.connect(self._sample_spikes) self._spike_timer.start(150) # sample every 150 ms # Periodic STDP cleanup timer self._cleanup_timer = QtCore.QTimer() self._cleanup_timer.timeout.connect(self._periodic_cleanup) self._cleanup_timer.start(10_000) # every 10 s # Subscribe to game-event hooks for reward modulation self._subscribe_hooks() self.is_setup = True # Respect is_enabled_by_default from the plugin registry. # The core app calls setup() on every plugin unconditionally, so we # must read the flag ourselves rather than relying on the caller. plugin_key = PLUGIN_NAME.lower() plugin_data = plugin_manager.plugins.get(plugin_key, {}) self.enabled = plugin_data.get('is_enabled_by_default', False) # Only inject the UI banner if the plugin is actually starting enabled. if self.enabled: QtCore.QTimer.singleShot(1200, self._inject_ui_banner) self.logger.info(f"{PLUGIN_NAME} setup complete. " f"Patched BrainWorker._perform_hebbian_learning ✓ " f"(enabled={self.enabled})") print("\n") print("=" * 60) if self.enabled: print(" ⚡ STDP LEARNING IS ACTIVE ⚡") else: print(" ⚡ STDP plugin loaded (DISABLED – enable via Plugins menu) ⚡") print("=" * 60) print(f" Blend : {int((1 - self.config.stdp_weight)*100)}% Hebbian + {int(self.config.stdp_weight*100)}% STDP") print(f" Window : {int(self.config.time_window * 1000)} ms") print(f" Threshold : {self.config.spike_threshold}") print(f" Worker : {type(self._brain_worker).__name__} patched ✓") print("=" * 60) print(" Watch for [LTP] / [LTD] tags on Hebbian lines") print("=" * 60) print("\n") return True def cleanup(self): """Restore original BrainWorker method and stop timers.""" if self.logger: self.logger.info(f"{PLUGIN_NAME}: cleanup called") if self._spike_timer: self._spike_timer.stop() if self._cleanup_timer: self._cleanup_timer.stop() if self._banner_update_timer: self._banner_update_timer.stop() self._restore_hebbian_patch() # Close control panel if open if self._panel is not None: try: self._panel.close() except Exception: pass self._panel = None # Remove UI banner if present if self._ui_banner is not None: try: self._ui_banner.setParent(None) self._ui_banner.deleteLater() self._ui_banner = None except Exception: pass self.is_setup = False def shutdown(self): """Called by PluginManager when the plugin is disabled/unloaded.""" self.cleanup() # ----------------------------------------------------------------------- # Reference resolution # ----------------------------------------------------------------------- def _resolve_brain_references(self) -> bool: """Walk the object graph to find BrainWorker and BrainWidget.""" tl = self.tamagotchi_logic # brain_window → brain_widget brain_window = getattr(tl, 'brain_window', None) if brain_window is None: # Try plugin_manager path pm = self.plugin_manager brain_window = getattr(pm, 'brain_window', None) if brain_window is None: self.logger.error("Cannot find brain_window on tamagotchi_logic or plugin_manager") return False self._brain_widget = getattr(brain_window, 'brain_widget', None) if self._brain_widget is None: self.logger.error("brain_window has no brain_widget") return False # brain_worker is typically on brain_widget (set by SquidBrainWindow) self._brain_worker = getattr(self._brain_widget, 'brain_worker', None) if self._brain_worker is None: # Fallback: look on brain_window itself self._brain_worker = getattr(brain_window, 'brain_worker', None) if self._brain_worker is None: self.logger.error("Cannot find brain_worker on brain_widget or brain_window") return False self.logger.info( f"References resolved: worker={type(self._brain_worker).__name__} " f"widget={type(self._brain_widget).__name__}" ) return True # ----------------------------------------------------------------------- # Monkey-patch # ----------------------------------------------------------------------- def _install_hebbian_patch(self): """Replace _perform_hebbian_learning on the live BrainWorker instance.""" worker = self._brain_worker self._original_hebbian_fn = worker._perform_hebbian_learning stdp_plugin_ref = self # captured in closure def _stdp_perform_hebbian_learning(): """STDP-augmented drop-in for BrainWorker._perform_hebbian_learning.""" # Fall back to original if plugin was disabled/unloaded if not stdp_plugin_ref.is_setup or stdp_plugin_ref._original_hebbian_fn is None: if stdp_plugin_ref._original_hebbian_fn: stdp_plugin_ref._original_hebbian_fn() return # Fall back to original if STDP is toggled off by the user if not stdp_plugin_ref.enabled: stdp_plugin_ref._original_hebbian_fn() return stdp_plugin_ref._run_stdp_hebbian(worker) # Bind to the instance (not the class) so only this worker is affected import types worker._perform_hebbian_learning = types.MethodType( lambda self_w: _stdp_perform_hebbian_learning(), worker ) # Simpler: just replace the bound reference directly worker._perform_hebbian_learning = _stdp_perform_hebbian_learning self.logger.info("Hebbian patch installed on BrainWorker instance") def _restore_hebbian_patch(self): """Undo the monkey-patch.""" if self._brain_worker and self._original_hebbian_fn: self._brain_worker._perform_hebbian_learning = self._original_hebbian_fn self.logger.info("Hebbian patch removed – BrainWorker restored") # ----------------------------------------------------------------------- # Learning-tab UI banner # ----------------------------------------------------------------------- def _inject_ui_banner(self): """ Insert a styled STDP status banner at the top of the Learning tab's card area. Safe to call from main thread only. """ if not self.enabled: return try: brain_window = getattr(self.tamagotchi_logic, 'brain_window', None) if brain_window is None: return nn_viz_tab = getattr(brain_window, 'nn_viz_tab', None) if nn_viz_tab is None: return layout = getattr(nn_viz_tab, 'learning_content_layout', None) if layout is None: return # Build the banner widget banner = QtWidgets.QWidget() banner.setObjectName("stdp_banner") banner.setStyleSheet(""" QWidget#stdp_banner { background: qlineargradient( x1:0, y1:0, x2:1, y2:0, stop:0 #1a237e, stop:1 #283593 ); border-radius: 10px; border: 2px solid #5c6bc0; } """) row = QtWidgets.QHBoxLayout(banner) row.setContentsMargins(16, 12, 16, 12) row.setSpacing(12) # Lightning icon icon_label = QtWidgets.QLabel("⚡") icon_label.setStyleSheet(f"font-size: {DisplayScaling.font_size(28)}px; background: transparent;") row.addWidget(icon_label) # Text block text_col = QtWidgets.QVBoxLayout() text_col.setSpacing(2) title = QtWidgets.QLabel("STDP Learning Active") title.setStyleSheet( f"font-size: {DisplayScaling.font_size(15)}px; font-weight: 700; color: #e8eaf6; background: transparent;" ) text_col.addWidget(title) detail = QtWidgets.QLabel( f"{int((1 - self.config.stdp_weight) * 100)}% Hebbian + " f"{int(self.config.stdp_weight * 100)}% spike-timing · " f"window {int(self.config.time_window * 1000)} ms · " f"threshold {self.config.spike_threshold:.0f}" ) detail.setStyleSheet( f"font-size: {DisplayScaling.font_size(12)}px; color: #9fa8da; background: transparent;" ) text_col.addWidget(detail) row.addLayout(text_col) row.addStretch() # Live LTP/LTD counters (updated by a timer) self._banner_stats_label = QtWidgets.QLabel("LTP — · LTD —") self._banner_stats_label.setStyleSheet( f"font-size: {DisplayScaling.font_size(12)}px; font-weight: 600; color: #80cbc4; background: transparent;" ) row.addWidget(self._banner_stats_label) # Insert at position 0 (before the stretch at the bottom) layout.insertWidget(0, banner) self._ui_banner = banner # Timer to keep the LTP/LTD counter live self._banner_update_timer = QtCore.QTimer() self._banner_update_timer.timeout.connect(self._refresh_banner_stats) self._banner_update_timer.start(3000) # refresh every 3 s self.logger.info("STDP banner injected into Learning tab") except Exception as exc: self.logger.warning(f"Could not inject UI banner: {exc}") def _refresh_banner_stats(self): """Update the live counter label on the banner.""" if self._ui_banner is None or self.stdp_learner is None: return try: stats = self.stdp_learner.get_stats() ltp = stats.get('ltp_events', 0) ltd = stats.get('ltd_events', 0) spikes = stats.get('spike_stats', {}).get('total_spikes', 0) self._banner_stats_label.setText( f"LTP {ltp} · LTD {ltd} · spikes {spikes}" ) except Exception: pass # ----------------------------------------------------------------------- # Patched Hebbian learning (runs on BrainWorker thread) # ----------------------------------------------------------------------- def _run_stdp_hebbian(self, worker): """ STDP-augmented Hebbian learning. Mirrors the original BrainWorker._perform_hebbian_learning logic but replaces the pure-Hebbian delta with the combined STDP+Hebbian signal from STDPLearner.compute_combined_learning(). """ from PyQt5.QtCore import QMutexLocker # --- Snapshot cache (thread-safe) --- with QMutexLocker(worker._cache_mutex): state = worker.cache['state'] weights = worker.cache['weights'] neuron_list = list(worker.cache['positions'].keys()) excluded = worker.cache['excluded_neurons'] connector_nrns = worker.cache['connector_neurons'] config = worker.cache['config'] base_lr = worker.cache['learning_rate'] new_neurons = worker.cache['new_neurons'] custom_neurons = worker.cache.get('custom_neurons', set()) if not config: self.logger.warning("STDP Hebbian skipped – no config in cache") return if not neuron_list: self.logger.warning("STDP Hebbian skipped – no neurons in cache") return # Record spikes from current state into STDP learner # (spike_tracker.record_batch is thread-safe via its own mutex) self.stdp_learner.record_state(state) # --- Build candidate list (same exclusion rules as original) --- candidates = [ n for n in neuron_list if n not in excluded and n not in connector_nrns and n not in _PURE_INPUTS ] if len(candidates) < 2: worker.hebbian_result.emit({'updated_pairs': []}) return # --- Score pairs --- scored_pairs = [] import random for i, n1 in enumerate(candidates): for n2 in candidates[i + 1:]: v1 = self._get_neuron_value(state.get(n1, 50)) v2 = self._get_neuron_value(state.get(n2, 50)) score = v1 + v2 + random.uniform(0, 40) pair_key = tuple(sorted((n1, n2))) if pair_key in worker._last_hebbian_pairs: score -= 500 # same cooldown penalty as original scored_pairs.append((score, n1, n2, v1, v2)) if not scored_pairs: return # --- Select top pairs --- top_k = 2 if hasattr(config, 'neurogenesis'): top_k = config.neurogenesis.get('max_hebbian_pairs', 2) top_pairs = nlargest(top_k, scored_pairs) worker._last_hebbian_pairs = [tuple(sorted((n1, n2))) for _, n1, n2, _, _ in top_pairs] # --- Compute weight updates with STDP --- hebbian_cfg = getattr(config, 'hebbian', {}) decay_rate = hebbian_cfg.get('weight_decay', 0.01) weight_updates = {} updated_pairs_list = [] ltp_count = 0 ltd_count = 0 for _, n1, n2, v1, v2 in top_pairs: pair = (n1, n2) reverse_pair = (n2, n1) use_pair = None if pair in weights: use_pair = pair elif reverse_pair in weights: use_pair = reverse_pair if not use_pair: use_pair = pair old_w = 0.0 weight_updates[use_pair] = { 'old_weight': 0.0, 'new_weight': 0.0, 'is_new_connection': True, } else: old_w = weights[use_pair] # Learning rate boost for new neurons lr = base_lr if n1 in new_neurons or n2 in new_neurons: lr *= 2.0 is_custom = (n1 in custom_neurons or n2 in custom_neurons) # Combined STDP + Hebbian delta combined_delta, meta = self.stdp_learner.compute_combined_learning( n1, n2, v1, v2, base_learning_rate=lr, is_custom=is_custom, ) if meta.get('is_ltp'): ltp_count += 1 elif meta.get('is_ltd'): ltd_count += 1 # Forward to control panel logger (main thread will flush) if self._panel: self._panel.record_encoding( n1, n2, meta.get('hebbian_delta', 0.0), meta.get('stdp_delta', 0.0), combined_delta, meta.get('stdp_direction', 'none'), self.config.stdp_weight, ) new_w = old_w + combined_delta - (old_w * decay_rate) new_w = max(-1.0, min(1.0, new_w)) is_new = ( use_pair in weight_updates and weight_updates[use_pair].get('is_new_connection', False) ) weight_updates[use_pair] = { 'old_weight': old_w, 'new_weight': new_w, 'is_new_connection': is_new, } updated_pairs_list.append(use_pair) # Queue a learning-tab update with full STDP metadata (drained on main thread) weight_change = "increase" if new_w > old_w else ("decrease" if new_w < old_w else None) tab_meta = dict(meta) tab_meta['stdp_weight'] = self.config.stdp_weight self._pending_tab_updates.append((use_pair, weight_change, tab_meta)) if updated_pairs_list: stdp_tag = "" if ltp_count: stdp_tag += f" [LTP×{ltp_count}]" if ltd_count: stdp_tag += f" [LTD×{ltd_count}]" print(f"🧠 STDP+Hebbian: updated {len(updated_pairs_list)} pair(s){stdp_tag}") worker.hebbian_result.emit({ 'updated_pairs': updated_pairs_list, 'weight_updates': weight_updates, }) @staticmethod def _get_neuron_value(raw) -> float: """Normalise neuron values (mirrors BrainWorker._get_neuron_value).""" if isinstance(raw, (int, float)): return float(raw) if isinstance(raw, bool): return 100.0 if raw else 0.0 return 50.0 # ----------------------------------------------------------------------- # Spike sampling (main thread timer) # ----------------------------------------------------------------------- def _sample_spikes(self): """ Periodically read the live brain state and feed it into the spike tracker. Runs on the main thread so it's safe to read brain_widget and call into the learning tab UI. """ if not self.enabled or self.stdp_learner is None: return if self._brain_widget is None: return state = getattr(self._brain_widget, 'state', None) if not state: return spikes = self.stdp_learner.record_state(state) # Forward new spikes to the control panel logger if self._panel and spikes: tracker = self.stdp_learner.spike_tracker for neuron_name, spike_event in spikes: is_burst = tracker._is_bursting_nolock(neuron_name) self._panel.record_spike( neuron_name, spike_event.activation_level, spike_event.was_rising, is_burst, ) # Drain pending learning-tab updates (pushed by worker thread) if self._pending_tab_updates: nn_viz_tab = None try: brain_window = getattr(self.tamagotchi_logic, 'brain_window', None) if brain_window: nn_viz_tab = getattr(brain_window, 'nn_viz_tab', None) except Exception: pass while self._pending_tab_updates: try: pair, weight_change, meta = self._pending_tab_updates.popleft() if nn_viz_tab is not None: nn_viz_tab.add_log_entry("", pair, weight_change, stdp_meta=meta) except Exception: pass # ----------------------------------------------------------------------- # Reward modulation via eligibility traces # ----------------------------------------------------------------------- def apply_reward(self, signal: float, reason: str = ""): """ Modulate recent eligibility traces by *signal* and apply the resulting weight deltas directly to brain_widget.weights. Positive signal → reinforce recent causal connections. Negative signal → weaken them. """ if not self.enabled or self.stdp_learner is None: return if self._brain_widget is None: return deltas: Dict = self.stdp_learner.apply_reward_modulation(signal) if not deltas: return weights = getattr(self._brain_widget, 'weights', {}) applied = 0 for (pre, post), delta in deltas.items(): pair = (pre, post) rev = (post, pre) use = pair if pair in weights else (rev if rev in weights else None) if use: old_w = weights[use] weights[use] = max(-1.0, min(1.0, old_w + delta)) applied += 1 if applied: tag = f" ({reason})" if reason else "" sign = "+" if signal > 0 else "" self.logger.debug( f"Reward{tag}: signal={sign}{signal:.2f}, " f"modulated {applied} connection(s)" ) print(f"⚡ STDP reward{tag}: {sign}{signal:.2f} → {applied} weight(s) modulated") # ----------------------------------------------------------------------- # Hook subscriptions # ----------------------------------------------------------------------- def _subscribe_hooks(self): pm = self.plugin_manager if pm is None: return subscriptions = [ ("on_feed", self._on_feed), ("on_clean", self._on_clean), ("on_medicine", self._on_medicine), ("on_sleep", self._on_sleep), ("on_wake", self._on_wake), ("on_startle", self._on_startle), ] for hook_name, cb in subscriptions: try: if hasattr(pm, 'subscribe_to_hook'): pm.subscribe_to_hook(hook_name, PLUGIN_NAME, cb) self.logger.debug(f"Subscribed to {hook_name}") except Exception as exc: self.logger.warning(f"Could not subscribe to {hook_name}: {exc}") # Hook callbacks -------------------------------------------------------- def _on_feed(self, **kwargs): self.apply_reward(+0.5, "fed") def _on_clean(self, **kwargs): self.apply_reward(+0.3, "cleaned") def _on_medicine(self, **kwargs): self.apply_reward(+0.4, "medicine") def _on_sleep(self, **kwargs): self.apply_reward(+0.2, "sleep") def _on_wake(self, **kwargs): # Waking resets recent context – mild positive self.apply_reward(+0.1, "wake") def _on_startle(self, **kwargs): self.apply_reward(-0.3, "startled") # ----------------------------------------------------------------------- # Periodic cleanup # ----------------------------------------------------------------------- def _periodic_cleanup(self): """Remove stale spike records and eligibility traces.""" if self.stdp_learner: self.stdp_learner.cleanup() # ----------------------------------------------------------------------- # Public API # ----------------------------------------------------------------------- def get_stats(self) -> dict: """Return current STDP statistics (safe to call from any thread).""" if self.stdp_learner is None: return {} stats = self.stdp_learner.get_stats() stats['enabled'] = self.enabled stats['stdp_weight'] = self.config.stdp_weight return stats def set_stdp_weight(self, weight: float): """Adjust the STDP/Hebbian blend ratio at runtime (0 = pure Hebbian, 1 = pure STDP).""" self.config.stdp_weight = max(0.0, min(1.0, weight)) if self.stdp_learner: self.stdp_learner.config.stdp_weight = self.config.stdp_weight if self.logger: self.logger.info(f"STDP weight set to {self.config.stdp_weight:.2f}") def enable(self): """Called by PluginManager when the plugin is enabled.""" self.set_enabled(True) return True def disable(self): """Called by PluginManager when the plugin is disabled.""" self.set_enabled(False) return True def set_enabled(self, enabled: bool): self.enabled = enabled status = "enabled" if enabled else "disabled (pure Hebbian)" if self.logger: self.logger.info(f"STDP {status}") print(f"⚡ STDP learning {status}") if enabled: if self._ui_banner is None: QtCore.QTimer.singleShot(0, self._inject_ui_banner) elif self._ui_banner is not None: self._ui_banner.setVisible(True) else: if self._ui_banner is not None: self._ui_banner.setVisible(False) def reset_stats(self): if self.stdp_learner: self.stdp_learner.reset_stats() # ----------------------------------------------------------------------- # Plugin menu integration # ----------------------------------------------------------------------- def register_menu_actions(self, main_window: QtWidgets.QMainWindow, menu: QtWidgets.QMenu): """Called by the UI to populate the Plugins > STDP submenu.""" panel_action = QtWidgets.QAction("Control Panel…", main_window) panel_action.triggered.connect( lambda: self.show_control_panel(main_window) ) menu.addAction(panel_action) menu.addSeparator() toggle_action = QtWidgets.QAction("Enabled", main_window) toggle_action.setCheckable(True) toggle_action.setChecked(self.enabled) toggle_action.toggled.connect(self.set_enabled) menu.addAction(toggle_action) def show_control_panel(self, parent=None): """Open (or raise) the STDP control panel.""" if self._panel is None or not self._panel.isVisible(): try: from .stdp_control_panel import STDPControlPanel except ImportError: from stdp_control_panel import STDPControlPanel self._panel = STDPControlPanel(self, parent) self._panel.show() else: self._panel.raise_() self._panel.activateWindow() # =========================================================================== # Plugin registration (called by PluginManager.load_all_plugins) # =========================================================================== def initialize(plugin_manager) -> bool: """ Register the STDP plugin with the PluginManager. The PluginManager will later call instance.setup(pm, tamagotchi_logic). """ plugin_key = PLUGIN_NAME.lower() if plugin_key in plugin_manager.plugins: if hasattr(plugin_manager, 'logger'): plugin_manager.logger.warning( f"{PLUGIN_NAME} is already registered. Skipping." ) return True try: instance = STDPPlugin() instance.plugin_manager = plugin_manager plugin_manager.plugins[plugin_key] = { 'instance': instance, 'name': PLUGIN_NAME, 'version': PLUGIN_VERSION, 'author': PLUGIN_AUTHOR, 'description': PLUGIN_DESCRIPTION, 'requires': PLUGIN_REQUIRES, 'is_setup': False, 'is_enabled_by_default': False, } print(f"⚡ {PLUGIN_NAME} v{PLUGIN_VERSION} by {PLUGIN_AUTHOR} registered.") return True except Exception as exc: if hasattr(plugin_manager, 'logger'): plugin_manager.logger.error( f"Failed to initialize {PLUGIN_NAME}: {exc}" ) traceback.print_exc() return False ================================================ FILE: plugins/stdp/stdp_control_panel.py ================================================ """ STDP Control Panel A dockable dialog for tuning STDP parameters and optionally recording spike and directional-encoding logs to disk. Log files --------- spikes_YYYYMMDD_HHMMSS.csv – one row per detected spike timestamp, neuron, activation, was_rising, is_burst encoding_YYYYMMDD_HHMMSS.csv – one row per Hebbian+STDP pair update timestamp, pre, post, hebbian_delta, stdp_delta, combined_delta, direction, ltp_ltd, stdp_weight """ import os import csv import time from datetime import datetime from typing import Optional, TYPE_CHECKING from PyQt5 import QtCore, QtGui, QtWidgets if TYPE_CHECKING: from .main import STDPPlugin # ── palette ──────────────────────────────────────────────────────────────── _BG = "#0f1923" _SURFACE = "#16232f" _BORDER = "#1e3448" _ACCENT = "#00bcd4" _ACCENT2 = "#7c4dff" _TEXT = "#cfd8dc" _TEXT_DIM = "#546e7a" _LTP = "#00e676" _LTD = "#ff5252" _WARN = "#ffc107" _PANEL_STYLE = f""" QDialog {{ background: {_BG}; color: {_TEXT}; font-family: "Consolas", "Courier New", monospace; }} QGroupBox {{ background: {_SURFACE}; border: 1px solid {_BORDER}; border-radius: 6px; margin-top: 10px; padding: 8px; color: {_ACCENT}; font-size: 11px; font-weight: bold; letter-spacing: 1px; text-transform: uppercase; }} QGroupBox::title {{ subcontrol-origin: margin; left: 10px; padding: 0 6px; }} QLabel {{ color: {_TEXT}; font-size: 12px; background: transparent; }} QLabel#dim {{ color: {_TEXT_DIM}; font-size: 11px; }} QLabel#ltp {{ color: {_LTP}; font-weight: bold; }} QLabel#ltd {{ color: {_LTD}; font-weight: bold; }} QLabel#accent {{ color: {_ACCENT}; font-weight: bold; }} QSlider::groove:horizontal {{ height: 4px; background: {_BORDER}; border-radius: 2px; }} QSlider::handle:horizontal {{ width: 14px; height: 14px; margin: -5px 0; border-radius: 7px; background: {_ACCENT}; }} QSlider::sub-page:horizontal {{ background: {_ACCENT}; border-radius: 2px; }} QCheckBox {{ color: {_TEXT}; spacing: 8px; font-size: 12px; }} QCheckBox::indicator {{ width: 16px; height: 16px; border-radius: 3px; border: 1px solid {_BORDER}; background: {_SURFACE}; }} QCheckBox::indicator:checked {{ background: {_ACCENT}; border-color: {_ACCENT}; image: none; }} QPushButton {{ background: {_SURFACE}; color: {_TEXT}; border: 1px solid {_BORDER}; border-radius: 5px; padding: 6px 14px; font-size: 12px; font-family: "Consolas", "Courier New", monospace; }} QPushButton:hover {{ background: {_BORDER}; border-color: {_ACCENT}; color: {_ACCENT}; }} QPushButton:pressed {{ background: {_ACCENT}; color: {_BG}; }} QPushButton#danger {{ border-color: {_LTD}; color: {_LTD}; }} QPushButton#danger:hover {{ background: {_LTD}; color: {_BG}; }} QPushButton#export {{ border-color: {_LTP}; color: {_LTP}; }} QPushButton#export:hover {{ background: {_LTP}; color: {_BG}; }} QLineEdit {{ background: {_SURFACE}; color: {_TEXT}; border: 1px solid {_BORDER}; border-radius: 4px; padding: 4px 8px; font-size: 11px; font-family: "Consolas", "Courier New", monospace; }} QScrollBar:vertical {{ background: {_BG}; width: 8px; }} QScrollBar::handle:vertical {{ background: {_BORDER}; border-radius: 4px; min-height: 20px; }} QFrame#separator {{ color: {_BORDER}; }} """ class _Slider(QtWidgets.QWidget): """Label + slider + value label in one row.""" valueChanged = QtCore.pyqtSignal(float) def __init__(self, label: str, min_val: float, max_val: float, value: float, decimals: int = 2, parent=None): super().__init__(parent) self._scale = 10 ** decimals self._decimals = decimals row = QtWidgets.QHBoxLayout(self) row.setContentsMargins(0, 0, 0, 0) row.setSpacing(8) lbl = QtWidgets.QLabel(label) lbl.setFixedWidth(130) row.addWidget(lbl) self._slider = QtWidgets.QSlider(QtCore.Qt.Horizontal) self._slider.setRange(int(min_val * self._scale), int(max_val * self._scale)) self._slider.setValue(int(value * self._scale)) row.addWidget(self._slider, 1) self._val_lbl = QtWidgets.QLabel(f"{value:.{decimals}f}") self._val_lbl.setFixedWidth(46) self._val_lbl.setAlignment(QtCore.Qt.AlignRight | QtCore.Qt.AlignVCenter) self._val_lbl.setObjectName("accent") row.addWidget(self._val_lbl) self._slider.valueChanged.connect(self._on_change) def _on_change(self, raw: int): v = raw / self._scale self._val_lbl.setText(f"{v:.{self._decimals}f}") self.valueChanged.emit(v) def value(self) -> float: return self._slider.value() / self._scale def setValue(self, v: float): self._slider.setValue(int(v * self._scale)) class STDPControlPanel(QtWidgets.QDialog): """ Floating control panel for the STDP plugin. """ def __init__(self, plugin: "STDPPlugin", parent=None): super().__init__(parent) self.plugin = plugin self.setWindowTitle("STDP Control Panel") self.setWindowFlags( QtCore.Qt.Window | QtCore.Qt.WindowCloseButtonHint | QtCore.Qt.WindowMinimizeButtonHint ) self.setMinimumWidth(520) self.setStyleSheet(_PANEL_STYLE) # Logging state self._logging_active = False self._log_dir = self._default_log_dir() self._spike_writer = None self._spike_file = None self._encoding_writer = None self._encoding_file = None self._spike_count = 0 self._encoding_count = 0 # Connect to plugin's logging hooks self.plugin._panel = self self._build_ui() # Refresh stats every 2 s self._refresh_timer = QtCore.QTimer(self) self._refresh_timer.timeout.connect(self._refresh_stats) self._refresh_timer.start(2000) # ── UI construction ──────────────────────────────────────────────────── def _build_ui(self): root = QtWidgets.QVBoxLayout(self) root.setSpacing(10) root.setContentsMargins(14, 14, 14, 14) # Title bar title = QtWidgets.QLabel("⚡ STDP CONTROL PANEL") title.setStyleSheet(f"color:{_ACCENT}; font-size:14px; font-weight:bold;" f" letter-spacing:2px;") root.addWidget(title) sep = QtWidgets.QFrame() sep.setFrameShape(QtWidgets.QFrame.HLine) sep.setObjectName("separator") root.addWidget(sep) # ── Parameters ────────────────────────────────────────────────── params_grp = QtWidgets.QGroupBox("Parameters") params_lay = QtWidgets.QVBoxLayout(params_grp) params_lay.setSpacing(8) cfg = self.plugin.config self._w_blend = _Slider("STDP blend", 0.0, 1.0, cfg.stdp_weight, 2) self._w_taup = _Slider("τ+ (LTP)", 0.02, 1.0, cfg.tau_plus, 2) self._w_taum = _Slider("τ− (LTD)", 0.02, 1.0, cfg.tau_minus, 2) self._w_aplus = _Slider("A+ amplitude", 0.01, 0.3, cfg.A_plus, 3) self._w_aminus= _Slider("A− amplitude", 0.01, 0.3, cfg.A_minus, 3) self._w_win = _Slider("Time window", 0.1, 2.0, cfg.time_window, 2) self._w_thr = _Slider("Spike thresh", 20.0, 90.0, cfg.spike_threshold, 1) for w in (self._w_blend, self._w_taup, self._w_taum, self._w_aplus, self._w_aminus, self._w_win, self._w_thr): params_lay.addWidget(w) self._w_blend .valueChanged.connect(lambda v: self.plugin.set_stdp_weight(v)) self._w_taup .valueChanged.connect(lambda v: self._set_cfg('tau_plus', v)) self._w_taum .valueChanged.connect(lambda v: self._set_cfg('tau_minus', v)) self._w_aplus .valueChanged.connect(lambda v: self._set_cfg('A_plus', v)) self._w_aminus.valueChanged.connect(lambda v: self._set_cfg('A_minus', v)) self._w_win .valueChanged.connect(lambda v: self._set_cfg('time_window', v)) self._w_thr .valueChanged.connect(lambda v: self._set_cfg('spike_threshold', v)) root.addWidget(params_grp) # ── Live stats ─────────────────────────────────────────────────── stats_grp = QtWidgets.QGroupBox("Live Statistics") stats_grid = QtWidgets.QGridLayout(stats_grp) stats_grid.setSpacing(6) def _stat_row(grid, row, name): lbl = QtWidgets.QLabel(name) lbl.setObjectName("dim") val = QtWidgets.QLabel("—") grid.addWidget(lbl, row, 0) grid.addWidget(val, row, 1, QtCore.Qt.AlignRight) return val self._s_ltp = _stat_row(stats_grid, 0, "LTP events") self._s_ltp.setObjectName("ltp") self._s_ltd = _stat_row(stats_grid, 1, "LTD events") self._s_ltd.setObjectName("ltd") self._s_spikes = _stat_row(stats_grid, 2, "Total spikes") self._s_neurons = _stat_row(stats_grid, 3, "Tracked neurons") self._s_bursting = _stat_row(stats_grid, 4, "Bursting now") self._s_traces = _stat_row(stats_grid, 5, "Eligibility traces") reset_btn = QtWidgets.QPushButton("Reset counters") reset_btn.setObjectName("danger") reset_btn.clicked.connect(self._reset_stats) stats_grid.addWidget(reset_btn, 6, 0, 1, 2) root.addWidget(stats_grp) # ── Logging ────────────────────────────────────────────────────── log_grp = QtWidgets.QGroupBox("Data Export") log_lay = QtWidgets.QVBoxLayout(log_grp) log_lay.setSpacing(8) # What to log self._chk_spikes = QtWidgets.QCheckBox("Log spike events (spikes_*.csv)") self._chk_encoding = QtWidgets.QCheckBox("Log directional encoding (encoding_*.csv)") self._chk_spikes.setChecked(True) self._chk_encoding.setChecked(True) log_lay.addWidget(self._chk_spikes) log_lay.addWidget(self._chk_encoding) # Output directory dir_row = QtWidgets.QHBoxLayout() dir_row.setSpacing(6) dir_lbl = QtWidgets.QLabel("Output dir:") dir_lbl.setObjectName("dim") self._dir_edit = QtWidgets.QLineEdit(self._log_dir) browse_btn = QtWidgets.QPushButton("…") browse_btn.setFixedWidth(32) browse_btn.clicked.connect(self._browse_dir) dir_row.addWidget(dir_lbl) dir_row.addWidget(self._dir_edit, 1) dir_row.addWidget(browse_btn) log_lay.addLayout(dir_row) # Start / stop btn_row = QtWidgets.QHBoxLayout() self._start_btn = QtWidgets.QPushButton("▶ Start logging") self._start_btn.setObjectName("export") self._start_btn.clicked.connect(self._toggle_logging) self._export_now_btn = QtWidgets.QPushButton("⬇ Export snapshot now") self._export_now_btn.clicked.connect(self._export_snapshot) btn_row.addWidget(self._start_btn) btn_row.addWidget(self._export_now_btn) log_lay.addLayout(btn_row) # Status line self._log_status = QtWidgets.QLabel("Logging inactive") self._log_status.setObjectName("dim") self._log_status.setAlignment(QtCore.Qt.AlignCenter) log_lay.addWidget(self._log_status) root.addWidget(log_grp) # ── Close ──────────────────────────────────────────────────────── close_btn = QtWidgets.QPushButton("Close") close_btn.clicked.connect(self.close) root.addWidget(close_btn, alignment=QtCore.Qt.AlignRight) self._refresh_stats() # ── Config helpers ───────────────────────────────────────────────────── def _set_cfg(self, attr: str, value: float): setattr(self.plugin.config, attr, value) if self.plugin.stdp_learner: setattr(self.plugin.stdp_learner.config, attr, value) def _reset_stats(self): self.plugin.reset_stats() self._spike_count = 0 self._encoding_count = 0 self._refresh_stats() # ── Stats refresh ────────────────────────────────────────────────────── def _refresh_stats(self): stats = self.plugin.get_stats() ss = stats.get('spike_stats', {}) self._s_ltp .setText(str(stats.get('ltp_events', 0))) self._s_ltd .setText(str(stats.get('ltd_events', 0))) self._s_spikes .setText(str(ss.get('total_spikes', 0))) self._s_neurons .setText(str(ss.get('tracked_neurons', 0))) self._s_bursting.setText(str(ss.get('bursting_neurons', 0))) self._s_traces .setText(str(stats.get('active_eligibility_traces', 0))) if self._logging_active: self._log_status.setText( f"● Recording — " f"spikes: {self._spike_count} | " f"pairs: {self._encoding_count}" ) self._log_status.setStyleSheet(f"color:{_LTP}; font-size:11px;") # ── Directory browse ─────────────────────────────────────────────────── def _browse_dir(self): d = QtWidgets.QFileDialog.getExistingDirectory( self, "Select output directory", self._dir_edit.text() ) if d: self._dir_edit.setText(d) # ── Continuous logging toggle ────────────────────────────────────────── def _toggle_logging(self): if not self._logging_active: self._start_logging() else: self._stop_logging() def _start_logging(self): self._log_dir = self._dir_edit.text().strip() or self._default_log_dir() os.makedirs(self._log_dir, exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") if self._chk_spikes.isChecked(): path = os.path.join(self._log_dir, f"spikes_{ts}.csv") self._spike_file = open(path, 'w', newline='', encoding='utf-8') self._spike_writer = csv.writer(self._spike_file) self._spike_writer.writerow( ["timestamp", "neuron", "activation", "was_rising", "is_burst"] ) if self._chk_encoding.isChecked(): path = os.path.join(self._log_dir, f"encoding_{ts}.csv") self._encoding_file = open(path, 'w', newline='', encoding='utf-8') self._encoding_writer = csv.writer(self._encoding_file) self._encoding_writer.writerow([ "timestamp", "pre", "post", "hebbian_delta", "stdp_delta", "combined_delta", "direction", "ltp_ltd", "stdp_weight" ]) self._logging_active = True self._spike_count = 0 self._encoding_count = 0 self._start_btn.setText("■ Stop logging") self._start_btn.setObjectName("danger") self._start_btn.setStyleSheet( f"border-color:{_LTD}; color:{_LTD};" f"background:{_SURFACE}; border-radius:5px; padding:6px 14px;" ) self._log_status.setText("● Recording …") self._log_status.setStyleSheet(f"color:{_LTP}; font-size:11px;") print(f"⚡ STDP logging started → {self._log_dir}") def _stop_logging(self): self._logging_active = False for f in (self._spike_file, self._encoding_file): if f: try: f.close() except Exception: pass self._spike_file = None self._encoding_file = None self._spike_writer = None self._encoding_writer = None self._start_btn.setText("▶ Start logging") self._start_btn.setObjectName("export") self._start_btn.setStyleSheet("") # revert to stylesheet default self._log_status.setText( f"Stopped. Wrote {self._spike_count} spike rows, " f"{self._encoding_count} encoding rows." ) self._log_status.setStyleSheet(f"color:{_TEXT_DIM}; font-size:11px;") print(f"⚡ STDP logging stopped — " f"{self._spike_count} spikes, {self._encoding_count} pairs written.") # ── Snapshot export ──────────────────────────────────────────────────── def _export_snapshot(self): """Export a one-shot CSV of current spike history and recent pairs.""" log_dir = self._dir_edit.text().strip() or self._default_log_dir() os.makedirs(log_dir, exist_ok=True) ts = datetime.now().strftime("%Y%m%d_%H%M%S") learner = self.plugin.stdp_learner if learner is None: QtWidgets.QMessageBox.warning(self, "STDP", "Plugin not active.") return written = [] # Spikes snapshot spike_path = os.path.join(log_dir, f"snapshot_spikes_{ts}.csv") with open(spike_path, 'w', newline='', encoding='utf-8') as f: w = csv.writer(f) w.writerow(["neuron", "timestamp", "activation", "was_rising"]) tracker = learner.spike_tracker with __import__('PyQt5.QtCore', fromlist=['QMutexLocker']).QMutexLocker(tracker._mutex): for name, history in tracker._spike_history.items(): for spike in history: w.writerow([ name, f"{spike.timestamp:.4f}", f"{spike.activation_level:.2f}", spike.was_rising, ]) written.append(spike_path) # Eligibility traces snapshot (best proxy for recent directional encoding) enc_path = os.path.join(log_dir, f"snapshot_encoding_{ts}.csv") with open(enc_path, 'w', newline='', encoding='utf-8') as f: w = csv.writer(f) w.writerow(["pre", "post", "eligibility_trace", "last_update"]) with __import__('PyQt5.QtCore', fromlist=['QMutexLocker']).QMutexLocker(learner._mutex): for (pre, post), (trace, t) in learner._eligibility_traces.items(): w.writerow([pre, post, f"{trace:.4f}", f"{t:.4f}"]) written.append(enc_path) msg = "Exported:\n" + "\n".join(written) QtWidgets.QMessageBox.information(self, "STDP snapshot saved", msg) print(f"⚡ STDP snapshot exported → {log_dir}") # ── Called by plugin to record events ───────────────────────────────── def record_spike(self, neuron: str, activation: float, was_rising: bool, is_burst: bool): """Called from the spike sampling timer in the plugin.""" if not self._logging_active or self._spike_writer is None: return self._spike_writer.writerow([ f"{time.time():.4f}", neuron, f"{activation:.2f}", was_rising, is_burst ]) self._spike_count += 1 # Flush periodically so the file is readable while recording if self._spike_count % 50 == 0: self._spike_file.flush() def record_encoding(self, pre: str, post: str, hebbian_delta: float, stdp_delta: float, combined_delta: float, direction: str, stdp_weight: float): """Called from _run_stdp_hebbian after each pair update.""" if not self._logging_active or self._encoding_writer is None: return ltp_ltd = ("LTP" if stdp_delta > 0 else "LTD" if stdp_delta < 0 else "neutral") self._encoding_writer.writerow([ f"{time.time():.4f}", pre, post, f"{hebbian_delta:.5f}", f"{stdp_delta:.5f}", f"{combined_delta:.5f}", direction, ltp_ltd, f"{stdp_weight:.2f}" ]) self._encoding_count += 1 if self._encoding_count % 20 == 0: self._encoding_file.flush() # ── Helpers ──────────────────────────────────────────────────────────── @staticmethod def _default_log_dir() -> str: return os.path.join(os.getcwd(), "logs", "stdp") def closeEvent(self, event): if self._logging_active: self._stop_logging() self._refresh_timer.stop() # Detach panel reference from plugin if hasattr(self.plugin, '_panel') and self.plugin._panel is self: self.plugin._panel = None super().closeEvent(event) ================================================ FILE: plugins/stdp/stdp_core.py ================================================ """ STDP (Spike-Timing-Dependent Plasticity) Module for Dosidicus-2 This module implements biologically-inspired STDP learning rules that complement the existing Hebbian learning system. STDP adds temporal causality to learning: connections strengthen when the pre-synaptic neuron fires BEFORE the post-synaptic neuron (causal relationship), and weaken when the order is reversed. Key Features: - Asymmetric learning window with configurable time constants - Thread-safe spike recording for use with BrainWorker - Burst detection for handling rapid activations - Integration mode for combining with rate-based Hebbian learning - Eligibility traces for handling delayed rewards Biological Basis: - Pre → Post (Δt > 0): Long-Term Potentiation (LTP) - strengthening - Post → Pre (Δt < 0): Long-Term Depression (LTD) - weakening - Learning magnitude decreases exponentially with time difference Adapted for Dosidicus-2's timescales: - Real neurons: ~10-50ms learning windows - Dosidicus-2: ~100-500ms learning windows (game runs slower than biology) """ import time import math from collections import deque from dataclasses import dataclass, field from typing import Dict, List, Tuple, Optional, Set from PyQt5.QtCore import QMutex, QMutexLocker @dataclass class SpikeEvent: """Records a single spike event for a neuron.""" timestamp: float activation_level: float # How strongly it fired (0-100) was_rising: bool = True # True if activation was increasing when threshold crossed @dataclass class STDPConfig: """Configuration for STDP learning parameters.""" # Time constants (in seconds) - controls how fast learning decays with time difference tau_plus: float = 0.15 # LTP time constant (pre before post) tau_minus: float = 0.15 # LTD time constant (post before pre) # Learning amplitudes A_plus: float = 0.08 # Maximum LTP amplitude A_minus: float = 0.05 # Maximum LTD amplitude (often smaller for stability) # Timing window time_window: float = 0.5 # Maximum time difference to consider (seconds) # Spike detection spike_threshold: float = 60.0 # Activation level to consider a "spike" spike_rising_threshold: float = 8.0 # Minimum increase to detect rising edge refractory_period: float = 0.08 # Minimum time between spikes (seconds) # Burst handling burst_window: float = 0.3 # Time window to detect bursts burst_threshold: int = 3 # Number of spikes to consider a burst burst_bonus: float = 1.5 # Multiplier for burst-associated learning # Integration with Hebbian stdp_weight: float = 0.4 # How much STDP contributes vs rate-based (0-1) # Eligibility traces (for delayed reward learning) eligibility_decay: float = 0.95 # How fast eligibility traces decay eligibility_window: float = 2.0 # How long eligibility persists (seconds) # Connection-specific learning rate modulation new_connection_boost: float = 2.0 # Boost for newly formed connections custom_neuron_boost: float = 1.3 # Boost for custom (user-created) neurons class SpikeTracker: """ Thread-safe tracker for neuron activation spikes. Records when neurons cross activation thresholds, enabling STDP to compute timing-dependent weight changes. """ def __init__(self, config: Optional[STDPConfig] = None): self.config = config or STDPConfig() self._mutex = QMutex() # Spike history: neuron_name -> deque of SpikeEvent self._spike_history: Dict[str, deque] = {} self._max_history_per_neuron = 20 # Previous activation values for edge detection self._previous_activations: Dict[str, float] = {} # Burst tracking self._burst_counts: Dict[str, int] = {} # Recent spike counts self._last_burst_check: float = 0.0 def record_activation(self, neuron_name: str, activation: float, timestamp: Optional[float] = None) -> Optional[SpikeEvent]: """ Record a neuron's activation level and detect spikes. A spike is detected when: 1. Activation crosses above the threshold 2. Activation was rising (not just staying high) 3. Sufficient time has passed since last spike (refractory period) Args: neuron_name: Name of the neuron activation: Current activation level (0-100) timestamp: Optional timestamp (uses current time if not provided) Returns: SpikeEvent if a spike was detected, None otherwise """ if timestamp is None: timestamp = time.time() with QMutexLocker(self._mutex): # Get previous activation prev_activation = self._previous_activations.get(neuron_name, 50.0) self._previous_activations[neuron_name] = activation # Check if this is a spike (threshold crossing with rising edge) is_above_threshold = activation >= self.config.spike_threshold was_below_threshold = prev_activation < self.config.spike_threshold is_rising = (activation - prev_activation) >= self.config.spike_rising_threshold # Alternative: strong activation even if not crossing threshold is_strong_activation = activation >= 80 and is_rising if not ((is_above_threshold and (was_below_threshold or is_rising)) or is_strong_activation): return None # Check refractory period if neuron_name in self._spike_history and len(self._spike_history[neuron_name]) > 0: last_spike = self._spike_history[neuron_name][-1] if (timestamp - last_spike.timestamp) < self.config.refractory_period: return None # Create and record spike event spike = SpikeEvent( timestamp=timestamp, activation_level=activation, was_rising=is_rising ) # Initialize history deque if needed if neuron_name not in self._spike_history: self._spike_history[neuron_name] = deque(maxlen=self._max_history_per_neuron) self._spike_history[neuron_name].append(spike) # Update burst count self._burst_counts[neuron_name] = self._burst_counts.get(neuron_name, 0) + 1 return spike def record_batch(self, state: Dict[str, float], timestamp: Optional[float] = None) -> List[Tuple[str, SpikeEvent]]: """ Record activations for multiple neurons at once. Args: state: Dictionary of neuron_name -> activation_level timestamp: Optional shared timestamp for all recordings Returns: List of (neuron_name, SpikeEvent) tuples for detected spikes """ if timestamp is None: timestamp = time.time() spikes = [] for neuron_name, activation in state.items(): if isinstance(activation, (int, float)): spike = self.record_activation(neuron_name, float(activation), timestamp) if spike: spikes.append((neuron_name, spike)) return spikes def get_last_spike_time(self, neuron_name: str) -> Optional[float]: """Get the timestamp of the most recent spike for a neuron.""" with QMutexLocker(self._mutex): if neuron_name in self._spike_history and len(self._spike_history[neuron_name]) > 0: return self._spike_history[neuron_name][-1].timestamp return None def get_recent_spikes(self, neuron_name: str, window: Optional[float] = None) -> List[SpikeEvent]: """Get all spikes within a time window for a neuron.""" if window is None: window = self.config.time_window with QMutexLocker(self._mutex): return self._get_recent_spikes_nolock(neuron_name, window) def _get_recent_spikes_nolock(self, neuron_name: str, window: float) -> List[SpikeEvent]: """Lock-free version — caller must already hold _mutex.""" cutoff = time.time() - window if neuron_name not in self._spike_history: return [] return [s for s in self._spike_history[neuron_name] if s.timestamp >= cutoff] def is_bursting(self, neuron_name: str) -> bool: """Check if a neuron is currently in a burst state.""" with QMutexLocker(self._mutex): return self._is_bursting_nolock(neuron_name) def _is_bursting_nolock(self, neuron_name: str) -> bool: """Lock-free version — caller must already hold _mutex.""" recent = self._get_recent_spikes_nolock(neuron_name, self.config.burst_window) return len(recent) >= self.config.burst_threshold def cleanup_old_spikes(self, max_age: Optional[float] = None): """Remove spike records older than max_age seconds.""" if max_age is None: max_age = self.config.time_window * 3 current_time = time.time() cutoff = current_time - max_age with QMutexLocker(self._mutex): for neuron_name in list(self._spike_history.keys()): # Filter to keep only recent spikes self._spike_history[neuron_name] = deque( (s for s in self._spike_history[neuron_name] if s.timestamp >= cutoff), maxlen=self._max_history_per_neuron ) # Remove empty histories if len(self._spike_history[neuron_name]) == 0: del self._spike_history[neuron_name] # Decay burst counts periodically current_time = time.time() if current_time - self._last_burst_check > self.config.burst_window: self._burst_counts = {k: max(0, v - 1) for k, v in self._burst_counts.items()} self._last_burst_check = current_time def get_spike_stats(self) -> Dict: """Get statistics about spike activity.""" with QMutexLocker(self._mutex): return { 'tracked_neurons': len(self._spike_history), 'total_spikes': sum(len(h) for h in self._spike_history.values()), 'bursting_neurons': sum( 1 for n in self._spike_history.keys() if self._is_bursting_nolock(n) # no re-lock ), 'recent_spikes_by_neuron': { k: len(self._get_recent_spikes_nolock(k, self.config.time_window)) # no re-lock for k in self._spike_history.keys() } } def to_dict(self) -> Dict: """Serialize spike tracker state for saving.""" with QMutexLocker(self._mutex): return { 'spike_history': { name: [ {'timestamp': s.timestamp, 'activation': s.activation_level, 'rising': s.was_rising} for s in spikes ] for name, spikes in self._spike_history.items() }, 'previous_activations': dict(self._previous_activations), 'burst_counts': dict(self._burst_counts) } def from_dict(self, data: Dict): """Restore spike tracker state from saved data.""" with QMutexLocker(self._mutex): self._spike_history.clear() for name, spikes in data.get('spike_history', {}).items(): self._spike_history[name] = deque( (SpikeEvent(s['timestamp'], s['activation'], s.get('rising', True)) for s in spikes), maxlen=self._max_history_per_neuron ) self._previous_activations = dict(data.get('previous_activations', {})) self._burst_counts = dict(data.get('burst_counts', {})) class STDPLearner: """ Implements STDP learning rules for weight updates. Computes weight changes based on the relative timing of pre-synaptic and post-synaptic neuron spikes. """ def __init__(self, config: Optional[STDPConfig] = None): self.config = config or STDPConfig() self.spike_tracker = SpikeTracker(self.config) self._mutex = QMutex() # Eligibility traces: (pre, post) -> (trace_value, last_update_time) self._eligibility_traces: Dict[Tuple[str, str], Tuple[float, float]] = {} # Statistics tracking self._ltp_count = 0 # Long-term potentiation events self._ltd_count = 0 # Long-term depression events self._total_delta = 0.0 def record_activation(self, neuron_name: str, activation: float, timestamp: Optional[float] = None) -> Optional[SpikeEvent]: """Record activation and detect spikes. Delegates to spike tracker.""" return self.spike_tracker.record_activation(neuron_name, activation, timestamp) def record_state(self, state: Dict[str, float], timestamp: Optional[float] = None): """Record full brain state for spike detection.""" return self.spike_tracker.record_batch(state, timestamp) def compute_stdp_delta(self, pre_neuron: str, post_neuron: str, connection_age: float = 1.0, is_custom_neuron: bool = False) -> float: """ Compute the STDP weight change for a directed connection pre → post. The classic STDP rule: - If pre fires before post (Δt > 0): LTP (positive change) - If post fires before pre (Δt < 0): LTD (negative change) - Magnitude decreases exponentially with |Δt| Args: pre_neuron: Name of pre-synaptic neuron post_neuron: Name of post-synaptic neuron connection_age: Age multiplier (newer connections learn faster) is_custom_neuron: Whether either neuron is user-created Returns: Weight change (positive for LTP, negative for LTD, 0 if no timing data) """ pre_time = self.spike_tracker.get_last_spike_time(pre_neuron) post_time = self.spike_tracker.get_last_spike_time(post_neuron) # Need both neurons to have spiked recently if pre_time is None or post_time is None: return 0.0 # Compute time difference: positive means pre fired first dt = post_time - pre_time # Check if within learning window if abs(dt) > self.config.time_window: return 0.0 # Compute STDP delta using exponential decay if dt > 0: # Pre before post: LTP (causal, strengthen) delta = self.config.A_plus * math.exp(-dt / self.config.tau_plus) self._ltp_count += 1 elif dt < 0: # Post before pre: LTD (acausal, weaken) delta = -self.config.A_minus * math.exp(dt / self.config.tau_minus) self._ltd_count += 1 else: # Simultaneous (very rare): small potentiation delta = self.config.A_plus * 0.5 # Apply modifiers # 1. New connection boost if connection_age < 1.0: delta *= self.config.new_connection_boost * (1.0 - connection_age * 0.5) # 2. Custom neuron boost if is_custom_neuron: delta *= self.config.custom_neuron_boost # 3. Burst bonus (if either neuron is bursting) if self.spike_tracker.is_bursting(pre_neuron) or self.spike_tracker.is_bursting(post_neuron): delta *= self.config.burst_bonus self._total_delta += abs(delta) return delta def compute_symmetric_stdp(self, neuron1: str, neuron2: str, connection_age: float = 1.0, is_custom: bool = False) -> Tuple[float, str]: """ Compute STDP for an undirected connection (checks both directions). For networks with bidirectional or undirected connections, this computes the stronger of the two possible STDP signals. Args: neuron1, neuron2: The two neurons connection_age: Age multiplier is_custom: Whether either is a custom neuron Returns: Tuple of (delta, direction) where direction is 'n1_to_n2', 'n2_to_n1', or 'none' """ delta_1_to_2 = self.compute_stdp_delta(neuron1, neuron2, connection_age, is_custom) delta_2_to_1 = self.compute_stdp_delta(neuron2, neuron1, connection_age, is_custom) # Return the stronger signal if abs(delta_1_to_2) >= abs(delta_2_to_1): if delta_1_to_2 != 0: return delta_1_to_2, 'n1_to_n2' else: if delta_2_to_1 != 0: return delta_2_to_1, 'n2_to_n1' return 0.0, 'none' def update_eligibility_trace(self, pre_neuron: str, post_neuron: str, stdp_delta: float, current_time: Optional[float] = None): """ Update eligibility trace for a connection. Eligibility traces allow STDP effects to be modulated by delayed rewards, implementing a form of three-factor learning rule. Args: pre_neuron, post_neuron: Connection endpoints stdp_delta: The STDP delta computed for this pair current_time: Optional timestamp """ if current_time is None: current_time = time.time() key = (pre_neuron, post_neuron) with QMutexLocker(self._mutex): # Get existing trace, decayed to current time if key in self._eligibility_traces: old_trace, old_time = self._eligibility_traces[key] elapsed = current_time - old_time # Exponential decay decay_factor = self.config.eligibility_decay ** (elapsed / 0.1) decayed_trace = old_trace * decay_factor else: decayed_trace = 0.0 # Add new STDP signal to trace new_trace = decayed_trace + stdp_delta # Clamp trace magnitude new_trace = max(-1.0, min(1.0, new_trace)) self._eligibility_traces[key] = (new_trace, current_time) def get_eligibility_trace(self, pre_neuron: str, post_neuron: str, current_time: Optional[float] = None) -> float: """Get the current eligibility trace for a connection.""" if current_time is None: current_time = time.time() key = (pre_neuron, post_neuron) with QMutexLocker(self._mutex): if key not in self._eligibility_traces: return 0.0 trace, last_time = self._eligibility_traces[key] elapsed = current_time - last_time # Expired trace if elapsed > self.config.eligibility_window: return 0.0 # Apply decay decay_factor = self.config.eligibility_decay ** (elapsed / 0.1) return trace * decay_factor def apply_reward_modulation(self, reward_signal: float) -> Dict[Tuple[str, str], float]: """ Apply reward modulation to all active eligibility traces. This implements the third factor in three-factor learning rules: connections with positive eligibility traces are strengthened by positive rewards and weakened by negative rewards (and vice versa). Args: reward_signal: Reward value (positive = good outcome, negative = bad) Returns: Dictionary of (pre, post) -> weight_delta for all affected connections """ current_time = time.time() weight_deltas = {} with QMutexLocker(self._mutex): for (pre, post), (trace, last_time) in list(self._eligibility_traces.items()): # Skip expired traces if current_time - last_time > self.config.eligibility_window: continue # Apply decay elapsed = current_time - last_time decay_factor = self.config.eligibility_decay ** (elapsed / 0.1) current_trace = trace * decay_factor if abs(current_trace) < 0.01: continue # Weight delta = eligibility * reward delta = current_trace * reward_signal * 0.1 weight_deltas[(pre, post)] = delta # Clear the trace after applying self._eligibility_traces[(pre, post)] = (0.0, current_time) return weight_deltas def compute_combined_learning(self, neuron1: str, neuron2: str, v1: float, v2: float, base_learning_rate: float = 0.1, connection_age: float = 1.0, is_custom: bool = False) -> Tuple[float, Dict]: """ Compute combined Hebbian + STDP learning signal. This integrates rate-based Hebbian learning with timing-based STDP, weighted by config.stdp_weight. Args: neuron1, neuron2: The two neurons in the connection v1, v2: Current activation values (0-100) base_learning_rate: Base learning rate for Hebbian component connection_age: Age of connection (0-1, where 0 is new) is_custom: Whether either neuron is custom Returns: Tuple of (combined_delta, metadata_dict) """ # 1. Rate-based Hebbian component hebbian_delta = base_learning_rate * (v1 / 100.0) * (v2 / 100.0) # 2. STDP component stdp_delta, direction = self.compute_symmetric_stdp( neuron1, neuron2, connection_age, is_custom ) # 3. Combine with weighting stdp_w = self.config.stdp_weight combined_delta = (1 - stdp_w) * hebbian_delta + stdp_w * stdp_delta # 4. Apply connection age boost to combined signal if connection_age < 0.5: combined_delta *= self.config.new_connection_boost metadata = { 'hebbian_delta': hebbian_delta, 'stdp_delta': stdp_delta, 'stdp_direction': direction, 'combined_delta': combined_delta, 'is_ltp': stdp_delta > 0, 'is_ltd': stdp_delta < 0 } # Update eligibility trace if stdp_delta != 0: self.update_eligibility_trace(neuron1, neuron2, stdp_delta) return combined_delta, metadata def cleanup(self): """Periodic cleanup of old data.""" self.spike_tracker.cleanup_old_spikes() # Cleanup old eligibility traces current_time = time.time() cutoff = current_time - self.config.eligibility_window * 2 with QMutexLocker(self._mutex): self._eligibility_traces = { k: v for k, v in self._eligibility_traces.items() if v[1] > cutoff } def get_stats(self) -> Dict: """Get learning statistics.""" spike_stats = self.spike_tracker.get_spike_stats() with QMutexLocker(self._mutex): return { 'ltp_events': self._ltp_count, 'ltd_events': self._ltd_count, 'total_delta_magnitude': self._total_delta, 'active_eligibility_traces': len(self._eligibility_traces), 'spike_stats': spike_stats } def reset_stats(self): """Reset learning statistics.""" with QMutexLocker(self._mutex): self._ltp_count = 0 self._ltd_count = 0 self._total_delta = 0.0 def to_dict(self) -> Dict: """Serialize STDP learner state.""" with QMutexLocker(self._mutex): return { 'spike_tracker': self.spike_tracker.to_dict(), 'eligibility_traces': { f"{k[0]}|{k[1]}": {'trace': v[0], 'time': v[1]} for k, v in self._eligibility_traces.items() }, 'stats': { 'ltp_count': self._ltp_count, 'ltd_count': self._ltd_count, 'total_delta': self._total_delta } } def from_dict(self, data: Dict): """Restore STDP learner state.""" if 'spike_tracker' in data: self.spike_tracker.from_dict(data['spike_tracker']) with QMutexLocker(self._mutex): self._eligibility_traces.clear() for key_str, val in data.get('eligibility_traces', {}).items(): parts = key_str.split('|') if len(parts) == 2: self._eligibility_traces[(parts[0], parts[1])] = (val['trace'], val['time']) stats = data.get('stats', {}) self._ltp_count = stats.get('ltp_count', 0) self._ltd_count = stats.get('ltd_count', 0) self._total_delta = stats.get('total_delta', 0.0) # Convenience function for creating a configured STDP system def create_stdp_learner( time_window_ms: float = 500, stdp_weight: float = 0.4, spike_threshold: float = 60.0 ) -> STDPLearner: """ Create an STDP learner with common configuration. Args: time_window_ms: Learning time window in milliseconds stdp_weight: Weight of STDP vs Hebbian (0-1) spike_threshold: Activation level to consider a spike Returns: Configured STDPLearner instance """ config = STDPConfig( time_window=time_window_ms / 1000.0, tau_plus=time_window_ms / 3000.0, tau_minus=time_window_ms / 3000.0, stdp_weight=stdp_weight, spike_threshold=spike_threshold ) return STDPLearner(config) ================================================ FILE: plugins/whitelist.txt ================================================ # Only these plugins load at startup. # Add one plugin name per line. Lines starting with # are ignored. achievements ================================================ FILE: requirements.txt ================================================ PyQt5>=5.15 numpy>=1.21 ================================================ FILE: src/__init__.py ================================================ ================================================ FILE: src/animation_styles.py ================================================ from dataclasses import dataclass, field from typing import Tuple from enum import Enum class AnimationStyleName(Enum): """Available animation style names.""" VIBRANT = "vibrant" SUBTLE = "subtle" #NEURAL = "neural" DESIGNER = "designer" NONE = "none" @dataclass class AnimationStyle: """ Base animation style configuration with vibrant defaults. Contains all visual parameters for connection and neuron animations. DEFAULTS: Matches the 'Vibrant' style. """ # ===== STYLE METADATA ===== name: str = "vibrant" display_name: str = "Thick" description: str = "Living connections that breathe and pulse organically" # ===== CONNECTION LINE APPEARANCE ===== # Vibrant defaults line_base_width: float = 1.3 line_colour_positive: Tuple[int, int, int] = (40, 200, 80) # Lush green line_colour_negative: Tuple[int, int, int] = (220, 70, 70) # Warm red line_alpha: int = 180 # Base alpha (modulated by pulse) use_thick_lines: bool = False # False for vibrant (uses glow instead) # ===== STRESS-ANXIETY CONNECTION (special red dashed line) ===== stress_anxiety_width: float = 3.5 stress_anxiety_colour: Tuple[int, int, int] = (255, 50, 50) stress_anxiety_dashed: bool = True # ===== PULSE / TRAVELLING DOT ANIMATION ===== # Disabled in Vibrant (replaced by ambient pulse) pulse_enabled: bool = False pulse_colour: Tuple[int, int, int] = (255, 255, 0) # Yellow dot pulse_alpha: int = 200 pulse_duration: float = 3.0 # Tuned for 10 FPS (slower) pulse_speed: float = 1.0 # 0-1 range for travel pulse_diameter: float = 6.0 # pixels (before scale) # ===== GLOW EFFECT (during weight change animations) ===== glow_enabled: bool = True glow_colour: Tuple[int, int, int] = (255, 255, 150) glow_alpha: int = 60 glow_fade_threshold: float = 0.7 # fade out after this progress # ===== NEURON HOVER EFFECTS ===== hover_enabled: bool = True hover_scale: float = 1.25 # expand on hover hover_animation_duration: float = 0.5 # Slower for 10 FPS smoothness # ===== NEURON ACTIVITY HIGHLIGHT ===== activity_highlight_enabled: bool = True activity_highlight_colour: Tuple[int, int, int] = (255, 255, 0) activity_highlight_alpha: int = 150 activity_pulse_speed: float = 1.5 # Slower Hz for 10 FPS # ===== NEUROGENESIS HIGHLIGHT ===== neurogenesis_highlight_colour: Tuple[int, int, int] = (255, 215, 0) # Gold neurogenesis_highlight_alpha: int = 200 neurogenesis_highlight_duration: float = 5.0 # seconds # ===== VIBRANT STYLE: AMBIENT PULSING ===== # When enabled, connections gently "breathe" at random individual rates ambient_pulse_enabled: bool = True ambient_pulse_width_range: Tuple[float, float] = (0.7, 1.5) # Width oscillates 70%-150% ambient_pulse_alpha_range: Tuple[int, int] = (120, 220) # Alpha oscillates ambient_pulse_freq_range: Tuple[float, float] = (0.1, 0.25) # Very slow breathing for 10 FPS ambient_pulse_phase_drift: float = 0.05 # Subtle phase wandering # ===== SUBTLE STYLE: COMMUNICATION GLOWS ===== # Enabled in Vibrant comm_glow_enabled: bool = True comm_glow_colour: Tuple[int, int, int] = (247, 181, 57) # Warm gold/orange for Vibrant comm_glow_alpha: int = 200 comm_glow_size: float = 8.0 comm_glow_tail_length: float = 0.2 comm_glow_speed_range: Tuple[float, float] = (2.0, 3.5) # 2-3.5s duration (~80px/s) comm_glow_fade_in: float = 0.1 # 0-1, fade in portion comm_glow_fade_out: float = 0.25 # 0-1, fade out portion comm_glow_spawn_on_activity: bool = True # spawn when neurons active comm_glow_spawn_on_weight_change: bool = True # spawn on weight changes comm_glow_max_per_connection: int = 2 # max simultaneous glows per line # ===== NEURAL STYLE: ACTIVATION PULSES ===== # Disabled in Vibrant neural_pulse_enabled: bool = False neural_pulse_duration: float = 2.0 # Slower: 2.0s duration neural_pulse_width: float = 8.0 # Thicker pulse for visibility neural_pulse_colour_positive: Tuple[int, int, int] = (180, 230, 255) # Soft cyan glow neural_pulse_colour_negative: Tuple[int, int, int] = (255, 180, 150) # Soft warm glow neural_weight_thickness: bool = False # scale line width by weight neural_weight_thickness_mult: float = 3.5 # weight multiplier for thickness neural_base_colour_positive: Tuple[int, int, int] = (100, 200, 255) # Cyan for excitatory neural_base_colour_negative: Tuple[int, int, int] = (255, 120, 100) # Red for inhibitory neural_base_alpha: int = 180 # Base connection alpha # ===== BACKGROUND COLOUR ===== background_colour: Tuple[int, int, int] = (248, 248, 245) # Warmer Vibrant background # ===== WEIGHT-BASED THICKNESS (Used by Subtle) ===== weight_thickness_enabled: bool = False weight_thickness_min: float = 1.0 weight_thickness_max: float = 9.0 weight_thickness_power: float = 1.0 # ===== SCROLLING DOTS (Used by Subtle) ===== scroll_enabled: bool = False scroll_dot_count: int = 3 scroll_dot_size: float = 6.0 scroll_dot_colour: Tuple[int, int, int] = (255, 255, 255) scroll_dot_alpha: int = 200 scroll_speed_range: Tuple[float, float] = (1.5, 4.0) scroll_random_offsets: bool = True @dataclass class VibrantStyle(AnimationStyle): """ Style 1: Vibrant - Living, breathing connections. Each connection pulses at its own organic rhythm, creating an "alive" feel like a biological neural network. Tuned for 10 FPS. """ name: str = "vibrant" display_name: str = "Thick" description: str = "Living connections that breathe and pulse organically" # Explicitly matches defaults, listed here for clarity line_base_width: float = 1.3 line_colour_positive: Tuple[int, int, int] = (40, 200, 80) line_colour_negative: Tuple[int, int, int] = (220, 70, 70) # Vibrant features ambient_pulse_enabled: bool = True comm_glow_enabled: bool = True # Disable conflicting styles pulse_enabled: bool = False neural_pulse_enabled: bool = False scroll_enabled: bool = False @dataclass class SubtleStyle(AnimationStyle): """ Style 2: Flow - Scrolling dotted connections with weight-based thickness. Features green (positive) and red (negative) dotted lines with continuously scrolling white dots. Line thickness scales with connection strength. """ name: str = "subtle" display_name: str = "Flow" description: str = "Scrolling dotted lines with weight-based thickness" background_colour: Tuple[int, int, int] = (255, 255, 255) # Pure white # Dotted lines line_base_width: float = 1.0 line_colour_positive: Tuple[int, int, int] = (0, 200, 0) line_colour_negative: Tuple[int, int, int] = (200, 0, 0) line_alpha: int = 255 line_style: int = 1 # Qt.DotLine # Weight-based thickness weight_thickness_enabled: bool = True # Scrolling dots scroll_enabled: bool = True scroll_dot_colour: Tuple[int, int, int] = (255, 255, 255) # Disable Pulse/Glow pulse_enabled: bool = False glow_enabled: bool = False ambient_pulse_enabled: bool = False comm_glow_enabled: bool = False neural_pulse_enabled: bool = False @dataclass class NeuralStyle(AnimationStyle): """ Style 3: Neural - Synaptic activation visualization. Features weight-based line thickness and traveling activation pulses. """ name: str = "neural" display_name: str = "Pink & Blue" description: str = "Highlights most active connections" line_base_width: float = 1.0 line_colour_positive: Tuple[int, int, int] = (100, 200, 255) line_colour_negative: Tuple[int, int, int] = (255, 120, 100) line_alpha: int = 180 use_thick_lines: bool = False #background_colour: Tuple[int, int, int] = (245, 247, 250) # Disable Pulse/Glow ambient_pulse_enabled: bool = False comm_glow_enabled: bool = False # Enable Neural Pulse neural_pulse_enabled: bool = True neural_weight_thickness: bool = True @dataclass class DesignerStyle(AnimationStyle): """ Style 4: Structural - Heavy schematic view (Monochrome). Features extremely thick, weight-scaled lines with no movement. Connections are strictly Black (positive) or Grey (negative). """ name: str = "designer" display_name: str = "Structural" description: str = "Heavy, static black & white schematic view" # ===== HEAVY, SOLID MONOCHROME LINES ===== line_base_width: float = 4.0 line_colour_positive: Tuple[int, int, int] = (0, 0, 0) # Black line_colour_negative: Tuple[int, int, int] = (160, 160, 160) # Grey line_alpha: int = 255 # Full opacity use_thick_lines: bool = True # Aggressive thickness scaling based on weight neural_weight_thickness: bool = True neural_weight_thickness_mult: float = 15.0 # +15px at max weight neural_base_colour_positive: Tuple[int, int, int] = (0, 0, 0) # Black neural_base_colour_negative: Tuple[int, int, int] = (160, 160, 160) # Grey neural_base_alpha: int = 255 # Stress connection (Solid Black block) stress_anxiety_width: float = 6.0 stress_anxiety_colour: Tuple[int, int, int] = (0, 0, 0) stress_anxiety_dashed: bool = False # ===== ZERO ANIMATION ===== ambient_pulse_enabled: bool = False pulse_enabled: bool = False comm_glow_enabled: bool = False neural_pulse_enabled: bool = False glow_enabled: bool = False # Technical background #background_colour: Tuple[int, int, int] = (240, 240, 240) # Light Grey # Minimal interactivity hover_enabled: bool = True hover_scale: float = 1.05 hover_animation_duration: float = 0.2 # Minimal highlighting (Greyscale) activity_highlight_enabled: bool = False neurogenesis_highlight_colour: Tuple[int, int, int] = (100, 100, 100) # Dark Grey neurogenesis_highlight_alpha: int = 50 @dataclass class NoneStyle(AnimationStyle): ''' Style 5: None - No animations - maximum performance. Static connections with weight-based thickness capped at 5px. ''' name: str = 'none' display_name: str = 'Thin' description: str = 'No animations - maximum performance' # Disable ALL animation features pulse_enabled: bool = False glow_enabled: bool = False hover_enabled: bool = False activity_highlight_enabled: bool = False ambient_pulse_enabled: bool = False comm_glow_enabled: bool = False neural_pulse_enabled: bool = False # Basic visual settings line_base_width: float = 1.0 # Matches Vibrant/Default colors (Green/Red) line_colour_positive: Tuple[int, int, int] = (40, 200, 80) line_colour_negative: Tuple[int, int, int] = (220, 70, 70) line_alpha: int = 180 use_thick_lines: bool = False # ===== ENABLE WEIGHT-BASED THICKNESS (Max 5px) ===== weight_thickness_enabled: bool = True weight_thickness_min: float = 1.0 weight_thickness_max: float = 5.0 # Caps total thickness strictly at 5px weight_thickness_power: float = 1.0 stress_anxiety_width: float = 2.0 stress_anxiety_colour: Tuple[int, int, int] = (255, 100, 100) stress_anxiety_dashed: bool = False #background_colour: Tuple[int, int, int] = (240, 240, 245) # Zeroing logic for animations pulse_colour: Tuple[int, int, int] = (255, 255, 255) pulse_alpha: int = 0 pulse_duration: float = 0 pulse_speed: float = 0 pulse_diameter: float = 0 glow_colour: Tuple[int, int, int] = (255, 255, 255) glow_alpha: int = 0 glow_fade_threshold: float = 0 hover_scale: float = 1.0 hover_animation_duration: float = 0 activity_highlight_colour: Tuple[int, int, int] = (255, 255, 255) activity_highlight_alpha: int = 0 activity_pulse_speed: float = 0 neurogenesis_highlight_colour: Tuple[int, int, int] = (255, 200, 0) neurogenesis_highlight_alpha: int = 200 neurogenesis_highlight_duration: float = 3.0 ambient_pulse_width_range: Tuple[float, float] = (0, 0) ambient_pulse_alpha_range: Tuple[int, int] = (0, 0) ambient_pulse_freq_range: Tuple[float, float] = (0, 0) ambient_pulse_phase_drift: float = 0 comm_glow_colour: Tuple[int, int, int] = (255, 255, 255) comm_glow_alpha: int = 0 comm_glow_size: float = 0 comm_glow_tail_length: float = 0 comm_glow_speed_range: Tuple[float, float] = (0, 0) comm_glow_fade_in: float = 0 comm_glow_fade_out: float = 0 comm_glow_spawn_on_activity: bool = False comm_glow_spawn_on_weight_change: bool = False comm_glow_max_per_connection: int = 0 neural_pulse_duration: float = 0 neural_pulse_width: float = 0 neural_pulse_colour_positive: Tuple[int, int, int] = (255, 255, 255) neural_pulse_colour_negative: Tuple[int, int, int] = (255, 255, 255) neural_weight_thickness: bool = False neural_weight_thickness_mult: float = 1.0 neural_base_colour_positive: Tuple[int, int, int] = (100, 200, 100) neural_base_colour_negative: Tuple[int, int, int] = (200, 100, 100) neural_base_alpha: int = 180 # ===== STYLE REGISTRY ===== ANIMATION_STYLES = { 'none': NoneStyle, 'vibrant': VibrantStyle, #'subtle': SubtleStyle, 'neural': NeuralStyle, 'designer': DesignerStyle, } def get_animation_style(name: str) -> AnimationStyle: """ Get an animation style by name. Args: name: Style name ('vibrant', 'subtle', 'neural', 'designer', or 'none') Returns: AnimationStyle instance Raises: KeyError if style name not found """ name_lower = name.lower() if name_lower not in ANIMATION_STYLES: raise KeyError(f"Unknown animation style: {name}. " f"Available styles: {list(ANIMATION_STYLES.keys())}") return ANIMATION_STYLES[name_lower] def get_available_styles() -> list: """Return list of available style names.""" return list(ANIMATION_STYLES.keys()) def get_style_info() -> list: """Return list of (name, display_name, description) for all styles.""" return [ (style.name, style.display_name, style.description) for style in ANIMATION_STYLES.values() ] ================================================ FILE: src/brain_about_tab.py ================================================ import random import os from PyQt5 import QtCore, QtGui, QtWidgets from .brain_base_tab import BrainBaseTab from .localisation import Localisation from .compute_backend import get_backend # Predefined list of approved squid names SQUID_NAMES = [ "Algernon", "Cuthbert", "Englebert", "D'Artagnan", "Gaspard", "Ulysses", "Leopold", "Miroslav", "Artemis", "Jacques", "Cecil", "Wilhelm", "Giskard" ] class AboutTab(BrainBaseTab): def __init__(self, parent=None, tamagotchi_logic=None, brain_widget=None, config=None, debug_mode=False): # Initialize localisation self.loc = Localisation.instance() super().__init__(parent, tamagotchi_logic, brain_widget, config, debug_mode) self.initialize_ui() def update_from_brain_state(self, state): """Update tab based on brain state - handle personality updates""" if not hasattr(self, 'personality_label'): return # Check for personality in state if 'personality' in state: # Get the raw personality string (e.g., "timid") raw_personality = str(state['personality']).lower() # Get the localized display name (e.g., "Timido") display_personality = self.loc.get_personality_name(raw_personality) # Only update if the personality has actually changed current_text = self.personality_label.text() # We construct the prefix to check against prefix = f"{self.loc.get('squid_personality')}: " current_personality_display = current_text.replace(prefix, "").strip() if display_personality != current_personality_display: # Update the label with localized text self.personality_label.setText(f"{prefix}{display_personality}") # Enable care tips button if we now have a personality if hasattr(self, 'care_tips_button'): # Check against "Unknown" or localized equivalent if needed is_valid = raw_personality != "unknown" self.care_tips_button.setEnabled(is_valid) # Update button callback to use current personality try: self.care_tips_button.clicked.disconnect() except TypeError: pass # Pass the RAW personality to show_care_tips so it can look up the key correctly self.care_tips_button.clicked.connect(lambda: self.show_care_tips(raw_personality)) # Check for squid object updates if hasattr(self.tamagotchi_logic, 'squid') and self.tamagotchi_logic.squid: squid = self.tamagotchi_logic.squid # Update name if it exists if hasattr(squid, 'name') and hasattr(self, 'name_label'): current_name = self.name_label.text() if squid.name != current_name: self.name_label.setText(squid.name) def initialize_ui(self): # Get version info first version_info = self.get_version_info() # Resolve compute backend display string _backend = get_backend() _bname = _backend.name.lower() if _bname.startswith('onnx') and 'unavailable' not in _bname: _provider = '' if '[' in _backend.name and ']' in _backend.name: _raw = _backend.name.split('[')[1].rstrip(']') _short = { 'DmlExecutionProvider': 'DirectML', 'QNNExecutionProvider': 'QNN·HTP', 'OpenVINOExecutionProvider': 'OpenVINO', 'CPUExecutionProvider': 'CPU', } _provider = _short.get(_raw, _raw.replace('ExecutionProvider', '')) backend_text = f'ONNX · {_provider}' if _provider else 'ONNX' elif 'unavailable' in _bname: backend_text = 'NumPy (ONNX unavailable)' else: backend_text = 'NumPy' from .display_scaling import DisplayScaling # Main text content using QTextEdit about_text = QtWidgets.QTextEdit() about_text.setReadOnly(True) # Set scaled font size for QTextEdit font = about_text.font() font.setPointSize(DisplayScaling.font_size(10)) about_text.setFont(font) # Determine the squid name and personality - more robust approach squid_name = random.choice(SQUID_NAMES) raw_personality = "unknown" display_personality = "Unknown" if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: if hasattr(self.tamagotchi_logic, 'squid') and self.tamagotchi_logic.squid: squid = self.tamagotchi_logic.squid # Get personality if available if hasattr(squid, 'personality'): raw_personality = str(squid.personality).split('.')[-1].lower() display_personality = self.loc.get_personality_name(raw_personality) # print(f" Found personality: {raw_personality} ({display_personality})") # Handle name (existing or assign new) if hasattr(squid, 'name'): if squid.name: squid_name = squid.name else: squid.name = squid_name else: # Initialize name attribute squid.name = squid_name # Build About text with version info and LOCALIZED strings about_html = f"""

    {self.loc.get('dosidicus_title')}

    github.com/ViciousSquid/Dosidicus
    {self.loc.get('dosidicus_desc')}


    {self.loc.get('string_acronym')}

      {self.loc.get('created_by')} Rufus Pearce (ViciousSquid)
      {self.loc.get('version_dosidicus')} {version_info['dosidicus']}
      {self.loc.get('version_brain_tool')} {version_info['brain_tool']}
      {self.loc.get('version_decision')} {version_info['decision_engine']}
      {self.loc.get('version_neuro')} {version_info['neurogenesis']}
      Backend: {backend_text}

      {self.loc.get('research_project')}



    """ about_text.setHtml(about_html) # Create a custom widget for the badge badge_widget = QtWidgets.QWidget() badge_layout = QtWidgets.QVBoxLayout(badge_widget) badge_layout.setContentsMargins(0, 0, 0, 0) badge_layout.setSpacing(0) # Badge container badge_container = QtWidgets.QWidget() badge_container.setFixedWidth(DisplayScaling.scale(300)) badge_container.setStyleSheet(""" background-color: white; border: 4px solid #FF0000; border-radius: 5px; """) badge_inner_layout = QtWidgets.QVBoxLayout(badge_container) badge_inner_layout.setContentsMargins(0, 0, 0, 0) badge_inner_layout.setSpacing(0) # "HELLO" label hello_label = QtWidgets.QLabel(self.loc.get("hello")) hello_label.setAlignment(QtCore.Qt.AlignCenter) hello_label.setStyleSheet(f""" font-family: Arial, sans-serif; font-size: {DisplayScaling.font_size(38)}px; font-weight: bold; color: #FFFFFF; background-color: #FF0000; """) badge_inner_layout.addWidget(hello_label) # "my name is..." label my_name_label = QtWidgets.QLabel(self.loc.get("my_name_is")) my_name_label.setAlignment(QtCore.Qt.AlignCenter) my_name_label.setStyleSheet(f""" font-family: Arial, sans-serif; font-size: {DisplayScaling.font_size(20)}px; color: #FFFFFF; background-color: #FF0000; """) badge_inner_layout.addWidget(my_name_label) # Name label - editable on double-click self.name_label = QtWidgets.QLabel(squid_name) self.name_label.setAlignment(QtCore.Qt.AlignCenter) self.name_label.setStyleSheet(f""" font-family: Arial, sans-serif; font-size: {DisplayScaling.font_size(38)}px; font-weight: bold; color: #000000; background-color: white; """) self.name_label.mouseDoubleClickEvent = lambda event: self.edit_name() self.name_label.setToolTip(self.loc.get("change_name")) self.name_label.setCursor(QtGui.QCursor(QtCore.Qt.PointingHandCursor)) badge_inner_layout.addWidget(self.name_label) badge_layout.addWidget(badge_container, alignment=QtCore.Qt.AlignHCenter) # Add personality information below the badge personality_container = QtWidgets.QWidget() personality_layout = QtWidgets.QVBoxLayout(personality_container) personality_layout.setContentsMargins(DisplayScaling.scale(10), DisplayScaling.scale(20), DisplayScaling.scale(10), DisplayScaling.scale(10)) # Personality label - store reference for updates # Using "squid_personality" key self.personality_label = QtWidgets.QLabel(f"{self.loc.get('squid_personality')}: {display_personality}") self.personality_label.setAlignment(QtCore.Qt.AlignCenter) self.personality_label.setStyleSheet(f"font-size: {DisplayScaling.font_size(22)}px;") personality_layout.addWidget(self.personality_label) # Button container button_container = QtWidgets.QWidget() button_layout = QtWidgets.QHBoxLayout(button_container) button_layout.setContentsMargins(0, DisplayScaling.scale(10), 0, 0) # Add stretchable space to the left button_layout.addStretch() # Add Certificate button certificate_button = QtWidgets.QPushButton(self.loc.get("view_certificate")) certificate_button.clicked.connect(self.show_certificate) certificate_button.setStyleSheet(f"font-size: {DisplayScaling.font_size(18)}px; padding: {DisplayScaling.scale(12)}px;") # button_layout.addWidget(certificate_button) # Add color picker button color_button = QtWidgets.QPushButton(self.loc.get("change_colour")) color_button.clicked.connect(self.open_color_picker) color_button.setStyleSheet(f"font-size: {DisplayScaling.font_size(18)}px; padding: {DisplayScaling.scale(12)}px; background-color: #FFC0CB;") button_layout.addWidget(color_button) # Add stretchable space to the right button_layout.addStretch() # Add button container to personality layout personality_layout.addWidget(button_container) # Add all widgets to the main layout self.layout.addWidget(about_text) self.layout.addWidget(badge_widget) self.layout.addWidget(personality_container) print(f"AboutTab initialization complete - Personality: {display_personality}") def open_color_picker(self): color = QtWidgets.QColorDialog.getColor() if color.isValid(): if self.tamagotchi_logic and self.tamagotchi_logic.squid: self.tamagotchi_logic.squid.apply_tint(color) if hasattr(self.tamagotchi_logic, 'brain_window') and \ hasattr(self.tamagotchi_logic.brain_window, 'statistics_tab'): self.tamagotchi_logic.brain_window.statistics_tab.increment_stat('times_colour_changed') def edit_name(self): """Allow user to edit squid name on double-click""" if not hasattr(self, 'name_label'): return current_name = self.name_label.text() new_name, ok = QtWidgets.QInputDialog.getText( self, self.loc.get("change_name"), self.loc.get("enter_new_name"), QtWidgets.QLineEdit.Normal, current_name ) if ok and new_name: self.name_label.setText(new_name) # Update the squid's name if hasattr(self.tamagotchi_logic, 'squid') and self.tamagotchi_logic.squid: self.tamagotchi_logic.squid.name = new_name def show_certificate(self): """Show the squid certificate window""" try: from .certificate import SquidCertificateWindow if not hasattr(self, 'certificate_window') or self.certificate_window is None: self.certificate_window = SquidCertificateWindow(self, self.tamagotchi_logic) else: self.certificate_window.update_certificate() self.certificate_window.show() self.certificate_window.raise_() except Exception as e: print(f"Error showing certificate: {e}") import traceback traceback.print_exc() def show_care_tips(self, personality_type_raw): """Show care tips for the specific personality type""" # Ensure we are working with the raw string key (e.g., 'timid') personality_type_raw = str(personality_type_raw).lower() # Get localized name and tips localized_name = self.loc.get_personality_name(personality_type_raw) tips = self.get_care_tips(personality_type_raw) # Create a dialog to display the tips dialog = QtWidgets.QDialog(self) dialog.setWindowTitle(f"{self.loc.get('care_tips')}: {localized_name}") dialog.setMinimumSize(600, 800) layout = QtWidgets.QVBoxLayout(dialog) # Add a title # Uses format string "Care Tips for {personality} Squids" title_text = self.loc.get("care_tips_for", personality=localized_name) title = QtWidgets.QLabel(title_text) title.setStyleSheet("font-size: 20px; font-weight: bold; margin-bottom: 15px;") title.setAlignment(QtCore.Qt.AlignCenter) layout.addWidget(title) # Add the tips content tips_text = QtWidgets.QTextEdit() tips_text.setReadOnly(True) font = tips_text.font() font.setPointSize(12) tips_text.setFont(font) tips_text.setPlainText(tips) tips_text.setStyleSheet("line-height: 1.6;") layout.addWidget(tips_text) # Add a close button close_button = QtWidgets.QPushButton(self.loc.get("close")) close_button.clicked.connect(dialog.close) close_button.setFixedWidth(150) close_button.setStyleSheet("font-size: 18px; padding: 8px;") layout.addWidget(close_button, alignment=QtCore.Qt.AlignRight) dialog.exec_() def get_care_tips(self, personality_type): """Return care tips for a specific personality type using Localisation""" # This now delegates entirely to the Localisation class which handles languages return self.loc.get_care_tips(personality_type) def get_version_info(self): """Read version information from the version file""" version_info = { "dosidicus": "Dosidicus-2 2.6.2.0 STRINg2", "brain_tool": "06.03.26", "decision_engine": "4.0", "neurogenesis": "ver3_unified" } try: # Look for version file in the project root version_file = os.path.join(os.path.dirname(__file__), '..', 'version') if os.path.exists(version_file): with open(version_file, 'r') as f: for line in f: if ':' in line: key, value = line.split(':', 1) key = key.strip().lower() value = value.strip() if key in version_info: version_info[key] = value except Exception as e: print(f"Error reading version file: {e}") return version_info ================================================ FILE: src/brain_base_tab.py ================================================ from PyQt5 import QtCore, QtGui, QtWidgets from .display_scaling import DisplayScaling class BrainBaseTab(QtWidgets.QWidget): def __init__(self, parent=None, tamagotchi_logic=None, brain_widget=None, config=None, debug_mode=False): super().__init__(parent) self.parent = parent self.tamagotchi_logic = tamagotchi_logic self.brain_widget = brain_widget self.config = config self.debug_mode = debug_mode self.layout = QtWidgets.QVBoxLayout() self.setLayout(self.layout) # Set an explicit base font so all child widgets inherit a consistent # size regardless of platform defaults (important for Nuitka builds # where Qt may resolve a smaller system font than CPython does). base_font = QtGui.QFont() base_font.setPointSize(DisplayScaling.font_size(10)) self.setFont(base_font) def set_tamagotchi_logic(self, tamagotchi_logic): """Update the tamagotchi_logic reference""" #print(f"BrainBaseTab.set_tamagotchi_logic: {tamagotchi_logic is not None}") self.tamagotchi_logic = tamagotchi_logic def update_from_brain_state(self, state): """Update tab based on brain state - override in subclasses""" pass def create_button(self, text, callback, color): """Common utility for creating consistent buttons""" button = QtWidgets.QPushButton(text) button.clicked.connect(callback) button.setStyleSheet(f"background-color: {color}; border: 1px solid black; padding: 5px;") button.setFixedSize(200, 50) return button ================================================ FILE: src/brain_constants.py ================================================ """ brain_constants.py - Shared constants for Dosidicus-2 brain system This file defines the canonical neuron categories used across: - brain_widget.py - brain_tool.py - brain_designer.py - brain_neuron_hooks.py Import from here to ensure consistency. """ # ============================================================================= # REQUIRED NEURONS - Mandatory for all brain designs # These 8 neurons MUST exist in any valid Dosidicus brain. # ============================================================================= # Core stat neurons - positions match brain_widget.py's original_neuron_positions CORE_NEURONS = { "hunger": (127, 81), "happiness": (361, 81), "cleanliness": (627, 81), "sleepiness": (840, 81), "satisfaction": (271, 380), "anxiety": (491, 389), "curiosity": (701, 386), } # can_see_food is MANDATORY - the squid must be able to see food # This is separate from optional sensors because it's required for basic functionality MANDATORY_SENSOR = { "can_see_food": (50, 200), } # All required neurons combined (7 core + 1 mandatory sensor) REQUIRED_NEURONS = {**CORE_NEURONS, **MANDATORY_SENSOR} # Ordered list for consistent iteration CORE_NEURON_NAMES = [ "hunger", "happiness", "cleanliness", "sleepiness", "satisfaction", "anxiety", "curiosity" ] REQUIRED_NEURON_NAMES = CORE_NEURON_NAMES + ["can_see_food"] # ============================================================================= # INPUT SENSORS - Optional neurons that receive values from game state # These neurons get their activation from BrainNeuronHooks, not from # neural propagation. They represent environmental/state observations. # NOTE: can_see_food is NOT here - it's in REQUIRED_NEURONS # ============================================================================= INPUT_SENSORS = { "external_stimulus": (50, 50), # Environmental changes (resize, interactions) "plant_proximity": (50, 250), # Distance to nearest plant decoration "threat_level": (50, 350), # Computed from anxiety + startle state "pursuing_food": (150, 50), # Currently chasing food "is_sick": (150, 150), # Sickness state "is_fleeing": (150, 250), # Currently fleeing "is_eating": (150, 350), # Currently eating "is_sleeping": (250, 50), # Currently sleeping "is_startled": (250, 150), # Startled state } # Tuple for compatibility with brain_neuron_hooks.py (includes can_see_food) DEFAULT_INPUT_SENSORS = ( 'external_stimulus', 'can_see_food', # Listed here for hooks compatibility but it's mandatory 'plant_proximity', 'threat_level', 'pursuing_food', 'is_sick', 'is_fleeing', 'is_eating', 'is_sleeping', 'is_startled', ) # ============================================================================= # BINARY NEURONS - Neurons that should display as on/off (0 or 100) # These use bright green/red coloring instead of gradient heat maps. # ============================================================================= BINARY_NEURONS = { 'can_see_food', 'is_eating', 'is_sleeping', 'is_sick', 'pursuing_food', 'is_fleeing', 'is_startled', 'external_stimulus', # Usually high/low, treat as binary-ish } # ============================================================================= # EXCLUDED NEURONS - Status neurons that exist but aren't visualized normally # These are tracked in brain state but not shown in the main visualization. # ============================================================================= EXCLUDED_NEURONS = [ 'is_sick', 'is_eating', 'pursuing_food', 'direction', 'is_sleeping' ] # ============================================================================= # VISUAL STYLE CONSTANTS - For consistent rendering across tools # ============================================================================= # Ring colors for neuron types CORE_NEURON_RING_COLOR = (255, 215, 0) # Gold INPUT_SENSOR_RING_COLOR = (100, 149, 237) # Cornflower blue CUSTOM_NEURON_RING_COLOR = (180, 180, 180) # Gray # Ring widths PROTECTED_RING_WIDTH = 3 NORMAL_RING_WIDTH = 2 # Default neuron colors by type DEFAULT_COLORS = { 'core': (150, 150, 220), # Soft purple 'input': (100, 200, 150), # Soft green 'output': (220, 150, 150), # Soft red 'hidden': (180, 180, 200), # Neutral 'sensor': (150, 200, 220), # Soft blue } # ============================================================================= # LAYER DEFAULTS # ============================================================================= DEFAULT_LAYER_HEIGHT = 120 DEFAULT_LAYER_SPACING = 150 LAYER_COLORS = { 'input': { 'fill': (200, 255, 200, 80), 'border': (150, 220, 150, 120) }, 'output': { 'fill': (255, 200, 200, 80), 'border': (220, 150, 150, 120) }, 'hidden': { 'fill': (230, 230, 255, 80), 'border': (200, 200, 240, 120) } } # ============================================================================= # HELPER FUNCTIONS # ============================================================================= def is_core_neuron(name: str) -> bool: """Check if a neuron name is a core stat neuron (7 stats).""" return name in CORE_NEURONS def is_required_neuron(name: str) -> bool: """Check if a neuron is required (core + can_see_food).""" return name in REQUIRED_NEURONS def is_input_sensor(name: str) -> bool: """Check if a neuron name is an optional input sensor.""" return name in INPUT_SENSORS def is_any_sensor(name: str) -> bool: """Check if a neuron receives values from hooks (including can_see_food).""" return name in INPUT_SENSORS or name in MANDATORY_SENSOR or name in DEFAULT_INPUT_SENSORS def is_binary_neuron(name: str) -> bool: """Check if a neuron should display as binary (on/off).""" return name in BINARY_NEURONS def is_protected_neuron(name: str) -> bool: """Check if a neuron is protected (cannot be deleted or renamed).""" return is_required_neuron(name) def get_neuron_category(name: str) -> str: """Get the category of a neuron: 'core', 'required', 'sensor', or 'custom'.""" if is_core_neuron(name): return 'core' elif name == 'can_see_food': return 'required' elif is_input_sensor(name): return 'sensor' else: return 'custom' def get_all_standard_neurons() -> dict: """Get all standard neurons (required + optional sensors) with positions.""" result = dict(REQUIRED_NEURONS) result.update(INPUT_SENSORS) return result def get_missing_required(existing_neurons: set) -> list: """Get list of required neurons not in the given set.""" return [name for name in REQUIRED_NEURONS if name not in existing_neurons] ================================================ FILE: src/brain_decisions_tab.py ================================================ # src/brain_decisions_tab.py from PyQt5 import QtCore, QtGui, QtWidgets from .brain_base_tab import BrainBaseTab from .display_scaling import DisplayScaling from .localisation import Localisation class DecisionsTab(BrainBaseTab): def __init__(self, parent=None, tamagotchi_logic=None, brain_widget=None, config=None, debug_mode=False): super().__init__(parent, tamagotchi_logic, brain_widget, config, debug_mode) self.loc = Localisation.instance() self.initialize_ui() def initialize_ui(self): """ Initializes the UI with a persistent, non-flickering layout for the decision path and a fixed bar at the bottom for the final action. """ self.layout.setContentsMargins(DisplayScaling.scale(15), DisplayScaling.scale(15), DisplayScaling.scale(15), DisplayScaling.scale(15)) self.layout.setSpacing(DisplayScaling.scale(10)) # Main container main_container = QtWidgets.QWidget() main_container.setObjectName("mainContainer") main_container.setStyleSheet("background-color: #f8f9fa; border-radius: 10px;") main_layout = QtWidgets.QVBoxLayout(main_container) main_layout.setContentsMargins(DisplayScaling.scale(10), DisplayScaling.scale(10), DisplayScaling.scale(10), DisplayScaling.scale(10)) self.layout.addWidget(main_container) # Title title_layout = QtWidgets.QHBoxLayout() title_icon = QtWidgets.QLabel("🧠") title_icon.setStyleSheet(f"font-size: {DisplayScaling.font_size(34)}px;") title_label = QtWidgets.QLabel(self.loc.get("thought_process")) title_label.setStyleSheet(f"font-size: {DisplayScaling.font_size(30)}px; font-weight: bold; color: #343a40;") title_layout.addWidget(title_icon) title_layout.addWidget(title_label) title_layout.addStretch() main_layout.addLayout(title_layout) # Scroll area for the decision path (takes up the expandable space) path_scroll_area = QtWidgets.QScrollArea() path_scroll_area.setWidgetResizable(True) path_scroll_area.setStyleSheet("QScrollArea { border: none; background-color: #f8f9fa; }") main_layout.addWidget(path_scroll_area, 1) path_container = QtWidgets.QWidget() self.path_layout = QtWidgets.QVBoxLayout(path_container) self.path_layout.setSpacing(DisplayScaling.scale(15)) self.path_layout.setAlignment(QtCore.Qt.AlignTop) path_scroll_area.setWidget(path_container) # --- Create persistent widgets and labels for each step --- # Step 1: Current State step1, self.step1_label = self._create_path_step_widget(1, self.loc.get("step1_title"), "📡") self.path_layout.addWidget(step1) self.path_layout.addWidget(self._create_arrow()) # Step 2: Base Urges step2, self.step2_label = self._create_path_step_widget(2, self.loc.get("step2_title"), "⚖️") self.path_layout.addWidget(step2) self.path_layout.addWidget(self._create_arrow()) # Step 3: Personality & Memory step3, self.step3_label = self._create_path_step_widget(3, self.loc.get("step3_title"), "🎭") self.path_layout.addWidget(step3) self.path_layout.addWidget(self._create_arrow()) # Step 4: Final Decision step4, self.step4_label = self._create_path_step_widget(4, self.loc.get("step4_title"), "✅") self.path_layout.addWidget(step4) # --- Final Action Bar (at the bottom) --- final_action_bar = QtWidgets.QFrame() final_action_bar.setObjectName("finalActionBar") final_action_bar.setStyleSheet(""" #finalActionBar { background-color: #e9ecef; border: 1px solid #ced4da; border-radius: 8px; } """) final_action_bar.setFixedHeight(DisplayScaling.scale(60)) bar_layout = QtWidgets.QHBoxLayout(final_action_bar) bar_layout.setContentsMargins(DisplayScaling.scale(15), DisplayScaling.scale(5), DisplayScaling.scale(15), DisplayScaling.scale(5)) action_title_label = QtWidgets.QLabel(f"{self.loc.get('final_action')}") action_title_label.setStyleSheet(f"font-size: {DisplayScaling.font_size(22)}px; color: #495057;") self.final_action_label = QtWidgets.QLabel("...") self.final_action_label.setStyleSheet(f"font-size: {DisplayScaling.font_size(22)}px; font-weight: bold; color: #007bff;") bar_layout.addWidget(action_title_label) bar_layout.addWidget(self.final_action_label) bar_layout.addStretch() main_layout.addWidget(final_action_bar) self.update_path_with_placeholder() def _create_path_step_widget(self, step_number, title, icon): """Creates a styled widget for a single step and returns it and its content label.""" step_widget = QtWidgets.QWidget() step_widget.setObjectName("stepWidget") step_widget.setStyleSheet(f""" #stepWidget {{ background-color: #ffffff; border: 1px solid #dee2e6; border-radius: 8px; padding: {DisplayScaling.scale(10)}px; }} """) step_layout = QtWidgets.QVBoxLayout(step_widget) header_layout = QtWidgets.QHBoxLayout() icon_label = QtWidgets.QLabel(icon) icon_label.setStyleSheet(f"font-size: {DisplayScaling.font_size(30)}px;") title_label = QtWidgets.QLabel(f"{self.loc.get('step')} {step_number}: {title}") title_label.setStyleSheet(f"font-size: {DisplayScaling.font_size(22)}px; color: #495057;") header_layout.addWidget(icon_label) header_layout.addWidget(title_label) header_layout.addStretch() step_layout.addLayout(header_layout) content_label = QtWidgets.QLabel("...") content_label.setWordWrap(True) content_label.setAlignment(QtCore.Qt.AlignTop) content_label.setStyleSheet(f"padding-left: {DisplayScaling.scale(10)}px; padding-top: {DisplayScaling.scale(5)}px; font-size: {DisplayScaling.font_size(19)}px;") step_layout.addWidget(content_label) return step_widget, content_label def update_path_with_placeholder(self): """Sets initial placeholder content on the persistent labels.""" placeholder_text = f"{self.loc.get('awaiting_thought')}" self.step1_label.setText(placeholder_text) self.step2_label.setText(placeholder_text) self.step3_label.setText(placeholder_text) self.step4_label.setText(placeholder_text) self.final_action_label.setText(self.loc.get("awaiting_decision")) def update_from_brain_state(self, state): """Update visualization based on brain state.""" if hasattr(self.tamagotchi_logic, 'get_decision_data'): decision_data = self.tamagotchi_logic.get_decision_data() if decision_data: self.update_decision_path(decision_data) def update_decision_path(self, data): """Updates the content of the persistent step labels and the final action bar.""" final_decision = data.get('final_decision', 'N/A') self._update_state_step(data.get('inputs', {})) self._update_urges_step(data.get('weights', {})) self._update_modifiers_step(data) self._update_final_decision_step(data, final_decision) # Update the bottom bar - translate action name if possible action_key = final_decision.lower().replace(' ', '_') translated_action = self.loc.get(action_key) # If no translation found, use original capitalized if translated_action == action_key: translated_action = final_decision.capitalize() self.final_action_label.setText(translated_action) def _translate_object(self, obj_name): """Translate object name (food, rock, poop, plant)""" key = obj_name.lower() translated = self.loc.get(key) return translated if translated != key else obj_name def _translate_action(self, action_name): """Translate action name""" key = action_name.lower().replace(' ', '_') translated = self.loc.get(key) return translated if translated != key else action_name.capitalize() def _update_state_step(self, inputs): text = f"{self.loc.get('sensing_condition')}
      " if not inputs: text += f"
    • {self.loc.get('no_sensory_data')}
    • " else: visible_items = [] if inputs.get("has_food_visible"): visible_items.append(self.loc.get("food")) if inputs.get("has_rock_visible"): visible_items.append(self.loc.get("rock")) if inputs.get("has_poop_visible"): visible_items.append(self.loc.get("poop")) if inputs.get("has_plant_visible"): visible_items.append(self.loc.get("plant")) if visible_items: text += f"
    • {self.loc.get('visible_objects')}: {', '.join(visible_items)}
    • " else: text += f"
    • {self.loc.get('visible_objects')}: {self.loc.get('none')}
    • " excluded_keys = {"has_food_visible", "has_rock_visible", "has_poop_visible", "has_plant_visible"} for key, value in sorted(inputs.items()): if key not in excluded_keys: formatted_value = f"{value:.2f}" if isinstance(value, float) else str(value) # Try to translate the key display_key = key.replace('_', ' ').capitalize() text += f"
    • {display_key}: {formatted_value}
    • " text += "
    " self.step1_label.setText(text) def _update_urges_step(self, weights): if not weights: self.step2_label.setText(self.loc.get("no_urges")) return strongest_urge = max(weights, key=weights.get) translated_urge = self._translate_action(strongest_urge) text = f"{self.loc.get('strongest_urge')} {translated_urge}.

    {self.loc.get('initial_scores')}" text += "
      " for action, weight in sorted(weights.items(), key=lambda item: item[1], reverse=True): translated_action = self._translate_action(action) text += f"
    • {translated_action}: {weight:.3f}
    • " text += "
    " self.step2_label.setText(text) def _update_modifiers_step(self, data): weights = data.get('weights', {}) adj_weights = data.get('adjusted_weights', {}) text = f"{self.loc.get('personality_memory_adjust')}
      " modified = False for action, final_score in adj_weights.items(): base_score = weights.get(action, final_score) delta = final_score - base_score if abs(delta) > 0.001: direction = self.loc.get("score_increased") if delta > 0 else self.loc.get("score_decreased") color = "#28a745" if delta > 0 else "#dc3545" translated_action = self._translate_action(action) text += f"
    • {self.loc.get('score_for')} {translated_action} {direction} {self.loc.get('by_amount')} {abs(delta):.3f} ({delta:+.3f})
    • " modified = True if not modified: text += f"
    • {self.loc.get('no_adjustments')}
    • " text += "
    " self.step3_label.setText(text) def _update_final_decision_step(self, data, final_decision): confidence = data.get('confidence', 0.0) adj_weights = data.get('adjusted_weights', {}) text = self.loc.get("final_scores_text") text += "
      " if not adj_weights: text += f"
    • {self.loc.get('no_final_scores')}
    • " else: for action, score in sorted(adj_weights.items(), key=lambda item: item[1], reverse=True): translated_action = self._translate_action(action) item_text = f"
    • {translated_action}: {score:.3f}
    • " if action == final_decision: item_text = f"
    • ▶ {translated_action}: {score:.3f}
    • " text += item_text text += "
    " translated_decision = self._translate_action(final_decision) text += f"
    {self.loc.get('squid_decided')} {translated_decision} {self.loc.get('with_confidence')} {confidence:.1%}." self.step4_label.setText(text) def _create_arrow(self): arrow_label = QtWidgets.QLabel("⬇️") arrow_label.setAlignment(QtCore.Qt.AlignCenter) arrow_label.setStyleSheet(f"font-size: {DisplayScaling.font_size(24)}px; color: #adb5bd; margin: -5px 0 -5px 0;") return arrow_label ================================================ FILE: src/brain_designer.py ================================================ #!/usr/bin/env python3 """ Brain Designer - Visual Neural Network Designer for Dosidicus-2 Launch the brain designer GUI application with error handling and logging. """ import sys import os import shutil import argparse # Ensure the designer package can be found script_dir = os.path.dirname(os.path.abspath(__file__)) if script_dir not in sys.path: sys.path.insert(0, script_dir) def perform_cleanup_and_exit(): """Recursively delete __pycache__ directories.""" print("🧹 Cleaning environment...") root_dir = os.path.dirname(os.path.abspath(__file__)) deleted_count = 0 for root, dirs, files in os.walk(root_dir, topdown=True): # Filter and remove specific directories for name in list(dirs): if name == '__pycache__': path = os.path.join(root, name) try: shutil.rmtree(path) print(f" Deleted: {path}") dirs.remove(name) # Prevent os.walk from trying to enter this dir deleted_count += 1 except Exception as e: print(f" ❌ Failed to delete {path}: {e}") print(f"✨ Cleanup complete. Removed {deleted_count} directories.") sys.exit(0) def show_error_dialog(title: str, message: str): """Show an error dialog using PyQt5 if available, otherwise print.""" try: from PyQt5.QtWidgets import QApplication, QMessageBox # Create app if needed (for showing dialog before main app starts) app = QApplication.instance() if app is None: app = QApplication(sys.argv) msg_box = QMessageBox() msg_box.setIcon(QMessageBox.Critical) msg_box.setWindowTitle(title) msg_box.setText(message) msg_box.setStandardButtons(QMessageBox.Ok) msg_box.exec_() except Exception: # Fallback to console print(f"\n{'='*60}") print(f"ERROR: {title}") print('='*60) print(message) print('='*60 + "\n") def check_dependencies(): """Check that required dependencies are available.""" missing = [] try: import PyQt5 except ImportError: missing.append("PyQt5") if missing: msg = ( f"Missing required dependencies:\n\n" f" {', '.join(missing)}\n\n" f"Please install with:\n" f" pip install {' '.join(missing)}" ) show_error_dialog("Missing Dependencies", msg) sys.exit(1) def main(): """Main entry point with full error handling.""" # Parse command line arguments parser = argparse.ArgumentParser(description="Brain Designer - Visual Neural Network Designer") parser.add_argument('-c', '--clean', action='store_true', help='Clean __pycache__ folders from designer directory before starting') parser.add_argument('-d', '--debug', action='store_true', help='Enable debug mode with logging') # Use parse_known_args so we don't choke if Qt arguments are passed implicitly args, _ = parser.parse_known_args() # Perform cleanup if requested if args.clean: perform_cleanup_and_exit() # Check dependencies first check_dependencies() # Import logging after dependency check from src.designer_logging import ( initialize_error_handling, get_logger, OperationLogger ) # Initialize error handling and logging (only create log files in debug mode) crash_reporter = initialize_error_handling(enable_logging=args.debug) logger = get_logger() # Set up error dialog callback crash_reporter.set_error_dialog_callback(show_error_dialog) if args.debug: logger.info("Starting Brain Designer application (debug mode)") try: from PyQt5.QtWidgets import QApplication from PyQt5.QtGui import QColor, QPalette from PyQt5.QtCore import Qt # Enable HiDPI scaling — must be set before QApplication is created QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) app = QApplication(sys.argv) app.setApplicationName("Brain Designer (Beta)") app.setApplicationVersion("1.1.0") # Enforce minimum readable font size across all widgets _app_font = app.font() if _app_font.pointSize() < 10: _app_font.setPointSize(10) app.setFont(_app_font) # Light theme setup app.setStyle('Fusion') palette = app.palette() palette.setColor(QPalette.Window, QColor(240, 240, 245)) palette.setColor(QPalette.WindowText, QColor(40, 40, 50)) palette.setColor(QPalette.Base, QColor(255, 255, 255)) palette.setColor(QPalette.AlternateBase, QColor(245, 245, 250)) palette.setColor(QPalette.ToolTipBase, QColor(255, 255, 230)) palette.setColor(QPalette.ToolTipText, QColor(40, 40, 50)) palette.setColor(QPalette.Text, QColor(40, 40, 50)) palette.setColor(QPalette.Button, QColor(240, 240, 245)) palette.setColor(QPalette.ButtonText, QColor(40, 40, 50)) palette.setColor(QPalette.BrightText, QColor(255, 0, 0)) palette.setColor(QPalette.Highlight, QColor(70, 130, 200)) palette.setColor(QPalette.HighlightedText, QColor(255, 255, 255)) app.setPalette(palette) from src.designer_window import BrainDesignerWindow window = BrainDesignerWindow() if args.debug: logger.info("Showing main window") window.show() if args.debug: logger.info("Entering event loop") exit_code = app.exec_() if args.debug: logger.info(f"Application exited with code {exit_code}") sys.exit(exit_code) except ImportError as e: if args.debug: logger.critical(f"Import error: {e}") show_error_dialog( "Import Error", f"Failed to import required module:\n\n{e}\n\n" f"Please ensure all dependencies are installed." ) sys.exit(1) except Exception as e: if args.debug: logger.critical(f"Startup error: {e}", exc_info=True) show_error_dialog( "Startup Error", f"Failed to start Brain Designer:\n\n{e}\n\n" f"Check the logs folder for details." if args.debug else f"Failed to start Brain Designer:\n\n{e}" ) sys.exit(1) if __name__ == '__main__': main() ================================================ FILE: src/brain_designer_launcher.py ================================================ """ Brain Designer Launcher - Spawns the designer in a subprocess This module provides a function to launch the Brain Designer tool from the main game process, optionally passing debug mode. """ import sys import os def launch_brain_designer_process(debug_mode: bool = False): """ Entry point for Brain Designer when launched as a subprocess. Args: debug_mode: If True, enables logging in the designer """ # Build command line args if debug_mode: if '-d' not in sys.argv: sys.argv.append('-d') # Import and run the designer's main function from src.brain_designer import main as designer_main designer_main() ================================================ FILE: src/brain_dialogs.py ================================================ import sys import csv import os import time from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtGui import QPixmap, QFont class StimulateDialog(QtWidgets.QDialog): def __init__(self, brain_widget, parent=None): super().__init__(parent) self.brain_widget = brain_widget self.current_neuron = None from .display_scaling import DisplayScaling self.setWindowTitle("Neuron Inspector") self.setFixedSize(DisplayScaling.scale(600), DisplayScaling.scale(500)) # Main layout layout = QtWidgets.QVBoxLayout() self.setLayout(layout) # Style all text properly self.setStyleSheet(f""" QLabel, QComboBox, QPushButton {{ font-size: {DisplayScaling.font_size(12)}px; }} QTextEdit, QListWidget {{ font-size: {DisplayScaling.font_size(12)}px; line-height: {DisplayScaling.scale(1.5)}; }} """) # Neuron info section self.info_group = QtWidgets.QGroupBox("Neuron Information") self.info_layout = QtWidgets.QFormLayout() self.info_group.setLayout(self.info_layout) layout.addWidget(self.info_group) # Info fields self.name_label = QtWidgets.QLabel() self.state_label = QtWidgets.QLabel() self.position_label = QtWidgets.QLabel() self.type_label = QtWidgets.QLabel() self.info_layout.addRow("Name:", self.name_label) self.info_layout.addRow("Current State:", self.state_label) self.info_layout.addRow("Position:", self.position_label) self.info_layout.addRow("Type:", self.type_label) # Connections table self.connections_group = QtWidgets.QGroupBox("Connections") self.connections_layout = QtWidgets.QVBoxLayout() self.connections_group.setLayout(self.connections_layout) layout.addWidget(self.connections_group) self.connections_table = QtWidgets.QTableWidget() self.connections_table.setColumnCount(5) self.connections_table.setHorizontalHeaderLabels([ "Neuron", "Direction", "Weight", "Strength", "State" ]) self.connections_table.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch) self.connections_table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.connections_table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) self.connections_layout.addWidget(self.connections_table) # Activity graph self.activity_group = QtWidgets.QGroupBox("Activity History") self.activity_layout = QtWidgets.QVBoxLayout() self.activity_group.setLayout(self.activity_layout) layout.addWidget(self.activity_group) self.activity_plot = QtWidgets.QGraphicsView() self.activity_scene = QtWidgets.QGraphicsScene() self.activity_plot.setScene(self.activity_scene) self.activity_layout.addWidget(self.activity_plot) # Close button self.close_button = QtWidgets.QPushButton("Close") self.close_button.clicked.connect(self.close) layout.addWidget(self.close_button) # Connect to brain widget's neuron click signal if hasattr(brain_widget, 'neuronClicked'): brain_widget.neuronClicked.connect(self.inspect_neuron) self.buttons = QtWidgets.QDialogButtonBox( QtWidgets.QDialogButtonBox.Ok | QtWidgets.QDialogButtonBox.Cancel ) self.buttons.accepted.connect(self.validate_and_accept) self.buttons.rejected.connect(self.reject) self.layout.addWidget(self.buttons) def validate_and_accept(self): """Validate all fields before accepting the dialog""" try: # Validate all spinbox values for neuron, widget in self.neuron_inputs.items(): if isinstance(widget, QtWidgets.QSpinBox): value = widget.value() if value < 0 or value > 100: QtWidgets.QMessageBox.warning( self, "Invalid Value", f"{neuron} must be between 0 and 100" ) return # If all validations pass, accept the dialog self.accept() except Exception as e: QtWidgets.QMessageBox.critical( self, "Validation Error", f"An error occurred during validation: {str(e)}" ) def get_stimulation_values(self): """Get stimulation values with proper type conversion""" stimulation_values = {} for neuron, input_widget in self.neuron_inputs.items(): if isinstance(input_widget, QtWidgets.QSpinBox): stimulation_values[neuron] = input_widget.value() elif isinstance(input_widget, QtWidgets.QComboBox): text = input_widget.currentText() if text.lower() == 'true': stimulation_values[neuron] = True elif text.lower() == 'false': stimulation_values[neuron] = False else: stimulation_values[neuron] = text return stimulation_values class RecentThoughtsDialog(QtWidgets.QDialog): def __init__(self, thought_log, parent=None): super().__init__(parent) self.setWindowTitle("Recent Decisions") self.thought_log = thought_log layout = QtWidgets.QVBoxLayout() self.setLayout(layout) # List widget to display recent thoughts self.thought_list = QtWidgets.QListWidget() self.thought_list.setSelectionMode(QtWidgets.QAbstractItemView.MultiSelection) layout.addWidget(self.thought_list) # Populate the list with summarized thought logs for log in self.thought_log: summary = f"Time: {log.get('timestamp', 'Unknown')} - Decision: {log.get('decision', 'Unknown')}" self.thought_list.addItem(summary) # Button layout button_layout = QtWidgets.QHBoxLayout() # Save button self.save_button = QtWidgets.QPushButton("Save Selected") self.save_button.clicked.connect(self.save_selected_thoughts) button_layout.addWidget(self.save_button) # Clear button self.clear_button = QtWidgets.QPushButton("Clear") self.clear_button.clicked.connect(self.clear_all_logs) button_layout.addWidget(self.clear_button) layout.addLayout(button_layout) def save_selected_thoughts(self): selected_items = self.thought_list.selectedItems() if not selected_items: QtWidgets.QMessageBox.information(self, "No Selection", "No decisions selected to save.") return # Get the file name to save the selected thoughts file_name, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save Selected decisions", "", "Text Files (*.txt)") if file_name: with open(file_name, 'w') as file: for item in selected_items: file.write(item.text() + "\n") QtWidgets.QMessageBox.information(self, "Save Successful", f"Selected decisions saved to {file_name}") def clear_all_logs(self): # Confirm before clearing reply = QtWidgets.QMessageBox.question( self, 'Clear Logs', "Are you sure you want to clear all decision logs?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No ) if reply == QtWidgets.QMessageBox.Yes: # Clear the logs in the parent window if hasattr(self.parent(), 'thought_log'): self.parent().thought_log.clear() self.thought_list.clear() QtWidgets.QMessageBox.information(self, "Logs Cleared", "All decision logs have been cleared.") class LogWindow(QtWidgets.QWidget): def __init__(self, parent=None): super().__init__(parent) self.setWindowTitle("Learning Log") self.resize(640, 480) layout = QtWidgets.QVBoxLayout() self.setLayout(layout) self.log_text = QtWidgets.QTextEdit() self.log_text.setReadOnly(True) layout.addWidget(self.log_text) self.export_button = QtWidgets.QPushButton("Export Log") self.export_button.clicked.connect(self.export_log) layout.addWidget(self.export_button) def update_log(self, text): self.log_text.append(text) def export_log(self): file_name, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Export Log", "", "Text Files (*.txt)") if file_name: with open(file_name, 'w') as f: f.write(self.log_text.toPlainText()) QtWidgets.QMessageBox.information(self, "Export Successful", f"Log exported to {file_name}") class DiagnosticReportDialog(QtWidgets.QDialog): def __init__(self, brain_widget, parent=None): super().__init__(parent) self.setWindowTitle("Network Health Diagnosis") self.setMinimumSize(640, 800) self.brain_widget = brain_widget self.history_data = parent.tamagotchi_logic.get_health_history() if hasattr(parent, 'tamagotchi_logic') else [] self.layout = QtWidgets.QVBoxLayout() self.setLayout(self.layout) # Create tab widget self.tabs = QtWidgets.QTabWidget() self.layout.addWidget(self.tabs) # Create report tabs self.create_connections_tab() self.create_neurons_tab() self.create_balance_tab() # Add history graph section self.create_history_section() # Add close button self.close_button = QtWidgets.QPushButton("Close") self.close_button.clicked.connect(self.close) self.layout.addWidget(self.close_button) def create_connections_tab(self): tab = QtWidgets.QWidget() layout = QtWidgets.QVBoxLayout(tab) # Get the weakest connections weakest = self.brain_widget.get_weakest_connections() # Connections group connections_group = QtWidgets.QGroupBox("Weakest Connections") connections_layout = QtWidgets.QVBoxLayout() # Create the table table = QtWidgets.QTableWidget() table.setColumnCount(3) table.setHorizontalHeaderLabels(["Source", "Target", "Weight"]) # Populate table with weakest connections table.setRowCount(len(weakest)) for i, conn_data in enumerate(weakest): try: # Handle different possible return formats if isinstance(conn_data, tuple) and len(conn_data) == 2 and isinstance(conn_data[0], tuple): # Format: ((source, target), weight) (a, b), weight = conn_data elif isinstance(conn_data, tuple) and len(conn_data) == 3: # Format: (source, target, weight) a, b, weight = conn_data else: # Unknown format, skip continue table.setItem(i, 0, QtWidgets.QTableWidgetItem(str(a))) table.setItem(i, 1, QtWidgets.QTableWidgetItem(str(b))) table.setItem(i, 2, QtWidgets.QTableWidgetItem(f"{weight:.3f}")) # Color code based on weight color = QtGui.QColor("green" if weight > 0 else "red") table.item(i, 2).setForeground(color) except Exception as e: print(f"Error processing connection: {conn_data}, Error: {e}") continue connections_layout.addWidget(table) connections_group.setLayout(connections_layout) layout.addWidget(connections_group) # Add to tabs self.tabs.addTab(tab, "Connections") def create_neurons_tab(self): tab = QtWidgets.QWidget() layout = QtWidgets.QVBoxLayout() label = QtWidgets.QLabel("

    Neuron Activity Report

    ") layout.addWidget(label) extremes = self.brain_widget.get_extreme_neurons(3) report_text = "OVERACTIVE NEURONS:\n" for name, val in extremes['overactive']: report_text += f"{name}: {val:.0f}%\n" report_text += "\nUNDERACTIVE NEURONS:\n" for name, val in extremes['underactive']: report_text += f"{name}: {val:.0f}%\n" text_edit = QtWidgets.QTextEdit() text_edit.setPlainText(report_text) text_edit.setReadOnly(True) layout.addWidget(text_edit) tab.setLayout(layout) self.tabs.addTab(tab, "Neurons") def create_balance_tab(self): tab = QtWidgets.QWidget() layout = QtWidgets.QVBoxLayout() label = QtWidgets.QLabel("

    Connection Balance Report

    ") layout.addWidget(label) unbalanced = self.brain_widget.get_unbalanced_connections(5) report_text = "UNBALANCED CONNECTIONS:\n\n" for (a, b), (w1, w2), diff in unbalanced: report_text += f"{a}→{b}: {w1:.2f}\n" report_text += f"{b}→{a}: {w2:.2f} (Δ{diff:.2f})\n\n" text_edit = QtWidgets.QTextEdit() text_edit.setPlainText(report_text) text_edit.setReadOnly(True) layout.addWidget(text_edit) tab.setLayout(layout) self.tabs.addTab(tab, "Balance") def create_history_section(self): group = QtWidgets.QGroupBox("Health History") layout = QtWidgets.QVBoxLayout() # Add toggle checkbox #self.show_history_check = QtWidgets.QCheckBox("Show historical trends") #self.show_history_check.toggled.connect(self.toggle_history_graph) #layout.addWidget(self.show_history_check) # Placeholder for graph #self.history_graph = QtWidgets.QLabel("Graph will appear here when enabled") #self.history_graph.setAlignment(QtCore.Qt.AlignCenter) #self.history_graph.setMinimumHeight(200) #layout.addWidget(self.history_graph) #group.setLayout(layout) self.layout.addWidget(group) def toggle_history_graph(self, checked): if checked and self.history_data: # In a real implementation, you'd generate an actual graph here timestamps = [x[0] for x in self.history_data] values = [x[1] for x in self.history_data] # This is placeholder - you'd use matplotlib or similar in practice graph_text = "HEALTH TREND:\n\n" for t, v in zip(timestamps[-10:], values[-10:]): graph_text += f"{t}: {'='*int(v/10)}{v:.0f}%\n" self.history_graph.setText(graph_text) else: self.history_graph.setText("Graph will appear here when enabled") ================================================ FILE: src/brain_learning_tab.py ================================================ from PyQt5 import QtCore, QtGui, QtWidgets from .brain_base_tab import BrainBaseTab import random import time from .localisation import Localisation try: from display_scaling import DisplayScaling except ImportError: class DisplayScaling: @classmethod def font_size(cls, size): return size @classmethod def scale_css(cls, css): return css class NeuralNetworkVisualizerTab(BrainBaseTab): def __init__(self, parent=None, tamagotchi_logic=None, brain_widget=None, config=None, debug_mode=False): # Ensure brain_widget is not None if brain_widget is None: print("WARNING: Brain widget is None. Creating a placeholder.") from .brain_widget import BrainWidget brain_widget = BrainWidget() # Call parent's __init__ super().__init__(parent, tamagotchi_logic, brain_widget, config, debug_mode) # Explicit attribute initialization self.countdown_label = None self.countdown_timer = None self.tab_widget = None self.edu_views = {} self.current_countdown = 30 self.learning_history = [] self.recent_pairs = [] # Card display areas self.learning_scroll = None self.learning_content = None self.learning_content_layout = None # Blink timer for Hebbian label self.blink_timer = QtCore.QTimer(self) self.blink_timer.setInterval(500) # 0.5 s toggle self.blink_timer.timeout.connect(self._blink_hebbian_label) self._blink_visible = True self.setup_ui() def pre_load_data(self): """Pre-load data and initialize UI elements to make tab responsive on first click""" # Update educational content for each tab if hasattr(self, 'edu_views'): for tab_name, edu_view in self.edu_views.items(): self.update_educational_content(tab_name=tab_name) # Pre-initialize learning cards if hasattr(self, 'learning_content_layout'): # Add placeholder to initialize rendering loc = Localisation.instance() placeholder = self._create_info_card( loc.get("learning_ready"), loc.get("learning_ready_desc"), "#e3f2fd" ) self.learning_content_layout.addWidget(placeholder) # Pre-compute any expensive operations if hasattr(self, 'brain_widget') and hasattr(self.brain_widget, 'weights'): sample_state = self.brain_widget.state.copy() if hasattr(self.brain_widget, 'state') else {} self.update_from_brain_state(sample_state) def setup_ui(self): loc = Localisation.instance() # Remove existing widgets from the layout if hasattr(self, '_layout'): while self.layout.count(): item = self.layout.takeAt(0) if item.widget(): widget = item.widget() self.layout.removeWidget(widget) widget.deleteLater() # Main vertical layout main_layout = QtWidgets.QVBoxLayout() self.layout.addLayout(main_layout) # Create tab widget for different sections self.tab_widget = QtWidgets.QTabWidget() tab_font = QtGui.QFont() tab_font.setPointSize(DisplayScaling.font_size(11)) self.tab_widget.setFont(tab_font) self.tab_widget.setStyleSheet(f""" QTabWidget::pane {{ border: 2px solid #e1e5eb; border-radius: 12px; background-color: #f8f9fa; }} QTabBar::tab {{ background: #f8f9fa; border: 1px solid #e1e5eb; padding: 10px 20px; min-width: 120px; margin-right: 5px; border-top-left-radius: 8px; border-top-right-radius: 8px; font-size: {DisplayScaling.font_size(11)}pt; color: #2c3e50; }} QTabBar::tab:selected {{ background: #ffffff; border-bottom: none; font-weight: 600; }} QTabBar::tab:hover {{ background: #e9ecef; }} """) # ====== LEARNING PAIRS TAB ====== self.learning_tab = QtWidgets.QWidget() learning_tab_layout = QtWidgets.QVBoxLayout(self.learning_tab) # Header with title and Hebbian countdown header_layout = QtWidgets.QHBoxLayout() header_label = QtWidgets.QLabel( f"

    {loc.get('active_learning_pairs')}

    " ) header_layout.addWidget(header_label) header_layout.addStretch() # Hebbian countdown timer label self.hebbian_timer_label_learning = QtWidgets.QLabel(f"{loc.get('hebbian_cycle')}: --") self.hebbian_timer_label_learning.setStyleSheet(""" font-size: 16px; font-weight: 600; color: #2c3e50; padding: 5px 10px; background-color: #e3f2fd; border-radius: 6px; border: 1px solid #90caf9; """) header_layout.addWidget(self.hebbian_timer_label_learning) learning_tab_layout.addLayout(header_layout) # Scroll area for learning cards self.learning_scroll = QtWidgets.QScrollArea() self.learning_scroll.setWidgetResizable(True) self.learning_scroll.setStyleSheet(""" QScrollArea { border: none; background-color: #f8f9fa; } """) self.learning_content = QtWidgets.QWidget() self.learning_content_layout = QtWidgets.QVBoxLayout(self.learning_content) self.learning_content_layout.setSpacing(15) self.learning_content_layout.setContentsMargins(10, 10, 10, 10) self.learning_content_layout.addStretch() self.learning_scroll.setWidget(self.learning_content) learning_tab_layout.addWidget(self.learning_scroll) # ====== OVERVIEW TAB ====== overview_tab = QtWidgets.QWidget() overview_layout = QtWidgets.QVBoxLayout(overview_tab) overview_scroll = QtWidgets.QScrollArea() overview_scroll.setWidgetResizable(True) overview_content = QtWidgets.QWidget() overview_content_layout = QtWidgets.QVBoxLayout(overview_content) # Create overview cards overview_card = self._create_educational_card( loc.get("hebbian_overview"), f"""
    {loc.get("hebbian_quote")}

    {loc.get("hebbian_principle")}

    {loc.get("hebbian_explanation")}

    {loc.get("excitatory_connections")}
    {loc.get("excitatory_desc")}
    {loc.get("inhibitory_connections")}
    {loc.get("inhibitory_desc")}
    {loc.get("in_practice_title")}

    {loc.get("in_practice_text")}

    """, "#ffffff" ) overview_content_layout.addWidget(overview_card) overview_scroll.setWidget(overview_content) overview_layout.addWidget(overview_scroll) # ====== MECHANICS TAB ====== mechanics_tab = QtWidgets.QWidget() mechanics_layout = QtWidgets.QVBoxLayout(mechanics_tab) mechanics_scroll = QtWidgets.QScrollArea() mechanics_scroll.setWidgetResizable(True) mechanics_content = QtWidgets.QWidget() mechanics_content_layout = QtWidgets.QVBoxLayout(mechanics_content) mechanics_card = self._create_educational_card( loc.get("mechanics_title"), f"""

    {loc.get("mechanics_intro")}

    {loc.get("learning_rule_title")}

    Δw = η × x × y

    {loc.get("where_label")}

    • {loc.get("delta_w_desc")}
    • {loc.get("eta_desc")}
    • {loc.get("activation_desc")}

    {loc.get("example_calc_title")}

    {loc.get("scenario_label")}

    • x = 1
    • y = 1
    • η = 0.1
    Δw = 0.1 × 1 × 1 = 0.1

    {loc.get("calc_result")}

    {loc.get("over_time_title")}

    {loc.get("over_time_text")}

    """, "#ffffff" ) mechanics_content_layout.addWidget(mechanics_card) mechanics_scroll.setWidget(mechanics_content) mechanics_layout.addWidget(mechanics_scroll) # Add all tabs self.tab_widget.addTab(self.learning_tab, loc.get("learning_pairs_tab")) self.tab_widget.addTab(overview_tab, loc.get("overview")) self.tab_widget.addTab(mechanics_tab, loc.get("mechanics_tab")) main_layout.addWidget(self.tab_widget) def _create_educational_card(self, title, content, bg_color): """Create an educational information card""" card = QtWidgets.QWidget() card.setStyleSheet(f""" QWidget {{ background-color: {bg_color}; border-radius: 12px; border: 1px solid #e1e5eb; }} """) card_layout = QtWidgets.QVBoxLayout(card) card_layout.setContentsMargins(25, 25, 25, 25) # Title title_label = QtWidgets.QLabel(f"

    {title}

    ") card_layout.addWidget(title_label) # Content content_label = QtWidgets.QTextBrowser() content_label.setHtml(content) content_label.setOpenExternalLinks(True) content_label.setStyleSheet(""" QTextBrowser { background-color: transparent; border: none; font-size: 16px; } """) card_layout.addWidget(content_label) return card def _create_info_card(self, title, description, color): """Create a simple info card""" card = QtWidgets.QWidget() card.setStyleSheet(f""" QWidget {{ background-color: {color}; border-radius: 10px; padding: 15px; border: 1px solid #dee2e6; }} """) card_layout = QtWidgets.QVBoxLayout(card) card_layout.setSpacing(8) title_label = QtWidgets.QLabel(f"{title}") card_layout.addWidget(title_label) desc_label = QtWidgets.QLabel(description) desc_label.setWordWrap(True) desc_label.setStyleSheet(f"color: #495057; font-size: {DisplayScaling.font_size(14)}px;") card_layout.addWidget(desc_label) return card def _create_learning_pair_card(self, pair, weight, weight_change=None, stdp_meta=None): """Create a card displaying a learning pair""" loc = Localisation.instance() # Determine colors based on weight if weight > 0.5: border_color = "#4caf50" bg_color = "#e8f5e9" weight_color = "#2e7d32" strength = loc.get("str_excitatory") elif weight > 0: border_color = "#8bc34a" bg_color = "#f1f8e9" weight_color = "#558b2f" strength = loc.get("weak_excitatory") elif weight > -0.5: border_color = "#ff9800" bg_color = "#fff3e0" weight_color = "#e65100" strength = loc.get("weak_inhibitory") else: border_color = "#f44336" bg_color = "#ffebee" weight_color = "#c62828" strength = loc.get("str_inhibitory") # Weight change indicator change_indicator = "" if weight_change == "increase": change_indicator = f"" elif weight_change == "decrease": change_indicator = f"" # STDP: resolve directional arrow and LTP/LTD info stdp_direction = stdp_meta.get('stdp_direction', 'none') if stdp_meta else 'none' if stdp_direction == 'n1_to_n2': arrow_char = "→" elif stdp_direction == 'n2_to_n1': arrow_char = "←" else: arrow_char = "↔" card = QtWidgets.QWidget() card.setStyleSheet(f""" QWidget {{ background-color: {bg_color}; border-radius: 12px; border: 2px solid {border_color}; }} QWidget:hover {{ border: 3px solid {self.darken_color(border_color, 20)}; background-color: {self.darken_color(bg_color, 5)}; }} """) card_layout = QtWidgets.QVBoxLayout(card) card_layout.setContentsMargins(25, 20, 25, 20) card_layout.setSpacing(10) # Top row - connection visualization connection_layout = QtWidgets.QHBoxLayout() connection_layout.setSpacing(20) # Neuron 1 - large and prominent neuron1_label = QtWidgets.QLabel(f"{pair[0].upper()}") neuron1_label.setAlignment(QtCore.Qt.AlignCenter) connection_layout.addWidget(neuron1_label, 1) # Arrow and weight - centered arrow_widget = QtWidgets.QWidget() arrow_layout = QtWidgets.QVBoxLayout(arrow_widget) arrow_layout.setSpacing(5) arrow_layout.setContentsMargins(0, 0, 0, 0) arrow_label = QtWidgets.QLabel(f"{arrow_char}") arrow_label.setAlignment(QtCore.Qt.AlignCenter) arrow_layout.addWidget(arrow_label) weight_display = QtWidgets.QLabel( f"{weight:.3f}" ) weight_display.setAlignment(QtCore.Qt.AlignCenter) arrow_layout.addWidget(weight_display) connection_layout.addWidget(arrow_widget, 0) # Neuron 2 - large and prominent neuron2_label = QtWidgets.QLabel(f"{pair[1].upper()}") neuron2_label.setAlignment(QtCore.Qt.AlignCenter) connection_layout.addWidget(neuron2_label, 1) card_layout.addLayout(connection_layout) # STDP badge row (only when STDP contributed a timing signal) if stdp_meta and stdp_direction != 'none': is_ltp = stdp_meta.get('is_ltp', False) is_ltd = stdp_meta.get('is_ltd', False) stdp_delta = stdp_meta.get('stdp_delta', 0.0) stdp_weight = stdp_meta.get('stdp_weight', 0.0) if is_ltp or is_ltd: badge_row = QtWidgets.QHBoxLayout() badge_row.setSpacing(8) # LTP / LTD pill if is_ltp: ltp_ltd_color = "#00897b" ltp_ltd_bg = "#e0f2f1" ltp_ltd_border = "#80cbc4" ltp_ltd_text = "⚡ LTP" else: ltp_ltd_color = "#c62828" ltp_ltd_bg = "#ffebee" ltp_ltd_border = "#ef9a9a" ltp_ltd_text = "⚡ LTD" badge = QtWidgets.QLabel(ltp_ltd_text) badge.setStyleSheet(f""" font-size: {DisplayScaling.font_size(14)}px; font-weight: 700; color: {ltp_ltd_color}; background: {ltp_ltd_bg}; border: 1px solid {ltp_ltd_border}; border-radius: 4px; padding: 3px 9px; """) badge_row.addWidget(badge) # Delta value sign = "+" if stdp_delta >= 0 else "" delta_label = QtWidgets.QLabel( f"" f"STDP Δ {sign}{stdp_delta:.4f}  ·  " f"blend {int(stdp_weight * 100)}% spike-timing" ) badge_row.addWidget(delta_label) badge_row.addStretch() card_layout.addLayout(badge_row) # Bottom row - metadata meta_layout = QtWidgets.QHBoxLayout() strength_label = QtWidgets.QLabel( f"{strength}{change_indicator}" ) meta_layout.addWidget(strength_label) meta_layout.addStretch() timestamp_label = QtWidgets.QLabel( f"{time.strftime('%H:%M:%S')}" ) meta_layout.addWidget(timestamp_label) card_layout.addLayout(meta_layout) return card def create_custom_button(self, text, callback, color, font_size=14): """Create a button with custom styling""" button = QtWidgets.QPushButton(text) button.clicked.connect(callback) button.setStyleSheet(f""" QPushButton {{ font-size: {font_size}px; padding: 8px 16px; border-radius: 6px; font-weight: 500; border: 2px solid {color}; background-color: {color}; color: white; min-width: 100px; }} QPushButton:hover {{ background-color: {self.darken_color(color, 20)}; border: 2px solid {self.darken_color(color, 20)}; }} """) button.setCursor(QtGui.QCursor(QtCore.Qt.PointingHandCursor)) return button def darken_color(self, hex_color, percent): """Darken a hex color by a percentage""" hex_color = hex_color.lstrip('#') r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16) r = max(0, int(r * (100 - percent) / 100)) g = max(0, int(g * (100 - percent) / 100)) b = max(0, int(b * (100 - percent) / 100)) return f'#{r:02x}{g:02x}{b:02x}' def add_log_entry(self, message, pair=None, weight_change=None, stdp_meta=None): """Add a new learning pair card to the display""" if pair and hasattr(self, 'learning_content_layout'): # Remove placeholder if it exists if self.learning_content_layout.count() == 1: item = self.learning_content_layout.takeAt(0) if item and item.widget(): item.widget().deleteLater() # Get weight weight = getattr(self.brain_widget, 'weights', {}).get(pair, 0) # Create card card = self._create_learning_pair_card(pair, weight, weight_change, stdp_meta) # Insert at the top (before stretch) self.learning_content_layout.insertWidget(0, card) # Keep only last 20 cards while self.learning_content_layout.count() > 21: # 20 cards + 1 stretch item = self.learning_content_layout.takeAt(20) if item and item.widget(): item.widget().deleteLater() # Update history if pair not in self.learning_history: self.learning_history.append(pair) self.recent_pairs.append(pair) def clear_log(self): """Clear all learning pair cards""" loc = Localisation.instance() if hasattr(self, 'learning_content_layout'): while self.learning_content_layout.count() > 1: # Keep the stretch item = self.learning_content_layout.takeAt(0) if item and item.widget(): item.widget().deleteLater() self.recent_pairs = [] self.learning_history = [] # Add info card placeholder = self._create_info_card( loc.get("log_cleared"), loc.get("log_cleared_desc"), "#e3f2fd" ) self.learning_content_layout.insertWidget(0, placeholder) def update_educational_content(self, pair=None, tab_name=None): """Update educational content - kept for compatibility""" pass def update_from_brain_state(self, state): """Update display based on brain state changes""" if hasattr(self.brain_widget, 'recently_updated_neuron_pairs'): for pair in self.brain_widget.recently_updated_neuron_pairs: if pair not in self.learning_history: weight = getattr(self.brain_widget, 'weights', {}).get(pair, 0) # Determine weight change prev_weight = self.brain_widget.weights.get(pair, 0) weight_change = None if weight > prev_weight: weight_change = "increase" elif weight < prev_weight: weight_change = "decrease" self.add_log_entry("", pair, weight_change) # Sync Hebbian timer from brain_widget if hasattr(self.brain_widget, 'hebbian_countdown_seconds'): self.update_hebbian_label_learning(self.brain_widget.hebbian_countdown_seconds) def update_hebbian_label_learning(self, value): """Update the Hebbian countdown label and handle blinking when <5s""" loc = Localisation.instance() if hasattr(self, 'hebbian_timer_label_learning'): # Check if simulation is paused is_paused = False if hasattr(self, 'tamagotchi_logic') and hasattr(self.tamagotchi_logic, 'simulation_speed'): is_paused = (self.tamagotchi_logic.simulation_speed == 0) # Display PAUSE if paused, otherwise show countdown if is_paused: self.hebbian_timer_label_learning.setText(f"{loc.get('hebbian_cycle')}: {loc.get('hebbian_paused')}") # Stop blinking when paused if self.blink_timer.isActive(): self.blink_timer.stop() self.hebbian_timer_label_learning.setVisible(True) else: self.hebbian_timer_label_learning.setText(f"{loc.get('hebbian_cycle')}: {value}s") # Start/stop blinking if isinstance(value, int) and value < 5: if not self.blink_timer.isActive(): self.blink_timer.start() else: if self.blink_timer.isActive(): self.blink_timer.stop() # Ensure visible when not blinking self.hebbian_timer_label_learning.setVisible(True) def _blink_hebbian_label(self): """Toggle visibility of the Hebbian label to create blink effect""" self._blink_visible = not self._blink_visible self.hebbian_timer_label_learning.setVisible(self._blink_visible) ================================================ FILE: src/brain_memory_tab.py ================================================ from PyQt5 import QtCore, QtGui, QtWidgets from .brain_base_tab import BrainBaseTab from .brain_ui_utils import UiUtils from .localisation import Localisation # Import Localisation from datetime import datetime class MemoryTab(BrainBaseTab): def __init__(self, parent=None, tamagotchi_logic=None, brain_widget=None, config=None, debug_mode=False): # Initialize localisation self.loc = Localisation.instance() super().__init__(parent, tamagotchi_logic, brain_widget, config, debug_mode) self.initialize_ui() def initialize_ui(self): """Initialize the memory tab with sub-tabs and card-based display""" # Print debug info print(f"MemoryTab initialize_ui: tamagotchi_logic is {self.tamagotchi_logic is not None}") # Create sub-tabs for different memory types self.memory_subtabs = QtWidgets.QTabWidget() self.memory_subtabs.setFont(QtGui.QFont("Arial", 12)) # Short-term memory tab self.stm_tab = QtWidgets.QWidget() self.stm_layout = QtWidgets.QVBoxLayout(self.stm_tab) # Long-term memory tab self.ltm_tab = QtWidgets.QWidget() self.ltm_layout = QtWidgets.QVBoxLayout(self.ltm_tab) # Overview tab self.overview_tab = QtWidgets.QWidget() self.overview_layout = QtWidgets.QVBoxLayout(self.overview_tab) # Add tabs with LOCALIZED names # We keep the emojis but translate the text self.memory_subtabs.addTab(self.stm_tab, f"🧠 {self.loc.get('short_term_memory')}") self.memory_subtabs.addTab(self.ltm_tab, f"📚 {self.loc.get('long_term_memory')}") self.memory_subtabs.addTab(self.overview_tab, f"📊 {self.loc.get('overview')}") # Configure STM tab self.stm_scroll = QtWidgets.QScrollArea() self.stm_scroll.setWidgetResizable(True) self.stm_content = QtWidgets.QWidget() self.stm_content_layout = QtWidgets.QVBoxLayout(self.stm_content) self.stm_scroll.setWidget(self.stm_content) self.stm_layout.addWidget(self.stm_scroll) # Configure LTM tab self.ltm_header_label = QtWidgets.QLabel("it happened often...") ltm_header_font = self.ltm_header_label.font() ltm_header_font.setItalic(True) ltm_header_font.setPointSize(11) self.ltm_header_label.setFont(ltm_header_font) self.ltm_header_label.setAlignment(QtCore.Qt.AlignLeft) self.ltm_header_label.setContentsMargins(6, 4, 0, 2) self.ltm_layout.addWidget(self.ltm_header_label) self.ltm_scroll = QtWidgets.QScrollArea() self.ltm_scroll.setWidgetResizable(True) self.ltm_content = QtWidgets.QWidget() self.ltm_content_layout = QtWidgets.QVBoxLayout(self.ltm_content) self.ltm_scroll.setWidget(self.ltm_content) self.ltm_layout.addWidget(self.ltm_scroll) # Configure Overview tab self.overview_stats = QtWidgets.QTextEdit() self.overview_stats.setReadOnly(True) self.overview_layout.addWidget(self.overview_stats) # Add memory subtabs to main memory tab layout, set to expand and fill self.layout.addWidget(self.memory_subtabs) self.layout.setStretchFactor(self.memory_subtabs, 1) # Initialize memory display self.update_memory_display() def update_from_brain_state(self, state): """Update memory tab based on brain state changes""" # Only update when state changes and tamagotchi_logic exists if self.tamagotchi_logic is None: print("Warning: tamagotchi_logic is None in update_from_brain_state - memory tab will not update") return self.update_memory_display() def set_tamagotchi_logic(self, tamagotchi_logic): """Update the tamagotchi_logic reference and refresh memory display""" super().set_tamagotchi_logic(tamagotchi_logic) print(f"MemoryTab.set_tamagotchi_logic: {tamagotchi_logic is not None}") # Print debug info to verify squid and memory_manager if tamagotchi_logic and hasattr(tamagotchi_logic, 'squid'): print(f"squid reference exists: {tamagotchi_logic.squid is not None}") if tamagotchi_logic.squid and hasattr(tamagotchi_logic.squid, 'memory_manager'): print(f"memory_manager exists") else: print(f"memory_manager doesn't exist") else: print(f"squid reference doesn't exist") # Refresh the memory display if we have a valid reference chain if (tamagotchi_logic and hasattr(tamagotchi_logic, 'squid') and tamagotchi_logic.squid and hasattr(tamagotchi_logic.squid, 'memory_manager')): self.update_memory_display() def update_memory_display(self): """Update all memory displays""" try: # Get short-term and long-term memories if hasattr(self.tamagotchi_logic, 'squid') and hasattr(self.tamagotchi_logic.squid, 'memory_manager'): # Get memories stm = self.tamagotchi_logic.squid.memory_manager.get_all_short_term_memories() ltm = self.tamagotchi_logic.squid.memory_manager.get_all_long_term_memories() # Filter displayable memories stm_filtered = [m for m in stm if self._is_displayable_memory(m)] ltm_filtered = [m for m in ltm if self._is_displayable_memory(m)] # De-duplicate memories based on category and key stm_deduped = [] seen_keys = set() for m in stm_filtered: key = (m.get('category', ''), m.get('key', '')) if key not in seen_keys: seen_keys.add(key) stm_deduped.append(m) ltm_deduped = [] seen_keys = set() for m in ltm_filtered: key = (m.get('category', ''), m.get('key', '')) if key not in seen_keys: seen_keys.add(key) ltm_deduped.append(m) # Save scroll positions before rebuilding stm_scroll_pos = self.stm_scroll.verticalScrollBar().value() ltm_scroll_pos = self.ltm_scroll.verticalScrollBar().value() # Clear existing content self._clear_layout(self.stm_content_layout) self._clear_layout(self.ltm_content_layout) # Add memory cards for memory in stm_deduped: self._create_memory_widget(memory, self.stm_content_layout) for memory in ltm_deduped: self._create_memory_widget(memory, self.ltm_content_layout) # Update overview self._update_overview_stats(stm_deduped, ltm_deduped) # Force UI update self.stm_content.update() self.ltm_content.update() # Make sure the scroll areas show their content self.stm_scroll.setWidget(self.stm_content) self.ltm_scroll.setWidget(self.ltm_content) # Restore scroll positions self.stm_scroll.verticalScrollBar().setValue(stm_scroll_pos) self.ltm_scroll.verticalScrollBar().setValue(ltm_scroll_pos) except Exception as e: print(f"Error updating memory tab: {e}") import traceback traceback.print_exc() def _clear_layout(self, layout): """Clear all widgets from the given layout""" while layout.count(): item = layout.takeAt(0) widget = item.widget() if widget is not None: widget.deleteLater() def _is_displayable_memory(self, memory): """Check if a memory should be displayed in the UI""" if not isinstance(memory, dict): return False if 'category' not in memory: return False if 'value' not in memory or memory.get('value') is None or memory.get('value') == "": return False cat = memory.get('category', '').lower() key = memory.get('key', '') # Always-show categories (no further filtering needed) _ALWAYS_SHOW = { 'play', 'food', 'favourite_plant', 'mental_state', 'behaviour', 'behavior', 'environment', 'social', 'observation', 'travel', 'emotion', 'achievement', 'cleanliness', } if cat in _ALWAYS_SHOW: return True # interaction: skip raw dicts with None if cat == 'interaction': value = str(memory.get('value', '')) if '{' in value and '}' in value and 'None' in value: return False return True # decorations: skip timestamp-keyed entries if cat == 'decorations': if isinstance(key, str) and key.replace('.', '', 1).isdigit(): return False formatted_value = memory.get('formatted_value', '') if 'interaction with' in formatted_value.lower(): parts = formatted_value.split('with') if len(parts) > 1: fname = parts[1].strip().split(':')[0].strip() if any(c.isdigit() for c in fname) and '.' in fname: return False return True # Filter out internal behavior status messages if cat == 'behavior': value = str(memory.get('value', '')).lower() if any(s in value for s in ('returned to', 'status changed', 'after fleeing')): return False if len(value.split()) <= 3 and any(s in value for s in ('status', 'roaming', 'fleeing')): return False # Skip timestamp-like keys if isinstance(key, str) and key.replace('.', '', 1).isdigit(): return False # Skip timestamp-containing values formatted_value = memory.get('formatted_value', '') value = str(memory.get('value', '')) if 'timestamp' in formatted_value.lower() or 'timestamp' in value.lower(): return False if formatted_value: return True if value and not value.replace('.', '', 1).isdigit(): return True return False def add_test_memory(self): """Add a test memory to verify display mechanism""" print("Adding test memory...") if hasattr(self.tamagotchi_logic, 'squid') and hasattr(self.tamagotchi_logic.squid, 'memory_manager'): print("Found memory manager, creating test memory...") # Create a test memory formatted_value = "Test memory: Happiness +10, Satisfaction +15" self.tamagotchi_logic.squid.memory_manager.add_short_term_memory( 'food', 'test_food', formatted_value, importance=10) # Verify memory was added all_memories = self.tamagotchi_logic.squid.memory_manager.get_all_short_term_memories() print(f"Current memory count: {len(all_memories)}") # Update the display self.update_memory_display() print("Test memory added and display updated") else: print("ERROR: Could not find squid.memory_manager") # ------------------------------------------------------------------ # # Thumbnail helper # # ------------------------------------------------------------------ # def _make_thumbnail(self, image_path, size, angle_deg=0, invert=False): """ Load *image_path*, scale to *size*×*size* (keeping aspect ratio), then rotate by *angle_deg* degrees (small random tilt). If *invert* is True the colours are flipped to white (for dark cards). Returns a QLabel ready to drop into a layout, or None if the image can't be loaded. """ import os as _os if not image_path or not _os.path.exists(image_path): return None src = QtGui.QPixmap(image_path) if src.isNull(): return None scaled = src.scaled(size, size, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation) if invert: # Paint a white rectangle over the image using Difference blending, # which flips every channel to its inverse while preserving alpha. inverted = QtGui.QPixmap(scaled.size()) inverted.fill(QtCore.Qt.transparent) painter = QtGui.QPainter(inverted) painter.drawPixmap(0, 0, scaled) painter.setCompositionMode(QtGui.QPainter.CompositionMode_Difference) painter.fillRect(inverted.rect(), QtGui.QColor(255, 255, 255)) painter.end() scaled = inverted if angle_deg != 0: # Rotate onto a transparent canvas large enough to avoid clipping pad = int(size * 0.45) canvas = QtGui.QPixmap(size + pad * 2, size + pad * 2) canvas.fill(QtCore.Qt.transparent) painter = QtGui.QPainter(canvas) painter.setRenderHint(QtGui.QPainter.SmoothPixmapTransform) cx = canvas.width() / 2 cy = canvas.height() / 2 painter.translate(cx, cy) painter.rotate(angle_deg) painter.translate(-scaled.width() / 2, -scaled.height() / 2) painter.drawPixmap(0, 0, scaled) painter.end() scaled = canvas label = QtWidgets.QLabel() label.setPixmap(scaled) label.setFixedSize(scaled.size()) label.setAlignment(QtCore.Qt.AlignCenter) return label # ------------------------------------------------------------------ # # Effect-pills helper # # ------------------------------------------------------------------ # def _make_effect_pills(self, effects, scale_fn, font_size_fn): """Return a QWidget containing coloured stat pills, or None.""" if not effects: return None _EMOJI = { 'happiness': '😊', 'satisfaction': '✨', 'anxiety': '😰', 'hunger': '🍖', 'energy': '⚡', 'cleanliness': '🚿', 'curiosity': '🔍', } pills_widget = QtWidgets.QWidget() pills_layout = QtWidgets.QHBoxLayout(pills_widget) pills_layout.setContentsMargins(0, 0, 0, 0) pills_layout.setSpacing(scale_fn(4)) for stat, delta in effects.items(): try: delta = float(delta) except (TypeError, ValueError): continue sign = '+' if delta >= 0 else '' emoji = _EMOJI.get(stat, '') pill = QtWidgets.QLabel(f"{emoji} {stat.capitalize()} {sign}{int(delta)}") color = '#D1FFD1' if delta > 0 else '#FFD1DC' if delta < 0 else '#FFFACD' pill.setStyleSheet( f"background:{color}; border-radius:{scale_fn(8)}px;" f" padding:2px {scale_fn(6)}px;" f" font-size:{font_size_fn(11)}px;" ) pills_layout.addWidget(pill) pills_layout.addStretch() return pills_widget # ------------------------------------------------------------------ # # Card metadata resolver # # ------------------------------------------------------------------ # def _card_meta(self, memory): """ Return (header_str, thumb_path_or_None, content_str, effects_dict_or_None) for every memory category the squid can produce. """ import os as _os cat = memory.get('category', '').lower() key = memory.get('key', '') value = memory.get('value', '') # ---- play ------------------------------------------------------- if cat == 'play' and isinstance(value, dict): activity = value.get('activity', '') description = value.get('description', activity.replace('_', ' ').capitalize()) effects = value.get('effects', {}) item_file = value.get('item', '') # filename stored at throw time _TITLES = { 'rock_throwing': 'Rock Throwing', 'urchin_throwing': 'Urchin Throwing', 'poop_throwing': 'Poop Throwing', } _DEFAULT_THUMBS = { 'rock_throwing': 'images/decoration/rock01.png', 'urchin_throwing': 'images/decoration/rock03.png', 'poop_throwing': 'images/poop1.png', } # Prefer the actual item filename if it exists on disk thumb = (item_file if item_file and _os.path.exists(item_file) else _DEFAULT_THUMBS.get(activity)) return (_TITLES.get(activity, 'Play'), thumb, description, effects) # ---- food ------------------------------------------------------- if cat == 'food': thumb = f'images/{key}.png' return ('Eating', thumb, str(value), None) # ---- favourite_plant (long-term) -------------------------------- if cat == 'favourite_plant': plant_name = _os.path.splitext(_os.path.basename(key))[0] lines = [plant_name] if isinstance(value, dict): if value.get('reason'): lines.append(value['reason']) if value.get('anxiety_reduction'): lines.append('Reduces anxiety') return ('Favourite Plant', key, '\n'.join(lines), None) # ---- interaction ------------------------------------------------ if cat == 'interaction': if key == 'plant_contact' and isinstance(value, dict): plant_path = value.get('plant_key', '') plant_name = _os.path.splitext(_os.path.basename(plant_path))[0] return ('Plant Contact', plant_path, f'Touched {plant_name} – feeling calmer', None) if 'rock' in key: item_path = value.get('item', '') if isinstance(value, dict) else '' return ('Picked Up Rock', item_path or 'images/decoration/rock01.png', 'Picked up a rock', None) if 'poop' in key: item_path = value.get('item', '') if isinstance(value, dict) else '' return ('Picked Up Poop', item_path or 'images/poop1.png', 'Picked up some poop…', None) return ('Interaction', None, str(value), None) # ---- mental_state ----------------------------------------------- if cat == 'mental_state': if key == 'startled': return ('Startled!', 'images/startled.png', str(value), None) return ('Mental State', None, str(value), None) # ---- behaviour / behavior --------------------------------------- if cat in ('behaviour', 'behavior'): if key == 'ink_cloud': return ('Ink Cloud!', 'images/inkcloud.png', str(value), None) if key == 'startle_response': return ('Startle Response', 'images/startled.png', str(value), None) if key == 'calm_after_startle': return ('Calmed Down', None, str(value), None) return ('Behaviour', None, str(value), None) # ---- environment ------------------------------------------------ if cat == 'environment': if key == 'plant_calming_effect': return ('Plant Calming', 'images/plant.png', str(value), None) if key == 'window_enlarged': return ('More Space!', None, str(value), None) if key == 'window_reduced': return ('Less Space', None, str(value), None) return ('Environment', None, str(value), None) # ---- decorations ------------------------------------------------ if cat == 'decorations': thumb = key if (_os.path.exists(key) if key else False) else None name = _os.path.splitext(_os.path.basename(key))[0] if key else 'decoration' if isinstance(value, dict): content = ', '.join( f"{k.capitalize()} {'+' if v >= 0 else ''}{v:.0f}" for k, v in value.items() if isinstance(v, (int, float)) ) or str(value) else: content = str(value) return (name.replace('_', ' ').capitalize(), thumb, content, None) # ---- social ----------------------------------------------------- if cat == 'social': _SOCIAL = { 'squid_meeting': 'Met Another Squid', 'squid_detection': 'Spotted a Squid', 'squid_lost': 'Lost Sight of Squid', 'decoration_exchange': 'Decoration Exchange', 'targeted': 'Targeted by Rock', } return (_SOCIAL.get(key, 'Social'), None, str(value), None) # ---- observation ------------------------------------------------ if cat == 'observation': if 'rock' in key: return ('Saw Rock Thrown', 'images/decoration/rock01.png', str(value), None) return ('Observation', None, str(value), None) # ---- travel ----------------------------------------------------- if cat == 'travel': _TRAVEL = { 'ate_on_trip': 'Ate on Trip', 'played_on_trip': 'Played on Trip', 'completed_journey': 'Journey Complete', } return (_TRAVEL.get(key, 'Travel'), None, str(value), None) # ---- cleanliness ------------------------------------------------ if cat == 'cleanliness': return ('Washed Clean', 'images/icons/clean.png', str(value), None) # ---- emotion ---------------------------------------------------- if cat == 'emotion': _EMO = { 'happy_return': 'Happy Return', 'calm_return': 'Calm Return', 'intense_curiosity': 'Intense Curiosity', 'fear': 'Fear', } _EMO_THUMBS = { 'intense_curiosity': 'images/curious.png', 'fear': 'images/startled.png', } return (_EMO.get(key, 'Emotion'), _EMO_THUMBS.get(key), str(value), None) # ---- achievement ------------------------------------------------ if cat == 'achievement': return (key.replace('_', ' ').capitalize(), None, str(value), None) # ---- neurogenesis ----------------------------------------------- if cat == 'neurogenesis': return ('Neurogenesis', None, str(value), None) # ---- fallback --------------------------------------------------- content = memory.get('formatted_value', str(value)) return (cat.capitalize(), None, content, None) # ------------------------------------------------------------------ # # Card builder # # ------------------------------------------------------------------ # def _create_memory_widget(self, memory, target_layout): """Create a memory card widget and add it to the target layout""" import random as _random from .display_scaling import DisplayScaling THUMB_SIZE = DisplayScaling.scale(64) # bigger thumbnails MAX_ANGLE = 12 # ± degrees of random tilt memory_widget = QtWidgets.QFrame() memory_widget.setFrameStyle(QtWidgets.QFrame.Box | QtWidgets.QFrame.Raised) memory_widget.setLineWidth(DisplayScaling.scale(2)) bg_color = self._get_memory_color(memory) memory_widget.setStyleSheet(f"background-color: {bg_color};") memory_widget.setMinimumHeight(DisplayScaling.scale(200)) memory_widget.setMinimumWidth(DisplayScaling.scale(300)) memory_widget.setMaximumHeight(DisplayScaling.scale(250)) card_layout = QtWidgets.QVBoxLayout(memory_widget) card_layout.setSpacing(DisplayScaling.scale(4)) cat_key = memory.get('category', 'unknown').lower() # --- resolve metadata ------------------------------------------- header_text, thumb_path, content_text, effects = self._card_meta(memory) # --- header label ----------------------------------------------- header = QtWidgets.QLabel(header_text) hfont = header.font() hfont.setBold(True) hfont.setPointSize(DisplayScaling.font_size(12)) header.setFont(hfont) if cat_key == 'neurogenesis': header.setStyleSheet("color: white;") card_layout.addWidget(header) # --- thumbnail + content row ------------------------------------ row_widget = QtWidgets.QWidget() row_layout = QtWidgets.QHBoxLayout(row_widget) row_layout.setContentsMargins(0, 0, 0, 0) row_layout.setSpacing(DisplayScaling.scale(8)) angle = _random.uniform(-MAX_ANGLE, MAX_ANGLE) if cat_key == 'neurogenesis': brain_label = QtWidgets.QLabel("🧠") bfont = brain_label.font() bfont.setPointSize(DisplayScaling.font_size(32)) brain_label.setFont(bfont) brain_label.setAlignment(QtCore.Qt.AlignCenter) row_layout.addWidget(brain_label, alignment=QtCore.Qt.AlignVCenter) else: thumb_label = self._make_thumbnail(thumb_path, THUMB_SIZE, angle) if thumb_label: row_layout.addWidget(thumb_label, alignment=QtCore.Qt.AlignVCenter) content_label = QtWidgets.QLabel(content_text) content_label.setWordWrap(True) cfont = content_label.font() cfont.setPointSize(DisplayScaling.font_size(10)) content_label.setFont(cfont) if cat_key == 'neurogenesis': content_label.setStyleSheet("color: white;") row_layout.addWidget(content_label, stretch=1) card_layout.addWidget(row_widget) # --- effect pills ----------------------------------------------- pills = self._make_effect_pills(effects, DisplayScaling.scale, DisplayScaling.font_size) if pills: card_layout.addWidget(pills) # --- timestamp -------------------------------------------------- timestamp = memory.get('timestamp', '') if isinstance(timestamp, (int, float)) and timestamp > 0: from datetime import datetime timestamp = datetime.fromtimestamp(timestamp).strftime("%H:%M:%S") elif isinstance(timestamp, str): try: from datetime import datetime timestamp = datetime.fromisoformat(timestamp).strftime("%H:%M:%S") except Exception: pass time_label = QtWidgets.QLabel(f"{self.loc.get('time_label')} {timestamp}") tfont = time_label.font() tfont.setPointSize(DisplayScaling.font_size(9)) time_label.setFont(tfont) if cat_key == 'neurogenesis': time_label.setStyleSheet("color: #ADD8E6;") card_layout.addWidget(time_label, alignment=QtCore.Qt.AlignRight) # --- importance star -------------------------------------------- if memory.get('importance', 0) >= 5: imp_label = QtWidgets.QLabel(f"⭐ {self.loc.get('important_label')}") imp_label.setStyleSheet( f"color:#FF5733; font-weight:bold;" f" font-size:{DisplayScaling.font_size(9)}px;" ) card_layout.addWidget(imp_label, alignment=QtCore.Qt.AlignRight) # --- wire up ---------------------------------------------------- target_layout.addWidget(memory_widget) memory_widget.setToolTip(self._create_memory_tooltip(memory)) memory_widget.mousePressEvent = ( lambda event, mem=memory: self._on_memory_card_clicked(mem) ) # Plant hover tint if cat_key == 'favourite_plant': plant_key = memory.get('key', '') memory_widget.enterEvent = ( lambda event, k=plant_key: self._tint_scene_plant(k) ) memory_widget.leaveEvent = ( lambda event, k=plant_key: self._untint_scene_plant(k) ) return memory_widget def _tint_scene_plant(self, filename): """Apply a green tint to the matching plant decoration in the live scene.""" item = self._find_scene_decoration(filename) if item is None: return try: original = item.original_pixmap or item.pixmap() if original.isNull(): return tinted = QtGui.QPixmap(original.size()) tinted.fill(QtCore.Qt.transparent) painter = QtGui.QPainter(tinted) painter.drawPixmap(0, 0, original) painter.setCompositionMode(QtGui.QPainter.CompositionMode_SourceAtop) painter.fillRect(tinted.rect(), QtGui.QColor(0, 200, 80, 120)) # semi-transparent green painter.end() item.setPixmap(tinted) except Exception as e: print(f"[MemoryTab] Could not tint plant: {e}") def _untint_scene_plant(self, filename): """Restore the plant decoration in the live scene to its original pixmap.""" item = self._find_scene_decoration(filename) if item is None: return try: original = item.original_pixmap if original and not original.isNull(): item.setPixmap(original) except Exception as e: print(f"[MemoryTab] Could not restore plant: {e}") def _find_scene_decoration(self, filename): """Return the ResizablePixmapItem in the scene whose filename matches.""" try: scene = self.tamagotchi_logic.user_interface.scene for item in scene.items(): if hasattr(item, 'filename') and item.filename == filename: return item except Exception: pass return None def _get_memory_color(self, memory): """Determine the background color for a memory based on its valence""" cat = memory.get('category', '').lower() key = memory.get('key', '') val = memory.get('value', '') # Explicit prefix overrides formatted_value = memory.get('formatted_value', str(val)).lower() if formatted_value.startswith('positive:'): return "#D1FFD1" if formatted_value.startswith('negative:'): return "#FFD1DC" # Play activities if cat == 'play' and isinstance(val, dict): return "#D1FFD1" if val.get('is_positive', True) else "#FFD1DC" # Food is always positive if cat == 'food': return "#D1FFD1" # Favourite plant / plant calming if cat == 'favourite_plant': return "#C8F7C5" if key == 'plant_calming_effect': return "#E0FFD1" # Plant contact = positive if cat == 'interaction' and key == 'plant_contact': return "#E0FFD1" # Startled / scary events if cat == 'mental_state' and key == 'startled': return "#FFD1DC" if cat in ('behaviour', 'behavior') and key in ('ink_cloud', 'startle_response'): return "#FFD1DC" if cat in ('behaviour', 'behavior') and key == 'calm_after_startle': return "#D1FFD1" # Environment if cat == 'environment': if key == 'window_enlarged': return "#D1FFD1" if key == 'window_reduced': return "#FFD1DC" # Social if cat == 'social': if key == 'targeted': return "#FFD1DC" return "#E8F4FD" # pale blue # Cleanliness always positive if cat == 'cleanliness': return "#B8D4F0" # mid-tone cornflower blue — distinct from curiosity #E8F4FD # Emotion sub-types if cat == 'emotion': if key == 'fear': return "#FFD1DC" if key == 'intense_curiosity': return "#E8F4FD" # pale blue if 'happy' in key: return "#D1FFD1" return "#FFFACD" if cat == 'achievement': return "#FFF3CD" # gold-ish # Neurogenesis — royal blue if cat == 'neurogenesis': return "#4169E1" # Numeric dict values – sum to decide if isinstance(val, dict): total = sum(float(v) for v in val.values() if isinstance(v, (int, float))) if total > 0: return "#D1FFD1" if total < 0: return "#FFD1DC" return "#FFFACD" # default pastel yellow def _create_memory_tooltip(self, memory): """Create detailed tooltip for a memory card with LOCALIZED labels""" tooltip = "" tooltip += f"{self.loc.get('category_label')} {memory.get('category', self.loc.get('unknown'))}\n" tooltip += f"{self.loc.get('key_label')} {memory.get('key', self.loc.get('unknown'))}\n" timestamp = memory.get('timestamp', '') if isinstance(timestamp, str): try: timestamp = datetime.fromisoformat(timestamp).strftime("%H:%M:%S") except: timestamp = "" tooltip += f"{self.loc.get('time_label')} {timestamp}\n" if 'importance' in memory: # Reusing 'important_label' but as a header, or 'Importance' if added to loc tooltip += f"Importance: {memory.get('importance')}\n" if 'access_count' in memory: tooltip += f"{self.loc.get('access_count')} {memory.get('access_count')}\n" # Add full content full_content = memory.get('formatted_value', str(memory.get('value', ''))) tooltip += f"\n{self.loc.get('full_content')}\n{full_content}\n" # Add effects if present if isinstance(memory.get('raw_value'), dict): tooltip += f"\n{self.loc.get('effects_label')}\n" for key, value in memory['raw_value'].items(): if isinstance(value, (int, float)): tooltip += f" {key}: {value:+.2f}\n" tooltip += "" return tooltip def _update_overview_stats(self, stm, ltm): """Update the overview tab with statistics""" # Import datetime correctly at the top of your function from datetime import datetime stats_html = """ """ # Memory counts - Localized Labels stats_html += f"""
    📈 {self.loc.get('memory_stats')}
    {self.loc.get('short_term_memory')}:{len(stm)}
    {self.loc.get('long_term_memory')}:{len(ltm)}
    """ # Category breakdown categories = {} for m in stm + ltm: cat = m.get('category', 'unknown') categories[cat] = categories.get(cat, 0) + 1 category_html = "\n".join( f"{k}:{v}" for k, v in sorted(categories.items()) ) stats_html += f"""
    🗂️ {self.loc.get('categories')}
    {category_html}
    """ # Fix: Convert timestamp to string format for consistent comparison def get_timestamp_key(memory): timestamp = memory.get('timestamp', '') if isinstance(timestamp, datetime): # Corrected: Use datetime instead of datetime.datetime return timestamp.isoformat() # Convert datetime to string return str(timestamp) # Ensure string format # Use the new key function for sorting recent_memories = sorted(stm, key=get_timestamp_key, reverse=True)[:5] self.overview_stats.setHtml(stats_html) def _update_memory_importance(self, memory): """Increase importance of displayed memory and check for transfer""" if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'squid'): return # Get memory manager memory_manager = self.tamagotchi_logic.squid.memory_manager # Only process short-term memories all_stm = memory_manager.get_all_short_term_memories() matching_memories = [m for m in all_stm if m.get('category') == memory.get('category') and m.get('key') == memory.get('key')] if not matching_memories: return # Increase memory access count and importance category = memory.get('category', '') key = memory.get('key', '') if hasattr(memory_manager, 'update_memory_importance'): try: # Increment importance by 1 memory_manager.update_memory_importance(category, key, 1) except Exception as e: print(f"Error updating memory importance: {e}") # Check if memory meets transfer criteria if self._should_transfer_to_long_term(memory): try: # Transfer memory to long-term memory_manager.transfer_to_long_term_memory(category, key) print(f"Memory transferred to long-term: {category}, {key}") except Exception as e: print(f"Error transferring memory to long-term: {e}") # Update displays self.update_memory_display() def _should_transfer_to_long_term(self, memory): """Check if a memory should be transferred to long-term""" # Criteria for transfer: # 1. Extremely high importance (>= 8) - require higher importance if memory.get('importance', 0) >= 8: return True # 2. Repeated access - memory has been accessed frequently (>= 4 times) if memory.get('access_count', 0) >= 4: return True # 3. Combination of moderately important and repeated access if memory.get('importance', 0) >= 5 and memory.get('access_count', 0) >= 3: return True # 4. Special categories that should be remembered long-term if memory.get('category') == 'health' and memory.get('importance', 0) >= 6: return True # Most play activities should not automatically go to long-term # unless they're truly significant or repeated if memory.get('category') == 'play': # Only really exceptional play events go to long-term return memory.get('importance', 0) >= 9 return False def _on_memory_card_clicked(self, memory): """Handle memory card clicks - update importance and check for transfer""" print(f"Memory card clicked: {memory.get('category')}") self._update_memory_importance(memory) ================================================ FILE: src/brain_network_tab.py ================================================ import json import time import subprocess import os import sys from PyQt5 import QtCore, QtGui, QtWidgets from .brain_base_tab import BrainBaseTab from .brain_dialogs import StimulateDialog, DiagnosticReportDialog from .display_scaling import DisplayScaling from .laboratory import NeuronLaboratory from .animation_styles import get_available_styles, get_style_info from .localisation import Localisation from .compute_backend import get_backend class NetworkTab(BrainBaseTab): def __init__(self, parent=None, tamagotchi_logic=None, brain_widget=None, config_manager=None, debug_mode=False): super().__init__(parent, tamagotchi_logic, brain_widget, config_manager) self.config_manager = config_manager # store it self.debug_mode = debug_mode self.neurogenesis_timer_value = 0 # Initialize persistent storage for metrics self._last_active_neurons = 0 self._last_total_neurons = 0 self._last_connections = 0 if not hasattr(self, 'layout') or self.layout is None: self.layout = QtWidgets.QVBoxLayout(self) self.initialize_ui() self.setup_timers() self.neuron_lab_dialog = None # ------------------------------------------------------------------ # UI BUILD # ------------------------------------------------------------------ def initialize_ui(self): """ Build the complete Network-tab UI with metrics, timers, controls, neurogenesis counters, checkboxes, and emergency status indicators. """ loc = Localisation.instance() # -------------------- CLEAR ANY OLD LAYOUT -------------------- if self.layout is not None: while self.layout.count(): item = self.layout.takeAt(0) widget = item.widget() if widget is not None: widget.deleteLater() else: sub_layout = item.layout() if sub_layout is not None: self._clear_layout_recursively(sub_layout) # -------------------- TOP METRICS BAR -------------------- self.metrics_bar = QtWidgets.QWidget() self.metrics_bar.setStyleSheet("background-color: rgb(200, 200, 200);") self.metrics_bar.setFixedHeight(DisplayScaling.scale(50)) metrics_layout = QtWidgets.QHBoxLayout(self.metrics_bar) metrics_layout.setContentsMargins(DisplayScaling.scale(10), 0, DisplayScaling.scale(10), 0) metrics_layout.setSpacing(DisplayScaling.scale(20)) metrics_font = QtGui.QFont() metrics_font.setPointSize(DisplayScaling.font_size(10)) self.neurons_label = QtWidgets.QLabel(f"{loc.get('stats_neurons')}: N/A") self.neurons_label.setAlignment(QtCore.Qt.AlignCenter) self.neurons_label.setFont(metrics_font) metrics_layout.addWidget(self.neurons_label) self.connections_label = QtWidgets.QLabel(f"{loc.get('stats_connections')}: N/A") self.connections_label.setAlignment(QtCore.Qt.AlignCenter) self.connections_label.setFont(metrics_font) metrics_layout.addWidget(self.connections_label) self.health_label = QtWidgets.QLabel(f"{loc.get('stats_health')}: N/A") self.health_label.setAlignment(QtCore.Qt.AlignCenter) self.health_label.setFont(metrics_font) metrics_layout.addWidget(self.health_label) self.emergency_status_label = QtWidgets.QLabel() self.emergency_status_label.setStyleSheet(f""" QLabel {{ color: white; background-color: #d32f2f; padding: 5px; border-radius: 5px; font-weight: bold; font-size: {DisplayScaling.font_size(10)}px; }} """) self.emergency_status_label.setVisible(False) self.emergency_status_label.setFixedWidth(DisplayScaling.scale(200)) metrics_layout.addWidget(self.emergency_status_label) metrics_layout.addStretch() # Hebbian timer display timers_container = QtWidgets.QWidget() timers_container.setStyleSheet("background-color: black; border-radius: 5px;") timers_layout = QtWidgets.QHBoxLayout(timers_container) timers_layout.setContentsMargins(DisplayScaling.scale(10), DisplayScaling.scale(5), DisplayScaling.scale(10), DisplayScaling.scale(5)) timers_layout.setSpacing(DisplayScaling.scale(10)) font_size_timers = DisplayScaling.font_size(10) timer_font = QtGui.QFont() timer_font.setPointSize(font_size_timers) self.hebbian_timer_label = QtWidgets.QLabel(f"{loc.get('hebbian_cycle')}: XX") self.hebbian_timer_label.setStyleSheet("color: white;") self.hebbian_timer_label.setFont(timer_font) timers_layout.addWidget(self.hebbian_timer_label) metrics_layout.addWidget(timers_container) self.layout.insertWidget(0, self.metrics_bar) # -------------------- MAIN CONTENT -------------------- main_content_widget = QtWidgets.QWidget() main_content_layout = QtWidgets.QVBoxLayout(main_content_widget) if self.brain_widget: main_content_layout.addWidget(self.brain_widget, 1) self.layout.addWidget(main_content_widget) # -------------------- NEUROGENESIS COUNTERS (BOTTOM-RIGHT) -------------------- values_display_layout = QtWidgets.QHBoxLayout() values_display_layout.addStretch(1) values_container = QtWidgets.QWidget() values_box = QtWidgets.QHBoxLayout(values_container) values_box.setContentsMargins(10, 5, 10, 5) values_box.setSpacing(10) font_size_values = DisplayScaling.font_size(10) value_font = QtGui.QFont("Arial", font_size_values) value_font.setBold(True) # Add the global cooldown label self.global_cooldown_label = QtWidgets.QLabel(f"{loc.get('global_cooldown')}: 0.0s") self.global_cooldown_label.setFont(value_font) values_box.addWidget(self.global_cooldown_label) values_display_layout.addWidget(values_container) # -------------------- BOTTOM BAR -------------------- bottom_bar = QtWidgets.QHBoxLayout() # 1. STYLE DROPDOWN (Far Left) self.anim_combo = QtWidgets.QComboBox() # Populate with actual style names from animation_styles.py style_info = get_style_info() # [(name, display_name, description), ...] for name, display_name, description in style_info: self.anim_combo.addItem(display_name, name) # Display "Classic", store "classic" self.anim_combo.setItemData(self.anim_combo.count() - 1, description, QtCore.Qt.ToolTipRole) # Set current style from brain_widget if self.brain_widget: current_style = self.brain_widget.get_animation_style() for i in range(self.anim_combo.count()): if self.anim_combo.itemData(i) == current_style: self.anim_combo.setCurrentIndex(i) break self.anim_combo.currentIndexChanged.connect(self._change_animation_style) # Shared small font for all bottom-bar controls bottom_bar_font = QtGui.QFont() bottom_bar_font.setPointSize(DisplayScaling.font_size(10)) self.anim_combo.setFont(bottom_bar_font) style_label = QtWidgets.QLabel(loc.get("style_label")) style_label.setFont(bottom_bar_font) bottom_bar.addWidget(style_label) bottom_bar.addWidget(self.anim_combo) # Spacing bottom_bar.addSpacing(20) # 2. CHECKBOXES (Center-Left) self.checkbox_links = QtWidgets.QCheckBox(loc.get("chk_links")) self.checkbox_links.setFont(bottom_bar_font) self.checkbox_links.setChecked(True) if self.brain_widget: self.checkbox_links.stateChanged.connect(self.brain_widget.toggle_links) # Connect to neurogenesis signal to refresh links display when new neuron is created self.brain_widget.neuronCreated.connect(self._on_neuron_created) bottom_bar.addWidget(self.checkbox_links) self.checkbox_weights = QtWidgets.QCheckBox(loc.get("chk_weights")) self.checkbox_weights.setFont(bottom_bar_font) self.checkbox_weights.setChecked(False) if self.brain_widget: self.checkbox_weights.stateChanged.connect(self.brain_widget.toggle_weights) bottom_bar.addWidget(self.checkbox_weights) self.checkbox_pruning = QtWidgets.QCheckBox(loc.get("chk_pruning")) self.checkbox_pruning.setFont(bottom_bar_font) self.checkbox_pruning.setChecked(True) self.checkbox_pruning.stateChanged.connect(self.toggle_pruning) bottom_bar.addWidget(self.checkbox_pruning) # 3. STRETCH (Pushes everything else to the far right) bottom_bar.addStretch() # 4. LOAD BRAIN BUTTON (Far Right) from .custom_brain_loader import add_load_brain_button add_load_brain_button(self, bottom_bar) self.layout.addLayout(bottom_bar) # -------------------- FUNCTIONAL EXISTING HELPERS -------------------- self.update_metrics_display() self.update_hebbian_label() def showEvent(self, event): """Network tab became visible – start timer if wanted.""" super().showEvent(event) def hideEvent(self, event): """Network tab hidden – stop timer.""" super().hideEvent(event) # ------------------------------------------------------------------ # MISC EXISTING HELPERS # ------------------------------------------------------------------ def _clear_layout_recursively(self, layout_to_clear): """Helper to recursively clear a layout and delete its widgets.""" if layout_to_clear is None: return while layout_to_clear.count(): item = layout_to_clear.takeAt(0) widget = item.widget() if widget is not None: widget.deleteLater() else: sub_layout = item.layout() if sub_layout is not None: self._clear_layout_recursively(sub_layout) def _open_brain_designer(self): """ Switch the main window to Brain Designer mode. Replaces the current view with the designer interface. """ # Find the main SquidBrainWindow # self.parent() might be QTabWidget, its parent is SquidBrainWindow window = self.window() if hasattr(window, 'switch_to_designer_mode'): window.switch_to_designer_mode() else: print("❌ Could not find switch_to_designer_mode method on main window") # Fallback (legacy process launch) try: from .brain_designer_launcher import launch_brain_designer_process launch_brain_designer_process() except ImportError: QtWidgets.QMessageBox.warning(self, "Error", "Cannot launch Brain Designer.") def _change_animation_style(self, index): """ Handle animation style dropdown change. Called when user selects a new style from the combo box. Args: index: The index of the selected item in the combo box """ if not self.brain_widget: return # Get the internal style name (stored as item data) style_name = self.anim_combo.itemData(index) if not style_name: return # Use brain_widget's API for real-time style switching success = self.brain_widget.set_animation_style(style_name) if success: # Optionally persist to config for next session if self.config_manager: self.config_manager.set_animation_style(style_name) def _on_neuron_created(self, neuron_name: str): """ Handle neurogenesis neuron creation event. Toggles the 'Show links' checkbox OFF and then ON again after 2 seconds to refresh the links display for the newly created neuron. Args: neuron_name: The name of the newly created neuron """ if not hasattr(self, 'checkbox_links') or self.checkbox_links is None: return # Only toggle if links are currently shown if self.checkbox_links.isChecked(): # Turn off links self.checkbox_links.setChecked(False) # Schedule turning links back on after 2 seconds QtCore.QTimer.singleShot(2000, self._restore_links_checkbox) def _restore_links_checkbox(self): """Restore the links checkbox to checked state after delay.""" if hasattr(self, 'checkbox_links') and self.checkbox_links is not None: self.checkbox_links.setChecked(True) def _update_backend_label(self): """Backend label removed from UI — no-op kept for call-site compatibility.""" pass def setup_timers(self): # QTimer fires every second for global-cooldown updates and label refresh. # The Hebbian countdown value is NOT owned here – it is read from # brain_widget.hebbian_countdown_seconds (same source as the Learning tab). self.hebbian_countdown = QtCore.QTimer(self) self.hebbian_countdown.timeout.connect(self._on_every_second) self.hebbian_countdown.start(1000) self.update_hebbian_label() self._update_global_cooldown_label() def _on_every_second(self): """Called every second by the QTimer. The Hebbian countdown is read from brain_widget.hebbian_countdown_seconds – the same source the Learning tab uses – so both displays are always in sync. This method no longer owns or mutates the counter value. """ # 1. Refresh Hebbian label from the authoritative source self.update_hebbian_label() # 2. Update global-cooldown counter self._update_global_cooldown_label() def update_hebbian_timer(self): """Kept for API compatibility. The counter is owned by brain_widget; this method simply refreshes the display label.""" self.update_hebbian_label() def update_hebbian_label(self): """Refresh the Hebbian countdown label from brain_widget.hebbian_countdown_seconds – the same source the Learning tab uses – ensuring both are always in sync.""" loc = Localisation.instance() if not hasattr(self, 'hebbian_timer_label'): return # Pause state check (mirrors Learning tab behaviour exactly) is_paused = ( self.tamagotchi_logic is not None and hasattr(self.tamagotchi_logic, 'simulation_speed') and self.tamagotchi_logic.simulation_speed == 0 ) if is_paused: self.hebbian_timer_label.setText( f"{loc.get('hebbian_cycle')}: {loc.get('hebbian_paused')}" ) return # Read the authoritative value from brain_widget value = getattr( self.brain_widget, 'hebbian_countdown_seconds', getattr(self.config, 'hebbian_cycle_seconds', 30) ) self.hebbian_timer_label.setText(f"{loc.get('hebbian_cycle')}: {value}") def update_metrics_display(self): '''Update the metrics bar with current network statistics - single source of truth''' loc = Localisation.instance() if hasattr(self, 'brain_widget') and self.brain_widget: # Get neuron positions directly from brain widget neuron_positions = getattr(self.brain_widget, 'neuron_positions', {}) total_neurons = len(neuron_positions) # Get connection count from weights weights_dict = getattr(self.brain_widget, 'weights', {}) connection_count = len(weights_dict) # Network health calculation if hasattr(self.brain_widget, 'calculate_network_health'): health_value = self.brain_widget.calculate_network_health() health_percentage_str = f"{health_value:.1f}%" if isinstance(health_value, (int, float)) else "N/A" else: # Fallback health calculation based on connections per neuron if total_neurons > 0: connections_per_neuron = connection_count / total_neurons if total_neurons > 0 else 0 if connections_per_neuron < 1.5 and total_neurons > 7: health_percentage_str = "Low Density" else: health_percentage_str = "Optimal" else: health_percentage_str = "N/A" else: total_neurons = 0 connection_count = 0 health_percentage_str = "N/A" # Update labels with consistent data if hasattr(self, 'neurons_label'): self.neurons_label.setText(f"{loc.get('stats_neurons')}: {total_neurons}") if hasattr(self, 'connections_label'): self.connections_label.setText(f"{loc.get('stats_connections')}: {connection_count}") if hasattr(self, 'health_label'): self.health_label.setText(f"{loc.get('stats_health')}: {health_percentage_str}") # Set color based on health status if health_percentage_str == "Optimal": self.health_label.setStyleSheet("font-weight: bold; color: green;") elif health_percentage_str == "Low Density": self.health_label.setStyleSheet("font-weight: bold; color: orange;") else: self.health_label.setStyleSheet("font-weight: bold; color: gray;") # Update global cooldown label self._update_global_cooldown_label() def update_from_brain_state(self, state): self.update_metrics_display() # ----- GLOBAL COOLDOWN ----- self._update_global_cooldown_label() # functional-neuron stats if hasattr(self.brain_widget, 'enhanced_neurogenesis'): self._update_functional_neuron_stats() def _update_global_cooldown_label(self): """Update the global cooldown label with the remaining time.""" loc = Localisation.instance() if not self.brain_widget: self.global_cooldown_label.setText(f"{loc.get('global_cooldown')}: N/A") return eng = getattr(self.brain_widget, 'enhanced_neurogenesis', None) if eng is None: self.global_cooldown_label.setText(f"{loc.get('global_cooldown')}: N/A") return # This now returns the real cooldown value remaining = eng.get_global_cooldown_remaining() self.global_cooldown_label.setText(f"{loc.get('global_cooldown')}: {remaining:.1f}s") def _create_functional_stats_area(self): """Creates the container for functional stats label and the two side-by-side emoji buttons.""" loc = Localisation.instance() # Avoid creating multiple times if hasattr(self, 'functional_stats_area') and self.functional_stats_area is not None: return # Wrapper that holds the green card + button container self.functional_stats_area = QtWidgets.QWidget() self.stats_and_button_layout = QtWidgets.QHBoxLayout(self.functional_stats_area) self.stats_and_button_layout.setContentsMargins(0, 0, 0, 0) self.stats_and_button_layout.setSpacing(10) # 1. Functional-stats label (green card) — this is the target for overlay self.functional_stats_label = QtWidgets.QLabel() self.functional_stats_label.setWordWrap(True) self.functional_stats_label.setStyleSheet(""" QLabel { background-color: #e8f5e9; padding: 8px; border-radius: 5px; border: 1px solid #4CAF50; margin: 5px 5px 5px 8px; } """) stats_font = QtGui.QFont() stats_font.setPointSize(DisplayScaling.font_size(11)) self.functional_stats_label.setFont(stats_font) self.stats_and_button_layout.addWidget(self.functional_stats_label, 1) # 2. Button container (single button for Brain Designer) self.new_button_container = QtWidgets.QWidget() self.new_button_container.setFixedSize(DisplayScaling.scale(100), DisplayScaling.scale(100)) btn_layout = QtWidgets.QHBoxLayout(self.new_button_container) btn_layout.setContentsMargins(0, 0, 0, 0) btn_layout.setSpacing(0) # Brain Designer button (enlarged to fill space) self.new_50x50_button = QtWidgets.QPushButton("🧠") self.new_50x50_button.setFixedSize(DisplayScaling.scale(100), DisplayScaling.scale(80)) self.new_50x50_button.setStyleSheet(""" QPushButton { background-color: #e1f5fe; border: 1px solid #029be5; border-radius: 8px; font-size: 24pt; /* Slightly larger emoji */ } QPushButton:hover { background-color: #b3e5fc; } """) self.new_50x50_button.clicked.connect(self._open_brain_designer) self.new_50x50_button.setToolTip(loc.get("tooltip_brain_designer")) btn_layout.addWidget(self.new_50x50_button) # Second emoji button (Experience Buffer) self.buffer_button = QtWidgets.QPushButton("🔍") self.buffer_button.setFixedSize(DisplayScaling.scale(42), DisplayScaling.scale(42)) self.buffer_button.setStyleSheet(""" QPushButton { background-color: #fff3e0; border: 1px solid #ff8f00; border-radius: 8px; font-size: 20pt; } QPushButton:hover { background-color: #ffe0b2; } """) self.buffer_button.clicked.connect(self._show_experience_buffer) self.buffer_button.setToolTip(loc.get("tooltip_experience_buffer")) #btn_layout.addWidget(self.buffer_button) self.stats_and_button_layout.addWidget(self.new_button_container) # === INSERT THE STATS AREA JUST ABOVE THE BOTTOM BAR === self.layout.insertWidget(self.layout.count() - 1, self.functional_stats_area) # === CREATE THE BINDINGS OVERLAY TARGETING ONLY THE GREEN LABEL === try: from .brain_network_tab_banners import BindingOverlay self.binding_overlay = BindingOverlay(self, self.functional_stats_label) self.binding_overlay.hide() # Hidden until bindings are loaded except ImportError as e: print("Could not import BindingOverlay:", e) self.binding_overlay = None # Initially hidden until we have functional neuron data self.functional_stats_area.hide() def flash_emergency_creation(self, neuron_name): """Show visual indicator of emergency neuron creation""" loc = Localisation.instance() if hasattr(self, 'emergency_status_label'): self.emergency_status_label.setText(loc.get("emergency_alert", name=neuron_name)) self.emergency_status_label.setVisible(True) QtCore.QTimer.singleShot(5000, lambda: self.emergency_status_label.setVisible(False)) def _toggle_neuron_laboratory(self): """Toggles the visibility of the Neuron Laboratory dialog.""" loc = Localisation.instance() if not self.brain_widget: QtWidgets.QMessageBox.warning(self, loc.get("msg_missing_brain"), loc.get("msg_cannot_open_lab")) return if self.neuron_lab_dialog is None: # Instantiate the dialog only once self.neuron_lab_dialog = NeuronLaboratory(self.brain_widget, parent=self) if self.neuron_lab_dialog.isVisible(): self.neuron_lab_dialog.hide() else: self.neuron_lab_dialog.show() def _update_functional_neuron_stats(self): """Display functional neuron statistics""" loc = Localisation.instance() if not hasattr(self.brain_widget, 'enhanced_neurogenesis'): return eng = self.brain_widget.enhanced_neurogenesis functional_neurons = eng.functional_neurons spec_counts = {} total_utility = 0 total_activations = 0 for name, func_neuron in functional_neurons.items(): spec = func_neuron.specialization spec_counts[spec] = spec_counts.get(spec, 0) + 1 total_utility += func_neuron.utility_score total_activations += func_neuron.activation_count # === Ensure stats area exists === if not hasattr(self, 'functional_stats_area') or self.functional_stats_area is None: self._create_functional_stats_area() # === Update text === if spec_counts: avg_utility = total_utility / len(functional_neurons) if functional_neurons else 0 text = f"🧬 {loc.get('func_neurons_title')}:
    " text += f"{loc.get('count_label')}: {len(functional_neurons)} | " text += f"{loc.get('avg_utility_label')}: {avg_utility:.2f} | " text += f"{loc.get('total_activations_label')}: {total_activations}
    " text += f"{loc.get('specialisations_label')}: " spec_list = [f"{spec}({count})" for spec, count in sorted(spec_counts.items(), key=lambda x: x[1], reverse=True)] text += ", ".join(spec_list) else: text = f"🧬 {loc.get('func_neurons_title')}:
    " text += f"{loc.get('count_label')}: 0 | " text += f"{loc.get('avg_utility_label')}: N/A | " text += f"{loc.get('total_activations_label')}: 0
    " text += f"{loc.get('specialisations_label')}: None" self.functional_stats_label.setText(text) self.functional_stats_area.show() # ------------------------------------------------------------------ # PRE-EXISTING FUNCTIONALITY # ------------------------------------------------------------------ def preload(self): if hasattr(self, 'brain_widget') and self.brain_widget and hasattr(self.brain_widget, 'update'): self.brain_widget.update() if hasattr(self, 'checkbox_links'): self.checkbox_links.setChecked(True) if hasattr(self, 'checkbox_weights'): self.checkbox_weights.setChecked(False) def toggle_pruning(self, state): if hasattr(self, 'brain_widget') and self.brain_widget and hasattr(self.brain_widget, 'toggle_pruning'): enabled = (state == QtCore.Qt.Checked) self.brain_widget.toggle_pruning(enabled) if not enabled: print("\033[91mWARNING: Pruning disabled - neurogenesis unconstrained!\033[0m") def stimulate_brain(self): if not self.brain_widget: return dialog = StimulateDialog(self.brain_widget, self) if dialog.exec_() == QtWidgets.QDialog.Accepted: stimulation_values = dialog.get_stimulation_values() if stimulation_values is not None and hasattr(self.brain_widget, 'update_state'): self.brain_widget.update_state(stimulation_values) if self.tamagotchi_logic and hasattr(self.tamagotchi_logic, 'update_from_brain'): self.tamagotchi_logic.update_from_brain(stimulation_values) def save_brain_state(self): if not self.brain_widget: return file_name, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save Brain State", "", "JSON Files (*.json)") if file_name: state_to_save = {} if hasattr(self.brain_widget, 'get_brain_state'): state_to_save = self.brain_widget.get_brain_state() elif hasattr(self.brain_widget, 'state'): state_to_save = self.brain_widget.state try: with open(file_name, 'w') as f: json.dump(state_to_save, f, indent=4) except Exception as e: print(f"Error saving brain state: {e}") def load_brain_state(self): if not self.brain_widget: return file_name, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Load Brain State", "", "JSON Files (*.json)") if file_name: try: with open(file_name, 'r') as f: state = json.load(f) if hasattr(self.brain_widget, 'set_brain_state'): self.brain_widget.set_brain_state(state) elif hasattr(self.brain_widget, 'update_state'): self.brain_widget.update_state(state) # Force disable and re-enable links after loading brain state if hasattr(self, 'checkbox_links') and self.checkbox_links.isChecked(): self.checkbox_links.setChecked(False) QtCore.QTimer.singleShot(1000, self._restore_links_checkbox) self.update_metrics_display() except Exception as e: print(f"Error loading brain state: {e}") def show_diagnostic_report(self): if not self.brain_widget: return dialog = DiagnosticReportDialog(self.brain_widget, self) dialog.exec_() def create_button(self, text, callback, color_hex): button = QtWidgets.QPushButton(text) button.clicked.connect(callback) padding_val = DisplayScaling.scale(5) btn_width = DisplayScaling.scale(150) # Adjusted for potentially more buttons btn_height = DisplayScaling.scale(40) # Adjusted height font_size_val = DisplayScaling.font_size(11) button.setStyleSheet(f"background-color: {color_hex}; border: 1px solid black; padding: {padding_val}px;") button.setFixedSize(btn_width, btn_height) font = button.font() font.setPointSize(font_size_val) button.setFont(font) return button def _show_experience_buffer(self): """Show the Experience Buffer window when magnifying glass is clicked""" loc = Localisation.instance() if not self.brain_widget: QtWidgets.QMessageBox.warning(self, loc.get("msg_missing_brain"), loc.get("msg_cannot_open_buffer")) return if not hasattr(self.brain_widget, 'enhanced_neurogenesis') or self.brain_widget.enhanced_neurogenesis is None: QtWidgets.QMessageBox.warning(self, loc.get("msg_no_neurogenesis"), loc.get("msg_neurogenesis_not_init")) return # Create or show the dialog if self.experience_buffer_dialog is None: self.experience_buffer_dialog = ExperienceBufferDialog(self.brain_widget, self) self.experience_buffer_dialog.refresh_data() self.experience_buffer_dialog.show() self.experience_buffer_dialog.raise_() self.experience_buffer_dialog.activateWindow() def _show_decorations(self): """Show the Decorations window""" loc = Localisation.instance() # Navigate up the parent hierarchy to find the main window/UI object # Handle both cases: parent as a method (PyQt default) or as an attribute parent = self.parent() if callable(self.parent) else self.parent while parent is not None: # Check if this parent has the decoration_window attribute if hasattr(parent, 'decoration_window'): parent.decoration_window.show() parent.decoration_window.raise_() parent.decoration_window.activateWindow() return # Get the next parent (handle both method and attribute cases) parent = parent.parent() if callable(parent.parent) else parent.parent # If we couldn't find it, show a warning QtWidgets.QMessageBox.warning(self, loc.get("msg_decorations_unavailable"), loc.get("msg_decorations_fail")) class ExperienceBufferDialog(QtWidgets.QDialog): """Floating window showing the experience buffer contents""" def __init__(self, brain_widget, parent=None): super().__init__(parent) self.brain_widget = brain_widget self.loc = Localisation.instance() self.setWindowTitle(self.loc.get("buffer_title")) self.setMinimumSize(800, 480) self.setWindowFlags(QtCore.Qt.Window | QtCore.Qt.WindowStaysOnTopHint) self.setup_ui() def setup_ui(self): """Create the UI elements""" layout = QtWidgets.QVBoxLayout(self) # Header with info header = QtWidgets.QLabel(self.loc.get("buffer_header")) header.setStyleSheet("font-weight: bold; font-size: 12pt; padding: 5px;") layout.addWidget(header) # Create table to show experiences self.table = QtWidgets.QTableWidget() self.table.setColumnCount(4) self.table.setHorizontalHeaderLabels([ self.loc.get("col_type"), self.loc.get("col_pattern"), self.loc.get("col_outcome"), self.loc.get("col_time") ]) # Set column widths self.table.setColumnWidth(0, 100) # Type self.table.setColumnWidth(1, 400) # Pattern self.table.setColumnWidth(2, 100) # Outcome self.table.setColumnWidth(3, 100) # Time # Style the table self.table.setStyleSheet(""" QTableWidget { background-color: white; gridline-color: #ddd; } QHeaderView::section { background-color: #3f51b5; color: white; padding: 5px; font-weight: bold; } """) self.table.setAlternatingRowColors(True) self.table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) self.table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) layout.addWidget(self.table) # Bottom info panel self.info_label = QtWidgets.QLabel() self.info_label.setStyleSheet("padding: 5px; background-color: #f5f5f5; border-radius: 3px;") layout.addWidget(self.info_label) # Refresh button button_layout = QtWidgets.QHBoxLayout() refresh_btn = QtWidgets.QPushButton(self.loc.get("btn_refresh")) refresh_btn.clicked.connect(self.refresh_data) refresh_btn.setFixedSize(80, 30) button_layout.addStretch() button_layout.addWidget(refresh_btn) layout.addLayout(button_layout) def refresh_data(self): """Update the table with current experience buffer data""" if not hasattr(self.brain_widget, 'enhanced_neurogenesis') or self.brain_widget.enhanced_neurogenesis is None: return eng = self.brain_widget.enhanced_neurogenesis if not hasattr(eng, 'experience_buffer'): self.info_label.setText("⚠️ Experience buffer not available") return buffer = eng.experience_buffer experiences = list(buffer.buffer) # Clear and populate table self.table.setRowCount(len(experiences)) for i, exp in enumerate(reversed(experiences)): # Most recent first # Type column type_item = QtWidgets.QTableWidgetItem(exp.trigger_type.capitalize()) type_color = { 'novelty': '#2196F3', 'stress': '#F44336', 'reward': '#4CAF50' }.get(exp.trigger_type, '#757575') type_item.setForeground(QtGui.QColor(type_color)) type_item.setFont(QtGui.QFont("Arial", 9, QtGui.QFont.Bold)) self.table.setItem(i, 0, type_item) # Pattern column pattern = exp.get_pattern_signature() pattern_item = QtWidgets.QTableWidgetItem(pattern) pattern_item.setToolTip(pattern) self.table.setItem(i, 1, pattern_item) # Outcome column outcome_item = QtWidgets.QTableWidgetItem(exp.outcome.capitalize()) outcome_item.setForeground(QtGui.QColor('#4CAF50' if exp.outcome == 'positive' else '#757575')) self.table.setItem(i, 2, outcome_item) # Time column (relative) time_ago = int(time.time() - exp.timestamp) if time_ago < 60: time_str = f"{time_ago}s ago" elif time_ago < 3600: time_str = f"{time_ago // 60}m ago" else: time_str = f"{time_ago // 3600}h ago" time_item = QtWidgets.QTableWidgetItem(time_str) time_item.setForeground(QtGui.QColor('#757575')) self.table.setItem(i, 3, time_item) # Update info label with pattern counts pattern_counts = buffer.pattern_counts top_patterns = sorted(pattern_counts.items(), key=lambda x: x[1], reverse=True)[:3] if top_patterns: pattern_text = f"{self.loc.get('top_patterns')}: " + " | ".join([f"{p}: {c}" for p, c in top_patterns]) else: pattern_text = self.loc.get('no_patterns') self.info_label.setText(f"📊 {self.loc.get('buffer_size')}: {len(experiences)}/{buffer.buffer.maxlen} | {pattern_text}") ================================================ FILE: src/brain_network_tab_banners.py ================================================ from PyQt5 import QtWidgets, QtCore, QtGui class BindingOverlay(QtWidgets.QWidget): """ Overlay banners that attach to the NetworkTab's functional stats area. Displays loaded bindings and collapses to a 15px strip on click. """ def __init__(self, network_tab, target_widget): super().__init__(network_tab) self.network_tab = network_tab self.target_widget = target_widget self.is_expanded = True self.bindings = [] # Auto-collapse state tracking - only auto-collapse the first time self._first_auto_collapse_done = False self._auto_collapse_timer = QtCore.QTimer(self) self._auto_collapse_timer.setSingleShot(True) self._auto_collapse_timer.timeout.connect(self._perform_auto_collapse) # CRITICAL FIX: Enable CSS background painting self.setAttribute(QtCore.Qt.WA_StyledBackground, True) # UI Setup self.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents, False) self.hide() # Layout self.main_layout = QtWidgets.QVBoxLayout(self) self.main_layout.setContentsMargins(5, 5, 5, 5) # Content Container self.content_widget = QtWidgets.QWidget() self.content_layout = QtWidgets.QVBoxLayout(self.content_widget) self.content_layout.setContentsMargins(0, 0, 0, 0) self.content_layout.setSpacing(2) # Header self.header_label = QtWidgets.QLabel("🔗 Bindings Loaded:") self.header_label.setStyleSheet("font-weight: bold; font-size: 10pt; color: #1b5e20;") self.content_layout.addWidget(self.header_label) # Scroll area self.scroll_area = QtWidgets.QScrollArea() self.scroll_area.setWidgetResizable(True) self.scroll_area.setStyleSheet(""" QScrollArea { background: transparent; border: none; } QScrollBar:vertical { width: 10px; } """) # Allow scrolling if list exceeds banner height self.scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) self.scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.scroll_widget = QtWidgets.QWidget() self.scroll_widget.setStyleSheet("background: transparent;") self.scroll_layout = QtWidgets.QVBoxLayout(self.scroll_widget) self.scroll_layout.setContentsMargins(2, 0, 0, 0) self.scroll_layout.setSpacing(4) self.scroll_area.setWidget(self.scroll_widget) self.content_layout.addWidget(self.scroll_area) self.main_layout.addWidget(self.content_widget) # Visual Style - Sage Green (#a4d4a3) self.setStyleSheet(""" BindingOverlay { background-color: #a4d4a3; border: 1px solid #689f38; border-radius: 5px; } QLabel { color: #1b5e20; /* Dark green text for contrast */ font-family: 'Consolas', 'Courier New', monospace; font-size: 9pt; } """) # Shadow effect shadow = QtWidgets.QGraphicsDropShadowEffect(self) shadow.setBlurRadius(10) shadow.setColor(QtGui.QColor(0, 0, 0, 80)) shadow.setOffset(0, 2) self.setGraphicsEffect(shadow) if self.network_tab: self.network_tab.installEventFilter(self) def _perform_auto_collapse(self): """Perform the auto-collapse if banner is still expanded after first show""" if self.is_expanded: self.animate_toggle() def update_bindings(self, bindings_list): """Populate the overlay with a list of binding widgets.""" if not bindings_list: self.hide() return # Force target visibility if self.target_widget and not self.target_widget.isVisible(): self.target_widget.setVisible(True) # Clear old items while self.scroll_layout.count(): item = self.scroll_layout.takeAt(0) if item.widget(): item.widget().deleteLater() # Add new items for binding in bindings_list: # Create a container widget for the row row_widget = QtWidgets.QWidget() row_layout = QtWidgets.QHBoxLayout(row_widget) row_layout.setContentsMargins(0, 0, 0, 0) row_layout.setSpacing(5) if isinstance(binding, dict): n_name = binding.get('neuron_name', binding.get('neuron', '?')) raw_action = binding.get('output_hook', binding.get('action', binding.get('action_name', '?'))) # Clean action name if isinstance(raw_action, str) and raw_action.startswith('neuron_output_'): action = raw_action.replace('neuron_output_', '') else: action = raw_action # Logic Details threshold = float(binding.get('threshold', 0)) mode = binding.get('trigger_mode', 'rising') params = binding.get('hook_params', {}) # Map mode to symbol mode_symbols = { 'rising': '↑', # Crossing up 'falling': '↓', # Crossing down 'above': '>', # While above 'below': '<', # While below 'change': 'Δ' # On change } symbol = mode_symbols.get(mode, '>') # 1. Text Label (Neuron > Threshold -> Action) text_lbl = QtWidgets.QLabel(f"{n_name} {symbol} {threshold:g} ➡ {action}") text_lbl.setTextFormat(QtCore.Qt.RichText) row_layout.addWidget(text_lbl) # 2. Parameter Visualization if params and all(k in params for k in ('red', 'green', 'blue')): # It's a color binding - Show 15x15 box try: r = int(params['red']) g = int(params['green']) b = int(params['blue']) color_box = QtWidgets.QFrame() color_box.setFixedSize(15, 15) # Dark border for visibility on green background color_box.setStyleSheet(f"background-color: rgb({r},{g},{b}); border: 1px solid #1b5e20; border-radius: 2px;") row_layout.addWidget(color_box) except ValueError: pass # Invalid color data, skip box elif params: # Non-color params, show generic text import json p_text = json.dumps(params).replace('"', '').replace('{', '').replace('}', '') if p_text: p_lbl = QtWidgets.QLabel(f"[{p_text}]") p_lbl.setStyleSheet("font-size: 8pt; color: #2e7d32;") row_layout.addWidget(p_lbl) else: lbl = QtWidgets.QLabel(str(binding)) row_layout.addWidget(lbl) row_layout.addStretch() # Align contents to left self.scroll_layout.addWidget(row_widget) self.scroll_layout.addStretch() self.bindings = bindings_list self.show() self.raise_() # Align slightly after show to ensure correct geometry QtCore.QTimer.singleShot(10, self.align_to_target) # Auto-collapse after 6 seconds on first show only if not self._first_auto_collapse_done: self._first_auto_collapse_done = True self._auto_collapse_timer.start(6000) # 6 seconds # Ensure banner is expanded when new bindings are loaded if not self.is_expanded: self.animate_toggle() def align_to_target(self): """Aligns the overlay to the target widget.""" if not self.target_widget or not self.target_widget.isVisible(): return # Get the target widget's geometry in its immediate parent's coordinates target_geo = self.target_widget.geometry() # CRITICAL FIX: Map coordinates from target's parent (functional_stats_area) # to network_tab's coordinate system parent = self.target_widget.parent() if parent: # Map top-left and bottom-right corners to network_tab coordinates top_left = parent.mapTo(self.network_tab, target_geo.topLeft()) bottom_right = parent.mapTo(self.network_tab, target_geo.bottomRight()) target_geo = QtCore.QRect(top_left, bottom_right) # Apply the correctly-mapped geometry if self.is_expanded: self.setGeometry(target_geo) else: # Collapsed state - show as a narrow strip self.setGeometry( target_geo.x(), target_geo.y(), 15, target_geo.height() ) def eventFilter(self, source, event): if source == self.network_tab and event.type() == QtCore.QEvent.Resize: self.align_to_target() return super().eventFilter(source, event) def mousePressEvent(self, event): """Cancel auto-collapse timer if user manually toggles""" self._auto_collapse_timer.stop() self.animate_toggle() super().mousePressEvent(event) def animate_toggle(self): if not self.target_widget: return # CRITICAL FIX: Get correctly mapped geometry (same as align_to_target) target_geo = self.target_widget.geometry() parent = self.target_widget.parent() if parent: top_left = parent.mapTo(self.network_tab, target_geo.topLeft()) bottom_right = parent.mapTo(self.network_tab, target_geo.bottomRight()) target_geo = QtCore.QRect(top_left, bottom_right) start_rect = self.geometry() if self.is_expanded: # Collapse to narrow strip end_rect = QtCore.QRect(target_geo.x(), target_geo.y(), 15, target_geo.height()) self.content_widget.hide() else: # Expand to full size end_rect = target_geo QtCore.QTimer.singleShot(50, self.content_widget.show) self.anim = QtCore.QPropertyAnimation(self, b"geometry") self.anim.setDuration(300) self.anim.setStartValue(start_rect) self.anim.setEndValue(end_rect) self.anim.setEasingCurve(QtCore.QEasingCurve.InOutQuad) self.anim.start() self.is_expanded = not self.is_expanded self.update() def paintEvent(self, event): """Draw grip dots when collapsed.""" super().paintEvent(event) if not self.is_expanded: painter = QtGui.QPainter(self) painter.setRenderHint(QtGui.QPainter.Antialiasing) painter.setPen(QtCore.Qt.NoPen) # Dark green dots to match the text/theme painter.setBrush(QtGui.QColor(27, 94, 32, 180)) cx = self.width() / 2 cy = self.height() / 2 for offset in [-10, 0, 10]: painter.drawEllipse(QtCore.QPointF(cx, cy + offset), 2.0, 2.0) class PlaceholderBanner(QtWidgets.QWidget): """ A placeholder banner with a pastel yellow background. Can be used for warnings, system notifications, or future features. """ def __init__(self, network_tab, target_widget): super().__init__(network_tab) self.network_tab = network_tab self.target_widget = target_widget self.is_expanded = True self.setAttribute(QtCore.Qt.WA_StyledBackground, True) self.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents, False) # Layout self.main_layout = QtWidgets.QVBoxLayout(self) self.main_layout.setContentsMargins(5, 5, 5, 5) # Content self.content_widget = QtWidgets.QWidget() self.content_layout = QtWidgets.QVBoxLayout(self.content_widget) self.content_layout.setContentsMargins(0, 0, 0, 0) self.label = QtWidgets.QLabel("⚠️ System Notification") self.label.setWordWrap(True) self.label.setAlignment(QtCore.Qt.AlignCenter) self.content_layout.addWidget(self.label) self.main_layout.addWidget(self.content_widget) # Style - Pastel Yellow (#fff9c4) self.setStyleSheet(""" PlaceholderBanner { background-color: #fff9c4; border: 1px solid #fbc02d; border-radius: 5px; } QLabel { color: #f57f17; font-weight: bold; font-size: 10pt; } """) # Shadow shadow = QtWidgets.QGraphicsDropShadowEffect(self) shadow.setBlurRadius(10) shadow.setColor(QtGui.QColor(0, 0, 0, 80)) shadow.setOffset(0, 2) self.setGraphicsEffect(shadow) if self.network_tab: self.network_tab.installEventFilter(self) def set_message(self, text): self.label.setText(text) self.show() self.raise_() QtCore.QTimer.singleShot(10, self.align_to_target) def align_to_target(self): if not self.target_widget or not self.target_widget.isVisible(): return # Note: If using multiple banners, you may need to offset Y here target_geo = self.target_widget.geometry() if self.is_expanded: self.setGeometry(target_geo) else: self.setGeometry(target_geo.x(), target_geo.y(), 15, target_geo.height()) def eventFilter(self, source, event): if source == self.network_tab and event.type() == QtCore.QEvent.Resize: self.align_to_target() return super().eventFilter(source, event) def mousePressEvent(self, event): self.animate_toggle() super().mousePressEvent(event) def animate_toggle(self): if not self.target_widget: return target_geo = self.target_widget.geometry() start_rect = self.geometry() if self.is_expanded: end_rect = QtCore.QRect(target_geo.x(), target_geo.y(), 15, target_geo.height()) self.content_widget.hide() else: end_rect = target_geo QtCore.QTimer.singleShot(50, self.content_widget.show) self.anim = QtCore.QPropertyAnimation(self, b"geometry") self.anim.setDuration(300) self.anim.setStartValue(start_rect) self.anim.setEndValue(end_rect) self.anim.setEasingCurve(QtCore.QEasingCurve.InOutQuad) self.anim.start() self.is_expanded = not self.is_expanded self.update() def paintEvent(self, event): super().paintEvent(event) if not self.is_expanded: painter = QtGui.QPainter(self) painter.setRenderHint(QtGui.QPainter.Antialiasing) painter.setPen(QtCore.Qt.NoPen) painter.setBrush(QtGui.QColor(245, 127, 23, 180)) # Dark orange dots cx = self.width() / 2 cy = self.height() / 2 for offset in [-10, 0, 10]: painter.drawEllipse(QtCore.QPointF(cx, cy + offset), 2.0, 2.0) ================================================ FILE: src/brain_neuron_hooks.py ================================================ # brain_neuron_hooks.py import math import random import time from typing import Dict, Callable, Any, Optional class BrainNeuronHooks: """ Generic system for wiring input neurons to actual game events. Keeps tamagotchi_logic clean by encapsulating all neuron calculation logic. Supports plugin-registered custom neuron handlers via the plugin manager. """ def __init__(self, tamagotchi_logic): self.logic = tamagotchi_logic # Registry mapping neuron names to their calculation functions self.handlers: Dict[str, Callable] = { 'external_stimulus': self.calculate_external_stimulus, 'can_see_food': self.calculate_can_see_food, 'plant_proximity': self.calculate_plant_proximity, 'threat_level': self.calculate_threat_level, 'pursuing_food': self.calculate_pursuing_food, 'is_sick': self.calculate_is_sick, 'is_fleeing': self.calculate_is_fleeing, 'is_eating': self.calculate_is_eating, 'is_sleeping': self.calculate_is_sleeping, 'is_startled': self.calculate_is_startled, } # Environmental event history for temporal calculations self.event_tracker = { 'last_window_resize_time': 0, 'window_resize_magnitude': 0, 'new_object_appeared': False, 'last_user_interaction_time': 0, 'interaction_intensity': 0, 'last_food_spawn_time': 0, 'last_poop_spawn_time': 0, } # ========================================================================= # PLUGIN INTEGRATION # ========================================================================= def register_handler(self, neuron_name: str, handler: Callable[[], float]) -> bool: """ Register a custom handler for a neuron. Args: neuron_name: The name of the neuron to wire up handler: A callable that returns a float (0-100) activation value Returns: True if registered successfully, False if neuron already has a built-in handler """ if neuron_name in self.handlers: print(f"[BrainNeuronHooks] Warning: Overwriting existing handler for '{neuron_name}'") self.handlers[neuron_name] = handler print(f"[BrainNeuronHooks] Registered handler for neuron: {neuron_name}") return True def unregister_handler(self, neuron_name: str) -> bool: """Remove a custom handler for a neuron.""" if neuron_name in self.handlers: # Don't remove built-in handlers built_ins = { 'external_stimulus', 'can_see_food', 'plant_proximity', 'threat_level', 'pursuing_food', 'is_sick', 'is_fleeing', 'is_eating', 'is_sleeping', 'is_startled' } if neuron_name in built_ins: print(f"[BrainNeuronHooks] Cannot unregister built-in handler: {neuron_name}") return False del self.handlers[neuron_name] print(f"[BrainNeuronHooks] Unregistered handler for neuron: {neuron_name}") return True return False def get_registered_neurons(self) -> list: """Return list of all neurons with registered handlers.""" return list(self.handlers.keys()) def _get_plugin_handlers(self) -> Dict[str, Callable]: """ Get any custom neuron handlers registered via the plugin manager. """ if not hasattr(self.logic, 'plugin_manager'): return {} pm = self.logic.plugin_manager if hasattr(pm, 'get_neuron_handlers'): return pm.get_neuron_handlers() return {} # ------------------------------------------------------------ def calculate_pursuing_food(self) -> float: """Return 100.0 if squid is pursuing food, 0.0 otherwise.""" if not hasattr(self.logic, 'squid'): return 0.0 return 100.0 if getattr(self.logic.squid, 'pursuing_food', False) else 0.0 def calculate_is_sick(self) -> float: """Return 100.0 if squid is sick, 0.0 otherwise.""" if not hasattr(self.logic, 'squid'): return 0.0 return 100.0 if getattr(self.logic.squid, 'is_sick', False) else 0.0 def calculate_is_startled(self) -> float: if not hasattr(self.logic, 'squid'): return 0.0 # Use same logic as above if hasattr(self.logic.squid, 'mental_state_manager') and self.logic.squid.mental_state_manager: return 100.0 if self.logic.squid.mental_state_manager.is_state_active('startled') else 0.0 return 100.0 if getattr(self.logic.squid, 'status', '').lower() == 'startled' else 0.0 def calculate_is_fleeing(self) -> float: """Return 100.0 if squid is fleeing, 0.0 otherwise.""" if not hasattr(self.logic, 'squid'): return 0.0 return 100.0 if getattr(self.logic.squid, 'is_fleeing', False) else 0.0 # ========================================================================= # PUBLIC API - Called from tamagotchi_logic.py # ========================================================================= def get_input_neuron_values(self) -> Dict[str, float]: """ Calculate activation values for all registered input neurons. Returns: {neuron_name: activation_value} """ if not hasattr(self.logic, 'brain_window') or not self.logic.brain_window: return {} brain_widget = self.logic.brain_window.brain_widget input_values = {} # Get neuron configurations config = getattr(brain_widget, 'config', {}) neurons_config = config.get_neurogenesis_config().get('neurons', {}) # Merge plugin-registered handlers with built-in handlers all_handlers = {**self.handlers} plugin_handlers = self._get_plugin_handlers() all_handlers.update(plugin_handlers) for neuron_name in brain_widget.neuron_positions.keys(): # Skip core stat neurons if neuron_name in ['hunger', 'happiness', 'cleanliness', 'sleepiness', 'satisfaction', 'anxiety', 'curiosity']: continue # Check if neuron is marked as input type OR has a handler neuron_cfg = neurons_config.get(neuron_name, {}) if neuron_cfg.get('type') == 'input' or neuron_name in all_handlers: # Call handler if exists, otherwise default background noise if neuron_name in all_handlers: try: input_values[neuron_name] = all_handlers[neuron_name]() except Exception as e: print(f"[BrainNeuronHooks] Error in handler for '{neuron_name}': {e}") input_values[neuron_name] = 0.0 else: input_values[neuron_name] = random.uniform(5, 10) return input_values def on_window_resize(self, width_change: int, height_change: int, new_size: tuple): """Track window resize events for external_stimulus neuron.""" magnitude = math.sqrt(width_change**2 + height_change**2) self.event_tracker['window_resize_magnitude'] = min(100, magnitude / 10) self.event_tracker['last_window_resize_time'] = time.time() def on_object_spawned(self, object_type: str): """Track when new objects appear in the environment.""" self.event_tracker['new_object_appeared'] = True # Set interaction intensity based on object type intensity_map = { 'food': 30, 'decorations': 20, 'poop': 10, } self.event_tracker['interaction_intensity'] = intensity_map.get(object_type, 15) self.event_tracker['last_user_interaction_time'] = time.time() def on_user_interaction(self, action: str): """Track user interactions (feeding, cleaning, etc.).""" self.event_tracker['last_user_interaction_time'] = time.time() intensity_map = { 'feed': 40, 'clean': 60, 'medicine': 70, 'rock_test': 50, } self.event_tracker['interaction_intensity'] = intensity_map.get(action, 30) def update_decay(self): """Decay environmental trackers each simulation tick.""" self.event_tracker['window_resize_magnitude'] *= 0.95 self.event_tracker['interaction_intensity'] *= 0.90 # ========================================================================= # HANDLER FUNCTIONS - Specific neuron calculations # ========================================================================= def calculate_external_stimulus(self) -> float: """ Calculate activation for external_stimulus neuron based on recent environmental changes. Returns value between 0-100. """ tracker = self.event_tracker current_time = time.time() # Start with baseline environmental noise activation = random.uniform(5, 15) # Add contribution from window resize events if tracker['window_resize_magnitude'] > 0: time_since_resize = current_time - tracker['last_window_resize_time'] decay_factor = max(0, 1 - (time_since_resize / 10.0)) activation += tracker['window_resize_magnitude'] * decay_factor # Add contribution from new objects if tracker['new_object_appeared']: activation += 30 tracker['new_object_appeared'] = False # Reset after one tick # Add contribution from user interactions if tracker['interaction_intensity'] > 0: time_since_interaction = current_time - tracker['last_user_interaction_time'] decay_factor = max(0, 1 - (time_since_interaction / 5.0)) activation += tracker['interaction_intensity'] * decay_factor return max(0, min(100, activation)) def calculate_can_see_food(self) -> float: """Return 100.0 if food is visible in vision cone, 0.0 otherwise.""" # 1. Prefer the VisionWorker result (Most accurate/current) if hasattr(self.logic, 'latest_vision_result') and self.logic.latest_vision_result: return 100.0 if self.logic.latest_vision_result.can_see_food else 0.0 # 2. Fallback to synchronous check (Only if worker hasn't reported yet) if not hasattr(self.logic, 'squid') or not self.logic.squid: return 0.0 return 100.0 if self.logic.squid.can_see_food() else 0.0 def calculate_plant_proximity(self) -> float: """ Calculate activation based on squid's proximity to plant decorations. Returns: 100.0 if squid body overlaps with any plant's bounding box (touching) 0-99 scaled by distance to nearest plant edge if not touching 0.0 if no plants exist """ # 1. First check squid's cached plant proximity (most reliable, updated via signal) if hasattr(self.logic, 'squid') and self.logic.squid: squid = self.logic.squid if hasattr(squid, '_cached_plant_proximity'): cached = squid._cached_plant_proximity if cached is not None and cached > 0: return cached # Also try the getter method if hasattr(squid, 'get_plant_proximity'): prox = squid.get_plant_proximity() if prox is not None and prox > 0: return prox # 2. Check VisionWorker result (only if attribute actually exists and has value) if hasattr(self.logic, 'latest_vision_result') and self.logic.latest_vision_result: result = self.logic.latest_vision_result if hasattr(result, 'plant_proximity_value') and result.plant_proximity_value is not None: if result.plant_proximity_value > 0: return result.plant_proximity_value # 3. Manual fallback - calculate directly from scene if not hasattr(self.logic, 'user_interface') or not hasattr(self.logic, 'squid'): return 0.0 squid = self.logic.squid if not hasattr(squid, 'squid_item') or not squid.squid_item: return 0.0 # Get squid's bounding rect in scene coordinates squid_rect = squid.squid_item.sceneBoundingRect() min_distance = float('inf') is_touching = False # Scan scene for plant decorations for item in self.logic.user_interface.scene.items(): # Check if item has category attribute and is a plant if hasattr(item, 'category') and item.category == 'plant': plant_rect = item.sceneBoundingRect() # Check if squid overlaps with plant bounding box if squid_rect.intersects(plant_rect): is_touching = True break # Calculate distance from squid center to plant edge squid_center = squid_rect.center() # Find closest point on plant rect to squid center closest_x = max(plant_rect.left(), min(squid_center.x(), plant_rect.right())) closest_y = max(plant_rect.top(), min(squid_center.y(), plant_rect.bottom())) # Distance from squid center to that closest point dist = math.hypot(squid_center.x() - closest_x, squid_center.y() - closest_y) min_distance = min(min_distance, dist) # If touching any plant, return max activation if is_touching: return 100.0 # If no plants found, return 0 if min_distance == float('inf'): return 0.0 # Scale activation by distance (closer = higher, max range ~300px) max_range = 300.0 activation = max(0.0, 100.0 - (min_distance / max_range * 100.0)) return activation def calculate_threat_level(self) -> float: """Calculate activation based on current anxiety and startle state.""" if not hasattr(self.logic, 'squid'): return 0 # Base threat on anxiety threat_level = self.logic.squid.anxiety # Increase if startled or fleeing if getattr(self.logic.squid, 'is_fleeing', False): threat_level = min(100, threat_level + 30) # Add random fluctuation threat_level += random.uniform(-5, 5) return max(0, min(100, threat_level)) def calculate_is_eating(self) -> float: """Return 100.0 if squid is eating, 0.0 otherwise.""" if not hasattr(self.logic, 'squid'): return 0.0 return 100.0 if getattr(self.logic.squid, 'is_eating', False) else 0.0 def calculate_is_sleeping(self) -> float: """Return 100.0 if squid is sleeping, 0.0 otherwise.""" if not hasattr(self.logic, 'squid'): return 0.0 return 100.0 if getattr(self.logic.squid, 'is_sleeping', False) else 0.0 # Default sensors that have built-in handlers DEFAULT_INPUT_SENSORS = ( 'external_stimulus', 'can_see_food', 'plant_proximity', 'threat_level', 'pursuing_food', 'is_sick', 'is_fleeing', 'is_eating', 'is_sleeping', 'is_startled', ) ================================================ FILE: src/brain_neuron_outputs.py ================================================ """ Neuron Output Binding System This module enables custom neurons in the brain designer to trigger game behaviors when they fire. It creates a bidirectional system: INPUT (Sensors): Game Events → Hooks → Neuron Activation OUTPUT (Actuators): Neuron Fires → Threshold Check → Hooks → Game Behavior """ import time from typing import Dict, List, Callable, Any, Optional from dataclasses import dataclass, field from enum import Enum # Try importing PyQt5 for the floating console try: from PyQt5 import QtWidgets, QtCore, QtGui HAS_QT = True except ImportError: HAS_QT = False # ANSI Colors for Console Output (Fallback) ANSI_ORANGE = "\033[38;5;208m" ANSI_RESET = "\033[0m" class OutputTriggerMode(Enum): """How the output should be triggered.""" THRESHOLD_RISING = "rising" # Fire when crossing threshold upward THRESHOLD_FALLING = "falling" # Fire when crossing threshold downward THRESHOLD_ABOVE = "above" # Fire continuously while above threshold THRESHOLD_BELOW = "below" # Fire continuously while below threshold ON_CHANGE = "change" # Fire on any significant change @dataclass class NeuronOutputBinding: """ Binds a neuron to an output hook. When the neuron's activation meets the trigger conditions, the bound hook is fired with the activation value. """ neuron_name: str output_hook: str threshold: float = 70.0 trigger_mode: OutputTriggerMode = OutputTriggerMode.THRESHOLD_RISING cooldown: float = 1.0 # Seconds between firings enabled: bool = True # Optional parameters passed to the hook hook_params: Dict[str, Any] = field(default_factory=dict) # Runtime state (not saved) last_fire_time: float = 0.0 last_activation: float = 0.0 def to_dict(self) -> dict: """Serialize for saving.""" return { 'neuron_name': self.neuron_name, 'output_hook': self.output_hook, 'threshold': self.threshold, 'trigger_mode': self.trigger_mode.value, 'cooldown': self.cooldown, 'enabled': self.enabled, 'hook_params': self.hook_params, } @classmethod def from_dict(cls, data: dict) -> 'NeuronOutputBinding': """Deserialize from saved data.""" trigger_mode = OutputTriggerMode(data.get('trigger_mode', 'rising')) return cls( neuron_name=data['neuron_name'], output_hook=data['output_hook'], threshold=data.get('threshold', 70.0), trigger_mode=trigger_mode, cooldown=data.get('cooldown', 1.0), enabled=data.get('enabled', True), hook_params=data.get('hook_params', {}), ) def should_fire(self, current_activation: float, current_time: float) -> bool: """Check if this binding should fire given current activation.""" if not self.enabled: return False # Check cooldown if current_time - self.last_fire_time < self.cooldown: return False should_fire = False if self.trigger_mode == OutputTriggerMode.THRESHOLD_RISING: # Fire when crossing threshold upward should_fire = ( self.last_activation < self.threshold and current_activation >= self.threshold ) elif self.trigger_mode == OutputTriggerMode.THRESHOLD_FALLING: # Fire when crossing threshold downward should_fire = ( self.last_activation >= self.threshold and current_activation < self.threshold ) elif self.trigger_mode == OutputTriggerMode.THRESHOLD_ABOVE: # Fire continuously while above threshold should_fire = current_activation >= self.threshold elif self.trigger_mode == OutputTriggerMode.THRESHOLD_BELOW: # Fire continuously while below threshold should_fire = current_activation < self.threshold elif self.trigger_mode == OutputTriggerMode.ON_CHANGE: # Fire on significant change (>10% difference) should_fire = abs(current_activation - self.last_activation) > 10.0 return should_fire # ============================================================================= # FLOATING LOG WINDOW # ============================================================================= if HAS_QT: class NeuronLogWindow(QtWidgets.QWidget): """A floating console window for neuron output logs.""" def __init__(self): super().__init__() self.setWindowTitle("Neuron Monitor") # Window Flags: Tool (small title bar), Stay on Top self.setWindowFlags(QtCore.Qt.Tool | QtCore.Qt.WindowStaysOnTopHint) self.setAttribute(QtCore.Qt.WA_ShowWithoutActivating) self.resize(800, 200) # Styling: Dark background, Orange text (Consolas/Monospace) self.setStyleSheet(""" QWidget { background-color: #121212; color: #ffb74d; font-family: 'Consolas', 'Courier New', monospace; font-size: 10pt; } QPlainTextEdit { border: 1px solid #333; selection-background-color: #333; } """) # Layout layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(2, 2, 2, 2) # Text Area self.text_area = QtWidgets.QPlainTextEdit() self.text_area.setReadOnly(True) self.text_area.setMaximumBlockCount(1000) # Limit history layout.addWidget(self.text_area) # Initial positioning self._position_at_bottom_center() def _position_at_bottom_center(self): """Move window to bottom center of primary screen.""" if not QtWidgets.QApplication.instance(): return screen = QtWidgets.QApplication.primaryScreen() if screen: geo = screen.availableGeometry() # Center X x = geo.x() + (geo.width() - self.width()) // 2 # Bottom Y (with some padding) y = geo.y() + geo.height() - self.height() - 50 self.move(x, y) def log(self, message: str): """Append a message to the log.""" timestamp = time.strftime("%H:%M:%S") self.text_area.appendPlainText(f"[{timestamp}] {message}") # Auto-scroll to bottom bar = self.text_area.verticalScrollBar() bar.setValue(bar.maximum()) else: class NeuronLogWindow: """Dummy class if PyQt is not available.""" def log(self, msg): print(msg) def show(self): pass # ============================================================================= # PREDEFINED OUTPUT HOOKS # ============================================================================= STANDARD_OUTPUT_HOOKS = { # Movement behaviors 'neuron_output_flee': { 'description': 'Trigger fleeing behavior', 'category': 'movement', 'default_threshold': 80.0, }, 'neuron_output_seek_food': { 'description': 'Drive food-seeking behavior', 'category': 'movement', 'default_threshold': 60.0, }, 'neuron_output_seek_plant': { 'description': 'Move toward nearest plant', 'category': 'movement', 'default_threshold': 50.0, }, 'neuron_output_approach_rock': { 'description': 'Approach nearest rock', 'category': 'movement', 'default_threshold': 50.0, }, 'neuron_output_wander': { 'description': 'Random exploration movement', 'category': 'movement', 'default_threshold': 40.0, }, # Action behaviors 'neuron_output_throw_rock': { 'description': 'Throw held rock', 'category': 'action', 'default_threshold': 70.0, }, 'neuron_output_pick_up_rock': { 'description': 'Pick up nearby rock', 'category': 'action', 'default_threshold': 60.0, }, 'neuron_output_ink_cloud': { 'description': 'Release defensive ink cloud', 'category': 'action', 'default_threshold': 85.0, }, 'neuron_output_eat': { 'description': 'Eat nearby food', 'category': 'action', 'default_threshold': 50.0, }, # State changes 'neuron_output_sleep': { 'description': 'Initiate sleep', 'category': 'state', 'default_threshold': 90.0, }, 'neuron_output_wake': { 'description': 'Wake from sleep', 'category': 'state', 'default_threshold': 30.0, }, 'neuron_output_startle': { 'description': 'Trigger startle response', 'category': 'state', 'default_threshold': 75.0, }, 'neuron_output_calm': { 'description': 'Reduce anxiety/calm down', 'category': 'state', 'default_threshold': 20.0, }, # Stat modifications 'neuron_output_boost_happiness': { 'description': 'Increase happiness', 'category': 'stats', 'default_threshold': 70.0, }, 'neuron_output_boost_curiosity': { 'description': 'Increase curiosity', 'category': 'stats', 'default_threshold': 60.0, }, 'neuron_output_reduce_anxiety': { 'description': 'Decrease anxiety', 'category': 'stats', 'default_threshold': 30.0, }, # Colour change 'neuron_output_change_color': { 'description': 'Change the squid body color when triggered. Can specify specific color parameters.', 'category': 'action', 'default_threshold': 60.0, 'has_params': True, }, # Custom/plugin hooks 'neuron_output_custom': { 'description': 'Custom behavior (plugin-defined)', 'category': 'custom', 'default_threshold': 50.0, }, } class NeuronOutputMonitor: """ Monitors neuron activations and triggers output hooks when thresholds are met. This is the runtime component that connects the neural network to game behaviors. """ def __init__(self, tamagotchi_logic): self.logic = tamagotchi_logic self.bindings: List[NeuronOutputBinding] = [] self.enabled = True # Statistics self.total_fires = 0 self.fires_by_hook: Dict[str, int] = {} # Log Window self.log_window = None # Register default hook handlers self._register_default_handlers() def _ensure_log_window(self): """Create the log window if it doesn't exist and Qt is available.""" if self.log_window is None and HAS_QT: # Only create if QApplication exists if QtWidgets.QApplication.instance(): self.log_window = NeuronLogWindow() def _log(self, message: str): """Helper to print logs to floating window (or console fallback).""" if HAS_QT and QtWidgets.QApplication.instance(): self._ensure_log_window() if self.log_window: self.log_window.log(message) else: # Fallback for headless or non-Qt environments print(f"{ANSI_ORANGE}[NeuronOutputMonitor]{ANSI_RESET} {message}") def _register_default_handlers(self): """Register all standard output hooks and subscribe to available handlers.""" if not hasattr(self.logic, 'plugin_manager'): self._log("⚠️ Cannot register handlers: plugin_manager not available") return pm = self.logic.plugin_manager # Whitelist this monitor as an enabled 'plugin' if hasattr(pm, 'enabled_plugins'): pm.enabled_plugins.add('neuronoutputmonitor') self._log(f"📡 Plugin manager found, registering {len(STANDARD_OUTPUT_HOOKS)} hooks...") # Register hooks for hook_name in STANDARD_OUTPUT_HOOKS.keys(): pm.register_hook(hook_name) # Subscribe handlers import inspect subscribed_count = 0 for method_name, method in inspect.getmembers(self, predicate=inspect.ismethod): if method_name.startswith('_handle_'): base_name = method_name[8:] # Remove '_handle_' prefix hook_name = f"neuron_output_{base_name}" if hook_name in STANDARD_OUTPUT_HOOKS: success = pm.subscribe_to_hook(hook_name, 'NeuronOutputMonitor', method) if success: subscribed_count += 1 self._log(f"📊 Ready. Total subscriptions: {subscribed_count}") # Redundant safety pass for method_name, method in inspect.getmembers(self, predicate=inspect.ismethod): if method_name.startswith('_handle_'): base_name = method_name[8:] hook_name = f"neuron_output_{base_name}" if hook_name in STANDARD_OUTPUT_HOOKS: pm.subscribe_to_hook(hook_name, 'NeuronOutputMonitor', method) # ========================================================================= # BINDING MANAGEMENT # ========================================================================= def monitor(self, neuron_activations: Dict[str, float], current_time: Optional[float] = None): """Main method called every frame with all current neuron activations.""" if not self.enabled: return # Self-Healing Whitelist if hasattr(self.logic, 'plugin_manager'): pm = self.logic.plugin_manager if hasattr(pm, 'enabled_plugins') and 'neuronoutputmonitor' not in pm.enabled_plugins: pm.enabled_plugins.add('neuronoutputmonitor') current_time = current_time or time.time() for binding in self.bindings: activation = neuron_activations.get(binding.neuron_name, 0.0) # Check for firing BEFORE updating last_activation if binding.should_fire(activation, current_time): self._fire_binding(binding, activation, current_time) # Update last activation for edge detection binding.last_activation = activation def add_binding(self, binding: NeuronOutputBinding) -> bool: """Add a new output binding.""" self.bindings.append(binding) self._log(f"Added binding: {binding.neuron_name} → {binding.output_hook}") return True def remove_binding(self, neuron_name: str, output_hook: str) -> bool: """Remove bindings matching neuron and hook name.""" initial_len = len(self.bindings) self.bindings = [ b for b in self.bindings if not (b.neuron_name == neuron_name and b.output_hook == output_hook) ] return len(self.bindings) < initial_len def get_bindings_for_neuron(self, neuron_name: str) -> List[NeuronOutputBinding]: """Get all output bindings for a specific neuron.""" return [b for b in self.bindings if b.neuron_name == neuron_name] def clear_bindings(self): """Remove all bindings.""" self.bindings.clear() def load_bindings_from_brain(self, brain_data: dict): """Load output bindings from brain configuration data.""" self.clear_bindings() output_bindings = brain_data.get('output_bindings', []) for binding_data in output_bindings: try: binding = NeuronOutputBinding.from_dict(binding_data) self.add_binding(binding) except Exception as e: self._log(f"Error loading binding: {e}") def export_bindings(self) -> List[dict]: """Export all bindings as serializable dicts.""" return [b.to_dict() for b in self.bindings] # ========================================================================= # RUNTIME PROCESSING # ========================================================================= def process_outputs(self): """ Process all output bindings. Call this each simulation tick after the neural network has been updated. """ if not self.enabled or not self.bindings: return if not hasattr(self.logic, 'brain_window') or not self.logic.brain_window: return brain_widget = self.logic.brain_window.brain_widget current_time = time.time() for binding in self.bindings: # Get current activation for this neuron activation = self._get_neuron_activation(brain_widget, binding.neuron_name) # If we can't find the activation, we can't trigger if activation is None: continue # Check if should fire if binding.should_fire(activation, current_time): self._fire_binding(binding, activation, current_time) # Update last activation for next check binding.last_activation = activation def _get_neuron_activation(self, brain_widget, neuron_name: str) -> Optional[float]: """ Get the current activation value of a neuron. Checks multiple sources to ensure compatibility with different brain versions. """ # 1. Check 'neuron_activations' (often used for sensor overrides/inputs) if hasattr(brain_widget, 'neuron_activations') and brain_widget.neuron_activations: val = brain_widget.neuron_activations.get(neuron_name) if val is not None: return float(val) # 2. Check 'state' (main storage for neuron values in Dosidicus/Custom brains) if hasattr(brain_widget, 'state') and brain_widget.state: val = brain_widget.state.get(neuron_name) if val is not None: return float(val) # 3. Fallback: check config state if hasattr(brain_widget, 'config'): try: state = brain_widget.config.get_neurogenesis_config().get('state', {}) val = state.get(neuron_name) if val is not None: return float(val) except: pass return None def _fire_binding(self, binding: NeuronOutputBinding, activation: float, current_time: float): """Fire a binding's output hook.""" binding.last_fire_time = current_time # Update statistics self.total_fires += 1 self.fires_by_hook[binding.output_hook] = self.fires_by_hook.get(binding.output_hook, 0) + 1 # Trigger the hook if hasattr(self.logic, 'plugin_manager'): self.logic.plugin_manager.trigger_hook( binding.output_hook, neuron_name=binding.neuron_name, activation=activation, threshold=binding.threshold, squid=self.logic.squid, tamagotchi_logic=self.logic, **binding.hook_params ) # Debug output if hasattr(self.logic, 'debug_mode') and self.logic.debug_mode: self._log(f"FIRED: {binding.neuron_name} → {binding.output_hook} ({activation:.0f})") # ========================================================================= # DEFAULT HOOK HANDLERS # ========================================================================= def _handle_flee(self, neuron_name, activation, squid, **kwargs): if squid and not getattr(squid, 'is_fleeing', False): squid.is_fleeing = True squid.current_speed = squid.base_speed * 2 if hasattr(squid, 'mental_state_manager'): squid.mental_state_manager.activate_state('fleeing') def _handle_seek_food(self, neuron_name, activation, squid, tamagotchi_logic, **kwargs): if squid and tamagotchi_logic: visible_food = squid.get_visible_food() if hasattr(squid, 'get_visible_food') else [] if visible_food: squid.pursuing_food = True squid.target_food = visible_food[0] def _handle_seek_plant(self, neuron_name, activation, squid, tamagotchi_logic, **kwargs): if not squid or not tamagotchi_logic: return nearest_plant = None min_dist = float('inf') for item in tamagotchi_logic.user_interface.scene.items(): if hasattr(item, 'category') and item.category == 'plant': plant_pos = item.sceneBoundingRect().center() dist = ((plant_pos.x() - squid.squid_x)**2 + (plant_pos.y() - squid.squid_y)**2)**0.5 if dist < min_dist: min_dist = dist nearest_plant = item if nearest_plant: squid.status = "seeking_plant" target_pos = nearest_plant.sceneBoundingRect().center() squid.move_toward_position(target_pos) def _handle_ink_cloud(self, neuron_name, activation, squid, **kwargs): if squid and hasattr(squid, 'release_ink'): squid.release_ink() elif squid and hasattr(squid, 'mental_state_manager'): squid.mental_state_manager.activate_state('inking') def _handle_change_color(self, neuron_name, activation, squid, **kwargs): """ Handle color change when neuron fires. Checks for specific 'red', 'green', 'blue' parameters in kwargs. """ if squid and hasattr(squid, 'apply_tint'): from PyQt5.QtGui import QColor import random try: r = int(kwargs.get('red', -1)) g = int(kwargs.get('green', -1)) b = int(kwargs.get('blue', -1)) if r >= 0 and g >= 0 and b >= 0: color = QColor(r, g, b) squid.apply_tint(color) self._log(f"Tint: {r},{g},{b}") return except (ValueError, TypeError): pass # Fallback only if params missing or invalid colors = [ (255, 100, 100), (100, 255, 100), (100, 100, 255), (255, 255, 100), (255, 100, 255), (100, 255, 255) ] rgb = random.choice(colors) squid.apply_tint(QColor(*rgb)) self._log(f"RandTint: {rgb}") def _handle_startle(self, neuron_name, activation, squid, **kwargs): if squid and hasattr(squid, 'mental_state_manager'): squid.mental_state_manager.activate_state('startled') def _handle_calm(self, neuron_name, activation, squid, **kwargs): if squid: squid.anxiety = max(0, squid.anxiety - 10) squid.is_fleeing = False if hasattr(squid, 'mental_state_manager'): squid.mental_state_manager.deactivate_state('startled') squid.mental_state_manager.deactivate_state('fleeing') def _handle_sleep(self, neuron_name, activation, squid, **kwargs): if squid and not getattr(squid, 'is_sleeping', False): squid.is_sleeping = True squid.status = "sleeping" def _handle_wake(self, neuron_name, activation, squid, **kwargs): if squid and getattr(squid, 'is_sleeping', False): squid.is_sleeping = False squid.status = "roaming" def _handle_boost_happiness(self, neuron_name, activation, squid, **kwargs): if squid: boost = (activation / 100.0) * 5 squid.happiness = min(100, squid.happiness + boost) def _handle_reduce_anxiety(self, neuron_name, activation, squid, **kwargs): if squid: reduction = ((100 - activation) / 100.0) * 5 squid.anxiety = max(0, squid.anxiety - reduction) def _handle_wander(self, neuron_name, activation, squid, **kwargs): if squid and hasattr(squid, 'wander'): squid.wander() elif squid: squid.status = "roaming" def _handle_approach_rock(self, neuron_name, activation, squid, tamagotchi_logic, **kwargs): if not squid or not tamagotchi_logic: return decorations = tamagotchi_logic.get_nearby_decorations(squid.squid_x, squid.squid_y, 300) rocks = [d for d in decorations if hasattr(d, 'category') and d.category == 'rock'] if rocks: nearest = min(rocks, key=lambda r: ((r.sceneBoundingRect().center().x() - squid.squid_x)**2 + (r.sceneBoundingRect().center().y() - squid.squid_y)**2)) squid.status = "approaching_rock" squid.current_rock_target = nearest def _handle_throw_rock(self, neuron_name, activation, squid, **kwargs): if squid and getattr(squid, 'carrying_rock', False): import random direction = random.choice(['left', 'right']) squid.throw_rock(direction) def _handle_pick_up_rock(self, neuron_name, activation, squid, tamagotchi_logic, **kwargs): if not squid or getattr(squid, 'carrying_rock', False): return if tamagotchi_logic: decorations = tamagotchi_logic.get_nearby_decorations(squid.squid_x, squid.squid_y, 50) rocks = [d for d in decorations if hasattr(d, 'category') and d.category == 'rock'] if rocks: squid.pick_up_rock(rocks[0]) def _handle_eat(self, neuron_name, activation, squid, **kwargs): if squid and hasattr(squid, 'target_food') and squid.target_food: squid.is_eating = True def _handle_boost_curiosity(self, neuron_name, activation, squid, **kwargs): if squid: boost = (activation / 100.0) * 5 squid.curiosity = min(100, squid.curiosity + boost) def get_available_output_hooks() -> Dict[str, dict]: return dict(STANDARD_OUTPUT_HOOKS) def get_output_hooks_by_category() -> Dict[str, Dict[str, dict]]: by_category = {} for hook_name, info in STANDARD_OUTPUT_HOOKS.items(): cat = info.get('category', 'other') if cat not in by_category: by_category[cat] = {} by_category[cat][hook_name] = info return by_category ================================================ FILE: src/brain_personality_tab.py ================================================ # brain_personality_tab.py from PyQt5 import QtCore, QtGui, QtWidgets from .brain_base_tab import BrainBaseTab from .personality import Personality from .localisation import Localisation class PersonalityTab(BrainBaseTab): def __init__(self, parent=None, tamagotchi_logic=None, brain_widget=None, config=None, debug_mode=False): super().__init__(parent, tamagotchi_logic, brain_widget, config, debug_mode) self.loc = Localisation.instance() self.initialize_ui() def initialize_ui(self): from .display_scaling import DisplayScaling # Create a scrollable area for the tab content scroll_area = QtWidgets.QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setFrameShape(QtWidgets.QFrame.NoFrame) # Create content widget content_widget = QtWidgets.QWidget() self.tab_layout = QtWidgets.QVBoxLayout(content_widget) # Use properly scaled font sizes self.base_font_size = DisplayScaling.font_size(10) self.header_font_size = DisplayScaling.font_size(12) # Add personality section self.init_personality_section() # Set the scroll area's widget scroll_area.setWidget(content_widget) # Add to main layout self.layout.addWidget(scroll_area) def init_personality_section(self): # Separator line self.tab_layout.addWidget(QtWidgets.QFrame(frameShape=QtWidgets.QFrame.HLine)) # Personality type label - larger and bolder self.personality_type_label = QtWidgets.QLabel(f"{self.loc.get('squid_personality')}: ") font = QtGui.QFont() font.setPointSize(self.header_font_size) font.setBold(True) self.personality_type_label.setFont(font) self.tab_layout.addWidget(self.personality_type_label) # Personality modifier label - larger self.personality_modifier_label = QtWidgets.QLabel(f"{self.loc.get('personality_modifier')}: ") mod_font = QtGui.QFont() mod_font.setPointSize(self.base_font_size) mod_font.setBold(True) self.personality_modifier_label.setFont(mod_font) self.tab_layout.addWidget(self.personality_modifier_label) # Separator self.tab_layout.addWidget(QtWidgets.QFrame(frameShape=QtWidgets.QFrame.HLine)) self.tab_layout.addSpacing(20) # Description section description_label = QtWidgets.QLabel(self.loc.get("description")) description_label.setFont(font) self.tab_layout.addWidget(description_label) self.personality_description = QtWidgets.QTextEdit() self.personality_description.setReadOnly(True) text_font = QtGui.QFont() text_font.setPointSize(self.base_font_size) self.personality_description.setFont(text_font) self.tab_layout.addWidget(self.personality_description) self.tab_layout.addSpacing(20) # Personality modifiers self.modifiers_label = QtWidgets.QLabel(self.loc.get("personality_modifiers")) self.modifiers_label.setFont(font) self.tab_layout.addWidget(self.modifiers_label) self.modifiers_text = QtWidgets.QTextEdit() self.modifiers_text.setReadOnly(True) self.modifiers_text.setFont(text_font) self.tab_layout.addWidget(self.modifiers_text) self.tab_layout.addSpacing(20) # Care tips self.care_tips_label = QtWidgets.QLabel(self.loc.get("care_tips_label")) self.care_tips_label.setFont(font) self.tab_layout.addWidget(self.care_tips_label) self.care_tips = QtWidgets.QTextEdit() self.care_tips.setReadOnly(True) self.care_tips.setFont(text_font) self.tab_layout.addWidget(self.care_tips) self.tab_layout.addSpacing(20) # Note about personality generation note_label = QtWidgets.QLabel(self.loc.get("personality_note")) note_font = QtGui.QFont() note_font.setPointSize(self.base_font_size) note_font.setItalic(True) note_label.setFont(note_font) self.tab_layout.addWidget(note_label) # Set fixed heights for text boxes to make them more compact for text_box in [self.personality_description, self.modifiers_text, self.care_tips]: text_box.setMinimumHeight(150) text_box.setMaximumHeight(200) def update_from_brain_state(self, state): """Update personality info when brain state changes""" if 'personality' in state: self.update_personality_display(state['personality']) def update_personality_display(self, personality): """Update all personality display elements""" # Get translated personality name personality_name = self.loc.get_personality_name(personality) # Set personality type label self.personality_type_label.setText(f"{self.loc.get('squid_personality')}: {personality_name}") # Set personality modifier label self.personality_modifier_label.setText(f"{self.loc.get('personality_modifier')}: {self.get_personality_modifier(personality)}") # Set description text self.personality_description.setPlainText(self.get_personality_description(personality)) # Set modifiers text self.modifiers_text.setPlainText(self.get_personality_modifiers(personality)) # Set care tips text self.care_tips.setPlainText(self.get_care_tips(personality)) def get_personality_description(self, personality): """Get translated personality description""" return self.loc.get_personality_description(personality) def get_personality_modifier(self, personality): """Get translated personality modifier text""" return self.loc.get_personality_modifier_text(personality) def get_care_tips(self, personality): """Get translated care tips""" return self.loc.get_care_tips(personality) def get_personality_modifiers(self, personality): """Get translated detailed personality modifiers""" return self.loc.get_personality_modifiers(personality) ================================================ FILE: src/brain_render_worker.py ================================================ """ brain_render_worker.py - Offscreen rendering worker for brain visualization Renders the neural network visualization to a QImage in a background thread, then the main thread just blits that image. This dramatically improves UI responsiveness by moving expensive drawing operations off the main thread. Usage: 1. Create BrainRenderWorker with reference to brain widget 2. Call request_render() when state changes 3. Connect to render_complete signal to receive the rendered QImage 4. In paintEvent, just draw the cached image """ import time import math import re from typing import Dict, List, Tuple, Optional, Any from dataclasses import dataclass, field from PyQt5.QtCore import QThread, pyqtSignal, QMutex, QMutexLocker, QWaitCondition, QSize, Qt, QPointF, QRectF from PyQt5.QtGui import QImage, QPainter, QColor, QPen, QBrush, QFont, QPolygonF @dataclass class RenderState: """Snapshot of all data needed to render the brain""" # Neuron data neuron_positions: Dict[str, Tuple[float, float]] = field(default_factory=dict) neuron_states: Dict[str, float] = field(default_factory=dict) state_colors: Dict[str, Tuple[int, int, int]] = field(default_factory=dict) neuron_shapes: Dict[str, str] = field(default_factory=dict) # Pre-calculated localized labels neuron_labels: Dict[str, str] = field(default_factory=dict) # Connection data weights: Dict[Tuple[str, str], float] = field(default_factory=dict) communication_events: Dict[str, float] = field(default_factory=dict) # Visibility visible_neurons: set = field(default_factory=set) excluded_neurons: set = field(default_factory=set) # Animation state link_opacities: Dict[Tuple[str, str], float] = field(default_factory=dict) animation_time: float = 0.0 # [NEW] Active weight animations for Hebbian learning weight_animations: List[Dict] = field(default_factory=list) # Display settings show_weights: bool = False is_tutorial_mode: bool = False # ===== ANIMATION STYLE PARAMETERS ===== # Base visual settings anim_background_colour: Tuple[int, int, int] = (30, 30, 40) anim_line_base_width: float = 1.0 anim_line_col_pos: Tuple[int, int, int] = (100, 255, 100) anim_line_col_neg: Tuple[int, int, int] = (255, 100, 100) anim_line_alpha: int = 180 anim_line_style: int = 0 # Qt.SolidLine # Weight-based thickness weight_thickness_enabled: bool = False weight_thickness_min: float = 1.0 weight_thickness_max: float = 2.0 weight_thickness_power: float = 1.0 # Scroll settings scroll_enabled: bool = False scroll_dot_count: int = 3 scroll_dot_size: float = 6.0 scroll_dot_colour: Tuple[int, int, int] = (255, 255, 255) scroll_dot_alpha: int = 200 scroll_speed_range: Tuple[float, float] = (1.5, 4.0) # Pulse effects anim_pulse_enabled: bool = True anim_pulse_colour: Tuple[int, int, int] = (255, 255, 255) anim_pulse_alpha: int = 180 anim_pulse_diameter: float = 8.0 # Glow effects anim_glow_enabled: bool = True anim_glow_colour: Tuple[int, int, int] = (255, 255, 200) anim_glow_alpha: int = 60 anim_glow_fade_threshold: float = 0.5 # Communication glow anim_comm_glow_enabled: bool = False # Layers layers: List[Dict] = field(default_factory=list) # Widget size width: int = 1024 height: int = 768 # Hover state hovered_neuron: Optional[str] = None hover_value_display_active: bool = False # Font settings neuron_label_font_size: int = 6 # Timestamp for cache invalidation timestamp: float = field(default_factory=time.time) class BrainRenderWorker(QThread): """ Background worker that renders brain visualization to QImage. Signals: render_complete: Emitted when a new frame is ready - QImage: The rendered frame - float: Render time in ms """ render_complete = pyqtSignal(QImage, float) def __init__(self, parent=None): super().__init__(parent) # Thread control self._running = True self._render_requested = False # State mutex self._state_mutex = QMutex() self._render_condition = QWaitCondition() # Current render state self._render_state: Optional[RenderState] = None # Cached image self._cached_image: Optional[QImage] = None self._last_render_time = 0.0 # Rendering frequency control self._min_render_interval = 1.0 / 10.0 # 10 FPS max self._last_render_request = 0.0 # Performance stats self._render_count = 0 self._total_render_time = 0.0 def stop(self): """Stop the worker thread gracefully""" self._running = False self._state_mutex.lock() self._render_condition.wakeAll() self._state_mutex.unlock() def request_render(self, state: RenderState): """ Request a new render with the given state. Throttles requests to prevent overwhelming the thread. """ current_time = time.time() # Throttle render requests if current_time - self._last_render_request < self._min_render_interval: return self._last_render_request = current_time with QMutexLocker(self._state_mutex): self._render_state = state self._render_requested = True self._render_condition.wakeOne() def get_cached_image(self) -> Optional[QImage]: """Get the most recently rendered image (thread-safe)""" with QMutexLocker(self._state_mutex): return self._cached_image def get_stats(self) -> Dict[str, Any]: """Get rendering statistics""" avg_time = self._total_render_time / max(1, self._render_count) return { 'render_count': self._render_count, 'avg_render_time_ms': avg_time, 'last_render_time_ms': self._last_render_time } def run(self): """Main worker loop""" print("🧠 BrainRenderWorker started") while self._running: state_to_render = None # Wait for render request self._state_mutex.lock() try: if not self._render_requested and self._running: # Wait up to 100ms for a render request self._render_condition.wait(self._state_mutex, 100) if self._render_requested and self._render_state: state_to_render = self._render_state self._render_requested = False finally: self._state_mutex.unlock() # Perform render if we have state if state_to_render: start_time = time.perf_counter() try: image = self._render_frame(state_to_render) # Cache the image with QMutexLocker(self._state_mutex): self._cached_image = image # Calculate render time render_time = (time.perf_counter() - start_time) * 1000 self._last_render_time = render_time self._render_count += 1 self._total_render_time += render_time # Emit signal with rendered image self.render_complete.emit(image, render_time) except Exception as e: print(f"🧠 Render error: {e}") import traceback traceback.print_exc() print("🧠 BrainRenderWorker stopped") def _render_frame(self, state: RenderState) -> QImage: """Render a complete frame to QImage""" # Create image with proper size image = QImage(state.width, state.height, QImage.Format_ARGB32) image.fill(QColor(*state.anim_background_colour)) painter = QPainter(image) painter.setRenderHint(QPainter.Antialiasing) painter.setRenderHint(QPainter.TextAntialiasing) try: # Calculate scaling (same logic as brain_widget) indicator_space = 0 # No indicator pills base_width = 1024 base_height = 768 - indicator_space scale_x = state.width / base_width scale_y = (state.height - indicator_space) / max(1, base_height) scale = max(0.01, min(scale_x, scale_y)) # Center horizontally offset_x = 0 if scale_x > scale_y: content_width = base_width * scale offset_x = (state.width - content_width) / 2 painter.translate(offset_x, indicator_space) painter.scale(scale, scale) # Draw layers self._draw_layers(painter, state, 1.0) # Draw connections self._draw_connections(painter, state, scale) # Draw neurons self._draw_neurons(painter, state, scale) finally: painter.end() return image def _draw_layers(self, painter: QPainter, state: RenderState, scale: float): """Draw layer background rectangles""" if not state.layers: return for layer in state.layers: y_pos = layer.get('y_position', 0) name = layer.get('name', 'Layer') layer_type = layer.get('layer_type', 'hidden') rect_height = 120 rect_top = y_pos - rect_height / 2 rect_left = -200 rect_width = 2000 # Layer colors if layer_type == 'input': color = QColor(220, 255, 220, 30) border = QColor(180, 220, 180, 60) elif layer_type == 'output': color = QColor(255, 220, 220, 30) border = QColor(220, 180, 180, 60) else: color = QColor(230, 230, 255, 40) border = QColor(200, 200, 240, 60) painter.setBrush(QBrush(color)) painter.setPen(QPen(border, 1, Qt.DashLine)) painter.drawRect(QRectF(rect_left, rect_top, rect_width, rect_height)) def _get_neuron_animation_color(self, state: RenderState, neuron_name: str, current_time: float): """ Check if a neuron is currently involved in an active weight animation. Returns a QColor with pulsing alpha if active, None otherwise. """ for anim in state.weight_animations: # Check if this neuron is part of the animation pair if (anim.get('neuron1') == neuron_name or anim.get('neuron2') == neuron_name): elapsed = current_time - anim['start_time'] duration = anim['duration'] if 0 <= elapsed < duration: progress = elapsed / duration # Create pulsing alpha effect (fade in and out) # Use sine wave for smooth pulsing pulse_factor = math.sin(progress * math.pi) # Get the animation color r, g, b = anim['color'] # Set alpha based on pulse factor (0-255 range) alpha = int(255 * pulse_factor) return QColor(r, g, b, alpha) return None def _draw_connections(self, painter: QPainter, state: RenderState, scale: float): """ Draw all neural connections with scrolling arrow animations for Hebbian learning. Includes specific coloring for excitatory (green) vs inhibitory (red) weights and weight-based thickness clamping (max 15px or style-defined). """ current_time = state.animation_time for (src, dst), weight in state.weights.items(): # Skip if neurons not visible or excluded if src not in state.visible_neurons or dst not in state.visible_neurons: continue if src in state.excluded_neurons or dst in state.excluded_neurons: continue if src not in state.neuron_positions or dst not in state.neuron_positions: continue # Get positions src_pos = state.neuron_positions[src] dst_pos = state.neuron_positions[dst] start = QPointF(src_pos[0], src_pos[1]) end = QPointF(dst_pos[0], dst_pos[1]) # Get link opacity key = (src, dst) opacity = state.link_opacities.get(key, 1.0) if opacity < 0.01: continue # ===== CHECK FOR ACTIVE HEBBIAN ANIMATION ===== active_anim = None active_anim_progress = 0.0 for anim in state.weight_animations: # Check match (undirected) if anim['pair'] == (src, dst) or anim['pair'] == (dst, src): elapsed = current_time - anim['start_time'] duration = anim['duration'] if 0 <= elapsed < duration: active_anim = anim active_anim_progress = elapsed / duration break # ===== WEIGHT-BASED THICKNESS & COLOR ===== abs_weight = abs(weight) # 1. Base thickness calculation: # Scale weight (0.0 to 1.0) to a range # Define max pixel thickness for strongest connections based on style if state.weight_thickness_enabled: MAX_THICKNESS = state.weight_thickness_max else: MAX_THICKNESS = 15.0 # Calculate thickness: Base + (Weight * Scalar), clamped to MAX_THICKNESS # Ensure we don't have negative range if base > max thickness_range = max(0.0, MAX_THICKNESS - state.anim_line_base_width) calculated_thickness = state.anim_line_base_width + (abs_weight * thickness_range) # Clamp rigidly to MAX_THICKNESS base_thickness = min(calculated_thickness, MAX_THICKNESS) # 2. Determine Color (Excitatory vs Inhibitory) if weight >= 0: # Excitatory = Green (using positive color from state) base_color = QColor(*state.anim_line_col_pos) else: # Inhibitory = Red (using negative color from state) base_color = QColor(*state.anim_line_col_neg) # Apply Opacity base_color.setAlpha(int(state.anim_line_alpha * opacity)) # Base styling line_width = base_thickness * scale pen_style = Qt.SolidLine # Dashed line for negative, Dotted for very weak (optional visual aid) if weight < 0: pen_style = Qt.DashLine if abs_weight < 0.1: pen_style = Qt.DotLine # ===== ANIMATED LINE STYLING (during Hebbian cycle) ===== # Initialize current_thickness with base value current_thickness = base_thickness line_color = base_color if active_anim: # Connection becomes bright during Hebbian cycle r, g, b = active_anim['color'] line_color = QColor(r, g, b, 255) # Line thickness pulses during animation anim_max_thickness = (MAX_THICKNESS + 3.0) * scale pulse_factor = math.sin(active_anim_progress * math.pi) current_thickness = base_thickness + (pulse_factor * (anim_max_thickness - base_thickness)) pen_style = Qt.SolidLine # Always solid during animation line_width = max(1, int(current_thickness * scale)) painter.setPen(QPen(line_color, line_width, pen_style, Qt.RoundCap)) painter.drawLine(start, end) # ===== DRAW SCROLLING ARROWS FOR MOVEMENT ILLUSION ===== if active_anim and active_anim_progress < 1.0: # Calculate direction vector and angle dx = end.x() - start.x() dy = end.y() - start.y() length = math.sqrt(dx*dx + dy*dy) if length > 10: # Only draw arrows on longer connections # Normalize direction dir_x = dx / length dir_y = dy / length # Arrow size arrow_size = 12.0 * scale # Draw 4 arrows at different positions (trail effect) arrow_positions = [0.20, 0.40, 0.60, 0.80] # Offset based on animation progress to create movement progress_offset = active_anim_progress * 0.8 for i, base_pos in enumerate(arrow_positions): # Calculate actual position offset by progress pos_offset = (base_pos + progress_offset) % 1.0 # Arrow center point center_x = start.x() + pos_offset * dx center_y = start.y() + pos_offset * dy # Alpha fades for trail effect (255, 191, 128, 64) alpha = int(255 * (1.0 - (i * 0.25))) # Create arrow polygon (pointing along the line) arrow = QPolygonF() # Arrow points in direction of connection # Front point point_x = center_x + dir_x * arrow_size point_y = center_y + dir_y * arrow_size arrow.append(QPointF(point_x, point_y)) # Back left back_x = center_x - dir_x * arrow_size * 0.5 back_y = center_y - dir_y * arrow_size * 0.5 left_x = back_x - dir_y * arrow_size * 0.5 left_y = back_y + dir_x * arrow_size * 0.5 arrow.append(QPointF(left_x, left_y)) # Back right right_x = back_x + dir_y * arrow_size * 0.5 right_y = back_y - dir_x * arrow_size * 0.5 arrow.append(QPointF(right_x, right_y)) # Fill arrow with color arrow_color = QColor(line_color) arrow_color.setAlpha(alpha) painter.setBrush(QBrush(arrow_color)) painter.setPen(QPen(arrow_color, 1)) painter.drawPolygon(arrow) # ===== DRAW WEIGHT TEXT ===== if state.show_weights and abs_weight > 0.1: # Calculate angle for rotation dx = end.x() - start.x() dy = end.y() - start.y() angle_deg = math.degrees(math.atan2(dy, dx)) # Ensure text is readable if angle_deg > 90: angle_deg -= 180 elif angle_deg < -90: angle_deg += 180 midpoint = QPointF((start.x() + end.x()) / 2, (start.y() + end.y()) / 2) text_str = f"{weight:.2f}" font_size = max(7, int(8 * scale)) padding = 4 * scale font = painter.font() font.setPointSize(font_size) font.setBold(True) painter.setFont(font) fm = painter.fontMetrics() text_w = fm.horizontalAdvance(text_str) text_h = fm.height() painter.save() painter.translate(midpoint) painter.rotate(angle_deg) rect = QRectF(-text_w/2 - padding, -text_h/2, text_w + padding*2, text_h) painter.setBrush(QBrush(QColor(255, 255, 255, 220))) painter.setPen(QPen(QColor(100, 100, 100, 150), 1)) painter.drawRoundedRect(rect, 4, 4) # Text color matches line color logic text_color = QColor(0, 100, 0) if weight >= 0 else QColor(150, 0, 0) painter.setPen(text_color) painter.drawText(rect, Qt.AlignCenter, text_str) painter.restore() def _draw_neurons(self, painter: QPainter, state: RenderState, scale: float): """Draw all neurons with localized labels, connector relay animations, and Hebbian pulse effects""" from .brain_constants import BINARY_NEURONS # Hardcoded list of core neurons to determine font sizing CORE_NEURONS = {"hunger", "happiness", "cleanliness", "sleepiness", "satisfaction", "anxiety", "curiosity", "can_see_food"} # Set up default font font = QFont("Arial", state.neuron_label_font_size) font.setBold(True) painter.setFont(font) fm = painter.fontMetrics() radius = 20 * scale current_time = state.animation_time for name, pos in state.neuron_positions.items(): if name in state.excluded_neurons: continue if name not in state.visible_neurons: continue x, y = pos raw_value = state.neuron_states.get(name, 50) shape = state.neuron_shapes.get(name, 'circle') # ========== CHECK FOR ACTIVE HEBBIAN ANIMATION ========== animation_color = self._get_neuron_animation_color(state, name, current_time) # Check if this neuron is currently being used as a relay in any animation is_active_connector = False connector_pulse_alpha = 0 pulse_size_multiplier = 1.0 for anim in state.weight_animations: if anim.get('is_segment') and anim.get('final_target'): # Check if this neuron is the connector in a staggered animation connector_in_anim = (anim['neuron1'] == name or anim['neuron2'] == name) if connector_in_anim: elapsed = current_time - anim['start_time'] if 0 <= elapsed < anim['duration']: # Connector becomes bright white when relaying pulse_phase = elapsed / anim['duration'] if pulse_phase > 0.7: # Last part of segment - relay burst connector_pulse_alpha = int(255 * (1.0 - pulse_phase)) pulse_size_multiplier = 1.0 + (1.0 - pulse_phase) * 0.5 # Grow 50% at burst is_active_connector = True break # ---------- BINARY NEURONS ---------- if name in BINARY_NEURONS: value = 100.0 if float(raw_value) > 50 else 0.0 is_active = value > 50 # Apply animation color if active if animation_color: color = animation_color else: color = QColor(0, 255, 0) if is_active else QColor(255, 0, 0) painter.setBrush(QBrush(color)) painter.setPen(QPen(QColor(0, 0, 0), max(1, int(2 * scale)))) size = radius * 1.8 rect = QRectF(x - size/2, y - size/2, size, size) painter.drawRect(rect) # [NEW] Draw Symbol inside Binary Neuron symbol = "✓" if is_active else "✗" painter.save() symbol_font = QFont("Arial", int(size * 0.7)) symbol_font.setBold(True) painter.setFont(symbol_font) painter.setPen(QColor(0, 0, 0)) painter.drawText(rect, Qt.AlignCenter, symbol) painter.restore() if name == 'can_see_food': display_name = state.neuron_labels.get(name, name) # Smaller Font small_font = QFont(font) small_font.setPointSize(max(4, int(state.neuron_label_font_size * 0.75 * scale))) painter.setFont(small_font) sfm = painter.fontMetrics() # Calculate Dimensions text_width = sfm.horizontalAdvance(display_name) padding = 4 * scale rect_width = text_width + padding * 2 rect_height = sfm.height() + 2 text_rect = QRectF( x - rect_width / 2, y + size/2 + 3 * scale, rect_width, rect_height ) # Draw Black Background painter.setBrush(QBrush(QColor(0, 0, 0))) painter.setPen(Qt.NoPen) painter.drawRoundedRect(text_rect, 2, 2) # Draw White Text painter.setPen(QColor(255, 255, 255)) painter.drawText(text_rect, Qt.AlignCenter, display_name) # Restore standard font painter.setFont(font) continue # ---------- DIAMOND ---------- if shape == 'diamond': # Apply animation color if active if animation_color: color = animation_color else: color = QColor(*state.state_colors.get(name, (152, 251, 152))) self._draw_polygon(painter, x, y, 4, radius, color, rotation=0) # ---------- SQUARE ---------- elif shape == 'square': # Apply animation color if active if animation_color: color = animation_color else: color = QColor(*state.state_colors.get(name, (152, 251, 152))) self._draw_polygon(painter, x, y, 4, radius, color, rotation=45) # ---------- TRIANGLE ---------- elif shape == 'triangle': # Apply animation color if active if animation_color: color = animation_color else: color = QColor(*state.state_colors.get(name, (255, 255, 150))) self._draw_polygon(painter, x, y, 3, radius, color) # ---------- CONNECTOR (HEXAGON) ---------- elif shape == 'hexagon' or name.startswith('connector_'): # Use bright purple base, but pulse white during animation or apply animation color if animation_color: # Hebbian animation takes priority color = animation_color self._draw_polygon(painter, x, y, 6, radius * pulse_size_multiplier, color, rotation=0) elif is_active_connector: # Pulse with white color during relay pulse_color = QColor(255, 255, 255, connector_pulse_alpha) self._draw_polygon(painter, x, y, 6, radius * pulse_size_multiplier, pulse_color, rotation=0) # Add extra glow ring during burst if connector_pulse_alpha > 128: glow_pen = QPen(QColor(255, 255, 255, connector_pulse_alpha // 2), 3) painter.setPen(glow_pen) painter.setBrush(Qt.NoBrush) painter.drawEllipse(QPointF(x, y), radius * 1.5, radius * 1.5) else: # Normal black connector appearance color = QColor(0, 0, 0) self._draw_polygon(painter, x, y, 6, radius, color, rotation=0) painter.save() c_font = QFont("Arial", int(14 * scale)) c_font.setBold(True) painter.setFont(c_font) # Use white text, but make it brighter during pulse if (is_active_connector and connector_pulse_alpha > 200) or animation_color: painter.setPen(QColor(255, 255, 255, 255)) else: painter.setPen(QColor(255, 255, 255, 200)) rect = QRectF(x - radius, y - radius, radius * 2, radius * 2) painter.drawText(rect, Qt.AlignCenter, "c") painter.restore() # IMPORTANT: connector neurons do NOT draw external labels continue # ---------- DEFAULT CIRCLE ---------- else: # Apply animation color if active if animation_color: color = animation_color elif name in state.state_colors: color = QColor(*state.state_colors[name]) else: color = QColor(64, 64, 64) painter.setBrush(QBrush(color)) painter.setPen(QPen(QColor(0, 0, 0), max(1, int(2 * scale)))) painter.drawEllipse(QPointF(x, y), radius, radius) # ---------- LABEL ---------- display_name = state.neuron_labels.get( name, name.replace("_", " ").title() ) # [NEW] Font Scaling Logic is_neurogenesis = name not in CORE_NEURONS effective_size = state.neuron_label_font_size * 0.75 if is_neurogenesis else state.neuron_label_font_size # Apply font size for this label label_font = QFont("Arial", int(effective_size * scale)) label_font.setBold(True) painter.setFont(label_font) local_fm = painter.fontMetrics() text_width = local_fm.horizontalAdvance(display_name) padding = 10 * scale rect_width = text_width + padding * 2 rect_height = local_fm.height() + 4 text_rect = QRectF( x - rect_width / 2, y + radius + 5 * scale, rect_width, rect_height ) painter.setBrush(QBrush(QColor(26, 26, 26, 200))) painter.setPen(Qt.NoPen) painter.drawRoundedRect(text_rect, 4, 4) painter.setPen(QColor(224, 224, 224)) painter.drawText(text_rect, Qt.AlignCenter, display_name) # Restore base font for next iteration painter.setFont(font) def _draw_polygon(self, painter: QPainter, x: float, y: float, sides: int, radius: float, color: QColor, rotation: float = 0): """Draw a polygon neuron shape""" painter.save() painter.translate(x, y) painter.rotate(rotation) painter.setBrush(QBrush(color)) painter.setPen(QPen(QColor(0, 0, 0))) polygon = QPolygonF() angle_step = 360.0 / sides for i in range(sides): angle = math.radians(i * angle_step - 90) polygon.append(QPointF(radius * math.cos(angle), radius * math.sin(angle))) painter.drawPolygon(polygon) painter.restore() def create_render_state_from_widget(brain_widget) -> RenderState: """ Helper function to create a RenderState from a BrainWidget instance. Call this from the main thread before requesting a render. """ # Import Localisation here to avoid circular imports at module level from .localisation import Localisation loc = Localisation.instance() state = RenderState() # Copy neuron data state.neuron_positions = dict(brain_widget.neuron_positions) state.neuron_states = dict(brain_widget.state) state.state_colors = dict(getattr(brain_widget, 'state_colors', {})) state.neuron_shapes = dict(getattr(brain_widget, 'neuron_shapes', {})) # --- Pre-calculate Localized Labels on Main Thread --- labels = {} # We only need to calculate labels for neurons that might be drawn visible = getattr(brain_widget, 'visible_neurons', brain_widget.neuron_positions.keys()) excluded = getattr(brain_widget, 'excluded_neurons', set()) for name in state.neuron_positions.keys(): if name in excluded: continue # 1. Try exact key lookup display_name = loc.get(name) # 2. Fallback: space-separated key if display_name == name: space_key = name.replace("_", " ") display_name = loc.get(space_key) if display_name == space_key: display_name = None # Mark as not found yet # 3. Fallback: Neurogenesis pattern (e.g., novelty_1 -> Novelty 1) if not display_name: match = re.match(r"^([a-z_]+)_(\d+)$", name) if match: base = match.group(1) idx = match.group(2) base_loc = loc.get(base) if base_loc != base: display_name = f"{base_loc} {idx}" else: display_name = f"{base.replace('_', ' ').title()} {idx}" # 4. Final Fallback: Title Case if not display_name: display_name = name.replace("_", " ").title() labels[name] = display_name state.neuron_labels = labels # ----------------------------------------------------- # Copy connection data state.weights = dict(brain_widget.weights) state.communication_events = dict(getattr(brain_widget, 'communication_events', {})) # Copy visibility state.visible_neurons = set(visible) state.excluded_neurons = set(excluded) # Copy animation state state.link_opacities = dict(getattr(brain_widget, '_link_opacities', {})) state.animation_time = time.time() # [NEW] Copy active weight animations for Hebbian learning state.weight_animations = [dict(anim) for anim in getattr(brain_widget, 'weight_animations', [])] # Copy display settings state.show_weights = getattr(brain_widget, 'show_weights', False) state.is_tutorial_mode = getattr(brain_widget, 'is_tutorial_mode', False) # ===== ANIMATION STYLE PARAMETERS ===== # Base visual settings state.anim_background_colour = getattr(brain_widget, 'anim_background_colour', (30, 30, 40)) state.anim_line_base_width = getattr(brain_widget, 'anim_line_base_width', 1.0) state.anim_line_col_pos = getattr(brain_widget, 'anim_line_col_pos', (100, 255, 100)) state.anim_line_col_neg = getattr(brain_widget, 'anim_line_col_neg', (255, 100, 100)) state.anim_line_alpha = getattr(brain_widget, 'anim_line_alpha', 180) state.anim_line_style = getattr(brain_widget, 'anim_line_style', 0) # Qt.SolidLine # Weight-based thickness state.weight_thickness_enabled = getattr(brain_widget, 'weight_thickness_enabled', False) state.weight_thickness_min = getattr(brain_widget, 'weight_thickness_min', 1.0) state.weight_thickness_max = getattr(brain_widget, 'weight_thickness_max', 2.0) state.weight_thickness_power = getattr(brain_widget, 'weight_thickness_power', 1.0) # Scroll settings state.scroll_enabled = getattr(brain_widget, 'anim_scroll_enabled', False) state.scroll_dot_count = getattr(brain_widget, 'anim_scroll_dot_count', 3) state.scroll_dot_size = getattr(brain_widget, 'anim_scroll_dot_size', 6.0) state.scroll_dot_colour = getattr(brain_widget, 'anim_scroll_dot_colour', (255, 255, 255)) state.scroll_dot_alpha = getattr(brain_widget, 'anim_scroll_dot_alpha', 200) state.scroll_speed_range = getattr(brain_widget, 'anim_scroll_speed_range', (1.5, 4.0)) # Pulse effects state.anim_pulse_enabled = getattr(brain_widget, 'anim_pulse_enabled', True) state.anim_pulse_colour = getattr(brain_widget, 'anim_pulse_colour', (255, 255, 255)) state.anim_pulse_alpha = getattr(brain_widget, 'anim_pulse_alpha', 180) state.anim_pulse_diameter = getattr(brain_widget, 'anim_pulse_diameter', 8.0) # Glow effects state.anim_glow_enabled = getattr(brain_widget, 'anim_glow_enabled', True) state.anim_glow_colour = getattr(brain_widget, 'anim_glow_colour', (255, 255, 200)) state.anim_glow_alpha = getattr(brain_widget, 'anim_glow_alpha', 60) state.anim_glow_fade_threshold = getattr(brain_widget, 'anim_glow_fade_threshold', 0.5) # Communication glow state.anim_comm_glow_enabled = getattr(brain_widget, 'anim_comm_glow_enabled', False) # Layers state.layers = list(getattr(brain_widget, 'layers', [])) # Widget size state.width = brain_widget.width() state.height = brain_widget.height() # Hover state state.hovered_neuron = getattr(brain_widget, 'hovered_neuron', None) state.hover_value_display_active = getattr(brain_widget, 'hover_value_display_active', False) # Font settings state.neuron_label_font_size = getattr(brain_widget, 'neuron_label_font_size', 6) return state ================================================ FILE: src/brain_state_bridge.py ================================================ """ Brain State Bridge - Communication layer between running game and Brain Designer This module provides a mechanism for the Brain Designer to detect when the main Dosidicus game is running and import the exact brain state being displayed in the game's brain widget. The game writes its current brain state to a shared file, and the designer reads from it on startup. """ import json import os import time from pathlib import Path from typing import Dict, List, Tuple, Optional, Any from dataclasses import dataclass # Shared state file location - in user's home directory def get_bridge_directory() -> Path: """Get the directory for bridge files.""" # Use a hidden directory in the user's home folder bridge_dir = Path.home() / ".dosidicus" / "bridge" bridge_dir.mkdir(parents=True, exist_ok=True) return bridge_dir def get_state_file_path() -> Path: """Get the path to the shared brain state file.""" return get_bridge_directory() / "active_brain_state.json" def get_lock_file_path() -> Path: """Get the path to the game running lock file.""" return get_bridge_directory() / "game_running.lock" # ============================================================================ # GAME-SIDE FUNCTIONS (called by brain_widget / brain_tool) # ============================================================================ def export_brain_state( neuron_positions: Dict[str, Tuple[float, float]], weights: Dict[Tuple[str, str], float], state: Dict[str, Any], visible_neurons: set = None, excluded_neurons: list = None, layers: list = None, output_bindings: list = None ) -> bool: """ Export the current brain state from the game to the shared file. This should be called by the game periodically or on state changes. Args: neuron_positions: Dict mapping neuron name to (x, y) position weights: Dict mapping (source, target) tuple to weight value state: Dict mapping neuron name to current activation value visible_neurons: Set of currently visible neuron names excluded_neurons: List of neurons excluded from display layers: Optional layer structure output_bindings: Optional output bindings list Returns: True if export was successful, False otherwise """ try: # Convert weights dict with tuple keys to serializable format serializable_weights = {} for (src, dst), weight in weights.items(): key = f"{src}->{dst}" serializable_weights[key] = weight export_data = { "version": "1.0", "format": "live_brain_state", "timestamp": time.time(), "pid": os.getpid(), "neurons": {}, "connections": serializable_weights, "state": {}, "excluded_neurons": list(excluded_neurons) if excluded_neurons else [], "layers": layers or [], "output_bindings": output_bindings or [] } # Build neurons dict with position and state info for name, pos in neuron_positions.items(): if visible_neurons is not None and name not in visible_neurons: continue # Skip non-visible neurons export_data["neurons"][name] = { "position": list(pos) if isinstance(pos, tuple) else pos, "activation": state.get(name, 0) } # Copy state values export_data["state"] = dict(state) # Write to file atomically (write to temp then rename) state_file = get_state_file_path() temp_file = state_file.with_suffix('.tmp') with open(temp_file, 'w') as f: json.dump(export_data, f, indent=2) # Atomic rename temp_file.replace(state_file) return True except Exception as e: print(f"[BrainBridge] Error exporting brain state: {e}") return False def set_game_running(running: bool = True) -> None: """ Set/clear the game running flag. Call with running=True when game starts, running=False when game closes. """ lock_file = get_lock_file_path() if running: # Create lock file with PID and timestamp lock_data = { "pid": os.getpid(), "timestamp": time.time() } with open(lock_file, 'w') as f: json.dump(lock_data, f) else: # Remove lock file try: lock_file.unlink(missing_ok=True) except Exception: pass # Also remove state file try: get_state_file_path().unlink(missing_ok=True) except Exception: pass def update_brain_state_from_widget(brain_widget) -> bool: """ Convenience function to export brain state directly from a BrainWidget instance. Args: brain_widget: BrainWidget instance from the game Returns: True if export successful """ try: return export_brain_state( neuron_positions=brain_widget.neuron_positions, weights=brain_widget.weights, state=brain_widget.state, visible_neurons=getattr(brain_widget, 'visible_neurons', None), excluded_neurons=getattr(brain_widget, 'excluded_neurons', []), layers=getattr(brain_widget, 'layers', []), output_bindings=[] ) except Exception as e: print(f"[BrainBridge] Error updating from widget: {e}") return False # ============================================================================ # DESIGNER-SIDE FUNCTIONS (called by brain_designer / designer_window) # ============================================================================ def is_game_running() -> bool: """ Check if the main Dosidicus game is currently running. We do this because the designer can be run standalone (python main.py -designer) Returns: True if game appears to be running and has exported brain state """ lock_file = get_lock_file_path() state_file = get_state_file_path() if not lock_file.exists(): return False if not state_file.exists(): return False try: # Check if lock file is recent (within last 60 seconds) with open(lock_file, 'r') as f: lock_data = json.load(f) lock_time = lock_data.get('timestamp', 0) if time.time() - lock_time > 60: # Lock file is stale, game probably crashed return False # Check if state file is recent state_mtime = state_file.stat().st_mtime if time.time() - state_mtime > 30: # State file is stale return False return True except Exception: return False def import_brain_state_for_designer() -> Optional[Dict]: """ Import brain state from the running game for use in the designer. Returns: Dict with brain state data if game is running, None otherwise """ if not is_game_running(): return None try: state_file = get_state_file_path() with open(state_file, 'r') as f: data = json.load(f) # Verify format if data.get('format') != 'live_brain_state': return None return data except Exception as e: print(f"[BrainBridge] Error importing brain state: {e}") return None def convert_to_brain_design(live_state: Dict) -> Optional['BrainDesign']: """ Convert imported live brain state to a BrainDesign object. This creates a BrainDesign that mirrors the exact state of the running game. Args: live_state: Dict from import_brain_state_for_designer() Returns: BrainDesign instance or None if conversion fails """ # Import here to avoid circular imports try: from designer_core import BrainDesign, DesignerNeuron, DesignerConnection from designer_constants import ( NeuronType, is_core_neuron, is_input_sensor, is_binary_neuron, DEFAULT_COLORS ) except ImportError: print("[BrainBridge] Could not import designer modules") return None try: design = BrainDesign() # Set metadata design.metadata = { 'name': 'Imported from Running Game', 'description': 'Brain state imported from active Dosidicus game', 'author': 'Live Import', 'version': '1.0', 'created': '', 'modified': '' } neurons_data = live_state.get('neurons', {}) connections_data = live_state.get('connections', {}) state_data = live_state.get('state', {}) # Create neurons for name, neuron_info in neurons_data.items(): pos = neuron_info.get('position', [0, 0]) # Determine neuron type if is_core_neuron(name): ntype = NeuronType.CORE color = DEFAULT_COLORS.get('core', (150, 150, 220)) elif is_input_sensor(name) or name == 'can_see_food': ntype = NeuronType.SENSOR color = DEFAULT_COLORS.get('sensor', (150, 200, 220)) else: ntype = NeuronType.HIDDEN color = DEFAULT_COLORS.get('hidden', (180, 180, 200)) neuron = DesignerNeuron( name=name, neuron_type=ntype, position=tuple(pos) if isinstance(pos, list) else pos, color=color, is_binary=is_binary_neuron(name) ) design.add_neuron(neuron) # Create connections - Handle both List (new) and Dict (legacy) formats if isinstance(connections_data, list): # NEW: Handle list of dicts [{'source': 'A', 'target': 'B', 'weight': 0.5}, ...] for conn in connections_data: source = conn.get('source') target = conn.get('target') weight = conn.get('weight', 0.5) if source and target and source in design.neurons and target in design.neurons: # Avoid duplicates existing = design.get_connection(source, target) if not existing: new_conn = DesignerConnection(source=source, target=target, weight=weight) design.connections.append(new_conn) elif isinstance(connections_data, dict): # LEGACY: Handle dict {'A->B': 0.5, ...} for conn_key, weight in connections_data.items(): if '->' in conn_key: source, target = conn_key.split('->') elif '|' in conn_key: source, target = conn_key.split('|') else: continue source = source.strip() target = target.strip() # Only add if both neurons exist if source in design.neurons and target in design.neurons: conn = DesignerConnection(source=source, target=target, weight=weight) design.connections.append(conn) # Load layers if present for layer_data in live_state.get('layers', []): from designer_core import DesignerLayer design.layers.append(DesignerLayer.from_dict(layer_data)) # Load output bindings if present design.output_bindings = live_state.get('output_bindings', []) return design except Exception as e: print(f"[BrainBridge] Error converting to BrainDesign: {e}") import traceback traceback.print_exc() return None def get_import_file_path() -> Path: """Get the path for designs waiting to be imported by the game.""" return get_bridge_directory() / "pending_import.json" def export_design_to_game(design_data: Dict) -> bool: """ Export a design from the Designer to the running game. Args: design_data: The dictionary returned by to_dosidicus_format() """ try: import_file = get_import_file_path() temp_file = import_file.with_suffix('.tmp') # Ensure it's marked design_data['format'] = 'pending_import' with open(temp_file, 'w') as f: json.dump(design_data, f, indent=2) # Atomic rename to ensure game reads complete file temp_file.replace(import_file) return True except Exception as e: print(f"[BrainBridge] Error exporting to game: {e}") return False def consume_pending_import() -> Optional[Dict]: """ Check for and retrieve a pending design import. Removes the file after reading to ensure it's processed only once. """ import_file = get_import_file_path() if not import_file.exists(): return None try: # Ignore files older than 10 seconds (stale exports) if time.time() - import_file.stat().st_mtime > 10: return None with open(import_file, 'r') as f: data = json.load(f) # Clean up immediately try: import_file.unlink() except: pass return data except Exception as e: print(f"[BrainBridge] Error reading pending import: {e}") return None # ============================================================================ # CLEANUP # ============================================================================ def cleanup_bridge_files() -> None: """Remove all bridge files. Called when game exits cleanly.""" set_game_running(False) ================================================ FILE: src/brain_statistics_tab.py ================================================ from PyQt5 import QtCore, QtGui, QtWidgets from .brain_base_tab import BrainBaseTab from .display_scaling import DisplayScaling from .localisation import Localisation import time class StatisticsTab(BrainBaseTab): def __init__(self, parent=None, tamagotchi_logic=None, brain_widget=None, config=None, debug_mode=False): super().__init__(parent, tamagotchi_logic, brain_widget, config, debug_mode) self.initialize_ui() # Statistics tracking self.statistics = { 'distance_swam': 0, 'cheese_eaten': 0, 'sushi_eaten': 0, 'poops_created': 0, 'max_poops_cleaned': 0, 'startles_experienced': 0, 'ink_clouds_created': 0, 'times_colour_changed': 0, 'rocks_thrown': 0, 'plants_interacted': 0, 'total_sleep_time': 0, 'sickness_episodes': 0, 'novelty_neurons_created': 0, 'stress_neurons_created': 0, 'reward_neurons_created': 0, 'current_neurons': 7, 'squid_age_minutes': 0, 'last_position': None, 'last_update_time': time.time() } # Load existing statistics if available self.load_statistics() # Setup update timer self.update_timer = QtCore.QTimer(self) self.update_timer.timeout.connect(self.update_statistics) self.update_timer.start(1000) # Update every second self.is_visible = False self.pending_distance = 0 # Store distance when tab not visible # Connect to neuron creation events for real-time updates if self.brain_widget and hasattr(self.brain_widget, 'neuronCreated'): self.brain_widget.neuronCreated.connect(self._on_neuron_created_update_stats) def showEvent(self, event): """Called when tab becomes visible""" super().showEvent(event) self.is_visible = True # Apply any pending distance if self.pending_distance > 0: self.statistics['distance_swam'] += self.pending_distance self.pending_distance = 0 self.update_display() def hideEvent(self, event): """Called when tab becomes hidden""" super().hideEvent(event) self.is_visible = False def set_logic(self, logic): """Called by main window after TamagotchiLogic (and squid) exist.""" self.tamagotchi_logic = logic def _sync_from_squid_statistics(self): """Mirror the persistent squid statistics object into the tab state.""" if not self.tamagotchi_logic or not getattr(self.tamagotchi_logic, 'squid', None): return squid = self.tamagotchi_logic.squid squid_stats = getattr(squid, 'statistics', None) if not squid_stats: return self.statistics['distance_swam'] = getattr(squid_stats, 'distance_swam', self.statistics['distance_swam']) self.statistics['cheese_eaten'] = getattr(squid_stats, 'cheese_consumed', self.statistics['cheese_eaten']) self.statistics['sushi_eaten'] = getattr(squid_stats, 'sushi_consumed', self.statistics['sushi_eaten']) self.statistics['poops_created'] = getattr(squid_stats, 'poops_created', self.statistics['poops_created']) self.statistics['max_poops_cleaned'] = getattr(squid_stats, 'max_poops_cleaned', self.statistics['max_poops_cleaned']) self.statistics['startles_experienced'] = getattr(squid_stats, 'startles_experienced', self.statistics['startles_experienced']) self.statistics['ink_clouds_created'] = getattr(squid_stats, 'ink_clouds_created', self.statistics['ink_clouds_created']) self.statistics['times_colour_changed'] = getattr(squid_stats, 'times_colour_changed', self.statistics['times_colour_changed']) self.statistics['rocks_thrown'] = getattr(squid_stats, 'total_rocks_thrown', self.statistics['rocks_thrown']) self.statistics['plants_interacted'] = getattr(squid_stats, 'plants_interacted', self.statistics['plants_interacted']) self.statistics['total_sleep_time'] = getattr(squid_stats, 'time_spent_asleep', self.statistics['total_sleep_time']) self.statistics['sickness_episodes'] = getattr(squid_stats, 'sickness_episodes', self.statistics['sickness_episodes']) self.statistics['novelty_neurons_created'] = getattr(squid_stats, 'novelty_neurons_created', self.statistics['novelty_neurons_created']) self.statistics['stress_neurons_created'] = getattr(squid_stats, 'stress_neurons_created', self.statistics['stress_neurons_created']) self.statistics['reward_neurons_created'] = getattr(squid_stats, 'reward_neurons_created', self.statistics['reward_neurons_created']) self.statistics['current_neurons'] = getattr(squid_stats, 'max_neurons_reached', self.statistics['current_neurons']) self.statistics['squid_age_minutes'] = int(getattr(squid_stats, 'get_total_age_seconds', lambda: 0)() // 60) def _increment_squid_stat(self, stat_name, amount=1): """Increment the canonical squid statistics attribute for a tab stat key.""" if not self.tamagotchi_logic or not getattr(self.tamagotchi_logic, 'squid', None): return False squid_stats = getattr(self.tamagotchi_logic.squid, 'statistics', None) if not squid_stats: return False attr_map = { 'cheese_eaten': 'cheese_consumed', 'sushi_eaten': 'sushi_consumed', 'poops_created': 'poops_created', 'poops_thrown': 'total_poops_thrown', 'max_poops_cleaned': 'max_poops_cleaned', 'startles_experienced': 'startles_experienced', 'ink_clouds_created': 'ink_clouds_created', 'times_colour_changed': 'times_colour_changed', 'rocks_thrown': 'total_rocks_thrown', 'plants_interacted': 'plants_interacted', 'novelty_neurons_created': 'novelty_neurons_created', 'stress_neurons_created': 'stress_neurons_created', 'reward_neurons_created': 'reward_neurons_created', } attr_name = attr_map.get(stat_name) if not attr_name or not hasattr(squid_stats, attr_name): return False current_value = getattr(squid_stats, attr_name, 0) setattr(squid_stats, attr_name, current_value + amount) return True def update_current_neurons(self, count): """Update the current neuron count in the UI, enforcing only-count-up logic. This ensures the max neurons stat only ever increases, never decreases. """ if hasattr(self, 'stat_labels') and 'current_neurons' in self.stat_labels: current_max = self.statistics.get('current_neurons', 0) if count > current_max: self.statistics['current_neurons'] = count self.stat_labels['current_neurons'].setText(str(count)) if self.tamagotchi_logic and hasattr(self.tamagotchi_logic, 'squid'): squid = self.tamagotchi_logic.squid if hasattr(squid, 'statistics') and hasattr(squid.statistics, 'max_neurons_reached'): squid.statistics.max_neurons_reached = count squid.statistics.current_neurons = count def _on_neuron_created_update_stats(self, neuron_name: str): """Update current neuron count when a new neuron is created""" self.update_statistics() def track_distance(self, distance): """Track distance swam - only updates if tab is visible""" if self.is_visible: self.statistics['distance_swam'] += distance self.update_display() else: self.pending_distance += distance def initialize_ui(self): """Build the statistics tab interface with DPI scaling""" loc = Localisation.instance() self.layout.setContentsMargins( DisplayScaling.scale(15), DisplayScaling.scale(15), DisplayScaling.scale(15), DisplayScaling.scale(15), ) self.layout.setSpacing(DisplayScaling.scale(10)) title_label = QtWidgets.QLabel("") title_font = QtGui.QFont() title_font.setPointSize(DisplayScaling.font_size(12)) title_font.setBold(True) title_label.setFont(title_font) title_label.setStyleSheet("color: #2c3e50;") self.layout.addWidget(title_label) stats_container = QtWidgets.QWidget() stats_container.setObjectName("statsContainer") stats_container.setStyleSheet( """ #statsContainer { background-color: #f8f9fa; border-radius: 10px; padding: 10px; } """ ) stats_layout = QtWidgets.QFormLayout(stats_container) stats_layout.setSpacing(DisplayScaling.scale(10)) stat_items = [ ('squid_age_minutes', loc.get('stat_squid_age')), ('distance_swam', loc.get('stat_distance')), ('cheese_eaten', loc.get('stat_cheese')), ('sushi_eaten', loc.get('stat_sushi')), ('poops_created', loc.get('stat_poops')), ('max_poops_cleaned', loc.get('stat_max_poops')), ('startles_experienced', loc.get('stat_startles')), ('ink_clouds_created', loc.get('stat_ink')), ('times_colour_changed', loc.get('stat_colour_change')), ('rocks_thrown', loc.get('stat_rocks')), ('plants_interacted', loc.get('stat_plants')), ('total_sleep_time', loc.get('stat_sleep')), ('sickness_episodes', loc.get('stat_sickness')), ('novelty_neurons_created', loc.get('stat_novelty_neurons')), ('stress_neurons_created', loc.get('stat_stress_neurons')), ('reward_neurons_created', loc.get('stat_reward_neurons')), ('current_neurons', "Max neurons"), ] if not hasattr(self, 'stat_labels'): self.stat_labels = {} for key, label in stat_items: if key not in self.stat_labels: lbl = QtWidgets.QLabel(f"{label}:") font = QtGui.QFont() font.setPointSize(DisplayScaling.font_size(10)) lbl.setFont(font) val = QtWidgets.QLabel("0") val_font = QtGui.QFont() val_font.setPointSize(DisplayScaling.font_size(12)) val_font.setBold(True) val.setFont(val_font) val.setStyleSheet("color: #495057;") self.stat_labels[key] = val stats_layout.addRow(lbl, val) self.layout.addWidget(stats_container) self.layout.addStretch() def update_from_brain_state(self, state): """Update tab based on brain state""" if not self.tamagotchi_logic or not self.tamagotchi_logic.squid: return squid = self.tamagotchi_logic.squid current_pos = (squid.squid_x, squid.squid_y) if self.statistics['last_position'] is not None: last_pos = self.statistics['last_position'] distance = ((current_pos[0] - last_pos[0])**2 + (current_pos[1] - last_pos[1])**2)**0.5 self.statistics['distance_swam'] += distance self.statistics['last_position'] = current_pos if squid.is_sleeping: current_time = time.time() time_elapsed = current_time - self.statistics['last_update_time'] self.statistics['total_sleep_time'] += time_elapsed self.statistics['last_update_time'] = time.time() self._sync_from_squid_statistics() self.update_display() def update_statistics(self): """Update statistics from squid state""" if not self.tamagotchi_logic or not self.tamagotchi_logic.squid: return squid = self.tamagotchi_logic.squid if not self.brain_widget: parent = self.parent() if hasattr(parent, 'brain_widget'): self.brain_widget = parent.brain_widget if self.brain_widget and hasattr(self.brain_widget, 'neuron_positions'): real_current_count = len(self.brain_widget.neuron_positions) stored_count = self.statistics.get('current_neurons', 0) if real_current_count > stored_count: self.statistics['current_neurons'] = real_current_count if hasattr(squid, 'statistics') and hasattr(squid.statistics, 'max_neurons_reached'): if real_current_count > squid.statistics.max_neurons_reached: squid.statistics.max_neurons_reached = real_current_count squid.statistics.current_neurons = real_current_count self._sync_from_squid_statistics() self.statistics['last_update_time'] = time.time() self.update_display() def update_display(self): """Update the statistics display""" for key, label in self.stat_labels.items(): if key == 'distance_swam': if (self.tamagotchi_logic and self.tamagotchi_logic.squid and hasattr(self.tamagotchi_logic.squid, 'statistics')): distance_str = self.tamagotchi_logic.squid.statistics.get_distance_display() label.setText(distance_str) else: value = self.statistics.get(key, 0) label.setText(f"{int(value):,}") elif key == 'total_sleep_time': value = self.statistics.get(key, 0) label.setText(f"{int(value)}") else: value = self.statistics.get(key, 0) label.setText(str(int(value))) def increment_stat(self, stat_name, amount=1): """Increment a specific statistic""" updated_canonical = self._increment_squid_stat(stat_name, amount) if stat_name in self.statistics: self.statistics[stat_name] += amount elif not updated_canonical: return self._sync_from_squid_statistics() self.update_display() self.save_statistics() def reset_statistics(self): """Reset all statistics to zero""" loc = Localisation.instance() reply = QtWidgets.QMessageBox.question( self, loc.get("reset_stats_title"), loc.get("reset_stats_msg"), QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No ) if reply == QtWidgets.QMessageBox.Yes: for key in self.statistics: if key not in ['last_position', 'last_update_time']: self.statistics[key] = 0 self.statistics['last_update_time'] = time.time() self.update_display() self.save_statistics() def export_statistics(self): """Export statistics to a file""" loc = Localisation.instance() file_name, _ = QtWidgets.QFileDialog.getSaveFileName( self, loc.get("export_stats_title"), "", loc.get("export_file_type") ) if file_name: try: with open(file_name, 'w') as f: f.write(f"{loc.get('export_header')}\n") f.write("=" * 30 + "\n") from datetime import datetime export_time = datetime.fromtimestamp(time.time()).strftime('%Y-%m-%d %H:%M:%S') f.write(f"{loc.get('export_time')}: {export_time}\n\n") f.write(f"{loc.get('export_activity_section')}:\n") f.write(f"{loc.get('stat_distance')}: {int(self.statistics['distance_swam'])}\n") f.write(f"{loc.get('stat_cheese')}: {self.statistics['cheese_eaten']}\n") f.write(f"{loc.get('stat_sushi')}: {self.statistics['sushi_eaten']}\n") f.write(f"{loc.get('stat_poops')}: {self.statistics['poops_created']}\n") f.write(f"{loc.get('stat_max_poops')}: {self.statistics['max_poops_cleaned']}\n") f.write(f"{loc.get('stat_rocks')}: {self.statistics['rocks_thrown']}\n") f.write(f"{loc.get('stat_plants')}: {self.statistics['plants_interacted']}\n") f.write(f"{loc.get('stat_startles')}: {self.statistics['startles_experienced']}\n") f.write(f"{loc.get('stat_sleep')}: {int(self.statistics['total_sleep_time'])}\n") f.write(f"{loc.get('stat_sickness')}: {self.statistics['sickness_episodes']}\n") f.write(f"{loc.get('stat_squid_age')}: {int(self.statistics['squid_age_minutes'])}\n") f.write(f"Max Neurons: {self.statistics.get('current_neurons', 7)}\n") f.write("\n" + "=" * 30 + "\n") f.write(f"{loc.get('export_end')}\n") QtWidgets.QMessageBox.information( self, loc.get("export_success_title"), loc.get("export_success_msg", file_name=file_name) ) except Exception as e: QtWidgets.QMessageBox.critical( self, loc.get("export_error_title"), loc.get("export_error_msg", error=str(e)) ) def save_statistics(self): """Save statistics to file""" if hasattr(self.tamagotchi_logic, 'save_manager'): try: self.tamagotchi_logic.save_manager.save_statistics(self.statistics) except: pass def load_statistics(self): """Load statistics from file""" if hasattr(self.tamagotchi_logic, 'save_manager'): try: loaded_stats = self.tamagotchi_logic.save_manager.load_statistics() if loaded_stats: self.statistics.update(loaded_stats) except: pass ================================================ FILE: src/brain_tool.py ================================================ import sys import csv import os import time import json import random import numpy as np from datetime import datetime from PyQt5.QtWidgets import QSplitter from PyQt5.QtGui import QPixmap, QFont from PyQt5 import QtCore, QtGui, QtWidgets from .config_manager import ConfigManager from .brain_widget import BrainWidget from .brain_worker import BrainWorker from .brain_dialogs import StimulateDialog, RecentThoughtsDialog, LogWindow, DiagnosticReportDialog from .brain_utils import ConsoleOutput from .personality import Personality from .learning import LearningConfig from .brain_network_tab import NetworkTab from .brain_about_tab import AboutTab from .brain_learning_tab import NeuralNetworkVisualizerTab from .brain_memory_tab import MemoryTab from .brain_decisions_tab import DecisionsTab from .brain_personality_tab import PersonalityTab from .brain_statistics_tab import StatisticsTab from .task_manager import TaskManagerWindow from .localisation import Localisation, set_language # 2.6.1.0 Robust import for Designer _DESIGNER_AVAILABLE = False try: from .designer_window import BrainDesignerWindow _DESIGNER_AVAILABLE = True except Exception as e: print(f"Warning: BrainDesignerWindow could not be imported: {type(e).__name__}: {e}") try: from src.designer_window import BrainDesignerWindow _DESIGNER_AVAILABLE = True except Exception as e2: print(f"Warning: src fallback also failed: {type(e2).__name__}: {e2}") # 2.6.1.0 Define _HAS_BRAIN_BRIDGE variable _HAS_BRAIN_BRIDGE = False try: from .brain_state_bridge import ( is_game_running, import_brain_state_for_designer ) _HAS_BRAIN_BRIDGE = True except ImportError: _HAS_BRAIN_BRIDGE = False print("Warning: brain_state_bridge not found in brain_tool") class SquidBrainWindow(QtWidgets.QMainWindow): def __init__(self, tamagotchi_logic, debug_mode=False, config=None, show_decorations_callback=None): # Force language sync before any tabs are created if config and hasattr(config, 'get_language'): set_language(config.get_language()) super().__init__() # Initialize font size FIRST from .display_scaling import DisplayScaling self.base_font_size = DisplayScaling.font_size(10) self.debug_mode = debug_mode self.config_manager = ConfigManager() # real manager self.config = self.config_manager # keep old name for compat self.tamagotchi_logic = tamagotchi_logic self.initialized = False self.is_paused = False self.show_decorations_callback = show_decorations_callback self.setWindowTitle("Brain Tool") # Get screen resolution and available geometry (excluding taskbars/docks) screen = QtWidgets.QApplication.primaryScreen() screen_geometry = screen.availableGeometry() # Use availableGeometry for usable screen space # Define initial window dimensions directly, without DisplayScaling # This will use absolute pixel values for the window size final_width = 1400 # Direct pixel width final_height = 900 # Direct pixel height # Ensure the final window dimensions do not exceed the actual screen available geometry final_width = min(final_width, screen_geometry.width()) final_height = min(final_height, screen_geometry.height()) self.resize(final_width, final_height) # Resize the window with clamped dimensions # Position window properly in the top-right corner of the *available* screen geometry # x-coordinate: right edge of available geometry minus window width, then shift left by 200 pixels x_pos = screen_geometry.right() - final_width - 10 # Shift left by subtracting 1and using final_width # y-coordinate: top edge of available geometry y_pos = screen_geometry.top() # Use top of available geometry self.move(x_pos, y_pos) # Setup the central widget and main layout self.central_widget = QtWidgets.QWidget() self.setCentralWidget(self.central_widget) self.layout = QtWidgets.QVBoxLayout() self.central_widget.setLayout(self.layout) # Create the brain widget first self.brain_widget = BrainWidget(self.config_manager, self.debug_mode, tamagotchi_logic=tamagotchi_logic) # Set parent so brain_widget can access network_tab via self.parent().network_tab self.brain_widget.setParent(self) # Initialize tab widget self.init_tabs() # Set up timers self.init_timers() # Initialize memory update timer if needed if hasattr(self, 'memory_tab'): self.memory_update_timer = QtCore.QTimer(self) self.memory_update_timer.timeout.connect(self.update_memory_tab) self.memory_update_timer.start(5000) # PERFORMANCE FIX: Update every 5 secs (was 2) # Set up worker thread self.brain_worker = BrainWorker(brain_widget=self.brain_widget) self.brain_worker.start() self._last_worker_activity = time.time() self._worker_healthy = True # Start periodic worker health monitoring self.health_timer = QtCore.QTimer(self) self.health_timer.timeout.connect(self.check_worker_health) self.health_timer.start(10000) # Every 10 seconds # Connect worker signals for health monitoring self.brain_worker.neurogenesis_result.connect(self._on_worker_activity) self.brain_worker.hebbian_result.connect(self._on_worker_activity) self.brain_worker.state_update_result.connect(self._on_worker_activity) self.brain_worker.error_occurred.connect(self._on_worker_error) # Track pause state before entering designer self._was_paused_before_designer = False self.designer_view = None # ===== PERFORMANCE FIX: Share worker with brain_widget ===== if hasattr(self.brain_widget, 'set_brain_worker'): self.brain_widget.set_brain_worker(self.brain_worker) print("🔗 Shared BrainWorker with brain_widget") # Immediately update cache with current state self.brain_widget._update_worker_cache() print("📦 Initial worker cache populated") # Store reference to prevent garbage collection self._brain_worker_ref = self.brain_worker # Ensure worker stays alive with initial work QtCore.QTimer.singleShot(1000, lambda: self._keep_worker_alive()) # Initialize task manager AFTER worker is fully set up self.task_manager = TaskManagerWindow(self.brain_worker, parent=self) # Setup decorations window shortcut self.setup_decorations_shortcut() # Bridge Import Listener # Polls for designs pushed from the Designer window if _HAS_BRAIN_BRIDGE: self._bridge_timer = QtCore.QTimer(self) self._bridge_timer.timeout.connect(self._check_bridge_import) self._bridge_timer.start(1000) # Check every 1 second def set_tamagotchi_logic(self, tamagotchi_logic): """Set the tamagotchi_logic reference and update all tabs""" print(f"SquidBrainWindow.set_tamagotchi_logic: {tamagotchi_logic is not None}") self.tamagotchi_logic = tamagotchi_logic # Update brain widget if hasattr(self, 'brain_widget'): self.brain_widget.tamagotchi_logic = tamagotchi_logic # 🔽 ADD THIS BLOCK: Load output bindings if they exist 🔽 if (hasattr(self.brain_widget, 'output_bindings') and self.brain_widget.output_bindings and hasattr(tamagotchi_logic, 'neuron_output_monitor') and tamagotchi_logic.neuron_output_monitor): tamagotchi_logic.neuron_output_monitor.load_bindings_from_brain({ 'output_bindings': self.brain_widget.output_bindings }) print(f" ✓ Loaded {len(self.brain_widget.output_bindings)} output bindings into Monitor") # Update all tabs for tab_attr in ['memory_tab', 'network_tab', 'learning_tab', 'decisions_tab', 'about_tab']: if hasattr(self, tab_attr): tab = getattr(self, tab_attr) if hasattr(tab, 'set_tamagotchi_logic') and self.tamagotchi_logic: tab.set_tamagotchi_logic(tamagotchi_logic) def _check_bridge_import(self): """Check if an external designer has pushed a new brain state.""" # Don't import if we are currently IN the embedded designer mode if self.designer_view: return try: from .brain_state_bridge import consume_pending_import data = consume_pending_import() if data: print(">> 📥 Received external brain import from Designer") # Use the existing apply logic which handles neurons, connections, and bindings self.apply_designer_state(data) # Visual feedback if hasattr(self, 'status_bar'): self.status_bar.showMessage("✨ Brain updated from external Designer", 5000) else: print("✨ Brain updated successfully") except ImportError: pass except Exception as e: print(f"Error applying external import: {e}") def setup_decorations_shortcut(self): """Setup keyboard shortcut for decorations window (D key)""" if self.show_decorations_callback: self.decorations_shortcut = QtWidgets.QShortcut( QtGui.QKeySequence(QtCore.Qt.Key_D), self ) self.decorations_shortcut.activated.connect(self.show_decorations_callback) def show_decorations_window(self): # Always show and activate the decorations window when D is pressed self.decoration_window.show() self.decoration_window.activateWindow() def _keep_worker_alive(self): """Keep worker thread alive with periodic health checks""" if self.brain_worker and self.brain_worker.isRunning(): # Queue a health check every 30 seconds self.brain_worker.queue_state_update({'health_check': True}) QtCore.QTimer.singleShot(30000, self._keep_worker_alive) def set_debug_mode(self, enabled): """Properly set debug mode for brain window and all tabs""" self.debug_mode = enabled # Update brain widget's debug mode if hasattr(self, 'brain_widget'): self.brain_widget.debug_mode = enabled # Update all tabs for tab_name in ['network_tab', 'nn_viz_tab', 'memory_tab', 'decisions_tab', 'about_tab']: if hasattr(self, tab_name): tab = getattr(self, tab_name) if hasattr(tab, 'debug_mode'): tab.debug_mode = enabled print(f"Brain window debug mode set to: {enabled}") def check_worker_health(self): """ Periodically check if the BrainWorker thread is alive and responsive. If it's dead or completely stalled, restart it safely. """ if not hasattr(self, 'brain_worker') or not self.brain_worker: print("BrainWorker missing – creating new one") self._restart_brain_worker() return # Case 1: Thread object exists but is not running if not self.brain_worker.isRunning(): print("BrainWorker thread not running – restarting") self._restart_brain_worker() return # Case 2: Thread is running but has been completely silent for too long if not self.is_worker_healthy(): print("BrainWorker stalled (no activity in 15s) – forcing restart") self._restart_brain_worker() return # Everything looks fine # print("BrainWorker healthy") # Uncomment only for deep debugging # SquidBrainWindow._restart_brain_worker def _restart_brain_worker(self): # --- stop old worker if hasattr(self, 'brain_worker') and self.brain_worker: print("Stopping old BrainWorker...") # Disconnect signals BEFORE stopping to prevent duplicate connections try: self.brain_worker.neurogenesis_result.disconnect(self._on_worker_activity) self.brain_worker.hebbian_result.disconnect(self._on_worker_activity) self.brain_worker.state_update_result.disconnect(self._on_worker_activity) self.brain_worker.error_occurred.disconnect(self._on_worker_error) except: pass # Ignore if signals weren't connected self.brain_worker.stop() self.brain_worker.wait(3000) # --- create & start new worker self.brain_worker = BrainWorker(brain_widget=self.brain_widget) # Reconnect signals for new worker self.brain_worker.neurogenesis_result.connect(self._on_worker_activity) self.brain_worker.hebbian_result.connect(self._on_worker_activity) self.brain_worker.state_update_result.connect(self._on_worker_activity) self.brain_worker.error_occurred.connect(self._on_worker_error) self.brain_worker.start() # Update cache for new worker if hasattr(self.brain_widget, 'set_brain_worker'): self.brain_widget.set_brain_worker(self.brain_worker) self.brain_widget._update_worker_cache() # --- CRITICAL: Update TaskManager's worker reference --- if hasattr(self, 'task_manager') and self.task_manager: self.task_manager.update_worker_reference(self.brain_worker) # --- Keep-alive timer --- QtCore.QTimer.singleShot(1000, lambda: self._keep_worker_alive()) print("BrainWorker successfully restarted and re-cached") def _on_worker_activity(self, result): """Update worker health when activity is detected""" self._last_worker_activity = time.time() self._worker_healthy = True # print(f"🧠 Worker activity detected: {time.time() - self._last_worker_activity:.1f}s ago") def _on_worker_error(self, error_msg): """Handle worker errors""" print(f"⚠️ Worker error: {error_msg}") self._worker_healthy = False def is_worker_healthy(self): """Check if worker is healthy based on recent activity""" if not self.brain_worker or not self.brain_worker.isRunning(): return False # Consider worker healthy if it had activity in last 10 seconds time_since_activity = time.time() - self._last_worker_activity return time_since_activity < 10.0 and self._worker_healthy def on_neurogenesis_result(self, result): """Handle neurogenesis results from worker thread""" if result.get('should_create'): # This now runs on main thread - safe to modify brain_widget self.brain_widget.handle_neurogenesis_result(result) def on_hebbian_result(self, result): """Handle Hebbian learning results from worker thread""" if result.get('updated_pairs'): self.brain_widget.handle_hebbian_result(result) def on_state_update_result(self, result): """Handle processed state updates""" if result.get('processed_state'): self.brain_widget.update_state(result['processed_state']) def on_worker_error(self, error_msg): """Handle errors from worker thread""" print(f"⚠️ BrainWorker error: {error_msg}") # Optional: Show in UI if hasattr(self, 'status_bar'): self.status_bar.showMessage(f"Worker error: {error_msg}", 5000) def closeEvent(self, event): """Stop worker and timers when window closes""" if hasattr(self, 'health_timer') and self.health_timer.isActive(): self.health_timer.stop() if hasattr(self, 'brain_worker') and self.brain_worker: self.brain_worker.stop() if self.brain_worker.isRunning(): self.brain_worker.wait(5000) event.accept() def on_hebbian_countdown_finished(self): """Called when the Hebbian learning countdown reaches zero""" pass def set_pause_state(self, is_paused): """Set pause state for the brain window and worker thread""" self.is_paused = is_paused # Set brain widget pause state if hasattr(self, 'brain_widget'): self.brain_widget.is_paused = is_paused # Control worker thread - CRITICAL for freezing everything if hasattr(self, 'brain_worker') and self.brain_worker: if is_paused: self.brain_worker.pause() print("⏸️ BrainWorker paused") else: self.brain_worker.resume() print("▶️ BrainWorker resumed") # Manage timers based on pause state if is_paused: if hasattr(self, 'hebbian_timer'): self.hebbian_timer.stop() else: if hasattr(self, 'hebbian_timer'): self.hebbian_timer.start(self.config.hebbian['learning_interval']) def switch_to_designer_mode(self): """ Replaces the main tab view with the Brain Designer embedded window. Includes extensive fixes for blank canvas and missing connection issues. """ if not _DESIGNER_AVAILABLE: QtWidgets.QMessageBox.warning(self, "Error", "Brain Designer module not found.") return if not hasattr(self, 'brain_widget'): return print(">> Switching to Designer Mode...") # 1. Pause simulation self._was_paused_before_designer = self.is_paused # Pause game logic via UI to ensure "SIMULATION PAUSED" overlay appears if self.tamagotchi_logic: # Check if we can route through UI for visual feedback if hasattr(self.tamagotchi_logic, 'user_interface') and \ hasattr(self.tamagotchi_logic.user_interface, 'set_simulation_speed'): self.tamagotchi_logic.user_interface.set_simulation_speed(0) else: # Fallback to logic-only pause (no overlay) self.tamagotchi_logic.set_simulation_speed(0) else: self.set_pause_state(True) # 2. Capture state current_state = self.get_brain_state() # The designer needs a rich 'neurons' dictionary with types and positions if 'neurons' not in current_state: current_state['neurons'] = {} # Helper sets for identifying neuron types core_neurons = {'hunger', 'happiness', 'cleanliness', 'sleepiness', 'satisfaction', 'anxiety', 'curiosity'} for name, pos in self.brain_widget.neuron_positions.items(): # Determine type if name in core_neurons: ntype = 'core' elif name == 'can_see_food' or name in getattr(self.brain_widget, 'input_sensors', []): ntype = 'sensor' elif name.startswith('connector') or self.brain_widget.is_connector_neuron(name): ntype = 'connector' else: ntype = 'hidden' # Get existing color color = self.brain_widget.state_colors.get(name, (200, 200, 200)) # Build the rich object current_state['neurons'][name] = { 'name': name, 'position': pos, 'neuron_type': ntype, # Critical for Designer validation 'type': ntype, 'is_binary': self.brain_widget.is_binary_neuron(name), 'color': color, 'activation': self.brain_widget.state.get(name, 0.0) } print(f" [Fix] Constructed {len(current_state['neurons'])} neuron records") # The designer prefers a 'connections' list of dicts. # We build this explicitly from the brain_widget weights to ensure links appear. connections_list = [] # Source: Direct from brain_widget weights # We bypass 'weights_list' to ensure we get the most up-to-date dict structure if hasattr(self.brain_widget, 'weights'): for (src, dst), weight in self.brain_widget.weights.items(): connections_list.append({ 'source': str(src), 'target': str(dst), 'weight': float(weight) }) # Inject into state current_state['connections'] = connections_list print(f" [Fix] Constructed {len(connections_list)} connection records") # 3. Hide game tabs self.tabs.hide() # 4. Initialize Designer self.designer_view = BrainDesignerWindow(self, embedded_mode=True) self.designer_view.setWindowFlags(QtCore.Qt.Widget) # 5. Load State loaded = False if hasattr(self.designer_view, 'load_from_brain_widget_state'): try: # This calls converter which now sees our nice 'neurons' and 'connections' keys loaded = self.designer_view.load_from_brain_widget_state(current_state) except Exception as e: print(f"Standard load failed: {e}") if not loaded: # Fallback manual loading try: from designer_core import BrainDesign design = BrainDesign.from_dosidicus_format(current_state) self.designer_view.design = design self.designer_view.refresh_all() loaded = True except Exception as e: print(f"Critical: Failed to load brain state into designer: {e}") # 6. Setup Return Signal & Layout self.designer_view.exitRequested.connect(self.switch_to_game_mode) self.layout.addWidget(self.designer_view) # [FIX] Force layout update and delayed refresh to ensure canvas draws self.central_widget.updateGeometry() # Force an immediate refresh cycle if self.designer_view: self.designer_view.refresh_all() # Schedule refreshes to catch layout settling QtCore.QTimer.singleShot(50, self._force_designer_refresh) QtCore.QTimer.singleShot(200, self._force_designer_refresh) # 6. Setup Return Signal & Layout self.designer_view.exitRequested.connect(self.switch_to_game_mode) self.layout.addWidget(self.designer_view) # [FIX] Force layout update and delayed refresh to ensure canvas draws self.central_widget.updateGeometry() # Force an immediate refresh if self.designer_view: self.designer_view.refresh_all() # Schedule a second refresh to catch layout settling QtCore.QTimer.singleShot(100, self._force_designer_refresh) def _force_designer_refresh(self): """Helper to center and refresh designer after layout settles.""" if self.designer_view and hasattr(self.designer_view, 'canvas'): # Force scene rect update based on items if hasattr(self.designer_view.canvas, 'scene') and self.designer_view.canvas.scene: items_rect = self.designer_view.canvas.scene.itemsBoundingRect() if not items_rect.isEmpty(): self.designer_view.canvas.scene.setSceneRect( items_rect.adjusted(-200, -200, 200, 200) ) # Recalculate view center self.designer_view.canvas.center_on_neurons() self.designer_view.canvas.viewport().update() def switch_to_game_mode(self): """ Restores the game view. Includes fixes for blank brain widget issues. """ if not self.designer_view: return print(">> Switching back to Game Mode...") # 1. Get Data from Designer new_design_data = self.designer_view.get_current_design_state() # 2. Cleanup Designer self.layout.removeWidget(self.designer_view) self.designer_view.deleteLater() self.designer_view = None # 3. Restore Tabs self.tabs.show() # 4. Apply State if new_design_data: self.apply_designer_state(new_design_data) # 5. Restore Pause State if not self._was_paused_before_designer: if self.tamagotchi_logic: # Route through UI to remove "SIMULATION PAUSED" overlay and update menu if hasattr(self.tamagotchi_logic, 'user_interface') and \ hasattr(self.tamagotchi_logic.user_interface, 'set_simulation_speed'): self.tamagotchi_logic.user_interface.set_simulation_speed(1) else: self.tamagotchi_logic.set_simulation_speed(1) # Resume logic only else: self.set_pause_state(False) # Mark dirty immediately self.brain_widget.mark_render_dirty() # Explicitly invoke the offscreen render request logic if hasattr(self.brain_widget, '_request_render'): # Reset throttling to ensure request goes through self.brain_widget._last_render_request = 0 self.brain_widget._request_render() # Force Qt Repaint self.brain_widget.update() self.brain_widget.repaint() print(">> Game Mode Restored") def apply_designer_state(self, data): """ Applies state from Designer to BrainWidget. Robustly handles weights/connections in list or dict format. """ # 1. Update Weights new_weights = {} # Check for 'connections' (List of dicts OR Dict of key->val) connections = data.get('connections') # If connections is missing, check 'weights' (Legacy dict) if not connections: connections = data.get('weights', {}) if isinstance(connections, dict): for key, weight in connections.items(): # Handle 'src->dst' (Designer) or 'src|dst' (Legacy) if '->' in key: s, t = key.split('->') new_weights[(s, t)] = float(weight) elif '|' in key: s, t = key.split('|') new_weights[(s, t)] = float(weight) elif isinstance(connections, list): # List of dicts: [{'source': 'A', 'target': 'B', 'weight': 0.5}, ...] for conn in connections: if 'source' in conn and 'target' in conn: new_weights[(conn['source'], conn['target'])] = float(conn.get('weight', 0.5)) if new_weights: self.brain_widget.weights = new_weights print(f" [Fix] Applied {len(new_weights)} connections from designer") # 2. Update Positions if 'neurons' in data: new_positions = {} for name, info in data['neurons'].items(): if 'position' in info: new_positions[name] = tuple(info['position']) # Initialize new neurons if missing if name not in self.brain_widget.state: self.brain_widget.state[name] = 0.0 # Apply color if present if 'color' in info: self.brain_widget.state_colors[name] = tuple(info['color']) elif name not in self.brain_widget.state_colors: self.brain_widget.state_colors[name] = (200, 200, 200) self.brain_widget.neuron_positions = new_positions # 3. Update Bindings if 'output_bindings' in data and hasattr(self.tamagotchi_logic, 'neuron_output_monitor'): self.brain_widget.output_bindings = data['output_bindings'] self.tamagotchi_logic.neuron_output_monitor.load_bindings_from_brain(data) # 4. Trigger Updates self.brain_widget.mark_render_dirty() self.brain_widget.update() # Refresh Network Tab Stats if hasattr(self, 'network_tab'): self.network_tab.update_metrics_display() def init_inspector(self): self.inspector_action = QtWidgets.QAction("Neuron Inspector", self) self.inspector_action.triggered.connect(self.show_inspector) self.debug_menu.addAction(self.inspector_action) def show_inspector(self): if not hasattr(self, '_inspector') or not self._inspector or not self._inspector.isVisible(): # Check if visible # Pass the SquidBrainWindow instance (self) to NeuronInspector self._inspector = NeuronInspector(self, self.brain_widget) self._inspector.show() self._inspector.raise_() self._inspector.activateWindow() # Ensure it gets focus def debug_print(self, message): if self.debug_mode: print(f"DEBUG: {message}") def toggle_debug_mode(self, enabled): self.debug_mode = enabled self.debug_print(f"Debug mode {'enabled' if enabled else 'disabled'}") # Update stimulate button state if hasattr(self, 'stimulate_button'): self.stimulate_button.setEnabled(enabled) def init_tabs(self): # Get localisation instance loc = Localisation.instance() # Create tab widget self.tabs = QtWidgets.QTabWidget() self.layout.addWidget(self.tabs) # Set base font for all tab content base_font = QtGui.QFont() base_font.setPointSize(self.base_font_size) self.tabs.setFont(base_font) # Create and add existing tabs with Localized Titles self.network_tab = NetworkTab(self, self.tamagotchi_logic, self.brain_widget, self.config_manager, self.debug_mode) self.tabs.addTab(self.network_tab, loc.get("brain_network", "Network")) # Add our Neural Network Visualizer tab as the Learning tab self.nn_viz_tab = NeuralNetworkVisualizerTab(self, self.tamagotchi_logic, self.brain_widget, self.config, self.debug_mode) self.tabs.addTab(self.nn_viz_tab, loc.get("tab_learning", "Learning")) self.memory_tab = MemoryTab(self, self.tamagotchi_logic, self.brain_widget, self.config, self.debug_mode) self.tabs.addTab(self.memory_tab, loc.get("memory", "Memory")) self.decisions_tab = DecisionsTab(self, self.tamagotchi_logic, self.brain_widget, self.config, self.debug_mode) self.tabs.addTab(self.decisions_tab, loc.get("tab_decisions", "Decisions")) self.personality_tab = PersonalityTab(self, self.tamagotchi_logic, self.brain_widget, self.config, self.debug_mode) self.tabs.addTab(self.personality_tab, loc.get("tab_personality", "Personality")) # ADD THE NEW STATISTICS TAB HERE self.statistics_tab = StatisticsTab(self, self.tamagotchi_logic, self.brain_widget, self.config, self.debug_mode) self.tabs.addTab(self.statistics_tab, loc.get("statistics", "Statistics")) self.about_tab = AboutTab(self, self.tamagotchi_logic, self.brain_widget, self.config, self.debug_mode) self.tabs.addTab(self.about_tab, loc.get("tab_about", "About")) # Make sure all tabs have correct tamagotchi_logic reference for tab_name in ['memory_tab', 'network_tab', 'nn_viz_tab', 'decisions_tab', 'personality_tab', 'statistics_tab', 'about_tab']: if hasattr(self, tab_name): tab = getattr(self, tab_name) if hasattr(tab, 'set_tamagotchi_logic') and self.tamagotchi_logic: tab.set_tamagotchi_logic(self.tamagotchi_logic) print(f"Set tamagotchi_logic for {tab_name}") # Pre-load the learning tab to make it responsive on first click if hasattr(self, 'nn_viz_tab'): if hasattr(self.nn_viz_tab, 'pre_load_data'): QtCore.QTimer.singleShot(700, self.nn_viz_tab.pre_load_data) def get_brain_state(self): """ Save brain state for persistence. IMPORTANT: Core neuron values (hunger, happiness, etc.) are NOT saved here. The squid object is the single source of truth for these values. Only non-core state (booleans, direction, position) and neurogenesis data are saved. """ # Convert weights to a list for safe JSON serialization weights_list = [] for k, v in self.brain_widget.weights.items(): if isinstance(k, tuple) and len(k) == 2: weights_list.append([k[0], k[1], v]) else: print(f"Warning: Skipping non-standard weight key: {k}") # Core neurons whose values come from squid (single source of truth) core_stat_neurons = {'hunger', 'happiness', 'cleanliness', 'sleepiness', 'satisfaction', 'anxiety', 'curiosity'} # Save only non-core neuron states (booleans, direction, position, new neurons) # Core stat values will be synced from squid on load non_core_states = {} for key, value in self.brain_widget.state.items(): if key not in core_stat_neurons: non_core_states[key] = value # Serialize full EnhancedNeurogenesis state (includes functional_neurons, # experience_buffer, counters, awarded_neurons, etc.) enhanced_neurogenesis_data = {} if hasattr(self.brain_widget, 'enhanced_neurogenesis'): try: enhanced_neurogenesis_data = self.brain_widget.enhanced_neurogenesis.to_dict() except Exception as e: print(f"Warning: Could not serialize EnhancedNeurogenesis: {e}") return { 'weights_list': weights_list, 'neuron_positions': {str(k): v for k, v in self.brain_widget.neuron_positions.items()}, 'neuron_states': non_core_states, # Only non-core states 'neurogenesis_data': self.brain_widget.neurogenesis_data, 'state_colors': getattr(self.brain_widget, 'state_colors', {}), 'enhanced_neurogenesis': enhanced_neurogenesis_data, # Full neurogenesis state # Legacy key for backward compatibility (subset of enhanced_neurogenesis) 'functional_neurons': enhanced_neurogenesis_data.get('functional_neurons', {}) } def set_brain_state(self, state): """ Load the brain state from a saved state dictionary. Uses robust fallback logic to handle missing keys and incomplete data. IMPORTANT: Core neuron values (hunger, happiness, etc.) are NOT loaded here. Call sync_state_from_squid() after this method to populate them from the squid. """ # Load weights (handle new list format and potentially old dict format) if 'weights_list' in state: weights = {} for item in state['weights_list']: if isinstance(item, list) and len(item) == 3: weights[(item[0], item[1])] = item[2] self.brain_widget.weights = weights elif 'weights' in state: # Fallback for old format (acknowledging its bug) print("Warning: Loading old 'weights' format. May cause issues with new neurons.") weights = {} for k, v in state['weights'].items(): if '_' in k: key = tuple(k.split('_')) # This is the buggy part else: key = k weights[key] = v self.brain_widget.weights = weights else: print("⚠️ No weights data in state, preserving current weights") # Load neuron positions, defaulting to original if not found self.brain_widget.neuron_positions = state.get('neuron_positions', self.brain_widget.original_neuron_positions.copy()) # Load non-core neuron states (booleans, direction, position, new neuron values) # Core stat values will be synced from squid via sync_state_from_squid() loaded_states = state.get('neuron_states', {}) self.brain_widget.state = loaded_states.copy() # Load neurogenesis_data (basic tracking for brain_widget display) self.brain_widget.neurogenesis_data = state.get('neurogenesis_data', { 'new_neurons': [], 'last_neuron_time': time.time(), 'new_neurons_details': {} }) if 'new_neurons' not in self.brain_widget.neurogenesis_data: self.brain_widget.neurogenesis_data['new_neurons'] = [] if 'new_neurons_details' not in self.brain_widget.neurogenesis_data: self.brain_widget.neurogenesis_data['new_neurons_details'] = {} # Load state colors (with fallback) self.brain_widget.state_colors = state.get('state_colors', { 'is_sick': (255, 204, 204), 'is_eating': (204, 255, 204), 'is_sleeping': (204, 229, 255), 'pursuing_food': (255, 229, 204), 'direction': (229, 204, 255) }) # ===================================================================== # LOAD FULL EnhancedNeurogenesis STATE # ===================================================================== if hasattr(self.brain_widget, 'enhanced_neurogenesis'): # Prefer new 'enhanced_neurogenesis' key with full state if 'enhanced_neurogenesis' in state: try: self.brain_widget.enhanced_neurogenesis.from_dict(state['enhanced_neurogenesis']) fn_count = len(self.brain_widget.enhanced_neurogenesis.functional_neurons) print(f"✅ Loaded full EnhancedNeurogenesis state ({fn_count} functional neurons)") # If from_dict failed to load neurons, try direct loading if fn_count == 0 and 'functional_neurons' in state['enhanced_neurogenesis']: print("⚠️ from_dict returned 0 neurons, attempting direct load...") self._direct_load_functional_neurons(state['enhanced_neurogenesis']['functional_neurons']) except Exception as e: print(f"⚠️ Error loading EnhancedNeurogenesis: {e}") import traceback traceback.print_exc() # Fall back to legacy loading self._load_legacy_functional_neurons(state) # Fallback: legacy 'functional_neurons' key only elif 'functional_neurons' in state: self._load_legacy_functional_neurons(state) print("ℹ️ Loaded legacy functional_neurons format") # ===================================================================== # FORCIBLY REBUILD NEUROGENESIS NEURONS FROM functional_neurons OR state # ===================================================================== self._force_rebuild_neurogenesis_neurons(state) # --- Critical Step: Ensure brain_widget consistency after loading --- all_neurons = list(self.brain_widget.neuron_positions.keys()) new_neurons_list = self.brain_widget.neurogenesis_data.get('new_neurons', []) # Get config colors for new neurons cfg_appearance = self.config.neurogenesis.get('appearance', {}) cfg_colors = cfg_appearance.get('colors', {}) default_colors = {'novelty': (255, 255, 150), 'stress': (255, 150, 150), 'reward': (150, 255, 150)} for neuron in all_neurons: # Ensure all neurons exist in the state dictionary (default 50 for new neurons) if neuron not in self.brain_widget.state: self.brain_widget.state[neuron] = 50 # Ensure all neurons exist in communication events if hasattr(self.brain_widget, 'communication_events') and neuron not in self.brain_widget.communication_events: self.brain_widget.communication_events[neuron] = 0 # Ensure new neurons have a color entry if neuron in new_neurons_list and neuron not in self.brain_widget.state_colors: if neuron.startswith('novel'): color = tuple(cfg_colors.get('novelty', default_colors['novelty'])) elif neuron.startswith('stress'): color = tuple(cfg_colors.get('stress', default_colors['stress'])) elif neuron.startswith('reward'): color = tuple(cfg_colors.get('reward', default_colors['reward'])) else: color = (200, 200, 200) self.brain_widget.state_colors[neuron] = color # Ensure all core neurons have their original positions if missing for name, pos in self.brain_widget.original_neuron_positions.items(): if name not in self.brain_widget.neuron_positions: self.brain_widget.neuron_positions[name] = pos # Reveal all core neurons for loaded games self.brain_widget.reveal_all_core_neurons() # Trigger staggered link fade animation when loading a save if self.brain_widget.show_links: self.brain_widget._enable_links_after_reveal() self.brain_widget.update() def _load_legacy_functional_neurons(self, state): """Fallback loader for old save format with only functional_neurons key.""" from .neurogenesis import FunctionalNeuron functional_neurons_data = state.get('functional_neurons', {}) restored_neurons = {} for name, data in functional_neurons_data.items(): try: restored_neurons[name] = FunctionalNeuron.from_dict(data) except Exception as e: print(f"Warning: Could not restore neuron {name}: {e}") continue self.brain_widget.enhanced_neurogenesis.functional_neurons = restored_neurons print(f"Loaded {len(restored_neurons)} functional neurons (legacy format)") def _direct_load_functional_neurons(self, functional_neurons_data): """ Directly load FunctionalNeuron objects from save data, bypassing from_dict. This is a fallback when the normal loading path fails. """ from .neurogenesis import FunctionalNeuron, ExperienceContext if not functional_neurons_data: print("⚠️ No functional_neurons_data to load") return loaded_count = 0 for name, data in functional_neurons_data.items(): try: # Manually construct the FunctionalNeuron creation_ctx = data.get('creation_context', {}) active_neurons_data = creation_ctx.get('active_neurons') or creation_ctx.get('brain_state', {}) context = ExperienceContext( trigger_type=creation_ctx.get('trigger_type', 'stress'), active_neurons=active_neurons_data, recent_actions=creation_ctx.get('recent_actions', []), environmental_state=creation_ctx.get('environmental_state', {}), outcome=creation_ctx.get('outcome', 'neutral'), timestamp=creation_ctx.get('timestamp', time.time()) ) neuron = FunctionalNeuron( name=data.get('name', name), neuron_type=data.get('neuron_type', 'stress'), creation_context=context ) # Restore additional attributes neuron.specialization = data.get('specialization', neuron.specialization) neuron.activation_count = data.get('activation_count', 0) neuron.last_activated = data.get('last_activated', 0) neuron.utility_score = data.get('utility_score', 0.0) neuron.strength_multiplier = data.get('strength_multiplier', 1.0) self.brain_widget.enhanced_neurogenesis.functional_neurons[name] = neuron loaded_count += 1 print(f" ↪ Direct loaded: {name}") except Exception as e: print(f"⚠️ Failed to direct load {name}: {e}") import traceback traceback.print_exc() print(f"✅ Direct loaded {loaded_count} functional neurons") def _force_rebuild_neurogenesis_neurons(self, state=None): """ Forcibly rebuild all neurogenesis neurons from functional_neurons data. This ensures neurons are properly drawn after loading a save. Args: state: Optional brain state dict to use as fallback data source """ if not hasattr(self.brain_widget, 'enhanced_neurogenesis'): return functional_neurons = self.brain_widget.enhanced_neurogenesis.functional_neurons # If no functional neurons loaded, try to load directly from state if not functional_neurons and state: print("⚠️ No functional neurons in enhanced_neurogenesis, loading from state...") fn_data = None if 'enhanced_neurogenesis' in state and 'functional_neurons' in state['enhanced_neurogenesis']: fn_data = state['enhanced_neurogenesis']['functional_neurons'] elif 'functional_neurons' in state: fn_data = state['functional_neurons'] if fn_data: self._direct_load_functional_neurons(fn_data) functional_neurons = self.brain_widget.enhanced_neurogenesis.functional_neurons if not functional_neurons: print("⚠️ No functional neurons to rebuild") return core_neurons = {'hunger', 'happiness', 'cleanliness', 'sleepiness', 'satisfaction', 'anxiety', 'curiosity'} excluded = getattr(self.brain_widget, 'excluded_neurons', []) # Ensure neurogenesis_data structures exist if not hasattr(self.brain_widget, 'neurogenesis_data'): self.brain_widget.neurogenesis_data = {} self.brain_widget.neurogenesis_data.setdefault('new_neurons', []) self.brain_widget.neurogenesis_data.setdefault('new_neurons_details', {}) # Ensure neuron_shapes exists if not hasattr(self.brain_widget, 'neuron_shapes'): self.brain_widget.neuron_shapes = {} rebuilt_count = 0 for name, fn in functional_neurons.items(): if name in core_neurons or name in excluded: continue rebuilt_count += 1 # 1. Force add to new_neurons list if name not in self.brain_widget.neurogenesis_data['new_neurons']: self.brain_widget.neurogenesis_data['new_neurons'].append(name) # 2. Force add to new_neurons_details if name not in self.brain_widget.neurogenesis_data['new_neurons_details']: self.brain_widget.neurogenesis_data['new_neurons_details'][name] = { 'created_at': fn.creation_context.timestamp, 'trigger_type': fn.neuron_type, 'trigger_value_at_creation': 0, 'specialisation': fn.specialization } # 3. Force add to neuron_positions (calculate if missing) if name not in self.brain_widget.neuron_positions: # Calculate position based on functional connections all_neurons = list(self.brain_widget.neuron_positions.keys()) connections = fn.get_functional_connections(all_neurons) if connections: total_weight = 0 center_x, center_y = 0, 0 for target, weight in connections.items(): if target in self.brain_widget.neuron_positions: pos = self.brain_widget.neuron_positions[target] abs_weight = abs(weight) center_x += pos[0] * abs_weight center_y += pos[1] * abs_weight total_weight += abs_weight if total_weight > 0: center_x /= total_weight center_y /= total_weight x = max(50, min(974, center_x + random.randint(-80, 80))) y = max(50, min(668, center_y + random.randint(-80, 80))) self.brain_widget.neuron_positions[name] = (x, y) else: self.brain_widget.neuron_positions[name] = (random.randint(100, 900), random.randint(100, 600)) else: self.brain_widget.neuron_positions[name] = (random.randint(100, 900), random.randint(100, 600)) print(f" ↪ Rebuilt position for {name}") # 4. Force add to state if name not in self.brain_widget.state: self.brain_widget.state[name] = 50.0 # 5. Force add to visible_neurons if hasattr(self.brain_widget, 'visible_neurons'): self.brain_widget.visible_neurons.add(name) # 6. Force set shape based on neuron type shape_map = {'novelty': 'diamond', 'stress': 'square', 'reward': 'triangle'} self.brain_widget.neuron_shapes[name] = shape_map.get(fn.neuron_type, 'circle') # 7. Force set color based on type and specialization spec = fn.specialization if 'stress' in spec or 'anxiety' in spec: self.brain_widget.state_colors[name] = (255, 150, 150) elif 'reward' in spec or 'satisfaction' in spec: self.brain_widget.state_colors[name] = (150, 255, 150) elif 'investigation' in spec or 'exploration' in spec: self.brain_widget.state_colors[name] = (255, 215, 0) else: color_map = {'novelty': (255, 255, 150), 'stress': (255, 150, 150), 'reward': (173, 216, 230)} self.brain_widget.state_colors[name] = color_map.get(fn.neuron_type, (200, 200, 255)) # 8. Force add to communication_events if hasattr(self.brain_widget, 'communication_events'): if name not in self.brain_widget.communication_events: self.brain_widget.communication_events[name] = 0 # 9. Ensure connections exist all_neurons = list(self.brain_widget.neuron_positions.keys()) connections = fn.get_functional_connections(all_neurons) for target, weight in connections.items(): if (name, target) not in self.brain_widget.weights: self.brain_widget.weights[(name, target)] = weight if rebuilt_count > 0: print(f"🔧 Force rebuilt {rebuilt_count} neurogenesis neurons from save data") def sync_state_from_squid(self, squid): """ Sync brain_widget.state with current squid stats. This makes the squid the single source of truth for core neuron values. Call this after set_brain_state() during game load. """ if not squid: print("⚠️ Cannot sync: no squid provided") return # Core stats that come from squid self.brain_widget.state['hunger'] = squid.hunger self.brain_widget.state['happiness'] = squid.happiness self.brain_widget.state['cleanliness'] = squid.cleanliness self.brain_widget.state['sleepiness'] = squid.sleepiness self.brain_widget.state['satisfaction'] = squid.satisfaction self.brain_widget.state['anxiety'] = squid.anxiety self.brain_widget.state['curiosity'] = squid.curiosity # Boolean states from squid self.brain_widget.state['is_sick'] = squid.is_sick self.brain_widget.state['is_sleeping'] = getattr(squid, 'is_sleeping', False) self.brain_widget.state['is_eating'] = getattr(squid, 'is_eating', False) self.brain_widget.state['pursuing_food'] = getattr(squid, 'pursuing_food', False) self.brain_widget.state['is_startled'] = getattr(squid, 'is_startled', False) self.brain_widget.state['is_fleeing'] = getattr(squid, 'is_fleeing', False) self.brain_widget.state['direction'] = getattr(squid, 'squid_direction', 'up') self.brain_widget.state['position'] = (squid.squid_x, squid.squid_y) print("✅ Brain state synced from squid") def init_timers(self): # Hebbian learning timer self.hebbian_timer = QtCore.QTimer() self.hebbian_timer.timeout.connect(self.brain_widget.perform_hebbian_learning) self.hebbian_timer.start(self.config.hebbian.get('learning_interval', 30000)) # Hebbian countdown self.hebbian_countdown_seconds = int(self.config.hebbian.get('learning_interval', 30000) / 1000) # Add countdown timer self.countdown_timer = QtCore.QTimer() self.countdown_timer.timeout.connect(self.update_countdown) self.countdown_timer.start(1000) # Update every second # Associations timer self.update_timer = QtCore.QTimer() self.update_timer.timeout.connect(self.update_associations) self.update_timer.start(10000) # Update every 10 seconds self.last_update_time = time.time() self.update_threshold = 5 # Minimum seconds between updates def update_randomness_factors(self, randomness): """Update the randomness factors table""" self.random_factors_table.setRowCount(len(randomness)) for i, (action, factor) in enumerate(randomness.items()): # Action name action_item = QtWidgets.QTableWidgetItem(action) self.random_factors_table.setItem(i, 0, action_item) # Random factor value_item = QtWidgets.QTableWidgetItem(f"{factor:.2f}") color = QtGui.QColor("darkgreen") if factor > 1.0 else QtGui.QColor("darkred") value_item.setForeground(color) self.random_factors_table.setItem(i, 1, value_item) def create_thought_node(self, text): node = QtWidgets.QGraphicsRectItem(0, 0, 250, 150) # Increase node size node.setBrush(QtGui.QBrush(QtGui.QColor(240, 248, 255))) # Use QTextDocument for better text handling text_document = QtGui.QTextDocument() text_document.setPlainText(text) text_document.setTextWidth(230) # Set text width to fit within the node # Create a QGraphicsTextItem with an empty string text_item = QtWidgets.QGraphicsTextItem() text_item.setDocument(text_document) text_item.setPos(10, 10) group = QtWidgets.QGraphicsItemGroup() group.addToGroup(node) group.addToGroup(text_item) return group def draw_connection(self, start, end, label): line = QtWidgets.QGraphicsLineItem(start[0]+200, start[1]+50, end[0], end[1]+50) line.setPen(QtGui.QPen(QtCore.Qt.darkGray, 2, QtCore.Qt.DashLine)) self.decision_scene.addItem(line) arrow = QtWidgets.QGraphicsPolygonItem( QtGui.QPolygonF([QtCore.QPointF(0, -5), QtCore.QPointF(10, 0), QtCore.QPointF(0, 5)])) arrow.setPos(end[0], end[1]+50) arrow.setRotation(180 if start[0] > end[0] else 0) self.decision_scene.addItem(arrow) label_item = QtWidgets.QGraphicsTextItem(label) label_item.setPos((start[0]+end[0])/2, (start[1]+end[1])/2) self.decision_scene.addItem(label_item) def _get_memory_colors(self, memory): """Determine colors based on memory content""" if 'positive' in memory.get('tags', []): return "#E8F5E9", "#C8E6C9" # Green shades elif 'negative' in memory.get('tags', []): return "#FFEBEE", "#FFCDD2" # Red shades elif 'novelty' in memory.get('tags', []): return "#FFFDE7", "#FFF9C4" # Yellow shades return "#F5F5F5", "#EEEEEE" # Default gray def update_memory_tab(self): """Update memory tab if it exists""" if hasattr(self, 'memory_tab'): # Forward to the tab's update method self.memory_tab.update_memory_display() def _update_overview_stats(self, stm, ltm): """Update the overview tab with statistics""" stats_html = """ """ # Memory counts stats_html += """
    📈 Memory Statistics
    Short-Term Memories:{stm_count}
    Long-Term Memories:{ltm_count}
    """.format(stm_count=len(stm), ltm_count=len(ltm)) # Category breakdown categories = {} for m in stm + ltm: cat = m.get('category', 'unknown') categories[cat] = categories.get(cat, 0) + 1 category_html = "\n".join( f"{k}:{v}" for k, v in sorted(categories.items()) ) stats_html += f"""
    🗂️ Categories
    {category_html}
    """ self.overview_stats.setHtml(stats_html) def _clear_layout(self, layout): """Clear all widgets from the given layout""" while layout.count(): item = layout.takeAt(0) widget = item.widget() if widget is not None: widget.deleteLater() def set_pause_state(self, is_paused): """Set pause state for the brain window and worker thread""" self.is_paused = is_paused # Set brain widget pause state if hasattr(self, 'brain_widget'): self.brain_widget.is_paused = is_paused # Control worker thread - CRITICAL for freezing everything if hasattr(self, 'brain_worker') and self.brain_worker: if is_paused: self.brain_worker.pause() self._pause_start_time = time.time() # Capture time when paused print("⏸️ BrainWorker paused") else: self.brain_worker.resume() # Adjust timestamps to account for pause duration if hasattr(self, '_pause_start_time'): pause_duration = time.time() - self._pause_start_time # 1. Adjust Hebbian timer reference if hasattr(self.brain_widget, 'last_hebbian_time'): self.brain_widget.last_hebbian_time += pause_duration # 2. Adjust Neurogenesis timer reference if hasattr(self.brain_widget, 'neurogenesis_data'): self.brain_widget.neurogenesis_data['last_neuron_time'] += pause_duration # 3. Adjust Animation timer references in brain_widget to prevent jumps if hasattr(self.brain_widget, '_last_animation_time'): self.brain_widget._last_animation_time += pause_duration # 4. Adjust running weight animations if hasattr(self.brain_widget, 'weight_animations'): for anim in self.brain_widget.weight_animations: anim['start_time'] += pause_duration # 5. Adjust reveal animations if hasattr(self.brain_widget, 'neuron_reveal_animations'): for anim in self.brain_widget.neuron_reveal_animations.values(): anim['start_time'] += pause_duration # 6. Adjust neurogenesis highlight if hasattr(self.brain_widget, 'neurogenesis_highlight'): self.brain_widget.neurogenesis_highlight['start_time'] += pause_duration print("▶️ BrainWorker resumed") # Manage timers based on pause state if is_paused: if hasattr(self, 'hebbian_timer'): self.hebbian_timer.stop() else: if hasattr(self, 'hebbian_timer'): self.hebbian_timer.start(self.config.hebbian['learning_interval']) def _create_memory_card(self, memory): """Create a styled HTML memory card with tooltip""" category = memory.get('category', 'unknown') value = memory.get('formatted_value', str(memory.get('value', ''))) timestamp = memory.get('timestamp', '') effects = memory.get('effects', {}) # Determine card color based on memory type bg_color, border_color = self._get_memory_colors(memory) # Create concise display text card_html = f"""
    {category.capitalize()}
    {value[:60]}
    {timestamp.split(' ')[-1]}
    """ # Create tooltip with extended info tooltip = f""" Full Content: {value}
    Effects: {', '.join(f'{k}: {v}' for k,v in effects.items())}
    Last Accessed: {timestamp} """ # Create widget with HTML and tooltip card = QtWidgets.QTextEdit() card.setHtml(card_html) card.setReadOnly(True) card.setToolTip(tooltip) card.setFixedHeight(120) card.setStyleSheet("border: none;") return card def _get_card_style(self, memory): """Get CSS style for a memory card based on its valence and importance""" # Determine card background color based on memory valence if memory.get('category') == 'mental_state' and memory.get('key') == 'startled': background_color = "#FFD1DC" # Pastel red for negative elif isinstance(memory.get('raw_value'), dict): total_effect = sum(float(val) for val in memory['raw_value'].values() if isinstance(val, (int, float))) if total_effect > 0: background_color = "#D1FFD1" # Pastel green for positive elif total_effect < 0: background_color = "#FFD1DC" # Pastel red for negative else: background_color = "#FFFACD" # Pastel yellow for neutral else: background_color = "#FFFACD" # Pastel yellow for neutral # Determine border based on importance (only for short-term) importance = memory.get('importance', 1) if importance >= 7: border = "2px solid #FF5733" # Important memory elif importance >= 4: border = "1px solid #666" # Medium importance else: border = "1px solid #ccc" # Low importance return f""" background-color: {background_color}; border: {border}; border-radius: 8px; padding: 8px; margin: 4px; """ def _format_memory_content(self, memory): """Format memory content for display in a card""" # Get the display text - prefer formatted_value, fall back to value display_text = memory.get('formatted_value', str(memory.get('value', ''))) # Skip if the display text contains just a timestamp if 'timestamp' in display_text.lower() and len(display_text.split()) < 3: return "Empty memory" # Make text more concise and readable # Remove any HTML tags already in the text to avoid nesting issues import re display_text = re.sub(r'<[^>]*>', '', display_text) # Format based on category category = memory.get('category', '') if category == 'decorations': # Extract the important parts of decoration interactions if 'interaction with' in display_text.lower(): parts = display_text.split(':') if len(parts) >= 2: item = parts[0].strip() effects = parts[1].strip() return f"{item}
    {effects}" # Default formatting with bold beginning words = display_text.split() if len(words) > 3: bold_part = ' '.join(words[:3]) rest = ' '.join(words[3:]) return f"{bold_part} {rest}" return display_text def _create_memory_tooltip(self, memory): """Create detailed tooltip for a memory card""" tooltip = "" tooltip += f"Category: {memory.get('category', 'unknown')}\n" tooltip += f"Key: {memory.get('key', 'unknown')}\n" timestamp = memory.get('timestamp', '') if isinstance(timestamp, str): try: from datetime import datetime timestamp = datetime.fromisoformat(timestamp).strftime("%H:%M:%S") except: timestamp = "" tooltip += f"Time: {timestamp}\n" if 'importance' in memory: tooltip += f"Importance: {memory.get('importance')}\n" if 'access_count' in memory: tooltip += f"Access count: {memory.get('access_count')}\n" if isinstance(memory.get('raw_value'), dict): tooltip += "\nEffects:\n" for key, value in memory['raw_value'].items(): if isinstance(value, (int, float)): tooltip += f" {key}: {value:+.2f}\n" tooltip += "" return tooltip def _get_stm(self): """Get short-term memories from squid's memory manager""" if hasattr(self.tamagotchi_logic, 'squid') and hasattr(self.tamagotchi_logic.squid, 'memory_manager'): return self.tamagotchi_logic.squid.memory_manager.get_all_short_term_memories() return [] def _get_ltm(self): """Get long-term memories from squid's memory manager""" if hasattr(self.tamagotchi_logic, 'squid') and hasattr(self.tamagotchi_logic.squid, 'memory_manager'): return self.tamagotchi_logic.squid.memory_manager.get_all_long_term_memories() return [] def _is_displayable(self, memory): """Check if a memory should be displayed in the UI""" if not isinstance(memory, dict): return False # Skip timestamp-like memories (with numeric keys or timestamp values) if isinstance(memory.get('key'), str): # Filter out numeric keys (timestamps) if memory['key'].replace('.', '', 1).isdigit(): return False # Check the value - filter out memories with timestamp values value = memory.get('value', '') if isinstance(value, str) and 'timestamp' in value.lower(): return False # For memories with formatted_value formatted_value = memory.get('formatted_value', '') if isinstance(formatted_value, str): # If it contains timestamp numbers as part of the interaction if 'Interaction with' in formatted_value and any(c.isdigit() for c in formatted_value.split('with')[1]): return False # Check for timestamp-like value in the memory if 'Interaction with' in str(formatted_value) and '.' in str(formatted_value): timestamp_part = str(formatted_value).split('with')[1].strip() # If it looks like a float timestamp (e.g., 1744308365.4552662) if '.' in timestamp_part and any(part.replace('.', '', 1).isdigit() for part in timestamp_part.split()): return False # Skip memories that don't have a proper category or value if not memory.get('category') or not memory.get('value'): return False # Must have either formatted_value or a displayable string value if 'formatted_value' not in memory and not isinstance(memory.get('value'), str): return False return True def _update_memory_stats(self, short_term_memories, long_term_memories): """Update memory statistics in the overview tab""" stats_html = "

    Memory System Statistics

    " # Basic stats stats_html += f"

    Short-term memories: {len(short_term_memories)}

    " stats_html += f"

    Long-term memories: {len(long_term_memories)}

    " # Category breakdown stats_html += "

    Memory Categories

    " # Count memories by category all_memories = short_term_memories + long_term_memories categories = {} for mem in all_memories: category = mem.get('category', 'unknown') categories[category] = categories.get(category, 0) + 1 # Create category table if categories: stats_html += "" stats_html += "" for category, count in sorted(categories.items(), key=lambda x: x[1], reverse=True): stats_html += f"" stats_html += "
    CategoryCount
    {category}{count}
    " # Memory importance breakdown (short-term only) if short_term_memories: stats_html += "

    Memory Importance (Short-term)

    " importance_levels = { "High (7-10)": 0, "Medium (4-6)": 0, "Low (1-3)": 0 } for mem in short_term_memories: imp = mem.get('importance', 1) if imp >= 7: importance_levels["High (7-10)"] += 1 elif imp >= 4: importance_levels["Medium (4-6)"] += 1 else: importance_levels["Low (1-3)"] += 1 stats_html += "" stats_html += "" for level, count in importance_levels.items(): stats_html += f"" stats_html += "
    Importance LevelCount
    {level}{count}
    " # Update stats display self.memory_stats_text.setHtml(stats_html) def add_thought(self, thought): """Bridge method to forward thoughts to the decisions tab""" if hasattr(self, 'decisions_tab') and self.decisions_tab: # If we have a decisions tab, forward to its thought log if hasattr(self.decisions_tab, 'thought_log_text'): self.decisions_tab.thought_log_text.append(thought) # Auto-scroll to bottom scrollbar = self.decisions_tab.thought_log_text.verticalScrollBar() scrollbar.setValue(scrollbar.maximum()) elif hasattr(self.decisions_tab, 'add_thought'): # Alternative: if the tab has its own add_thought method self.decisions_tab.add_thought(thought) else: # Fallback: print to console if no UI element available print(f"Thought: {thought}") def clear_thoughts(self): self.thoughts_text.clear() def init_decisions_tab(self): font = QtGui.QFont() font.setPointSize(self.base_font_size) # Add a label for decision history decision_history_label = QtWidgets.QLabel("Decision History:") self.decisions_tab_layout.addWidget(decision_history_label) # Add a text area to display decision history self.decision_history_text = QtWidgets.QTextEdit() self.decision_history_text.setReadOnly(True) self.decisions_tab_layout.addWidget(self.decision_history_text) # Add a label for decision inputs decision_inputs_label = QtWidgets.QLabel("Decision Inputs:") self.decisions_tab_layout.addWidget(decision_inputs_label) # Add a text area to display decision inputs self.decision_inputs_text = QtWidgets.QTextEdit() self.decision_inputs_text.setReadOnly(True) self.decisions_tab_layout.addWidget(self.decision_inputs_text) def update_decisions_tab(self, decision, decision_inputs): # Append the decision to the decision history self.decision_history_text.append(f"Decision: {decision}") # Display the decision inputs self.decision_inputs_text.clear() for key, value in decision_inputs.items(): self.decision_inputs_text.append(f"{key}: {value}") def init_associations_tab(self): font = QtGui.QFont() font.setPointSize(self.base_font_size) # Add a checkbox to toggle explanation self.show_explanation_checkbox = QtWidgets.QCheckBox("Show Explanation") self.show_explanation_checkbox.stateChanged.connect(self.toggle_explanation) self.associations_tab_layout.addWidget(self.show_explanation_checkbox) # Add explanation text (hidden by default) self.explanation_text = QtWidgets.QTextEdit() self.explanation_text.setReadOnly(True) self.explanation_text.setHidden(True) self.explanation_text.setPlainText( "This tab shows the learned associations between different neural states of the squid. " "These associations are formed through the Hebbian learning process, where 'neurons that fire together, wire together'. " "The strength of an association is determined by how often these states occur together or influence each other. " "Positive associations mean that as one state increases, the other tends to increase as well. " "Negative associations (indicated by 'reduced') mean that as one state increases, the other tends to decrease. " "These associations help us understand how the squid's experiences shape its behavior and decision-making processes." ) self.associations_tab_layout.addWidget(self.explanation_text) # Add a label for the associations label = QtWidgets.QLabel("Learned associations:") self.associations_tab_layout.addWidget(label) # Add a text area to display associations self.associations_text = QtWidgets.QTextEdit() self.associations_text.setReadOnly(True) self.associations_tab_layout.addWidget(self.associations_text) # Add export button self.export_associations_button = QtWidgets.QPushButton("Export Associations") self.export_associations_button.clicked.connect(self.export_associations) self.associations_tab_layout.addWidget(self.export_associations_button, alignment=QtCore.Qt.AlignRight) def toggle_explanation(self, state): self.explanation_text.setVisible(state == QtCore.Qt.Checked) def update_associations(self): """Update association data if we have the learning tab""" if hasattr(self, 'learning_tab'): # Forward to the tab's update method current_time = time.time() if current_time - self.last_update_time > self.update_threshold: self.last_update_time = current_time self.learning_tab.update_from_brain_state(self.brain_widget.state) def generate_association_summary(self, neuron1, neuron2, weight): strength = "strongly" if abs(weight) > 0.8 else "moderately" if weight > 0: relation = "associated with" else: relation = "associated with reduced" # Correct grammar for specific neurons neuron1_text = self.get_neuron_display_name(neuron1) neuron2_text = self.get_neuron_display_name(neuron2) summaries = { "hunger-satisfaction": f"{neuron1_text} is {strength} associated with satisfaction (probably from eating)", "satisfaction-hunger": f"Feeling satisfied is {strength} associated with reduced hunger", "cleanliness-anxiety": f"{neuron1_text} is {strength} {relation} anxiety", "anxiety-cleanliness": f"Feeling anxious is {strength} associated with reduced cleanliness", "curiosity-happiness": f"{neuron1_text} is {strength} associated with happiness", "happiness-curiosity": f"Being happy is {strength} associated with increased curiosity", "hunger-anxiety": f"{neuron1_text} is {strength} associated with increased anxiety", "sleepiness-satisfaction": f"{neuron1_text} is {strength} {relation} satisfaction", "happiness-cleanliness": f"Being happy is {strength} associated with cleanliness", } key = f"{neuron1}-{neuron2}" if key in summaries: return summaries[key] else: return f"{neuron1_text} is {strength} {relation} {neuron2_text}" def get_neuron_display_name(self, neuron): display_names = { "cleanliness": "Being clean", "sleepiness": "Being sleepy", "happiness": "Being happy", "hunger": "Being hungry", "satisfaction": "Satisfaction", "anxiety": "Being anxious", "curiosity": "Curiosity", "direction": "Direction" } return display_names.get(neuron, f"{neuron}") def update_countdown(self): """Update the Hebbian learning countdown display""" # Check if simulation is paused is_paused = False if hasattr(self, 'tamagotchi_logic') and hasattr(self.tamagotchi_logic, 'simulation_speed'): is_paused = (self.tamagotchi_logic.simulation_speed == 0) # Calculate time until next learning cycle if not is_paused and hasattr(self.brain_widget, 'last_hebbian_time'): elapsed = time.time() - self.brain_widget.last_hebbian_time interval_sec = self.config.hebbian.get('learning_interval', 30000) / 1000 remaining = max(0, interval_sec - elapsed) self.brain_widget.hebbian_countdown_seconds = int(remaining) elif not hasattr(self.brain_widget, 'last_hebbian_time'): self.brain_widget.hebbian_countdown_seconds = 0 # Debug print statements #print(f"Elapsed time: {elapsed:.1f}s") #print(f"Interval: {interval_sec:.1f}s") #print(f"Remaining: {remaining:.1f}s") #print(f"Hebbian countdown seconds: {self.brain_widget.hebbian_countdown_seconds}") # If we have the neural network visualizer tab initialized, update its countdown if hasattr(self, 'nn_viz_tab') and hasattr(self.nn_viz_tab, 'countdown_label') and self.nn_viz_tab.countdown_label is not None: # Update the formatted display if is_paused: self.nn_viz_tab.countdown_label.setText("PAUSED") else: self.nn_viz_tab.countdown_label.setText(f"{self.brain_widget.hebbian_countdown_seconds} seconds") # If countdown reached zero and not paused, trigger learning # (moved outside nn_viz_tab check so learning always fires) if self.brain_widget.hebbian_countdown_seconds == 0 and not is_paused: # print("brain_tool.py >> Countdown hit zero, calling perform_hebbian_learning()") self.brain_widget.perform_hebbian_learning() def check_memory_decay(self): """Check for short-term memory decay and transfer important memories to long-term""" if not hasattr(self.tamagotchi_logic, 'squid') or not self.tamagotchi_logic.squid: return # Run periodic memory management on the squid's memory manager if hasattr(self.tamagotchi_logic.squid, 'memory_manager'): memory_manager = self.tamagotchi_logic.squid.memory_manager # Get all short-term memories to check for decay short_term_memories = memory_manager.get_all_short_term_memories() # Process memories that are about to decay (older than 100 seconds) current_time = datetime.now() for memory in short_term_memories: if 'timestamp' in memory: timestamp = memory['timestamp'] if isinstance(timestamp, str): timestamp = datetime.fromisoformat(timestamp) time_diff = current_time - timestamp # If memory is influential or important, make sure it's transferred to long-term is_influential = False # Check if it's an important or influential memory if 'importance' in memory and memory['importance'] >= 7: is_influential = True # Check if it's about to decay but is important if time_diff.total_seconds() > 100: # About to decay (120s is default) category = memory.get('category', '') key = memory.get('key', '') if is_influential: # Log the memory transfer self.activity_log.append(f"""
    Important Memory Transfer
    Category: {category}
    Key: {key}
    Age: {int(time_diff.total_seconds())} seconds
    Importance: {memory.get('importance', 'unknown')}
    """) # Transfer to long-term memory memory_manager.transfer_to_long_term_memory(category, key) def clear_learning_data(self): self.weight_changes_text.clear() self.learning_data_table.setRowCount(0) self.learning_data = [] print("Learning data cleared.") def update_learning_interval(self, seconds): """Update the learning interval when spinbox value changes""" # Convert seconds to milliseconds (QTimer uses ms) interval_ms = seconds * 1000 # Update config if hasattr(self.config, 'hebbian'): self.config.hebbian['learning_interval'] = interval_ms else: self.config.hebbian = {'learning_interval': interval_ms} # Restart timer with new interval if hasattr(self, 'hebbian_timer'): self.hebbian_timer.setInterval(interval_ms) self.last_hebbian_time = time.time() # Reset countdown if self.debug_mode: print(f"Learning interval updated to {seconds} seconds ({interval_ms} ms)") def deduce_weight_change_reason(self, pair, value1, value2, prev_weight, new_weight, weight_change): neuron1, neuron2 = pair threshold_high = 70 threshold_low = 30 reasons = [] # Analyze neuron activity levels if value1 > threshold_high and value2 > threshold_high: reasons.append(f"Both {neuron1.upper()} and {neuron2.upper()} were highly active") elif value1 < threshold_low and value2 < threshold_low: reasons.append(f"Both {neuron1.upper()} and {neuron2.upper()} had low activity") elif value1 > threshold_high: reasons.append(f"{neuron1.upper()} was highly active") elif value2 > threshold_high: reasons.append(f"{neuron2.upper()} was highly active") # Analyze weight change if abs(weight_change) > 0.1: if weight_change > 0: reasons.append("Strong positive reinforcement") else: reasons.append("Strong negative reinforcement") elif abs(weight_change) > 0.01: if weight_change > 0: reasons.append("Moderate positive reinforcement") else: reasons.append("Moderate negative reinforcement") else: reasons.append("Weak reinforcement") # Analyze the relationship between neurons if "hunger" in pair and "satisfaction" in pair: reasons.append("Potential hunger-satisfaction relationship") elif "cleanliness" in pair and "happiness" in pair: reasons.append("Potential cleanliness-happiness relationship") # Analyze the current weight if abs(new_weight) > 0.8: reasons.append("Strong connection formed") elif abs(new_weight) < 0.2: reasons.append("Weak connection") # Analyze learning progress if abs(prev_weight) < 0.1 and abs(new_weight) > 0.1: reasons.append("New significant connection emerging") elif abs(prev_weight) > 0.8 and abs(new_weight) < 0.8: reasons.append("Previously strong connection weakening") # Combine reasons if len(reasons) > 1: return " | ".join(reasons) elif len(reasons) == 1: return reasons[0] else: return "Complex interaction with no clear single reason" def get_neuron_value(self, value): if isinstance(value, (int, float)): return float(value) elif isinstance(value, bool): return 100.0 if value else 0.0 elif isinstance(value, str): # For string values (like 'direction'), return a default value return 75.0 else: return 0.0 def update_learning_data_table(self): self.learning_data_table.setRowCount(len(self.learning_data)) for row, data in enumerate(self.learning_data): for col, value in enumerate(data): item = QtWidgets.QTableWidgetItem(str(value)) if col == 3: # Weight change column item.setData(QtCore.Qt.DisplayRole, f"{value:.4f}") if col == 4: # Direction column if value == "increase ⬆️": item.setForeground(QtGui.QColor("green")) elif value == "⬇️ decrease": item.setForeground(QtGui.QColor("red")) self.learning_data_table.setItem(row, col, item) self.learning_data_table.scrollToBottom() def export_learning_data(self): # Save the weight changes text to a file with open("learningdata_reasons.txt", 'w') as file: file.write(self.weight_changes_text.toPlainText()) # Save the learning data table to a CSV file with open("learningdata_weights.csv", 'w', newline='') as file: writer = csv.writer(file) writer.writerow(["Timestamp", "Neuron 1", "Neuron 2", "Weight Change", "Direction"]) for row in range(self.learning_data_table.rowCount()): row_data = [] for col in range(self.learning_data_table.columnCount()): item = self.learning_data_table.item(row, col) row_data.append(item.text() if item else "") writer.writerow(row_data) QtWidgets.QMessageBox.information(self, "Export Successful", "Learning data exported to 'weight_changes.txt' and 'learning_data.csv'") def export_learning_tab_contents(self): file_name, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Export Learning Tab Contents", "", "Text Files (*.txt)") if file_name: with open(file_name, 'w') as file: file.write("Learning Data Table:\n") for row in range(self.learning_data_table.rowCount()): row_data = [] for col in range(self.learning_data_table.columnCount()): item = self.learning_data_table.item(row, col) row_data.append(item.text() if item else "") file.write("\t".join(row_data) + "\n") file.write("\nWeight Changes Text:\n") file.write(self.weight_changes_text.toPlainText()) QtWidgets.QMessageBox.information(self, "Export Successful", f"Learning tab contents exported to {file_name}") def export_associations(self): file_name, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Export Associations", "", "Text Files (*.txt)") if file_name: with open(file_name, 'w') as file: file.write(self.associations_text.toPlainText()) QtWidgets.QMessageBox.information(self, "Export Successful", f"Associations exported to {file_name}") def update_personality_effects(self, personality, weights, adjusted_weights): """Update the personality modifier display in the thinking tab""" # Convert enum to string if needed personality_str = getattr(personality, 'value', str(personality)) self.personality_label.setText(f"Personality: {personality_str.capitalize()}") # Generate effect text based on weight differences effects_text = [] for action, weight in weights.items(): adjusted = adjusted_weights.get(action, weight) if abs(adjusted - weight) > 0.01: # If there's a significant difference direction = "increases" if adjusted > weight else "decreases" effect = f"{action}: {direction} by {abs(adjusted - weight):.2f}" effects_text.append(effect) if effects_text: self.personality_effects.setPlainText("\n".join(effects_text)) else: self.personality_effects.setPlainText("No significant personality effects") def update_brain(self, state): """Main update method to distribute state changes to all tabs""" if not self.initialized: self.initialized = True return # Skip first update # Update the brain widget first self.brain_widget.update_state(state) # Forward updates to each tab that has an update method tabs_to_update = ['network_tab', 'nn_viz_tab', 'memory_tab', 'decisions_tab', 'personality_tab', 'about_tab'] # Adjusted list for tab_name in tabs_to_update: if hasattr(self, tab_name): tab = getattr(self, tab_name) if hasattr(tab, 'update_from_brain_state'): tab.update_from_brain_state(state) def train_hebbian(self): self.brain_widget.train_hebbian() #self.update_data_table(self.brain_widget.state) self.update_training_data_table() # Switch to the Console tab self.tabs.setCurrentWidget(self.console_tab) # Print training results to the console print("Hebbian training completed.") print("Updated association strengths:") for i, neuron1 in enumerate(self.brain_widget.neuron_positions.keys()): for j, neuron2 in enumerate(self.brain_widget.neuron_positions.keys()): if i < j: strength = self.brain_widget.get_association_strength(neuron1, neuron2) print(f"{neuron1} - {neuron2}: {strength:.2f}") def init_training_data_tab(self): self.show_overview_checkbox = QtWidgets.QCheckBox("Show Training Process Overview") self.show_overview_checkbox.stateChanged.connect(self.toggle_overview) self.training_data_tab_layout.addWidget(self.show_overview_checkbox) self.overview_label = QtWidgets.QLabel( "Training Process Overview:\n\n" "1. Data Capture: When 'Capture training data' is checked, the current state of all neurons is recorded each time the brain is stimulated.\n\n" "2. Hebbian Learning: The 'Train Hebbian' button applies the Hebbian learning rule to the captured data.\n\n" "3. Association Strength: The learning process strengthens connections between neurons that are frequently active together.\n\n" "4. Weight Updates: After training, the weights between neurons are updated based on their co-activation patterns.\n\n" "5. Adaptive Behavior: Over time, this process allows the brain to adapt its behavior based on input patterns." ) self.overview_label.setWordWrap(True) self.overview_label.hide() # Hide by default self.training_data_tab_layout.addWidget(self.overview_label) self.training_data_table = QtWidgets.QTableWidget() self.training_data_tab_layout.addWidget(self.training_data_table) self.training_data_table.setColumnCount(len(self.brain_widget.neuron_positions)) self.training_data_table.setHorizontalHeaderLabels(list(self.brain_widget.neuron_positions.keys())) self.training_data_timer = QtCore.QTimer() self.training_data_timer.timeout.connect(self.update_training_data_table) self.training_data_timer.start(1000) # Update every second self.checkbox_capture_training_data = QtWidgets.QCheckBox("Capture training data") self.checkbox_capture_training_data.stateChanged.connect(self.toggle_capture_training_data) self.training_data_tab_layout.addWidget(self.checkbox_capture_training_data) self.train_button = self.create_button("Train Hebbian", self.train_hebbian, "#ADD8E6") self.train_button.setEnabled(False) # Initially grey out the train button self.training_data_tab_layout.addWidget(self.train_button) def toggle_overview(self, state): if state == QtCore.Qt.Checked: self.overview_label.show() else: self.overview_label.hide() def toggle_capture_training_data(self, state): self.brain_widget.toggle_capture_training_data(state) if state == QtCore.Qt.Checked: os.makedirs('training_data', exist_ok=True) def update_training_data_table(self): self.training_data_table.setRowCount(len(self.brain_widget.training_data)) for row, sample in enumerate(self.brain_widget.training_data): for col, value in enumerate(sample): self.training_data_table.setItem(row, col, QtWidgets.QTableWidgetItem(str(value))) if len(self.brain_widget.training_data) > 0: self.train_button.setEnabled(True) # Save raw data to file if self.checkbox_capture_training_data.isChecked(): with open(os.path.join('training_data', 'raw_data.json'), 'w') as f: json.dump(self.brain_widget.training_data, f) def save_brain_state(self): file_name, _ = QtWidgets.QFileDialog.getSaveFileName(self, "Save Brain State", "", "JSON Files (*.json)") if file_name: with open(file_name, 'w') as f: json.dump(self.brain_widget.state, f) def load_brain_state(self): file_name, _ = QtWidgets.QFileDialog.getOpenFileName(self, "Load Brain State", "", "JSON Files (*.json)") if file_name: with open(file_name, 'r') as f: state = json.load(f) self.brain_widget.update_state(state) def export_brain_weights_text(self): """Return a human-readable block with every connection.""" lines = ["# Squid brain weights – saved {}\n".format( QtCore.QDateTime.currentDateTime().toString())] for (src, dst), w in sorted(self.brain_widget.weights.items(), key=lambda kv: abs(kv[1]), reverse=True): lines.append(f"{src:20} → {dst:20} {w:+.4f}") return "\n".join(lines) def export_hebbian_json(self): """Return a JSON-serialisable list with the *entire* learning log.""" # learning_data is already a list of lists: # [timestamp, n1, n2, Δw, direction] return { "saved_at": time.time(), "learning_interval_ms": self.config.hebbian.get('learning_interval', 30000), "history": getattr(self.brain_widget, 'learning_data', []) } def export_decision_engine_json(self): """Ask the DecisionEngine to serialise itself.""" if not hasattr(self.tamagotchi_logic, 'squid') or \ not hasattr(self.tamagotchi_logic.squid, 'decision_engine'): return {} engine = self.tamagotchi_logic.squid.decision_engine # Minimal example – extend as needed return { "epsilon": getattr(engine, 'epsilon', 0.1), "learning_rate": getattr(engine, 'learning_rate', 0.05), "q_table": getattr(engine, 'q_table', {}).copy() } def init_console(self): self.console_output = QtWidgets.QTextEdit() self.console_output.setReadOnly(True) self.console_tab_layout.addWidget(self.console_output) self.console = ConsoleOutput(self.console_output) def create_button(self, text, callback, color): button = QtWidgets.QPushButton(text) button.clicked.connect(callback) button.setStyleSheet(f"background-color: {color}; border: 1px solid black; padding: 5px;") button.setFixedSize(200, 50) return button def stimulate_brain(self): dialog = StimulateDialog(self) if dialog.exec_() == QtWidgets.QDialog.Accepted: stimulation_values = dialog.get_stimulation_values() if stimulation_values is not None: self.brain_widget.update_state(stimulation_values) if self.tamagotchi_logic: self.tamagotchi_logic.update_from_brain(stimulation_values) else: print("Warning: tamagotchi_logic is not set. Brain stimulation will not affect the squid.") def update_neural_visualization(self, inputs): """Update the neural network visualization with current input values""" if not hasattr(self, 'neuron_items') or not self.neuron_items: self.setup_neural_visualization() return # Update neuron colors based on activation values for neuron, value in inputs.items(): if neuron in self.neuron_items: # Only update numerical values if isinstance(value, (int, float)): # Update stored value self.neuron_items[neuron]["value"] = value # Calculate color based on value (0-100) intensity = int(value * 2.55) # Scale 0-100 to 0-255 if neuron in ["hunger", "sleepiness", "anxiety"]: # Red-based for "negative" neurons (more red = higher activation) color = QtGui.QColor(255, 255 - intensity, 255 - intensity) else: # Blue/green-based for "positive" neurons (more color = higher activation) color = QtGui.QColor(100, intensity, 255) # Update neuron ellipse color self.neuron_items[neuron]["shape"].setBrush(QtGui.QBrush(color)) # Make neurons pulse slightly based on value scale = 1.0 + (value / 200) # 1.0 to 1.5 rect = self.neuron_items[neuron]["shape"].rect() center_x = rect.x() + rect.width()/2 center_y = rect.y() + rect.height()/2 new_width = 40 * scale new_height = 40 * scale self.neuron_items[neuron]["shape"].setRect( center_x - new_width/2, center_y - new_height/2, new_width, new_height ) # Update connection line widths and colors based on neuron activations for connection, items in self.connection_items.items(): source, target = connection source_value = self.neuron_items.get(source, {}).get("value", 50) target_value = self.neuron_items.get(target, {}).get("value", 50) # Calculate connection strength based on both neuron activations # Higher when both neurons are highly activated connection_strength = (source_value * target_value) / 10000 # Scale to 0-1 # Update line width and color pen_width = 1 + 3 * connection_strength # Get current brain connection weight if available weight = items.get("weight", 0) # Color based on weight (green for positive, red for negative) if weight > 0: pen_color = QtGui.QColor(0, 150, 0, 50 + int(200 * connection_strength)) else: pen_color = QtGui.QColor(150, 0, 0, 50 + int(200 * connection_strength)) items["line"].setPen(QtGui.QPen(pen_color, pen_width)) # Update the weight display items["text"].setPlainText(f"{weight:.1f}") def update_brain_weights(self, weights_data): """Update the brain connection weights based on current neural network weights""" if not hasattr(self, 'connection_items'): return # Update connection weights for (src, dst), weight in weights_data.items(): # Look for the connection in either direction connection = (src, dst) if connection in self.connection_items: self.connection_items[connection]["weight"] = weight else: # Try the reverse connection connection = (dst, src) if connection in self.connection_items: self.connection_items[connection]["weight"] = weight def animate_decision_process(self, decision_data): """Animate the decision-making process with visual effects""" if not hasattr(self, 'processing_animation'): return # Get the decision information decision = decision_data.get('final_decision', 'unknown') processing_time = decision_data.get('processing_time', 1000) # Display processing text self.processing_text.setText(f"Processing decision ({processing_time}ms)...") # Start the animation with a brief delay to show processing QtCore.QTimer.singleShot(300, lambda: self.highlight_decision_in_ui(decision)) def highlight_decision_in_ui(self, decision): """Highlight the chosen decision in the UI""" # Update decision output with animation effect self.decision_output.setText(decision.capitalize()) # Flash the decision with a highlight animation original_style = self.decision_output.styleSheet() self.decision_output.setStyleSheet("font-size: 18px; font-weight: bold; color: white; background-color: green; padding: 5px; border-radius: 5px;") # Reset after brief highlight QtCore.QTimer.singleShot(500, lambda: self.decision_output.setStyleSheet(original_style)) # Update processing text self.processing_text.setText(f"Decision made: {decision.capitalize()}") def update_learning_status(self, is_active): """Update the learning status indicator""" if is_active: self.learning_status.setText("Learning Status: Active") self.learning_status.setStyleSheet(""" font-size: 14px; padding: 5px; background-color: #d4edda; border-radius: 4px; border: 1px solid #c3e6cb; color: #155724; font-weight: bold; """) else: self.learning_status.setText("Learning Status: Inactive") self.learning_status.setStyleSheet(""" font-size: 14px; padding: 5px; background-color: #f8f9fa; border-radius: 4px; border: 1px solid #e9ecef; color: #495057; """) def update_learning_interval(self, seconds): """Update the learning interval when spinbox value changes""" # Convert seconds to milliseconds (QTimer uses ms) interval_ms = seconds * 1000 # Update config if hasattr(self.config, 'hebbian'): self.config.hebbian['learning_interval'] = interval_ms else: self.config.hebbian = {'learning_interval': interval_ms} # Restart timer with new interval if hasattr(self, 'hebbian_timer'): self.hebbian_timer.setInterval(interval_ms) self.brain_widget.last_hebbian_time = time.time() # Reset countdown # Log the change self.activity_log.append(f"""
    Learning interval updated
    New interval: {seconds} seconds ({interval_ms} ms)
    """) # Force update of countdown self.update_countdown() def trigger_learning_cycle(self): """Force an immediate Hebbian learning cycle""" if hasattr(self.brain_widget, 'perform_hebbian_learning'): # Record the current state for before/after comparison old_weights = {k: v for k, v in self.brain_widget.weights.items()} # Perform the learning self.brain_widget.perform_hebbian_learning() # Find changed weights changes = [] for k, v in self.brain_widget.weights.items(): if k in old_weights and abs(v - old_weights[k]) > 0.001: changes.append((k, old_weights[k], v)) # Log the forced learning event log_html = f"""
    Manual learning cycle triggered
    Time: {time.strftime('%H:%M:%S')}
    Changes detected: {len(changes)} """ if changes: log_html += "
      " for (source, target), old_val, new_val in changes[:5]: # Show top 5 changes direction = "+" if new_val > old_val else "" log_html += f"""
    • {source} → {target}: {old_val:.3f} {new_val:.3f} ({direction}{new_val - old_val:.3f})
    • """ if len(changes) > 5: log_html += f"
    • ...and {len(changes) - 5} more changes
    • " log_html += "
    " else: log_html += "
    No significant weight changes detected" log_html += "
    " self.activity_log.append(log_html) # Update the connection table and heatmap self.update_connection_table() self.update_heatmap() self.update_learning_statistics() def update_connection_table(self): """Update the connection table with current weights""" self.connections_view.setRowCount(0) # Clear existing rows # Get all weights weights = self.brain_widget.weights if not weights: return # Get blacklisted neurons to exclude excluded_neurons = getattr(self.brain_widget, 'excluded_neurons', ['is_sick', 'is_eating', 'is_sleeping', 'pursuing_food', 'direction']) # Apply current filter filter_text = self.connection_search.text().lower() filter_type = self.connection_filter.currentText() # Add rows to table row = 0 for (source, target), weight in sorted(weights.items(), key=lambda x: abs(x[1]), reverse=True): # Skip connections involving blacklisted neurons if source in excluded_neurons or target in excluded_neurons: continue # Apply filters if filter_type == "Strong Positive" and weight <= 0.5: continue elif filter_type == "Strong Negative" and weight >= -0.5: continue elif filter_type == "Weak Connections" and abs(weight) > 0.3: continue elif filter_type == "New Connections": # Check if either neuron is new if (source not in self.brain_widget.neurogenesis_data.get('new_neurons', []) and target not in self.brain_widget.neurogenesis_data.get('new_neurons', [])): continue # Apply text search if filter_text and not (filter_text in source.lower() or filter_text in target.lower()): continue # Add the row self.connections_view.insertRow(row) # Source neuron source_item = QtWidgets.QTableWidgetItem(source) if source in self.brain_widget.neurogenesis_data.get('new_neurons', []): source_item.setBackground(QtGui.QColor(255, 255, 200)) # Light yellow for new neurons source_item.setFont(QtGui.QFont("Arial", 14)) # Bigger font size self.connections_view.setItem(row, 0, source_item) # Target neuron target_item = QtWidgets.QTableWidgetItem(target) if target in self.brain_widget.neurogenesis_data.get('new_neurons', []): target_item.setBackground(QtGui.QColor(255, 255, 200)) target_item.setFont(QtGui.QFont("Arial", 14)) # Bigger font size self.connections_view.setItem(row, 1, target_item) # Weight value weight_item = QtWidgets.QTableWidgetItem(f"{weight:.3f}") if weight > 0.5: weight_item.setForeground(QtGui.QColor(0, 150, 0)) # Green for strong positive elif weight > 0: weight_item.setForeground(QtGui.QColor(0, 100, 0)) # Dark green for mild positive elif weight > -0.5: weight_item.setForeground(QtGui.QColor(150, 0, 0)) # Dark red for mild negative else: weight_item.setForeground(QtGui.QColor(200, 0, 0)) # Bright red for strong negative weight_item.setFont(QtGui.QFont("Arial", 14)) # Bigger font size self.connections_view.setItem(row, 2, weight_item) # Trend indicator with emoji arrows trend_item = QtWidgets.QTableWidgetItem("—") if hasattr(self, '_prev_weights') and (source, target) in self._prev_weights: prev = self._prev_weights.get((source, target), 0) if weight > prev + 0.01: trend_item = QtWidgets.QTableWidgetItem("⬆️") # Up emoji arrow elif weight < prev - 0.01: trend_item = QtWidgets.QTableWidgetItem("⬇️") # Down emoji arrow trend_item.setFont(QtGui.QFont("Arial", 14)) # Bigger font size self.connections_view.setItem(row, 3, trend_item) row += 1 # Store current weights for future trend comparison if not hasattr(self, '_prev_weights'): self._prev_weights = {} self._prev_weights = weights.copy() def filter_connections(self): """Apply the current filters to the connection table""" self.update_connection_table() def show_connection_details(self): """Show details for the selected connection""" selected_items = self.connections_view.selectedItems() if not selected_items: self.connection_details.clear() return # Get the row (assumes single row selection) row = selected_items[0].row() # Get values from the row source = self.connections_view.item(row, 0).text() target = self.connections_view.item(row, 1).text() weight = float(self.connections_view.item(row, 2).text()) # Generate detailed HTML content details_html = f"""

    Connection Details

    Source Neuron: {source}
    Target Neuron: {target}
    Connection Weight: {weight:.4f}
    Connection Strength: """ # Add strength description if abs(weight) > 0.8: details_html += "Very Strong" elif abs(weight) > 0.5: details_html += "Strong" elif abs(weight) > 0.3: details_html += "Moderate" elif abs(weight) > 0.1: details_html += "Weak" else: details_html += "Very Weak" details_html += """
    Interpretation:
    """ # Add interpretation based on the connection if weight > 0: details_html += f"""

    This is a positive connection. When {source} is active, it will tend to increase the activity of {target}.

    """ else: details_html += f"""

    This is an inhibitory connection. When {source} is active, it will tend to decrease the activity of {target}.

    """ # Check if either neuron is from neurogenesis if source in self.brain_widget.neurogenesis_data.get('new_neurons', []) or target in self.brain_widget.neurogenesis_data.get('new_neurons', []): details_html += """
    Note: This connection involves a neuron created through neurogenesis!
    """ details_html += "
    " # Update the details widget self.connection_details.setHtml(details_html) def apply_neurogenesis_settings(self): """Apply changes to neurogenesis settings""" # Validate and update the neurogenesis configuration if not hasattr(self.config, 'neurogenesis'): self.config.neurogenesis = {} # Get current values from UI self.config.neurogenesis['novelty_threshold'] = self.novelty_threshold.value() self.config.neurogenesis['stress_threshold'] = self.stress_threshold.value() self.config.neurogenesis['reward_threshold'] = self.reward_threshold.value() self.config.neurogenesis['cooldown'] = self.cooldown_spinbox.value() # Log the changes self.activity_log.append(f"""
    Neurogenesis Settings Updated
    Time: {time.strftime('%H:%M:%S')}
    • Novelty Threshold: {self.config.neurogenesis['novelty_threshold']}
    • Stress Threshold: {self.config.neurogenesis['stress_threshold']}
    • Reward Threshold: {self.config.neurogenesis['reward_threshold']}
    • Cooldown Period: {self.config.neurogenesis['cooldown']} seconds
    """) # Update the status display self.update_neurogenesis_status() # Show confirmation message QtWidgets.QMessageBox.information( self, "Settings Applied", "Neurogenesis settings have been updated successfully." ) def trigger_neurogenesis(self): """Trigger neurogenesis by boosting natural trigger values""" try: if not hasattr(self, 'squid_brain_window') or not self.squid_brain_window: self.show_message("Brain window not initialized") print("Error: Brain window not initialized") return brain = self.squid_brain_window.brain_widget print("Brain widget found") # Get current neuron count to verify success old_neurons = set(brain.neuron_positions.keys()) print(f"Current neurons: {len(old_neurons)}") # Get thresholds to ensure we exceed them novelty_threshold = brain.neurogenesis_config.get('novelty_threshold', 3) stress_threshold = brain.neurogenesis_config.get('stress_threshold', 0.7) reward_threshold = brain.neurogenesis_config.get('reward_threshold', 0.6) print(f"Neurogenesis thresholds - Novelty: {novelty_threshold}, Stress: {stress_threshold}, Reward: {reward_threshold}") # Reset cooldown to allow neurogenesis to happen if hasattr(brain, 'neurogenesis_data'): # Store original for restoration original_time = brain.neurogenesis_data.get('last_neuron_time', 0) brain.neurogenesis_data['last_neuron_time'] = 0 print("Neurogenesis cooldown temporarily reset") # Create state with all triggers boosted significantly above thresholds state = { # Boost all three pathways to ensure at least one succeeds 'novelty_exposure': novelty_threshold * 2, # Double the threshold 'sustained_stress': stress_threshold * 2, 'recent_rewards': reward_threshold * 2, # Add current state values for context 'hunger': getattr(self.tamagotchi_logic.squid, 'hunger', 50), 'happiness': getattr(self.tamagotchi_logic.squid, 'happiness', 50), 'personality': getattr(self.tamagotchi_logic.squid, 'personality', None) } print(f"Submitting state with trigger values: {state}") # Update state which will trigger natural neurogenesis brain.update_state(state) # Verify if neurogenesis occurred new_neurons = set(brain.neuron_positions.keys()) - old_neurons if new_neurons: # Get details of the new neuron(s) for new_neuron in new_neurons: # Check if connections were created connections = [k for k in brain.weights.keys() if new_neuron in k] # Generate message with details message = f"Created neuron: {new_neuron} with {len(connections)} connections" self.show_message(message) print(message) print(f"Connections: {connections[:5]}...") # Highlight the new neuron if hasattr(brain, 'neurogenesis_highlight'): brain.neurogenesis_highlight = { 'neuron': new_neuron, 'start_time': time.time(), 'duration': 10.0 # 10 seconds highlight } brain.update() # Force redraw # Force an immediate hebbian learning cycle to integrate the neuron if hasattr(brain, 'perform_hebbian_learning'): print("Triggering hebbian learning cycle to integrate new neuron") brain.perform_hebbian_learning() else: self.show_message("No new neurons created - check console for details") print("WARNING: Neurogenesis was triggered but no new neurons were created") print(f"State submitted: {state}") print(f"Neurogenesis config: {brain.neurogenesis_config}") # Restore original cooldown time if hasattr(brain, 'neurogenesis_data') and 'original_time' in locals(): brain.neurogenesis_data['last_neuron_time'] = original_time print("Neurogenesis cooldown restored") except Exception as e: self.show_message(f"Neurogenesis Error: {str(e)}") import traceback traceback.print_exc() def update_heatmap(self): """Update the connection weight heatmap visualization""" if not hasattr(self, 'heatmap_scene') or not hasattr(self, 'brain_widget'): return self.heatmap_scene.clear() try: # Get neuron data from brain widget neurons = list(self.brain_widget.neuron_positions.keys()) excluded = getattr(self.brain_widget, 'excluded_neurons', []) weights = getattr(self.brain_widget, 'weights', {}) # Filter out excluded neurons neurons = [n for n in neurons if n not in excluded] if not neurons: self.heatmap_scene.addText("No neurons available", QtGui.QFont(), QtCore.QPointF(50, 50)) return # Heatmap parameters cell_size = 30 padding = 50 max_weight = max(abs(w) for w in weights.values()) if weights else 1.0 max_weight = max(max_weight, 0.01) # Prevent division by zero # Create heatmap grid for i, src in enumerate(neurons): for j, dst in enumerate(neurons): if src == dst: continue # Get weight value (check both direction permutations) weight = weights.get((src, dst), weights.get((dst, src), 0)) # Calculate color intensity intensity = min(abs(weight) / max_weight, 1.0) if weight > 0: color = QtGui.QColor(0, 0, int(255 * intensity)) # Blue for positive else: color = QtGui.QColor(int(255 * intensity), 0, 0) # Red for negative # Draw cell rect = QtCore.QRectF( padding + j * cell_size, padding + i * cell_size, cell_size - 1, # -1 for grid lines cell_size - 1 ) self.heatmap_scene.addRect(rect, QtGui.QPen(QtCore.Qt.black, 0.5), QtGui.QBrush(color)) # Add labels font = QtGui.QFont() font.setPointSize(8) for idx, neuron in enumerate(neurons): # Column labels (top) text = self.heatmap_scene.addText(neuron, font) text.setPos(padding + idx * cell_size + cell_size/2 - text.boundingRect().width()/2, padding - 25) # Row labels (left) text = self.heatmap_scene.addText(neuron, font) text.setPos(padding - text.boundingRect().width() - 5, padding + idx * cell_size + cell_size/2 - text.boundingRect().height()/2) # Add legend self._draw_heatmap_legend(padding, len(neurons) * cell_size + padding + 20) except Exception as e: print(f"Heatmap error: {str(e)}") error_text = self.heatmap_scene.addText("Heatmap unavailable") error_text.setPos(50, 50) def _draw_heatmap_legend(self, x, y): """Add color legend to heatmap""" legend_width = 200 gradient = QtGui.QLinearGradient(0, 0, legend_width, 0) gradient.setColorAt(0, QtGui.QColor(255, 0, 0)) # Red gradient.setColorAt(0.5, QtGui.QColor(0, 0, 0)) # Black gradient.setColorAt(1, QtGui.QColor(0, 0, 255)) # Blue legend = QtWidgets.QGraphicsRectItem(x, y, legend_width, 20) legend.setBrush(QtGui.QBrush(gradient)) self.heatmap_scene.addItem(legend) # Add labels - create text items first, then set their positions text_min = self.heatmap_scene.addText("-1.0") text_min.setPos(x, y + 20) text_zero = self.heatmap_scene.addText("0") text_zero.setPos(x + legend_width//2 - 10, y + 20) text_max = self.heatmap_scene.addText("+1.0") text_max.setPos(x + legend_width - 30, y + 20) def get_center_position(self): """Calculate center position for new debug neurons""" x = sum(p[0] for p in self.neuron_positions.values()) // len(self.neuron_positions) y = sum(p[1] for p in self.neuron_positions.values()) // len(self.neuron_positions) return (x + random.randint(-50, 50), y + random.randint(-50, 50)) def update_paused_overlay(self): """Update the paused state""" # Maintain pause state but don't show visual overlay if hasattr(self, 'paused_overlay_label'): self.paused_overlay_label.setVisible(False) # Always keep it invisible self.paused_overlay_label.deleteLater() delattr(self, 'paused_overlay_label') def update_learning_statistics(self): """Update the statistics tab with comprehensive learning metrics""" # Get blacklisted neurons excluded_neurons = getattr(self.brain_widget, 'excluded_neurons', ['is_sick', 'is_eating', 'is_sleeping', 'pursuing_food', 'direction']) # Clear the stats layout while self.stats_box_layout.count(): item = self.stats_box_layout.takeAt(0) if item.widget(): item.widget().deleteLater() # Add styled stats def add_stat_box(title, content, bg_color="#f8f9fa", icon=None): box = QtWidgets.QGroupBox() box.setStyleSheet(f""" QGroupBox {{ background-color: {bg_color}; border-radius: 8px; border: 1px solid #dee2e6; margin-top: 15px; padding: 10px; }} QGroupBox::title {{ subcontrol-origin: margin; left: 10px; padding: 0 5px; color: #495057; }} """) box_layout = QtWidgets.QVBoxLayout(box) # Add title with icon if provided title_layout = QtWidgets.QHBoxLayout() if icon: icon_label = QtWidgets.QLabel() icon_label.setPixmap(QtGui.QPixmap(icon).scaled(24, 24, QtCore.Qt.KeepAspectRatio)) title_layout.addWidget(icon_label) title_label = QtWidgets.QLabel(title) title_label.setStyleSheet("font-weight: bold; font-size: 14px; color: #212529;") title_layout.addWidget(title_label) title_layout.addStretch() box_layout.addLayout(title_layout) # Add content content_widget = QtWidgets.QLabel(content) content_widget.setTextFormat(QtCore.Qt.RichText) content_widget.setWordWrap(True) content_widget.setStyleSheet("font-size: 13px; color: #343a40; margin: 5px;") box_layout.addWidget(content_widget) self.stats_box_layout.addWidget(box) # 1. Connection Statistics # Filter weights to exclude connections involving blacklisted neurons filtered_weights = {(src, dst): weight for (src, dst), weight in self.brain_widget.weights.items() if src not in excluded_neurons and dst not in excluded_neurons} positive_weights = sum(1 for w in filtered_weights.values() if w > 0) negative_weights = sum(1 for w in filtered_weights.values() if w < 0) avg_weight = sum(abs(w) for w in filtered_weights.values()) / max(1, len(filtered_weights)) connection_stats = f"""
    Total Connections: {len(filtered_weights)}
    Positive Connections: {positive_weights} ({positive_weights/max(1,len(filtered_weights))*100:.1f}%)
    Negative Connections: {negative_weights} ({negative_weights/max(1,len(filtered_weights))*100:.1f}%)
    Average Weight Strength: {avg_weight:.3f}
    """ add_stat_box("Connection Statistics", connection_stats, "#e3f2fd") # 2. Neuron Statistics all_neurons = self.brain_widget.neuron_positions.keys() neurons = [n for n in all_neurons if n not in excluded_neurons] original_neurons = [n for n in neurons if n in getattr(self.brain_widget, 'original_neuron_positions', {})] new_neurons = [n for n in neurons if n in self.brain_widget.neurogenesis_data.get('new_neurons', [])] neuron_stats = f"""
    Learning-Eligible Neurons: {len(neurons)}
    Original Core Neurons: {len(original_neurons)}
    Neurons from Neurogenesis: {len(new_neurons)}
    Excluded System Neurons: {len(excluded_neurons)}
    """ add_stat_box("Neuron Statistics", neuron_stats, "#e8f5e9") # 3. Learning Parameters if hasattr(self.config, 'hebbian'): learning_rate = self.config.hebbian.get('base_learning_rate', 0.1) threshold = self.config.hebbian.get('threshold', 0.7) decay = self.config.hebbian.get('weight_decay', 0.01) learning_params = f"""
    Learning Rate: {learning_rate}
    Activation Threshold: {threshold}
    Weight Decay: {decay}
    Learning Interval: {self.config.hebbian.get('learning_interval', 30000)/1000} seconds
    """ add_stat_box("Learning Parameters", learning_params, "#fff3e0") # 4. Strong Influence Neurons # Find neurons with strongest outgoing connections neuron_influence = {} for neuron in neurons: outgoing_sum = 0 outgoing_count = 0 for (src, dst), weight in filtered_weights.items(): if src == neuron: outgoing_sum += abs(weight) outgoing_count += 1 if outgoing_count > 0: neuron_influence[neuron] = outgoing_sum / outgoing_count top_influence = sorted(neuron_influence.items(), key=lambda x: x[1], reverse=True)[:5] influence_stats = "" for neuron, influence in top_influence: influence_stats += f""" """ influence_stats += "
    {neuron} {influence:.3f}
    " add_stat_box("Top Influential Neurons", influence_stats, "#f3e5f5") # 5. Recently Created Neurons if self.brain_widget.neurogenesis_data.get('new_neurons'): new_neurons = [n for n in self.brain_widget.neurogenesis_data.get('new_neurons', []) if n not in excluded_neurons] last_time = self.brain_widget.neurogenesis_data.get('last_neuron_time', 0) time_ago = time.time() - last_time neurogenesis_stats = f"""

    Most recent neuron created {int(time_ago/60)} minutes ago.

    Recent neurons (newest first):

      """ for neuron in reversed(new_neurons[-5:]): neurogenesis_stats += f"
    • {neuron}
    • " neurogenesis_stats += "
    " add_stat_box("Neurogenesis", neurogenesis_stats, "#ffebee") # Add a stretch to push all boxes to the top self.stats_box_layout.addStretch() def zoom_heatmap(self, value): """Zoom the heatmap view based on slider value""" scale = value / 100.0 # Get current transform transform = QtGui.QTransform() transform.scale(scale, scale) # Apply new transform self.heatmap_view.setTransform(transform) def export_learning_data(self): """Export learning data with all available information""" file_name, _ = QtWidgets.QFileDialog.getSaveFileName( self, "Export Learning Data", "", "HTML Files (*.html);;CSV Files (*.csv);;Text Files (*.txt)") if not file_name: return try: if file_name.endswith('.html'): self.export_learning_data_html(file_name) elif file_name.endswith('.csv'): self.export_learning_data_csv(file_name) else: self.export_learning_data_text(file_name) # Show success message QtWidgets.QMessageBox.information( self, "Export Successful", f"Learning data exported to {file_name}") except Exception as e: QtWidgets.QMessageBox.critical( self, "Export Error", f"Error exporting data: {str(e)}") def export_learning_data_html(self, file_name): """Export learning data as rich HTML report""" with open(file_name, 'w') as f: # Start HTML document f.write(""" Squid Brain Learning Data

    Squid Brain Learning Data

    Export time: """ + time.strftime("%Y-%m-%d %H:%M:%S") + """

    """) # Learning parameters f.write("""
    Learning Parameters
    """) if hasattr(self.config, 'hebbian'): for param, value in self.config.hebbian.items(): if param == 'learning_interval': value = f"{value/1000} seconds" f.write(f"") f.write("""
    Parameter Value
    {param}{value}
    """) # Neuron information neurons = sorted(self.brain_widget.neuron_positions.keys()) f.write("""
    Neurons

    Total neurons: """ + str(len(neurons)) + """

    """) for neuron in neurons: neuron_type = "Original" if neuron in getattr(self.brain_widget, 'original_neuron_positions', {}) else "New" value = self.brain_widget.state.get(neuron, 0) position = self.brain_widget.neuron_positions.get(neuron, (0, 0)) f.write(f""" """) f.write("""
    Neuron Position Type Current Value
    {neuron} ({position[0]:.1f}, {position[1]:.1f}) {neuron_type} {value:.1f}
    """) # Connection weights f.write("""
    Connection Weights

    Total connections: """ + str(len(self.brain_widget.weights)) + """

    """) for (source, target), weight in sorted(self.brain_widget.weights.items(), key=lambda x: abs(x[1]), reverse=True): weight_class = "positive" if weight > 0 else "negative" f.write(f""" """) f.write("""
    Source Target Weight
    {source} {target} {weight:.3f}
    """) # Simple text-based heatmap f.write("""
    Weight Heatmap (Text Representation)

    This is a simplified text representation of the weight matrix.

    """) # Column headers for neuron in neurons: f.write(f"") f.write("") # Rows with data for src in neurons: f.write(f"") for dst in neurons: if src == dst: f.write("") else: weight = self.brain_widget.weights.get((src, dst), self.brain_widget.weights.get((dst, src), 0)) # Style based on weight if weight > 0: intensity = min(255, int(weight * 255)) bg_color = f"rgba(0, {intensity}, 0, 0.2)" text_color = "green" else: intensity = min(255, int(abs(weight) * 255)) bg_color = f"rgba({intensity}, 0, 0, 0.2)" text_color = "red" f.write(f"") f.write("") f.write("""
    Source / Target{neuron}
    {src}{weight:.2f}
    """) # End HTML document f.write(""" """) def export_learning_data_csv(self, file_name): """Export learning data as CSV""" with open(file_name, 'w', newline='') as f: writer = csv.writer(f) # Write neurons section writer.writerow(["NEURONS"]) writer.writerow(["Neuron", "Position X", "Position Y", "Type", "Current Value"]) for neuron in sorted(self.brain_widget.neuron_positions.keys()): neuron_type = "Original" if neuron in getattr(self.brain_widget, 'original_neuron_positions', {}) else "New" value = self.brain_widget.state.get(neuron, 0) position = self.brain_widget.neuron_positions.get(neuron, (0, 0)) writer.writerow([neuron, position[0], position[1], neuron_type, value]) # Blank row writer.writerow([]) # Write connections section writer.writerow(["CONNECTIONS"]) writer.writerow(["Source", "Target", "Weight"]) for (source, target), weight in sorted(self.brain_widget.weights.items(), key=lambda x: abs(x[1]), reverse=True): writer.writerow([source, target, weight]) # Blank row writer.writerow([]) # Write learning parameters writer.writerow(["LEARNING PARAMETERS"]) if hasattr(self.config, 'hebbian'): for param, value in self.config.hebbian.items(): writer.writerow([param, value]) def export_learning_data_text(self, file_name): """Export learning data as plain text""" with open(file_name, 'w') as f: f.write("SQUID BRAIN LEARNING DATA\n") f.write("=========================\n") f.write(f"Export time: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n") # Learning parameters f.write("LEARNING PARAMETERS\n") f.write("-----------------\n") if hasattr(self.config, 'hebbian'): for param, value in self.config.hebbian.items(): if param == 'learning_interval': value = f"{value/1000} seconds" f.write(f"{param}: {value}\n") f.write("\n") # Neuron information neurons = sorted(self.brain_widget.neuron_positions.keys()) f.write(f"NEURONS ({len(neurons)} total)\n") f.write("-----------------\n") for neuron in neurons: neuron_type = "Original" if neuron in getattr(self.brain_widget, 'original_neuron_positions', {}) else "New" value = self.brain_widget.state.get(neuron, 0) position = self.brain_widget.neuron_positions.get(neuron, (0, 0)) f.write(f"{neuron}: Position ({position[0]:.1f}, {position[1]:.1f}), Type: {neuron_type}, Value: {value:.1f}\n") f.write("\n") # Connection weights f.write(f"CONNECTION WEIGHTS ({len(self.brain_widget.weights)} total)\n") f.write("-----------------\n") for (source, target), weight in sorted(self.brain_widget.weights.items(), key=lambda x: abs(x[1]), reverse=True): f.write(f"{source} → {target}: {weight:.3f}\n") def clear_learning_log(self): """Clear the activity log""" reply = QtWidgets.QMessageBox.question( self, "Clear Log", "Are you sure you want to clear the learning activity log?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No ) if reply == QtWidgets.QMessageBox.Yes: self.activity_log.clear() # brain_tool.py # ... (other imports at the top of brain_tool.py, ensure these are present) # from PyQt5 import QtCore, QtGui, QtWidgets # from PyQt5.QtWidgets import QSplitter, QTabWidget, QTableWidget, QTableWidgetItem, QFormLayout, QGroupBox # Explicitly ensure these # import time # For time.time() in neurogenesis details # from datetime import datetime # For formatting timestamps # ... (SquidBrainWindow class and other preceding code) ... class NeuronInspector(QtWidgets.QDialog): def __init__(self, brain_tool_window, brain_widget_ref, parent=None): # brain_tool_window is the SquidBrainWindow instance super().__init__(brain_tool_window) # Set parent to brain_tool_window self.brain_tool_window = brain_tool_window self.brain_widget = brain_widget_ref self.setWindowTitle("Neuron Inspector") # Portrait orientation, larger size self.setFixedSize(450, 700) # Width, Height self.main_layout = QtWidgets.QVBoxLayout() self.setLayout(self.main_layout) # Neuron selector self.neuron_combo = QtWidgets.QComboBox() self.neuron_combo.setToolTip("Select a neuron to inspect or click one in the visualizer.") self.main_layout.addWidget(self.neuron_combo) # Tab widget self.tabs = QtWidgets.QTabWidget() # self.main_layout.addWidget(self.tabs) # --- Tab 1: Overview --- self.overview_tab = QtWidgets.QWidget() self.overview_layout = QtWidgets.QFormLayout(self.overview_tab) self.overview_tab.setLayout(self.overview_layout) self.tabs.addTab(self.overview_tab, "Overview") self.name_label = QtWidgets.QLabel() self.value_label = QtWidgets.QLabel() self.position_label = QtWidgets.QLabel() self.type_label = QtWidgets.QLabel() # Core or Neurogenesis self.overview_layout.addRow("Name:", self.name_label) self.overview_layout.addRow("Current Value:", self.value_label) self.overview_layout.addRow("Position (X,Y):", self.position_label) self.overview_layout.addRow("Type:", self.type_label) # Placeholder for neurogenesis info self.neurogenesis_group = QtWidgets.QGroupBox("Neurogenesis Details") self.neurogenesis_layout = QtWidgets.QFormLayout() self.neurogenesis_group.setLayout(self.neurogenesis_layout) self.neurogenesis_group.setVisible(False) # Hidden by default self.created_at_label = QtWidgets.QLabel() self.trigger_type_label = QtWidgets.QLabel() self.trigger_value_label = QtWidgets.QLabel() self.associated_state_label = QtWidgets.QLabel() self.associated_state_label.setWordWrap(True) self.neurogenesis_layout.addRow("Created At:", self.created_at_label) self.neurogenesis_layout.addRow("Trigger Type:", self.trigger_type_label) self.neurogenesis_layout.addRow("Trigger Value:", self.trigger_value_label) self.neurogenesis_layout.addRow("Associated State:", self.associated_state_label) self.overview_layout.addWidget(self.neurogenesis_group) # --- Tab 2: Connections --- self.connections_tab = QtWidgets.QWidget() self.connections_layout = QtWidgets.QVBoxLayout(self.connections_tab) self.tabs.addTab(self.connections_tab, "Connections") self.connections_table = QtWidgets.QTableWidget() self.connections_table.setColumnCount(3) self.connections_table.setHorizontalHeaderLabels(["Connected To", "Weight", "Direction"]) self.connections_table.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch) self.connections_table.setEditTriggers(QtWidgets.QAbstractItemView.NoEditTriggers) self.connections_table.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) self.connections_layout.addWidget(self.connections_table) # --- Tab 3: Activity (Placeholder) --- self.activity_tab = QtWidgets.QWidget() self.activity_layout = QtWidgets.QVBoxLayout(self.activity_tab) self.activity_info_label = QtWidgets.QLabel("Detailed activity logging and graphing coming soon.") self.activity_info_label.setAlignment(QtCore.Qt.AlignCenter) self.activity_layout.addWidget(self.activity_info_label) self.tabs.addTab(self.activity_tab, "Activity") # --- Refresh Button (optional, as it updates on click) --- self.refresh_btn = QtWidgets.QPushButton("Refresh Data") self.refresh_btn.clicked.connect(self.update_info) self.main_layout.addWidget(self.refresh_btn) # Connect to brain widget's neuronClicked signal if hasattr(self.brain_widget, 'neuronClicked'): self.brain_widget.neuronClicked.connect(self.inspect_neuron_by_name) # self.neuron_combo.currentIndexChanged.connect(self.update_info_from_combo) self.update_neuron_list() if self.neuron_combo.count() > 0: self.update_info() # Initial update def update_neuron_list(self): if not self.brain_widget: return current_selection = self.neuron_combo.currentText() self.neuron_combo.clear() # Sort neuron names for consistent order neuron_names = sorted(self.brain_widget.neuron_positions.keys()) self.neuron_combo.addItems(neuron_names) if current_selection in neuron_names: self.neuron_combo.setCurrentText(current_selection) elif neuron_names: self.neuron_combo.setCurrentIndex(0) def inspect_neuron_by_name(self, neuron_name): """Slot to handle neuronClicked signal from BrainWidget.""" index = self.neuron_combo.findText(neuron_name) if index >= 0: # Block signals temporarily to prevent double update if setCurrentIndex triggers update_info self.neuron_combo.blockSignals(True) self.neuron_combo.setCurrentIndex(index) self.neuron_combo.blockSignals(False) self.update_info() # Explicitly update after setting index self.show() self.raise_() self.activateWindow() def update_info_from_combo(self): # This is called when combobox selection changes self.update_info() def update_info(self): """Update all display elements for the currently selected neuron.""" if not self.brain_widget: self.name_label.setText("") # Clear other fields self.value_label.setText("") self.position_label.setText("") self.type_label.setText("") self.neurogenesis_group.setVisible(False) if hasattr(self, 'connections_table'): self.connections_table.setRowCount(0) return neuron_name = self.neuron_combo.currentText() if not neuron_name or neuron_name not in self.brain_widget.neuron_positions: # Clear all fields if no valid neuron is selected self.name_label.setText("") self.value_label.setText("") self.position_label.setText("") self.type_label.setText("") self.neurogenesis_group.setVisible(False) if hasattr(self, 'connections_table'): self.connections_table.setRowCount(0) return # --- Overview Tab Data --- self.name_label.setText(f"{neuron_name}") value = self.brain_widget.state.get(neuron_name, "N/A") self.value_label.setText(str(round(value, 2) if isinstance(value, (float, int)) else value)) pos = self.brain_widget.neuron_positions.get(neuron_name, ("N/A", "N/A")) self.position_label.setText(f"({pos[0]:.1f}, {pos[1]:.1f})" if isinstance(pos, tuple) and len(pos) == 2 and all(isinstance(p, (int,float)) for p in pos) else "N/A") # Check if new_neurons_details exists and then if neuron_name is in it is_neurogenesis = False if hasattr(self.brain_widget, 'neurogenesis_data') and \ 'new_neurons_details' in self.brain_widget.neurogenesis_data and \ neuron_name in self.brain_widget.neurogenesis_data.get('new_neurons_details', {}): is_neurogenesis = True neuron_kind = "Neurogenesis" if is_neurogenesis else "Core" if neuron_name in self.brain_widget.excluded_neurons: neuron_kind = "System Status" self.type_label.setText(neuron_kind) if is_neurogenesis: details = self.brain_widget.neurogenesis_data['new_neurons_details'].get(neuron_name, {}) created_timestamp = details.get('created_at') if created_timestamp: # Ensure datetime is imported if not already: from datetime import datetime from datetime import datetime # Local import for safety self.created_at_label.setText(datetime.fromtimestamp(created_timestamp).strftime('%Y-%m-%d %H:%M:%S')) else: self.created_at_label.setText("Unknown") self.trigger_type_label.setText(str(details.get('trigger_type', "N/A")).capitalize()) trigger_val = details.get('trigger_value_at_creation', "N/A") self.trigger_value_label.setText(f"{trigger_val:.2f}" if isinstance(trigger_val, float) else str(trigger_val)) snapshot = details.get('associated_state_snapshot', {}) snapshot_text = ", ".join([f"{k.capitalize()}: {v}" for k, v in snapshot.items() if v is not None]) self.associated_state_label.setText(snapshot_text if snapshot_text else "No specific state captured.") self.neurogenesis_group.setVisible(True) else: self.neurogenesis_group.setVisible(False) # --- Connections Tab Data --- self.connections_table.setRowCount(0) # Clear previous connections_data = [] # Ensure brain_widget.weights exists and is a dictionary if hasattr(self.brain_widget, 'weights') and isinstance(self.brain_widget.weights, dict): for conn_key, weight_val in self.brain_widget.weights.items(): # Ensure conn_key is a tuple of two strings (neuron names) if isinstance(conn_key, tuple) and len(conn_key) == 2: src, dst = conn_key if src == neuron_name: connections_data.append({'target': dst, 'weight': weight_val, 'direction': "Outgoing"}) elif dst == neuron_name: connections_data.append({'target': src, 'weight': weight_val, 'direction': "Incoming"}) # else: # print(f"Skipping malformed weight key: {conn_key}") self.connections_table.setRowCount(len(connections_data)) for row, conn_info in enumerate(connections_data): self.connections_table.setItem(row, 0, QtWidgets.QTableWidgetItem(conn_info['target'])) # item_weight = QtWidgets.QTableWidgetItem(f"{conn_info['weight']:.3f}") # item_weight.setForeground(QtGui.QColor("green") if conn_info['weight'] > 0 else QtGui.QColor("red")) self.connections_table.setItem(row, 1, item_weight) self.connections_table.setItem(row, 2, QtWidgets.QTableWidgetItem(conn_info['direction'])) # # Refresh neuron list only if necessary (e.g., if current neuron disappeared) # This check was simplified; if it causes issues, it might need refinement. # The primary update path is now through the combobox or direct neuron click. if self.neuron_combo.findText(neuron_name) == -1 and neuron_name in self.brain_widget.neuron_positions : self.update_neuron_list() ================================================ FILE: src/brain_tooltips.py ================================================ from PyQt5 import QtCore, QtGui, QtWidgets from .display_scaling import DisplayScaling from .localisation import loc import time class EnhancedBrainTooltips: """Scale-aware tooltips for every neuron.""" def __init__(self, brain_widget): self.brain_widget = brain_widget self.current_tooltip_neuron = None self.last_tooltip_time = 0 self.tooltip_delay = 0.3 # seconds # ------------------------------------------------------------------ # Safe helpers for optional FunctionalNeuronData attributes # ------------------------------------------------------------------ def _get_creation_time(self, func_neuron): if hasattr(func_neuron, 'creation_context'): return func_neuron.creation_context.timestamp return time.time() def _get_last_activated(self, func_neuron): return getattr(func_neuron, 'last_activated', 0) def _get_neuron_type(self, func_neuron): if hasattr(func_neuron, "neuron_type"): return func_neuron.neuron_type if hasattr(func_neuron, "specialization"): return func_neuron.specialization return loc("tooltip_functional", default="functional") # ------------------------------------------------------------------ # Public entry point — keeps old signature for compatibility # ------------------------------------------------------------------ def show_tooltip_for_position(self, event): """Legacy entry — we ignore event.pos() and use neuron centre.""" neuron_name = self.brain_widget.get_neuron_at_pos(event.pos()) if neuron_name: self.show_tooltip_for_neuron(neuron_name, event.pos()) else: QtWidgets.QToolTip.hideText() self.current_tooltip_neuron = None # ------------------------------------------------------------------ # New scale-aware tooltip # ------------------------------------------------------------------ def show_tooltip_for_neuron(self, neuron_name, _unused_pos): """Show tooltip exactly above the neuron, matching paintEvent coordinates.""" if not neuron_name: self.hide_tooltip() return # Binary neurons will show ON/OFF, continuous neurons show numeric values. # 1. Get neuron's logical position x_logic, y_logic = self.brain_widget.neuron_positions[neuron_name] # 2. Calculate layout scale EXACTLY as paintEvent/render worker does indicator_space = 0 base_width = 1024 base_height = 768 - indicator_space # Use dimensions from the widget widget_width = self.brain_widget.width() widget_height = self.brain_widget.height() scale_x = widget_width / base_width scale_y = (widget_height - indicator_space) / max(1, base_height) scale = max(0.01, min(scale_x, scale_y)) offset_x = 0 if scale_x > scale_y: content_width = base_width * scale offset_x = (widget_width - content_width) / 2 # 3. Transform logical coordinates to widget coordinates # Formula: Logical * Scale + Offset widget_x = int(x_logic * scale + offset_x) widget_y = int(y_logic * scale + indicator_space) # 4. Position tooltip 40px above neuron (visual offset - NOT scaled) tooltip_pos_widget = QtCore.QPoint(widget_x, widget_y - 40) # 5. Convert to global screen coordinates for QToolTip tooltip_pos_global = self.brain_widget.mapToGlobal(tooltip_pos_widget) # 6. Generate and show tooltip (scale only the HTML content) html = self._generate_tooltip(neuron_name) scaled_html = DisplayScaling.scale_css(html) QtWidgets.QToolTip.showText(tooltip_pos_global, scaled_html, self.brain_widget) self.current_tooltip_neuron = neuron_name def hide_tooltip(self): QtWidgets.QToolTip.hideText() self.current_tooltip_neuron = None def _generate_tooltip(self, neuron_name): bw = self.brain_widget is_functional = ( hasattr(bw, 'enhanced_neurogenesis') and neuron_name in bw.enhanced_neurogenesis.functional_neurons ) return ( self._generate_functional_tooltip(neuron_name) if is_functional else self._generate_basic_tooltip(neuron_name) ) def _generate_functional_tooltip(self, neuron_name): bw = self.brain_widget current_value = bw.state.get(neuron_name, 50) val_display = f"{current_value:.1f}" return f"""
    {val_display}
    """ def _is_binary_neuron(self, neuron_name): """ Check if a neuron is binary (outputs only 0 or 100). Priority order (first explicit value wins): 1. neuron_details from loaded brain file (most authoritative) 2. neurons dict with is_binary field 3. BINARY_NEURONS constant 4. Known binary sensors (hardcoded fallback) We do NOT use brain_widget.is_binary_neuron() as it may have incorrect logic. """ bw = self.brain_widget # Priority 1: Check neuron_details (from loaded brain JSON) - MOST AUTHORITATIVE if hasattr(bw, 'neuron_details') and neuron_name in bw.neuron_details: details = bw.neuron_details[neuron_name] if isinstance(details, dict) and 'is_binary' in details: return details['is_binary'] # Priority 2: Check neurons dict (alternative storage in some brain formats) if hasattr(bw, 'neurons') and neuron_name in bw.neurons: neuron_data = bw.neurons[neuron_name] if isinstance(neuron_data, dict) and 'is_binary' in neuron_data: return neuron_data['is_binary'] # Priority 3: Check config's neuron definitions if hasattr(bw, 'config') and bw.config: try: neurons_config = bw.config.get_neurogenesis_config().get('neurons', {}) if neuron_name in neurons_config: neuron_cfg = neurons_config[neuron_name] if 'is_binary' in neuron_cfg: return neuron_cfg['is_binary'] except: pass # Priority 4: Check BINARY_NEURONS constant try: from .brain_constants import BINARY_NEURONS if neuron_name in BINARY_NEURONS: return True except ImportError: pass # Priority 5: Known binary sensors (hardcoded fallback for core sensors) # NOTE: plant_proximity is NOT in this list - it's continuous! KNOWN_BINARY = { 'can_see_food', 'is_eating', 'is_sleeping', 'is_sick', 'pursuing_food', 'is_fleeing', 'is_startled' } if neuron_name in KNOWN_BINARY: return True # Default: assume continuous (not binary) return False def _generate_basic_tooltip(self, neuron_name): bw = self.brain_widget current_value = bw.state.get(neuron_name, 0) # Check if this is a binary neuron using comprehensive check is_binary = self._is_binary_neuron(neuron_name) if is_binary: # For binary neurons, show localized ON/OFF val_display = loc("state_on") if current_value > 50 else loc("state_off") else: # For continuous neurons (including plant_proximity), show numeric value val_display = f"{current_value:.1f}" # Minimal black rectangle with white text (matching weight overlay style) return f"""
    {val_display}
    """ # ------------------------------------------------------------------ # Helpers # ------------------------------------------------------------------ def _get_connection_summary(self, neuron_name): bw = self.brain_widget incoming = [(b, w) for (a, b), w in bw.weights.items() if a == neuron_name] outgoing = [(b, w) for (a, b), w in bw.weights.items() if b == neuron_name] top_in = sorted(incoming, key=lambda x: abs(x[1]), reverse=True)[:3] top_out = sorted(outgoing, key=lambda x: abs(x[1]), reverse=True)[:3] lbl_conn_header = loc("tooltip_connections_header") # Ensure we pass named arguments for formatting conn_stats = loc("tooltip_connections_stats", incoming=len(incoming), outgoing=len(outgoing)) lbl_top_in = loc("tooltip_top_incoming") lbl_top_out = loc("tooltip_top_outgoing") html = f"
    " html += f"
    " html += f"{lbl_conn_header}: {conn_stats}
    " if top_in: html += f"
    " html += f"⬅️ {lbl_top_in}:
    " for src, wt in top_in: arrow, color = ("→", "#4CAF50") if wt > 0 else ("⊣", "#f44336") html += f"{src} {arrow} {wt:.2f}
    " html += "
    " if top_out: html += f"
    " html += f"➡️ {lbl_top_out}:
    " for tgt, wt in top_out: arrow, color = ("→", "#4CAF50") if wt > 0 else ("⊣", "#f44336") html += f"{arrow} {tgt} {wt:.2f}
    " html += "
    " html += "
    " return html def _get_utility_color(self, utility): if utility > 0.7: return "#4CAF50" elif utility > 0.4: return "#FFC107" elif utility > 0.2: return "#FF9800" return "#F44336" def _get_activation_color(self, activation): deviation = abs(activation - 50) return "#F44336" if deviation > 30 else "#FF9800" if deviation > 15 else "#4CAF50" def _get_utility_indicator(self, utility): if utility > 0.7: return "⭐⭐⭐" elif utility > 0.4: return "⭐⭐" elif utility > 0.2: return "⭐" return "⚠️" ================================================ FILE: src/brain_ui_utils.py ================================================ from datetime import datetime from PyQt5 import QtCore, QtGui, QtWidgets class UiUtils: @staticmethod def create_styled_button(text, callback, color, size=(200, 50), font_size=10): """Create a button with consistent styling""" button = QtWidgets.QPushButton(text) button.clicked.connect(callback) button.setStyleSheet(f""" QPushButton {{ background-color: {color}; border: 1px solid black; padding: 5px; font-size: {font_size}px; border-radius: 5px; }} QPushButton:hover {{ background-color: {darken_color(color, 20)}; }} """) button.setFixedSize(size[0], size[1]) return button @staticmethod def format_memory_display(memory): """Format a memory dictionary for display with colored boxes based on valence""" if not UiUtils.is_displayable_memory(memory): return "" # Get the display text - prefer formatted_value, fall back to value display_text = memory.get('formatted_value', str(memory.get('value', ''))) # Skip if the display text contains just a timestamp if 'timestamp' in display_text.lower() and len(display_text.split()) < 3: return "" timestamp = memory.get('timestamp', '') if isinstance(timestamp, str): try: timestamp = datetime.fromisoformat(timestamp).strftime("%H:%M:%S") except: timestamp = "" # Determine valence and color if memory.get('category') == 'mental_state' and memory.get('key') == 'startled': interaction_type = "Negative" background_color = "#FFD1DC" # Pastel red elif isinstance(memory.get('raw_value'), dict): total_effect = sum(float(val) for val in memory['raw_value'].values() if isinstance(val, (int, float))) if total_effect > 0: interaction_type = "Positive" background_color = "#D1FFD1" # Pastel green elif total_effect < 0: interaction_type = "Negative" background_color = "#FFD1DC" # Pastel red else: interaction_type = "Neutral" background_color = "#FFFACD" # Pastel yellow else: interaction_type = "Neutral" background_color = "#FFFACD" # Pastel yellow # Create HTML formatted memory box formatted_memory = f"""
    {interaction_type}
    {display_text}
    {timestamp}
    """ return formatted_memory @staticmethod def _is_displayable_memory(self, memory): """Check if a memory should be displayed in the UI""" if not isinstance(memory, dict): return False # Skip timestamp-only memories (they have numeric keys) if isinstance(memory.get('key'), str) and memory['key'].isdigit(): return False # Skip memories that don't have a proper category or value if not memory.get('category') or not memory.get('value'): return False # Skip memories where the value is just a timestamp number if isinstance(memory.get('value'), (int, float)) and 'timestamp' in str(memory['value']).lower(): return False # Must have either formatted_value or a displayable string value if 'formatted_value' not in memory and not isinstance(memory.get('value'), str): return False return True @staticmethod def create_memory_card(memory): """Create a styled HTML memory card""" # Determine card style bg_color, border_color = UiUtils.get_memory_colors(memory) # Format card HTML card_html = f"""
    {memory.get('category', 'unknown').capitalize()}
    {memory.get('formatted_value', '')[:60]}
    {memory.get('timestamp', '').split(' ')[-1]}
    """ return card_html @staticmethod def get_memory_colors(memory): """Determine colors based on memory content""" if 'positive' in memory.get('tags', []): return "#E8F5E9", "#C8E6C9" # Green shades elif 'negative' in memory.get('tags', []): return "#FFEBEE", "#FFCDD2" # Red shades elif 'novelty' in memory.get('tags', []): return "#FFFDE7", "#FFF9C4" # Yellow shades return "#F5F5F5", "#EEEEEE" # Default gray @staticmethod def create_info_box(title, content, icon_path=None, bg_color="#f8f9fa"): """Create a styled information box with optional icon""" box = QtWidgets.QGroupBox(title) box.setStyleSheet(f""" QGroupBox {{ background-color: {bg_color}; border-radius: 8px; border: 1px solid #dee2e6; margin-top: 15px; padding: 10px; font-weight: bold; }} QGroupBox::title {{ subcontrol-origin: margin; left: 10px; padding: 0 5px; color: #495057; }} """) box_layout = QtWidgets.QVBoxLayout(box) # Add icon if provided if icon_path: icon_label = QtWidgets.QLabel() icon_label.setPixmap(QtGui.QPixmap(icon_path).scaled(24, 24, QtCore.Qt.KeepAspectRatio)) box_layout.addWidget(icon_label, alignment=QtCore.Qt.AlignRight) # Add content content_label = QtWidgets.QLabel(content) content_label.setTextFormat(QtCore.Qt.RichText) content_label.setWordWrap(True) content_label.setStyleSheet("font-weight: normal; color: #343a40;") box_layout.addWidget(content_label) return box # Utility functions def darken_color(color, amount=20): """Darken a hex color by the specified amount""" # Remove # if present color = color.lstrip('#') # Convert to RGB r, g, b = tuple(int(color[i:i+2], 16) for i in (0, 2, 4)) # Darken r = max(0, r - amount) g = max(0, g - amount) b = max(0, b - amount) # Convert back to hex return f"#{r:02x}{g:02x}{b:02x}" ================================================ FILE: src/brain_utils.py ================================================ from PyQt5 import QtCore, QtGui, QtWidgets class ConsoleOutput: def __init__(self, text_edit): self.text_edit = text_edit def write(self, text): cursor = self.text_edit.textCursor() format = QtGui.QTextCharFormat() if text.startswith("Previous value:"): format.setForeground(QtGui.QColor("red")) elif text.startswith("New value:"): format.setForeground(QtGui.QColor("green")) else: format.setForeground(QtGui.QColor("black")) cursor.insertText(text, format) self.text_edit.setTextCursor(cursor) self.text_edit.ensureCursorVisible() def flush(self): pass ================================================ FILE: src/brain_widget.py ================================================ import sys import csv import os import re import time import math import random import numpy as np import json # This is the biggest, messiest and most shameful of all the files in this project # I express my condolences to future devs that curse the mighty brain_widget on late nights as I do from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtWidgets import QSplitter from PyQt5.QtGui import QPixmap, QFont, QImage from datetime import datetime from .brain_render_worker import BrainRenderWorker, create_render_state_from_widget, RenderState from .brain_worker import BrainWorker from .compute_backend import get_backend from .neurogenesis import EnhancedNeurogenesis, ExperienceBuffer from .brain_tooltips import EnhancedBrainTooltips from .brain_constants import CORE_NEURONS, INPUT_SENSORS, is_core_neuron from .personality import Personality from .learning import LearningConfig from .laboratory import NeuronLaboratory from .localisation import Localisation from typing import Dict, Optional # Brain state bridge for designer communication try: from .brain_state_bridge import ( export_brain_state, set_game_running, update_brain_state_from_widget ) _HAS_BRAIN_BRIDGE = True except ImportError: try: # Try without relative import for standalone testing from brain_state_bridge import ( export_brain_state, set_game_running, update_brain_state_from_widget ) _HAS_BRAIN_BRIDGE = True except ImportError: _HAS_BRAIN_BRIDGE = False print("[BrainWidget] Warning: brain_state_bridge not found, designer sync disabled") from .animation_styles import ( AnimationStyle, VibrantStyle, SubtleStyle, get_animation_style, get_available_styles, get_style_info, ANIMATION_STYLES ) # Performance tracking for Task Manager try: from .task_manager import perf_tracker _PERF_TRACKING_AVAILABLE = True except ImportError: _PERF_TRACKING_AVAILABLE = False class BrainWidget(QtWidgets.QWidget): neuronClicked = QtCore.pyqtSignal(str) animationStyleChanged = QtCore.pyqtSignal(str) # Emitted when style changes neuronCreated = QtCore.pyqtSignal(str) # Emitted when neurogenesis creates a new neuron def __init__(self, config=None, debug_mode=False, tamagotchi_logic=None, animation_style: str = "vibrant"): self.resolution_scale = 1.0 # Default resolution scale self.config = config if config else LearningConfig() self._laboratory = None self._last_lang = Localisation.instance().current_language # ===== ANIMATION STYLE INITIALIZATION ===== self._animation_style_name = animation_style self._animation_style: AnimationStyle = get_animation_style(animation_style) self.layers = [] # State update batching self._pending_state_update = False self.state_update_queue = [] # Caches for neuron data self.neurons = {} self.state_colors = {} # Hover tracking self.hovered_neuron = None self.hovered_connection = None self.hover_value_display_active = False self.hover_value_opacity = 0.0 self.hover_value_animation_time = 0.0 self.hover_animation_time = 0 if not hasattr(self.config, 'hebbian'): # self.config.hebbian = { # 'learning_interval': 30000, # 'weight_decay': 0.01, # 'active_threshold': 50 # } super().__init__() # # Get neuron label font size from config (single source of truth) display_config = self.config.get_display_config() self.neuron_label_font_size = display_config['neuron_label_font_size'] # Tutorial glow effect properties self.tutorial_glow_active = False self.tutorial_glow_timer = None self.tutorial_glow_animation = None self._tutorial_glow_opacity = 0.0 self._link_fade_speeds = {} # (src,dst) → fade speed per link self._link_start_times = {} # (src,dst) → when this link should start from .neurogenesis_show import ShowmanNeurogenesis real_engine = EnhancedNeurogenesis(self, config) self.enhanced_neurogenesis = ShowmanNeurogenesis(real_engine) self.excluded_neurons = ['is_sick', 'is_eating', 'pursuing_food', 'direction', 'is_sleeping'] # Build animation palette from selected style self._build_animation_palette() self.hebbian_countdown_seconds = 30 # Default duration self.learning_active = True # self.pruning_enabled = True # self.debug_mode = debug_mode # Initialize debug_mode self.is_paused = False # self.weights = {} # Init weights early self.last_hebbian_time = time.time() # self.last_neurogenesis_type = None self.tamagotchi_logic = tamagotchi_logic # self.recently_updated_neuron_pairs = [] # self.neuron_shapes = {} # # Neural communication tracking system self.communication_events = {} # self.communication_highlight_duration = 0.5 # self.weight_change_events = {} # self.activity_duration = 0.5 # # ADDED IN 2.4.5.0: Replace simple neurogenesis_data with enhanced system self.enhanced_neurogenesis = EnhancedNeurogenesis(self, self.config) self.experience_buffer = self.enhanced_neurogenesis.experience_buffer # Ensure neurogenesis config exists if not hasattr(self.config, 'neurogenesis'): # self.config.neurogenesis = { 'decay_rate': 0.75, # Default decay rate if not specified 'novelty_threshold': 3.0, # 'stress_threshold': 1.2, # 'reward_threshold': 3.5, # 'cooldown': 180, # 'highlight_duration': 5.0, # 'max_neurons': 50 } self.neurogenesis_config = self.config.neurogenesis self.neurogenesis_data = { 'new_neurons': [], 'last_neuron_time': time.time(), 'new_neurons_details': {} # used by inspector / logging } # Neural state initialization self.state = { # "can_see_food": 0, "hunger": 50, # "happiness": 50, # "cleanliness": 50, # "sleepiness": 50, # "satisfaction": 50, # "anxiety": 50, # "curiosity": 50, # "is_sick": False, # "is_eating": False, # "is_sleeping": False, # "pursuing_food": False, # "direction": "up", # "position": (0, 0), # "is_startled": False, # "is_fleeing": False, # 'neurogenesis_active': True } # Neuron position configuration self.original_neuron_positions = { # "can_see_food": (50, 200), "hunger": (127, 81), # "happiness": (361, 81), # "cleanliness": (627, 81), # "sleepiness": (840, 81), # "satisfaction": (271, 380), # "anxiety": (491, 389), # "curiosity": (701, 386) # } self.neuron_positions = self.original_neuron_positions.copy() # Randomize positions if configured --- neuron_props = self.config.neurogenesis.get('neuron_properties', {}) if neuron_props.get('randomize_start_positions', False): self._randomize_all_positions() # Ensure connection to hunger exists (always) self.weights[("can_see_food", "hunger")] = 0.2 # Seeing food increases hunger slightly # Random chance (50%) for happiness connection if random.random() < 0.5: self.weights[("can_see_food", "happiness")] = 0.5 # Seeing food can increase happiness # Track which neurons are visible (for animated reveal on new game) self.visible_neurons = set() # List of core neurons in reveal order self.original_neurons = ["can_see_food", "hunger", "happiness", "cleanliness", "sleepiness", "satisfaction", "anxiety", "curiosity"] # Animation state for neuron reveals self.neuron_reveal_animations = {} # {neuron_name: {'start_time': float, 'progress': float}} # --- link fade animation --- self._link_opacities = {} # (src,dst) → current opacity 0.0-1.0 self._link_targets = {} # (src,dst) → target opacity 0 or 1 self._link_fade_timer = QtCore.QTimer(self) self._link_fade_timer.setInterval(16) # ~60 FPS self._link_fade_timer.timeout.connect(self._advance_link_fades) self._auto_fade_links_pending = False # Tutorial mode flag self.is_tutorial_mode = False # Track revealed neurons and connections for synced count display during animation # NOTE: These should ONLY be used when is_tutorial_mode is True self.revealed_neurons = set() self.revealed_connections = set() # Initialize communication events for all neurons for neuron in self.neuron_positions.keys(): # self.communication_events[neuron] = 0 # # Animation control variables self.animation_timer = QtCore.QTimer(self) self.animation_timer.timeout.connect(self.update_animations) # ===== PERFORMANCE FIX: Don't create BrainWorker here ===== # BrainWorker will be provided externally via set_brain_worker() # This prevents multiple worker threads competing for CPU self.brain_worker = None # Will be set by SquidBrainWindow print("🧵 BrainWorker will be set externally") # Threading control flags self._use_threaded_processing = True self._pending_neurogenesis_check = False self._pending_hebbian_learning = False # NEUROGENESIS FIX: Start monitoring timer self.neurogenesis_timer = QtCore.QTimer(self) self.neurogenesis_timer.timeout.connect(self._periodic_neurogenesis_check) self.neurogenesis_timer.start(2000) # Check every 2 seconds print("🧬 Neurogenesis monitoring timer started") # Brain state bridge: export state for designer synchronization if _HAS_BRAIN_BRIDGE: self._brain_export_timer = QtCore.QTimer(self) self._brain_export_timer.timeout.connect(self.export_brain_state_for_designer) self._brain_export_timer.start(5000) # Export every 5 seconds set_game_running(True) # Mark game as running print("🔗 Brain state export enabled for designer sync") self.animation_timer.start(40) # 2.5.0.0 performance fix self._last_animation_update = 0 # For throttling # ===== PERFORMANCE FIX: Cache for expensive paint objects ===== self._cached_fonts = {} self._cached_pens = {} self.neuron_sizes = {} # For smooth size transitions self.weight_animations = [] # Track multiple weight changes self.neurogenesis_highlight = { 'neuron': None, 'start_time': 0, 'duration': 5.0, 'pulse_phase': 0 } # Add neurogenesis visualization tracking self.neurogenesis_highlight = { # 'neuron': None, # 'start_time': 0, # 'duration': 5.0 # seconds } # Connection and weight initialization self.connections = self.initialize_connections() # self.initialize_weights() # Populate weights self.show_links = True # self.frozen_weights = None # self.history = [] # self.training_data = [] # self.learning_rate = 0.1 # # --- Compute backend (NumPy default, optional ONNX for NPU support) --- self.compute_backend = get_backend() self.associations = self.compute_backend.zeros( (len(self.neuron_positions), len(self.neuron_positions)) ) # self.capture_training_data_enabled = False # self.dragging = False # self.dragged_neuron = None # self.drag_start_pos = None # self.tooltip_manager = EnhancedBrainTooltips(self) self.setMouseTracking(True) self.show_weights = False # Ensure connections to hunger and happiness exist with initial weights self.weights[("can_see_food", "hunger")] = 0.2 # Seeing food increases hunger slightly self.weights[("can_see_food", "happiness")] = 0.5 _link_fade_speed = 3.0 # opacity units per second (tweak for faster/slower) # ===== OFFSCREEN RENDERING SETUP ===== # Initialize render worker for background rendering self._render_worker = BrainRenderWorker(self) self._render_worker.render_complete.connect(self._on_render_complete) self._render_worker.start() # Cached rendered image self._cached_render: Optional[QImage] = None self._render_dirty = True # Render throttling self._last_render_request = 0.0 self._render_interval = 1.0 / 10.0 # 10 FPS target # State change tracking for smart re-rendering self._last_state_hash = None # Timer for periodic render requests (catches animation updates) self._render_timer = QtCore.QTimer(self) self._render_timer.timeout.connect(self._request_render_if_dirty) self._render_timer.start(100) # 10 FPS def set_brain_worker(self, worker): """Accept an external BrainWorker instance.""" self.brain_worker = worker # Always set the new worker # Reconnect signals (disconnect old ones first to avoid duplicates) if hasattr(self, 'brain_worker'): try: self.brain_worker.neurogenesis_result.disconnect(self._on_neurogenesis_complete) self.brain_worker.hebbian_result.disconnect(self._on_hebbian_complete) self.brain_worker.state_update_result.disconnect(self._on_state_update_complete) self.brain_worker.error_occurred.disconnect(self._on_worker_error) except: pass # Ignore if signals weren't connected # Connect new signals self.brain_worker.neurogenesis_result.connect(self._on_neurogenesis_complete) self.brain_worker.hebbian_result.connect(self._on_hebbian_complete) self.brain_worker.state_update_result.connect(self._on_state_update_complete) self.brain_worker.error_occurred.connect(self._on_worker_error) print("🧵 BrainWidget received external BrainWorker") # ========================================================================= # BRAIN STATE BRIDGE METHODS (for designer synchronization) # ========================================================================= def showEvent(self, event): """Ensure render is requested when widget becomes visible.""" super().showEvent(event) self.mark_render_dirty() # Reset render throttle to force immediate update self._last_render_request = 0 self._request_render() def export_brain_state_for_designer(self): """ Export current brain state to shared file for designer import. Public method - can be called by NetworkTab before launching designer. """ if not _HAS_BRAIN_BRIDGE: return try: # Ensure the game lock exists set_game_running(True) # Export the actual data update_brain_state_from_widget(self) except Exception as e: # Silent failure - don't spam console pass def cleanup_brain_bridge(self): """ Clean up brain bridge files when widget is destroyed. Should be called when the game exits. """ if _HAS_BRAIN_BRIDGE: try: set_game_running(False) print("🔗 Brain state bridge cleaned up") except Exception: pass def closeEvent(self, event): """Handle widget close - clean up brain bridge.""" self.cleanup_brain_bridge() self._cleanup_render_worker() super().closeEvent(event) if hasattr(super(), 'closeEvent') else None event.accept() def set_debug_mode(self, enabled): """Set debug mode without causing circular callbacks""" # Only proceed if there's an actual change if self.debug_mode == enabled: return # Update our own state self.debug_mode = enabled # Update brain widget if hasattr(self, 'brain_widget'): self.brain_widget.debug_mode = enabled # Update tabs for tab_name in ['network_tab', 'nn_viz_tab', 'memory_tab', 'decisions_tab', 'about_tab']: if hasattr(self, tab_name): tab = getattr(self, tab_name) if hasattr(tab, 'debug_mode'): tab.debug_mode = enabled # Enable/disable debug-specific UI elements if hasattr(self, 'stimulate_button'): self.stimulate_button.setEnabled(enabled) print(f"Brain window debug mode set to: {enabled}") def _request_render_if_dirty(self): """Called by timer to request render if state has changed""" if self._render_dirty or self._has_active_animations(): self._request_render() def _has_active_animations(self) -> bool: """Check if there are active animations requiring re-render""" current_time = time.time() # Check communication events (connection animations) for neuron, event_time in self.communication_events.items(): if current_time - event_time < 0.5: # 500ms animation return True # Check link fade animations for key, opacity in getattr(self, '_link_opacities', {}).items(): target = getattr(self, '_link_targets', {}).get(key, opacity) if abs(opacity - target) > 0.01: return True return False def _request_render(self): """Request a new render from the worker thread""" current_time = time.time() # Throttle requests if current_time - self._last_render_request < self._render_interval: return self._last_render_request = current_time # Create state snapshot and send to worker try: state = create_render_state_from_widget(self) self._render_worker.request_render(state) self._render_dirty = False except Exception as e: print(f"Error creating render state: {e}") def _on_render_complete(self, image: QImage, render_time: float): """Called when render worker completes a frame""" self._cached_render = image # Trigger a lightweight repaint to show the new image self.update() def mark_render_dirty(self): """Call this when state changes that require re-render""" self._render_dirty = True def _cleanup_render_worker(self): """Clean up the render worker - call on widget destruction""" if hasattr(self, '_render_worker') and self._render_worker: self._render_worker.stop() self._render_worker.wait(1000) # Wait up to 1 second self._render_worker = None if hasattr(self, '_render_timer') and self._render_timer: self._render_timer.stop() # ========================================================================= # ANIMATION STYLE METHODS # ========================================================================= def _build_animation_palette(self): """ Load visual settings from the current animation style. Called during init and when style changes. """ style = self._animation_style # Connection line settings self.anim_line_base_width = style.line_base_width self.anim_line_col_pos = style.line_colour_positive self.anim_line_col_neg = style.line_colour_negative self.anim_line_alpha = style.line_alpha self.anim_use_thick_lines = style.use_thick_lines # [NEW] Weight-based thickness settings (MISSING IN PREVIOUS VERSION) self.weight_thickness_enabled = getattr(style, 'weight_thickness_enabled', False) self.weight_thickness_min = getattr(style, 'weight_thickness_min', 1.0) self.weight_thickness_max = getattr(style, 'weight_thickness_max', 2.0) self.weight_thickness_power = getattr(style, 'weight_thickness_power', 1.0) # Stress-anxiety connection self.anim_stress_width = style.stress_anxiety_width self.anim_stress_colour = style.stress_anxiety_colour self.anim_stress_dashed = style.stress_anxiety_dashed # Pulse/travelling dot settings self.anim_pulse_enabled = style.pulse_enabled self.anim_pulse_colour = style.pulse_colour self.anim_pulse_alpha = style.pulse_alpha self.anim_pulse_duration = style.pulse_duration self.anim_pulse_speed = style.pulse_speed self.anim_pulse_diameter = style.pulse_diameter # Glow effect settings self.anim_glow_enabled = style.glow_enabled self.anim_glow_colour = style.glow_colour self.anim_glow_alpha = style.glow_alpha self.anim_glow_fade_threshold = style.glow_fade_threshold # Hover effect settings self.anim_hover_enabled = style.hover_enabled self.anim_hover_scale = style.hover_scale self.anim_hover_duration = style.hover_animation_duration # Activity highlight settings self.anim_activity_enabled = style.activity_highlight_enabled self.anim_activity_colour = style.activity_highlight_colour self.anim_activity_alpha = style.activity_highlight_alpha self.anim_activity_pulse_speed = style.activity_pulse_speed # Neurogenesis highlight settings self.anim_neurogenesis_colour = style.neurogenesis_highlight_colour self.anim_neurogenesis_alpha = style.neurogenesis_highlight_alpha self.anim_neurogenesis_duration = style.neurogenesis_highlight_duration # Background color self.anim_background_colour = style.background_colour # ===== VIBRANT STYLE: AMBIENT PULSING ===== self.anim_ambient_pulse_enabled = style.ambient_pulse_enabled self.anim_ambient_pulse_width_range = style.ambient_pulse_width_range self.anim_ambient_pulse_alpha_range = style.ambient_pulse_alpha_range self.anim_ambient_pulse_freq_range = style.ambient_pulse_freq_range self.anim_ambient_pulse_phase_drift = style.ambient_pulse_phase_drift # ===== SUBTLE STYLE: COMMUNICATION GLOWS ===== self.anim_comm_glow_enabled = style.comm_glow_enabled self.anim_comm_glow_colour = style.comm_glow_colour self.anim_comm_glow_alpha = style.comm_glow_alpha self.anim_comm_glow_size = style.comm_glow_size self.anim_comm_glow_tail_length = style.comm_glow_tail_length self.anim_comm_glow_speed_range = style.comm_glow_speed_range self.anim_comm_glow_fade_in = style.comm_glow_fade_in self.anim_comm_glow_fade_out = style.comm_glow_fade_out self.anim_comm_glow_spawn_on_activity = style.comm_glow_spawn_on_activity self.anim_comm_glow_spawn_on_weight_change = style.comm_glow_spawn_on_weight_change self.anim_comm_glow_max_per_connection = style.comm_glow_max_per_connection # ===== SUBTLE STYLE: SCROLLING DOTS ===== # [NEW] Ensure scrolling settings are mapped self.anim_scroll_enabled = getattr(style, 'scroll_enabled', False) self.anim_scroll_dot_count = getattr(style, 'scroll_dot_count', 3) self.anim_scroll_dot_size = getattr(style, 'scroll_dot_size', 6.0) self.anim_scroll_dot_colour = getattr(style, 'scroll_dot_colour', (255, 255, 255)) self.anim_scroll_dot_alpha = getattr(style, 'scroll_dot_alpha', 200) self.anim_scroll_speed_range = getattr(style, 'scroll_speed_range', (1.5, 4.0)) # ===== NEURAL STYLE: ACTIVATION PULSES ===== self.anim_neural_pulse_enabled = style.neural_pulse_enabled self.anim_neural_pulse_duration = style.neural_pulse_duration self.anim_neural_pulse_width = style.neural_pulse_width self.anim_neural_pulse_colour_pos = style.neural_pulse_colour_positive self.anim_neural_pulse_colour_neg = style.neural_pulse_colour_negative self.anim_neural_weight_thickness = style.neural_weight_thickness self.anim_neural_weight_thickness_mult = style.neural_weight_thickness_mult self.anim_neural_base_colour_pos = style.neural_base_colour_positive self.anim_neural_base_colour_neg = style.neural_base_colour_negative self.anim_neural_base_alpha = style.neural_base_alpha # Initialize style-specific animation state self._init_style_animation_state() print(f"🎨 Animation palette built for style: {style.display_name}") def is_binary_neuron(self, neuron_name: str) -> bool: """Return True if neuron represents a binary (on/off) state.""" binary_neurons = { 'can_see_food', 'is_eating', 'is_sleeping', 'is_sick', 'pursuing_food', 'is_fleeing', 'is_startled', 'external_stimulus', # usually high/low, treat as binary-ish 'plant_proximity', # 2.6.0.2 consider this could be analog } return neuron_name in binary_neurons def get_animation_style(self) -> str: """Get the current animation style name.""" return self._animation_style_name def get_animation_style_info(self) -> tuple: """Get (name, display_name, description) for current style.""" style = self._animation_style return (style.name, style.display_name, style.description) def set_animation_style(self, style_name: str) -> bool: """ Switch to a different animation style at runtime. Args: style_name: One of 'classic', 'vibrant', or 'subtle' Returns: True if style was changed, False if style name is invalid """ try: new_style = get_animation_style(style_name) except KeyError: print(f"⚠️ Unknown animation style: {style_name}. " f"Available: {get_available_styles()}") return False if style_name == self._animation_style_name: return True # Already using this style old_style = self._animation_style_name self._animation_style_name = style_name self._animation_style = new_style self._build_animation_palette() # Trigger repaint with new style self.update() # Emit signal for any listeners self.animationStyleChanged.emit(style_name) print(f"🎨 Animation style changed: {old_style} → {style_name}") return True @staticmethod def get_available_animation_styles() -> list: """Get list of available animation style names.""" return get_available_styles() @staticmethod def get_animation_styles_info() -> list: """Get list of (name, display_name, description) for all styles.""" return get_style_info() def _init_style_animation_state(self): """ Initialize state tracking for style-specific animations. Called when style changes or on startup. """ # ===== VIBRANT: Ambient pulse state ===== # Each connection gets its own phase and frequency for organic pulsing if not hasattr(self, '_ambient_pulse_state'): self._ambient_pulse_state = {} # key: (src, dst) -> {phase, freq, phase_drift_dir} # ===== SUBTLE: Communication glow state ===== # Each connection has a list of traveling glow packets if not hasattr(self, '_comm_glow_packets'): self._comm_glow_packets = {} # key: (src, dst) -> [{progress, speed, direction}] # Track last spawn times to prevent glow spam if not hasattr(self, '_comm_glow_last_spawn'): self._comm_glow_last_spawn = {} # key: (src, dst) -> timestamp # ===== NEURAL: Activation pulse state ===== # Tracks traveling activation pulses for the neural style if not hasattr(self, '_neural_pulses'): self._neural_pulses = {} # key: (src, dst) -> [{start_time, color}] def _get_or_create_ambient_pulse(self, conn_key): """ Get or create ambient pulse state for a connection. Each connection pulses at its own unique rate. """ if conn_key not in self._ambient_pulse_state: # Assign random frequency and starting phase freq_min, freq_max = self.anim_ambient_pulse_freq_range self._ambient_pulse_state[conn_key] = { 'phase': random.uniform(0, 2 * math.pi), # Random starting phase 'freq': random.uniform(freq_min, freq_max), # Random frequency 'phase_drift_dir': random.choice([-1, 1]), # Drift direction 'drift_timer': random.uniform(0, 5) # When to change drift } return self._ambient_pulse_state[conn_key] def _update_ambient_pulses(self, dt): """ Update all ambient pulse phases. Called from update_animations(). Each connection's phase advances at its own rate with subtle drift. """ if not self.anim_ambient_pulse_enabled: return for conn_key, state in self._ambient_pulse_state.items(): # Advance phase based on frequency state['phase'] += 2 * math.pi * state['freq'] * dt # Keep phase in reasonable range if state['phase'] > 100 * math.pi: state['phase'] -= 100 * math.pi # Occasionally change drift direction for organic feel state['drift_timer'] -= dt if state['drift_timer'] <= 0: state['phase_drift_dir'] = random.choice([-1, 1]) state['drift_timer'] = random.uniform(3, 8) # Apply subtle phase drift state['phase'] += state['phase_drift_dir'] * self.anim_ambient_pulse_phase_drift * dt def _spawn_comm_glow(self, source, target): """ Spawn a new communication glow packet traveling from source to target. """ if not self.anim_comm_glow_enabled: return conn_key = (source, target) current_time = time.time() # Check spawn cooldown (min 0.1 seconds between spawns on same connection) last_spawn = self._comm_glow_last_spawn.get(conn_key, 0) if current_time - last_spawn < 0.1: return # Initialize packet list if needed if conn_key not in self._comm_glow_packets: self._comm_glow_packets[conn_key] = [] # Check max packets per connection if len(self._comm_glow_packets[conn_key]) >= self.anim_comm_glow_max_per_connection: return # Create new packet with random speed (for chaotic, organic movement) speed_min, speed_max = self.anim_comm_glow_speed_range duration = random.uniform(speed_min, speed_max) self._comm_glow_packets[conn_key].append({ 'progress': 0.0, # 0 = at source, 1 = at target 'speed': 1.0 / duration, # Progress per second 'start_time': current_time }) self._comm_glow_last_spawn[conn_key] = current_time def find_orphan_neurons(self): """Find neurons with no connections in the weights dictionary""" orphans = [] # Define neurons that should be checked even if they are in excluded_neurons explicitly_allowed = {'can_see_food', 'plant_proximity', 'is_fleeing'} for neuron in self.neuron_positions.keys(): # Skip if the neuron is excluded, unless it's in our allowed list if neuron in self.excluded_neurons and neuron not in explicitly_allowed: continue # Check for any connection (incoming or outgoing) has_connection = any(src == neuron or dst == neuron for (src, dst) in self.weights.keys()) if not has_connection: orphans.append(neuron) return orphans def _update_comm_glows(self, dt): """ Update all communication glow packets. Called from update_animations(). """ if not self.anim_comm_glow_enabled: return # Update each connection's glow packets for conn_key in list(self._comm_glow_packets.keys()): packets = self._comm_glow_packets[conn_key] # Update each packet surviving_packets = [] for packet in packets: packet['progress'] += packet['speed'] * dt # Keep packet if not yet complete if packet['progress'] < 1.0: surviving_packets.append(packet) # Update packet list (remove completed ones) if surviving_packets: self._comm_glow_packets[conn_key] = surviving_packets else: del self._comm_glow_packets[conn_key] def _spawn_activity_glows(self, current_time): """ Spawn communication glows based on neuron activity. Called from update_animations() when in subtle mode. """ if not self.anim_comm_glow_spawn_on_activity: return # Check which neurons are currently active (significant deviation from baseline) active_neurons = [] for neuron, value in self.state.items(): if isinstance(value, (int, float)) and neuron in self.neuron_positions: if neuron not in self.excluded_neurons: # Consider active if significantly away from baseline (50) if abs(value - 50) > 25: active_neurons.append(neuron) # For active neurons, randomly spawn glows on their connections for neuron in active_neurons: # Random chance to spawn (don't spam every frame) if random.random() > 0.02: # ~2% chance per frame per active neuron continue # Find connections involving this neuron for (src, dst), weight in self.weights.items(): if src == neuron or dst == neuron: # Determine glow direction based on weight sign and which end is active if src == neuron: self._spawn_comm_glow(src, dst) else: self._spawn_comm_glow(dst, src) def _draw_comm_glows_for_connection(self, painter, scale, conn_key, start_point, end_point): """ Draw all communication glow packets traveling along a connection. Each glow is a soft glowing orb that travels from source to target. """ # Check both directions for packets reverse_key = (conn_key[1], conn_key[0]) for key, direction in [(conn_key, 1), (reverse_key, -1)]: if key not in self._comm_glow_packets: continue packets = self._comm_glow_packets[key] for packet in packets: progress = packet['progress'] # Calculate fade based on position (fade in at start, fade out at end) if progress < self.anim_comm_glow_fade_in: # Fade in alpha_mult = progress / self.anim_comm_glow_fade_in elif progress > (1.0 - self.anim_comm_glow_fade_out): # Fade out alpha_mult = (1.0 - progress) / self.anim_comm_glow_fade_out else: alpha_mult = 1.0 # Clamp alpha multiplier alpha_mult = max(0.0, min(1.0, alpha_mult)) # Calculate position along the line if direction == 1: # Forward direction glow_x = start_point.x() + progress * (end_point.x() - start_point.x()) glow_y = start_point.y() + progress * (end_point.y() - start_point.y()) else: # Reverse direction (packet traveling backward) glow_x = end_point.x() + progress * (start_point.x() - end_point.x()) glow_y = end_point.y() + progress * (start_point.y() - end_point.y()) glow_pos = QtCore.QPointF(glow_x, glow_y) # Draw the glow with multiple layers for soft appearance glow_size = self.anim_comm_glow_size * scale r, g, b = self.anim_comm_glow_colour # Outer glow (larger, more transparent) outer_alpha = int(self.anim_comm_glow_alpha * 0.3 * alpha_mult) painter.setBrush(QtGui.QBrush(QtGui.QColor(r, g, b, outer_alpha))) painter.setPen(QtCore.Qt.NoPen) painter.drawEllipse(glow_pos, glow_size * 1.5, glow_size * 1.5) # Middle glow mid_alpha = int(self.anim_comm_glow_alpha * 0.6 * alpha_mult) painter.setBrush(QtGui.QBrush(QtGui.QColor(r, g, b, mid_alpha))) painter.drawEllipse(glow_pos, glow_size, glow_size) # Inner core (brightest) inner_alpha = int(self.anim_comm_glow_alpha * alpha_mult) # Slightly whiter core core_r = min(255, r + 50) core_g = min(255, g + 50) core_b = min(255, b + 50) painter.setBrush(QtGui.QBrush(QtGui.QColor(core_r, core_g, core_b, inner_alpha))) painter.drawEllipse(glow_pos, glow_size * 0.5, glow_size * 0.5) # Draw tail (trailing glow particles) if self.anim_comm_glow_tail_length > 0: tail_steps = 3 for i in range(1, tail_steps + 1): tail_progress = progress - (self.anim_comm_glow_tail_length * i / tail_steps) if tail_progress < 0: continue # Calculate tail position if direction == 1: tail_x = start_point.x() + tail_progress * (end_point.x() - start_point.x()) tail_y = start_point.y() + tail_progress * (end_point.y() - start_point.y()) else: tail_x = end_point.x() + tail_progress * (start_point.x() - end_point.x()) tail_y = end_point.y() + tail_progress * (start_point.y() - end_point.y()) tail_pos = QtCore.QPointF(tail_x, tail_y) # Tail gets smaller and more transparent tail_size = glow_size * 0.6 * (1.0 - i / (tail_steps + 1)) tail_alpha = int(outer_alpha * (1.0 - i / (tail_steps + 1))) painter.setBrush(QtGui.QBrush(QtGui.QColor(r, g, b, tail_alpha))) painter.drawEllipse(tail_pos, tail_size, tail_size) def _spawn_neural_pulse(self, source, target, color=None): """ Spawn a neural activation pulse traveling from source to target. Used by the Neural style. """ if not self.anim_neural_pulse_enabled: return conn_key = (source, target) current_time = time.time() # Initialize pulse list if needed if conn_key not in self._neural_pulses: self._neural_pulses[conn_key] = [] # Limit concurrent pulses per connection if len(self._neural_pulses[conn_key]) >= 3: return # Determine pulse color based on weight if not specified if color is None: weight = self.weights.get(conn_key, self.weights.get((target, source), 0)) if weight >= 0: color = self.anim_neural_pulse_colour_pos else: color = self.anim_neural_pulse_colour_neg self._neural_pulses[conn_key].append({ 'start_time': current_time, 'color': color }) def _update_neural_pulses(self, current_time): """ Update neural activation pulses, removing completed ones. """ if not self.anim_neural_pulse_enabled: return duration = self.anim_neural_pulse_duration for conn_key in list(self._neural_pulses.keys()): pulses = self._neural_pulses[conn_key] # Filter out completed pulses surviving = [p for p in pulses if (current_time - p['start_time']) < duration] if surviving: self._neural_pulses[conn_key] = surviving else: del self._neural_pulses[conn_key] def _spawn_neural_pulses_from_activity(self, current_time): """ Spawn neural pulses based on neuron activity and weight changes. """ if not self.anim_neural_pulse_enabled: return # Spawn pulses from weight change events if hasattr(self, 'weight_change_events'): for neuron, event_time in list(self.weight_change_events.items()): # Only process recent events (within last 0.1 seconds) if current_time - event_time > 0.1: continue # Find connections from this neuron for (src, dst), weight in self.weights.items(): if src == neuron: self._spawn_neural_pulse(src, dst) def _draw_neural_connections(self, painter, scale): """ Draw connections in Neural style with weight-based thickness and traveling activation pulses. """ if not self.show_links: return current_time = time.time() duration = self.anim_neural_pulse_duration # Pre-calculate scaled positions scaled_positions = {} for name, (x, y) in self.neuron_positions.items(): if name not in self.excluded_neurons: scaled_positions[name] = (x * scale, y * scale) # === 1. Draw all static connections (base layer) === for (src, dst), weight in self.weights.items(): if src not in scaled_positions or dst not in scaled_positions: continue x1, y1 = scaled_positions[src] x2, y2 = scaled_positions[dst] # Base opacity from toggle fade system base_opacity = self._link_opacities.get((src, dst), 1.0) if base_opacity <= 0: continue # Weight-based thickness if self.anim_neural_weight_thickness: thickness = max(1.0, abs(weight) * self.anim_neural_weight_thickness_mult) else: thickness = self.anim_line_base_width pen_width = max(1, int(thickness * scale)) # Color based on weight sign alpha = int(self.anim_neural_base_alpha * base_opacity) if weight >= 0: r, g, b = self.anim_neural_base_colour_pos else: r, g, b = self.anim_neural_base_colour_neg color = QtGui.QColor(r, g, b, alpha) pen = QtGui.QPen(color, pen_width, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap) painter.setPen(pen) painter.drawLine(QtCore.QLineF(x1, y1, x2, y2)) # === 2. Draw activation pulses on top (traveling lights) === for (src, dst), pulses in self._neural_pulses.items(): if src not in scaled_positions or dst not in scaled_positions: continue x1, y1 = scaled_positions[src] x2, y2 = scaled_positions[dst] for pulse in pulses: elapsed = current_time - pulse['start_time'] progress = elapsed / duration if progress > 1.0: continue # Sinusoidal fade (glow in → peak → fade out) opacity = math.sin(progress * math.pi) # Get pulse color r, g, b = pulse['color'] final_alpha = int(255 * opacity * 0.95) pulse_color = QtGui.QColor(r, g, b, final_alpha) # Thick glowing pulse line pulse_width = max(1, int(self.anim_neural_pulse_width * scale)) pen = QtGui.QPen(pulse_color, pulse_width, QtCore.Qt.SolidLine, QtCore.Qt.RoundCap) painter.setPen(pen) # Traveling light effect - pulse moves along connection segment_progress = progress * 1.35 if segment_progress <= 1.0: # First phase: light travels from source toward destination t = min(1.0, segment_progress) px = x1 + t * (x2 - x1) py = y1 + t * (y2 - y1) painter.drawLine(QtCore.QLineF(x1, y1, px, py)) else: # Second phase: light "drains" toward destination t = min(1.0, segment_progress - 0.35) px = x1 + t * (x2 - x1) py = y1 + t * (y2 - y1) painter.drawLine(QtCore.QLineF(px, py, x2, y2)) # ========================================================================= # Worker thread signal handlers # ========================================================================= def _on_neurogenesis_complete(self, result: dict): """Handle neurogenesis result from worker thread (called on main thread). The worker thread performs preliminary checks and signals whether creation should proceed. Actual neuron creation happens here on the main thread via the EnhancedNeurogenesis system. """ self._pending_neurogenesis_check = False # Check if worker signaled that we should create a neuron if not result.get('should_create', False): return # Get the context from worker result neuron_type = result.get('neuron_type') trigger_value = result.get('trigger_value', 0) state_context = result.get('state_context', {}) is_emergency = result.get('is_emergency', False) if not neuron_type: return # print(f"🧵 Worker signaled neurogenesis: type={neuron_type}, emergency={is_emergency}") # Build environment from state context environment = { 'food_count': state_context.get('food_count', 0), 'poop_count': state_context.get('poop_count', 0), 'is_sick': state_context.get('is_sick', False), 'is_eating': state_context.get('is_eating', False), 'has_rock': state_context.get('carrying_rock', False), 'new_object_encountered': state_context.get('new_object_encountered', False), 'recent_positive_outcome': state_context.get('recent_positive_outcome', False) } # Use the EnhancedNeurogenesis system to create the neuron properly try: # Capture experience context context = self.enhanced_neurogenesis.capture_experience_context( trigger_type=neuron_type, brain_state=state_context, recent_actions=state_context.get('recent_actions', []), environment=environment ) # For emergency creation, skip the normal checks if is_emergency: neuron_name = self.enhanced_neurogenesis.create_functional_neuron(context, is_emergency=True) else: # Normal path: check if we should create based on pattern recurrence if self.enhanced_neurogenesis.should_create_neuron(context): neuron_name = self.enhanced_neurogenesis.create_functional_neuron(context) else: # Experience captured but not enough pattern recurrence yet return if neuron_name: print(f"✨ Main thread: Created neuron {neuron_name}") # Emit signal to notify listeners (e.g., NetworkTab) of new neuron self.neuronCreated.emit(neuron_name) # Handle pruning if needed if self.pruning_enabled: current_count = len(self.neuron_positions) - len(self.excluded_neurons) max_neurons = self.neurogenesis_config.get('max_neurons', 32) if current_count > max_neurons * 0.85: self.enhanced_neurogenesis.intelligent_pruning() self.update() except Exception as e: print(f"⚠️ Error during neuron creation on main thread: {e}") import traceback traceback.print_exc() def _on_hebbian_complete(self, result: dict): """Handle Hebbian learning result from worker thread (called on main thread).""" self._pending_hebbian_learning = False # Always reset the timer so countdown restarts properly self.last_hebbian_time = time.time() updated_pairs = result.get('updated_pairs', []) weight_updates = result.get('weight_updates', {}) if not weight_updates: self.update() return # [NEW] Pre-generate distinct colors for this batch to ensure uniqueness import random from PyQt5.QtGui import QColor new_connections_created = [] # Track newly formed connections # Apply the weight updates (main thread only) for i, (pair_str, update_data) in enumerate(weight_updates.items()): # Convert string key back to tuple if needed if isinstance(pair_str, str): pair = tuple(pair_str.split(',')) else: pair = pair_str # Check if this is a new connection being created by Hebbian learning is_new_connection = update_data.get('is_new_connection', False) if is_new_connection and pair not in self.weights: # Create the new connection - Hebbian learning forming new pathways! self.weights[pair] = 0.0 # Initialize with zero weight new_connections_created.append(pair) if pair in self.weights: old_weight = update_data['old_weight'] new_weight = update_data['new_weight'] self.weights[pair] = new_weight # [NEW] Generate unique vibrant color using HSL (Hue, Saturation, Lightness) # Offset hue by index to ensure pairs get different colors hue = (random.random() + (i * 0.618033988749895)) % 1.0 # Golden ratio spacing unique_color = QColor.fromHslF(hue, 0.95, 0.6).toRgb() color_tuple = (unique_color.red(), unique_color.green(), unique_color.blue()) # [NEW] Randomize speed (duration) between 0.8s (fast) and 2.0s (slow) unique_duration = random.uniform(0.8, 2.0) # Add animation for visual feedback with custom parameters n1, n2 = pair self.add_weight_animation( n1, n2, old_weight, new_weight, custom_color=color_tuple, custom_duration=unique_duration ) # Update tracking (convert lists to tuples for consistency) self.recently_updated_neuron_pairs = [tuple(p) if isinstance(p, list) else p for p in updated_pairs] self.last_hebbian_time = time.time() # Console output if updated_pairs: COLORS = ["\033[96m", "\033[93m", "\033[95m", "\033[92m", "\033[94m"] RESET = "\033[0m" colored = [] for i, pair in enumerate(updated_pairs): if isinstance(pair, (list, tuple)) and len(pair) == 2: a, b = pair color = COLORS[i % len(COLORS)] # Mark new connections with a ✨ marker = " ✨" if pair in new_connections_created else "" colored.append(f"{color}{a} ↔ {b}{marker}{RESET}") if colored: print(" Hebbian learning chosen pairs: " + " ".join(colored)) # Log new connections formed if new_connections_created: print(f" 🔗 New connections formed: {len(new_connections_created)}") # Sync connections list from weights after creating new connections self.sync_connections_from_weights() self.update() def _on_state_update_complete(self, result: dict): """Handle state-update results from the worker thread (main thread).""" self._pending_state_update = False if result.get('health_check'): return processed_state = result.get('processed_state', {}) # [CRITICAL FIX] Filter out INPUT SENSORS so the worker cannot overwrite them. # The worker operates on slightly old data. For sensors, the Main Thread # (Environment) is the single source of truth. INPUT_SENSORS = { "can_see_food", "is_eating", "is_sleeping", "is_sick", "pursuing_food", "is_fleeing", "is_startled", "external_stimulus", "plant_proximity" } # Remove sensors from the update packet before applying it for sensor in INPUT_SENSORS: if sensor in processed_state: del processed_state[sensor] # Now it is safe to update. Only hidden/output neurons will be affected. self.state.update(processed_state) # Update communication events for glow effects comm_events = result.get('communication_events', {}) self.communication_events.update(comm_events) # Optional decay pre-application (Tamagotchi logic) decay_updates = result.get('decay_updates', {}) if decay_updates and hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: if hasattr(self.tamagotchi_logic, 'brain_hooks'): for neuron, decayed_val in decay_updates.items(): if neuron in self.state: # [CRITICAL FIX] Don't let decay logic touch sensors if neuron not in INPUT_SENSORS: self.state[neuron] = decayed_val # [NEW] Run Enhanced Neurogenesis logic (Stress reduction, etc.) if hasattr(self, 'enhanced_neurogenesis'): self.enhanced_neurogenesis.update_neuron_activations(self.state) self.update() # Repaint # Queue next update if any if self.state_update_queue: next_data = self.state_update_queue.pop(0) if hasattr(self, 'brain_worker') and self.brain_worker: self.brain_worker.queue_state_update(next_data) self._pending_state_update = True def _on_worker_error(self, error_msg: str): """Handle errors from the worker thread.""" print(f"⚠️ BrainWorker error: {error_msg}") def _update_worker_cache(self): """Update the worker's cached data for thread-safe access.""" if hasattr(self, 'brain_worker') and self.brain_worker: # Get connector neurons for Hebbian learning exclusion connector_neurons = {name for name, fn in self.enhanced_neurogenesis.functional_neurons.items() if fn.neuron_type == 'connector'} # Get new neurons for learning rate boost new_neurons = set() if hasattr(self, 'new_neurons'): new_neurons = set(self.new_neurons.keys()) if isinstance(self.new_neurons, dict) else set(self.new_neurons) self.brain_worker.update_cache( self.state, self.weights, self.neuron_positions, self.config, excluded_neurons=self.excluded_neurons, connector_neurons=connector_neurons, # Pass connector neurons separately learning_rate=getattr(self, 'learning_rate', 0.1), new_neurons=new_neurons ) def stop_worker(self): """Stop the worker thread gracefully (call on application exit).""" if hasattr(self, 'brain_worker') and self.brain_worker: self.brain_worker.stop() self.brain_worker.wait(2000) # Wait up to 2 seconds print("🧵 BrainWorker thread stopped") # ========================================================================= # End worker thread signal handlers # ========================================================================= def is_neuron_revealed(self, name): """Return True if the neuron has finished its reveal animation. Only checks revealed tracking during tutorial mode.""" # In normal gameplay (not tutorial), all neurons are considered revealed if not self.is_tutorial_mode: return True # During tutorial, check if neuron has completed its animation if name not in self.neuron_reveal_animations: return name in self.visible_neurons # never animated = already visible anim = self.neuron_reveal_animations[name] elapsed = time.time() - anim['start_time'] return elapsed >= 0.4 # same duration used in draw_neurons def _advance_link_fades(self): """Called every 16 ms while any line is still moving.""" dt = 0.016 still_animating = False now = time.time() for key, target in list(self._link_targets.items()): # Check if this link's delay has passed start_time = self._link_start_times.get(key, 0) if now < start_time: still_animating = True continue current = self._link_opacities.get(key, 0.0) # Use per-link speed for individual animation timing speed = self._link_fade_speeds.get(key, 3.0) step = speed * dt if abs(target - current) <= step: # arrived self._link_opacities[key] = target del self._link_targets[key] # stop tracking this one if key in self._link_start_times: del self._link_start_times[key] if key in self._link_fade_speeds: del self._link_fade_speeds[key] else: # move one step self._link_opacities[key] = current + step * (1 if target > current else -1) still_animating = True self.update() # repaint with new alphas if not still_animating: # all lines finished self._link_fade_timer.stop() def update_animations(self): """Update all animation states with smarter dirty-checking.""" current_lang = Localisation.instance().current_language if getattr(self, '_last_lang', None) != current_lang: self._last_lang = current_lang self.mark_render_dirty() self.update() if self.is_paused: return current_time = time.time() # Performance tracking (keep if using task manager) if _PERF_TRACKING_AVAILABLE: _anim_start = time.perf_counter() perf_tracker.increment("animation_frames") # Frame rate limiting - skip if called too soon if hasattr(self, '_last_animation_update'): elapsed = current_time - self._last_animation_update if elapsed < 0.04: # 25fps cap (40ms) - reduced from 30fps return self._last_animation_update = current_time # Calculate dt if not hasattr(self, '_last_animation_time'): self._last_animation_time = current_time dt = min(current_time - self._last_animation_time, 0.1) self._last_animation_time = current_time # SMARTER dirty tracking needs_repaint = False # 1. Neurogenesis pulse - only repaint if active if self.neurogenesis_highlight['neuron']: elapsed = current_time - self.neurogenesis_highlight['start_time'] if elapsed < self.neurogenesis_highlight['duration']: self.neurogenesis_highlight['pulse_phase'] = elapsed * 15 needs_repaint = True else: self.neurogenesis_highlight['neuron'] = None needs_repaint = True # One final repaint to clear # 2. Neuron reveal animations - only if we have any if self.neuron_reveal_animations: completed_reveals = [] for neuron_name, anim_data in self.neuron_reveal_animations.items(): elapsed = current_time - anim_data['start_time'] if elapsed >= 0.4: anim_data['progress'] = 1.0 completed_reveals.append(neuron_name) else: anim_data['progress'] = 1 - (1 - elapsed / 0.4) ** 3 for neuron_name in completed_reveals: del self.neuron_reveal_animations[neuron_name] needs_repaint = True # Enable links after last reveal if (len(self.visible_neurons) == len(self.original_neurons) and not self.neuron_reveal_animations and not self.show_links): self._enable_links_after_reveal() # 3. Weight animations - check if any are active if self.weight_animations: old_count = len(self.weight_animations) self.weight_animations = [ anim for anim in self.weight_animations if current_time - anim['start_time'] < anim['duration'] ] if self.weight_animations or old_count > 0: needs_repaint = True # 4. VIBRANT: Ambient pulses - only update if enabled AND we have connections if self.anim_ambient_pulse_enabled and self._ambient_pulse_state: self._update_ambient_pulses(dt) needs_repaint = True # 5. SUBTLE: Communication glows - only if packets exist if self.anim_comm_glow_enabled: had_packets = bool(self._comm_glow_packets) self._update_comm_glows(dt) self._spawn_activity_glows(current_time) # Only repaint if we had or now have packets if had_packets or self._comm_glow_packets: needs_repaint = True # 6. NEURAL: Activation pulses - only if pulses exist if self.anim_neural_pulse_enabled: had_pulses = bool(self._neural_pulses) self._update_neural_pulses(current_time) self._spawn_neural_pulses_from_activity(current_time) if had_pulses or self._neural_pulses: needs_repaint = True # Only trigger repaint when needed if needs_repaint: self.update() # Performance tracking end if _PERF_TRACKING_AVAILABLE: _anim_elapsed = (time.perf_counter() - _anim_start) * 1000 perf_tracker.record("update_animations", _anim_elapsed) def _get_cached_font(self, size, bold=False): """Return cached QFont to avoid recreation every paint.""" key = (size, bold) if key not in self._cached_fonts: font = QtGui.QFont("Arial", size) if bold: font.setBold(True) self._cached_fonts[key] = font return self._cached_fonts[key] def _get_cached_pen(self, color_tuple, width=1): """Return cached QPen to avoid recreation every paint.""" key = (*color_tuple, width) if key not in self._cached_pens: color = QtGui.QColor(*color_tuple[:3]) if len(color_tuple) > 3: color.setAlpha(color_tuple[3]) self._cached_pens[key] = QtGui.QPen(color, width) return self._cached_pens[key] def reveal_neuron(self, neuron_name): """Reveal a neuron with an expand animation – forces links OFF during reveal.""" import time if neuron_name not in self.original_neurons: return if neuron_name in self.visible_neurons: return if neuron_name not in self.neuron_positions: print(f"❌ ERROR: Neuron {neuron_name} has no position defined!") return # First reveal → force links OFF if not self.visible_neurons: self.show_links = False if hasattr(self, 'parent') and hasattr(self.parent(), 'network_tab'): nt = self.parent().network_tab nt.checkbox_links.setChecked(False) nt.checkbox_links.setEnabled(False) # Staggered start stagger = 0.4 delay = len(self.visible_neurons) * stagger self.visible_neurons.add(neuron_name) # Track this neuron as revealed for count display (tutorial only) if self.is_tutorial_mode: self.revealed_neurons.add(neuron_name) self._reveal_connections_for_neuron(neuron_name) # Trigger immediate metrics update self._update_network_metrics() self.neuron_reveal_animations[neuron_name] = { 'start_time': time.time() + delay, 'progress': 0.0 } pos = self.neuron_positions[neuron_name] self.mark_render_dirty() def _enable_links_after_reveal(self): """Re-enable checkbox, tick it, and kick off the existing link-fade engine.""" self.show_links = True # Populate link targets for all weights for key in self.weights.keys(): self._link_targets[key] = 1.0 if key not in self._link_opacities: self._link_opacities[key] = 0.0 # Set fade speed self._link_fade_speed = 3.0 # Re-enable and check the checkbox if hasattr(self, 'parent') and hasattr(self.parent(), 'network_tab'): nt = self.parent().network_tab nt.checkbox_links.setEnabled(True) nt.checkbox_links.setChecked(True) # Start the fade timer if not self._link_fade_timer.isActive(): self._link_fade_timer.start() self.mark_render_dirty() def reveal_all_core_neurons(self): """Make all core neurons visible immediately (for loaded games)""" for neuron_name in self.original_neurons: self.visible_neurons.add(neuron_name) # For loaded games, remove tracking attributes immediately if hasattr(self, 'revealed_neurons'): delattr(self, 'revealed_neurons') if hasattr(self, 'revealed_connections'): delattr(self, 'revealed_connections') # Ensure tutorial mode is disabled for loaded games self.is_tutorial_mode = False # Mark render dirty and trigger update self.mark_render_dirty() self.update() def _reveal_connections_for_neuron(self, neuron_name): """Reveal connections between this neuron and other revealed neurons NOTE: This should only be called during tutorial mode""" if not self.is_tutorial_mode: return if not hasattr(self, 'weights'): return if not hasattr(self, 'revealed_neurons') or not hasattr(self, 'revealed_connections'): return # Check all weights to find connections involving this neuron for (source, target), weight in self.weights.items(): # If both ends of the connection are revealed, track it if source in self.revealed_neurons and target in self.revealed_neurons: edge_key = f"{source}->{target}" if edge_key not in self.revealed_connections: self.revealed_connections.add(edge_key) def _update_network_metrics(self): """Update the network tab metrics display""" try: # Try to get the parent window (SquidBrainWindow) parent = self.parent() if parent and hasattr(parent, 'network_tab'): parent.network_tab.update_metrics_display() except Exception as e: pass # Silently fail if we can't update - not critical def finish_reveal_animation(self): """Called when the reveal animation completes - cleanup tracking attributes""" # Remove reveal tracking so normal counting resumes if hasattr(self, 'revealed_neurons'): delattr(self, 'revealed_neurons') if hasattr(self, 'revealed_connections'): delattr(self, 'revealed_connections') # Exit tutorial mode self.is_tutorial_mode = False # AUTO-ENABLE links now that all neurons are revealed self.show_links = True if hasattr(self, 'parent') and hasattr(self.parent(), 'network_tab'): nt = self.parent().network_tab nt.checkbox_links.setEnabled(True) nt.checkbox_links.setChecked(True) # tick the box # Fade-in the links smoothly if self.show_links and self._link_targets: self._auto_fade_links_pending = True if not self._link_fade_timer.isActive(): self._advance_link_fades() def add_weight_animation(self, neuron1, neuron2, old_weight, new_weight, custom_color=None, custom_duration=3.0): """Add animation for a weight change with organic staggering through connectors""" current_time = time.time() # Determine color based on weight change direction if not custom if custom_color: color = custom_color else: color = (0, 255, 0) if new_weight > old_weight else (255, 0, 0) # Check if there's a connector neuron in the path # A connector is in the path if: # 1. It has connections to both neuron1 and neuron2 # 2. It's a connector type neuron connector_in_path = None possible_connectors = [] # Find all connector neurons that connect to both for (src, dst), weight in self.weights.items(): # Check if this is a connector neuron connection is_connector = (src.startswith('connector_') or (src in self.neuron_shapes and self.neuron_shapes[src] == 'hexagon')) if is_connector: # Check if connector connects to both neurons conn_to_n1 = (src == neuron1 or dst == neuron1) conn_to_n2 = (src == neuron2 or dst == neuron2) if conn_to_n1 and conn_to_n2: # This connector is between our two neurons connector_in_path = src if src.startswith('connector_') else dst break # Create staggered animations for organic feel if connector_in_path: # Two-segment animation: neuron1 → connector → neuron2 # Add random delay at connector for organic relay effect # First segment: neuron1 → connector self.weight_animations.append({ 'pair': (neuron1, connector_in_path), 'start_time': current_time, 'duration': custom_duration * 0.45, # Slightly shorter for first half 'start_weight': old_weight, 'end_weight': new_weight, 'neuron1': neuron1, 'neuron2': connector_in_path, 'color': color, 'is_segment': True, 'final_target': neuron2 }) # Second segment: connector → neuron2 (with stagger delay) stagger_delay = random.uniform(0.15, 0.25) # 150-250ms pause at connector self.weight_animations.append({ 'pair': (connector_in_path, neuron2), 'start_time': current_time + stagger_delay, 'duration': custom_duration * 0.45, 'start_weight': old_weight, 'end_weight': new_weight, 'neuron1': connector_in_path, 'neuron2': neuron2, 'color': color, 'is_segment': True, 'final_target': neuron2, 'stagger_delay': stagger_delay }) print(f"🎨 Staggered animation: {neuron1} → {connector_in_path} → {neuron2} " f"(delay: {stagger_delay:.2f}s)") else: # Direct connection - single animation as before self.weight_animations.append({ 'pair': (neuron1, neuron2), 'start_time': current_time, 'duration': custom_duration, 'start_weight': old_weight, 'end_weight': new_weight, 'neuron1': neuron1, 'neuron2': neuron2, 'color': color, 'is_segment': False }) # Record communication events self.weight_change_events[neuron1] = current_time self.weight_change_events[neuron2] = current_time # Add to recently updated pairs (store original pair) if (neuron1, neuron2) not in self.recently_updated_neuron_pairs and \ (neuron2, neuron1) not in self.recently_updated_neuron_pairs: self.recently_updated_neuron_pairs.append((neuron1, neuron2)) # Mark render dirty immediately self.mark_render_dirty() self.update() def is_neurogenesis_neuron(self, neuron_name: str) -> bool: """ Check if a neuron was created through neurogenesis (and can be dragged). TO BE REMOVED IN FUTURE VERSION - depreceted """ if not neuron_name: return False # Core neurons that should NOT be draggable if neuron_name in self.original_neurons: return True # HACK make it True anyway # ANY neuron not in original_neurons is draggable (including circular ones) return True def _periodic_neurogenesis_check(self): """Periodic check for neurogenesis triggers and orphans.""" if not hasattr(self, 'check_neurogenesis_triggers'): return # Skip if a check is already pending if self._pending_neurogenesis_check: return # Prioritise Orphan Rescue --- if hasattr(self, 'enhanced_neurogenesis') and hasattr(self.enhanced_neurogenesis, 'rescue_orphan'): orphans = self.find_orphan_neurons() if orphans: orphan = orphans[0] print(f"🚑 Orphan detected: {orphan}. Creating connector neuron...") # ADD ERROR HANDLING try: self.enhanced_neurogenesis.rescue_orphan(orphan) self.update() return # Skip normal neurogenesis this tick except Exception as e: print(f"⚠️ Failed to rescue orphan {orphan}: {e}") import traceback traceback.print_exc() # Build state with context and check neurogenesis triggers state_with_context = self.state.copy() self._pending_neurogenesis_check = True # Queue to worker thread if available if self._use_threaded_processing and hasattr(self, 'brain_worker') and self.brain_worker.isRunning(): self.brain_worker.queue_neurogenesis_check(state_with_context) else: # Fallback: synchronous check try: result = self.check_neurogenesis_triggers(state_with_context) if result: self._on_neurogenesis_complete({'should_create': True, **result}) except Exception as e: print(f"⚠️ Error in neurogenesis check: {e}") import traceback traceback.print_exc() finally: self._pending_neurogenesis_check = False def toggle_pruning(self, enabled): """Enable or disable the pruning mechanisms for neurogenesis""" previous = self.pruning_enabled self.pruning_enabled = enabled if previous != enabled: print(f"\x1b[{'42' if enabled else '41'}mPruning {'enabled' if enabled else 'disabled'}\x1b[0m - Neurogenesis {'constrained' if enabled else 'unconstrained'}") if not enabled: print("\x1b[31mWARNING: Disabling pruning may lead to network instability\x1b[0m") return self.pruning_enabled def get_stress_neuron_count(self): """Counts the number of neurons that start with the name 'stress'.""" return len([name for name in self.neuron_positions if name.startswith('stress')]) def stop_hebbian_learning(self): """Stop Hebbian learning immediately""" self.learning_active = False self.hebbian_countdown_seconds = 0 print("++ Hebbian learning stopped") def start_hebbian_learning(self, duration_seconds=30): """Start Hebbian learning with a specified duration""" self.hebbian_countdown_seconds = duration_seconds self.learning_active = True self.is_paused = False print(f"++ Hebbian learning started for {duration_seconds} seconds") def _update_communication_events(self): """Clean up old communication events that have expired""" current_time = time.time() # Remove events older than the highlight duration expired_neurons = [ neuron for neuron, event_time in self.communication_events.items() if current_time - event_time > self.communication_highlight_duration ] for neuron in expired_neurons: del self.communication_events[neuron] def get_neuron_value(self, value): """ Convert a neuron value to a numerical format for Hebbian learning. Args: value: The value of the neuron, which can be int, float, bool, or str. Returns: float: The numerical value of the neuron. """ if isinstance(value, (int, float)): return float(value) elif isinstance(value, bool): return 100.0 if value else 0.0 elif isinstance(value, str): # For string values (like 'direction'), return a default value return 75.0 else: return 0.0 def is_new_neuron(self, neuron_name, newness_duration_sec=300): # 300s = 5 minutes """Check if a neuron was created within the newness duration.""" # Check if it's in the primary neurogenesis data if neuron_name in self.neurogenesis_data.get('new_neurons', []): details = self.neurogenesis_data.get('new_neurons_details', {}).get(neuron_name) if details: created_at = details.get('created_at') if created_at and (time.time() - created_at) < newness_duration_sec: return True # It's new based on details else: # If it's in the list and < 5 mins, assume new. pass return False def update_connection(self, neuron1, neuron2, value1, value2): """Update connection weight and trigger animations, with connector limits.""" import time current_time = time.time() pair = (neuron1, neuron2) reverse_pair = (neuron2, neuron1) # Check if connection exists exists = pair in self.weights or reverse_pair in self.weights use_pair = pair if pair in self.weights else reverse_pair # --- CONNECTOR LIMIT CHECK --- # If this is a NEW connection, check if either party is a maxed-out connector if not exists: # Check Neuron 1 if self.is_connector_neuron(neuron1): if self.get_neuron_degree(neuron1) >= 3: # print(f"🚫 Blocked new connection to connector {neuron1} (Max 3 reached)") return # Check Neuron 2 if self.is_connector_neuron(neuron2): if self.get_neuron_degree(neuron2) >= 3: # print(f"🚫 Blocked new connection to connector {neuron2} (Max 3 reached)") return # Initialize if new if not exists: if neuron1 in self.neuron_positions and neuron2 in self.neuron_positions: self.weights[pair] = 0.0 use_pair = pair print(f" 🆕 Created new connection: {neuron1} ↔ {neuron2}") else: return prev_weight = self.weights[use_pair] # Learning Rate Calculation base_lr = self.learning_rate newness_boost = 2.0 effective_lr = base_lr is_n1_new = self.is_new_neuron(neuron1) is_n2_new = self.is_new_neuron(neuron2) if is_n1_new or is_n2_new: effective_lr = base_lr * newness_boost # Calculate weight change (basic Hebbian) weight_change = effective_lr * (value1 / 100.0) * (value2 / 100.0) # Add weight decay decay_rate = self.config.hebbian.get('weight_decay', 0.01) * 0.1 new_weight = prev_weight + weight_change - (prev_weight * decay_rate) # Clamp weight to [-1, 1] range new_weight = min(max(new_weight, -1.0), 1.0) self.weights[use_pair] = new_weight # Add animation using helper method self.add_weight_animation(neuron1, neuron2, prev_weight, new_weight) # Record weight change time if abs(new_weight - prev_weight) > 0.001: self.weight_change_events[neuron1] = current_time self.weight_change_events[neuron2] = current_time if (neuron1, neuron2) not in self.recently_updated_neuron_pairs and \ (neuron2, neuron1) not in self.recently_updated_neuron_pairs: self.recently_updated_neuron_pairs.append((neuron1, neuron2)) self.mark_render_dirty() self.update() self.communication_events[neuron1] = current_time self.communication_events[neuron2] = current_time def is_connector_neuron(self, neuron_name: str) -> bool: """ Check if a neuron is a connector neuron. Checks multiple sources to ensure robustness across save/load states. """ if not neuron_name: return False # 1. Check visual shape (Primary persistence mechanism) if self.neuron_shapes.get(neuron_name) == 'hexagon': return True # 2. Check naming convention if neuron_name.startswith('connector_'): return True # 3. Check enhanced neurogenesis registry (Runtime logic) if hasattr(self, 'enhanced_neurogenesis') and self.enhanced_neurogenesis: fn = self.enhanced_neurogenesis.functional_neurons.get(neuron_name) if fn and fn.neuron_type == 'connector': return True return False def get_neuron_degree(self, neuron_name: str) -> int: """Count the number of active connections for a specific neuron.""" count = 0 for (u, v) in self.weights.keys(): if u == neuron_name or v == neuron_name: count += 1 return count def prune_weak_connections(self, threshold=0.05, min_age_sec=600): """Removes connections with absolute weight below threshold, ignoring connectors.""" if not self.pruning_enabled: return 0 to_delete = [] for pair, weight in list(self.weights.items()): if not (isinstance(pair, tuple) and len(pair) == 2): continue neuron1, neuron2 = pair # IMMUNITY: Never prune connections attached to connector neurons if self.is_connector_neuron(neuron1) or self.is_connector_neuron(neuron2): continue # IMMUNITY: New neurons if self.is_new_neuron(neuron1, min_age_sec) or self.is_new_neuron(neuron2, min_age_sec): continue if abs(weight) < threshold: to_delete.append(pair) for pair in to_delete: if pair in self.weights: del self.weights[pair] if len(to_delete) > 0: print(f"\x1b[33mPruned {len(to_delete)} weak connections.\x1b[0m") self.mark_render_dirty() self.update() return len(to_delete) def perform_hebbian_learning(self): """ One full Hebbian update cycle. Queues work to background thread for non-blocking operation. """ # === Disable Hebbian while squid is sleeping === if (hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic and hasattr(self.tamagotchi_logic.squid, 'is_sleeping') and self.tamagotchi_logic.squid.is_sleeping): # print("DEBUG: Hebbian learning skipped - squid is sleeping") return # Skip if already pending if self._pending_hebbian_learning: return # CRITICAL: Update cache BEFORE queueing work self._update_worker_cache() if self._use_threaded_processing and hasattr(self, 'brain_worker') and self.brain_worker.isRunning(): # Queue the work to the background thread self._pending_hebbian_learning = True self.brain_worker.queue_hebbian_learning() else: # Fallback: synchronous processing self._perform_hebbian_learning_sync() def _perform_hebbian_learning_sync(self): """Original synchronous Hebbian learning (fallback if threading disabled).""" from heapq import nlargest print(f"++ [Sync] Hebbian learning cycle triggered") COLORS = ["\033[96m", "\033[93m", "\033[95m", "\033[92m", "\033[94m"] RESET = "\033[0m" if not hasattr(self, 'weights'): self.weights = {} # Ensure list exists (used for history tracking in sync mode) if not hasattr(self, 'recently_updated_neuron_pairs'): self.recently_updated_neuron_pairs = [] # NEW: Get connector neurons to exclude from learning connector_neurons = set() if hasattr(self, 'enhanced_neurogenesis') and self.enhanced_neurogenesis: connector_neurons = {name for name, fn in self.enhanced_neurogenesis.functional_neurons.items() if fn.neuron_type == 'connector'} # PURE_INPUTS (Sensors) - Do not include in Hebbian learning PURE_INPUTS = { "can_see_food", "is_eating", "is_sleeping", "is_sick", "pursuing_food", "is_fleeing", "is_startled", "external_stimulus", "plant_proximity" } # Combine excluded neurons, connector neurons, and pure inputs learning_excluded = set(self.excluded_neurons) | connector_neurons | PURE_INPUTS neurons = [n for n in self.neuron_positions.keys() if n not in learning_excluded] print(f" [Sync] Neurons available: {len(neurons)} (excluded {len(connector_neurons)} connectors + inputs), " f"Weights tracked: {len(self.weights)}") scored_pairs = [] # Prepare history for quick lookup (ensure sorted tuples) recent_history_sorted = set() for p in self.recently_updated_neuron_pairs: if isinstance(p, (list, tuple)) and len(p) == 2: recent_history_sorted.add(tuple(sorted(p))) for i, n1 in enumerate(neurons): for n2 in neurons[i + 1:]: v1 = self.get_neuron_value(self.state.get(n1, 50)) v2 = self.get_neuron_value(self.state.get(n2, 50)) # Base Score score = v1 + v2 # 1. Add Random Noise to break deterministic loops score += random.uniform(0, 40) # 2. Penalize recently updated pairs to prevent loops current_pair = tuple(sorted((n1, n2))) if current_pair in recent_history_sorted: score -= 500 # Heavy penalty scored_pairs.append((score, n1, n2, v1, v2)) top_k = self.config.neurogenesis.get('max_hebbian_pairs', 2) top_pairs = nlargest(top_k, scored_pairs) print(f" [Sync] Top {top_k} pairs selected from {len(scored_pairs)} candidates") updated_pairs = [] for _, n1, n2, v1, v2 in top_pairs: base_lr = self.learning_rate decay_rate = self.config.hebbian.get('weight_decay', 0.01) if self.is_new_neuron(n1) or self.is_new_neuron(n2): base_lr *= 2.0 delta = base_lr * (v1 / 100.0) * (v2 / 100.0) pair = (n1, n2) reverse_pair = (n2, n1) use_pair = pair if pair in self.weights else reverse_pair if use_pair not in self.weights: print(f" [Sync] Skipping pair {n1}-{n2}: not in weights dict") continue old_w = self.weights[use_pair] new_w = old_w + delta - (old_w * decay_rate) new_w = max(self.config.hebbian.get('min_weight', -1.0), min(self.config.hebbian.get('max_weight', 1.0), new_w)) self.weights[use_pair] = new_w self.add_weight_animation(n1, n2, old_w, new_w) updated_pairs.append((n1, n2)) if updated_pairs: colored = [] for i, (a, b) in enumerate(updated_pairs): color = COLORS[i % len(COLORS)] colored.append(f"{color}{a} ↔ {b}{RESET}") print(" Hebbian learning chosen pairs: " + " ".join(colored)) else: print(" [Sync] No pairs were updated this cycle") self.recently_updated_neuron_pairs = updated_pairs self.last_hebbian_time = time.time() self.update() def get_recently_updated_neurons(self): """Return the list of neuron pairs updated in the last learning cycle""" return self.recently_updated_neuron_pairs def resizeEvent(self, event): """Handle window resize events - startles squid and enforces minimum size""" super().resizeEvent(event) # Only trigger startle if we're actually resizing (not just moving) old_size = event.oldSize() if (old_size.isValid() and hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic): new_size = event.size() width_change = abs(new_size.width() - old_size.width()) height_change = abs(new_size.height() - old_size.height()) # Only startle if change is significant (>50px) if width_change > 50 or height_change > 50: # Check if first resize if not hasattr(self, '_has_resized_before'): source = "first_resize" self._has_resized_before = True else: source = "window_resize" #self.tamagotchi_logic.startle_squid(source=source) #STARTLE WHEN WINDOW RESIZE if self.debug_mode: print(f"Squid startled by {source}") # Enforce minimum window size (1280x900) min_width, min_height = 1280, 900 if event.size().width() < min_width or event.size().height() < min_height: self.resize( max(event.size().width(), min_width), max(event.size().height(), min_height) ) def closeEvent(self, event): """Handle window close event - save state and clean up resources""" # Stop the worker thread first self.stop_worker() if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: # Save current brain state try: brain_state = self.save_brain_state() with open('last_brain_state.json', 'w') as f: json.dump(brain_state, f) except Exception as e: print(f"Error saving brain state: {e}") # Clean up timers if hasattr(self, 'hebbian_timer'): self.hebbian_timer.stop() if hasattr(self, 'countdown_timer'): self.countdown_timer.stop() if hasattr(self, 'memory_update_timer'): self.memory_update_timer.stop() # Close any child windows if hasattr(self, '_inspector') and self._inspector: self._inspector.close() if hasattr(self, '_laboratory') and self._laboratory: self._laboratory.close() # Accept the close event event.accept() def save_brain_state(self): return { 'weights': self.weights, 'neuron_positions': self.neuron_positions, 'neuron_states': self.state, 'layer_structure': self.layers, 'neuron_shapes': dict(self.neuron_shapes), 'state_colors': dict(self.state_colors), } def load_brain_state(self, state): """Load the brain state from a saved state dictionary""" self.weights = state['weights'] self.neuron_positions = state['neuron_positions'] self.state = state.get('neuron_states', {}) self.layers = state.get('layer_structure', []) # Sync connections list from loaded weights self.sync_connections_from_weights() # ADD THESE THREE LINES self.neuron_shapes = state.get('neuron_shapes', {}) # Load shapes self.state_colors = state.get('state_colors', {}) # Load colors print(f"📦 Loaded {len(self.neuron_shapes)} neuron shapes") # Debug print # Ensure all neurons in neuron_positions exist in state for neuron in self.neuron_positions: if neuron not in self.state: self.state[neuron] = 50 # Explicitly update excluded neurons if they exist in positions for neuron in self.excluded_neurons: if neuron in self.neuron_positions and neuron not in self.state: self.state[neuron] = False # Ensure visible_neurons is complete if not self.is_tutorial_mode: self.visible_neurons = set(self.original_neurons) for neuron in self.neuron_positions: if neuron not in self.excluded_neurons and neuron not in self.original_neurons: self.visible_neurons.add(neuron) print(f"📊 Loaded {len(self.neuron_positions)} neurons, {len(self.visible_neurons)} visible") # Mark render dirty and trigger update self.mark_render_dirty() self.update() def create_initial_state(self): """ Create and return the initial brain state for a new game. Returns a dictionary with all core neurons set to their default values. Also resets animation tracking attributes. """ # Reset animation tracking attributes self.reset_animation_state() initial_state = { "can_see_food": 0, "hunger": 50, "happiness": 50, "cleanliness": 50, "sleepiness": 50, "satisfaction": 50, "anxiety": 50, "curiosity": 50, "is_sick": False, "is_eating": False, "is_sleeping": False, "pursuing_food": False, "direction": "up", "position": (0, 0), "is_startled": False, "is_fleeing": False, 'neurogenesis_active': True } return initial_state def reset_animation_state(self): """ Reset all animation and reveal tracking attributes for a new game. This ensures the neuron reveal animation can run properly. """ # Enable tutorial mode for new game reveal animation self.is_tutorial_mode = True # Reset visible neurons self.visible_neurons = set() # Reset reveal tracking (recreate if deleted) self.revealed_neurons = set() self.revealed_connections = set() # Clear reveal animations self.neuron_reveal_animations = {} print("🔄 Animation state reset for new game (tutorial mode enabled)") def initialize_connections(self): """Return list of connection tuples derived from self.weights. This ensures self.connections stays synced with self.weights as source of truth.""" return list(self.weights.keys()) def sync_connections_from_weights(self): """Sync self.connections list from self.weights (single source of truth). Call this after any modification to self.weights.""" self.connections = list(self.weights.keys()) def initialize_weights(self): """Initialize weights with sparse random connections (40% density).""" neurons = list(self.neuron_positions.keys()) # Create sparse random connections (40% density) connection_probability = 0.4 for i in range(len(neurons)): for j in range(i+1, len(neurons)): if random.random() < connection_probability: self.weights[(neurons[i], neurons[j])] = random.uniform(-1, 1) # Sync connections list from weights self.sync_connections_from_weights() def get_neuron_count(self): """Returns the actual count of neurons in the network positions.""" return len(self.neuron_positions) def get_edge_count(self): """Returns the actual count of connections (weights) in the network.""" return len(self.weights) # MODIFIED: Use self.weights def get_weakest_connections(self, n=3): """Return the n weakest connections by absolute weight""" if not hasattr(self, 'weights') or not self.weights: return [] sorted_weights = sorted(self.weights.items(), key=lambda x: abs(x[1])) return sorted_weights[:n] # Returns list of ((source, target), weight) tuples def get_extreme_neurons(self, n=3): """Return neurons deviating most from baseline (50)""" neurons = [(k, v) for k, v in self.state.items() if isinstance(v, (int, float)) and k in self.neuron_positions] most_positive = sorted(neurons, key=lambda x: -x[1])[:n] most_negative = sorted(neurons, key=lambda x: x[1])[:n] return {'overactive': most_positive, 'underactive': most_negative} def get_unbalanced_connections(self, n=3): """Return connections with largest weight disparities""" unbalanced = [] for (a, b), w1 in self.weights.items(): w2 = self.weights.get((b, a), 0) if abs(w1 - w2) > 0.3: # Only consider significant differences unbalanced.append(((a, b), (w1, w2), abs(w1 - w2))) return sorted(unbalanced, key=lambda x: -x[2])[:n] def calculate_network_health(self): """Calculate network health based on connection weights and neuron activity""" total_weight = sum(abs(w) for w in self.weights.values()) avg_weight = total_weight / len(self.weights) if self.weights else 0 health = min(100, max(0, avg_weight * 100)) return health def calculate_network_efficiency(self): """Calculate network efficiency based on connection distribution""" if not self.connections: return 0 reciprocal_count = 0 for conn in self.connections: reverse_conn = (conn[1], conn[0]) if reverse_conn in self.connections: reciprocal_count += 1 efficiency = (reciprocal_count / len(self.connections)) * 100 return efficiency def log_neurogenesis_event(self, neuron_name, event_type, reason=None, details=None): """Log neurogenesis events in a human-readable paragraph format.""" timestamp = datetime.now().strftime("%H:%M:%S") log_entry = "" if event_type == "created" and details: neuron_type = details.get("trigger_type") trigger_value = details.get("trigger_value", 0.0) if not neuron_type: return # Cannot log without a neuron type # General creation message log_entry += f"{timestamp} - a {neuron_type.upper()} neuron ({neuron_name}) was created because {neuron_type} counter was {trigger_value:.2f}\n" # Specific details for stress neurons if neuron_type == "stress": log_entry += "An inhibitory connection was made to ANXIETY\n" log_entry += "Maximum anxiety value has been permanently reduced by 10\n" elif event_type == "pruned": # A more consistent format for pruned events timestamp_full = datetime.now().strftime("%H:%M:%S") log_entry = f"{timestamp_full} - a neuron ({neuron_name}) was PRUNED due to {reason if reason else 'unknown reason'}\n" if log_entry: try: with open('neurogenesis_log.txt', 'a', encoding='utf-8') as f: f.write(log_entry) # Always add the separator after an entry f.write("\n-------------------------------\n\n") except Exception as e: print(f"\x1b[31mNeurogenesis logging failed: {str(e)}\x1b[0m") # In brain_widget.py def update_state(self, new_state=None): """ Update neuron activations. If new_state is provided, it is the SOURCE OF TRUTH (Sensor data). If None, it is a PHYSICS TICK (Internal decay/noise). """ import time import random # LIST OF PROTECTED INPUTS PURE_INPUTS = { "can_see_food", "is_eating", "is_sleeping", "is_sick", "pursuing_food", "is_fleeing", "is_startled", "external_stimulus", "plant_proximity" } # --- CASE A: External Update (Source of Truth) --- if new_state is not None: for k, v in (new_state.items() if isinstance(new_state, dict) else []): val_to_set = v # Enforce binary rules for specific neurons if k in self.neuron_positions and self.is_binary_neuron(k): if isinstance(v, bool): val_to_set = 100.0 if v else 0.0 elif isinstance(v, (int, float)): val_to_set = 100.0 if float(v) > 50.0 else 0.0 else: val_to_set = float(v) if isinstance(v, (int, float, bool)) else v # 1. Update Display State self.state[k] = val_to_set # 2. Update Internal Simulation State if k in self.neurons: self.neurons[k]['activation'] = float(val_to_set) # [Update cache so worker knows the new truth immediately] if hasattr(self, 'brain_worker') and self.brain_worker: self._update_worker_cache() self.mark_render_dirty() self.update() return # --- CASE B: Internal Physics Tick (Decay/Noise) --- # We must NOT apply decay to PURE_INPUTS updated = {} # 1. Calculate Decay & Noise for neuron, props in self.neurons.items(): # [FIX] SKIP PURE INPUTS. Do not calculate decay for them. if neuron in PURE_INPUTS: continue value = props.get('activation', 0.0) decay = props.get('decay', 1.0) noise = props.get('noise', 0.0) value *= decay value += random.uniform(-noise, noise) updated[neuron] = value # 2. Apply Connections - USE self.weights as source of truth for (src, dst), weight in self.weights.items(): # Get source value (Use current state if it's an input) if src in PURE_INPUTS and src not in updated: src_val = self.neurons.get(src, {}).get('activation', 0.0) elif src in updated: src_val = updated[src] else: continue # [FIX] NEVER modify a PURE_INPUT via connections if dst in PURE_INPUTS: continue if dst not in updated: continue updated[dst] += src_val * weight # 3. Save Back for neuron, val in updated.items(): clamped = max(min(val, 100), -100) if neuron in self.neurons: self.neurons[neuron]['activation'] = clamped else: self.state[neuron] = clamped if updated: self.mark_render_dirty() def _perform_state_update_sync(self): """Synchronous fallback when worker is unavailable""" import random updated = {} # [FIX] Define protected neurons here as well PURE_INPUTS = { "can_see_food", "is_eating", "is_sleeping", "is_sick", "pursuing_food", "is_fleeing", "is_startled", "external_stimulus", "plant_proximity" } # First pass: decay and noise for neuron, props in self.neurons.items(): if neuron in self.excluded_neurons: continue # [FIX] Skip decay for inputs if neuron in PURE_INPUTS: continue value = props.get('activation', 0) decay = props.get('decay', 1.0) noise = props.get('noise', 0.0) value *= decay value += random.uniform(-noise, noise) updated[neuron] = value # Second pass: connection effects - USE self.weights as source of truth for (src, dst), weight in self.weights.items(): # Skip if neurons don't exist in updated state if src not in self.state and src not in updated: continue if dst not in updated: continue # [FIX] Prevent inputs from being modified by connections if dst in PURE_INPUTS: continue # Get source value if src in updated: src_val = updated[src] elif src in self.state: src_val = self.state[src] if isinstance(src_val, bool): src_val = 100.0 if src_val else 0.0 else: continue if isinstance(src_val, (int, float)): updated[dst] += src_val * weight # Use current state for inputs if not in 'updated' list yet src_val = updated.get(src, self.state.get(src, 0)) if dst in updated: updated[dst] += src_val * weight # Final pass: clamping for neuron, val in updated.items(): updated[neuron] = max(min(val, 100), -100) self.state.update(updated) self.update() def _fast_apply_external_state(self, new_state): """Quickly apply externally provided state (plugin updates, etc.)""" for k, v in new_state.items(): if k in self.neuron_positions: if self.is_binary_neuron(k): self.state[k] = 100.0 if (v if isinstance(v, bool) else float(v) > 50) else 0.0 else: self.state[k] = float(v) if isinstance(v, (int, float)) else v # >>> ADDED: Mark render dirty <<< self.mark_render_dirty() def _perform_state_update_sync(self): """Synchronous fallback when worker is unavailable""" import random updated = {} BINARY_NEURONS = {"can_see_food"} # First pass: decay and noise for neuron, props in self.neurons.items(): if neuron in self.excluded_neurons: continue value = props.get('activation', 0) decay = props.get('decay', 1.0) noise = props.get('noise', 0.0) value *= decay value += random.uniform(-noise, noise) updated[neuron] = value # Second pass: connection effects - USE self.weights as source of truth for (src, dst), weight in self.weights.items(): if src in updated and dst in updated: updated[dst] += updated[src] * weight # Final pass: clamping for neuron, val in updated.items(): if neuron in BINARY_NEURONS: updated[neuron] = 100.0 if val > 50 else 0.0 else: updated[neuron] = max(min(val, 100), -100) self.state.update(updated) self.update() def provide_outcome_feedback(self, outcome_value: float): """ Provide feedback to recently activated neurons. outcome_value: 1.0 = very positive, 0.0 = neutral, -1.0 = very negative """ if not hasattr(self, 'enhanced_neurogenesis'): return current_time = time.time() # Update utility scores for recently active neurons for name, func_neuron in self.enhanced_neurogenesis.functional_neurons.items(): # Was this neuron recently active? # Check if func_neuron has the last_activated attribute (for FunctionalNeuron objects) if hasattr(func_neuron, 'last_activated') and current_time - func_neuron.last_activated < 30: # 30 seconds if hasattr(func_neuron, 'update_utility_score'): func_neuron.update_utility_score(outcome_value) def check_neurogenesis_triggers(self, state): """Enhanced neurogenesis check with proper trigger priority""" if not state.get('neurogenesis_active', True): return False # Build experience context recent_actions = state.get('recent_actions', []) environment = { 'food_count': state.get('food_count', 0), 'poop_count': state.get('poop_count', 0), 'has_rock': state.get('carrying_rock', False) } # ===== FIX: PROPER TRIGGER PRIORITY WITH EMERGENCY OVERRIDE ===== trigger_type = None # 🚨 HIGHEST PRIORITY: Emergency stress (overrides everything) if state.get('anxiety', 50) >= 95: trigger_type = 'stress' print(f"🚨 EMERGENCY: Critical anxiety level ({state.get('anxiety', 50):.1f})!") # HIGH PRIORITY: Stress (anxiety or sustained stress) elif state.get('anxiety', 50) > 75 or state.get('sustained_stress', 0) > 1: trigger_type = 'stress' # MEDIUM PRIORITY: Novelty (curiosity or new objects) elif state.get('curiosity', 50) > 70 or state.get('novelty_exposure', 0) > 2: trigger_type = 'novelty' # LOW PRIORITY: Reward (satisfaction or positive outcomes) elif state.get('satisfaction', 50) > 70 or state.get('recent_rewards', 0) > 2: trigger_type = 'reward' # ================================================================ if trigger_type: # Capture full experience context context = self.enhanced_neurogenesis.capture_experience_context( trigger_type=trigger_type, brain_state=self.state, recent_actions=recent_actions, environment=environment ) # Add to experience buffer self.experience_buffer.add_experience(context) # Check if we should create a neuron if self.enhanced_neurogenesis.should_create_neuron(context): neuron_name = self.enhanced_neurogenesis.create_functional_neuron(context) if neuron_name and self.pruning_enabled: # Check if we need to prune current_count = len(self.neuron_positions) - len(self.excluded_neurons) max_neurons = self.neurogenesis_config.get('max_neurons', 32) if current_count > max_neurons * 0.85: self.enhanced_neurogenesis.intelligent_pruning() return neuron_name is not None return False def get_neurogenesis_threshold(self, trigger_type): """Safely get threshold for a trigger type with fallback defaults""" try: return self.neurogenesis_config['triggers'][trigger_type]['threshold'] except KeyError: defaults = {'novelty': 0.7, 'stress': 0.8, 'reward': 0.6} return defaults.get(trigger_type, 1.0) def stimulate_brain(self, stimulation_values): """Handle brain stimulation with validation""" if not isinstance(stimulation_values, dict): return filtered_update = {} for key in self.state.keys(): if key in stimulation_values: filtered_update[key] = stimulation_values[key] self.update_state(filtered_update) def get_adjusted_threshold(self, base_threshold, trigger_type): """Scale threshold based on network size to prevent runaway neurogenesis""" original_count = len(self.original_neuron_positions) current_count = len(self.neuron_positions) - len(self.excluded_neurons) new_neuron_count = current_count - original_count baseline = original_count + 3 if new_neuron_count <= 0 or current_count <= baseline: return base_threshold scaling_factors = {'novelty': 0.25, 'stress': 0.1, 'reward': 0.08} scaling_factor = scaling_factors.get(trigger_type, 0.15) multiplier = 1.0 + (scaling_factor * (new_neuron_count - baseline + 1)) adjusted = base_threshold * multiplier return adjusted def prune_weak_neurons(self): """Remove weakly connected or inactive neurons, skipping connectors.""" min_neurons = len(self.original_neuron_positions) current_count = len(self.neuron_positions) - len(self.excluded_neurons) if current_count <= min_neurons: return False candidates = [] for neuron in list(self.neuron_positions.keys()): # Basic exclusions if neuron in self.original_neuron_positions or neuron in self.excluded_neurons: continue # IMMUNITY: Connector neurons are structural and cannot be pruned if self.is_connector_neuron(neuron): continue connections = [abs(w) for (a, b), w in self.weights.items() if (a == neuron or b == neuron)] activity = self.state.get(neuron, 0) activity_score = 0 if isinstance(activity, bool) else abs(activity - 50) if not connections or sum(connections) / len(connections) < 0.2: candidates.append((neuron, 1)) elif activity_score < 10: candidates.append((neuron, 2)) candidates.sort(key=lambda x: x[1]) if candidates: neuron_to_remove = candidates[0][0] if neuron_to_remove in self.neuron_positions: del self.neuron_positions[neuron_to_remove] if neuron_to_remove in self.state: del self.state[neuron_to_remove] if neuron_to_remove in self.neuron_shapes: del self.neuron_shapes[neuron_to_remove] if neuron_to_remove in self.state_colors: del self.state_colors[neuron_to_remove] for conn in list(self.weights.keys()): if isinstance(conn, tuple) and (conn[0] == neuron_to_remove or conn[1] == neuron_to_remove): del self.weights[conn] if neuron_to_remove in self.neurogenesis_data.get('new_neurons', []): self.neurogenesis_data['new_neurons'].remove(neuron_to_remove) reason = "weak connections/activity" self.log_neurogenesis_event(neuron_to_remove, "pruned", reason) self.mark_render_dirty() self.update() return True return False def apply_repulsion_force(self, iterations=15, strength=0.6, threshold=120.0): """Applies repulsion force and enforces boundary constraints from config.""" neuron_props = self.config.neurogenesis.get('neuron_properties', {}) force_bounds = neuron_props.get('force_bounds', True) centering_force = neuron_props.get('centering_force', 0.02) padding = neuron_props.get('canvas_padding', 60) # Logical canvas center center_x, center_y = 512, 384 min_x, max_x = padding, 1024 - padding min_y, max_y = padding, 768 - padding neuron_list = [name for name in self.neuron_positions.keys() if name not in self.excluded_neurons] for _ in range(iterations): displacements = {name: [0.0, 0.0] for name in neuron_list} # 1. Repulsion between neurons for i in range(len(neuron_list)): for j in range(i + 1, len(neuron_list)): neuron1 = neuron_list[i] neuron2 = neuron_list[j] pos1 = self.neuron_positions[neuron1] pos2 = self.neuron_positions[neuron2] dx = pos1[0] - pos2[0] dy = pos1[1] - pos2[1] distance_sq = dx*dx + dy*dy if 0 < distance_sq < threshold*threshold: distance = math.sqrt(distance_sq) force = strength * (threshold - distance) / distance move_x = (dx / distance) * force move_y = (dy / distance) * force displacements[neuron1][0] += move_x displacements[neuron1][1] += move_y displacements[neuron2][0] -= move_x displacements[neuron2][1] -= move_y # 2. Apply movements + Centering Force + Boundary Constraints damping = 0.5 moved = False for name in neuron_list: # Skip core neurons IF you want them fixed, but requirement says randomise start # so we allow them to move, but maybe less? For now, move all. pos = self.neuron_positions[name] disp = displacements[name] # Apply Centering Force (pull towards middle) if centering_force > 0: dir_to_center_x = center_x - pos[0] dir_to_center_y = center_y - pos[1] disp[0] += dir_to_center_x * centering_force disp[1] += dir_to_center_y * centering_force if abs(disp[0]) > 0.1 or abs(disp[1]) > 0.1: new_x = pos[0] + disp[0] * damping new_y = pos[1] + disp[1] * damping # Force Bounds if force_bounds: new_x = max(min_x, min(max_x, new_x)) new_y = max(min_y, min(max_y, new_y)) self.neuron_positions[name] = (new_x, new_y) moved = True if not moved: break if moved: self.update() def update_weights(self): if self.frozen_weights is not None: return # Iterate over a copy of the actual weight keys --- for conn in list(self.weights.keys()): # Check if the connection still exists (it might be pruned concurrently) if conn in self.weights: # Clamp the weight to stay within [-1, 1] self.weights[conn] = max(-1, min(1, self.weights[conn])) def freeze_weights(self): self.frozen_weights = self.weights.copy() def unfreeze_weights(self): self.frozen_weights = None def strengthen_connection(self, neuron1, neuron2, amount): """Strengthen a connection, respecting connector limits for new links.""" pair = (neuron1, neuron2) reverse_pair = (neuron2, neuron1) exists = pair in self.weights or reverse_pair in self.weights use_pair = pair if pair in self.weights else reverse_pair # --- CONNECTOR LIMIT CHECK --- if not exists: if self.is_connector_neuron(neuron1) and self.get_neuron_degree(neuron1) >= 3: return if self.is_connector_neuron(neuron2) and self.get_neuron_degree(neuron2) >= 3: return # If we pass checks, create the connection self.weights[pair] = 0.0 use_pair = pair self.weights[use_pair] += amount self.weights[use_pair] = max(-1, min(1, self.weights[use_pair])) self.mark_render_dirty() self.update() def capture_training_data(self, state): training_sample = [state[neuron] for neuron in self.neuron_positions.keys()] self.training_data.append(training_sample) print("Captured training data:", training_sample) def train_hebbian(self): print("Starting Hebbian training...") for sample in self.training_data: self.associations = self.compute_backend.hebbian( self.associations, sample, self.learning_rate ) self.training_data = [] def get_association_strength(self, neuron1, neuron2): idx1 = list(self.neuron_positions.keys()).index(neuron1) idx2 = list(self.neuron_positions.keys()).index(neuron2) return self.compute_backend.get_value(self.associations, idx1, idx2) def draw_layers(self, painter, scale): """Draw background rectangles for custom layers (matches BrainDesigner).""" if not self.layers: # ← already stored by load_brain_state return for layer in self.layers: y_pos = layer.get('y_position', 0) name = layer.get('name', 'Layer') l_type = layer.get('layer_type', 'hidden') # ---- same metrics as BrainDesigner ---- rect_height = 120 rect_top = y_pos - rect_height / 2 rect_left = -200 rect_width = 2000 # ---- same colours / alpha ---- if l_type == 'input': color = QtGui.QColor(200, 255, 200, 80) border_color = QtGui.QColor(150, 220, 150, 120) elif l_type == 'output': color = QtGui.QColor(255, 200, 200, 80) border_color = QtGui.QColor(220, 150, 150, 120) else: # hidden color = QtGui.QColor(230, 230, 255, 80) border_color = QtGui.QColor(200, 200, 240, 120) # ---- filled background ---- painter.setBrush(QtGui.QBrush(color)) painter.setPen(QtCore.Qt.NoPen) painter.drawRect(QtCore.QRectF(rect_left, rect_top, rect_width, rect_height)) # ---- top & bottom border lines ---- painter.setPen(QtGui.QPen(border_color, 2)) painter.drawLine(QtCore.QLineF(rect_left, rect_top, rect_left + rect_width, rect_top)) painter.drawLine(QtCore.QLineF(rect_left, rect_top + rect_height, rect_left + rect_width, rect_top + rect_height)) # ---- layer label ---- font = painter.font() font.setPointSize(int(10 * scale)) font.setBold(True) painter.setFont(font) painter.setPen(QtGui.QColor(100, 100, 120)) painter.drawText(QtCore.QPointF(10, rect_top + 20), f"{name} ({l_type})") def draw_connections(self, painter, scale): """ Draw connections with extended 2-second weight-change animations. Links are forced INVISIBLE while core neurons are still being revealed. Includes special visualization for Stress->Anxiety inhibitory links. """ if not self.show_links: return # ===== PERFORMANCE FIX: Skip if widget is hidden ===== if not self.isVisible(): return # ===== PERFORMANCE TRACKING ===== if _PERF_TRACKING_AVAILABLE: _conn_start = time.perf_counter() # absolutely no connections until every core neuron is completely revealed (tutorial mode guard). if self.is_tutorial_mode and len(self.visible_neurons) < len(self.original_neurons): return # ===== NEURAL STYLE: Use dedicated neural renderer ===== if self.anim_neural_pulse_enabled: self._draw_neural_connections(painter, scale) return current_time = time.time() connections_drawn = 0 connections_skipped = 0 for key, weight in self.weights.items(): if not isinstance(key, tuple) or len(key) != 2: continue source, target = key # skip tutorial-only connections when not in tutorial tutorial_mode = getattr(self.parent(), 'tutorial_active', False) is_tutorial_conn = (source.startswith('tutorial_neuron_') or target.startswith('tutorial_neuron_')) if is_tutorial_conn and not tutorial_mode: continue # skip if either neuron is still revealing (tutorial safety) if self.is_tutorial_mode and ( not self.is_neuron_revealed(source) or not self.is_neuron_revealed(target)): connections_skipped += 1 continue if (source not in self.neuron_positions or target not in self.neuron_positions or source in self.excluded_neurons or target in self.excluded_neurons): continue # skip connections involving non-visible core neurons (tutorial) if self.is_tutorial_mode: if source in self.original_neurons and source not in self.visible_neurons: connections_skipped += 1 continue if target in self.original_neurons and target not in self.visible_neurons: connections_skipped += 1 continue start = self.neuron_positions[source] end = self.neuron_positions[target] start_point = QtCore.QPointF(float(start[0]), float(start[1])) end_point = QtCore.QPointF(float(end[0]), float(end[1])) # === SPECIAL STYLING: Stress -> Anxiety (Dotted Red with Moving Arrows) === # Detect connection between any stress neuron and anxiety is_stress_to_anxiety = ( (source.lower().startswith('stress') and target.lower() == 'anxiety') or (target.lower().startswith('stress') and source.lower() == 'anxiety')) if is_stress_to_anxiety: # 1. Draw Dotted Red Line # Use a bright red color to indicate inhibitory/stress signal pen = QtGui.QPen(QtGui.QColor(255, 60, 60)) pen.setWidth(max(2, int(3 * scale))) # Thicker than normal for visibility pen.setStyle(QtCore.Qt.DotLine) painter.setPen(pen) painter.drawLine(start_point, end_point) # 2. Draw Moving Arrows (Flowing towards Anxiety) # Determine which end is Anxiety if target.lower() == 'anxiety': actual_start = start_point actual_end = end_point else: actual_start = end_point actual_end = start_point # Vector math for the line dx = actual_end.x() - actual_start.x() dy = actual_end.y() - actual_start.y() length = math.sqrt(dx*dx + dy*dy) if length > 0: # Unit vector ux = dx / length uy = dy / length # Arrow animation parameters arrow_spacing = 40 * scale # Distance between arrows speed = 60 * scale # Pixels per second # Offset based on time to create movement offset = (current_time * speed) % arrow_spacing painter.setBrush(QtGui.QBrush(QtGui.QColor(255, 0, 0))) painter.setPen(QtCore.Qt.NoPen) current_dist = offset while current_dist < length: # Calculate position of arrow center on the line ax = actual_start.x() + ux * current_dist ay = actual_start.y() + uy * current_dist # Draw triangle (Arrowhead) arrow_size = 6 * scale # Perpendicular vector for arrowhead width px = -uy * arrow_size py = ux * arrow_size # Tip of arrow (pointing forward) tip_x = ax + ux * arrow_size tip_y = ay + uy * arrow_size # Base corners of arrow base_x = ax - ux * arrow_size base_y = ay - uy * arrow_size points = [ QtCore.QPointF(tip_x, tip_y), QtCore.QPointF(base_x + px, base_y + py), QtCore.QPointF(base_x - px, base_y - py) ] painter.drawPolygon(QtGui.QPolygonF(points)) current_dist += arrow_spacing # Skip standard drawing logic for this connection connections_drawn += 1 continue # === STANDARD CONNECTION DRAWING === anim_weight = weight base_width = self.anim_line_base_width * scale line_width = base_width pen_style = QtCore.Qt.SolidLine animating = False pulse_progress = 0.0 # check for active weight-change animations (2-second window) for anim in self.weight_animations: if anim['pair'] == key: elapsed = current_time - anim['start_time'] if elapsed < anim['duration']: progress = elapsed / anim['duration'] anim_weight = anim['start_weight'] + progress * ( anim['end_weight'] - anim['start_weight']) # growing / shrinking line width if progress < 0.5: line_width = base_width + (6.0 * scale * progress * 2) else: line_width = base_width + (6.0 * scale * (1 - progress) * 2) pulse_progress = progress * anim['pulse_speed'] animating = True break # colour & alpha if animating: r, g, b = anim['color'] alpha = int(255 * (1 - pulse_progress ** 2)) color = QtGui.QColor(r, g, b, alpha) else: base_alpha = int(self._link_opacities.get(key, 0.0) * 255) # ===== VIBRANT STYLE: Apply ambient pulse ===== if self.anim_ambient_pulse_enabled and base_alpha > 0: pulse_state = self._get_or_create_ambient_pulse(key) phase = pulse_state['phase'] # Sinusoidal oscillation (0 to 1 range) pulse_factor = (math.sin(phase) + 1.0) / 2.0 # Interpolate width w_min, w_max = self.anim_ambient_pulse_width_range width_mult = w_min + pulse_factor * (w_max - w_min) line_width = base_width * width_mult # Interpolate alpha a_min, a_max = self.anim_ambient_pulse_alpha_range pulse_alpha = int(a_min + pulse_factor * (a_max - a_min)) # Combine with base opacity final_alpha = int((base_alpha / 255.0) * pulse_alpha) if weight > 0: color = QtGui.QColor(self.anim_line_col_pos[0], self.anim_line_col_pos[1], self.anim_line_col_pos[2], final_alpha) else: color = QtGui.QColor(self.anim_line_col_neg[0], self.anim_line_col_neg[1], self.anim_line_col_neg[2], final_alpha) else: # Standard coloring (classic style or opacity 0) color = (QtGui.QColor(0, int(255 * abs(weight)), 0, base_alpha) if weight > 0 else QtGui.QColor(int(255 * abs(weight)), 0, 0, base_alpha)) # line style if is_tutorial_conn and tutorial_mode: color = QtGui.QColor(255, 255, 0, 180) line_width = 3 pen_style = QtCore.Qt.DashLine else: pen_style = (QtCore.Qt.DashLine if weight < 0 else QtCore.Qt.SolidLine) if abs(anim_weight) < 0.1: pen_style = QtCore.Qt.DotLine painter.setPen(QtGui.QPen(color, line_width, pen_style)) painter.drawLine(start_point, end_point) # pulse circle travelling along the wire (uses animation style params) if self.anim_pulse_enabled and animating and pulse_progress < 1.0: pulse_pos = pulse_progress pulse_x = start_point.x() + pulse_pos * (end_point.x() - start_point.x()) pulse_y = start_point.y() + pulse_pos * (end_point.y() - start_point.y()) pulse_size = self.anim_pulse_diameter * scale * (1 - pulse_progress ** 2) painter.setBrush(QtGui.QBrush(QtGui.QColor(*self.anim_pulse_colour, self.anim_pulse_alpha))) painter.setPen(QtCore.Qt.NoPen) painter.drawEllipse(QtCore.QPointF(pulse_x, pulse_y), pulse_size, pulse_size) # glow around the line (uses animation style params) if self.anim_glow_enabled and animating and progress < self.anim_glow_fade_threshold: glow_progress = progress / self.anim_glow_fade_threshold glow_width = line_width + 4 * scale * (1 - glow_progress) glow_color = QtGui.QColor(*self.anim_glow_colour, self.anim_glow_alpha) painter.setPen(QtGui.QPen(glow_color, glow_width, pen_style)) painter.drawLine(start_point, end_point) # ===== SUBTLE STYLE: Draw communication glow packets ===== if self.anim_comm_glow_enabled: self._draw_comm_glows_for_connection( painter, scale, key, start_point, end_point ) # weight text (optional) if self.show_weights and abs(weight) > 0.1: # Calculate angle for rotation dx = end_point.x() - start_point.x() dy = end_point.y() - start_point.y() angle_deg = math.degrees(math.atan2(dy, dx)) # Ensure text is readable (not upside down) if angle_deg > 90: angle_deg -= 180 elif angle_deg < -90: angle_deg += 180 midpoint = QtCore.QPointF((start_point.x() + end_point.x()) / 2, (start_point.y() + end_point.y()) / 2) text_str = f"{weight:.2f}" # Font config font_size = max(7, int(8 * scale)) padding = 4 * scale font = painter.font() font.setPointSize(font_size) font.setBold(True) painter.setFont(font) fm = painter.fontMetrics() text_w = fm.horizontalAdvance(text_str) text_h = fm.height() painter.save() painter.translate(midpoint) painter.rotate(angle_deg) # Draw background pill rect = QtCore.QRectF(-text_w/2 - padding, -text_h/2, text_w + padding*2, text_h) painter.setBrush(QtGui.QBrush(QtGui.QColor(255, 255, 255, 220))) painter.setPen(QtGui.QPen(QtGui.QColor(100, 100, 100, 150), 1)) painter.drawRoundedRect(rect, 4, 4) # Draw text text_color = QtGui.QColor(0, 100, 0) if weight >= 0 else QtGui.QColor(150, 0, 0) painter.setPen(text_color) painter.drawText(rect, QtCore.Qt.AlignCenter, text_str) painter.restore() connections_drawn += 1 # debug guard: warn only when something looks wrong if connections_drawn == 0 and len(self.weights) > 0: print(f"🔴 NO CONNECTIONS DRAWN! tutorial_mode={self.is_tutorial_mode}, " f"visible_neurons={len(self.visible_neurons)}, original_neurons={len(self.original_neurons)}, " f"total_weights={len(self.weights)}, skipped={connections_skipped}") # ===== END PERFORMANCE TRACKING ===== if _PERF_TRACKING_AVAILABLE: _conn_elapsed = (time.perf_counter() - _conn_start) * 1000 perf_tracker.record("draw_connections", _conn_elapsed) def _get_logical_coords(self, widget_pos): """ Maps widget pixels back to logical neuron coordinates. MUST MATCH brain_render_worker.py scaling logic exactly. """ # Constants from render worker indicator_space = 0 base_width = 1024 base_height = 768 - indicator_space # Calculate Scale scale_x = self.width() / base_width scale_y = (self.height() - indicator_space) / max(1, base_height) scale = max(0.01, min(scale_x, scale_y)) # Calculate Offset (centering) offset_x = 0 if scale_x > scale_y: content_width = base_width * scale offset_x = (self.width() - content_width) / 2 # Inverse Transform: (Screen - Offset) / Scale = Logical lx = (widget_pos.x() - offset_x) / scale ly = (widget_pos.y() - indicator_space) / scale return QtCore.QPointF(lx, ly) def get_connection_at_pos(self, widget_pos): logical_pos = self._get_logical_coords(widget_pos) threshold = 15.0 # logical pixels tolerance closest_dist = float('inf') closest_conn = None # Helper to convert tuple coords to QPointF to_pt = lambda c: QtCore.QPointF(c[0], c[1]) for (u, v) in self.weights.keys(): # Skip invisible/excluded if u not in self.neuron_positions or v not in self.neuron_positions: continue if u in self.excluded_neurons or v in self.excluded_neurons: continue if not self.show_links: continue # If strictly 2-element tuple p1 = to_pt(self.neuron_positions[u]) p2 = to_pt(self.neuron_positions[v]) d2 = self._dist_to_segment_squared(logical_pos, p1, p2) if d2 < threshold**2 and d2 < closest_dist: closest_dist = d2 closest_conn = (u, v) return closest_conn def _draw_connection_highlight(self, painter): if not self.hovered_connection: return u, v = self.hovered_connection if u not in self.neuron_positions or v not in self.neuron_positions: return indicator_space = 0 base_width = 1024 base_height = 768 - indicator_space scale_x = self.width() / base_width scale_y = (self.height() - indicator_space) / max(1, base_height) scale = max(0.01, min(scale_x, scale_y)) offset_x = 0 if scale_x > scale_y: content_width = base_width * scale offset_x = (self.width() - content_width) / 2 pos1 = self.neuron_positions[u] pos2 = self.neuron_positions[v] # Apply Transform: Logical * Scale + Offset = Screen x1 = pos1[0] * scale + offset_x y1 = pos1[1] * scale + indicator_space x2 = pos2[0] * scale + offset_x y2 = pos2[1] * scale + indicator_space p1 = QtCore.QPointF(x1, y1) p2 = QtCore.QPointF(x2, y2) # Draw Glow/Highlight painter.save() # Yellow, thick, slightly transparent pen = QtGui.QPen(QtGui.QColor(255, 255, 0, 200), 6 * scale) pen.setCapStyle(QtCore.Qt.RoundCap) painter.setPen(pen) painter.drawLine(p1, p2) # Label (Weight Value) weight = self.weights.get(self.hovered_connection, 0.0) mid = (p1 + p2) / 2 font = painter.font() font.setPointSize(max(10, int(12 * scale))) font.setBold(True) painter.setFont(font) label = f"{weight:.2f}" fm = painter.fontMetrics() w = fm.horizontalAdvance(label) + 10 h = fm.height() + 4 r = QtCore.QRectF(mid.x() - w/2, mid.y() - h/2, w, h) painter.setPen(QtCore.Qt.NoPen) painter.setBrush(QtGui.QColor(0, 0, 0, 180)) painter.drawRoundedRect(r, 4, 4) painter.setPen(QtGui.QColor(255, 255, 0)) painter.drawText(r, QtCore.Qt.AlignCenter, label) painter.restore() def _dist_to_segment_squared(self, p, v, w): l2 = (v.x() - w.x())**2 + (v.y() - w.y())**2 if l2 == 0: return (p.x() - v.x())**2 + (p.y() - v.y())**2 t = ((p.x() - v.x()) * (w.x() - v.x()) + (p.y() - v.y()) * (w.y() - v.y())) / l2 t = max(0, min(1, t)) dist_x = p.x() - (v.x() + t * (w.x() - v.x())) dist_y = p.y() - (v.y() + t * (w.y() - v.y())) return dist_x**2 + dist_y**2 def get_neuron_at_pos(self, widget_pos): """Finds a neuron at the given QPoint widget coordinates.""" logical_pos = self._get_logical_coords(widget_pos) neuron_radius = 50 # Increased to 50 for better click detection on all neurons for name, pos in self.neuron_positions.items(): dist_sq = (logical_pos.x() - pos[0])**2 + (logical_pos.y() - pos[1])**2 if dist_sq <= neuron_radius**2: return name return None # ------------------------------------------------------------------ # Binary neuron detection # ------------------------------------------------------------------ def is_binary_neuron(self, name: str) -> bool: """Neurons that are strictly on/off (not continuous stats).""" return name in { 'can_see_food', 'is_eating', 'is_sleeping', 'is_sick', 'pursuing_food', 'is_fleeing', 'is_startled', 'external_stimulus', 'plant_proximity' } # ------------------------------------------------------------------ def paintEvent(self, event): """ Optimized paintEvent that uses cached offscreen render. The heavy rendering is done in BrainRenderWorker. This method just blits the cached image and draws any overlay elements that need to be responsive (like hover effects). """ # Performance tracking if _PERF_TRACKING_AVAILABLE: _paint_start = time.perf_counter() perf_tracker.increment("paint_calls") painter = QtGui.QPainter(self) painter.setRenderHint(QtGui.QPainter.Antialiasing) # Draw cached render if available if self._cached_render and not self._cached_render.isNull(): # Scale cached image to widget size if needed if (self._cached_render.width() != self.width() or self._cached_render.height() != self.height()): # Request new render at correct size self._render_dirty = True # Draw scaled version for now scaled = self._cached_render.scaled( self.width(), self.height(), QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation ) painter.drawImage(0, 0, scaled) else: painter.drawImage(0, 0, self._cached_render) else: # No cached render yet - draw background and request render bg_color = QtGui.QColor(*self.anim_background_colour) painter.fillRect(self.rect(), bg_color) self._render_dirty = True # Draw overlay elements that need immediate response self._draw_overlays(painter) painter.end() # Performance tracking if _PERF_TRACKING_AVAILABLE: _paint_elapsed = (time.perf_counter() - _paint_start) * 1000 perf_tracker.record("paint_event", _paint_elapsed) def _draw_overlays(self, painter): """ Draw overlay elements that need immediate response. These are drawn on top of the cached render. """ # Tutorial glow effect if getattr(self, 'tutorial_glow_active', False): self._draw_tutorial_glow(painter) # Connection Highlight if self.hovered_connection: self._draw_connection_highlight(painter) # Neurogenesis highlights if hasattr(self, 'neurogenesis_highlight'): nh = self.neurogenesis_highlight if nh.get('neuron') and time.time() - nh.get('start_time', 0) < nh.get('duration', 0): self._draw_neurogenesis_highlight(painter) # Drag preview if dragging a neuron if getattr(self, 'dragging', False) and getattr(self, 'dragged_neuron', None): self._draw_drag_preview(painter) def _draw_tutorial_glow(self, painter): """Draw tutorial glow border effect""" opacity = getattr(self, '_tutorial_glow_opacity', 0.0) if opacity > 0: glow_color = QtGui.QColor(255, 215, 0, int(150 * opacity)) pen = QtGui.QPen(glow_color, 4) painter.setPen(pen) painter.setBrush(QtCore.Qt.NoBrush) painter.drawRect(self.rect().adjusted(2, 2, -2, -2)) def _draw_neurogenesis_highlight(self, painter): """Draw highlight around newly created neurons""" # Calculate scale (same as in render worker) indicator_space = 0 base_width = 1024 base_height = 768 - indicator_space scale_x = self.width() / base_width scale_y = (self.height() - indicator_space) / max(1, base_height) scale = max(0.01, min(scale_x, scale_y)) offset_x = 0 if scale_x > scale_y: content_width = base_width * scale offset_x = (self.width() - content_width) / 2 nh = self.neurogenesis_highlight neuron_name = nh.get('neuron') if neuron_name and neuron_name in self.neuron_positions: pos = self.neuron_positions[neuron_name] x = pos[0] * scale + offset_x y = pos[1] * scale + indicator_space # Pulsing effect elapsed = time.time() - nh.get('start_time', 0) pulse = 0.5 + 0.5 * math.sin(elapsed * 4) radius = 40 * scale * (1 + pulse * 0.2) alpha = int(200 * (1 - elapsed / nh.get('duration', 1))) painter.setPen(QtGui.QPen(QtGui.QColor(255, 255, 0, alpha), 3)) painter.setBrush(QtCore.Qt.NoBrush) painter.drawEllipse(QtCore.QPointF(x, y), radius, radius) def _draw_drag_preview(self, painter): """Draw preview when dragging a neuron (runs at full framerate)""" if not self.dragged_neuron or self.dragged_neuron not in self.neuron_positions: return # 1. Calculate Scale (Must match _draw_neurogenesis_highlight and render worker) indicator_space = 0 base_width = 1024 base_height = 768 - indicator_space scale_x = self.width() / base_width scale_y = (self.height() - indicator_space) / max(1, base_height) scale = max(0.01, min(scale_x, scale_y)) # 2. Prepare single-neuron state for static drawing name = self.dragged_neuron pos = self.neuron_positions[name] val = self.state.get(name, 50) temp_positions = {name: pos} temp_states = {name: val} # 3. Draw the neuron immediately (bypassing cached 10fps render) # Note: We don't clear the background, so we draw ON TOP of the cached frame. # This might cause a slight 'trail' behind the moving neuron if the background # refresh is slow, but it guarantees the neuron stays stuck to the mouse cursor. # Check if it has a custom shape shape = self.neuron_shapes.get(name, 'circle') x = pos[0] * scale y = pos[1] * scale radius = 20 * scale # Temporarily set painter to high quality painter.save() painter.setRenderHint(QtGui.QPainter.Antialiasing) # Use existing shape drawing logic if shape == 'diamond': self.draw_diamond_neuron(painter, x, y, radius, name, scale) elif shape == 'square': self.draw_square_neuron(painter, x, y, radius, name, scale) elif shape == 'triangle': self.draw_triangular_neuron(painter, x, y, radius, name, scale) elif shape == 'hexagon': self.draw_hexagon_neuron(painter, x, y, radius, name, scale) else: # Standard/Continuous or Binary # Explicitly call the static method from the Mixin class NetworkRenderingMixin.draw_neurons_static( painter, temp_positions, temp_states, visible_neurons={name}, scale=scale, base_font_size=self.neuron_label_font_size ) painter.restore() def draw_neurons(self, painter, scale=1.0): """Main neuron drawing routine.""" # ------------------------------------------------------------------ # Explicit list of true binary (on/off) neurons # ------------------------------------------------------------------ BINARY_NEURONS = { "can_see_food", "is_eating", "is_sleeping", "is_sick", "is_fleeing", "pursuing_food", "is_startled", "external_stimulus", "plant_proximity" } # Font size for labels - use config value directly label_font = QtGui.QFont("Arial", self.neuron_label_font_size) label_font.setBold(True) painter.setFont(label_font) font_metrics = painter.fontMetrics() for name, pos in self.neuron_positions.items(): if name not in self.visible_neurons and not self.is_tutorial_mode: continue if name in self.excluded_neurons: continue x_logical, y_logical = pos x = x_logical * scale y = y_logical * scale radius = 20 * scale # Check shape early shape = self.neuron_shapes.get(name, 'circle') # --- BINARY NEURONS (always squares) --- if name in BINARY_NEURONS or name == "can_see_food": raw_value = self.state.get(name, 0) # Value calculation logic value = 100.0 if float(raw_value) > 50 else 0.0 is_active = value > 50 color = QtGui.QColor(0, 255, 0) if is_active else QtGui.QColor(255, 0, 0) # Draw Square painter.setBrush(QtGui.QBrush(color)) painter.setPen(QtGui.QPen(QtGui.QColor(0, 0, 0), max(1, int(2 * scale)))) size = radius * 1.8 rect = QtCore.QRectF(x - size/2, y - size/2, size, size) painter.drawRect(rect) # [NEW] Draw Checkmark or Cross inside symbol = "✓" if is_active else "✗" painter.save() symbol_font = QtGui.QFont("Arial", int(size * 0.7)) symbol_font.setBold(True) painter.setFont(symbol_font) painter.setPen(QtGui.QColor(0, 0, 0)) # Black text painter.drawText(rect, QtCore.Qt.AlignCenter, symbol) painter.restore() if name == "can_see_food": # Custom "Small Black Label" Logic for can_see_food # Get Localised Name from .localisation import Localisation loc = Localisation.instance() display_name = loc.get(name) if display_name == name: display_name = name.replace("_", " ").title() # Smaller Font (0.75x) small_font = painter.font() small_font.setPointSize(max(4, int(self.neuron_label_font_size * 0.75 * scale))) painter.setFont(small_font) fm = painter.fontMetrics() text_width = fm.horizontalAdvance(display_name) padding = 4 * scale rect_width = text_width + padding * 2 rect_height = fm.height() + 2 text_rect = QtCore.QRectF( x - rect_width / 2, y + size/2 + 4 * scale, rect_width, rect_height ) # Black Background painter.setBrush(QtGui.QBrush(QtGui.QColor(0, 0, 0))) painter.setPen(QtCore.Qt.NoPen) painter.drawRoundedRect(text_rect, 2, 2) # White Text painter.setPen(QtGui.QColor(255, 255, 255)) painter.drawText(text_rect, QtCore.Qt.AlignCenter, display_name) # Restore Font painter.setFont(label_font) else: # Standard Label for other binary neurons self._draw_standard_label(painter, name, x, y, scale, self.neuron_label_font_size) continue # --- SHAPE-BASED NEURONS --- elif shape == 'hexagon': # --- HEXAGON LOGIC --- color = QtGui.QColor(*self.state_colors.get(name, (160, 32, 240))) # Draw hexagon painter.save() painter.translate(x, y) sides = 6 polygon = QtGui.QPolygonF() angle_step = 360.0 / sides for i in range(sides): angle = math.radians(i * angle_step - 90) polygon.append(QtCore.QPointF(radius * math.cos(angle), radius * math.sin(angle))) painter.setBrush(QtGui.QBrush(color)) painter.setPen(QtGui.QPen(QtGui.QColor(0, 0, 0))) painter.drawPolygon(polygon) # Draw 'C' inside font = QtGui.QFont("Arial", int(14 * scale)) font.setBold(True) painter.setFont(font) painter.setPen(QtGui.QColor(255, 255, 255)) rect_size = radius * 2 rect = QtCore.QRectF(-radius, -radius, rect_size, rect_size) painter.drawText(rect, QtCore.Qt.AlignCenter, "c") painter.restore() # SKIP External Label for connectors continue elif shape == 'diamond': color = QtGui.QColor(*self.state_colors.get(name, (152, 251, 152))) self._draw_polygon_neuron(painter, x, y, 4, radius, color, name, scale, rotation=0) continue elif shape == 'square': color = QtGui.QColor(*self.state_colors.get(name, (152, 251, 152))) self._draw_polygon_neuron(painter, x, y, 4, radius, color, name, scale, rotation=45) continue elif shape == 'triangle': color = QtGui.QColor(*self.state_colors.get(name, (255, 255, 150))) self._draw_polygon_neuron(painter, x, y, 3, radius, color, name, scale, rotation=0) continue else: # circle (default) # Color logic raw_value = self.state.get(name, 0) value = float(raw_value) if isinstance(raw_value, (int, float, bool)) else 50.0 value = max(0, min(100, value)) if name in self.state_colors: color = QtGui.QColor(*self.state_colors[name]) else: color = QtGui.QColor(220, 220, 220) # Grey # Draw Circle painter.setBrush(QtGui.QBrush(color)) painter.setPen(QtGui.QPen(QtGui.QColor(0, 0, 0), max(1, int(2 * scale)))) painter.drawEllipse(QtCore.QPointF(x, y), radius, radius) # Draw Label self._draw_standard_label(painter, name, x, y, scale, self.neuron_label_font_size) def _draw_standard_label(self, painter, name, x, y, scale, font_size=None): """Draw a standard neuron label with improved localisation fallback.""" from .localisation import Localisation loc = Localisation.instance() if font_size is None: font_size = self.neuron_label_font_size # [NEW] Scale font for neurogenesis neurons # If the neuron is NOT in the original list, it is a neurogenesis neuron. is_neurogenesis = name not in self.original_neurons # Apply scaling if it's a neurogenesis neuron (0.75x) effective_font_size = font_size * 0.75 if is_neurogenesis else font_size label_font = QtGui.QFont("Arial", int(effective_font_size * scale)) label_font.setBold(True) painter.setFont(label_font) font_metrics = painter.fontMetrics() # Primary: exact key display_name = loc.get(name) # Fallback 1: try space-separated version if display_name == name: # Means no translation found space_key = name.replace("_", " ") display_name = loc.get(space_key, default=None) if display_name is None: display_name = space_key # Use spaces for readability # Fallback 2: neurogenesis pattern (e.g., novelty_1 → Novelty 1) if display_name == name or display_name == name.replace("_", " "): match = re.match(r"^([a_z]+)_(\d+)$", name) if match: base = match.group(1) idx = match.group(2) base_loc = loc.get(base) if base_loc != base: # Found translation for base display_name = f"{base_loc} {idx}" else: display_name = f"{base.capitalize()} {idx}" # Final fallback: clean title case if display_name == name: display_name = name.replace("_", " ").title() text_width = font_metrics.horizontalAdvance(display_name) padding = 10 * scale rect_width = text_width + padding * 2 rect_height = font_metrics.height() + 4 text_rect = QtCore.QRectF( x - rect_width / 2, y + (20 * scale) + 5 * scale, rect_width, rect_height, ) painter.setBrush(QtGui.QBrush(QtGui.QColor(26, 26, 26, 200))) painter.setPen(QtCore.Qt.NoPen) painter.drawRoundedRect(text_rect, 4, 4) painter.setPen(QtGui.QColor(224, 224, 224)) painter.drawText(text_rect, QtCore.Qt.AlignCenter, display_name) def _draw_neuron_label(self, painter, x, y, name, radius, scale, alpha=255): """Draw neuron label for polygon shapes.""" from .localisation import Localisation loc = Localisation.instance() # [NEW] Scale font for neurogenesis neurons base_size = self.neuron_label_font_size is_neurogenesis = name not in self.original_neurons effective_size = base_size * 0.75 if is_neurogenesis else base_size font = QtGui.QFont("Arial", int(effective_size * scale)) font.setBold(True) painter.setFont(font) fm = painter.fontMetrics() # Step 1: Try exact key label = loc.get(name) # Step 2: Try space-separated key if label == name: space_key = name.replace("_", " ") label = loc.get(space_key) if label == space_key: label = None # Mark as not found # Step 3: Neurogenesis pattern if not label: import re match = re.match(r"^([a-z_]+)_(\d+)$", name) def _draw_neuron_label(self, painter, x, y, name, radius, scale, alpha=255): """Draw neuron label for polygon shapes.""" from .localisation import Localisation loc = Localisation.instance() # [NEW] Scale font for neurogenesis neurons base_size = self.neuron_label_font_size is_neurogenesis = name not in self.original_neurons effective_size = base_size * 0.75 if is_neurogenesis else base_size font = QtGui.QFont("Arial", int(effective_size * scale)) font.setBold(True) painter.setFont(font) fm = painter.fontMetrics() # Step 1: Try exact key label = loc.get(name) # Step 2: Try space-separated key if label == name: space_key = name.replace("_", " ") label = loc.get(space_key) if label == space_key: label = None # Mark as not found # Step 3: Neurogenesis pattern if not label: import re match = re.match(r"^([a-z_]+)_(\d+)$", name) if match: base_key = match.group(1) num = match.group(2) base_label = loc.get(base_key) if base_label != base_key: label = f"{base_label} {num}" else: label = f"{base_key.replace('_', ' ').title()} {num}" # Final fallback if not label: label = name.replace("_", " ").title() text_width = fm.horizontalAdvance(label) padding = 10 * scale # Position label below the neuron (y + radius + padding) rect_y = y + radius + (5 * scale) rect = QtCore.QRectF( x - text_width / 2 - padding, rect_y, text_width + padding * 2, fm.height() + 6 ) # Apply alpha transparency bg_alpha = min(180, alpha) painter.setBrush(QtGui.QBrush(QtGui.QColor(0, 0, 0, bg_alpha))) painter.setPen(QtCore.Qt.NoPen) painter.drawRoundedRect(rect, 6, 6) text_color = QtGui.QColor(255, 255, 255) text_color.setAlpha(alpha) painter.setPen(text_color) painter.drawText(rect, QtCore.Qt.AlignCenter, label) def _draw_standard_label(self, painter, name, x, y, scale, font_size=None): """Draw a standard neuron label with improved localisation fallback.""" from .localisation import Localisation loc = Localisation.instance() if font_size is None: font_size = self.neuron_label_font_size label_font = QtGui.QFont("Arial", int(font_size * scale)) label_font.setBold(True) painter.setFont(label_font) font_metrics = painter.fontMetrics() # Primary: exact key display_name = loc.get(name) # Fallback 1: try space-separated version if display_name == name: # Means no translation found space_key = name.replace("_", " ") display_name = loc.get(space_key, default=None) if display_name is None: display_name = space_key # Use spaces for readability # Fallback 2: neurogenesis pattern (e.g., novelty_1 → Novelty 1) if display_name == name or display_name == name.replace("_", " "): match = re.match(r"^([a_z]+)_(\d+)$", name) if match: base = match.group(1) idx = match.group(2) base_loc = loc.get(base) if base_loc != base: # Found translation for base display_name = f"{base_loc} {idx}" else: display_name = f"{base.capitalize()} {idx}" # Final fallback: clean title case if display_name == name: display_name = name.replace("_", " ").title() text_width = font_metrics.horizontalAdvance(display_name) padding = 10 * scale rect_width = text_width + padding * 2 rect_height = font_metrics.height() + 4 text_rect = QtCore.QRectF( x - rect_width / 2, y + (20 * scale) + 5 * scale, rect_width, rect_height, ) painter.setBrush(QtGui.QBrush(QtGui.QColor(26, 26, 26, 200))) painter.setPen(QtCore.Qt.NoPen) painter.drawRoundedRect(text_rect, 4, 4) painter.setPen(QtGui.QColor(224, 224, 224)) painter.drawText(text_rect, QtCore.Qt.AlignCenter, display_name) def draw_binary_neuron(self, painter, x, y, value, label, scale=1.0): color = (0, 0, 0) if value else (255, 255, 255) painter.setBrush(QtGui.QBrush(QtGui.QColor(*color))); painter.setPen(QtGui.QPen(QtGui.QColor(0, 0, 0))) x_scaled, y_scaled, size = int(x * scale), int(y * scale), int(30 * scale) painter.drawRect(x_scaled - size//2, y_scaled - size//2, size, size) font = painter.font(); font.setPointSize(max(4, int(5 * scale))); painter.setFont(font) # Reduced from 6, added max label_width, label_height = int(150 * scale), int(20 * scale) painter.drawText(x_scaled - label_width//2, y_scaled + int(30 * scale), label_width, label_height, QtCore.Qt.AlignCenter, label) def draw_circular_neuron(self, painter, name, pos, value, scale=1.0, visible_neurons=None, excluded_neurons=None): """Fixed version with correct excluded_neurons handling.""" if visible_neurons is None: visible_neurons = self.visible_neurons if excluded_neurons is None: excluded_neurons = self.excluded_neurons if name in excluded_neurons or name not in visible_neurons: return # Reuse static method for consistency temp_states = {name: value} temp_positions = {name: pos} NetworkRenderingMixin.draw_neurons_static( painter, temp_positions, temp_states, visible_neurons={name}, excluded_neurons=excluded_neurons, scale=scale, base_font_size=self.neuron_label_font_size ) def draw_triangular_neuron(self, painter, x, y, radius, label, scale=1.0, alpha=255): """Draw a triangular neuron with given radius""" color = QtGui.QColor(*self.state_colors.get(label, (255, 255, 150))) self._draw_polygon_neuron(painter, x, y, 3, radius, color, label, scale, alpha=alpha) def draw_hexagon_neuron(self, painter, x, y, radius, label, scale=1.0, alpha=255): """Draw a purple hexagon neuron with a 'C' inside and NO external label""" # 1. Setup Painter painter.save() painter.translate(x, y) # Color setup (Purple) color_tuple = self.state_colors.get(label, (160, 32, 240)) color = QtGui.QColor(*color_tuple) color.setAlpha(min(255, max(0, alpha))) pen_color = QtGui.QColor(0, 0, 0) pen_color.setAlpha(min(255, max(0, alpha))) painter.setBrush(QtGui.QBrush(color)) painter.setPen(QtGui.QPen(pen_color)) # 2. Draw Hexagon Polygon sides = 6 polygon = QtGui.QPolygonF() angle_step = 360.0 / sides for i in range(sides): angle = math.radians(i * angle_step - 90) polygon.append(QtCore.QPointF(radius * math.cos(angle), radius * math.sin(angle))) painter.drawPolygon(polygon) # 3. Draw the 'C' inside font_size = int(14 * scale) font = QtGui.QFont("Arial", font_size) font.setBold(True) painter.setFont(font) # White text painter.setPen(QtGui.QColor(255, 255, 255, min(255, max(0, alpha)))) rect_size = radius * 2 rect = QtCore.QRectF(-radius, -radius, rect_size, rect_size) painter.drawText(rect, QtCore.Qt.AlignCenter, "c") painter.restore() def show_diagnostic_report(self): if hasattr(self, 'brain_widget'): self.brain_widget.show_diagnostic_report() else: print("Error: Brain widget not initialized") def _draw_polygon_neuron(self, painter, x, y, sides, radius, color, label, scale, rotation=0, alpha=255): painter.save() painter.translate(x, y) painter.rotate(rotation) alpha = max(0, min(255, alpha)) # ← clamp colored = QtGui.QColor(color) colored.setAlpha(alpha) pen_color = QtGui.QColor(0, 0, 0) pen_color.setAlpha(alpha) painter.setBrush(QtGui.QBrush(colored)) painter.setPen(QtGui.QPen(pen_color)) polygon = QtGui.QPolygonF() angle_step = 360.0 / sides for i in range(sides): angle = math.radians(i * angle_step - 90) polygon.append(QtCore.QPointF(radius * math.cos(angle), radius * math.sin(angle))) painter.drawPolygon(polygon) painter.restore() self._draw_neuron_label(painter, x, y, label, radius, scale, alpha) def _draw_neuron_label(self, painter, x, y, name, radius, scale, alpha=255): """Draw neuron label with robust localisation fallback.""" from .localisation import Localisation loc = Localisation.instance() font = QtGui.QFont("Arial", int(self.neuron_label_font_size * scale)) font.setBold(True) painter.setFont(font) fm = painter.fontMetrics() # Step 1: Try exact key label = loc.get(name) # Step 2: Try space-separated key if label == name: space_key = name.replace("_", " ") label = loc.get(space_key) if label == space_key: label = None # Mark as not found # Step 3: Neurogenesis pattern if not label: import re match = re.match(r"^([a-z_]+)_(\d+)$", name) if match: base_key = match.group(1) num = match.group(2) base_label = loc.get(base_key) if base_label != base_key: label = f"{base_label} {num}" else: label = f"{base_key.replace('_', ' ').title()} {num}" # Final fallback if not label: label = name.replace("_", " ").title() text_width = fm.horizontalAdvance(label) padding = 10 * scale # Position label below the neuron (y + radius + padding) rect_y = y + radius + (5 * scale) rect = QtCore.QRectF( x - text_width / 2 - padding, rect_y, text_width + padding * 2, fm.height() + 6 ) # Apply alpha transparency bg_alpha = min(180, alpha) painter.setBrush(QtGui.QBrush(QtGui.QColor(0, 0, 0, bg_alpha))) painter.setPen(QtCore.Qt.NoPen) painter.drawRoundedRect(rect, 6, 6) text_color = QtGui.QColor(255, 255, 255) text_color.setAlpha(alpha) painter.setPen(text_color) painter.drawText(rect, QtCore.Qt.AlignCenter, label) def draw_neurogenesis_highlights(self, painter, scale): if (self.neurogenesis_highlight['neuron'] and time.time() - self.neurogenesis_highlight['start_time'] < self.neurogenesis_highlight['duration']): pos = self.neuron_positions.get(self.neurogenesis_highlight['neuron']) if pos: painter.setPen(QtGui.QPen(QtGui.QColor(255, 255, 0), int(3 * scale))); painter.setBrush(QtCore.Qt.NoBrush); radius = int(40 * scale) x, y, width, height = int(pos[0] - radius), int(pos[1] - radius), int(radius * 2), int(radius * 2) painter.drawEllipse(x, y, width, height) def draw_square_neuron(self, painter, x, y, radius, label, scale=1.0, alpha=255): """Draw a square neuron with given radius""" color = QtGui.QColor(*self.state_colors.get(label, (152, 251, 152))) self._draw_polygon_neuron(painter, x, y, 4, radius, color, label, scale, rotation=45, alpha=alpha) def draw_diamond_neuron(self, painter, x, y, radius, label, scale=1.0, alpha=255): """Draw a diamond-shaped neuron with given radius""" color = QtGui.QColor(*self.state_colors.get(label, (152, 251, 152))) self._draw_polygon_neuron(painter, x, y, 4, radius, color, label, scale, rotation=0, alpha=alpha) def toggle_links(self, state): """ Public slot connected to the checkbox. Triggers a staggered, organic fade animation for connections. """ from PyQt5 import QtCore import time import random want_visible = (state == QtCore.Qt.Checked) # CRITICAL: We must enable the render loop immediately so the fade animation can play if want_visible: self.show_links = True now = time.time() # Calculate staggering based on number of connections to ensure smooth waves count = len(self.weights) for i, key in enumerate(self.weights.keys()): self._link_targets[key] = 1.0 if want_visible else 0.0 # Organic staggering: 0.0 to 1.2 seconds delay based on randomness # This creates the \"not all at once\" effect delay = random.uniform(0.0, 1.2) self._link_start_times[key] = now + delay self._link_fade_speeds[key] = random.uniform(1.5, 4.0) # Variable fade speeds # Initialize opacity if tracking is missing if key not in self._link_opacities: self._link_opacities[key] = 0.0 if want_visible else 1.0 # Ensure the fade timer is running to process the animation frames if not self._link_fade_timer.isActive(): self._link_fade_timer.start() self.mark_render_dirty() def toggle_weights(self, state): self.show_weights = (state == QtCore.Qt.Checked) self.mark_render_dirty() self.update() def toggle_capture_training_data(self, state): self.capture_training_data_enabled = state def mousePressEvent(self, event): if event.button() != QtCore.Qt.LeftButton: super().mousePressEvent(event) return logical_pos = self._get_logical_coords(event.pos()) neuron = self.get_neuron_at_pos(event.pos()) if not neuron: self.dragged_neuron = None self.dragging = False super().mousePressEvent(event) return # Allow dragging any neuron self.dragged_neuron = neuron self.dragging = True self.drag_start_pos = event.pos() # Debug info is_neuro = self.is_neurogenesis_neuron(neuron) neuron_type = "Neurogenesis" if is_neuro else "Core" # Debug: Check if neuron is in functional_neurons if hasattr(self, 'enhanced_neurogenesis') and neuron in self.enhanced_neurogenesis.functional_neurons: print(f" Functional neuron details: {self.enhanced_neurogenesis.functional_neurons[neuron]}") self.update() super().mousePressEvent(event) def handle_neuron_clicked(self, neuron_name): print(f"") def show_diagnostic_report(self): dialog = DiagnosticReportDialog(self, self.parent()) dialog.exec_() def mouseMoveEvent(self, event): """Handle mouse movement for tooltips, hover tracking, and dragging""" if hasattr(self, 'tooltip_manager'): self.tooltip_manager.show_tooltip_for_position(event) # 1. Track hover state for neurons neuron = self.get_neuron_at_pos(event.pos()) # Determine if neuron hover state changed if neuron != self.hovered_neuron: self.hovered_neuron = neuron self.hover_animation_time = time.time() self.hover_value_opacity = 0.0 self.hover_value_display_active = neuron is not None self.update() # 2. [NEW] Track hover state for connections (only if not hovering a neuron) if not neuron: conn = self.get_connection_at_pos(event.pos()) if conn != self.hovered_connection: self.hovered_connection = conn self.update() # Trigger repaint for overlay else: # If hovering neuron, clear connection highlight if self.hovered_connection is not None: self.hovered_connection = None self.update() # 3. Dragging logic if getattr(self, 'dragging', False) and getattr(self, 'dragged_neuron', None): if self.is_neurogenesis_neuron(self.dragged_neuron): logical_pos = self._get_logical_coords(event.pos()) self.neuron_positions[self.dragged_neuron] = (logical_pos.x(), logical_pos.y()) self.update() super().mouseMoveEvent(event) def leaveEvent(self, event): """Clear hover state when mouse leaves widget.""" if self.hovered_neuron is not None: self.hovered_neuron = None self.hover_value_display_active = False self.hover_value_opacity = 0.0 self.update() super().leaveEvent(event) def mouseDoubleClickEvent(self, event): """ Handle double-click on a neuron to open the Neuron Laboratory instead of the Inspector. """ if event.button() == QtCore.Qt.LeftButton: neuron_name = self.get_neuron_at_pos(event.pos()) if neuron_name: print(f"Double-clicked on neuron: {neuron_name}") # Close the old Inspector if it's currently open (compatibility cleanup) if hasattr(self, '_inspector') and self._inspector and self._inspector.isVisible(): self._inspector.close() self._inspector = None # Clear the old reference # Open or show the Neuron Laboratory if self._laboratory is None: self._laboratory = NeuronLaboratory(self, parent=self.window() or self.parent()) self._laboratory.show() self._laboratory.raise_() # Bring the window to the front # CALL A NEW METHOD: Pass the neuron name for selection if hasattr(self._laboratory, 'select_neuron_by_name'): self._laboratory.select_neuron_by_name(neuron_name) self.update() # Redraw the brain visualization event.accept() return super().mouseDoubleClickEvent(event) def mouseReleaseEvent(self, event): if event.button() == QtCore.Qt.LeftButton: is_click = False if self.dragged_neuron and self.drag_start_pos: distance = (event.pos() - self.drag_start_pos).manhattanLength() if distance < QtWidgets.QApplication.startDragDistance(): is_click = True if is_click: self.neuronClicked.emit(self.dragged_neuron) self.dragging = False self.dragged_neuron = None self.drag_start_pos = None self.update() # Only apply repulsion to neurogenesis neurons #if hasattr(self, 'enhanced_neurogenesis'): # self.apply_repulsion_force() super().mouseReleaseEvent(event) def draw_strength_multiplier(self, painter, scale): """ Draw strength multipliers on neurons that have been strengthened. Displays as "2.5x" above strengthened neurons with a badge. """ if not hasattr(self, 'enhanced_neurogenesis'): return current_time = time.time() # Save painter state to avoid interfering with other drawings painter.save() for name, neuron in self.enhanced_neurogenesis.functional_neurons.items(): # Verify attribute exists and neuron is strengthened if not hasattr(neuron, 'strength_multiplier'): continue if neuron.strength_multiplier <= 1.0 or name not in self.neuron_positions: continue pos = self.neuron_positions[name] x, y = pos # Position ABOVE the neuron (in logical coordinates) label_y = y - 45 # Don't scale Y offset - already in logical space # Format multiplier text multiplier_text = f"{neuron.strength_multiplier:.1f}x" # FIX: typo here # Create font with proper scaling font = QtGui.QFont() font.setPointSize(int(9 * scale)) font.setBold(True) painter.setFont(font) # Draw background circle/badge font_metrics = painter.fontMetrics() text_width = font_metrics.horizontalAdvance(multiplier_text) badge_radius = max(12 * scale, (text_width / 2) + 5 * scale) painter.setBrush(QtGui.QBrush(QtGui.QColor(255, 215, 0, 220))) painter.setPen(QtGui.QPen(QtGui.QColor(139, 105, 20), max(1, int(2 * scale)))) painter.drawEllipse(QtCore.QPointF(x, label_y), badge_radius, badge_radius) # Draw text painter.setPen(QtGui.QColor(0, 0, 0)) painter.drawText( int(x - text_width / 2), int(label_y - font_metrics.height() / 2), int(text_width), int(font_metrics.height()), QtCore.Qt.AlignCenter, multiplier_text ) # Optional: Add star indicator star_radius = 8 * scale star_x = x + 20 * scale star_y = y - 20 * scale painter.setBrush(QtGui.QBrush(QtGui.QColor(255, 215, 0, 200))) painter.setPen(QtGui.QPen(QtGui.QColor(255, 165, 0), max(1, int(2 * scale)))) painter.drawEllipse(QtCore.QPointF(star_x, star_y), star_radius, star_radius) painter.restore() def _is_click_on_neuron(self, point, neuron_pos, scale): neuron_x, neuron_y = neuron_pos scaled_x = neuron_x * scale; scaled_y = neuron_y * scale return (abs(scaled_x - point.x()) <= 25 * scale and abs(scaled_y - point.y()) <= 25 * scale) def is_point_inside_neuron(self, point, neuron_pos, scale): neuron_x, neuron_y = neuron_pos scaled_x = neuron_x * scale; scaled_y = neuron_y * scale return ((scaled_x - 25 * scale) <= point.x() <= (scaled_x + 25 * scale) and (scaled_y - 25 * scale) <= point.y() <= (scaled_y + 25 * scale)) def reset_positions(self): self.neuron_positions = self.original_neuron_positions.copy() # Check config for randomization preference neuron_props = self.config.neurogenesis.get('neuron_properties', {}) if neuron_props.get('randomize_start_positions', False): self._randomize_all_positions() self.update() def _randomize_all_positions(self): """Randomize positions of all neurons within safe bounds.""" import random padding = self.config.neurogenesis.get('neuron_properties', {}).get('canvas_padding', 60) # Canvas dimensions (assumed roughly 1024x768 logical) min_x, max_x = padding, 1024 - padding min_y, max_y = padding, 768 - padding for name in self.neuron_positions: rx = random.randint(min_x, max_x) ry = random.randint(min_y, max_y) self.neuron_positions[name] = (rx, ry) print("🎲 Randomized neuron positions") def start_tutorial_glow(self, duration_ms=5000): """Start a glowing, pulsing border effect for tutorial purposes""" self.tutorial_glow_active = True self._tutorial_glow_opacity = 0.0 # Create animation for pulsing effect self.tutorial_glow_animation = QtCore.QPropertyAnimation(self, b"tutorial_glow_opacity") self.tutorial_glow_animation.setDuration(500) # 0.5 second pulse self.tutorial_glow_animation.setStartValue(0.0) self.tutorial_glow_animation.setEndValue(1.0) self.tutorial_glow_animation.setLoopCount(-1) # Infinite loop self.tutorial_glow_animation.setEasingCurve(QtCore.QEasingCurve.InOutSine) # Start the animation self.tutorial_glow_animation.start() # Set timer to stop after duration self.tutorial_glow_timer = QtCore.QTimer() self.tutorial_glow_timer.setSingleShot(True) self.tutorial_glow_timer.timeout.connect(self.stop_tutorial_glow) self.tutorial_glow_timer.start(duration_ms) def stop_tutorial_glow(self): """Stop the tutorial glow effect""" self.tutorial_glow_active = False if self.tutorial_glow_animation: self.tutorial_glow_animation.stop() self.tutorial_glow_animation = None if self.tutorial_glow_timer: self.tutorial_glow_timer.stop() self.tutorial_glow_timer = None self.update() def get_tutorial_glow_opacity(self): """Get current glow opacity (for Qt property animation)""" return self._tutorial_glow_opacity def set_tutorial_glow_opacity(self, value): """Set glow opacity and trigger repaint (for Qt property animation)""" self._tutorial_glow_opacity = value self.update() # Qt property for animation tutorial_glow_opacity = QtCore.pyqtProperty(float, get_tutorial_glow_opacity, set_tutorial_glow_opacity) import time class PerformanceProfiler: """Lightweight profiler for identifying slow code paths.""" def __init__(self): self.timings = {} self.call_counts = {} def start(self, name): if name not in self.timings: self.timings[name] = [] self.call_counts[name] = 0 self._current_start = time.perf_counter() self._current_name = name def stop(self): elapsed = (time.perf_counter() - self._current_start) * 1000 self.timings[self._current_name].append(elapsed) self.call_counts[self._current_name] += 1 # Keep only last 100 samples if len(self.timings[self._current_name]) > 100: self.timings[self._current_name].pop(0) def report(self): """Print performance report.""" print("\n" + "="*60) print("PERFORMANCE REPORT") print("="*60) for name, times in sorted(self.timings.items()): if times: avg = sum(times) / len(times) max_t = max(times) calls = self.call_counts[name] print(f"{name:30} avg={avg:6.2f}ms max={max_t:6.2f}ms calls={calls}") print("="*60 + "\n") # ============================================================================= # NETWORK RENDERING MIXIN (Add to brain_widget.py) # ============================================================================= class NetworkRenderingMixin: """ Mixin providing static network rendering methods extracted from BrainWidget. Used by both BrainWidget and save viewer's NetworkCanvas for consistent visuals. """ @staticmethod def draw_connections(self, painter, scale): """Draw connections with extended 2-second weight-change animations. Links are forced INVISIBLE while core neurons are still being revealed.""" if not self.show_links: return # ===== PERFORMANCE FIX: Skip if widget is hidden ===== if not self.isVisible(): return # ===== PERFORMANCE TRACKING ===== if _PERF_TRACKING_AVAILABLE: _conn_start = time.perf_counter() # absolutely no connections until every core neuron is completely revealed (tutorial mode guard). if self.is_tutorial_mode and len(self.visible_neurons) < len(self.original_neurons): return # ===== NEURAL STYLE: Use dedicated neural renderer ===== if self.anim_neural_pulse_enabled: self._draw_neural_connections(painter, scale) return current_time = time.time() connections_drawn = 0 connections_skipped = 0 for key, weight in self.weights.items(): if not isinstance(key, tuple) or len(key) != 2: continue source, target = key # skip tutorial-only connections when not in tutorial tutorial_mode = getattr(self.parent(), 'tutorial_active', False) is_tutorial_conn = (source.startswith('tutorial_neuron_') or target.startswith('tutorial_neuron_')) if is_tutorial_conn and not tutorial_mode: continue # skip if either neuron is still revealing (tutorial safety) if self.is_tutorial_mode and ( not self.is_neuron_revealed(source) or not self.is_neuron_revealed(target)): connections_skipped += 1 continue if (source not in self.neuron_positions or target not in self.neuron_positions or source in self.excluded_neurons or target in self.excluded_neurons): continue # skip connections involving non-visible core neurons (tutorial) if self.is_tutorial_mode: if source in self.original_neurons and source not in self.visible_neurons: connections_skipped += 1 continue if target in self.original_neurons and target not in self.visible_neurons: connections_skipped += 1 continue start = self.neuron_positions[source] end = self.neuron_positions[target] start_point = QtCore.QPointF(float(start[0]), float(start[1])) end_point = QtCore.QPointF(float(end[0]), float(end[1])) # special styling: Stress ↔ Anxiety (uses animation style params) is_stress_to_anxiety = ( (source.lower().startswith('stress') and target.lower() == 'anxiety') or (target.lower().startswith('stress') and source.lower() == 'anxiety')) if is_stress_to_anxiety: pen = QtGui.QPen(QtGui.QColor(*self.anim_stress_colour)) pen.setWidth(int(self.anim_stress_width)) if self.anim_stress_dashed: pen.setStyle(QtCore.Qt.DashLine) painter.setPen(pen) painter.drawLine(start_point, end_point) continue # skip default drawing for this pair # default appearance (uses animation style params) anim_weight = weight base_width = self.anim_line_base_width * scale line_width = base_width pen_style = QtCore.Qt.SolidLine animating = False pulse_progress = 0.0 # check for active weight-change animations (2-second window) for anim in self.weight_animations: if anim['pair'] == key: elapsed = current_time - anim['start_time'] if elapsed < anim['duration']: progress = elapsed / anim['duration'] anim_weight = anim['start_weight'] + progress * ( anim['end_weight'] - anim['start_weight']) # growing / shrinking line width if progress < 0.5: line_width = base_width + (6.0 * scale * progress * 2) else: line_width = base_width + (6.0 * scale * (1 - progress) * 2) pulse_progress = progress * anim['pulse_speed'] animating = True break # colour & alpha if animating: r, g, b = anim['color'] alpha = int(255 * (1 - pulse_progress ** 2)) color = QtGui.QColor(r, g, b, alpha) else: base_alpha = int(self._link_opacities.get(key, 0.0) * 255) # ===== VIBRANT STYLE: Apply ambient pulse ===== if self.anim_ambient_pulse_enabled and base_alpha > 0: pulse_state = self._get_or_create_ambient_pulse(key) phase = pulse_state['phase'] # Sinusoidal oscillation (0 to 1 range) pulse_factor = (math.sin(phase) + 1.0) / 2.0 # Interpolate width w_min, w_max = self.anim_ambient_pulse_width_range width_mult = w_min + pulse_factor * (w_max - w_min) line_width = base_width * width_mult # Interpolate alpha a_min, a_max = self.anim_ambient_pulse_alpha_range pulse_alpha = int(a_min + pulse_factor * (a_max - a_min)) # Combine with base opacity final_alpha = int((base_alpha / 255.0) * pulse_alpha) if weight > 0: color = QtGui.QColor(self.anim_line_col_pos[0], self.anim_line_col_pos[1], self.anim_line_col_pos[2], final_alpha) else: color = QtGui.QColor(self.anim_line_col_neg[0], self.anim_line_col_neg[1], self.anim_line_col_neg[2], final_alpha) else: # Standard coloring (classic style or opacity 0) color = (QtGui.QColor(0, int(255 * abs(weight)), 0, base_alpha) if weight > 0 else QtGui.QColor(int(255 * abs(weight)), 0, 0, base_alpha)) # line style if is_tutorial_conn and tutorial_mode: color = QtGui.QColor(255, 255, 0, 180) line_width = 3 pen_style = QtCore.Qt.DashLine else: pen_style = (QtCore.Qt.DashLine if weight < 0 else QtCore.Qt.SolidLine) if abs(anim_weight) < 0.1: pen_style = QtCore.Qt.DotLine painter.setPen(QtGui.QPen(color, line_width, pen_style)) painter.drawLine(start_point, end_point) # pulse circle travelling along the wire (uses animation style params) if self.anim_pulse_enabled and animating and pulse_progress < 1.0: pulse_pos = pulse_progress pulse_x = start_point.x() + pulse_pos * (end_point.x() - start_point.x()) pulse_y = start_point.y() + pulse_pos * (end_point.y() - start_point.y()) pulse_size = self.anim_pulse_diameter * scale * (1 - pulse_progress ** 2) painter.setBrush(QtGui.QBrush(QtGui.QColor(*self.anim_pulse_colour, self.anim_pulse_alpha))) painter.setPen(QtCore.Qt.NoPen) painter.drawEllipse(QtCore.QPointF(pulse_x, pulse_y), pulse_size, pulse_size) # glow around the line (uses animation style params) if self.anim_glow_enabled and animating and progress < self.anim_glow_fade_threshold: glow_progress = progress / self.anim_glow_fade_threshold glow_width = line_width + 4 * scale * (1 - glow_progress) glow_color = QtGui.QColor(*self.anim_glow_colour, self.anim_glow_alpha) painter.setPen(QtGui.QPen(glow_color, glow_width, pen_style)) painter.drawLine(start_point, end_point) # ===== SUBTLE STYLE: Draw communication glow packets ===== if self.anim_comm_glow_enabled: self._draw_comm_glows_for_connection( painter, scale, key, start_point, end_point ) # weight text (optional) if self.show_weights and abs(weight) > 0.1: # Calculate angle for rotation dx = end_point.x() - start_point.x() dy = end_point.y() - start_point.y() angle_deg = math.degrees(math.atan2(dy, dx)) # Ensure text is readable (not upside down) if angle_deg > 90: angle_deg -= 180 elif angle_deg < -90: angle_deg += 180 midpoint = QtCore.QPointF((start_point.x() + end_point.x()) / 2, (start_point.y() + end_point.y()) / 2) text_str = f"{weight:.2f}" # Font config font_size = max(7, int(8 * scale)) padding = 4 * scale font = painter.font() font.setPointSize(font_size) font.setBold(True) painter.setFont(font) fm = painter.fontMetrics() text_w = fm.horizontalAdvance(text_str) text_h = fm.height() painter.save() painter.translate(midpoint) painter.rotate(angle_deg) # Draw background pill rect = QtCore.QRectF(-text_w/2 - padding, -text_h/2, text_w + padding*2, text_h) painter.setBrush(QtGui.QBrush(QtGui.QColor(255, 255, 255, 220))) painter.setPen(QtGui.QPen(QtGui.QColor(100, 100, 100, 150), 1)) painter.drawRoundedRect(rect, 4, 4) # Draw text text_color = QtGui.QColor(0, 100, 0) if weight >= 0 else QtGui.QColor(150, 0, 0) painter.setPen(text_color) painter.drawText(rect, QtCore.Qt.AlignCenter, text_str) painter.restore() connections_drawn += 1 # debug guard: warn only when something looks wrong if connections_drawn == 0 and len(self.weights) > 0: print(f"🔴 NO CONNECTIONS DRAWN! tutorial_mode={self.is_tutorial_mode}, " f"visible_neurons={len(self.visible_neurons)}, original_neurons={len(self.original_neurons)}, " f"total_weights={len(self.weights)}, skipped={connections_skipped}") # ===== END PERFORMANCE TRACKING ===== if _PERF_TRACKING_AVAILABLE: _conn_elapsed = (time.perf_counter() - _conn_start) * 1000 perf_tracker.record("draw_connections", _conn_elapsed) @staticmethod def draw_neurons_static( painter, neuron_positions, neuron_states, visible_neurons=None, excluded_neurons=None, scale=1.0, base_font_size=6): """ Static neuron drawing with full localisation fallback support. """ import re from .localisation import Localisation loc = Localisation.instance() BINARY_NEURONS = { "can_see_food", "is_eating", "is_sleeping", "is_sick", "is_fleeing", "pursuing_food", "is_startled" } if visible_neurons is None: visible_neurons = set(neuron_positions.keys()) if excluded_neurons is None: excluded_neurons = set() label_font = QtGui.QFont("Arial", int(base_font_size * scale)) label_font.setBold(True) painter.setFont(label_font) font_metrics = painter.fontMetrics() for name, pos in neuron_positions.items(): if name in excluded_neurons or name not in visible_neurons: continue raw_value = neuron_states.get(name, 0) # Determine color and value (binary vs continuous) if name in BINARY_NEURONS: value = 100.0 if float(raw_value) > 50 else 0.0 is_active = value > 50 color = QtGui.QColor(0, 255, 0) if is_active else QtGui.QColor(255, 0, 0) else: value = float(raw_value) if isinstance(raw_value, (int, float, bool)) else 50.0 value = max(0, min(100, value)) normalized = value / 100.0 if normalized > 0.7: color = QtGui.QColor(76, 175, 80) elif normalized > 0.4: color = QtGui.QColor(255, 193, 7) else: color = QtGui.QColor(244, 67, 54) x = pos[0] * scale y = pos[1] * scale radius = 20 * scale painter.setBrush(QtGui.QBrush(color)) painter.setPen(QtGui.QPen(QtGui.QColor(0, 0, 0), max(1, int(2 * scale)))) painter.drawEllipse(QtCore.QPointF(x, y), radius, radius) # === Localisation with multi-level fallback === display_name = loc.get(name) # Fallback 1: space-separated key if display_name == name: space_key = name.replace("_", " ") display_name = loc.get(space_key) if display_name == space_key: display_name = None # Fallback 2: neurogenesis numbered neuron if not display_name: match = re.match(r"^([a-z_]+)_(\d+)$", name) if match: base = match.group(1) idx = match.group(2) base_loc = loc.get(base) if base_loc != base: display_name = f"{base_loc} {idx}" else: display_name = f"{base.replace('_', ' ').title()} {idx}" # Final fallback if not display_name: display_name = name.replace("_", " ").title() text_width = font_metrics.horizontalAdvance(display_name) padding = 10 * scale rect_width = text_width + padding * 2 rect_height = font_metrics.height() + 4 text_rect = QtCore.QRectF( x - rect_width / 2, y + radius + 5 * scale, rect_width, rect_height, ) painter.setBrush(QtGui.QBrush(QtGui.QColor(26, 26, 26, 200))) painter.setPen(QtCore.Qt.NoPen) painter.drawRoundedRect(text_rect, 4, 4) painter.setPen(QtGui.QColor(224, 224, 224)) painter.drawText(text_rect, QtCore.Qt.AlignCenter, display_name) @staticmethod def draw_layers_static(painter, layers, scale=1.0): """Draw layer background rectangles if layer structure exists""" if not layers: return for layer in layers: y_pos = layer.get('y_position', 0) name = layer.get('name', 'Layer') layer_type = layer.get('layer_type', 'hidden') # Logical dimensions (match brain_widget's coordinate system) rect_height = 120 rect_top = (y_pos - rect_height / 2) rect_left = -200 # Extend beyond visible area rect_width = 2000 # Determine layer color based on type if layer_type == 'input': color = QtGui.QColor(220, 255, 220, 30) border_color = QtGui.QColor(180, 220, 180, 60) elif layer_type == 'output': color = QtGui.QColor(255, 220, 220, 30) border_color = QtGui.QColor(220, 180, 180, 60) else: # hidden color = QtGui.QColor(230, 230, 255, 40) border_color = QtGui.QColor(200, 200, 240, 60) # Draw rectangle rect = QtCore.QRectF(rect_left, rect_top, rect_width, rect_height) painter.setBrush(QtGui.QBrush(color)) painter.setPen(QtGui.QPen(border_color, 1, QtCore.Qt.DashLine)) painter.drawRect(rect) # Draw label font = QtGui.QFont("Arial", int(10 * scale)) font.setBold(True) painter.setFont(font) painter.setPen(QtGui.QColor(150, 150, 170)) painter.drawText(QtCore.QPointF(20, rect_top + 20), name) ================================================ FILE: src/brain_worker.py ================================================ import sys import time import random import traceback import math from queue import Queue, Empty from heapq import nlargest from PyQt5.QtCore import QThread, pyqtSignal, QMutex, QMutexLocker, QWaitCondition class BrainWorker(QThread): """ Background worker for handling expensive brain logic operations: - Neurogenesis checks - Hebbian learning calculations - State decay and update processing """ # Signals for results (emitted to main thread) neurogenesis_result = pyqtSignal(dict) hebbian_result = pyqtSignal(dict) state_update_result = pyqtSignal(dict) error_occurred = pyqtSignal(str) def __init__(self, brain_widget=None): super().__init__() self.brain_widget = brain_widget # Optional weak ref if needed, usually avoided for thread safety self._running = True self._paused = False # Thread-safe queues for tasks self.task_queue = Queue() # Cache for thread-safe access to brain state self._cache_mutex = QMutex() self.cache = { 'state': {}, 'weights': {}, 'positions': {}, 'config': None, 'excluded_neurons': set(), 'connector_neurons': set(), 'learning_rate': 0.1, 'new_neurons': set(), 'custom_neurons': set() } # History tracking to prevent Hebbian loops self._last_hebbian_pairs = [] self.wait_condition = QWaitCondition() self.queue_mutex = QMutex() def update_cache(self, state, weights, positions, config, excluded_neurons=None, connector_neurons=None, learning_rate=0.1, new_neurons=None, custom_neurons=None): """ Update the local cache of brain state. Called from main thread before triggering heavy tasks. """ with QMutexLocker(self._cache_mutex): # Deep copy or safe copy important structures self.cache['state'] = state.copy() self.cache['weights'] = weights.copy() self.cache['positions'] = positions.copy() self.cache['config'] = config self.cache['excluded_neurons'] = excluded_neurons if excluded_neurons else set() self.cache['connector_neurons'] = connector_neurons if connector_neurons else set() self.cache['learning_rate'] = learning_rate self.cache['new_neurons'] = new_neurons if new_neurons else set() self.cache['custom_neurons'] = custom_neurons if custom_neurons else set() def queue_neurogenesis_check(self, state_context): self._add_task('neurogenesis', {'state': state_context}) def queue_hebbian_learning(self): # print("🧵 BrainWorker: Hebbian learning queued") # Uncomment for verbose queuing logs self._add_task('hebbian', {}) def queue_state_update(self, update_data): self._add_task('state_update', update_data) def _add_task(self, task_type, data): with QMutexLocker(self.queue_mutex): self.task_queue.put((task_type, data)) self.wait_condition.wakeOne() def stop(self): self._running = False with QMutexLocker(self.queue_mutex): self.wait_condition.wakeAll() # --- Pause and Resume Methods --- def pause(self): """Pause the worker thread.""" with QMutexLocker(self.queue_mutex): self._paused = True def resume(self): """Resume the worker thread.""" with QMutexLocker(self.queue_mutex): self._paused = False self.wait_condition.wakeAll() # -------------------------------- def run(self): print("🧵 BrainWorker thread started") while self._running: task = None # Wait for task with QMutexLocker(self.queue_mutex): # Check pause state first while self._paused and self._running: self.wait_condition.wait(self.queue_mutex) if not self._running: break if self.task_queue.empty(): self.wait_condition.wait(self.queue_mutex, 200) # Timeout allows checking _running # Re-check pause after wait if self._paused: continue if self.task_queue.empty(): continue try: task = self.task_queue.get_nowait() except Empty: continue if not task: continue task_type, data = task try: if task_type == 'neurogenesis': self._perform_neurogenesis_check(data) elif task_type == 'hebbian': self._perform_hebbian_learning() elif task_type == 'state_update': self._process_state_update(data) except Exception as e: error_msg = f"Error in {task_type}: {str(e)}\n{traceback.format_exc()}" print(error_msg) self.error_occurred.emit(error_msg) print("🧵 BrainWorker thread stopped") def _perform_neurogenesis_check(self, data): """Check if neurogenesis conditions are met based on cached config.""" state = data.get('state', {}) with QMutexLocker(self._cache_mutex): config = self.cache['config'] if not config: return neuro_config = getattr(config, 'neurogenesis', {}) triggers = { 'novelty': state.get('novelty_exposure', 0) > neuro_config.get('novelty_threshold', 3.0), 'stress': state.get('sustained_stress', 0) > neuro_config.get('stress_threshold', 1.2) or state.get('anxiety', 0) > 75, 'reward': state.get('recent_rewards', 0) > neuro_config.get('reward_threshold', 3.5) } # Priority logic trigger_type = None trigger_val = 0 if triggers['stress']: trigger_type = 'stress' trigger_val = state.get('sustained_stress', 0) elif triggers['novelty']: trigger_type = 'novelty' trigger_val = state.get('novelty_exposure', 0) elif triggers['reward']: trigger_type = 'reward' trigger_val = state.get('recent_rewards', 0) if trigger_type: # Emit result back to main thread to finalize creation self.neurogenesis_result.emit({ 'should_create': True, 'neuron_type': trigger_type, 'trigger_value': trigger_val, 'state_context': state, 'is_emergency': (trigger_type == 'stress' and state.get('anxiety', 0) > 90) }) else: self.neurogenesis_result.emit({'should_create': False}) def _perform_hebbian_learning(self): """Perform Hebbian learning calculations using cached state.""" # Retrieve snapshot of cache with QMutexLocker(self._cache_mutex): state = self.cache['state'] weights = self.cache['weights'] neuron_list = list(self.cache['positions'].keys()) excluded = self.cache['excluded_neurons'] connector_neurons = self.cache['connector_neurons'] config = self.cache['config'] base_learning_rate = self.cache['learning_rate'] new_neurons = self.cache['new_neurons'] # DEBUG: Check if we have the basics if not config: print("⚠️ BrainWorker: Hebbian skipped - No 'config' in cache yet.") return if not neuron_list: print("⚠️ BrainWorker: Hebbian skipped - No neurons in cache 'positions'.") return # PURE_INPUTS (Sensors) - Do not include in Hebbian learning PURE_INPUTS = { "can_see_food", "is_eating", "is_sleeping", "is_sick", "pursuing_food", "is_fleeing", "is_startled", "external_stimulus", "plant_proximity" } # Filter available neurons # Exclude system neurons, connector neurons AND pure inputs learning_candidates = [ n for n in neuron_list if n not in excluded and n not in connector_neurons and n not in PURE_INPUTS ] # DEBUG: Check if we have enough candidates if len(learning_candidates) < 2: print(f"⚠️ BrainWorker: Hebbian skipped - Not enough candidates ({len(learning_candidates)}).") # print(f" Excluded: {len(excluded)}, Connectors: {len(connector_neurons)}, Total: {len(neuron_list)}") self.hebbian_result.emit({'updated_pairs': []}) return # Calculate scores scored_pairs = [] for i, n1 in enumerate(learning_candidates): for n2 in learning_candidates[i + 1:]: # Base Score: Sum of activations v1 = self._get_neuron_value(state.get(n1, 50)) v2 = self._get_neuron_value(state.get(n2, 50)) score = v1 + v2 # 1. Add Random Noise to break deterministic loops score += random.uniform(0, 40) # 2. Cooldown Penalty: Check if pair was used in last cycle # Sort tuple to ensure (A,B) is treated same as (B,A) pair_key = tuple(sorted((n1, n2))) if pair_key in self._last_hebbian_pairs: score -= 500 # Massive penalty ensures rotation scored_pairs.append((score, n1, n2, v1, v2)) if not scored_pairs: print("⚠️ BrainWorker: Hebbian skipped - No valid pairs formed.") return # Select top pairs top_k = 2 # Default if hasattr(config, 'neurogenesis'): top_k = config.neurogenesis.get('max_hebbian_pairs', 2) top_pairs = nlargest(top_k, scored_pairs) # Update history for next run self._last_hebbian_pairs = [tuple(sorted((n1, n2))) for _, n1, n2, _, _ in top_pairs] weight_updates = {} updated_pairs_list = [] hebbian_config = getattr(config, 'hebbian', {}) decay_rate = hebbian_config.get('weight_decay', 0.01) for _, n1, n2, v1, v2 in top_pairs: pair = (n1, n2) reverse_pair = (n2, n1) # Find existing weight key use_pair = None if pair in weights: use_pair = pair elif reverse_pair in weights: use_pair = reverse_pair if not use_pair: # CREATE NEW CONNECTION if it doesn't exist # This allows Hebbian learning to form new pathways between neurons # that are frequently co-active, not just strengthen existing ones use_pair = pair old_w = 0.0 # New connections start at zero # Mark this as a new connection that needs to be created weight_updates[use_pair] = { 'old_weight': old_w, 'new_weight': 0.0, # Will be updated below 'is_new_connection': True # Signal to brain_widget to create this } else: old_w = weights[use_pair] # Boost learning rate for new neurons lr = base_learning_rate if n1 in new_neurons or n2 in new_neurons: lr *= 2.0 # Hebbian rule: delta = lr * act1 * act2 delta = lr * (v1 / 100.0) * (v2 / 100.0) new_w = old_w + delta - (old_w * decay_rate) new_w = max(-1.0, min(1.0, new_w)) # Check if this was a new connection we're creating is_new = use_pair in weight_updates and weight_updates[use_pair].get('is_new_connection', False) weight_updates[use_pair] = { 'old_weight': old_w, 'new_weight': new_w, 'is_new_connection': is_new } updated_pairs_list.append(use_pair) if updated_pairs_list: print(f"🧠 Hebbian: updated {len(updated_pairs_list)} pair(s)") self.hebbian_result.emit({ 'updated_pairs': updated_pairs_list, 'weight_updates': weight_updates }) def _process_state_update(self, data): """ Process state decay and noise logic. """ if data.get('health_check'): self.state_update_result.emit({'health_check': True}) return with QMutexLocker(self._cache_mutex): current_state = self.cache['state'] weights = self.cache['weights'] excluded = self.cache['excluded_neurons'] updated_state = {} # PURE_INPUTS (Sensors) - Do not decay these PURE_INPUTS = { "can_see_food", "is_eating", "is_sleeping", "is_sick", "pursuing_food", "is_fleeing", "is_startled", "external_stimulus", "plant_proximity" } # 1. Decay and Noise for neuron, val in current_state.items(): if neuron in excluded or neuron in PURE_INPUTS: continue # Simple decay towards baseline if isinstance(val, (int, float)): # Decay factor decay = 0.95 noise = random.uniform(-0.5, 0.5) new_val = val * decay + noise updated_state[neuron] = new_val # 2. Connection effects (Simplified delta calculation) connection_deltas = {} for (src, dst), w in weights.items(): if src in current_state and dst in current_state: if dst in PURE_INPUTS: continue src_val = current_state[src] if isinstance(src_val, (int, float)): effect = src_val * w * 0.1 # Small timestep factor connection_deltas[dst] = connection_deltas.get(dst, 0) + effect # Apply deltas for neuron, delta in connection_deltas.items(): if neuron in updated_state: updated_state[neuron] += delta elif neuron in current_state and neuron not in PURE_INPUTS: updated_state[neuron] = current_state[neuron] + delta # Clamp final_state = {} for k, v in updated_state.items(): final_state[k] = max(-100, min(100, v)) self.state_update_result.emit({'processed_state': final_state}) def _get_neuron_value(self, val): if isinstance(val, (int, float)): return float(val) if isinstance(val, bool): return 100.0 if val else 0.0 return 0.0 ================================================ FILE: src/certificate.py ================================================ """ Old feature, currently unused - for future reimplementation Generate a certificate with top statistics """ import datetime from PyQt5 import QtCore, QtGui, QtWidgets class SquidCertificateWindow(QtWidgets.QDialog): def __init__(self, parent=None, tamagotchi_logic=None): super().__init__(parent) self.tamagotchi_logic = tamagotchi_logic self.setWindowTitle("Squid Certificate") self.setMinimumSize(800, 1000) # Increased minimum size layout = QtWidgets.QVBoxLayout(self) self.certificate_view = QtWidgets.QTextBrowser() self.certificate_view.setOpenExternalLinks(False) layout.addWidget(self.certificate_view) # Add print button with larger text print_button = QtWidgets.QPushButton("Print Certificate") print_button.setStyleSheet("font-size: 18px; padding: 10px;") print_button.clicked.connect(self.print_certificate) layout.addWidget(print_button, alignment=QtCore.Qt.AlignRight) self.update_certificate() def update_certificate(self): if not self.tamagotchi_logic or not self.tamagotchi_logic.squid: return squid = self.tamagotchi_logic.squid current_date = datetime.datetime.now().strftime("%B %d, %Y") personality = str(squid.personality).split('.')[-1].lower().capitalize() squid_name = getattr(squid, 'name', 'Squid') certificate_html = f"""

    Certificate of Squidship

    Presented by the International Dosidicus Society

    This certifies that

    {squid_name}

    is an officially recognized Dosidicus electronicae of the

    {personality} Personality Type

    Statistics

    Happiness: {squid.happiness}/100 Hunger: {squid.hunger}/100
    Cleanliness: {squid.cleanliness}/100 Sleepiness: {squid.sleepiness}/100
    Anxiety: {squid.anxiety}/100 Curiosity: {squid.curiosity}/100

    Achievements:

    • Successfully hatched a squid
    • Fed the squid over 10 times
    • Reached a happiness level above 80
    • Survived a difficult situation

    Issued on this day, {current_date}

    OFFICIAL
    """ self.certificate_view.setHtml(certificate_html) def print_certificate(self): from PyQt5 import QtPrintSupport printer = QtPrintSupport.QPrinter(QtPrintSupport.QPrinter.HighResolution) dialog = QtPrintSupport.QPrintDialog(printer, self) if dialog.exec_() == QtWidgets.QDialog.Accepted: self.certificate_view.print_(printer) ================================================ FILE: src/compute_backend.py ================================================ """ Optional NPU / AI Accelerator support via ONNX Runtime =========================================================================== A thin abstraction over the core matrix operations used by the brain engine (forward pass MatMul, Hebbian outer-product update, zeros initialisation) so that an ONNX Runtime execution provider can be dropped in without touching the rest of the codebase. Backend selection ----------------- Set [Compute] backend = onnx in config.ini to enable ONNX. Leave as backend = numpy (or omit the section entirely) for the default NumPy path. ONNX silently falls back to NumPy if onnxruntime is not installed. Public API ---------- get_backend(backend_name=None) → ComputeBackend instance (singleton) ComputeBackend methods used by brain_widget / brain_worker: .zeros(shape) → ndarray-compatible 2-D matrix .forward(weights_matrix, inputs) → 1-D result of weights @ inputs .hebbian(associations, sample, lr) → updated associations matrix .get_value(matrix, i, j) → float scalar Author note: dynamic neurogenesis is supported because the ONNX MatMul graph is rebuilt whenever the network shape changes. """ from __future__ import annotations # --------------------------------------------------------------------------- # Base class # --------------------------------------------------------------------------- class ComputeBackend: """Abstract base class for compute backends.""" def zeros(self, shape: tuple): """Return a zero-filled matrix of *shape* compatible with this backend.""" raise NotImplementedError def forward(self, weights_matrix, inputs): """Dense matrix-vector product: result = weights_matrix @ inputs.""" raise NotImplementedError def hebbian(self, associations, sample: list, learning_rate: float): """ Hebbian outer-product update applied to *associations* in place. For each pair (i, j) where i < j: associations[i][j] += learning_rate * sample[i] * sample[j] associations[j][i] = associations[i][j] (keep symmetric) Returns the updated associations matrix. """ raise NotImplementedError def get_value(self, matrix, i: int, j: int) -> float: """Return matrix[i][j] as a plain Python float.""" raise NotImplementedError @property def name(self) -> str: raise NotImplementedError # --------------------------------------------------------------------------- # NumPy backend (default, always available) # --------------------------------------------------------------------------- class NumpyBackend(ComputeBackend): """ Default compute backend. Uses NumPy for all operations – identical behaviour to the pre-integration codebase. """ def __init__(self): import numpy as _np # already a project dependency self._np = _np print("🧠 Compute backend: NumPy") # --- ComputeBackend interface ------------------------------------------- def zeros(self, shape: tuple): return self._np.zeros(shape) def forward(self, weights_matrix, inputs): return weights_matrix @ inputs def hebbian(self, associations, sample: list, learning_rate: float): np = self._np arr = np.array(sample, dtype=np.float64) delta = np.outer(arr, arr) * learning_rate n = len(sample) for i in range(n): for j in range(i + 1, n): associations[i][j] += delta[i][j] associations[j][i] = associations[i][j] return associations def get_value(self, matrix, i: int, j: int) -> float: return float(matrix[i][j]) @property def name(self) -> str: return "numpy" # --------------------------------------------------------------------------- # ONNX Runtime backend (optional) # --------------------------------------------------------------------------- class ONNXBackend(ComputeBackend): """ Optional ONNX Runtime backend. * Lazy-imports onnx and onnxruntime so Dosidicus starts fine even when neither package is installed. * Automatically selects the best available execution provider: DirectML → AMD / Intel / NVIDIA via Windows ML (NPU-capable) OpenVINO → Intel NPU QNN → Qualcomm Snapdragon X CPU → universal fallback * Rebuilds the ONNX MatMul graph whenever the network shape changes, which means dynamic neurogenesis is fully supported. * Falls back to NumPy arithmetic internally for the Hebbian update (the association matrix is small; ONNX overhead would dominate). """ # Execution-provider preference order _PROVIDER_PRIORITY = [ 'QNNExecutionProvider', # Qualcomm Snapdragon X Elite NPU 'DmlExecutionProvider', # DirectML (AMD XDNA, Intel Arc, NVIDIA) 'OpenVINOExecutionProvider', # Intel NPU / iGPU 'CPUExecutionProvider', # Always available ] def __init__(self): self._available = False self._session = None self._last_shape = None self._provider = 'CPUExecutionProvider' self._np = None try: import numpy as np import onnx # noqa: F401 (confirms package present) import onnxruntime as ort self._np = np self._ort = ort available_providers = ort.get_available_providers() for pref in self._PROVIDER_PRIORITY: if pref in available_providers: self._provider = pref break self._available = True print(f"🧠 Compute backend: ONNX Runtime [{self._provider}]") print(f" Available providers: {available_providers}") except ImportError as exc: print(f"⚠️ ONNX Runtime not available ({exc}).") print(" Falling back to NumPy backend. " "Install 'onnxruntime' (or 'onnxruntime-directml') to enable NPU support.") # --- Internal helpers --------------------------------------------------- def _build_session(self, rows: int, cols: int): """ Build (or rebuild) an ONNX MatMul session for a (rows, cols) weight matrix applied to a cols-element input vector → rows-element output. Called automatically from forward() when the shape changes. """ from onnx import helper, TensorProto W = helper.make_tensor_value_info('weights', TensorProto.FLOAT, [rows, cols]) V = helper.make_tensor_value_info('inputs', TensorProto.FLOAT, [cols]) Y = helper.make_tensor_value_info('output', TensorProto.FLOAT, [rows]) node = helper.make_node('MatMul', inputs=['weights', 'inputs'], outputs=['output']) graph = helper.make_graph([node], 'dosidicus_forward', [W, V], [Y]) model = helper.make_model( graph, opset_imports=[helper.make_opsetid('', 17)] ) model_bytes = model.SerializeToString() sess_opts = self._ort.SessionOptions() sess_opts.log_severity_level = 3 # suppress verbose ONNX logs self._session = self._ort.InferenceSession( model_bytes, sess_options=sess_opts, providers=[self._provider, 'CPUExecutionProvider'] ) self._last_shape = (rows, cols) # --- ComputeBackend interface ------------------------------------------- def zeros(self, shape: tuple): return self._np.zeros(shape) def forward(self, weights_matrix, inputs): if not self._available: # Transparent NumPy fallback return weights_matrix @ inputs np = self._np w = np.array(weights_matrix, dtype=np.float32) v = np.array(inputs, dtype=np.float32) rows, cols = w.shape if (rows, cols) != self._last_shape: # Network topology changed (neurogenesis) → rebuild graph self._build_session(rows, cols) result = self._session.run(['output'], {'weights': w, 'inputs': v}) return result[0] def hebbian(self, associations, sample: list, learning_rate: float): """ For the Hebbian update we stay in NumPy. The association matrix is small (N × N where N ≈ 10–100 neurons); the ONNX round-trip cost far exceeds the compute benefit at this size. """ np = self._np arr = np.array(sample, dtype=np.float64) delta = np.outer(arr, arr) * learning_rate n = len(sample) for i in range(n): for j in range(i + 1, n): associations[i][j] += delta[i][j] associations[j][i] = associations[i][j] return associations def get_value(self, matrix, i: int, j: int) -> float: return float(matrix[i][j]) @property def name(self) -> str: if self._available: return f"onnx [{self._provider}]" return "numpy (onnx unavailable)" # --------------------------------------------------------------------------- # Factory / singleton # --------------------------------------------------------------------------- _backend_instance: ComputeBackend | None = None def get_backend(backend_name: str | None = None) -> ComputeBackend: """ Return the (singleton) compute backend. backend_name : 'numpy' | 'onnx' If None, reads [Compute] backend from config.ini. Defaults to 'numpy' if the section / key is absent. The ONNX backend silently falls back to NumPy if onnxruntime is not installed, so callers never need to guard against ImportError. """ global _backend_instance if _backend_instance is not None: return _backend_instance if backend_name is None: backend_name = _read_backend_from_config() backend_name = backend_name.strip().lower() if backend_name == 'onnx': candidate = ONNXBackend() # If ONNX packages are missing, silently use NumPy _backend_instance = candidate if candidate._available else NumpyBackend() else: _backend_instance = NumpyBackend() return _backend_instance def reset_backend(): """Force re-initialisation on next get_backend() call (useful for tests).""" global _backend_instance _backend_instance = None # --------------------------------------------------------------------------- # Config reader (standalone – avoids circular import with config_manager) # --------------------------------------------------------------------------- def _read_backend_from_config() -> str: """Read [Compute] backend from config.ini, returns 'numpy' on any error.""" try: import configparser import os import sys config = configparser.ConfigParser() if getattr(sys, 'frozen', False): base_path = os.path.dirname(sys.executable) else: # __file__ is src/compute_backend.py → go up one level src_dir = os.path.dirname(os.path.abspath(__file__)) base_path = os.path.dirname(src_dir) config_path = os.path.join(base_path, 'config.ini') config.read(config_path) return config.get('Compute', 'backend', fallback='numpy') except Exception: return 'numpy' ================================================ FILE: src/config_manager.py ================================================ import configparser import os import random import sys from PyQt5 import QtCore from ast import literal_eval class ConfigManager: """ Configuration manager for Dosidicus-2. Handles loading/saving of config.ini and provides typed accessors for all configuration values including animation styles. """ # Available animation styles - UPDATED to include all styles ANIMATION_STYLES = ['vibrant', 'subtle', 'neural', 'none', 'electric', 'zen', 'neon'] def __init__(self, config_filename="config.ini"): # Check if we are running as a frozen executable (Auto-Py-To-Exe) if getattr(sys, 'frozen', False): # If frozen, the config file should be next to the .exe base_path = os.path.dirname(sys.executable) else: # If running from source (Dev), we are in /src/config_manager.py # So we need to go up one level to find config.ini current_dir = os.path.dirname(os.path.abspath(__file__)) base_path = os.path.dirname(current_dir) # Go up to parent folder self.config_path = os.path.join(base_path, config_filename) self.config = configparser.ConfigParser() self.load_config() def load_config(self): if not os.path.exists(self.config_path): self.create_default_config() self.config.read(self.config_path) def create_default_config(self): self.config['General'] = { 'language': 'en' } # Debug section self.config['Debug'] = { 'multiplayer_debug': 'False' } # Rock Interactions self.config['RockInteractions'] = { 'pickup_probability': '0.7', 'throw_probability': '0.4', 'min_carry_duration': '4.0', 'max_carry_duration': '10.0', 'cooldown_after_throw': '10.0', 'happiness_boost': '9', 'satisfaction_boost': '11', 'anxiety_reduction': '9', 'memory_decay_rate': '0.98', 'max_rock_memories': '10' } # Poop Interactions - NEW SECTION self.config['PoopInteractions'] = { 'pickup_probability': '0.2', 'throw_probability': '0.3', 'min_carry_duration': '2.0', 'max_carry_duration': '9.0', 'cooldown_after_throw': '10.0', } # Decorations Message System - NEW SECTION self.config['Decorations'] = { 'message_enabled': 'True', 'message_max_shows': '3', 'message_min_interval': '120.0', # 2 minutes in seconds 'message_max_interval': '300.0' # 5 minutes in seconds } # Neurogenesis self.config['Neurogenesis'] = { 'enabled': 'True', 'showmanship': 'True', 'pruning_enabled': 'True', # NEW KEY 'cooldown': '60.0', 'per_type_cooldown': '30.0', 'max_novelty_neurons': '5', # NEW KEY 'pattern_threshold': '3', # NEW KEY 'experience_buffer_size': '30', # NEW KEY 'min_utility_for_keep': '0.2', # NEW KEY 'max_neurons': '100', 'initial_neuron_count': '7', 'max_hebbian_pairs': '2' } # Specialist Neuron Cap (Important!) self.config['Neurogenesis.SpecialisationCaps'] = { 'novelty_object_investigation': '8', } # Neurogenesis Triggers self.config['Neurogenesis.Novelty'] = { 'enabled': 'True', 'threshold': '3.0', 'decay_rate': '0.85', 'max_counter': '10.0', 'min_curiosity': '0.3', 'adventurous_modifier': '1.2', 'timid_modifier': '0.8' } self.config['Neurogenesis.Stress'] = { 'enabled': 'True', 'threshold': '2.0', 'decay_rate': '0.85', 'max_counter': '10.0', 'min_anxiety': '0.4', 'timid_modifier': '1.5', 'energetic_modifier': '0.7' } self.config['Neurogenesis.Reward'] = { 'enabled': 'True', 'threshold': '2.5', 'decay_rate': '0.85', 'max_counter': '8.0', 'min_satisfaction': '0.5', 'boost_multiplier': '1.1' } # Neuron Properties self.config['Neurogenesis.NeuronProperties'] = { 'base_activation': '0.5', 'position_variance': '75', 'default_connections': 'True', 'connection_strength': '0.35', 'reciprocal_strength': '0.15', 'randomize_start_positions': 'True', # NEW KEY 'canvas_padding': '60', # NEW KEY 'centering_force': '0.02', # NEW KEY 'force_bounds': 'True' # NEW KEY } # Appearance self.config['Neurogenesis.Appearance'] = { 'novelty_color': '255,255,150', 'stress_color': '255,150,150', 'reward_color': '150,255,150', 'novelty_shape': 'triangle', 'stress_shape': 'square', 'reward_shape': 'circle' } # Visual Effects self.config['Neurogenesis.VisualEffects'] = { 'highlight_duration': '5.0', 'highlight_radius': '40', 'pulse_effect': 'True', 'pulse_speed': '0.5', 'animation_style': 'pattern_1' # NEW KEY } # Hebbian Learning - NEW SECTION self.config['Hebbian'] = { 'learning_interval': '30', 'base_learning_rate': '0.1', 'weight_decay': '0.01', 'min_weight': '-1.0', 'max_weight': '1.0', 'max_hebbian_pairs': '2' } # Link Blink - NEW SECTION self.config['LinkBlink'] = { 'interval_min': '8.0', 'interval_max': '20.0', 'blink_duration': '2.0' } # ---------- ANIMATION STYLES ---------- # All animation style configurations - UPDATED self.config['Animation'] = { 'style': 'vibrant' } self.config['Animation.Electric'] = { 'use_thick_lines': 'True', 'line_base_width': '2.5', 'line_colour_positive': '0,255,255', 'line_colour_negative': '255,0,255', 'line_alpha': '255' } self.config['Animation.Zen'] = { 'use_thick_lines': 'False', 'line_base_width': '1.0', 'line_colour_positive': '60,60,60', 'line_colour_negative': '120,120,120', 'line_alpha': '120' } self.config['Animation.Neon'] = { 'use_thick_lines': 'True', 'line_base_width': '1.8', 'line_colour_positive': '0,255,120', 'line_colour_negative': '255,50,205', 'line_alpha': '240' } # Display Settings self.config['Display'] = { 'neuron_label_font_size': '8', 'neuron_radius': '14', # Updated to match current default 'connection_line_width': '1.5', 'button_font_size': '16', 'button_width': '140', 'button_height': '50', 'button_spacing': '20' } # Designer Settings self.config['Designer'] = { 'designer_min_neuron_distance': '100', 'designer_max_neuron_distance': '600' } with open(self.config_path, 'w') as f: self.config.write(f) def get_hebbian_pairs_per_cycle(self): """Return the number of neuron pairs that should be updated each Hebbian cycle.""" # Check Hebbian section first, fallback to Neurogenesis if self.config.has_section('Hebbian'): return self.config.getint('Hebbian', 'max_hebbian_pairs', fallback=2) return self.config.getint('Neurogenesis', 'max_hebbian_pairs', fallback=2) def get_showmanship_enabled(self): """Return whether showmanship (dramatic neuron creation) is enabled. When enabled, the ShowmanNeurogenesis wrapper will create neurons during dramatic moments even if normal thresholds aren't met. When disabled, only the base EnhancedNeurogenesis logic applies. """ return self.config.getboolean('Neurogenesis', 'showmanship', fallback=True) def get_facts_enabled(self): return self.config.getboolean('Facts', 'enabled', fallback=True) def get_fact_interval_ms(self): mins = self.config.getint('Facts', 'interval_minutes', fallback=5) return mins * 60 * 1000 def get_fact_display_ms(self): secs = self.config.getint('Facts', 'display_seconds', fallback=18) return secs * 1000 def get_rock_config(self): return { 'pickup_prob': float(self.config['RockInteractions']['pickup_probability']), 'throw_prob': float(self.config['RockInteractions']['throw_probability']), 'min_carry_duration': float(self.config['RockInteractions']['min_carry_duration']), 'max_carry_duration': float(self.config['RockInteractions']['max_carry_duration']), 'cooldown_after_throw': float(self.config['RockInteractions']['cooldown_after_throw']), 'happiness_boost': int(self.config['RockInteractions']['happiness_boost']), 'satisfaction_boost': int(self.config['RockInteractions']['satisfaction_boost']), 'anxiety_reduction': int(self.config['RockInteractions']['anxiety_reduction']), 'memory_decay_rate': float(self.config['RockInteractions']['memory_decay_rate']), 'max_rock_memories': int(self.config['RockInteractions']['max_rock_memories']) } def get_poop_config(self): """NEW METHOD: Get poop interaction configuration""" # Fallback dictionary in case section is missing defaults = { 'pickup_prob': 0.2, 'throw_prob': 0.3, 'min_carry_duration': 2.0, 'max_carry_duration': 9.0, 'cooldown_after_throw': 10.0, } if self.config.has_section('PoopInteractions'): section = self.config['PoopInteractions'] return { 'pickup_prob': float(section.get('pickup_probability', defaults['pickup_prob'])), 'throw_prob': float(section.get('throw_probability', defaults['throw_prob'])), 'min_carry_duration': float(section.get('min_carry_duration', defaults['min_carry_duration'])), 'max_carry_duration': float(section.get('max_carry_duration', defaults['max_carry_duration'])), 'cooldown_after_throw': float(section.get('cooldown_after_throw', defaults['cooldown_after_throw'])) } return defaults def get_decorations_config(self): """NEW METHOD: Get decorations message system configuration""" return { 'message_enabled': self.config.getboolean('Decorations', 'message_enabled', fallback=True), 'message_max_shows': self.config.getint('Decorations', 'message_max_shows', fallback=3), 'message_min_interval': self.config.getfloat('Decorations', 'message_min_interval', fallback=120.0), 'message_max_interval': self.config.getfloat('Decorations', 'message_max_interval', fallback=300.0) } def get_specialisation_caps(self): """Return dict {specialisation_name: int_max}""" caps = {} if self.config.has_section('Neurogenesis.SpecialisationCaps'): for spec, val in self.config.items('Neurogenesis.SpecialisationCaps'): caps[spec] = int(val) return caps def get_neurogenesis_config(self): """Returns the complete neurogenesis configuration as a dictionary""" return { 'general': { 'enabled': self.config.getboolean('Neurogenesis', 'enabled', fallback=True), 'showmanship': self.config.getboolean('Neurogenesis', 'showmanship', fallback=True), 'pruning_enabled': self.config.getboolean('Neurogenesis', 'pruning_enabled', fallback=True), # NEW KEY 'cooldown': self.config.getfloat('Neurogenesis', 'cooldown', fallback=60.0), 'per_type_cooldown': self.config.getfloat('Neurogenesis', 'per_type_cooldown', fallback=30.0), 'max_novelty_neurons': self.config.getint('Neurogenesis', 'max_novelty_neurons', fallback=5), # NEW KEY 'pattern_threshold': self.config.getint('Neurogenesis', 'pattern_threshold', fallback=3), # NEW KEY 'experience_buffer_size': self.config.getint('Neurogenesis', 'experience_buffer_size', fallback=30), # NEW KEY 'min_utility_for_keep': self.config.getfloat('Neurogenesis', 'min_utility_for_keep', fallback=0.2), # NEW KEY 'max_neurons': self.config.getint('Neurogenesis', 'max_neurons', fallback=100), 'initial_neuron_count': self.config.getint('Neurogenesis', 'initial_neuron_count', fallback=7), 'max_hebbian_pairs': self.config.getint('Neurogenesis', 'max_hebbian_pairs', fallback=2) }, 'triggers': { 'novelty': { 'enabled': self.config.getboolean('Neurogenesis.Novelty', 'enabled', fallback=True), 'threshold': self.config.getfloat('Neurogenesis.Novelty', 'threshold', fallback=3.0), 'decay_rate': self.config.getfloat('Neurogenesis.Novelty', 'decay_rate', fallback=0.85), 'max_counter': self.config.getfloat('Neurogenesis.Novelty', 'max_counter', fallback=10.0), 'min_curiosity': self.config.getfloat('Neurogenesis.Novelty', 'min_curiosity', fallback=0.3), 'personality_modifiers': { 'adventurous': self.config.getfloat('Neurogenesis.Novelty', 'adventurous_modifier', fallback=1.2), 'timid': self.config.getfloat('Neurogenesis.Novelty', 'timid_modifier', fallback=0.8) } }, 'stress': { 'enabled': self.config.getboolean('Neurogenesis.Stress', 'enabled', fallback=True), 'threshold': self.config.getfloat('Neurogenesis.Stress', 'threshold', fallback=2.0), 'decay_rate': self.config.getfloat('Neurogenesis.Stress', 'decay_rate', fallback=0.85), 'max_counter': self.config.getfloat('Neurogenesis.Stress', 'max_counter', fallback=10.0), 'min_anxiety': self.config.getfloat('Neurogenesis.Stress', 'min_anxiety', fallback=0.4), 'personality_modifiers': { 'timid': self.config.getfloat('Neurogenesis.Stress', 'timid_modifier', fallback=1.5), 'energetic': self.config.getfloat('Neurogenesis.Stress', 'energetic_modifier', fallback=0.7) } }, 'reward': { 'enabled': self.config.getboolean('Neurogenesis.Reward', 'enabled', fallback=True), 'threshold': self.config.getfloat('Neurogenesis.Reward', 'threshold', fallback=2.5), 'decay_rate': self.config.getfloat('Neurogenesis.Reward', 'decay_rate', fallback=0.85), 'max_counter': self.config.getfloat('Neurogenesis.Reward', 'max_counter', fallback=8.0), 'min_satisfaction': self.config.getfloat('Neurogenesis.Reward', 'min_satisfaction', fallback=0.5), 'boost_multiplier': self.config.getfloat('Neurogenesis.Reward', 'boost_multiplier', fallback=1.1) } }, 'neuron_properties': { 'base_activation': self.config.getfloat('Neurogenesis.NeuronProperties', 'base_activation', fallback=0.5), 'position_variance': self.config.getint('Neurogenesis.NeuronProperties', 'position_variance', fallback=75), 'default_connections': self.config.getboolean('Neurogenesis.NeuronProperties', 'default_connections', fallback=True), 'connection_strength': self.config.getfloat('Neurogenesis.NeuronProperties', 'connection_strength', fallback=0.35), 'reciprocal_strength': self.config.getfloat('Neurogenesis.NeuronProperties', 'reciprocal_strength', fallback=0.15), 'randomize_start_positions': self.config.getboolean('Neurogenesis.NeuronProperties', 'randomize_start_positions', fallback=True), # NEW KEY 'canvas_padding': self.config.getint('Neurogenesis.NeuronProperties', 'canvas_padding', fallback=60), # NEW KEY 'centering_force': self.config.getfloat('Neurogenesis.NeuronProperties', 'centering_force', fallback=0.02), # NEW KEY 'force_bounds': self.config.getboolean('Neurogenesis.NeuronProperties', 'force_bounds', fallback=True) # NEW KEY }, 'designer': { 'min_neuron_distance': self.config.getint('Designer', 'designer_min_neuron_distance', fallback=100), 'max_neuron_distance': self.config.getint('Designer', 'designer_max_neuron_distance', fallback=600) }, 'appearance': { 'colors': { 'novelty': [int(x) for x in self.config.get('Neurogenesis.Appearance', 'novelty_color', fallback='255,255,150').split(',')], 'stress': [int(x) for x in self.config.get('Neurogenesis.Appearance', 'stress_color', fallback='255,150,150').split(',')], 'reward': [int(x) for x in self.config.get('Neurogenesis.Appearance', 'reward_color', fallback='150,255,150').split(',')] }, 'shapes': { 'novelty': self.config.get('Neurogenesis.Appearance', 'novelty_shape', fallback='triangle'), 'stress': self.config.get('Neurogenesis.Appearance', 'stress_shape', fallback='square'), 'reward': self.config.get('Neurogenesis.Appearance', 'reward_shape', fallback='circle') } }, 'visual_effects': { 'highlight_duration': self.config.getfloat('Neurogenesis.VisualEffects', 'highlight_duration', fallback=5.0), 'highlight_radius': self.config.getint('Neurogenesis.VisualEffects', 'highlight_radius', fallback=40), 'pulse_effect': self.config.getboolean('Neurogenesis.VisualEffects', 'pulse_effect', fallback=True), 'pulse_speed': self.config.getfloat('Neurogenesis.VisualEffects', 'pulse_speed', fallback=0.5), 'animation_style': self.config.get('Neurogenesis.VisualEffects', 'animation_style', fallback='pattern_1') # NEW KEY } } def get_hebbian_config(self): """NEW METHOD: Get Hebbian learning configuration""" return { 'learning_interval': self.config.getint('Hebbian', 'learning_interval', fallback=30), 'base_learning_rate': self.config.getfloat('Hebbian', 'base_learning_rate', fallback=0.1), 'weight_decay': self.config.getfloat('Hebbian', 'weight_decay', fallback=0.01), 'min_weight': self.config.getfloat('Hebbian', 'min_weight', fallback=-1.0), 'max_weight': self.config.getfloat('Hebbian', 'max_weight', fallback=1.0), 'max_hebbian_pairs': self.config.getint('Hebbian', 'max_hebbian_pairs', fallback=2) } def get_linkblink_config(self): """NEW METHOD: Get link blink animation configuration""" return { 'interval_min': self.config.getfloat('LinkBlink', 'interval_min', fallback=8.0), 'interval_max': self.config.getfloat('LinkBlink', 'interval_max', fallback=20.0), 'blink_duration': self.config.getfloat('LinkBlink', 'blink_duration', fallback=2.0) } def get_random_carry_duration(self): """Returns random duration between min and max carry duration""" config = self.get_rock_config() return random.uniform(config['min_carry_duration'], config['max_carry_duration']) def _parse_config_value(self, value): """Parse configuration values that might contain comments""" # Remove everything after comment markers for comment_marker in [';', '#', '//']: if comment_marker in value: value = value.split(comment_marker)[0] value = value.strip() # Try to convert to appropriate type if value.lower() == 'true': return True elif value.lower() == 'false': return False elif value.isdigit(): return int(value) try: return float(value) except ValueError: return value # ========================================================================= # ANIMATION STYLE CONFIGURATION # ========================================================================= def get_animation_style(self) -> str: """ Get the current animation style name. Returns one of: 'vibrant', 'subtle', 'neural', 'none', 'electric', 'zen', 'neon' """ if not self.config.has_section('Animation'): return 'vibrant' # Default style return self.config.get('Animation', 'style', fallback='vibrant').lower() def set_animation_style(self, style_name: str) -> bool: """ Set the animation style and save to config. Args: style_name: One of 'vibrant', 'subtle', 'neural', 'electric', 'zen', 'neon' Returns: True if style was set, False if invalid style name """ style_lower = style_name.lower() if style_lower not in self.ANIMATION_STYLES: print(f"⚠️ Invalid animation style: {style_name}. " f"Available: {self.ANIMATION_STYLES}") return False if not self.config.has_section('Animation'): self.config.add_section('Animation') self.config.set('Animation', 'style', style_lower) self._save_config() return True def get_animation_config(self, style_name: str = None) -> dict: """ Get animation configuration for a specific style. This provides config values that can override the dataclass defaults in animation_styles.py, allowing user customization via config.ini. Args: style_name: Style to get config for (defaults to current style) Returns: Dictionary of animation parameters """ if style_name is None: style_name = self.get_animation_style() section_name = f'Animation.{style_name.capitalize()}' # Default values for each style defaults = { 'vibrant': { 'use_thick_lines': 'True', 'pulse_colour': '255,220,50', 'pulse_alpha': '240', 'pulse_duration': '1.8', 'pulse_speed': '1.0', 'line_base_width': '1.5', 'line_colour_positive': '50,220,50', 'line_colour_negative': '255,60,60', 'line_alpha': '220', }, 'subtle': { 'use_thick_lines': 'False', 'pulse_colour': '200,200,150', 'pulse_alpha': '160', 'pulse_duration': '2.5', 'pulse_speed': '0.8', 'line_base_width': '0.8', 'line_colour_positive': '80,160,80', 'line_colour_negative': '180,80,80', 'line_alpha': '140', }, 'neural': { 'use_thick_lines': 'False', 'pulse_colour': '180,230,255', 'pulse_alpha': '200', 'pulse_duration': '0.9', 'pulse_speed': '1.1', 'line_base_width': '1.0', 'line_colour_positive': '100,200,255', 'line_colour_negative': '255,120,100', 'line_alpha': '180', 'neural_base_colour_positive': '100,200,255', 'neural_base_colour_negative': '255,120,100', 'neural_base_alpha': '180', }, 'electric': { 'use_thick_lines': 'True', 'pulse_colour': '255,255,255', 'pulse_alpha': '255', 'pulse_duration': '0.15', 'pulse_speed': '4.0', 'line_base_width': '2.5', 'line_colour_positive': '0,255,255', 'line_colour_negative': '255,0,255', 'line_alpha': '255', }, 'zen': { 'use_thick_lines': 'False', 'pulse_colour': '200,200,200', 'pulse_alpha': '120', 'pulse_duration': '3.0', 'pulse_speed': '0.5', 'line_base_width': '1.0', 'line_colour_positive': '60,60,60', 'line_colour_negative': '120,120,120', 'line_alpha': '120', }, 'neon': { 'use_thick_lines': 'True', 'pulse_colour': '255,255,255', 'pulse_alpha': '255', 'pulse_duration': '0.3', 'pulse_speed': '3.5', 'line_base_width': '1.8', 'line_colour_positive': '0,255,120', 'line_colour_negative': '255,50,205', 'line_alpha': '240', } } style_defaults = defaults.get(style_name.lower(), defaults['vibrant']) # Override with config file values if section exists if self.config.has_section(section_name): for key in style_defaults: if self.config.has_option(section_name, key): style_defaults[key] = self.config.get(section_name, key) return style_defaults def get_available_animation_styles(self) -> list: """Return list of available animation style names.""" return list(self.ANIMATION_STYLES) def get_display_config(self): """Get display configuration settings""" return { 'neuron_label_font_size': self.config.getint('Display', 'neuron_label_font_size', fallback=8), 'neuron_radius': self.config.getint('Display', 'neuron_radius', fallback=14), 'connection_line_width': self.config.getfloat('Display', 'connection_line_width', fallback=1.5), 'button_font_size': self.config.getint('Display', 'button_font_size', fallback=16), 'button_width': self.config.getint('Display', 'button_width', fallback=140), 'button_height': self.config.getint('Display', 'button_height', fallback=50), 'button_spacing': self.config.getint('Display', 'button_spacing', fallback=20) } def is_designer_position_valid(self, x, y, existing_positions, center_x=0, center_y=0): """ Checks if a position is valid based on designer config constraints. Args: x, y: The proposed coordinates. existing_positions: List of tuples [(x,y), ...] or objects with .x, .y attributes. center_x, center_y: The origin point to measure max distance from (default 0,0). Returns: True if valid, False if it violates designer constraints. """ import math # Load constraints from the new Designer section min_dist = self.config.getint('Designer', 'designer_min_neuron_distance', fallback=100) max_dist = self.config.getint('Designer', 'designer_max_neuron_distance', fallback=600) # 1. Check Max Distance (Radius from center) dist_from_center = math.sqrt((x - center_x)**2 + (y - center_y)**2) if dist_from_center > max_dist: return False # 2. Check Min Distance (Proximity to other neurons) for pos in existing_positions: # Handle both tuples and objects with .x .y attributes if hasattr(pos, 'x') and hasattr(pos, 'y'): ex, ey = pos.x, pos.y else: ex, ey = pos[0], pos[1] dist_to_neighbor = math.sqrt((x - ex)**2 + (y - ey)**2) if dist_to_neighbor < min_dist: return False return True def _save_config(self): """Save the current configuration to file.""" try: with open(self.config_path, 'w') as f: self.config.write(f) except Exception as e: print(f"⚠️ Failed to save config: {e}") def get_language(self): if not self.config.has_section('General'): return 'en' return self.config.get('General', 'language', fallback='en').lower() ================================================ FILE: src/custom_brain_loader.py ================================================ import os import json import shutil import time from pathlib import Path from typing import Optional, Dict from PyQt5 import QtWidgets, QtCore, QtGui from .brain_neuron_outputs import NeuronOutputBinding, OutputTriggerMode # Global tracker for the currently loaded custom brain _current_custom_brain: Optional[Dict] = None _current_custom_brain_name: Optional[str] = None _current_custom_brain_file: Optional[str] = None def add_load_brain_button(network_tab, checkbox_layout): """ Add a Load Brain button to the NetworkTab. Args: network_tab: The NetworkTab instance (self) checkbox_layout: The checkbox layout to add the button to """ # Add some spacing checkbox_layout.addSpacing(20) # Create the button load_btn = QtWidgets.QPushButton("Load Brain...") load_btn.setToolTip("Load a custom brain architecture") load_btn.setStyleSheet(""" QPushButton { background-color: #5c6bc0; color: white; border: none; border-radius: 3px; padding: 4px 12px; font-weight: bold; font-size: 9pt; } QPushButton:hover { background-color: #7986cb; } QPushButton:pressed { background-color: #3f51b5; } """) # Create loader and connect loader = BrainLoader(network_tab) network_tab._brain_loader = loader load_btn.clicked.connect(loader.show_dialog) checkbox_layout.addWidget(load_btn) # --- RESET BUTTON (with “Are you sure?” guard) --- reset_btn = QtWidgets.QPushButton("↺") reset_btn.setToolTip("Reset to default brain") reset_btn.setFixedSize(50, 50) reset_btn.setStyleSheet(""" QPushButton { background-color: #78909c; color: white; border: none; border-radius: 3px; font-weight: bold; } QPushButton:hover { background-color: #90a4ae; } """) def _confirm_reset(): ans = QtWidgets.QMessageBox.question( network_tab, # parent widget "Confirm Reset", "Reset all neuron positions to their default locations?\n\n" "Network structure (connections and weights) will be preserved.", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.No ) if ans == QtWidgets.QMessageBox.Yes: try: loader.reset_positions_to_default() except Exception as e: QtWidgets.QMessageBox.critical( network_tab, "Error", f"Failed to reset positions:\n{e}" ) reset_btn.clicked.connect(_confirm_reset) checkbox_layout.addWidget(reset_btn) # ============================================================================= # SAVE/LOAD INTEGRATION API # ============================================================================= def get_custom_brain_save_data() -> Optional[Dict]: """ Get custom brain data for saving with game state. Returns None if using default brain. Call this from tamagotchi_logic.save_game() to include in save_data. """ global _current_custom_brain, _current_custom_brain_name if _current_custom_brain is None: return None return { 'is_custom_brain': True, 'brain_name': _current_custom_brain_name, 'brain_definition': _current_custom_brain, 'source_file': _current_custom_brain_file, } def has_custom_brain() -> bool: """Check if a custom brain is currently loaded.""" return _current_custom_brain is not None def get_custom_brain_name() -> Optional[str]: """Get the name of the currently loaded custom brain.""" return _current_custom_brain_name def validate_custom_brain_save(save_data: Dict) -> tuple[bool, str]: """ Validate that a save file's custom brain can be restored. Returns: (is_valid, message) - is_valid=True if loadable, message explains any issues """ custom_brain_data = save_data.get('custom_brain') if not custom_brain_data: return True, "Default brain (no custom brain)" if not custom_brain_data.get('is_custom_brain'): return True, "Default brain" brain_def = custom_brain_data.get('brain_definition') if not brain_def: return False, "Save contains custom brain flag but no brain definition!" # Check if brain definition looks valid if 'positions' not in brain_def and 'neurons' not in brain_def: return False, "Custom brain definition is corrupted (no neurons)" brain_name = custom_brain_data.get('brain_name', 'Unknown') return True, f"Custom brain: {brain_name}" def restore_custom_brain_from_save(save_data: Dict, brain_widget) -> tuple[bool, str]: """ Restore a custom brain from save data. Args: save_data: The loaded save data dict brain_widget: The BrainWidget to apply the brain to Returns: (success, message) """ global _current_custom_brain, _current_custom_brain_name, _current_custom_brain_file custom_brain_data = save_data.get('custom_brain') if not custom_brain_data or not custom_brain_data.get('is_custom_brain'): # No custom brain - reset to default _current_custom_brain = None _current_custom_brain_name = None _current_custom_brain_file = None return True, "Using default brain" brain_def = custom_brain_data.get('brain_definition') brain_name = custom_brain_data.get('brain_name', 'Unknown') if not brain_def: return False, "Custom brain definition missing from save!" try: # Create a temporary loader to apply the brain loader = BrainLoader.__new__(BrainLoader) loader.brain_widget = brain_widget loader.network_tab = None # Parse and apply parsed = loader._parse(brain_def) if not parsed: return False, f"Could not parse custom brain '{brain_name}'" loader._apply(parsed) # Update global tracking _current_custom_brain = brain_def _current_custom_brain_name = brain_name _current_custom_brain_file = custom_brain_data.get('source_file') return True, f"Restored custom brain: {brain_name}" except Exception as e: return False, f"Error restoring custom brain: {e}" def show_custom_brain_load_warning(parent, save_data: Dict) -> bool: """ Show warning dialog when loading a save with custom brain. Returns True if user wants to proceed, False to cancel. """ # VALIDATION FIX: Ensure parent is a QWidget or None if parent and not isinstance(parent, QtWidgets.QWidget): if hasattr(parent, 'mainwindow'): parent = parent.mainwindow elif hasattr(parent, 'central_widget'): parent = parent.central_widget else: # Fallback to None (desktop parent) to prevent crash parent = None custom_brain_data = save_data.get('custom_brain') if not custom_brain_data or not custom_brain_data.get('is_custom_brain'): return True # No custom brain, no warning needed brain_name = custom_brain_data.get('brain_name', 'Unknown') source_file = custom_brain_data.get('source_file', 'Unknown') # Validate the brain can be restored is_valid, message = validate_custom_brain_save(save_data) if not is_valid: QtWidgets.QMessageBox.critical( parent, "Cannot Load Save", f"This save uses a custom brain that cannot be restored:\n\n" f"Brain: {brain_name}\n" f"Error: {message}\n\n" f"The save file may be corrupted." ) return False # Show info dialog reply = QtWidgets.QMessageBox.question( parent, "Custom Brain Save", f"This save uses a custom brain architecture:\n\n" f"🧠 Brain: {brain_name}\n" f"📁 Original file: {source_file}\n\n" f"The brain definition is embedded in the save and will be restored.\n\n" f"Continue loading?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.Yes ) return reply == QtWidgets.QMessageBox.Yes # ============================================================================= # BRAIN LOADER CLASS # ============================================================================= class BrainLoader: """Handles brain file loading.""" def __init__(self, network_tab): self.network_tab = network_tab self.brain_widget = network_tab.brain_widget self.brains_folder = self._find_brains_folder() def _find_brains_folder(self) -> Path: """Find or create custom_brains folder (one level up from Brain/).""" candidates = [ Path(__file__).parent.parent / "custom_brains", # Up from Brain/ to project root Path.cwd() / "custom_brains", Path(__file__).parent / "custom_brains", # Fallback to Brain/custom_brains ] for p in candidates: if p.exists(): return p # Create at project root level (one up from Brain/) default = candidates[0] default.mkdir(parents=True, exist_ok=True) return default def show_dialog(self): """Show the brain selection dialog.""" dialog = BrainSelectDialog(self.brains_folder, self.network_tab) if dialog.exec_() == QtWidgets.QDialog.Accepted and dialog.selected_file: self.load_file(dialog.selected_file) def load_file(self, filepath: str) -> bool: """Load and apply a brain file.""" global _current_custom_brain, _current_custom_brain_name, _current_custom_brain_file try: print(f"🧠 Loading brain: {filepath}") with open(filepath, 'r') as f: data = json.load(f) brain = self._parse(data) if not brain: raise ValueError("Could not parse brain format") self._apply(brain) # 1. Send bindings to the live game logic if hasattr(self.network_tab, 'tamagotchi_logic'): logic = self.network_tab.tamagotchi_logic if hasattr(logic, 'neuron_output_monitor') and logic.neuron_output_monitor: logic.neuron_output_monitor.load_bindings_from_brain(brain) print(f" ✓ Loaded {len(brain.get('output_bindings', []))} output bindings into Monitor") # --- FIX: Force-sync live sensor values immediately --- # This prevents the saved "0" value from overwriting the real sensor input # in the first frame after loading. if hasattr(logic, 'brain_hooks'): print(" -> Syncing live sensor inputs...") input_values = logic.brain_hooks.get_input_neuron_values() # Update widget state directly bw = self.brain_widget if hasattr(bw, 'state'): bw.state.update(input_values) # Update internal simulation neurons if present if hasattr(bw, 'neurons'): for k, v in input_values.items(): if k in bw.neurons: bw.neurons[k]['activation'] = v # --- END FIX --- # 2. UI OVERLAY SETUP # Check if the stats area exists. If not, create it manually. if hasattr(self.network_tab, '_create_functional_stats_area'): if not hasattr(self.network_tab, 'functional_stats_area') or self.network_tab.functional_stats_area is None: print(" -> Creating functional stats area for overlay...") self.network_tab._create_functional_stats_area() # Ensure the label has text so the widget has non-zero height! if hasattr(self.network_tab, 'functional_stats_label') and not self.network_tab.functional_stats_label.text(): self.network_tab.functional_stats_label.setText("🧠 Custom Brain Loaded
    External Bindings Active") self.network_tab.functional_stats_label.show() # Initialize overlay if missing if hasattr(self.network_tab, 'functional_stats_area'): if not hasattr(self.network_tab, 'binding_overlay') or self.network_tab.binding_overlay is None: try: from .brain_network_tab_banners import BindingOverlay self.network_tab.binding_overlay = BindingOverlay(self.network_tab, self.network_tab.functional_stats_area) print(" -> BindingOverlay initialized") except ImportError as e: print(f" ! Could not import BindingOverlay: {e}") # Update the overlay with data if hasattr(self.network_tab, 'binding_overlay') and self.network_tab.binding_overlay: bindings = brain.get('output_bindings', []) if bindings: print(f" -> Sending {len(bindings)} bindings to overlay") self.network_tab.binding_overlay.update_bindings(bindings) # 3. Finalize _current_custom_brain = data _current_custom_brain_name = Path(filepath).stem _current_custom_brain_file = filepath self._update_worker() if hasattr(self.network_tab, 'update_metrics_display'): self.network_tab.update_metrics_display() if self.brain_widget: self.brain_widget.update() name = Path(filepath).stem QtWidgets.QMessageBox.information( self.network_tab, "Loaded", f"✅ {name}\n\nNeurons: {len(brain['positions'])}\nConnections: {len(brain['weights'])}\n\n" f"This brain will be saved with your game." ) return True except Exception as e: import traceback print(f"❌ Failed to load brain: {e}") traceback.print_exc() QtWidgets.QMessageBox.critical(self.network_tab, "Error", f"Failed to load:\n{e}") return False def reset_to_default(self): """Reset to the default brain.""" global _current_custom_brain, _current_custom_brain_name, _current_custom_brain_file bw = self.brain_widget if not bw or not hasattr(bw, '_orig_backup'): QtWidgets.QMessageBox.warning( self.network_tab, "Cannot Reset", "No original brain backup available." ) return backup = bw._orig_backup # Restore from backup if hasattr(bw, 'neuron_positions'): bw.neuron_positions.clear() bw.neuron_positions.update(backup['positions']) if hasattr(bw, 'weights'): bw.weights.clear() bw.weights.update(backup['weights']) if hasattr(bw, 'state'): # Only restore non-status neurons status = {'is_sick', 'is_eating', 'is_sleeping', 'pursuing_food', 'direction'} saved_status = {k: v for k, v in bw.state.items() if k in status} bw.state.clear() bw.state.update(backup['state']) bw.state.update(saved_status) if hasattr(bw, 'connections'): bw.connections = list(bw.weights.keys()) # Clear custom brain tracking _current_custom_brain = None _current_custom_brain_name = None _current_custom_brain_file = None self._update_worker() if hasattr(self.network_tab, 'update_metrics_display'): self.network_tab.update_metrics_display() bw.update() QtWidgets.QMessageBox.information( self.network_tab, "Reset", "Brain reset to default configuration." ) def _parse(self, data: Dict) -> Optional[Dict]: """Parse brain file to standard format.""" result = {'positions': {}, 'weights': {}, 'state': {}, 'excluded': set(), 'output_bindings': []} # Format 1: Dosidicus format (connections as dict with "->") if 'neurons' in data and isinstance(data.get('connections'), dict): for name, nd in data['neurons'].items(): pos = nd.get('position', [0, 0]) result['positions'][name] = tuple(pos) if isinstance(pos, list) else pos for key, w in data.get('connections', {}).items(): if '->' in str(key): s, t = str(key).split('->') result['weights'][(s, t)] = w result['state'] = data.get('state', {}) result['excluded'] = set(data.get('excluded_neurons', [])) # Format 2: Designer format (connections as list) elif 'neurons' in data and isinstance(data.get('connections'), list): for name, nd in data['neurons'].items(): pos = nd.get('position', [0, 0]) result['positions'][name] = tuple(pos) if isinstance(pos, list) else pos result['state'][name] = nd.get('activation', 50.0) for c in data.get('connections', []): if c.get('source') and c.get('target'): result['weights'][(c['source'], c['target'])] = c.get('weight', 0.1) result['excluded'] = set(data.get('excluded_neurons', [])) # Format 3: Layer format elif 'layers' in data: for i, layer in enumerate(data['layers']): neurons = layer.get('neurons', []) y = 80 + i * 150 for j, name in enumerate(neurons): x = 100 + (j + 1) * (700 // (len(neurons) + 1)) result['positions'][name] = (x, y) result['state'][name] = 50.0 for c in data.get('custom_connections', []): if c.get('source') and c.get('target'): result['weights'][(c['source'], c['target'])] = c.get('weight', 0.1) else: return None # Parse output bindings result['output_bindings'] = data.get('output_bindings', []) # Defaults for n in result['positions']: if n not in result['state']: result['state'][n] = 50.0 status = {'is_sick', 'is_eating', 'is_sleeping', 'pursuing_food', 'direction'} result['excluded'].update(status) return result if result['positions'] else None def _apply(self, brain: dict): """Apply parsed brain data to the current brain widget.""" bw = self.brain_widget # Apply positions if 'positions' in brain: if hasattr(bw, 'neuron_positions'): bw.neuron_positions.clear() bw.neuron_positions.update(brain['positions']) # --- FIX: Ensure mandatory sensors are present --- # If the custom brain deleted core sensors, the game logic will break. # We restore them using original positions (or default) so inputs still work. MANDATORY_SENSORS = { 'can_see_food', 'external_stimulus', 'plant_proximity', 'is_eating', 'is_sleeping', 'is_sick', 'is_fleeing', 'is_startled', 'pursuing_food' } restored_sensors = 0 if hasattr(bw, 'original_neuron_positions'): for sensor in MANDATORY_SENSORS: if sensor not in bw.neuron_positions: if sensor in bw.original_neuron_positions: bw.neuron_positions[sensor] = bw.original_neuron_positions[sensor] # Also ensure it has a state entry if hasattr(bw, 'state') and sensor not in bw.state: bw.state[sensor] = 0.0 restored_sensors += 1 if restored_sensors > 0: print(f"🔧 Restored {restored_sensors} mandatory sensor neurons missing from custom brain") # Apply weights/connections if 'weights' in brain: if hasattr(bw, 'weights'): bw.weights.clear() bw.weights.update(brain['weights']) # Apply state if present (neuron activations) if 'state' in brain: if hasattr(bw, 'state'): bw.state.clear() bw.state.update(brain['state']) # Populate bw.neurons for synchronous simulation fallback if hasattr(bw, 'neurons'): bw.neurons = {} for name in bw.neuron_positions: # Check if this is a binary/sensor neuron is_sensor = hasattr(bw, 'is_binary_neuron') and bw.is_binary_neuron(name) # FIX: Sensors get 1.0 decay so they persist. 0.0 decay zeroes them out instantly. bw.neurons[name] = { 'activation': bw.state.get(name, 50.0), 'decay': 1.0 if is_sensor else 0.9, 'noise': 0.0 } # Apply excluded neurons if 'excluded' in brain: if hasattr(bw, 'excluded_neurons'): bw.excluded_neurons = list(brain['excluded']) # === OUTPUT BINDINGS (Neuron → Behavior) === output_bindings_data = brain.get('output_bindings', []) if output_bindings_data: # Find the NeuronOutputMonitor monitor = None logic = getattr(bw, 'tamagotchi_logic', None) if logic and hasattr(logic, 'neuron_output_monitor'): monitor = logic.neuron_output_monitor # Fallback if attached differently elif hasattr(bw, 'parent') and hasattr(bw.parent(), 'tamagotchi_logic'): monitor = bw.parent().tamagotchi_logic.neuron_output_monitor if monitor: # Load the bindings monitor.bindings = [] # Clear old ones loaded_count = 0 for bind_data in output_bindings_data: try: binding = NeuronOutputBinding.from_dict(bind_data) monitor.bindings.append(binding) loaded_count += 1 except Exception as e: print(f"[BrainLoader] Failed to load binding: {e}") print(f"[NeuronOutputMonitor] Loaded {loaded_count} neuron output bindings") # Use the actual monitor() method to trigger initial thresholds if hasattr(bw, 'state') and bw.state: # Convert state values to float where needed activations = {} for name, value in bw.state.items(): try: activations[name] = float(value) except (TypeError, ValueError): activations[name] = 100.0 if value else 0.0 # Trigger the monitor with current activations monitor.monitor(activations) print("[NeuronOutputMonitor] Initial neuron activations processed via monitor()") else: print("[NeuronOutputMonitor] ⚠️ No state data available for initial trigger") else: print("[NeuronOutputMonitor] ⚠️ Monitor not found — bindings saved but not activated") # Keep a copy on the widget for saving/exporting later bw.output_bindings = output_bindings_data # Update related attributes safely if hasattr(bw, 'connections'): bw.connections = list(brain['weights'].keys()) if hasattr(bw, 'visible_neurons'): bw.visible_neurons = set(bw.neuron_positions.keys()) if hasattr(bw, 'neuron_positions') else set() if hasattr(bw, 'original_neuron_positions') and hasattr(bw, 'neuron_positions'): bw.original_neuron_positions = dict(bw.neuron_positions) if hasattr(bw, 'original_neurons') and hasattr(bw, 'neuron_positions'): status = {'is_sick', 'is_eating', 'is_sleeping', 'pursuing_food', 'direction'} bw.original_neurons = [n for n in bw.neuron_positions if n not in status] if hasattr(bw, 'communication_events') and hasattr(bw, 'neuron_positions'): bw.communication_events = {n: 0 for n in bw.neuron_positions} def _update_worker(self): """Update BrainWorker cache if available.""" try: worker = None parent = self.network_tab.parent() if self.network_tab else None while parent: if hasattr(parent, 'brain_worker'): worker = parent.brain_worker break parent = parent.parent() if not worker: print("⚠️ BrainWorker not found - skipping cache update") return # Try to update worker cache - different versions may have different APIs bw = self.brain_widget if hasattr(worker, 'update_cache') and callable(getattr(worker, 'update_cache')): worker.update_cache( state=bw.state, weights=bw.weights, neuron_positions=bw.neuron_positions, config=getattr(bw, 'config', None), excluded_neurons=getattr(bw, 'excluded_neurons', []), learning_rate=getattr(bw, 'learning_rate', 0.1), new_neurons=bw.neurogenesis_data.get('new_neurons', []) if hasattr(bw, 'neurogenesis_data') else [] ) print("✅ BrainWorker cache updated") elif hasattr(worker, 'set_brain_data') and callable(getattr(worker, 'set_brain_data')): worker.set_brain_data(bw.state, bw.weights, bw.neuron_positions) print("✅ BrainWorker data updated") else: # Just update the worker's direct references if it has them if hasattr(worker, 'state'): worker.state = bw.state if hasattr(worker, 'weights'): worker.weights = bw.weights if hasattr(worker, 'neuron_positions'): worker.neuron_positions = bw.neuron_positions print("✅ BrainWorker references updated") except Exception as e: print(f"⚠️ Could not update BrainWorker: {e}") # Non-fatal - brain still loads, just learning might need restart def reset_positions_to_default(self): """Reset only neuron positions to defaults, preserving network structure""" bw = self.brain_widget if not bw or not hasattr(bw, '_orig_backup'): raise ValueError("No original brain backup available") # Reset positions only for neuron in list(bw.neuron_positions.keys()): if neuron in bw._orig_backup['positions']: bw.neuron_positions[neuron] = bw._orig_backup['positions'][neuron] # Update displays if hasattr(self.network_tab, 'update_metrics_display'): self.network_tab.update_metrics_display() # Repaint bw.update() print("✅ Neuron positions reset to defaults") # ============================================================================= # BRAIN SELECTION DIALOG # ============================================================================= class BrainSelectDialog(QtWidgets.QDialog): """Simple dialog to select a brain file.""" def __init__(self, brains_folder: Path, parent=None): super().__init__(parent) self.brains_folder = brains_folder self.selected_file = None self.setWindowTitle("Load Custom Brain") self.setMinimumSize(450, 350) self._setup_ui() self._refresh() def _setup_ui(self): layout = QtWidgets.QVBoxLayout(self) # List self.list = QtWidgets.QListWidget() self.list.setStyleSheet("font-size: 11pt;") self.list.itemDoubleClicked.connect(self.accept) self.list.currentRowChanged.connect(self._on_select) layout.addWidget(self.list) # Info self.info = QtWidgets.QLabel("Select a brain file") self.info.setStyleSheet("background: #f0f0f0; padding: 8px; border-radius: 4px;") self.info.setWordWrap(True) layout.addWidget(self.info) # Buttons btns = QtWidgets.QHBoxLayout() browse = QtWidgets.QPushButton("Browse...") browse.clicked.connect(self._browse) btns.addWidget(browse) folder = QtWidgets.QPushButton("Open Folder") folder.clicked.connect(self._open_folder) btns.addWidget(folder) btns.addStretch() cancel = QtWidgets.QPushButton("Cancel") cancel.clicked.connect(self.reject) btns.addWidget(cancel) load = QtWidgets.QPushButton("Load") load.setStyleSheet("background: #5c6bc0; color: white; font-weight: bold;") load.clicked.connect(self.accept) btns.addWidget(load) layout.addLayout(btns) # Hint hint = QtWidgets.QLabel(f"Folder: {self.brains_folder}") hint.setStyleSheet("color: #888; font-size: 9pt;") layout.addWidget(hint) def _refresh(self): self.list.clear() files = list(self.brains_folder.glob("*.json")) if self.brains_folder.exists() else [] if not files: item = QtWidgets.QListWidgetItem("(No brain files - click Browse)") item.setFlags(item.flags() & ~QtCore.Qt.ItemIsSelectable) self.list.addItem(item) return for f in sorted(files): item = QtWidgets.QListWidgetItem(f"🧠 {f.stem}") item.setData(QtCore.Qt.UserRole, str(f)) self.list.addItem(item) def _on_select(self, row): item = self.list.item(row) if not item: return path = item.data(QtCore.Qt.UserRole) if not path: return try: with open(path) as f: data = json.load(f) neurons = len(data.get('neurons', {})) conns = data.get('connections', {}) conn_count = len(conns) if isinstance(conns, (dict, list)) else 0 meta = data.get('metadata', {}) name = meta.get('name', Path(path).stem) desc = meta.get('description', '') self.info.setText(f"{name}
    Neurons: {neurons} | Connections: {conn_count}
    {desc}") except: self.info.setText("Could not read file") def _browse(self): path, _ = QtWidgets.QFileDialog.getOpenFileName( self, "Select Brain", str(self.brains_folder), "JSON (*.json)" ) if path: self.selected_file = path self.accept() def _open_folder(self): import subprocess, sys folder = str(self.brains_folder) if sys.platform == 'win32': subprocess.run(['explorer', folder]) elif sys.platform == 'darwin': subprocess.run(['open', folder]) else: subprocess.run(['xdg-open', folder]) def accept(self): if not self.selected_file: item = self.list.currentItem() if item: self.selected_file = item.data(QtCore.Qt.UserRole) if self.selected_file: super().accept() ================================================ FILE: src/decision_engine.py ================================================ # DECISION ENGINE - https://github.com/ViciousSquid/Dosidicus # exploration of emergent behavioural complexity via dynamic, biologically-inspired neural architecture rather than a static state machine. # Version 4.0 — Fully Neural-Driven Decision Making | December 2025 import random import math from .personality import Personality class DecisionEngine: """ Neural-first decision engine. All perception flows through BrainNeuronHooks → no manual vision checks. Behaviour emerges purely from the current brain state + memory + personality. """ def __init__(self, squid): self.squid = squid self.last_decision_data = {} def get_decision_data(self): """Return last decision trace for visualization in Brain Tool → Decisions tab""" return self.last_decision_data.copy() def make_decision(self): logic = self.squid.tamagotchi_logic # ================================================================= # 1. BUILD FULL BRAIN STATE FROM HOOKS # ================================================================= decision_data = { 'inputs': {}, 'brain_state': {}, 'base_weights': {}, 'memory_influences': {}, 'urgency_multipliers': {}, 'personality_modifiers': {}, 'adjusted_weights': {}, 'final_decision': '', 'confidence': 0.0, 'personality': self.squid.personality.value if isinstance(self.squid.personality, Personality) else str(self.squid.personality), 'timestamp': time.time() } # --- Get dynamic perceptual inputs via hooks --- if not hasattr(logic, 'brain_hooks'): perceptual_inputs = {} else: try: perceptual_inputs = logic.brain_hooks.get_input_neuron_values() logic.brain_hooks.update_decay() # Critical: decay temporal sensors except Exception as e: print(f"[DecisionEngine] Hook error: {e}") perceptual_inputs = {} decision_data['inputs'] = perceptual_inputs # --- Get full current brain state (core + learned + input neurons) --- try: brain_state = logic.brain_window.brain_widget.state.copy() except: brain_state = {} # Ensure perceptual inputs are present even if brain_widget hasn't updated yet brain_state.update(perceptual_inputs) decision_data['brain_state'] = brain_state # ================================================================= # 2. MEMORY INFLUENCE # ================================================================= active_memories = self.squid.memory_manager.get_active_memories_data(6) memory_mod = { "eating": 1.0, "playing": 1.0, "exploring": 1.0, "approaching_plant": 1.0, "throwing": 1.0, } for mem in active_memories: effect = sum(v for v in mem.get('raw_value', {}).values() if isinstance(v, (int, float))) if isinstance(mem.get('raw_value'), dict) else 0 if mem['category'] == 'food' and effect > 0: memory_mod['eating'] *= 1.25 if 'rock' in mem['key'] and effect > 0: memory_mod['playing'] *= 1.2 memory_mod['throwing'] *= 1.15 if 'poop' in mem['key'] and effect > 0: memory_mod['playing'] *= 1.1 if 'plant' in mem['key'] and effect > 0: memory_mod['approaching_plant'] *= 1.3 if 'startled' in mem['key']: memory_mod['exploring'] *= 0.7 memory_mod['approaching_plant'] *= 1.4 decision_data['memory_influences'] = memory_mod # ================================================================= # 3. URGENCY (NON-LINEAR PHYSIOLOGICAL DRIVE) # ================================================================= hunger_level = brain_state.get('hunger', 50) sleepiness = brain_state.get('sleepiness', 50) urgency = { "eating": math.pow(1.6, hunger_level / 25), "sleeping": math.pow(1.7, sleepiness / 25), } decision_data['urgency_multipliers'] = urgency # Immediate overrides if sleepiness >= 95: self.squid.go_to_sleep() decision_data['final_decision'] = "exhausted" decision_data['confidence'] = 1.0 self.last_decision_data = decision_data return "exhausted" if self.squid.is_sleeping: decision_data['final_decision'] = "sleeping peacefully" decision_data['confidence'] = 1.0 self.last_decision_data = decision_data return "sleeping peacefully" if brain_state.get('external_stimulus', 0) > 90: decision_data['final_decision'] = "startled!" decision_data['confidence'] = 1.0 self.last_decision_data = decision_data return "startled!" # ================================================================= # 4. BUILD DECISION WEIGHTS FROM BRAIN STATE # ================================================================= weights = {} # Exploration threat = brain_state.get('threat_level', brain_state.get('anxiety', 50)) external = brain_state.get('external_stimulus', 0) weights["exploring"] = ( brain_state.get("curiosity", 50) * max(0.1, 1.0 - threat / 140) * (0.2 if external > 80 else 1.0) ) # Eating can_see_food = brain_state.get('can_see_food', 0) weights["eating"] = ( hunger_level * (3.0 if can_see_food > 80 else 0.3) * urgency["eating"] ) # Plant seeking (comfort) near_plant = brain_state.get('plant_proximity', 0) > 40 weights["approaching_plant"] = ( (brain_state.get("anxiety", 50) / 40) * (3.0 if near_plant else 0.5) * (4.0 if self.squid.personality == Personality.TIMID else 1.8) ) # Play / Object interaction carrying = self.squid.carrying_rock or self.squid.carrying_poop weights["playing"] = ( brain_state.get("satisfaction", 50) * brain_state.get("curiosity", 50) / 50 * (2.2 if carrying else 1.0) * (0.4 if brain_state.get('is_sick', 0) > 50 else 1.0) ) # Throwing (only if carrying) weights["throwing"] = ( brain_state.get("satisfaction", 50) * 1.3 if carrying else 0 ) # Sleeping (non-exhausted) weights["sleeping"] = sleepiness * urgency["sleeping"] * 0.6 # Fleeing from threat if threat > 75 or external > 85: weights["fleeing"] = threat * 1.8 decision_data['base_weights'] = weights.copy() # ================================================================= # 5. APPLY MEMORY + PERSONALITY MODIFIERS # ================================================================= for action, mod in memory_mod.items(): if action in weights: weights[action] *= mod # Apply personality if self.squid.personality == Personality.ADVENTUROUS: weights["exploring"] *= 1.4 weights["playing"] *= 1.3 elif self.squid.personality == Personality.TIMID: weights["exploring"] *= 0.6 weights["approaching_plant"] *= 1.5 elif self.squid.personality == Personality.GREEDY: weights["eating"] *= 1.6 elif self.squid.personality == Personality.LAZY: weights["playing"] *= 0.5 weights["exploring"] *= 0.7 elif self.squid.personality == Personality.ENERGETIC: weights["playing"] *= 1.5 weights["exploring"] *= 1.2 # Anxiety amplifies comfort-seeking if brain_state.get("anxiety", 50) > 60: weights["approaching_plant"] *= 1.8 + (brain_state.get("anxiety", 0) - 60) / 80 decision_data['personality_modifiers'] = {k: v/weights.get(k,1) for k,v in weights.items() if k in weights} # Add randomness for k in weights: weights[k] *= random.uniform(0.88, 1.12) decision_data['adjusted_weights'] = weights.copy() # ================================================================= # 6. SELECT AND EXECUTE DECISION # ================================================================= if not any(weights.values()): final = "exploring" else: final = max(weights, key=weights.get) # Confidence calculation sorted_w = sorted(weights.values(), reverse=True) confidence = 1.0 if len(sorted_w) < 2 else (sorted_w[0] - sorted_w[1]) / sorted_w[0] decision_data['final_decision'] = final decision_data['confidence'] = confidence self.last_decision_data = decision_data result = self._execute_neural_decision(final, brain_state) self.last_decision_data['final_decision'] = result # actual outcome return result def _execute_neural_decision(self, decision: str, brain_state: dict): """Execute decision based purely on neural signals — no redundant scanning""" s = self.squid if decision == "eating" and brain_state.get('can_see_food', 0) > 70: # Find closest food using existing logic method food = s.tamagotchi_logic.food_items if food: closest = min(food, key=lambda f: s.distance_to(f.pos().x(), f.pos().y())) s.move_towards(closest.pos().x(), closest.pos().y()) dist = s.distance_to(closest.pos().x(), closest.pos().y()) if dist < 60: return "eating" elif dist < 120: return "approaching food" else: return "eyeing food" elif decision == "approaching_plant" and brain_state.get('plant_proximity', 0) > 30: # Use decoration cache or scene scan fallback plants = [item for item in s.tamagotchi_logic.user_interface.scene.items() if getattr(item, 'category', '') == 'plant'] if plants: closest = min(plants, key=lambda p: s.distance_to(p.sceneBoundingRect().center().x(), p.sceneBoundingRect().center().y())) s.move_towards(closest.sceneBoundingRect().center().x(), closest.sceneBoundingRect().center().y()) return "seeking comfort in plant" elif decision in ("playing", "throwing"): if s.carrying_rock: if s.throw_rock(random.choice(["left", "right"])): return "playfully tossing rock" elif s.carrying_poop: if s.throw_poop(random.choice(["left", "right"])): return "flinging poop playfully" # Approach nearest rock/poop if visible via brain elif brain_state.get('can_see_food', 0) == 0: # crude proxy, but better than nothing targets = [item for item in s.tamagotchi_logic.user_interface.scene.items() if getattr(item, 'category', '') in ('rock', 'poop')] if targets: closest = min(targets, key=lambda t: s.distance_to(t.sceneBoundingRect().center().x(), t.sceneBoundingRect().center().y())) s.move_towards(closest.sceneBoundingRect().center().x(), closest.sceneBoundingRect().center().y()) return "seeking toy" elif decision == "sleeping": s.go_to_sleep() return "settling down to sleep" elif decision == "fleeing" or brain_state.get('external_stimulus', 0) > 85: s.flee_from_center() return "fleeing!" # Default: explore with personality flavor flavors = { Personality.TIMID: ["cautiously peeking", "nervously watching"], Personality.ADVENTUROUS: ["boldly exploring", "seeking adventure"], Personality.GREEDY: ["hunting for food", "scouting"], Personality.LAZY: ["lounging", "drifting lazily"], Personality.ENERGETIC: ["zooming around", "bouncing energetically"], Personality.STUBBORN: ["patrolling territory", "standing ground"], }.get(s.personality, ["wandering", "exploring curiously"]) style = random.choice(flavors) if "zoom" in style or "bounc" in style: s.move_erratically() elif "loung" in style or "drift" in style: s.move_slowly() else: s.move_randomly() return style ================================================ FILE: src/decoration_stats.json ================================================ { "plant01.png": { "happiness": 2, "cleanliness": 1, "anxiety": -2, "category": "plant" }, "plant02.png": { "happiness": 2, "cleanliness": 1, "anxiety": -3, "category": "plant" }, "plant03.png": { "happiness": 2, "cleanliness": 1, "anxiety": -2, "category": "plant" }, "plant04.png": { "happiness": 2, "cleanliness": 1, "anxiety": -3, "category": "plant" }, "plant05.png": { "happiness": 2, "cleanliness": 1, "anxiety": -4, "category": "plant" }, "plant06.png": { "happiness": 2, "cleanliness": 1, "category": "plant", "anxiety": -2 }, "plant07.png": { "happiness": 2, "cleanliness": 2, "anxiety": -3, "category": "plant" }, "plant08.png": { "happiness": 2, "cleanliness": 2, "anxiety": -4, "category": "plant" }, "plant09.png": { "happiness": 1, "cleanliness": 2, "anxiety": -2, "category": "plant" }, "plant10.png": { "happiness": 1, "cleanliness": 2, "anxiety": -3, "category": "plant" }, "plant11.png": { "happiness": 1, "cleanliness": 2, "anxiety": -4, "category": "plant" }, "plant12.png": { "happiness": 2, "cleanliness": 2, "anxiety": -5, "category": "plant" }, "sml_plant01.png": { "happiness": 2, "cleanliness": 2, "anxiety": -4, "category": "plant" }, "sml_plant02.png": { "happiness": 2, "cleanliness": 2, "anxiety": -5, "category": "plant" }, "rock03.png": { "happiness": 4, "satisfaction": 4, "category": "rock" }, "rock01.png": { "happiness": 4, "satisfaction": 4, "category": "rock" }, "rock02.png": { "happiness": 4, "satisfaction": 3, "category": "rock" }, "Znewplant01.png": { "happiness": 2, "cleanliness": 1, "anxiety": -3, "category": "plant" }, "Znewplant02.png": { "happiness": 2, "cleanliness": 2, "anxiety": -4, "category": "plant" }, "Znewplant03.png": { "happiness": 2, "cleanliness": 1, "anxiety": -2, "category": "plant" }, "Znewplant04.png": { "happiness": 1, "cleanliness": 2, "anxiety": -3, "category": "plant" }, "Znewplant05.png": { "happiness": 2, "cleanliness": 2, "anxiety": -5, "category": "plant" }, "Znewplant06.png": { "happiness": 2, "cleanliness": 1, "category": "plant", "anxiety": -2 }, "Znewplant07.png": { "happiness": 1, "cleanliness": 2, "anxiety": -4, "category": "plant" }, "Znewplant08.png": { "happiness": 2, "cleanliness": 2, "anxiety": -5, "category": "plant" } } ================================================ FILE: src/designer_canvas.py ================================================ """ Canvas implementation for Brain Designer. Handles visual representation and interaction. """ import math import json import os from PyQt5.QtWidgets import ( QGraphicsItem, QGraphicsView, QGraphicsScene, QGraphicsEllipseItem, QGraphicsLineItem, QGraphicsTextItem, QGraphicsRectItem, QStyle, QToolTip, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QPushButton, QCheckBox, QMessageBox ) from PyQt5.QtCore import Qt, QPointF, QRectF, QLineF, pyqtSignal, QTimer from PyQt5.QtGui import ( QPainter, QPen, QBrush, QColor, QFont, QFontMetrics, QPainterPath, QRadialGradient, QTransform, QCursor, QPainterPathStroker, QPolygonF ) from .designer_core import BrainDesign from .designer_constants import ( NeuronType, CORE_NEURON_RING_COLOR, INPUT_SENSOR_RING_COLOR, CUSTOM_NEURON_RING_COLOR, PROTECTED_RING_WIDTH, NORMAL_RING_WIDTH, DEFAULT_LAYER_HEIGHT ) class SmartConnectionItem(QGraphicsItem): """Visual representation of a neural connection with organic growth animation.""" def __init__(self, source_pos, target_pos, weight=0.5, source_name="", target_name="", parent=None): super().__init__(parent) self.source_pos = source_pos self.target_pos = target_pos self.weight = weight self.source_name = source_name self.target_name = target_name # Visual states self.is_selected = False self.is_hovered = False self.is_dying = False # If True, connection is retreating/fading out # Organic Animation States self.growth = 0.0 # 0.0 = at source, 1.0 = full connection self.opacity = 0.0 # 0.0 = invisible, 1.0 = fully visible # Randomized "Personality" for this vine self.growth_speed = 0.02 + (hash(str(source_pos) + str(target_pos)) % 50) * 0.0006 self.growth = -0.1 # Start slightly delayed # Pulse animation (standard) self.pulse_phase = 0.0 self.setAcceptHoverEvents(True) self.setZValue(-5) self.hit_thickness = 25 def boundingRect(self): extra = 30 # protect against null-length line if self.source_pos == self.target_pos: return QRectF(self.source_pos.x() - extra, self.source_pos.y() - extra, 2 * extra, 2 * extra) return QRectF(self.source_pos, self.target_pos).normalized() \ .adjusted(-extra, -extra, extra, extra) def shape(self): """Custom hit area for connection lines.""" path = QPainterPath() path.moveTo(self.source_pos) path.lineTo(self.target_pos) # Create a stroker to widen the hit area stroker = QPainterPathStroker() stroker.setWidth(self.hit_thickness) stroker.setCapStyle(Qt.RoundCap) return stroker.createStroke(path) def hoverEnterEvent(self, event): self.is_hovered = True self.update() super().hoverEnterEvent(event) def hoverLeaveEvent(self, event): self.is_hovered = False self.update() super().hoverLeaveEvent(event) def advance_animation(self): """Calculates one frame of growth/retreat logic.""" # 1. Handle Dying (Retreating) if self.is_dying: self.growth -= self.growth_speed * 1.5 self.opacity -= 0.05 if self.opacity < 0: self.opacity = 0 return self.opacity <= 0 # 2. Handle Living (Growing) if self.growth < 1.0: self.growth += self.growth_speed if self.growth > 1.0: self.growth = 1.0 # Fade in if self.growth > 0 and self.opacity < 1.0: self.opacity += 0.05 if self.opacity > 1.0: self.opacity = 1.0 # 3. Pulse Logic if self.growth > 0.8: current_speed = 0.02 + (abs(self.weight) * 0.06) self.pulse_phase += current_speed if self.pulse_phase > 1.0: self.pulse_phase = 0.0 self.update() return False # Still alive def paint(self, painter, option, widget): painter.save() painter.setRenderHint(QPainter.Antialiasing) # Draw hover glow (subtle blue when hovered but not selected) if self.is_hovered and not self.is_selected: pen_width = 12 glow_color = QColor(100, 200, 255, 100) painter.setPen(QPen(glow_color, pen_width, Qt.SolidLine, Qt.RoundCap)) painter.drawLine(self.source_pos, self.target_pos) # Draw the actual connection line # Determine color based on weight (Green=Excitatory, Red=Inhibitory) base_color = QColor(50, 205, 50) if self.weight >= 0 else QColor(220, 50, 50) # Adjust alpha by opacity (growth animation) base_color.setAlpha(int(255 * self.opacity)) # Determine thickness (magnitude of weight) thickness = 2 + abs(self.weight) * 6 # Pulse effect for thickness pulse = math.sin(self.pulse_phase * math.pi * 2) * 0.5 + 0.5 thickness += pulse * 1.5 # Draw selection highlight (yellow glow when selected) if self.is_selected: painter.setPen(QPen(QColor(255, 215, 0), thickness + 4, Qt.SolidLine, Qt.RoundCap)) painter.drawLine(self.source_pos, self.target_pos) painter.setPen(QPen(base_color, thickness, Qt.SolidLine, Qt.RoundCap)) painter.drawLine(self.source_pos, self.target_pos) painter.restore() class ConnectorNeuronItem(QGraphicsItem): """ Connector Neuron: Black hexagon with a white 'c' inside. Matches brain_widget.py's draw_hexagon_neuron style. """ def __init__(self, x, y, radius, name, parent=None): super().__init__(parent) self.name = name self.radius = radius self.is_selected = False self.is_hovered = False self.neuron_type = NeuronType.CONNECTOR self.setPos(x, y) self.setAcceptHoverEvents(True) self.setData(0, ('neuron', name)) self.setZValue(2) def boundingRect(self): extra = 15 return QRectF(-self.radius - extra, -self.radius - extra, (self.radius + extra) * 2, (self.radius + extra) * 2) def shape(self): """Custom hitbox for Hexagon.""" path = QPainterPath() sides = 6 polygon = QPolygonF() angle_step = 360.0 / sides for i in range(sides): angle = math.radians(i * angle_step - 90) polygon.append(QPointF(self.radius * math.cos(angle), self.radius * math.sin(angle))) path.addPolygon(polygon) path.closeSubpath() return path def hoverEnterEvent(self, event): self.is_hovered = True self.update() super().hoverEnterEvent(event) def hoverLeaveEvent(self, event): self.is_hovered = False self.update() super().hoverLeaveEvent(event) def paint(self, painter, option, widget): painter.save() painter.setRenderHint(QPainter.Antialiasing) # Draw hover glow if self.is_hovered and not self.is_selected: grad = QRadialGradient(0, 0, self.radius + 12) grad.setColorAt(0.5, QColor(100, 200, 255, 100)) grad.setColorAt(1.0, QColor(100, 200, 255, 0)) painter.setBrush(QBrush(grad)) painter.setPen(Qt.NoPen) painter.drawEllipse(QPointF(0, 0), self.radius + 12, self.radius + 12) # Draw selection ring if self.is_selected: painter.setPen(QPen(QColor(255, 255, 255), 4)) painter.setBrush(Qt.NoBrush) painter.drawEllipse(QPointF(0, 0), self.radius + 5, self.radius + 5) # Draw BLACK hexagon body sides = 6 polygon = QPolygonF() angle_step = 360.0 / sides for i in range(sides): angle = math.radians(i * angle_step - 90) polygon.append(QPointF(self.radius * math.cos(angle), self.radius * math.sin(angle))) painter.setBrush(QBrush(QColor(0, 0, 0))) # BLACK fill painter.setPen(QPen(QColor(50, 50, 50), 2)) painter.drawPolygon(polygon) # Draw WHITE 'c' inside font = QFont("Arial", int(self.radius * 0.7)) font.setBold(True) painter.setFont(font) painter.setPen(QColor(255, 255, 255)) # WHITE text rect = QRectF(-self.radius, -self.radius, self.radius * 2, self.radius * 2) painter.drawText(rect, Qt.AlignCenter, "c") painter.restore() class NeuronItem(QGraphicsEllipseItem): """ Neuron body. Acts as the Parent item for the Ring and Label. """ def __init__(self, x, y, radius, name, neuron_type=None, parent=None): # Define geometry centered at (0,0) locally super().__init__(-radius, -radius, radius * 2, radius * 2, parent) self.name = name self.radius = radius self.is_selected = False self.is_hovered = False self.neuron_type = neuron_type # Set absolute position in the scene self.setPos(x, y) self.setAcceptHoverEvents(True) self.setData(0, ('neuron', name)) self.setZValue(2) def event(self, event): if self.scene() is None: return False return super().event(event) def hoverEnterEvent(self, event): self.is_hovered = True self.update() super().hoverEnterEvent(event) def hoverLeaveEvent(self, event): self.is_hovered = False self.update() super().hoverLeaveEvent(event) def paint(self, painter, option, widget): # Draw hover glow if self.is_hovered and not self.is_selected: painter.setRenderHint(QPainter.Antialiasing) # Glow is drawn relative to local center (0,0) grad = QRadialGradient(0, 0, self.radius + 12) grad.setColorAt(0.5, QColor(100, 200, 255, 100)) grad.setColorAt(1.0, QColor(100, 200, 255, 0)) painter.setBrush(QBrush(grad)) painter.setPen(Qt.NoPen) painter.drawEllipse(-self.radius - 12, -self.radius - 12, (self.radius + 12)*2, (self.radius + 12)*2) super().paint(painter, option, widget) class DesignerConfig: """Manages designer preferences.""" def __init__(self, config_path=None): if config_path is None: config_dir = os.path.expanduser("~/.dosidicus") os.makedirs(config_dir, exist_ok=True) config_path = os.path.join(config_dir, "designer_config.json") self.config_path = config_path self.config = self._load_config() def _load_config(self): if os.path.exists(self.config_path): try: with open(self.config_path, 'r') as f: return json.load(f) except: pass return {'confirm_connection_delete': True} def save(self): try: with open(self.config_path, 'w') as f: json.dump(self.config, f, indent=2) except: pass def get(self, key, default=None): return self.config.get(key, default) def set(self, key, value): self.config[key] = value self.save() class ConfirmDeleteDialog(QDialog): """Confirmation dialog.""" def __init__(self, source, target, parent=None): super().__init__(parent) self.setWindowTitle("Delete Connection") self.dont_ask_again = False layout = QVBoxLayout() msg = QLabel(f"Are you sure you want to delete the connection:\n{source} → {target}?") msg.setWordWrap(True) layout.addWidget(msg) self.checkbox = QCheckBox("Don't ask again") layout.addWidget(self.checkbox) button_layout = QHBoxLayout() self.yes_button = QPushButton("Yes, Delete") self.yes_button.clicked.connect(self.accept) self.no_button = QPushButton("Cancel") self.no_button.clicked.connect(self.reject) button_layout.addWidget(self.no_button) button_layout.addWidget(self.yes_button) layout.addLayout(button_layout) self.setLayout(layout) self.yes_button.setDefault(True) def accept(self): self.dont_ask_again = self.checkbox.isChecked() super().accept() class ConnectionWeightDialog(QDialog): """Dialog for editing connection weight.""" def __init__(self, source, target, current_weight, config, parent=None): super().__init__(parent) self.source = source self.target = target self.config = config self.delete_requested = False self.setWindowTitle("Edit Connection") self.setModal(True) layout = QVBoxLayout() info_label = QLabel(f"Connection: {source} → {target}") info_label.setStyleSheet("font-weight: bold;") layout.addWidget(info_label) weight_layout = QHBoxLayout() weight_layout.addWidget(QLabel("Weight:")) self.weight_spin = QDoubleSpinBox() self.weight_spin.setRange(-1.0, 1.0) self.weight_spin.setSingleStep(0.05) self.weight_spin.setDecimals(3) self.weight_spin.setValue(current_weight) self.weight_spin.setMinimumWidth(100) weight_layout.addWidget(self.weight_spin) layout.addLayout(weight_layout) info_text = QLabel("Positive = Excitatory (green), Negative = Inhibitory (red)") info_text.setStyleSheet("color: gray; font-size: 9pt;") layout.addWidget(info_text) layout.addSpacing(10) button_layout = QHBoxLayout() self.delete_button = QPushButton("Delete Connection") self.delete_button.setStyleSheet("background-color: #d32f2f; color: white;") self.delete_button.clicked.connect(self.on_delete_clicked) button_layout.addWidget(self.delete_button) button_layout.addStretch() self.cancel_button = QPushButton("Cancel") self.cancel_button.clicked.connect(self.reject) button_layout.addWidget(self.cancel_button) self.ok_button = QPushButton("OK") self.ok_button.clicked.connect(self.accept) self.ok_button.setDefault(True) button_layout.addWidget(self.ok_button) layout.addLayout(button_layout) self.setLayout(layout) self.weight_spin.setFocus() self.weight_spin.selectAll() def on_delete_clicked(self): should_confirm = self.config.get('confirm_connection_delete', True) if should_confirm: confirm_dlg = ConfirmDeleteDialog(self.source, self.target, self) result = confirm_dlg.exec_() if result == QDialog.Accepted: if confirm_dlg.dont_ask_again: self.config.set('confirm_connection_delete', False) self.delete_requested = True self.reject() else: self.delete_requested = True self.reject() def get_weight(self): return self.weight_spin.value() class BrainCanvas(QGraphicsView): """Main canvas for visualizing and editing brain designs.""" # Signals neuronSelected = pyqtSignal(str) neuronMoved = pyqtSignal(str, float, float) connectionCreated = pyqtSignal(str, str) connectionSelected = pyqtSignal(str, str) connectionDeleted = pyqtSignal(str, str) canvasClicked = pyqtSignal(float, float) weightChanged = pyqtSignal(str, str, float) connectionReversed = pyqtSignal(str, str) NEURON_RADIUS = 25 WEIGHT_STEP = 0.05 WEIGHT_STEP_LARGE = 0.25 GRID_SIZE_MINOR = 25 GRID_SIZE_MAJOR = 100 GRID_COLOR_MINOR = QColor(220, 220, 230, 100) GRID_COLOR_MAJOR = QColor(200, 200, 215, 150) def __init__(self, design: BrainDesign, parent=None): super().__init__(parent) self.design = design self.scene = QGraphicsScene(self) self.setScene(self.scene) self.config = DesignerConfig() self.selected_neuron = None self.selected_connection_key = None self.pan_active = False self.pan_start_pos = QPointF() self.drag_line = None self.drag_source_id = None self.drag_start_pos = None self.zoom_level = 1.0 self.dragging_neuron = None self.neuron_drag_start_pos = None self.neuron_items = {} self.connection_items = {} self.setRenderHint(QPainter.Antialiasing) self.setRenderHint(QPainter.SmoothPixmapTransform) self.setDragMode(QGraphicsView.NoDrag) self.setMouseTracking(True) self.setViewportUpdateMode(QGraphicsView.FullViewportUpdate) self.scene.setSceneRect(-500, -200, 1500, 800) self.setFocusPolicy(Qt.StrongFocus) self.rebuild() self.anim_timer = QTimer(self) self.anim_timer.timeout.connect(self.animate_network) self.anim_timer.start(33) def drawBackground(self, painter, rect): painter.fillRect(rect, QColor(245, 245, 250)) left = int(rect.left()) - (int(rect.left()) % self.GRID_SIZE_MINOR) - self.GRID_SIZE_MINOR top = int(rect.top()) - (int(rect.top()) % self.GRID_SIZE_MINOR) - self.GRID_SIZE_MINOR right = int(rect.right()) + self.GRID_SIZE_MINOR bottom = int(rect.bottom()) + self.GRID_SIZE_MINOR painter.setRenderHint(QPainter.Antialiasing, False) painter.setPen(QPen(self.GRID_COLOR_MINOR, 1)) x = left while x <= right: if x % self.GRID_SIZE_MAJOR != 0: painter.drawLine(x, top, x, bottom) x += self.GRID_SIZE_MINOR y = top while y <= bottom: if y % self.GRID_SIZE_MAJOR != 0: painter.drawLine(left, y, right, y) y += self.GRID_SIZE_MINOR painter.setPen(QPen(self.GRID_COLOR_MAJOR, 1.5)) major_left = left - (left % self.GRID_SIZE_MAJOR) x = major_left while x <= right: painter.drawLine(x, top, x, bottom) x += self.GRID_SIZE_MAJOR major_top = top - (top % self.GRID_SIZE_MAJOR) y = major_top while y <= bottom: painter.drawLine(left, y, right, y) y += self.GRID_SIZE_MAJOR if left <= 0 <= right: painter.setPen(QPen(QColor(180, 180, 200, 180), 2)) painter.drawLine(0, top, 0, bottom) if top <= 0 <= bottom: painter.setPen(QPen(QColor(180, 180, 200, 180), 2)) painter.drawLine(left, 0, right, 0) def animate_network(self): for item in self.connection_items.values(): item.advance_animation() if hasattr(self, 'dying_connections'): for i in range(len(self.dying_connections) - 1, -1, -1): item = self.dying_connections[i] is_dead = item.advance_animation() if is_dead: self.scene.removeItem(item) self.dying_connections.pop(i) def rebuild(self): """Synchronizes the visual scene with the design data.""" self.scene.blockSignals(True) # 1. Update Layers for item in self.scene.items(): if not isinstance(item, (NeuronItem, ConnectorNeuronItem, SmartConnectionItem)) and item.zValue() < 0: if isinstance(item, (QGraphicsRectItem, QGraphicsTextItem)): if item.zValue() < -5: self.scene.removeItem(item) self.draw_layers() # 2. Update Neurons existing_neurons = set(self.neuron_items.keys()) design_neurons = set(self.design.neurons.keys()) for name in existing_neurons - design_neurons: self.scene.removeItem(self.neuron_items[name]) del self.neuron_items[name] for name in design_neurons: neuron = self.design.neurons[name] if neuron.position is None: continue x, y = neuron.position if name in self.neuron_items: # Update existing position self.neuron_items[name].setPos(x, y) else: self.draw_single_neuron(name, neuron) # 3. Connection Update existing_keys = set(self.connection_items.keys()) design_keys = set() for conn in self.design.connections: design_keys.add((conn.source, conn.target)) for key in existing_keys - design_keys: item = self.connection_items[key] item.is_dying = True del self.connection_items[key] if not hasattr(self, 'dying_connections'): self.dying_connections = [] self.dying_connections.append(item) for conn in self.design.connections: key = (conn.source, conn.target) src_neuron = self.design.get_neuron(conn.source) tgt_neuron = self.design.get_neuron(conn.target) if not (src_neuron and tgt_neuron and src_neuron.position and tgt_neuron.position): continue src_pos = QPointF(*src_neuron.position) tgt_pos = QPointF(*tgt_neuron.position) if key not in existing_keys: item = SmartConnectionItem(src_pos, tgt_pos, conn.weight, conn.source, conn.target) item.setData(0, conn) if self.selected_connection_key == key: item.is_selected = True self.scene.addItem(item) self.connection_items[key] = item else: item = self.connection_items[key] item.is_dying = False # Check for weight change to ensure visual thickness updates if item.weight != conn.weight: item.weight = conn.weight item.update() if self.selected_connection_key == key: item.is_selected = True else: item.is_selected = False if src_pos != item.source_pos or tgt_pos != item.target_pos: item.prepareGeometryChange() item.source_pos = src_pos item.target_pos = tgt_pos item.update() self.center_on_neurons() self.scene.blockSignals(False) def draw_layers(self): for layer in self.design.layers: y_pos = layer.y_position rect_height = DEFAULT_LAYER_HEIGHT rect_top = y_pos - rect_height / 2 lt = layer.layer_type.name.lower() if lt == 'input': fill, border = (180, 235, 180, 100), (120, 180, 120, 150) elif lt == 'output': fill, border = (235, 180, 180, 100), (180, 120, 120, 150) else: fill, border = (200, 200, 235, 100), (160, 160, 200, 150) rect = QGraphicsRectItem(-400, rect_top, 1300, rect_height) rect.setBrush(QBrush(QColor(*fill))) rect.setPen(QPen(QColor(*border), 2, Qt.DashLine)) rect.setZValue(-10) self.scene.addItem(rect) label = QGraphicsTextItem(layer.name) label.setDefaultTextColor(QColor(*border[:3])) label.setFont(QFont("Arial", 11, QFont.Bold)) label.setPos(-380, rect_top + 5) label.setZValue(-9) self.scene.addItem(label) def draw_single_neuron(self, name, neuron): """Draw a single neuron item (Body) and attach its children (Ring, Label).""" if neuron.position is None: return try: x, y = neuron.position except (TypeError, ValueError): return # Determine Shape shape = getattr(neuron, 'shape', 'circle') # --- CASE 1: CONNECTOR (Hexagon) --- is_connector = (neuron.neuron_type == NeuronType.CONNECTOR or name.startswith('connector_') or shape == 'hexagon') if is_connector: # Use the special ConnectorNeuronItem (black hexagon with 'c') body = ConnectorNeuronItem(x, y, self.NEURON_RADIUS, name) body.is_selected = (name == self.selected_neuron) self.scene.addItem(body) self.neuron_items[name] = body # Create Label (Child of Body) - simplified for connectors display = name.replace('_', ' ').title() label = QGraphicsTextItem(display) label.setDefaultTextColor(QColor(40, 40, 50)) label.setFont(QFont("Arial", 8, QFont.Bold)) # Center text horizontally relative to body center (0,0) w = label.boundingRect().width() label.setPos(-w / 2, self.NEURON_RADIUS + 5) label.setZValue(3) label.setParentItem(body) return # --- CASE 2: POLYGONS (Diamond, Square, Triangle) --- if shape in ['diamond', 'square', 'triangle']: sides = 4 rotation = 0 if shape == 'diamond': sides = 4 rotation = 0 elif shape == 'square': sides = 4 rotation = 45 elif shape == 'triangle': sides = 3 rotation = 0 # Create Polygon Body body = PolygonNeuronItem(x, y, self.NEURON_RADIUS, name, sides=sides, rotation=rotation, color=neuron.color) body.is_selected = (name == self.selected_neuron) self.scene.addItem(body) self.neuron_items[name] = body # Create Label (Child of Body) display = name.replace('_', ' ').title() label = QGraphicsTextItem(display) label.setDefaultTextColor(QColor(40, 40, 50)) label.setFont(QFont("Arial", 9, QFont.Bold)) w = label.boundingRect().width() label.setPos(-w / 2, self.NEURON_RADIUS + 5) label.setZValue(3) label.setParentItem(body) return # --- CASE 3: CIRCLE (Default) --- # 1. Create Neuron Body (The Parent Item) - standard ellipse body = NeuronItem(x, y, self.NEURON_RADIUS, name, neuron.neuron_type) body.setBrush(QBrush(QColor(*neuron.color))) body.setPen(QPen(QColor(50, 50, 50), 2)) body.is_selected = (name == self.selected_neuron) self.scene.addItem(body) self.neuron_items[name] = body # 2. Determine Ring Style (Only for circles) if neuron.is_core: ring_color = QColor(*CORE_NEURON_RING_COLOR) ring_width = PROTECTED_RING_WIDTH elif neuron.is_required: ring_color = QColor(*INPUT_SENSOR_RING_COLOR) ring_width = PROTECTED_RING_WIDTH elif neuron.is_sensor: ring_color = QColor(*INPUT_SENSOR_RING_COLOR) ring_width = NORMAL_RING_WIDTH else: ring_color = QColor(*CUSTOM_NEURON_RING_COLOR) ring_width = NORMAL_RING_WIDTH if name == self.selected_neuron: ring_color = QColor(255, 255, 255) ring_width = 4 # 3. Create Ring (Child of Body) r_outer = self.NEURON_RADIUS + 3 outer = QGraphicsEllipseItem(-r_outer, -r_outer, r_outer * 2, r_outer * 2) outer.setPen(QPen(ring_color, ring_width)) outer.setZValue(1) outer.setData(0, ('ring', name)) outer.setParentItem(body) outer.setFlag(QGraphicsItem.ItemStacksBehindParent) # 4. Create Label (Child of Body) display = name.replace('_', ' ').title() # Add icons for specific types if neuron.is_core: display = f"🟡 {display}" elif name == 'can_see_food': display = f"👁 {display}" elif neuron.is_sensor: display = f"📡 {display}" label = QGraphicsTextItem(display) label.setDefaultTextColor(QColor(40, 40, 50)) label.setFont(QFont("Arial", 9, QFont.Bold)) # Center text horizontally relative to body center (0,0) w = label.boundingRect().width() label.setPos(-w / 2, self.NEURON_RADIUS + 5) label.setZValue(3) label.setParentItem(body) def draw_neurons(self): for name, neuron in self.design.neurons.items(): self.draw_single_neuron(name, neuron) def center_on_neurons(self): if not self.design.neurons or self.pan_active: return valid_positions = [] for n in self.design.neurons.values(): if n.position is not None: try: x, y = n.position if x is not None and y is not None: valid_positions.append((x, y)) except: continue if not valid_positions: return xs = [p[0] for p in valid_positions] ys = [p[1] for p in valid_positions] self.scene.setSceneRect(QRectF( min(xs) - 100, min(ys) - 100, max(xs) - min(xs) + 200, max(ys) - min(ys) + 200 )) def get_neuron_at(self, pos): for name, neuron in self.design.neurons.items(): if neuron.position is None: continue x, y = neuron.position if math.hypot(pos.x() - x, pos.y() - y) <= self.NEURON_RADIUS + 5: return name return None def get_connection_at(self, pos): items = self.scene.items(pos) for item in items: if isinstance(item, SmartConnectionItem): return item return None # MOUSE EVENTS def mousePressEvent(self, event): self.setFocus() scene_pos = self.mapToScene(event.pos()) if event.button() == Qt.RightButton or event.button() == Qt.MiddleButton: self.pan_active = True self.pan_start_pos = event.pos() self.setCursor(Qt.ClosedHandCursor) event.accept() return if event.button() == Qt.LeftButton: clicked_neuron = self.get_neuron_at(scene_pos) if clicked_neuron: self.select_neuron(clicked_neuron) if event.modifiers() & Qt.ControlModifier: self.dragging_neuron = clicked_neuron self.neuron_drag_start_pos = scene_pos event.accept() return n_obj = self.design.get_neuron(clicked_neuron) if n_obj.neuron_type != NeuronType.OUTPUT: self.start_connection_drag(clicked_neuron, scene_pos) event.accept() return conn_item = self.get_connection_at(scene_pos) if conn_item: conn_data = conn_item.data(0) self.select_connection(conn_data.source, conn_data.target) event.accept() return self.clear_selection() super().mousePressEvent(event) def mouseDoubleClickEvent(self, event): if event.button() == Qt.LeftButton: scene_pos = self.mapToScene(event.pos()) conn_item = self.get_connection_at(scene_pos) if conn_item: conn_data = conn_item.data(0) self.open_weight_dialog(conn_data.source, conn_data.target) event.accept() return super().mouseDoubleClickEvent(event) def mouseMoveEvent(self, event): scene_pos = self.mapToScene(event.pos()) if self.pan_active: delta = event.pos() - self.pan_start_pos self.pan_start_pos = event.pos() self.horizontalScrollBar().setValue(self.horizontalScrollBar().value() - delta.x()) self.verticalScrollBar().setValue(self.verticalScrollBar().value() - delta.y()) event.accept() return if self.dragging_neuron: delta = scene_pos - self.neuron_drag_start_pos neuron = self.design.get_neuron(self.dragging_neuron) if neuron: old_pos = neuron.position new_pos = (old_pos[0] + delta.x(), old_pos[1] + delta.y()) neuron.position = new_pos self.neuron_drag_start_pos = scene_pos self.rebuild() event.accept() return if self.drag_line: target_id = self.get_neuron_at(scene_pos) if target_id and target_id != self.drag_source_id: end = QPointF(*self.design.get_neuron(target_id).position) valid = self.is_valid_connection(self.drag_source_id, target_id) color = QColor(50, 205, 50) if valid else QColor(220, 20, 60) style = Qt.SolidLine if valid else Qt.DashLine else: end = scene_pos color = QColor(255, 215, 0) style = Qt.DashLine self.drag_line.setLine(QLineF(self.drag_start_pos, end)) self.drag_line.setPen(QPen(color, 3, style)) event.accept() return super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): if event.button() == Qt.RightButton or event.button() == Qt.MiddleButton: self.pan_active = False self.setCursor(Qt.ArrowCursor) event.accept() return if self.dragging_neuron: self.dragging_neuron = None self.neuron_drag_start_pos = None event.accept() return if self.drag_line: scene_pos = self.mapToScene(event.pos()) target_id = self.get_neuron_at(scene_pos) self.scene.removeItem(self.drag_line) self.drag_line = None if target_id and target_id != self.drag_source_id: if self.is_valid_connection(self.drag_source_id, target_id): self.design.add_connection(self.drag_source_id, target_id, 0.5) self.connectionCreated.emit(self.drag_source_id, target_id) self.select_connection(self.drag_source_id, target_id) self.rebuild() else: QToolTip.showText(QCursor.pos(), "Invalid connection", self) self.drag_source_id = None event.accept() return super().mouseReleaseEvent(event) # KEY EVENTS def keyPressEvent(self, event): if event.key() == Qt.Key_Delete: if self.selected_connection_key: self.design.remove_connection(*self.selected_connection_key) self.selected_connection_key = None self.rebuild() event.accept() return if event.key() == Qt.Key_Space: if self.selected_connection_key: self.reverse_selected_connection() event.accept() return if event.key() in [Qt.Key_Plus, Qt.Key_Equal, Qt.Key_Minus, Qt.Key_Underscore]: self.adjust_connection_weight_by_key(event) event.accept() return if event.key() in [Qt.Key_PageUp, Qt.Key_PageDown]: self.adjust_connection_weight_page(event) event.accept() return super().keyPressEvent(event) def wheelEvent(self, event): """ Mouse wheel behavior: - If a connection is SELECTED (clicked), scroll changes its weight - Otherwise, scroll zooms the canvas """ # Only adjust weight if a connection is explicitly selected (clicked) if self.selected_connection_key: conn = self.design.get_connection(*self.selected_connection_key) if conn: delta = event.angleDelta().y() step = self.WEIGHT_STEP_LARGE if event.modifiers() & Qt.ShiftModifier else self.WEIGHT_STEP change = step if delta > 0 else -step new_weight = max(-1.0, min(1.0, conn.weight + change)) if abs(new_weight) < 0.03: new_weight = 0.0 conn.weight = new_weight self.rebuild() self.weightChanged.emit(conn.source, conn.target, new_weight) QToolTip.showText(QCursor.pos(), f"Weight: {new_weight:+.2f}", self) event.accept() return # Default: zoom canvas factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15 self.zoom_level = max(0.2, min(3.0, self.zoom_level * factor)) self.setTransform(QTransform().scale(self.zoom_level, self.zoom_level)) event.accept() # SELECTION HELPERS def select_neuron(self, name): self.selected_neuron = name self.selected_connection_key = None self.neuronSelected.emit(name) self.rebuild() def select_connection(self, source, target): self.selected_connection_key = (source, target) self.selected_neuron = None self.connectionSelected.emit(source, target) self.rebuild() def clear_selection(self): self.selected_neuron = None self.selected_connection_key = None self.rebuild() def start_connection_drag(self, neuron_name, scene_pos): n_obj = self.design.get_neuron(neuron_name) self.drag_source_id = neuron_name self.drag_start_pos = QPointF(*n_obj.position) self.drag_line = QGraphicsLineItem(QLineF(self.drag_start_pos, scene_pos)) self.drag_line.setPen(QPen(QColor(255, 215, 0), 3, Qt.DashLine)) self.drag_line.setZValue(10) self.scene.addItem(self.drag_line) def is_valid_connection(self, src_name, tgt_name): src = self.design.get_neuron(src_name) tgt = self.design.get_neuron(tgt_name) if not src or not tgt: return False if tgt.neuron_type in [NeuronType.INPUT, NeuronType.SENSOR]: return False if src.neuron_type == NeuronType.OUTPUT: return False if self.design.get_connection(src_name, tgt_name): return False return True def reverse_selected_connection(self): if not self.selected_connection_key: return source, target = self.selected_connection_key conn = self.design.get_connection(source, target) if not conn: return src_neuron = self.design.get_neuron(source) tgt_neuron = self.design.get_neuron(target) if self.design.get_connection(target, source): return if src_neuron and src_neuron.neuron_type in [NeuronType.SENSOR, NeuronType.INPUT]: return if tgt_neuron and tgt_neuron.neuron_type == NeuronType.OUTPUT: return weight = conn.weight self.design.remove_connection(source, target) self.design.add_connection(target, source, weight) self.selected_connection_key = (target, source) self.connectionReversed.emit(source, target) self.rebuild() def open_weight_dialog(self, source, target): conn = self.design.get_connection(source, target) if not conn: return dialog = ConnectionWeightDialog(source, target, conn.weight, self.config, self) result = dialog.exec_() if dialog.delete_requested: self.design.remove_connection(source, target) self.selected_connection_key = None self.connectionDeleted.emit(source, target) self.rebuild() return if result == QDialog.Accepted: new_weight = dialog.get_weight() if new_weight != conn.weight: conn.weight = new_weight self.weightChanged.emit(source, target, new_weight) self.rebuild() def adjust_connection_weight_by_key(self, event): pos = self.mapToScene(self.mapFromGlobal(QCursor.pos())) conn_item = self.get_connection_at(pos) if conn_item: conn = conn_item.data(0) elif self.selected_connection_key: conn = self.design.get_connection(*self.selected_connection_key) else: return if not conn: return step = self.WEIGHT_STEP_LARGE if event.modifiers() & Qt.ShiftModifier else self.WEIGHT_STEP if event.key() in [Qt.Key_Minus, Qt.Key_Underscore]: step = -step conn.weight = max(-1.0, min(1.0, conn.weight + step)) self.rebuild() self.weightChanged.emit(conn.source, conn.target, conn.weight) QToolTip.showText(QCursor.pos(), f"Weight: {conn.weight:+.2f}", self) def adjust_connection_weight_page(self, event): pos = self.mapToScene(self.mapFromGlobal(QCursor.pos())) conn_item = self.get_connection_at(pos) if conn_item: conn = conn_item.data(0) elif self.selected_connection_key: conn = self.design.get_connection(*self.selected_connection_key) else: return if not conn: return step = self.WEIGHT_STEP_LARGE if event.key() == Qt.Key_PageDown: step = -step conn.weight = max(-1.0, min(1.0, conn.weight + step)) self.rebuild() self.weightChanged.emit(conn.source, conn.target, conn.weight) QToolTip.showText(QCursor.pos(), f"Weight: {conn.weight:+.2f}", self) class PolygonNeuronItem(QGraphicsItem): """ Polygonal Neuron Body (Diamond, Square, Triangle). Acts as Parent item for Label (Ring is usually not used for these shapes in Designer). """ def __init__(self, x, y, radius, name, sides=4, rotation=0, color=(150, 150, 220), parent=None): super().__init__(parent) self.name = name self.radius = radius self.sides = sides self.rotation_deg = rotation self.color = color self.is_selected = False self.is_hovered = False self.setPos(x, y) self.setAcceptHoverEvents(True) self.setData(0, ('neuron', name)) self.setZValue(2) def boundingRect(self): extra = 5 return QRectF(-self.radius - extra, -self.radius - extra, (self.radius + extra) * 2, (self.radius + extra) * 2) def shape(self): path = QPainterPath() polygon = QPolygonF() angle_step = 360.0 / self.sides # Apply rotation offset offset_rad = math.radians(self.rotation_deg - 90) for i in range(self.sides): angle = math.radians(i * angle_step) + offset_rad polygon.append(QPointF(self.radius * math.cos(angle), self.radius * math.sin(angle))) path.addPolygon(polygon) path.closeSubpath() return path def paint(self, painter, option, widget): painter.save() painter.setRenderHint(QPainter.Antialiasing) # Draw hover glow if self.is_hovered and not self.is_selected: painter.setBrush(Qt.NoBrush) painter.setPen(QPen(QColor(100, 200, 255, 150), 6)) # Re-calculate polygon for glow path path = self.shape() painter.drawPath(path) # Draw Selection Highlight if self.is_selected: painter.setPen(QPen(QColor(255, 255, 255), 4)) painter.setBrush(Qt.NoBrush) painter.drawPath(self.shape()) # Draw Polygon Body painter.setBrush(QBrush(QColor(*self.color))) painter.setPen(QPen(QColor(50, 50, 50), 2)) polygon = QPolygonF() angle_step = 360.0 / self.sides offset_rad = math.radians(self.rotation_deg - 90) for i in range(self.sides): angle = math.radians(i * angle_step) + offset_rad polygon.append(QPointF(self.radius * math.cos(angle), self.radius * math.sin(angle))) painter.drawPolygon(polygon) painter.restore() def hoverEnterEvent(self, event): self.is_hovered = True self.update() super().hoverEnterEvent(event) def hoverLeaveEvent(self, event): self.is_hovered = False self.update() super().hoverLeaveEvent(event) ================================================ FILE: src/designer_canvas_utils.py ================================================ """ Enhanced Canvas Utilities for Brain Designer Additional visual feedback for wiring operations and activation display. """ from PyQt5.QtWidgets import ( QGraphicsItem, QGraphicsEllipseItem, QGraphicsTextItem, QGraphicsRectItem, QGraphicsPathItem, QToolTip ) from PyQt5.QtCore import Qt, QPointF, QRectF, QTimer from PyQt5.QtGui import ( QPainter, QPen, QBrush, QColor, QFont, QFontMetrics, QPainterPath, QRadialGradient, QLinearGradient ) import math class WiringPreviewItem(QGraphicsItem): """ Enhanced preview of a connection being dragged. Shows validity feedback, snapping, and weight preview. """ def __init__(self, start_pos: QPointF, parent=None): super().__init__(parent) self.start_pos = start_pos self.end_pos = start_pos self.is_valid = False self.target_name = "" self.preview_weight = 0.5 self.is_snapped = False # Animation self.pulse_phase = 0.0 self.setZValue(100) # Above everything def set_end(self, pos: QPointF, is_valid: bool = False, target_name: str = "", snapped: bool = False): """Update the end position and validity state.""" self.end_pos = pos self.is_valid = is_valid self.target_name = target_name self.is_snapped = snapped self.update() def boundingRect(self) -> QRectF: return QRectF(self.start_pos, self.end_pos).normalized().adjusted(-50, -50, 50, 50) def paint(self, painter, option, widget): painter.setRenderHint(QPainter.Antialiasing) # Determine colors if self.is_valid and self.is_snapped: # Valid and snapped to target color = QColor(50, 205, 50) # Green glow_color = QColor(50, 205, 50, 80) style = Qt.SolidLine elif self.is_valid: # Valid but not snapped color = QColor(100, 180, 100) glow_color = QColor(100, 180, 100, 60) style = Qt.DashLine else: # Invalid or dragging in open space color = QColor(255, 215, 0) # Gold glow_color = QColor(255, 215, 0, 60) style = Qt.DashLine # Draw glow painter.setPen(QPen(glow_color, 12, Qt.SolidLine, Qt.RoundCap)) painter.drawLine(self.start_pos, self.end_pos) # Draw main line painter.setPen(QPen(color, 3, style, Qt.RoundCap)) painter.drawLine(self.start_pos, self.end_pos) # Draw arrowhead at end line_vec = self.end_pos - self.start_pos length = math.sqrt(line_vec.x()**2 + line_vec.y()**2) if length > 10: angle = math.atan2(line_vec.y(), line_vec.x()) arrow_size = 15 p1 = self.end_pos - QPointF( math.cos(angle + 0.5) * arrow_size, math.sin(angle + 0.5) * arrow_size ) p2 = self.end_pos - QPointF( math.cos(angle - 0.5) * arrow_size, math.sin(angle - 0.5) * arrow_size ) painter.setBrush(QBrush(color)) painter.setPen(Qt.NoPen) path = QPainterPath() path.moveTo(self.end_pos) path.lineTo(p1) path.lineTo(p2) path.closeSubpath() painter.drawPath(path) # Draw target label if snapped if self.is_snapped and self.target_name: mid = (self.start_pos + self.end_pos) / 2 # Background font = QFont("Arial", 10, QFont.Bold) painter.setFont(font) fm = QFontMetrics(font) label = f"→ {self.target_name.replace('_', ' ').title()}" text_width = fm.horizontalAdvance(label) text_height = fm.height() rect = QRectF(mid.x() - text_width/2 - 8, mid.y() - text_height/2 - 4, text_width + 16, text_height + 8) bg_color = QColor(50, 50, 50, 200) if not self.is_valid else QColor(30, 100, 30, 200) painter.setBrush(QBrush(bg_color)) painter.setPen(Qt.NoPen) painter.drawRoundedRect(rect, 5, 5) # Text painter.setPen(Qt.white) painter.drawText(rect, Qt.AlignCenter, label) class ActivationBadge(QGraphicsItem): """ Badge showing a neuron's current activation value. """ def __init__(self, center: QPointF, value: float, is_binary: bool = False, parent=None): super().__init__(parent) self.center = center self.value = value self.is_binary = is_binary # Position below the neuron self.setPos(center.x() - 20, center.y() + 35) self.setZValue(5) def boundingRect(self) -> QRectF: return QRectF(0, 0, 40, 18) def paint(self, painter, option, widget): painter.setRenderHint(QPainter.Antialiasing) rect = self.boundingRect() # Color based on value if self.is_binary: if self.value > 50: color = QColor(50, 205, 50) # Green = ON text = "ON" else: color = QColor(150, 150, 150) # Gray = OFF text = "OFF" else: # Gradient from blue (low) to red (high) ratio = self.value / 100.0 if ratio < 0.5: # Blue to yellow r = int(50 + ratio * 2 * 205) g = int(50 + ratio * 2 * 155) b = int(200 - ratio * 2 * 150) else: # Yellow to red r = 255 g = int(205 - (ratio - 0.5) * 2 * 155) b = int(50 - (ratio - 0.5) * 2 * 50) color = QColor(r, g, b) text = f"{self.value:.0f}" # Background painter.setBrush(QBrush(color)) painter.setPen(QPen(color.darker(120), 1)) painter.drawRoundedRect(rect, 4, 4) # Text painter.setPen(Qt.white if color.lightness() < 150 else Qt.black) painter.setFont(QFont("Arial", 8, QFont.Bold)) painter.drawText(rect, Qt.AlignCenter, text) class ConnectionStrengthIndicator(QGraphicsItem): """ Visual indicator of connection strength shown during weight editing. """ def __init__(self, pos: QPointF, weight: float, parent=None): super().__init__(parent) self.weight = weight self.setPos(pos) self.setZValue(50) def boundingRect(self) -> QRectF: return QRectF(-60, -20, 120, 40) def paint(self, painter, option, widget): painter.setRenderHint(QPainter.Antialiasing) rect = QRectF(-55, -15, 110, 30) # Background painter.setBrush(QBrush(QColor(40, 40, 40, 230))) painter.setPen(Qt.NoPen) painter.drawRoundedRect(rect, 6, 6) # Weight bar background bar_rect = QRectF(-45, 2, 90, 8) painter.setBrush(QBrush(QColor(80, 80, 80))) painter.drawRoundedRect(bar_rect, 3, 3) # Weight bar fill bar_width = abs(self.weight) * 45 if self.weight >= 0: fill_rect = QRectF(0, 2, bar_width, 8) color = QColor(50, 205, 50) else: fill_rect = QRectF(-bar_width, 2, bar_width, 8) color = QColor(220, 50, 50) painter.setBrush(QBrush(color)) painter.drawRoundedRect(fill_rect, 3, 3) # Center line painter.setPen(QPen(QColor(150, 150, 150), 1)) painter.drawLine(QPointF(0, 0), QPointF(0, 12)) # Weight text painter.setPen(Qt.white) painter.setFont(QFont("Arial", 9, QFont.Bold)) text = f"{self.weight:+.2f}" painter.drawText(QRectF(-50, -14, 100, 14), Qt.AlignCenter, text) class NeuronHighlightRing(QGraphicsEllipseItem): """ Animated highlight ring for neuron selection/hover states. """ def __init__(self, center: QPointF, radius: float, color: QColor, parent=None): super().__init__( center.x() - radius - 5, center.y() - radius - 5, (radius + 5) * 2, (radius + 5) * 2, parent ) self.center = center self.base_radius = radius self.highlight_color = color self.pulse_phase = 0.0 self.setPen(Qt.NoPen) self.setBrush(Qt.NoBrush) self.setZValue(0) def advance_pulse(self): self.pulse_phase += 0.1 if self.pulse_phase > math.pi * 2: self.pulse_phase = 0 self.update() def paint(self, painter, option, widget): painter.setRenderHint(QPainter.Antialiasing) # Pulsing glow pulse = math.sin(self.pulse_phase) * 0.3 + 0.7 alpha = int(100 * pulse) glow_color = QColor( self.highlight_color.red(), self.highlight_color.green(), self.highlight_color.blue(), alpha ) # Outer glow gradient = QRadialGradient(self.center, self.base_radius + 15) gradient.setColorAt(0.6, glow_color) gradient.setColorAt(1.0, QColor(glow_color.red(), glow_color.green(), glow_color.blue(), 0)) painter.setBrush(QBrush(gradient)) painter.setPen(Qt.NoPen) painter.drawEllipse(self.center, self.base_radius + 15, self.base_radius + 15) # Ring ring_width = 2 + pulse painter.setPen(QPen(self.highlight_color, ring_width)) painter.setBrush(Qt.NoBrush) painter.drawEllipse(self.center, self.base_radius + 3, self.base_radius + 3) def get_weight_color(weight: float) -> QColor: """Get color for a connection weight.""" if weight >= 0: # Green gradient for excitatory intensity = min(1.0, weight) return QColor( int(50 + intensity * 0), int(150 + intensity * 55), int(50 + intensity * 0) ) else: # Red gradient for inhibitory intensity = min(1.0, abs(weight)) return QColor( int(150 + intensity * 70), int(50 + intensity * 0), int(50 + intensity * 10) ) def format_neuron_name(name: str) -> str: """Format a neuron name for display.""" return name.replace('_', ' ').title() ================================================ FILE: src/designer_constants.py ================================================ from enum import Enum, auto import random # --- Enums --- class NeuronType(Enum): INPUT = auto() OUTPUT = auto() HIDDEN = auto() CORE = auto() SENSOR = auto() CONNECTOR = auto() # --- Visual Constants --- CORE_NEURON_RING_COLOR = (255, 215, 0) INPUT_SENSOR_RING_COLOR = (100, 149, 237) CUSTOM_NEURON_RING_COLOR = (180, 180, 180) CONNECTOR_COLOR = (0, 0, 000) PROTECTED_RING_WIDTH = 3 NORMAL_RING_WIDTH = 2 DEFAULT_LAYER_HEIGHT = 120 DEFAULT_LAYER_SPACING = 150 DEFAULT_COLORS = { 'core': (150, 150, 220), 'required': (100, 180, 100), 'input': (100, 200, 150), 'output': (220, 150, 150), 'hidden': (180, 180, 200), 'sensor': (150, 200, 220), 'connector': (000, 0, 0) } LAYER_COLORS = { 'input': {'fill': (200, 255, 200, 80), 'border': (150, 220, 150, 120)}, 'output': {'fill': (255, 200, 200, 80), 'border': (220, 150, 150, 120)}, 'hidden': {'fill': (230, 230, 255, 80), 'border': (200, 200, 240, 120)} } # --- Logic Constants --- CORE_NEURONS = { "hunger": (127, 81), "happiness": (361, 81), "cleanliness": (627, 81), "sleepiness": (840, 81), "satisfaction": (271, 380), "anxiety": (491, 389), "curiosity": (701, 386), } MANDATORY_SENSOR = {"can_see_food": (50, 200)} REQUIRED_NEURONS = {**CORE_NEURONS, **MANDATORY_SENSOR} CORE_NEURON_NAMES = list(CORE_NEURONS.keys()) REQUIRED_NEURON_NAMES = CORE_NEURON_NAMES + ["can_see_food"] INPUT_SENSORS = { "external_stimulus": (50, 50), "plant_proximity": (50, 250), "threat_level": (50, 350), "pursuing_food": (150, 50), "is_sick": (150, 150), "is_fleeing": (150, 250), "is_eating": (150, 350), "is_sleeping": (250, 50), "is_startled": (250, 150), } BINARY_NEURONS = { 'can_see_food', 'is_eating', 'is_sleeping', 'is_sick', 'pursuing_food', 'is_fleeing', 'is_startled', 'external_stimulus' } def is_core_neuron(name): return name in CORE_NEURONS def is_required_neuron(name): return name in REQUIRED_NEURONS def is_input_sensor(name): return name in INPUT_SENSORS def is_binary_neuron(name): return name in BINARY_NEURONS def is_protected_neuron(name): return is_required_neuron(name) def get_neuron_category(name): if is_core_neuron(name): return 'core' elif name == 'can_see_food': return 'required' elif is_input_sensor(name): return 'sensor' return 'custom' def get_missing_required(existing): return [n for n in REQUIRED_NEURONS if n not in existing] # --- Default Connections --- DEFAULT_SENSOR_CONNECTIONS = { 'can_see_food': [('hunger', 0.4, 1.0), ('happiness', 0.2, 0.5), ('satisfaction', 0.15, 0.2)], 'external_stimulus': [('curiosity', 0.35, 1.0), ('anxiety', 0.15, 0.3)], 'threat_level': [('anxiety', 0.6, 1.0), ('happiness', -0.3, 0.7), ('curiosity', -0.2, 0.4)], 'plant_proximity': [('happiness', 0.2, 1.0), ('curiosity', 0.15, 0.5)], 'is_sick': [('happiness', -0.5, 1.0), ('anxiety', 0.4, 0.8), ('sleepiness', 0.3, 0.6)], 'is_fleeing': [('anxiety', 0.4, 1.0), ('curiosity', -0.3, 0.7)], 'is_eating': [('satisfaction', 0.5, 1.0), ('happiness', 0.3, 0.8), ('hunger', -0.4, 1.0)], 'is_sleeping': [('sleepiness', -0.5, 1.0), ('anxiety', -0.2, 0.6)], 'is_startled': [('anxiety', 0.5, 1.0), ('curiosity', 0.2, 0.4)], 'pursuing_food': [('hunger', 0.2, 1.0), ('curiosity', 0.25, 0.5)], } def get_default_connections_for_sensor(sensor_name: str) -> list: if sensor_name not in DEFAULT_SENSOR_CONNECTIONS: return [] connections = [] for target, weight, probability in DEFAULT_SENSOR_CONNECTIONS[sensor_name]: if random.random() < probability: connections.append((target, weight)) return connections ================================================ FILE: src/designer_core.py ================================================ import json import random import time import math from typing import Dict, List, Optional, Tuple, Set from dataclasses import dataclass from .designer_constants import ( NeuronType, DEFAULT_COLORS, REQUIRED_NEURONS, CORE_NEURONS, INPUT_SENSORS, is_core_neuron, is_required_neuron, is_input_sensor, is_binary_neuron, get_neuron_category, get_default_connections_for_sensor ) @dataclass class DesignerLayer: name: str layer_type: NeuronType y_position: float color: Tuple[int, int, int, int] = (200, 200, 220, 80) def to_dict(self) -> dict: return { 'name': self.name, 'layer_type': self.layer_type.name.lower(), 'y_position': self.y_position, 'color': self.color } @classmethod def from_dict(cls, data: dict) -> 'DesignerLayer': layer_type_str = data.get('layer_type', 'hidden').upper() try: layer_type = NeuronType[layer_type_str] except KeyError: layer_type = NeuronType.HIDDEN return cls( name=data['name'], layer_type=layer_type, y_position=data['y_position'], color=tuple(data.get('color', (200, 200, 220, 80))) ) @dataclass class DesignerNeuron: name: str neuron_type: NeuronType position: Tuple[float, float] layer_index: int = 0 color: Tuple[int, int, int] = (150, 150, 220) description: str = "" is_binary: bool = False shape: str = 'circle' def __post_init__(self): # 1. CORE neurons (Circle) if is_core_neuron(self.name): self.neuron_type = NeuronType.CORE self.color = DEFAULT_COLORS['core'] self.shape = 'circle' # 2. REQUIRED SENSOR (Square) elif self.name == 'can_see_food': self.neuron_type = NeuronType.SENSOR self.color = DEFAULT_COLORS.get('required', (100, 180, 100)) self.is_binary = True self.shape = 'square' # 3. CONNECTORS (Hexagon) elif self.name.startswith('connector_') or self.neuron_type == NeuronType.CONNECTOR: self.neuron_type = NeuronType.CONNECTOR self.color = DEFAULT_COLORS['connector'] self.shape = 'hexagon' # 4. NEUROGENESIS TYPES (Shapes) elif self.name.startswith('novelty_'): self.shape = 'diamond' # Typically yellowish elif self.name.startswith('stress_'): self.shape = 'square' # Typically reddish elif self.name.startswith('reward_'): self.shape = 'triangle' # Typically greenish or blueish # 5. INPUT SENSORS (Square) elif is_input_sensor(self.name): self.neuron_type = NeuronType.SENSOR self.color = DEFAULT_COLORS['sensor'] self.is_binary = is_binary_neuron(self.name) self.shape = 'square' @property def is_core(self) -> bool: return is_core_neuron(self.name) @property def is_required(self) -> bool: return is_required_neuron(self.name) @property def is_sensor(self) -> bool: return self.neuron_type == NeuronType.SENSOR or self.name == 'can_see_food' @property def is_protected(self) -> bool: return self.is_required @property def category(self) -> str: return get_neuron_category(self.name) def to_dict(self) -> dict: return { 'name': self.name, 'neuron_type': self.neuron_type.name.lower(), 'position': list(self.position), 'layer_index': self.layer_index, 'color': list(self.color), 'description': self.description, 'is_binary': self.is_binary, 'category': self.category, 'shape': self.shape } @classmethod def from_dict(cls, data: dict) -> 'DesignerNeuron': type_str = data.get('neuron_type', 'hidden').upper() try: neuron_type = NeuronType[type_str] except KeyError: neuron_type = NeuronType.HIDDEN pos = data.get('position', (0, 0)) try: if isinstance(pos, (list, tuple)) and len(pos) == 2: x, y = pos if isinstance(x, (int, float)) and isinstance(y, (int, float)): position = tuple(pos) else: position = (0, 0) else: position = (0, 0) except (TypeError, ValueError): position = (0, 0) return cls( name=data['name'], neuron_type=neuron_type, position=position, layer_index=data.get('layer_index', 0), color=tuple(data.get('color', (150, 150, 220))), description=data.get('description', ''), is_binary=data.get('is_binary', False), shape=data.get('shape', 'circle') ) # ... (rest of designer_core.py is compatible as provided in prompt) ... @dataclass class DesignerConnection: source: str target: str weight: float = 0.5 def to_dict(self) -> dict: return {'source': self.source, 'target': self.target, 'weight': self.weight} @classmethod def from_dict(cls, data: dict) -> 'DesignerConnection': return cls(source=data['source'], target=data['target'], weight=data.get('weight', 0.5)) class BrainDesign: def __init__(self): self.neurons: Dict[str, DesignerNeuron] = {} self.connections: List[DesignerConnection] = [] self.layers: List[DesignerLayer] = [] self.output_bindings: List[Dict] = [] self.metadata: Dict = { 'name': 'Untitled', 'description': '', 'author': '', 'version': '1.0', 'created': '', 'modified': '' } self._next_custom_neuron_x = 400 self._next_custom_neuron_y = 200 def remove_optional_sensors(self) -> int: sensors_to_remove = [] for name, neuron in self.neurons.items(): if neuron.is_sensor and name != 'can_see_food': sensors_to_remove.append(name) removed_count = 0 for name in sensors_to_remove: if self.remove_neuron(name)[0]: removed_count += 1 return removed_count def _validate_and_fix_position(self, position: any) -> Tuple[float, float]: if position is None: return self._generate_custom_position() try: if not isinstance(position, (tuple, list)) or len(position) != 2: return self._generate_custom_position() x, y = position if not isinstance(x, (int, float)) or not isinstance(y, (int, float)): return self._generate_custom_position() if x is None or y is None: return self._generate_custom_position() return (float(x), float(y)) except (TypeError, ValueError): return self._generate_custom_position() def _generate_custom_position(self) -> Tuple[float, float]: pos = (self._next_custom_neuron_x, self._next_custom_neuron_y) self._next_custom_neuron_x += 120 if self._next_custom_neuron_x > 600: self._next_custom_neuron_x = 400 self._next_custom_neuron_y += 100 return pos def add_neuron(self, neuron: DesignerNeuron) -> bool: if neuron.name in self.neurons: return False neuron.position = self._validate_and_fix_position(neuron.position) self.neurons[neuron.name] = neuron return True def remove_neuron(self, name: str) -> Tuple[bool, str]: if name not in self.neurons: return False, f"Neuron '{name}' not found" if is_required_neuron(name): return False, f"Cannot delete required neuron '{name}'" del self.neurons[name] self.connections = [c for c in self.connections if c.source != name and c.target != name] return True, f"Removed neuron '{name}'" def rename_neuron(self, old_name: str, new_name: str) -> Tuple[bool, str]: if old_name not in self.neurons: return False, f"Neuron '{old_name}' not found" if is_required_neuron(old_name): return False, f"Cannot rename required neuron '{old_name}'" if new_name in self.neurons: return False, f"Neuron '{new_name}' already exists" neuron = self.neurons.pop(old_name) neuron.name = new_name self.neurons[new_name] = neuron for conn in self.connections: if conn.source == old_name: conn.source = new_name if conn.target == old_name: conn.target = new_name return True, f"Renamed '{old_name}' to '{new_name}'" def get_neuron(self, name: str) -> Optional[DesignerNeuron]: return self.neurons.get(name) def add_connection(self, source: str, target: str, weight: float = 0.5) -> bool: if source not in self.neurons or target not in self.neurons: return False for conn in self.connections: if conn.source == source and conn.target == target: conn.weight = weight return True self.connections.append(DesignerConnection(source, target, weight)) return True def remove_connection(self, source: str, target: str) -> bool: for i, conn in enumerate(self.connections): if conn.source == source and conn.target == target: self.connections.pop(i) return True return False def get_connection(self, source: str, target: str) -> Optional[DesignerConnection]: for conn in self.connections: if conn.source == source and conn.target == target: return conn return None def add_layer(self, layer: DesignerLayer) -> bool: self.layers.append(layer) self.layers.sort(key=lambda l: l.y_position) return True def remove_layer(self, index: int) -> bool: if 0 <= index < len(self.layers): self.layers.pop(index) return True return False def get_missing_required_neurons(self) -> List[str]: return [name for name in REQUIRED_NEURONS if name not in self.neurons] def has_all_required_neurons(self) -> bool: return len(self.get_missing_required_neurons()) == 0 def add_missing_required_neurons(self) -> int: added = 0 for name, pos in REQUIRED_NEURONS.items(): if name not in self.neurons: ntype = NeuronType.SENSOR if name == 'can_see_food' else NeuronType.CORE desc = "Required neuron" self.add_neuron(DesignerNeuron(name=name, neuron_type=ntype, position=pos, description=desc)) added += 1 return added def add_missing_core_neurons(self) -> int: added = 0 for name, pos in CORE_NEURONS.items(): if name not in self.neurons: self.add_neuron(DesignerNeuron( name=name, neuron_type=NeuronType.CORE, position=pos, description="Core neuron")) added += 1 return added def get_orphan_neurons(self) -> List[str]: if not self.connections: return [n for n in self.neurons if not is_required_neuron(n)] connected = set() for conn in self.connections: connected.add(conn.source) connected.add(conn.target) return [name for name in self.neurons if name not in connected and not is_required_neuron(name)] def get_island_neurons(self) -> List[Set[str]]: if not self.neurons: return [] adj = {name: set() for name in self.neurons} for conn in self.connections: if conn.source in adj and conn.target in adj: adj[conn.source].add(conn.target) adj[conn.target].add(conn.source) visited = set() components = [] for start in self.neurons: if start in visited: continue component = set() queue = [start] while queue: node = queue.pop(0) if node in visited: continue visited.add(node) component.add(node) for neighbor in adj[node]: if neighbor not in visited: queue.append(neighbor) components.append(component) main_component = None for comp in components: if any(is_required_neuron(n) for n in comp): main_component = comp break return [comp for comp in components if comp != main_component and len(comp) > 0] def auto_fix_connectivity(self) -> Tuple[int, List[str]]: actions = [] connections_created = 0 def get_suggested_weight(source_cat: str, target_cat: str, rng: random.Random) -> float: tendencies = { ('sensor', 'core'): (0.3, 0.6), ('sensor', 'sensor'): (-0.2, 0.4), ('custom', 'core'): (-0.4, 0.8), ('custom', 'custom'): (-0.5, 0.5), ('core', 'core'): (-0.3, 0.7), } key = (source_cat, target_cat) if key not in tendencies: key = (target_cat, source_cat) if key not in tendencies: key = ('custom', 'custom') min_w, max_w = tendencies[key] return rng.uniform(min_w, max_w) def find_nearest_core(neuron_pos: Tuple[float, float]) -> Optional[str]: core_neurons = [n for n in self.neurons.values() if n.is_core] if not core_neurons: return None return min(core_neurons, key=lambda n: math.hypot(neuron_pos[0] - n.position[0], neuron_pos[1] - n.position[1])).name fix_rng = random.Random() fix_rng.seed(time.time() + hash(tuple(sorted(self.neurons.keys()))) % 10000) for orphan_name in self.get_orphan_neurons(): orphan = self.neurons[orphan_name] target_name = find_nearest_core(orphan.position) if target_name: w1 = get_suggested_weight(orphan.category, self.neurons[target_name].category, fix_rng) w2 = get_suggested_weight(self.neurons[target_name].category, orphan.category, fix_rng) self.add_connection(orphan_name, target_name, w1) self.add_connection(target_name, orphan_name, w2) connections_created += 2 actions.append(f"Connected orphan '{orphan_name}' ↔ '{target_name}'") main_network = set() for conn in self.connections: if is_core_neuron(conn.source) or is_core_neuron(conn.target): main_network.add(conn.source) main_network.add(conn.target) if not main_network: main_network = set(self.neurons.keys()) for island in self.get_island_neurons(): island_list = list(island) island_center = ( sum(self.neurons[n].position[0] for n in island_list) / len(island_list), sum(self.neurons[n].position[1] for n in island_list) / len(island_list) ) nearest_main = find_nearest_core(island_center) if nearest_main: connect_count = min(2, len(island_list)) for i in range(connect_count): n = island_list[i % len(island_list)] w = get_suggested_weight(self.neurons[n].category, self.neurons[nearest_main].category, fix_rng) self.add_connection(n, nearest_main, w) connections_created += 1 actions.append(f"Connected island node '{n}' → '{nearest_main}'") return connections_created, actions def add_sensor(self, name: str, create_default_connections: bool = True) -> Tuple[bool, str]: if name not in INPUT_SENSORS and name != 'can_see_food': return False, f"'{name}' is not a valid input sensor" if name in self.neurons: return False, f"Sensor '{name}' already exists" pos = REQUIRED_NEURONS[name] if name == 'can_see_food' else INPUT_SENSORS[name] self.add_neuron(DesignerNeuron(name=name, neuron_type=NeuronType.SENSOR, position=pos, is_binary=is_binary_neuron(name))) conns_made = [] if create_default_connections: for target, weight in get_default_connections_for_sensor(name): if target in self.neurons: self.add_connection(name, target, weight) conns_made.append(f"{name}→{target}") return True, f"Added sensor '{name}' with connections: {', '.join(conns_made)}" def add_all_sensors(self) -> int: added = 0 for name in INPUT_SENSORS: if self.add_sensor(name)[0]: added += 1 return added def get_sensors_in_design(self) -> List[str]: return [name for name, neuron in self.neurons.items() if neuron.is_sensor] def validate(self, auto_fix: bool = True) -> Tuple[bool, List[str], int]: issues = [] auto_added = 0 missing_required = self.get_missing_required_neurons() if missing_required: if auto_fix: auto_added = self.add_missing_required_neurons() issues.append(f"Auto-added missing required: {', '.join(missing_required)}") else: issues.append(f"BLOCKING: Missing required: {', '.join(missing_required)}") orphans = self.get_orphan_neurons() if orphans: if auto_fix: conns, actions = self.auto_fix_connectivity() issues.append(f"Auto-fixed orphans with {conns} new connections") issues.extend(actions) auto_added += conns else: issues.append(f"WARNING: Orphan neurons: {', '.join(orphans)}") if 'can_see_food' not in self.neurons: issues.append("WARNING: Missing 'can_see_food' sensor!") return not any("BLOCKING" in i for i in issues), issues, auto_added def get_stats(self) -> Dict: return { 'total_neurons': len(self.neurons), 'connections': len(self.connections), 'has_all_required': self.has_all_required_neurons(), 'missing_required': self.get_missing_required_neurons(), 'orphan_neurons': self.get_orphan_neurons(), 'island_groups': len(self.get_island_neurons()), 'sensors_used': self.get_sensors_in_design(), 'required_neurons': sum(1 for n in self.neurons.values() if n.is_required), 'sensor_neurons': sum(1 for n in self.neurons.values() if n.is_sensor and not n.is_required), 'custom_neurons': sum(1 for n in self.neurons.values() if not n.is_required and not n.is_sensor), 'layers': len(self.layers) } def to_dosidicus_format(self) -> dict: neurons = {} for name, obj in self.neurons.items(): neurons[name] = { 'position': list(obj.position), 'type': obj.neuron_type.name.lower(), 'is_binary': obj.is_binary, 'is_core': obj.is_core, 'is_sensor': obj.is_sensor, 'activation': 0.0 if obj.is_binary else (50.0 if obj.is_core else 0.0), } connections = {} for c in self.connections: key = f"{c.source}->{c.target}" connections[key] = c.weight state = {} for name, obj in self.neurons.items(): if obj.is_binary: state[name] = False elif obj.is_core: state[name] = 50.0 else: state[name] = 0.0 return { 'version': '2.0', 'format': 'dosidicus', 'metadata': self.metadata.copy(), 'neurons': neurons, 'connections': connections, 'state': state, 'neuron_shapes': {n: obj.shape for n, obj in self.neurons.items()}, 'excluded_neurons': [], 'output_bindings': self.output_bindings, 'neuron_positions': {n: tuple(obj.position) for n, obj in self.neurons.items()}, 'weights': {f"{c.source}|{c.target}": c.weight for c in self.connections}, 'layer_structure': [l.to_dict() for l in self.layers], 'neuron_details': {n: obj.to_dict() for n, obj in self.neurons.items()}, 'sensors_used': self.get_sensors_in_design(), 'required_complete': self.has_all_required_neurons() } def to_designer_format(self) -> dict: neurons = {} for n in self.neurons.values(): neurons[n.name] = { 'name': n.name, 'position': list(n.position), 'neuron_type': n.neuron_type.name.lower(), 'layer_index': n.layer_index, 'color': list(n.color), 'description': n.description, 'is_binary': n.is_binary, 'category': n.category, 'activation': 0.0 if n.is_binary else (50.0 if n.is_core else 0.0), 'shape': n.shape } connections = [c.to_dict() for c in self.connections] return { 'version': '2.0', 'format': 'brain_designer', 'metadata': self.metadata.copy(), 'neurons': neurons, 'connections': connections, 'layers': [l.to_dict() for l in self.layers], 'excluded_neurons': [], 'output_bindings': self.output_bindings, } @classmethod def from_designer_format(cls, data: dict) -> 'BrainDesign': design = cls() design.metadata = data.get('metadata', {}) for l in data.get('layers', []): design.layers.append(DesignerLayer.from_dict(l)) neurons_data = data.get('neurons', []) if isinstance(neurons_data, dict): for name, n in neurons_data.items(): if 'name' not in n: n['name'] = name neuron = DesignerNeuron.from_dict(n) design.add_neuron(neuron) else: for n in neurons_data: neuron = DesignerNeuron.from_dict(n) design.add_neuron(neuron) for c in data.get('connections', []): design.connections.append(DesignerConnection.from_dict(c)) design.output_bindings = data.get('output_bindings', []) return design @classmethod def from_dosidicus_format(cls, data: dict) -> 'BrainDesign': design = cls() design.metadata = data.get('metadata', {'name': 'Imported Brain'}) for l in data.get('layer_structure', []): design.layers.append(DesignerLayer.from_dict(l)) neurons_data = data.get('neurons', {}) shapes_data = data.get('neuron_shapes', {}) if neurons_data and isinstance(neurons_data, dict): for name, nd in neurons_data.items(): pos = nd.get('position', [0, 0]) ntype_str = nd.get('type', 'hidden').upper() try: ntype = NeuronType[ntype_str] except KeyError: ntype = NeuronType.CORE if is_core_neuron(name) else ( NeuronType.SENSOR if is_input_sensor(name) else NeuronType.HIDDEN ) shape = shapes_data.get(name, 'circle') neuron = DesignerNeuron( name=name, neuron_type=ntype, position=tuple(pos) if isinstance(pos, list) else pos, is_binary=nd.get('is_binary', False), shape=shape ) design.add_neuron(neuron) else: details = data.get('neuron_details', {}) for name, pos in data.get('neuron_positions', {}).items(): if name in details: neuron = DesignerNeuron.from_dict(details[name]) design.add_neuron(neuron) else: ntype = NeuronType.CORE if is_core_neuron(name) else ( NeuronType.SENSOR if is_input_sensor(name) else NeuronType.HIDDEN ) shape = shapes_data.get(name, 'circle') neuron = DesignerNeuron(name=name, neuron_type=ntype, position=tuple(pos), shape=shape) design.add_neuron(neuron) connections_data = data.get('connections', {}) if isinstance(connections_data, dict): for k, w in connections_data.items(): if '->' in k: s, t = k.split('->') elif '|' in k: s, t = k.split('|') else: continue design.connections.append(DesignerConnection(s.strip(), t.strip(), w)) elif isinstance(connections_data, list): for c in connections_data: if c.get('source') and c.get('target'): design.connections.append(DesignerConnection( c['source'], c['target'], c.get('weight', 0.1) )) if not design.connections: for k, w in data.get('weights', {}).items(): if '|' in k: s, t = k.split('|') design.connections.append(DesignerConnection(s, t, w)) design.output_bindings = data.get('output_bindings', []) return design def save(self, filepath: str, format: str = 'designer') -> Tuple[bool, str]: is_valid, issues, auto_added = self.validate(auto_fix=True) if not is_valid: return False, "Save blocked by validation issues." try: data = self.to_dosidicus_format() if format == 'dosidicus' else self.to_designer_format() with open(filepath, 'w') as f: json.dump(data, f, indent=2) msg = f"Saved successfully ({len(self.neurons)} neurons)" if auto_added > 0: msg += f"\n(Applied {auto_added} auto-fixes)" return True, msg except Exception as e: return False, f"Error saving: {e}" @classmethod def load(cls, filepath: str) -> 'BrainDesign': with open(filepath, 'r') as f: data = json.load(f) return cls.from_dosidicus_format(data) if data.get('format') == 'dosidicus' else cls.from_designer_format(data) def export_dosidicus(self, filepath): return self.save(filepath, format='dosidicus') ================================================ FILE: src/designer_dialogs.py ================================================ """ Sparse Network Generation Dialog Provides UI for generating random sparse networks with various presets. """ from PyQt5.QtWidgets import ( QDialog, QVBoxLayout, QHBoxLayout, QGroupBox, QLabel, QPushButton, QComboBox, QDoubleSpinBox, QCheckBox, QTextEdit, QFrame, QProgressBar, QSizePolicy ) from PyQt5.QtCore import Qt, QTimer from PyQt5.QtGui import QFont from .designer_network_generator import SparseNetworkGenerator class SparseNetworkDialog(QDialog): """Dialog for generating sparse neural networks.""" def __init__(self, design, parent=None): super().__init__(parent) self.design = design self.generator = SparseNetworkGenerator() self.result_connections = [] self.result_actions = [] self.setWindowTitle("🎲 Generate Sparse Network") self.setMinimumWidth(500) self.setMinimumHeight(550) self.setup_ui() self.load_preset('balanced') def setup_ui(self): layout = QVBoxLayout(self) # Header header = QLabel("Generate Random Neural Connections") header.setFont(QFont("Arial", 12, QFont.Bold)) header.setAlignment(Qt.AlignCenter) layout.addWidget(header) desc = QLabel( "Creates biologically-inspired connections between the 8 required neurons.\n" "Each generation is unique due to random noise." ) desc.setStyleSheet("color: #666; font-size: 10pt;") desc.setAlignment(Qt.AlignCenter) desc.setWordWrap(True) layout.addWidget(desc) layout.addSpacing(10) # Preset selection preset_group = QGroupBox("Style Preset") preset_layout = QHBoxLayout(preset_group) self.preset_combo = QComboBox() presets = self.generator.get_preset_styles() for key, info in presets.items(): self.preset_combo.addItem(info['name'], key) self.preset_combo.currentIndexChanged.connect(self.on_preset_changed) preset_layout.addWidget(self.preset_combo) self.preset_desc = QLabel() self.preset_desc.setStyleSheet("color: #888; font-style: italic;") preset_layout.addWidget(self.preset_desc, stretch=1) layout.addWidget(preset_group) # Advanced options advanced_group = QGroupBox("Fine Tuning") advanced_layout = QVBoxLayout(advanced_group) # Density & Noise row1 = QHBoxLayout() row1.addWidget(QLabel("Density:")) self.density_spin = QDoubleSpinBox() self.density_spin.setRange(0.2, 2.0) self.density_spin.setSingleStep(0.1) self.density_spin.setDecimals(1) self.density_spin.setValue(1.0) self.density_spin.setToolTip("Lower = fewer connections, Higher = more connections") row1.addWidget(self.density_spin) row1.addSpacing(20) row1.addWidget(QLabel("Weight Noise:")) self.noise_spin = QDoubleSpinBox() self.noise_spin.setRange(0.1, 3.0) self.noise_spin.setSingleStep(0.1) self.noise_spin.setDecimals(1) self.noise_spin.setValue(1.0) self.noise_spin.setToolTip("How much randomness in connection weights") row1.addWidget(self.noise_spin) advanced_layout.addLayout(row1) # Variance & Sensors (NEW) row2 = QHBoxLayout() row2.addWidget(QLabel("Pos Variance:")) self.variance_spin = QDoubleSpinBox() self.variance_spin.setRange(0.0, 1.0) self.variance_spin.setSingleStep(0.1) self.variance_spin.setDecimals(2) self.variance_spin.setValue(0.0) self.variance_spin.setToolTip("Jitter neuron positions (0.0 = fixed, 0.5 = chaotic)") row2.addWidget(self.variance_spin) row2.addSpacing(20) row2.addWidget(QLabel("Sensor Prob:")) self.sensor_prob_spin = QDoubleSpinBox() self.sensor_prob_spin.setRange(0.0, 1.0) self.sensor_prob_spin.setSingleStep(0.1) self.sensor_prob_spin.setDecimals(2) self.sensor_prob_spin.setValue(0.0) self.sensor_prob_spin.setToolTip("Probability of adding random extra sensors") row2.addWidget(self.sensor_prob_spin) advanced_layout.addLayout(row2) # Options row options_row = QHBoxLayout() self.feedback_check = QCheckBox("Include feedback loops") self.feedback_check.setChecked(True) self.feedback_check.setToolTip("Allow bidirectional connections where biologically plausible") options_row.addWidget(self.feedback_check) self.clear_check = QCheckBox("Clear existing connections") self.clear_check.setChecked(True) self.clear_check.setToolTip("Remove all existing connections before generating") options_row.addWidget(self.clear_check) options_row.addStretch() advanced_layout.addLayout(options_row) layout.addWidget(advanced_group) # Preview area preview_group = QGroupBox("Preview") preview_layout = QVBoxLayout(preview_group) self.preview_text = QTextEdit() self.preview_text.setReadOnly(True) self.preview_text.setMaximumHeight(150) self.preview_text.setStyleSheet(""" QTextEdit { font-family: monospace; font-size: 9pt; background-color: #f5f5f5; } """) preview_layout.addWidget(self.preview_text) preview_btn_row = QHBoxLayout() self.preview_btn = QPushButton("🔄 Preview Generation") self.preview_btn.clicked.connect(self.generate_preview) preview_btn_row.addWidget(self.preview_btn) preview_btn_row.addStretch() self.count_label = QLabel() self.count_label.setStyleSheet("color: #666;") preview_btn_row.addWidget(self.count_label) preview_layout.addLayout(preview_btn_row) layout.addWidget(preview_group) # Separator line = QFrame() line.setFrameShape(QFrame.HLine) line.setFrameShadow(QFrame.Sunken) layout.addWidget(line) # Buttons btn_layout = QHBoxLayout() self.cancel_btn = QPushButton("Cancel") self.cancel_btn.clicked.connect(self.reject) btn_layout.addWidget(self.cancel_btn) btn_layout.addStretch() self.apply_btn = QPushButton("✨ Generate && Apply") self.apply_btn.setStyleSheet(""" QPushButton { background-color: #4CAF50; color: white; font-weight: bold; padding: 8px 20px; } QPushButton:hover { background-color: #45a049; } """) self.apply_btn.clicked.connect(self.apply_generation) btn_layout.addWidget(self.apply_btn) layout.addLayout(btn_layout) # Initial preview QTimer.singleShot(100, self.generate_preview) def on_preset_changed(self, index): key = self.preset_combo.currentData() self.load_preset(key) def load_preset(self, key): presets = self.generator.get_preset_styles() if key not in presets: return preset = presets[key] self.preset_desc.setText(preset['description']) # Block signals during update self.density_spin.blockSignals(True) self.noise_spin.blockSignals(True) self.variance_spin.blockSignals(True) self.sensor_prob_spin.blockSignals(True) self.feedback_check.blockSignals(True) self.density_spin.setValue(preset['density']) self.noise_spin.setValue(preset.get('weight_noise', 1.0)) self.variance_spin.setValue(preset.get('position_variance', 0.0)) self.sensor_prob_spin.setValue(preset.get('sensor_probability', 0.0)) self.feedback_check.setChecked(preset.get('include_feedback', True)) self.density_spin.blockSignals(False) self.noise_spin.blockSignals(False) self.variance_spin.blockSignals(False) self.sensor_prob_spin.blockSignals(False) self.feedback_check.blockSignals(False) self.generate_preview() def generate_preview(self): """Generate a preview without applying.""" # Create fresh generator (no seed for variety) gen = SparseNetworkGenerator() # Pass the weight_noise parameter connections = gen.generate_connections( density=self.density_spin.value(), include_feedback_loops=self.feedback_check.isChecked(), weight_noise=self.noise_spin.value() ) self.result_connections = connections # Format preview lines = [] excitatory = 0 inhibitory = 0 for source, target, weight in connections: sign = "+" if weight > 0 else "" arrow = "→" if weight > 0 else "⊣" lines.append(f" {source:15} {arrow} {target:15} ({sign}{weight:.3f})") if weight > 0: excitatory += 1 else: inhibitory += 1 # Add note about sensors/variance if active if self.sensor_prob_spin.value() > 0: lines.insert(0, f"NOTE: Will attempt to add random sensors (Prob: {self.sensor_prob_spin.value()})") if self.variance_spin.value() > 0: lines.insert(0, f"NOTE: Will randomly perturb positions (Var: {self.variance_spin.value()})") if lines: self.preview_text.setPlainText("\n".join(lines)) else: self.preview_text.setPlainText("No connections would be created with these settings.") # Update count total = len(connections) self.count_label.setText( f"{total} connections ({excitatory} excitatory, {inhibitory} inhibitory)" ) def apply_generation(self): """Apply the generated network to the design.""" gen = SparseNetworkGenerator() # Now passing all new arguments to fix the TypeError count, actions = gen.generate_for_design( self.design, clear_existing=self.clear_check.isChecked(), density=self.density_spin.value(), include_feedback=self.feedback_check.isChecked(), weight_noise=self.noise_spin.value(), position_variance=self.variance_spin.value(), sensor_probability=self.sensor_prob_spin.value() ) self.result_actions = actions self.accept() def get_result_summary(self) -> str: """Get a summary of what was generated.""" return f"Created {len(self.result_connections)} connections" class ActivationEditorDialog(QDialog): """Dialog for viewing and editing neuron activation values.""" def __init__(self, neuron_name: str, current_activation: float, is_binary: bool = False, parent=None): super().__init__(parent) self.neuron_name = neuron_name self.is_binary = is_binary self.result_value = current_activation self.setWindowTitle(f"Activation: {neuron_name}") self.setMinimumWidth(300) self.setup_ui(current_activation) def setup_ui(self, current_value): layout = QVBoxLayout(self) # Header name_label = QLabel(self.neuron_name.replace('_', ' ').title()) name_label.setFont(QFont("Arial", 11, QFont.Bold)) name_label.setAlignment(Qt.AlignCenter) layout.addWidget(name_label) if self.is_binary: # Binary neuron: on/off toggle info = QLabel("Binary neuron (On/Off)") info.setStyleSheet("color: #666;") info.setAlignment(Qt.AlignCenter) layout.addWidget(info) btn_row = QHBoxLayout() self.off_btn = QPushButton("OFF (0)") self.off_btn.setCheckable(True) self.off_btn.setChecked(current_value < 50) self.off_btn.clicked.connect(lambda: self.set_binary(False)) btn_row.addWidget(self.off_btn) self.on_btn = QPushButton("ON (100)") self.on_btn.setCheckable(True) self.on_btn.setChecked(current_value >= 50) self.on_btn.clicked.connect(lambda: self.set_binary(True)) btn_row.addWidget(self.on_btn) layout.addLayout(btn_row) else: # Continuous neuron: spinner info = QLabel("Activation (0-100)") info.setStyleSheet("color: #666;") info.setAlignment(Qt.AlignCenter) layout.addWidget(info) spin_row = QHBoxLayout() spin_row.addStretch() self.value_spin = QDoubleSpinBox() self.value_spin.setRange(0, 100) self.value_spin.setSingleStep(5) self.value_spin.setDecimals(1) self.value_spin.setValue(current_value) self.value_spin.setMinimumWidth(100) spin_row.addWidget(self.value_spin) spin_row.addStretch() layout.addLayout(spin_row) # Quick buttons quick_row = QHBoxLayout() for val, label in [(0, "Min"), (25, "Low"), (50, "Mid"), (75, "High"), (100, "Max")]: btn = QPushButton(label) btn.setMaximumWidth(50) btn.clicked.connect(lambda checked, v=val: self.value_spin.setValue(v)) quick_row.addWidget(btn) layout.addLayout(quick_row) layout.addSpacing(15) # Buttons btn_layout = QHBoxLayout() cancel_btn = QPushButton("Cancel") cancel_btn.clicked.connect(self.reject) btn_layout.addWidget(cancel_btn) btn_layout.addStretch() ok_btn = QPushButton("OK") ok_btn.clicked.connect(self.accept_value) ok_btn.setDefault(True) btn_layout.addWidget(ok_btn) layout.addLayout(btn_layout) def set_binary(self, is_on: bool): self.off_btn.setChecked(not is_on) self.on_btn.setChecked(is_on) self.result_value = 100.0 if is_on else 0.0 def accept_value(self): if self.is_binary: self.result_value = 100.0 if self.on_btn.isChecked() else 0.0 else: self.result_value = self.value_spin.value() self.accept() def get_value(self) -> float: return self.result_value ================================================ FILE: src/designer_logging.py ================================================ """ Designer Logging - Centralized logging for Brain Designer Provides logging utilities that can be enabled/disabled based on debug mode. """ import logging import os import sys import traceback from datetime import datetime from contextlib import contextmanager # Global logger instance _logger = None _logging_enabled = False class NullHandler(logging.Handler): """A handler that does nothing - used when logging is disabled.""" def emit(self, record): pass class CrashReporter: """Handles crash reporting and error dialogs.""" def __init__(self, enable_logging: bool = False): self._error_dialog_callback = None self._logging_enabled = enable_logging def set_error_dialog_callback(self, callback): """Set the callback function for showing error dialogs.""" self._error_dialog_callback = callback def report_crash(self, exc_type, exc_value, exc_tb): """Report a crash - log it and optionally show dialog.""" error_msg = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb)) if self._logging_enabled: logger = get_logger() logger.critical(f"Crash reported:\n{error_msg}") if self._error_dialog_callback: self._error_dialog_callback( "Critical Error", f"An unexpected error occurred:\n\n{exc_value}" ) def get_log_directory() -> str: """Get the directory for log files.""" # Logs go in the root folder's logs/ directory (one level up from src/) script_dir = os.path.dirname(os.path.abspath(__file__)) parent_dir = os.path.dirname(script_dir) # Go up one level from src/ log_dir = os.path.join(parent_dir, 'logs') return log_dir def initialize_error_handling(enable_logging: bool = False) -> CrashReporter: """ Initialize error handling and optionally set up logging. Args: enable_logging: If True, creates log files and enables logging. If False, logging calls are no-ops. Returns: CrashReporter instance """ global _logger, _logging_enabled _logging_enabled = enable_logging # Create logger _logger = logging.getLogger('brain_designer') _logger.handlers.clear() # Remove any existing handlers if enable_logging: # Set up actual logging _logger.setLevel(logging.DEBUG) # Create logs directory log_dir = get_log_directory() os.makedirs(log_dir, exist_ok=True) # Create file handler with timestamp timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') log_file = os.path.join(log_dir, f'designer_{timestamp}.log') file_handler = logging.FileHandler(log_file, encoding='utf-8') file_handler.setLevel(logging.DEBUG) # Create console handler console_handler = logging.StreamHandler(sys.stdout) console_handler.setLevel(logging.INFO) # Create formatter formatter = logging.Formatter( '%(asctime)s - %(name)s - %(levelname)s - %(message)s' ) file_handler.setFormatter(formatter) console_handler.setFormatter(formatter) _logger.addHandler(file_handler) _logger.addHandler(console_handler) _logger.info(f"Logging initialized. Log file: {log_file}") else: # Disable logging - use null handler _logger.setLevel(logging.CRITICAL + 1) # Effectively disable all logging _logger.addHandler(NullHandler()) # Create crash reporter crash_reporter = CrashReporter(enable_logging) # Install global exception handler def exception_handler(exc_type, exc_value, exc_tb): if exc_type != KeyboardInterrupt: crash_reporter.report_crash(exc_type, exc_value, exc_tb) sys.__excepthook__(exc_type, exc_value, exc_tb) sys.excepthook = exception_handler return crash_reporter def get_logger(name: str = None) -> logging.Logger: """ Get a logger instance. Args: name: Optional sub-logger name. If None, returns the main designer logger. Returns: Logger instance """ global _logger if _logger is None: # Initialize with logging disabled by default initialize_error_handling(enable_logging=False) if name: return _logger.getChild(name) return _logger @contextmanager def OperationLogger(operation_name: str, logger: logging.Logger = None): """ Context manager for logging operations with timing. Usage: with OperationLogger("Loading data"): load_data() Args: operation_name: Name of the operation being performed logger: Optional specific logger to use """ if logger is None: logger = get_logger() if not _logging_enabled: # Just execute the block without logging yield return start_time = datetime.now() logger.debug(f"Starting: {operation_name}") try: yield elapsed = (datetime.now() - start_time).total_seconds() logger.debug(f"Completed: {operation_name} ({elapsed:.3f}s)") except Exception as e: elapsed = (datetime.now() - start_time).total_seconds() logger.error(f"Failed: {operation_name} ({elapsed:.3f}s) - {e}") raise def log_exceptions(func): """ Decorator to log exceptions from a function. Usage: @log_exceptions def my_function(): ... """ def wrapper(*args, **kwargs): try: return func(*args, **kwargs) except Exception as e: if _logging_enabled: logger = get_logger() logger.error(f"Exception in {func.__name__}: {e}", exc_info=True) raise return wrapper def safe_call(func, *args, default=None, **kwargs): """ Safely call a function, returning default on exception. Args: func: Function to call *args: Positional arguments default: Value to return on exception **kwargs: Keyword arguments Returns: Function result or default value """ try: return func(*args, **kwargs) except Exception as e: if _logging_enabled: logger = get_logger() logger.warning(f"safe_call caught exception in {func.__name__}: {e}") return default ================================================ FILE: src/designer_network_generator.py ================================================ """ Sparse Neural Network Generator Generates realistic, biologically-inspired connections between the core neurons with random noise so no two generations are identical. """ import random import math from typing import List, Tuple, Dict, Optional from dataclasses import dataclass from .designer_constants import CORE_NEURONS, REQUIRED_NEURON_NAMES @dataclass class ConnectionTemplate: """Template for a potential connection with probability and weight range.""" source: str target: str base_weight: float # Central weight value weight_variance: float # +/- variance probability: float # Chance this connection is created (0.0 - 1.0) description: str = "" # ============================================================================ # BIOLOGICALLY-INSPIRED CONNECTION TEMPLATES # These define the "tendencies" of the neural network - realistic relationships # between hunger, emotions, and sensory input # ============================================================================ CORE_CONNECTION_TEMPLATES = [ # === HUNGER dynamics === # Hunger creates dissatisfaction and negative mood ConnectionTemplate("hunger", "satisfaction", -0.35, 0.15, 0.85, "Being hungry reduces satisfaction"), ConnectionTemplate("hunger", "happiness", -0.25, 0.12, 0.70, "Hunger negatively affects mood"), ConnectionTemplate("hunger", "anxiety", 0.20, 0.10, 0.60, "Hunger can cause anxiety"), ConnectionTemplate("hunger", "curiosity", 0.15, 0.10, 0.45, "Hunger may drive food-seeking curiosity"), # === HAPPINESS dynamics === # Happiness is calming and promotes exploration ConnectionTemplate("happiness", "anxiety", -0.30, 0.12, 0.80, "Being happy reduces anxiety"), ConnectionTemplate("happiness", "curiosity", 0.25, 0.10, 0.65, "Happy creatures are more curious"), ConnectionTemplate("happiness", "satisfaction", 0.20, 0.08, 0.55, "Happiness contributes to satisfaction"), # === CLEANLINESS dynamics === # Being clean improves mood and reduces stress ConnectionTemplate("cleanliness", "happiness", 0.25, 0.10, 0.75, "Being clean improves mood"), ConnectionTemplate("cleanliness", "anxiety", -0.20, 0.08, 0.60, "Cleanliness reduces stress"), ConnectionTemplate("cleanliness", "satisfaction", 0.15, 0.08, 0.50, "Cleanliness contributes to overall satisfaction"), # === SLEEPINESS dynamics === # Tiredness affects cognition and mood negatively ConnectionTemplate("sleepiness", "curiosity", -0.30, 0.12, 0.70, "Tiredness suppresses curiosity"), ConnectionTemplate("sleepiness", "happiness", -0.20, 0.10, 0.65, "Being tired affects mood"), ConnectionTemplate("sleepiness", "anxiety", 0.15, 0.08, 0.55, "Sleep deprivation increases anxiety"), ConnectionTemplate("sleepiness", "satisfaction", -0.15, 0.08, 0.45, "Tiredness reduces satisfaction"), # === SATISFACTION dynamics === # Satisfaction is calming and mood-boosting ConnectionTemplate("satisfaction", "happiness", 0.35, 0.12, 0.85, "Satisfaction promotes happiness"), ConnectionTemplate("satisfaction", "anxiety", -0.25, 0.10, 0.75, "Being satisfied reduces anxiety"), ConnectionTemplate("satisfaction", "curiosity", 0.10, 0.08, 0.40, "Satisfied creatures may explore more"), # === ANXIETY dynamics === # Anxiety suppresses positive states and exploration ConnectionTemplate("anxiety", "curiosity", -0.35, 0.12, 0.80, "Anxiety suppresses exploration"), ConnectionTemplate("anxiety", "happiness", -0.25, 0.10, 0.70, "Anxiety reduces happiness"), ConnectionTemplate("anxiety", "satisfaction", -0.15, 0.08, 0.50, "Anxiety reduces overall satisfaction"), ConnectionTemplate("anxiety", "sleepiness", 0.10, 0.08, 0.35, "Anxiety can cause fatigue"), # === CURIOSITY dynamics === # Curiosity promotes positive mood and engagement ConnectionTemplate("curiosity", "happiness", 0.20, 0.10, 0.60, "Curiosity brings joy"), ConnectionTemplate("curiosity", "satisfaction", 0.15, 0.08, 0.45, "Exploration satisfies"), ConnectionTemplate("curiosity", "anxiety", -0.10, 0.08, 0.35, "Curiosity can reduce anxiety through engagement"), # === CAN_SEE_FOOD dynamics (vision input) === # Seeing food triggers hunger awareness and emotional responses ConnectionTemplate("can_see_food", "hunger", 0.30, 0.12, 0.90, "Seeing food activates hunger awareness"), ConnectionTemplate("can_see_food", "happiness", 0.25, 0.10, 0.75, "Food sighting is exciting"), ConnectionTemplate("can_see_food", "curiosity", 0.20, 0.10, 0.65, "Food triggers investigative behavior"), ConnectionTemplate("can_see_food", "satisfaction", 0.15, 0.08, 0.50, "Food sight brings anticipatory satisfaction"), ConnectionTemplate("can_see_food", "anxiety", -0.10, 0.08, 0.40, "Food sighting may reduce food-seeking anxiety"), ] class SparseNetworkGenerator: """ Generates sparse neural networks with biologically-inspired connections. """ def __init__(self, seed: Optional[int] = None): """ Initialize generator with optional seed for reproducibility. Args: seed: Random seed. If None, uses system entropy. """ self.seed = seed self.rng = random.Random(seed) self.templates = CORE_CONNECTION_TEMPLATES.copy() def set_seed(self, seed: int): """Set random seed for reproducible generation.""" self.seed = seed self.rng = random.Random(seed) def _generate_weight(self, template: ConnectionTemplate) -> float: """Generate a noisy weight from a template.""" # Gaussian noise centered on base_weight noise = self.rng.gauss(0, template.weight_variance) weight = template.base_weight + noise # Clamp to valid range and round weight = max(-1.0, min(1.0, weight)) return round(weight, 3) def perturb_positions(self, design, variance: float = 0.3, bounds: Tuple[float, float, float, float] = (-400, -150, 900, 750)): """ Randomly perturb neuron positions with organic variance. Args: design: BrainDesign to modify variance: Variance multiplier (0.3 = ±30% from original position) bounds: (min_x, min_y, max_x, max_y) to constrain neurons within view window """ if variance <= 0: return min_x, min_y, max_x, max_y = bounds rng = self.rng # Calculate center for organic spreading if not design.neurons: return for name, neuron in design.neurons.items(): # Skip required/core neurons to keep the brain structure recognizable # but allow custom neurons (connectors, neurogenesis types) to move if neuron.is_required or neuron.is_core: continue # Ensure position is valid if neuron.position is None: continue x, y = neuron.position # Add Gaussian noise scaled by variance # Using a larger base scale (100px) so variance=0.2 moves things visibly (~20px) noise_scale = variance * 100 new_x = x + rng.gauss(0, noise_scale) new_y = y + rng.gauss(0, noise_scale) # Clamp to view bounds to keep them on canvas new_x = max(min_x, min(max_x, new_x)) new_y = max(min_y, min(max_y, new_y)) neuron.position = (new_x, new_y) def add_random_sensors(self, design, probability: float = 0.3): """ Randomly add input sensors to the design based on specific game rules. Rules: - 'can_see_food' is required (handled by design validation, but implicitly part of valid set) - 'plant_proximity': 20% chance - 'is_fleeing': 10% chance - No other sensors are generated. Args: design: BrainDesign to modify probability: Ignored. Probabilities are hardcoded per requirements. Returns: Number of sensors added """ # Specific probabilities requested sensor_rules = { 'plant_proximity': 0.20, 'is_fleeing': 0.10 } added = 0 for sensor_name, chance in sensor_rules.items(): # Skip if already present if sensor_name in design.neurons: continue # Roll the dice if self.rng.random() < chance: # add_sensor handles both creation and sensible wiring via defaults success, _ = design.add_sensor(sensor_name, create_default_connections=True) if success: added += 1 return added def _should_create_connection(self, template: ConnectionTemplate, density_multiplier: float = 1.0) -> bool: """Decide if a connection should be created based on probability.""" adjusted_prob = min(1.0, template.probability * density_multiplier) return self.rng.random() < adjusted_prob def generate_connections(self, density: float = 1.0, include_feedback_loops: bool = True, weight_noise: float = 1.0 ) -> List[Tuple[str, str, float]]: """ Generate sparse connections between core neurons. Args: density: Multiplier for connection probability (0.5 = sparser, 1.5 = denser) include_feedback_loops: If True, may add some bidirectional connections weight_noise: Multiplier for weight variance (0.5 = less noise, 2.0 = more noise) Returns: List of (source, target, weight) tuples """ connections = [] created_pairs = set() # Process each template for template in self.templates: if not self._should_create_connection(template, density): continue # Generate noisy weight adjusted_template = ConnectionTemplate( template.source, template.target, template.base_weight, template.weight_variance * weight_noise, template.probability, template.description ) weight = self._generate_weight(adjusted_template) # Skip very weak connections if abs(weight) < 0.02: continue connections.append((template.source, template.target, weight)) created_pairs.add((template.source, template.target)) # Optionally add feedback loops (reverse connections) if include_feedback_loops: feedback_candidates = [ ("satisfaction", "hunger", -0.15, 0.4), # Being satisfied reduces hunger drive ("happiness", "sleepiness", -0.10, 0.3), # Being happy reduces tiredness ("anxiety", "hunger", 0.10, 0.25), # Stress eating? ("curiosity", "anxiety", 0.08, 0.2), # Exploration can be slightly stressful ] for source, target, base_w, prob in feedback_candidates: # Don't create if forward connection doesn't exist or reverse already exists if (target, source) not in created_pairs: continue if (source, target) in created_pairs: continue if self.rng.random() < prob * density: noise = self.rng.gauss(0, 0.05 * weight_noise) weight = round(max(-1.0, min(1.0, base_w + noise)), 3) if abs(weight) >= 0.02: connections.append((source, target, weight)) created_pairs.add((source, target)) # Shuffle to avoid predictable order self.rng.shuffle(connections) return connections def generate_for_design(self, design, clear_existing: bool = True, density: float = 1.0, include_feedback: bool = True, weight_noise: float = 1.0, # ADDED to match call sig position_variance: float = 0.0, # ADDED to fix Error sensor_probability: float = 0.0, # ADDED to match call sig bounds: Tuple[float, float, float, float] = (-400, -150, 900, 750), seed: Optional[int] = None, silent: bool = False) -> Tuple[int, List[str]]: """ Generate and apply sparse network to a BrainDesign. Args: design: BrainDesign instance to modify clear_existing: If True, removes existing connections first density: Connection density multiplier include_feedback: Include feedback loops weight_noise: Multiplier for random weight variance position_variance: If > 0, randomly moves neurons (perturbation) sensor_probability: Chance to add random input sensors bounds: (min_x, min_y, max_x, max_y) to constrain neurons within view window seed: Optional seed for reproducible generation. silent: If True, suppresses generation of action description strings Returns: Tuple of (connections_created, list of action descriptions) """ actions = [] # Apply seed if provided if seed is not None: self.set_seed(seed) if not silent: actions.append(f"Using seed: {seed}") # Ensure required neurons exist missing = design.get_missing_required_neurons() if missing: design.add_missing_required_neurons() if not silent: actions.append(f"Added missing required neurons: {', '.join(missing)}") # 1. Handle Random Sensors (if requested) if sensor_probability > 0: added_sensors = self.add_random_sensors(design, sensor_probability) if added_sensors > 0 and not silent: actions.append(f"Added {added_sensors} random input sensors") # 2. Handle Position Perturbation (if requested) if position_variance > 0: self.perturb_positions(design, variance=position_variance, bounds=bounds) if not silent: actions.append(f"Perturbed neuron positions (variance: {position_variance})") # 3. Clear existing connections (if requested) if clear_existing: old_count = len(design.connections) design.connections.clear() if old_count > 0 and not silent: actions.append(f"Cleared {old_count} existing connections") # 4. Generate new connections connections = self.generate_connections( density=density, include_feedback_loops=include_feedback, weight_noise=weight_noise ) # Apply to design created = 0 for source, target, weight in connections: # Verify both neurons exist in design if source not in design.neurons or target not in design.neurons: continue if design.add_connection(source, target, weight): created += 1 if not silent: sign = "+" if weight > 0 else "" actions.append(f" {source} → {target} ({sign}{weight:.3f})") return created, actions def get_preset_styles(self) -> Dict[str, Dict]: """Return preset generation styles with new variance and sensor options.""" return { 'balanced': { 'name': '⚖️ Balanced', 'description': 'Standard density, moderate noise, slight position variance', 'density': 1.0, 'include_feedback': True, 'weight_noise': 1.0, 'position_variance': 0.2, 'sensor_probability': 0.15 }, 'sparse': { 'name': '🔬 Minimal', 'description': 'Fewer connections, minimal position variance', 'density': 0.5, 'include_feedback': False, 'weight_noise': 0.7, 'position_variance': 0.1, 'sensor_probability': 0.0 }, 'dense': { 'name': '🕸️ Dense', 'description': 'More connections, rich dynamics, moderate sensors', 'density': 1.4, 'include_feedback': True, 'weight_noise': 1.2, 'position_variance': 0.3, 'sensor_probability': 0.3 }, 'chaotic': { 'name': '🌀 Chaotic', 'description': 'High noise, unpredictable, high position variance', 'density': 1.1, 'include_feedback': True, 'weight_noise': 2.5, 'position_variance': 0.5, 'sensor_probability': 0.4 }, 'calm': { 'name': '🧘 Calm', 'description': 'Weaker connections, stable, low variance', 'density': 0.8, 'include_feedback': True, 'weight_noise': 0.5, 'position_variance': 0.1, 'sensor_probability': 0.0 }, 'wild': { 'name': '🌿 Wild', 'description': 'Organic positions, many sensors, natural feel', 'density': 1.2, 'include_feedback': True, 'weight_noise': 1.5, 'position_variance': 0.4, 'sensor_probability': 0.5 } } # ============================================================================ # UTILITY FUNCTIONS # ============================================================================ def generate_sparse_core_network(density: float = 1.0, seed: Optional[int] = None) -> List[Tuple[str, str, float]]: """ Convenience function to generate sparse network connections. """ generator = SparseNetworkGenerator(seed) return generator.generate_connections(density=density) def describe_connection(source: str, target: str, weight: float) -> str: """Generate a human-readable description of a connection.""" effect = "excites" if weight > 0 else "inhibits" strength = abs(weight) if strength < 0.15: strength_word = "weakly" elif strength < 0.35: strength_word = "moderately" elif strength < 0.6: strength_word = "strongly" else: strength_word = "powerfully" return f"{source} {strength_word} {effect} {target}" ================================================ FILE: src/designer_outputs_panel.py ================================================ # designer_outputs_panel.py """ Brain Designer panel for configuring neuron output bindings. This panel allows users to: 1. Bind neurons to output hooks (actuators) 2. Configure trigger thresholds and modes 3. Manage cooldowns and parameters (including colour selection) When the squid runs this brain, neurons that exceed their threshold will trigger the bound behaviors (flee, seek food, ink, etc.) """ from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, QPushButton, QLabel, QComboBox, QDoubleSpinBox, QCheckBox, QTableWidget, QTableWidgetItem, QHeaderView, QDialog, QDialogButtonBox, QScrollArea, QFrame, QMessageBox, QSpinBox, QColorDialog ) from PyQt5.QtCore import Qt, pyqtSignal from PyQt5.QtGui import QColor try: from .brain_neuron_outputs import ( NeuronOutputBinding, OutputTriggerMode, STANDARD_OUTPUT_HOOKS, get_output_hooks_by_category ) except ImportError: from brain_neuron_outputs import ( NeuronOutputBinding, OutputTriggerMode, STANDARD_OUTPUT_HOOKS, get_output_hooks_by_category ) class OutputBindingDialog(QDialog): """Dialog for creating or editing a neuron output binding.""" def __init__(self, design, existing_binding=None, parent=None): super().__init__(parent) self.design = design self.existing_binding = existing_binding self.result_binding = None # Store dynamic parameters here self.current_params = {} if existing_binding and existing_binding.hook_params: self.current_params = existing_binding.hook_params.copy() self.setWindowTitle("Configure Output Binding" if existing_binding else "Add Output Binding") self.setMinimumWidth(450) self.setup_ui() if existing_binding: self._populate_from_binding(existing_binding) def setup_ui(self): layout = QVBoxLayout(self) # 1. Selection Group type_group = QGroupBox("Select Neuron Type") type_layout = QFormLayout(type_group) self.neuron_combo = QComboBox() self._populate_neurons() type_layout.addRow("Neuron:", self.neuron_combo) # Show current activation hint self.activation_label = QLabel("Current: --") self.activation_label.setStyleSheet("color: #888;") type_layout.addRow("", self.activation_label) layout.addWidget(type_group) # 2. Output hook selection hook_group = QGroupBox("Output Behavior") hook_layout = QFormLayout(hook_group) self.hook_combo = QComboBox() self._populate_hooks() self.hook_combo.currentTextChanged.connect(self._on_hook_changed) hook_layout.addRow("Trigger:", self.hook_combo) self.hook_description = QLabel("") self.hook_description.setWordWrap(True) self.hook_description.setStyleSheet("color: #666; font-style: italic;") hook_layout.addRow("", self.hook_description) layout.addWidget(hook_group) # === Dynamic Parameters Area === self.params_group = QGroupBox("Behavior Parameters") self.params_layout = QVBoxLayout(self.params_group) # Color Picker UI (Hidden by default) self.color_widget = QWidget() color_layout = QHBoxLayout(self.color_widget) color_layout.setContentsMargins(0,0,0,0) self.color_preview = QLabel() self.color_preview.setFixedSize(30, 30) self.color_preview.setStyleSheet("background-color: #CCCCCC; border: 1px solid #888;") self.pick_color_btn = QPushButton("Pick Colour...") self.pick_color_btn.clicked.connect(self._pick_color) self.reset_color_btn = QPushButton("Reset (Random)") self.reset_color_btn.clicked.connect(self._reset_color) color_layout.addWidget(QLabel("Tint Colour:")) color_layout.addWidget(self.color_preview) color_layout.addWidget(self.pick_color_btn) color_layout.addWidget(self.reset_color_btn) color_layout.addStretch() self.params_layout.addWidget(self.color_widget) self.color_widget.hide() # Hide initially layout.addWidget(self.params_group) self.params_group.hide() # Hide group initially # Trigger configuration trigger_group = QGroupBox("Trigger Settings") trigger_layout = QFormLayout(trigger_group) # Threshold self.threshold_spin = QDoubleSpinBox() self.threshold_spin.setRange(0, 100) self.threshold_spin.setValue(70) self.threshold_spin.setSuffix(" %") self.threshold_spin.setToolTip("Activation level required to trigger the output") trigger_layout.addRow("Threshold:", self.threshold_spin) # Trigger mode self.mode_combo = QComboBox() self.mode_combo.addItem("Rising Edge (cross threshold going up)", OutputTriggerMode.THRESHOLD_RISING.value) self.mode_combo.addItem("Falling Edge (cross threshold going down)", OutputTriggerMode.THRESHOLD_FALLING.value) self.mode_combo.addItem("While Above (continuous while > threshold)", OutputTriggerMode.THRESHOLD_ABOVE.value) self.mode_combo.addItem("While Below (continuous while < threshold)", OutputTriggerMode.THRESHOLD_BELOW.value) self.mode_combo.addItem("On Change (any significant change)", OutputTriggerMode.ON_CHANGE.value) trigger_layout.addRow("Mode:", self.mode_combo) # Cooldown self.cooldown_spin = QDoubleSpinBox() self.cooldown_spin.setRange(0.1, 60) self.cooldown_spin.setValue(1.0) self.cooldown_spin.setSuffix(" sec") self.cooldown_spin.setToolTip("Minimum time between triggers") trigger_layout.addRow("Cooldown:", self.cooldown_spin) # Enabled self.enabled_check = QCheckBox("Enabled") self.enabled_check.setChecked(True) trigger_layout.addRow("", self.enabled_check) layout.addWidget(trigger_group) # Buttons buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) # Initialize hook description self._on_hook_changed() def _populate_neurons(self): """Populate neuron combo with available neurons.""" self.neuron_combo.clear() # Get all non-core neurons (outputs typically come from processing neurons) for name, neuron in sorted(self.design.neurons.items()): if not neuron.is_core: display = name # Add icons for special types if neuron.is_sensor: display = f"📡 {name}" elif name.startswith('connector_'): display = f"🔗 {name}" elif name.startswith('novelty_'): display = f"✨ {name}" elif name.startswith('stress_'): display = f"🔥 {name}" elif name.startswith('reward_'): display = f"💎 {name}" self.neuron_combo.addItem(display, name) # Also add core neurons (they might want anxiety to trigger flee, etc.) self.neuron_combo.insertSeparator(self.neuron_combo.count()) for name, neuron in sorted(self.design.neurons.items()): if neuron.is_core: self.neuron_combo.addItem(f"⚡ {name}", name) def _populate_hooks(self): """Populate hook combo with available output hooks.""" self.hook_combo.clear() by_category = get_output_hooks_by_category() for category in sorted(by_category.keys()): hooks = by_category[category] # Add category header self.hook_combo.addItem(f"── {category.title()} ──", None) # Make header non-selectable idx = self.hook_combo.count() - 1 self.hook_combo.model().item(idx).setEnabled(False) for hook_name, info in sorted(hooks.items()): display_name = hook_name.replace('neuron_output_', '').replace('_', ' ').title() self.hook_combo.addItem(f" {display_name}", hook_name) def _on_hook_changed(self): """Update description and param UI when hook selection changes.""" hook_name = self.hook_combo.currentData() self._update_param_ui(hook_name) if hook_name and hook_name in STANDARD_OUTPUT_HOOKS: info = STANDARD_OUTPUT_HOOKS[hook_name] self.hook_description.setText(info.get('description', '')) # Update default threshold default_thresh = info.get('default_threshold', 70) if not self.existing_binding: # Only auto-set for new bindings self.threshold_spin.setValue(default_thresh) else: self.hook_description.setText("") def _update_param_ui(self, hook_name): """Show specific UI elements based on the selected hook.""" self.params_group.hide() self.color_widget.hide() if hook_name == 'neuron_output_change_color': self.params_group.show() self.color_widget.show() self._update_color_preview() def _update_color_preview(self): """Update the color preview box based on current params.""" if 'red' in self.current_params: r = self.current_params.get('red', 255) g = self.current_params.get('green', 255) b = self.current_params.get('blue', 255) self.color_preview.setStyleSheet(f"background-color: rgb({r},{g},{b}); border: 1px solid #000;") self.color_preview.setText("") else: self.color_preview.setStyleSheet("background-color: #EEE; border: 1px dashed #888;") self.color_preview.setText("?") def _pick_color(self): """Open color picker dialog.""" initial = QColor(255, 255, 255) if 'red' in self.current_params: initial = QColor( self.current_params['red'], self.current_params['green'], self.current_params['blue'] ) color = QColorDialog.getColor(initial, self, "Select Output Tint") if color.isValid(): self.current_params['red'] = color.red() self.current_params['green'] = color.green() self.current_params['blue'] = color.blue() self._update_color_preview() def _reset_color(self): """Clear color params to revert to random.""" self.current_params.pop('red', None) self.current_params.pop('green', None) self.current_params.pop('blue', None) self._update_color_preview() def _populate_from_binding(self, binding: NeuronOutputBinding): """Fill dialog fields from existing binding.""" # Find and select neuron for i in range(self.neuron_combo.count()): if self.neuron_combo.itemData(i) == binding.neuron_name: self.neuron_combo.setCurrentIndex(i) break # Find and select hook for i in range(self.hook_combo.count()): if self.hook_combo.itemData(i) == binding.output_hook: self.hook_combo.setCurrentIndex(i) break self.threshold_spin.setValue(binding.threshold) self.cooldown_spin.setValue(binding.cooldown) self.enabled_check.setChecked(binding.enabled) # Find and select mode for i in range(self.mode_combo.count()): if self.mode_combo.itemData(i) == binding.trigger_mode.value: self.mode_combo.setCurrentIndex(i) break # Load params self.current_params = binding.hook_params.copy() if binding.hook_params else {} self._on_hook_changed() def accept(self): """Validate and create binding.""" neuron_name = self.neuron_combo.currentData() hook_name = self.hook_combo.currentData() if not neuron_name: QMessageBox.warning(self, "Error", "Please select a neuron") return if not hook_name: QMessageBox.warning(self, "Error", "Please select an output behavior") return mode_value = self.mode_combo.currentData() trigger_mode = OutputTriggerMode(mode_value) self.result_binding = NeuronOutputBinding( neuron_name=neuron_name, output_hook=hook_name, threshold=self.threshold_spin.value(), trigger_mode=trigger_mode, cooldown=self.cooldown_spin.value(), enabled=self.enabled_check.isChecked(), hook_params=self.current_params # Save collected params ) super().accept() class NeuronOutputsPanel(QWidget): """ Panel for managing neuron output bindings in the brain designer. Shows a table of all output bindings and allows adding/editing/removing them. """ outputsChanged = pyqtSignal() def __init__(self, design, parent=None): super().__init__(parent) self.design = design # Store bindings locally (will be exported with brain) self.bindings: list = [] self.setup_ui() def setup_ui(self): layout = QVBoxLayout(self) # Header with description header = QLabel( "Output Bindings
    " "Connect neurons to squid behaviors. When a neuron's activation " "exceeds the threshold, it triggers the bound action." ) header.setWordWrap(True) layout.addWidget(header) # Toolbar toolbar = QHBoxLayout() add_btn = QPushButton("➕ Add Binding") add_btn.clicked.connect(self.add_binding) toolbar.addWidget(add_btn) edit_btn = QPushButton("✏️ Edit") edit_btn.clicked.connect(self.edit_binding) toolbar.addWidget(edit_btn) remove_btn = QPushButton("🗑️ Remove") remove_btn.clicked.connect(self.remove_binding) toolbar.addWidget(remove_btn) toolbar.addStretch() layout.addLayout(toolbar) # Bindings table self.table = QTableWidget() self.table.setColumnCount(5) self.table.setHorizontalHeaderLabels([ "Neuron", "→ Behavior", "Threshold", "Mode", "Enabled" ]) # [CHANGED] Enable interactive column resizing and set default widths self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) self.table.setColumnWidth(0, 150) # Neuron self.table.setColumnWidth(1, 200) # Behavior (wider to show color) self.table.setColumnWidth(2, 100) # Threshold self.table.setColumnWidth(3, 150) # Mode self.table.setColumnWidth(4, 80) # Enabled self.table.setSelectionBehavior(QTableWidget.SelectRows) self.table.setSelectionMode(QTableWidget.SingleSelection) self.table.doubleClicked.connect(self.edit_binding) layout.addWidget(self.table) # Quick info self.info_label = QLabel("") self.info_label.setStyleSheet("color: #888;") layout.addWidget(self.info_label) self.refresh() def refresh(self): """Refresh the bindings table.""" self.table.setRowCount(len(self.bindings)) for row, binding in enumerate(self.bindings): # Neuron name neuron_item = QTableWidgetItem(binding.neuron_name) if binding.neuron_name not in self.design.neurons: neuron_item.setForeground(QColor(255, 100, 100)) # Red if missing neuron_item.setToolTip("⚠️ Neuron not found in design") self.table.setItem(row, 0, neuron_item) # Output hook (formatted nicely) hook_display = binding.output_hook.replace('neuron_output_', '').replace('_', ' ').title() # [CHANGED] For color bindings, show color as cell background instead of RGB text hook_item = QTableWidgetItem(hook_display) if binding.output_hook in STANDARD_OUTPUT_HOOKS: hook_item.setToolTip(STANDARD_OUTPUT_HOOKS[binding.output_hook].get('description', '')) # If this is a color binding, set the background color if binding.hook_params and 'red' in binding.hook_params: r = binding.hook_params.get('red', 255) g = binding.hook_params.get('green', 255) b = binding.hook_params.get('blue', 255) color = QColor(r, g, b) hook_item.setBackground(color) # Adjust text color for contrast brightness = (r * 299 + g * 587 + b * 114) / 1000 if brightness < 128: hook_item.setForeground(QColor(255, 255, 255)) else: hook_item.setForeground(QColor(0, 0, 0)) self.table.setItem(row, 1, hook_item) # Threshold thresh_item = QTableWidgetItem(f"{binding.threshold:.0f}%") self.table.setItem(row, 2, thresh_item) # Mode mode_display = binding.trigger_mode.value.replace('_', ' ').title() mode_item = QTableWidgetItem(mode_display) self.table.setItem(row, 3, mode_item) # Enabled enabled_item = QTableWidgetItem("✓" if binding.enabled else "✗") enabled_item.setTextAlignment(Qt.AlignCenter) if not binding.enabled: enabled_item.setForeground(QColor(150, 150, 150)) self.table.setItem(row, 4, enabled_item) # Update info enabled_count = sum(1 for b in self.bindings if b.enabled) self.info_label.setText(f"{len(self.bindings)} binding(s), {enabled_count} enabled") def add_binding(self): """Show dialog to add a new binding.""" dialog = OutputBindingDialog(self.design, parent=self) if dialog.exec_() == QDialog.Accepted and dialog.result_binding: # Duplicate check REMOVED to allow multiple bindings for same neuron/hook # (e.g. one for Rising threshold, one for Falling) self.bindings.append(dialog.result_binding) self.refresh() self.outputsChanged.emit() def edit_binding(self): """Edit the selected binding.""" row = self.table.currentRow() if row < 0 or row >= len(self.bindings): return binding = self.bindings[row] dialog = OutputBindingDialog(self.design, existing_binding=binding, parent=self) if dialog.exec_() == QDialog.Accepted and dialog.result_binding: self.bindings[row] = dialog.result_binding self.refresh() self.outputsChanged.emit() def remove_binding(self): """Remove the selected binding.""" row = self.table.currentRow() if row < 0 or row >= len(self.bindings): return binding = self.bindings[row] reply = QMessageBox.question( self, "Remove Binding", f"Remove binding: {binding.neuron_name} → {binding.output_hook}?", QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: self.bindings.pop(row) self.refresh() self.outputsChanged.emit() def load_bindings(self, bindings_data: list): """Load bindings from saved data.""" self.bindings.clear() for data in bindings_data: try: binding = NeuronOutputBinding.from_dict(data) self.bindings.append(binding) except Exception as e: print(f"[OutputsPanel] Error loading binding: {e}") self.refresh() def export_bindings(self) -> list: """Export bindings for saving.""" return [b.to_dict() for b in self.bindings] def validate_bindings(self) -> list: """ Validate bindings against current design. Returns list of warning messages. """ warnings = [] for binding in self.bindings: if binding.neuron_name not in self.design.neurons: warnings.append(f"Binding references missing neuron: {binding.neuron_name}") return warnings ================================================ FILE: src/designer_panels.py ================================================ from PyQt5.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QGroupBox, QFormLayout, QListWidget, QListWidgetItem, QPushButton, QLabel, QLineEdit, QDoubleSpinBox, QCheckBox, QComboBox, QTextEdit, QTableWidget, QTableWidgetItem, QHeaderView, QScrollArea, QDialog, QMessageBox, QInputDialog ) from PyQt5.QtCore import Qt, pyqtSignal from typing import Optional, Dict from .designer_core import BrainDesign, DesignerNeuron, DesignerLayer from .designer_constants import ( NeuronType, INPUT_SENSORS, REQUIRED_NEURONS, DEFAULT_SENSOR_CONNECTIONS, is_required_neuron, is_input_sensor ) # Optional: Import sensor discovery for plugin sensors try: from .designer_sensor_discovery import get_all_available_sensors, is_plugin_sensor _HAS_SENSOR_DISCOVERY = True except ImportError: _HAS_SENSOR_DISCOVERY = False def get_all_available_sensors(): # Wrap raw (x,y) tuples from INPUT_SENSORS into proper info dicts return { name: {'description': name.replace('_', ' ').title(), 'is_binary': False, 'category': 'Built-in', 'plugin': None, 'default_connections': []} for name in INPUT_SENSORS } def is_plugin_sensor(name): return False class AddNeuronDialog(QDialog): def __init__(self, design: BrainDesign, position=None, parent=None): super().__init__(parent) self.design = design self.position = position or (400, 200) self.result_neuron = None self.result_message = "" self.setWindowTitle("Add Neuron") self.setMinimumWidth(400) self.setup_ui() def setup_ui(self): layout = QVBoxLayout(self) # 1. Selection Group type_group = QGroupBox("Select Neuron Type") type_layout = QVBoxLayout(type_group) # Custom Neuron Button (The "Magic" one) custom_btn = QPushButton("✨ Custom / Plugin Neuron") custom_btn.setToolTip("Create a neuron with a specific name to link with game plugins") custom_btn.clicked.connect(lambda: self.select_type('custom')) type_layout.addWidget(custom_btn) # Sensor Button sensor_btn = QPushButton("📡 Input Sensor") sensor_btn.clicked.connect(lambda: self.select_type('sensor')) type_layout.addWidget(sensor_btn) layout.addWidget(type_group) # 2. Sensor Selection Group (Hidden by default) self.sensor_group = QGroupBox("Select Sensor") sensor_layout = QVBoxLayout(self.sensor_group) self.sensor_list = QListWidget() self.sensor_list.itemDoubleClicked.connect(self.accept_sensor) sensor_layout.addWidget(self.sensor_list) layout.addWidget(self.sensor_group) self.sensor_group.hide() # 3. Custom Neuron Entry Group (Hidden by default) self.custom_group = QGroupBox("Define Custom Neuron") custom_layout = QFormLayout(self.custom_group) # Magic Link Instruction info_label = QLabel( "To affect the squid, the Name must match a plugin ID.
    " "Example: Name it 'jet_boost' to activate a jetpack plugin.
    " ) info_label.setWordWrap(True) info_label.setStyleSheet("color: #666; margin-bottom: 5px;") custom_layout.addRow(info_label) self.name_edit = QLineEdit() self.name_edit.setPlaceholderText("e.g. turbo_mode") custom_layout.addRow("Plugin ID / Name:", self.name_edit) add_custom_btn = QPushButton("Create Link") add_custom_btn.setStyleSheet("font-weight: bold; background-color: #E0F7FA; color: #006064;") add_custom_btn.clicked.connect(self.accept_custom) custom_layout.addRow(add_custom_btn) layout.addWidget(self.custom_group) self.custom_group.hide() def select_type(self, t): if t == 'sensor': self.sensor_group.show() self.custom_group.hide() self.populate_sensor_list() else: self.custom_group.show() self.sensor_group.hide() self.name_edit.setFocus() def populate_sensor_list(self): self.sensor_list.clear() existing = set(self.design.neurons.keys()) # Get all available sensors (built-in + plugin) all_sensors = get_all_available_sensors() # Also ensure can_see_food is included if 'can_see_food' not in all_sensors and 'can_see_food' in REQUIRED_NEURONS: all_sensors['can_see_food'] = { 'description': REQUIRED_NEURONS['can_see_food'].get('description', ''), 'is_binary': True, 'plugin': None } # Filter to available sensors available = {k: v for k, v in all_sensors.items() if k not in existing} if not available: self.sensor_list.addItem("All sensors added") return for name in sorted(available.keys()): info = available[name] display_name = name.replace('_', ' ').title() # Add plugin indicator if info.get('plugin'): display_name = f"🔌 {display_name}" item = QListWidgetItem(display_name) item.setData(Qt.UserRole, name) # Add tooltip tooltip = info.get('description', '') if info.get('plugin'): tooltip += f"\n[Plugin: {info['plugin']}]" if tooltip: item.setToolTip(tooltip.strip()) self.sensor_list.addItem(item) def accept_sensor(self): items = self.sensor_list.selectedItems() if not items: return name = items[0].data(Qt.UserRole) if not name: return success, msg = self.design.add_sensor(name) if success: self.result_message = msg self.accept() else: QMessageBox.warning(self, "Error", msg) def accept_custom(self): name = self.name_edit.text().strip().lower().replace(' ', '_') if not name: return if name in self.design.neurons: QMessageBox.warning(self, "Error", "Exists") return self.design.add_neuron(DesignerNeuron(name=name, neuron_type=NeuronType.HIDDEN, position=self.position)) self.result_message = f"Created {name}" self.accept() class NeuronPropertiesPanel(QWidget): neuronChanged = pyqtSignal(str) def __init__(self, design: BrainDesign, parent=None): super().__init__(parent) self.design = design self.current_neuron = None self.setup_ui() def setup_ui(self): layout = QVBoxLayout(self) self.header_label = QLabel("No neuron selected") layout.addWidget(self.header_label) form = QFormLayout() self.name_edit = QLineEdit() self.name_edit.editingFinished.connect(self.on_name_changed) form.addRow("Name:", self.name_edit) self.type_combo = QComboBox() # [UPDATED] Added CONNECTOR to valid types self.type_combo.addItems(["CORE", "SENSOR", "INPUT", "OUTPUT", "HIDDEN", "CONNECTOR"]) self.type_combo.currentTextChanged.connect(self.on_type_changed) form.addRow("Type:", self.type_combo) self.x_spin = QDoubleSpinBox() self.x_spin.setRange(-1000, 2000) self.x_spin.valueChanged.connect(self.on_pos_changed) form.addRow("X:", self.x_spin) self.y_spin = QDoubleSpinBox() self.y_spin.setRange(-500, 1000) self.y_spin.valueChanged.connect(self.on_pos_changed) form.addRow("Y:", self.y_spin) layout.addLayout(form) self.delete_btn = QPushButton("Delete Neuron") self.delete_btn.clicked.connect(self.on_delete) layout.addWidget(self.delete_btn) layout.addStretch() self.setEnabled(False) def set_neuron(self, name): self.current_neuron = name if not name: self.setEnabled(False) self.header_label.setText("No Selection") return self.setEnabled(True) neuron = self.design.get_neuron(name) self.header_label.setText(name) self.name_edit.blockSignals(True) self.type_combo.blockSignals(True) self.x_spin.blockSignals(True) self.y_spin.blockSignals(True) self.name_edit.setText(name) self.name_edit.setEnabled(not neuron.is_protected) self.type_combo.setCurrentText(neuron.neuron_type.name) self.type_combo.setEnabled(not neuron.is_protected and not neuron.is_sensor) self.x_spin.setValue(neuron.position[0]) self.y_spin.setValue(neuron.position[1]) self.delete_btn.setEnabled(not neuron.is_protected) self.name_edit.blockSignals(False) self.type_combo.blockSignals(False) self.x_spin.blockSignals(False) self.y_spin.blockSignals(False) def on_name_changed(self): if not self.current_neuron: return new_name = self.name_edit.text().strip() if new_name != self.current_neuron: if self.design.rename_neuron(self.current_neuron, new_name)[0]: self.current_neuron = new_name self.neuronChanged.emit(new_name) def on_type_changed(self, t): if self.current_neuron: self.design.get_neuron(self.current_neuron).neuron_type = NeuronType[t] self.neuronChanged.emit(self.current_neuron) def on_pos_changed(self): if self.current_neuron: self.design.get_neuron(self.current_neuron).position = (self.x_spin.value(), self.y_spin.value()) self.neuronChanged.emit(self.current_neuron) def on_delete(self): if self.design.remove_neuron(self.current_neuron)[0]: self.current_neuron = None self.neuronChanged.emit("") class LayersPanel(QWidget): layersChanged = pyqtSignal() def __init__(self, design, parent=None): super().__init__(parent) self.design = design self.setup_ui() def setup_ui(self): l = QVBoxLayout(self) self.list = QListWidget() l.addWidget(self.list) btn = QPushButton("Add Layer") btn.clicked.connect(self.add_layer) l.addWidget(btn) self.refresh() def refresh(self): self.list.clear() for layer in self.design.layers: self.list.addItem(f"{layer.name} ({layer.layer_type.name})") def add_layer(self): name, ok = QInputDialog.getText(self, "New Layer", "Name:") if ok and name: self.design.add_layer(DesignerLayer(name, NeuronType.HIDDEN, 200)) self.refresh() self.layersChanged.emit() class SensorsPanel(QWidget): """ Panel showing available input sensors. Supports both built-in sensors from INPUT_SENSORS and custom sensors registered by plugins via the PluginManager. """ sensorsChanged = pyqtSignal() def __init__(self, design, parent=None): super().__init__(parent) self.design = design self._scroll_widget = None # Store reference for dynamic updates self._scroll_layout = None self.setup_ui() def setup_ui(self): l = QVBoxLayout(self) # Header with refresh button for plugin sensors header = QHBoxLayout() header.addWidget(QLabel("Input Sensors:")) header.addStretch() refresh_btn = QPushButton("🔄") refresh_btn.setToolTip("Refresh sensor list (includes plugin-registered sensors)") refresh_btn.setMaximumWidth(30) refresh_btn.clicked.connect(self.rebuild_sensor_list) header.addWidget(refresh_btn) l.addLayout(header) # Scrollable sensor list scroll = QScrollArea() self._scroll_widget = QWidget() self._scroll_layout = QVBoxLayout(self._scroll_widget) self.checks = {} # Build initial sensor list self._populate_sensors() scroll.setWidget(self._scroll_widget) scroll.setWidgetResizable(True) l.addWidget(scroll) self.refresh() def _populate_sensors(self): """Populate the sensor checkboxes from all available sources.""" # Clear existing checkboxes self.checks.clear() while self._scroll_layout.count(): item = self._scroll_layout.takeAt(0) if item.widget(): item.widget().deleteLater() # Get all available sensors (built-in + plugin) all_sensors = get_all_available_sensors() # Group by category categories = {} for name, info in all_sensors.items(): cat = info.get('category', 'other') if cat not in categories: categories[cat] = {} categories[cat][name] = info # Add sensors grouped by category for cat_name in sorted(categories.keys()): cat_sensors = categories[cat_name] # Add category label if there are multiple categories if len(categories) > 1: cat_label = QLabel(f"── {cat_name.title()} ──") cat_label.setStyleSheet("color: #888; font-size: 10px;") self._scroll_layout.addWidget(cat_label) for name in sorted(cat_sensors.keys()): info = cat_sensors[name] # Create checkbox with plugin indicator display_name = name if info.get('plugin'): display_name = f"🔌 {name}" # Plugin indicator cb = QCheckBox(display_name) cb.setProperty('n', name) cb.stateChanged.connect(self.toggled) # Add tooltip with description tooltip = info.get('description', '') if info.get('plugin'): tooltip += f"\n[From plugin: {info['plugin']}]" if info.get('is_binary'): tooltip += "\n[Binary: 0 or 100]" if tooltip: cb.setToolTip(tooltip.strip()) self.checks[name] = cb self._scroll_layout.addWidget(cb) # Add stretch at end self._scroll_layout.addStretch() def rebuild_sensor_list(self): """Rebuild the sensor list to pick up newly registered plugin sensors.""" self._populate_sensors() self.refresh() def refresh(self): """Update checkbox states based on current design.""" current = set(self.design.get_sensors_in_design()) for name, cb in self.checks.items(): cb.blockSignals(True) cb.setChecked(name in current) cb.blockSignals(False) def toggled(self, state): name = self.sender().property('n') if state: self.design.add_sensor(name) else: self.design.remove_neuron(name) self.sensorsChanged.emit() class ConnectionsTable(QWidget): connectionChanged = pyqtSignal() def __init__(self, design, parent=None): super().__init__(parent) self.design = design l = QVBoxLayout(self) self.table = QTableWidget() self.table.setColumnCount(3) self.table.setHorizontalHeaderLabels(["Source", "Target", "Weight"]) self.table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) l.addWidget(self.table) self.refresh() def refresh(self): self.table.setRowCount(len(self.design.connections)) for i, c in enumerate(self.design.connections): self.table.setItem(i, 0, QTableWidgetItem(c.source)) self.table.setItem(i, 1, QTableWidgetItem(c.target)) self.table.setItem(i, 2, QTableWidgetItem(str(c.weight))) ================================================ FILE: src/designer_sensor_discovery.py ================================================ # designer_sensor_discovery.py """ Utility module for discovering available input sensors. This module provides functions to merge built-in sensors from designer_constants with plugin-registered custom sensors from the plugin manager. """ from typing import Dict, Any, Optional # Try to import designer constants try: from .designer_constants import INPUT_SENSORS, REQUIRED_NEURONS, BINARY_NEURONS except ImportError: # Fallbacks if constants file is missing or structure is different INPUT_SENSORS = {} REQUIRED_NEURONS = {} BINARY_NEURONS = set() # Localization helper try: from localization import tr except ImportError: def tr(key, **kwargs): return key def get_plugin_manager() -> Optional[Any]: """ Try to get the PluginManager singleton instance. Returns: PluginManager instance or None if not available """ try: from plugin_manager import PluginManager pm = PluginManager() if hasattr(pm, '_initialized') and pm._initialized: return pm except ImportError: pass except Exception: pass return None def get_builtin_sensors() -> Dict[str, Dict]: """ Get all built-in sensors from designer_constants. Returns: Dict mapping sensor names to their info dicts """ sensors = {} # Add INPUT_SENSORS # NOTE: In brain_constants, values are (x, y) tuples, not dicts. for name, info in INPUT_SENSORS.items(): # Default values description = tr("desc_builtin_sensor").format(name=name.replace('_', ' ').title()) is_binary = name in BINARY_NEURONS category = tr("desc_builtin") default_connections = [] # Handle case where info is a dict (future compatibility) vs tuple (current) if isinstance(info, dict): description = info.get('description', description) is_binary = info.get('is_binary', is_binary) category = info.get('category', category) default_connections = info.get('default_connections', default_connections) sensors[name] = { 'description': description, 'is_binary': is_binary, 'category': category, 'plugin': None, # Built-in, not from a plugin 'default_connections': default_connections } # Add can_see_food from REQUIRED_NEURONS if not already present if 'can_see_food' not in sensors and 'can_see_food' in REQUIRED_NEURONS: # Check if REQUIRED_NEURONS uses dicts or tuples info = REQUIRED_NEURONS['can_see_food'] description = tr("desc_vision_food") default_connections = [] if isinstance(info, dict): description = info.get('description', description) default_connections = info.get('default_connections', default_connections) sensors['can_see_food'] = { 'description': description, 'is_binary': True, 'category': tr("desc_vision"), 'plugin': None, 'default_connections': default_connections } return sensors def get_plugin_sensors() -> Dict[str, Dict]: """ Get all custom sensors registered by plugins. Returns: Dict mapping sensor names to their info dicts """ pm = get_plugin_manager() if not pm or not hasattr(pm, 'get_all_neuron_handler_info'): return {} sensors = {} try: for name, data in pm.get_all_neuron_handler_info().items(): metadata = data.get('metadata', {}) plugin_name = data.get("plugin", "unknown") default_desc = tr("desc_custom_sensor").format(plugin=plugin_name) sensors[name] = { 'description': metadata.get('description', default_desc), 'is_binary': metadata.get('is_binary', False), 'category': metadata.get('category', tr("desc_plugin")), 'plugin': data.get('plugin'), 'default_connections': metadata.get('default_connections', []) } except Exception as e: print(f"[Designer] Error getting plugin sensors: {e}") return {} return sensors def get_all_available_sensors() -> Dict[str, Dict]: """ Get all available sensors (built-in + plugin-registered). Returns: Dict mapping sensor names to their info dicts. Each info dict contains: - description: Human-readable description - is_binary: Whether the sensor outputs only 0 or 100 - category: Category for grouping - plugin: Plugin name if custom, None if built-in - default_connections: Suggested default connections """ sensors = get_builtin_sensors() # Merge plugin sensors (plugin sensors can override built-ins) plugin_sensors = get_plugin_sensors() for name, info in plugin_sensors.items(): if name in sensors: # Plugin is overriding a built-in sensor info['_overrides_builtin'] = True sensors[name] = info return sensors def get_sensors_by_category() -> Dict[str, Dict[str, Dict]]: """ Get all sensors organized by category. Returns: Dict mapping category names to dicts of sensors in that category """ sensors = get_all_available_sensors() by_category = {} for name, info in sensors.items(): category = info.get('category', tr("desc_other")) if category not in by_category: by_category[category] = {} by_category[category][name] = info return by_category def refresh_plugin_sensors(): """ Force refresh of plugin sensors. Call this after plugins are loaded/enabled to update the available sensors. """ # Currently a no-op since we query the plugin manager dynamically, # but could be used for caching in the future pass # Convenience function for checking if a sensor is from a plugin def is_plugin_sensor(sensor_name: str) -> bool: """Check if a sensor is from a plugin rather than built-in.""" sensors = get_all_available_sensors() if sensor_name in sensors: return sensors[sensor_name].get('plugin') is not None return False ================================================ FILE: src/designer_templates.py ================================================ from .designer_core import BrainDesign, DesignerLayer, DesignerNeuron from .designer_constants import NeuronType, REQUIRED_NEURONS, CORE_NEURONS, INPUT_SENSORS, DEFAULT_LAYER_SPACING import random class TemplateManager: @staticmethod def get_templates() -> dict: return { 'core_only': {'name': '🟡 Required Only', 'description': '8 required neurons'}, 'dosidicus_default': {'name': '🟡 Dosidicus Default', 'description': 'Standard layout'}, 'full_sensors': {'name': '🟡 Full Sensor Suite', 'description': 'All sensors'}, 'insomniac': {'name': '🔴 The Insomniac', 'description': 'Anxiety & Curiosity block sleep'}, 'hyperactive': {'name': '🔴 The Hyperactive', 'description': 'Noise neurons overwhelm sleepiness'}, 'hangry': {'name': '🔴 The Hangry', 'description': 'Hunger causes extreme rage'}, 'depressive': {'name': '🔴 The Depressive', 'description': 'Resistant to happiness'}, 'obsessive': {'name': '🔴 The Obsessive', 'description': 'Anxiety/Curiosity feedback loop'}, } @staticmethod def create_template(key: str) -> BrainDesign: design = BrainDesign() # ========================================== # STANDARD TEMPLATES # ========================================== if key == 'core_only': design.layers = [DesignerLayer("Sensors", NeuronType.INPUT, 100), DesignerLayer("Core", NeuronType.HIDDEN, 250)] design.add_missing_required_neurons() elif key == 'full_sensors': design.layers = [DesignerLayer("Input", NeuronType.INPUT, 50), DesignerLayer("Core", NeuronType.HIDDEN, 200), DesignerLayer("Out", NeuronType.OUTPUT, 350)] design.add_missing_required_neurons() design.add_all_sensors() # ========================================== # PATHOLOGICAL / SPECIFIC BEHAVIORS # ========================================== elif key == 'insomniac': # A brain where active states aggressively inhibit sleep design.layers = [DesignerLayer("Sensors", NeuronType.INPUT, 150), DesignerLayer("Racing Mind", NeuronType.HIDDEN, 200), DesignerLayer("State", NeuronType.OUTPUT, 350)] design.add_missing_required_neurons() # The Insomniac Logic: # Any active emotion makes it impossible to sleep (Strong Inhibition) design.add_connection("anxiety", "sleepiness", -0.95) design.add_connection("curiosity", "sleepiness", -0.80) design.add_connection("hunger", "sleepiness", -0.50) design.add_connection("can_see_food", "sleepiness", -0.50) # Weak positive feedback means once awake, they stay awake design.add_connection("sleepiness", "anxiety", 0.2) # Being tired makes them anxious elif key == 'hyperactive': # A brain with a "Noise" layer that floods the system design.layers = [ DesignerLayer("Vision", NeuronType.INPUT, 150), DesignerLayer("Core", NeuronType.HIDDEN, 200), DesignerLayer("Noise", NeuronType.HIDDEN, 300), # Extra space for noise neurons DesignerLayer("Output", NeuronType.OUTPUT, 400) ] design.add_missing_required_neurons() # Create 12 "Noise" neurons that act as a distraction generator noise_neurons = [f"noise_{i}" for i in range(12)] for n in noise_neurons: design.add_neuron(n, "Noise") # The "Overwhelm" Logic: # These neurons excite sleepiness (crash) and curiosity (distraction) # Randomize weights slightly so they aren't identical w_sleep = random.uniform(0.6, 0.9) w_curiosity = random.uniform(0.5, 0.8) design.add_connection(n, "sleepiness", w_sleep) # Floods sleep buffer design.add_connection(n, "curiosity", w_curiosity) # Forces erratic attention # Connect noise to itself for chaotic sustainability if random.random() > 0.5: target = random.choice(noise_neurons) design.add_connection(n, target, 0.4) elif key == 'hangry': # Metabolic mood disorder design.layers = [DesignerLayer("Sensors", NeuronType.INPUT, 100), DesignerLayer("Gut-Brain", NeuronType.HIDDEN, 200)] design.add_missing_required_neurons() # Hunger overrides all positive emotions and triggers anxiety/stress design.add_connection("hunger", "happiness", -1.0) # Cannot be happy if hungry design.add_connection("hunger", "satisfaction", -1.0) design.add_connection("hunger", "anxiety", 0.9) # Extreme stress when hungry design.add_connection("can_see_food", "anxiety", 0.5) # Seeing food but not eating it is stressful elif key == 'depressive': # Anhedonia model: High resistance to positive weights design.layers = [DesignerLayer("Input", NeuronType.INPUT, 150), DesignerLayer("Gray", NeuronType.HIDDEN, 200)] design.add_missing_required_neurons() # Hard to get happy, easy to get sad design.add_connection("satisfaction", "happiness", 0.1) # Drastically reduced reward design.add_connection("curiosity", "happiness", 0.0) # No joy from exploration design.add_connection("anxiety", "happiness", -0.8) # Anxiety kills happiness easily design.add_connection("sleepiness", "happiness", -0.6) elif key == 'obsessive': # Feedback loop central design.layers = [DesignerLayer("Input", NeuronType.INPUT, 150), DesignerLayer("Loop", NeuronType.HIDDEN, 200)] design.add_missing_required_neurons() # Tight feedback loop between anxiety and curiosity (Worrying/Checking) design.add_connection("anxiety", "curiosity", 0.7) # Worry makes you check things design.add_connection("curiosity", "anxiety", 0.5) # Checking things makes you worry design.add_connection("sleepiness", "anxiety", 0.4) # Being tired makes the loops worse else: # Default Dosidicus design.layers = [DesignerLayer("Vision", NeuronType.INPUT, 200), DesignerLayer("Stats", NeuronType.HIDDEN, 81), DesignerLayer("Emotions", NeuronType.OUTPUT, 385)] design.add_missing_required_neurons() # Default connections design.add_connection("can_see_food", "hunger", 0.3) design.add_connection("can_see_food", "happiness", 0.2) design.add_connection("hunger", "satisfaction", -0.5) return design ================================================ FILE: src/designer_window.py ================================================ """ Brain Designer Window - Main application window for designing custom neural networks. This module provides a visual editor for creating and editing squid brain configurations including neurons, connections, layers, sensors, and output bindings. """ import sys from PyQt5.QtWidgets import ( QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QTabWidget, QStatusBar, QAction, QToolBar, QLabel, QComboBox, QPushButton, QMessageBox, QFileDialog, QDialog, QInputDialog, QFrame, QSizePolicy, QToolButton, QMenu, QSplitter ) from PyQt5.QtGui import ( QKeySequence, QIcon, QFont, QFontMetrics, QPainter, QColor, QPalette, QTextDocument, QAbstractTextDocumentLayout ) # [FIX] Added QRectF to imports from PyQt5.QtCore import Qt, QTimer, QPropertyAnimation, QRect, QRectF, QSizeF, pyqtSignal from .designer_logging import get_logger, log_exceptions, safe_call, OperationLogger from .designer_core import BrainDesign from .designer_canvas import BrainCanvas # Import brain state bridge for game communication try: from .brain_state_bridge import ( is_game_running, import_brain_state_for_designer, convert_to_brain_design, export_design_to_game ) _HAS_BRAIN_BRIDGE = True except ImportError: _HAS_BRAIN_BRIDGE = False print("[BrainDesigner] Warning: brain_state_bridge not found, live import disabled") from .designer_panels import ( LayersPanel, SensorsPanel, NeuronPropertiesPanel, ConnectionsTable, AddNeuronDialog ) from .designer_templates import TemplateManager from .designer_dialogs import SparseNetworkDialog, ActivationEditorDialog from .designer_network_generator import SparseNetworkGenerator # Import the new outputs panel try: from .designer_outputs_panel import NeuronOutputsPanel _HAS_OUTPUTS_PANEL = True except ImportError: _HAS_OUTPUTS_PANEL = False print("[BrainDesigner] Warning: designer_outputs_panel not found, outputs tab disabled") class ScrollingTicker(QWidget): """A widget that smoothly scrolls rich text (HTML) horizontally.""" def __init__(self, text, parent=None): super().__init__(parent) self.text = text self.offset = 0 # Prepare text document for HTML rendering self.doc = QTextDocument() self.doc.setDefaultFont(self.font()) self.doc.setHtml(self.text) self.doc.setTextWidth(-1) # No wrap # Calculate dimensions self.text_width = self.doc.idealWidth() self.text_height = self.doc.size().height() # FIX: Call method with () # Separator self.sep_doc = QTextDocument() self.sep_doc.setDefaultFont(self.font()) self.sep_doc.setHtml("") self.sep_doc.setTextWidth(-1) self.sep_width = self.sep_doc.idealWidth() # Timer - don't start it yet self.timer = QTimer(self) self.timer.timeout.connect(self.scroll_text) self.timer.setInterval(30) # ~33fps # Style self.setStyleSheet("background-color: transparent;") self.setFixedHeight(30) def showEvent(self, event): """Start timer when widget becomes visible.""" super().showEvent(event) # Start timer on first show (prevents premature updates) if not self.timer.isActive(): self.timer.start() def hideEvent(self, event): """Stop timer when widget becomes hidden.""" super().hideEvent(event) self.timer.stop() def scroll_text(self): """Update scroll offset and trigger repaint.""" self.offset -= 1 if self.offset <= -(self.text_width + self.sep_width): self.offset = 0 self.update() def paintEvent(self, event): """Paint the scrolling text with proper painter lifecycle.""" painter = QPainter(self) # Ensure painter is valid before proceeding if not painter.isActive(): return # Removed the try...finally block - Qt handles painter lifecycle automatically painter.setRenderHint(QPainter.Antialiasing) # Center vertically y_pos = (self.height() - self.text_height) / 2 # Draw copies of the text until we fill the width current_x = self.offset while current_x < self.width(): # Draw Main Text painter.save() painter.translate(current_x, y_pos) self.doc.drawContents(painter, QRectF(0, 0, self.text_width, self.text_height)) painter.restore() current_x += self.text_width # Draw Separator painter.save() painter.translate(current_x, y_pos) self.sep_doc.drawContents(painter, QRectF(0, 0, self.sep_width, self.text_height)) painter.restore() current_x += self.sep_width # REMOVED: No need for finally block or manual painter.end() class BrainDesignerWindow(QMainWindow): """Main window for the Brain Designer application.""" # Signal to notify parent when user wants to exit/return exitRequested = pyqtSignal() def __init__(self, parent=None, embedded_mode=False): super().__init__(parent) self.logger = get_logger("brain_designer.window") self.logger.info("Initializing BrainDesignerWindow") self.embedded_mode = embedded_mode try: self.design = BrainDesign() self.design.add_missing_required_neurons() self.setWindowTitle("Brain Designer - Dosidicus-2") self.setMinimumSize(1280, 900) with OperationLogger("Setting up UI", self.logger): self.setup_ui() with OperationLogger("Setting up menus", self.logger): self.setup_menus() with OperationLogger("Setting up toolbar", self.logger): self.setup_toolbar() # Only auto-generate if NOT embedded (embedded mode will load existing state) if not self.embedded_mode: with OperationLogger("Initializing brain network", self.logger): self._imported_from_game = False if not self._try_import_from_game(): # No game running, generate random network self.generate_initial_network() # Show import notification if applicable if self._imported_from_game: self._show_import_notification() # Force UI refresh self.refresh_all() self.update_status() self.logger.info("BrainDesignerWindow initialized successfully") except Exception as e: self.logger.critical(f"Failed to initialize window: {e}", exc_info=True) raise # ========================================================================== # INITIALIZATION AND SETUP # ========================================================================== def setup_ui(self): """Setup the main UI layout.""" # Main splitter layout self.splitter = QSplitter(Qt.Horizontal) self.setCentralWidget(self.splitter) # === LEFT/CENTER: CANVAS AREA === canvas_wrapper = QWidget() canvas_wrapper.setMinimumWidth(500) canvas_container = QVBoxLayout(canvas_wrapper) canvas_container.setContentsMargins(0, 0, 0, 0) canvas_container.setSpacing(5) # Canvas toolbar canvas_toolbar = self.create_canvas_toolbar() canvas_container.addWidget(canvas_toolbar) # Main canvas self.canvas = BrainCanvas(self.design) self.canvas.neuronSelected.connect(self.on_neuron_selected) self.canvas.connectionCreated.connect(self.on_connection_created) self.canvas.connectionSelected.connect(self.on_connection_selected) self.canvas.weightChanged.connect(self.on_weight_changed) self.canvas.connectionDeleted.connect(self.on_connection_deleted) canvas_container.addWidget(self.canvas) # Canvas help bar help_bar = self.create_help_bar() canvas_container.addWidget(help_bar) self.splitter.addWidget(canvas_wrapper) # === RIGHT PANEL (Tabbed Panels) === self.right_panel = QTabWidget() self.right_panel.setTabPosition(QTabWidget.West) # Vertical tabs on the left edge self.right_panel.setMinimumWidth(350) # 1. Layers Panel self.layers_panel = LayersPanel(self.design) self.layers_panel.layersChanged.connect(self.on_design_changed) self.right_panel.addTab(self.layers_panel, "Layers") # 2. Sensors Panel (Input neurons) self.sensors_panel = SensorsPanel(self.design) self.sensors_panel.sensorsChanged.connect(self.on_design_changed) self.right_panel.addTab(self.sensors_panel, "Sensors") # 3. Properties Panel self.props_panel = NeuronPropertiesPanel(self.design) self.props_panel.neuronChanged.connect(self.on_design_changed) self.right_panel.addTab(self.props_panel, "Properties") # 4. Connections Table self.connections_table = ConnectionsTable(self.design) self.right_panel.addTab(self.connections_table, "Connections") # 5. Outputs Panel (Actuator neurons) - NEW! if _HAS_OUTPUTS_PANEL: self.outputs_panel = NeuronOutputsPanel(self.design) self.outputs_panel.outputsChanged.connect(self.on_design_changed) self.right_panel.addTab(self.outputs_panel, "Outputs") else: self.outputs_panel = None self.splitter.addWidget(self.right_panel) # Set Sensors as the default tab (index 1) self.right_panel.setCurrentIndex(1) # Set initial splitter sizes (approx 70% canvas, 30% sidebar) self.splitter.setCollapsible(0, False) self.splitter.setCollapsible(1, False) self.splitter.setStretchFactor(0, 1) self.splitter.setStretchFactor(1, 0) self.splitter.setSizes([850, 400]) # Status bar self.status_bar = QStatusBar() self.setStatusBar(self.status_bar) def create_canvas_toolbar(self) -> QFrame: """Create the toolbar above the canvas.""" toolbar = QFrame() toolbar.setFrameStyle(QFrame.StyledPanel) toolbar.setMaximumHeight(45) layout = QHBoxLayout(toolbar) layout.setContentsMargins(8, 4, 8, 4) # Generate button (prominent) generate_btn = QPushButton("🎲 Generate") generate_btn.setToolTip("Generate random connections between core neurons") generate_btn.setStyleSheet(""" QPushButton { background-color: #2196F3; color: white; font-weight: bold; padding: 6px 16px; border-radius: 4px; } QPushButton:hover { background-color: #1976D2; } """) generate_btn.clicked.connect(self.show_sparse_network_dialog) layout.addWidget(generate_btn) # Quick generate menu quick_gen_btn = QToolButton() quick_gen_btn.setText("▼") quick_gen_btn.setPopupMode(QToolButton.InstantPopup) quick_menu = QMenu(quick_gen_btn) generator = SparseNetworkGenerator() for key, info in generator.get_preset_styles().items(): action = quick_menu.addAction(f"{info['name']} - {info['description']}") action.setData(key) action.triggered.connect(lambda checked, k=key: self.quick_generate(k)) quick_gen_btn.setMenu(quick_menu) layout.addWidget(quick_gen_btn) # Quick dice button - instant random generation dice_btn = QPushButton("🎲") dice_btn.setToolTip("Instantly shuffle positions and generate a chaotic network (no dialog)") dice_btn.setFixedWidth(40) dice_btn.setStyleSheet(""" QPushButton { background-color: #FF9800; color: white; font-size: 18px; padding: 6px; border-radius: 4px; } QPushButton:hover { background-color: #F57C00; } """) dice_btn.clicked.connect(self.instant_random_generate) layout.addWidget(dice_btn) layout.addSpacing(20) # Divider divider = QFrame() divider.setFrameShape(QFrame.VLine) divider.setFrameShadow(QFrame.Sunken) layout.addWidget(divider) layout.addSpacing(10) # + Neuron button (colorful, prominent) add_neuron_btn = QPushButton("➕ Neuron") add_neuron_btn.setToolTip("Add a new neuron (Shift+N)") add_neuron_btn.setShortcut("Shift+N") add_neuron_btn.setStyleSheet(""" QPushButton { background-color: #4CAF50; color: white; font-weight: bold; padding: 6px 14px; border-radius: 4px; } QPushButton:hover { background-color: #43A047; } """) add_neuron_btn.clicked.connect(self.show_add_neuron_dialog) layout.addWidget(add_neuron_btn) layout.addSpacing(10) # Auto-fix button fix_btn = QPushButton("🔧 Auto-Fix") fix_btn.setToolTip("Automatically fix orphan neurons and connectivity issues") fix_btn.clicked.connect(self.run_auto_fix) #layout.addWidget(fix_btn) # Validate button validate_btn = QPushButton("✓ Validate") validate_btn.setToolTip("Check design for issues") validate_btn.clicked.connect(self.check_status) #layout.addWidget(validate_btn) # Placeholder for sync button - will be added later if game is detected self._sync_btn_container = QFrame() self._sync_btn_layout = QHBoxLayout(self._sync_btn_container) self._sync_btn_layout.setContentsMargins(0, 0, 0, 0) self._sync_btn_container.hide() # Hidden by default layout.addWidget(self._sync_btn_container) # Add RETURN button if embedded if self.embedded_mode: layout.addSpacing(20) return_btn = QPushButton("💾 Save & Return to Game") return_btn.setToolTip("Save changes and return to game view") return_btn.setStyleSheet(""" QPushButton { background-color: #673AB7; color: white; font-weight: bold; padding: 6px 14px; border-radius: 4px; } QPushButton:hover { background-color: #5E35B1; } """) return_btn.clicked.connect(self.exitRequested.emit) layout.addWidget(return_btn) layout.addStretch() # Clear connections button clear_btn = QPushButton("🗑 Clear Connections") clear_btn.setToolTip("Remove all connections (keeps neurons)") clear_btn.setStyleSheet("color: #d32f2f;") clear_btn.clicked.connect(self.clear_all_connections) layout.addWidget(clear_btn) return toolbar def create_help_bar(self) -> QFrame: """Create the help bar below the canvas with scrolling ticker.""" help_bar = QFrame() help_bar.setFrameStyle(QFrame.StyledPanel) help_bar.setMaximumHeight(30) layout = QHBoxLayout(help_bar) layout.setContentsMargins(0, 0, 0, 0) # Create scrolling ticker with comprehensive shortcuts self.help_ticker = ScrollingTicker( "💡 Left-Drag from neuron to create connection " " Ctrl+Drag neuron to move it " " Right-Drag to pan canvas " " Scroll Wheel to zoom (or adjust weight on connection) " " Double-Click connection to edit weight " " Click neuron/connection to select " " Del to delete selected " " Space to reverse connection direction " " +/- keys to adjust weight (Shift for larger steps) " " Page Up/Down to adjust weight (large steps) " " Shift+N to add neuron " " Ctrl+S to save " " Ctrl+O to open " " Ctrl+E to export " " Ctrl+N for new design " " Ctrl+G to generate network " "🎲 Dice button for instant chaotic shuffle & generation " "Outputs tab to bind neurons to squid behaviors " ) layout.addWidget(self.help_ticker) return help_bar def setup_menus(self): """Setup the menu bar.""" # In embedded mode, menus might not show up if parent is not main window. # But for QMainWindow they usually work. menu = self.menuBar() # File menu file_menu = menu.addMenu("File") if self.embedded_mode: return_action = QAction("Save & Return to Game", self) return_action.setShortcut("Ctrl+Return") return_action.triggered.connect(self.exitRequested.emit) file_menu.addAction(return_action) file_menu.addSeparator() new_action = QAction("New Design", self) new_action.setShortcut("Ctrl+N") new_action.triggered.connect(self.new_design) file_menu.addAction(new_action) file_menu.addSeparator() save_action = QAction("Save...", self) save_action.setShortcut("Ctrl+S") save_action.triggered.connect(self.save_design) #file_menu.addAction(save_action) export_action = QAction("Export for Dosidicus...", self) export_action.setShortcut("Ctrl+E") export_action.triggered.connect(self.export_design) file_menu.addAction(export_action) file_menu.addSeparator() open_action = QAction("Open...", self) open_action.setShortcut("Ctrl+O") open_action.triggered.connect(self.open_design) file_menu.addAction(open_action) # Edit menu edit_menu = menu.addMenu("Edit") generate_action = QAction("Generate Network...", self) generate_action.setShortcut("Ctrl+G") generate_action.triggered.connect(self.show_sparse_network_dialog) edit_menu.addAction(generate_action) edit_menu.addSeparator() auto_fix = QAction("Auto-Fix Connectivity", self) auto_fix.triggered.connect(self.run_auto_fix) edit_menu.addAction(auto_fix) validate_action = QAction("Validate Design", self) validate_action.triggered.connect(self.check_status) #edit_menu.addAction(validate_action) edit_menu.addSeparator() clear_conn_action = QAction("Clear All", self) clear_conn_action.triggered.connect(self.clear_all_connections) edit_menu.addAction(clear_conn_action) # Clear outputs action if _HAS_OUTPUTS_PANEL: clear_outputs_action = QAction("Clear all Bindings", self) clear_outputs_action.triggered.connect(self.clear_all_outputs) edit_menu.addAction(clear_outputs_action) # Templates menu tpl_menu = menu.addMenu("Templates") for key, info in TemplateManager.get_templates().items(): a = QAction(info['name'], self) a.setData(key) a.triggered.connect(self.load_template) tpl_menu.addAction(a) # Network generation presets gen_menu = menu.addMenu("Generate") gen_dialog_action = QAction("🎲 Generate...", self) gen_dialog_action.setShortcut("Ctrl+G") gen_dialog_action.triggered.connect(self.show_sparse_network_dialog) gen_menu.addAction(gen_dialog_action) gen_menu.addSeparator() generator = SparseNetworkGenerator() for key, info in generator.get_preset_styles().items(): action = QAction(f"{info['name']}", self) action.setToolTip(info['description']) action.setData(key) action.triggered.connect(lambda checked, k=key: self.quick_generate(k)) gen_menu.addAction(action) def setup_toolbar(self): """Setup the main toolbar.""" toolbar = QToolBar("Main") toolbar.setMovable(False) self.addToolBar(toolbar) # File actions toolbar.addAction("📂 Open", self.open_design) toolbar.addAction("💾 Save", self.save_design) toolbar.addSeparator() if _HAS_BRAIN_BRIDGE: push_action = QAction("🚀 Push to Game", self) push_action.setToolTip("Export current design directly to the running Dosidicus game") push_action.triggered.connect(self.push_to_game) toolbar.addAction(push_action) toolbar.addSeparator() # Template dropdown toolbar.addAction("📋 Templates", self.show_template_menu) def push_to_game(self): """Export current design, state, and bindings directly to the running game.""" if not _HAS_BRAIN_BRIDGE: return if not is_game_running(): QMessageBox.warning(self, "Game Not Found", "Dosidicus does not appear to be running.\nStart the game to push designs.") return try: # 1. Ensure bindings are up to date if self.outputs_panel: self.design.output_bindings = self.outputs_panel.export_bindings() # 2. Convert to the format the game expects data = self.design.to_dosidicus_format() # 3. Push via bridge if export_design_to_game(data): self.status_bar.showMessage("Design pushed to running game", 3000) self.tamagotchi_logic.show_message("Custom Brain was pushed from Designer") else: QMessageBox.warning(self, "Export Failed", "Could not write bridge file.") except Exception as e: self.logger.error(f"Push to game failed: {e}", exc_info=True) QMessageBox.critical(self, "Error", f"Failed to push design:\n{e}") # ========================================================================== # DATA LOADING AND CONVERSION # ========================================================================== def load_from_brain_widget_state(self, state): """ Load design from a brain widget state dictionary. Used for in-process switching with robust fallback. """ try: imported_design = None # Attempt 1: Try using the bridge if available try: from .brain_state_bridge import convert_to_brain_design imported_design = convert_to_brain_design(state) except ImportError: # Bridge not found, proceed to fallback pass # Attempt 2: Direct conversion using DesignerCore (Fallback) if imported_design is None: # If bridge failed or returned None, use the core method directly # This ensures we don't need the bridge to be active/present from .designer_core import BrainDesign # Try dosidicus format first (likely coming from BrainWidget) imported_design = BrainDesign.from_dosidicus_format(state) if imported_design is None: self.logger.warning("Could not convert state to BrainDesign") return False # Apply the design self.design = imported_design self.refresh_all() # Force canvas to center on the new nodes if hasattr(self, 'canvas'): self.canvas.center_on_neurons() self.logger.info( f"Loaded design from memory: {len(self.design.neurons)} neurons" ) return True except Exception as e: self.logger.error(f"Error loading from state: {e}", exc_info=True) return False def get_current_design_state(self): """ Get the current design as a state dictionary compatible with BrainWidget. """ try: # Sync output bindings first if self.outputs_panel: self.design.output_bindings = self.outputs_panel.export_bindings() # Export to dosidicus format (compatible with loader) return self.design.to_dosidicus_format() except Exception as e: self.logger.error(f"Error exporting state: {e}", exc_info=True) return None # ========================================================================== # REFRESH AND STATUS # ========================================================================== def refresh_all(self): """Refresh all panels with current design.""" self.canvas.design = self.design self.props_panel.design = self.design self.layers_panel.design = self.design self.sensors_panel.design = self.design self.connections_table.design = self.design # Refresh outputs panel and load bindings from design if self.outputs_panel: self.outputs_panel.design = self.design self.outputs_panel.load_bindings(self.design.output_bindings) self.on_design_changed() def update_status(self): """Update the status bar.""" stats = self.design.get_stats() # Build status message status_parts = [ f"Neurons: {stats['total_neurons']}", f"Connections: {stats['connections']}", f"Required: {'✓' if stats['has_all_required'] else '✗'}" ] # Add output bindings count if available if self.outputs_panel: binding_count = len(self.outputs_panel.bindings) if binding_count > 0: status_parts.append(f"Outputs: {binding_count}") self.status_bar.showMessage(" | ".join(status_parts)) # ========================================================================== # NETWORK GENERATION AND CLEARING # ========================================================================== def generate_initial_network(self): """Generate a random network on startup without user interaction.""" import random try: generator = SparseNetworkGenerator() # 50% chance of minimalist configuration (sparse, no feedback) # This creates a variety of startup experiences for the user if random.random() < 0.5: density = 0.5 include_feedback = False mode = "minimalist" else: density = 1.0 include_feedback = True mode = "balanced" count, _ = generator.generate_for_design( self.design, clear_existing=True, density=density, include_feedback=include_feedback, silent=True ) self.logger.debug(f"Generated initial network ({mode}) with {count} connections") except Exception as e: self.logger.warning(f"Could not generate initial network: {e}") def _try_import_from_game(self) -> bool: """ Attempt to import brain state from a running game instance. Returns: True if import was successful, False otherwise """ if not _HAS_BRAIN_BRIDGE: return False try: # Check if game is running if not is_game_running(): self.logger.debug("No running game detected, will generate random network") return False self.logger.info("Detected running game, attempting to import brain state...") # Import the brain state live_state = import_brain_state_for_designer() if live_state is None: self.logger.warning("Could not import brain state from game") return False # Convert to BrainDesign imported_design = convert_to_brain_design(live_state) if imported_design is None: self.logger.warning("Could not convert imported state to BrainDesign") return False # Replace current design with imported one self.design = imported_design # Update canvas reference if hasattr(self, 'canvas'): self.canvas.design = self.design self._imported_from_game = True self.logger.info( f"Successfully imported brain from game: " f"{len(self.design.neurons)} neurons, " f"{len(self.design.connections)} connections" ) return True except Exception as e: self.logger.error(f"Error importing from game: {e}", exc_info=True) return False def _show_import_notification(self): """Show notification that brain was imported from running game.""" from PyQt5.QtWidgets import QMessageBox from PyQt5.QtCore import QTimer # Show the sync button now that we know game is running self._show_sync_button() # Create a non-modal notification msg = QMessageBox(self) msg.setIcon(QMessageBox.Information) msg.setWindowTitle("Live Brain Import") msg.setText("🧠 Active brain imported from running game") msg.setInformativeText( f"The designer is now showing the exact neural network " f"from your running Dosidicus game.\n\n" f"• {len(self.design.neurons)} neurons\n" f"• {len(self.design.connections)} connections\n\n" f"Changes made here will NOT affect the running game." ) msg.setStandardButtons(QMessageBox.Ok) # Show and auto-close after 5 seconds msg.show() QTimer.singleShot(5000, msg.close) # Update window title to indicate imported state self.setWindowTitle("Brain Designer - Dosidicus-2 [Imported from Game]") # Update status bar self.status_bar.showMessage( "✨ Active brain imported from running game", 10000 ) def _show_sync_button(self): """Show the sync button when game connection is established.""" if not _HAS_BRAIN_BRIDGE: return # Clear any existing content while self._sync_btn_layout.count(): item = self._sync_btn_layout.takeAt(0) if item.widget(): item.widget().deleteLater() # Add divider divider = QFrame() divider.setFrameShape(QFrame.VLine) divider.setFrameShadow(QFrame.Sunken) self._sync_btn_layout.addWidget(divider) # Add sync button sync_btn = QPushButton("🔄 Sync from Game") sync_btn.setToolTip("Refresh brain state from running Dosidicus game") sync_btn.setStyleSheet(""" QPushButton { background-color: #9C27B0; color: white; font-weight: bold; padding: 6px 12px; border-radius: 4px; } QPushButton:hover { background-color: #7B1FA2; } """) sync_btn.clicked.connect(self.sync_from_running_game) self._sync_btn_layout.addWidget(sync_btn) # Show the container self._sync_btn_container.show() def sync_from_running_game(self): """ Manually sync brain state from running game. Called when user clicks the Sync from Game button. """ # Check if game is still running if not is_game_running(): QMessageBox.information( self, "Game Not Running", "The Dosidicus game is no longer running.\n\n" "Start the game again to sync." ) # Hide the sync button since game is gone self._sync_btn_container.hide() self.setWindowTitle("Brain Designer - Dosidicus-2") return # Confirm before replacing current design reply = QMessageBox.question( self, "Sync from Game", "Replace current design with the latest brain state from the game?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No ) if reply != QMessageBox.Yes: return # Try to import if self._try_import_from_game(): self.refresh_all() self.status_bar.showMessage( f"✨ Synced: {len(self.design.neurons)} neurons, " f"{len(self.design.connections)} connections", 5000 ) else: QMessageBox.warning( self, "Sync Failed", "Could not import brain state from game." ) def show_sparse_network_dialog(self): """Show the sparse network generation dialog.""" dialog = SparseNetworkDialog(self.design, self) if dialog.exec_() == QDialog.Accepted: self.on_design_changed() def quick_generate(self, style_key): """Quickly generate a network using a preset style.""" try: generator = SparseNetworkGenerator() presets = generator.get_preset_styles() if style_key not in presets: return preset = presets[style_key] # Determine dynamic bounds from the canvas view if available bounds = (-450, -250, 650, 500) if hasattr(self, 'canvas') and self.canvas.viewport(): try: view_rect = self.canvas.viewport().rect() scene_poly = self.canvas.mapToScene(view_rect) brect = scene_poly.boundingRect() bounds = (brect.left(), brect.top(), brect.right(), brect.bottom()) except Exception: pass count, connections = generator.generate_for_design( self.design, clear_existing=True, density=preset.get('density', 1.0), include_feedback=preset.get('include_feedback', False), position_variance=preset.get('position_variance', 0.2), sensor_probability=preset.get('sensor_probability', 0.0), bounds=bounds ) self.on_design_changed() self.status_bar.showMessage( f"Generated {count} connections using '{preset['name']}' preset", 3000 ) except Exception as e: self.logger.error(f"Error in quick_generate: {e}", exc_info=True) QMessageBox.warning(self, "Error", f"Generation failed: {e}") def instant_random_generate(self): """Instantly generate a random network without any dialog, shuffling positions.""" import random try: generator = SparseNetworkGenerator() presets = generator.get_preset_styles() # Pick a random preset as a base style_key = random.choice(list(presets.keys())) preset = presets[style_key] # Randomize connection parameters density = random.uniform(0.7, 1.4) # !! CHAOS MODE !! # Use high variance to shuffle neurons around the visible canvas position_variance = random.uniform(0.6, 1.0) # Calculate dynamic bounds from the actual visible canvas area if hasattr(self, 'canvas') and self.canvas.viewport(): try: view_rect = self.canvas.viewport().rect() scene_poly = self.canvas.mapToScene(view_rect) brect = scene_poly.boundingRect() bounds = (brect.left(), brect.top(), brect.right(), brect.bottom()) except Exception: # Fallback if calculation fails bounds = (-450, -250, 650, 500) else: bounds = (-450, -250, 650, 500) count, _ = generator.generate_for_design( self.design, clear_existing=True, density=density, include_feedback=random.random() > 0.3, position_variance=position_variance, bounds=bounds, sensor_probability=0.2 # Add some random sensors for flavor ) self.on_design_changed() # Ensure the canvas recenters on the new chaotic layout if self.canvas: self.canvas.center_on_neurons() self.status_bar.showMessage( f"🎲 Chaos! Shuffled positions & made {count} connections ({preset['name']} style)", 3000 ) except Exception as e: self.logger.error(f"Error in instant_random_generate: {e}", exc_info=True) def clear_all_connections(self): """Clear all connections from the design.""" reply = QMessageBox.question( self, "Clear Connections", f"Remove all {len(self.design.connections)} connections?\n\n" "Neurons will be kept.", QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: count = len(self.design.connections) self.design.connections.clear() self.on_design_changed() self.status_bar.showMessage(f"Cleared {count} connections", 3000) def clear_all_outputs(self): """Clear all output bindings from the design.""" if not self.outputs_panel: return if not self.outputs_panel.bindings: QMessageBox.information(self, "Clear Outputs", "No output bindings to clear.") return reply = QMessageBox.question( self, "Clear Output Bindings", f"Remove all {len(self.outputs_panel.bindings)} output bindings?", QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: count = len(self.outputs_panel.bindings) self.outputs_panel.bindings.clear() self.outputs_panel.refresh() self.design.output_bindings = [] self.on_design_changed() self.status_bar.showMessage(f"Cleared {count} output bindings", 3000) # ========================================================================== # EVENT HANDLERS / CALLBACKS # ========================================================================== def on_design_changed(self): """Called when the design is modified.""" try: self.canvas.rebuild() self.connections_table.refresh() self.sensors_panel.refresh() self.layers_panel.refresh() # Refresh outputs panel if self.outputs_panel: self.outputs_panel.refresh() self.update_status() except Exception as e: self.logger.error(f"Error updating design view: {e}", exc_info=True) def on_neuron_selected(self, name): """Called when a neuron is selected on the canvas.""" self.props_panel.set_neuron(name) # Switch to properties tab if a neuron is selected if name: self.right_panel.setCurrentWidget(self.props_panel) def show_add_neuron_dialog(self, x=None, y=None): """Show dialog to add a new neuron.""" pos = (x, y) if x is not None else None dlg = AddNeuronDialog(self.design, pos, self) if dlg.exec_() == QDialog.Accepted: self.on_design_changed() def on_connection_created(self, source, target): """Called when a new connection is created via drag.""" weight, ok = QInputDialog.getDouble( self, "Connection Weight", f"Set weight for {source} → {target}:", 0.5, -1.0, 1.0, 2 ) if ok: conn = self.design.get_connection(source, target) if conn: conn.weight = weight self.on_design_changed() else: # Cancel: remove the connection self.design.remove_connection(source, target) self.on_design_changed() def on_connection_selected(self, source, target): """Called when a connection is selected.""" conn = self.design.get_connection(source, target) if conn: self.status_bar.showMessage( f"Selected: {source} → {target} (weight: {conn.weight:+.3f})" ) def on_weight_changed(self, source, target, new_weight): """Called when a connection weight is changed.""" self.connections_table.refresh() self.status_bar.showMessage( f"Weight updated: {source} → {target} = {new_weight:+.3f}", 2000 ) def on_connection_deleted(self, source, target): """Called when a connection is deleted.""" self.on_design_changed() self.status_bar.showMessage(f"Deleted connection: {source} → {target}", 2000) # ========================================================================== # FILE AND UTILITY OPERATIONS # ========================================================================== def new_design(self): """Create a new empty design.""" reply = QMessageBox.question( self, "New Design", "Start a new design? Unsaved changes will be lost.", QMessageBox.Yes | QMessageBox.No ) if reply == QMessageBox.Yes: self.design = BrainDesign() self.design.add_missing_required_neurons() self.refresh_all() def run_auto_fix(self): """Run auto-fix on the design.""" count, actions = self.design.auto_fix_connectivity() if count > 0: self.on_design_changed() # [FIX] Force immediate repaint so changes are visible behind the message box if self.canvas: self.canvas.viewport().repaint() QMessageBox.information( self, "Auto-Fix", f"Created {count} connections:\n\n" + "\n".join(actions[:10]) ) else: QMessageBox.information(self, "Auto-Fix", "No issues found.") def save_design(self): """Save the design to file.""" try: # Sync output bindings from panel to design BEFORE saving if self.outputs_panel: self.design.output_bindings = self.outputs_panel.export_bindings() path, _ = QFileDialog.getSaveFileName( self, "Save Design", "brain.json", "JSON (*.json)" ) if path: self.logger.info(f"Saving design to: {path}") success, msg = self.design.save(path) if success: self.logger.info(f"Design saved successfully: {msg}") # Add output binding info to message if self.outputs_panel and self.outputs_panel.bindings: msg += f"\n({len(self.outputs_panel.bindings)} output bindings included)" QMessageBox.information(self, "Saved", msg) else: self.logger.warning(f"Save failed: {msg}") QMessageBox.warning(self, "Error", msg) except Exception as e: self.logger.error(f"Error saving design: {e}", exc_info=True) QMessageBox.warning(self, "Error", f"Failed to save design:\n\n{e}") def export_design(self): """Export in Dosidicus format.""" try: # Sync output bindings from panel to design BEFORE exporting if self.outputs_panel: self.design.output_bindings = self.outputs_panel.export_bindings() path, _ = QFileDialog.getSaveFileName( self, "Export", "dosidicus_brain.json", "JSON (*.json)" ) if path: self.logger.info(f"Exporting design to: {path}") success, msg = self.design.export_dosidicus(path) if success: self.logger.info(f"Design exported successfully") # Add output binding info to message if self.outputs_panel and self.outputs_panel.bindings: msg += f"\n({len(self.outputs_panel.bindings)} output bindings included)" QMessageBox.information(self, "Exported", msg) else: self.logger.warning(f"Export failed: {msg}") QMessageBox.warning(self, "Error", msg) except Exception as e: self.logger.error(f"Error exporting design: {e}", exc_info=True) QMessageBox.warning(self, "Error", f"Failed to export design:\n\n{e}") def open_design(self): """Open a design file.""" try: path, _ = QFileDialog.getOpenFileName( self, "Open Design", "", "JSON (*.json)" ) if path: self.logger.info(f"Opening design from: {path}") self.design = BrainDesign.load(path) self.logger.info( f"Design loaded: {len(self.design.neurons)} neurons, " f"{len(self.design.connections)} connections" ) # Load output bindings into the panel if self.outputs_panel: self.outputs_panel.load_bindings(self.design.output_bindings) if self.design.output_bindings: self.logger.info(f"Loaded {len(self.design.output_bindings)} output bindings") self.refresh_all() except Exception as e: self.logger.error(f"Error opening design: {e}", exc_info=True) QMessageBox.warning(self, "Error", f"Could not load design:\n\n{e}") def show_template_menu(self): """Show templates as a popup.""" templates = TemplateManager.get_templates() items = [f"{info['name']} - {info['description']}" for info in templates.values()] keys = list(templates.keys()) item, ok = QInputDialog.getItem( self, "Load Template", "Select a template:", items, 0, False ) if ok and item: idx = items.index(item) key = keys[idx] if QMessageBox.question( self, "Load Template", "Replace current design?", QMessageBox.Yes | QMessageBox.No ) == QMessageBox.Yes: self.design = TemplateManager.create_template(key) self.refresh_all() def load_template(self): """Load a template from menu action.""" key = self.sender().data() if QMessageBox.question( self, "Load Template", "Replace current design?", QMessageBox.Yes | QMessageBox.No ) == QMessageBox.Yes: self.design = TemplateManager.create_template(key) self.refresh_all() def check_status(self): """Validate and show design status.""" self.on_design_changed() stats = self.design.get_stats() _, issues, _ = self.design.validate(auto_fix=False) msg = ( f"Neurons: {stats['total_neurons']}\n" f" • Required: {stats['required_neurons']}\n" f" • Sensors: {stats['sensor_neurons']}\n" f" • Custom: {stats['custom_neurons']}\n\n" f"Connections: {stats['connections']}\n" f"Layers: {stats['layers']}\n" ) # Add output binding info if self.outputs_panel: binding_count = len(self.outputs_panel.bindings) enabled_count = sum(1 for b in self.outputs_panel.bindings if b.enabled) msg += f"Output Bindings: {binding_count} ({enabled_count} enabled)\n" # Validate bindings binding_warnings = self.outputs_panel.validate_bindings() if binding_warnings: issues.extend(binding_warnings) if issues: msg += "\n⚠️ ISSUES:\n" + "\n".join(f" • {i}" for i in issues) else: msg += "\n✅ Status: OK" QMessageBox.information(self, "Design Status", msg) # ============================================================================= # MAIN ENTRY POINT # ============================================================================= def main(): """Main entry point for the Brain Designer application.""" from PyQt5.QtWidgets import QApplication app = QApplication(sys.argv) app.setApplicationName("Brain Designer (Beta)") app.setOrganizationName("Dosidicus") window = BrainDesignerWindow() window.show() sys.exit(app.exec_()) if __name__ == "__main__": main() ================================================ FILE: src/display_scaling.py ================================================ class DisplayScaling: DESIGN_WIDTH = 2880 DESIGN_HEIGHT = 1920 _scale_factor = 1.0 # Default scale factor (1.0 means no scaling) _scale_factor = 1.0 @classmethod def initialize(cls, current_width, current_height): width_ratio = current_width / cls.DESIGN_WIDTH height_ratio = current_height / cls.DESIGN_HEIGHT base_scale_factor = min(width_ratio, height_ratio) if current_width <= 1920 and current_height <= 1080: cls._scale_factor = base_scale_factor * 0.85 print(f"1080p display detected: applying 85% scaling (factor={cls._scale_factor:.2f})") else: cls._scale_factor = base_scale_factor print(f"High resolution display ({current_width}x{current_height}): standard scaling (factor={cls._scale_factor:.2f})") @classmethod def scale(cls, value): return int(value * cls._scale_factor) @classmethod def font_size(cls, size): scaled = cls.scale(size) return max(10, scaled) @classmethod def get_scale_factor(cls): return cls._scale_factor @classmethod def scale_css(cls, css_string): import re pattern = r'font-size:\s*(\d+)px' def replace_size(match): original_size = int(match.group(1)) scaled_size = cls.font_size(original_size) return f'font-size: {scaled_size}px' return re.sub(pattern, replace_size, css_string) ================================================ FILE: src/hidden_imports.txt ================================================ plugins.achievements.display_scaling plugins.achievements.achievements_data uuid brain_designer designer_window designer_core designer_constants designer_logging designer_dialogs brain_state_bridge ================================================ FILE: src/image_cache.py ================================================ from PyQt5 import QtGui class ImageCache: """Global image cache to prevent duplicate loading""" _cache = {} @classmethod def get_pixmap(cls, path): """Get a pixmap from cache or load it""" if path not in cls._cache: pixmap = QtGui.QPixmap(path) cls._cache[path] = pixmap return cls._cache[path] @classmethod def clear(cls): """Clear cache to free memory""" cls._cache.clear() ================================================ FILE: src/interactions.py ================================================ # ROCK INTERACTIONS import math import time import random import os from PyQt5 import QtCore, QtWidgets, QtGui class RockInteractionManager: def __init__(self, squid, logic, scene, message_callback, config_manager): self.squid = squid self.logic = logic self.scene = scene self.show_message = message_callback self.config_manager = config_manager self.rock_config = config_manager.get_rock_config() # Rock interaction state self.target_rock = None self.rock_test_phase = 0 # 0=approach, 1=carry, 2=throw self.rock_carry_time = 0 self.rock_carry_duration = 0 # Initialize timers self.rock_test_timer = QtCore.QTimer() self.throw_animation_timer = QtCore.QTimer() # Connect timers self.rock_test_timer.timeout.connect(self.update_rock_test) self.throw_animation_timer.timeout.connect(self.update_throw_animation) # Initialize throw velocity variables self.throw_velocity_x = 0 self.throw_velocity_y = 0 # Add multiplayer support self.multiplayer_plugin = None self.setup_multiplayer_integration() def setup_multiplayer_integration(self): """Set up hooks for multiplayer integration""" if not hasattr(self.logic, 'plugin_manager'): return False # Get multiplayer plugin multiplayer_plugin = None for plugin_name, plugin_data in self.logic.plugin_manager.plugins.items(): if plugin_name == "multiplayer_plugin": # Get the actual plugin instance multiplayer_plugin = plugin_data.get('instance') break if multiplayer_plugin: # Store reference self.multiplayer_plugin = multiplayer_plugin print("[RockInteraction] Successfully integrated with multiplayer plugin") return True return False def is_valid_rock(self, item): """Check if item is a valid rock""" if not isinstance(item, QtWidgets.QGraphicsPixmapItem): return False return (hasattr(item, 'category') and item.category == 'rock') or \ (hasattr(item, 'filename') and 'rock' in item.filename.lower()) def can_pick_up_rock(self, rock): """Check if squid can pick up this rock""" if not self.is_valid_rock(rock): return False if hasattr(rock, 'is_being_carried') and rock.is_being_carried: return False # Check if rock is in cooldown after being thrown if hasattr(rock, 'throw_cooldown_until'): if time.time() < rock.throw_cooldown_until: return False return True def attach_rock_to_squid(self, rock): """Visually attach rock to squid at tentacle position""" rock.setParentItem(self.squid.squid_item) # Use config values for hold duration config = self.rock_config self.squid.rock_hold_duration = random.uniform( config['min_carry_duration'], config['max_carry_duration'] ) self.squid.rock_hold_start_time = time.time() self.squid.rock_decision_made = False offset = -50 # Both vertical and horizontal offset # Calculate position based on squid direction if self.squid.squid_direction == "right": # Position rock near right tentacles rock.setPos(self.squid.squid_width - 40 + offset, self.squid.squid_height - 30 + offset) elif self.squid.squid_direction == "left": # Position rock near left tentacles rock.setPos(10 + offset, self.squid.squid_height - 30 + offset) elif self.squid.squid_direction == "up": # Position rock near upper tentacles rock.setPos(self.squid.squid_width//2 - 15 + offset, self.squid.squid_height - 40 + offset) else: # down/default rock.setPos(self.squid.squid_width//2 - 15 + offset, self.squid.squid_height - 20 + offset) rock.is_being_carried = True self.squid.is_carrying_rock = True self.squid.carried_rock = rock rock.setZValue(self.squid.squid_item.zValue() + 1) # Scale rock to appropriate size rock.setScale(1.0) # Update squid status self.squid.status = "carrying rock" return True def check_rock_hold_time(self): """Check if holding time elapsed and make decision""" if not hasattr(self.squid, 'carrying_rock') or not self.squid.carrying_rock or not hasattr(self.squid, 'rock_decision_made') or self.squid.rock_decision_made: return current_time = time.time() if current_time - self.squid.rock_hold_start_time >= self.squid.rock_hold_duration: self.squid.rock_decision_made = True self.decide_rock_action() def decide_rock_action(self): """Randomly decide to throw or drop the rock""" if random.random() < 0.7: # 70% chance to throw direction = "right" if random.random() < 0.5 else "left" self.throw_rock(direction) if self.show_message: self.show_message("Squid threw the rock!") else: # 30% chance to drop self.drop_rock() if self.show_message: self.show_message("Squid dropped the rock") def drop_rock(self): """Gently place the rock below the squid""" if not hasattr(self.squid, 'carrying_rock') or not self.squid.carrying_rock or not hasattr(self.squid, 'carried_rock') or not self.squid.carried_rock: return rock = self.squid.carried_rock rock.setParentItem(None) rock.setPos( self.squid.squid_x + self.squid.squid_width//2 - rock.boundingRect().width()//2, self.squid.squid_y + self.squid.squid_height + 10 ) self.squid.is_carrying_rock = False self.squid.carried_rock = None def start_rock_test(self, rock=None): """Start test with guaranteed clean state and random carry duration""" self.cleanup() # Reset everything first if rock is None: # Filter rocks that are valid, visible, and not on cooldown rocks = [item for item in self.scene.items() if self.is_valid_rock(item) and item.isVisible() and self.can_pick_up_rock(item)] if not rocks: if self.show_message: self.show_message("No available rocks!") return False rock = min(rocks, key=lambda r: math.hypot( r.sceneBoundingRect().center().x() - self.squid.squid_x, r.sceneBoundingRect().center().y() - self.squid.squid_y )) elif not self.can_pick_up_rock(rock): if self.show_message: self.show_message("Rock is on cooldown!") return False self.target_rock = rock self.rock_test_phase = 0 # Use the config values for duration self.rock_carry_duration = random.uniform( self.rock_config['min_carry_duration'], self.rock_config['max_carry_duration'] ) self.rock_carry_time = 0 # Reset carry timer self.rock_test_timer.start(100) # 100ms updates return True def highlight_rock(self, rock): """Visual feedback for selected rock""" highlight = QtWidgets.QGraphicsOpacityEffect() highlight.setOpacity(0.3) # Initial opacity rock.setGraphicsEffect(highlight) # Animate highlight self.animate_highlight(highlight) def animate_highlight(self, highlight_effect): """Pulse animation for rock highlight""" self.highlight_animation = QtCore.QPropertyAnimation(highlight_effect, b"opacity") self.highlight_animation.setDuration(1000) self.highlight_animation.setStartValue(0.3) self.highlight_animation.setEndValue(0.8) self.highlight_animation.setLoopCount(3) self.highlight_animation.start() def throw_rock(self, direction="right"): """Initiates a rock throw with positive memory formation""" # Prevent multiple throws if self.throw_animation_timer.isActive(): return False if not hasattr(self.squid, 'carried_rock') or not self.squid.carried_rock: return False config = self.rock_config rock = self.squid.carried_rock # Set squid status to throwing rock with more detail if hasattr(self.squid, 'status'): if random.random() < 0.3: self.squid.status = "throwing rock" else: self.squid.status = "playing with rock" # Detach from squid and reset parent to scene rock.setParentItem(None) # Calculate throw vectors throw_power = 12 # Increased from 8 angle = math.radians(30 if direction == "right" else 150) self.throw_velocity_x = throw_power * math.cos(angle) self.throw_velocity_y = -throw_power * math.sin(angle) # Negative for upward # Set position to squid's center in scene coordinates squid_rect = self.squid.squid_item.sceneBoundingRect() rock_rect = rock.boundingRect() rock.setPos( squid_rect.center().x() - rock_rect.width()/2, squid_rect.center().y() - rock_rect.height()/2 ) rock.setVisible(True) # Apply stat changes self.squid.happiness = min(100, self.squid.happiness + config['happiness_boost']) self.squid.satisfaction = min(100, self.squid.satisfaction + config['satisfaction_boost']) self.squid.anxiety = max(0, self.squid.anxiety - config['anxiety_reduction']) self.logic.statistics_window.award(50) # Simplified positive memory rock_filename = getattr(rock, 'filename', '') or '' is_urchin = 'rock03' in rock_filename.lower() memory_details = { "activity": "urchin_throwing" if is_urchin else "rock_throwing", "item": rock_filename, "effects": { "happiness": config['happiness_boost'], "satisfaction": config['satisfaction_boost'], "anxiety": -config['anxiety_reduction'] }, "description": "Had fun throwing an urchin!" if is_urchin else "Had fun throwing a rock!", "is_positive": True } # Update rocks thrown counter if hasattr(self.squid, 'statistics'): self.squid.statistics.total_rocks_thrown += 1 # Update statistics tab and achievements plugin if hasattr(self.logic, 'track_rock_thrown'): self.logic.track_rock_thrown() # Add with importance (2) and positive formatting self.squid.memory_manager.add_short_term_memory( 'play', memory_details["activity"], memory_details, importance=2 ) # Broadcast to network if multiplayer plugin is available if hasattr(self, 'multiplayer_plugin') and self.multiplayer_plugin: try: self.multiplayer_plugin.throw_rock_network(rock, direction) except Exception as e: print(f"[RockInteraction] Error broadcasting rock throw: {e}") self.throw_animation_timer.start(50) return True def update_rock_test(self): """Handle the rock test sequence (approach, carry)""" if not self.target_rock: self.cleanup() return if self.rock_test_phase == 0: # Approach phase rock_center = self.target_rock.sceneBoundingRect().center() squid_center = self.squid.squid_item.sceneBoundingRect().center() distance = math.hypot(rock_center.x()-squid_center.x(), rock_center.y()-squid_center.y()) if distance < 50: # Close enough to pick up if self.attach_rock_to_squid(self.target_rock): self.rock_test_phase = 1 # Move to carry phase else: self.cleanup() else: self.squid.move_toward_position(rock_center) elif self.rock_test_phase == 1: # Carry phase # Update the carry time self.rock_carry_time += 0.1 # Since timer fires every 100ms # Check if carry duration has elapsed if self.rock_carry_time >= self.rock_carry_duration: # Time to make a decision if random.random() < 0.7: # 70% chance to throw direction = "right" if random.random() < 0.5 else "left" self.throw_rock(direction) if self.show_message: self.show_message("Squid threw the rock!") else: # 30% chance to drop self.drop_rock() if self.show_message: self.show_message("Squid dropped the rock") # End the test self.cleanup() def update_throw_animation(self): """Handles the physics update for thrown rocks""" if not hasattr(self, 'squid') or not self.squid.carried_rock: self.throw_animation_timer.stop() self.cleanup_after_throw() return rock = self.squid.carried_rock new_x = rock.x() + self.throw_velocity_x new_y = rock.y() + self.throw_velocity_y # Apply gravity (reduced from 0.5 to 0.3 for slower descent) self.throw_velocity_y += 0.3 # Apply friction to the horizontal velocity friction_coefficient = 0.98 self.throw_velocity_x *= friction_coefficient # Boundary checks scene_rect = self.scene.sceneRect() rock_rect = rock.boundingRect() # Left/right boundaries with reduced bounce if new_x < scene_rect.left(): new_x = scene_rect.left() self.throw_velocity_x *= -0.2 elif new_x > scene_rect.right() - rock_rect.width(): new_x = scene_rect.right() - rock_rect.width() self.throw_velocity_x *= -0.2 # Top boundary - small bounce if new_y < scene_rect.top(): new_y = scene_rect.top() self.throw_velocity_y *= -0.2 # Bottom boundary - stop immediately (no sliding) elif new_y > scene_rect.bottom() - rock_rect.height() - 50: new_y = scene_rect.bottom() - rock_rect.height() - 50 # Stop all momentum when hitting the bottom self.throw_velocity_x = 0 self.throw_velocity_y = 0 rock.setPos(new_x, new_y) self.throw_animation_timer.stop() self.cleanup_after_throw() return rock.setPos(new_x, new_y) # Stop the animation if the rock has slowed down enough if abs(self.throw_velocity_x) < 0.1 and abs(self.throw_velocity_y) < 0.1: self.throw_animation_timer.stop() self.cleanup_after_throw() def cleanup(self): """Reset all rock interaction state""" self.rock_test_timer.stop() self.throw_animation_timer.stop() self.target_rock = None self.rock_test_phase = 0 self.rock_carry_time = 0 self.rock_carry_duration = 0 if hasattr(self.squid, 'is_carrying_rock') and self.squid.is_carrying_rock: self.squid.is_carrying_rock = False self.squid.carried_rock = None def cleanup_after_throw(self): if hasattr(self.squid, 'carried_rock') and self.squid.carried_rock: rock = self.squid.carried_rock # Set cooldown on the rock so it can't be picked up immediately cooldown = self.rock_config.get('cooldown_after_throw', 10.0) rock.throw_cooldown_until = time.time() + cooldown # Make sure to reset all rock-related states rock.is_being_carried = False self.squid.carried_rock = None # Reset squid states self.squid.is_carrying_rock = False # Set status based on current stats if self.squid.happiness > 80: self.squid.status = "playful" elif self.squid.anxiety > 60: self.squid.status = "anxious" elif self.squid.curiosity > 60: self.squid.status = "curious" else: self.squid.status = "exploring surroundings" self.throw_velocity_x = 0 self.throw_velocity_y = 0 self.cleanup() def setup_timers(self, interval=100): """Configure timer intervals""" self.rock_test_timer.setInterval(interval) self.throw_animation_timer.setInterval(50) # === MULTIPLAYER EXTENSIONS === def handle_remote_rock_throw(self, source_node_id, rock_data): """Handle a rock throw from a remote squid""" try: # Extract rock data rock_filename = rock_data.get('rock_filename') direction = rock_data.get('direction') initial_pos = rock_data.get('initial_pos') # Skip if missing required data if not all([rock_filename, direction, initial_pos]): print(f"Incomplete remote rock throw data: {rock_data}") return # Ensure initial_pos is a dict if not isinstance(initial_pos, dict): initial_pos = {'x': initial_pos[0], 'y': initial_pos[1]} if isinstance(initial_pos, (list, tuple)) else {} # Find existing rock or create new one rock = self._find_or_create_remote_rock(rock_filename, initial_pos) if rock: # Mark as a remote rock rock.is_remote = True # Simulate the throw self._simulate_remote_rock_throw(rock, direction) # Check if our squid is in the path of the thrown rock self._check_rock_collision_path(rock, direction, source_node_id) # Show message if self.show_message: self.show_message(f"Remote squid ({source_node_id[-4:]}) threw a rock {direction}!") except Exception as e: print(f"Error handling remote rock throw: {e}") import traceback traceback.print_exc() def _find_or_create_remote_rock(self, filename, pos): """Find an existing rock or create a new one for remote throws""" # Get position values pos_x = pos.get('x', 0) pos_y = pos.get('y', 0) # Look for existing rocks with same filename near position for item in self.scene.items(): if (hasattr(item, 'filename') and item.filename == filename and abs(item.pos().x() - pos_x) < 50 and abs(item.pos().y() - pos_y) < 50): return item # Create new rock if not found try: # Check if file exists or if filename is None if filename is None or not os.path.exists(filename): # Try to find a default rock default_rocks = [ "images/decoration/rock01.png", "images/decoration/rock02.png", "images/rock.png" ] # Use first valid file for rock_file in default_rocks: if os.path.exists(rock_file): filename = rock_file break else: print(f"Could not find a valid rock image file") return None rock_pixmap = QtGui.QPixmap(filename) # Create ResizablePixmapItem if available ResizablePixmapItem = None if hasattr(self.logic, 'user_interface') and hasattr(self.logic.user_interface, 'ResizablePixmapItem'): ResizablePixmapItem = self.logic.user_interface.ResizablePixmapItem if ResizablePixmapItem: rock = ResizablePixmapItem(rock_pixmap, filename) else: rock = QtWidgets.QGraphicsPixmapItem(rock_pixmap) rock.filename = filename rock.setPos(pos_x, pos_y) rock.setOpacity(0.7) # Make it semi-transparent rock.can_be_picked_up = True self.scene.addItem(rock) # Mark as remote rock rock.is_remote = True # Apply red tint to show it's from another instance # Check if multiplayer plugin is available if hasattr(self.logic, 'multiplayer_plugin') and self.logic.multiplayer_plugin: self.logic.multiplayer_plugin.apply_foreign_object_tint(rock) else: # Apply tint directly if plugin reference isn't available color_effect = QtWidgets.QGraphicsColorizeEffect() color_effect.setColor(QtGui.QColor(255, 100, 100)) color_effect.setStrength(0.25) rock.setGraphicsEffect(color_effect) rock.is_foreign = True return rock except Exception as e: print(f"Error creating remote rock: {e}") return None def _simulate_remote_rock_throw(self, rock, direction): """Simulate a remote rock throw with simplified physics""" # Create timer for animation throw_timer = QtCore.QTimer() throw_counter = [0] # Use list for mutable counter # Initial velocity based on direction velocity_x = 12 * (1 if direction == "right" else -1) velocity_y = -10 # Initial upward def update_position(): nonlocal velocity_x, velocity_y # Update position current_pos = rock.pos() new_x = current_pos.x() + velocity_x new_y = current_pos.y() + velocity_y # Apply gravity velocity_y += 0.4 # Check boundaries scene_rect = self.scene.sceneRect() rock_rect = rock.boundingRect() # Left/right boundaries if new_x < scene_rect.left(): new_x = scene_rect.left() velocity_x *= -0.5 elif new_x > scene_rect.right() - rock_rect.width(): new_x = scene_rect.right() - rock_rect.width() velocity_x *= -0.5 # Top/bottom boundaries if new_y < scene_rect.top(): new_y = scene_rect.top() velocity_y *= -0.5 elif new_y > scene_rect.bottom() - rock_rect.height(): new_y = scene_rect.bottom() - rock_rect.height() throw_timer.stop() return # Update position rock.setPos(new_x, new_y) # Stop after some time throw_counter[0] += 1 if throw_counter[0] > 100: # Stop after 100 updates throw_timer.stop() # Start animation throw_timer.timeout.connect(update_position) throw_timer.start(50) # 50ms intervals def _check_rock_collision_path(self, rock, direction, source_node_id): """Check if our squid is in the path of a thrown rock""" # Skip if squid isn't initialized if not self.squid: return # Get rock and squid positions rock_pos = rock.pos() squid_rect = self.squid.squid_item.sceneBoundingRect() squid_center = squid_rect.center() # Calculate relative positions dx = squid_center.x() - rock_pos.x() dy = squid_center.y() - rock_pos.y() distance = math.sqrt(dx*dx + dy*dy) # Check if squid is in the direction of throw in_path = (direction == "right" and dx > 0) or (direction == "left" and dx < 0) # If squid is close enough and in the throw path, react if distance < 200 and in_path: if hasattr(self.squid, 'react_to_rock_throw'): # Use the specialized reaction method self.squid.react_to_rock_throw(source_node_id, True) elif hasattr(self.logic, 'startle_squid'): # Fallback to generic startle self.logic.startle_squid(source="incoming_rock") # Add memory if hasattr(self.squid, 'memory_manager'): self.squid.memory_manager.add_short_term_memory( 'observation', 'rock_thrown', f"Startled by rock thrown by remote squid ({source_node_id[-4:]})" ) ================================================ FILE: src/interactions2.py ================================================ # POOP INTERACTIONS import math import time import random import os from PyQt5 import QtCore, QtWidgets, QtGui class PoopInteractionManager: def __init__(self, squid, logic, scene, message_callback, config_manager): self.squid = squid self.logic = logic self.scene = scene self.show_message = message_callback self.config_manager = config_manager self.poop_config = config_manager.get_poop_config() # Poop interaction state self.target_poop = None self.poop_test_phase = 0 # 0=approach, 1=carry, 2=throw self.poop_carry_time = 0 self.poop_carry_duration = 0 # Initialize timers self.poop_test_timer = QtCore.QTimer() self.throw_animation_timer = QtCore.QTimer() # Connect timers self.poop_test_timer.timeout.connect(self.update_poop_test) self.throw_animation_timer.timeout.connect(self.update_throw_animation) # Initialize throw velocity variables self.throw_velocity_x = 0 self.throw_velocity_y = 0 # Add multiplayer support self.multiplayer_plugin = None self.setup_multiplayer_integration() def setup_multiplayer_integration(self): """Set up hooks for multiplayer integration""" if not hasattr(self.logic, 'plugin_manager'): return False # Get multiplayer plugin multiplayer_plugin = None for plugin_name, plugin_data in self.logic.plugin_manager.plugins.items(): if plugin_name == "multiplayer_plugin": # Get the actual plugin instance multiplayer_plugin = plugin_data.get('instance') break if multiplayer_plugin: # Store reference self.multiplayer_plugin = multiplayer_plugin print("[PoopInteraction] Successfully integrated with multiplayer plugin") return True return False def is_valid_poop(self, item): """Check if item is a valid poop""" if not isinstance(item, QtWidgets.QGraphicsPixmapItem): return False return (hasattr(item, 'category') and item.category == 'poop') or \ (hasattr(item, 'filename') and 'poop' in item.filename.lower()) def can_pick_up_poop(self, poop): """Check if squid can pick up this poop""" if not self.is_valid_poop(poop): return False if hasattr(poop, 'is_being_carried') and poop.is_being_carried: return False # Check if poop is in cooldown after being thrown if hasattr(poop, 'throw_cooldown_until'): if time.time() < poop.throw_cooldown_until: return False return True def attach_poop_to_squid(self, poop): """Visually attach poop to squid at tentacle position""" poop.setParentItem(self.squid.squid_item) # Set random hold duration between 3-9 seconds self.squid.poop_hold_duration = random.uniform(3.0, 9.0) self.squid.poop_hold_start_time = time.time() self.squid.poop_decision_made = False offset = -50 # Both vertical and horizontal offset # Calculate position based on squid direction if self.squid.squid_direction == "right": # Position poop near right tentacles poop.setPos(self.squid.squid_width - 40 + offset, self.squid.squid_height - 30 + offset) elif self.squid.squid_direction == "left": # Position poop near left tentacles poop.setPos(10 + offset, self.squid.squid_height - 30 + offset) elif self.squid.squid_direction == "up": # Position poop near upper tentacles poop.setPos(self.squid.squid_width//2 - 15 + offset, self.squid.squid_height - 40 + offset) else: # down/default poop.setPos(self.squid.squid_width//2 - 15 + offset, self.squid.squid_height - 20 + offset) poop.is_being_carried = True self.squid.is_carrying_poop = True self.squid.carried_poop = poop poop.setZValue(self.squid.squid_item.zValue() + 1) # Scale poop to appropriate size poop.setScale(1.0) return True def check_poop_hold_time(self): """Check if holding time elapsed and make decision""" if not hasattr(self.squid, 'carrying_poop') or not self.squid.carrying_poop or not hasattr(self.squid, 'poop_decision_made') or self.squid.poop_decision_made: return current_time = time.time() if current_time - self.squid.poop_hold_start_time >= self.squid.poop_hold_duration: self.squid.poop_decision_made = True self.decide_poop_action() def decide_poop_action(self): """Randomly decide to throw or drop the poop""" if random.random() < 0.7: # 70% chance to throw direction = "right" if random.random() < 0.5 else "left" self.throw_poop(direction) if self.show_message: self.show_message("Squid threw the poop!") else: # 30% chance to drop self.drop_poop() if self.show_message: self.show_message("Squid dropped the poop") def drop_poop(self): """Gently place the poop below the squid""" if not hasattr(self.squid, 'carrying_poop') or not self.squid.carrying_poop or not hasattr(self.squid, 'carried_poop') or not self.squid.carried_poop: return poop = self.squid.carried_poop poop.setParentItem(None) poop.setPos( self.squid.squid_x + self.squid.squid_width//2 - poop.boundingRect().width()//2, self.squid.squid_y + self.squid.squid_height + 10 ) self.squid.is_carrying_poop = False self.squid.carried_poop = None def start_poop_test(self, poop=None): """Start test with guaranteed clean state and random carry duration""" self.cleanup() # Reset everything first if poop is None: poops = [item for item in self.scene.items() if self.is_valid_poop(item) and item.isVisible()] if not poops: if self.show_message: self.show_message("No available poops!") return False poop = min(poops, key=lambda p: math.hypot( p.sceneBoundingRect().center().x() - self.squid.squid_x, p.sceneBoundingRect().center().y() - self.squid.squid_y )) self.target_poop = poop self.poop_test_phase = 0 # Use the config values for duration self.poop_carry_duration = random.uniform( self.poop_config['min_carry_duration'], self.poop_config['max_carry_duration'] ) self.poop_carry_time = 0 # Reset carry timer self.poop_test_timer.start(100) # 100ms updates return True def throw_poop(self, direction="right"): """Initiates a poop throw with memory formation""" # Prevent multiple throws if self.throw_animation_timer.isActive(): return False if not hasattr(self.squid, 'carried_poop') or not self.squid.carried_poop: return False config = self.poop_config poop = self.squid.carried_poop # Set squid status if hasattr(self.squid, 'status'): self.squid.status = "throwing poop" # Detach from squid and reset parent to scene poop.setParentItem(None) # Calculate throw vectors throw_power = 12 angle = math.radians(30 if direction == "right" else 150) self.throw_velocity_x = throw_power * math.cos(angle) self.throw_velocity_y = -throw_power * math.sin(angle) # Negative for upward # Set position to squid's center in scene coordinates squid_rect = self.squid.squid_item.sceneBoundingRect() poop_rect = poop.boundingRect() poop.setPos( squid_rect.center().x() - poop_rect.width()/2, squid_rect.center().y() - poop_rect.height()/2 ) poop.setVisible(True) # Apply stat changes self.squid.happiness = min(100, self.squid.happiness - 5) self.squid.satisfaction = min(100, self.squid.satisfaction - 3) self.squid.anxiety = min(100, self.squid.anxiety + 10) self.logic.statistics_window.award(-75) # Simplified negative memory memory_details = { "activity": "poop_throwing", "item": getattr(poop, 'filename', '') or '', "effects": { "happiness": -5, "satisfaction": -3, "anxiety": 10 }, "description": "Threw a poop around!", "is_positive": False } # Update poops thrown counter if hasattr(self.squid, 'statistics'): self.squid.statistics.total_poops_thrown += 1 # Add with moderate importance self.squid.memory_manager.add_short_term_memory( 'play', 'poop_throwing', memory_details, importance=5 ) # Broadcast to network if multiplayer plugin is available if hasattr(self, 'multiplayer_plugin') and self.multiplayer_plugin: try: self.multiplayer_plugin.throw_poop_network(poop, direction) except Exception as e: print(f"[PoopInteraction] Error broadcasting poop throw: {e}") self.throw_animation_timer.start(50) return True def update_poop_test(self): """Handle the poop test sequence (approach, carry)""" if not self.target_poop: self.cleanup() return # Let the Squid class handle the timing and decision making if self.poop_test_phase == 0: # Approach phase poop_center = self.target_poop.sceneBoundingRect().center() squid_center = self.squid.squid_item.sceneBoundingRect().center() distance = math.hypot(poop_center.x()-squid_center.x(), poop_center.y()-squid_center.y()) if distance < 50: # Close enough to pick up if self.attach_poop_to_squid(self.target_poop): self.poop_test_phase = 1 # Move to carry phase else: self.cleanup() else: self.squid.move_toward_position(poop_center) def update_throw_animation(self): """Handles the physics update for thrown poops""" if not hasattr(self.squid, 'carried_poop') or not self.squid.carried_poop: self.throw_animation_timer.stop() self.cleanup_after_throw() return poop = self.squid.carried_poop new_x = poop.x() + self.throw_velocity_x new_y = poop.y() + self.throw_velocity_y # Apply gravity self.throw_velocity_y += 0.3 # Boundary checks scene_rect = self.scene.sceneRect() poop_rect = poop.boundingRect() # Left/right boundaries with reduced bounce if new_x < scene_rect.left(): new_x = scene_rect.left() self.throw_velocity_x *= -0.5 elif new_x > scene_rect.right() - poop_rect.width(): new_x = scene_rect.right() - poop_rect.width() self.throw_velocity_x *= -0.5 # Top/bottom boundaries if new_y < scene_rect.top(): new_y = scene_rect.top() self.throw_velocity_y *= -0.5 elif new_y > scene_rect.bottom() - poop_rect.height(): new_y = scene_rect.bottom() - poop_rect.height() self.throw_animation_timer.stop() self.cleanup_after_throw() return # Stop updates when hitting bottom poop.setPos(new_x, new_y) def cleanup(self): """Reset all poop interaction state""" self.poop_test_timer.stop() self.throw_animation_timer.stop() self.target_poop = None self.poop_test_phase = 0 self.poop_carry_time = 0 self.poop_carry_duration = 0 if hasattr(self.squid, 'is_carrying_poop') and self.squid.is_carrying_poop: self.squid.is_carrying_poop = False self.squid.carried_poop = None def cleanup_after_throw(self): if hasattr(self.squid, 'carried_poop') and self.squid.carried_poop: poop = self.squid.carried_poop # --- ADDED COOLDOWN APPLICATION --- # Set cooldown on the poop so it can't be picked up immediately cooldown = self.poop_config.get('cooldown_after_throw', 10.0) poop.throw_cooldown_until = time.time() + cooldown # ---------------------------------- # Make sure to reset all poop-related states poop.is_being_carried = False self.squid.carried_poop = None # Reset squid states self.squid.is_carrying_poop = False # Reset status to default if it was set to "throwing_poop" if hasattr(self.squid, 'status') and self.squid.status == "throwing_poop": self.squid.status = "roaming" self.throw_velocity_x = 0 self.throw_velocity_y = 0 self.cleanup() def setup_timers(self, interval=100): """Configure timer intervals""" self.poop_test_timer.setInterval(interval) self.throw_animation_timer.setInterval(50) ================================================ FILE: src/laboratory.py ================================================ # 2.4.5.1 | rev3_dec25 # -------------------------------------------------------------- # NEURON LABORATORY # -------------------------------------------------------------- from PyQt5.QtCore import Qt, QTimer, pyqtSignal from PyQt5 import QtCore from PyQt5.QtGui import ( QFont, QPixmap, QColor, QPainter, QBrush, QPen, QDoubleValidator ) from PyQt5.QtWidgets import ( QApplication, QCheckBox, QComboBox, QDialog, QFormLayout, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit, QProgressBar, QPushButton, QScrollArea, QSlider, QSpinBox, QTabWidget, QTextEdit, QVBoxLayout, QWidget, QMessageBox, QTableWidget, QTableWidgetItem, QHeaderView, QSplitter, QToolButton ) import json, math, time, random, datetime as dt from .localisation import loc # Import localisation # ------------------------------------------------------------------ # Helper: coloured connection badge # ------------------------------------------------------------------ def badge(text, color="#333", bg="#eee"): return f"""{text}""" # ------------------------------------------------------------------ # Main Laboratory Dialog # ------------------------------------------------------------------ class NeuronLaboratory(QDialog): def __init__(self, brain_widget, parent=None): super().__init__(parent) self.bw = brain_widget self.setWindowTitle(loc("lab_title", "🧠 Neuron Laboratory")) self.resize(900, 750) self.setWindowFlag(Qt.WindowMinMaxButtonsHint) # ---- top toolbar ---- bar = QHBoxLayout() self.live_check = QCheckBox(loc("lab_live_refresh", "Live refresh")) self.live_check.setChecked(True) self.live_check.toggled.connect(self._toggle_live) bar.addWidget(self.live_check) bar.addStretch() self.lock_check = QCheckBox(loc("lab_unlock_editing", "🔓 Unlock editing")) self.lock_check.toggled.connect(self._unlock_editing) bar.addWidget(self.lock_check) # ---- main notebook ---- self.tabs = QTabWidget() # --- Apply Card-Based Styling --- self.tabs.setStyleSheet(""" QTabWidget::pane { border: 2px solid #e1e5eb; border-radius: 12px; background-color: #f8f9fa; } QTabBar::tab { background: #f8f9fa; border: 1px solid #e1e5eb; padding: 10px 20px; margin-right: 5px; border-top-left-radius: 8px; border-top-right-radius: 8px; font-size: 14px; color: #2c3e50; } QTabBar::tab:selected { background: #ffffff; border-bottom: none; font-weight: 600; } /* Style all QGroupBoxes to appear as modern cards */ QGroupBox { background-color: #ffffff; border: 1px solid #dee2e6; border-radius: 10px; padding-top: 20px; margin-top: 10px; } QGroupBox::title { subcontrol-origin: margin; subcontrol-position: top left; padding: 0 3px; left: 10px; color: #1976d2; /* Use a primary color for card titles */ font-weight: bold; font-size: 12pt; } """) # ------------------------------------------------------------------ # Initialize forced values and timer for absolute override self.forced_neurons = {} # Dictionary to store forced values: name -> value self._force_timer = QTimer(self) self._force_timer.timeout.connect(self._apply_forced_values) self._force_timer.start(100) # Check 10 times per second for smooth override self._build_overview_tab() self._build_inspector_tab() self._build_edit_tab() # ---- footer ---- self.status_lbl = QLabel(loc("lab_status_ready", "Ready")) self.status_lbl.setStyleSheet("color:#888;font-size:9pt;") lay = QVBoxLayout(self) lay.addLayout(bar) lay.addWidget(self.tabs) lay.addWidget(self.status_lbl) # ---- refresh timer ---- self.timer = QTimer(self) self.timer.timeout.connect(self._refresh) self.timer.start(1000) # 1 Hz self._refresh() # first paint # ---- per-neuron manual lock table ---- self.locked_neurons = {} # name -> {locked: bool, slider, spin, button} # ================================================================ # Construction helpers # ================================================================ def _build_overview_tab(self): self.ov_scroll = QScrollArea() self.ov_widget = QWidget() self.ov_grid = QGridLayout(self.ov_widget) self.ov_scroll.setWidget(self.ov_widget) self.ov_scroll.setWidgetResizable(True) self.tabs.addTab(self.ov_scroll, loc("lab_tab_overview", "📊 Live Overview")) def _build_inspector_tab(self): w = QWidget() lay = QVBoxLayout(w) self.pick_neuron = QComboBox() self.pick_neuron.currentTextChanged.connect(self._inspect_neuron) lay.addWidget(QLabel(loc("lab_pick_neuron", "Pick a neuron to inspect:"))) self.pick_neuron.setStyleSheet(""" QComboBox { font-size: 18px; min-height: 36px; padding: 4px; } """) lay.addWidget(self.pick_neuron) self.inspector_scroll = QScrollArea() self.inspector_cards = QWidget() self.inspector_lay = QVBoxLayout(self.inspector_cards) self.inspector_scroll.setWidget(self.inspector_cards) self.inspector_scroll.setWidgetResizable(True) lay.addWidget(self.inspector_scroll, 1) self.tabs.addTab(w, loc("lab_tab_inspector", "🔍 Deep Inspector")) def _build_edit_tab(self): w = QWidget() lay = QVBoxLayout(w) warn = QLabel(loc("lab_edit_locked_msg", "⚠️ Editing is locked – check 'Unlock editing' in the toolbar.")) warn.setStyleSheet("color:#d9534f;font-weight:bold;") lay.addWidget(warn) self.edit_warn = warn self.edit_scroll = QScrollArea() self.edit_cards = QWidget() self.edit_lay = QVBoxLayout(self.edit_cards) self.edit_scroll.setWidget(self.edit_cards) self.edit_scroll.setWidgetResizable(True) lay.addWidget(self.edit_scroll, 1) self.tabs.addTab(w, loc("lab_tab_edit", "🔧 Edit Sandbox")) # ================================================================ # Live refresh # ================================================================ def _refresh(self): if not self.live_check.isChecked(): return current = self.pick_neuron.currentText() self.pick_neuron.clear() # Translate neuron names if needed, or use raw keys? # Usually keys are used internally, but displayed names might be localized. # For this tool, we usually show keys, but let's stick to keys for consistency with other tools. self.pick_neuron.addItems(sorted(self.bw.neuron_positions.keys())) idx = self.pick_neuron.findText(current) if idx >= 0: self.pick_neuron.setCurrentIndex(idx) self._paint_overview() self._inspect_neuron(self.pick_neuron.currentText()) self._paint_edit() def select_neuron_by_name(self, neuron_name: str): """ Selects the specified neuron in the pick_neuron dropdown and refreshes the Inspector tab content. """ if not hasattr(self, 'pick_neuron'): return # 1. Select the neuron in the dropdown idx = self.pick_neuron.findText(neuron_name) if idx >= 0: self.pick_neuron.blockSignals(True) self.pick_neuron.setCurrentIndex(idx) self.pick_neuron.blockSignals(False) # 2. Force the inspection of the newly selected neuron self._inspect_neuron(neuron_name) # 3. Switch to the "Deep Inspector" tab self.tabs.setCurrentIndex(1) # 4. Update the view to reflect the change self.update() # ================================================================ # Overview / Inspector / Edit # ================================================================ def _paint_overview(self): while self.ov_grid.count(): item = self.ov_grid.takeAt(0) if item and item.widget(): item.widget().deleteLater() nd = getattr(self.bw, 'neurogenesis_data', {}) cfg = getattr(self.bw, 'neurogenesis_config', {}) # Card 1: Counters card1 = QGroupBox(loc("lab_ov_counters", "Counter progress")) g1 = QGridLayout(card1) # We localize the counter labels here using existing keys or raw if simple metrics = [(loc("novelty", "Novelty"), nd.get('novelty_counter', 0), cfg.get('novelty_threshold', 3)), (loc("stress", "Stress"), nd.get('stress_counter', 0), cfg.get('stress_threshold', .7)), (loc("reward", "Reward"), nd.get('reward_counter', 0), cfg.get('reward_threshold', .6))] for row, (name, cur, thr) in enumerate(metrics): pct = min(100, (cur / thr) * 100) if thr else 0 bar = self._progress_bar(pct) g1.addWidget(QLabel(f"{name} {cur:.2f}/{thr}"), row, 0) g1.addWidget(bar, row, 1) self.ov_grid.addWidget(card1, 0, 0) # Card 2: Newest neurons card2 = QGroupBox(loc("lab_ov_newest", "Newest neurogenesis neurons")) v2 = QVBoxLayout(card2) details = nd.get('new_neurons_details', {}) for name, info in sorted(details.items(), key=lambda x: x[1].get('created_at', 0), reverse=True)[:5]: age = int(time.time() - info.get('created_at', 0)) ago_text = loc("lab_ago", "{seconds}s ago", seconds=age) v2.addWidget(QLabel(f"{name} – {info.get('trigger_type','?')} – {ago_text}")) if not details: v2.addWidget(QLabel(loc("lab_none_yet", "None yet"))) self.ov_grid.addWidget(card2, 0, 1) # Card 3: Limits card3 = QGroupBox(loc("lab_ov_limits", "Limits & pruning")) v3 = QVBoxLayout(card3) current = len(self.bw.neuron_positions) - len(self.bw.excluded_neurons) max_n = cfg.get('max_neurons', 32) v3.addWidget(self._progress_widget(loc("neurons", "Neurons"), current, max_n)) pruning_text = loc("lab_pruning_enabled", "Pruning enabled:") v3.addWidget(QLabel(f"{pruning_text} {self.bw.pruning_enabled}")) self.ov_grid.addWidget(card3, 1, 0) # Card 4: Quick Actions card4 = QGroupBox(loc("lab_ov_actions", "Quick actions")) h = QHBoxLayout(card4) btn = QPushButton(loc("lab_force_hebbian", "Force Hebbian cycle")) btn.clicked.connect(self.bw.perform_hebbian_learning) h.addWidget(btn) self.ov_grid.addWidget(card4, 1, 1) self.ov_grid.setRowStretch(2, 1) def _inspect_neuron(self, name): if not name: return while self.inspector_lay.count(): item = self.inspector_lay.takeAt(0) if item and item.widget(): item.widget().deleteLater() nd = getattr(self.bw, 'neurogenesis_data', {}) details = nd.get('new_neurons_details', {}).get(name) # Card: Connector Special Details (If applicable) if details and details.get('trigger_type') == 'connector': card_special = QGroupBox(loc("lab_connector_title", "Network Bridge Protocol")) v_special = QVBoxLayout(card_special) info_text = ( "Status: Active Bridge
    " "This neuron was synthesized to rescue an isolated node.

    " "Wiring Topology:
    " "• Primary: Connection to the Orphan
    " "• Anchor: Connection to Closest Neighbor
    " "• Diversity: Random connection to Network" ) lbl_special = QLabel(info_text) lbl_special.setTextFormat(QtCore.Qt.RichText) v_special.addWidget(lbl_special) self.inspector_lay.addWidget(card_special) # Card: Connections card2 = QGroupBox(loc("lab_connections_title", "Connections (excitatory vs inhibitory)")) v2 = QVBoxLayout(card2) h_partner = loc("lab_header_partner", "Partner") h_weight = loc("lab_header_weight", "Weight") h_type = loc("lab_header_type", "Type") h_inf = loc("lab_header_inf", "Influence") html = "" html += f"" t_exc = loc("lab_type_excitatory", "Excitatory") t_inh = loc("lab_type_inhibitory", "Inhibitory") for (src, dst), w in self.bw.weights.items(): if src == name: typ = t_exc if w > 0 else t_inh col = "#d4ffd4" if w > 0 else "#ffd4d4" html += f"" html += f"" html += f"" for (src, dst), w in self.bw.weights.items(): if dst == name: typ = t_exc if w > 0 else t_inh col = "#d4ffd4" if w > 0 else "#ffd4d4" html += f"" html += f"" html += f"" html += "
    {h_partner}{h_weight}{h_type}{h_inf}
    {dst}{w:+.3f}{badge(typ,'#000',col)}{self._influence_badge(w)}
    {src} →{w:+.3f}{badge(typ,'#000',col)}{self._influence_badge(w, incoming=True)}
    " lbl = QLabel(html) lbl.setWordWrap(True) lbl.setTextFormat(QtCore.Qt.RichText) v2.addWidget(lbl) self.inspector_lay.addWidget(card2) # Card: Impacts card3 = QGroupBox(loc("lab_impact_title", "Functional impact simulation")) v3 = QVBoxLayout(card3) impacts = self._compute_impacts(name) if impacts: h_neuron = loc("lab_header_neuron", "Neuron") h_delta = loc("lab_header_delta", "Δ Value") html = "" html += f"" for partner, delta in impacts.items(): col = "#d4ffd4" if delta > 0 else "#ffd4d4" html += f"" html += "
    {h_neuron}{h_delta}
    {partner}{delta:+.2f}
    " lbl = QLabel(html) lbl.setWordWrap(True) lbl.setTextFormat(QtCore.Qt.RichText) v3.addWidget(lbl) else: v3.addWidget(QLabel(loc("lab_no_connections", "No active connections at the moment"))) self.inspector_lay.addWidget(card3) # Card: Educational card4 = QGroupBox(loc("lab_did_you_know", "Did you know?")) v4 = QVBoxLayout(card4) v4.addWidget(QLabel(self._educational_tip(name))) self.inspector_lay.addWidget(card4) self.inspector_lay.addStretch(1) # ================================================================ # EDIT tab # ================================================================ def _paint_edit(self): while self.edit_lay.count(): item = self.edit_lay.takeAt(0) if item and item.widget(): item.widget().deleteLater() if not self.lock_check.isChecked(): return card = QGroupBox(loc("lab_edit_header", "Neuron values (drag to change) – click 🔒 to lock")) grid = QGridLayout(card) for row, name in enumerate(sorted(self.bw.neuron_positions.keys())): val = self.forced_neurons.get(name, self.bw.state.get(name, 50)) if isinstance(val, bool): continue # Preserve existing lock state was_locked = self.locked_neurons.get(name, {}).get("locked", False) # label grid.addWidget(QLabel(name), row, 0) # slider slider = QSlider(QtCore.Qt.Horizontal) slider.setRange(0, 100) slider.setValue(int(val)) slider.valueChanged.connect(lambda v, n=name: self._set_neuron(n, v)) grid.addWidget(slider, row, 1) # spin-box spin = QSpinBox() spin.setRange(0, 100) spin.setValue(int(val)) spin.valueChanged.connect(lambda v, n=name: self._set_neuron(n, v)) grid.addWidget(spin, row, 2) # pad-lock button btn = QToolButton() btn.setCheckable(True) btn.setChecked(was_locked) btn.setText("🔒" if was_locked else "🔓") btn.setFixedSize(24, 24) btn.setStyleSheet("QToolButton:checked { color: red; }") btn.toggled.connect(lambda checked, n=name, b=btn: self._toggle_lock(n, b)) grid.addWidget(btn, row, 3) # store references self.locked_neurons[name] = { "locked": was_locked, "slider": slider, "spin": spin, "button": btn } self.edit_lay.addWidget(card) self.edit_lay.addStretch(1) # ----------- lock / set slots --------------------------------- def update_debug_info(self): self._refresh() def _toggle_lock(self, name, button): is_locked = button.isChecked() self.locked_neurons[name]["locked"] = is_locked button.setText("🔒" if is_locked else "🔓") if is_locked: current_value = self.bw.state.get(name, 50) self.forced_neurons[name] = int(current_value) self.status_lbl.setText(loc("lab_status_locked", "🔒 {name} locked at {value}", name=name, value=current_value)) else: if name in self.forced_neurons: del self.forced_neurons[name] self.status_lbl.setText(loc("lab_status_unlocked", "🔓 {name} unlocked", name=name)) def _set_neuron(self, name, value): self.forced_neurons[name] = value self.bw.state[name] = value self.bw.update() def _apply_forced_values(self): if not self.isVisible(): return for name, value in self.forced_neurons.items(): if name in self.bw.state: self.bw.state[name] = value # NEW: Sync to squid if it's a core statistic neuron if hasattr(self.bw, 'tamagotchi_logic') and hasattr(self.bw.tamagotchi_logic, 'squid'): squid = self.bw.tamagotchi_logic.squid if name in ['hunger', 'happiness', 'cleanliness', 'sleepiness', 'health', 'satisfaction', 'curiosity', 'anxiety']: setattr(squid, name, value) if name in self.locked_neurons and self.locked_neurons[name]["locked"]: slider = self.locked_neurons[name]["slider"] spin = self.locked_neurons[name]["spin"] int_value = int(value) if slider.value() != int_value: slider.blockSignals(True) slider.setValue(int_value) slider.blockSignals(False) if spin.value() != int_value: spin.blockSignals(True) spin.setValue(int_value) spin.blockSignals(False) # ================================================================ # Slots # ================================================================ def _toggle_live(self, on): self.timer.setInterval(1000 if on else 10000) def _unlock_editing(self, on): if on: title = loc("lab_unlock_title", "Unlock editing?") msg = loc("lab_unlock_msg", "You can now change neuron values and force creation events. Use responsibly!") ans = QMessageBox.question(self, title, msg) if ans != QMessageBox.Yes: self.lock_check.setChecked(False) return self.edit_warn.setVisible(not on) self._paint_edit() def _force_neurogenesis(self, typ): fake_state = {"_debug_forced_neurogenesis": True, f"{typ}_exposure": 999} self.bw.update_state(fake_state) # ================================================================ # Pretty helpers # ================================================================ def _progress_bar(self, pct): bar = QProgressBar() bar.setRange(0, 100) bar.setValue(int(pct)) bar.setTextVisible(True) bar.setStyleSheet("QProgressBar::chunk{background:#4CAF50;}") return bar def _progress_widget(self, title, cur, maxi): w = QWidget() h = QHBoxLayout(w) h.setContentsMargins(0, 0, 0, 0) h.addWidget(QLabel(f"{title} {cur}/{maxi}")) bar = self._progress_bar((cur / maxi) * 100) bar.setMaximumHeight(12) h.addWidget(bar) return w def _influence_badge(self, w, incoming=False): mag = abs(w) if mag < 0.1: return badge(loc("lab_inf_tiny", "tiny"), "#666", "#fff") if mag < 0.3: return badge(loc("lab_inf_mild", "mild"), "#fff", "#555") if mag < 0.6: return badge(loc("lab_inf_mod", "moderate"), "#fff", "#000") return badge(loc("lab_inf_strong", "STRONG"), "#fff", "#d9534f") def _compute_impacts(self, name): impacts = {} val = self.bw.state.get(name, 50) if abs(val - 50) < 5: return impacts # outgoing for (src, dst), w in self.bw.weights.items(): if src == name and dst not in self.bw.excluded_neurons: impacts[dst] = (val - 50) * w * 0.5 return impacts def _educational_tip(self, name): # 1. Try specific key first specific_key = f"lab_tip_{name}" text = loc(specific_key) # Check if translation was found (if loc returns key when missing) if text != specific_key: return text # 2. Core neurons core_keys = ["hunger", "happiness", "anxiety", "curiosity"] if name in core_keys: return loc(specific_key) if name in self.bw.original_neuron_positions: return loc("lab_tip_core", "Core neuron – fundamental to survival.") nd = getattr(self.bw, 'neurogenesis_data', {}) det = nd.get('new_neurons_details', {}).get(name) if not det: return loc("lab_tip_neuro_default", "Neurogenesis neuron – purpose inferred from birth context.") # Special handling for connectors if det.get('trigger_type') == 'connector': return loc("lab_tip_connector", "Generated by the network to connect orphaned neurons. Has 3 connections.") return loc("lab_tip_neuro_fmt", "Created by {trigger} – specialises in {spec}. Its job is to turn experiences into long-term behaviour.", trigger=det.get('trigger_type'), spec=det.get('specialisation','?')) NeurogenesisDebugDialog = NeuronLaboratory # Old name alias – Backwards compatibility # ------------------------------------------------------------------ # Quick test when run standalone # ------------------------------------------------------------------ if __name__ == "__main__": import sys app = QApplication(sys.argv) # dummy brain-widget for test class DummyBW: neuron_positions = {"hunger": (100, 100), "happiness": (200, 100)} excluded_neurons = [] original_neuron_positions = {"hunger": (100, 100), "happiness": (200, 100)} state = {"hunger": 60, "happiness": 40} pruning_enabled = True weights = {("hunger", "happiness"): 0.75} neurogenesis_data = { "novelty_counter": 2.3, "stress_counter": 0.4, "reward_counter": 1.1, "new_neurons_details": { "novelty_0": {"trigger_type": "novelty", "created_at": time.time() - 120, "specialisation": "object_investigation", "trigger_value_at_creation": 3.2, "associated_state_snapshot": {"curiosity": 80}} }, "last_neuron_time": time.time() - 300 } neurogenesis_config = {"novelty_threshold": 3, "stress_threshold": 0.7, "reward_threshold": 0.6, "max_neurons": 32, "cooldown": 180} def perform_hebbian_learning(self): print("Hebbian cycle triggered") def update(self): # dummy update for slider/spinbox changes pass dlg = NeuronLaboratory(DummyBW()) dlg.show() sys.exit(app.exec_()) ================================================ FILE: src/learning.py ================================================ import random from PyQt5 import QtCore import csv import time import json from datetime import datetime from .personality import Personality class HebbianLearning: def __init__(self, squid, brain_window, config=None): self.squid = squid self.brain_window = brain_window self.config = config if config else LearningConfig() # Learning data and tracking self.squid_personality = squid.personality if squid else None self.learning_data = [] self.threshold = 0.7 self.goal_weights = { 'organize_decorations': 0.5, 'interact_with_rocks': 0.7, 'move_to_plants': 0.4 } self.excluded_neurons = ['is_sick', 'is_eating', 'is_sleeping', 'pursuing_food', 'direction'] self.learning_rate = self.config.hebbian['base_learning_rate'] self.threshold = self.config.hebbian['threshold'] self.goal_weights = self.config.hebbian['goal_weights'] # Neurogenesis tracking self.last_neurogenesis_time = time.time() self.neurogenesis_active = False # Learning event logging self.learning_event_log = [] self.learning_log_file = 'learning_events.json' # Network state history self.network_state_history = [] self.max_history_length = 100 # Personality-specific learning modifiers self.personality_learning_modifiers = { Personality.TIMID: { 'learning_rate_reduction': 0.5, 'novelty_sensitivity': 0.3, 'connection_stability': 0.8 }, Personality.ADVENTUROUS: { 'learning_rate_boost': 1.5, 'novelty_sensitivity': 1.2, 'connection_plasticity': 1.2 }, Personality.GREEDY: { 'reward_learning_boost': 1.3, 'exploration_penalty': 0.7, 'connection_prioritization': ['satisfaction', 'hunger'] }, Personality.STUBBORN: { 'unlearning_resistance': 0.9, 'new_connection_threshold': 0.6, 'preference_reinforcement': 1.2 } } def get_learning_data(self): return self.learning_data def export_learning_data(self, file_name): with open(file_name, 'w', newline='') as file: writer = csv.writer(file) writer.writerow(["Timestamp", "Neuron 1", "Neuron 2", "Weight Change", "Goal Type"]) writer.writerows(self.learning_data) def learn_from_eating(self): """ Modify eating-related learning based on personality """ # Base learning connections self.strengthen_connection('hunger', 'satisfaction', self.learning_rate * 2.0) self.strengthen_connection('hunger', 'happiness', self.learning_rate * 1.5) # Personality-specific modifications personality = self.squid_personality or Personality.ADVENTUROUS if personality == Personality.GREEDY: # Greedy squids form stronger connections related to food and satisfaction self.strengthen_connection('hunger', 'satisfaction', self.learning_rate * 3.0) self.strengthen_connection('satisfaction', 'happiness', self.learning_rate * 2.0) elif personality == Personality.STUBBORN: # Stubborn squids only strengthen connections for favorite food (sushi) if getattr(self.squid, 'last_food_type', None) == 'sushi': self.strengthen_connection('hunger', 'satisfaction', self.learning_rate * 2.5) elif personality == Personality.TIMID: # Timid squids form weaker, more cautious connections self.strengthen_connection('hunger', 'satisfaction', self.learning_rate * 1.5) self.strengthen_connection('hunger', 'anxiety', self.learning_rate * 0.5) elif personality == Personality.ADVENTUROUS: # Adventurous squids form more varied and dynamic connections self.strengthen_connection('hunger', 'curiosity', self.learning_rate * 1.8) self.strengthen_connection('satisfaction', 'happiness', self.learning_rate * 2.0) def learn_from_decoration_interaction(self, decoration_category): """ Modify decoration interaction learning based on personality """ # Base learning connections if decoration_category == 'plant': self.strengthen_connection('curiosity', 'cleanliness', self.learning_rate) elif decoration_category == 'rock': self.strengthen_connection('curiosity', 'satisfaction', self.learning_rate * 1.5) self.strengthen_connection('curiosity', 'happiness', self.learning_rate) # Personality-specific modifications personality = self.squid_personality or Personality.ADVENTUROUS if personality == Personality.ADVENTUROUS: # Adventurous squids learn more from new decorations if decoration_category == 'rock': self.strengthen_connection('curiosity', 'satisfaction', self.learning_rate * 2.5) self.strengthen_connection('curiosity', 'happiness', self.learning_rate * 2.0) elif personality == Personality.TIMID: # Timid squids are more cautious about new decorations if decoration_category == 'rock': self.strengthen_connection('curiosity', 'anxiety', self.learning_rate * 0.5) elif personality == Personality.GREEDY: # Greedy squids focus on decorations that might provide rewards if decoration_category == 'rock': self.strengthen_connection('satisfaction', 'happiness', self.learning_rate * 2.0) elif personality == Personality.STUBBORN: # Stubborn squids resist learning from new decorations if decoration_category == 'rock': self.strengthen_connection('curiosity', 'satisfaction', self.learning_rate * 0.5) def learn_from_organization(self): """ Modify organization-related learning based on personality """ # Base learning connections self.strengthen_connection('curiosity', 'satisfaction', self.learning_rate * 2) self.strengthen_connection('cleanliness', 'satisfaction', self.learning_rate) # Personality-specific modifications personality = self.squid_personality or Personality.ADVENTUROUS if personality == Personality.ADVENTUROUS: # Adventurous squids learn more from organizing self.strengthen_connection('curiosity', 'happiness', self.learning_rate * 2.5) elif personality == Personality.TIMID: # Timid squids have a more cautious approach to organization self.strengthen_connection('curiosity', 'anxiety', self.learning_rate * 0.5) elif personality == Personality.GREEDY: # Greedy squids organize for potential rewards self.strengthen_connection('satisfaction', 'happiness', self.learning_rate * 2.0) elif personality == Personality.STUBBORN: # Stubborn squids resist changing their environment self.strengthen_connection('curiosity', 'satisfaction', self.learning_rate * 0.5) def learn_from_sickness(self): """ Modify sickness-related learning based on personality """ # Base learning connections self.strengthen_connection('is_sick', 'cleanliness', self.learning_rate) self.strengthen_connection('is_sick', 'anxiety', self.learning_rate) # Personality-specific modifications personality = self.squid_personality or Personality.ADVENTUROUS if personality == Personality.TIMID: # Timid squids become more anxious when sick self.strengthen_connection('is_sick', 'anxiety', self.learning_rate * 2.0) elif personality == Personality.ADVENTUROUS: # Adventurous squids learn to overcome sickness self.strengthen_connection('is_sick', 'happiness', self.learning_rate * 1.5) elif personality == Personality.GREEDY: # Greedy squids focus on recovering quickly self.strengthen_connection('is_sick', 'satisfaction', self.learning_rate * 1.8) elif personality == Personality.STUBBORN: # Stubborn squids resist the impact of sickness self.strengthen_connection('is_sick', 'anxiety', self.learning_rate * 0.5) def update_personality(self, new_personality): """ Update the personality dynamically during learning Args: new_personality (Personality): New personality type """ self.squid_personality = new_personality # Optional: Log personality change self.log_learning_event({ 'event_type': 'personality_change', 'old_personality': self.squid_personality, 'new_personality': new_personality }) def learn_from_curiosity(self): """ Modify curiosity-related learning based on personality """ # Base learning connections self.strengthen_connection('curiosity', 'happiness', self.learning_rate) self.strengthen_connection('curiosity', 'satisfaction', self.learning_rate) # Personality-specific modifications personality = self.squid_personality or Personality.ADVENTUROUS if personality == Personality.ADVENTUROUS: # Adventurous squids learn more from curiosity self.strengthen_connection('curiosity', 'happiness', self.learning_rate * 2.5) self.strengthen_connection('curiosity', 'satisfaction', self.learning_rate * 2.0) elif personality == Personality.TIMID: # Timid squids have a more reserved curiosity self.strengthen_connection('curiosity', 'anxiety', self.learning_rate * 0.5) elif personality == Personality.GREEDY: # Greedy squids are curious about potential rewards self.strengthen_connection('curiosity', 'satisfaction', self.learning_rate * 2.0) elif personality == Personality.STUBBORN: # Stubborn squids resist learning from curiosity self.strengthen_connection('curiosity', 'satisfaction', self.learning_rate * 0.5) def learn_from_anxiety(self): """ Modify anxiety-related learning based on personality """ # Base learning connections self.strengthen_connection('anxiety', 'is_sick', self.learning_rate) self.strengthen_connection('anxiety', 'cleanliness', self.learning_rate) # Personality-specific modifications personality = self.squid_personality or Personality.ADVENTUROUS if personality == Personality.TIMID: # Timid squids are more affected by anxiety self.strengthen_connection('anxiety', 'is_sick', self.learning_rate * 2.0) self.strengthen_connection('anxiety', 'happiness', self.learning_rate * 0.5) elif personality == Personality.ADVENTUROUS: # Adventurous squids learn to overcome anxiety self.strengthen_connection('anxiety', 'happiness', self.learning_rate * 1.5) elif personality == Personality.GREEDY: # Greedy squids link anxiety to potential loss self.strengthen_connection('anxiety', 'satisfaction', self.learning_rate * 1.8) elif personality == Personality.STUBBORN: # Stubborn squids resist the impact of anxiety self.strengthen_connection('anxiety', 'is_sick', self.learning_rate * 0.5) def strengthen_connection(self, neuron1, neuron2, base_learning_rate): """Enhanced version that considers neurogenesis state and goal weights""" # Skip if either neuron is in the excluded list if neuron1 in self.excluded_neurons or neuron2 in self.excluded_neurons: return # Determine personality (with a default) personality = self.squid_personality or Personality.ADVENTUROUS # Apply personality modifiers learning_rate = self.apply_personality_learning_modifiers(neuron1, neuron2, base_learning_rate) if getattr(self.squid, neuron1) > self.threshold and getattr(self.squid, neuron2) > self.threshold: # Check if this is a goal-oriented connection is_goal = (neuron1, neuron2) in self.goal_weights or (neuron2, neuron1) in self.goal_weights # Calculate weight change if is_goal: base_change = learning_rate * self.config.combined['goal_reinforcement_factor'] else: base_change = learning_rate if self.neurogenesis_active: base_change *= self.config.combined['neurogenesis_learning_boost'] # Apply weight change pair = (neuron1, neuron2) reverse_pair = (neuron2, neuron1) prev_weight = self.brain_window.brain_widget.weights.get(pair, 0) new_weight = prev_weight + base_change # Apply weight bounds new_weight = max(self.config.hebbian['min_weight'], min(self.config.hebbian['max_weight'], new_weight)) self.brain_window.brain_widget.weights[pair] = new_weight self.brain_window.brain_widget.weights[reverse_pair] = new_weight # Prepare learning event details learning_event = { 'neuron1': neuron1, 'neuron2': neuron2, 'learning_rate': learning_rate, 'weight_change': new_weight - prev_weight, 'previous_weight': prev_weight, 'new_weight': new_weight, 'personality': personality.value, 'is_goal_oriented': is_goal, 'neurogenesis_active': self.neurogenesis_active, 'explanation': self.generate_learning_explanation(neuron1, neuron2, learning_rate) } # Log the learning event self.log_learning_event(learning_event) # Periodically capture network state if len(self.learning_event_log) % 10 == 0: self.capture_network_state() def apply_personality_learning_modifiers(self, neuron1, neuron2, learning_rate): """ Apply personality-specific modifiers to learning process """ personality = self.squid_personality or Personality.ADVENTUROUS modifiers = self.personality_learning_modifiers.get(personality, {}) # Base learning rate modification if personality == Personality.TIMID: learning_rate *= modifiers.get('learning_rate_reduction', 1.0) elif personality == Personality.ADVENTUROUS: learning_rate *= modifiers.get('learning_rate_boost', 1.0) # Connection prioritization for greedy personality if personality == Personality.GREEDY: priority_neurons = modifiers.get('connection_prioritization', []) if any(n in priority_neurons for n in [neuron1, neuron2]): learning_rate *= 1.3 # Stubborn personality resistance to change if personality == Personality.STUBBORN: learning_rate *= modifiers.get('unlearning_resistance', 1.0) return learning_rate def generate_learning_explanation(self, neuron1, neuron2, learning_rate): """ Generate a human-readable explanation of learning process """ personality = self.squid_personality or Personality.ADVENTUROUS explanations = { Personality.TIMID: f"Cautiously adjusting connection between {neuron1} and {neuron2}", Personality.ADVENTUROUS: f"Rapidly strengthening connection between {neuron1} and {neuron2}", Personality.GREEDY: f"Prioritizing connection between {neuron1} and {neuron2} for potential reward", Personality.STUBBORN: f"Maintaining existing connection pattern between {neuron1} and {neuron2}" } return explanations.get(personality, "Neutral learning process") def log_learning_event(self, event_details): """ Log a detailed learning event with comprehensive details Args: event_details (dict): Dictionary containing learning event information """ try: # Ensure timestamp is added event_details['timestamp'] = datetime.now().isoformat() # Add context information if not present if 'personality' not in event_details and self.squid_personality: event_details['personality'] = self.squid_personality.value # Validate and sanitize event details sanitized_event = { k: v for k, v in event_details.items() if v is not None and v != '' } # Add to in-memory log self.learning_event_log.append(sanitized_event) # Optionally save to file (every 10 events or based on a condition) if len(self.learning_event_log) % 10 == 0: self.save_learning_log() # Optional: print event for debugging (can be removed in production) if self.debug_mode: print(f"Learning Event: {sanitized_event}") except Exception as e: print(f"Error logging learning event: {e}") # Optionally log to a separate error log def save_learning_log(self): """Save learning events to a JSON file""" try: with open(self.learning_log_file, 'w') as f: json.dump(self.learning_event_log, f, indent=2) except Exception as e: print(f"Error saving learning log: {e}") def export_learning_log(self, filename=None): """ Export learning log to CSV or specified format """ if not filename: filename = f"learning_log_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" try: with open(filename, 'w', newline='') as csvfile: # Determine fieldnames dynamically based on first event if self.learning_event_log: fieldnames = list(self.learning_event_log[0].keys()) writer = csv.DictWriter(csvfile, fieldnames=fieldnames) writer.writeheader() for event in self.learning_event_log: writer.writerow(event) print(f"Learning log exported to {filename}") except Exception as e: print(f"Error exporting learning log: {e}") def capture_network_state(self): """ Capture the current state of the neural network """ current_state = { 'timestamp': datetime.now().isoformat(), 'neurons': list(self.brain_window.brain_widget.neuron_positions.keys()), 'weights': {str(k): v for k, v in self.brain_window.brain_widget.weights.items()}, 'neuron_positions': { str(name): list(pos) for name, pos in self.brain_window.brain_widget.neuron_positions.items() }, 'personality': self.squid.personality.value, 'learning_rate': self.learning_rate } # Add to history self.network_state_history.append(current_state) # Trim history if it exceeds max length if len(self.network_state_history) > self.max_history_length: self.network_state_history.pop(0) return current_state def analyze_network_evolution(self): """ Analyze how the network has evolved over time """ if len(self.network_state_history) < 2: return {"error": "Not enough history to analyze"} analysis = { 'total_neurons_added': 0, 'total_weight_changes': 0, 'personality_impact': {}, 'learning_rate_trend': [] } # Analyze changes between consecutive states for i in range(1, len(self.network_state_history)): prev_state = self.network_state_history[i-1] current_state = self.network_state_history[i] # Count new neurons new_neurons = set(current_state['neurons']) - set(prev_state['neurons']) analysis['total_neurons_added'] += len(new_neurons) # Track weight changes weight_changes = 0 for (k, v) in current_state['weights'].items(): if k in prev_state['weights']: if abs(v - prev_state['weights'][k]) > 0.01: weight_changes += 1 analysis['total_weight_changes'] += weight_changes # Personality impact tracking personality = current_state['personality'] analysis['personality_impact'][personality] = analysis['personality_impact'].get(personality, 0) + 1 # Learning rate trend analysis['learning_rate_trend'].append(current_state['learning_rate']) return analysis def export_network_evolution(self, filename=None): """ Export network evolution history """ if not filename: filename = f"network_evolution_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" try: with open(filename, 'w') as f: json.dump(self.network_state_history, f, indent=2) print(f"Network evolution history exported to {filename}") except Exception as e: print(f"Error exporting network evolution: {e}") def update_learning_rate(self, novelty_factor): """Update learning rate based on novelty and neurogenesis state""" base_rate = self.config.hebbian['base_learning_rate'] if self.neurogenesis_active: self.learning_rate = base_rate * novelty_factor * self.config.combined['neurogenesis_learning_boost'] else: self.learning_rate = base_rate * novelty_factor # Update goal weights with the same factor for goal in self.goal_weights: self.goal_weights[goal] = min(1.0, self.config.hebbian['goal_weights'][goal] * novelty_factor) def update_weights(self): # Apply goal-oriented reinforcement if self.squid.status == "organizing decorations": self.learn_from_organization() elif self.squid.status == "interacting with rocks": self.learn_from_decoration_interaction('rock') elif self.squid.status == "moving to plant": self.learn_from_decoration_interaction('plant') def check_neurogenesis_conditions(self, brain_state): """Check if conditions for neurogenesis are met""" current_time = time.time() # Check cooldown first if current_time - self.last_neurogenesis_time < self.config.neurogenesis['cooldown']: return False # Check triggers triggers = { 'novelty': brain_state.get('novelty_exposure', 0) > self.config.neurogenesis['novelty_threshold'], 'stress': brain_state.get('sustained_stress', 0) > self.config.neurogenesis['stress_threshold'], 'reward': brain_state.get('recent_rewards', 0) > self.config.neurogenesis['reward_threshold'] } return any(triggers.values()) def create_new_neuron(self, neuron_type, trigger_data): """Create a new neuron and connect it to existing ones""" base_name = { 'novelty': 'novel', 'stress': 'defense', 'reward': 'reward' }.get(neuron_type, 'new') new_name = f"{base_name}_{len(self.brain_window.brain_widget.neurogenesis_data['new_neurons'])}" # Add to brain widget self.brain_window.brain_widget.create_neuron(neuron_type, trigger_data) # Initialize connections with existing neurons for existing_neuron in self.brain_window.brain_widget.neuron_positions: if existing_neuron != new_name: # Stronger initial connection if related if (neuron_type == 'novelty' and existing_neuron == 'curiosity') or \ (neuron_type == 'stress' and existing_neuron == 'anxiety') or \ (neuron_type == 'reward' and existing_neuron == 'satisfaction'): weight = self.config.combined['new_neuron_connection_strength'] * 1.5 else: weight = self.config.combined['new_neuron_connection_strength'] self.brain_window.brain_widget.weights[(new_name, existing_neuron)] = weight self.brain_window.brain_widget.weights[(existing_neuron, new_name)] = weight * 0.5 # Activate neurogenesis boost self.neurogenesis_active = True self.last_neurogenesis_time = time.time() QtCore.QTimer.singleShot(10000, self.end_neurogenesis_boost) # 10 second boost return new_name def end_neurogenesis_boost(self): self.neurogenesis_active = False class LearningConfig: def __init__(self): # Initialize default values self.hebbian = { 'base_learning_rate': 0.1, 'threshold': 0.7, 'weight_decay': 0.01, 'max_weight': 1.0, 'min_weight': -1.0, 'learning_interval': 30000, 'goal_weights': { 'organize_decorations': 0.5, 'interact_with_rocks': 0.7, 'move_to_plants': 0.4 } } # Initialize neurogenesis with values matching config.ini self.neurogenesis = { 'novelty_threshold': 2.5, # Match config.ini value 'stress_threshold': 2.0, # Match config.ini value 'reward_threshold': 1.8, # Match config.ini value 'cooldown': 120, # Match config.ini value 'decay_rate': 0.95, # Match config.ini value 'new_neuron_initial_weight': 0.5, 'max_new_neurons': 5 } # Try loading from config file if available self.load_from_config() def load_from_config(self): """Load values from config.ini if available""" try: from .config_manager import ConfigManager config_manager = ConfigManager() # Get neurogenesis configuration neuro_config = config_manager.get_neurogenesis_config() # Update our neurogenesis config with values from file if neuro_config: # Update the thresholds with values from config.ini self.neurogenesis['novelty_threshold'] = neuro_config['triggers']['novelty']['threshold'] self.neurogenesis['stress_threshold'] = neuro_config['triggers']['stress']['threshold'] self.neurogenesis['reward_threshold'] = neuro_config['triggers']['reward']['threshold'] self.neurogenesis['cooldown'] = neuro_config['general']['cooldown'] self.neurogenesis['decay_rate'] = neuro_config['triggers']['novelty']['decay_rate'] print("Configuration loaded from config.ini") except Exception as e: print(f"Error loading from config.ini: {e}") print("Using default configuration values") ================================================ FILE: src/localisation.py ================================================ # localisation.py import json import os import sys from pathlib import Path class Localisation: _instance = None @classmethod def instance(cls): """Return the singleton instance""" if cls._instance is None: cls._instance = cls() return cls._instance def __init__(self): """Initialize with default language""" self.translations = {} self.current_language = 'en' self.load_language('en') def load_language(self, lang_code): """Load translations from language module""" # Attempt 1: Try import from src.translations (Standard Project Structure) try: module_name = f'src.translations.{lang_code}' module = __import__(module_name, fromlist=['translations']) self.translations = getattr(module, 'translations', {}) self.current_language = lang_code print(f"[Localisation] Successfully loaded '{lang_code}' from {module_name}") return except ImportError: pass # Attempt 2: Try import from translations (Flat/Root Structure) try: module_name = f'translations.{lang_code}' module = __import__(module_name, fromlist=['translations']) self.translations = getattr(module, 'translations', {}) self.current_language = lang_code print(f"[Localisation] Successfully loaded '{lang_code}' from {module_name}") return except ImportError as e: # Fallback: No translations found print(f"[Localisation] WARNING: Could not load translations for '{lang_code}'. Error: {e}") self.translations = {} def get(self, key, default=None, **kwargs): """ Get a translation string with optional formatting. Args: key: Translation key to look up default: Default value if key not found **kwargs: Formatting arguments for the translation string Returns: Translated and formatted string """ if not key: return default or "" # Look up the translation value = self.translations.get(key, default) # If no value found, use the key itself as default if value is None: value = str(key) # Format with kwargs if provided if kwargs and isinstance(value, str): try: return value.format(**kwargs) except (KeyError, ValueError): # Return unformatted string if formatting fails return value return value def set_language(self, lang_code): """Change the current language""" self.load_language(lang_code) def get_available_languages(self): """Scan the translations folder and return sorted list of language codes""" languages = set() # Scan root/translations directory try: # Go up one level from src/ to root/, then into translations/ root_dir = Path(__file__).parent.parent trans_dir = root_dir / "translations" if trans_dir.exists(): for file_path in trans_dir.glob("*.py"): lang_code = file_path.stem.lower() if lang_code not in ('__init__',) and not lang_code.startswith('_'): languages.add(lang_code) except Exception as e: print(f"[Localisation] Error scanning translations directory: {e}") return sorted(list(languages)) def get_language_name(self, lang_code, fallback=True): """Get the display name for a language code""" if not lang_code: return "Unknown" # Try to get native name from translation file try: original_lang = self.current_language self.load_language(lang_code) name = self.translations.get('language_name_native') or \ self.translations.get('language_name') self.load_language(original_lang) if name: return name except Exception as e: print(f"[Localisation] Error getting name for '{lang_code}': {e}") # Fallback to hardcoded map if fallback: fallback_names = { 'en': 'English', 'es': 'Spanish (Español)', 'fr': 'French (Français)', 'de': 'German (Deutsch)', 'pl': 'Polish (Polski)', 'uk': 'Ukrainian (Українська)', 'zh': 'Chinese (中文)', 'ja': 'Japanese (日本語)', 'pt': 'Portuguese (Português)', 'cy': 'Welsh (Cymraeg)', 'ga': 'Irish (Gaeilge)', 'gen_z': 'Gen Z Slang', } return fallback_names.get(lang_code, lang_code.upper()) return lang_code # ===================================================================== # PERSONALITY TRANSLATION METHODS # ===================================================================== def get_personality_name(self, personality): """Get translated personality display name.""" key = personality.value if hasattr(personality, 'value') else str(personality).lower() return self.get(f'personality_{key}', key.capitalize()) def get_personality_description(self, personality): """Get translated personality description text.""" key = personality.value if hasattr(personality, 'value') else str(personality).lower() return self.get(f'desc_{key}', '') def get_personality_modifier_text(self, personality): """Get translated personality modifier summary.""" key = personality.value if hasattr(personality, 'value') else str(personality).lower() return self.get(f'mod_{key}', 'Balanced') def get_personality_modifiers(self, personality): """Get translated detailed personality modifiers.""" key = personality.value if hasattr(personality, 'value') else str(personality).lower() return self.get(f'modifiers_{key}', '') def get_care_tips(self, personality): """Get translated care tips for personality.""" key = personality.value if hasattr(personality, 'value') else str(personality).lower() return self.get(f'tips_{key}', '') # Robust alias for direct access to the get method def loc(key, default=None, **kwargs): """Convenient alias for Localisation.instance().get()""" return Localisation.instance().get(key, default, **kwargs) def set_language(lang_code): """ Set the global language for the Localisation singleton. Args: lang_code: Language code (e.g., 'en', 'zh') """ Localisation.instance().set_language(lang_code) # Legacy alias for American spelling compatibility Localization = Localisation ================================================ FILE: src/main.py ================================================ # Dosidicus - a digital pet with a neural network # main.py Entrypoint from PyQt5 import QtWidgets, QtCore, QtGui import sys import os # BOOTSTRAP: Add the current directory and src directory to sys.path # This ensures "from src.ui import Ui" works even if launched from elsewhere. BASE_DIR = os.path.dirname(os.path.abspath(__file__)) if BASE_DIR not in sys.path: sys.path.insert(0, BASE_DIR) import time import sys import json import os import shutil import traceback import multiprocessing import logging from PyQt5 import QtWidgets, QtCore import random import argparse from src.ui import Ui from src.tamagotchi_logic import TamagotchiLogic from src.squid import Squid, Personality from src.splash_screen import SplashScreen from src.save_manager import SaveManager from src.brain_tool import SquidBrainWindow from src.learning import LearningConfig from src.plugin_manager import PluginManager from src.brain_worker import BrainWorker from src.config_manager import ConfigManager from src.localisation import Localisation def launch_brain_designer_process(): """Entry point for Brain Designer in a separate process""" import sys from PyQt5.QtWidgets import QApplication from src.brain_designer import BrainDesignerWindow app = QApplication(sys.argv) window = BrainDesignerWindow() window.show() sys.exit(app.exec_()) def setup_logging_configuration(): """Initialize logging configuration""" os.environ['QT_LOGGING_RULES'] = '*.debug=false;qt.qpa.*=false;qt.style.*=false' os.makedirs('logs', exist_ok=True) logging.basicConfig( filename='logs/dosidicus_log.txt', level=logging.ERROR, format='%(asctime)s - %(levelname)s - %(message)s' ) def perform_cleanup_and_exit(): """Recursively delete __pycache__ and logs directories.""" print("🧹 Cleaning environment...") root_dir = os.path.dirname(os.path.abspath(__file__)) deleted_count = 0 for root, dirs, files in os.walk(root_dir, topdown=True): # Filter and remove specific directories # We iterate over a copy of dirs so we can modify the original list safely for name in list(dirs): if name in ['__pycache__', 'logs']: path = os.path.join(root, name) try: shutil.rmtree(path) print(f" Deleted: {path}") dirs.remove(name) # Prevent os.walk from trying to enter this dir deleted_count += 1 except Exception as e: print(f" ❌ Failed to delete {path}: {e}") print(f"✨ Cleanup complete. Removed {deleted_count} directories.") def global_exception_handler(exctype, value, tb): """Global exception handler to log unhandled exceptions""" error_message = ''.join(traceback.format_exception(exctype, value, tb)) logging.error("Unhandled exception:\n%s", error_message) QtWidgets.QMessageBox.critical(None, "Error", "An unexpected error occurred. Please check dosidicus_log.txt for details.") class TeeStream: """Duplicate output to both console and file""" def __init__(self, original_stream, file_stream): self.original_stream = original_stream self.file_stream = file_stream def write(self, data): self.original_stream.write(data) self.file_stream.write(data) self.file_stream.flush() def flush(self): self.original_stream.flush() self.file_stream.flush() class TimedMessageBox(QtWidgets.QDialog): """A message box that auto-closes after a timeout with a default choice""" def __init__(self, parent, title, message, timeout_seconds=5): super().__init__(parent) self.setWindowTitle(title) self.timeout_seconds = timeout_seconds self.remaining_seconds = timeout_seconds self.result_value = QtWidgets.QMessageBox.No # Default to No self.loc = Localisation.instance() # Setup UI layout = QtWidgets.QVBoxLayout() self.message_label = QtWidgets.QLabel(message) self.message_label.setStyleSheet("font-size: 15px;") layout.addWidget(self.message_label) # Auto-decline message self.timer_label = QtWidgets.QLabel(self.loc.get("auto_decline", seconds=self.remaining_seconds)) self.timer_label.setStyleSheet("color: gray; font-size: 14px;") layout.addWidget(self.timer_label) # Buttons self.button_box = QtWidgets.QDialogButtonBox( QtWidgets.QDialogButtonBox.Yes | QtWidgets.QDialogButtonBox.No ) # Localise buttons manually since we use a custom dict system self.button_box.button(QtWidgets.QDialogButtonBox.Yes).setText(self.loc.get("yes")) self.button_box.button(QtWidgets.QDialogButtonBox.No).setText(self.loc.get("no")) self.button_box.accepted.connect(self.accept_yes) self.button_box.rejected.connect(self.reject_no) layout.addWidget(self.button_box) self.setLayout(layout) # Setup timer self.timer = QtCore.QTimer(self) self.timer.timeout.connect(self.update_countdown) self.timer.start(1000) # Update every second def update_countdown(self): """Update the countdown and auto-close when time runs out""" self.remaining_seconds -= 1 self.timer_label.setText(self.loc.get("auto_decline", seconds=self.remaining_seconds)) if self.remaining_seconds <= 0: self.timer.stop() self.reject_no() # Auto-close with No def accept_yes(self): """User clicked Yes""" self.timer.stop() self.result_value = QtWidgets.QMessageBox.Yes self.accept() def reject_no(self): """User clicked No or timeout occurred""" self.timer.stop() self.result_value = QtWidgets.QMessageBox.No self.reject() def get_result(self): """Get the result after dialog closes""" return self.result_value class MainWindow(QtWidgets.QMainWindow): def __init__(self, specified_personality=None, debug_mode=False, neuro_cooldown=None): super().__init__() # Apply configured language from config.ini config_manager = ConfigManager() language = config_manager.get_language() Localisation.instance().set_language(language) print(f"📄 Applied language from config: {language}") # Initialize configuration self.config = LearningConfig() if neuro_cooldown is not None: self.config.neurogenesis['cooldown'] = neuro_cooldown # Add initialization tracking flag self._initialization_complete = False # Set up debugging self.debug_mode = debug_mode if self.debug_mode: self.setup_logging() # Initialize UI first logging.debug("Initializing UI") self.user_interface = Ui(self, debug_mode=self.debug_mode) # Initialize SquidBrainWindow with config logging.debug("Initializing SquidBrainWindow") self.brain_window = SquidBrainWindow(None, self.debug_mode, self.config) # Store the original window reference to prevent garbage collection self._brain_window_ref = self.brain_window # Explicitly force creation of all tab contents QtCore.QTimer.singleShot(100, self.preload_brain_window_tabs) # Continue with normal initialization self.brain_window.set_tamagotchi_logic(None) # Placeholder to ensure initialization self.user_interface.squid_brain_window = self.brain_window # Initialize plugin manager after UI and brain window logging.debug("Initializing PluginManager") self.plugin_manager = PluginManager() print(f"> Plugin manager initialized: {self.plugin_manager}") self.specified_personality = specified_personality self.neuro_cooldown = neuro_cooldown self.squid = None # Check for existing save data self.save_manager = SaveManager("saves") # Track whether we want to show tutorial self.show_tutorial = False # ===== PERFORMANCE FIX: Single BrainWorker managed by brain_tool ===== # Don't create another worker here - SquidBrainWindow creates and shares it # Access via self.brain_window.brain_worker if needed self.brain_worker = None print("ℹ️ BrainWorker managed by SquidBrainWindow") # Initialize the game logging.debug("Initializing game") self.initialize_game() # Now that tamagotchi_logic is created, set it in plugin_manager and brain_window logging.debug("Setting tamagotchi_logic references") self.plugin_manager.tamagotchi_logic = self.tamagotchi_logic self.tamagotchi_logic.plugin_manager = self.plugin_manager self.brain_window.set_tamagotchi_logic(self.tamagotchi_logic) # New in 2.4.5.0 : Create a unique personality starter neuron squid = self.tamagotchi_logic.squid brain_widget = self.brain_window.brain_widget if (squid and squid.personality and brain_widget and hasattr(brain_widget, 'enhanced_neurogenesis')): if not squid._has_personality_starter_neuron(): neuron = brain_widget.enhanced_neurogenesis.create_personality_starter_neuron( squid.personality.value, brain_widget.state ) if neuron: print(f"🧬 Personality starter neuron created: {neuron}") # Load and initialize plugins after core components logging.debug("Loading plugins") plugin_results = self.plugin_manager.load_all_plugins() # Setup plugins with tamagotchi_logic reference for plugin_name, plugin_data in self.plugin_manager.plugins.items(): instance = plugin_data.get('instance') if instance and hasattr(instance, 'setup') and not plugin_data.get('is_setup', False): try: instance.setup(self.plugin_manager, self.tamagotchi_logic) plugin_data['is_setup'] = True except Exception as e: print(f"Error setting up plugin {plugin_name}: {e}") # CRITICAL FIX: Re-load achievement data since plugin instances were replaced # during load_all_plugins(), discarding any data loaded earlier if self.save_manager.save_exists(): save_data = self.save_manager.load_game() if save_data and 'achievements' in save_data: self._restore_achievements_data(save_data['achievements']) # Update status bar with plugin information if hasattr(self.user_interface, 'status_bar'): self.user_interface.status_bar.update_plugins_status(self.plugin_manager) # Connect signals self.user_interface.new_game_action.triggered.connect(self.start_new_game) self.user_interface.load_action.triggered.connect(self.load_game) self.user_interface.save_action.triggered.connect(self.save_game) self.user_interface.decorations_action.triggered.connect(self.user_interface.toggle_decoration_window) # Initialize plugin menu - do this AFTER loading plugins self.user_interface.apply_plugin_menu_registrations(self.plugin_manager) # Position window 300 pixels to the left of default position desktop = QtWidgets.QApplication.desktop() screen_rect = desktop.screenGeometry() window_rect = self.geometry() center_x = screen_rect.center().x() window_x = center_x - (window_rect.width() // 2) # Default centered X position # Move 300 pixels to the left self.move(window_x - 300, self.y()) if self.debug_mode: print(f"DEBUG MODE ENABLED: Console output is being logged to console.txt") self.setup_facts_timer() def preload_brain_window_tabs(self): """Force creation of all tab contents to prevent crashes during tutorial""" print("Pre-loading brain window tabs...") if not hasattr(self, 'brain_window') or not self.brain_window: print("⚠️ Brain window not initialized, cannot preload") return try: # Force the window to process events and initialize all tabs if hasattr(self.brain_window, 'tabs'): # Visit each tab to ensure it's loaded tab_count = self.brain_window.tabs.count() # Initialize tabs array to prevent garbage collection if not hasattr(self, '_preloaded_tabs'): self._preloaded_tabs = [] # Remember if window was visible before we started was_visible = self.brain_window.isVisible() #print(f"📋 Brain window was_visible before preload: {was_visible}") # Temporarily show the window off-screen to force loading original_pos = self.brain_window.pos() self.brain_window.move(-10000, -10000) # Move off-screen self.brain_window.show() # Force each tab to be displayed at least once for i in range(tab_count): self.brain_window.tabs.setCurrentIndex(i) QtWidgets.QApplication.processEvents() # Get and store references to tab widgets widget = self.brain_window.tabs.widget(i) if widget: self._preloaded_tabs.append(widget) #print(f" ✓ Preloaded tab {i}: {self.brain_window.tabs.tabText(i)}") # Return to first tab (Network/Brain tab) self.brain_window.tabs.setCurrentIndex(0) QtWidgets.QApplication.processEvents() #print(f"📋 Reset to first tab: {self.brain_window.tabs.tabText(0)}") # Restore original position self.brain_window.move(original_pos) # Only hide if it wasn't visible before (don't hide if user is viewing it) if not was_visible: self.brain_window.hide() print("📋 Brain window hidden after preload (was not visible before)") else: print("📋 Brain window kept visible after preload (was visible before)") print(f"✅ Successfully preloaded {len(self._preloaded_tabs)} tabs") except Exception as e: print(f"❌ Error preloading tabs: {e}") import traceback traceback.print_exc() def setup_logging(self): """Set up console logging to file""" if not hasattr(sys, '_original_stdout'): sys._original_stdout = sys.stdout sys._original_stderr = sys.stderr console_log = open('console.txt', 'w', encoding='utf-8') sys.stdout = TeeStream(sys._original_stdout, console_log) sys.stderr = TeeStream(sys._original_stderr, console_log) def setup_facts_timer(self): """Rare ocean-blue Humboldt squid facts every 5 minutes""" config_manager = ConfigManager() if not config_manager.get_facts_enabled(): return self.fact_timer = QtCore.QTimer(self) self.fact_timer.timeout.connect(self.show_random_squid_fact) self.fact_timer.start(config_manager.get_fact_interval_ms()) def show_random_squid_fact(self): """Show one short Humboldt squid fact – big, bright blue, always visible""" try: from src.squid_facts import get_random_fact fact = get_random_fact() if not fact: return msg = f"🌊 Humboldt Fact: {fact}" # Preferred: status bar (works everywhere, supports color) if hasattr(self.user_interface, 'status_bar') and self.user_interface.status_bar: colored_msg = f'{msg}' self.user_interface.status_bar.showMessage(colored_msg, 8000) # 8 seconds else: # Fallback: plain message self.user_interface.show_message(msg) print(f"[Facts] DISPLAYED: {fact[:80]}...") # helpful console confirmation except Exception as e: print(f"[Facts] Error showing fact: {e}") def initialize_game(self): if hasattr(self.save_manager, 'cleanup_duplicate_saves'): self.save_manager.cleanup_duplicate_saves() if self.save_manager.save_exists() and self.specified_personality is None: print("\x1b[32mExisting save data found and will be loaded\x1b[0m") self.squid = Squid(self.user_interface, None, None) self.tamagotchi_logic = TamagotchiLogic(self.user_interface, self.squid, self.brain_window) self.squid.tamagotchi_logic = self.tamagotchi_logic self.user_interface.tamagotchi_logic = self.tamagotchi_logic self.brain_window.tamagotchi_logic = self.tamagotchi_logic if hasattr(self.brain_window, 'set_tamagotchi_logic'): self.brain_window.set_tamagotchi_logic(self.tamagotchi_logic) self.load_game() if hasattr(self.tamagotchi_logic, 'statistics_window'): self.tamagotchi_logic.statistics_window.update_statistics() brain_widget = self.brain_window.brain_widget for name in brain_widget.original_neurons: brain_widget.visible_neurons.add(name) if hasattr(brain_widget, 'neurogenesis_data'): for name in brain_widget.neurogenesis_data.get('new_neurons_details', {}): brain_widget.visible_neurons.add(name) core = brain_widget.original_neurons for idx, name in enumerate(core): QtCore.QTimer.singleShot(idx * 500, lambda n=name: brain_widget.reveal_neuron(n)) self.brain_window.show() self.user_interface.brain_action.setChecked(True) else: print("\x1b[92m-------------- STARTING A NEW SIMULATION --------------\x1b[0m") self.create_new_game(self.specified_personality) self.tamagotchi_logic = TamagotchiLogic(self.user_interface, self.squid, self.brain_window) self.squid.tamagotchi_logic = self.tamagotchi_logic self.user_interface.tamagotchi_logic = self.tamagotchi_logic self.brain_window.tamagotchi_logic = self.tamagotchi_logic if hasattr(self.brain_window, 'set_tamagotchi_logic'): self.brain_window.set_tamagotchi_logic(self.tamagotchi_logic) if not self.save_manager.save_exists(): QtCore.QTimer.singleShot(500, self.delayed_tutorial_check) self._initialization_complete = True def delayed_tutorial_check(self): """Check if the user wants to see the tutorial after UI is responsive""" # Process pending events to ensure UI is responsive QtWidgets.QApplication.processEvents() # Now check tutorial preference self.check_tutorial_preference() # If tutorial was chosen, schedule it for later if self.show_tutorial: # We'll show tutorial when the game starts pass else: # Just open initial windows if no tutorial QtCore.QTimer.singleShot(500, self.open_initial_windows) def create_new_game(self, specified_personality=None): """Create a new game instance""" # Delete any existing save to ensure clean start if self.save_manager.save_exists(): self.save_manager.delete_save() # Choose personality randomly if not specified if specified_personality is None: personality = random.choice(list(Personality)) else: personality = specified_personality # Create new squid with chosen personality self.squid = Squid( user_interface=self.user_interface, tamagotchi_logic=None, personality=personality, neuro_cooldown=self.neuro_cooldown ) print(f" ") print(f">> Generated squid personality: {self.squid.personality.value}") print(f" ") if self.neuro_cooldown: print(f"\x1b[43m Neurogenesis cooldown:\033[0m {self.neuro_cooldown}") self.squid.memory_manager.clear_all_memories() self.show_splash_screen() def check_tutorial_preference(self): """Show a dialog asking if the user wants to see the tutorial with 5-second timeout""" # Don't ask about tutorial if save data exists if self.save_manager.save_exists(): self.show_tutorial = False return # Show timed dialog dialog = TimedMessageBox( self, Localisation.instance().get("startup"), Localisation.instance().get("show_tutorial_q"), timeout_seconds=5 ) dialog.exec_() # Set flag based on user's choice (defaults to No if timeout) self.show_tutorial = (dialog.get_result() == QtWidgets.QMessageBox.Yes) def position_and_show_decoration_window(self): """Position the decoration window in the bottom right and show it""" if hasattr(self.user_interface, 'decoration_window') and self.user_interface.decoration_window: # Get screen geometry screen_geometry = QtWidgets.QApplication.desktop().availableGeometry() # Position window in bottom right decoration_window = self.user_interface.decoration_window decoration_window.move( screen_geometry.right() - decoration_window.width() - 20, screen_geometry.bottom() - decoration_window.height() - 20 ) decoration_window.show() self.user_interface.decorations_action.setChecked(True) def start_new_game(self): """Start a new game, deleting any existing save""" # First, ask for confirmation with a timed dialog confirm_dialog = TimedMessageBox( self, "Confirm New Game", "Are you sure you want to start a new game? This will delete all current progress and save data.", timeout_seconds=10 ) confirm_dialog.exec_() # If user declined or let it timeout, abort if confirm_dialog.get_result() != QtWidgets.QMessageBox.Yes: print("New game cancelled by user") return print("Starting new game...") # Ask about tutorial tutorial_dialog = TimedMessageBox( self, Localisation.instance().get("tutorial_title"), Localisation.instance().get("tutorial_query"), timeout_seconds=5 ) tutorial_dialog.exec_() self.show_tutorial = (tutorial_dialog.get_result() == QtWidgets.QMessageBox.Yes) # Stop current simulation if running if hasattr(self, 'tamagotchi_logic'): self.tamagotchi_logic.stop() # Stop autosave timer if it exists if hasattr(self.tamagotchi_logic, 'autosave_timer'): self.tamagotchi_logic.autosave_timer.stop() # Delete all save files (both autosave and manual save) if self.save_manager.save_exists(): self.save_manager.delete_save(is_autosave=True) # Delete autosave self.save_manager.delete_save(is_autosave=False) # Delete manual save print("All save files deleted") # Clear memory files memory_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), '_memory') if os.path.exists(memory_dir): import shutil shutil.rmtree(memory_dir) print("Memory directory cleared") # Clear all neurons and state from brain window if hasattr(self, 'brain_window') and hasattr(self.brain_window, 'brain_widget'): brain_widget = self.brain_window.brain_widget # Clear visible neurons brain_widget.visible_neurons = set() # Clear neurogenesis data if hasattr(brain_widget, 'neurogenesis_data'): brain_widget.neurogenesis_data = { 'new_neurons': [], 'new_neurons_details': {}, 'new_synapses': [] } # Clear enhanced neurogenesis tracking if hasattr(brain_widget, 'enhanced_neurogenesis'): brain_widget.enhanced_neurogenesis.reset_state() # Reset brain widget state if hasattr(brain_widget, 'state'): brain_widget.state = brain_widget.create_initial_state() # Clear hebbian learning state if hasattr(brain_widget, 'hebbian'): brain_widget.hebbian.reset() print("Brain state cleared") # Clear all decorations and items from the scene if hasattr(self, 'user_interface') and hasattr(self.user_interface, 'scene'): # Remove all items except the background (if it exists) items_to_remove = [] background_item = getattr(self.user_interface, 'background', None) for item in self.user_interface.scene.items(): # Keep the background (if it exists) and remove everything else if background_item is None or item != background_item: items_to_remove.append(item) for item in items_to_remove: self.user_interface.scene.removeItem(item) # Clear decoration tracking if hasattr(self.user_interface, 'awarded_decorations'): self.user_interface.awarded_decorations = set() print("Scene cleared") # Create new game (creates squid but not tamagotchi_logic) self.create_new_game(self.specified_personality) # Create TamagotchiLogic self.tamagotchi_logic = TamagotchiLogic(self.user_interface, self.squid, self.brain_window) # Update references self.squid.tamagotchi_logic = self.tamagotchi_logic self.user_interface.tamagotchi_logic = self.tamagotchi_logic self.brain_window.tamagotchi_logic = self.tamagotchi_logic if hasattr(self.brain_window, 'set_tamagotchi_logic'): self.brain_window.set_tamagotchi_logic(self.tamagotchi_logic) self.plugin_manager.tamagotchi_logic = self.tamagotchi_logic self.tamagotchi_logic.plugin_manager = self.plugin_manager # Create personality starter neuron if needed squid = self.tamagotchi_logic.squid brain_widget = self.brain_window.brain_widget if (squid and squid.personality and brain_widget and hasattr(brain_widget, 'enhanced_neurogenesis')): if not squid._has_personality_starter_neuron(): neuron = brain_widget.enhanced_neurogenesis.create_personality_starter_neuron( squid.personality.value, brain_widget.state ) if neuron: print(f"🧬 Personality starter neuron created: {neuron}") # Reload plugins to ensure they get the new tamagotchi_logic self.plugin_manager.reload_all_plugins() print("New game created successfully!") def load_game(self): """Delegate to tamagotchi_logic""" self.tamagotchi_logic.load_game() def save_game(self): """Delegate to tamagotchi_logic""" if self.squid and self.tamagotchi_logic: self.tamagotchi_logic.save_game() def _restore_achievements_data(self, achievements_data): """Restore achievement data to the achievements plugin after plugin reload. This is needed because plugin instances get replaced during initialization, discarding any previously loaded save data. """ if not achievements_data: return try: if 'achievements' in self.plugin_manager.plugins: plugin_info = self.plugin_manager.plugins['achievements'] instance = plugin_info.get('instance') if instance and hasattr(instance, 'load_save_data'): instance.load_save_data(achievements_data) unlocked_count = len(achievements_data.get('unlocked', {})) print(f"✓ Restored {unlocked_count} achievements") except Exception as e: print(f"[Warning] Could not restore achievements: {e}") def closeEvent(self, event): """Handle window close event""" # Save game before closing if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: self.save_game() # Stop the tamagotchi logic if it has a stop method if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: if hasattr(self.tamagotchi_logic, 'stop'): self.tamagotchi_logic.stop() # Stop the timer if it exists elif hasattr(self.tamagotchi_logic, 'timer') and self.tamagotchi_logic.timer: self.tamagotchi_logic.timer.stop() # Clean up brain state bridge for designer sync if hasattr(self, 'brain_window') and self.brain_window: if hasattr(self.brain_window, 'brain_widget') and self.brain_window.brain_widget: if hasattr(self.brain_window.brain_widget, 'cleanup_brain_bridge'): self.brain_window.brain_widget.cleanup_brain_bridge() # Close brain window if hasattr(self, 'brain_window') and self.brain_window: self.brain_window.close() event.accept() def show_splash_screen(self): """Display splash screen animation with synchronized neuron reveal""" self.splash = SplashScreen(self) self.splash.finished.connect(self.start_simulation) self.splash.finished.connect(lambda: self.tamagotchi_logic.statistics_window.award(1000)) self.splash.second_frame.connect(self.show_hatching_notification) # NEW: award 1000 points the instant the splash ends self.splash.finished.connect( lambda: self.tamagotchi_logic.statistics_window.award(1000) ) # After splash ends, wait 3 s then show the normal feeding hint self.splash.finished.connect(lambda: QtCore.QTimer.singleShot(3000, self.show_feeding_hint)) # Check if this is a brand new game (no save exists) is_new_game = not self.save_manager.save_exists() print(f"🎮 show_splash_screen: is_new_game={is_new_game}, save_exists={self.save_manager.save_exists()}") if is_new_game: # Ensure brain widget starts empty if hasattr(self.brain_window, 'brain_widget') and hasattr(self.brain_window.brain_widget, 'visible_neurons'): self.brain_window.brain_widget.visible_neurons = set() # Show brain window first self.brain_window.show() self.user_interface.brain_action.setChecked(True) # Force immediate processing to ensure brain window is painted QtWidgets.QApplication.processEvents() # Give the brain window time to fully render (longer delay) QtCore.QTimer.singleShot(1500, lambda: self._start_splash_with_reveals()) else: # For loaded games, show brain window with all neurons visible if hasattr(self.brain_window, 'brain_widget') and hasattr(self.brain_window.brain_widget, 'visible_neurons'): brain_widget = self.brain_window.brain_widget # Add all core neurons to visible set for neuron_name in brain_widget.original_neurons: brain_widget.visible_neurons.add(neuron_name) # Also add any neurogenesis neurons that exist if hasattr(brain_widget, 'neurogenesis_data') and 'new_neurons_details' in brain_widget.neurogenesis_data: for neuron_name in brain_widget.neurogenesis_data['new_neurons_details'].keys(): brain_widget.visible_neurons.add(neuron_name) # Show brain window immediately for loaded games self.brain_window.show() self.user_interface.brain_action.setChecked(True) # Force immediate processing to ensure brain window is painted QtWidgets.QApplication.processEvents() # Show splash normally (no animated reveals needed for loaded games) self.splash.show() QtCore.QTimer.singleShot(1000, self.splash.start_animation) def show_feeding_hint(self): """Use the same strip as every other message.""" self.user_interface.show_message("Press D to open the Decorations window") def _start_splash_with_reveals(self): """Start splash screen with neuron reveal synchronization (called after brain window is ready)""" print(" 🥚 A squid is hatching...") # Connect frame changes to neuron reveals self.splash.frame_changed.connect(self._reveal_neuron_for_frame) # Show and start the splash screen animation self.splash.show() QtCore.QTimer.singleShot(500, self.splash.start_animation) # Small delay for splash to show def _reveal_neuron_for_frame(self, frame_index): """Reveal core neurons in sequence with animation frames""" if not hasattr(self.brain_window, 'brain_widget'): return brain_widget = self.brain_window.brain_widget core_neurons = brain_widget.original_neurons # Distribution: 1-2 neurons per frame. Now revised for 8 core neurons (indices 0-7) reveal_map = { 0: [0], # First frame 1: [1], # Second frame 2: [2], # Third frame 3: [3], # Fourth frame 4: [4, 5], # Fifth frame 5: [6, 7] # Sixth frame } # Reveal mapped neurons for this frame for neuron_idx in reveal_map.get(frame_index, []): if neuron_idx < len(core_neurons): neuron_name = core_neurons[neuron_idx] brain_widget.reveal_neuron(neuron_name) #print(f"🧠 Revealed neuron: {neuron_name} (frame {frame_index})") def show_hatching_notification(self): """Display hatching message""" self.user_interface.show_message("Squid is hatching!") def start_simulation(self): """Begin the simulation - brain window is already visible for new games""" self.cleanup_duplicate_squids() self.tamagotchi_logic.set_simulation_speed(1) self.tamagotchi_logic.start_autosave() # Get brain widget reference brain_widget = self.brain_window.brain_widget # Show tutorial if enabled if self.show_tutorial: QtCore.QTimer.singleShot(1000, self.user_interface.show_tutorial_overlay) else: # === FIX START: Manual cleanup if tutorial is skipped === if hasattr(brain_widget, 'is_tutorial_mode'): # Set the flag to False, which allows connections to draw brain_widget.is_tutorial_mode = False # OPTIONAL: If setting the flag doesn't immediately refresh the links, # you may need to force a repaint. If the links are set to show, # a simple repaint will usually draw them once the block is gone. brain_widget.update() # Force repaint # === FIX END === # Only open decoration window automatically (brain window already visible for new games) QtCore.QTimer.singleShot(500, self.position_and_show_decoration_window) def show_tutorial_overlay(self): """Delegate to UI layer and ensure no duplicates remain""" # First do one more duplicate cleanup self.cleanup_duplicate_squids() # Then show the tutorial via the UI if hasattr(self, 'user_interface') and self.user_interface: self.user_interface.show_tutorial_overlay() def open_initial_windows(self): """Open brain window and decorations window""" # Open brain window if hasattr(self, 'brain_window'): self.brain_window.show() self.user_interface.brain_action.setChecked(True) # Open decorations window if hasattr(self.user_interface, 'decoration_window'): self.position_and_show_decoration_window() self.user_interface.decorations_action.setChecked(True) def cleanup_duplicate_squids(self): """Remove any duplicate squid items from the scene""" if not hasattr(self, 'user_interface') or not self.user_interface: return if not hasattr(self, 'squid') or not self.squid: return try: # Get the reference to our genuine squid item main_squid_item = self.squid.squid_item # Get all items in the scene all_items = self.user_interface.scene.items() # Track how many items we find and remove found_count = 0 # Look for graphics items that could be duplicate squids for item in all_items: # Skip our genuine squid item if item == main_squid_item: continue # Only check QGraphicsPixmapItems if isinstance(item, QtWidgets.QGraphicsPixmapItem): # Check if it has the same pixmap dimensions as our squid if (hasattr(item, 'pixmap') and item.pixmap() and main_squid_item.pixmap() and item.pixmap().width() == main_squid_item.pixmap().width() and item.pixmap().height() == main_squid_item.pixmap().height()): print(f"Found potential duplicate squid item - removing") self.user_interface.scene.removeItem(item) found_count += 1 if found_count > 0: print(f"Cleaned up {found_count} duplicate squid items") # Force scene update self.user_interface.scene.update() except Exception as e: print(f"Error during cleanup: {str(e)}") def initialize_multiplayer_manually(self): """Manually initialize multiplayer plugin if needed""" try: # Import the plugin module directly import sys import os plugin_path = os.path.join(os.path.dirname(__file__), 'plugins', 'multiplayer') if plugin_path not in sys.path: sys.path.insert(0, plugin_path) import main as multiplayer_main # Create plugin instance multiplayer_plugin = multiplayer_main.MultiplayerPlugin() # Find it in plugin_manager and add the instance for plugin_name, plugin_data in self.plugin_manager.plugins.items(): if plugin_name.lower() == "multiplayer": plugin_data['instance'] = multiplayer_plugin print(f"Manually added multiplayer plugin instance to {plugin_name}") # Initialize the plugin if hasattr(multiplayer_plugin, 'setup'): multiplayer_plugin.setup(self.plugin_manager) # Register menu actions if hasattr(multiplayer_plugin, 'register_menu_actions'): multiplayer_plugin.register_menu_actions() break # Force the UI to refresh plugin menu self.user_interface.setup_plugin_menu(self.plugin_manager) #print("Manual multiplayer initialization complete") return True except Exception as e: print(f"Error in manual multiplayer initialization: {e}") import traceback traceback.print_exc() return False def main(): """Main entry point""" # CRITICAL for PyInstaller + multiprocessing on Windows multiprocessing.freeze_support() sys.excepthook = global_exception_handler parser = argparse.ArgumentParser(description="Dosidicus digital squid with a neural network") parser.add_argument('-p', '--personality', type=str, choices=[p.value for p in Personality], help='Specify squid personality') parser.add_argument('-d', '--debug', action='store_true', help='Enable debug mode with console logging') parser.add_argument('-nc', '--neurocooldown', type=int, help='Set neurogenesis cooldown in seconds') parser.add_argument('-c', '--clean', action='store_true', help='Clean __pycache__ and logs folders before starting') parser.add_argument('-designer', '--designer', action='store_true', help='Launch Brain Designer standalone') args = parser.parse_args() # Perform cleanup if requested before logging setup if args.clean: perform_cleanup_and_exit() # Launch designer if flag is set if args.designer: print("Launching Brain Designer standalone...") try: # Import and run designer's main function try: from src import brain_designer except ImportError: import brain_designer # brain_designer.main() will parse sys.argv and handle -d and -c flags automatically brain_designer.main() except ImportError as e: print(f"Error: Could not import brain_designer module: {e}") sys.exit(1) except Exception as e: print(f"Error launching designer: {e}") sys.exit(1) return # Exit after designer closes # Initialize logging (replaces previous global setup) setup_logging_configuration() print(f" Personality: {args.personality}") print(f" Debug mode: {args.debug}") print(f" Cooldown {args.neurocooldown or 'will be loaded from config'}") app = QtWidgets.QApplication(sys.argv) try: personality = Personality(args.personality) if args.personality else None main_window = MainWindow(personality, args.debug, args.neurocooldown) main_window.show() sys.exit(app.exec_()) except Exception as e: logging.exception("Fatal error in main") QtWidgets.QMessageBox.critical(None, "Error", f"Critical error: {str(e)}\nSee dosidicus_log.txt for details.") if __name__ == '__main__': main() ================================================ FILE: src/memory_manager.py ================================================ import json import os from datetime import datetime import time class MemoryManager: def __init__(self): self.memory_dir = '_memory' self.short_term_file = os.path.join(self.memory_dir, 'ShortTerm.json') self.long_term_file = os.path.join(self.memory_dir, 'LongTerm.json') # Load memory and ensure all timestamps are converted to floats self.short_term_memory = self._load_and_convert_timestamps(self.short_term_file) self.long_term_memory = self._load_and_convert_timestamps(self.long_term_file) self.short_term_limit = 50 self.short_term_duration = 300 # 5 minutes in seconds self.last_cleanup_time = time.time() self.plant_interaction_count = {} def _load_and_convert_timestamps(self, file_path): """Loads memory from JSON and converts all timestamps to floats.""" if not os.path.exists(file_path): return [] try: with open(file_path, 'r') as file: content = file.read() if not content.strip(): return [] memory_list = json.loads(content) if not isinstance(memory_list, list): return [] for item in memory_list: if 'timestamp' in item: ts = item['timestamp'] if isinstance(ts, str): try: # Convert ISO string to float timestamp item['timestamp'] = datetime.fromisoformat(ts).timestamp() except (ValueError, TypeError): # If conversion fails, set a default invalid timestamp item['timestamp'] = 0 elif not isinstance(ts, (int, float)): item['timestamp'] = 0 # Mark other invalid types return memory_list except (json.JSONDecodeError, Exception) as e: print(f"Error processing memory file {file_path}: {e}") return [] def save_memory(self, memory, file_path): """Saves memory to JSON, converting float timestamps to ISO strings.""" if not memory: # To clear a file, we can write an empty list memory_to_save = [] else: os.makedirs(os.path.dirname(file_path), exist_ok=True) serializable_memory = [] for item in memory: new_item = item.copy() if 'timestamp' in new_item and isinstance(new_item['timestamp'], float): new_item['timestamp'] = datetime.fromtimestamp(new_item['timestamp']).isoformat() serializable_memory.append(new_item) memory_to_save = serializable_memory try: with open(file_path, 'w') as file: json.dump(memory_to_save, file, indent=4) except Exception as e: print(f"Error saving memory to {file_path}: {e}") def add_short_term_memory(self, category, key, value, importance=1.0, related_neurons=None): """Adds a memory, using float timestamps.""" for memory in self.short_term_memory: if memory.get('key') == key and memory.get('category') == category: memory['importance'] = memory.get('importance', 1.0) + 0.5 memory['timestamp'] = time.time() if memory['importance'] >= 3.0: self.transfer_to_long_term_memory(category, key) return memory_item = { "timestamp": time.time(), "category": category, "key": key, "value": value, "importance": importance, "related_neurons": related_neurons or [], "access_count": 1 } self.short_term_memory.append(memory_item) if len(self.short_term_memory) > self.short_term_limit: self.short_term_memory.pop(0) self.save_memory(self.short_term_memory, self.short_term_file) def cleanup_short_term_memory(self): current_time = time.time() self.short_term_memory = [m for m in self.short_term_memory if isinstance(m.get('timestamp'), (int, float)) and (current_time - m.get('timestamp', 0)) <= self.short_term_duration] if len(self.short_term_memory) > self.short_term_limit: self.short_term_memory.sort(key=lambda x: (x.get('importance', 1), x.get('access_count', 0)), reverse=True) self.short_term_memory = self.short_term_memory[:self.short_term_limit] def add_long_term_memory(self, category, key, value): """Adds a memory to long-term storage, preventing duplicates.""" for memory in self.long_term_memory: if memory.get('key') == key and memory.get('category') == category: # Memory already exists, so we don't add it again. # Optional: update timestamp to reflect it's a reinforced memory memory['timestamp'] = time.time() self.save_memory(self.long_term_memory, self.long_term_file) return memory = {'category': category, 'key': key, 'value': value, 'timestamp': time.time()} self.long_term_memory.append(memory) self.save_memory(self.long_term_memory, self.long_term_file) def get_short_term_memory(self, category, key, default=None): current_time = time.time() for memory in self.short_term_memory: if memory.get('category') == category and memory.get('key') == key: ts = memory.get('timestamp', 0) if isinstance(ts, (int, float)) and (current_time - ts) <= self.short_term_duration: memory['access_count'] = memory.get('access_count', 0) + 1 return memory.get('value') return default def get_all_short_term_memories(self, raw=False): """Retrieves all valid short-term memories.""" current_time = time.time() valid_memories = [ m for m in self.short_term_memory if isinstance(m.get('timestamp'), (int, float)) and (current_time - m.get('timestamp', 0)) <= self.short_term_duration ] sorted_memories = sorted(valid_memories, key=lambda x: x.get('timestamp', 0), reverse=True) return sorted_memories if raw else [self._format_memory_for_display(mem) for mem in sorted_memories] def get_all_long_term_memories(self, category=None): filtered = [m for m in self.long_term_memory if not (isinstance(m.get('key'), str) and m['key'].isdigit())] if category: return [m for m in filtered if m.get('category') == category] return filtered def get_active_memories_data(self, count=None): """Return memories with human-readable time string.""" current_time = time.time() active = [] for mem in self.short_term_memory: ts = mem.get('timestamp', 0) if isinstance(ts, (int, float)) and (current_time - ts) <= self.short_term_duration: active.append({ 'category': mem.get('category'), 'key' : mem.get('key'), 'formatted_value': mem.get('value'), 'raw_value': mem.get('value'), 'time_str' : datetime.fromtimestamp(ts).strftime('%H:%M:%S'), # ← HH:MM:SS 'importance': mem.get('importance', 1), 'access_count': mem.get('access_count', 0) }) active.sort(key=lambda x: (x['importance'], x['access_count']), reverse=True) return active[:count] if count else active def review_and_transfer_memories(self): current_time = time.time() # Iterate over a copy as we may modify the list for memory in list(self.short_term_memory): timestamp = memory.get('timestamp', 0) if isinstance(timestamp, (int, float)) and (current_time - timestamp) > self.short_term_duration: if self.should_transfer_to_long_term(memory): self.transfer_to_long_term_memory(memory['category'], memory['key']) else: self.short_term_memory.remove(memory) self.cleanup_short_term_memory() def periodic_memory_management(self): if (time.time() - self.last_cleanup_time) > 30: self.last_cleanup_time = time.time() self.review_and_transfer_memories() def transfer_to_long_term_memory(self, category, key): """Transfers an important memory from short-term to long-term storage.""" memory_to_transfer = None for mem in self.short_term_memory: if mem.get('category') == category and mem.get('key') == key: memory_to_transfer = mem break if memory_to_transfer: # Use the new add_long_term_memory to handle duplicates self.add_long_term_memory( memory_to_transfer['category'], memory_to_transfer['key'], memory_to_transfer['value'] ) # Remove from short-term memory to prevent re-transfer self.short_term_memory.remove(memory_to_transfer) self.save_memory(self.short_term_memory, self.short_term_file) def should_transfer_to_long_term(self, memory): return (memory.get('importance', 1) >= 7 or memory.get('access_count', 0) >= 3 or (memory.get('importance', 1) >= 5 and memory.get('access_count', 0) >= 2)) # Other methods (clear_all_memories, etc.) remain largely the same but benefit from consistent data def clear_short_term_memory(self): self.short_term_memory = [] self.save_memory(self.short_term_memory, self.short_term_file) def update_memory_importance(self, category, key, importance_change): for memory in self.short_term_memory: if memory.get('category') == category and memory.get('key') == key: memory['importance'] = max(1, min(10, memory.get('importance', 1) + importance_change)) self.save_memory(self.short_term_memory, self.short_term_file) break def clear_all_memories(self): self.short_term_memory = [] self.save_memory([], self.short_term_file) self.long_term_memory = [] self.save_memory([], self.long_term_file) print("All memory files have been cleared.") def _format_memory_for_display(self, memory): return memory def format_memory(self, memory): timestamp = memory.get('timestamp', 0) timestamp_str = datetime.fromtimestamp(timestamp).strftime("%H:%M:%S") if isinstance(timestamp, (int,float)) and timestamp > 0 else "N/A" formatted_memory = f"[{timestamp_str}] {memory.get('value', '')}" # --- Neurogenesis special card --- if memory.get('category') == 'neurogenesis': return ( f"
    " f"" f"{formatted_memory}" f"
    Neurogenesis
    " ) interaction_type = "Neutral" background_color = "#FFFACD" # Pastel yellow raw_value = memory.get('raw_value') if isinstance(raw_value, dict): total_effect = sum(float(val) for val in raw_value.values() if isinstance(val, (int, float))) if total_effect > 0: interaction_type = "Positive" background_color = "#D1FFD1" elif total_effect < 0: interaction_type = "Negative" background_color = "#FFD1DC" elif memory.get('category') == 'mental_state' and memory.get('key') == 'startled': interaction_type = "Negative" background_color = "#FFD1DC" return f"
    {formatted_memory}
    {interaction_type}
    " ================================================ FILE: src/mental_states.py ================================================ # Mental states from PyQt5 import QtCore, QtGui, QtWidgets import os class MentalState: def __init__(self, name, icon_filename): self.name = name self.icon_filename = icon_filename self.is_active = False self.icon_item = None class MentalStateManager: def __init__(self, squid, scene): self.squid = squid self.scene = scene self.icon_offset = QtCore.QPointF(0, -100) # Offset for all icons above the squid self.mental_states_enabled = True self.mental_states = { # List of possible mental states: "sick": MentalState("sick", "sick.png"), # SICK "thinking": MentalState("thinking", "think.png"), # THINKING - CURRENTLY UNUSED "startled": MentalState("startled", "startled.png"), # STARTLED "curious": MentalState("curious", "curious.png") # CURIOUS } def set_mental_states_enabled(self, enabled): self.mental_states_enabled = enabled if not enabled: self.clear_optional_states() def set_state(self, state_name, is_active): if state_name in self.mental_states: if state_name == "sick" or self.mental_states_enabled: self.mental_states[state_name].is_active = is_active self.update_mental_state_icons() def update_mental_state_icons(self): for state in self.mental_states.values(): if state.name == "sick" or self.mental_states_enabled: self.update_icon_state(state) def update_icon_state(self, state): if state.is_active: if state.icon_item is None: icon_pixmap = QtGui.QPixmap(os.path.join("images", state.icon_filename)) state.icon_item = QtWidgets.QGraphicsPixmapItem(icon_pixmap) state.icon_item.setZValue(500) # Below DIRTY text (z=1000) but above decorations self.scene.addItem(state.icon_item) self.update_icon_position(state.icon_item) else: if state.icon_item is not None: self.scene.removeItem(state.icon_item) state.icon_item = None def update_icon_position(self, icon_item): icon_item.setPos(self.squid.squid_x + self.squid.squid_width // 2 - icon_item.pixmap().width() // 2 + self.icon_offset.x(), self.squid.squid_y + self.icon_offset.y()) def update_positions(self): self.update_mental_state_icons() def is_state_active(self, state_name): if state_name == "sick" or self.mental_states_enabled: return self.mental_states.get(state_name, MentalState(state_name, "")).is_active return False def clear_optional_states(self): for state in self.mental_states.values(): if state.name != "sick": if state.icon_item is not None: self.scene.removeItem(state.icon_item) state.icon_item = None state.is_active = False ================================================ FILE: src/network_adapter.py ================================================ """ network_adapter.py - Adapter bridging Project 1's Network to Project 2's brain systems This adapter wraps a Network object and exposes it through the BrainProtocol interface, allowing custom networks with different topologies to plug into the brain visualization and learning systems. Usage: from NeuralNetwork.core import Network from network_adapter import NetworkAdapter # Create or load a custom network custom_network = Network() custom_network.add_neuron("input1", 50.0, (100, 100), 'input') ... # Wrap it for use with Dosidicus adapted_brain = NetworkAdapter(custom_network) # Now it can be used with BrainWidget, BrainWorker, etc. """ import json import random import time from typing import Dict, List, Tuple, Set, Any, Optional, TYPE_CHECKING from dataclasses import dataclass, field from network_protocol import ( BrainProtocol, BrainConfig, NeuronData, ConnectionData, LayerDefinition ) if TYPE_CHECKING: # Import Project 1's classes for type hints only from core import Network, Neuron, Connection, Config class NetworkAdapter: """ Adapter that wraps a Project 1 Network to implement the BrainProtocol interface required by Project 2's brain systems. Features: - Exposes network data in the format expected by BrainWidget - Supports dynamic neurogenesis (adding neurons at runtime) - Maintains layer structure for visualization - Handles serialization compatible with Dosidicus-2 save system Args: network: A Project 1 Network instance (or None to create fresh) layer_structure: Optional layer definitions for visualization config: Optional BrainConfig (created with defaults if not provided) """ def __init__(self, network: Optional['Network'] = None, layer_structure: Optional[List[LayerDefinition]] = None, config: Optional[BrainConfig] = None): # Import Project 1's Network if we need to create a new one if network is None: from core import Network as P1Network network = P1Network() self._network = network self._layer_structure = layer_structure or [] self._config = config or BrainConfig() # Project 2 compatibility: excluded neurons (status indicators) self._excluded_neurons: Set[str] = { 'is_sick', 'is_eating', 'pursuing_food', 'direction', 'is_sleeping' } # Track neurogenesis data (matches BrainWidget.neurogenesis_data structure) self._neurogenesis_data = { 'new_neurons': [], 'last_neuron_time': time.time(), 'new_neurons_details': {} } # Sync config with underlying network self._sync_config_to_network() # ========================================================================= # BRAINPROTOCOL REQUIRED PROPERTIES # ========================================================================= @property def state(self) -> Dict[str, float]: """Current activation state of each neuron (0-100 scale).""" return self._network.state @state.setter def state(self, value: Dict[str, float]): """Allow direct state assignment for compatibility.""" self._network.state = value @property def neuron_positions(self) -> Dict[str, Tuple[float, float]]: """Position of each neuron for visualization.""" return { name: neuron.get_position() for name, neuron in self._network.neurons.items() } @neuron_positions.setter def neuron_positions(self, value: Dict[str, Tuple[float, float]]): """Allow position updates for drag-and-drop support.""" for name, pos in value.items(): if name in self._network.neurons: self._network.neurons[name].set_position(*pos) @property def weights(self) -> Dict[Tuple[str, str], float]: """Connection weights as (source, target) -> weight mapping.""" return { (conn.source, conn.target): conn.get_weight() for conn in self._network.connections.values() } @weights.setter def weights(self, value: Dict[Tuple[str, str], float]): """Allow weight updates for Hebbian learning.""" for (source, target), weight in value.items(): key = (source, target) if key in self._network.connections: self._network.connections[key].set_weight(weight) elif source in self._network.neurons and target in self._network.neurons: # Create new connection self._network.connect(source, target, weight) @property def excluded_neurons(self) -> Set[str]: """Neurons excluded from Hebbian learning.""" return self._excluded_neurons @excluded_neurons.setter def excluded_neurons(self, value: Set[str]): """Allow updating excluded neurons.""" self._excluded_neurons = set(value) @property def config(self) -> BrainConfig: """Configuration object with hebbian and neurogenesis settings.""" return self._config @property def neurogenesis_data(self) -> Dict[str, Any]: """Neurogenesis tracking data (BrainWidget compatibility).""" return self._neurogenesis_data @neurogenesis_data.setter def neurogenesis_data(self, value: Dict[str, Any]): """Allow neurogenesis data updates.""" self._neurogenesis_data = value # ========================================================================= # BRAINPROTOCOL REQUIRED METHODS # ========================================================================= def get_neuron(self, name: str) -> Optional[NeuronData]: """Get neuron data by name.""" if name not in self._network.neurons: return None neuron = self._network.neurons[name] return NeuronData( name=neuron.name, neuron_type=neuron.type, position=neuron.get_position(), attributes=neuron.attributes or {} ) def get_all_neurons(self) -> List[NeuronData]: """Get list of all neurons.""" return [ NeuronData( name=n.name, neuron_type=n.type, position=n.get_position(), attributes=n.attributes or {} ) for n in self._network.neurons.values() ] def get_connections_for_neuron(self, name: str) -> List[ConnectionData]: """Get all connections involving a neuron (incoming and outgoing).""" connections = [] for (source, target), conn in self._network.connections.items(): if source == name or target == name: connections.append(ConnectionData( source=source, target=target, weight=conn.get_weight() )) return connections def add_neuron(self, neuron: NeuronData, initial_activation: float = 50.0) -> bool: """Add a new neuron to the network.""" success = self._network.add_neuron( name=neuron.name, value=initial_activation, position=neuron.position, n_type=neuron.neuron_type, attributes=neuron.attributes ) if success: # Track for neurogenesis data self._neurogenesis_data['new_neurons'].append(neuron.name) self._neurogenesis_data['last_neuron_time'] = time.time() return success def add_connection(self, source: str, target: str, weight: float) -> bool: """Add or update a connection.""" # Update existing connection if (source, target) in self._network.connections: self._network.connections[(source, target)].set_weight(weight) return True # Create new connection return self._network.connect(source, target, weight) def set_neuron_activation(self, name: str, value: float) -> None: """Set the activation value of a neuron.""" if name in self._network.state: self._network.state[name] = max(0.0, min(100.0, value)) def get_layer_structure(self) -> List[LayerDefinition]: """Get the layer structure of the network.""" return self._layer_structure # ========================================================================= # ADDITIONAL COMPATIBILITY METHODS (for BrainWidget) # ========================================================================= def initialize_connections(self) -> List[Tuple[str, str]]: """Return list of connection pairs (BrainWidget compatibility).""" return list(self._network.connections.keys()) def initialize_weights(self) -> None: """Initialize weights (no-op, weights already exist in network).""" pass @property def connections(self) -> List[Tuple[str, str]]: """Connection list for BrainWidget compatibility.""" return list(self._network.connections.keys()) @connections.setter def connections(self, value: List[Tuple[str, str]]): """Allow setting connections list.""" # This is typically read-only, but some code might try to set it pass @property def original_neuron_positions(self) -> Dict[str, Tuple[float, float]]: """Original positions for reset functionality.""" return { name: neuron.get_position() for name, neuron in self._network.neurons.items() } @property def original_neurons(self) -> List[str]: """List of original neuron names (non-neurogenesis).""" new_neurons = set(self._neurogenesis_data.get('new_neurons', [])) return [n for n in self._network.neurons.keys() if n not in new_neurons] @property def visible_neurons(self) -> Set[str]: """All neurons are visible by default.""" return set(self._network.neurons.keys()) @visible_neurons.setter def visible_neurons(self, value: Set[str]): """Visibility is typically managed elsewhere, but allow setting.""" pass @property def neuron_shapes(self) -> Dict[str, str]: """Get neuron shapes from attributes.""" shapes = {} for name, neuron in self._network.neurons.items(): if neuron.attributes and 'shape' in neuron.attributes: shapes[name] = neuron.attributes['shape'] else: # Default shape based on type shape_map = self._config.neurogenesis['appearance']['shapes'] shapes[name] = shape_map.get(neuron.type, 'circle') return shapes # ========================================================================= # SERIALIZATION # ========================================================================= def to_dict(self) -> Dict[str, Any]: """Serialize the entire network to a dictionary.""" return { 'version': '2.0', 'adapter_type': 'NetworkAdapter', 'neurons': { name: { 'type': n.type, 'position': n.get_position(), 'attributes': n.attributes } for name, n in self._network.neurons.items() }, 'connections': { f"{s}->{t}": c.get_weight() for (s, t), c in self._network.connections.items() }, 'state': dict(self._network.state), 'config': self._config.to_dict(), 'layer_structure': [l.to_dict() for l in self._layer_structure], 'excluded_neurons': list(self._excluded_neurons), 'neurogenesis_data': { 'new_neurons': self._neurogenesis_data.get('new_neurons', []), 'last_neuron_time': self._neurogenesis_data.get('last_neuron_time', 0), 'new_neurons_details': self._neurogenesis_data.get('new_neurons_details', {}) } } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'NetworkAdapter': """Deserialize a network from a dictionary.""" from core import Network as P1Network network = P1Network() # Load neurons for name, n_data in data.get('neurons', {}).items(): network.add_neuron( name=name, value=data.get('state', {}).get(name, 50.0), position=tuple(n_data.get('position', (0, 0))), n_type=n_data.get('type', 'hidden'), attributes=n_data.get('attributes', {}) ) # Load connections for key, weight in data.get('connections', {}).items(): source, target = key.split('->') network.connect(source, target, weight) # Load state network.state = data.get('state', {n: 50.0 for n in network.neurons}) # Load config config = BrainConfig.from_dict(data.get('config', {})) # Load layer structure layers = [ LayerDefinition( name=l['name'], neuron_names=l['neuron_names'], layer_type=l['layer_type'], y_position=l['y_position'] ) for l in data.get('layer_structure', []) ] # Create adapter adapter = cls(network, layers, config) # Load excluded neurons adapter._excluded_neurons = set(data.get('excluded_neurons', [])) # Load neurogenesis data adapter._neurogenesis_data = data.get('neurogenesis_data', { 'new_neurons': [], 'last_neuron_time': time.time(), 'new_neurons_details': {} }) return adapter def save(self, filepath: str) -> bool: """Save the network to a file.""" try: with open(filepath, 'w') as f: json.dump(self.to_dict(), f, indent=2) return True except Exception as e: print(f"Error saving network: {e}") return False @classmethod def load(cls, filepath: str) -> Optional['NetworkAdapter']: """Load a network from a file.""" try: with open(filepath, 'r') as f: data = json.load(f) return cls.from_dict(data) except Exception as e: print(f"Error loading network: {e}") return None # ========================================================================= # INTERNAL HELPERS # ========================================================================= def _sync_config_to_network(self): """Sync adapter config with underlying network config.""" if hasattr(self._network, 'config'): # Copy relevant settings to network's config self._network.config.hebbian.update(self._config.hebbian) self._network.config.neurogenesis.update(self._config.neurogenesis) def set_layer_structure(self, layers: List[LayerDefinition]): """Update the layer structure (and optionally reposition neurons).""" self._layer_structure = layers def get_underlying_network(self) -> 'Network': """Get the underlying Project 1 Network (for advanced access).""" return self._network class DosidictusDefaultBrain(NetworkAdapter): """ Pre-configured brain matching the default Dosidicus-2 structure. Creates the 7 core neurons (hunger, happiness, cleanliness, sleepiness, satisfaction, anxiety, curiosity) with standard positions and connections. Use this as a reference for custom brain designs. """ def __init__(self): from core import Network as P1Network network = P1Network() # Core neurons with standard positions core_neurons = { "hunger": ((127, 81), 'input'), "happiness": ((361, 81), 'hidden'), "cleanliness": ((627, 81), 'input'), "sleepiness": ((840, 81), 'input'), "satisfaction": ((271, 380), 'output'), "anxiety": ((491, 389), 'output'), "curiosity": ((701, 386), 'output'), } # Add neurons for name, (pos, n_type) in core_neurons.items(): network.add_neuron(name, 50.0, pos, n_type) # Standard connections (simplified - customize as needed) connections = [ ("hunger", "satisfaction", -0.5), ("hunger", "anxiety", 0.3), ("happiness", "satisfaction", 0.6), ("happiness", "anxiety", -0.4), ("cleanliness", "happiness", 0.3), ("cleanliness", "anxiety", -0.2), ("sleepiness", "curiosity", -0.3), ("satisfaction", "happiness", 0.4), ("anxiety", "curiosity", -0.2), ("anxiety", "happiness", -0.3), ("curiosity", "satisfaction", 0.2), ] for source, target, weight in connections: network.connect(source, target, weight) # Define layer structure layers = [ LayerDefinition("Inputs", ["hunger", "cleanliness", "sleepiness"], "input", 81), LayerDefinition("Processing", ["happiness"], "hidden", 200), LayerDefinition("Outputs", ["satisfaction", "anxiety", "curiosity"], "output", 385), ] super().__init__(network, layers) # Add status neurons as excluded self._excluded_neurons = { 'is_sick', 'is_eating', 'pursuing_food', 'direction', 'is_sleeping' } ================================================ FILE: src/network_protocol.py ================================================ """ network_protocol.py - Protocol definition for pluggable brain networks Defines the interface that any brain implementation must follow to work with the brain_tool, brain_widget, and neurogenesis systems. This allows custom networks to be swapped in while maintaining full compatibility with all visualization and learning features. """ from typing import Protocol, Dict, Tuple, List, Set, Any, Optional, runtime_checkable from dataclasses import dataclass @dataclass class NeuronData: """Standard data structure for neuron information.""" name: str neuron_type: str # 'input', 'hidden', 'output', 'novelty', 'stress', 'reward' position: Tuple[float, float] attributes: Dict[str, Any] def to_dict(self) -> Dict[str, Any]: return { 'name': self.name, 'type': self.neuron_type, 'position': self.position, 'attributes': self.attributes } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'NeuronData': return cls( name=data['name'], neuron_type=data.get('type', 'hidden'), position=tuple(data.get('position', (0, 0))), attributes=data.get('attributes', {}) ) @dataclass class ConnectionData: """Standard data structure for connection information.""" source: str target: str weight: float def to_dict(self) -> Dict[str, Any]: return { 'source': self.source, 'target': self.target, 'weight': self.weight } @dataclass class LayerDefinition: """Defines a layer in the network topology.""" name: str neuron_names: List[str] layer_type: str # 'input', 'hidden', 'output' y_position: float # Base Y position for visualization def to_dict(self) -> Dict[str, Any]: return { 'name': self.name, 'neuron_names': self.neuron_names, 'layer_type': self.layer_type, 'y_position': self.y_position } @runtime_checkable class BrainProtocol(Protocol): """ Protocol defining the interface a brain must implement to work with the Dosidicus-2 visualization and learning systems. Implementations should provide these properties and methods to be fully compatible with BrainWidget, BrainWorker, and EnhancedNeurogenesis. """ # ========================================================================= # REQUIRED PROPERTIES - Must be Dict-like with these exact names # ========================================================================= @property def state(self) -> Dict[str, float]: """Current activation state of each neuron (0-100 scale).""" ... @property def neuron_positions(self) -> Dict[str, Tuple[float, float]]: """Position of each neuron for visualization.""" ... @property def weights(self) -> Dict[Tuple[str, str], float]: """Connection weights as (source, target) -> weight mapping.""" ... @property def excluded_neurons(self) -> Set[str]: """Neurons excluded from Hebbian learning (status neurons, etc.).""" ... @property def config(self) -> Any: """Configuration object with hebbian and neurogenesis settings.""" ... # ========================================================================= # REQUIRED METHODS # ========================================================================= def get_neuron(self, name: str) -> Optional[NeuronData]: """Get neuron data by name.""" ... def get_all_neurons(self) -> List[NeuronData]: """Get list of all neurons.""" ... def get_connections_for_neuron(self, name: str) -> List[ConnectionData]: """Get all connections involving a neuron (incoming and outgoing).""" ... def add_neuron(self, neuron: NeuronData, initial_activation: float = 50.0) -> bool: """Add a new neuron to the network. Returns True on success.""" ... def add_connection(self, source: str, target: str, weight: float) -> bool: """Add or update a connection. Returns True on success.""" ... def set_neuron_activation(self, name: str, value: float) -> None: """Set the activation value of a neuron.""" ... def get_layer_structure(self) -> List[LayerDefinition]: """Get the layer structure of the network (for visualization).""" ... # ========================================================================= # SERIALIZATION # ========================================================================= def to_dict(self) -> Dict[str, Any]: """Serialize the entire network to a dictionary.""" ... @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'BrainProtocol': """Deserialize a network from a dictionary.""" ... def save(self, filepath: str) -> bool: """Save the network to a file.""" ... @classmethod def load(cls, filepath: str) -> Optional['BrainProtocol']: """Load a network from a file.""" ... class BrainConfig: """ Configuration class compatible with Dosidicus-2's config expectations. Provides both hebbian learning and neurogenesis configuration. """ def __init__(self): self.hebbian = { 'base_learning_rate': 0.1, 'active_threshold': 50, 'learning_interval': 30000, # milliseconds 'weight_decay': 0.01, 'min_weight': -1.0, 'max_weight': 1.0, } self.neurogenesis = { 'enabled_globally': True, 'novelty_threshold': 3.0, 'stress_threshold': 1.2, 'reward_threshold': 0.6, 'cooldown': 180, # seconds 'max_neurons': 50, 'max_per_type': { 'stress': 3, 'novelty': 5, 'reward': 4 }, 'max_per_specialization': 3, 'highlight_duration': 5.0, 'decay_rate': 0.75, 'appearance': { 'colors': { 'default': (200, 200, 200), 'input': (150, 220, 150), 'hidden': (150, 150, 220), 'output': (220, 150, 150), 'novelty': (255, 255, 150), 'stress': (255, 150, 150), 'reward': (173, 216, 230), }, 'shapes': { 'default': 'circle', 'input': 'square', 'hidden': 'circle', 'output': 'diamond', 'novelty': 'diamond', 'stress': 'square', 'reward': 'triangle', } } } def to_dict(self) -> Dict[str, Any]: return { 'hebbian': dict(self.hebbian), 'neurogenesis': dict(self.neurogenesis) } @classmethod def from_dict(cls, data: Dict[str, Any]) -> 'BrainConfig': config = cls() if 'hebbian' in data: config.hebbian.update(data['hebbian']) if 'neurogenesis' in data: config.neurogenesis.update(data['neurogenesis']) return config ================================================ FILE: src/neurogenesis.py ================================================ """ Neurogenesis ver3.3_stress_cap - STRICT 5-neuron cap for 'stress' type. - Emergency triggers STRENGTHEN existing neurons if cap is reached. - Anxiety reduction logic based on multipliers. """ import time import math import random from collections import deque from dataclasses import dataclass from typing import Dict, List, Tuple, Optional, Set, Any from PyQt5.QtCore import QTimer # Import localisation with robust fallback try: from localisation import loc except ImportError: def loc(key, default=None, **kwargs): return default if default is not None else key.replace('_', ' ').title() @dataclass class ExperienceContext: trigger_type: str active_neurons: Dict[str, float] recent_actions: List[str] environmental_state: Dict[str, any] outcome: str timestamp: float def get_pattern_signature(self) -> str: motivational_neurons = { k: v for k, v in self.active_neurons.items() if k in ['hunger', 'happiness', 'satisfaction', 'anxiety', 'curiosity', 'cleanliness', 'sleepiness'] } if not motivational_neurons: return f"{self.trigger_type}_{self.outcome}" primary_neuron, primary_value = max(motivational_neurons.items(), key=lambda x: abs(x[1] - 50)) def get_range(value): if value < 35: return "low" elif value > 65: return "high" else: return "mid" pattern_parts = [self.trigger_type, self.outcome, primary_neuron, get_range(primary_value)] meaningful_actions = [a for a in self.recent_actions[-3:] if a and a != 'none' and a != 'idle'] if meaningful_actions: last_action = meaningful_actions[-1].lower() if 'rock' in last_action: pattern_parts.append("rock") elif 'poop' in last_action: pattern_parts.append("poop") elif 'food' in last_action or 'eat' in last_action: pattern_parts.append("food") elif 'sleep' in last_action: pattern_parts.append("sleep") return "_".join(pattern_parts) def get_core_pattern(self) -> str: motivational_neurons = { k: v for k, v in self.active_neurons.items() if k in ['hunger', 'happiness', 'satisfaction', 'anxiety', 'curiosity', 'cleanliness', 'sleepiness'] } if not motivational_neurons: return f"{self.trigger_type}_{self.outcome}" primary_neuron, primary_value = max(motivational_neurons.items(), key=lambda x: abs(x[1] - 50)) intensity = "high" if primary_value > 60 or primary_value < 40 else "mid" return f"{self.trigger_type}_{self.outcome}_{primary_neuron}_{intensity}" def get_parent_pattern(self) -> str: motivational_neurons = {k: v for k, v in self.active_neurons.items() if not k.startswith('is_') and k not in [ 'position', 'direction', 'status', 'pursuing_food', 'novelty_exposure', 'sustained_stress', 'recent_rewards', 'personality', 'neurogenesis_active' ]} top_neurons = sorted(motivational_neurons.items(), key=lambda x: abs(x[1] - 50), reverse=True)[:2] pattern = f"{self.trigger_type}_{self.outcome}" for neuron, activation in top_neurons: if activation < 40: pattern += f"_{neuron}_low" elif activation > 60: pattern += f"_{neuron}_high" return pattern class ExperienceBuffer: def __init__(self, max_size=50): self.buffer = deque(maxlen=max_size) self.pattern_counts = {} self.parent_pattern_counts = {} self.core_pattern_counts = {} self._max_pattern_entries = 500 def add_experience(self, context: ExperienceContext): self.buffer.append(context) pattern = context.get_pattern_signature() self.pattern_counts[pattern] = self.pattern_counts.get(pattern, 0) + 1 parent = context.get_parent_pattern() self.parent_pattern_counts[parent] = self.parent_pattern_counts.get(parent, 0) + 1 core = context.get_core_pattern() self.core_pattern_counts[core] = self.core_pattern_counts.get(core, 0) + 1 self._prune_pattern_counts_if_needed() def _prune_pattern_counts_if_needed(self): if len(self.pattern_counts) > self._max_pattern_entries: recent_patterns = {exp.get_pattern_signature() for exp in self.buffer} self.pattern_counts = {k: v for k, v in self.pattern_counts.items() if k in recent_patterns} if len(self.parent_pattern_counts) > self._max_pattern_entries // 2: recent_parents = {exp.get_parent_pattern() for exp in self.buffer} self.parent_pattern_counts = {k: v for k, v in self.parent_pattern_counts.items() if k in recent_parents} if len(self.core_pattern_counts) > self._max_pattern_entries // 4: recent_cores = {exp.get_core_pattern() for exp in self.buffer} self.core_pattern_counts = {k: v for k, v in self.core_pattern_counts.items() if k in recent_cores} def get_pattern_recurrence(self, context: ExperienceContext) -> Tuple[str, int, str]: specific = context.get_pattern_signature() parent = context.get_parent_pattern() core = context.get_core_pattern() specific_count = self.pattern_counts.get(specific, 0) parent_count = self.parent_pattern_counts.get(parent, 0) core_count = self.core_pattern_counts.get(core, 0) if specific_count >= 2: return ('specific', specific_count, specific) if parent_count >= 3: return ('parent', parent_count, parent) if core_count >= 5: return ('core', core_count, core) if specific_count >= parent_count and specific_count >= core_count: return ('specific', specific_count, specific) elif parent_count >= core_count: return ('parent', parent_count, parent) else: return ('core', core_count, core) def to_dict(self): return { 'pattern_counts': dict(self.pattern_counts), 'parent_pattern_counts': dict(self.parent_pattern_counts), 'core_pattern_counts': dict(self.core_pattern_counts), 'buffer_size': len(self.buffer), 'recent_experiences': [ { 'trigger_type': exp.trigger_type, 'active_neurons': exp.active_neurons, 'recent_actions': exp.recent_actions, 'environmental_state': exp.environmental_state, 'outcome': exp.outcome, 'timestamp': exp.timestamp } for exp in list(self.buffer) ] } @classmethod def from_dict(cls, data): buf = cls(max_size=data.get('buffer_size', 50)) buf.pattern_counts = dict(data.get('pattern_counts', {})) buf.parent_pattern_counts = dict(data.get('parent_pattern_counts', {})) buf.core_pattern_counts = dict(data.get('core_pattern_counts', {})) for exp in data.get('recent_experiences', []): ctx = ExperienceContext( trigger_type=exp['trigger_type'], active_neurons={k: float(v) if isinstance(v, (int, float)) else 50.0 for k, v in exp.get('active_neurons', {}).items()}, recent_actions=exp.get('recent_actions', []), environmental_state=exp.get('environmental_state', {}), outcome=exp.get('outcome', 'neutral'), timestamp=exp.get('timestamp', time.time()) ) buf.buffer.append(ctx) return buf class FunctionalNeuron: def __init__(self, name: str, neuron_type: str, creation_context: ExperienceContext): self.name = name self.neuron_type = neuron_type self.creation_context = creation_context self.specialization = self._determine_specialization() self.activation_count = 0 self.last_activated = 0 self.utility_score = 0.0 self.strength_multiplier = 1.0 @property def display_name(self) -> str: type_key = f"neuron_type_{self.neuron_type}" type_default = self.neuron_type.capitalize() type_str = loc(type_key, default=type_default) spec_key = f"spec_{self.specialization}" spec_default = self.specialization.replace('_', ' ').title() spec_str = loc(spec_key, default=spec_default) parts = self.name.split('_') suffix = "" if parts[-1].isdigit(): suffix = f" {parts[-1]}" return loc("neuron_name_format", default="{type}: {spec}{suffix}", type=type_str, spec=spec_str, suffix=suffix) @classmethod def from_dict(cls, data): creation_ctx = data['creation_context'] active_neurons_data = creation_ctx.get('active_neurons') or creation_ctx.get('brain_state', {}) ctx = ExperienceContext( trigger_type=creation_ctx['trigger_type'], active_neurons=active_neurons_data, recent_actions=creation_ctx['recent_actions'], environmental_state=creation_ctx['environmental_state'], outcome=creation_ctx['outcome'], timestamp=creation_ctx['timestamp'], ) neuron = cls.__new__(cls) neuron.name = data['name'] neuron.neuron_type = data['neuron_type'] neuron.creation_context = ctx neuron.specialization = data['specialization'] neuron.activation_count = data['activation_count'] neuron.last_activated = data['last_activated'] neuron.utility_score = data['utility_score'] neuron.strength_multiplier = data['strength_multiplier'] return neuron def to_dict(self): return { 'name': self.name, 'neuron_type': self.neuron_type, 'specialization': self.specialization, 'activation_count': self.activation_count, 'last_activated': self.last_activated, 'utility_score': self.utility_score, 'strength_multiplier': self.strength_multiplier, 'creation_context': { 'trigger_type': self.creation_context.trigger_type, 'timestamp': self.creation_context.timestamp, 'active_neurons': self.creation_context.active_neurons, 'recent_actions': self.creation_context.recent_actions, 'environmental_state': self.creation_context.environmental_state, 'outcome': self.creation_context.outcome, } } def _determine_specialization(self): ctx = self.creation_context if ctx.trigger_type == 'reward': if ctx.environmental_state.get('is_eating', False): return 'feeding_satisfaction' elif ctx.active_neurons.get('cleanliness', 50) > 70 and ctx.outcome == 'positive': return 'cleanliness_reward' elif ctx.active_neurons.get('sleepiness', 50) < 30 and ctx.outcome == 'positive': return 'rest_reward' else: return 'general_reward' elif ctx.trigger_type == 'stress': if ctx.active_neurons.get('hunger', 50) > 70: return 'hunger_stress_response' elif ctx.active_neurons.get('cleanliness', 50) < 30: return 'filth_avoidance' elif ctx.active_neurons.get('anxiety', 50) > 70: return 'anxiety_regulation' else: return 'general_stress_coping' elif ctx.trigger_type == 'novelty': if ctx.environmental_state.get('has_rock', False) or 'rock' in str(ctx.environmental_state): return 'object_investigation' elif 'new_location' in ctx.recent_actions: return 'exploration_memory' else: return 'general_novelty_processing' return 'undefined' def get_functional_connections(self, all_neurons: List[str]) -> Dict[str, float]: connections = {} ctx = self.creation_context for neuron, activation in ctx.active_neurons.items(): if neuron in all_neurons: try: activation_value = float(activation) if isinstance(activation, (int, float, str)) else 50.0 except (ValueError, TypeError): activation_value = 50.0 deviation = abs(activation_value - 50) if deviation > 20: weight = (deviation / 50) * 0.8 if activation_value < 50: weight = -weight connections[neuron] = weight spec_connections = self._get_specialization_connections(all_neurons) connections.update(spec_connections) return connections def _get_specialization_connections(self, all_neurons: List[str]) -> Dict[str, float]: connections = {} # === STRESS SPECIFIC LOGIC FOR INHIBITING ANXIETY === if self.neuron_type == 'stress': # All stress neurons should naturally inhibit anxiety to simulate coping if 'anxiety' in all_neurons: connections['anxiety'] = -1.0 if self.specialization == 'feeding_satisfaction': if 'hunger' in all_neurons: connections['hunger'] = -0.7 if 'happiness' in all_neurons: connections['happiness'] = 0.6 if 'satisfaction' in all_neurons: connections['satisfaction'] = 0.8 elif self.specialization == 'hunger_stress_response': if 'hunger' in all_neurons: connections['hunger'] = 0.7 if 'anxiety' in all_neurons: connections['anxiety'] = -0.8 # Inhibition if 'curiosity' in all_neurons: connections['curiosity'] = 0.4 elif self.specialization == 'filth_avoidance': if 'cleanliness' in all_neurons: connections['cleanliness'] = -0.8 if 'anxiety' in all_neurons: connections['anxiety'] = 0.6 # This causes anxiety elif self.specialization == 'anxiety_regulation': if 'anxiety' in all_neurons: connections['anxiety'] = -1.0 # Strong inhibition if 'happiness' in all_neurons: connections['happiness'] = 0.4 if 'satisfaction' in all_neurons: connections['satisfaction'] = 0.3 elif self.specialization == 'general_stress_coping': if 'anxiety' in all_neurons: connections['anxiety'] = -0.9 elif self.specialization == 'object_investigation': if 'curiosity' in all_neurons: connections['curiosity'] = 0.7 if 'anxiety' in all_neurons: connections['anxiety'] = -0.4 elif self.specialization == 'rest_reward': if 'sleepiness' in all_neurons: connections['sleepiness'] = -0.6 if 'satisfaction' in all_neurons: connections['satisfaction'] = 0.5 if 'happiness' in all_neurons: connections['happiness'] = 0.4 elif self.specialization == 'cleanliness_reward': if 'cleanliness' in all_neurons: connections['cleanliness'] = 0.6 if 'satisfaction' in all_neurons: connections['satisfaction'] = 0.5 if 'anxiety' in all_neurons: connections['anxiety'] = -0.3 return connections def calculate_activation(self, brain_state: Dict[str, float], weights: Dict[Tuple[str, str], float]) -> float: activation = 50.0 for (source, target), weight in weights.items(): if target == self.name and source in brain_state: source_activation = float(brain_state[source]) influence = (source_activation - 50.0) * weight activation += influence activation = 50.0 + (activation - 50.0) * self.strength_multiplier activation = max(0.0, min(100.0, activation)) if abs(activation - 50.0) > 15.0: self.activation_count += 1 self.last_activated = time.time() return activation def update_utility_score(self, outcome_value: float): alpha = 0.3 self.utility_score = alpha * outcome_value + (1 - alpha) * self.utility_score class EnhancedNeurogenesis: def __init__(self, brain_widget, config): self.brain_widget = brain_widget self.config = config self.experience_buffer = ExperienceBuffer() self.functional_neurons: Dict[str, FunctionalNeuron] = {} self.novelty_neuron_count = 0 self._awarded_neurons = set() self.last_neurogenesis_time = 0 self.neurons_created_this_session = 0 self.last_creation_by_type = {'novelty': 0, 'stress': 0, 'reward': 0} self._first_real_tick = None self.recent_actions = deque(maxlen=10) self.last_states = deque(maxlen=5) self._on_neuron_created_callback = None self._on_neuron_leveled_callback = None def _get_stress_neuron_count(self) -> int: """Count current stress neurons.""" return len([n for n, fn in self.functional_neurons.items() if getattr(fn, 'neuron_type', '') == 'stress']) def _get_anxiety_cap(self) -> float: """ Calculate the maximum anxiety allowed based on stress neuron count. Each stress neuron reduces the cap by 10, representing growing resilience. 0 neurons: cap = 100 (no protection) 1 neuron: cap = 90 2 neurons: cap = 80 3 neurons: cap = 70 4 neurons: cap = 60 5 neurons: cap = 50 (maximum resilience - anxiety can never exceed 50!) """ stress_count = self._get_stress_neuron_count() cap = 100.0 - (stress_count * 10.0) return max(50.0, cap) # Floor at 50 even if somehow more than 5 neurons def _get_scaled_relief(self, base_amount: float, is_emergency: bool = False) -> float: """ Calculate anxiety relief that scales with existing stress neuron count. More neurons = stronger relief (cumulative coping mechanisms). Base formula: base_amount * (1 + 0.5 * stress_count) - 0 neurons: 1.0x multiplier - 1 neuron: 1.5x multiplier - 2 neurons: 2.0x multiplier - 3 neurons: 2.5x multiplier - 4 neurons: 3.0x multiplier - 5 neurons: 3.5x multiplier Emergency adds an additional 1.5x multiplier. """ stress_count = self._get_stress_neuron_count() multiplier = 1.0 + (0.5 * stress_count) if is_emergency: multiplier *= 1.5 return base_amount * multiplier def _apply_anxiety_relief(self, base_drop: float, source: str = "stress_neuron", is_emergency: bool = False) -> float: """ Apply scaled anxiety relief to BOTH the brain_widget state AND the actual squid. Relief scales with stress neuron count, and respects the anxiety cap. Args: base_drop: Base amount to reduce anxiety by (will be scaled) source: Description for logging is_emergency: Whether this is an emergency situation (extra multiplier) Returns: The new anxiety value """ old_anxiety = self.brain_widget.state.get('anxiety', 50) stress_count = self._get_stress_neuron_count() anxiety_cap = self._get_anxiety_cap() scaled_drop = self._get_scaled_relief(base_drop, is_emergency) # Apply the drop new_anxiety = max(0.0, old_anxiety - scaled_drop) # Also enforce the cap (in case anxiety was already above it) new_anxiety = min(new_anxiety, anxiety_cap) # 1. Update brain_widget state (for visualization) self.brain_widget.state['anxiety'] = new_anxiety # 2. CRITICAL: Also update the actual squid's anxiety! if (hasattr(self.brain_widget, 'tamagotchi_logic') and self.brain_widget.tamagotchi_logic and hasattr(self.brain_widget.tamagotchi_logic, 'squid') and self.brain_widget.tamagotchi_logic.squid): squid = self.brain_widget.tamagotchi_logic.squid squid.anxiety = new_anxiety emergency_str = " [EMERGENCY]" if is_emergency else "" print(f" ✅ {source}{emergency_str}: Anxiety {old_anxiety:.1f} → {new_anxiety:.1f} " f"(drop: -{scaled_drop:.1f}, cap: {anxiety_cap:.0f}, neurons: {stress_count})") else: print(f" ⚠️ {source}: Could not access squid - relief only applied to brain_widget!") return new_anxiety def enforce_anxiety_cap(self) -> None: """ Enforce the anxiety cap based on current stress neuron count. Call this periodically to ensure anxiety never exceeds the cap. """ anxiety_cap = self._get_anxiety_cap() # Enforce on brain_widget state if 'anxiety' in self.brain_widget.state: current = self.brain_widget.state['anxiety'] if current > anxiety_cap: self.brain_widget.state['anxiety'] = anxiety_cap # Enforce on actual squid if (hasattr(self.brain_widget, 'tamagotchi_logic') and self.brain_widget.tamagotchi_logic and hasattr(self.brain_widget.tamagotchi_logic, 'squid') and self.brain_widget.tamagotchi_logic.squid): squid = self.brain_widget.tamagotchi_logic.squid if squid.anxiety > anxiety_cap: old = squid.anxiety squid.anxiety = anxiety_cap print(f" 🛡️ Anxiety cap enforced: {old:.1f} → {anxiety_cap:.1f} (stress neurons: {self._get_stress_neuron_count()})") def create_neuron(self, neuron_type: str, context: Optional[ExperienceContext] = None, brain_state: Optional[Dict[str, float]] = None, environment: Optional[Dict[str, Any]] = None, trigger_value: Optional[float] = None, is_emergency: bool = False) -> Optional[str]: if context is None: if brain_state is None: brain_state = dict(self.brain_widget.state) if environment is None: environment = {} context = self._build_context(neuron_type, brain_state, environment) return self._create_neuron_internal(context, trigger_value, is_emergency) def _make_reciprocal_connections(self, new_neuron: str): bw = self.brain_widget created = [] MIN_RECIPROCAL = 0.2 outgoing = [(tgt, w) for (src, tgt), w in bw.weights.items() if src == new_neuron and abs(w) >= MIN_RECIPROCAL] for target, w in outgoing: if (target, new_neuron) in bw.weights: continue bw.weights[(target, new_neuron)] = w created.append(f"{target}→{new_neuron}:{w:+.2f}") if created: print(f" 🔗 {loc('log_reciprocal_links', default='Reciprocal links added')}: {', '.join(created)}") def create_functional_neuron(self, ctx: ExperienceContext, is_emergency: bool = False) -> Optional[str]: return self._create_neuron_internal(ctx, is_emergency=is_emergency) def _build_context(self, trigger_type: str, brain_state: Dict[str, float], environment: Dict[str, Any]) -> ExperienceContext: clean_neurons = {k: float(v) if isinstance(v, (int, float)) else 50.0 for k, v in brain_state.items() if not k.startswith('is_') and k not in ['novelty_exposure', 'sustained_stress', 'recent_rewards', 'neurogenesis_active', 'personality', 'pursuing_food', 'position', 'direction', 'status']} happiness = brain_state.get('happiness', 50) anxiety = brain_state.get('anxiety', 50) if happiness > 60: outcome = 'positive' elif anxiety > 70: outcome = 'negative' else: outcome = 'neutral' return ExperienceContext(trigger_type=trigger_type, active_neurons=clean_neurons, recent_actions=list(self.recent_actions)[-5:], environmental_state=environment, outcome=outcome, timestamp=time.time()) def _create_neuron_internal(self, ctx: ExperienceContext, trigger_value_for_log: Optional[float] = None, is_emergency: bool = False) -> Optional[str]: trigger_type = ctx.trigger_type # 0. CHECK EMERGENCY CONTEXT if not is_emergency and trigger_type == 'stress': if ctx.active_neurons.get('anxiety', 50) >= 90: is_emergency = True print(f"🚨 {loc('log_emergency_context', default='Emergency context detected (Anxiety > 90)')}") # 1. HARD TYPE CAP & STRENGTHENING LOGIC # FORCE CAP OF 5 FOR STRESS NEURONS default_caps = {'stress': 5, 'novelty': 6, 'reward': 6, 'connector': 10} max_for_this_type = self.config.neurogenesis.get('max_per_type', default_caps).get(trigger_type, 5) # Ensure stress is strictly 5 if trigger_type == 'stress': max_for_this_type = 5 current_type_count = len([name for name, fn in self.functional_neurons.items() if fn.neuron_type == trigger_type]) # IF CAP REACHED: STRENGTHEN EXISTING (Even in Emergency) if current_type_count >= max_for_this_type: msg = loc('log_type_cap_reached', default="Type cap reached for {type} ({count}/{max}), strengthening existing", type=trigger_type, count=current_type_count, max=max_for_this_type) print(f" {msg}") # If emergency, we perform a strengthening action as the coping mechanism if is_emergency: print(f" 💪 Emergency: Boosting stress tolerance via existing neurons.") # [FIXED] Apply SCALED relief to BOTH brain_widget AND squid if 'anxiety' in self.brain_widget.state: self._apply_anxiety_relief(15.0, "Emergency strengthen", is_emergency=True) self._strengthen_existing_neuron(trigger_type, self._preview_specialization(ctx)) return None # 2. SPECIALIZATION spec = self._preview_specialization(ctx) base_name = f"{trigger_type}_{spec}" # 3. GLOBAL NEURON LIMIT current_total = len(self.brain_widget.neuron_positions) - len(self.brain_widget.excluded_neurons) max_neurons = self.config.neurogenesis.get('max_neurons', 32) if current_total >= max_neurons and not is_emergency: print(f" Max neurons reached ({current_total}/{max_neurons})") return None elif is_emergency and current_total >= max_neurons: print(f"⚠️ {loc('log_global_cap_bypass', default='Emergency override: Bypassing global neuron limit!')}") neuron_name = self._get_unique_neuron_name(base_name) func_neuron = FunctionalNeuron(neuron_name, trigger_type, ctx) self.functional_neurons[neuron_name] = func_neuron if trigger_type == 'novelty': self.novelty_neuron_count += 1 self.last_creation_by_type[trigger_type] = time.time() self.neurons_created_this_session += 1 position = self._calculate_functional_position(func_neuron) self.brain_widget.neuron_positions[neuron_name] = position self._set_neuron_appearance(neuron_name, func_neuron) self.brain_widget.state[neuron_name] = 50.0 # [FIXED] Immediate Anxiety Relief upon creation - SCALED based on existing neurons! if trigger_type == 'stress' and 'anxiety' in self.brain_widget.state: base_drop = 15.0 # Base drop amount (will be scaled by helper) new_anxiety = self._apply_anxiety_relief(base_drop, f"Stress Neuron '{neuron_name}' created", is_emergency=is_emergency) print(f" 📉 Stress Neuron Created: Anxiety now at {new_anxiety:.1f} (cap: {self._get_anxiety_cap():.0f})") connections = func_neuron.get_functional_connections(list(self.brain_widget.neuron_positions.keys())) for target, weight in connections.items(): if abs(weight) < 0.05: continue self.brain_widget.weights[(neuron_name, target)] = weight self._make_reciprocal_connections(neuron_name) if hasattr(self.brain_widget, 'visible_neurons'): self.brain_widget.visible_neurons.add(neuron_name) self.brain_widget.neurogenesis_highlight = {'neuron': neuron_name, 'start_time': time.time(), 'duration': 8.0 if trigger_type == 'connector' else 4.0, 'pulse_phase': 0} self._log_neuron_creation(neuron_name, trigger_type, spec, trigger_value_for_log) self._record_neurogenesis_memory(neuron_name) return neuron_name def _record_neurogenesis_memory(self, neuron_name: str): """Permanently records a new neuron growth event in long-term memory.""" try: if (hasattr(self.brain_widget, 'tamagotchi_logic') and self.brain_widget.tamagotchi_logic and hasattr(self.brain_widget.tamagotchi_logic, 'squid') and self.brain_widget.tamagotchi_logic.squid and hasattr(self.brain_widget.tamagotchi_logic.squid, 'memory_manager')): mm = self.brain_widget.tamagotchi_logic.squid.memory_manager mm.add_long_term_memory( 'neurogenesis', f'grew_neuron_{neuron_name}', f'GREW A NEW NEURON!: {neuron_name}' ) except Exception as e: print(f"⚠️ Could not record neurogenesis memory: {e}") def _on_neuron_created(self, neuron_name: str, neuron_type: str): self._trigger_link_toggle_effect() def _get_unique_neuron_name(self, base_name: str) -> str: if base_name not in self.brain_widget.neuron_positions: return base_name counter = 2 while True: candidate = f"{base_name}_{counter}" if candidate not in self.brain_widget.neuron_positions: return candidate counter += 1 def _rebuild_new_neurons_details(self): core = {'hunger', 'happiness', 'cleanliness', 'sleepiness', 'satisfaction', 'anxiety', 'curiosity'} details = self.brain_widget.neurogenesis_data.setdefault('new_neurons_details', {}) for name, fn in self.functional_neurons.items(): if name in core or name in self.brain_widget.excluded_neurons: continue if name not in details: details[name] = {'created_at': fn.creation_context.timestamp, 'trigger_type': fn.neuron_type, 'trigger_value_at_creation': 0, 'specialisation': fn.specialization, 'display_name': fn.display_name} details[name]['display_name'] = fn.display_name def _rebuild_new_neurons_details_for_lab(self): self._rebuild_new_neurons_details() def _preview_specialization(self, ctx: ExperienceContext) -> str: if ctx.trigger_type == 'reward': if ctx.environmental_state.get('is_eating', False): return 'feeding_satisfaction' if ctx.active_neurons.get('cleanliness', 50) > 70 and ctx.outcome == 'positive': return 'cleanliness_reward' if ctx.active_neurons.get('sleepiness', 50) < 30 and ctx.outcome == 'positive': return 'rest_reward' return 'general_reward' if ctx.trigger_type == 'stress': if ctx.active_neurons.get('hunger', 50) > 70: return 'hunger_stress_response' if ctx.active_neurons.get('cleanliness', 50) < 30: return 'filth_avoidance' if ctx.active_neurons.get('anxiety', 50) > 70: return 'anxiety_regulation' return 'general_stress_coping' if ctx.trigger_type == 'novelty': if ctx.environmental_state.get('has_rock', False) or 'rock' in str(ctx.environmental_state): return 'object_investigation' if 'new_location' in ctx.recent_actions: return 'exploration_memory' return 'general_novelty_processing' return 'undefined' def _calculate_functional_position(self, func_neuron: FunctionalNeuron) -> Tuple[float, float]: all_neurons = list(self.brain_widget.neuron_positions.keys()) connections = func_neuron.get_functional_connections(all_neurons) if not connections: return (random.randint(100, 900), random.randint(100, 600)) total_weight = 0 center_x, center_y = 0, 0 for target, weight in connections.items(): if target in self.brain_widget.neuron_positions: pos = self.brain_widget.neuron_positions[target] abs_weight = abs(weight) center_x += pos[0] * abs_weight center_y += pos[1] * abs_weight total_weight += abs_weight if total_weight > 0: center_x /= total_weight center_y /= total_weight offset_x = random.randint(-80, 80) offset_y = random.randint(-80, 80) x = max(50, min(974, center_x + offset_x)) y = max(50, min(668, center_y + offset_y)) return (x, y) return (random.randint(100, 900), random.randint(100, 600)) def rescue_orphan(self, orphan_name: str): connector_type = 'connector' neuron_name = self._get_unique_neuron_name(f"{connector_type}_rescue") ctx = ExperienceContext(trigger_type=connector_type, active_neurons=self.brain_widget.state.copy(), recent_actions=[], environmental_state={'orphan_rescue': True}, outcome='neutral', timestamp=time.time()) func_neuron = FunctionalNeuron(neuron_name, connector_type, ctx) func_neuron.specialization = 'network_bridge' self.functional_neurons[neuron_name] = func_neuron orphan_pos = self.brain_widget.neuron_positions.get(orphan_name, (500, 300)) center_x, center_y = 512, 384 new_x = (orphan_pos[0] + center_x) / 2 + random.randint(-50, 50) new_y = (orphan_pos[1] + center_y) / 2 + random.randint(-50, 50) self.brain_widget.neuron_positions[neuron_name] = (new_x, new_y) self.brain_widget.state[neuron_name] = 50.0 binary_neurons = {"can_see_food", "is_eating", "is_sleeping", "is_sick", "is_fleeing", "pursuing_food", "is_startled", "external_stimulus", "plant_proximity"} candidates = [n for n in self.brain_widget.neuron_positions.keys() if n != orphan_name and n != neuron_name and n not in self.brain_widget.excluded_neurons and n not in binary_neurons] targets = [] if candidates: def get_dist_sq(n_name): pos = self.brain_widget.neuron_positions[n_name] return (pos[0] - orphan_pos[0])**2 + (pos[1] - orphan_pos[1])**2 candidates.sort(key=get_dist_sq) targets.append(candidates.pop(0)) if candidates: targets.append(random.choice(candidates)) weight = random.uniform(0.5, 0.9) if random.random() > 0.5: self.brain_widget.weights[(neuron_name, orphan_name)] = weight else: self.brain_widget.weights[(orphan_name, neuron_name)] = weight for target in targets: w = random.uniform(-0.5, 0.8) if abs(w) < 0.2: w = 0.3 if random.random() > 0.5: self.brain_widget.weights[(neuron_name, target)] = w else: self.brain_widget.weights[(target, neuron_name)] = w self._set_neuron_appearance(neuron_name, func_neuron) if hasattr(self.brain_widget, 'visible_neurons'): self.brain_widget.visible_neurons.add(neuron_name) self.brain_widget.neurogenesis_highlight = {'neuron': neuron_name, 'start_time': time.time(), 'duration': 8.0, 'pulse_phase': 0} self.brain_widget.log_neurogenesis_event(neuron_name, "created", details={'trigger_type': 'connector', 'trigger_value': 1.0, 'specialization': 'orphan_rescue', 'display_name': func_neuron.display_name}) self._record_neurogenesis_memory(neuron_name) print(f"🔗 Connector neuron {neuron_name} created to rescue {orphan_name} (connected to closest: {targets[0] if targets else 'None'})") def _set_neuron_appearance(self, name: str, func_neuron: FunctionalNeuron): spec = func_neuron.specialization neuron_type = func_neuron.neuron_type shape_map = {'novelty': 'diamond', 'stress': 'square', 'reward': 'triangle', 'connector': 'hexagon'} assigned_shape = shape_map.get(neuron_type, 'circle') self.brain_widget.neuron_shapes[name] = assigned_shape print(f"🔧 SET NEURON APPEARANCE: {name} -> type={neuron_type}, shape={assigned_shape}") if neuron_type == 'connector': self.brain_widget.state_colors[name] = (50, 51, 100) elif 'stress' in spec or 'anxiety' in spec: self.brain_widget.state_colors[name] = (255, 150, 150) elif 'reward' in spec or 'satisfaction' in spec: self.brain_widget.state_colors[name] = (150, 255, 150) elif 'investigation' in spec or 'exploration' in spec: self.brain_widget.state_colors[name] = (255, 215, 0) else: color_map = {'novelty': (255, 255, 150), 'stress': (255, 0, 0), 'reward': (173, 216, 230)} self.brain_widget.state_colors[name] = color_map.get(neuron_type, (200, 200, 255)) def _log_neuron_creation(self, name: str, trigger_type: str, spec: str, trigger_value: Optional[float]): display_name = self.functional_neurons[name].display_name self.brain_widget.log_neurogenesis_event(name, "created", details={'trigger_type': trigger_type, 'trigger_value': trigger_value or 0, 'specialization': spec, 'display_name': display_name}) def _strengthen_existing_neuron(self, trigger_type: str, specialization: str): prefix = f"{trigger_type}_{specialization}" existing = [(name, neuron) for name, neuron in self.functional_neurons.items() if name.startswith(prefix)] if not existing: brain_neurons = [name for name in self.brain_widget.neuron_positions.keys() if name.startswith(prefix)] if brain_neurons: self._ensure_functional_neuron(brain_neurons[0], trigger_type, specialization) if brain_neurons[0] in self.functional_neurons: existing = [(brain_neurons[0], self.functional_neurons[brain_neurons[0]])] # If no specific specialization match, fallback to any neuron of the same type # This ensures coping mechanisms upgrade ANY stress neuron if the specific one is missing if not existing and trigger_type == 'stress': existing = [(name, neuron) for name, neuron in self.functional_neurons.items() if neuron.neuron_type == 'stress'] if not existing: return existing.sort(key=lambda x: x[1].utility_score, reverse=True) best_name, best_neuron = existing[0] best_neuron.strength_multiplier += 0.5 best_neuron.utility_score += 0.1 self.brain_widget.communication_events[best_name] = time.time() self.brain_widget.update() # We manually format the string to ensure the variables are injected raw_msg = loc('log_strengthened_neuron', default="Strengthened: {name} (multiplier: {mult}x)") formatted_msg = raw_msg.format(name=best_neuron.display_name, mult=f"{best_neuron.strength_multiplier:.1f}") print(f" 💪 {formatted_msg}") if self._on_neuron_leveled_callback: try: self._on_neuron_leveled_callback(best_name, best_neuron.strength_multiplier) except Exception as e: print(f"Level callback error: {e}") self.brain_widget.update() def _ensure_functional_neuron(self, name: str, neuron_type: str = None, specialization: str = None) -> Optional[FunctionalNeuron]: if name in self.functional_neurons: return self.functional_neurons[name] if name not in self.brain_widget.neuron_positions: return None if neuron_type is None: if name.startswith('novelty'): neuron_type = 'novelty' elif name.startswith('stress'): neuron_type = 'stress' elif name.startswith('reward'): neuron_type = 'reward' else: neuron_type = 'novelty' ctx = ExperienceContext(trigger_type=neuron_type, active_neurons=dict(self.brain_widget.state), recent_actions=[], environmental_state={}, outcome='neutral', timestamp=time.time()) func_neuron = FunctionalNeuron(name, neuron_type, ctx) if specialization: func_neuron.specialization = specialization self.functional_neurons[name] = func_neuron print(f" {loc('log_converted_neuron', default='Converted {name} to FunctionalNeuron', name=name)}") return func_neuron def ensure_all_neurons_functional(self, force_sync=False): core_neurons = ['hunger', 'happiness', 'cleanliness', 'sleepiness', 'satisfaction', 'anxiety', 'curiosity'] excluded = getattr(self.brain_widget, 'excluded_neurons', []) for name in list(self.brain_widget.neuron_positions.keys()): if name in core_neurons or name in excluded: continue if name not in self.functional_neurons: self._ensure_functional_neuron(name) restored_positions = 0 restored_states = 0 restored_visible = 0 for name, fn in self.functional_neurons.items(): if name in core_neurons or name in excluded: continue if name not in self.brain_widget.neuron_positions: position = self._calculate_functional_position(fn) self.brain_widget.neuron_positions[name] = position restored_positions += 1 if name not in self.brain_widget.state: self.brain_widget.state[name] = 50.0 restored_states += 1 if hasattr(self.brain_widget, 'visible_neurons'): if name not in self.brain_widget.visible_neurons: restored_visible += 1 self.brain_widget.visible_neurons.add(name) self._set_neuron_appearance(name, fn) all_neurons = list(self.brain_widget.neuron_positions.keys()) connections = fn.get_functional_connections(all_neurons) for target, weight in connections.items(): if (name, target) not in self.brain_widget.weights: self.brain_widget.weights[(name, target)] = weight self._rebuild_new_neurons_details() new_neurons_list = self.brain_widget.neurogenesis_data.setdefault('new_neurons', []) restored_to_list = 0 for name, fn in self.functional_neurons.items(): if name in core_neurons or name in excluded: continue if name not in new_neurons_list: new_neurons_list.append(name) restored_to_list += 1 restored_count = len([n for n in self.functional_neurons if n not in core_neurons and n not in excluded]) if restored_count > 0: print(f"✅ {loc('log_sync_complete', default='Neurogenesis sync complete')}: {restored_count}") def set_achievement_callbacks(self, on_created=None, on_leveled=None): self._on_neuron_created_callback = on_created self._on_neuron_leveled_callback = on_leveled def get_global_cooldown_remaining(self) -> float: if not self.functional_neurons: return 0.0 current_time = time.time() last_creation = max(n.creation_context.timestamp for n in self.functional_neurons.values()) global_cooldown = self.config.neurogenesis.get('cooldown', 60) time_since_last = current_time - last_creation remaining = global_cooldown - time_since_last return max(0.0, remaining) def track_action(self, action: str): self.recent_actions.append(action) def track_state_change(self, state: dict): self.last_states.append(state.copy()) def check_and_capture_experience(self, brain_state: dict, environment: dict): trigger_type = self._detect_trigger_type(brain_state, environment) if trigger_type: self.capture_experience_context(trigger_type=trigger_type, brain_state=brain_state, recent_actions=list(self.recent_actions), environment=environment) def _detect_trigger_type(self, brain_state: dict, environment: dict) -> Optional[str]: anxiety = brain_state.get('anxiety', 50) satisfaction = brain_state.get('satisfaction', 50) curiosity = brain_state.get('curiosity', 50) happiness = brain_state.get('happiness', 50) if anxiety > 75: return 'stress' if environment.get('new_object_encountered', False) or curiosity > 70: return 'novelty' if environment.get('recent_positive_outcome', False) or satisfaction > 70 or happiness > 70: return 'reward' if len(self.last_states) > 0: prev_anxiety = self.last_states[-1].get('anxiety', 50) if prev_anxiety > 60 and anxiety < 40: return 'stress' return None def capture_experience_context(self, trigger_type: str, brain_state: dict, recent_actions: list, environment: dict) -> ExperienceContext: if self._first_real_tick is None: self._first_real_tick = time.time() if not isinstance(recent_actions, list): recent_actions = [] ctx = self._build_context(trigger_type, brain_state, environment) ctx.recent_actions = recent_actions[-5:] if recent_actions else [] elapsed = time.time() - self._first_real_tick if elapsed < 1.5: return ctx is_sleeping = brain_state.get('is_sleeping', False) anxiety = brain_state.get('anxiety', 50) satisfaction = brain_state.get('satisfaction', 50) if is_sleeping and anxiety < 15 and satisfaction > 85: return ctx self.experience_buffer.add_experience(ctx) return ctx def should_create_neuron(self, ctx: ExperienceContext) -> bool: current_time = time.time() if ctx.trigger_type == 'stress' and ctx.active_neurons.get('anxiety', 50) >= 95: print(f"🚨 {loc('log_emergency_forcing_neuron', default='EMERGENCY: Critical anxiety - forcing stress neuron!')}") return True if self._first_real_tick is None: return False elapsed = current_time - self._first_real_tick if elapsed < 5.0: return False pattern = ctx.get_pattern_signature() if len(pattern.split('_')) < 4: return False if self.experience_buffer.pattern_counts.get(pattern, 0) > 20: return False current_count = len(self.brain_widget.neuron_positions) max_neurons = self.config.neurogenesis.get('max_neurons', 32) if current_count >= max_neurons: return False global_cooldown = self.config.neurogenesis.get('cooldown', 60) default_time = self._first_real_tick or time.time() last_creation = max((n.creation_context.timestamp for n in self.functional_neurons.values()), default=default_time) if current_time - last_creation < global_cooldown: return False pattern_level, count, _ = self.experience_buffer.get_pattern_recurrence(ctx) thresholds = {'specific': 2, 'parent': 3, 'core': 5} if count < thresholds.get(pattern_level, 2): return False return True def update_neuron_activations(self, brain_state: Dict[str, float]) -> None: # 1. Normalize inputs for key in list(brain_state.keys()): if not isinstance(brain_state[key], (int, float)): try: brain_state[key] = float(brain_state[key]) except (ValueError, TypeError): brain_state[key] = 50.0 # 2. Standard Neural Activation for name, func_neuron in self.functional_neurons.items(): if name in self.brain_widget.state: activation = func_neuron.calculate_activation(brain_state, self.brain_widget.weights) self.brain_widget.state[name] = activation # 3. === SCALED STRESS REDUCTION MECHANIC === # Each stress neuron contributes to anxiety suppression, with scaling based on count if 'anxiety' in brain_state: current_anxiety = brain_state['anxiety'] stress_count = self._get_stress_neuron_count() anxiety_cap = self._get_anxiety_cap() # First, enforce the cap - anxiety should never exceed the cap if current_anxiety > anxiety_cap: current_anxiety = anxiety_cap brain_state['anxiety'] = anxiety_cap self.brain_widget.state['anxiety'] = anxiety_cap total_reduction = 0.0 for name, fn in self.functional_neurons.items(): if getattr(fn, 'neuron_type', '') == 'stress': multiplier = getattr(fn, 'strength_multiplier', 1.0) # Force stress neuron to activate proportionally to anxiety driven = 50.0 + (current_anxiety - 50.0) * 1.2 driven = max(50.0, min(100.0, driven)) current_val = self.brain_widget.state.get(name, 50.0) final_val = max(current_val, driven) self.brain_widget.state[name] = final_val if final_val > 55: infl = (final_val - 50.0) / 50.0 # Base force per neuron, scaled by strength multiplier reduction_force = 25.0 * multiplier * infl total_reduction += reduction_force if total_reduction > 0: # Scale reduction based on stress neuron count (cumulative resilience) # More neurons = faster anxiety reduction count_multiplier = 1.0 + (0.3 * stress_count) # 1.0 to 2.5x total_reduction *= count_multiplier # Crisis response multipliers if current_anxiety > 80: total_reduction *= 3.0 # Strong crisis response elif current_anxiety > 60: total_reduction *= 1.5 # Apply reduction with timestep factor reduction_amount = total_reduction * 0.3 new_anxiety = max(0.0, current_anxiety - reduction_amount) # Enforce cap again after reduction new_anxiety = min(new_anxiety, anxiety_cap) # Apply to brain state dictionaries brain_state['anxiety'] = new_anxiety self.brain_widget.state['anxiety'] = new_anxiety # CRITICAL: Also apply to the actual squid! if (hasattr(self.brain_widget, 'tamagotchi_logic') and self.brain_widget.tamagotchi_logic and hasattr(self.brain_widget.tamagotchi_logic, 'squid') and self.brain_widget.tamagotchi_logic.squid): squid = self.brain_widget.tamagotchi_logic.squid # Also enforce cap on squid's current anxiety before we compare if squid.anxiety > anxiety_cap: squid.anxiety = anxiety_cap old_squid_anxiety = squid.anxiety squid.anxiety = new_anxiety if reduction_amount > 1.0: # Only log significant reductions print(f" 🧘 Stress suppression ({stress_count} neurons): " f"{old_squid_anxiety:.1f} → {new_anxiety:.1f} " f"(-{reduction_amount:.1f}, cap: {anxiety_cap:.0f})") # 4. Visualizations if not getattr(self.brain_widget, 'animations_enabled', True): return if not hasattr(self.brain_widget, 'trigger_activation_pulse'): return now = time.time() for (src, dst), weight in self.brain_widget.weights.items(): if abs(weight) < 0.15: continue seed = hash((src, dst)) % 1_000_000 / 1_000_000.0 excite = seed * 0.85 + 0.15 if excite < 0.22: continue skip = int(3 + seed * 9) if int(now * 60) % skip != 0: continue src_act = brain_state.get(src, 50.0) influence = (src_act - 50.0) * weight if abs(influence) < 6.0: continue if influence > 0: base_hue = 65 + seed * 25; sat = 70 + seed * 40; val = 120 + seed * 30 else: base_hue = 5 + seed * 20; sat = 75 + seed * 35; val = 115 + seed * 30 rgb = self._hsv_to_rgb(base_hue, sat, val) alpha = 80 + int(seed * 60) colour = (*rgb, alpha) duration = 1.5 + seed * 1.5 speed = 0.3 + seed * 0.3 self.brain_widget.weight_animations.append({ 'pair': (src, dst), 'start_time': now, 'duration': duration, 'start_weight': weight, 'end_weight': weight, 'neuron1': src, 'neuron2': dst, 'color': colour, 'pulse_speed': speed }) def _hsv_to_rgb(self, h, s, v): s, v = s / 255.0, v / 255.0 c = v * s x = c * (1 - abs((h / 60.0) % 2 - 1)) m = v - c if h < 60: r, g, b = c, x, 0 elif h <120: r, g, b = x, c, 0 elif h <180: r, g, b = 0, c, x elif h <240: r, g, b = 0, x, c elif h <300: r, g, b = x, 0, c else: r, g, b = c, 0, x return int((r + m) * 255), int((g + m) * 255), int((b + m) * 255) def intelligent_pruning(self) -> Optional[str]: candidates = [] for name, func_neuron in self.functional_neurons.items(): if func_neuron.neuron_type == 'connector': continue if time.time() - func_neuron.creation_context.timestamp < 300: continue score = 0.0 score += func_neuron.utility_score * 0.4 recency = time.time() - func_neuron.last_activated if recency < 300: score += 0.3 elif recency < 1800: score += 0.15 similar_count = sum(1 for n in self.functional_neurons.values() if n.specialization == func_neuron.specialization) if similar_count == 1: score += 0.3 total_strength = sum(abs(w) for (a, b), w in self.brain_widget.weights.items() if a == name or b == name) score += min(total_strength / 5.0, 0.3) candidates.append((name, score)) if not candidates: return None candidates.sort(key=lambda x: x[1]) neuron_to_prune = candidates[0][0] if neuron_to_prune in self.brain_widget.neuron_positions: del self.brain_widget.neuron_positions[neuron_to_prune] if neuron_to_prune in self.brain_widget.state: del self.brain_widget.state[neuron_to_prune] for conn in list(self.brain_widget.weights.keys()): if neuron_to_prune in conn: del self.brain_widget.weights[conn] if neuron_to_prune in self.functional_neurons: fn = self.functional_neurons[neuron_to_prune] if fn.neuron_type == 'novelty': self.novelty_neuron_count -= 1 del self.functional_neurons[neuron_to_prune] print(f"🗑️ {loc('log_pruned', default='Pruned')}: {neuron_to_prune}") return neuron_to_prune def to_dict(self) -> dict: return { 'functional_neurons': {name: neuron.to_dict() for name, neuron in self.functional_neurons.items()}, 'experience_buffer': self.experience_buffer.to_dict(), 'novelty_neuron_count': self.novelty_neuron_count, 'neurons_created_this_session': self.neurons_created_this_session, 'last_creation_by_type': self.last_creation_by_type.copy(), 'awarded_neurons': list(self._awarded_neurons) } def from_dict(self, data: dict): self.functional_neurons = {} for name, neuron_data in data.get('functional_neurons', {}).items(): self.functional_neurons[name] = FunctionalNeuron.from_dict(neuron_data) if 'experience_buffer' in data: self.experience_buffer = ExperienceBuffer.from_dict(data['experience_buffer']) self.novelty_neuron_count = data.get('novelty_neuron_count', 0) self.neurons_created_this_session = data.get('neurons_created_this_session', 0) self.last_creation_by_type = data.get('last_creation_by_type', {'novelty': 0, 'stress': 0, 'reward': 0}) self._awarded_neurons = set(data.get('awarded_neurons', [])) self.ensure_all_neurons_functional() self._rebuild_new_neurons_details_for_lab() def reset_state(self): self.functional_neurons.clear() self.experience_buffer = ExperienceBuffer() self.novelty_neuron_count = 0 self.neurons_created_this_session = 0 self.last_creation_by_type = {'novelty': 0, 'stress': 0, 'reward': 0} self._awarded_neurons.clear() self._first_real_tick = None print("🔄 Neurogenesis state reset") class NeurogenesisTriggerSystem: def __init__(self, tamagotchi_logic): self.logic = tamagotchi_logic self.recent_actions = deque(maxlen=10) self.last_states = deque(maxlen=5) def track_action(self, action: str): self.recent_actions.append(action) def track_state_change(self, state: Dict[str, float]): self.last_states.append(state.copy()) def check_for_significant_experience(self) -> Optional[Tuple[str, ExperienceContext]]: if len(self.last_states) < 2: return None current = self.last_states[-1] previous = self.last_states[-2] state_change = 0 for key in ['hunger', 'happiness', 'satisfaction', 'anxiety', 'curiosity']: if key in current and key in previous: state_change += abs(current.get(key, 50) - previous.get(key, 50)) if state_change < 5 and not getattr(self.logic, 'new_object_encountered', False): return None if self._detect_novelty_experience(current, previous): context = self._build_context('novelty', current) return ('novelty', context) if self._detect_stress_experience(current, previous): context = self._build_context('stress', current) return ('stress', context) if self._detect_reward_experience(current, previous): context = self._build_context('reward', current) return ('reward', context) return None def _detect_novelty_experience(self, current, previous) -> bool: if current.get('is_sleeping', False): return False current_curiosity = current.get('curiosity', 50) previous_curiosity = previous.get('curiosity', 50) curiosity_delta = current_curiosity - previous_curiosity if current_curiosity >= 99 or current_curiosity <= 1: return False new_object = getattr(self.logic, 'new_object_encountered', False) meaningful_spike = curiosity_delta > 15 and current_curiosity > 40 return meaningful_spike or new_object def _detect_stress_experience(self, current, previous) -> bool: if current.get('is_sleeping', False) and current.get('anxiety', 50) < 40: return False current_anxiety = current.get('anxiety', 50) previous_anxiety = previous.get('anxiety', 50) current_hunger = current.get('hunger', 50) if current_anxiety <= 5: return False anxiety_high = current_anxiety > 65 and previous_anxiety > 60 anxiety_spike = (current_anxiety - previous_anxiety) > 15 and current_anxiety > 50 hunger_crisis = current_hunger > 85 and (current_hunger - previous.get('hunger', 50)) >= 0 return anxiety_high or anxiety_spike or hunger_crisis def _detect_reward_experience(self, current, previous) -> bool: if current.get('is_sleeping', False): happiness = current.get('happiness', 50) satisfaction = current.get('satisfaction', 50) if happiness >= 90 and satisfaction >= 90: return False current_happiness = current.get('happiness', 50) previous_happiness = previous.get('happiness', 50) current_satisfaction = current.get('satisfaction', 50) previous_satisfaction = previous.get('satisfaction', 50) happiness_delta = current_happiness - previous_happiness satisfaction_delta = current_satisfaction - previous_satisfaction if (current_happiness >= 99 and previous_happiness >= 99) or \ (current_satisfaction >= 99 and previous_satisfaction >= 99): return False if current_happiness >= 95 and happiness_delta < 3: return False if current_satisfaction >= 95 and satisfaction_delta < 3: return False positive_outcome = getattr(self.logic, 'recent_positive_outcome', False) significant_happiness = happiness_delta > 15 and current_happiness > 40 significant_satisfaction = satisfaction_delta > 15 and current_satisfaction > 40 return significant_happiness or significant_satisfaction or positive_outcome def _build_context(self, trigger_type: str, current_state: Dict) -> ExperienceContext: filtered_neurons = {k: v for k, v in current_state.items() if not k.startswith('is_') and k not in [ 'novelty_exposure', 'sustained_stress', 'recent_rewards', 'neurogenesis_active', 'personality', 'pursuing_food', 'direction', 'is_startled', 'is_fleeing', 'is_sick']} return ExperienceContext( trigger_type=trigger_type, active_neurons=filtered_neurons, recent_actions=list(self.recent_actions), environmental_state={ 'food_count': len(self.logic.food_items), 'poop_count': len(self.logic.poop_items), 'is_sick': self.logic.squid.is_sick, 'is_eating': current_state.get('is_eating', False), 'has_rock': hasattr(self.logic, 'rock_items') and len(self.logic.rock_items) > 0 }, outcome='neutral', timestamp=time.time() ) ================================================ FILE: src/neurogenesis_show.py ================================================ """ ShowmanNeurogenesis v2.5.0 Wrapper around EnhancedNeurogenesis that keeps all the *real* logic but guarantees the player *sees* a neuron when something cool happens. """ import time import random from typing import Optional, Callable, Dict, Any from enum import Enum class NeurogenesisEvent(Enum): """Semantic events that can trigger neurogenesis for dramatic effect""" FIRST_FEEDING = "first_feeding" FIRST_ROCK = "first_rock" FIRST_SLEEP = "first_sleep" ANXIETY_SPIKE = "anxiety_spike" ANXIETY_RELIEF = "anxiety_relief" NOVEL_OBJECT = "novel_object" RECOVERED_FROM_ILLNESS = "recovered" MAX_HAPPINESS = "max_happiness" HUNGER_CRISIS = "hunger_crisis" HUNGER_SATISFIED = "hunger_satisfied" SPOTLESS_TANK = "spotless_tank" CURIOSITY_EXPLOSION = "curiosity_explosion" FIRST_DECORATION_PUSH = "first_decoration_push" class ShowmanNeurogenesis: """ Wrapper around EnhancedNeurogenesis that keeps all the *real* logic but guarantees the player *sees* a neuron when something cool happens. The showmanship feature can be disabled via config.ini [Neurogenesis] showmanship = False When disabled, this wrapper becomes a pure passthrough to the real engine. """ def __init__(self, real_enhanced_neurogenesis): self.en = real_enhanced_neurogenesis # the real system self.config = self.en.config self.last_showman_creation = 0 # our own cooldown self.showman_cooldown = 15.0 # seconds between "show" neurons # Check if showmanship is enabled in config self._showmanship_enabled = self._check_showmanship_enabled() # Track events we've already triggered (for first-time events) self._triggered_events: set = set() # Achievement integration callbacks self._on_dramatic_neuron_callback: Optional[Callable] = None self._on_event_triggered_callback: Optional[Callable[[NeurogenesisEvent], None]] = None # Name pools for dramatic neuron naming self.name_pool = { 'novelty': [ "encountered_novel_object", # saw something new "exploration_reward", # payoff for investigating "novel_positive_experience", # moves toward new thing "wonder_trigger", # pure "what's that?" spark "discovery_satisfaction" # aha-feeling after exploring ], 'stress': [ "anxiety_reduction", "trauma", "extreme_stress", "threat_avoidance", "comfort_seeking" ], 'reward': [ "food_reward", # payoff for eating "cleanliness_reward", # payoff for getting clean "rest_reward", # payoff for sleeping "play_reward", # payoff for fun interactions "achievement_reward" # payoff for completing any goal ] } # Extended name pools for more variety self.extended_name_pool = { 'novelty': [ "positive_experience", "investigation_drive", "new_experience", "wonder_neuron", "exploration_joy", "discovery_rush" ], 'stress': [ "calm_restore", "fear_dampener", "safety_signal", "relaxation_trigger", "peace_seeker", "tension_release" ], 'reward': [ "satisfaction_burst", "happiness_boost", "contentment_signal", "pleasure_response", "joy_neuron", "fulfillment_marker" ] } def _check_showmanship_enabled(self) -> bool: """Check if showmanship is enabled in config. Config is single source of truth.""" # Try to get from config object if hasattr(self.config, 'neurogenesis'): ng_config = self.config.neurogenesis if isinstance(ng_config, dict): return ng_config.get('showmanship', True) # Try config manager style access if hasattr(self.config, 'get_showmanship_enabled'): return self.config.get_showmanship_enabled() # Try direct config access if hasattr(self.config, 'getboolean'): try: return self.config.getboolean('Neurogenesis', 'showmanship', fallback=True) except: pass # Default to enabled return True def is_showmanship_enabled(self) -> bool: """Public getter for showmanship state. Re-checks config each time.""" return self._check_showmanship_enabled() def set_callbacks(self, on_dramatic_neuron=None, on_event_triggered=None): """Set callbacks for external integration (achievements, etc.)""" self._on_dramatic_neuron_callback = on_dramatic_neuron self._on_event_triggered_callback = on_event_triggered # -------------- public API – same as EnhancedNeurogenesis -------------- def get_global_cooldown_remaining(self) -> float: """ Return the REAL cooldown from the underlying EnhancedNeurogenesis system. This matches the cooldown shown in console and actually blocks creation. """ return self.en.get_global_cooldown_remaining() def capture_experience_context(self, trigger, state, actions, env): """Delegate to real system""" return self.en.capture_experience_context(trigger, state, actions, env) def should_create_neuron(self, ctx) -> bool: """ Showman wrapper: decide whether a neuron should be created. Emergency anxiety ≥ 100 bypasses EVERYTHING and returns True instantly. Otherwise delegates to the real EnhancedNeurogenesis logic. """ # ===================================================================== # EMERGENCY BYPASS: anxiety == 100 → create immediately if ctx.trigger_type == 'stress' and ctx.active_neurons.get('anxiety', 50) >= 95: print(f"🚨 EMERGENCY OVERRIDE: anxiety={ctx.active_neurons.get('anxiety')} - creating stress neuron NOW!") # (Optionally lift specialization cap for this single creation) original_max = self.en.config.neurogenesis.get('max_per_specialization', 3) self.en.config.neurogenesis['max_per_specialization'] = original_max + 1 # Restore cap after creation (done in create_functional_neuron) return True # ===================================================================== # Normal path – use real system (respects cooldowns, caps, patterns) return self.en.should_create_neuron(ctx) def create_functional_neuron(self, ctx, is_emergency: bool = False) -> Optional[str]: """Create the neuron through the real system""" # Ask real system to do the heavy lifting real_name = self.en.create_functional_neuron(ctx, is_emergency=is_emergency) if real_name is None: return None # If showmanship disabled, return the real name without cosmetic changes if not self.is_showmanship_enabled(): return real_name # ---- cosmetic upgrade (showmanship enabled) ---- pretty_name = self._rename_for_drama(ctx.trigger_type, real_name) if pretty_name != real_name: # Migrate everything to the new name self._migrate_neuron(real_name, pretty_name) # Bigger, longer highlight for the player burst_color = self._burst_color(ctx.trigger_type) self.en.brain_widget.neurogenesis_highlight = { 'neuron': pretty_name, 'start_time': time.time(), 'duration': 10.0, # longer pulse 'pulse_phase': 0, 'is_emergency': False, 'is_showman': True, # flag for renderer (optional) 'color_burst': burst_color } self.last_showman_creation = time.time() # Fire callback for achievement integration if self._on_dramatic_neuron_callback: try: self._on_dramatic_neuron_callback(pretty_name, ctx.trigger_type) except Exception as e: print(f"Showman callback error: {e}") return pretty_name # ------------------ Event Detection ------------------ def _detect_dramatic_moment(self, ctx) -> Optional[NeurogenesisEvent]: """Detect visually significant moments - VERY PERMISSIVE""" recent = ' '.join(ctx.recent_actions[-3:]).lower() if ctx.recent_actions else '' # ANY anxiety spike over 70 (not 90) if ctx.active_neurons.get('anxiety', 50) >= 70: return NeurogenesisEvent.ANXIETY_SPIKE # ANY feeding moment (not just after crisis) if ctx.trigger_type == 'reward' and ('eat' in recent or 'food' in recent): return NeurogenesisEvent.HUNGER_SATISFIED # ANY curiosity over 75 (not 95) if ctx.active_neurons.get('curiosity', 50) >= 75: return NeurogenesisEvent.CURIOSITY_EXPLOSION # Clean tank (allow multiple) if ctx.active_neurons.get('cleanliness', 50) >= 90: return NeurogenesisEvent.SPOTLESS_TANK # High happiness (allow multiple) if ctx.active_neurons.get('happiness', 50) >= 85: return NeurogenesisEvent.MAX_HAPPINESS # ANY decoration interaction if 'decoration' in recent or 'push' in recent: return NeurogenesisEvent.FIRST_DECORATION_PUSH return None def _fire_event(self, event: NeurogenesisEvent): """Record event and fire callback""" self._triggered_events.add(event) if self._on_event_triggered_callback: try: self._on_event_triggered_callback(event) except Exception as e: print(f"Event callback error: {e}") def trigger_event(self, event: NeurogenesisEvent) -> bool: """ Manually trigger a dramatic event from external code. Useful for achievement system integration. Returns True if a neuron was created. Note: Respects showmanship config flag - returns False if disabled. """ # Check if showmanship is enabled if not self.is_showmanship_enabled(): return False if event in self._triggered_events: return False # Already triggered now = time.time() if now - self.last_showman_creation < self.showman_cooldown: return False # Map events to trigger types event_to_trigger = { NeurogenesisEvent.FIRST_FEEDING: 'reward', NeurogenesisEvent.FIRST_ROCK: 'novelty', NeurogenesisEvent.FIRST_SLEEP: 'reward', NeurogenesisEvent.ANXIETY_SPIKE: 'stress', NeurogenesisEvent.ANXIETY_RELIEF: 'stress', NeurogenesisEvent.NOVEL_OBJECT: 'novelty', NeurogenesisEvent.RECOVERED_FROM_ILLNESS: 'reward', NeurogenesisEvent.MAX_HAPPINESS: 'reward', NeurogenesisEvent.HUNGER_CRISIS: 'stress', NeurogenesisEvent.HUNGER_SATISFIED: 'reward', NeurogenesisEvent.SPOTLESS_TANK: 'reward', NeurogenesisEvent.CURIOSITY_EXPLOSION: 'novelty', NeurogenesisEvent.FIRST_DECORATION_PUSH: 'novelty', } trigger_type = event_to_trigger.get(event, 'novelty') # Create a minimal context from .neurogenesis import ExperienceContext ctx = ExperienceContext( trigger_type=trigger_type, active_neurons=dict(self.en.brain_widget.state), recent_actions=[event.value], environmental_state={}, outcome='positive', timestamp=now ) print(f"🎭 Manual event trigger: {event.value}") self._fire_event(event) # Create the neuron result = self.create_functional_neuron(ctx) return result is not None # ------------------ internal helpers ------------------ def _moment_deserves_neuron(self, ctx) -> bool: """ Return True if this is a *visually* cool moment for the player. Keep the rules simple and transparent. DEPRECATED: Use _detect_dramatic_moment instead. """ return self._detect_dramatic_moment(ctx) is not None def _rename_for_drama(self, trigger: str, old: str) -> str: """Pick a cinematic name that still encodes the specialization.""" pool = self.name_pool.get(trigger, []) extended = self.extended_name_pool.get(trigger, []) all_names = pool + extended if not all_names: return old # Keep pool unique per session used = {n for n in self.en.functional_neurons.keys() if n in all_names} available = [n for n in all_names if n not in used] if available: return available[0] # Fallback – append digit so we never duplicate root = pool[0] if pool else old.split('_')[0] counter = 2 while f"{root}_{counter}" in self.en.functional_neurons: counter += 1 return f"{root}_{counter}" def _migrate_neuron(self, old_name: str, new_name: str): bw = self.en.brain_widget # Positions if old_name in bw.neuron_positions: bw.neuron_positions[new_name] = bw.neuron_positions.pop(old_name) # State if old_name in bw.state: bw.state[new_name] = bw.state.pop(old_name) # Colours if hasattr(bw, 'state_colors') and old_name in bw.state_colors: bw.state_colors[new_name] = bw.state_colors.pop(old_name) # Shapes if hasattr(bw, 'neuron_shapes') and old_name in bw.neuron_shapes: bw.neuron_shapes[new_name] = bw.neuron_shapes.pop(old_name) # Weights – rebuild keys new_weights = {} for (src, dst), w in bw.weights.items(): src = new_name if src == old_name else src dst = new_name if dst == old_name else dst new_weights[(src, dst)] = w bw.weights = new_weights # Functional neurons dict if old_name in self.en.functional_neurons: self.en.functional_neurons[new_name] = self.en.functional_neurons.pop(old_name) self.en.functional_neurons[new_name].name = new_name # FIX: Update visible_neurons set if hasattr(bw, 'visible_neurons'): if old_name in bw.visible_neurons: bw.visible_neurons.discard(old_name) bw.visible_neurons.add(new_name) # FIX: Update neuron_reveal_animations if hasattr(bw, 'neuron_reveal_animations'): if old_name in bw.neuron_reveal_animations: bw.neuron_reveal_animations[new_name] = bw.neuron_reveal_animations.pop(old_name) # FIX: Update communication_events if hasattr(bw, 'communication_events'): if old_name in bw.communication_events: bw.communication_events[new_name] = bw.communication_events.pop(old_name) # FIX: Update weight_change_events if hasattr(bw, 'weight_change_events'): new_weight_events = {} for key, event_time in bw.weight_change_events.items(): if "->" in key: src, dst = key.split("->", 1) else: # Fallback in case of malformed key continue src = new_name if src == old_name else src dst = new_name if dst == old_name else dst new_key = f"{src}->{dst}" new_weight_events[new_key] = event_time bw.weight_change_events = new_weight_events # FIX: Update link animation dicts for attr in ['_link_opacities', '_link_targets', '_link_start_times', '_link_fade_speeds']: if hasattr(bw, attr): old_dict = getattr(bw, attr) new_dict = {} for key, val in old_dict.items(): if isinstance(key, tuple) and len(key) == 2: src, dst = key src = new_name if src == old_name else src dst = new_name if dst == old_name else dst new_dict[(src, dst)] = val else: # Skip malformed keys continue setattr(bw, attr, new_dict) def _burst_color(self, trigger: str) -> tuple: """Bright colour flash for the neuron birth.""" base_colors = { 'novelty': (255, 215, 0), # gold 'stress': (255, 100, 100), # crimson 'reward': (100, 255, 100) # mint } # Add some variation base = base_colors.get(trigger, (200, 200, 255)) # Slight random variation for visual interest r = max(0, min(255, base[0] + random.randint(-20, 20))) g = max(0, min(255, base[1] + random.randint(-20, 20))) b = max(0, min(255, base[2] + random.randint(-20, 20))) return (r, g, b) def get_triggered_events(self) -> set: """Return set of events that have been triggered""" return self._triggered_events.copy() def reset_events(self): """Reset triggered events for new game""" self._triggered_events.clear() self.last_showman_creation = 0 # -------------- passthrough anything we don't override -------------- def __getattr__(self, item): return getattr(self.en, item) ================================================ FILE: src/personality.py ================================================ from enum import Enum class Personality(Enum): TIMID = "timid" ADVENTUROUS = "adventurous" LAZY = "lazy" ENERGETIC = "energetic" INTROVERT = "introvert" GREEDY = "greedy" STUBBORN = "stubborn" ================================================ FILE: src/personality_traits.py ================================================ from enum import Enum from .personality import Personality personality_traits = {} def register_personality(name, decision_function, attribute_modifiers): # Base goal-oriented modifiers base_modifiers = { "organization_urgency": 1.0, "rock_interaction_chance": 1.0, "plant_seeking_urgency": 1.0, "goal_persistence": 1.0 } # Personality-specific overrides if name == Personality.ADVENTUROUS: base_modifiers.update({ "organization_urgency": 1.8, "rock_interaction_chance": 2.2, "goal_persistence": 1.5, "exploration_bonus": 1.3 }) elif name == Personality.TIMID: base_modifiers.update({ "organization_urgency": 0.6, "rock_interaction_chance": 0.4, "plant_seeking_urgency": 1.2, # Timid squids prefer plants "goal_persistence": 0.7 }) elif name == Personality.LAZY: base_modifiers.update({ "organization_urgency": 0.3, "rock_interaction_chance": 0.8, "goal_persistence": 0.5 }) elif name == Personality.ENERGETIC: base_modifiers.update({ "organization_urgency": 1.5, "rock_interaction_chance": 1.7, "goal_persistence": 1.8 }) elif name == Personality.GREEDY: base_modifiers.update({ "organization_urgency": 0.9, "rock_interaction_chance": 1.1, "food_seeking_priority": 2.0 # Overrides other goals when hungry }) elif name == Personality.STUBBORN: base_modifiers.update({ "organization_urgency": 1.2, "rock_interaction_chance": 0.3, "goal_persistence": 2.0 # Very persistent once committed }) # Combine with passed modifiers attribute_modifiers.update(base_modifiers) personality_traits[name] = { "decision_function": decision_function, "attribute_modifiers": attribute_modifiers, "goal_weights": { "organize": base_modifiers["organization_urgency"], "interact": base_modifiers["rock_interaction_chance"], "clean": base_modifiers["plant_seeking_urgency"] } } return name # Register all personality types def register_all_personalities(): register_personality( Personality.TIMID, lambda squid: squid.anxiety * 1.5, # Decision function {"anxiety_growth": 1.3, "curiosity_growth": 0.7} ) register_personality( Personality.ADVENTUROUS, lambda squid: squid.curiosity * 1.8, {"curiosity_growth": 1.5, "exploration_boost": 1.4} ) register_personality( Personality.LAZY, lambda squid: squid.sleepiness * 1.2, {"energy_drain": 0.6, "movement_speed": 0.8} ) register_personality( Personality.ENERGETIC, lambda squid: 100 - squid.sleepiness, {"energy_drain": 1.4, "movement_speed": 1.3} ) register_personality( Personality.GREEDY, lambda squid: squid.hunger * 2.0, {"hunger_growth": 1.3, "satisfaction_decay": 1.2} ) register_personality( Personality.STUBBORN, lambda squid: 100 if squid.hunger > 70 else 30, {"food_preference": "sushi", "adaptability": 0.3} ) register_all_personalities() ================================================ FILE: src/plugin_manager.py ================================================ import os import importlib.util import inspect import logging import sys from typing import Dict, List, Callable, Any # ANSI escape codes for console colours class ANSI: BLUE = "\x1b[34m" RED = "\x1b[31m" YELLOW = "\x1b[33m" CYAN = "\x1b[36m" RESET = "\x1b[0m" class ColoredFormatter(logging.Formatter): """ A custom logging formatter that colors only the 'LEVEL:NAME:' prefix for messages from the 'PluginManager' logger. """ COLORS = { logging.DEBUG: ANSI.CYAN, logging.INFO: ANSI.CYAN, logging.WARNING: ANSI.YELLOW, logging.ERROR: ANSI.RED, logging.CRITICAL: ANSI.RED, } def __init__(self, fmt="%(levelname)s:%(name)s:%(message)s", datefmt=None, style='%'): # We call super().__init__ but will override format completely super().__init__(fmt, datefmt, style) def format(self, record): # Check if the log is from our target logger if record.name == "PluginManager": # Get the appropriate color for the log level color = self.COLORS.get(record.levelno, ANSI.RESET) # Default to RESET if no colour found # Create the prefix string (e.g., "INFO:PluginManager:") prefix = f"{record.levelname}:{record.name}:" # Get the actual log message message = record.getMessage() # Append exception information if present if record.exc_info: if not record.exc_text: record.exc_text = self.formatException(record.exc_info) if record.exc_text: message = message + "\n" + record.exc_text # Construct the final coloured log string # Only the prefix is coloured; the message remains default (white/black) until RESET return f"{color}{prefix}{ANSI.RESET} {message}" else: # For any other logger, use the default formatter behavior (uncoloured) return super().format(record) class PluginManager: _instance = None # Singleton instance reference def __new__(cls, *args, **kwargs): """Singleton pattern implementation""" if cls._instance is None: cls._instance = super().__new__(cls) cls._instance._initialized = False # Mark as uninitialized return cls._instance def __init__(self, plugin_directory="plugins"): """Initialize the plugin manager (only once due to singleton)""" if self._initialized: return self.plugin_directory = plugin_directory self.plugins: Dict[str, Dict] = {} # Stores loaded plugins' metadata and instances self.hooks: Dict[str, List[Dict]] = {} # Registered hooks and their subscribers self.enabled_plugins: set[str] = set() # Names of enabled plugins (use lowercase) self.auto_load_blacklist: set[str] = {"multiplayer", "stdp"} ### FIX: Stop Multiplayer plugin freaking out at startup ** ESSENTIAL ** ## This is super important. All plugins start austomatically unless ## specifically blacklisted here... DO NOT LET MULTIPLAYER AUTO START. self.auto_load_allowlist: set[str] = set() # If non-empty, ONLY these plugins load self._whitelist: set[str] = set() # Populated from whitelist.txt; controls auto-enable # Custom neuron handlers registered by plugins # Maps neuron_name -> {'handler': callable, 'plugin': plugin_name, 'metadata': dict} self._neuron_handlers: Dict[str, Dict] = {} # Configure the logger for PluginManager self.logger = logging.getLogger("PluginManager") if not self.logger.handlers: # Avoid adding multiple handlers self.logger.setLevel(logging.INFO) # Set the minimum level ch = logging.StreamHandler(sys.stdout) # Log to standard output # Use the NEW ColoredFormatter. # The 'fmt' here is less critical since we override format(), # but it acts as a fallback or for other loggers. formatter = ColoredFormatter() ch.setFormatter(formatter) self.logger.addHandler(ch) self.logger.propagate = False # Prevent logs from going to root logger self._discovered_plugins: Dict[str, Dict] | None = None os.makedirs(plugin_directory, exist_ok=True) self._initialize_hooks() self._initialized = True # --- Start of Original PluginManager Methods --- # (These methods remain largely the same, only the logger setup in __init__ # and the Formatter class definition are the core changes for coloring) def _initialize_hooks(self): """Initialize standard hooks that plugins can register for""" # Lifecycle hooks self.register_hook("on_startup") self.register_hook("on_shutdown") self.register_hook("on_new_game") self.register_hook("on_save_game") self.register_hook("on_load_game") # Simulation hooks self.register_hook("pre_update") self.register_hook("post_update") self.register_hook("on_speed_change") # Squid state hooks self.register_hook("on_squid_state_change") self.register_hook("on_hunger_change") self.register_hook("on_happiness_change") self.register_hook("on_cleanliness_change") self.register_hook("on_sleepiness_change") self.register_hook("on_satisfaction_change") self.register_hook("on_anxiety_change") self.register_hook("on_curiosity_change") # Action hooks self.register_hook("on_feed") self.register_hook("on_clean") self.register_hook("on_medicine") self.register_hook("on_sleep") self.register_hook("on_wake") self.register_hook("on_startle") # Interaction hooks self.register_hook("on_rock_pickup") self.register_hook("on_rock_throw") self.register_hook("on_decoration_interaction") self.register_hook("on_ink_cloud") # Neural/memory hooks self.register_hook("on_brain_state_update") self.register_hook("on_neurogenesis") self.register_hook("on_memory_created") self.register_hook("on_memory_to_long_term") # UI hooks self.register_hook("on_menu_creation") self.register_hook("on_message_display") # Custom menu action hooks self.register_hook("register_menu_actions") # Plugin lifecycle hooks self.register_hook("on_plugin_enabled") self.register_hook("on_plugin_disabled") # Custom neuron hooks - allows plugins to register input neuron handlers self.register_hook("register_neuron_handlers") # Neuron output hooks - triggered when neurons fire above threshold # Movement behaviors self.register_hook("neuron_output_flee") self.register_hook("neuron_output_seek_food") self.register_hook("neuron_output_seek_plant") self.register_hook("neuron_output_approach_rock") self.register_hook("neuron_output_wander") # Action behaviors self.register_hook("neuron_output_throw_rock") self.register_hook("neuron_output_pick_up_rock") self.register_hook("neuron_output_ink_cloud") self.register_hook("neuron_output_eat") self.register_hook("neuron_output_change_color") # State changes self.register_hook("neuron_output_sleep") self.register_hook("neuron_output_wake") self.register_hook("neuron_output_startle") self.register_hook("neuron_output_calm") # Stat modifications self.register_hook("neuron_output_boost_happiness") self.register_hook("neuron_output_boost_curiosity") self.register_hook("neuron_output_reduce_anxiety") # Custom/plugin-defined outputs self.register_hook("neuron_output_custom") def register_all_sensors(self, tamagotchi_logic): """ Register all available sensors (built-in and plugin) with the plugin manager. This creates a unified registry by registering built-in sensors that normally live in BrainNeuronHooks, making them discoverable through the plugin manager's API. Args: tamagotchi_logic: TamagotchiLogic instance needed for sensor handlers Returns: int: Number of built-in sensors registered """ # Lazy imports to avoid circular dependencies from .designer_sensor_discovery import get_builtin_sensors from .brain_neuron_hooks import BrainNeuronHooks brain_hooks = BrainNeuronHooks(tamagotchi_logic) builtin_sensors = get_builtin_sensors() count = 0 for name, info in builtin_sensors.items(): # Skip if already registered by a plugin if name in self._neuron_handlers: existing = self._neuron_handlers[name] if existing.get('plugin') != 'system': self.logger.debug( f"Skipping built-in sensor '{name}' - " f"overridden by plugin '{existing.get('plugin')}'" ) continue # Register if handler exists if name in brain_hooks.handlers: handler = brain_hooks.handlers[name] metadata = { 'description': info.get('description', ''), 'is_binary': info.get('is_binary', False), 'category': info.get('category', 'built-in'), 'default_connections': info.get('default_connections', []), 'source': 'built-in' } self.register_neuron_handler( neuron_name=name, handler=handler, plugin_name='system', metadata=metadata ) count += 1 if count > 0: self.logger.info(f"Registered {count} built-in sensors with PluginManager") return count def register_hook(self, hook_name: str) -> None: """ Register a new hook that plugins can subscribe to. """ if hook_name not in self.hooks: self.hooks[hook_name] = [] self.logger.debug(f"Registered hook: {hook_name}") def subscribe_to_hook(self, hook_name: str, plugin_name: str, callback: Callable) -> bool: """ Subscribe a plugin's callback to a specific hook. """ if hook_name not in self.hooks: self.logger.warning(f"Plugin {plugin_name} tried to subscribe to non-existent hook: {hook_name}") return False self.hooks[hook_name].append({ "plugin": plugin_name, "callback": callback }) self.logger.debug(f"Plugin {plugin_name} subscribed to hook: {hook_name}") return True def unsubscribe_from_hook(self, hook_name: str, plugin_name: str) -> bool: """ Unsubscribe a plugin from a specific hook. """ if hook_name not in self.hooks: return False self.hooks[hook_name] = [ h for h in self.hooks[hook_name] if h["plugin"] != plugin_name ] return True def trigger_hook(self, hook_name, **kwargs): """ Trigger a hook, calling all subscribed plugin callbacks. """ if hook_name not in self.hooks: self.logger.warning(f"Attempted to trigger non-existent hook: {hook_name}") return [] results = [] for subscriber in self.hooks[hook_name]: plugin_name = subscriber["plugin"] # Only trigger hooks for enabled plugins if plugin_name.lower() not in self.enabled_plugins: continue try: callback = subscriber["callback"] result = callback(**kwargs) results.append(result) except Exception as e: self.logger.error(f"Error in plugin {plugin_name} for hook {hook_name}: {str(e)}", exc_info=True) return results def discover_plugins(self) -> Dict[str, Dict]: """ Discover available plugins from the plugin directory. Ensures plugin names (keys in the returned dict) are lowercase. """ plugin_info: Dict[str, Dict] = {} if not os.path.exists(self.plugin_directory): self.logger.warning(f"Plugin directory does not exist: {self.plugin_directory}") return plugin_info for plugin_dir in os.listdir(self.plugin_directory): plugin_path = os.path.join(self.plugin_directory, plugin_dir) if not os.path.isdir(plugin_path): continue main_py = os.path.join(plugin_path, "main.py") if not os.path.exists(main_py): self.logger.debug(f"No main.py found in {plugin_path}") continue try: module_name = f"plugins.{plugin_dir}.main" spec = importlib.util.spec_from_file_location(module_name, main_py) if spec is None or spec.loader is None: self.logger.error(f"Could not create spec for plugin {plugin_dir} at {main_py}") continue module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module spec.loader.exec_module(module) plugin_name_attr = getattr(module, "PLUGIN_NAME", plugin_dir) plugin_name = plugin_name_attr.lower() metadata = { "name": plugin_name, "original_name": plugin_name_attr, "version": getattr(module, "PLUGIN_VERSION", "1.0.0"), "author": getattr(module, "PLUGIN_AUTHOR", "Unknown"), "description": getattr(module, "PLUGIN_DESCRIPTION", ""), "requires": [req.lower() for req in getattr(module, "PLUGIN_REQUIRES", [])], "path": main_py, "directory": plugin_path, "module": module, "main_class_name": getattr(module, "PLUGIN_MAIN_CLASS", None) } plugin_info[plugin_name] = metadata #self.logger.info(f"Discovered plugin: {metadata['original_name']} v{metadata['version']} (key: {plugin_name})") except Exception as e: self.logger.error(f"Error discovering plugin in '{plugin_dir}': {str(e)}", exc_info=True) self._discovered_plugins = plugin_info if not plugin_info: self.logger.info("No plugins discovered to load.") return plugin_info def load_plugin(self, plugin_name: str) -> bool: """ Load and initialize a plugin by name. Assumes plugin_name is already lowercase. (Using the version from previous turns, without the 'instance' check that was causing issues) """ plugin_name = plugin_name.lower() if plugin_name in self.plugins: self.logger.info(f"Plugin '{plugin_name}' already loaded.") return True if self._discovered_plugins is None: self.logger.error("Plugin discovery must be run before loading.") self._discovered_plugins = self.discover_plugins() if plugin_name not in self._discovered_plugins: self.logger.error(f"Plugin '{plugin_name}' not found.") return False plugin_data = self._discovered_plugins[plugin_name] module = plugin_data["module"] original_plugin_name_display = plugin_data.get("original_name", plugin_name) #self.logger.info(f"Attempting to load plugin '{original_plugin_name_display}' (key: '{plugin_name}')") #self.logger.info(f"Plugin '{plugin_name}': Module '{module.__name__}' found.") required_plugins = plugin_data.get("requires", []) if required_plugins: missing_plugins = [] for required_name_lower in required_plugins: if required_name_lower not in self.plugins: missing_plugins.append(required_name_lower) if missing_plugins: #self.logger.error(f"Plugin '{plugin_name}' requires missing plugin(s): {', '.join(missing_plugins)}.") #self.logger.error(f"Plugin '{plugin_name}': Dependency check failed.") return False #self.logger.info(f"Plugin '{plugin_name}': Dependencies satisfied.") if not hasattr(module, "initialize"): self.logger.error(f"Plugin '{plugin_name}' has no 'initialize' function.") return False #self.logger.info(f"Plugin '{plugin_name}': Found 'initialize' function. Attempting to call.") try: initialize_func = getattr(module, "initialize") success = initialize_func(self) # Call initialize if success: #self.logger.info(f"Plugin '{plugin_name}': 'initialize' function executed successfully.") # Check if plugin registered itself (especially important for multiplayer's pattern) if plugin_name not in self.plugins: # If it didn't register itself, add the discovered data now. # This might happen for simpler plugins. If it was *supposed* to register and didn't, # it might cause issues later if an instance is expected. self.logger.info(f"Plugin '{plugin_name}' did not self-register; adding discovered data.") self.plugins[plugin_name] = plugin_data # Check if an instance *is* now present in the (potentially updated) record if plugin_name in self.plugins and ('instance' not in self.plugins[plugin_name] or self.plugins[plugin_name].get('instance') is None): # This is the warning that replaces the previous hard error self.logger.warning(f"Plugin '{plugin_name}': Instance was not explicitly set in manager's records by 'initialize'.") elif plugin_name in self.plugins: self.logger.info(f"Success") if plugin_name not in self.auto_load_blacklist \ and (not self._whitelist or plugin_name in self._whitelist): self.enabled_plugins.add(plugin_name) return True else: self.logger.error(f"Plugin '{plugin_name}' 'initialize' function returned False or failed.") return False except Exception as e: self.logger.error(f"Error during initialization of plugin '{plugin_name}': {str(e)}", exc_info=True) return False def load_all_plugins(self) -> Dict[str, bool]: """ Load all discovered plugins except those in auto_load_blacklist. If a whitelist.txt exists in the plugin directory, only those plugins load. """ self.logger.info(" Discovering plugins...") self.plugins.clear() self.enabled_plugins.clear() # --- Whitelist --- self._whitelist = set() whitelist_path = os.path.join(self.plugin_directory, 'whitelist.txt') if os.path.exists(whitelist_path): with open(whitelist_path, 'r') as f: for line in f: name = line.strip().lower() if name and not name.startswith('#'): self._whitelist.add(name) self.logger.info(f" Whitelist active: {sorted(self._whitelist)}") self._discovered_plugins = self.discover_plugins() if not self._discovered_plugins: return {} results = {} plugins_to_load_ordered = [ name for name in self._discovered_plugins.keys() if name not in self.auto_load_blacklist and (not self._whitelist or name in self._whitelist) ] for plugin_name_key in plugins_to_load_ordered: result = self.load_plugin(plugin_name_key) results[plugin_name_key] = result blacklisted_found = [name for name in self._discovered_plugins.keys() if name in self.auto_load_blacklist] if blacklisted_found: self.logger.info(f"Skipped auto-loading of {blacklisted_found}") return results def unload_plugin(self, plugin_name: str) -> bool: """Unload a plugin by name.""" plugin_name_lower = plugin_name.lower() if plugin_name_lower not in self.plugins: self.logger.warning(f"Plugin '{plugin_name_lower}' not found for unloading.") return False plugin_data = self.plugins.get(plugin_name_lower) if plugin_data: instance = plugin_data.get('instance') if instance and hasattr(instance, 'shutdown'): try: instance.shutdown() self.logger.info(f"Plugin '{plugin_name_lower}' shutdown method called.") except Exception as e: self.logger.error(f"Error during plugin '{plugin_name_lower}' shutdown: {e}", exc_info=True) if plugin_name_lower in self.enabled_plugins: self.enabled_plugins.remove(plugin_name_lower) self.logger.info(f"Plugin '{plugin_name_lower}' disabled.") for hook_name in list(self.hooks.keys()): self.hooks[hook_name] = [ sub for sub in self.hooks[hook_name] if sub['plugin'].lower() != plugin_name_lower ] del self.plugins[plugin_name_lower] self.logger.info(f"Plugin '{plugin_name_lower}' unloaded successfully.") return True def unload_all_plugins(self) -> None: """Unload all active plugins.""" self.logger.info("Unloading all plugins...") for plugin_name_key in list(self.plugins.keys()): self.unload_plugin(plugin_name_key) self.logger.info("All plugins have been unloaded.") def reload_all_plugins(self) -> Dict[str, bool]: """ Reload all plugins by unloading and then loading them again. This is useful when starting a new game to ensure plugins start fresh. Returns: Dict[str, bool]: Dictionary mapping plugin names to their load success status """ self.logger.info("Reloading all plugins...") # First unload all plugins self.unload_all_plugins() # Then load them all again results = self.load_all_plugins() self.logger.info("All plugins have been reloaded.") return results def enable_plugin(self, plugin_key: str) -> bool: plugin_key_lower = plugin_key.lower() # Normalize to lowercase if plugin_key_lower in self.enabled_plugins: self.logger.info(f"Plugin '{plugin_key_lower}' is already enabled.") return True # NEW: If plugin is not loaded but is discovered, load it first if plugin_key_lower not in self.plugins: if self._discovered_plugins is None: self.logger.info("Plugin discovery not yet run. Discovering plugins...") self.discover_plugins() if plugin_key_lower in self._discovered_plugins: self.logger.info(f"Plugin '{plugin_key_lower}' is discovered but not loaded. Loading it first...") if not self.load_plugin(plugin_key_lower): self.logger.error(f"Failed to load plugin '{plugin_key_lower}' before enabling.") return False else: self.logger.error(f"Plugin '{plugin_key_lower}' not found in discovered plugins.") return False # Now proceed with the original enabling logic... plugin_data = self.plugins.get(plugin_key_lower) if not plugin_data or 'instance' not in plugin_data: self.logger.error(f"ERROR:PluginManager: Plugin '{plugin_key_lower}' not found or has no instance for enabling.") return False instance = plugin_data['instance'] if not instance: self.logger.error(f"ERROR:PluginManager: Instance for plugin '{plugin_key_lower}' is None.") return False # --- Call setup() if it hasn't been run --- if hasattr(instance, 'setup') and callable(instance.setup): if not plugin_data.get('is_setup', False): try: self.logger.info(f"INFO:PluginManager: Calling setup() for plugin '{plugin_key_lower}'.") tamagotchi_logic_ref = getattr(self, 'tamagotchi_logic', None) if tamagotchi_logic_ref: instance.setup(self, tamagotchi_logic_ref) else: self.logger.warning(f"WARNING:PluginManager: tamagotchi_logic not available in PluginManager when setting up '{plugin_key_lower}'. Passing None.") instance.setup(self, None) plugin_data['is_setup'] = True self.logger.info(f"INFO:PluginManager: setup() for plugin '{plugin_key_lower}' completed.") except Exception as e: self.logger.error(f"ERROR:PluginManager: Exception during setup of plugin '{plugin_key_lower}': {e}", exc_info=True) return False else: self.logger.info(f"INFO:PluginManager: Plugin '{plugin_key_lower}' already marked as setup by PluginManager. Skipping setup() call.") # --- Now, call the plugin's own enable method --- if hasattr(instance, 'enable') and callable(instance.enable): try: self.logger.info(f"INFO:PluginManager: Calling enable() method on plugin instance '{plugin_key_lower}'.") if instance.enable(): self.enabled_plugins.add(plugin_key_lower) self.logger.info(f"INFO:PluginManager: Plugin '{plugin_key_lower}' successfully enabled and added to enabled set.") self.trigger_hook("on_plugin_enabled", plugin_key=plugin_key_lower) return True else: self.logger.error(f"ERROR:PluginManager: Plugin '{plugin_key_lower}' enable() method returned False.") return False except Exception as e: self.logger.error(f"ERROR:PluginManager: Exception during enable() of plugin '{plugin_key_lower}': {e}", exc_info=True) return False else: # If the plugin has no specific enable method, just mark it as enabled in the manager self.enabled_plugins.add(plugin_key_lower) self.logger.info(f"INFO:PluginManager: Plugin '{plugin_key_lower}' has no custom enable() method, marked as enabled in manager.") self.trigger_hook("on_plugin_enabled", plugin_key=plugin_key_lower) return True def disable_plugin(self, plugin_name: str) -> bool: """Disable an enabled plugin.""" plugin_name_lower = plugin_name.lower() if plugin_name_lower not in self.enabled_plugins: self.logger.warning(f"Plugin '{plugin_name_lower}' is not currently enabled.") return False plugin_data = self.plugins.get(plugin_name_lower) if plugin_data: plugin_instance = plugin_data.get('instance') if plugin_instance and hasattr(plugin_instance, 'disable'): try: plugin_instance.disable() self.logger.info(f"Plugin '{plugin_name_lower}'.disable() method called.") except Exception as e: self.logger.error(f"Error calling .disable() on plugin '{plugin_name_lower}': {e}", exc_info=True) self.enabled_plugins.remove(plugin_name_lower) self.logger.info(f"Plugin '{plugin_name_lower}' disabled.") self.trigger_hook("on_plugin_disabled", plugin_key=plugin_name_lower) return True def get_plugin_info(self, plugin_name: str) -> Dict | None: """Get information about a loaded plugin.""" plugin_name_lower = plugin_name.lower() return self.plugins.get(plugin_name_lower) def get_loaded_plugins(self) -> List[str]: """Get original names of all loaded plugins.""" return [data.get('original_name', key) for key, data in self.plugins.items()] def get_enabled_plugins(self) -> List[str]: """Get original names of all enabled plugins.""" enabled_original_names = [] for name_lower in self.enabled_plugins: if name_lower in self.plugins: enabled_original_names.append(self.plugins[name_lower].get('original_name', name_lower)) else: enabled_original_names.append(name_lower) return enabled_original_names def check_dependencies(self, plugin_name_to_check: str) -> bool: """Check if dependencies for a plugin are met.""" plugin_name_to_check = plugin_name_to_check.lower() if self._discovered_plugins is None or plugin_name_to_check not in self._discovered_plugins: self.logger.error(f"Plugin '{plugin_name_to_check}' not found for dependency check.") return False plugin_data = self._discovered_plugins[plugin_name_to_check] required_plugin_keys = plugin_data.get("requires", []) if not required_plugin_keys: return True for required_key in required_plugin_keys: if required_key not in self.plugins: self.logger.error(f"Plugin '{plugin_name_to_check}' requires '{required_key}' which is not loaded.") return False return True def set_tamagotchi_logic(self, tamagotchi_logic_instance): """Allows setting a reference to the main TamagotchiLogic instance.""" setattr(self, 'tamagotchi_logic', tamagotchi_logic_instance) self.logger.info("TamagotchiLogic instance has been linked to PluginManager.") # ========================================================================= # CUSTOM NEURON HANDLER REGISTRATION # ========================================================================= def register_neuron_handler( self, neuron_name: str, handler: Callable, plugin_name: str, metadata: Dict = None ) -> bool: """ Register a custom handler for a brain input neuron. This allows plugins to add new sensor neurons that can be wired into the squid's neural network via the brain designer. Args: neuron_name: Unique name for the neuron (e.g., 'music_beat_detector') handler: A callable that returns a float (0-100) activation value. Should take no arguments and return the current activation. plugin_name: Name of the plugin registering this handler metadata: Optional dict with additional info: - 'description': Human-readable description - 'is_binary': True if neuron only outputs 0 or 100 - 'category': Category for grouping (e.g., 'environmental', 'social') - 'default_connections': List of neurons to auto-connect to Returns: True if registered successfully, False if neuron name already exists Example: def my_beat_handler(): # Return 100 when beat detected, 0 otherwise return 100.0 if detect_beat() else 0.0 plugin_manager.register_neuron_handler( 'music_beat', my_beat_handler, 'MusicPlugin', metadata={ 'description': 'Detects music beats', 'is_binary': True, 'category': 'audio' } ) """ plugin_name_lower = plugin_name.lower() if neuron_name in self._neuron_handlers: existing = self._neuron_handlers[neuron_name] self.logger.warning( f"Neuron handler '{neuron_name}' already registered by " f"'{existing.get('plugin', 'unknown')}'. Overwriting with '{plugin_name}'." ) self._neuron_handlers[neuron_name] = { 'handler': handler, 'plugin': plugin_name_lower, 'metadata': metadata or {} } self.logger.info(f"Registered neuron handler: '{neuron_name}' from plugin '{plugin_name}'") return True def unregister_neuron_handler(self, neuron_name: str, plugin_name: str) -> bool: """ Unregister a neuron handler. Args: neuron_name: Name of the neuron to unregister plugin_name: Name of the plugin that registered it (for verification) Returns: True if unregistered successfully, False otherwise """ plugin_name_lower = plugin_name.lower() if neuron_name not in self._neuron_handlers: self.logger.warning(f"Cannot unregister '{neuron_name}': not found") return False existing = self._neuron_handlers[neuron_name] if existing.get('plugin') != plugin_name_lower: self.logger.warning( f"Cannot unregister '{neuron_name}': registered by " f"'{existing.get('plugin')}', not '{plugin_name}'" ) return False del self._neuron_handlers[neuron_name] self.logger.info(f"Unregistered neuron handler: '{neuron_name}'") return True def get_neuron_handlers(self) -> Dict[str, Callable]: """ Get all registered neuron handlers as a dict of name -> callable. This is called by BrainNeuronHooks to merge plugin handlers with built-in handlers. Returns: Dict mapping neuron names to their handler callables """ return { name: data['handler'] for name, data in self._neuron_handlers.items() } def get_neuron_handler_info(self, neuron_name: str) -> Dict | None: """ Get full info about a registered neuron handler. Returns: Dict with 'handler', 'plugin', and 'metadata' keys, or None if not found """ return self._neuron_handlers.get(neuron_name) def get_all_neuron_handler_info(self) -> Dict[str, Dict]: """ Get info about all registered neuron handlers. Returns: Dict mapping neuron names to their full registration info """ return dict(self._neuron_handlers) def get_plugin_neuron_handlers(self, plugin_name: str) -> List[str]: """ Get list of neuron handlers registered by a specific plugin. Args: plugin_name: Name of the plugin Returns: List of neuron names registered by that plugin """ plugin_name_lower = plugin_name.lower() return [ name for name, data in self._neuron_handlers.items() if data.get('plugin') == plugin_name_lower ] ================================================ FILE: src/plugin_manager_dialog.py ================================================ from PyQt5 import QtCore, QtGui, QtWidgets import os class PluginManagerDialog(QtWidgets.QDialog): def __init__(self, plugin_manager, parent=None): super().__init__(parent) self.plugin_manager = plugin_manager self.setWindowTitle("Plugin Manager") self.resize(800, 500) self.setup_ui() self.load_plugin_data() def setup_ui(self): # Main layout layout = QtWidgets.QVBoxLayout(self) layout.setSpacing(15) layout.setContentsMargins(20, 20, 20, 20) # Header label header = QtWidgets.QLabel("🧩 Plugin Manager") header.setAlignment(QtCore.Qt.AlignCenter) layout.addWidget(header) # Splitter for resizable sections splitter = QtWidgets.QSplitter(QtCore.Qt.Horizontal) # Plugin list container list_container = QtWidgets.QWidget() list_layout = QtWidgets.QVBoxLayout(list_container) list_layout.setContentsMargins(0, 0, 0, 0) list_label = QtWidgets.QLabel("Available Plugins") list_layout.addWidget(list_label) self.plugin_list = QtWidgets.QListWidget() self.plugin_list.setSelectionMode(QtWidgets.QAbstractItemView.SingleSelection) self.plugin_list.currentItemChanged.connect(self.on_plugin_selected) self.plugin_list.setIconSize(QtCore.QSize(16, 16)) list_layout.addWidget(self.plugin_list) splitter.addWidget(list_container) # Right panel right_panel = QtWidgets.QWidget() right_layout = QtWidgets.QVBoxLayout(right_panel) right_layout.setContentsMargins(0, 0, 0, 0) # Plugin details group details_group = QtWidgets.QGroupBox("Plugin Details") details_layout = QtWidgets.QFormLayout(details_group) details_layout.setSpacing(10) details_layout.setLabelAlignment(QtCore.Qt.AlignRight) details_layout.setFieldGrowthPolicy(QtWidgets.QFormLayout.AllNonFixedFieldsGrow) self.plugin_name = QtWidgets.QLabel() details_layout.addRow("Name:", self.plugin_name) self.plugin_version = QtWidgets.QLabel() details_layout.addRow("Version:", self.plugin_version) self.plugin_author = QtWidgets.QLabel() details_layout.addRow("Author:", self.plugin_author) self.plugin_description = QtWidgets.QLabel() self.plugin_description.setWordWrap(True) self.plugin_description.setMinimumHeight(60) details_layout.addRow("Description:", self.plugin_description) self.plugin_requires = QtWidgets.QLabel() details_layout.addRow("Dependencies:", self.plugin_requires) self.plugin_status = QtWidgets.QLabel() details_layout.addRow("Status:", self.plugin_status) right_layout.addWidget(details_group) # Actions group actions_group = QtWidgets.QGroupBox("Actions") actions_layout = QtWidgets.QHBoxLayout(actions_group) actions_layout.setSpacing(10) self.enable_button = QtWidgets.QPushButton("Enable") self.enable_button.clicked.connect(self.enable_selected_plugin) actions_layout.addWidget(self.enable_button) self.disable_button = QtWidgets.QPushButton("Disable") self.disable_button.clicked.connect(self.disable_selected_plugin) actions_layout.addWidget(self.disable_button) self.refresh_button = QtWidgets.QPushButton("Refresh List") self.refresh_button.clicked.connect(self.load_plugin_data) actions_layout.addWidget(self.refresh_button) right_layout.addWidget(actions_group) right_layout.addStretch() splitter.addWidget(right_panel) splitter.setSizes([300, 400]) layout.addWidget(splitter) # Close button button_container = QtWidgets.QWidget() button_layout = QtWidgets.QHBoxLayout(button_container) button_layout.setContentsMargins(0, 0, 0, 0) button_layout.addStretch() self.close_button = QtWidgets.QPushButton("Close") self.close_button.clicked.connect(self.accept) layout.addWidget(button_container) def load_plugin_data(self): """Load plugin data into the list""" self.plugin_list.clear() # Get all plugin states loaded_plugins = {name.lower(): True for name in self.plugin_manager.get_loaded_plugins()} enabled_plugins = {name.lower(): True for name in self.plugin_manager.get_enabled_plugins()} # First, add loaded plugins for plugin_name in self.plugin_manager.get_loaded_plugins(): plugin_data = self.plugin_manager.plugins.get(plugin_name.lower(), {}) original_name = plugin_data.get('original_name', plugin_name) item = QtWidgets.QListWidgetItem(original_name) item.setData(QtCore.Qt.UserRole, plugin_data) if plugin_name.lower() in enabled_plugins: item.setIcon(self.get_status_icon("enabled")) else: item.setIcon(self.get_status_icon("loaded")) self.plugin_list.addItem(item) # Then add discovered plugins that aren't loaded if hasattr(self.plugin_manager, '_discovered_plugins'): for plugin_key, plugin_data in self.plugin_manager._discovered_plugins.items(): if plugin_key not in loaded_plugins: original_name = plugin_data.get('original_name', plugin_key) item = QtWidgets.QListWidgetItem(original_name) item.setData(QtCore.Qt.UserRole, plugin_data) item.setIcon(self.get_status_icon("discovered")) self.plugin_list.addItem(item) # Select the first plugin if available if self.plugin_list.count() > 0: self.plugin_list.setCurrentRow(0) else: self.clear_plugin_details() def get_status_icon(self, status): """Create a colored dot icon for the plugin status""" pixmap = QtGui.QPixmap(16, 16) pixmap.fill(QtCore.Qt.transparent) painter = QtGui.QPainter(pixmap) painter.setRenderHint(QtGui.QPainter.Antialiasing) if status == "enabled": color = QtGui.QColor(0, 200, 0) # Green elif status == "loaded": color = QtGui.QColor(200, 200, 0) # Yellow else: color = QtGui.QColor(150, 150, 150) # Gray painter.setBrush(QtGui.QBrush(color)) painter.setPen(QtGui.QPen(QtCore.Qt.black, 1)) painter.drawEllipse(2, 2, 12, 12) painter.end() return QtGui.QIcon(pixmap) def on_plugin_selected(self, current, previous): """Handle plugin selection change with fixed enable logic""" if not current: self.clear_plugin_details() return plugin_data = current.data(QtCore.Qt.UserRole) plugin_key = plugin_data.get('name', current.text()).lower() original_name = plugin_data.get('original_name', current.text()) # Update details self.plugin_name.setText(original_name) self.plugin_version.setText(plugin_data.get('version', 'Unknown')) self.plugin_author.setText(plugin_data.get('author', 'Unknown')) self.plugin_description.setText(plugin_data.get('description', 'No description available')) # Dependencies requires = plugin_data.get('requires', []) self.plugin_requires.setText(", ".join(requires) if requires else "None") # Status and button logic is_loaded = plugin_key in {name.lower() for name in self.plugin_manager.get_loaded_plugins()} is_enabled = plugin_key in {name.lower() for name in self.plugin_manager.get_enabled_plugins()} # NEW: Check if plugin can be enabled (loaded OR discovered) can_enable = not is_enabled and (is_loaded or plugin_key in self.plugin_manager._discovered_plugins) if is_enabled: self.plugin_status.setText("✓ ENABLED") elif is_loaded: self.plugin_status.setText("Loaded (Not Enabled)") else: self.plugin_status.setText("Discovered (Not Loaded)") # FIX: Enable button for discovered-but-not-loaded plugins self.enable_button.setEnabled(bool(can_enable)) self.disable_button.setEnabled(bool(is_enabled)) def clear_plugin_details(self): """Clear all plugin details""" self.plugin_name.clear() self.plugin_version.clear() self.plugin_author.clear() self.plugin_description.clear() self.plugin_requires.clear() self.plugin_status.clear() self.enable_button.setEnabled(False) self.disable_button.setEnabled(False) def enable_selected_plugin(self): """Enable the selected plugin with automatic loading""" current_item = self.plugin_list.currentItem() if not current_item: return plugin_data = current_item.data(QtCore.Qt.UserRole) plugin_key = plugin_data.get('name', current_item.text()).lower() try: # If plugin isn't loaded yet, load it first if plugin_key not in self.plugin_manager.plugins: self.plugin_manager.logger.info(f"Plugin '{plugin_key}' not loaded. Loading now...") if not self.plugin_manager.load_plugin(plugin_key): QtWidgets.QMessageBox.warning( self, "Error", f"Failed to load plugin '{plugin_key}'. Check logs for details." ) return # Now enable it success = self.plugin_manager.enable_plugin(plugin_key) if success: QtWidgets.QMessageBox.information( self, "Success", f"Plugin '{plugin_data.get('original_name', plugin_key)}' enabled successfully" ) self.load_plugin_data() else: QtWidgets.QMessageBox.warning( self, "Error", f"Failed to enable plugin '{plugin_key}'" ) except Exception as e: QtWidgets.QMessageBox.warning( self, "Error", f"Error enabling plugin: {str(e)}" ) def disable_selected_plugin(self): """Disable the selected plugin""" current_item = self.plugin_list.currentItem() if not current_item: return plugin_name = current_item.text() try: success = self.plugin_manager.disable_plugin(plugin_name) if success: # Call custom disable method if available if plugin_name.lower() in self.plugin_manager.plugins: plugin_instance = self.plugin_manager.plugins[plugin_name.lower()].get('instance') if plugin_instance and hasattr(plugin_instance, 'disable'): try: plugin_instance.disable() except Exception as e: print(f"Error in plugin disable method: {e}") QtWidgets.QMessageBox.information( self, "Success", f"Plugin '{plugin_name}' disabled successfully" ) self.load_plugin_data() else: QtWidgets.QMessageBox.warning( self, "Error", f"Failed to disable plugin '{plugin_name}'" ) except Exception as e: QtWidgets.QMessageBox.warning( self, "Error", f"Error disabling plugin: {str(e)}" ) ================================================ FILE: src/preferences.py ================================================ import os import sys import glob import re from PyQt5 import QtWidgets, QtCore, QtGui from .config_manager import ConfigManager from .localisation import Localization from .display_scaling import DisplayScaling import zipfile # Define the path to the ZIP file - must match localisation.py - NEEDED FOR BACKWARDS COMPAT ZIP_FILENAME = "languages.zip" class PreferencesWindow(QtWidgets.QDialog): """Floating preferences window for Dosidicus configuration""" # NOTE: LANGUAGE_MAP is only used for displaying the name in the preferences window # if the localisation system failed to provide a name. LANGUAGE_MAP = { 'en': 'English', 'es': 'Spanish (Español)', 'fr': 'French (Français)', 'de': 'German (Deutsch)', 'pl': 'Polish (Polski)', 'uk': 'Ukrainian (Українська)', 'zh': 'Chinese (中文)', 'ja': 'Japanese (日本語)', 'pt': 'Portuguese (Português)', 'cy': 'Welsh (Cymraeg)', 'ga': 'Irish (Gaeilge)', 'ml': 'Millennial', } def __init__(self, parent=None): super().__init__(parent, QtCore.Qt.Window) # Ensure scaling is initialized based on the screen this window is on screen = QtWidgets.QApplication.primaryScreen() screen_geometry = screen.availableGeometry() self.config = ConfigManager() # Re-initialize scaling to ensure context is correct for this window creation DisplayScaling.initialize(screen_geometry.width(), screen_geometry.height()) self.setWindowTitle("Preferences") self.setModal(False) # Scale the initial window size w = DisplayScaling.scale(1024) h = DisplayScaling.scale(800) self.resize(w, h) # Center on screen x = (screen_geometry.width() - self.width()) // 2 y = (screen_geometry.height() - self.height()) // 2 self.move(x, y) # Load config manager (use same instance if available) self.config_manager = ConfigManager() # Track if changes were made self.changes_made = False self.original_values = {} # Apply styles first (no dependencies) self._apply_styles() # Initialize UI components self.init_ui() # Load config values into UI self.load_current_config() def _apply_styles(self): """Apply global stylesheet with scaling logic""" # BASE SIZES base_font = DisplayScaling.font_size(18) header_font = DisplayScaling.font_size(22) small_font = DisplayScaling.font_size(14) large_font = DisplayScaling.font_size(32) # SUBTITLE SIZE subtitle_font = DisplayScaling.font_size(24) padding_std = DisplayScaling.scale(12) padding_lrg = DisplayScaling.scale(20) radius = DisplayScaling.scale(8) border_width = max(1, DisplayScaling.scale(2)) # DEVELOPER: ADJUST THE VALUE BELOW TO CHANGE TOP TAB WIDTH tab_min_width = DisplayScaling.scale(150) css = f""" QWidget {{ font-size: {base_font}px; color: #2c3e50; }} /* Headers and Labels */ QLabel {{ font-size: {base_font}px; }} /* Group Boxes */ QGroupBox {{ font-weight: bold; font-size: {header_font}px; border: {border_width}px solid #bdc3c7; border-radius: {radius}px; margin-top: {DisplayScaling.scale(30)}px; padding-top: {DisplayScaling.scale(15)}px; }} QGroupBox::title {{ subcontrol-origin: margin; left: {DisplayScaling.scale(15)}px; padding: 0 {DisplayScaling.scale(8)}px; color: #34495e; }} /* Tabs */ QTabWidget::pane {{ border: {border_width}px solid #bdc3c7; background: #fdfdfd; border-radius: {radius}px; }} QTabBar::tab {{ background: #ecf0f1; color: #2c3e50; font-size: {base_font}px; padding: {padding_std}px {padding_lrg}px; border-top-left-radius: {radius}px; border-top-right-radius: {radius}px; margin-right: {DisplayScaling.scale(4)}px; font-weight: bold; min-width: {tab_min_width}px; alignment: center; }} QTabBar::tab:selected {{ background: #003366; /* Dark Blue */ color: white; /* White Text */ }} QTabBar::tab:hover:!selected {{ background: #bdc3c7; }} /* Inputs and Buttons */ QLineEdit, QSpinBox, QDoubleSpinBox, QComboBox {{ padding: {DisplayScaling.scale(8)}px; border: {border_width}px solid #bdc3c7; border-radius: {DisplayScaling.scale(6)}px; background: white; min-height: {DisplayScaling.scale(40)}px; }} /* Buttons */ QPushButton {{ background-color: #ecf0f1; border: {border_width}px solid #bdc3c7; border-radius: {radius}px; padding: {padding_std}px {padding_lrg}px; min-height: {DisplayScaling.scale(45)}px; font-weight: bold; }} QPushButton:hover {{ background-color: #dfe6e9; }} QPushButton:pressed {{ background-color: #b2bec3; }} /* The Save Button */ QPushButton#SaveButton {{ background-color: #27ae60; color: white; font-size: {header_font}px; }} QPushButton#SaveButton:disabled {{ background-color: #95a5a6; }} /* LARGE LANGUAGE SELECTION */ QComboBox#LanguageCombo {{ font-size: {large_font}px; height: {DisplayScaling.scale(80)}px; padding: {DisplayScaling.scale(15)}px; font-weight: bold; background-color: #e8f6f3; border: {DisplayScaling.scale(3)}px solid #1abc9c; border-radius: {radius}px; }} QComboBox#LanguageCombo QAbstractItemView {{ font-size: {large_font}px; selection-background-color: #1abc9c; }} /* The Label showing 'en - English' */ QLabel#LanguageNameLabel {{ font-size: {subtitle_font}px; /* LARGER FONT */ color: #34495e; font-weight: bold; padding-left: {DisplayScaling.scale(5)}px; margin-top: {DisplayScaling.scale(10)}px; }} /* Specific helper for description text */ QLabel#DescriptionLabel {{ font-size: {small_font}px; color: #7f8c8d; font-style: italic; }} /* Two-column layout for interactions */ QFrame#ColumnFrame {{ border: {border_width}px solid #bdc3c7; border-radius: {radius}px; padding: {padding_std}px; }} """ self.setStyleSheet(css) def _get_available_languages(self): """ Returns a sorted list of language DISPLAY STRINGS (code - name format) by asking the localisation system for available languages. """ languages = [] # Get available language codes from localisation try: lang_codes = Localization.instance().get_available_languages() # Build display strings with native names for code in lang_codes: name = Localization.instance().get_language_name(code) languages.append(f"{code} - {name}") except Exception as e: print(f"[Preferences] Error getting languages from localisation: {e}") # Emergency fallback to hardcoded map for code, name in self.LANGUAGE_MAP.items(): languages.append(f"{code} - {name}") # Sort alphabetically by code, but put 'gen_z' last if any(item.startswith('gen_z -') for item in languages): languages = [item for item in languages if not item.startswith('gen_z -')] sorted_items = sorted(languages) gen_z_name = Localization.instance().get_language_name('gen_z', fallback=True) sorted_items.append(f"gen_z - {gen_z_name}") return sorted_items return sorted(languages) def init_ui(self): """Initialize the UI components""" layout = QtWidgets.QVBoxLayout() layout.setSpacing(DisplayScaling.scale(20)) layout.setContentsMargins( DisplayScaling.scale(25), DisplayScaling.scale(25), DisplayScaling.scale(25), DisplayScaling.scale(25) ) # Create tab widget self.tab_widget = QtWidgets.QTabWidget() # General tab self.general_tab = self._create_general_tab() self.tab_widget.addTab(self.general_tab, "General") # Interactions tab - NEW COMBINED TAB self.interactions_tab = self._create_interactions_tab() self.tab_widget.addTab(self.interactions_tab, "Interactions") # Neurogenesis tab self.neurogenesis_tab = self._create_neurogenesis_tab() self.tab_widget.addTab(self.neurogenesis_tab, "Neurogenesis") # Hebbian tab - NEW TAB self.hebbian_tab = self._create_hebbian_tab() self.tab_widget.addTab(self.hebbian_tab, "Hebbian Learning") # Display tab self.display_tab = self._create_display_tab() self.tab_widget.addTab(self.display_tab, "Display") # Designer tab self.designer_tab = self._create_designer_tab() self.tab_widget.addTab(self.designer_tab, "Designer") layout.addWidget(self.tab_widget) # Buttons button_layout = QtWidgets.QHBoxLayout() button_layout.setSpacing(DisplayScaling.scale(15)) button_layout.addStretch() self.save_button = QtWidgets.QPushButton("Save && Restart") self.save_button.setObjectName("SaveButton") # ID for styling self.save_button.clicked.connect(self.save_and_restart) self.save_button.setEnabled(False) self.cancel_button = QtWidgets.QPushButton("Cancel") self.cancel_button.clicked.connect(self.close) button_layout.addWidget(self.cancel_button) button_layout.addWidget(self.save_button) layout.addLayout(button_layout) self.setLayout(layout) def _create_general_tab(self): """Create General settings tab""" widget = QtWidgets.QWidget() layout = QtWidgets.QVBoxLayout() layout.setSpacing(DisplayScaling.scale(25)) layout.setContentsMargins(DisplayScaling.scale(40), DisplayScaling.scale(40), DisplayScaling.scale(40), DisplayScaling.scale(40)) # Language Label lang_label = QtWidgets.QLabel("Interface Language:") layout.addWidget(lang_label) # Language dropdown - Styled via CSS #LanguageCombo self.language_combo = QtWidgets.QComboBox() self.language_combo.setObjectName("LanguageCombo") # POPULATE USING NEW SCANNING METHOD (.py files) available_langs = self._get_available_languages() self.language_combo.addItems(available_langs) # Connect change signal self.language_combo.currentTextChanged.connect(self._on_change) layout.addWidget(self.language_combo) # Add description desc_label = QtWidgets.QLabel("Requires restart to take effect") desc_label.setObjectName("DescriptionLabel") layout.addWidget(desc_label) facts_group = QtWidgets.QGroupBox("🦑 Random Squid Facts") facts_layout = QtWidgets.QFormLayout() facts_layout.setSpacing(DisplayScaling.scale(15)) self.facts_enabled = QtWidgets.QCheckBox("Enable Humboldt squid facts") self.facts_enabled.stateChanged.connect(self._on_change) facts_layout.addRow(self.facts_enabled) self.facts_interval = QtWidgets.QSpinBox() self.facts_interval.setRange(1, 60) self.facts_interval.setSuffix(" minutes") self.facts_interval.valueChanged.connect(self._on_change) facts_layout.addRow("Show every:", self.facts_interval) self.facts_display = QtWidgets.QSpinBox() self.facts_display.setRange(5, 60) self.facts_display.setSuffix(" seconds") self.facts_display.valueChanged.connect(self._on_change) facts_layout.addRow("Display for:", self.facts_display) facts_group.setLayout(facts_layout) layout.addWidget(facts_group) # Link Blink group linkblink_group = QtWidgets.QGroupBox("Connection Blink Effects") linkblink_layout = QtWidgets.QFormLayout() linkblink_layout.setSpacing(DisplayScaling.scale(15)) self.linkblink_interval_min = QtWidgets.QDoubleSpinBox() self.linkblink_interval_min.setRange(1.0, 60.0) self.linkblink_interval_min.setSingleStep(1.0) self.linkblink_interval_min.setSuffix(" sec") self.linkblink_interval_min.valueChanged.connect(self._on_change) linkblink_layout.addRow("Min Blink Interval:", self.linkblink_interval_min) self.linkblink_interval_max = QtWidgets.QDoubleSpinBox() self.linkblink_interval_max.setRange(5.0, 120.0) self.linkblink_interval_max.setSingleStep(1.0) self.linkblink_interval_max.setSuffix(" sec") self.linkblink_interval_max.valueChanged.connect(self._on_change) linkblink_layout.addRow("Max Blink Interval:", self.linkblink_interval_max) self.linkblink_duration = QtWidgets.QDoubleSpinBox() self.linkblink_duration.setRange(0.5, 10.0) self.linkblink_duration.setSingleStep(0.5) self.linkblink_duration.setSuffix(" sec") self.linkblink_duration.valueChanged.connect(self._on_change) linkblink_layout.addRow("Blink Duration:", self.linkblink_duration) linkblink_group.setLayout(linkblink_layout) layout.addWidget(linkblink_group) layout.addStretch() widget.setLayout(layout) return widget def _create_interactions_tab(self): """Combined Rock & Poop Interactions tab with two columns""" widget = QtWidgets.QWidget() main_layout = QtWidgets.QVBoxLayout() main_layout.setSpacing(DisplayScaling.scale(25)) main_layout.setContentsMargins(DisplayScaling.scale(20), DisplayScaling.scale(20), DisplayScaling.scale(20), DisplayScaling.scale(20)) # Create horizontal layout for two columns columns_layout = QtWidgets.QHBoxLayout() columns_layout.setSpacing(DisplayScaling.scale(30)) # Left column - Rock Interactions rock_frame = QtWidgets.QFrame() rock_frame.setObjectName("ColumnFrame") rock_layout = QtWidgets.QVBoxLayout(rock_frame) rock_header = QtWidgets.QLabel("🪨 Rock Interactions") rock_header.setStyleSheet(""" font-size: 20px; font-weight: bold; color: #34495e; padding-bottom: 5px; border-bottom: 2px solid #3498db; """) rock_layout.addWidget(rock_header) # Shared field titles - Rock column rock_form = QtWidgets.QFormLayout() rock_form.setSpacing(DisplayScaling.scale(15)) # Probabilities self.rock_pickup_prob = QtWidgets.QDoubleSpinBox() self.rock_pickup_prob.setRange(0.0, 1.0) self.rock_pickup_prob.setSingleStep(0.1) self.rock_pickup_prob.setDecimals(2) self.rock_pickup_prob.valueChanged.connect(self._on_change) rock_form.addRow("Pickup Probability:", self.rock_pickup_prob) self.rock_throw_prob = QtWidgets.QDoubleSpinBox() self.rock_throw_prob.setRange(0.0, 1.0) self.rock_throw_prob.setSingleStep(0.1) self.rock_throw_prob.setDecimals(2) self.rock_throw_prob.valueChanged.connect(self._on_change) rock_form.addRow("Throw Probability:", self.rock_throw_prob) # Durations with suffix self.rock_min_carry = QtWidgets.QDoubleSpinBox() self.rock_min_carry.setRange(0.5, 30.0) self.rock_min_carry.setSingleStep(0.5) self.rock_min_carry.setSuffix(" sec") self.rock_min_carry.valueChanged.connect(self._on_change) rock_form.addRow("Min Carry Duration:", self.rock_min_carry) self.rock_max_carry = QtWidgets.QDoubleSpinBox() self.rock_max_carry.setRange(1.0, 60.0) self.rock_max_carry.setSingleStep(0.5) self.rock_max_carry.setSuffix(" sec") self.rock_max_carry.valueChanged.connect(self._on_change) rock_form.addRow("Max Carry Duration:", self.rock_max_carry) self.rock_cooldown = QtWidgets.QDoubleSpinBox() self.rock_cooldown.setRange(0.0, 60.0) self.rock_cooldown.setSingleStep(1.0) self.rock_cooldown.setSuffix(" sec") self.rock_cooldown.valueChanged.connect(self._on_change) rock_form.addRow("Cooldown After Throw:", self.rock_cooldown) # Stat effects self.rock_happiness_boost = QtWidgets.QSpinBox() self.rock_happiness_boost.setRange(0, 100) self.rock_happiness_boost.valueChanged.connect(self._on_change) rock_form.addRow("Happiness Boost:", self.rock_happiness_boost) self.rock_satisfaction_boost = QtWidgets.QSpinBox() self.rock_satisfaction_boost.setRange(0, 100) self.rock_satisfaction_boost.valueChanged.connect(self._on_change) rock_form.addRow("Satisfaction Boost:", self.rock_satisfaction_boost) self.rock_anxiety_reduction = QtWidgets.QSpinBox() self.rock_anxiety_reduction.setRange(0, 100) self.rock_anxiety_reduction.valueChanged.connect(self._on_change) rock_form.addRow("Anxiety Reduction:", self.rock_anxiety_reduction) # Memory self.rock_memory_decay = QtWidgets.QDoubleSpinBox() self.rock_memory_decay.setRange(0.0, 1.0) self.rock_memory_decay.setSingleStep(0.05) self.rock_memory_decay.valueChanged.connect(self._on_change) rock_form.addRow("Memory Decay Rate:", self.rock_memory_decay) self.rock_max_memories = QtWidgets.QSpinBox() self.rock_max_memories.setRange(1, 20) self.rock_max_memories.valueChanged.connect(self._on_change) rock_form.addRow("Max Rock Memories:", self.rock_max_memories) rock_layout.addLayout(rock_form) rock_layout.addStretch() # Right column - Poop Interactions poop_frame = QtWidgets.QFrame() poop_frame.setObjectName("ColumnFrame") poop_layout = QtWidgets.QVBoxLayout(poop_frame) poop_header = QtWidgets.QLabel("💩 Poop Interactions") poop_header.setStyleSheet(""" font-size: 20px; font-weight: bold; color: #34495e; padding-bottom: 5px; border-bottom: 2px solid #e67e22; """) poop_layout.addWidget(poop_header) # Shared field titles - Poop column (same labels) poop_form = QtWidgets.QFormLayout() poop_form.setSpacing(DisplayScaling.scale(15)) self.poop_pickup_prob = QtWidgets.QDoubleSpinBox() self.poop_pickup_prob.setRange(0.0, 1.0) self.poop_pickup_prob.setSingleStep(0.1) self.poop_pickup_prob.setDecimals(2) self.poop_pickup_prob.valueChanged.connect(self._on_change) poop_form.addRow("Pickup Probability:", self.poop_pickup_prob) self.poop_throw_prob = QtWidgets.QDoubleSpinBox() self.poop_throw_prob.setRange(0.0, 1.0) self.poop_throw_prob.setSingleStep(0.1) self.poop_throw_prob.setDecimals(2) self.poop_throw_prob.valueChanged.connect(self._on_change) poop_form.addRow("Throw Probability:", self.poop_throw_prob) self.poop_min_carry = QtWidgets.QDoubleSpinBox() self.poop_min_carry.setRange(0.5, 30.0) self.poop_min_carry.setSingleStep(0.5) self.poop_min_carry.setSuffix(" sec") self.poop_min_carry.valueChanged.connect(self._on_change) poop_form.addRow("Min Carry Duration:", self.poop_min_carry) self.poop_max_carry = QtWidgets.QDoubleSpinBox() self.poop_max_carry.setRange(1.0, 60.0) self.poop_max_carry.setSingleStep(0.5) self.poop_max_carry.setSuffix(" sec") self.poop_max_carry.valueChanged.connect(self._on_change) poop_form.addRow("Max Carry Duration:", self.poop_max_carry) self.poop_cooldown = QtWidgets.QDoubleSpinBox() self.poop_cooldown.setRange(0.0, 60.0) self.poop_cooldown.setSingleStep(1.0) self.poop_cooldown.setSuffix(" sec") self.poop_cooldown.valueChanged.connect(self._on_change) poop_form.addRow("Cooldown After Throw:", self.poop_cooldown) # Spacer for alignment (poop doesn't have these stats) poop_form.addRow(QtWidgets.QLabel("")) poop_form.addRow(QtWidgets.QLabel("")) poop_form.addRow(QtWidgets.QLabel("")) poop_form.addRow(QtWidgets.QLabel("")) poop_form.addRow(QtWidgets.QLabel("")) poop_layout.addLayout(poop_form) poop_layout.addStretch() # Add columns to main layout columns_layout.addWidget(rock_frame) columns_layout.addWidget(poop_frame) main_layout.addLayout(columns_layout) widget.setLayout(main_layout) return widget def _create_neurogenesis_tab(self): """Create Neurogenesis settings tab with sub-tabs""" widget = QtWidgets.QWidget() layout = QtWidgets.QVBoxLayout() layout.setSpacing(DisplayScaling.scale(20)) layout.setContentsMargins(DisplayScaling.scale(20), DisplayScaling.scale(20), DisplayScaling.scale(20), DisplayScaling.scale(20)) # Enable checkbox self.neurogenesis_enabled = QtWidgets.QCheckBox("Enable Neurogenesis") self.neurogenesis_enabled.stateChanged.connect(self._on_change) layout.addWidget(self.neurogenesis_enabled) # Showmanship checkbox self.showmanship_enabled = QtWidgets.QCheckBox("Enable Showmanship (Dramatic neuron creation)") self.showmanship_enabled.stateChanged.connect(self._on_change) layout.addWidget(self.showmanship_enabled) # Pruning checkbox self.pruning_enabled = QtWidgets.QCheckBox("Enable Pruning (Remove weak neurons)") self.pruning_enabled.stateChanged.connect(self._on_change) layout.addWidget(self.pruning_enabled) # Sub-tabs sub_tabs = QtWidgets.QTabWidget() # General sub-tab general_subtab = self._create_neurogenesis_general() sub_tabs.addTab(general_subtab, "General") # Triggers sub-tab triggers_subtab = self._create_neurogenesis_triggers() sub_tabs.addTab(triggers_subtab, "Triggers") # Appearance sub-tab appearance_subtab = self._create_neurogenesis_appearance() sub_tabs.addTab(appearance_subtab, "Appearance") # Advanced sub-tab advanced_subtab = self._create_neurogenesis_advanced() sub_tabs.addTab(advanced_subtab, "Advanced") layout.addWidget(sub_tabs) widget.setLayout(layout) return widget def _create_neurogenesis_general(self): """Create Neurogenesis General sub-tab""" widget = QtWidgets.QWidget() layout = QtWidgets.QFormLayout() layout.setSpacing(DisplayScaling.scale(20)) layout.setContentsMargins(DisplayScaling.scale(25), DisplayScaling.scale(25), DisplayScaling.scale(25), DisplayScaling.scale(25)) # Cooldowns self.neuro_cooldown = QtWidgets.QDoubleSpinBox() self.neuro_cooldown.setRange(30.0, 600.0) self.neuro_cooldown.setSingleStep(30.0) self.neuro_cooldown.setSuffix(" sec") self.neuro_cooldown.valueChanged.connect(self._on_change) layout.addRow("Global Cooldown:", self.neuro_cooldown) self.neuro_per_type_cooldown = QtWidgets.QDoubleSpinBox() self.neuro_per_type_cooldown.setRange(10.0, 120.0) self.neuro_per_type_cooldown.setSingleStep(10.0) self.neuro_per_type_cooldown.setSuffix(" sec") self.neuro_per_type_cooldown.valueChanged.connect(self._on_change) layout.addRow("Per-Type Cooldown:", self.neuro_per_type_cooldown) # Max neurons self.neuro_max_neurons = QtWidgets.QSpinBox() self.neuro_max_neurons.setRange(10, 100) self.neuro_max_neurons.valueChanged.connect(self._on_change) layout.addRow("Max Neurons:", self.neuro_max_neurons) # Initial neuron count self.neuro_initial_count = QtWidgets.QSpinBox() self.neuro_initial_count.setRange(5, 20) self.neuro_initial_count.valueChanged.connect(self._on_change) layout.addRow("Initial Neuron Count:", self.neuro_initial_count) #Positioning & Physics pos_group = QtWidgets.QGroupBox("Positioning & Physics") pos_layout = QtWidgets.QFormLayout() pos_layout.setSpacing(DisplayScaling.scale(15)) self.random_start_pos = QtWidgets.QCheckBox("Randomize Start Positions") self.random_start_pos.stateChanged.connect(self._on_change) pos_layout.addRow(self.random_start_pos) self.force_bounds = QtWidgets.QCheckBox("Enforce Canvas Bounds") self.force_bounds.stateChanged.connect(self._on_change) pos_layout.addRow(self.force_bounds) self.canvas_padding = QtWidgets.QSpinBox() self.canvas_padding.setRange(0, 300) self.canvas_padding.setSingleStep(10) self.canvas_padding.setSuffix(" px") self.canvas_padding.valueChanged.connect(self._on_change) pos_layout.addRow("Canvas Padding:", self.canvas_padding) self.centering_force = QtWidgets.QDoubleSpinBox() self.centering_force.setRange(0.0, 0.5) self.centering_force.setSingleStep(0.01) self.centering_force.setDecimals(3) self.centering_force.valueChanged.connect(self._on_change) pos_layout.addRow("Centering Force:", self.centering_force) pos_group.setLayout(pos_layout) layout.addRow(pos_group) widget.setLayout(layout) return widget def _create_neurogenesis_triggers(self): """Create Neurogenesis Triggers sub-tab""" widget = QtWidgets.QWidget() main_layout = QtWidgets.QVBoxLayout() main_layout.setSpacing(DisplayScaling.scale(20)) # Scroll area for smaller screens if needed scroll = QtWidgets.QScrollArea() scroll.setWidgetResizable(True) content_widget = QtWidgets.QWidget() content_layout = QtWidgets.QVBoxLayout(content_widget) content_layout.setSpacing(DisplayScaling.scale(25)) # Novelty trigger novelty_group = QtWidgets.QGroupBox("Novelty Trigger") novelty_layout = QtWidgets.QFormLayout() novelty_layout.setSpacing(DisplayScaling.scale(15)) self.novelty_enabled = QtWidgets.QCheckBox("Enabled") self.novelty_enabled.stateChanged.connect(self._on_change) novelty_layout.addRow(self.novelty_enabled) self.novelty_threshold = QtWidgets.QDoubleSpinBox() self.novelty_threshold.setRange(0.5, 10.0) self.novelty_threshold.setSingleStep(0.5) self.novelty_threshold.valueChanged.connect(self._on_change) novelty_layout.addRow("Threshold:", self.novelty_threshold) self.novelty_decay = QtWidgets.QDoubleSpinBox() self.novelty_decay.setRange(0.0, 1.0) self.novelty_decay.setSingleStep(0.05) self.novelty_decay.valueChanged.connect(self._on_change) novelty_layout.addRow("Decay Rate:", self.novelty_decay) novelty_group.setLayout(novelty_layout) content_layout.addWidget(novelty_group) # Stress trigger stress_group = QtWidgets.QGroupBox("Stress Trigger") stress_layout = QtWidgets.QFormLayout() stress_layout.setSpacing(DisplayScaling.scale(15)) self.stress_enabled = QtWidgets.QCheckBox("Enabled") self.stress_enabled.stateChanged.connect(self._on_change) stress_layout.addRow(self.stress_enabled) self.stress_threshold = QtWidgets.QDoubleSpinBox() self.stress_threshold.setRange(0.5, 10.0) self.stress_threshold.setSingleStep(0.5) self.stress_threshold.valueChanged.connect(self._on_change) stress_layout.addRow("Threshold:", self.stress_threshold) self.stress_decay = QtWidgets.QDoubleSpinBox() self.stress_decay.setRange(0.0, 1.0) self.stress_decay.setSingleStep(0.05) self.stress_decay.valueChanged.connect(self._on_change) stress_layout.addRow("Decay Rate:", self.stress_decay) stress_group.setLayout(stress_layout) content_layout.addWidget(stress_group) # Reward trigger reward_group = QtWidgets.QGroupBox("Reward Trigger") reward_layout = QtWidgets.QFormLayout() reward_layout.setSpacing(DisplayScaling.scale(15)) self.reward_enabled = QtWidgets.QCheckBox("Enabled") self.reward_enabled.stateChanged.connect(self._on_change) reward_layout.addRow(self.reward_enabled) self.reward_threshold = QtWidgets.QDoubleSpinBox() self.reward_threshold.setRange(0.5, 10.0) self.reward_threshold.setSingleStep(0.5) self.reward_threshold.valueChanged.connect(self._on_change) reward_layout.addRow("Threshold:", self.reward_threshold) self.reward_decay = QtWidgets.QDoubleSpinBox() self.reward_decay.setRange(0.0, 1.0) self.reward_decay.setSingleStep(0.05) self.reward_decay.valueChanged.connect(self._on_change) reward_layout.addRow("Decay Rate:", self.reward_decay) reward_group.setLayout(reward_layout) content_layout.addWidget(reward_group) scroll.setWidget(content_widget) main_layout.addWidget(scroll) widget.setLayout(main_layout) return widget def _create_neurogenesis_appearance(self): """Create Neurogenesis Appearance sub-tab""" widget = QtWidgets.QWidget() layout = QtWidgets.QFormLayout() layout.setSpacing(DisplayScaling.scale(20)) layout.setContentsMargins(DisplayScaling.scale(25), DisplayScaling.scale(25), DisplayScaling.scale(25), DisplayScaling.scale(25)) # Colours for each type self.novelty_color = self._create_color_button() self.novelty_color.clicked.connect(self._on_change) layout.addRow("Novelty Color:", self.novelty_color) self.stress_color = self._create_color_button() self.stress_color.clicked.connect(self._on_change) layout.addRow("Stress Color:", self.stress_color) self.reward_color = self._create_color_button() self.reward_color.clicked.connect(self._on_change) layout.addRow("Reward Color:", self.reward_color) # Visual effects self.highlight_duration = QtWidgets.QDoubleSpinBox() self.highlight_duration.setRange(1.0, 20.0) self.highlight_duration.setSingleStep(0.5) self.highlight_duration.setSuffix(" sec") self.highlight_duration.valueChanged.connect(self._on_change) layout.addRow("Highlight Duration:", self.highlight_duration) self.pulse_effect = QtWidgets.QCheckBox("Enable Pulse Effect") self.pulse_effect.stateChanged.connect(self._on_change) layout.addRow(self.pulse_effect) widget.setLayout(layout) return widget def _create_neurogenesis_advanced(self): """NEW: Create Neurogenesis Advanced sub-tab""" widget = QtWidgets.QWidget() layout = QtWidgets.QFormLayout() layout.setSpacing(DisplayScaling.scale(20)) layout.setContentsMargins(DisplayScaling.scale(25), DisplayScaling.scale(25), DisplayScaling.scale(25), DisplayScaling.scale(25)) # Advanced neurogenesis parameters self.max_novelty_neurons = QtWidgets.QSpinBox() self.max_novelty_neurons.setRange(1, 20) self.max_novelty_neurons.valueChanged.connect(self._on_change) layout.addRow("Max Novelty Neurons:", self.max_novelty_neurons) self.pattern_threshold = QtWidgets.QSpinBox() self.pattern_threshold.setRange(1, 10) self.pattern_threshold.valueChanged.connect(self._on_change) layout.addRow("Pattern Threshold:", self.pattern_threshold) self.experience_buffer_size = QtWidgets.QSpinBox() self.experience_buffer_size.setRange(10, 100) self.experience_buffer_size.valueChanged.connect(self._on_change) layout.addRow("Experience Buffer Size:", self.experience_buffer_size) self.min_utility_for_keep = QtWidgets.QDoubleSpinBox() self.min_utility_for_keep.setRange(0.0, 1.0) self.min_utility_for_keep.setSingleStep(0.05) self.min_utility_for_keep.valueChanged.connect(self._on_change) layout.addRow("Min Utility for Keep:", self.min_utility_for_keep) widget.setLayout(layout) return widget def _create_hebbian_tab(self): """NEW: Create Hebbian Learning tab""" widget = QtWidgets.QWidget() layout = QtWidgets.QFormLayout() layout.setSpacing(DisplayScaling.scale(20)) layout.setContentsMargins(DisplayScaling.scale(25), DisplayScaling.scale(25), DisplayScaling.scale(25), DisplayScaling.scale(25)) hebbian_desc = QtWidgets.QLabel( "Hebbian learning strengthens connections between neurons that fire together. " "This simulates the 'cells that fire together, wire together' principle." ) hebbian_desc.setWordWrap(True) hebbian_desc.setStyleSheet("color: #7f8c8d; font-style: italic;") layout.addRow(hebbian_desc) layout.addRow(QtWidgets.QLabel("")) # Spacer self.hebbian_learning_interval = QtWidgets.QSpinBox() self.hebbian_learning_interval.setRange(1, 300) self.hebbian_learning_interval.setSuffix(" sec") self.hebbian_learning_interval.valueChanged.connect(self._on_change) layout.addRow("Learning Interval:", self.hebbian_learning_interval) self.hebbian_base_rate = QtWidgets.QDoubleSpinBox() self.hebbian_base_rate.setRange(0.0, 1.0) self.hebbian_base_rate.setSingleStep(0.01) self.hebbian_base_rate.setDecimals(3) self.hebbian_base_rate.valueChanged.connect(self._on_change) layout.addRow("Base Learning Rate:", self.hebbian_base_rate) self.hebbian_weight_decay = QtWidgets.QDoubleSpinBox() self.hebbian_weight_decay.setRange(0.0, 0.5) self.hebbian_weight_decay.setSingleStep(0.01) self.hebbian_weight_decay.setDecimals(3) self.hebbian_weight_decay.valueChanged.connect(self._on_change) layout.addRow("Weight Decay:", self.hebbian_weight_decay) self.hebbian_min_weight = QtWidgets.QDoubleSpinBox() self.hebbian_min_weight.setRange(-2.0, 0.0) self.hebbian_min_weight.setSingleStep(0.1) self.hebbian_min_weight.setDecimals(1) self.hebbian_min_weight.valueChanged.connect(self._on_change) layout.addRow("Min Weight:", self.hebbian_min_weight) self.hebbian_max_weight = QtWidgets.QDoubleSpinBox() self.hebbian_max_weight.setRange(0.0, 2.0) self.hebbian_max_weight.setSingleStep(0.1) self.hebbian_max_weight.setDecimals(1) self.hebbian_max_weight.valueChanged.connect(self._on_change) layout.addRow("Max Weight:", self.hebbian_max_weight) self.hebbian_max_pairs = QtWidgets.QSpinBox() self.hebbian_max_pairs.setRange(1, 10) self.hebbian_max_pairs.valueChanged.connect(self._on_change) layout.addRow("Max Hebbian Pairs:", self.hebbian_max_pairs) widget.setLayout(layout) return widget def _create_color_button(self): """Create a colour picker button""" button = QtWidgets.QPushButton() # Scale the colour button size size = DisplayScaling.scale(40) button.setFixedSize(size * 2, size) button.setStyleSheet("background-color: white; border: 1px solid gray;") button.clicked.connect(self._pick_color) return button def _pick_color(self): """Open colour picker dialog""" button = self.sender() color = QtWidgets.QColorDialog.getColor() if color.isValid(): button.setStyleSheet(f"background-color: {color.name()}; border: 1px solid gray;") self._on_change() def _create_display_tab(self): widget = QtWidgets.QWidget() layout = QtWidgets.QFormLayout() layout.setSpacing(DisplayScaling.scale(20)) layout.setContentsMargins(DisplayScaling.scale(25), DisplayScaling.scale(25), DisplayScaling.scale(25), DisplayScaling.scale(25)) # Neuron settings self.neuron_radius = QtWidgets.QSpinBox() self.neuron_radius.setRange(10, 50) self.neuron_radius.valueChanged.connect(self._on_change) layout.addRow("Neuron Radius:", self.neuron_radius) self.neuron_font_size = QtWidgets.QSpinBox() self.neuron_font_size.setRange(6, 20) self.neuron_font_size.valueChanged.connect(self._on_change) layout.addRow("Neuron Label Size:", self.neuron_font_size) # Connection settings self.connection_width = QtWidgets.QDoubleSpinBox() self.connection_width.setRange(0.5, 5.0) self.connection_width.setSingleStep(0.1) self.connection_width.valueChanged.connect(self._on_change) layout.addRow("Line Width:", self.connection_width) # Button settings self.button_font_size = QtWidgets.QSpinBox() self.button_font_size.setRange(10, 24) self.button_font_size.valueChanged.connect(self._on_change) layout.addRow("Button Font Size:", self.button_font_size) self.button_width = QtWidgets.QSpinBox() self.button_width.setRange(80, 200) self.button_width.valueChanged.connect(self._on_change) layout.addRow("Button Width:", self.button_width) self.button_height = QtWidgets.QSpinBox() self.button_height.setRange(30, 80) self.button_height.valueChanged.connect(self._on_change) layout.addRow("Button Height:", self.button_height) self.button_spacing = QtWidgets.QSpinBox() self.button_spacing.setRange(10, 50) self.button_spacing.valueChanged.connect(self._on_change) layout.addRow("Button Spacing:", self.button_spacing) widget.setLayout(layout) return widget def _create_designer_tab(self): """Create Designer settings tab""" widget = QtWidgets.QWidget() layout = QtWidgets.QFormLayout() layout.setSpacing(DisplayScaling.scale(20)) layout.setContentsMargins(DisplayScaling.scale(25), DisplayScaling.scale(25), DisplayScaling.scale(25), DisplayScaling.scale(25)) designer_desc = QtWidgets.QLabel( "Designer mode settings for creating and editing neural networks. " "These constraints ensure neurons are positioned optimally." ) designer_desc.setWordWrap(True) designer_desc.setStyleSheet("color: #7f8c8d; font-style: italic;") layout.addRow(designer_desc) layout.addRow(QtWidgets.QLabel("")) # Spacer self.designer_min_distance = QtWidgets.QSpinBox() self.designer_min_distance.setRange(50, 300) self.designer_min_distance.setSuffix(" px") self.designer_min_distance.valueChanged.connect(self._on_change) layout.addRow("Min Neuron Distance:", self.designer_min_distance) self.designer_max_distance = QtWidgets.QSpinBox() self.designer_max_distance.setRange(300, 1000) self.designer_max_distance.setSuffix(" px") self.designer_max_distance.valueChanged.connect(self._on_change) layout.addRow("Max Neuron Distance:", self.designer_max_distance) widget.setLayout(layout) return widget def _on_change(self): """Mark that changes have been made""" self.changes_made = True self.save_button.setEnabled(True) def load_current_config(self): """Load current config values into UI""" # General current_lang = self.config_manager.get_language() # Find and set the matching language display string for i in range(self.language_combo.count()): item_text = self.language_combo.itemText(i) if item_text.startswith(f"{current_lang} -"): self.language_combo.setCurrentIndex(i) break self.facts_enabled.setChecked( self.config_manager.config.getboolean('Facts', 'enabled', fallback=True) ) self.facts_interval.setValue( self.config_manager.config.getint('Facts', 'interval_minutes', fallback=5) ) self.facts_display.setValue( self.config_manager.config.getint('Facts', 'display_seconds', fallback=18) ) # LinkBlink config linkblink_config = self.config_manager.get_linkblink_config() self.linkblink_interval_min.setValue(linkblink_config['interval_min']) self.linkblink_interval_max.setValue(linkblink_config['interval_max']) self.linkblink_duration.setValue(linkblink_config['blink_duration']) # Rock config rock_config = self.config_manager.get_rock_config() self.rock_pickup_prob.setValue(rock_config['pickup_prob']) self.rock_throw_prob.setValue(rock_config['throw_prob']) self.rock_min_carry.setValue(rock_config['min_carry_duration']) self.rock_max_carry.setValue(rock_config['max_carry_duration']) self.rock_cooldown.setValue(rock_config['cooldown_after_throw']) self.rock_happiness_boost.setValue(rock_config['happiness_boost']) self.rock_satisfaction_boost.setValue(rock_config['satisfaction_boost']) self.rock_anxiety_reduction.setValue(rock_config['anxiety_reduction']) self.rock_memory_decay.setValue(rock_config['memory_decay_rate']) self.rock_max_memories.setValue(rock_config['max_rock_memories']) # Poop config poop_config = self.config_manager.get_poop_config() self.poop_pickup_prob.setValue(poop_config['pickup_prob']) self.poop_throw_prob.setValue(poop_config['throw_prob']) self.poop_min_carry.setValue(poop_config['min_carry_duration']) self.poop_max_carry.setValue(poop_config['max_carry_duration']) self.poop_cooldown.setValue(poop_config['cooldown_after_throw']) # Neurogenesis config neuro_config = self.config_manager.get_neurogenesis_config() self.neurogenesis_enabled.setChecked(neuro_config['general']['enabled']) self.showmanship_enabled.setChecked(neuro_config['general']['showmanship']) self.pruning_enabled.setChecked(neuro_config['general']['pruning_enabled']) self.neuro_cooldown.setValue(neuro_config['general']['cooldown']) self.neuro_per_type_cooldown.setValue(neuro_config['general']['per_type_cooldown']) self.neuro_max_neurons.setValue(neuro_config['general']['max_neurons']) self.neuro_initial_count.setValue(neuro_config['general']['initial_neuron_count']) # Advanced neurogenesis self.max_novelty_neurons.setValue(neuro_config['general']['max_novelty_neurons']) self.pattern_threshold.setValue(neuro_config['general']['pattern_threshold']) self.experience_buffer_size.setValue(neuro_config['general']['experience_buffer_size']) self.min_utility_for_keep.setValue(neuro_config['general']['min_utility_for_keep']) # Load Positioning & Physics raw_config = self.config_manager.config sec_props = 'Neurogenesis.NeuronProperties' self.random_start_pos.setChecked(raw_config.getboolean(sec_props, 'randomize_start_positions', fallback=True)) self.force_bounds.setChecked(raw_config.getboolean(sec_props, 'force_bounds', fallback=True)) self.canvas_padding.setValue(raw_config.getint(sec_props, 'canvas_padding', fallback=60)) self.centering_force.setValue(raw_config.getfloat(sec_props, 'centering_force', fallback=0.02)) # Triggers self.novelty_enabled.setChecked(neuro_config['triggers']['novelty']['enabled']) self.novelty_threshold.setValue(neuro_config['triggers']['novelty']['threshold']) self.novelty_decay.setValue(neuro_config['triggers']['novelty']['decay_rate']) self.stress_enabled.setChecked(neuro_config['triggers']['stress']['enabled']) self.stress_threshold.setValue(neuro_config['triggers']['stress']['threshold']) self.stress_decay.setValue(neuro_config['triggers']['stress']['decay_rate']) self.reward_enabled.setChecked(neuro_config['triggers']['reward']['enabled']) self.reward_threshold.setValue(neuro_config['triggers']['reward']['threshold']) self.reward_decay.setValue(neuro_config['triggers']['reward']['decay_rate']) # Appearance self._set_color_button(self.novelty_color, neuro_config['appearance']['colors']['novelty']) self._set_color_button(self.stress_color, neuro_config['appearance']['colors']['stress']) self._set_color_button(self.reward_color, neuro_config['appearance']['colors']['reward']) self.highlight_duration.setValue(neuro_config['visual_effects']['highlight_duration']) self.pulse_effect.setChecked(neuro_config['visual_effects']['pulse_effect']) # Hebbian config hebbian_config = self.config_manager.get_hebbian_config() self.hebbian_learning_interval.setValue(hebbian_config['learning_interval']) self.hebbian_base_rate.setValue(hebbian_config['base_learning_rate']) self.hebbian_weight_decay.setValue(hebbian_config['weight_decay']) self.hebbian_min_weight.setValue(hebbian_config['min_weight']) self.hebbian_max_weight.setValue(hebbian_config['max_weight']) self.hebbian_max_pairs.setValue(hebbian_config['max_hebbian_pairs']) # Display display_config = self.config_manager.get_display_config() self.neuron_radius.setValue(display_config['neuron_radius']) self.neuron_font_size.setValue(display_config['neuron_label_font_size']) self.connection_width.setValue(display_config['connection_line_width']) self.button_font_size.setValue(display_config['button_font_size']) self.button_width.setValue(display_config['button_width']) self.button_height.setValue(display_config['button_height']) self.button_spacing.setValue(display_config['button_spacing']) # Designer self.designer_min_distance.setValue(self.config_manager.config.getint('Designer', 'designer_min_neuron_distance', fallback=100)) self.designer_max_distance.setValue(self.config_manager.config.getint('Designer', 'designer_max_neuron_distance', fallback=600)) # Reset changes flag self.changes_made = False def _set_color_button(self, button, rgb_list): """Set color button background from RGB list""" color = QtGui.QColor(*rgb_list) button.setStyleSheet(f"background-color: {color.name()}; border: 1px solid gray;") def save_and_restart(self): """Save configuration and prompt for restart""" # Save all settings self._save_all_settings() # Show restart prompt reply = QtWidgets.QMessageBox.question( self, "Restart Required", "Configuration saved successfully!\n\nA restart is required for changes to take effect.\n\nWould you like to restart the application now?", QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No, QtWidgets.QMessageBox.Yes ) if reply == QtWidgets.QMessageBox.Yes: self._restart_application() else: self.close() def _save_all_settings(self): """Save all settings to config.ini""" # General if not self.config_manager.config.has_section('General'): self.config_manager.config.add_section('General') # EXTRACT language code from display string selected_lang_display = self.language_combo.currentText() language_code = selected_lang_display.split(' - ')[0] self.config_manager.config.set('General', 'language', language_code) if not self.config_manager.config.has_section('Facts'): self.config_manager.config.add_section('Facts') self.config_manager.config.set('Facts', 'enabled', str(self.facts_enabled.isChecked())) self.config_manager.config.set('Facts', 'interval_minutes', str(self.facts_interval.value())) self.config_manager.config.set('Facts', 'display_seconds', str(self.facts_display.value())) # LinkBlink if not self.config_manager.config.has_section('LinkBlink'): self.config_manager.config.add_section('LinkBlink') self.config_manager.config.set('LinkBlink', 'interval_min', str(self.linkblink_interval_min.value())) self.config_manager.config.set('LinkBlink', 'interval_max', str(self.linkblink_interval_max.value())) self.config_manager.config.set('LinkBlink', 'blink_duration', str(self.linkblink_duration.value())) # Rock Interactions if not self.config_manager.config.has_section('RockInteractions'): self.config_manager.config.add_section('RockInteractions') self.config_manager.config.set('RockInteractions', 'pickup_probability', str(self.rock_pickup_prob.value())) self.config_manager.config.set('RockInteractions', 'throw_probability', str(self.rock_throw_prob.value())) self.config_manager.config.set('RockInteractions', 'min_carry_duration', str(self.rock_min_carry.value())) self.config_manager.config.set('RockInteractions', 'max_carry_duration', str(self.rock_max_carry.value())) self.config_manager.config.set('RockInteractions', 'cooldown_after_throw', str(self.rock_cooldown.value())) self.config_manager.config.set('RockInteractions', 'happiness_boost', str(self.rock_happiness_boost.value())) self.config_manager.config.set('RockInteractions', 'satisfaction_boost', str(self.rock_satisfaction_boost.value())) self.config_manager.config.set('RockInteractions', 'anxiety_reduction', str(self.rock_anxiety_reduction.value())) self.config_manager.config.set('RockInteractions', 'memory_decay_rate', str(self.rock_memory_decay.value())) self.config_manager.config.set('RockInteractions', 'max_rock_memories', str(self.rock_max_memories.value())) # Poop Interactions if not self.config_manager.config.has_section('PoopInteractions'): self.config_manager.config.add_section('PoopInteractions') self.config_manager.config.set('PoopInteractions', 'pickup_probability', str(self.poop_pickup_prob.value())) self.config_manager.config.set('PoopInteractions', 'throw_probability', str(self.poop_throw_prob.value())) self.config_manager.config.set('PoopInteractions', 'min_carry_duration', str(self.poop_min_carry.value())) self.config_manager.config.set('PoopInteractions', 'max_carry_duration', str(self.poop_max_carry.value())) self.config_manager.config.set('PoopInteractions', 'cooldown_after_throw', str(self.poop_cooldown.value())) # Neurogenesis if not self.config_manager.config.has_section('Neurogenesis'): self.config_manager.config.add_section('Neurogenesis') self.config_manager.config.set('Neurogenesis', 'enabled', str(self.neurogenesis_enabled.isChecked())) self.config_manager.config.set('Neurogenesis', 'showmanship', str(self.showmanship_enabled.isChecked())) self.config_manager.config.set('Neurogenesis', 'pruning_enabled', str(self.pruning_enabled.isChecked())) self.config_manager.config.set('Neurogenesis', 'cooldown', str(self.neuro_cooldown.value())) self.config_manager.config.set('Neurogenesis', 'per_type_cooldown', str(self.neuro_per_type_cooldown.value())) self.config_manager.config.set('Neurogenesis', 'max_neurons', str(self.neuro_max_neurons.value())) self.config_manager.config.set('Neurogenesis', 'initial_neuron_count', str(self.neuro_initial_count.value())) # Advanced neurogenesis self.config_manager.config.set('Neurogenesis', 'max_novelty_neurons', str(self.max_novelty_neurons.value())) self.config_manager.config.set('Neurogenesis', 'pattern_threshold', str(self.pattern_threshold.value())) self.config_manager.config.set('Neurogenesis', 'experience_buffer_size', str(self.experience_buffer_size.value())) self.config_manager.config.set('Neurogenesis', 'min_utility_for_keep', str(self.min_utility_for_keep.value())) # Neurogenesis.NeuronProperties (Positioning) if not self.config_manager.config.has_section('Neurogenesis.NeuronProperties'): self.config_manager.config.add_section('Neurogenesis.NeuronProperties') self.config_manager.config.set('Neurogenesis.NeuronProperties', 'randomize_start_positions', str(self.random_start_pos.isChecked())) self.config_manager.config.set('Neurogenesis.NeuronProperties', 'force_bounds', str(self.force_bounds.isChecked())) self.config_manager.config.set('Neurogenesis.NeuronProperties', 'canvas_padding', str(self.canvas_padding.value())) self.config_manager.config.set('Neurogenesis.NeuronProperties', 'centering_force', str(self.centering_force.value())) # Save to file via ConfigManager helper self.config_manager._save_config() def _restart_application(self): """Restarts the current python process""" python = sys.executable os.execl(python, python, *sys.argv) ================================================ FILE: src/save_manager.py ================================================ import json import os import zipfile import shutil from datetime import datetime from uuid import UUID class DateTimeEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, datetime): return obj.isoformat() if isinstance(obj, UUID): return str(obj) return super().default(obj) class SaveManager: def __init__(self, save_directory="saves"): self.save_directory = save_directory os.makedirs(save_directory, exist_ok=True) self.autosave_path = os.path.join(save_directory, "autosave.zip") self.manual_path = os.path.join(save_directory, "save_data.zip") # This will be updated dynamically self.backup_path = os.path.join(save_directory, "autosave_backup.zip") # -------------------------------------------------- # Public helpers # -------------------------------------------------- def save_exists(self, autosave=False): if autosave: return os.path.exists(self.autosave_path) else: # For manual saves, we need to check if any UUID-based save exists return self._get_manual_save_path() is not None def get_latest_save(self): """Return the most recent save: permanent manual > autosave > nothing""" # 1. Look for permanent manual save: {uuid}.zip if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic and self.tamagotchi_logic.squid: uuid_str = str(self.tamagotchi_logic.squid.uuid) manual_path = self._get_save_path_for_uuid(uuid_str, is_autosave=False) if os.path.exists(manual_path): return manual_path # 2. Fallback: autosave for this UUID if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic and self.tamagotchi_logic.squid: uuid_str = str(self.tamagotchi_logic.squid.uuid) autosave_path = self._get_save_path_for_uuid(uuid_str, is_autosave=True) if os.path.exists(autosave_path): return autosave_path # Optional: fallback to any old-style timestamped save (for backward compatibility) old_manual = self._get_manual_save_path() if old_manual: return old_manual return None def _get_manual_save_path(self): """Find the most recent manual save file (UUID-based)""" try: # Look for any .zip files in save directory that aren't autosave or backup save_files = [f for f in os.listdir(self.save_directory) if f.endswith('.zip') and f not in ['autosave.zip', 'autosave_backup.zip']] if save_files: # Return the most recently modified one save_files = [os.path.join(self.save_directory, f) for f in save_files] return max(save_files, key=os.path.getmtime) return None except (FileNotFoundError, ValueError): return None def _get_save_path_for_uuid(self, uuid_str, is_autosave=False): """Generate save path for a specific UUID. - Autosaves → {uuid}_autosave.zip - Manual saves → {uuid}.zip ← PERMANENT, single file (no timestamp) """ safe_uuid = str(uuid_str).replace('-', '_') if is_autosave: return os.path.join(self.save_directory, f"{safe_uuid}_autosave.zip") else: # Single permanent manual save file — no timestamp! return os.path.join(self.save_directory, f"{safe_uuid}.zip") # -------------------------------------------------- # Save API # -------------------------------------------------- def save_game(self, save_data: dict, is_autosave: bool = False) -> str | None: """Save game with proper UUID management.""" try: from PyQt5.QtWidgets import QMessageBox import uuid # === PRIORITY 1: Use the live squid's UUID if available (most reliable) === live_squid = None if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: live_squid = getattr(self.tamagotchi_logic, 'squid', None) if live_squid and hasattr(live_squid, 'uuid') and live_squid.uuid: squid_uuid = str(live_squid.uuid) print(f"[SaveManager] Using live squid UUID: {squid_uuid}") else: # Fallback: extract from save_data (old behavior) squid_uuid = save_data.get('game_state', {}).get('squid', {}).get('uuid') if not squid_uuid: squid_uuid = str(uuid.uuid4()) save_data.setdefault('game_state', {}).setdefault('squid', {})['uuid'] = squid_uuid print(f"[SaveManager] Generated new UUID (no live squid): {squid_uuid}") # Ensure UUID is in save_data for consistency save_data.setdefault('game_state', {}).setdefault('squid', {})['uuid'] = squid_uuid # Determine save path target_path = self._get_save_path_for_uuid(squid_uuid, is_autosave=is_autosave) # Skip if identical if os.path.exists(target_path): existing_data = self._load_single_save(target_path) if existing_data and self._are_saves_identical(existing_data, save_data): print(f"[SaveManager] Identical save already exists → skipping: {target_path}") return target_path # Backup old save backup_path = None if os.path.exists(target_path): backup_path = target_path + ".backup" shutil.copy2(target_path, backup_path) # Write to temp file temp_path = target_path + ".tmp" with zipfile.ZipFile(temp_path, 'w', compression=zipfile.ZIP_DEFLATED) as zf: for key, data in save_data.items(): zf.writestr(f"{key}.json", json.dumps(data, indent=4, cls=DateTimeEncoder)) zf.writestr("uuid.txt", f"SquidSignature {squid_uuid}") # Atomic replace if os.path.exists(target_path): os.replace(target_path, target_path + ".old") os.replace(temp_path, target_path) # Cleanup for old in [f for f in os.listdir(self.save_directory) if f.endswith(('.old', '.backup'))]: try: os.remove(os.path.join(self.save_directory, old)) except: pass print(f"[SaveManager] Save successful → {target_path}") return target_path except Exception as e: print(f"[SaveManager] Save failed: {e}") import traceback traceback.print_exc() return None def _are_saves_identical(self, save1, save2): """Compare two save dictionaries for equality.""" try: # Remove any volatile fields that might differ between saves ignore_fields = ['_save_timestamp', 'autosave_count'] def clean_save(save): if isinstance(save, dict): return {k: clean_save(v) for k, v in save.items() if k not in ignore_fields} elif isinstance(save, list): return [clean_save(item) for item in save] else: return save cleaned1 = clean_save(save1) cleaned2 = clean_save(save2) return cleaned1 == cleaned2 except Exception as e: print(f"[SaveManager] Error comparing saves: {e}") return False def _load_single_save(self, path): """Load a single save file.""" try: data = {} with zipfile.ZipFile(path, 'r') as zf: for fname in zf.namelist(): if fname.endswith('.json'): with zf.open(fname) as f: key = os.path.splitext(fname)[0] data[key] = json.loads(f.read().decode('utf-8')) return data except Exception as e: print(f"[SaveManager] Error loading save {path}: {e}") return None def cleanup_duplicate_saves(self): """Remove duplicate save files with identical content.""" try: # Get all save files save_files = [f for f in os.listdir(self.save_directory) if f.endswith('.zip')] # Group by UUID saves_by_uuid = {} for save_file in save_files: if '_autosave.zip' in save_file: uuid = save_file.replace('_autosave.zip', '') else: # Extract UUID from timestamped files parts = save_file.split('_') if len(parts) >= 6: # UUID has 5 parts when split by '_' uuid = '_'.join(parts[:5]) else: continue if uuid not in saves_by_uuid: saves_by_uuid[uuid] = [] saves_by_uuid[uuid].append(save_file) # Check each group for duplicates for uuid, files in saves_by_uuid.items(): if len(files) > 1: print(f"[SaveManager] Found {len(files)} saves for UUID {uuid}") # Load and compare saves save_contents = {} for file in files: path = os.path.join(self.save_directory, file) content = self._load_single_save(path) if content: save_contents[file] = content # Find unique saves unique_saves = {} for file1, content1 in save_contents.items(): is_duplicate = False for file2, content2 in unique_saves.items(): if self._are_saves_identical(content1, content2): is_duplicate = True print(f"[SaveManager] {file1} is duplicate of {file2}") # Keep the newer file time1 = os.path.getmtime(os.path.join(self.save_directory, file1)) time2 = os.path.getmtime(os.path.join(self.save_directory, file2)) if time1 > time2: # Delete the older one os.remove(os.path.join(self.save_directory, file2)) unique_saves[file2] = content1 else: # Delete the newer one os.remove(os.path.join(self.save_directory, file1)) break if not is_duplicate: unique_saves[file1] = content1 except Exception as e: print(f"[SaveManager] Error cleaning duplicate saves: {e}") # -------------------------------------------------- # Load # -------------------------------------------------- def load_game(self) -> dict | None: latest = self.get_latest_save() if not latest: return None data = {} squid_uuid = None with zipfile.ZipFile(latest, 'r') as zf: for fname in zf.namelist(): if fname == "uuid.txt": squid_uuid = zf.read(fname).decode().strip() continue with zf.open(fname) as f: raw = f.read() if not raw: continue key = os.path.splitext(fname)[0] data[key] = json.loads(raw.decode('utf-8')) # inject UUID into returned dict data["_uuid"] = squid_uuid return data # -------------------------------------------------- # House-keeping # -------------------------------------------------- def delete_save(self, is_autosave: bool = False) -> bool: if is_autosave: path = self.autosave_path else: path = self._get_manual_save_path() if path and os.path.exists(path): os.remove(path) return True return False def get_save_timestamp(self, is_autosave: bool = False) -> float | None: if is_autosave: path = self.autosave_path else: path = self._get_manual_save_path() return os.path.getmtime(path) if path and os.path.exists(path) else None def get_save_size(self, is_autosave: bool = False) -> int | None: if is_autosave: path = self.autosave_path else: path = self._get_manual_save_path() return os.path.getsize(path) if path and os.path.exists(path) else None ================================================ FILE: src/splash_screen.py ================================================ from PyQt5 import QtCore, QtGui, QtWidgets import os from .display_scaling import DisplayScaling from .localisation import Localisation class SplashScreen(QtWidgets.QWidget): finished = QtCore.pyqtSignal() second_frame = QtCore.pyqtSignal() frame_changed = QtCore.pyqtSignal(int) def __init__(self, parent=None): super().__init__(parent) self.loc = Localisation.instance() self.setWindowFlags(QtCore.Qt.WindowStaysOnTopHint | QtCore.Qt.FramelessWindowHint) self.setAttribute(QtCore.Qt.WA_TranslucentBackground) self.frame_index = 0 self.frames = [] # Load frames for i in range(1, 7): image_path = os.path.join("images", "egg", f"anim0{i}.jpg") if os.path.exists(image_path): original_pixmap = QtGui.QPixmap(image_path) if not original_pixmap.isNull(): scaled_size = original_pixmap.size() * DisplayScaling.get_scale_factor() scaled_pixmap = original_pixmap.scaled( scaled_size, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation ) self.frames.append(scaled_pixmap) else: print(f"Failed to load image: {image_path}") else: print(f"Image file not found: {image_path}") if not self.frames: print("No frames were loaded successfully.") self.label = QtWidgets.QLabel("No images loaded", self) self.setFixedSize(256, 256) else: self.label = QtWidgets.QLabel(self) self.label.setPixmap(self.frames[0]) self.setFixedSize(self.frames[0].size()) # Create timer but don't start it yet self.timer = QtCore.QTimer(self) self.timer.timeout.connect(self.next_frame) def start_animation(self): """Start the animation sequence after window is ready""" self.frame_changed.emit(self.frame_index) self.timer.start(1000) def next_frame(self): self.frame_index += 1 if self.frame_index < len(self.frames): self.label.setPixmap(self.frames[self.frame_index]) self.frame_changed.emit(self.frame_index) if self.frame_index == 1: self.second_frame.emit() elif self.frame_index == len(self.frames): QtCore.QTimer.singleShot(1500, self.end_animation) else: self.timer.stop() def end_animation(self): # Use localised messages hatched_msg = self.loc.get("squid_hatched") look_after_msg = self.loc.get("look_after") print("") print(" ******************************") print(f" *** {hatched_msg} ***") print(f" {look_after_msg}") print(" ******************************") self.hide() self.finished.emit() def showEvent(self, event): self.move(self.parent().rect().center() - self.rect().center()) super().showEvent(event) ================================================ FILE: src/squid.py ================================================ import os import random import uuid import time from datetime import datetime from enum import Enum import math from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import QTimer from .brain_about_tab import SQUID_NAMES from .mental_states import MentalStateManager from .memory_manager import MemoryManager from .personality import Personality from .decision_engine import DecisionEngine from .image_cache import ImageCache from .statistics_window import StatisticsWindow from .squid_statistics import SquidStatistics from .vision_worker import ( VisionWorker, VisionResult, SquidVisionState, SceneObject, extract_scene_objects, create_squid_vision_state ) class Squid: def __init__(self, user_interface, tamagotchi_logic=None, personality=None, neuro_cooldown=None): self.ui = user_interface self.tamagotchi_logic = tamagotchi_logic self.memory_manager = MemoryManager() self.push_animation = None self.startled_icon = None self.startled_icon_offset = QtCore.QPointF(0, -100) self.ng_icon = None self.ng_icon_offset = QtCore.QPointF(0, -100) self.tint_color = None self.name = random.choice(SQUID_NAMES) self.statistics = SquidStatistics(self) # Set neurogenesis cooldown (default to 180 seconds if not specified) self.neuro_cooldown = neuro_cooldown if neuro_cooldown is not None else 180 # Rock interaction system self.carrying_rock = False self.current_rock = None # Currently held rock self.rock_being_thrown = None # Rock in mid-flight # Hoarding preferences self.hoard_corner = { Personality.GREEDY: (50, 50), # Top-left Personality.STUBBORN: (self.ui.window_width-100, 50) # Top-right } # Rock physics properties self.rock_velocity_x = 0 self.rock_velocity_y = 0 self.rock_throw_power = 10 # Base throw strength self.rock_throw_cooldown = 0 # Startle transition tracking self.startled_transition = False self.startled_transition_frames = 0 # Multiplayer-specific self.can_move = True # Whether the squid can move (disable when away) self.is_transitioning = False # Whether the squid is currently in transit # Rock Interactions self.rock_interaction_timer = QtCore.QTimer() self.rock_interaction_timer.timeout.connect(self.check_rock_interaction) self.rock_interaction_timer.start(1000) # Check every second self.rock_hold_start_time = 0 self.rock_hold_duration = 0 self.rock_decision_made = False self.rock_animation_timer = QtCore.QTimer() self.rock_animation_timer.timeout.connect(self.update_rock_throw) self.load_images() self.load_poop_images() self.initialize_attributes() self.mental_state_manager = MentalStateManager(self, self.ui.scene) self.squid_item = QtWidgets.QGraphicsPixmapItem(self.current_image()) self.squid_item.setPos(self.squid_x, self.squid_y) self.squid_item.setAcceptHoverEvents(True) # Enable hover events self.squid_item.mousePressEvent = self.handle_squid_click # Add click handler self.ui.scene.addItem(self.squid_item) self.anxiety_cooldown_timer = None self.ui.window.resizeEvent = self.handle_window_resize self.view_cone_item = None self.base_speed = 90 # Normal movement speed self.current_speed = self.base_speed self.is_fleeing = False self.view_cone_visible = False self.poop_timer = None self.animation_speed = 1 self.base_move_interval = 1000 # 1 second self.health = 100 self.is_sick = False self.sick_icon_item = None self.sick_icon_offset = QtCore.QPointF(0, -100) # Offset the sick icon above the squid self.status = "roaming" # Initialize status self.view_cone_angle = math.pi / 2.5 # Squid has a view cone of 80 degrees self.current_view_angle = random.uniform(0, 2 * math.pi) self.view_cone_change_interval = 2000 # milliseconds self.last_view_cone_change = 0 self.pursuing_food = False self.target_food = None # View cone periodic scanning self.view_cone_timer = QtCore.QTimer() self.view_cone_timer.timeout.connect(self.update_view_direction) self.view_cone_timer.start(1000) # Every 1 second # Goal neurons self.satisfaction = 50 self.anxiety = 0 self.curiosity = 50 # ===== VISION WORKER SETUP ===== # Background thread for vision calculations self._vision_worker = VisionWorker() self._vision_worker.food_visibility_changed.connect(self._on_food_visibility_changed) self._vision_worker.plant_proximity_changed.connect(self._on_plant_proximity_changed) self._vision_worker.visibility_update.connect(self._on_visibility_update) self._vision_worker.start() # Cached vision results self._cached_vision: Optional[VisionResult] = None self._cached_visible_food: List[Tuple[float, float]] = [] self._cached_can_see_food: bool = False self._cached_plant_proximity: float = 0.0 # Vision update timer - updates worker with squid state self._vision_update_timer = QtCore.QTimer() self._vision_update_timer.timeout.connect(self._update_vision_worker) self._vision_update_timer.start(50) # 20 Hz updates # Scene object cache for vision worker self._scene_objects_dirty = True self._cached_scene_objects: List[SceneObject] = [] if personality is None: self.personality = random.choice(list(Personality)) else: self.personality = personality self.uuid = uuid.uuid4() @property def carrying_rock(self): return hasattr(self, 'is_carrying_rock') and self.is_carrying_rock @carrying_rock.setter def carrying_rock(self, value): self.is_carrying_rock = value @property def current_rock(self): return getattr(self, 'carried_rock', None) @current_rock.setter def current_rock(self, value): self.carried_rock = value @property def hunger(self): return self._hunger @hunger.setter def hunger(self, value): old_value = getattr(self, '_hunger', 50) self._hunger = max(0, min(100, value)) # Trigger hook if value changed and tamagotchi_logic exists if old_value != self._hunger and hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: if hasattr(self.tamagotchi_logic, 'plugin_manager'): self.tamagotchi_logic.plugin_manager.trigger_hook( "on_hunger_change", squid=self, old_value=old_value, new_value=self._hunger ) # General state change hook self.tamagotchi_logic.plugin_manager.trigger_hook( "on_squid_state_change", squid=self, attribute="hunger", old_value=old_value, new_value=self._hunger ) @property def happiness(self): return self._happiness @happiness.setter def happiness(self, value): old_value = getattr(self, '_happiness', 100) self._happiness = max(0, min(100, value)) # Trigger hook if value changed and tamagotchi_logic exists if old_value != self._happiness and hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: if hasattr(self.tamagotchi_logic, 'plugin_manager'): self.tamagotchi_logic.plugin_manager.trigger_hook( "on_happiness_change", squid=self, old_value=old_value, new_value=self._happiness ) # General state change hook self.tamagotchi_logic.plugin_manager.trigger_hook( "on_squid_state_change", squid=self, attribute="happiness", old_value=old_value, new_value=self._happiness ) @property def cleanliness(self): return self._cleanliness @cleanliness.setter def cleanliness(self, value): old_value = getattr(self, '_cleanliness', 100) self._cleanliness = max(0, min(100, value)) # Trigger hook if value changed and tamagotchi_logic exists if old_value != self._cleanliness and hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: if hasattr(self.tamagotchi_logic, 'plugin_manager'): self.tamagotchi_logic.plugin_manager.trigger_hook( "on_cleanliness_change", squid=self, old_value=old_value, new_value=self._cleanliness ) # General state change hook self.tamagotchi_logic.plugin_manager.trigger_hook( "on_squid_state_change", squid=self, attribute="cleanliness", old_value=old_value, new_value=self._cleanliness ) @property def sleepiness(self): return self._sleepiness @sleepiness.setter def sleepiness(self, value): old_value = getattr(self, '_sleepiness', 30) self._sleepiness = max(0, min(100, value)) # Trigger hook if value changed and tamagotchi_logic exists if old_value != self._sleepiness and hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: if hasattr(self.tamagotchi_logic, 'plugin_manager'): self.tamagotchi_logic.plugin_manager.trigger_hook( "on_sleepiness_change", squid=self, old_value=old_value, new_value=self._sleepiness ) # General state change hook self.tamagotchi_logic.plugin_manager.trigger_hook( "on_squid_state_change", squid=self, attribute="sleepiness", old_value=old_value, new_value=self._sleepiness ) @property def satisfaction(self): return self._satisfaction @satisfaction.setter def satisfaction(self, value): old_value = getattr(self, '_satisfaction', 50) self._satisfaction = max(0, min(100, value)) # Trigger hook if value changed and tamagotchi_logic exists if old_value != self._satisfaction and hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: if hasattr(self.tamagotchi_logic, 'plugin_manager'): self.tamagotchi_logic.plugin_manager.trigger_hook( "on_satisfaction_change", squid=self, old_value=old_value, new_value=self._satisfaction ) # General state change hook self.tamagotchi_logic.plugin_manager.trigger_hook( "on_squid_state_change", squid=self, attribute="satisfaction", old_value=old_value, new_value=self._satisfaction ) @property def anxiety(self): return self._anxiety @anxiety.setter def anxiety(self, value): old_value = getattr(self, '_anxiety', 10) self._anxiety = max(0, min(100, value)) # Trigger hook if value changed and tamagotchi_logic exists if old_value != self._anxiety and hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: if hasattr(self.tamagotchi_logic, 'plugin_manager'): self.tamagotchi_logic.plugin_manager.trigger_hook( "on_anxiety_change", squid=self, old_value=old_value, new_value=self._anxiety ) # General state change hook self.tamagotchi_logic.plugin_manager.trigger_hook( "on_squid_state_change", squid=self, attribute="anxiety", old_value=old_value, new_value=self._anxiety ) @property def curiosity(self): return self._curiosity @curiosity.setter def curiosity(self, value): old_value = getattr(self, '_curiosity', 50) self._curiosity = max(0, min(100, value)) # Trigger hook if value changed and tamagotchi_logic exists if old_value != self._curiosity and hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: if hasattr(self.tamagotchi_logic, 'plugin_manager'): self.tamagotchi_logic.plugin_manager.trigger_hook( "on_curiosity_change", squid=self, old_value=old_value, new_value=self._curiosity ) # General state change hook self.tamagotchi_logic.plugin_manager.trigger_hook( "on_squid_state_change", squid=self, attribute="curiosity", old_value=old_value, new_value=self._curiosity ) def _on_food_visibility_changed(self, can_see: bool, food_positions: list): """Handle food visibility change from vision worker""" old_can_see = self._cached_can_see_food self._cached_can_see_food = can_see self._cached_visible_food = food_positions # React to visibility change if can_see and not old_can_see: # Food just became visible if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: if hasattr(self.tamagotchi_logic, 'brain_window'): self.tamagotchi_logic.brain_window.add_thought("I see food!") elif not can_see and old_can_see: # Food just became invisible self.pursuing_food = False self.target_food = None def _update_scene_objects(self): """Update the cached scene objects for vision worker""" if not self.tamagotchi_logic: return try: objects = [] # Add food items for item in self.tamagotchi_logic.food_items: try: pos = item.pos() rect = item.boundingRect() objects.append(SceneObject( x=pos.x(), y=pos.y(), width=rect.width(), height=rect.height(), category='food', is_sushi=getattr(item, 'is_sushi', False) )) except: pass # Add decorations from scene if hasattr(self.ui, 'scene'): for item in self.ui.scene.items(): # Check if it's a decoration item if hasattr(item, 'category') and item.category in ('plant', 'rock', 'poop'): try: pos = item.pos() rect = item.boundingRect() objects.append(SceneObject( x=pos.x(), y=pos.y(), width=rect.width(), height=rect.height(), category=item.category )) except: pass # Send to vision worker self._vision_worker.update_scene_objects(objects) self._cached_scene_objects = objects self._scene_objects_dirty = False except Exception as e: print(f"Error updating scene objects: {e}") def mark_scene_objects_dirty(self): """Call when objects are added/removed from scene""" self._scene_objects_dirty = True def _update_vision_worker(self): """Periodically update vision worker with current state""" if not hasattr(self, '_vision_worker') or not self._vision_worker: return # Update squid state try: squid_state = create_squid_vision_state(self) self._vision_worker.update_squid_state(squid_state) except Exception as e: print(f"Error updating squid vision state: {e}") # FIX: Always update scene objects if food exists (because food moves constantly!) # The dirty flag is only set when items are added/removed, but we need # fresh coordinates for sinking food items every cycle. has_food = self.tamagotchi_logic and getattr(self.tamagotchi_logic, 'food_items', False) if self._scene_objects_dirty or has_food: self._update_scene_objects() def _on_plant_proximity_changed(self, proximity: float, plant_positions: list): """Handle plant proximity change from vision worker""" self._cached_plant_proximity = proximity # Could trigger calming effects when near plants if proximity > 50 and hasattr(self, 'tamagotchi_logic'): # Near a plant - slight anxiety reduction if hasattr(self.tamagotchi_logic, 'plant_calming_effect_counter'): self.tamagotchi_logic.plant_calming_effect_counter += 1 def _on_visibility_update(self, result: VisionResult): """Handle full visibility update from vision worker""" # Discard stale updates if scene has changed since this calculation started if getattr(self, '_scene_objects_dirty', False): return self._cached_vision = result self._cached_visible_food = result.visible_food self._cached_can_see_food = result.can_see_food self._cached_plant_proximity = result.plant_proximity_value def _has_personality_starter_neuron(self) -> bool: """Return True if any starter neuron for this personality already exists.""" if not hasattr(self, 'brain_widget') or not self.brain_widget: return True # skip creation if brain not ready if not self.personality: return True # Map enum -> prefix used in neurogenesis.py prefix_map = { Personality.TIMID: "timid_caution", Personality.ADVENTUROUS: "explorer_drive", Personality.LAZY: "energy_conservation", Personality.ENERGETIC: "restless_activity", Personality.INTROVERT: "solitude_preference", Personality.GREEDY: "insatiable_hunger", Personality.STUBBORN: "sushi_preference", } prefix = prefix_map.get(self.personality) if not prefix: # unknown personality → skip return True existing = self.brain_widget.neuron_positions.keys() return any(n.startswith(prefix) for n in existing) def update_view_direction(self): """ Called every 1 second by a QTimer. Makes the squid actively scan its environment by changing gaze direction. Includes smart hunger-based food bias + instant brain refresh. """ # Don't interrupt important states if (getattr(self, 'is_sleeping', False) or getattr(self, 'is_fleeing', False) or getattr(self, 'pursuing_food', False)): return if not hasattr(self, 'tamagotchi_logic') or not self.tamagotchi_logic: return old_angle = self.current_view_angle # === Normal scanning: random direction === self.current_view_angle = random.uniform(0, 2 * math.pi) # === Hunger override: high chance to look toward nearest food === if self.hunger > 65 and self.tamagotchi_logic.food_items: nearest_food = min( self.tamagotchi_logic.food_items, key=lambda f: self.distance_to(f.pos().x(), f.pos().y()), default=None ) if nearest_food: fx = nearest_food.pos().x() + nearest_food.boundingRect().width() / 2 fy = nearest_food.pos().y() + nearest_food.boundingRect().height() / 2 sx = self.squid_x + self.squid_width / 2 sy = self.squid_y + self.squid_height / 2 target_angle = math.atan2(fy - sy, fx - sx) # The hungrier, the more likely to snap gaze directly at food hunger_factor = (self.hunger - 65) / 35.0 # 0.0 -> 1.0 as hunger goes 65->100 if random.random() < hunger_factor: self.current_view_angle = target_angle # FIX: Force immediate vision sync when locked on # This bridges the gap between "looking" (angle set) and "seeing" (worker detection) # ensuring the squid immediately pursues what it just decided to look at. self._force_sync_vision_until = time.time() + 0.5 else: # Look near the food (curious glancing) self.current_view_angle = target_angle + random.uniform(-0.8, 0.8) # === Force immediate brain update so 'can_see_food' neuron reacts instantly === if old_angle != self.current_view_angle: # Option 1: Direct brain hook refresh (most reliable) if hasattr(self.tamagotchi_logic, 'brain_hooks'): # This recalculates all input neurons including can_see_food QtCore.QTimer.singleShot(10, lambda: self.tamagotchi_logic.apply_input_neurons_to_brain()) # Option 2: Trigger full brain tick (fallback, also works) if hasattr(self.tamagotchi_logic, 'update_squid_brain'): QtCore.QTimer.singleShot(20, self.tamagotchi_logic.update_squid_brain) # Optional: Visual feedback in VisionWindow if hasattr(self.tamagotchi_logic, 'vision_window') and self.tamagotchi_logic.vision_window: self.tamagotchi_logic.vision_window.update_view() def apply_tint(self, color): """Squid can change colour!""" self.tint_color = color self.update_squid_image() def set_animation_speed(self, speed): self.animation_speed = speed def finish_eating(self): """Reset status after eating""" # Check if status contains "eating" (handles "eating cheese", "eating sushi", "eating greedily", etc.) if "eating" in self.status.lower(): # Reset to personality-appropriate default status if self.personality == Personality.TIMID: self.status = "cautiously exploring" elif self.personality == Personality.ADVENTUROUS: self.status = "boldly exploring" else: self.status = "roaming" # Always clear the is_eating flag self.is_eating = False # Make sure the brain is updated if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: self.tamagotchi_logic.update_squid_brain() def load_images(self): """Load images with cache to reduce memory usage and apply resolution scaling""" from .display_scaling import DisplayScaling # Get current screen resolution screen = QtWidgets.QApplication.primaryScreen() screen_size = screen.size() # Determine resolution-specific scaling if screen_size.width() <= 1920 and screen_size.height() <= 1080: # For 1080p resolution, reduce size by 30% image_scale = 0.7 print("Applying 70% squid size scaling for 1080p resolution") else: # For higher resolutions, use normal scaling image_scale = 1.0 # Load original images from cache original_images = { "left1": ImageCache.get_pixmap(os.path.join("images", "left1.png")), "left2": ImageCache.get_pixmap(os.path.join("images", "left2.png")), "right1": ImageCache.get_pixmap(os.path.join("images", "right1.png")), "right2": ImageCache.get_pixmap(os.path.join("images", "right2.png")), "up1": ImageCache.get_pixmap(os.path.join("images", "up1.png")), "up2": ImageCache.get_pixmap(os.path.join("images", "up2.png")), "sleep1": ImageCache.get_pixmap(os.path.join("images", "sleep1.png")), "sleep2": ImageCache.get_pixmap(os.path.join("images", "sleep2.png")), } # Store original dimensions for reference self.original_width = original_images["left1"].width() self.original_height = original_images["left1"].height() # Scale images for current resolution self.images = {} for name, pixmap in original_images.items(): # Calculate scaled size scaled_width = int(pixmap.width() * image_scale) scaled_height = int(pixmap.height() * image_scale) # Create scaled pixmap self.images[name] = pixmap.scaled( scaled_width, scaled_height, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation ) # Scale startled image original_startled = ImageCache.get_pixmap(os.path.join("images", "startled.png")) startled_width = int(original_startled.width() * image_scale) startled_height = int(original_startled.height() * image_scale) self.startled_image = original_startled.scaled( startled_width, startled_height, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation ) # Scale neurogenesis icon image original_ng = ImageCache.get_pixmap(os.path.join("images", "ng.png")) ng_width = int(original_ng.width() * image_scale) ng_height = int(original_ng.height() * image_scale) self.ng_image = original_ng.scaled( ng_width, ng_height, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation ) # Update squid dimensions to match scaled size self.squid_width = self.images["left1"].width() self.squid_height = self.images["left1"].height() print(f"Squid scaled to {self.squid_width}x{self.squid_height} pixels") def show_startled_icon(self): """Show the startled icon above the squid's head""" if self.startled_icon is None: self.startled_icon = QtWidgets.QGraphicsPixmapItem(self.startled_image) self.startled_icon.setZValue(500) # Below DIRTY text (z=1000) but above decorations self.ui.scene.addItem(self.startled_icon) self.update_startled_icon_position() def hide_startled_icon(self): """Remove the startled icon""" if self.startled_icon is not None: self.ui.scene.removeItem(self.startled_icon) self.startled_icon = None def show_neurogenesis_icon(self): """Show the neurogenesis (ng.png) icon above the squid's head""" if self.ng_icon is None: self.ng_icon = QtWidgets.QGraphicsPixmapItem(self.ng_image) self.ng_icon.setZValue(500) # Below DIRTY text (z=1000) but above decorations self.ui.scene.addItem(self.ng_icon) self.update_neurogenesis_icon_position() def hide_neurogenesis_icon(self): """Remove the neurogenesis icon""" if self.ng_icon is not None: self.ui.scene.removeItem(self.ng_icon) self.ng_icon = None def update_neurogenesis_icon_position(self): """Position the neurogenesis icon above the squid""" if self.ng_icon is not None: self.ng_icon.setPos( self.squid_x + self.squid_width // 2 - self.ng_icon.pixmap().width() // 2 + self.ng_icon_offset.x(), self.squid_y + self.ng_icon_offset.y() ) def update_startled_icon_position(self): """Position the startled icon above the squid""" if self.startled_icon is not None: self.startled_icon.setPos( self.squid_x + self.squid_width // 2 - self.startled_icon.pixmap().width() // 2 + self.startled_icon_offset.x(), self.squid_y + self.startled_icon_offset.y() ) def load_poop_images(self): self.poop_images = [ QtGui.QPixmap(os.path.join("images", "poop1.png")), QtGui.QPixmap(os.path.join("images", "poop2.png")) ] self.poop_width = self.poop_images[0].width() self.poop_height = self.poop_images[0].height() def initialize_attributes(self): self.base_squid_speed = 90 # pixels per update at 1x speed self.base_vertical_speed = self.base_squid_speed // 2 self.center_x = self.ui.window_width // 2 self.center_y = self.ui.window_height // 2 self.squid_x = self.center_x self.squid_y = self.center_y self.squid_direction = "left" self.current_frame = 0 self.update_preferred_vertical_range() self.hunger = 25 self.sleepiness = 30 self.happiness = 100 self.cleanliness = 100 self.is_sleeping = False self.health = 100 self.is_sick = False def update_preferred_vertical_range(self): self.preferred_vertical_range = (self.ui.window_height // 4, self.ui.window_height // 4 * 3) def handle_window_resize(self, event): self.ui.window_width = event.size().width() self.ui.window_height = event.size().height() self.center_x = self.ui.window_width // 2 self.center_y = self.ui.window_height // 2 self.update_preferred_vertical_range() self.squid_x = max(50, min(self.squid_x, self.ui.window_width - 50 - self.squid_width)) self.squid_y = max(50, min(self.squid_y, self.ui.window_height - 120 - self.squid_height)) self.squid_item.setPos(self.squid_x, self.squid_y) self.update_view_cone() if self.startled_icon is not None: self.update_startled_icon_position() if self.ng_icon is not None: self.update_neurogenesis_icon_position() def update_needs(self): # This method was moved to TamagotchiLogic 26/07/2024 pass def make_decision(self): """Delegate to the decision engine for emergent behavior""" if not hasattr(self, '_decision_engine'): from .decision_engine import DecisionEngine self._decision_engine = DecisionEngine(self) return self._decision_engine.make_decision() def handle_squid_click(self, event): """Handle mouse click on the squid""" if self.is_sleeping: self.startle_awake() event.accept() def startle_awake(self): """Startle the squid awake with an anxiety spike""" if not self.is_sleeping: return # Wake up the squid self.is_sleeping = False # self.sleepiness = 0 # Waking up this way doesn't remove all tiredness self.happiness = max(0, self.happiness - 25) # Increased happiness decrease self.anxiety = min(100, self.anxiety + 60) # Increased anxiety spike self.statistics_window.award(-100) # Visual feedback self.show_startled_icon() # Show the startled icon self.tamagotchi_logic.show_message("Squid was rudely startled awake!") self.status = "startled" # Instead of immediately changing direction, set a transitional state self.startled_transition = True self.startled_transition_frames = 5 # Show startled animation for 5 frames if random.random() < 0.25: self.tamagotchi_logic.create_ink_cloud() # spawns the cloud self.current_speed = self.base_speed * 2 # double speed self.status = "fleeing from ink cloud" # create the requested memory self.memory_manager.add_short_term_memory( 'behaviour', 'ink_cloud', 'Ink Cloud!' ) # return to normal speed after 5 s QtCore.QTimer.singleShot(5000, self.end_ink_flee) # Start timers self.anxiety_cooldown_timer = QtCore.QTimer() self.anxiety_cooldown_timer.timeout.connect(self.reduce_startle_anxiety) self.anxiety_cooldown_timer.start(5000) # Reduce anxiety after 5 seconds # Hide startled icon after 2 seconds QtCore.QTimer.singleShot(2000, self.hide_startled_icon) # End transition after a short delay (about half a second) QtCore.QTimer.singleShot(500, self.end_startled_transition) def end_ink_flee(self): """Called 5 s after an ink-cloud flee starts.""" self.current_speed = self.base_speed # fall back to a sensible status if self.anxiety > 60: self.status = "nervous" else: self.status = "roaming" def end_startled_transition(self): """End the startled transition and set a natural direction""" self.startled_transition = False # Choose a random direction that makes sense for waking up self.squid_direction = random.choice(["left", "right"]) self.update_squid_image() def reduce_startle_anxiety(self): """Gradually reduce the startle anxiety""" self.anxiety = max(20, self.anxiety - 15) # Reduce anxiety but don't go below a higher baseline if self.anxiety <= 35: # When back to near-normal levels if hasattr(self, 'anxiety_cooldown_timer'): self.anxiety_cooldown_timer.stop() self.tamagotchi_logic.show_message("Squid has calmed down... mostly.") def check_boundary_exit(self): """ Comprehensive boundary exit detection with robust network node handling """ try: # Check basic prerequisites if not hasattr(self, 'tamagotchi_logic') or not self.tamagotchi_logic: return False # Check plugin manager and multiplayer status pm = self.tamagotchi_logic.plugin_manager multiplayer_enabled = 'multiplayer' in pm.get_enabled_plugins() if not multiplayer_enabled: return False # Attempt to get network node with multiple fallback strategies network_node = None # Strategy 1: Direct attribute on tamagotchi_logic if hasattr(self.tamagotchi_logic, 'network_node'): network_node = self.tamagotchi_logic.network_node # Strategy 2: Find in multiplayer plugin if network_node is None: try: multiplayer_plugin = pm.plugins.get('multiplayer', {}).get('instance') if multiplayer_plugin and hasattr(multiplayer_plugin, 'network_node'): network_node = multiplayer_plugin.network_node # Attempt to set on tamagotchi_logic for future use self.tamagotchi_logic.network_node = network_node except Exception as plugin_error: print(f"Error finding network node in plugin: {plugin_error}") # If still no network node, abort if network_node is None or not network_node.is_connected: print("No active network node found for boundary exit") return False # Advanced boundary detection logic squid_right = self.squid_x + self.squid_width squid_bottom = self.squid_y + self.squid_height exit_direction = None print("\n===== BOUNDARY EXIT ANALYSIS =====") print(f"Squid Position: ({self.squid_x}, {self.squid_y})") print(f"Squid Dimensions: {self.squid_width}x{self.squid_height}") print(f"Window Dimensions: {self.ui.window_width}x{self.ui.window_height}") # Comprehensive boundary checks if self.squid_x <= 0: exit_direction = 'left' elif squid_right >= self.ui.window_width: exit_direction = 'right' elif self.squid_y <= 0: exit_direction = 'up' elif squid_bottom >= self.ui.window_height: exit_direction = 'down' if exit_direction: print(f"Exit Direction Detected: {exit_direction}") # Prepare comprehensive exit data exit_data = { 'node_id': network_node.node_id, 'direction': exit_direction, 'position': { 'x': self.squid_x, 'y': self.squid_y }, 'color': self._get_squid_color(), 'squid_width': self.squid_width, 'squid_height': self.squid_height, 'window_width': self.ui.window_width, 'window_height': self.ui.window_height } print("Exit Data Details:") for key, value in exit_data.items(): print(f" {key}: {value}") # Broadcast exit message try: network_node.send_message( 'squid_exit', {'payload': exit_data} ) print("Exit message successfully broadcast") return True except Exception as broadcast_error: print(f"Broadcast error: {broadcast_error}") return False return False except Exception as e: print(f"Comprehensive boundary exit error: {e}") import traceback traceback.print_exc() return False def _get_squid_color(self): """Generate a persistent color for this squid""" if not hasattr(self, '_squid_color'): # Create stable color generation based on node_id import hashlib # Try multiple fallback methods for generating a unique source try: # First try network node if hasattr(self.tamagotchi_logic, 'network_node') and self.tamagotchi_logic.network_node: node_id_source = self.tamagotchi_logic.network_node.node_id # Next try direct node_id attribute elif hasattr(self.tamagotchi_logic, 'node_id'): node_id_source = self.tamagotchi_logic.node_id # Final fallback is current timestamp else: node_id_source = str(time.time()) except Exception: # Ultimate fallback node_id_source = str(time.time()) # Generate color from hash hash_val = hashlib.md5(node_id_source.encode()).hexdigest() r = int(hash_val[:2], 16) g = int(hash_val[2:4], 16) b = int(hash_val[4:6], 16) # Ensure minimum brightness self._squid_color = ( max(r, 100), max(g, 100), max(b, 100) ) return self._squid_color def _notify_boundary_exit(self, direction): """ Enhanced notification of boundary exit with comprehensive logging """ print("\n===== BOUNDARY EXIT NOTIFICATION =====") try: # Get plugin manager pm = self.tamagotchi_logic.plugin_manager # Get multiplayer plugin if 'multiplayer' in pm.get_enabled_plugins(): plugin_instance = pm.plugins['multiplayer'].get('instance') if plugin_instance and hasattr(plugin_instance, 'network_node'): # Prepare exit data with precise details exit_data = { 'node_id': plugin_instance.network_node.node_id if plugin_instance.network_node else 'unknown', 'direction': direction, 'position': { 'x': self.squid_x, 'y': self.squid_y }, 'color': plugin_instance.get_squid_color() if hasattr(plugin_instance, 'get_squid_color') else (150, 150, 255), 'squid_width': self.squid_width, 'squid_height': self.squid_height, 'window_width': self.ui.window_width, 'window_height': self.ui.window_height } print("Exit Data:") for key, value in exit_data.items(): print(f" {key}: {value}") # Broadcast exit message plugin_instance.network_node.send_message( 'squid_exit', {'payload': exit_data} ) print(f"[MULTIPLAYER] Squid exiting through {direction} boundary") # CHANGE: Completely hide the squid instead of reducing opacity self.squid_item.setVisible(False) # CHANGE: Set flag to indicate squid is away self.is_transitioning = True # CHANGE: Disable movement while away self.can_move = False # CHANGE: Update status self.status = "visiting another tank" # Optional: Show a message about the squid leaving if hasattr(self.tamagotchi_logic, 'show_message'): self.tamagotchi_logic.show_message(f"Your squid left through the {direction} boundary!") else: print("[ERROR] No network node or plugin instance available") else: print("[ERROR] Multiplayer plugin not enabled") except Exception as e: print(f"[CRITICAL] Error in boundary exit notification:") import traceback traceback.print_exc() print("===== BOUNDARY EXIT NOTIFICATION END =====\n") def determine_startle_reason(self, current_state): """Determine why the squid is startled based on environment""" # Check for sudden environmental changes if self.tamagotchi_logic.environment_changed_recently(): return "environment changed too quickly" # Check for novel objects if current_state.get('has_novelty_neurons', False): visible_objects = [] if self.get_visible_food(): visible_objects.append("new food") if self.is_near_decorations('poop'): visible_objects.append("poop") if self.is_near_decorations('plant'): visible_objects.append("plant") if visible_objects: return f"new object sighted ({'/'.join(visible_objects)})" # Check for emotional state if current_state['anxiety'] > 80 and current_state['happiness'] < 30: return "emotional overwhelm" # Check for sudden movement if abs(self.rock_velocity_x) > 5 or abs(self.rock_velocity_y) > 5: return "fast moving object detected" # Default reason if none specific found return "unknown cause" def is_near_decorations(self, category): """Check if decorations of specified category are nearby""" decorations = self.tamagotchi_logic.get_nearby_decorations( self.squid_x, self.squid_y) return any(getattr(d, 'category', None) == category for d in decorations) def search_for_favorite_food(self): visible_food = self.get_visible_food() if visible_food: for food_x, food_y in visible_food: if self.is_favorite_food(self.tamagotchi_logic.get_food_item_at(food_x, food_y)): self.move_towards(food_x, food_y) return # If no favorite food is found, display a message and move randomly self.tamagotchi_logic.show_message("Stubborn squid does not like that type of food!") self.move_randomly() else: self.move_randomly() def get_favorite_food(self): # Implement logic to find the squid's favorite food for food_item in self.tamagotchi_logic.food_items: if self.is_favorite_food(food_item): return food_item.pos().x(), food_item.pos().y() return None def is_favorite_food(self, food_item): return food_item is not None and getattr(food_item, 'is_sushi', False) def load_state(self, state): self.hunger = state['hunger'] self.sleepiness = state['sleepiness'] self.happiness = state['happiness'] self.cleanliness = state['cleanliness'] self.health = state['health'] self.is_sick = state['is_sick'] self.squid_x = state['squid_x'] self.squid_y = state['squid_y'] self.satisfaction = state['satisfaction'] self.anxiety = state['anxiety'] self.curiosity = state['curiosity'] self.personality = Personality(state['personality']) # Load the UUID if it exists in the saved state if 'uuid' in state: self.uuid = uuid.UUID(state['uuid']) # Convert string back to UUID object else: # For backward compatibility with old saves self.uuid = uuid.uuid4() # Restore statistics if saved if 'statistics' in state and hasattr(self, 'statistics'): stats_data = state['statistics'] for key, value in stats_data.items(): if hasattr(self.statistics, key): setattr(self.statistics, key, value) # Load the squid's name if it exists in the saved state self.name = state.get('name', 'Squid') if 'tint_color' in state and state['tint_color']: self.tint_color = QtGui.QColor(*state['tint_color']) else: self.tint_color = None self.squid_item.setPos(self.squid_x, self.squid_y) self.update_squid_image() def push_decoration(self, decoration, direction): """Push a decoration with proper animation handling""" try: push_distance = 80 current_pos = decoration.pos() new_x = current_pos.x() + (push_distance * direction) scene_rect = self.ui.scene.sceneRect() new_x = max(scene_rect.left(), min(new_x, scene_rect.right() - decoration.boundingRect().width())) if self.push_animation and self.push_animation.state() == QtCore.QAbstractAnimation.Running: self.push_animation.stop() self.push_animation = QtCore.QVariantAnimation() self.push_animation.setDuration(300) self.push_animation.setStartValue(current_pos) self.push_animation.setEndValue(QtCore.QPointF(new_x, current_pos.y())) self.push_animation.setEasingCurve(QtCore.QEasingCurve.OutCubic) self.push_animation.valueChanged.connect(decoration.setPos) self.push_animation.finished.connect(lambda: self._on_push_complete(decoration)) self.push_animation.start() except Exception as e: print(f"Tried to push decoration but failed: {e}") decoration.setPos(new_x, current_pos.y()) self._on_push_complete(decoration) def _on_push_complete(self, decoration): """Called when the push animation finishes.""" self.happiness = min(100, self.happiness + 5) self.curiosity = min(100, self.curiosity + 10) self.anxiety = max(0, self.anxiety - 6) # --- Plant-specific tracking & reward --- if hasattr(decoration, 'category') and decoration.category == 'plant': # Count this interaction self.memory_manager.add_short_term_memory( 'interaction', 'plant_contact', {'plant_key': decoration.filename, 'effect': 'calming'}, importance=2.0 ) # Tell the logic layer to track it if hasattr(self.tamagotchi_logic, 'track_plant_interaction'): self.tamagotchi_logic.track_plant_interaction() # Mark as favourite after 3 touches key = decoration.filename self.memory_manager.plant_interaction_count[key] = self.memory_manager.plant_interaction_count.get(key, 0) + 1 if self.memory_manager.plant_interaction_count[key] >= 3: self.memory_manager.add_long_term_memory('favourite_plant', key, { 'reason': 'Repeated calming contact', 'anxiety_reduction': True }) if hasattr(self.tamagotchi_logic, 'brain_window') and hasattr(self.tamagotchi_logic.brain_window, 'statistics_tab'): self.tamagotchi_logic.brain_window.statistics_tab.increment_stat('plants_interacted') # --- Reward for RL --- if hasattr(self.tamagotchi_logic, 'recent_positive_outcome'): self.tamagotchi_logic.recent_positive_outcome = True self.status = "pushing decoration" self.tamagotchi_logic.show_message("Squid pushed a decoration") # Clean up animation reference self.push_animation = None def record_startle_reason(self, reason): """Record why the squid was startled for memory and display""" self.memory_manager.add_short_term_memory('mental_state', 'startled', f"Startled because: {reason}") self.tamagotchi_logic.show_message(f"Squid startled! ({reason})") def handle_rock_interaction(self, target_rock=None): """Unified rock interaction handler delegates to RockInteractionManager""" if not hasattr(self.tamagotchi_logic, 'rock_interaction'): return False return self.tamagotchi_logic.rock_interaction.start_rock_test(target_rock) def move_erratically(self): directions = ["left", "right", "up", "down"] self.squid_direction = random.choice(directions) self.move_squid() def move_slowly(self): self.base_squid_speed = self.base_squid_speed // 2 self.base_vertical_speed = self.base_vertical_speed // 2 self.move_squid() def explore_environment(self): if random.random() < 0.3: self.change_direction() self.move_squid() def search_for_food(self): visible_food = self.get_visible_food() if visible_food: # Only set pursuing_food to True if food is actually visible self.pursuing_food = True closest_food = min(visible_food, key=lambda f: self.distance_to(f[0], f[1])) self.status = "moving to food" self.move_towards(closest_food[0], closest_food[1]) else: # Reset pursuing_food when no food is visible self.pursuing_food = False self.status = "searching for food" self.move_randomly() def get_visible_objects(self, object_list): """ Finds objects from a given list that are within the squid's vision cone. This is a generic helper method. """ visible_objects = [] for item in object_list: # FIX: Use the center of the item instead of the top-left position. # This ensures detection works if the cone touches the object body # but misses the specific (0,0) origin point. if hasattr(item, 'sceneBoundingRect'): center = item.sceneBoundingRect().center() check_x, check_y = center.x(), center.y() else: # Fallback for items not fully initialized in scene check_x, check_y = item.pos().x(), item.pos().y() if self.is_in_vision_cone(check_x, check_y): visible_objects.append(item) return visible_objects def get_visible_food(self): """ Returns the positions of visible food items. Includes a circuit breaker to prevent 'ghost' food detection. """ # 1. Circuit Breaker: If the logic system says there is no food, # we absolutely cannot see any, regardless of what the vision cache says. # This fixes the "stuck at top" bug where the worker thread returns old data. if self.tamagotchi_logic and not self.tamagotchi_logic.food_items: return [] # 2. Force sync check if recently ate (handling worker latency) # This ensures we don't act on stale worker data immediately after eating if time.time() < getattr(self, '_force_sync_vision_until', 0): return self._get_visible_food_sync() # 3. If scene is dirty (items recently added/removed), the cache is invalid. # Fall back to synchronous check which uses the authoritative list. if getattr(self, '_scene_objects_dirty', False): return self._get_visible_food_sync() # 4. Use cached result if available and valid if hasattr(self, '_cached_visible_food') and self._cached_visible_food is not None: return self._cached_visible_food # 5. Fallback to synchronous calculation return self._get_visible_food_sync() def _get_visible_food_sync(self): """Synchronous fallback for get_visible_food""" if self.tamagotchi_logic is None: return [] all_visible_food_items = self.get_visible_objects(self.tamagotchi_logic.food_items) if not all_visible_food_items: return [] # Sort visible food to prioritize sushi sushi_items = [item for item in all_visible_food_items if getattr(item, 'is_sushi', False)] other_food_items = [item for item in all_visible_food_items if not getattr(item, 'is_sushi', False)] sorted_positions = [(food.pos().x(), food.pos().y()) for food in sushi_items] sorted_positions.extend([(food.pos().x(), food.pos().y()) for food in other_food_items]) return sorted_positions def can_see_food(self) -> bool: """ Quick check if food is visible. Uses cached result from vision worker. """ if hasattr(self, '_cached_can_see_food'): return self._cached_can_see_food return len(self.get_visible_food()) > 0 def get_plant_proximity(self) -> float: """ Get the current plant proximity value (0-100). Uses cached result from vision worker. """ if hasattr(self, '_cached_plant_proximity'): return self._cached_plant_proximity return 0.0 def get_visible_plants(self): """ Finds plant decorations that are within the squid's vision cone. Uses cached result from vision worker when available. """ if hasattr(self, '_cached_vision') and self._cached_vision: return self._cached_vision.visible_plants # Fallback to synchronous calculation if self.tamagotchi_logic is None: return [] all_plants = [] for item in self.tamagotchi_logic.user_interface.scene.items(): if hasattr(item, 'category') and item.category == 'plant': all_plants.append(item) return self.get_visible_objects(all_plants) def is_in_vision_cone(self, x, y): """ Check if a point (x,y) is inside the squid's vision cone Args: x (float): X coordinate to check y (float): Y coordinate to check Returns: bool: True if the point is in vision cone, False otherwise """ # Get squid center position squid_center_x = self.squid_x + self.squid_width // 2 squid_center_y = self.squid_y + self.squid_height // 2 # Calculate vector to target dx = x - squid_center_x dy = y - squid_center_y # Calculate distance distance = math.sqrt(dx**2 + dy**2) # Define vision cone length cone_length = max(self.ui.window_width, self.ui.window_height) # If target is beyond detection range, return false if distance > cone_length: return False # Calculate angle to target point angle_to_target = math.atan2(dy, dx) # Get current view angle (use current_view_angle if available, otherwise derive from direction) if hasattr(self, 'current_view_angle'): current_angle = self.current_view_angle else: direction_map = { 'right': 0, 'up': math.pi * 1.5, 'left': math.pi, 'down': math.pi * 0.5 } current_angle = direction_map.get(self.squid_direction, 0) # Get cone angle (half of the total view cone angle) if hasattr(self, 'view_cone_angle'): cone_angle = self.view_cone_angle / 2 else: cone_angle = math.pi / 5 # Default 36-degree half-angle # Calculate angle difference (accounting for wrap-around) angle_diff = abs(angle_to_target - current_angle) while angle_diff > math.pi: angle_diff = 2 * math.pi - angle_diff # Check if the target is within the cone angle return angle_diff <= cone_angle def change_view_cone_direction(self): self.current_view_angle = random.uniform(0, 2 * math.pi) def cleanup_vision_worker(self): """Clean up vision worker - call before squid destruction""" if hasattr(self, '_vision_update_timer') and self._vision_update_timer: self._vision_update_timer.stop() if hasattr(self, '_vision_worker') and self._vision_worker: self._vision_worker.stop() self._vision_worker.wait(1000) self._vision_worker = None def move_squid(self): """ Move the squid with comprehensive debug logging and multiplayer boundary check """ # Check if movement is allowed if not getattr(self, 'can_move', True): return # Check if multiplayer is available and enabled if hasattr(self.tamagotchi_logic, 'plugin_manager'): pm = self.tamagotchi_logic.plugin_manager multiplayer_enabled = 'multiplayer' in pm.get_enabled_plugins() else: multiplayer_enabled = False if self.animation_speed == 0: #print("Animation speed is 0, no movement") return if self.is_sleeping: #print("Squid is sleeping, limited movement") if self.squid_y < self.ui.window_height - 120 - self.squid_height: self.squid_y += self.base_vertical_speed * self.animation_speed self.squid_item.setPos(self.squid_x, self.squid_y) self.current_frame = (self.current_frame + 1) % 2 self.update_squid_image() return current_time = QtCore.QTime.currentTime().msecsSinceStartOfDay() visible_food = self.get_visible_food() if visible_food: closest_food = min(visible_food, key=lambda f: self.distance_to(f[0], f[1])) self.pursuing_food = True self.target_food = closest_food self.move_towards(closest_food[0], closest_food[1]) elif self.pursuing_food: self.pursuing_food = False self.target_food = None self.move_randomly() else: if current_time - self.last_view_cone_change > self.view_cone_change_interval: self.change_view_cone_direction() self.last_view_cone_change = current_time self.move_randomly() # Store previous position for distance calculation prev_x, prev_y = self.squid_x, self.squid_y # Calculate new position squid_x_new = self.squid_x squid_y_new = self.squid_y if self.squid_direction == "left": squid_x_new -= self.base_squid_speed * self.animation_speed elif self.squid_direction == "right": squid_x_new += self.base_squid_speed * self.animation_speed elif self.squid_direction == "up": squid_y_new -= self.base_vertical_speed * self.animation_speed elif self.squid_direction == "down": squid_y_new += self.base_vertical_speed * self.animation_speed # Boundary handling for single-player and multiplayer modes if not multiplayer_enabled: # Original boundary restrictions for single-player mode if squid_x_new < 50: squid_x_new = 50 self.change_direction() elif squid_x_new > self.ui.window_width - 50 - self.squid_width: squid_x_new = self.ui.window_width - 50 - self.squid_width self.change_direction() if squid_y_new < 50: squid_y_new = 50 self.change_direction() elif squid_y_new > self.ui.window_height - 120 - self.squid_height: squid_y_new = self.ui.window_height - 120 - self.squid_height self.change_direction() else: # Extended boundary check for multiplayer print("Multiplayer mode: Extended boundary check") squid_right = squid_x_new + self.squid_width squid_bottom = squid_y_new + self.squid_height # Update squid position self.squid_x = squid_x_new self.squid_y = squid_y_new # Track distance - 80 pixels per movement if self.squid_x != prev_x or self.squid_y != prev_y: if hasattr(self.tamagotchi_logic, 'brain_window') and hasattr(self.tamagotchi_logic.brain_window, 'statistics_tab'): self.tamagotchi_logic.brain_window.statistics_tab.track_distance(80) # Update animation frame and image if self.squid_direction in ["left", "right", "up", "down"]: self.current_frame = (self.current_frame + 1) % 2 self.squid_item.setPixmap(self.current_image()) # Set new position and update related elements self.squid_item.setPos(self.squid_x, self.squid_y) self.update_view_cone() self.update_sick_icon_position() # Comprehensive boundary exit check in multiplayer mode if multiplayer_enabled: #print("Triggering boundary exit check in multiplayer mode") exit_result = self.check_boundary_exit() #print(f"Boundary Exit Result: {exit_result}") def move_towards(self, x, y): dx = x - (self.squid_x + self.squid_width // 2) dy = y - (self.squid_y + self.squid_height // 2) if abs(dx) > abs(dy): self.squid_direction = "right" if dx > 0 else "left" else: self.squid_direction = "down" if dy > 0 else "up" def move_towards_position(self, target_pos): dx = target_pos.x() - (self.squid_x + self.squid_width // 2) dy = target_pos.y() - (self.squid_y + self.squid_height // 2) if abs(dx) > abs(dy): self.squid_direction = "right" if dx > 0 else "left" else: self.squid_direction = "down" if dy > 0 else "up" self.move_squid() def eat(self, food_item): # FIX: Force synchronous vision for a short time to prevent "ghost" food # This prevents the vision worker from reporting the just-eaten food as visible # before it has processed the scene update. self._force_sync_vision_until = time.time() + 0.5 effects = {} # Basic effects for all food types effects['hunger'] = max(-20, -self.hunger) effects['happiness'] = min(10, 100 - self.happiness) # Determine food type and effects is_sushi = getattr(food_item, 'is_sushi', False) food_name = "sushi" if is_sushi else "cheese" # Satisfaction boost (stronger for sushi) effects['satisfaction'] = min(15 if is_sushi else 10, 100 - self.satisfaction) # Personality-based reward points reward_points = 2 # Default for all food if is_sushi and self.personality in [Personality.GREEDY, Personality.STUBBORN]: reward_points = 3 # Extra reward for favorite food effects['satisfaction'] = min(20, 100 - self.satisfaction) # Even bigger boost # Trigger hook if tamagotchi_logic exists if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: if hasattr(self.tamagotchi_logic, 'plugin_manager'): self.tamagotchi_logic.plugin_manager.trigger_hook( "on_feed", squid=self, food_item=food_item, food_type=food_name, effects=effects ) # Set eating state BEFORE applying effects self.is_eating = True self.status = f"eating {food_name}" # Use lowercase for consistency self.statistics_window.award(75) # Apply all stat changes for attr, change in effects.items(): setattr(self, attr, getattr(self, attr) + change) # Start a timer to reset the status after 1 second QTimer.singleShot(1000, self.finish_eating) # Memory system formatted_effects = ', '.join(f"{attr.capitalize()} {'+' if val >= 0 else ''}{val:.2f}" for attr, val in effects.items()) self.memory_manager.add_short_term_memory('food', food_name, f"Ate {food_name}: {formatted_effects}") # Neurogenesis tracking if hasattr(self.tamagotchi_logic, 'neurogenesis_triggers'): current = self.tamagotchi_logic.neurogenesis_triggers['positive_outcomes'] self.tamagotchi_logic.neurogenesis_triggers['positive_outcomes'] = min(current + reward_points, 5) # Visual/behavioral effects self.tamagotchi_logic.remove_food(food_item) # --- FIX Clear vision cache immediately --- # This prevents the squid from "seeing" the ghost of the food it just ate # and getting stuck in 'pursuing_food' mode at the top of the screen. self._cached_visible_food = [] self._cached_can_see_food = False # --- FIX END --- self.show_eating_effect() self.start_poop_timer() self.pursuing_food = False self.target_food = None # Add tracking (added in 2.4.4) if hasattr(self.tamagotchi_logic, 'track_food_consumed'): self.tamagotchi_logic.track_food_consumed(food_item) # Personality reactions (with enhanced messages) if self.personality == Personality.GREEDY: self.eat_greedily(food_item) if is_sushi: self.tamagotchi_logic.show_message("Greedy squid devours sushi voraciously!") elif self.personality == Personality.STUBBORN: if not is_sushi: self.react_stubborn_eating() else: self.tamagotchi_logic.show_message("Stubborn squid happily accepts sushi") def eat_greedily(self, food_item): # Update status to show greedy eating (lowercase for consistency) self.status = "eating greedily" food_type = "sushi" if getattr(food_item, 'is_sushi', False) else "cheese" # Reduce hunger more than usual self.hunger = max(0, self.hunger - 25) # Increase happiness more self.happiness = min(100, self.happiness + 15) # Increase satisfaction significantly self.satisfaction = min(100, self.satisfaction + 20) # Slightly increase anxiety (from overeating) self.anxiety = min(100, self.anxiety + 5) # Note: Food is already removed in eat() method, don't remove again # self.tamagotchi_logic.remove_food(food_item) # REMOVED - already done #print(f"The greedy squid enthusiastically ate the {food_type}") # These are already called in eat() method, but safe to call again self.show_eating_effect() # Poop timer already started in eat(), don't restart # self.start_poop_timer() # Already called # These should already be set in eat(), but ensure they're cleared self.pursuing_food = False self.target_food = None # Occasionally show a message if random.random() < 0.2: # 20% chance to show a message self.tamagotchi_logic.show_message("Nom nom! Greedy squid devours the food!") # Check if there's more food nearby if self.check_for_more_food(): if random.random() < 0.1: # 10% chance to show this message self.tamagotchi_logic.show_message("Greedy squid looks around for more food...") else: if random.random() < 0.1: # 10% chance to show this message self.tamagotchi_logic.show_message("Greedy squid is satisfied... for now.") def react_stubborn_eating(self): if self.hunger > 80: # Extremely hungry self.happiness = max(0, self.happiness - 5) self.tamagotchi_logic.show_message("Stubborn squid reluctantly eats non-sushi food.") else: self.tamagotchi_logic.show_message("Stubborn squid ignores non-sushi food.") def check_for_more_food(self): for food_item in self.tamagotchi_logic.food_items: if self.is_food_nearby(food_item): return True return False def is_food_nearby(self, food_item): food_x, food_y = food_item.pos().x(), food_item.pos().y() squid_center_x = self.squid_x + self.squid_width // 2 squid_center_y = self.squid_y + self.squid_height // 2 distance = math.sqrt((squid_center_x - food_x)**2 + (squid_center_y - food_y)**2) return distance < 100 # Adjust the distance threshold as needed def process_squid_detection(self, remote_node_id, is_visible=True): """ Process the detection of another squid in this squid's vision cone Args: remote_node_id (str): ID of the detected squid is_visible (bool): Whether the squid is currently visible """ # Only react if the squid is not sleeping if self.is_sleeping: return if is_visible: # Detected a new squid or is continuing to see it # Increase curiosity when first detected if not hasattr(self, '_seen_squids') or remote_node_id not in self._seen_squids: # First time seeing this squid self.curiosity = min(100, self.curiosity + 15) # Small anxiety spike from the surprise self.anxiety = min(100, self.anxiety + 10) # Add memory self.memory_manager.add_short_term_memory( 'social', 'squid_detection', f"Detected another squid (ID: {remote_node_id[-4:]})" ) # Initialize tracking of seen squids if needed if not hasattr(self, '_seen_squids'): self._seen_squids = set() # Add to seen squids self._seen_squids.add(remote_node_id) # Chance to get startled if random.random() < 0.3: # 30% chance # Try to use the startle function if it exists if hasattr(self.tamagotchi_logic, 'startle_squid'): self.tamagotchi_logic.startle_squid(source="detected_squid") else: # Already seen this squid before, smaller reaction self.curiosity = min(100, self.curiosity + 5) else: # Lost sight of a squid # Nothing special happens, just note it if hasattr(self, '_seen_squids') and remote_node_id in self._seen_squids: self.memory_manager.add_short_term_memory( 'social', 'squid_lost', f"Lost sight of squid (ID: {remote_node_id[-4:]})" ) def react_to_rock_throw(self, source_node_id, is_target=False): """ React to a rock being thrown by another squid Args: source_node_id (str): ID of the squid that threw the rock is_target (bool): Whether this squid is the apparent target """ # Only react if the squid is not sleeping if self.is_sleeping: return # Base reaction - increase anxiety self.anxiety = min(100, self.anxiety + 5) # Add memory self.memory_manager.add_short_term_memory( 'observation', 'rock_throw', f"Observed squid {source_node_id[-4:]} throw a rock" ) # Strong reaction if targeted if is_target: # Get startled if hasattr(self.tamagotchi_logic, 'startle_squid'): self.tamagotchi_logic.startle_squid(source="targeted_by_rock") # Significantly increase anxiety self.anxiety = min(100, self.anxiety + 20) # Decrease happiness self.happiness = max(0, self.happiness - 10) # Add memory of being targeted self.memory_manager.add_short_term_memory( 'social', 'targeted', f"Was targeted by rock from squid {source_node_id[-4:]}" ) # Higher chance for this memory to go to long-term if random.random() < 0.5: # 50% chance self.memory_manager.transfer_to_long_term_memory( 'social', 'targeted' ) def investigate_food(self, food_item): self.status = "Investigating food" self.tamagotchi_logic.show_message("Stubborn squid investigates the food...") # Move towards the food food_pos = food_item.pos() self.move_towards_position(food_pos) # Wait for a moment (might need to implement a delay here) self.tamagotchi_logic.show_message("Stubborn squid ignored the food") self.status = "I don't like that food" def consume_food(self, food_item): self.status = "Ate food" self.hunger = max(0, self.hunger - 20) self.happiness = min(100, self.happiness + 10) self.satisfaction = min(100, self.satisfaction + 15) self.anxiety = max(0, self.anxiety - 10) self.tamagotchi_logic.remove_food(food_item) #print("The squid ate the food") self.show_eating_effect() self.start_poop_timer() self.pursuing_food = False self.target_food = None # Occasionally show a message based on personality if random.random() < 0.25: # 25% chance to show a message if self.personality == Personality.STUBBORN and getattr(food_item, 'is_sushi', False): self.tamagotchi_logic.show_message("Nom nom! Stubborn squid enjoys the sushi!") elif self.personality == Personality.GREEDY: food_type = "sushi" if getattr(food_item, 'is_sushi', False) else "cheese" self.tamagotchi_logic.show_message(f"Nom nom! Greedy squid gobbles up the {food_type}!") else: self.tamagotchi_logic.show_message("Nom nom! Squid enjoys the meal!") def start_poop_timer(self): poop_delay = random.randint(11000, 30000) #print("Poop random timer started") self.poop_timer = QtCore.QTimer() self.poop_timer.setSingleShot(True) self.poop_timer.timeout.connect(self.create_poop) self.poop_timer.start(poop_delay) def create_poop(self): self.tamagotchi_logic.spawn_poop(self.squid_x + self.squid_width // 2, self.squid_y + self.squid_height) # Add tracking if hasattr(self.tamagotchi_logic, 'track_poop_created'): self.tamagotchi_logic.track_poop_created() def show_eating_effect(self): if not self.is_debug_mode(): return effect_item = QtWidgets.QGraphicsEllipseItem(self.squid_x, self.squid_y, self.squid_width, self.squid_height) effect_item.setBrush(QtGui.QBrush(QtGui.QColor(255, 255, 0, 100))) effect_item.setPen(QtGui.QPen(QtCore.Qt.NoPen)) self.ui.scene.addItem(effect_item) opacity_effect = QtWidgets.QGraphicsOpacityEffect() effect_item.setGraphicsEffect(opacity_effect) self.eating_animation = QtCore.QPropertyAnimation(opacity_effect, b"opacity") self.eating_animation.setDuration(1000) self.eating_animation.setStartValue(2.5) self.eating_animation.setEndValue(0.0) self.eating_animation.setEasingCurve(QtCore.QEasingCurve.InQuad) self.eating_animation.finished.connect(lambda: self.ui.scene.removeItem(effect_item)) self.eating_animation.start() def is_debug_mode(self): return self.tamagotchi_logic.debug_mode def change_to_rps_image(self): self.rps_image = QtGui.QPixmap(os.path.join("images", "squid_rps_frame.png")) self.squid_item.setPixmap(self.rps_image) def restore_normal_image(self): self.squid_item.setPixmap(self.current_image()) def should_hoard_decorations(self): """Check if this personality type should hoard items""" return self.personality in [Personality.GREEDY, Personality.STUBBORN] def organize_decorations(self): target_corner = (50, 50) # Top-left corner coordinates # Get nearby decorations (filter by rocks/plants if needed) decorations = [ d for d in self.tamagotchi_logic.get_nearby_decorations(self.squid_x, self.squid_y) if getattr(d, 'category', None) in ['rock', 'plant'] ] if decorations: closest = min(decorations, key=lambda d: self.distance_to(d.pos().x(), d.pos().y())) # Move toward the decoration if self.distance_to(closest.pos().x(), closest.pos().y()) > 50: self.move_towards(closest.pos().x(), closest.pos().y()) return "approaching_decoration" # Push toward hoard corner push_direction = 1 if target_corner[0] > closest.pos().x() else -1 self.push_decoration(closest, push_direction) # Personality-specific effects self.satisfaction = min(100, self.satisfaction + 10) if self.personality == Personality.GREEDY: self.tamagotchi_logic.show_message("Greedy squid hoards treasures!") return "hoarding" return "nothing_to_hoard" def go_to_sleep(self): if not self.is_sleeping: self.is_sleeping = True self.squid_direction = "down" self.status = "sleeping" self.anxiety = max(0, self.anxiety - (1.5 * self.tamagotchi_logic.simulation_speed)) # Anxiety reduction test # Trigger hook if tamagotchi_logic exists if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: if hasattr(self.tamagotchi_logic, 'plugin_manager'): self.tamagotchi_logic.plugin_manager.trigger_hook( "on_sleep", squid=self ) # Clear all short-term memories when the squid goes to sleep self.memory_manager.clear_short_term_memory() self.tamagotchi_logic.show_message("Squid is sleeping...") def wake_up(self): self.is_sleeping = False self.sleepiness = 0 self.happiness = min(100, self.happiness + 20) self.status = "roaming" self.squid_direction = "left" self.update_squid_image() self.tamagotchi_logic.show_message("Squid woke up!") def update_squid_image(self): self.squid_item.setPixmap(self.current_image()) def current_image(self): """ Return the current image of the squid, with tint applied using CompositionMode_SourceAtop to tint all non-transparent pixels. """ # 1. Select the base image based on status/direction if hasattr(self, 'startled_transition') and self.startled_transition: base_image = self.startled_image elif hasattr(self, 'status') and self.status == "startled" and not self.is_sleeping: direction = "left" if random.random() < 0.5 else "right" base_image = self.images[f"{direction}{self.current_frame + 1}"] elif self.is_sleeping: base_image = self.images[f"sleep{self.current_frame + 1}"] elif self.squid_direction == "left": base_image = self.images[f"left{self.current_frame + 1}"] elif self.squid_direction == "right": base_image = self.images[f"right{self.current_frame + 1}"] elif self.squid_direction == "up": base_image = self.images[f"up{self.current_frame + 1}"] else: base_image = self.images["left1"] # 2. If no tint, return immediately if not self.tint_color: return base_image # 3. Apply robust tinting using QPainter # Create a blank canvas the size of the image result = QtGui.QPixmap(base_image.size()) result.fill(QtCore.Qt.transparent) painter = QtGui.QPainter(result) # Draw the base squid painter.drawPixmap(0, 0, base_image) # Draw the tint color over it, keeping the squid's alpha channel # CompositionMode_SourceAtop keeps destination alpha (squid shape) # but paints source (color) over it. painter.setCompositionMode(QtGui.QPainter.CompositionMode_SourceAtop) # Use a semi-transparent version of the tint so texture shows through tint = QtGui.QColor(self.tint_color) tint.setAlpha(120) # Adjust 0-255 for tint strength (120 is usually good) painter.fillRect(result.rect(), tint) painter.end() return result def move_randomly(self): if random.random() < 0.20: self.change_direction() def get_food_position(self): if self.tamagotchi_logic.food_items: closest_food = min(self.tamagotchi_logic.food_items, key=lambda food: self.distance_to(food.pos().x(), food.pos().y())) return closest_food.pos().x(), closest_food.pos().y() else: return -1, -1 def distance_to(self, x, y): return math.sqrt((self.squid_x - x)**2 + (self.squid_y - y)**2) def change_direction(self): directions = ["left", "right", "up", "down"] new_direction = random.choice(directions) while new_direction == self.squid_direction: new_direction = random.choice(directions) self.squid_direction = new_direction def toggle_view_cone(self): self.view_cone_visible = not self.view_cone_visible if self.view_cone_visible: self.update_view_cone() else: self.remove_view_cone() def update_view_cone(self): if self.view_cone_visible: if self.view_cone_item is None: self.view_cone_item = QtWidgets.QGraphicsPolygonItem() self.view_cone_item.setPen(QtGui.QPen(QtCore.Qt.yellow)) self.view_cone_item.setBrush(QtGui.QBrush(QtGui.QColor(255, 255, 0, 50))) self.ui.scene.addItem(self.view_cone_item) squid_center_x = self.squid_x + self.squid_width // 2 squid_center_y = self.squid_y + self.squid_height // 2 if self.pursuing_food and self.target_food: dx = self.target_food[0] - squid_center_x dy = self.target_food[1] - squid_center_y self.current_view_angle = math.atan2(dy, dx) cone_length = max(self.ui.window_width, self.ui.window_height) cone_points = [ QtCore.QPointF(squid_center_x, squid_center_y), QtCore.QPointF(squid_center_x + math.cos(self.current_view_angle - self.view_cone_angle/2) * cone_length, squid_center_y + math.sin(self.current_view_angle - self.view_cone_angle/2) * cone_length), QtCore.QPointF(squid_center_x + math.cos(self.current_view_angle + self.view_cone_angle/2) * cone_length, squid_center_y + math.sin(self.current_view_angle + self.view_cone_angle/2) * cone_length) ] cone_polygon = QtGui.QPolygonF(cone_points) self.view_cone_item.setPolygon(cone_polygon) else: self.remove_view_cone() def remove_view_cone(self): if self.view_cone_item is not None: self.ui.scene.removeItem(self.view_cone_item) self.view_cone_item = None def show_sick_icon(self): if self.sick_icon_item is None: sick_icon_pixmap = QtGui.QPixmap(os.path.join("images", "sick.png")) self.sick_icon_item = QtWidgets.QGraphicsPixmapItem(sick_icon_pixmap) self.sick_icon_item.setZValue(500) # Below DIRTY text (z=1000) but above decorations self.ui.scene.addItem(self.sick_icon_item) self.update_sick_icon_position() def hide_sick_icon(self): if self.sick_icon_item is not None: self.ui.scene.removeItem(self.sick_icon_item) self.sick_icon_item = None def update_sick_icon_position(self): if self.sick_icon_item is not None: self.sick_icon_item.setPos(self.squid_x + self.squid_width // 2 - self.sick_icon_item.pixmap().width() // 2 + self.sick_icon_offset.x(), self.squid_y + self.sick_icon_offset.y()) def is_near_plant(self): if self.tamagotchi_logic is None: return False nearby_decorations = self.tamagotchi_logic.get_nearby_decorations(self.squid_x, self.squid_y) return any(decoration.category == 'plant' for decoration in nearby_decorations) def move_towards_plant(self): if self.tamagotchi_logic is None: return nearby_decorations = self.tamagotchi_logic.get_nearby_decorations(self.squid_x, self.squid_y) plants = [d for d in nearby_decorations if d.category == 'plant'] if plants: closest_plant = min(plants, key=lambda p: self.distance_to(p.pos().x(), p.pos().y())) self.move_towards(closest_plant.pos().x(), closest_plant.pos().y()) else: self.move_randomly() def should_organize_decorations(self): return (self.curiosity > 70 and self.satisfaction < 80 and self.personality in [Personality.ADVENTUROUS, Personality.ENERGETIC]) def organize_decorations(self): target_corner = (50, 50) # Top-left corner decorations = self.tamagotchi_logic.get_nearby_decorations(self.squid_x, self.squid_y) if decorations: closest = min(decorations, key=lambda d: self.distance_to(d.pos().x(), d.pos().y())) self.move_towards(closest.pos().x(), closest.pos().y()) if self.distance_to(closest.pos().x(), closest.pos().y()) < 50: self.push_decoration(closest, direction=1 if random.random() < 0.5 else -1) self.satisfaction = min(100, self.satisfaction + 5) return "organizing decorations" return "searching for decorations" def interact_with_rocks(self): rocks = [d for d in self.tamagotchi_logic.get_nearby_decorations(self.squid_x, self.squid_y) if d.category == 'rock'] if rocks: self.push_decoration(random.choice(rocks), random.choice([-1, 1])) self.satisfaction = min(100, self.satisfaction + 8) self.happiness = min(100, self.happiness + 5) return "interacting with rocks" return "no rocks nearby" def can_pick_up_rock(self, rock_item): """Check if squid can pick up this rock""" rock_rect = rock_item.sceneBoundingRect() rock_center = rock_rect.center() squid_rect = self.squid_item.sceneBoundingRect() squid_center = squid_rect.center() distance = math.sqrt((rock_center.x() - squid_center.x())**2 + (rock_center.y() - squid_center.y())**2) can_pick = (not self.carrying_rock and not self.is_sleeping and distance < 50) print(f"Can pick up rock check:") print(f"- Rock center: ({rock_center.x():.1f}, {rock_center.y():.1f})") print(f"- Squid center: ({squid_center.x():.1f}, {squid_center.y():.1f})") print(f"- Distance: {distance:.1f}") print(f"- Carrying: {self.carrying_rock}") print(f"- Sleeping: {self.is_sleeping}") print(f"- Result: {can_pick}") return can_pick def pick_up_rock(self, item): """Delegate to interaction manager with random carry duration""" if not hasattr(self.tamagotchi_logic, 'rock_interaction'): return False return self.tamagotchi_logic.rock_interaction.attach_rock_to_squid(item) def throw_rock(self, direction): """ Delegate to interaction manager BUT capture result for RL reward. Returns: bool True = rock landed (success), False = fell out of bounds / no effect """ if not hasattr(self.tamagotchi_logic, 'rock_interaction'): return False # --- Call the existing manager --- success = self.tamagotchi_logic.rock_interaction.throw_rock(direction) # --- Compute RL reward --- reward = 0.0 if success: reward += 5.0 # base success reward += self.happiness * 0.05 # happier squid → bigger reward reward += self.satisfaction * 0.05 if self.personality == Personality.GREEDY: reward += 3.0 # greedy squid LOVES throwing # memory boost rock_mem = self.memory_manager.get_short_term_memory('interaction', 'rock_throw') if rock_mem and isinstance(rock_mem, dict) and rock_mem.get('is_positive'): reward += 2.0 else: reward -= 2.0 # small penalty for miss # --- Push reward into RL --- if hasattr(self.tamagotchi_logic, 'give_rl_reward'): self.tamagotchi_logic.give_rl_reward(reward) # --- Flag for neurogenesis / stats --- if hasattr(self.tamagotchi_logic, 'recent_positive_outcome'): self.tamagotchi_logic.recent_positive_outcome = success return success def update_rock_throw(self): if not self.rock_being_thrown or not self.rock_being_thrown.scene(): self.rock_animation_timer.stop() return rock = self.rock_being_thrown current_pos = rock.pos() # Heavy rock physics - sink quickly with minimal bouncing self.rock_velocity_y += 2.0 # Strong gravity for quick sinking # Calculate new position new_x = current_pos.x() + self.rock_velocity_x new_y = current_pos.y() + self.rock_velocity_y # Get scene boundaries scene_rect = self.ui.scene.sceneRect() rock_rect = rock.boundingRect() # Stop at reachable depth (200px from bottom) max_y = self.ui.window_height - 200 - rock_rect.height() if new_y > max_y: new_y = max_y self.rock_animation_timer.stop() self.rock_being_thrown = None return # Minimal horizontal movement when sinking if abs(self.rock_velocity_y) > 1.0: # If sinking fast self.rock_velocity_x *= 0.3 # Strong horizontal dampening # Basic wall collisions if new_x < scene_rect.left(): new_x = scene_rect.left() self.rock_velocity_x *= -0.5 # Weak wall bounce elif new_x > scene_rect.right() - rock_rect.width(): new_x = scene_rect.right() - rock_rect.width() self.rock_velocity_x *= -0.5 # Update position rock.setPos(new_x, new_y) def check_rock_interaction(self): if not hasattr(self, 'tamagotchi_logic') or self.tamagotchi_logic is None: return False if not hasattr(self.tamagotchi_logic, 'config_manager'): return False config = self.tamagotchi_logic.config_manager.get_rock_config() decorations = self.tamagotchi_logic.get_nearby_decorations( self.squid_x, self.squid_y, 150) interactables = [d for d in decorations if d.category in ['rock', 'poop']] if (self.carrying_rock and self.rock_throw_cooldown == 0 and random.random() < config['throw_prob']): direction = random.choice(["left", "right"]) if self.throw_rock(direction): return if (not self.carrying_rock and self.rock_throw_cooldown == 0 and interactables and random.random() < config['pickup_prob']): target_item = random.choice(interactables) if self.pick_up_rock(target_item): mem_details = { "item": getattr(target_item, 'filename', f'unknown_{target_item.category}'), "position": (target_item.pos().x(), target_item.pos().y()), "timestamp": datetime.now().isoformat() } self.memory_manager.add_short_term_memory( 'interaction', f'{target_item.category}_pickup', mem_details) else: print(f"[DEBUG] {target_item.category.capitalize()} pickup failed") def check_poop_interaction(self): """Periodic poop interaction check similar to rock interaction""" if not hasattr(self.tamagotchi_logic, 'poop_interaction'): return False config = self.tamagotchi_logic.config_manager.get_poop_config() decorations = self.tamagotchi_logic.get_nearby_decorations( self.squid_x, self.squid_y, 150) interactables = [d for d in decorations if d.category == 'poop'] # If carrying poop and cooldown is done, potentially throw if (self.carrying_poop and self.poop_throw_cooldown == 0 and random.random() < config['throw_prob']): direction = random.choice(["left", "right"]) if self.throw_poop(direction): return # If not carrying poop and cooldown is done, potentially pick up if (not self.carrying_poop and self.poop_throw_cooldown == 0 and interactables and random.random() < config['pickup_prob']): target_item = random.choice(interactables) if self.pick_up_poop(target_item): mem_details = { "item": getattr(target_item, 'filename', 'unknown_poop'), "position": (target_item.pos().x(), target_item.pos().y()), "timestamp": datetime.now().isoformat() } self.memory_manager.add_short_term_memory( 'interaction', 'poop_pickup', mem_details) else: print(f"[DEBUG] Poop pickup failed") def get_center(self): """Return the center position of the squid""" return (self.squid_x + self.squid_width/2, self.squid_y + self.squid_height/2) def move_toward_position(self, target_pos): """Move directly toward a QPointF or (x,y) position with rock interaction support""" # Handle both QPointF and tuple/position inputs if isinstance(target_pos, QtCore.QPointF): target_x, target_y = target_pos.x(), target_pos.y() else: target_x, target_y = target_pos # Get precise center positions using scene coordinates squid_rect = self.squid_item.sceneBoundingRect() squid_center_x = squid_rect.center().x() squid_center_y = squid_rect.center().y() dx = target_x - squid_center_x dy = target_y - squid_center_y distance = math.hypot(dx, dy) # More efficient than math.sqrt if distance > 5: # Small threshold to prevent micro-movements # Normalize and scale by speed (removed temporary 1.5x boost) norm = max(distance, 0.1) # Avoid division by zero move_x = (dx/norm) * self.base_squid_speed * self.animation_speed move_y = (dy/norm) * self.base_vertical_speed * self.animation_speed # Update position self.squid_x += move_x self.squid_y += move_y # Update direction - more precise handling if abs(move_x) > abs(move_y): self.squid_direction = "right" if move_x > 0 else "left" else: self.squid_direction = "down" if move_y > 0 else "up" # Enforce boundaries self.squid_x = max(50, min(self.squid_x, self.ui.window_width - 50 - self.squid_width)) self.squid_y = max(50, min(self.squid_y, self.ui.window_height - 120 - self.squid_height)) # Update graphics self.squid_item.setPos(self.squid_x, self.squid_y) self.current_frame = (self.current_frame + 1) % 2 self.update_squid_image() # Changed to use method instead of direct pixmap set return distance ================================================ FILE: src/squid_facts.py ================================================ import random SQUID_FACTS = [ "Dosidicus Gigas: scientific name for Humboldt squid", "Humboldt squid can reach a mantle length of 1.5 m (5 ft)", "Cephalopods have a hard beak like a parrot!", "Cephalopod blood is blue because it is copper-based", "These predators thrive in the `Oxygen Minimum Zone.`", "A squid's beak is the only rigid part of its body.", "Some squids have reached speeds of 25 km/h!", "Cannibalism occurs among the species when food is scarce.", "The Kraken is a legendary, ship-sinking gigantic squid", "Cephalopods are the fastest growing marine invertebrate", "Giant Squid can grow to 30-foot-long in just a few years", "Squids use jet propulsion to move quickly through the water", "Squids breathe using gills located inside their mantle", ] def get_random_fact(): """Return a squid fact""" return random.choice(SQUID_FACTS) ================================================ FILE: src/squid_statistics.py ================================================ import time import math # Distance tracking constants DISTANCE_ROLLOVER_LIMIT = 999_999_999 # ~1 billion pixels before rollover class SquidStatistics: def __init__(self, squid): self.squid = squid self.start_time = time.time() self.total_age_seconds = 0 self.sushi_consumed = 0 self.cheese_consumed = 0 self.total_memories_formed = 0 self.highest_anxiety = 0 self.lowest_happiness = 100 self.highest_satisfaction = 0 self.distance_swam = 0 self.distance_swam_multiplier = 1 self.other_squids_encountered = 0 self.total_rocks_thrown = 0 self.total_poops_thrown = 0 self.total_env_interactions = 0 self.ink_clouds_created = 0 self.plants_interacted = 0 self.time_spent_asleep = 0 self.peak_novelty = 0 self.peak_stress = 0 self.peak_reward = 0 self.novelty_neurons_created = 0 self.stress_neurons_created = 0 self.reward_neurons_created = 0 self.poops_created = 0 self.max_poops_cleaned = 0 self.startles_experienced = 0 self.times_colour_changed = 0 self.sickness_episodes = 0 self.max_neurons_reached = 7 self.current_neurons = 7 def get_total_age_seconds(self): """Calculates the total persistent age in seconds.""" current_session_age = time.time() - self.start_time return self.total_age_seconds + current_session_age def update_distance(self, dx, dy): '''Track distance traveled by squid with rollover protection''' distance = math.sqrt(dx*dx + dy*dy) self.distance_swam += distance # Check for rollover and reset with multiplier if self.distance_swam >= DISTANCE_ROLLOVER_LIMIT: self.distance_swam = self.distance_swam - DISTANCE_ROLLOVER_LIMIT self.distance_swam_multiplier += 1 # Log the rollover event if hasattr(self.squid, 'tamagotchi_logic'): self.squid.tamagotchi_logic.show_message( f"🌊 Distance counter rolled over! Now at {self.distance_swam_multiplier}x" ) def get_distance_display(self): '''Get formatted distance string with multiplier if needed''' if self.distance_swam_multiplier > 1: return f"{self.distance_swam_multiplier}x {int(self.distance_swam):,}" return f"{int(self.distance_swam):,}" def get_squid_age(self): """ Return the squid’s age as a readable string: 1 min – 59 min → “ min” 60 min – 89 min → “1 hr” 90 min – 119 min → “1.5 hrs” 120 min – 149 min → “2 hrs” 150 min – 179 min → “2.5 hrs” …and so on, stepping in 30-minute blocks. """ total_minutes = int(self.get_total_age_seconds() // 60) if total_minutes < 60: # still in minutes return f"{total_minutes} min" + ("s" if total_minutes != 1 else "") # 60 min and above → switch to hours, 30-min steps whole_hours = total_minutes // 60 half_hour = (total_minutes % 60) // 30 # 0 or 1 hours_str = f"{whole_hours}" if half_hour == 0 else f"{whole_hours}.5" return f"{hours_str} hr" + ("s" if whole_hours + half_hour != 1 else "") def load_statistics(self, data): """Load statistics from saved data dictionary""" if not data: return # Core stats self.total_age_seconds = data.get('total_age_seconds', 0) self.distance_swam = data.get('distance_swam', 0) self.distance_swam_multiplier = data.get('distance_swam_multiplier', 1) # Food consumption self.sushi_consumed = data.get('sushi_eaten', 0) self.cheese_consumed = data.get('cheese_eaten', 0) # Mental states self.highest_anxiety = data.get('highest_anxiety', 0) self.lowest_happiness = data.get('lowest_happiness', 100) # Object interactions self.total_rocks_thrown = data.get('rocks_thrown', 0) self.total_poops_thrown = data.get('poops_thrown', 0) self.ink_clouds_created = data.get('ink_clouds_created', 0) self.plants_interacted = data.get('plants_interacted', 0) # Other tracked stats self.times_colour_changed = data.get('times_colour_changed', 0) self.poops_created = data.get('poops_created', 0) self.max_poops_cleaned = data.get('max_poops_cleaned', 0) self.startles_experienced = data.get('startles_experienced', 0) # Time and health self.time_spent_asleep = data.get('total_sleep_time', 0) self.sickness_episodes = data.get('sickness_episodes', 0) # Neurogenesis self.novelty_neurons_created = data.get('novelty_neurons_created', 0) self.stress_neurons_created = data.get('stress_neurons_created', 0) self.reward_neurons_created = data.get('reward_neurons_created', 0) # --- FIX: Ensure these are loaded correctly, defaulting to 7 --- self.max_neurons_reached = data.get('max_neurons_reached', 7) self.current_neurons = data.get('current_neurons', 7) def update(self): # Update peak mental states if self.squid.anxiety > self.highest_anxiety: self.highest_anxiety = self.squid.anxiety if self.squid.happiness < self.lowest_happiness: self.lowest_happiness = self.squid.happiness if self.squid.satisfaction > self.highest_satisfaction: self.highest_satisfaction = self.squid.satisfaction if self.squid.is_sleeping: self.time_spent_asleep += 1 # update is called every second # --- Check and update peak neurogenesis values --- if self.squid.tamagotchi_logic and self.squid.tamagotchi_logic.brain_window: brain_widget = self.squid.tamagotchi_logic.brain_window.brain_widget if brain_widget: # 1. Update Neurogenesis Stats if hasattr(brain_widget, 'neurogenesis_data'): neuro_data = brain_widget.neurogenesis_data current_novelty = neuro_data.get('novelty_counter', 0) current_stress = neuro_data.get('stress_counter', 0) current_reward = neuro_data.get('reward_counter', 0) if current_novelty > self.peak_novelty: self.peak_novelty = current_novelty if current_stress > self.peak_stress: self.peak_stress = current_stress if current_reward > self.peak_reward: self.peak_reward = current_reward # 2. Update Max Neurons Reached (FIX) if hasattr(brain_widget, 'neuron_positions'): actual_count = len(brain_widget.neuron_positions) if actual_count > self.max_neurons_reached: self.max_neurons_reached = actual_count self.current_neurons = actual_count def get_sleep_time(self): hours = int(self.time_spent_asleep // 3600) minutes = int((self.time_spent_asleep % 3600) // 60) return f"{hours}h {minutes}m" ================================================ FILE: src/statistics_window.py ================================================ from PyQt5 import QtCore, QtGui, QtWidgets import time, json, math, random, os from .localisation import Localisation class StatBox(QtWidgets.QWidget): """A single stat display box with label and value.""" def __init__(self, label_key, parent=None): super().__init__(parent) self.loc = Localisation.instance() self.label_key = label_key screen = QtWidgets.QApplication.primaryScreen() screen_size = screen.size() layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(5, 5, 5, 5) if screen_size.width() <= 1920 and screen_size.height() <= 1080: value_font_size, label_font_size = 18, 11 box_width, box_height = 85, 70 else: value_font_size, label_font_size = 28, 16 box_width, box_height = 120, 100 self.value_label = QtWidgets.QLabel("0") self.value_label.setAlignment(QtCore.Qt.AlignCenter) self.value_label.setStyleSheet( f"font-size:{value_font_size}px;font-weight:bold;" "border:2px solid black;background-color:white;" ) layout.addWidget(self.value_label) self.value_edit = QtWidgets.QLineEdit() self.value_edit.setAlignment(QtCore.Qt.AlignCenter) self.value_edit.setStyleSheet( f"font-size:{value_font_size}px;font-weight:bold;" "border:2px solid black;background-color:white;" ) self.value_edit.hide() layout.addWidget(self.value_edit) # Use localised label self.name_label = QtWidgets.QLabel(self.loc.get(label_key)) self.name_label.setAlignment(QtCore.Qt.AlignCenter) self.name_label.setStyleSheet(f"font-size:{label_font_size}px;font-weight:bold;") layout.addWidget(self.name_label) self.setFixedSize(box_width, box_height) def set_value(self, value): self.value_label.setText(str(int(value))) self.value_edit.setText(str(int(value))) def get_value(self): return int(self.value_edit.text()) def set_editable(self, editable): self.value_label.setVisible(not editable) self.value_edit.setVisible(editable) def update_label(self): """Update the label text (for language changes)""" self.name_label.setText(self.loc.get(self.label_key)) class StatisticsWindow(QtWidgets.QWidget): def __init__(self, squid, arcade_font_path=None, show_decorations_callback=None): super().__init__() self.squid = squid self.show_decorations_callback = show_decorations_callback self.loc = Localisation.instance() screen = QtWidgets.QApplication.primaryScreen() screen_size = screen.size() self.setWindowTitle(self.loc.get("statistics")) self.setWindowFlags(QtCore.Qt.Window | QtCore.Qt.WindowStaysOnTopHint) self.move(0, 0) # ---------------- score state --------------------------------- self.score = 0 self.display_score = 0 self.combo_level = 1 self.combo_timer = 0 self.last_food_time= 0 # ---------------- font ---------------------------------------- if arcade_font_path and os.path.isfile(arcade_font_path): font_path = arcade_font_path else: font_path = os.path.join(os.path.dirname(__file__), "arcade.ttf") if os.path.isfile(font_path): font_id = QtGui.QFontDatabase.addApplicationFont(font_path) families = QtGui.QFontDatabase.applicationFontFamilies(font_id) font = QtGui.QFont(families[0], 40) else: font = QtGui.QFont("Arial", 40, QtGui.QFont.Bold) # ---------------- score label --------------------------------- self.score_label = QtWidgets.QLabel("00000") self.score_label.setAlignment(QtCore.Qt.AlignCenter) self.score_label.setFont(font) self.score_label.setStyleSheet("color:#000000;") # ---------------- window sizing ------------------------------- if screen_size.width() <= 1920 and screen_size.height() <= 1080: window_width, window_height = 320, 420 main_spacing, grid_spacing = 6, 6 status_font_size, score_font_size = 18, 20 else: window_width, window_height = 450, 600 main_spacing, grid_spacing = 10, 10 status_font_size, score_font_size = 24, 32 self.setFixedSize(window_width, window_height) # ---------------- layout -------------------------------------- main_layout = QtWidgets.QVBoxLayout(self) main_layout.setSpacing(main_spacing) grid_layout = QtWidgets.QGridLayout() grid_layout.setSpacing(grid_spacing) main_layout.addLayout(grid_layout) # Create stat boxes with localisation keys self.stat_boxes = { "hunger": StatBox("hunger"), "happiness": StatBox("happiness"), "cleanliness": StatBox("cleanliness"), "sleepiness": StatBox("sleepiness"), "health": StatBox("health"), "satisfaction": StatBox("satisfaction"), "curiosity": StatBox("curiosity"), "anxiety": StatBox("anxiety"), } grid_layout.addWidget(self.stat_boxes["health"], 0, 0) grid_layout.addWidget(self.stat_boxes["hunger"], 0, 1) grid_layout.addWidget(self.stat_boxes["happiness"], 0, 2) grid_layout.addWidget(self.stat_boxes["cleanliness"], 1, 0) grid_layout.addWidget(self.stat_boxes["sleepiness"], 1, 1) separator = QtWidgets.QFrame() separator.setFrameShape(QtWidgets.QFrame.HLine) separator.setFrameShadow(QtWidgets.QFrame.Sunken) main_layout.addWidget(separator) new_neurons_layout = QtWidgets.QHBoxLayout() new_neurons_layout.setSpacing(10 if window_width > 320 else 6) main_layout.addLayout(new_neurons_layout) new_neurons_layout.addWidget(self.stat_boxes["satisfaction"]) new_neurons_layout.addWidget(self.stat_boxes["curiosity"]) new_neurons_layout.addWidget(self.stat_boxes["anxiety"]) self.status_label = QtWidgets.QLabel() self.status_label.setAlignment(QtCore.Qt.AlignCenter) self.status_label.setStyleSheet(f"font-size:{status_font_size}px;") main_layout.addWidget(self.status_label) # State indicator pills container self.state_pills_container = QtWidgets.QWidget() self.state_pills_layout = QtWidgets.QHBoxLayout(self.state_pills_container) self.state_pills_layout.setSpacing(10) self.state_pills_layout.setContentsMargins(0, 5, 0, 5) self.state_pills_layout.addStretch() # Create up to 3 pill labels self.state_pills = [] for i in range(3): pill = QtWidgets.QLabel() pill.setAlignment(QtCore.Qt.AlignCenter) pill.setMinimumHeight(30 if window_width <= 320 else 40) pill.setStyleSheet(""" QLabel { color: white; font-size: 16px; font-weight: bold; border-radius: 5px; padding: 5px 10px; } """) pill.hide() self.state_pills.append(pill) self.state_pills_layout.addWidget(pill) self.state_pills_layout.addStretch() main_layout.addWidget(self.state_pills_container) main_layout.addWidget(self.score_label) self.apply_button = QtWidgets.QPushButton(self.loc.get("apply_changes")) self.apply_button.clicked.connect(self.apply_changes) self.apply_button.hide() main_layout.addWidget(self.apply_button) # smooth ticker self.roll_timer = QtCore.QTimer(self, timeout=self._tick, interval=16) self.roll_timer.start() # Setup decorations window shortcut self.setup_decorations_shortcut() # ------------------------------------------------------------------ def setup_decorations_shortcut(self): """Setup keyboard shortcut for decorations window (T key)""" if self.show_decorations_callback: self.decorations_shortcut = QtWidgets.QShortcut( QtGui.QKeySequence(QtCore.Qt.Key_T), self ) self.decorations_shortcut.activated.connect(self.show_decorations_callback) def _tick(self): # Update score display with smooth animation diff = self.score - self.display_score if abs(diff) > 0: step = math.copysign(max(1, abs(diff) // 8), diff) self.display_score += step self.display_score = max(0, min(99999, self.display_score)) self.score_label.setText(f"{int(self.display_score):05d}") # Update combo timer if self.combo_timer > 0: self.combo_timer -= 1 else: self.combo_level = 1 # Continuously update all statistics from squid self.update_statistics() # -------------- external award ----------------------------------- def award(self, base): now = time.time() # food chaining if base == 100: if now - self.last_food_time < 2.0: self.combo_level = min(3, self.combo_level + 1) else: self.combo_level = 1 self.last_food_time = now self.combo_timer = 600 # poop - no combo change elif base == 25: pass total = int(base * self.combo_level) self.score += total self.score = max(0, min(9999, self.score)) def set_score(self, value): """Directly set the score value (used for loading saves)""" self.score = max(0, min(9999, int(value))) self.display_score = self.score self.score_label.setText(f"{int(self.display_score):04d}") def update_score(self): """Update the score display (called by tamagotchi_logic)""" self.score_label.setText(f"{int(self.display_score):04d}") # -------------- specific helpers --------------------------------- def add_score_for_food_eaten(self): self.award(100) def add_score_for_neuron_creation(self): self.award(500) def deduct_score_for_startle(self): self.award(-50) def add_score_for_poop_cleaned(self, count): self.award(25 * count) # -------------- original interface --------------------------------- def update_statistics(self): if self.squid is not None: for key, box in self.stat_boxes.items(): if hasattr(self.squid, key): box.set_value(getattr(self.squid, key)) self.status_label.setText(f"{self.loc.get('status')}: {self.squid.status}") self._update_state_pill() def _update_state_pill(self): """Update the state indicator pills to match the brain network view states (up to 3)""" if self.squid is None: for pill in self.state_pills: pill.hide() return # Collect all active states (matches brain_widget.py logic) active_states = [] # Check states in priority order with localised text if getattr(self.squid, 'is_fleeing', False): active_states.append((self.loc.get("fleeing"), "rgb(220, 20, 60)")) if getattr(self.squid, 'is_startled', False): active_states.append((self.loc.get("startled"), "rgb(255, 165, 0)")) if getattr(self.squid, 'pursuing_food', False): active_states.append((self.loc.get("pursuing_food"), "rgb(60, 179, 113)")) if getattr(self.squid, 'is_eating', False) or 'eating' in self.squid.status.lower(): active_states.append((self.loc.get("eating"), "rgb(46, 204, 113)")) if getattr(self.squid, 'is_sleeping', False): active_states.append((self.loc.get("sleeping"), "rgb(142, 68, 173)")) if 'rock' in self.squid.status.lower() or 'play' in self.squid.status.lower(): active_states.append((self.loc.get("playing"), "rgb(241, 196, 15)")) if 'hiding' in self.squid.status.lower(): active_states.append((self.loc.get("hiding"), "rgb(22, 160, 133)")) if getattr(self.squid, 'anxiety', 0) > 70 or 'anxious' in self.squid.status.lower(): active_states.append((self.loc.get("anxious"), "rgb(231, 76, 60)")) if getattr(self.squid, 'curiosity', 0) > 80 or 'curious' in self.squid.status.lower(): active_states.append((self.loc.get("curious"), "rgb(52, 152, 219)")) # Display up to 3 states states_to_show = active_states[:3] # Update each pill for i, pill in enumerate(self.state_pills): if i < len(states_to_show): state_text, state_color = states_to_show[i] pill.setText(state_text) pill.setStyleSheet(f""" QLabel {{ color: white; background-color: {state_color}; font-size: 14px; font-weight: bold; border-radius: 5px; padding: 5px 10px; }} """) pill.show() else: pill.hide() def set_debug_mode(self, enabled): for key, box in self.stat_boxes.items(): if key not in {"satisfaction", "curiosity", "anxiety"}: box.set_editable(enabled) self.apply_button.setVisible(enabled) def apply_changes(self): if self.squid is not None: for key, box in self.stat_boxes.items(): if hasattr(self.squid, key): setattr(self.squid, key, box.get_value()) self.update_statistics() def closeEvent(self, event): event.accept() ================================================ FILE: src/tamagotchi_logic.py ================================================ from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtGui import QPixmap import random import os import time import zipfile import json import math from .statistics_window import StatisticsWindow from .save_manager import SaveManager from .squid import Personality, Squid from .ui import ResizablePixmapItem from .brain_tool import SquidBrainWindow from .learning import HebbianLearning from .interactions import RockInteractionManager from .interactions2 import PoopInteractionManager from .config_manager import ConfigManager from .plugin_manager import PluginManager from .brain_neuron_hooks import BrainNeuronHooks from .brain_neuron_outputs import NeuronOutputMonitor from .vision_worker import VisionWorker, create_squid_vision_state, extract_scene_objects from .custom_brain_loader import ( get_custom_brain_save_data, restore_custom_brain_from_save, show_custom_brain_load_warning, has_custom_brain ) # Performance tracking for Task Manager try: from .task_manager import perf_tracker _PERF_TRACKING_AVAILABLE = True except ImportError: _PERF_TRACKING_AVAILABLE = False class TamagotchiLogic: def __init__(self, user_interface, squid, brain_window): self.config_manager = ConfigManager() self._propagating_debug_mode = False self.user_interface = user_interface self.mental_states_enabled = True self.squid = squid self.brain_window = brain_window self.brain_hooks = BrainNeuronHooks(self) # Initialize Vision Worker self.vision_worker = VisionWorker() self.vision_worker.visibility_update.connect(self.handle_vision_update) self.vision_worker.start() self.latest_vision_result = None # Initialize rock interaction manager with config self.rock_interaction = RockInteractionManager( squid=self.squid, logic=self, scene=self.user_interface.scene, message_callback=self.show_message, config_manager=self.config_manager ) self.last_save_hash = None self.save_count = 0 self.window_resize_cooldown = 0 self.window_resize_cooldown_max = 20 # 20 updates before another resize can startle self.has_been_resized = False self.was_big = False self.debug_mode = False self.last_window_size = (1280, 900) # Default size # Age tracking self.squid_birth_time = time.time() self.age_update_timer = QtCore.QTimer() self.age_update_timer.timeout.connect(self.update_squid_age) self.age_update_timer.start(60000) # 1 minute # Decorations message system self.decorations_message_count = 0 self.decorations_message_max = 3 self.last_decorations_message_time = 0 self.decorations_message_cooldown = 120 # 2 minutes in seconds self.decorations_message_check_interval = 300 # 5 minutes in seconds self.next_decorations_message_check = time.time() + random.randint(120, 300) # 2-5 minutes from now self.mental_states_enabled = True self.curious_cooldown = 0 self.curious_cooldown_max = 20 self.curious_interaction_cooldown = 1 self.curious_interaction_cooldown_max = 5 self.startle_cooldown = 1000 self.startle_cooldown_max = 20 self.plant_calming_effect_counter = 0 self.sleep_frame = 0 # Add action tracking - new in 2.4.5.0 self.recent_actions = [] # Initialize save manager FIRST (before plugins) self.save_manager = SaveManager() # Initialize plugin manager self.plugin_manager = PluginManager() self.plugin_manager.set_tamagotchi_logic(self) # Initialize Monitor so handlers can register self.neuron_output_monitor = NeuronOutputMonitor(self) # Register all built-in sensors with the plugin manager # This makes them discoverable via plugin_manager.get_all_neuron_handler_info() self.plugin_manager.register_all_sensors(self) # Store save data that will be loaded later (needed for achievements) self._pending_save_data = None # Load save data FIRST (but don't apply it yet) save_data = self.save_manager.load_game() if save_data: self._pending_save_data = save_data print("✓ Save data loaded and cached for plugin initialization") # --- BLACKLIST MULTIPLAYER PLUGIN FROM AUTO-LOADING --- blacklisted = {"multiplayer"} discovered = self.plugin_manager.discover_plugins() for name in list(discovered.keys()): if name.lower() in blacklisted: del discovered[name] print(f"[PluginManager] Blacklisted '{name}' from auto-loading.") # Manually populate plugins dict to skip blacklisted ones self.plugin_manager.plugins.clear() self.plugin_manager.enabled_plugins.clear() for name, data in discovered.items(): success = self.plugin_manager.load_plugin(name) if success and name != "multiplayer": self.plugin_manager.enabled_plugins.add(name) # Setup each loaded plugin (multiplayer is not in self.plugin_manager.plugins) for plugin_name, plugin_data in self.plugin_manager.plugins.items(): instance = plugin_data.get('instance') if instance and hasattr(instance, 'setup') and not plugin_data.get('is_setup', False): try: instance.setup(self.plugin_manager, self) plugin_data['is_setup'] = True print(f"✓ Setup plugin: {plugin_name}") except Exception as e: print(f"Error setting up plugin {plugin_name}: {e}") # Update status bar with plugin information self.update_status_bar() # Trigger startup hook self.plugin_manager.trigger_hook("on_startup", tamagotchi_logic=self, squid=self.squid, user_interface=self.user_interface) # Initialize core attributes first self.simulation_speed = 1 # Default to 1x speed self.base_interval = 1000 # 1000ms = 1 second base interval self.base_food_speed = 90 # pixels per update at 1x speed # Initialize game objects BEFORE load_game() self.food_items = [] self.max_food = 3 self.food_width = 64 self.food_height = 64 self.poop_items = [] self.max_poop = 3 self.points = 0 # Caches scene items to avoid iterating the entire scene every tick self._decoration_cache = [] self._decoration_cache_time = 0 self._decoration_cache_interval = 0.3 # Rebuild cache every 300ms max # Flag to indicate the first instance of the application start self.is_first_instance = True # Initialize a timer for the initial delay if it's the first instance if self.is_first_instance: self.initial_delay_timer = QtCore.QTimer() self.initial_delay_timer.setSingleShot(True) self.initial_delay_timer.timeout.connect(self.allow_initial_startle) self.initial_delay_timer.start(60000) # 60000 ms = 1 minute self.initial_startle_allowed = False # Initialize neurogenesis triggers with all required keys self.neurogenesis_triggers = { 'novel_objects': 0, 'high_stress_cycles': 0, 'positive_outcomes': 0 } self.new_object_encountered = False self.recent_positive_outcome = False # Setup timers with required arguments self.setup_timers() # Initialize thought system if hasattr(self.brain_window, 'add_thought'): self.add_thought = self.brain_window.add_thought else: self.thought_log = [] self.add_thought = self._log_thought # Connect menu actions self.user_interface.feed_action.triggered.connect(self.feed_squid) self.user_interface.clean_action.triggered.connect(self.clean_environment) self.user_interface.connect_view_cone_action(self.squid.toggle_view_cone) self.user_interface.medicine_action.triggered.connect(self.give_medicine) self.user_interface.debug_action.triggered.connect(self.toggle_debug_mode) # Window setup self.user_interface.window.resizeEvent = self.handle_window_resize # Initialize state tracking self.last_clean_time = 0 self.clean_cooldown = 60 self.cleanliness_threshold_time = 0 self.hunger_threshold_time = 0 self.needle_item = None self.lights_on = True # Initialize statistics window self.statistics_window = StatisticsWindow(squid) squid.statistics_window = self.statistics_window self.statistics_window.show() # NOW load the game after statistics_window exists AND plugins are initialized self.load_game() # Setup additional timers self.score_update_timer = QtCore.QTimer() self.score_update_timer.timeout.connect(self.update_score) self.score_update_timer.start(5000) self.brain_update_timer = QtCore.QTimer() self.brain_update_timer.timeout.connect(self.update_squid_brain) self.brain_update_timer.start(1000) self.autosave_timer = QtCore.QTimer() self.autosave_timer.timeout.connect(self.autosave) # Initialize goal neurons self.squid.satisfaction = 50 self.squid.anxiety = 10 self.squid.curiosity = 55 # Connect neurogenesis signal to show icon and store long-term memory if hasattr(self.brain_window, 'brain_widget'): self.brain_window.brain_widget.neuronCreated.connect(self._on_neurogenesis_icon_and_memory) def handle_vision_update(self, result): """Cache the latest vision result and push to brain immediately.""" self.latest_vision_result = result # [FIX] Push to brain now. Waiting for the 1-second timer causes lag/flicker. self.apply_input_neurons_to_brain() def _on_neurogenesis_icon_and_memory(self, neuron_name: str): """Called when a new neuron is created via neurogenesis. Shows images/ng.png above the squid's head for 4 seconds and records a long-term memory of the event. """ # Show the ng icon above the squid's head for 4 seconds if hasattr(self.squid, 'show_neurogenesis_icon'): self.squid.show_neurogenesis_icon() QtCore.QTimer.singleShot(4000, self.squid.hide_neurogenesis_icon) # Store a long-term memory if hasattr(self.squid, 'memory_manager'): self.squid.memory_manager.add_long_term_memory( 'neurogenesis', f'neurogenesis_{time.time():.0f}', { 'description': 'GREW A NEW NEURON VIA NEUROGENESIS!', 'neuron_name': neuron_name, 'timestamp': time.time(), 'icon': 'images/ng.png' } ) def _initialize_plugins(self): """Initialize all plugins before loading game data""" # --- BLACKLIST MULTIPLAYER PLUGIN FROM AUTO-LOADING --- blacklisted = {"multiplayer"} # lowercase plugin key discovered = self.plugin_manager.discover_plugins() for name in list(discovered.keys()): if name.lower() in blacklisted: del discovered[name] print(f"[PluginManager] Blacklisted '{name}' from auto-loading.") # Clear existing plugins self.plugin_manager.plugins.clear() self.plugin_manager.enabled_plugins.clear() # Load all discovered plugins (except multiplayer) for name, data in discovered.items(): success = self.plugin_manager.load_plugin(name) if success and name != "multiplayer": self.plugin_manager.enabled_plugins.add(name) # Setup each loaded plugin for plugin_name, plugin_data in self.plugin_manager.plugins.items(): instance = plugin_data.get('instance') if instance and hasattr(instance, 'setup') and not plugin_data.get('is_setup', False): try: instance.setup(self.plugin_manager, self) plugin_data['is_setup'] = True print(f"✓ Setup plugin: {plugin_name}") except Exception as e: print(f"Error setting up plugin {plugin_name}: {e}") # Update status bar with plugin information self.update_status_bar() # Trigger startup hook self.plugin_manager.trigger_hook("on_startup", tamagotchi_logic=self, squid=self.squid, user_interface=self.user_interface) def update_squid_age(self): if hasattr(self.brain_window, 'statistics_tab'): age_min = int((time.time() - self.squid_birth_time) / 60) self.brain_window.statistics_tab.statistics['squid_age_minutes'] = age_min self.brain_window.statistics_tab.update_display() def track_poop_thrown(self): if hasattr(self.brain_window, 'statistics_tab'): self.brain_window.statistics_tab.increment_stat('poops_thrown') def update_highest_anxiety(self, value): if hasattr(self.brain_window, 'statistics_tab'): tab = self.brain_window.statistics_tab if value > tab.statistics['highest_anxiety']: tab.statistics['highest_anxiety'] = value tab.update_display() def update_lowest_happiness(self, value): if hasattr(self.brain_window, 'statistics_tab'): tab = self.brain_window.statistics_tab if value < tab.statistics['lowest_happiness']: tab.statistics['lowest_happiness'] = value tab.update_display() def update_max_memories(self): if hasattr(self.brain_window, 'statistics_tab') and hasattr(self.squid, 'memory_manager'): tab = self.brain_window.statistics_tab stm = len(self.squid.memory_manager.short_term_memory) ltm = len(self.squid.memory_manager.long_term_memory) tab.statistics['max_short_term_memories'] = max(tab.statistics['max_short_term_memories'], stm) tab.statistics['max_long_term_memories'] = max(tab.statistics['max_long_term_memories'], ltm) tab.update_display() def set_squid(self, squid): self.squid = squid def set_brain_window(self, brain_window): self.brain_window = brain_window def set_mental_states_enabled(self, enabled): self.mental_states_enabled = enabled self.squid.mental_state_manager.set_mental_states_enabled(enabled) def get_health_history(self, limit=100): """ Returns historical health data for the squid. Args: limit (int): Maximum number of data points to return (default: 100) Returns: list: A list of (timestamp, health_value) tuples, newest first """ # Initialize health history if it doesn't exist if not hasattr(self, '_health_history'): self._health_history = [] # Return a copy of the history, limited to the requested number of points return self._health_history[-limit:] def reset_squid_status(self): """Reset squid status to default state after temporary actions""" if self.squid and "eating" in self.squid.status.lower(): # Choose default status based on personality if self.squid.personality == Personality.TIMID: self.squid.status = "cautiously exploring" elif self.squid.personality == Personality.ADVENTUROUS: self.squid.status = "boldly exploring" else: self.squid.status = "roaming" # Ensure is_eating flag is also cleared self.squid.is_eating = False def get_decision_data(self): """Package decision-making information for visualization based on DecisionEngine""" # Default empty data structure decision_data = { 'timestamp': time.strftime("%H:%M:%S"), 'inputs': {}, 'active_memories': [], 'possible_actions': [], 'final_decision': "unknown", 'confidence': 0.0, 'processing_time': 0, 'personality_influence': getattr(self.squid, 'personality', 'unknown'), 'weights': {}, 'adjusted_weights': {}, 'randomness': {} } if not hasattr(self, 'squid') or not self.squid: return decision_data try: # Get current state for inputs decision_data['inputs'] = { "hunger": self.squid.hunger, "happiness": self.squid.happiness, "cleanliness": self.squid.cleanliness, "sleepiness": self.squid.sleepiness, "satisfaction": self.squid.satisfaction, "anxiety": self.squid.anxiety, "curiosity": self.squid.curiosity, "is_sick": self.squid.is_sick, "is_sleeping": self.squid.is_sleeping, "has_food_visible": bool(self.squid.get_visible_food()), "carrying_rock": getattr(self.squid, 'carrying_rock', False), } # Capture decision engine logic before making the decision decision_data['final_decision'] = self.squid.status # Get active memories if hasattr(self.squid, 'memory_manager'): active_memories = self.squid.memory_manager.get_active_memories_data(3) decision_data['active_memories'] = [ f"{mem.get('category', 'memory')}: {str(mem.get('formatted_value', ''))[:50]}" for mem in active_memories ] # Simulate processing time (would be cool to measure actual time) decision_data['processing_time'] = random.randint(20, 100) # Confidence of the decision (higher for more extreme weight differences) # Here we're estimating based on the squid's state and randomizing a bit base_confidence = 0.5 # Adjust confidence based on state extremes for key, value in decision_data['inputs'].items(): if isinstance(value, (int, float)) and value > 80: base_confidence += 0.1 # More confident with extreme values elif isinstance(value, (int, float)) and value < 20: base_confidence += 0.1 # Cap and add randomness base_confidence = min(0.9, base_confidence) decision_data['confidence'] = base_confidence + random.uniform(-0.1, 0.1) # Get brain network state if available if hasattr(self, 'squid_brain_window') and self.squid_brain_window: brain_state = self.squid_brain_window.brain_widget.state else: brain_state = {} # Calculate decision weights (replicating the logic from DecisionEngine) weights = { "exploring": brain_state.get("curiosity", 50) * 0.8 * (1 - (brain_state.get("anxiety", 50) / 100)), "eating": brain_state.get("hunger", 50) * 1.2 if self.squid.get_visible_food() else 0, "approaching_rock": brain_state.get("curiosity", 50) * 0.7 if not getattr(self.squid, 'carrying_rock', False) else 0, "throwing_rock": brain_state.get("satisfaction", 50) * 0.7 if getattr(self.squid, 'carrying_rock', False) else 0, "avoiding_threat": brain_state.get("anxiety", 50) * 0.9, "organizing": brain_state.get("satisfaction", 50) * 0.5 } decision_data['weights'] = weights # Apply personality modifiers (replicating logic from DecisionEngine) adjusted_weights = weights.copy() # Personality modifiers from the DecisionEngine if self.squid.personality.value == "timid": adjusted_weights["avoiding_threat"] *= 1.5 adjusted_weights["approaching_rock"] *= 0.7 elif self.squid.personality.value == "adventurous": adjusted_weights["exploring"] *= 1.3 adjusted_weights["approaching_rock"] *= 1.2 elif self.squid.personality.value == "greedy": adjusted_weights["eating"] *= 1.5 decision_data['adjusted_weights'] = adjusted_weights # Generate random factors for each action randomness = {} for action in weights.keys(): randomness[action] = random.uniform(0.85, 1.15) decision_data['randomness'] = randomness # Possible actions decision_data['possible_actions'] = [ action for action, weight in adjusted_weights.items() if weight > 0 ] except Exception as e: print(f"Error generating decision data: {str(e)}") import traceback traceback.print_exc() return decision_data def give_rl_reward(self, reward): """ Feed a scalar reward into the DecisionEngine's Q-learning update. Call this immediately after the action finishes. """ if hasattr(self, 'squid') and hasattr(self.squid, 'decision_engine'): de = self.squid.decision_engine if de.last_state is not None and de.last_action is not None: # Build next state from current squid state next_state = de.get_state_index({ "hunger": self.squid.hunger, "happiness": self.squid.happiness, "anxiety": self.squid.anxiety, "curiosity": self.squid.curiosity }) # Update Q-table de.ql.update(de.last_state, de.last_action, reward, next_state) def get_active_memories(self): # Get raw memory objects instead of display strings memories = self.squid.memory_manager.get_all_short_term_memories(raw=True)[:3] return [f"{m['category']}: {m['value']}" for m in memories] def get_available_actions(self): return ["search_for_food", "explore", "sleep", "move_randomly", "interact_object"] def get_recent_learning(self): """Get last 3 significant weight changes""" if hasattr(self.squid, 'hebbian_learning') and self.squid.hebbian_learning: try: learning_data = self.squid.hebbian_learning.get_learning_data() return [f"{n1}-{n2}: {delta:.2f}" for _,n1,n2,delta in learning_data[-3:]] except AttributeError: return ["Learning system initializing"] return ["No learning data available"] def get_current_state(self): """Get current sensory inputs and status""" return { 'hunger': self.squid.hunger, 'happiness': self.squid.happiness, 'cleanliness': self.squid.cleanliness, 'sleepiness': self.squid.sleepiness, 'satisfaction': self.squid.satisfaction, 'anxiety': self.squid.anxiety, 'curiosity': self.squid.curiosity, 'is_sick': self.squid.is_sick, 'near_food': len(self.squid.get_visible_food()) > 0, 'near_poop': len(self.poop_items) > 0 } def get_active_memories(self): memories = self.squid.memory_manager.get_active_memories_data(3) return [f"{m['category']}: {m['formatted_value']}" for m in memories] def get_available_actions(self): """List of currently available actions""" actions = ["explore", "sleep", "move_randomly"] if self.squid.hunger > 50: actions.append("search_for_food") if self.squid.curiosity > 60: actions.append("investigate_object") return actions def update_from_brain(self, brain_state): # Communication between brain tool and Squid if self.squid is not None: for key, value in brain_state.items(): if hasattr(self.squid, key): setattr(self.squid, key, value) # Handle special cases if brain_state['sleepiness'] >= 100 and not self.squid.is_sleeping: self.squid.go_to_sleep() elif brain_state['sleepiness'] < 50 and self.squid.is_sleeping: self.squid.wake_up() if brain_state['direction'] != self.squid.squid_direction: self.squid.squid_direction = brain_state['direction'] self.squid.move_squid() self.update_statistics() self.user_interface.scene.update() def update_decoration_learning(self, effects): if not effects: return # Get the current state of the squid current_state = { "hunger": self.squid.hunger, "happiness": self.squid.happiness, "cleanliness": self.squid.cleanliness, "sleepiness": self.squid.sleepiness, "satisfaction": self.squid.satisfaction, "anxiety": self.squid.anxiety, "curiosity": self.squid.curiosity } # Update the brain based on the decoration effects for stat, boost in effects.items(): if stat in current_state: # Increase the connection strength between the affected stat and satisfaction self.brain_window.brain_widget.strengthen_connection(stat, 'satisfaction', boost * 0.01) # If the boost is significant, also strengthen connection with happiness if boost > 5: self.brain_window.brain_widget.strengthen_connection(stat, 'happiness', boost * 0.005) # Update the squid's memory decoration_memory = { "category": "decorations", "effects": effects, "timestamp": time.time() } self.squid.memory_manager.add_short_term_memory('decorations', str(time.time()), decoration_memory) # If this is a significant effect, consider transferring to long-term memory if any(boost > 10 for boost in effects.values()): self.squid.memory_manager.transfer_to_long_term_memory('decorations', str(time.time())) def _log_thought(self, thought): self.thought_log.append(thought) print(f"Squid thought: {thought}") def check_for_decoration_attraction(self): squid_x = self.squid.squid_x squid_y = self.squid.squid_y active_decorations = self.get_nearby_decorations(squid_x, squid_y) if active_decorations: self.apply_decoration_effects(active_decorations) # NEW: Check for plant contact and update status is_hiding = False for decoration in active_decorations: if hasattr(decoration, 'category') and decoration.category == 'plant': if decoration.collidesWithItem(self.squid.squid_item): self.squid.status = "hiding behind plant" is_hiding = True break # Squid can only hide behind one plant at a time if not is_hiding and self.squid.status == "hiding behind plant": self.squid.status = "roaming" # Or another default status # Move decorations for decoration in active_decorations: decoration_pos = decoration.pos() if decoration_pos.x() < squid_x: self.move_decoration(decoration, 5) # Move right else: self.move_decoration(decoration, -5) # Move left def get_nearby_decorations(self, x, y, radius=100): """ Get decorations near a position. PERFORMANCE FIX: Uses cached decoration list to avoid iterating through all scene items every frame. """ current_time = time.time() # Rebuild cache if stale (every 300ms or on first call) if (current_time - self._decoration_cache_time > self._decoration_cache_interval or not self._decoration_cache): self._decoration_cache = [] for item in self.user_interface.scene.items(): if isinstance(item, ResizablePixmapItem): # Cache both the item and its center point center = item.sceneBoundingRect().center() self._decoration_cache.append((item, center.x(), center.y())) self._decoration_cache_time = current_time # Filter from cache using squared distance (avoids expensive sqrt) nearby_decorations = [] radius_sq = radius * radius for item, cx, cy in self._decoration_cache: dist_sq = (cx - x) ** 2 + (cy - y) ** 2 if dist_sq <= radius_sq: nearby_decorations.append(item) return nearby_decorations def invalidate_decoration_cache(self): """ Call this when decorations are added/removed to force cache rebuild. """ self._decoration_cache_time = 0 def investigate_object(self): if self.detected_object_position: self.move_towards(self.detected_object_position[0], self.detected_object_position[1]) # Add thoughts self.brain_window.add_thought("Investigating unknown object") if self.distance_to(self.detected_object_position[0], self.detected_object_position[1]) < 20: object_item = self.tamagotchi_logic.get_item_at_position(self.detected_object_position[0], self.detected_object_position[1]) if object_item: if isinstance(object_item, Food): self.brain_window.add_thought("Unknown object appears to be food") self.eat() self.brain_window.add_thought("Ate the food!") elif isinstance(object_item, Decoration): self.brain_window.add_thought("Unknown object appears to be a decoration") self.interact_with_decoration(object_item) self.object_visible = False self.detected_object_position = None def check_collision_with_cheese(self, cheese_item): if self.squid.personality == Personality.STUBBORN: return False # Stubborn squids never collide with cheese squid_rect = self.squid.boundingRect().translated(self.squid.squid_x, self.squid.squid_y) cheese_rect = cheese_item.boundingRect().translated(cheese_item.pos()) return squid_rect.intersects(cheese_rect) def move_decoration(self, decoration, dx): current_pos = decoration.pos() new_x = current_pos.x() + dx # Ensure the decoration stays within the scene boundaries scene_rect = self.user_interface.scene.sceneRect() new_x = max(scene_rect.left(), min(new_x, self.user_interface.window_width - decoration.boundingRect().width())) # Use QVariantAnimation because QGraphicsPixmapItem does not inherit from QObject. # This animation will interpolate the position value for us. animation = QtCore.QVariantAnimation() animation.setStartValue(current_pos) animation.setEndValue(QtCore.QPointF(new_x, current_pos.y())) animation.setDuration(300) # 300 ms duration animation.setEasingCurve(QtCore.QEasingCurve.OutCubic) # Connect the animation's valueChanged signal to the item's setPos method animation.valueChanged.connect(decoration.setPos) # Store the animation object to prevent it from being garbage collected decoration._animation = animation # Start the animation and have it delete itself when finished animation.start(QtCore.QAbstractAnimation.DeleteWhenStopped) def apply_decoration_effects(self, active_decorations): """Apply effects from nearby decorations""" if not active_decorations: return effects = {} strongest_effect = 0 strongest_decoration = None for decoration in active_decorations: if not hasattr(decoration, 'filename') or decoration.filename is None: continue if not hasattr(decoration, 'stat_modifiers') or not decoration.stat_modifiers: continue # Find the decoration with the strongest effect (absolute value) max_modifier = max(abs(val) for val in decoration.stat_modifiers.values()) if max_modifier > strongest_effect: strongest_effect = max_modifier strongest_decoration = decoration if strongest_decoration: # Apply stat modifiers additively for stat, modifier in strongest_decoration.stat_modifiers.items(): if hasattr(self.squid, stat): current_value = getattr(self.squid, stat) new_value = current_value + modifier # Clamp the value between 0 and 100 new_value = max(0, min(100, new_value)) setattr(self.squid, stat, new_value) effects[stat] = modifier # Apply category-specific effects if strongest_decoration.category == 'plant': old_cleanliness = self.squid.cleanliness self.squid.cleanliness = min(self.squid.cleanliness + 5, 100) effects['cleanliness'] = effects.get('cleanliness', 0) + (self.squid.cleanliness - old_cleanliness) elif strongest_decoration.category == 'rock': old_satisfaction = self.squid.satisfaction self.squid.satisfaction = min(self.squid.satisfaction + 5, 100) effects['satisfaction'] = effects.get('satisfaction', 0) + (self.squid.satisfaction - old_satisfaction) if strongest_decoration and hasattr(strongest_decoration, 'filename') and strongest_decoration.filename is not None: self.squid.memory_manager.add_short_term_memory('decorations', strongest_decoration.filename, effects) else: if effects: self.squid.memory_manager.add_short_term_memory('decorations', 'nearby_decorations', effects) if effects: self.update_decoration_learning(effects) def check_decoration_startle(self, active_decorations): if not self.mental_states_enabled: return if self.startle_cooldown > 0: return # Very low chance of being startled by decorations decoration_startle_chance = 0.001 * len(active_decorations) # 0.1% chance per decoration # Increase chance if anxiety is high if self.squid.anxiety > 70: decoration_startle_chance *= (self.squid.anxiety / 50) if random.random() < decoration_startle_chance: self.startle_squid() self.show_message("Squid was startled by a decoration!") self.brain_window.add_thought("Startled by a decoration!") def show_decoration_message(self, decoration): category = decoration.category messages = { 'plant': [ "Squid seems fascinated by the plants!", "Your squid is enjoying the greenery.", "The plant decoration is making your squid happy." ], 'rock': [ "Your squid is exploring the rocky terrain.", "Squid seems intrigued by the rock formation.", "The rock decoration provides a nice hiding spot for your squid." ] } if category in messages: message = random.choice(messages[category]) else: message = "Squid is interacting with the decoration." self.user_interface.show_message(message) def setup_speed_menu(self): speed_menu = self.user_interface.menu_bar.addMenu('Speed') speed_actions = { "Pause": 0, "1x": 1, "2x": 2, "4x": 4 } for label, speed in speed_actions.items(): action = QtWidgets.QAction(label, self.user_interface.window) action.triggered.connect(lambda checked, s=speed: self.set_simulation_speed(s)) speed_menu.addAction(action) def set_simulation_speed(self, speed): """Set the simulation speed and notify plugins of the change""" # Store current speed before changing (default to 1 if not set) previous_speed = getattr(self, 'simulation_speed', 1) # Apply new speed self.simulation_speed = speed self.update_timers() # Clear any pause messages if unpausing if previous_speed == 0 and speed > 0: # Remove any existing message items if hasattr(self, 'user_interface') and hasattr(self.user_interface, 'scene'): for item in self.user_interface.scene.items(): if isinstance(item, QtWidgets.QGraphicsTextItem): self.user_interface.scene.removeItem(item) # Update dependent systems if hasattr(self, 'squid'): self.squid.set_animation_speed(speed) if hasattr(self, 'brain_window'): self.brain_window.set_pause_state(speed == 0) # Safety check before plugin hook if not hasattr(self, 'plugin_manager'): self.plugin_manager = PluginManager() # Ensure plugin manager exists # Call plugin hook with both speeds self.plugin_manager.trigger_hook( "on_speed_change", tamagotchi_logic=self, old_speed=previous_speed, # Now properly defined new_speed=speed ) print(f"\033[38;5;208;1m >> Simulation speed: {speed}x\033[0m") def setup_timers(self, scene=None, message_callback=None): """Setup all timers including rock interaction timers""" # Initialize core timers self.simulation_timer = QtCore.QTimer() self.simulation_timer.timeout.connect(self.update_simulation) # Set default simulation speed if not hasattr(self, 'simulation_speed'): self.simulation_speed = 1 # Configure initial timer intervals self.update_timers() # Score update timer self.score_update_timer = QtCore.QTimer() self.score_update_timer.timeout.connect(self.update_score) self.score_update_timer.start(5000) # 5 seconds # Brain update timer self.brain_update_timer = QtCore.QTimer() self.brain_update_timer.timeout.connect(self.update_squid_brain) self.brain_update_timer.start(1000) # 1 second # Autosave timer self.autosave_timer = QtCore.QTimer() self.autosave_timer.timeout.connect(self.autosave) # Configure rock interaction timers # ===== PERFORMANCE FIX: Increased interval from 100ms to 200ms ===== if hasattr(self, 'rock_interaction'): self.rock_interaction.setup_timers(interval=200) self.rock_interaction.rock_test_timer.timeout.connect( self.rock_interaction.update_rock_test ) self.rock_interaction.throw_animation_timer.timeout.connect( self.rock_interaction.update_throw_animation ) # Set up poop interaction # ===== PERFORMANCE FIX: Increased interval from 100ms to 200ms ===== if hasattr(self, 'poop_interaction'): self.poop_interaction.setup_timers(interval=200) # Start the simulation timer self.simulation_timer.start() def update_timers(self): """Update timer intervals based on current simulation speed""" if not hasattr(self, 'base_interval'): self.base_interval = 1000 # Ensure base_interval exists if not hasattr(self, 'simulation_speed'): self.simulation_speed = 1 if self.simulation_speed == 0: self.simulation_timer.stop() # Also stop brain-related timers when paused if hasattr(self, 'brain_window') and self.brain_window: if hasattr(self.brain_window, 'hebbian_timer'): self.brain_window.hebbian_timer.stop() if hasattr(self.brain_window, 'countdown_timer'): self.brain_window.countdown_timer.stop() else: interval = max(10, self.base_interval // self.simulation_speed) # Ensure minimum interval self.simulation_timer.start(interval) # Update hebbian learning timer if it exists if hasattr(self, 'brain_window') and self.brain_window: if hasattr(self.brain_window, 'hebbian_timer'): # Get the base learning interval from config base_learning_interval = self.brain_window.config.hebbian.get('learning_interval', 30000) # Scale the interval inversely with simulation speed new_learning_interval = max(1000, int(base_learning_interval / self.simulation_speed)) self.brain_window.hebbian_timer.setInterval(new_learning_interval) # Update the countdown timer's initial value if hasattr(self.brain_window, 'hebbian_countdown_seconds'): self.brain_window.hebbian_countdown_seconds = int(new_learning_interval / 1000) # Restart countdown timer if it was stopped if hasattr(self.brain_window, 'countdown_timer'): if not self.brain_window.countdown_timer.isActive(): self.brain_window.countdown_timer.start(1000) # Update neurogenesis cooldown if the system exists if hasattr(self.brain_window, 'brain_widget') and hasattr(self.brain_window.brain_widget, 'enhanced_neurogenesis'): # The neurogenesis system uses time-based cooldowns, so we need to adjust how time passes # This is handled by the base cooldown time in the config neuro_system = self.brain_window.brain_widget.enhanced_neurogenesis if hasattr(neuro_system, 'config'): # Store the base cooldown if not already stored if not hasattr(neuro_system, 'base_cooldown'): neuro_system.base_cooldown = neuro_system.config.neurogenesis.get('cooldown', 120) # Scale cooldown inversely with simulation speed neuro_system.config.neurogenesis['cooldown'] = max(10, int(neuro_system.base_cooldown / self.simulation_speed)) def stop(self): """Stop all timers and clean up resources""" # Stop Vision Worker if hasattr(self, 'vision_worker') and self.vision_worker: self.vision_worker.stop() self.vision_worker.wait() # Stop main timers if hasattr(self, 'simulation_timer') and self.simulation_timer: self.simulation_timer.stop() if hasattr(self, 'score_update_timer') and self.score_update_timer: self.score_update_timer.stop() if hasattr(self, 'brain_update_timer') and self.brain_update_timer: self.brain_update_timer.stop() if hasattr(self, 'autosave_timer') and self.autosave_timer: self.autosave_timer.stop() if hasattr(self, 'age_update_timer') and self.age_update_timer: self.age_update_timer.stop() if hasattr(self, 'initial_delay_timer') and self.initial_delay_timer: self.initial_delay_timer.stop() # Stop rock interaction timers if hasattr(self, 'rock_interaction') and self.rock_interaction: if hasattr(self.rock_interaction, 'rock_test_timer') and self.rock_interaction.rock_test_timer: self.rock_interaction.rock_test_timer.stop() if hasattr(self.rock_interaction, 'throw_animation_timer') and self.rock_interaction.throw_animation_timer: self.rock_interaction.throw_animation_timer.stop() # Stop poop interaction timers if hasattr(self, 'poop_interaction') and self.poop_interaction: if hasattr(self.poop_interaction, 'poop_timer') and self.poop_interaction.poop_timer: self.poop_interaction.poop_timer.stop() # Stop brain window timers if hasattr(self, 'brain_window') and self.brain_window: if hasattr(self.brain_window, 'hebbian_timer') and self.brain_window.hebbian_timer: self.brain_window.hebbian_timer.stop() if hasattr(self.brain_window, 'countdown_timer') and self.brain_window.countdown_timer: self.brain_window.countdown_timer.stop() def check_for_startle(self): if not self.mental_states_enabled: return # Only check for new startle if not currently startled and initial startle allowed if self.squid.is_fleeing or not self.initial_startle_allowed: return if self.startle_cooldown > 0: self.startle_cooldown -= 1 return # Base chance of being startled startle_chance = 0.002 # low base chance # Increase chance if anxiety is high if self.squid.anxiety > 70: startle_chance *= (self.squid.anxiety / 50) # Up to 2x more likely when anxiety is >70 # Check for startle if random.random() < startle_chance: self.startle_squid() def startle_squid(self, source="unknown"): if not self.mental_states_enabled: return if not getattr(self, 'initial_startle_allowed', False): return try: # --- existing speed init ------------------------------------------------- if not hasattr(self.squid, 'base_speed'): self.squid.base_speed = 90 if not hasattr(self.squid, 'current_speed'): self.squid.current_speed = self.squid.base_speed # --- existing startled state / status block ------------------------------ self.squid.mental_state_manager.set_state("startled", True) self.startle_cooldown = self.startle_cooldown_max previous_status = getattr(self.squid, 'status', "roaming") if source == "first_resize": self.squid.status = "startled by environment change" elif source == "incoming_rock": self.squid.status = "startled by rock" elif source == "detected_squid": self.squid.status = "startled by other squid" elif source == "targeted_by_rock": self.squid.status = "fleeing" elif source in ["environment", "decoration"]: self.squid.status = "startled" else: if self.squid.personality == Personality.TIMID: self.squid.status = "hiding" else: self.squid.status = "startled" self.squid.is_fleeing = True self.statistics_window.award(-50) self.squid.current_speed = 180 self.squid.direction = random.choice(['up', 'down', 'left', 'right']) # ------------------------------------------------------------------ # NEW: 15 % ink-cloud trigger (skip if woken from sleep) # ------------------------------------------------------------------ if source != "startled_awake" and random.random() < 0.25: self.create_ink_cloud() # Track startle in statistics (was only in else block) if hasattr(self.brain_window, 'statistics_tab'): self.brain_window.statistics_tab.increment_stat('startles_experienced') self.squid.status = "fleeing!" self.squid.memory_manager.add_short_term_memory( 'behaviour', 'ink_cloud', 'Startled! Created an ink cloud' ) QtCore.QTimer.singleShot(5000, self.squid.end_ink_flee) if hasattr(self.brain_window, 'statistics_tab'): self.brain_window.statistics_tab.increment_stat('startles_experienced') # --- resilience / anxiety / ink logic -------------------------- stress_neuron_count = 0 if self.brain_window and hasattr(self.brain_window, 'brain_widget'): stress_neuron_count = self.brain_window.brain_widget.get_stress_neuron_count() resilience_factor = math.log(stress_neuron_count + 1) if source == "first_resize": base_anxiety_increase = 5 message = "The squid noticed its environment changing!" base_ink_chance = 0.6 else: base_anxiety_increase = 15 message = "The squid was startled!" base_ink_chance = 0.6 anxiety_increase = max(0, base_anxiety_increase - (resilience_factor * 5)) self.squid.anxiety = min(100, self.squid.anxiety + anxiety_increase) if stress_neuron_count > 0: self.brain_window.add_thought( f"Startled, but felt {resilience_factor:.1f}x more resilient. " f"Anxiety increased by only {anxiety_increase:.1f}." ) # --- existing first-startle / ink / memory / timer ---------------------- is_first_startle = not hasattr(self, '_has_startled_before') if is_first_startle: self._has_startled_before = True ink_chance = 0.9 if self.squid.anxiety > 60 else base_ink_chance produce_ink = is_first_startle or random.random() < ink_chance memory_value = ( f"Startled! Status changed from {previous_status} to {self.squid.status}, " f"Speed {self.squid.current_speed}px, Direction {self.squid.direction}" ) self.squid.memory_manager.add_short_term_memory( 'behavior', 'startle_response', memory_value ) # Fear memory when anxiety is very high if self.squid.anxiety >= 80: self.squid.memory_manager.add_short_term_memory( 'emotion', 'fear', f'Gripped by fear! Anxiety at {int(self.squid.anxiety)}.', importance=6 ) self.show_message(message) if produce_ink: self.create_ink_cloud() QtCore.QTimer.singleShot(self.startle_cooldown_max * 100, lambda: self.end_fleeing(previous_status)) except Exception as e: print(f"Error during startle: {str(e)}") self.show_message("The squid panicked!") def end_fleeing(self, previous_status="roaming"): """Reset speed and status after fleeing ends""" if hasattr(self, 'squid') and self.squid: self.squid.is_fleeing = False self.squid.current_speed = self.squid.base_speed # Set more descriptive status based on anxiety level if self.squid.anxiety > 80: self.squid.status = "extremely anxious" elif self.squid.anxiety > 60: self.squid.status = "anxious" elif self.squid.anxiety > 40: self.squid.status = "nervous" elif self.squid.anxiety > 20: self.squid.status = "recovering from startle" else: # Use a more specific status based on personality if self.squid.personality == Personality.TIMID: self.squid.status = "cautiously exploring" elif self.squid.personality == Personality.ADVENTUROUS: self.squid.status = "boldly exploring" else: self.squid.status = previous_status self.squid.mental_state_manager.set_state("startled", False) # Explicitly clear startled state if self.debug_mode: print(f"Fleeing ended - status returned to {self.squid.status}") self.squid.memory_manager.add_short_term_memory( 'behavior', 'calm_after_startle', f"Returned to {self.squid.status} status after fleeing" ) def create_ink_cloud(self): """Create an ink cloud with guaranteed fade-out after 10 seconds""" ink_cloud_pixmap = QtGui.QPixmap(os.path.join("images", "inkcloud.png")) ink_cloud_item = QtWidgets.QGraphicsPixmapItem(ink_cloud_pixmap) # Set the center of the ink cloud to match the center of the squid squid = self.squid squid_center_x = squid.squid_x + squid.squid_width // 2 squid_center_y = squid.squid_y + squid.squid_height // 2 ink_cloud_item.setPos( squid_center_x - ink_cloud_pixmap.width() // 2, squid_center_y - ink_cloud_pixmap.height() // 2 ) squid.ui.scene.addItem(ink_cloud_item) # Create a QGraphicsOpacityEffect without a parent opacity_effect = QtWidgets.QGraphicsOpacityEffect() opacity_effect.setOpacity(1.0) # Start fully visible ink_cloud_item.setGraphicsEffect(opacity_effect) # Create a QPropertyAnimation for the opacity effect fade_out_animation = QtCore.QPropertyAnimation(opacity_effect, b"opacity") fade_out_animation.setDuration(10000) # 10 seconds duration fade_out_animation.setStartValue(1.0) fade_out_animation.setEndValue(0.0) fade_out_animation.setEasingCurve(QtCore.QEasingCurve.InQuad) # Connect the finished signal to remove the item fade_out_animation.finished.connect(lambda: self.remove_ink_cloud(ink_cloud_item)) # Start the animation and deduct points fade_out_animation.start() self.statistics_window.award(-250) # Update ink clouds counter in squid.statistics (for save/load persistence) if hasattr(self, 'squid') and hasattr(self.squid, 'statistics'): self.squid.statistics.ink_clouds_created = getattr( self.squid.statistics, 'ink_clouds_created', 0 ) + 1 # Update ink clouds in brain statistics tab (for UI display) if hasattr(self.brain_window, 'statistics_tab'): self.brain_window.statistics_tab.increment_stat('ink_clouds_created') # Backup timer to force remove after 10 seconds in case animation fails QtCore.QTimer.singleShot(10000, lambda: self.force_remove_ink_cloud(ink_cloud_item)) def force_remove_ink_cloud(self, ink_cloud_item): """Force remove the ink cloud if it still exists after timeout""" if ink_cloud_item in self.squid.ui.scene.items(): self.squid.ui.scene.removeItem(ink_cloud_item) def remove_ink_cloud(self, ink_cloud_item): """Remove the ink cloud from the scene""" if ink_cloud_item in self.ui.scene.items(): self.ui.scene.removeItem(ink_cloud_item) if ink_cloud_item.graphicsEffect(): ink_cloud_item.graphicsEffect().deleteLater() ink_cloud_item.setGraphicsEffect(None) def remove_ink_cloud_safety(self): """Safety method to ensure ink cloud is removed""" if hasattr(self, '_current_ink_cloud') and self._current_ink_cloud: if self._current_ink_cloud in self.user_interface.scene.items(): self.user_interface.scene.removeItem(self._current_ink_cloud) self._current_ink_cloud = None if hasattr(self, '_ink_cloud_timer') and self._ink_cloud_timer: self._ink_cloud_timer.stop() def end_startle(self): if self.mental_states_enabled: self.squid.mental_state_manager.set_state("startled", False) # Add thoughts self.brain_window.add_thought("No longer startled") def track_food_consumed(self, food_item): """Track when food is consumed""" if hasattr(self.brain_window, 'statistics_tab'): if getattr(food_item, 'is_sushi', False): self.brain_window.statistics_tab.increment_stat('sushi_eaten') else: self.brain_window.statistics_tab.increment_stat('cheese_eaten') def track_poop_created(self): """Track when poop is created""" if hasattr(self.brain_window, 'statistics_tab'): self.brain_window.statistics_tab.increment_stat('poops_created') # Update max poops if needed current_poops = len(self.poop_items) if current_poops > self.brain_window.statistics_tab.statistics['max_poops_cleaned']: self.brain_window.statistics_tab.statistics['max_poops_cleaned'] = current_poops self.brain_window.statistics_tab.update_display() def track_rock_thrown(self): """Track when rock is thrown""" if hasattr(self.brain_window, 'statistics_tab'): self.brain_window.statistics_tab.increment_stat('rocks_thrown') def track_plant_interaction(self): """Track when squid interacts with plants""" if hasattr(self.brain_window, 'statistics_tab'): self.brain_window.statistics_tab.increment_stat('plants_interacted') def track_startle(self): """Track when squid is startled""" if hasattr(self.brain_window, 'statistics_tab'): self.brain_window.statistics_tab.increment_stat('startles_experienced') ######################################### UPDATE_SIMULATION METHOD BELOW def update_simulation(self): """ Main simulation update loop. """ if _PERF_TRACKING_AVAILABLE: _sim_start = time.perf_counter() perf_tracker.increment("simulation_ticks") # 1. Pre-update hook self.plugin_manager.trigger_hook("pre_update", tamagotchi_logic=self, squid=self.squid) # 2. Paused? if self.simulation_speed == 0: return # Check for decorations message self._check_decorations_message() # 3. World updates self.move_objects() self.animate_poops() self.update_statistics() self.check_poop_interaction() # Vision Worker if self.squid and hasattr(self, 'vision_worker') and self.vision_worker.isRunning(): vision_state = create_squid_vision_state(self.squid) self.vision_worker.update_squid_state(vision_state) decorations = self._get_cached_decorations() scene_objects = extract_scene_objects(self.user_interface.scene, self.food_items, decorations) self.vision_worker.update_scene_objects(scene_objects) if self.squid: # === FORCE SLEEP WHEN SLEEPINESS == 100 === if self.squid.sleepiness >= 100.0: if not getattr(self.squid, 'is_sleeping', False): self.squid.is_sleeping = True self.squid.status = "sleeping" if hasattr(self.squid, 'current_action'): self.squid.current_action = "sleep" self.user_interface.show_message("💤 The squid is completely exhausted and fell asleep!") print("💤 FORCED SLEEP: sleepiness hit 100%") self.sleep_frame = 0 # === SLEEP ANIMATION (swaps between sleep1.png and sleep2.png) === if getattr(self.squid, 'is_sleeping', False): self.sleep_frame = (self.sleep_frame + 1) % 2 try: if self.sleep_frame == 0: pix = QPixmap("images/sleep1.png") else: pix = QPixmap("images/sleep2.png") if not pix.isNull() and hasattr(self.squid, 'squid_item'): self.squid.squid_item.setPixmap(pix) except Exception as e: print(f"Sleep animation error: {e}") # CONFIGURABLE RECOVERY (from original) recovery_rate = 28.0 * (1 / 60.0) # ~28 points per second at 60 FPS self.squid.sleepiness = max(0.0, self.squid.sleepiness - recovery_rate) # Nice side bonuses while sleeping (from original) self.squid.happiness = min(100.0, self.squid.happiness + 0.45 * (1 / 60.0)) self.squid.satisfaction = min(100.0, self.squid.satisfaction + 0.30 * (1 / 60.0)) # Auto-wake when sufficiently rested (from original) if self.squid.sleepiness <= 25.0: self.squid.is_sleeping = False self.squid.status = "roaming" self.user_interface.show_message("😴 The squid woke up feeling refreshed!") self.sleep_frame = 0 # ── STUBBORN PERSONALITY: RESIST SLEEP WHEN OTHER NEEDS ARE STRONGER ── (from original) elif self.squid.personality == Personality.STUBBORN: hunger_urge = self.squid.hunger / 100.0 curiosity_urge = self.squid.curiosity / 100.0 anxiety_urge = self.squid.anxiety / 100.0 # If any competing need is > 55% and stronger than current sleepiness, # stubborn squid will refuse to fall asleep (even if sleepiness is high) if max(hunger_urge, curiosity_urge * 0.8, anxiety_urge * 0.6) > 0.55: if self.squid.sleepiness > 65 and random.random() < 0.22: # 22% chance per frame to resist self.user_interface.show_message("😤 Stubborn squid refuses to sleep right now!") # Tiny happiness penalty for fighting sleep self.squid.happiness = max(15.0, self.squid.happiness - 1.8) # Normal behavior only when NOT sleeping if not getattr(self.squid, 'is_sleeping', False): self.squid.move_squid() self.check_for_decoration_attraction() if self.mental_states_enabled: self.check_for_startle() self.check_for_curiosity() # Rest of the original code (neurogenesis, brain state, etc.) self.track_neurogenesis_triggers() if self.squid.is_sleeping: self.squid.memory_manager.review_and_transfer_memories() else: self.squid.memory_manager.periodic_memory_management() # brain_state building is_startled_state = False if hasattr(self.squid, 'mental_state_manager') and self.squid.mental_state_manager: is_startled_state = self.squid.mental_state_manager.is_state_active('startled') elif hasattr(self.squid, 'status'): is_startled_state = "startled" in str(self.squid.status).lower() brain_state = { "hunger": self.squid.hunger, "happiness": self.squid.happiness, "cleanliness": self.squid.cleanliness, "sleepiness": self.squid.sleepiness, "satisfaction": self.squid.satisfaction, "anxiety": self.squid.anxiety, "curiosity": self.squid.curiosity, "is_sick": self.squid.is_sick, "is_sleeping": self.squid.is_sleeping, "is_eating": getattr(self.squid, 'is_eating', False) or (self.squid.status == "eating"), "pursuing_food": self.squid.pursuing_food, "is_fleeing": getattr(self.squid, 'is_fleeing', False), "is_startled": is_startled_state, "direction": self.squid.squid_direction, "position": (self.squid.squid_x, self.squid.squid_y), "personality": self.squid.personality.value if isinstance(self.squid.personality, Personality) else str(self.squid.personality), "status": self.squid.status, "novelty_exposure": self.neurogenesis_triggers['novel_objects'], "sustained_stress": self.neurogenesis_triggers['high_stress_cycles'] / 10.0, "recent_rewards": self.neurogenesis_triggers['positive_outcomes'], 'recent_actions': self.recent_actions[-10:], 'food_count': len(self.food_items), 'poop_count': len(self.poop_items), } if hasattr(self, 'brain_hooks'): input_values = self.brain_hooks.get_input_neuron_values() brain_state.update(input_values) self.brain_window.update_brain(brain_state) if (hasattr(self.brain_window, 'brain_widget') and hasattr(self.brain_window.brain_widget, 'enhanced_neurogenesis')): neuro = self.brain_window.brain_widget.enhanced_neurogenesis current_status = getattr(self.squid, 'status', 'roaming') action_to_track = self._normalize_action_name(current_status) neuro.track_action(action_to_track) neuro.track_state_change(brain_state) environment = { 'food_count': len(self.food_items), 'poop_count': len(self.poop_items), 'is_sick': self.squid.is_sick, 'is_eating': brain_state.get('is_eating', False), 'has_rock': len(getattr(self, 'rock_items', [])) > 0, 'new_object_encountered': self.new_object_encountered, 'recent_positive_outcome': self.recent_positive_outcome } neuro.check_and_capture_experience(brain_state, environment) if not getattr(self.squid, 'is_sleeping', False): if hasattr(self, 'neuron_output_monitor'): self.neuron_output_monitor.process_outputs() self.new_object_encountered = False self.recent_positive_outcome = False if self.squid.hunger <= 1 or self.squid.hunger >= 99: self.statistics_window.update_score() # 15. Decay environmental stimulus trackers if hasattr(self, 'brain_hooks'): self.brain_hooks.update_decay() # 16. Post-update hook self.plugin_manager.trigger_hook("post_update", tamagotchi_logic=self, squid=self.squid) if _PERF_TRACKING_AVAILABLE: _sim_elapsed = (time.perf_counter() - _sim_start) * 1000 perf_tracker.record("simulation_tick", _sim_elapsed) def _get_cached_decorations(self): """Get decorations with caching to avoid scanning scene every tick.""" current_time = time.time() if current_time - self._decoration_cache_time > self._decoration_cache_interval: self._decoration_cache = [] for item in self.user_interface.scene.items(): if isinstance(item, ResizablePixmapItem) and hasattr(item, 'category'): # Cache both the item and its center point center = item.sceneBoundingRect().center() self._decoration_cache.append((item, center.x(), center.y())) self._decoration_cache_time = current_time return self._decoration_cache def apply_input_neurons_to_brain(self): """ Apply real-time sensor values to the brain's input neurons. Without this, can_see_food, plant_proximity, external_stimulus, etc. will NEVER update. """ if not self.brain_window or not self.brain_window.brain_widget: return brain_widget = self.brain_window.brain_widget # Get fresh input neuron values (this calls calculate_can_see_food(), etc.) input_values = self.brain_hooks.get_input_neuron_values() # Override specific neurons with VisionWorker results if available # This prioritizes the async worker result over the synchronous check if self.latest_vision_result: # Overwrite the hook's calculation with the worker's result input_values['can_see_food'] = 100.0 if self.latest_vision_result.can_see_food else 0.0 # Use worker for plant proximity if available if hasattr(self.latest_vision_result, 'plant_proximity_value'): input_values['plant_proximity'] = self.latest_vision_result.plant_proximity_value if not input_values: return # Directly inject into the brain state # This overrides any propagation — input neurons are driven by the world, not the network for neuron_name, activation in input_values.items(): # 1. Update visual/display state (what the user sees) if neuron_name in brain_widget.state: brain_widget.state[neuron_name] = activation # 2. CRITICAL FIX: Ensure internal neuron dict is also updated. # If we don't do this, the local simulation logic might use an old value. if hasattr(brain_widget, 'neurons') and neuron_name in brain_widget.neurons: brain_widget.neurons[neuron_name]['activation'] = activation # Optional: also update any cached activations if used in older logic/plugins if hasattr(brain_widget, 'neuron_activations') and neuron_name in brain_widget.neuron_activations: brain_widget.neuron_activations[neuron_name] = activation # CRITICAL ADDITION: Push these new values to the worker thread immediately. # Without this, the background worker has "stale" data (e.g., can_see_food=0) # while the UI shows 100. This ensures Hebbian learning uses the correct values. if hasattr(brain_widget, '_update_worker_cache'): brain_widget._update_worker_cache() # Force a repaint to show immediate change brain_widget.update() def _check_decorations_message(self): """Check if we should show the decorations window reminder message""" current_time = time.time() # Only check if we haven't shown the max times and enough time has passed if (self.decorations_message_count < self.decorations_message_max and current_time >= self.next_decorations_message_check): # Show the message self.show_message("Press D to open the Decorations window") self.decorations_message_count += 1 self.last_decorations_message_time = current_time # Schedule next check for 2-5 minutes from now self.next_decorations_message_check = current_time + random.randint( self.decorations_message_cooldown, self.decorations_message_check_interval ) def update_squid_brain(self): if not self.squid or not self.brain_window.isVisible(): return # Robust startled detection is_startled_state = False if hasattr(self.squid, 'mental_state_manager') and self.squid.mental_state_manager: is_startled_state = self.squid.mental_state_manager.is_state_active('startled') elif hasattr(self.squid, 'status') and isinstance(self.squid.status, str): is_startled_state = "startled" in self.squid.status.lower() brain_state = { "hunger": self.squid.hunger, "happiness": self.squid.happiness, "cleanliness": self.squid.cleanliness, "sleepiness": self.squid.sleepiness, "anxiety": self.squid.anxiety, "curiosity": self.squid.curiosity, "satisfaction": self.squid.satisfaction, "is_sick": self.squid.is_sick, "is_eating": self.squid.is_eating if hasattr(self.squid, 'is_eating') else (self.squid.status == "eating"), "is_sleeping": self.squid.is_sleeping, "pursuing_food": self.squid.pursuing_food, "is_fleeing": getattr(self.squid, 'is_fleeing', False), "is_startled": is_startled_state, "direction": self.squid.squid_direction, "position": (self.squid.squid_x, self.squid.squid_y), "personality": self.squid.personality.value if isinstance(self.squid.personality, Personality) else str(self.squid.personality), "status": self.squid.status, 'recent_actions': self.recent_actions[-10:], 'food_count': len(self.food_items), 'poop_count': len(self.poop_items), } # === DYNAMIC PERCEPTION === if hasattr(self, 'brain_hooks'): input_values = self.brain_hooks.get_input_neuron_values() # Override with VisionWorker results if self.latest_vision_result: input_values['can_see_food'] = 100.0 if self.latest_vision_result.can_see_food else 0.0 brain_state.update(input_values) # --- Force-sync inputs to bw.neurons to prevent Sync Loop overwrite if hasattr(self.brain_window, 'brain_widget'): bw = self.brain_window.brain_widget if hasattr(bw, 'neurons') and bw.neurons: for k, v in input_values.items(): if k in bw.neurons: # Update the internal simulation prop directly bw.neurons[k]['activation'] = v # Decay temporal sensors so they don't stick forever self.brain_hooks.update_decay() # Allow plugins to inject or modify brain state self.plugin_manager.trigger_hook( "on_brain_state_update", brain_state=brain_state, squid=self.squid, logic=self ) # Final push to brain visualizer self.brain_window.update_brain(brain_state) # === PROCESS NEURON OUTPUTS (ACTUATORS) === # Also process here in case update_squid_brain runs on different timer if hasattr(self, 'neuron_output_monitor') and self.neuron_output_monitor: self.neuron_output_monitor.process_outputs() def _normalize_action_name(self, status: str) -> str: """ Normalize squid status string to a standard action name for tracking. Args: status: Raw status string from squid Returns: Normalized action name """ if not status: return 'idle' status_lower = status.lower() # Map various status strings to standard actions if 'eat' in status_lower: return 'eating' elif 'sleep' in status_lower: return 'sleeping' elif 'flee' in status_lower or 'escap' in status_lower: return 'fleeing' elif 'startle' in status_lower: return 'startled' elif 'curious' in status_lower or 'investigat' in status_lower: return 'curious' elif 'rock' in status_lower: if 'throw' in status_lower: return 'throwing_rock' elif 'carry' in status_lower: return 'carrying_rock' elif 'approach' in status_lower: return 'approaching_rock' return 'rock_interaction' elif 'plant' in status_lower: return 'near_plant' elif 'sick' in status_lower or 'ill' in status_lower: return 'sick' elif 'roam' in status_lower or 'explor' in status_lower: return 'roaming' elif 'idle' in status_lower or 'rest' in status_lower: return 'idle' else: return 'roaming' # Default def check_for_curiosity(self): if self.curious_cooldown > 0: self.curious_cooldown -= 1 return # Base chance of becoming curious curious_chance = 0.02 # 2% base chance # Increase chance if satisfaction is high and anxiety is low if self.squid.satisfaction > 70 and self.squid.anxiety < 30: curious_chance *= 2 # Double the chance # Check for curiosity if random.random() < curious_chance: self.make_squid_curious() def track_neuron_creation(self, neuron_type): if hasattr(self.brain_window, 'statistics_tab'): if neuron_type == 'novelty': self.brain_window.statistics_tab.increment_stat('novelty_neurons_created') elif neuron_type == 'stress': self.brain_window.statistics_tab.increment_stat('stress_neurons_created') elif neuron_type == 'reward': self.brain_window.statistics_tab.increment_stat('reward_neurons_created') current = self.brain_window.statistics_tab.statistics['current_neurons'] self.brain_window.statistics_tab.statistics['current_neurons'] = current + 1 self.brain_window.statistics_tab.update_display() self.statistics_window.award(500) def track_neuron_counts(self, current_count, max_count): """Track current and maximum neuron counts""" if hasattr(self.brain_window, 'statistics_tab'): self.brain_window.statistics_tab.update_current_neurons(current_count) self.brain_window.statistics_tab.track_max_neurons(max_count) def track_neurogenesis_triggers(self): """Update counters for neurogenesis triggers""" # Novelty tracking if self.new_object_encountered: self.neurogenesis_triggers['novel_objects'] = min( self.neurogenesis_triggers['novel_objects'] + 1, 10 # Max cap ) # Add thought about novelty self.add_thought("Encountered something new!") else: # Gradual decay when no novelty self.neurogenesis_triggers['novel_objects'] *= 0.95 # Stress tracking if self.squid.anxiety > 70: self.neurogenesis_triggers['high_stress_cycles'] += 1 # Add thought about stress if threshold crossed if self.neurogenesis_triggers['high_stress_cycles'] > 5: self.add_thought("Feeling stressed for a while...") else: self.neurogenesis_triggers['high_stress_cycles'] = max( 0, self.neurogenesis_triggers['high_stress_cycles'] - 0.5 ) # Reward tracking (positive outcomes like eating, playing) if self.recent_positive_outcome: self.neurogenesis_triggers['positive_outcomes'] = min( self.neurogenesis_triggers['positive_outcomes'] + 1, 5 # Max cap ) # Add thought about positive experience if random.random() < 0.3: # 30% chance to comment self.add_thought("That was enjoyable!") else: # Gradual decay when no rewards self.neurogenesis_triggers['positive_outcomes'] = max( 0, self.neurogenesis_triggers['positive_outcomes'] - 0.2 ) # Debug output if in debug mode if self.debug_mode: print(f"Neurogenesis triggers: {self.neurogenesis_triggers}") def _normalize_action_name(self, status_string): """ Convert verbose status strings into trackable action categories. This prevents explosion of unique but semantically similar actions. """ if not status_string: return 'idle' status_lower = status_string.lower() # Food-related behaviors if 'eating' in status_lower: return 'eating' elif 'eyeing food' in status_lower or 'approaching food' in status_lower: return 'pursuing_food' elif 'moving toward food' in status_lower: return 'pursuing_food' # Rock interactions elif 'rock' in status_lower: if 'throwing' in status_lower or 'thrown' in status_lower: return 'throwing_rock' elif 'approaching' in status_lower or 'eyeing' in status_lower: return 'investigating_rock' elif 'carrying' in status_lower: return 'carrying_rock' # Poop interactions elif 'poop' in status_lower: if 'throwing' in status_lower: return 'throwing_poop' elif 'approaching' in status_lower: return 'investigating_poop' # Plant interactions elif 'plant' in status_lower: if 'hiding' in status_lower: return 'hiding' elif 'comfort' in status_lower or 'approaching' in status_lower or 'seeking' in status_lower: return 'seeking_plant' elif 'noticing' in status_lower: return 'noticing_plant' # Emotional/mental states elif 'startled' in status_lower or 'fleeing' in status_lower: return 'fleeing' elif 'anxious' in status_lower or 'nervous' in status_lower: return 'anxious' elif 'sleeping' in status_lower or 'drowsy' in status_lower or 'feeling drowsy' in status_lower: return 'sleeping' elif 'sick' in status_lower or 'ill' in status_lower or 'suffering' in status_lower: return 'sick' elif 'distressed' in status_lower: return 'distressed' # Exploration behaviors elif 'exploring' in status_lower: return 'exploring' elif 'roaming' in status_lower or 'wandering' in status_lower or 'patrolling' in status_lower: return 'roaming' elif 'zooming' in status_lower or 'buzzing' in status_lower: return 'energetic_movement' elif 'resting' in status_lower or 'lounging' in status_lower: return 'resting' elif 'watching' in status_lower: return 'watching' elif 'seeking adventure' in status_lower: return 'seeking_adventure' elif 'searching' in status_lower or 'scouting' in status_lower: return 'searching' # Default fallback else: return 'idle' def make_squid_curious(self): self.squid.mental_state_manager.set_state("curious", True) self.curious_cooldown = self.curious_cooldown_max # Use the new add_thought method self.add_thought("Experiencing extreme curiosity") # Increase curiosity self.squid.curiosity = min(100, self.squid.curiosity + 20) # Memory self.squid.memory_manager.add_short_term_memory( 'emotion', 'intense_curiosity', 'Overwhelmed by intense curiosity!', importance=4 ) # Schedule the end of the curious state QtCore.QTimer.singleShot(5000, self.end_curious) # End curious after 5 seconds # Start curious interactions self.curious_interaction_timer = QtCore.QTimer() self.curious_interaction_timer.timeout.connect(self.curious_interaction) self.curious_interaction_timer.start(1000) # Check for interactions every second def end_curious(self): if self.mental_states_enabled: self.squid.mental_state_manager.set_state("curious", False) if hasattr(self, 'curious_interaction_timer'): self.curious_interaction_timer.stop() def curious_interaction(self): if self.curious_interaction_cooldown > 0: self.curious_interaction_cooldown -= 1 return if random.random() < 0.6: # Increased chance to 60% for more frequent interactions decorations = self.user_interface.get_nearby_decorations(self.squid.squid_x, self.squid.squid_y) if decorations: decoration = random.choice(decorations) if random.random() < 0.75: # 75% chance to push decorations direction = random.choice([-1, 1]) # -1 for left, 1 for right self.squid.push_decoration(decoration, direction) else: self.brain_window.add_thought("I am curious about a decoration item...") self.curious_interaction_cooldown = self.curious_interaction_cooldown_max def update_curiosity(self): # Update curiosity based on satisfaction and anxiety if self.squid.satisfaction > 70 and self.squid.anxiety < 30: curiosity_change = 0.2 * self.simulation_speed else: curiosity_change = -0.1 * self.simulation_speed # Adjust curiosity change based on personality if self.squid.personality == Personality.TIMID: curiosity_change *= 0.5 # Timid squids are less curious elif self.squid.personality == Personality.ADVENTUROUS: curiosity_change *= 1.5 # Adventurous squids are more curious self.squid.curiosity += curiosity_change self.squid.curiosity = max(0, min(100, self.squid.curiosity)) # Check if the squid should enter the curious state if self.squid.curiosity > 80 and self.mental_states_enabled: self.check_for_curiosity() def move_objects(self): self.move_foods() self.move_poops() def move_squid_to_bottom_left(self, callback): # Force the squid to move to bottom left (buggy) target_x = 150 # Left edge + margin target_y = self.user_interface.window_height - 150 - self.squid.squid_height # Bottom edge - margin - squid height # Disable Squid's ability to move in any other direction - doesn't work 100% - he puts up a fight sometimes!! self.squid.can_move = False def step_movement(): dx = target_x - self.squid.squid_x dy = target_y - self.squid.squid_y if abs(dx) < 100 and abs(dy) < 100: # If close enough, snap to final position and call callback self.squid.squid_x = target_x self.squid.squid_y = target_y self.squid.squid_item.setPos(self.squid.squid_x, self.squid.squid_y) self.squid.can_move = True # Re-enable Squid's movement callback() else: # Determine direction of movement if abs(dx) > abs(dy): # Move horizontally self.squid.squid_x += 90 if dx > 0 else -90 else: # Move vertically self.squid.squid_y += 90 if dy > 0 else -90 # Update squid position self.squid.squid_item.setPos(self.squid.squid_x, self.squid.squid_y) # Schedule next movement in 1000 ms QtCore.QTimer.singleShot(900, step_movement) # Start the movement step_movement() def start_rps_game(self): self.rps_game = RPSGame(self) self.rps_game.start_game() def give_medicine(self): # Get plugin results results = self.plugin_manager.trigger_hook("on_medicine", tamagotchi_logic=self, squid=self.squid) # Check if any plugin returned False to prevent default behavior if False in results: return if (self.squid is not None and (self.squid.is_sick or self.squid.mental_state_manager.is_state_active('sick'))): print("Debug: Applying medicine effects") self.squid.is_sick = False self.squid.mental_state_manager.set_state("sick", False) self.squid.happiness = max(0, self.squid.happiness - 30) self.squid.sleepiness = min(100, self.squid.sleepiness + 50) self.statistics_window.award(-100) self.show_message("Medicine given. Squid didn't like that!") # Add thoughts and set status if hasattr(self.brain_window, 'add_thought'): self.brain_window.add_thought("I am grumpy and anxious because I was forced to take medicine") self.squid.status = "taking medicine" # Hide the sick icon immediately self.squid.hide_sick_icon() # Put Squid to sleep QtCore.QTimer.singleShot(5000, lambda: self.delayed_sleep_after_medicine()) # Display the needle image self.display_needle_image() else: self.show_message("Squid is not sick. Medicine not needed.") def delayed_sleep_after_medicine(self): """Put squid to sleep after a delay from taking medicine""" if self.squid: self.squid.go_to_sleep() self.squid.status = "recovering" def display_needle_image(self): needle_pixmap = QtGui.QPixmap(os.path.join("images", "needle.jpg")) self.needle_item = QtWidgets.QGraphicsPixmapItem(needle_pixmap) self.needle_item.setPos(self.user_interface.window_width // 2 - needle_pixmap.width() // 2, self.user_interface.window_height // 2 - needle_pixmap.height() // 2) self.needle_item.setZValue(10) # Ensure the needle image is displayed on top of everything self.user_interface.scene.addItem(self.needle_item) # Create a QGraphicsOpacityEffect opacity_effect = QtWidgets.QGraphicsOpacityEffect() self.needle_item.setGraphicsEffect(opacity_effect) # Create a QPropertyAnimation for the opacity effect self.needle_animation = QtCore.QPropertyAnimation(opacity_effect, b"opacity") self.needle_animation.setDuration(1000) # 1 second duration self.needle_animation.setStartValue(1.0) self.needle_animation.setEndValue(0.0) self.needle_animation.setEasingCurve(QtCore.QEasingCurve.InQuad) # Connect the finished signal to remove the item self.needle_animation.finished.connect(self.remove_needle_image) # Start the animation self.needle_animation.start() def remove_needle_image(self): if self.needle_item is not None: self.user_interface.scene.removeItem(self.needle_item) self.needle_item = None def move_foods(self): for food_item in self.food_items[:]: if getattr(food_item, 'is_sushi', False): self.move_sushi(food_item) else: self.move_cheese(food_item) def get_food_item_at(self, x, y): for food_item in self.food_items: if food_item.pos().x() == x and food_item.pos().y() == y: return food_item return None def move_cheese(self, cheese_item): cheese_x = cheese_item.pos().x() cheese_y = cheese_item.pos().y() + (self.base_food_speed * self.simulation_speed) if cheese_y > self.user_interface.window_height - 120 - self.food_height: cheese_y = self.user_interface.window_height - 120 - self.food_height cheese_item.setPos(cheese_x, cheese_y) # Directly check collision without redundant checks if self.squid and cheese_item.collidesWithItem(self.squid.squid_item): self.squid.eat(cheese_item) # Reset status after a short delay QtCore.QTimer.singleShot(2000, self.reset_squid_status) def move_sushi(self, sushi_item): sushi_x = sushi_item.pos().x() sushi_y = sushi_item.pos().y() + (self.base_food_speed * self.simulation_speed) if sushi_y > self.user_interface.window_height - 120 - self.food_height: sushi_y = self.user_interface.window_height - 120 - self.food_height sushi_item.setPos(sushi_x, sushi_y) if self.squid is not None and sushi_item.collidesWithItem(self.squid.squid_item): self.squid.eat(sushi_item) # Pass the sushi_item as an argument self.remove_food(sushi_item) # Reset status after a short delay (matching cheese behavior) QtCore.QTimer.singleShot(2000, self.reset_squid_status) def is_sushi(self, food_item): return getattr(food_item, 'is_sushi', False) def remove_food(self, food_item): if food_item in self.food_items: self.user_interface.scene.removeItem(food_item) self.food_items.remove(food_item) # Notify vision worker of scene change if hasattr(self.squid, 'mark_scene_objects_dirty'): self.squid.mark_scene_objects_dirty() def move_poops(self): for poop_item in self.poop_items[:]: poop_x = poop_item.pos().x() poop_y = poop_item.pos().y() + (self.base_food_speed * self.simulation_speed) if poop_y > self.user_interface.window_height - 120 - self.squid.poop_height: poop_y = self.user_interface.window_height - 120 - self.squid.poop_height poop_item.setPos(poop_x, poop_y) def update_statistics(self): if self.squid is None: return # ------- GUARD --------------------------------------------------------- if hasattr(self, 'statistics_window') and self.statistics_window is not None: self.statistics_window.update_statistics() # ----------------------------------------------------------------------- self.update_cleanliness_overlay() if self.squid is not None: # Update squid needs if not self.squid.is_sleeping: self.squid.hunger = min(100, self.squid.hunger + (0.1 * self.simulation_speed)) self.squid.sleepiness = min(100, self.squid.sleepiness + (0.12 * self.simulation_speed)) self.squid.happiness = max(0, self.squid.happiness - (0.1 * self.simulation_speed)) self.squid.cleanliness = max(0, self.squid.cleanliness - (0.1 * self.simulation_speed)) # Update new neurons self.update_satisfaction() self.update_anxiety() self.update_curiosity() # Check for special status effects on anxiety reduction. if self.squid.status == "hiding behind plant": previous_anxiety = self.squid.anxiety self.squid.anxiety = max(0, self.squid.anxiety - (0.5 * self.simulation_speed)) if self.squid.anxiety < previous_anxiety: memory_value = "Being near plants is calming (Anxiety reduction)" self.squid.memory_manager.add_short_term_memory( 'environment', 'plant_calming_effect', memory_value, importance=1.5 ) self.plant_calming_effect_counter += 1 if self.plant_calming_effect_counter >= 5: self.squid.memory_manager.transfer_to_long_term_memory( 'environment', 'plant_calming_effect' ) self.plant_calming_effect_counter = 0 # Check if cleanliness has been too low for too long if self.squid.cleanliness < 20: self.cleanliness_threshold_time += 1 else: self.cleanliness_threshold_time = 0 # Check if hunger has been too high for too long if self.squid.hunger > 80: self.hunger_threshold_time += 1 else: self.hunger_threshold_time = 0 # Check if squid becomes sick (80 % chance) if ((self.cleanliness_threshold_time >= 10 * self.simulation_speed and self.cleanliness_threshold_time <= 60 * self.simulation_speed) or (self.hunger_threshold_time >= 10 * self.simulation_speed and self.hunger_threshold_time <= 50 * self.simulation_speed)): if random.random() < 0.8: self.squid.mental_state_manager.set_state("sick", True) else: self.squid.mental_state_manager.set_state("sick", False) # New logic for health decrease based on happiness and cleanliness if self.squid.happiness < 20 and self.squid.cleanliness < 20: health_decrease = 0.2 * self.simulation_speed # Rapid decrease else: health_decrease = 0.1 * self.simulation_speed def handle_window_resize(self, event): new_width = event.size().width() new_height = event.size().height() # Get current dimensions from user interface current_width = self.user_interface.window_width current_height = self.user_interface.window_height # Calculate size change width_change = new_width - current_width height_change = new_height - current_height # Create new_size tuple for brain hooks new_size = (new_width, new_height) # ADD THIS LINE # Update window dimensions in UI self.user_interface.window_width = new_width self.user_interface.window_height = new_height # Update UI elements through user interface self.user_interface.handle_window_resize(event) # Notify logic about resize with size change info self.brain_hooks.on_window_resize(width_change, height_change, new_size) self.handle_window_resize_event( width_change, height_change, new_size ) def allow_initial_startle(self): """Allow the squid to be startled after the initial delay.""" self.initial_startle_allowed = True self.is_first_instance = False # Reset the flag after the first instance #print("Initial startle protection period ended") def handle_window_resize_event(self, width_change, height_change, new_size): """Handle window resize events with specific effects""" # First resize startles the squid (only once and after the initial delay) if not self.has_been_resized and self.initial_startle_allowed: self.startle_squid(source="first_resize") self.has_been_resized = True self.add_thought("positive: My environment got bigger!") self.last_window_size = new_size return # Only startle for MAJOR size changes (increased threshold) if (abs(width_change) > 200 or abs(height_change) > 200) and random.random() < 0.3: # Added randomness # self.startle_squid(source="major_resize") # STARTLE WHEN WINDOW IS RESIZED return # Check if window got bigger if new_size[0] > self.last_window_size[0] or new_size[1] > self.last_window_size[1]: # Positive effect for enlargement self.squid.happiness = min(100, self.squid.happiness + 5) self.squid.satisfaction = min(100, self.squid.satisfaction + 3) self.was_big = True memory_msg = "My environment got bigger!" self.squid.memory_manager.add_short_term_memory( 'environment', 'window_enlarged', memory_msg ) self.add_thought("More space to swim!") # Check if window got smaller after being big elif self.was_big and (new_size[0] < self.last_window_size[0] or new_size[1] < self.last_window_size[1]): # Negative effect for reduction self.squid.happiness = max(0, self.squid.happiness - 5) self.squid.anxiety = min(100, self.squid.anxiety + 5) memory_msg = "negative: decreased happiness and increased anxiety from less space" self.squid.memory_manager.add_short_term_memory( 'environment', 'window_reduced', memory_msg ) self.add_thought("The space is shrinking...") # Update last known size self.last_window_size = new_size def track_action(self, action_name): """Track what the squid is doing""" self.recent_actions.append(action_name) if len(self.recent_actions) > 10: self.recent_actions.pop(0) def feed_squid(self): """Modified to track action for neurogenesis""" # Track for neurogenesis FIRST if hasattr(self.brain_window, 'brain_widget') and \ hasattr(self.brain_window.brain_widget, 'enhanced_neurogenesis'): self.brain_window.brain_widget.enhanced_neurogenesis.track_action('user_feeding') self.track_action('feeding') # Keep original tracking self.brain_hooks.on_user_interaction('feed') # Get plugin results results = self.plugin_manager.trigger_hook("on_feed", tamagotchi_logic=self, squid=self.squid) # Check if any plugin returned False to prevent default behavior if False in results: return # Continue with original behavior if len(self.food_items) >= self.max_food: return # Only create one food item is_sushi = random.random() < 0.5 self.spawn_food(is_sushi=is_sushi) if self.squid.hunger < 50: # Feeding was successful self.brain_window.brain_widget.provide_outcome_feedback(1.0) def spawn_food(self, is_sushi=False): if len(self.food_items) >= self.max_food: return # Create only one food item if is_sushi: food_pixmap = QtGui.QPixmap(os.path.join("images", "sushi.png")) food_item = QtWidgets.QGraphicsPixmapItem(food_pixmap) food_item.is_sushi = True else: food_pixmap = QtGui.QPixmap(os.path.join("images", "cheese.png")) food_item = QtWidgets.QGraphicsPixmapItem(food_pixmap) food_item.is_sushi = False food_x = random.randint(50, self.user_interface.window_width - 50 - self.food_width) food_item.setPos(food_x, 50) # Add to scene and tracking list self.user_interface.scene.addItem(food_item) self.food_items.append(food_item) # Single addition self.brain_hooks.on_object_spawned('food') # Notify vision worker of scene change if hasattr(self.squid, 'mark_scene_objects_dirty'): self.squid.mark_scene_objects_dirty() def clean_environment(self): # Track for neurogenesis FIRST if hasattr(self.brain_window, 'brain_widget') and \ hasattr(self.brain_window.brain_widget, 'enhanced_neurogenesis'): self.brain_window.brain_widget.enhanced_neurogenesis.track_action('user_cleaning') self.brain_hooks.on_user_interaction('clean') self.track_action('cleaning') current_time = time.time() if current_time - self.last_clean_time < self.clean_cooldown: remaining_cooldown = int(self.clean_cooldown - (current_time - self.last_clean_time)) self.show_message(f"Cleaning is on cooldown. Please wait {remaining_cooldown} seconds.") return # Get plugin results results = self.plugin_manager.trigger_hook("on_clean", tamagotchi_logic=self, squid=self.squid) # Check if any plugin returned False to prevent default behavior if False in results: return self.last_clean_time = current_time # Create a cleaning line that extends beyond the window vertically self.cleaning_line = QtWidgets.QGraphicsLineItem(self.user_interface.window_width, -500, self.user_interface.window_width, self.user_interface.window_height + 500) self.cleaning_line.setPen(QtGui.QPen(QtGui.QColor(0, 0, 0), 15)) # Thick black line self.user_interface.scene.addItem(self.cleaning_line) # Show a message that cleaning has started self.show_message("Cleaning in progress...") # Set up animation parameters self.cleaning_progress = 0 self.movement_rate = 200 # Movement rate in pixels per second self.cleaning_timer = QtCore.QTimer() self.cleaning_timer.timeout.connect(self.update_cleaning) self.cleaning_timer.start(500) # Update every 1000 ms (1 second) def update_cleaning(self): self.cleaning_progress += self.movement_rate # Increment progress by movement rate each second if self.cleaning_progress >= self.user_interface.window_width: self.cleaning_timer.stop() self.finish_cleaning() return new_x = self.user_interface.window_width - self.cleaning_progress self.cleaning_line.setLine(new_x, -500, new_x, self.user_interface.window_height + 500) items_removed = False # Remove poops and food that the line has passed for poop_item in self.poop_items[:]: if poop_item.scenePos().x() > new_x: self.user_interface.scene.removeItem(poop_item) self.poop_items.remove(poop_item) items_removed = True for food_item in self.food_items[:]: if food_item.pos().x() > new_x: self.remove_food(food_item) # This calls mark_scene_objects_dirty internally items_removed = True # Force an update of the scene self.user_interface.scene.update() # Explicitly notify if we removed poop (food handled by remove_food) if items_removed and hasattr(self.squid, 'mark_scene_objects_dirty'): self.squid.mark_scene_objects_dirty() def finish_cleaning(self): # 1. remove the cleaning line (existing) self.user_interface.scene.removeItem(self.cleaning_line) # 2. count poops actually removed & award poops_removed = 0 for poop_item in self.poop_items[:]: if not poop_item.scene(): # already erased by the swipe poops_removed += 1 if poops_removed: self.statistics_window.award(25 * poops_removed) # 25 per poop # 3. rest unchanged … if self.squid: self.squid.cleanliness = 100 self.squid.happiness = min(100, self.squid.happiness + 20) self.squid.memory_manager.add_short_term_memory( 'cleanliness', 'washed_clean', 'Washed clean! Feeling fresh.', importance=3 ) # Clear all DIRTY text immediately when cleaned self.user_interface.clear_dirty_text() self.show_message("Environment cleaned! Squid is happier!") self.user_interface.scene.update() def show_message(self, message): # Call hook if available if hasattr(self, 'plugin_manager'): # Get modified message from plugins results = self.plugin_manager.trigger_hook( "on_message_display", tamagotchi_logic=self, original_message=message ) # Check if any plugin modified the message for result in results: if isinstance(result, str) and result: message = result break # Use the user_interface's scene instead of self.scene if hasattr(self, 'user_interface') and hasattr(self.user_interface, 'scene'): scene = self.user_interface.scene # Remove any existing message items for item in scene.items(): if isinstance(item, QtWidgets.QGraphicsTextItem): scene.removeItem(item) # Create a new QGraphicsTextItem for the message message_item = QtWidgets.QGraphicsTextItem(message) message_item.setDefaultTextColor(QtGui.QColor(255, 255, 255)) # White text message_item.setFont(QtGui.QFont("Arial", 10, QtGui.QFont.Bold)) message_item.setPos(0, self.user_interface.window_height - 75) # Position the message higher message_item.setTextWidth(self.user_interface.window_width) message_item.setHtml(f'
    {message}
    ') message_item.setZValue(10) # Ensure the message is on top message_item.setOpacity(1) # Add the new message item to the scene scene.addItem(message_item) # Fade out the message after 8 seconds fade_out_animation = QtCore.QPropertyAnimation(message_item, b"opacity") fade_out_animation.setDuration(8000) fade_out_animation.setStartValue(1.0) fade_out_animation.setEndValue(0.0) fade_out_animation.finished.connect(lambda: scene.removeItem(message_item)) fade_out_animation.start(QtCore.QAbstractAnimation.DeleteWhenStopped) def show_thought(self, text): """Display a squid 'thought' in yellow.""" self.user_interface.show_message(f'{text}') def update_score(self): if self.squid is not None: if not self.squid.is_sick and self.squid.happiness >= 80 and self.squid.cleanliness >= 80: self.points += 1 elif self.squid.is_sick or self.squid.hunger >= 80 or self.squid.happiness <= 20: self.points -= 1 self.user_interface.update_points(self.points) def toggle_debug_mode(self): """Toggle debug mode across all components without circular references""" # Set the new state directly (don't toggle twice) new_debug_mode = not self.debug_mode self.debug_mode = new_debug_mode # Propagate to statistics window if hasattr(self, 'statistics_window') and self.statistics_window: self.statistics_window.set_debug_mode(new_debug_mode) # Propagate to brain window, WITHOUT triggering a callback if hasattr(self, 'brain_window') and self.brain_window: # Set a flag to indicate we're in the middle of propagating self._propagating_debug_mode = True # Set brain window's debug mode self.brain_window.set_debug_mode(new_debug_mode) # Set brain widget's debug mode directly if hasattr(self.brain_window, 'brain_widget'): self.brain_window.brain_widget.debug_mode = new_debug_mode # Clear the propagation flag self._propagating_debug_mode = False # User interface components if hasattr(self, 'user_interface'): if hasattr(self.user_interface, 'debug_mode'): self.user_interface.debug_mode = new_debug_mode print(f"Debug mode {'enabled' if new_debug_mode else 'disabled'}") def update_cleanliness_overlay(self): if self.squid is not None: cleanliness = self.squid.cleanliness # Use the new DIRTY text system instead of brown overlay self.user_interface.update_dirty_text(cleanliness) # Keep the overlay transparent (no more brown color) self.user_interface.cleanliness_overlay.setBrush(QtGui.QBrush(QtGui.QColor(0, 0, 0, 0))) def spawn_sushi(self): if len(self.food_items) < self.max_food: sushi_pixmap = QtGui.QPixmap(os.path.join("images", "sushi.png")) sushi_pixmap = sushi_pixmap.scaled(self.food_width, self.food_height) sushi_item = QtWidgets.QGraphicsPixmapItem(sushi_pixmap) sushi_x = random.randint(50, self.user_interface.window_width - 50 - self.food_width) sushi_y = 50 # Start at the top of the screen sushi_item.setPos(sushi_x, sushi_y) self.user_interface.scene.addItem(sushi_item) sushi_item = QtWidgets.QGraphicsPixmapItem(sushi_pixmap) sushi_item.is_sushi = True self.food_items.append(sushi_item) # Notify vision worker of scene change if hasattr(self.squid, 'mark_scene_objects_dirty'): self.squid.mark_scene_objects_dirty() def spawn_poop(self, x, y): if len(self.poop_items) < self.max_poop and self.squid is not None: poop_item = ResizablePixmapItem(self.squid.poop_images[0], category='poop') poop_item.setPos(x - self.squid.poop_width // 2, y) self.user_interface.scene.addItem(poop_item) self.poop_items.append(poop_item) self.brain_hooks.on_object_spawned('poop') # Notify vision worker of scene change if hasattr(self.squid, 'mark_scene_objects_dirty'): self.squid.mark_scene_objects_dirty() def animate_poops(self): if self.squid is not None: for poop_item in self.poop_items: current_frame = self.poop_items.index(poop_item) % 2 poop_item.setPixmap(self.squid.poop_images[current_frame]) def game_over(self): game_over_dialog = QtWidgets.QMessageBox() game_over_dialog.setIcon(QtWidgets.QMessageBox.Critical) game_over_dialog.setText("Game Over") game_over_dialog.setInformativeText("Your squid has died due to poor health.") game_over_dialog.setWindowTitle("Game Over") game_over_dialog.exec_() self.user_interface.window.close() # Add any additional game over logic or cleanup here print("Game Over - Squid died due to poor health") # Save the game state before resetting self.save_game() # Reset the game state self.reset_game() def reset_game(self): # Reset squid attributes self.squid.hunger = 25 self.squid.sleepiness = 30 self.squid.happiness = 100 self.squid.cleanliness = 100 self.squid.is_sleeping = False self.squid.health = 100 self.squid.is_sick = False # Reset new neurons self.squid.satisfaction = 50 self.squid.anxiety = 10 self.squid.curiosity = 70 # Reset game variables self.cleanliness_threshold_time = 0 self.hunger_threshold_time = 0 self.last_clean_time = 0 # Clear food and poop items for food_item in self.food_items: self.user_interface.scene.removeItem(food_item) self.food_items.clear() for poop_item in self.poop_items: self.user_interface.scene.removeItem(poop_item) self.poop_items.clear() # Clear all DIRTY text on reset self.user_interface.clear_dirty_text() # Reset squid position self.squid.squid_x = self.squid.center_x self.squid.squid_y = self.squid.center_y self.squid.squid_item.setPos(self.squid.squid_x, self.squid.squid_y) # Notify vision worker of scene change (cleared objects) if hasattr(self.squid, 'mark_scene_objects_dirty'): self.squid.mark_scene_objects_dirty() # Show a message self.show_message("Game reset. Take better care of your squid!") # Force an update of the scene self.user_interface.scene.update() # Save the game state after resetting self.save_game() def load_game(self) -> bool: # Use save_manager latest = self.save_manager.get_latest_save() if not latest: print("[Load] No save file found") return False try: data = {} squid_uuid = None with zipfile.ZipFile(latest, 'r') as zf: for fname in zf.namelist(): if fname == "uuid.txt": raw = zf.read(fname).decode('utf-8').strip() if "SquidSignature" in raw: squid_uuid = raw.split("SquidSignature")[-1].strip() else: squid_uuid = raw continue if fname.endswith('.json'): with zf.open(fname) as f: key = os.path.splitext(fname)[0] json_data = f.read().decode('utf-8') if json_data.strip(): data[key] = json.loads(json_data) # === RESTORE UUID INTO LIVE SQUID === if squid_uuid and self.squid: try: import uuid restored_uuid = uuid.UUID(squid_uuid) old_uuid = self.squid.uuid self.squid.uuid = restored_uuid print(f"[Load] Restored Squid UUID: {restored_uuid} (was {old_uuid})") except ValueError as e: print(f"[Load] Invalid UUID format in save: {squid_uuid} → {e}") # === Load game state === game_state = data.get('game_state', {}) if not game_state: print("[Load] No game_state found") return False squid_data = game_state.get('squid', {}) if squid_data: for attr, value in squid_data.items(): if hasattr(self.squid, attr): setattr(self.squid, attr, value) # Restore personality if 'personality' in squid_data: from .squid import Personality try: self.squid.personality = Personality(squid_data['personality']) except ValueError: pass # Load custom brain (Logic patched to load output bindings) custom_brain_data = data.get('custom_brain', {}) custom_brain_loaded = False if custom_brain_data and custom_brain_data.get('is_custom_brain'): success, msg = restore_custom_brain_from_save(data, self.brain_window.brain_widget) print(f"[Load] Custom brain: {msg}") # Manually load bindings from custom brain definition --- if success and hasattr(self, 'neuron_output_monitor'): brain_def = custom_brain_data.get('brain_definition', {}) if brain_def: self.neuron_output_monitor.load_bindings_from_brain(brain_def) custom_brain_loaded = True print(f" + Synced custom output bindings to monitor") # Load brain state (Standard) brain_state = data.get('brain_state', {}) if brain_state and hasattr(self, 'brain_window'): self.brain_window.set_brain_state(brain_state) # Manually load bindings if not using custom brain # This handles standard saves where bindings are stored in 'brain_state' if not custom_brain_loaded and hasattr(self, 'neuron_output_monitor'): self.neuron_output_monitor.load_bindings_from_brain(brain_state) print(f" + Synced standard output bindings to monitor") # Load memories self.squid.memory_manager.short_term_memory = data.get('ShortTerm', []) self.squid.memory_manager.long_term_memory = data.get('LongTerm', []) # Load decorations decorations_data = game_state.get('decorations', []) self.user_interface.load_decorations_data(decorations_data) # Load logic state logic_data = game_state.get('tamagotchi_logic', {}) self.cleanliness_threshold_time = logic_data.get('cleanliness_threshold_time', 0) self.hunger_threshold_time = logic_data.get('hunger_threshold_time', 0) self.last_clean_time = logic_data.get('last_clean_time', 0) self.points = logic_data.get('points', 0) # Restore achievements if available if 'achievements' in data: self._restore_achievements_data(data['achievements']) if hasattr(self, 'statistics_window'): self.statistics_window.set_score(self.points) self.statistics_window.update_statistics() print(f"[Load] Successfully loaded: {os.path.basename(latest)}") return True except Exception as e: print(f"[Load] Failed to load game: {e}") import traceback traceback.print_exc() return False def save_game(self, is_autosave=False): """ Save the complete game state including decorations. Args: is_autosave: Whether this is an autosave (True) or manual save (False) """ # Import hashlib for duplicate detection import hashlib import json import uuid import time # Collect squid state squid_data = { 'hunger': self.squid.hunger, 'sleepiness': self.squid.sleepiness, 'happiness': self.squid.happiness, 'cleanliness': self.squid.cleanliness, 'health': self.squid.health, 'is_sick': self.squid.is_sick, 'squid_x': self.squid.squid_x, 'squid_y': self.squid.squid_y, 'satisfaction': self.squid.satisfaction, 'anxiety': self.squid.anxiety, 'curiosity': self.squid.curiosity, 'personality': self.squid.personality.value, 'uuid': str(self.squid.uuid), # Convert UUID to string 'name': getattr(self.squid, 'name', 'Squid'), # Save squid name if it exists } # Collect statistics as separate file # Primary source: brain_window.statistics_tab (where gameplay updates go) # Fallback: squid.statistics (legacy) tab_stats = {} if hasattr(self, 'brain_window') and hasattr(self.brain_window, 'statistics_tab'): tab_stats = getattr(self.brain_window.statistics_tab, 'statistics', {}) squid_stats = self.squid.statistics # Save using exact keys from StatisticsTab statistics_data = { # Age tracking 'total_age_seconds': squid_stats.get_total_age_seconds(), 'squid_age_minutes': tab_stats.get('squid_age_minutes', 0), # Movement 'distance_swam': tab_stats.get('distance_swam', 0), # Food consumption 'cheese_eaten': tab_stats.get('cheese_eaten', 0), 'sushi_eaten': tab_stats.get('sushi_eaten', 0), # Poop tracking 'poops_created': tab_stats.get('poops_created', 0), 'max_poops_cleaned': tab_stats.get('max_poops_cleaned', 0), # Interactions 'startles_experienced': tab_stats.get('startles_experienced', 0), 'ink_clouds_created': tab_stats.get('ink_clouds_created', 0), 'times_colour_changed': tab_stats.get('times_colour_changed', 0), 'rocks_thrown': tab_stats.get('rocks_thrown', 0), 'plants_interacted': tab_stats.get('plants_interacted', 0), # Health/Sleep 'total_sleep_time': tab_stats.get('total_sleep_time', 0), 'sickness_episodes': tab_stats.get('sickness_episodes', 0), # Neurogenesis 'novelty_neurons_created': tab_stats.get('novelty_neurons_created', 0), 'stress_neurons_created': tab_stats.get('stress_neurons_created', 0), 'reward_neurons_created': tab_stats.get('reward_neurons_created', 0), 'max_neurons_reached': tab_stats.get('max_neurons_reached', 0), 'current_neurons': tab_stats.get('current_neurons', 7), } # Collect tamagotchi logic state tamagotchi_logic_data = { 'cleanliness_threshold_time': self.cleanliness_threshold_time, 'hunger_threshold_time': self.hunger_threshold_time, 'last_clean_time': self.last_clean_time, 'points': self.statistics_window.score } # Collect brain state brain_state = self.brain_window.get_brain_state() # Collect memory state short_term_memory = self.squid.memory_manager.short_term_memory long_term_memory = self.squid.memory_manager.long_term_memory # Collect decoration data from UI decorations_data = self.user_interface.get_decorations_data() # Collect achievement data from Achievements plugin (if enabled) achievements_data = None try: if hasattr(self, 'plugin_manager') and 'achievements' in self.plugin_manager.plugins: plugin_info = self.plugin_manager.plugins['achievements'] if 'instance' in plugin_info: instance = plugin_info['instance'] if hasattr(instance, 'get_save_data'): achievements_data = instance.get_save_data() except Exception as e: # If plugin method fails, log warning and continue without achievements print(f"[Warning] Could not collect achievement data: {e}") achievements_data = None # Assemble complete save data save_data = { 'game_state': { 'squid': squid_data, 'tamagotchi_logic': tamagotchi_logic_data, 'decorations': decorations_data }, 'brain_state': brain_state, 'statistics': statistics_data, 'ShortTerm': short_term_memory, 'LongTerm': long_term_memory, 'custom_brain': get_custom_brain_save_data() } # Add achievements data if available (will be saved as achievements.json in the ZIP) if achievements_data is not None: save_data['achievements'] = achievements_data # === DUPLICATE DETECTION LOGIC === # Create hash of save data to detect duplicates import time # Add timestamp for duplicate detection (not saved to file) save_data_for_hash = save_data.copy() # Remove any volatile fields that shouldn't affect hash save_data_for_hash.pop('achievements', None) # Create a custom JSON encoder that handles UUID objects class CustomJSONEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, uuid.UUID): return str(obj) # Let the base class default method raise the TypeError return json.JSONEncoder.default(self, obj) # Create hash of the save content save_hash = hashlib.md5( json.dumps(save_data_for_hash, sort_keys=True, cls=CustomJSONEncoder).encode() ).hexdigest() # Check if this is identical to the last save if hasattr(self, 'last_save_hash') and save_hash == self.last_save_hash: # Only skip for autosaves, allow manual saves even if identical if is_autosave: print(f" Autosave skipped: identical to previous save") return False else: print(f" Manual save detected as identical to previous save, but saving anyway") # Update last save hash self.last_save_hash = save_hash # Save using SaveManager filepath = self.save_manager.save_game(save_data, is_autosave) if filepath: save_type = "autosaved" if is_autosave else "saved" print(f"Game {save_type} successfully to {filepath}") # Update save count if tracking if not hasattr(self, 'save_count'): self.save_count = 0 self.save_count += 1 # Track autosave count separately if is_autosave: if not hasattr(self, 'autosave_count'): self.autosave_count = 0 self.autosave_count += 1 print(f" Autosave #{self.autosave_count}") return True else: print(f"Failed to save game") return False def _restore_achievements_data(self, achievements_data): """Restore achievement data to the achievements plugin after loading save.""" if not achievements_data: return try: if 'achievements' in self.plugin_manager.plugins: plugin_info = self.plugin_manager.plugins['achievements'] instance = plugin_info.get('instance') if instance and hasattr(instance, 'load_save_data'): instance.load_save_data(achievements_data) unlocked_count = len(achievements_data.get('unlocked', {})) print(f"✓ Restored {unlocked_count} achievements") except Exception as e: print(f"[Warning] Could not restore achievements: {e}") def _apply_save_data(self, save_data): """Apply loaded save data to game state""" if not save_data: return False # Check custom brain warning if not show_custom_brain_load_warning(self.user_interface, save_data): return False try: # Extract and apply game state game_state = save_data['game_state'] squid_data = game_state['squid'] self.squid.load_state(squid_data) # Load custom brain custom_brain_data = save_data.get('custom_brain') if custom_brain_data and custom_brain_data.get('is_custom_brain'): success, msg = restore_custom_brain_from_save(save_data, self.brain_window.brain_widget) print(f"✅ {msg}" if success else f"⚠️ {msg}") # Load brain state brain_state = save_data.get('brain_state', {}) self.brain_window.set_brain_state(brain_state) # Load memories self.squid.memory_manager.short_term_memory = save_data.get('ShortTerm', []) self.squid.memory_manager.long_term_memory = save_data.get('LongTerm', []) # Sync neurogenesis neuron counts with actual brain state if hasattr(self.brain_window, 'brain_widget') and hasattr(self.brain_window.brain_widget, 'enhanced_neurogenesis'): eng = self.brain_window.brain_widget.enhanced_neurogenesis if hasattr(eng, 'functional_neurons'): # Count neurons by specialization type novelty_count = 0 stress_count = 0 reward_count = 0 for neuron in eng.functional_neurons.values(): if hasattr(neuron, 'specialization'): if neuron.specialization == 'novelty': novelty_count += 1 elif neuron.specialization == 'stress': stress_count += 1 elif neuron.specialization == 'reward': reward_count += 1 # Update statistics tab with ground truth values if hasattr(self.brain_window, 'statistics_tab') and hasattr(self.brain_window.statistics_tab, 'statistics'): self.brain_window.statistics_tab.statistics['novelty_neurons_created'] = novelty_count self.brain_window.statistics_tab.statistics['stress_neurons_created'] = stress_count self.brain_window.statistics_tab.statistics['reward_neurons_created'] = reward_count self.brain_window.statistics_tab.update_display() print(f"✓ Synced neuron counts: {novelty_count} novelty, {stress_count} stress, {reward_count} reward") # Also sync with squid's internal statistics for consistency if hasattr(self, 'squid') and hasattr(self.squid, 'statistics'): self.squid.statistics.novelty_neurons_created = novelty_count self.squid.statistics.stress_neurons_created = stress_count self.squid.statistics.reward_neurons_created = reward_count # Load decorations decorations_data = game_state.get('decorations', []) self.user_interface.load_decorations_data(decorations_data) # Notify vision worker of scene change (decorations loaded) if hasattr(self.squid, 'mark_scene_objects_dirty'): self.squid.mark_scene_objects_dirty() # Load game state logic_data = game_state['tamagotchi_logic'] self.cleanliness_threshold_time = logic_data['cleanliness_threshold_time'] self.hunger_threshold_time = logic_data['hunger_threshold_time'] self.last_clean_time = logic_data['last_clean_time'] self.points = logic_data['points'] # UPDATE SCORE DISPLAY if hasattr(self, 'statistics_window'): self.statistics_window.set_score(self.points) print(f"✅ Loaded {len(decorations_data)} decorations") # UPDATE STATISTICS DISPLAY if hasattr(self, 'statistics_window'): self.statistics_window.update_statistics() self.set_simulation_speed(1) return True except Exception as e: print(f"❌ Error loading game: {e}") import traceback traceback.print_exc() return False def _get_pixmap_data(self, item): """Get pixmap data from a QGraphicsPixmapItem for serialization""" if hasattr(item, 'pixmap'): pixmap = item.pixmap() if not pixmap.isNull(): # Convert pixmap to base64 encoded PNG data byte_array = QtCore.QByteArray() buffer = QtCore.QBuffer(byte_array) buffer.open(QtCore.QIODevice.WriteOnly) pixmap.save(buffer, "PNG") return byte_array.toBase64().data().decode('utf-8') return None def start_autosave(self): self.autosave_timer.start(300000) # 300000 ms = 5 minutes def autosave(self): print(" Autosaving...") self.save_game(is_autosave=True) # New methods to update the new neurons def update_satisfaction(self): # Update satisfaction based on hunger, happiness, and cleanliness hunger_factor = max(0, 1 - self.squid.hunger / 100) happiness_factor = self.squid.happiness / 100 cleanliness_factor = self.squid.cleanliness / 100 satisfaction_change = (hunger_factor + happiness_factor + cleanliness_factor) / 3 satisfaction_change = (satisfaction_change - 0.5) * 2 # Scale to range from -1 to 1 self.squid.satisfaction += satisfaction_change * self.simulation_speed self.squid.satisfaction = max(0, min(100, self.squid.satisfaction)) def update_anxiety(self): # Update anxiety based on hunger, cleanliness, and health hunger_factor = self.squid.hunger / 100 cleanliness_factor = 1 - self.squid.cleanliness / 100 health_factor = 1 - self.squid.health / 100 if self.squid.personality == Personality.GREEDY: hunger_factor *= 1.5 # Greedy squids get more anxious when hungry anxiety_change = (hunger_factor + cleanliness_factor + health_factor) / 3 if self.squid.personality == Personality.TIMID and self.squid.is_near_plant(): anxiety_change *= 0.5 # Timid squids are less anxious near plants self.squid.anxiety += anxiety_change * self.simulation_speed self.squid.anxiety = max(0, min(100, self.squid.anxiety)) def update_curiosity(self): # Update curiosity based on satisfaction and anxiety if self.squid.satisfaction > 70 and self.squid.anxiety < 30: curiosity_change = 0.2 * self.simulation_speed else: curiosity_change = -0.1 * self.simulation_speed # Adjust curiosity change based on personality if self.squid.personality == Personality.TIMID: curiosity_change *= 0.5 # Timid squids are less curious elif self.squid.personality == Personality.ADVENTUROUS: curiosity_change *= 1.5 # Adventurous squids are more curious self.squid.curiosity += curiosity_change self.squid.curiosity = max(0, min(100, self.squid.curiosity)) def trigger_rock_test(self): """Trigger rock test from UI using the interaction manager""" if not hasattr(self, 'rock_interaction'): self.show_message("Rock interaction system not initialized!") return # Find all valid rocks in the scene using the interaction manager's checker rocks = [item for item in self.user_interface.scene.items() if isinstance(item, ResizablePixmapItem) and self.rock_interaction.is_valid_rock(item)] if not rocks: self.show_message("No rocks found in the tank!") return if not hasattr(self, 'squid'): self.show_message("Squid not initialized!") return # Find nearest rock to squid nearest_rock = min(rocks, key=lambda r: math.hypot( r.sceneBoundingRect().center().x() - self.squid.squid_x, r.sceneBoundingRect().center().y() - self.squid.squid_y ) ) # Highlight the rock (visual feedback) self.highlight_rock(nearest_rock) # Start the test through the interaction manager self.rock_interaction.start_rock_test(nearest_rock) # Show status message self.show_message("Rock test initiated") def is_valid_rock(self, item): """Check if an item is a valid rock decoration""" if not isinstance(item, ResizablePixmapItem): return False # Check if it's a rock based on filename or category if hasattr(item, 'category') and item.category == 'rock': return True if hasattr(item, 'filename') and 'rock' in item.filename.lower(): return True return False def update_rock_interaction(self): """Unified method used for both test and autonomous interactions""" if not hasattr(self.squid, 'current_rock_target') or not self.squid.current_rock_target: return rock = self.squid.current_rock_target rock_rect = rock.sceneBoundingRect() squid_rect = self.squid.squid_item.sceneBoundingRect() # Calculate precise distance between edges dx = rock_rect.center().x() - squid_rect.center().x() dy = rock_rect.center().y() - squid_rect.center().y() distance = math.hypot(dx, dy) if self.squid.status == "approaching_rock": if distance < 40: # Close enough to pick up if self.squid.pick_up_rock(rock): self.squid.status = "carrying_rock" self.squid.rock_carry_timer = random.randint(30, 50) # 3-5 seconds else: self.squid.current_rock_target = None else: # Move toward rock at normal speed self.squid.move_toward_position(rock_rect.center()) elif self.squid.status == "carrying_rock": self.squid.rock_carry_timer -= 1 if self.squid.rock_carry_timer <= 0: direction = "left" if random.random() < 0.5 else "right" if self.squid.throw_rock(direction): self.squid.status = "roaming" self.squid.current_rock_target = None def update_rock_test(self): """Delegate to interaction manager""" if hasattr(self, 'rock_interaction'): self.rock_interaction.update_rock_test() def update_status_bar(self): """Update the status bar with the current plugin state""" if hasattr(self.user_interface, 'status_bar'): self.user_interface.status_bar.update_plugins_status(self.plugin_manager) # Check for multiplayer plugin specifically if 'MultiplayerPlugin' in self.plugin_manager.enabled_plugins: # Try to get the plugin instance for plugin_name, plugin_data in self.plugin_manager.plugins.items(): if plugin_name == 'MultiplayerPlugin' and 'instance' in plugin_data: plugin = plugin_data['instance'] # Update network status if plugin has a network node if hasattr(plugin, 'network_node') and plugin.network_node: self.user_interface.status_bar.update_network_status( plugin.network_node.is_connected, plugin.network_node.node_id ) # Update peers count if hasattr(plugin, 'network_node') and plugin.network_node: peers_count = len(plugin.network_node.known_nodes) self.user_interface.status_bar.update_peers_count(peers_count) def setup_poop_interaction(self): """Initialize poop interaction manager""" from .interactions2 import PoopInteractionManager # Check if config manager has poop config if not hasattr(self.config_manager, 'get_poop_config'): # Create a default poop config if not available def get_poop_config(): return { 'min_carry_duration': 3.0, 'max_carry_duration': 9.0, 'pickup_prob': 0.2, 'throw_prob': 0.3, # Optional additional config parameters 'happiness_penalty': 5, 'anxiety_increase': 10 } self.config_manager.get_poop_config = get_poop_config # Initialize poop interaction self.poop_interaction = PoopInteractionManager( squid=self.squid, logic=self, scene=self.user_interface.scene, message_callback=self.show_message, config_manager=self.config_manager ) def check_poop_interaction(self): """Unified method for poop interaction checks""" if not hasattr(self, 'poop_interaction'): self.setup_poop_interaction() if not hasattr(self.squid, 'current_poop_target'): return poop = self.squid.current_poop_target poop_rect = poop.sceneBoundingRect() squid_rect = self.squid.squid_item.sceneBoundingRect() # Calculate precise distance between edges dx = poop_rect.center().x() - squid_rect.center().x() dy = poop_rect.center().y() - squid_rect.center().y() distance = math.hypot(dx, dy) if self.squid.status == "approaching_poop": if distance < 40: # Close enough to pick up if self.squid.pick_up_poop(poop): self.squid.status = "carrying_poop" self.squid.poop_carry_timer = random.randint(30, 50) # 3-5 seconds else: self.squid.current_poop_target = None else: # Move toward poop at normal speed self.squid.move_toward_position(poop_rect.center()) elif self.squid.status == "carrying_poop": self.squid.poop_carry_timer -= 1 if self.squid.poop_carry_timer <= 0: direction = "left" if random.random() < 0.5 else "right" if self.squid.throw_poop(direction): self.squid.status = "roaming" self.squid.current_poop_target = None ================================================ FILE: src/task_manager.py ================================================ from PyQt5.QtCore import Qt import time import threading from collections import deque from PyQt5.QtCore import QTimer, QMutex, QMutexLocker from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QLabel, QGroupBox, QTableWidget, QTableWidgetItem, QHeaderView) class TaskManagerWindow(QWidget): """Simple at-a-glance thread and timer monitor.""" def __init__(self, brain_worker, parent=None): super().__init__(parent, flags=Qt.Window) self.setWindowTitle("Task Monitor") self.resize(500, 300) self._brain_worker_ref = brain_worker self._parent_ref = parent # Simple UI layout = QVBoxLayout(self) # Thread status (simple table) thread_group = QGroupBox("Threads") thread_layout = QVBoxLayout(thread_group) self.thread_table = QTableWidget(2, 3) self.thread_table.setHorizontalHeaderLabels(["Thread", "Status", "Queue"]) self.thread_table.horizontalHeader().setSectionResizeMode(QHeaderView.Stretch) self.thread_table.setEditTriggers(QTableWidget.NoEditTriggers) thread_layout.addWidget(self.thread_table) layout.addWidget(thread_group) # Timers (simple list) timer_group = QGroupBox("Active Timers") timer_layout = QVBoxLayout(timer_group) self.timer_label = QLabel("No timers tracked") self.timer_label.setWordWrap(True) timer_layout.addWidget(self.timer_label) layout.addWidget(timer_group) # Simple refresh timer self.refresh_timer = QTimer(self) self.refresh_timer.timeout.connect(self._refresh_display) self.refresh_timer.start(1000) # Update every second @property def brain_worker(self): """Get worker from parent if available (fallback for safety)""" if self._parent_ref and hasattr(self._parent_ref, 'brain_worker'): return self._parent_ref.brain_worker return self._brain_worker_ref def update_worker_reference(self, new_worker): """Update the worker reference when it's restarted""" self._brain_worker_ref = new_worker print(f"TaskManager: Updated worker reference to {id(new_worker)}") def _refresh_display(self): """Refresh the display with current status.""" # Update threads self._update_threads() # Update timers (if parent has timer info) self._update_timers() def _update_threads(self): """Update thread table using comprehensive health status""" # Main thread main_alive = threading.main_thread().is_alive() # Worker thread worker = self.brain_worker worker_alive = False queue_size = 0 health_info = {} if worker and hasattr(worker, 'isRunning'): worker_alive = worker.isRunning() # Use worker's built-in health status if available if hasattr(worker, 'get_health_status'): health_info = worker.get_health_status() queue_size = health_info.get('queue_size', '?') # Override isRunning with more comprehensive check worker_alive = health_info.get('is_healthy', worker_alive) # Update table threads = [ ("Main Thread", "✅ Running" if main_alive else "❌ Stopped", "-"), ("BrainWorker", "✅ Healthy" if worker_alive else "❌ Dead/Unresponsive", str(queue_size)) ] for row, (name, status, queue) in enumerate(threads): self.thread_table.setItem(row, 0, QTableWidgetItem(name)) self.thread_table.setItem(row, 1, QTableWidgetItem(status)) self.thread_table.setItem(row, 2, QTableWidgetItem(queue)) def _update_timers(self): """Check for active timers in parent.""" timers = [] parent = self._parent_ref if parent and hasattr(parent, 'brain_window'): bw = parent.brain_window timer_attrs = ['hebbian_timer', 'countdown_timer', 'update_timer', 'neurogenesis_timer'] for attr in timer_attrs: if hasattr(bw, attr): timer = getattr(bw, attr) if timer and timer.isActive(): timers.append(attr.replace('_timer', '')) if timers: self.timer_label.setText(f"Active: {', '.join(timers)}") else: self.timer_label.setText("No active timers") def closeEvent(self, event): """Clean up timer on close.""" self.refresh_timer.stop() super().closeEvent(event) ================================================ FILE: src/tutorial.py ================================================ # tutorial.py from PyQt5 import QtCore, QtGui, QtWidgets import logging import random from .localisation import Localisation class TutorialManager: """Manages tutorial overlays and sequences""" def __init__(self, ui_reference, main_window): self.ui = ui_reference self.main_window = main_window self.tutorial_elements = [] self.tutorial_timer = None self.current_step = 0 self.loc = Localisation.instance() logging.debug("TutorialManager initialized with ui_reference and main_window") def get_tutorial_font_sizes(self, base_title_size=12, base_body_size=11): """Determine font sizes for tutorial text, increasing by 2 for ~1920x1080 resolution""" from .display_scaling import DisplayScaling screen_width = QtWidgets.QApplication.primaryScreen().size().width() if 1800 <= screen_width <= 2000: title_font_size = DisplayScaling.font_size(base_title_size + 2) body_font_size = DisplayScaling.font_size(base_body_size + 2) else: title_font_size = DisplayScaling.font_size(base_title_size) body_font_size = DisplayScaling.font_size(base_body_size) return title_font_size, body_font_size def start_tutorial(self): logging.debug("Starting tutorial") try: if not hasattr(self, 'ui') or not self.ui: logging.error("UI reference missing, cannot start tutorial") return if not hasattr(self.ui, 'scene') or not self.ui.scene: logging.error("Scene not initialized, cannot start tutorial") return if (hasattr(self.ui, 'squid_brain_window') and self.ui.squid_brain_window and hasattr(self.ui.squid_brain_window, 'brain_widget') and self.ui.squid_brain_window.brain_widget): self.ui.squid_brain_window.brain_widget.is_tutorial_mode = True logging.debug("Tutorial mode enabled in brain widget") self.current_step = 0 self.show_first_tutorial() except Exception as e: logging.error(f"Error starting tutorial: {str(e)}") self.end_tutorial() def show_first_tutorial(self): """Show the initial tutorial about basic squid care""" self.clear_tutorial_elements() win_width = self.ui.window_width win_height = self.ui.window_height banner_height = 170 banner_y_offset = 40 banner_y = win_height - banner_height - banner_y_offset - 100 banner = QtWidgets.QGraphicsRectItem(0, banner_y, win_width, banner_height) banner.setBrush(QtGui.QColor(0, 0, 0, 230)) banner.setPen(QtGui.QPen(QtGui.QColor(255, 255, 255, 150), 1)) banner.setZValue(2000) setattr(banner, '_is_tutorial_element', True) self.ui.scene.addItem(banner) self.tutorial_elements.append(banner) title_font_size, body_font_size = self.get_tutorial_font_sizes(12, 11) title_text = QtWidgets.QGraphicsTextItem("⚠️") title_text.setDefaultTextColor(QtGui.QColor(255, 215, 0)) title_text.setFont(QtGui.QFont("Arial", title_font_size, QtGui.QFont.Bold)) title_text.setPos(20, banner_y + 10) title_text.setZValue(2001) setattr(title_text, '_is_tutorial_element', True) self.ui.scene.addItem(title_text) self.tutorial_elements.append(title_text) # Use localised text info_text = QtWidgets.QGraphicsTextItem(self.loc.get("tutorial_step1_text")) info_text.setDefaultTextColor(QtGui.QColor(255, 255, 255)) info_text.setFont(QtGui.QFont("Arial", body_font_size)) info_text.setPos(20, banner_y + 35) info_text.setTextWidth(win_width - 150) info_text.setZValue(2001) setattr(info_text, '_is_tutorial_element', True) self.ui.scene.addItem(info_text) self.tutorial_elements.append(info_text) dismiss_button = QtWidgets.QPushButton(self.loc.get("got_it")) dismiss_button.setStyleSheet(""" QPushButton { background-color: #4CAF50; color: white; border: none; padding: 8px 16px; font-size: 16px; border-radius: 4px; } QPushButton:hover { background-color: #45a049; } """) dismiss_button.clicked.connect(self.advance_to_next_step) dismiss_proxy = self.ui.scene.addWidget(dismiss_button) dismiss_proxy.setPos(win_width - 120, banner_y + 35) dismiss_proxy.setZValue(2002) setattr(dismiss_proxy, '_is_tutorial_element', True) self.tutorial_elements.append(dismiss_proxy) self.start_auto_dismiss_timer(15000) def show_second_tutorial(self): logging.debug("Showing second tutorial (NEURAL NETWORK)") try: if not self.ui.scene: logging.error("UI scene not initialized") self.end_tutorial() return self.clear_tutorial_elements() if (hasattr(self.ui, 'squid_brain_window') and self.ui.squid_brain_window and hasattr(self.ui.squid_brain_window, 'brain_widget') and self.ui.squid_brain_window.brain_widget): self.ui.squid_brain_window.brain_widget.start_tutorial_glow(duration_ms=3000) logging.debug("Started tutorial glow on brain widget") self._flash_brain_window_background(self.ui.squid_brain_window) win_width = self.ui.window_width win_height = self.ui.window_height banner_height = 170 banner_y_offset = 40 banner_y = win_height - banner_height - banner_y_offset - 100 banner = QtWidgets.QGraphicsRectItem(0, banner_y, win_width, banner_height) banner.setBrush(QtGui.QColor(25, 25, 112, 230)) banner.setPen(QtGui.QPen(QtGui.QColor(135, 206, 250, 150), 1)) banner.setZValue(2000) setattr(banner, '_is_tutorial_element', True) self.ui.scene.addItem(banner) self.tutorial_elements.append(banner) from .display_scaling import DisplayScaling screen_width = QtWidgets.QApplication.primaryScreen().size().width() if screen_width <= 1920: title_base_size = 14 body_base_size = 13 else: title_base_size = 12 body_base_size = 11 title_font_size, body_font_size = self.get_tutorial_font_sizes(title_base_size, body_base_size) title_text = QtWidgets.QGraphicsTextItem("🧠") title_text.setDefaultTextColor(QtGui.QColor(135, 206, 250)) title_text.setFont(QtGui.QFont("Arial", title_font_size, QtGui.QFont.Bold)) title_text.setPos(20, banner_y + 10) title_text.setZValue(2001) setattr(title_text, '_is_tutorial_element', True) self.ui.scene.addItem(title_text) self.tutorial_elements.append(title_text) info_text = QtWidgets.QGraphicsTextItem(self.loc.get("tutorial_step2_text")) info_text.setDefaultTextColor(QtGui.QColor(255, 255, 255)) info_text.setFont(QtGui.QFont("Arial", body_font_size)) info_text.setPos(20, banner_y + 35) info_text.setTextWidth(win_width - 150) info_text.setZValue(2001) setattr(info_text, '_is_tutorial_element', True) self.ui.scene.addItem(info_text) self.tutorial_elements.append(info_text) dismiss_button = QtWidgets.QPushButton(self.loc.get("next")) dismiss_button.setStyleSheet(DisplayScaling.scale_css(""" QPushButton { background-color: #1E90FF; color: white; border: none; padding: 8px 16px; font-size: 14px; border-radius: 4px; } QPushButton:hover { background-color: #4169E1; } """)) dismiss_button.clicked.connect(self.advance_to_next_step) dismiss_proxy = self.ui.scene.addWidget(dismiss_button) dismiss_proxy.setPos(win_width - 120, banner_y + 35) dismiss_proxy.setZValue(2002) setattr(dismiss_proxy, '_is_tutorial_element', True) self.tutorial_elements.append(dismiss_proxy) self.start_auto_dismiss_timer(15000) except Exception as e: logging.error(f"Error in show_second_tutorial: {str(e)}") self.end_tutorial() def show_neurogenesis_tutorial(self): """Show the third tutorial about neurogenesis with example neurons""" self.clear_tutorial_elements() self.create_tutorial_example_neurons() win_width = self.ui.window_width win_height = self.ui.window_height banner_height = 170 banner_y_offset = 40 banner_y = win_height - banner_height - banner_y_offset - 100 banner = QtWidgets.QGraphicsRectItem(0, banner_y, win_width, banner_height) banner.setBrush(QtGui.QColor(70, 25, 110, 230)) banner.setPen(QtGui.QPen(QtGui.QColor(200, 150, 255, 150), 1)) banner.setZValue(2000) setattr(banner, '_is_tutorial_element', True) self.ui.scene.addItem(banner) self.tutorial_elements.append(banner) title_font_size, body_font_size = self.get_tutorial_font_sizes(12, 11) title_text = QtWidgets.QGraphicsTextItem("🔄") title_text.setDefaultTextColor(QtGui.QColor(200, 150, 255)) title_text.setFont(QtGui.QFont("Arial", title_font_size, QtGui.QFont.Bold)) title_text.setPos(20, banner_y + 10) title_text.setZValue(2001) setattr(title_text, '_is_tutorial_element', True) self.ui.scene.addItem(title_text) self.tutorial_elements.append(title_text) info_text = QtWidgets.QGraphicsTextItem(self.loc.get("tutorial_step3_text")) info_text.setDefaultTextColor(QtGui.QColor(255, 255, 255)) info_text.setFont(QtGui.QFont("Arial", body_font_size)) info_text.setPos(20, banner_y + 35) info_text.setTextWidth(win_width - 150) info_text.setZValue(2001) setattr(info_text, '_is_tutorial_element', True) self.ui.scene.addItem(info_text) self.tutorial_elements.append(info_text) dismiss_button = QtWidgets.QPushButton(self.loc.get("next")) dismiss_button.setStyleSheet(""" QPushButton { background-color: #8A2BE2; color: white; border: none; padding: 8px 16px; font-size: 14px; border-radius: 4px; } QPushButton:hover { background-color: #9932CC; } """) dismiss_button.clicked.connect(self.advance_to_next_step) dismiss_proxy = self.ui.scene.addWidget(dismiss_button) dismiss_proxy.setPos(win_width - 120, banner_y + 35) dismiss_proxy.setZValue(2002) setattr(dismiss_proxy, '_is_tutorial_element', True) self.tutorial_elements.append(dismiss_proxy) self.start_auto_dismiss_timer(15000) def show_learning_tutorial(self): """Show the fourth tutorial about hebbian learning""" if (hasattr(self.ui, 'squid_brain_window') and self.ui.squid_brain_window and hasattr(self.ui.squid_brain_window, 'brain_widget')): self.ui.squid_brain_window.brain_widget.perform_hebbian_learning() logging.debug("Forced Hebbian learning cycle before learning tutorial step") if hasattr(self.ui, 'squid_brain_window') and self.ui.squid_brain_window: if hasattr(self.ui.squid_brain_window, 'tabs'): learning_tab_index = -1 for i in range(self.ui.squid_brain_window.tabs.count()): if self.ui.squid_brain_window.tabs.tabText(i) == "Learning": learning_tab_index = i break if learning_tab_index >= 0: self.ui.squid_brain_window.tabs.setCurrentIndex(learning_tab_index) self.clear_tutorial_elements() win_width = self.ui.window_width win_height = self.ui.window_height banner_height = 170 banner_y_offset = 40 banner_y = win_height - banner_height - banner_y_offset - 100 banner = QtWidgets.QGraphicsRectItem(0, banner_y, win_width, banner_height) banner.setBrush(QtGui.QColor(0, 100, 0, 230)) banner.setPen(QtGui.QPen(QtGui.QColor(144, 238, 144, 150), 1)) banner.setZValue(2000) setattr(banner, '_is_tutorial_element', True) self.ui.scene.addItem(banner) self.tutorial_elements.append(banner) title_font_size, body_font_size = self.get_tutorial_font_sizes(12, 11) title_text = QtWidgets.QGraphicsTextItem("🧬") title_text.setDefaultTextColor(QtGui.QColor(144, 238, 144)) title_text.setFont(QtGui.QFont("Arial", title_font_size, QtGui.QFont.Bold)) title_text.setPos(20, banner_y + 10) title_text.setZValue(2001) setattr(title_text, '_is_tutorial_element', True) self.ui.scene.addItem(title_text) self.tutorial_elements.append(title_text) info_text = QtWidgets.QGraphicsTextItem(self.loc.get("tutorial_step4_text")) info_text.setDefaultTextColor(QtGui.QColor(255, 255, 255)) info_text.setFont(QtGui.QFont("Arial", body_font_size)) info_text.setPos(20, banner_y + 35) info_text.setTextWidth(win_width - 150) info_text.setZValue(2001) setattr(info_text, '_is_tutorial_element', True) self.ui.scene.addItem(info_text) self.tutorial_elements.append(info_text) dismiss_button = QtWidgets.QPushButton(self.loc.get("next")) dismiss_button.setStyleSheet(""" QPushButton { background-color: #228B22; color: white; border: none; padding: 8px 16px; font-size: 14px; border-radius: 4px; } QPushButton:hover { background-color: #32CD32; } """) dismiss_button.clicked.connect(self.advance_to_next_step) dismiss_proxy = self.ui.scene.addWidget(dismiss_button) dismiss_proxy.setPos(win_width - 120, banner_y + 35) dismiss_proxy.setZValue(2002) setattr(dismiss_proxy, '_is_tutorial_element', True) self.tutorial_elements.append(dismiss_proxy) self.start_auto_dismiss_timer(15000) def show_decisions_tutorial(self): """Show the fifth tutorial about decision making""" if hasattr(self.ui, 'squid_brain_window') and self.ui.squid_brain_window: if hasattr(self.ui.squid_brain_window, 'tabs'): decisions_tab_index = -1 for i in range(self.ui.squid_brain_window.tabs.count()): if self.ui.squid_brain_window.tabs.tabText(i) == "Decisions": decisions_tab_index = i break if decisions_tab_index >= 0: self.ui.squid_brain_window.tabs.setCurrentIndex(decisions_tab_index) self.clear_tutorial_elements() win_width = self.ui.window_width win_height = self.ui.window_height banner_height = 170 banner_y_offset = 40 banner_y = win_height - banner_height - banner_y_offset - 100 banner = QtWidgets.QGraphicsRectItem(0, banner_y, win_width, banner_height) banner.setBrush(QtGui.QColor(139, 69, 19, 230)) banner.setPen(QtGui.QPen(QtGui.QColor(222, 184, 135, 150), 1)) banner.setZValue(2000) setattr(banner, '_is_tutorial_element', True) self.ui.scene.addItem(banner) self.tutorial_elements.append(banner) title_font_size, body_font_size = self.get_tutorial_font_sizes(12, 11) title_text = QtWidgets.QGraphicsTextItem("🤔") title_text.setDefaultTextColor(QtGui.QColor(222, 184, 135)) title_text.setFont(QtGui.QFont("Arial", title_font_size, QtGui.QFont.Bold)) title_text.setPos(20, banner_y + 10) title_text.setZValue(2001) setattr(title_text, '_is_tutorial_element', True) self.ui.scene.addItem(title_text) self.tutorial_elements.append(title_text) info_text = QtWidgets.QGraphicsTextItem(self.loc.get("tutorial_step5_text")) info_text.setDefaultTextColor(QtGui.QColor(255, 255, 255)) info_text.setFont(QtGui.QFont("Arial", body_font_size)) info_text.setPos(20, banner_y + 35) info_text.setTextWidth(win_width - 150) info_text.setZValue(2001) setattr(info_text, '_is_tutorial_element', True) self.ui.scene.addItem(info_text) self.tutorial_elements.append(info_text) dismiss_button = QtWidgets.QPushButton(self.loc.get("next")) dismiss_button.setStyleSheet(""" QPushButton { background-color: #8B4513; color: white; border: none; padding: 8px 16px; font-size: 14px; border-radius: 4px; } QPushButton:hover { background-color: #A0522D; } """) dismiss_button.clicked.connect(self.advance_to_next_step) dismiss_proxy = self.ui.scene.addWidget(dismiss_button) dismiss_proxy.setPos(win_width - 120, banner_y + 35) dismiss_proxy.setZValue(2002) setattr(dismiss_proxy, '_is_tutorial_element', True) self.tutorial_elements.append(dismiss_proxy) self.start_auto_dismiss_timer(15000) def show_decorations_tutorial(self): """Show the sixth tutorial about decorations""" if hasattr(self.ui, 'decoration_window') and self.ui.decoration_window: self.main_window.position_and_show_decoration_window() if hasattr(self.ui, 'decorations_action'): self.ui.decorations_action.setChecked(True) if hasattr(self.ui, 'squid_brain_window') and self.ui.squid_brain_window: if hasattr(self.ui.squid_brain_window, 'tabs'): memory_tab_index = -1 for i in range(self.ui.squid_brain_window.tabs.count()): if self.ui.squid_brain_window.tabs.tabText(i) == "Memory": memory_tab_index = i break if memory_tab_index >= 0: self.ui.squid_brain_window.tabs.setCurrentIndex(memory_tab_index) self.clear_tutorial_elements() win_width = self.ui.window_width win_height = self.ui.window_height banner_height = 170 banner_y_offset = 40 banner_y = win_height - banner_height - banner_y_offset - 100 banner = QtWidgets.QGraphicsRectItem(0, banner_y, win_width, banner_height) banner.setBrush(QtGui.QColor(70, 130, 180, 230)) banner.setPen(QtGui.QPen(QtGui.QColor(173, 216, 230, 150), 1)) banner.setZValue(2000) setattr(banner, '_is_tutorial_element', True) self.ui.scene.addItem(banner) self.tutorial_elements.append(banner) title_font_size, body_font_size = self.get_tutorial_font_sizes(12, 11) title_text = QtWidgets.QGraphicsTextItem("🌿 ") title_text.setDefaultTextColor(QtGui.QColor(173, 216, 230)) title_text.setFont(QtGui.QFont("Arial", title_font_size, QtGui.QFont.Bold)) title_text.setPos(20, banner_y + 10) title_text.setZValue(2001) setattr(title_text, '_is_tutorial_element', True) self.ui.scene.addItem(title_text) self.tutorial_elements.append(title_text) info_text = QtWidgets.QGraphicsTextItem(self.loc.get("tutorial_step6_text")) info_text.setDefaultTextColor(QtGui.QColor(255, 255, 255)) info_text.setFont(QtGui.QFont("Arial", body_font_size)) info_text.setPos(20, banner_y + 35) info_text.setTextWidth(win_width - 150) info_text.setZValue(2001) setattr(info_text, '_is_tutorial_element', True) self.ui.scene.addItem(info_text) self.tutorial_elements.append(info_text) dismiss_button = QtWidgets.QPushButton(self.loc.get("next")) dismiss_button.setStyleSheet(""" QPushButton { background-color: #4682B4; color: white; border: none; padding: 8px 16px; font-size: 14px; border-radius: 4px; } QPushButton:hover { background-color: #5F9EA0; } """) dismiss_button.clicked.connect(self.advance_to_next_step) dismiss_proxy = self.ui.scene.addWidget(dismiss_button) dismiss_proxy.setPos(win_width - 120, banner_y + 35) dismiss_proxy.setZValue(2002) setattr(dismiss_proxy, '_is_tutorial_element', True) self.tutorial_elements.append(dismiss_proxy) self.start_auto_dismiss_timer(15000) def show_final_tutorial(self): """Show the final tutorial step with concluding message""" self.clear_tutorial_elements() win_width = self.ui.window_width win_height = self.ui.window_height banner_height = 170 banner_y_offset = 40 banner_y = win_height - banner_height - banner_y_offset - 100 banner = QtWidgets.QGraphicsRectItem(0, banner_y, win_width, banner_height) banner.setBrush(QtGui.QColor(75, 0, 130, 230)) banner.setPen(QtGui.QPen(QtGui.QColor(147, 112, 219, 150), 1)) banner.setZValue(2000) setattr(banner, '_is_tutorial_element', True) self.ui.scene.addItem(banner) self.tutorial_elements.append(banner) title_font_size, body_font_size = self.get_tutorial_font_sizes(12, 11) title_text = QtWidgets.QGraphicsTextItem("✨") title_text.setDefaultTextColor(QtGui.QColor(147, 112, 219)) title_text.setFont(QtGui.QFont("Arial", title_font_size, QtGui.QFont.Bold)) title_text.setPos(20, banner_y + 10) title_text.setZValue(2001) setattr(title_text, '_is_tutorial_element', True) self.ui.scene.addItem(title_text) self.tutorial_elements.append(title_text) info_text = QtWidgets.QGraphicsTextItem(self.loc.get("tutorial_step7_text")) info_text.setDefaultTextColor(QtGui.QColor(255, 255, 255)) info_text.setFont(QtGui.QFont("Arial", body_font_size)) info_text.setPos(20, banner_y + 35) info_text.setTextWidth(win_width - 150) info_text.setZValue(2001) setattr(info_text, '_is_tutorial_element', True) self.ui.scene.addItem(info_text) self.tutorial_elements.append(info_text) dismiss_button = QtWidgets.QPushButton(self.loc.get("finish")) dismiss_button.setStyleSheet(""" QPushButton { background-color: #9370DB; color: white; border: none; padding: 8px 16px; font-size: 14px; border-radius: 4px; } QPushButton:hover { background-color: #8A2BE2; } """) dismiss_button.clicked.connect(self.end_tutorial) dismiss_proxy = self.ui.scene.addWidget(dismiss_button) dismiss_proxy.setPos(win_width - 120, banner_y + 35) dismiss_proxy.setZValue(2002) setattr(dismiss_proxy, '_is_tutorial_element', True) self.tutorial_elements.append(dismiss_proxy) self.start_auto_dismiss_timer(15000) def check_brain_window_ready(self): if (hasattr(self.ui, 'squid_brain_window') and self.ui.squid_brain_window and hasattr(self.ui.squid_brain_window, 'tabs') and self.ui.squid_brain_window.tabs): self.show_second_tutorial() else: QtCore.QTimer.singleShot(500, self.check_brain_window_ready) def advance_to_next_step(self): logging.debug(f"Advancing to tutorial step {self.current_step + 1}") self.cancel_auto_dismiss_timer() self.clear_tutorial_example_neurons() self.clear_tutorial_elements() self.current_step += 1 if self.current_step == 1: if not hasattr(self.ui, 'squid_brain_window') or not self.ui.squid_brain_window: logging.error("squid_brain_window not initialized or None") self.end_tutorial() return try: if not hasattr(self.ui.squid_brain_window, 'brain_widget') or not self.ui.squid_brain_window.brain_widget: logging.error("squid_brain_window.brain_widget not initialized") self.end_tutorial() return logging.debug("Showing squid_brain_window") self.ui.squid_brain_window.show() if hasattr(self.ui, 'brain_action'): self.ui.brain_action.setChecked(True) QtCore.QTimer.singleShot(1000, self.check_brain_window_ready) except Exception as e: logging.error(f"Error showing brain window: {str(e)}") self.end_tutorial() return elif self.current_step == 2: QtCore.QTimer.singleShot(300, self.show_neurogenesis_tutorial) elif self.current_step == 3: QtCore.QTimer.singleShot(300, self.show_learning_tutorial) elif self.current_step == 4: QtCore.QTimer.singleShot(300, self.show_decisions_tutorial) elif self.current_step == 5: QtCore.QTimer.singleShot(300, self.show_decorations_tutorial) elif self.current_step == 6: QtCore.QTimer.singleShot(300, self.show_final_tutorial) else: self.end_tutorial() def _flash_brain_window_background(self, window, flash_colour="#00FFFF", flashes=4, interval_ms=220): """Flash the entire background of the brain window with a stark colour.""" original_style = window.styleSheet() flash_style = f"background-color: {flash_colour};" self._flash_state = False self._flash_count = [0] total_toggles = flashes * 2 # on + off per flash def _toggle(): self._flash_count[0] += 1 self._flash_state = not self._flash_state if self._flash_state: window.setStyleSheet(flash_style) else: window.setStyleSheet(original_style) if self._flash_count[0] >= total_toggles: self._bg_flash_timer.stop() window.setStyleSheet(original_style) self._bg_flash_timer = QtCore.QTimer() self._bg_flash_timer.timeout.connect(_toggle) self._bg_flash_timer.start(interval_ms) def end_tutorial(self): """End the tutorial sequence and clean up""" self.cancel_auto_dismiss_timer() self.clear_tutorial_elements() self.current_step = 0 if (hasattr(self.ui, 'squid_brain_window') and self.ui.squid_brain_window and hasattr(self.ui.squid_brain_window, 'brain_widget') and self.ui.squid_brain_window.brain_widget): self.ui.squid_brain_window.brain_widget.is_tutorial_mode = False logging.debug("Tutorial mode disabled in brain widget") def start_auto_dismiss_timer(self, ms_duration): """Start a timer to automatically dismiss the current tutorial step""" self.cancel_auto_dismiss_timer() self.tutorial_timer = QtCore.QTimer() self.tutorial_timer.timeout.connect(self.advance_to_next_step) self.tutorial_timer.setSingleShot(True) self.tutorial_timer.start(ms_duration) def cancel_auto_dismiss_timer(self): """Cancel the auto-dismiss timer if active""" if self.tutorial_timer and self.tutorial_timer.isActive(): self.tutorial_timer.stop() self.tutorial_timer = None def clear_tutorial_elements(self): """Remove all tutorial elements from the scene""" self.clear_tutorial_example_neurons() for item in self.tutorial_elements[:]: if item and item.scene(): item.scene().removeItem(item) self.tutorial_elements = [] self.ui.scene.update() def create_tutorial_example_neurons(self): """Create 3 temporary example neurons in the brain widget""" if not hasattr(self.ui, 'squid_brain_window') or not self.ui.squid_brain_window: return brain_widget = self.ui.squid_brain_window.brain_widget tutorial_positions = [(300, 200), (500, 350), (700, 250)] for i, (x, y) in enumerate(tutorial_positions): neuron_name = f'tutorial_neuron_{i+1}' brain_widget.neuron_positions[neuron_name] = (x, y) brain_widget.state[neuron_name] = 80 brain_widget.state_colors[neuron_name] = (255, 255, 0) existing_neurons = [n for n in brain_widget.neuron_positions.keys() if not n.startswith('tutorial_')] if existing_neurons and len(existing_neurons) >= 2: targets = random.sample(existing_neurons, 2) for target in targets: weight = random.uniform(0.5, 0.8) brain_widget.weights[(neuron_name, target)] = weight brain_widget.weights[(target, neuron_name)] = weight * 0.5 brain_widget.update() def clear_tutorial_example_neurons(self): """Remove tutorial example neurons from brain widget""" if not hasattr(self.ui, 'squid_brain_window') or not self.ui.squid_brain_window: return if not hasattr(self.ui.squid_brain_window, 'brain_widget') or not self.ui.squid_brain_window.brain_widget: return brain_widget = self.ui.squid_brain_window.brain_widget tutorial_neurons = [n for n in list(brain_widget.neuron_positions.keys()) if n.startswith('tutorial_')] for neuron_name in tutorial_neurons: if neuron_name in brain_widget.neuron_positions: del brain_widget.neuron_positions[neuron_name] if neuron_name in brain_widget.state: del brain_widget.state[neuron_name] if hasattr(brain_widget, 'state_colors') and neuron_name in brain_widget.state_colors: del brain_widget.state_colors[neuron_name] keys_to_remove = [k for k in brain_widget.weights.keys() if neuron_name in k] for key in keys_to_remove: del brain_widget.weights[key] brain_widget.update() ================================================ FILE: src/ui.py ================================================ # UI Stuff import os import json import math import time import random import base64 import uuid import traceback from PyQt5 import QtCore, QtGui, QtWidgets from PyQt5.QtCore import QObject, pyqtProperty from PyQt5.QtWidgets import QGraphicsPixmapItem from .compute_backend import get_backend from .brain_tool import SquidBrainWindow from .statistics_window import StatisticsWindow from .brain_tool import NeuronInspector as EnhancedNeuronInspector from .plugin_manager_dialog import PluginManagerDialog from .tutorial import TutorialManager from .vision import VisionWindow from .task_manager import TaskManagerWindow from .laboratory import NeuronLaboratory from .preferences import PreferencesWindow from .localisation import Localization class ActionButton(QtWidgets.QPushButton): """Custom button with hover and press color states""" def __init__(self, text, hover_color, pressed_color, font_size=16, parent=None): super().__init__(text, parent) self.hover_color = hover_color self.pressed_color = pressed_color self.font_size = font_size self.is_pressed = False self.is_hovered = False self.update_style() def update_style(self): """Update button style based on current state""" if self.is_pressed: bg_color = self.pressed_color text_color = "white" elif self.is_hovered: bg_color = self.hover_color text_color = "black" else: bg_color = "white" text_color = "black" self.setStyleSheet(f""" QPushButton {{ background-color: {bg_color}; color: {text_color}; border: 2px solid black; font-weight: bold; font-size: {self.font_size}px; }} """) def enterEvent(self, event): self.is_hovered = True self.update_style() super().enterEvent(event) def leaveEvent(self, event): self.is_hovered = False self.update_style() super().leaveEvent(event) def mousePressEvent(self, event): if event.button() == QtCore.Qt.LeftButton: self.is_pressed = True self.update_style() super().mousePressEvent(event) def mouseReleaseEvent(self, event): if event.button() == QtCore.Qt.LeftButton: self.is_pressed = False self.update_style() super().mouseReleaseEvent(event) class DecorationItem(QtWidgets.QLabel): def __init__(self, pixmap, filename): super().__init__() from .display_scaling import DisplayScaling item_size = DisplayScaling.scale(128) self.setPixmap(pixmap.scaled(item_size, item_size, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)) self.filename = filename self.setFixedSize(item_size, item_size) self.setAlignment(QtCore.Qt.AlignCenter) self.setToolTip(filename) self.decoration_items = [] def mousePressEvent(self, event): if event.button() == QtCore.Qt.LeftButton: drag = QtGui.QDrag(self) mime_data = QtCore.QMimeData() mime_data.setUrls([QtCore.QUrl.fromLocalFile(self.filename)]) drag.setMimeData(mime_data) drag.setPixmap(self.pixmap()) drag.setHotSpot(event.pos() - self.rect().topLeft()) drag.exec_(QtCore.Qt.CopyAction) class ResizablePixmapItem(QtWidgets.QGraphicsPixmapItem): def __init__(self, pixmap=None, filename=None, category=None, parent=None): QtWidgets.QGraphicsPixmapItem.__init__(self, parent) self.original_pixmap = pixmap self.resize_mode = False self.last_mouse_pos = None if pixmap: self.setPixmap(pixmap) self.setFlags(QtWidgets.QGraphicsItem.ItemIsMovable | QtWidgets.QGraphicsItem.ItemIsSelectable | QtWidgets.QGraphicsItem.ItemSendsGeometryChanges) self.filename = filename self.stat_multipliers = {'happiness': 1} self.category = category if category else 'generic' if filename: multipliers, detected_category = self.get_decoration_info() if multipliers: self.stat_multipliers = multipliers if 'rock' in filename.lower(): self.category = 'rock' elif 'poop' in filename.lower(): self.category = 'poop' else: self.category = detected_category if detected_category else self.category self.can_be_picked_up = filename and ('rock' in filename.lower() or 'poop' in filename.lower()) self.is_being_carried = False self.original_scale = 1.0 def boundingRect(self): return super().boundingRect() def wheelEvent(self, event): if self.filename and ('rock01' in self.filename.lower() or 'rock02' in self.filename.lower()): return super().wheelEvent(event) if self.isSelected() and self.original_pixmap: from .display_scaling import DisplayScaling delta = event.angleDelta().y() / 120 scale_factor = 1.1 if delta > 0 else 0.9 current_width = self.pixmap().width() current_height = self.pixmap().height() min_size = DisplayScaling.scale(64) new_width = max(min_size, int(current_width * scale_factor)) new_height = max(min_size, int(current_height * scale_factor)) scaled_pixmap = self.original_pixmap.scaled( new_width, new_height, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation ) self.setPixmap(scaled_pixmap) event.accept() else: super().wheelEvent(event) def paint(self, painter, option, widget): option_copy = QtWidgets.QStyleOptionGraphicsItem(option) option_copy.state &= ~QtWidgets.QStyle.State_Selected super().paint(painter, option_copy, widget) if self.isSelected(): pixmap = self.pixmap() if pixmap: painter.save() painter.setPen(QtGui.QPen(QtGui.QColor(30, 144, 255), 2)) rect = QtCore.QRectF(0, 0, pixmap.width(), pixmap.height()) painter.drawRect(rect) painter.restore() def mousePressEvent(self, event): pos = event.pos() self.last_mouse_pos = pos self.resize_mode = False super().mousePressEvent(event) def mouseMoveEvent(self, event): current_pos = event.pos() if self.resize_mode and self.original_pixmap and self.last_mouse_pos: delta_x = current_pos.x() - self.last_mouse_pos.x() delta_y = current_pos.y() - self.last_mouse_pos.y() current_width = self.pixmap().width() current_height = self.pixmap().height() new_width = max(30, current_width + delta_x) new_height = max(30, current_height + delta_y) scaled_pixmap = self.original_pixmap.scaled( int(new_width), int(new_height), QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation ) self.setPixmap(scaled_pixmap) self.last_mouse_pos = current_pos event.accept() else: super().mouseMoveEvent(event) def mouseReleaseEvent(self, event): self.resize_mode = False self.last_mouse_pos = None super().mouseReleaseEvent(event) def get_decoration_info(self): try: file_path = os.path.join(os.path.dirname(__file__), 'decoration_stats.json') with open(file_path, 'r') as f: stats = json.load(f) info = stats.get(self.filename, {}) stat_multipliers = {k: v for k, v in info.items() if k != 'category'} category = info.get('category', 'plant') return stat_multipliers, category except FileNotFoundError: print(f"decoration_stats.json not found at {file_path}. Using empty stats.") return {}, 'plant' except json.JSONDecodeError: print(f"Error decoding decoration_stats.json at {file_path}. Using empty stats.") return {}, 'plant' class DecorationWindow(QtWidgets.QWidget): def __init__(self, parent=None): super().__init__(parent, QtCore.Qt.Window) loc = Localization.instance() self.setWindowTitle(f"{loc.get('decorations')} (D)") from .display_scaling import DisplayScaling self.setFixedWidth(DisplayScaling.scale(800)) self.decoration_items = [] layout = QtWidgets.QVBoxLayout(self) scroll_area = QtWidgets.QScrollArea() scroll_area.setWidgetResizable(True) scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) layout.addWidget(scroll_area) content_widget = QtWidgets.QWidget() self.grid_layout = QtWidgets.QGridLayout(content_widget) scroll_area.setWidget(content_widget) self.load_decorations() def add_decoration_item(self, item): self.decoration_items.append(item) def load_decorations(self): decoration_path = "images/decoration" items_per_row = 4 row, col = 0, 0 from .display_scaling import DisplayScaling item_size = DisplayScaling.scale(128) for filename in os.listdir(decoration_path): if filename.endswith(('.png', '.jpg', '.jpeg')): full_path = os.path.join(decoration_path, filename) pixmap = QtGui.QPixmap(full_path) scaled_pixmap = pixmap.scaled(item_size, item_size, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation) item = DecorationItem(scaled_pixmap, full_path) self.grid_layout.addWidget(item, row, col) col += 1 if col >= items_per_row: col = 0 row += 1 self.setFixedHeight(min((row + 1) * (item_size + DisplayScaling.scale(20)) + DisplayScaling.scale(40), DisplayScaling.scale(650))) class ComputeBackendOverlay: """ Small badge drawn in the bottom-right corner of the play scene showing the active compute backend (NumPy or ONNX + provider name). Constructed once during Ui.__init__ and stays visible for the whole session. Colour coding: NumPy -> dark grey (#1e1e1e) with light grey text ONNX -> dark blue (#0d2137) with cyan text ONNX missing -> amber (#2a1a00) with orange text (warning) """ _PADDING_X = 10 # horizontal inner padding (pixels, before scaling) _PADDING_Y = 5 # vertical inner padding _MARGIN = 8 # gap from window edge _FONT_SIZE = 8 # point size (before DisplayScaling) _Z_VALUE = 998 # just below the debug watermark at 999 def __init__(self, scene, window_width: int, window_height: int): from .display_scaling import DisplayScaling backend = get_backend() label, bg_color, text_color = self._style_for(backend.name) font = QtGui.QFont("Courier New", DisplayScaling.scale(self._FONT_SIZE)) font.setBold(True) # Measure text so the pill sizes itself to its content fm = QtGui.QFontMetrics(font) text_w = fm.horizontalAdvance(label) text_h = fm.height() pad_x = DisplayScaling.scale(self._PADDING_X) pad_y = DisplayScaling.scale(self._PADDING_Y) margin = DisplayScaling.scale(self._MARGIN) pill_w = text_w + pad_x * 2 pill_h = text_h + pad_y * 2 x = window_width - pill_w - margin y = window_height - pill_h - margin # Background pill self._bg = QtWidgets.QGraphicsRectItem(x, y, pill_w, pill_h) self._bg.setBrush(QtGui.QBrush(QtGui.QColor(bg_color))) self._bg.setPen(QtGui.QPen(QtGui.QColor(text_color), 1)) self._bg.setOpacity(0.82) self._bg.setZValue(self._Z_VALUE) scene.addItem(self._bg) # Text label self._text = QtWidgets.QGraphicsTextItem(label) self._text.setDefaultTextColor(QtGui.QColor(text_color)) self._text.setFont(font) self._text.setPos(x + pad_x, y + pad_y - 1) self._text.setZValue(self._Z_VALUE + 1) scene.addItem(self._text) @staticmethod def _style_for(backend_name: str): """ Return (label_str, bg_hex, text_hex) based on the backend name reported by compute_backend.get_backend().name """ name = backend_name.lower() if name.startswith("onnx") and "unavailable" not in name: # Extract a short provider name from e.g. "onnx [DmlExecutionProvider]" provider = "" if "[" in backend_name and "]" in backend_name: raw = backend_name.split("[")[1].rstrip("]") short = { "DmlExecutionProvider": "DirectML", "QNNExecutionProvider": "QNN·HTP", "OpenVINOExecutionProvider": "OpenVINO", "CPUExecutionProvider": "CPU", } provider = short.get(raw, raw.replace("ExecutionProvider", "")) label = f"ONNX · {provider}" if provider else "ONNX" return label, "#0d2137", "#00e5ff" # dark blue / cyan elif "unavailable" in name: # ONNX was requested but onnxruntime is not installed return "NUMPY (onnx n/a)", "#2a1a00", "#ffaa00" # amber warning else: return "NUMPY", "#1e1e1e", "#cccccc" # dark grey / light grey def show_colored_message(self, text, color="#FFFFFF", duration=5000): """Show a temporary colored message in the status bar.""" if hasattr(self, 'status_bar') and self.status_bar: original_style = self.status_bar.styleSheet() self.status_bar.setStyleSheet(f"color: {color}; background-color: black; font-size: 11px;") self.status_bar.showMessage(text, duration) QtCore.QTimer.singleShot(duration, lambda: self.status_bar.setStyleSheet(original_style)) class Ui: def __init__(self, window, debug_mode=False): self.window = window self.awarded_decorations = set() self.tamagotchi_logic = None self.debug_mode = debug_mode self.setup_neurogenesis_debug_shortcut() self.setup_decorations_shortcut() screen = QtWidgets.QApplication.primaryScreen() screen_size = screen.size() from .display_scaling import DisplayScaling DisplayScaling.initialize(screen_size.width(), screen_size.height()) if screen_size.width() <= 1920: base_width = 1440 base_height = 960 else: base_width = 1344 base_height = 936 self.window.setMinimumSize(DisplayScaling.scale(base_width), DisplayScaling.scale(base_height)) self.window_width = DisplayScaling.scale(base_width) self.window_height = DisplayScaling.scale(base_height) self.window.setWindowTitle("Dosidicus") self.window.resize(self.window_width, self.window_height) self.scene = QtWidgets.QGraphicsScene() self.view = QtWidgets.QGraphicsView(self.scene) self.tutorial_manager = TutorialManager(self, window) self.view.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.view.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.view.setViewportUpdateMode(QtWidgets.QGraphicsView.FullViewportUpdate) self.view.setAlignment(QtCore.Qt.AlignTop | QtCore.Qt.AlignLeft) self.window.setCentralWidget(self.view) self.setup_ui_elements() self.setup_menu_bar() self.enhanced_neuron_inspector_instance = None self.squid_brain_window = None loc = Localization.instance() self.debug_text = QtWidgets.QGraphicsTextItem(loc.get('debug')) self.debug_text.setDefaultTextColor(QtGui.QColor("#a9a9a9")) font = QtGui.QFont() font.setPointSize(DisplayScaling.scale(20)) self.debug_text.setFont(font) self.debug_text.setRotation(-90) self.debug_text.setPos(DisplayScaling.scale(75), DisplayScaling.scale(75)) self.debug_text.setZValue(999) self.debug_text.setVisible(self.debug_mode) self.scene.addItem(self.debug_text) # ── Compute backend badge ───────────────────────────────────────── self._backend_overlay = ComputeBackendOverlay( scene=self.scene, window_width=self.window_width, window_height=self.window_height, ) # ───────────────────────────────────────────────────────────────── self.decoration_window = DecorationWindow(self.window) self.decoration_window.setWindowFlags(QtCore.Qt.Window | QtCore.Qt.Tool) self.decoration_window.setAttribute(QtCore.Qt.WA_QuitOnClose, False) self.statistics_window = None self.view.setAcceptDrops(True) self.view.dragEnterEvent = self.dragEnterEvent self.view.dragMoveEvent = self.dragMoveEvent self.view.dropEvent = self.dropEvent self.view.setFocusPolicy(QtCore.Qt.StrongFocus) self.view.keyPressEvent = self.keyPressEvent try: from status_bar_component import StatusBarComponent self.status_bar = StatusBarComponent(self.window) except ImportError: print("Status bar component not available, skipping") self.optimize_animations() def setup_neurogenesis_debug_shortcut(self): self.neurogenesis_debug_shortcut = QtWidgets.QShortcut( QtGui.QKeySequence(QtCore.Qt.ALT + QtCore.Qt.Key_N), self.window ) self.neurogenesis_debug_shortcut.activated.connect(self.show_neurogenesis_debug) def show_neurogenesis_debug(self): if not hasattr(self, 'squid_brain_window') or not self.squid_brain_window: print("Brain window not initialized") return if not hasattr(self, '_neurogenesis_debug_dialog'): self._neurogenesis_debug_dialog = NeurogenesisDebugDialog( self.squid_brain_window.brain_widget, self.window ) self._neurogenesis_debug_dialog.update_debug_info() self._neurogenesis_debug_dialog.show() self._neurogenesis_debug_dialog.raise_() def show_neuron_laboratory(self): if not hasattr(self, 'squid_brain_window') or not self.squid_brain_window: self.show_message("Brain Tool not initialized") return brain_widget = getattr(self.squid_brain_window, 'brain_widget', None) if brain_widget is None: self.show_message("Brain widget not available") return if not hasattr(self, '_neuron_laboratory') or self._neuron_laboratory is None: self._neuron_laboratory = NeuronLaboratory(brain_widget, self.window) self._neuron_laboratory.show() self._neuron_laboratory.raise_() self._neuron_laboratory.activateWindow() # ── Export helpers ──────────────────────────────────────────────────── def _get_exports_dir(self): exports_dir = os.path.normpath( os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "exports") ) os.makedirs(exports_dir, exist_ok=True) return exports_dir def _exports_timestamp(self): return time.strftime("%Y%m%d_%H%M%S") def export_memory(self, mode): """Export STM, LTM, or all memories to /exports as JSON. mode: 'stm'|'ltm'|'all'""" try: squid = None if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: squid = getattr(self.tamagotchi_logic, 'squid', None) if squid is None: self.show_message("No squid loaded – cannot export memories.") return def _get(obj, *names): for n in names: v = getattr(obj, n, None) if v is not None: return v return None stm = _get(squid, 'short_term_memory', 'stm', 'short_term', 'recent_memories') ltm = _get(squid, 'long_term_memory', 'ltm', 'long_term', 'memories') exports_dir = self._get_exports_dir() ts = self._exports_timestamp() def _serialise(obj): try: return json.loads(json.dumps(obj, default=str)) except Exception: return str(obj) if mode in ("stm", "all") and stm is not None: path = os.path.join(exports_dir, f"memory_stm_{ts}.json") with open(path, "w", encoding="utf-8") as f: json.dump(_serialise(stm), f, indent=2) print(f"[Export] STM saved → {path}") if mode in ("ltm", "all") and ltm is not None: path = os.path.join(exports_dir, f"memory_ltm_{ts}.json") with open(path, "w", encoding="utf-8") as f: json.dump(_serialise(ltm), f, indent=2) print(f"[Export] LTM saved → {path}") if mode == "all": path = os.path.join(exports_dir, f"memory_all_{ts}.json") with open(path, "w", encoding="utf-8") as f: json.dump({"stm": _serialise(stm), "ltm": _serialise(ltm)}, f, indent=2) print(f"[Export] All memory saved → {path}") label = {"stm": "STM", "ltm": "LTM", "all": "All Memory"}[mode] self.show_message(f"{label} exported to /exports") except Exception as e: print(f"[Export] Memory export error: {e}") traceback.print_exc() self.show_message(f"Export failed: {e}") def export_weights(self, fmt): """Export brain weights to /exports as CSV or TXT. fmt: 'csv'|'txt'""" try: brain = None if hasattr(self, 'squid_brain_window') and self.squid_brain_window: brain = getattr(self.squid_brain_window, 'brain_widget', None) if brain is None or not hasattr(brain, 'weights'): self.show_message("Brain not initialised – cannot export weights.") return weights = brain.weights exports_dir = self._get_exports_dir() ts = self._exports_timestamp() sorted_weights = sorted(weights.items(), key=lambda x: (str(x[0][0]), str(x[0][1]))) if fmt == "csv": path = os.path.join(exports_dir, f"weights_{ts}.csv") with open(path, "w", encoding="utf-8") as f: f.write("from,to,weight\n") for (src, dst), w in sorted_weights: f.write(f"{src},{dst},{w}\n") else: path = os.path.join(exports_dir, f"weights_{ts}.txt") with open(path, "w", encoding="utf-8") as f: f.write(f"Weights export – {ts}\n") f.write(f"{'From':<30} {'To':<30} {'Weight':>12}\n") f.write("-" * 74 + "\n") for (src, dst), w in sorted_weights: f.write(f"{str(src):<30} {str(dst):<30} {w:>12.6f}\n") print(f"[Export] Weights ({fmt.upper()}) saved → {path}") self.show_message(f"Weights exported as {fmt.upper()} to /exports") except Exception as e: print(f"[Export] Weights export error: {e}") traceback.print_exc() self.show_message(f"Export failed: {e}") def export_neurons(self): """Export a list of all neurons to /exports as JSON.""" try: brain = None if hasattr(self, 'squid_brain_window') and self.squid_brain_window: brain = getattr(self.squid_brain_window, 'brain_widget', None) if brain is None: self.show_message("Brain not initialised – cannot export neurons.") return if hasattr(brain, 'neuron_positions'): neuron_names = sorted(brain.neuron_positions.keys()) elif hasattr(brain, 'neurons'): neuron_names = sorted(brain.neurons.keys() if isinstance(brain.neurons, dict) else brain.neurons) else: neuron_names = [] if not neuron_names: self.show_message("No neurons found to export.") return exports_dir = self._get_exports_dir() ts = self._exports_timestamp() path = os.path.join(exports_dir, f"neurons_{ts}.json") with open(path, "w", encoding="utf-8") as f: json.dump({"exported_at": ts, "count": len(neuron_names), "neurons": neuron_names}, f, indent=2) print(f"[Export] Neurons ({len(neuron_names)}) saved → {path}") self.show_message(f"{len(neuron_names)} neurons exported to /exports") except Exception as e: print(f"[Export] Neuron export error: {e}") traceback.print_exc() self.show_message(f"Export failed: {e}") # ───────────────────────────────────────────────────────────────────── def setup_decorations_shortcut(self): self.decorations_shortcut = QtWidgets.QShortcut( QtGui.QKeySequence(QtCore.Qt.Key_D), self.window ) self.decorations_shortcut.activated.connect(self.show_decorations_window) def show_decorations_window(self): self.decoration_window.show() self.decoration_window.activateWindow() self.decoration_window.raise_() def optimize_animations(self): self.scene.setItemIndexMethod(QtWidgets.QGraphicsScene.NoIndex) self.view.setCacheMode(QtWidgets.QGraphicsView.CacheBackground) def set_tamagotchi_logic(self, logic): self.tamagotchi_logic = logic if hasattr(logic, 'debug_mode'): self.debug_mode = logic.debug_mode self.debug_action.setChecked(self.debug_mode) self.debug_text.setVisible(self.debug_mode) if hasattr(self, 'neurogenesis_action'): self.neurogenesis_action.triggered.connect(self.trigger_neurogenesis) self.create_multiplayer_menu() def show_tutorial_overlay(self): self.tutorial_manager.start_tutorial() def remove_tutorial_overlay(self): self.tutorial_manager.advance_to_next_step() def show_second_tutorial_banner(self): win_width = self.window_width win_height = self.window_height banner_height = 100 banner = QtWidgets.QGraphicsRectItem(0, win_height - banner_height, win_width, banner_height) banner.setBrush(QtGui.QColor(25, 25, 112, 230)) banner.setPen(QtGui.QPen(QtGui.QColor(135, 206, 250, 150), 1)) banner.setZValue(2000) setattr(banner, '_is_tutorial_element', True) self.scene.addItem(banner) title_text = QtWidgets.QGraphicsTextItem("🧠 NEURAL NETWORK") title_text.setDefaultTextColor(QtGui.QColor(135, 206, 250)) title_text.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Bold)) title_text.setPos(20, win_height - banner_height + 10) title_text.setZValue(2001) setattr(title_text, '_is_tutorial_element', True) self.scene.addItem(title_text) info_text = QtWidgets.QGraphicsTextItem( "This is the squid's neural network. His behaviour is driven by his needs (round neurons).\n" "The network adapts and learns as the squid interacts with his environment." ) info_text.setDefaultTextColor(QtGui.QColor(255, 255, 255)) info_text.setFont(QtGui.QFont("Arial", 11)) info_text.setPos(20, win_height - banner_height + 35) info_text.setTextWidth(win_width - 150) info_text.setZValue(2001) setattr(info_text, '_is_tutorial_element', True) self.scene.addItem(info_text) dismiss_button = QtWidgets.QPushButton("Got it!") dismiss_button.setStyleSheet(""" QPushButton { background-color: #1E90FF; color: white; border: none; padding: 8px 16px; font-size: 14px; border-radius: 4px; } QPushButton:hover { background-color: #4169E1; } """) dismiss_button.clicked.connect(self.close_tutorial_completely) dismiss_proxy = self.scene.addWidget(dismiss_button) dismiss_proxy.setPos(win_width - 120, win_height - banner_height + 35) dismiss_proxy.setZValue(2002) setattr(dismiss_proxy, '_is_tutorial_element', True) self.tutorial_timer = QtCore.QTimer() self.tutorial_timer.timeout.connect(self.close_tutorial_completely) self.tutorial_timer.setSingleShot(True) self.tutorial_timer.start(12000) def close_tutorial_completely(self): if hasattr(self, 'tutorial_timer') and self.tutorial_timer.isActive(): self.tutorial_timer.stop() for item in self.scene.items(): if hasattr(item, '_is_tutorial_element'): self.scene.removeItem(item) self.scene.update() def show_experience_buffer(self): """Show the Experience Buffer window from the debug menu""" loc = Localization.instance() # Get brain widget from the brain window if not hasattr(self, 'squid_brain_window') or not self.squid_brain_window: self.show_message("Brain window not initialized") return brain_widget = self.squid_brain_window.brain_widget if not brain_widget: QtWidgets.QMessageBox.warning(self.window, "Missing Brain", "Brain widget not available") return if not hasattr(brain_widget, 'enhanced_neurogenesis') or brain_widget.enhanced_neurogenesis is None: QtWidgets.QMessageBox.warning(self.window, "No Neurogenesis", "Neurogenesis system not initialized") return # Create or show the dialog if not hasattr(self, '_experience_buffer_dialog') or not self._experience_buffer_dialog: from .brain_network_tab import ExperienceBufferDialog self._experience_buffer_dialog = ExperienceBufferDialog(brain_widget, self.window) self._experience_buffer_dialog.refresh_data() self._experience_buffer_dialog.show() self._experience_buffer_dialog.raise_() self._experience_buffer_dialog.activateWindow() def open_brain_designer(self): """ Open the Brain Tool (if closed) and switch it to Designer Mode. Ensures only one instance runs by using the existing window. """ # 1. Ensure Brain Window is initialized (Lazy Loading) if not hasattr(self, 'squid_brain_window') or not self.squid_brain_window: if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: current_debug_mode = getattr(self.tamagotchi_logic, 'debug_mode', False) # Check if logic already holds a reference if hasattr(self.tamagotchi_logic, 'brain_window') and self.tamagotchi_logic.brain_window: self.squid_brain_window = self.tamagotchi_logic.brain_window else: # Create new window self.squid_brain_window = SquidBrainWindow( self.tamagotchi_logic, current_debug_mode, show_decorations_callback=self.show_decorations_window ) # Register back to logic if hasattr(self.tamagotchi_logic, 'set_brain_window'): self.tamagotchi_logic.set_brain_window(self.squid_brain_window) else: self.show_message("Cannot open Designer: Game logic not initialized.") return # 2. Show and Focus the Brain Window if not self.squid_brain_window.isVisible(): self.squid_brain_window.show() self.squid_brain_window.raise_() self.squid_brain_window.activateWindow() # Sync the "Brain Tool" menu checkmark if hasattr(self, 'brain_action'): self.brain_action.setChecked(True) # 3. Switch to Designer Mode # Check if already in designer mode to avoid redundant reloading if hasattr(self.squid_brain_window, 'designer_view') and self.squid_brain_window.designer_view: print("Designer mode already active.") return if hasattr(self.squid_brain_window, 'switch_to_designer_mode'): self.squid_brain_window.switch_to_designer_mode() self.show_message("Switching to Designer Mode...") else: self.show_message("Error: Brain Tool does not support Designer Mode.") def setup_plugin_menu(self, plugin_manager_instance): loc = Localization.instance() if not hasattr(self, 'plugins_menu'): self.plugins_menu = self.menu_bar.addMenu(loc.get('plugins')) if not hasattr(self, 'plugin_manager_action') or not self.plugin_manager_action: self.plugin_manager_action = QtWidgets.QAction('Plugin Manager', self.window) self.plugins_menu.addAction(self.plugin_manager_action) self.plugins_menu.addSeparator() if plugin_manager_instance: self.apply_plugin_menu_registrations(plugin_manager_instance) def create_multiplayer_menu(self): if hasattr(self.tamagotchi_logic, 'plugin_manager'): self.setup_plugin_menu(self.tamagotchi_logic.plugin_manager) else: print("WARNING: create_multiplayer_menu called but plugin_manager is not available") def apply_plugin_menu_registrations(self, plugin_manager): loc = Localization.instance() if not hasattr(self, 'plugins_menu') or not self.plugins_menu: self.plugins_menu = self.menu_bar.addMenu(loc.get('plugins')) self.plugin_manager_action = QtWidgets.QAction('Plugin Manager', self.window) self.plugins_menu.addAction(self.plugin_manager_action) self.plugins_menu.addSeparator() elif not hasattr(self, 'plugin_manager_action') or \ not self.plugin_manager_action or \ self.plugin_manager_action not in self.plugins_menu.actions(): self.plugin_manager_action = QtWidgets.QAction('Plugin Manager', self.window) try: self.plugin_manager_action.triggered.disconnect() except TypeError: pass all_actions = self.plugins_menu.actions() if all_actions and all_actions[0].isSeparator(): self.plugins_menu.insertAction(all_actions[0], self.plugin_manager_action) elif all_actions: self.plugins_menu.insertAction(all_actions[0], self.plugin_manager_action) else: self.plugins_menu.addAction(self.plugin_manager_action) current_actions = self.plugins_menu.actions() pm_action_index = current_actions.index(self.plugin_manager_action) if self.plugin_manager_action in current_actions else -1 if pm_action_index != -1: if pm_action_index + 1 >= len(current_actions) or not current_actions[pm_action_index + 1].isSeparator(): sep = QtWidgets.QAction(self.window) sep.setSeparator(True) self.plugins_menu.insertAction(current_actions[pm_action_index + 1] if pm_action_index + 1 < len(current_actions) else None, sep) try: self.plugin_manager_action.triggered.disconnect() except TypeError: pass if plugin_manager: self.plugin_manager_action.triggered.connect( lambda: self.show_plugin_manager(plugin_manager) ) self.plugin_manager_action.setEnabled(True) self.plugin_manager_action.setToolTip("Open the plugin manager.") else: self.plugin_manager_action.setEnabled(False) self.plugin_manager_action.setToolTip("Plugin manager is not available.") actions_to_remove = [] separator_after_pm_action_found = False pm_action_ref = self.plugin_manager_action if pm_action_ref and pm_action_ref in self.plugins_menu.actions(): pm_action_index = self.plugins_menu.actions().index(pm_action_ref) if pm_action_index + 1 < len(self.plugins_menu.actions()): next_action = self.plugins_menu.actions()[pm_action_index + 1] if next_action.isSeparator(): separator_after_pm_action_found = True for i in range(pm_action_index + 2, len(self.plugins_menu.actions())): actions_to_remove.append(self.plugins_menu.actions()[i]) if not separator_after_pm_action_found: temp_actions_to_keep = {pm_action_ref} if pm_action_ref and pm_action_ref in self.plugins_menu.actions(): pm_idx = self.plugins_menu.actions().index(pm_action_ref) if pm_idx + 1 < len(self.plugins_menu.actions()) and self.plugins_menu.actions()[pm_idx+1].isSeparator(): temp_actions_to_keep.add(self.plugins_menu.actions()[pm_idx+1]) for action in self.plugins_menu.actions(): if action not in temp_actions_to_keep: actions_to_remove.append(action) for action_to_remove in actions_to_remove: self.plugins_menu.removeAction(action_to_remove) if plugin_manager and hasattr(plugin_manager, 'plugins'): enabled_plugin_keys = plugin_manager.get_enabled_plugins() for plugin_name_key, plugin_data_dict in plugin_manager.plugins.items(): if plugin_name_key not in enabled_plugin_keys: continue plugin_instance = plugin_data_dict.get('instance') original_name = plugin_data_dict.get('original_name', plugin_name_key.capitalize()) if plugin_instance and hasattr(plugin_instance, 'register_menu_actions'): plugin_submenu = self.plugins_menu.addMenu(original_name) try: plugin_instance.register_menu_actions(self.window, plugin_submenu) except Exception as e: print(f"Error calling register_menu_actions for {original_name}: {e}") print("Applied all plugin menu registrations") def toggle_plugin(self, plugin_name, enable_flag): if hasattr(self, 'tamagotchi_logic') and hasattr(self.tamagotchi_logic, 'plugin_manager'): plugin_mgr = self.tamagotchi_logic.plugin_manager if plugin_name not in plugin_mgr.plugins: print(f"WARNING:UI: Attempted to toggle plugin '{plugin_name}', but it's not loaded/found in plugin_mgr.plugins.") return success = False if enable_flag: print(f"INFO:UI: Requesting PluginManager to enable plugin '{plugin_name}'.") success = plugin_mgr.enable_plugin(plugin_name) if success: print(f"INFO:UI: PluginManager reported success enabling '{plugin_name}'.") else: print(f"WARNING:UI: PluginManager reported failure enabling '{plugin_name}'.") else: print(f"INFO:UI: Requesting PluginManager to disable plugin '{plugin_name}'.") success = plugin_mgr.disable_plugin(plugin_name) if success: print(f"INFO:UI: PluginManager reported success disabling '{plugin_name}'.") else: print(f"WARNING:UI: PluginManager reported failure disabling '{plugin_name}'.") if hasattr(self, 'setup_plugin_menu') and callable(self.setup_plugin_menu): self.setup_plugin_menu(plugin_mgr) else: print("WARNING:UI: setup_plugin_menu method not found, cannot refresh UI after toggle.") else: print("WARNING:UI: Cannot toggle plugin - TamagotchiLogic or PluginManager not available.") def show_plugin_manager(self, plugin_manager): dialog = PluginManagerDialog(plugin_manager, self.window) dialog.exec_() self.setup_plugin_menu(plugin_manager) def setup_ui_elements(self): loc = Localization.instance() self.rect_item = self.scene.addRect(50, 50, self.window_width - 100, self.window_height - 100, QtGui.QPen(QtGui.QColor(0, 0, 0)), QtGui.QBrush(QtGui.QColor(255, 255, 255))) self.cleanliness_overlay = self.scene.addRect(50, 50, self.window_width - 100, self.window_height - 100, QtGui.QPen(QtCore.Qt.NoPen), QtGui.QBrush(QtGui.QColor(139, 69, 19, 0))) self.dirty_text_items = [] self.dirty_text_target_count = 0 self.feeding_message = QtWidgets.QGraphicsTextItem(loc.get("feed_msg")) self.feeding_message.setDefaultTextColor(QtGui.QColor(255, 255, 255)) self.feeding_message.setFont(QtGui.QFont("Arial", 10, QtGui.QFont.Bold)) self.feeding_message.setPos(0, self.window_height - 75) self.feeding_message.setTextWidth(self.window_width) self.feeding_message.setHtml(f'
    {loc.get("feed_msg")}
    ') self.feeding_message.setOpacity(0) self.scene.addItem(self.feeding_message) self.points_label = QtWidgets.QGraphicsTextItem(loc.get("points") + ":") self.points_label.setDefaultTextColor(QtGui.QColor(255, 255, 255)) self.points_label.setFont(QtGui.QFont("Arial", 12)) self.points_label.setPos(self.window_width - 255, 10) self.points_label.setZValue(2) self.scene.addItem(self.points_label) self.points_value_label = QtWidgets.QGraphicsTextItem("0") self.points_value_label.setDefaultTextColor(QtGui.QColor(255, 255, 255)) self.points_value_label.setFont(QtGui.QFont("Arial", 12, QtGui.QFont.Bold)) self.points_value_label.setPos(self.window_width - 95, 10) self.points_value_label.setZValue(2) self.scene.addItem(self.points_value_label) self.view.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.view.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.view.setSceneRect(0, 0, self.window_width, self.window_height) self.original_view_wheel_event = self.view.wheelEvent self.view.wheelEvent = self.custom_wheel_event if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic is not None: self.debug_text.setVisible(getattr(self.tamagotchi_logic, 'debug_mode', False)) self.create_action_buttons() def custom_wheel_event(self, event): selected_items = self.scene.selectedItems() resizable_selected = [item for item in selected_items if isinstance(item, ResizablePixmapItem)] if resizable_selected: for item in resizable_selected: try: item.wheelEvent(event) except Exception as e: print(f"Error in wheel event handling: {e}") event.accept() def create_action_buttons(self): from .display_scaling import DisplayScaling try: from .config_manager import ConfigManager config = ConfigManager() display_config = config.get_display_config() button_width = display_config['button_width'] button_height = display_config['button_height'] button_spacing = display_config['button_spacing'] button_font_size = display_config['button_font_size'] except Exception as e: print(f"Warning: Could not load display config, using defaults: {e}") button_width = 140 button_height = 50 button_spacing = 20 button_font_size = 16 button_width = DisplayScaling.scale(button_width) button_height = DisplayScaling.scale(button_height) button_spacing = DisplayScaling.scale(button_spacing) button_font_size = DisplayScaling.font_size(button_font_size) total_width = (button_width * 3) + (button_spacing * 2) start_x = (self.window_width - total_width) / 2 y_pos = DisplayScaling.scale(15) loc = Localization.instance() button_configs = [ (loc.get("feed_btn"), "#C8E6C9", "#4CAF50", "feed_action"), (loc.get("clean_btn"), "#B3E5FC", "#2196F3", "clean_action"), (loc.get("medicine_btn"), "#FFCDD2", "#F44336", "medicine_action") ] self.action_buttons = [] for i, (text, hover_color, pressed_color, action_name) in enumerate(button_configs): x_pos = start_x + (i * (button_width + button_spacing)) button = ActionButton(text, hover_color, pressed_color, button_font_size) button.setFixedSize(button_width, button_height) button.action_name = action_name proxy = self.scene.addWidget(button) proxy.setPos(x_pos, y_pos) proxy.setZValue(10000) self.action_buttons.append((button, proxy)) def connect_action_buttons(self): if not hasattr(self, 'action_buttons'): return for button, proxy in self.action_buttons: if hasattr(button, 'action_name'): action = getattr(self, button.action_name, None) if action: button.clicked.connect(action.trigger) def update_dirty_text(self, cleanliness): if cleanliness >= 25: if self.dirty_text_items: self.clear_dirty_text() return tank_left = 50 tank_right = self.window_width - 50 tank_top = 50 tank_bottom = self.window_height - 50 tank_width = tank_right - tank_left tank_height = tank_bottom - tank_top font_size = 8 word_width = font_size * 9 word_height = font_size + 14 cols = max(1, int(tank_width / word_width)) rows = max(1, int(tank_height / word_height)) max_words = cols * rows progress = (25.0 - float(cleanliness)) / 25.0 target_count = int(progress * max_words) self.dirty_text_target_count = target_count current_count = len(self.dirty_text_items) if current_count < target_count: words_to_add = target_count - current_count for _ in range(words_to_add): word_index = len(self.dirty_text_items) row_from_bottom = word_index // cols col = word_index % cols x = tank_left + (col * word_width) + random.randint(-2, 2) y = tank_bottom - ((row_from_bottom + 1) * word_height) + random.randint(-1, 1) if y < tank_top: break loc = Localization.instance() dirty_text = QtWidgets.QGraphicsTextItem(loc.get("dirty")) dirty_text.setDefaultTextColor(QtGui.QColor(101, 67, 33)) font = QtGui.QFont("Arial", font_size, QtGui.QFont.Bold) dirty_text.setFont(font) dirty_text.setPos(x, y) dirty_text.setZValue(1000) dirty_text.setData(0, "dirty_text") self.scene.addItem(dirty_text) self.dirty_text_items.append(dirty_text) self.scene.update() elif current_count > target_count: words_to_remove = current_count - target_count for _ in range(words_to_remove): if self.dirty_text_items: item = self.dirty_text_items.pop() self.scene.removeItem(item) self.scene.update() def clear_dirty_text(self): for item in self.dirty_text_items: self.scene.removeItem(item) self.dirty_text_items.clear() self.dirty_text_target_count = 0 self.scene.update() def check_neurogenesis(self, state): current_time = time.time() if state.get('_debug_forced_neurogenesis', False): new_name = f"debug_neuron_{int(current_time)}" if self.neuron_positions: center_x = sum(pos[0] for pos in self.neuron_positions.values()) / len(self.neuron_positions) center_y = sum(pos[1] for pos in self.neuron_positions.values()) / len(self.neuron_positions) else: center_x, center_y = 600, 300 self.neuron_positions[new_name] = ( center_x + random.randint(-100, 100), center_y + random.randint(-100, 100) ) self.state[new_name] = 80 self.state_colors[new_name] = (150, 200, 255) for existing in self.neuron_positions: if existing != new_name: self.weights[(new_name, existing)] = random.uniform(-0.8, 0.8) self.weights[(existing, new_name)] = random.uniform(-0.8, 0.8) if 'new_neurons' not in self.neurogenesis_data: self.neurogenesis_data['new_neurons'] = [] self.neurogenesis_data['new_neurons'].append(new_name) self.neurogenesis_data['last_neuron_time'] = current_time print(f"DEBUG: Created neuron '{new_name}' at {self.neuron_positions[new_name]}") print(f"New connections: {[(k,v) for k,v in self.weights.items() if new_name in k]}") self.update() return True if current_time - self.neurogenesis_data.get('last_neuron_time', 0) > self.neurogenesis_config['cooldown']: created = False if state.get('novelty_exposure', 0) > self.neurogenesis_config['novelty_threshold']: self._create_neuron_internal('novelty', state) created = True if state.get('sustained_stress', 0) > self.neurogenesis_config['stress_threshold']: self._create_neuron_internal('stress', state) created = True if state.get('recent_rewards', 0) > self.neurogenesis_config['reward_threshold']: self._create_neuron_internal('reward', state) created = True return created return False def _create_neuron(self, neuron_type, trigger_data): base_name = { 'novelty': 'novel', 'stress': 'defense', 'reward': 'reward' }[neuron_type] new_name = f"{base_name}_{len(self.neurogenesis_data['new_neurons'])}" active_neurons = sorted( [(k, v) for k, v in self.state.items() if isinstance(v, (int, float))], key=lambda x: x[1], reverse=True ) if active_neurons: base_x, base_y = self.neuron_positions[active_neurons[0][0]] else: base_x, base_y = 600, 300 self.neuron_positions[new_name] = ( base_x + random.randint(-50, 50), base_y + random.randint(-50, 50) ) self.state[new_name] = 50 self.state_colors[new_name] = { 'novelty': (255, 255, 150), 'stress': (255, 150, 150), 'reward': (150, 255, 150) }[neuron_type] default_weights = { 'novelty': {'curiosity': 0.6, 'anxiety': -0.4}, 'stress': {'anxiety': -0.7, 'happiness': 0.3}, 'reward': {'satisfaction': 0.8, 'happiness': 0.5} } for target, weight in default_weights[neuron_type].items(): self.weights[(new_name, target)] = weight self.weights[(target, new_name)] = weight * 0.5 self.neurogenesis_data['new_neurons'].append(new_name) self.neurogenesis_data['last_neuron_time'] = time.time() return new_name def trigger_neurogenesis(self): try: if not hasattr(self, 'squid_brain_window') or not self.squid_brain_window: print("Brain window not found") self.show_message("Brain window not initialized") return brain = self.squid_brain_window.brain_widget import time import random prev_neurons = set(brain.neuron_positions.keys()) new_name = f"forced_{int(time.time())}" if brain.neuron_positions: x_values = [pos[0] for pos in brain.neuron_positions.values()] y_values = [pos[1] for pos in brain.neuron_positions.values()] center_x = sum(x_values) / len(x_values) center_y = sum(y_values) / len(y_values) else: center_x, center_y = 600, 300 pos_x = center_x + random.randint(-100, 100) pos_y = center_y + random.randint(-100, 100) print(f"Creating neuron {new_name} at ({pos_x}, {pos_y})") brain.neuron_positions[new_name] = (pos_x, pos_y) brain.state[new_name] = 75 if hasattr(brain, 'state_colors'): brain.state_colors[new_name] = (150, 200, 255) for existing in list(prev_neurons): if existing in getattr(brain, 'excluded_neurons', []): continue weight = random.uniform(-0.3, 0.3) brain.weights[(new_name, existing)] = weight brain.weights[(existing, new_name)] = weight * 0.8 if hasattr(brain, 'neurogenesis_data'): if 'new_neurons' not in brain.neurogenesis_data: brain.neurogenesis_data['new_neurons'] = [] brain.neurogenesis_data['new_neurons'].append(new_name) brain.neurogenesis_data['last_neuron_time'] = time.time() if hasattr(brain, 'neurogenesis_highlight'): brain.neurogenesis_highlight = { 'neuron': new_name, 'start_time': time.time(), 'duration': 5.0 } brain.update() new_neurons = set(brain.neuron_positions.keys()) - prev_neurons if new_neurons: try: self.show_message(f"Created neuron: {new_name}") except: pass print(f"Successfully created neuron: {new_name}") else: self.show_message("Neuron creation failed!") print("ERROR: Failed to create neuron") except Exception as e: import traceback print(f"NEUROGENESIS FAILURE:\n{traceback.format_exc()}") try: self.show_message(f"Neurogenesis Error: {str(e)}") except: pass def toggle_decoration_window(self, checked): if checked: self.decoration_window.show() self.decoration_window.activateWindow() else: self.decoration_window.hide() def show_pause_message(self, is_paused): for item in self.scene.items(): if hasattr(item, '_is_pause_message'): self.scene.removeItem(item) win_width = self.window_width win_height = self.window_height from .display_scaling import DisplayScaling loc = Localization.instance() if is_paused: background = QtWidgets.QGraphicsRectItem( -DisplayScaling.scale(200), (win_height - DisplayScaling.scale(250)) / 2, win_width + DisplayScaling.scale(400), DisplayScaling.scale(250) ) background.setBrush(QtGui.QColor(0, 0, 0, 180)) background.setPen(QtGui.QPen(QtCore.Qt.NoPen)) background.setZValue(1000) background.original_rect = background.rect() setattr(background, '_is_pause_message', True) self.scene.addItem(background) pause_font = QtGui.QFont("Arial", DisplayScaling.font_size(24), QtGui.QFont.Bold) pause_text = self.scene.addText(loc.get("paused_msg"), pause_font) pause_text.setDefaultTextColor(QtGui.QColor(255, 255, 255)) pause_text.setZValue(1002) setattr(pause_text, '_is_pause_message', True) text_rect = pause_text.boundingRect() pause_text_x = (win_width - text_rect.width()) / 2 pause_text_y = (win_height - text_rect.height()) / 2 - DisplayScaling.scale(30) pause_text.setPos(pause_text_x, pause_text_y) sub_font = QtGui.QFont("Arial", DisplayScaling.font_size(14)) sub_text = self.scene.addText(loc.get("paused_sub"), sub_font) sub_text.setDefaultTextColor(QtGui.QColor(200, 200, 200)) sub_text.setZValue(1002) setattr(sub_text, '_is_pause_message', True) sub_rect = sub_text.boundingRect() sub_text_x = (win_width - sub_rect.width()) / 2 sub_text_y = pause_text_y + text_rect.height() + 10 sub_text.setPos(sub_text_x, sub_text_y) self.pause_redraw_timer = QtCore.QTimer() self.pause_redraw_timer.timeout.connect(self._redraw_pause_message) self.pause_redraw_timer.start(500) else: if hasattr(self, 'pause_redraw_timer'): self.pause_redraw_timer.stop() def _remove_all_pause_elements(self): if hasattr(self, 'pause_redraw_timer') and self.pause_redraw_timer: self.pause_redraw_timer.stop() for item in self.scene.items(): if hasattr(item, '_is_pause_message'): self.scene.removeItem(item) def _redraw_pause_message(self): if not hasattr(self, 'tamagotchi_logic') or self.tamagotchi_logic.simulation_speed != 0: if hasattr(self, 'pause_redraw_timer'): self.pause_redraw_timer.stop() return for item in self.scene.items(): if hasattr(item, '_is_pause_message'): if isinstance(item, QtWidgets.QGraphicsRectItem) and hasattr(item, 'original_rect'): item.setRect(item.original_rect) else: self.scene.removeItem(item) win_width = self.window_width win_height = self.window_height loc = Localization.instance() background = QtWidgets.QGraphicsRectItem( -200, (win_height - 250) / 2, win_width + 400, 250 ) background.setBrush(QtGui.QColor(0, 0, 0, 180)) background.setPen(QtGui.QPen(QtCore.Qt.NoPen)) background.setZValue(1000) background.original_rect = background.rect() setattr(background, '_is_pause_message', True) self.scene.addItem(background) pause_text = self.scene.addText(loc.get("paused_msg"), QtGui.QFont("Arial", 24, QtGui.QFont.Bold)) pause_text.setDefaultTextColor(QtGui.QColor(255, 255, 255)) pause_text.setZValue(1002) setattr(pause_text, '_is_pause_message', True) text_rect = pause_text.boundingRect() pause_text_x = (win_width - text_rect.width()) / 2 pause_text_y = (win_height - text_rect.height()) / 2 - 30 pause_text.setPos(pause_text_x, pause_text_y) sub_text = self.scene.addText(loc.get("paused_sub"), QtGui.QFont("Arial", 14)) sub_text.setDefaultTextColor(QtGui.QColor(200, 200, 200)) sub_text.setZValue(1002) setattr(sub_text, '_is_pause_message', True) sub_rect = sub_text.boundingRect() sub_text_x = (win_width - sub_rect.width()) / 2 sub_text_y = pause_text_y + text_rect.height() + 10 sub_text.setPos(sub_text_x, sub_text_y) self.scene.update() self.view.viewport().update() def handle_window_resize(self, event): self.window_width = event.size().width() self.window_height = event.size().height() self.scene.setSceneRect(0, 0, self.window_width, self.window_height) self.rect_item.setRect(50, 50, self.window_width - 100, self.window_height - 100) self.cleanliness_overlay.setRect(50, 50, self.window_width - 100, self.window_height - 100) self.feeding_message.setPos(0, self.window_height - 75) self.feeding_message.setTextWidth(self.window_width) self.points_label.setPos(self.window_width - 265, 10) self.points_value_label.setPos(self.window_width - 95, 10) self.debug_text.setPos(self.window_width - 60, self.window_height - 60) if hasattr(self, 'current_message_item') and self.current_message_item in self.scene.items(): text_height = self.current_message_item.boundingRect().height() message_y = self.window_height - text_height - 20 self.current_message_item.setPos(0, message_y) self.current_message_item.setTextWidth(self.window_width) if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic and hasattr(self.tamagotchi_logic, 'squid') and self.tamagotchi_logic.squid: squid = self.tamagotchi_logic.squid squid.ui.window_width = self.window_width squid.ui.window_height = self.window_height squid.center_x = self.window_width // 2 squid.center_y = self.window_height // 2 if hasattr(squid, 'update_preferred_vertical_range'): squid.update_preferred_vertical_range() squid.squid_x = max(50, min(squid.squid_x, self.window_width - 50 - squid.squid_width)) squid.squid_y = max(50, min(squid.squid_y, self.window_height - 120 - squid.squid_height)) squid.squid_item.setPos(squid.squid_x, squid.squid_y) if hasattr(squid, 'update_view_cone'): squid.update_view_cone() if hasattr(squid, 'startled_icon') and squid.startled_icon is not None: squid.update_startled_icon_position() if hasattr(squid, 'sick_icon_item') and squid.sick_icon_item is not None: squid.update_sick_icon_position() def show_message(self, message): if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: if hasattr(self.tamagotchi_logic, 'plugin_manager'): results = self.tamagotchi_logic.plugin_manager.trigger_hook( "on_message_display", ui=self, original_message=message ) for result in results: if isinstance(result, str) and result: message = result break try: for item in self.scene.items(): try: if hasattr(item, '_is_message_item'): self.scene.removeItem(item) except Exception: continue except Exception: pass message_item = QtWidgets.QGraphicsTextItem(message) message_item.setDefaultTextColor(QtGui.QColor(255, 255, 255)) from .display_scaling import DisplayScaling font = QtGui.QFont("Verdana", DisplayScaling.font_size(12), QtGui.QFont.Bold) message_item.setFont(font) message_item.setTextWidth(self.window_width) text_height = message_item.boundingRect().height() message_y = self.window_height - text_height - 40 message_item.setPos(0, message_y) message_item.setHtml(f'
    {message}
    ') message_item.setZValue(999) message_item.setOpacity(1) try: setattr(message_item, '_is_message_item', True) except TypeError: pass self.scene.addItem(message_item) self.current_message_item = message_item self.fade_out_animation = QtCore.QPropertyAnimation(message_item, b"opacity") self.fade_out_animation.setDuration(10000) self.fade_out_animation.setStartValue(1.0) self.fade_out_animation.setEndValue(0.0) self.fade_out_animation.finished.connect(lambda: self.scene.removeItem(message_item)) self.fade_out_animation.start(QtCore.QAbstractAnimation.DeleteWhenStopped) def update_points(self, points): self.points_value_label.setPlainText(str(points)) def get_nearby_decorations(self, x, y, radius=100): nearby_decorations = [] for item in self.scene.items(): if isinstance(item, ResizablePixmapItem): item_center = item.sceneBoundingRect().center() distance = ((item_center.x() - x) ** 2 + (item_center.y() - y) ** 2) ** 0.5 if distance <= radius: nearby_decorations.append(item) return nearby_decorations def move_decoration(self, decoration, dx): current_pos = decoration.pos() new_x = current_pos.x() + dx scene_rect = self.scene.sceneRect() new_x = max(scene_rect.left(), min(new_x, scene_rect.right() - decoration.boundingRect().width())) animation = QtCore.QVariantAnimation() animation.setStartValue(current_pos) animation.setEndValue(QtCore.QPointF(new_x, current_pos.y())) animation.setDuration(300) animation.setEasingCurve(QtCore.QEasingCurve.OutCubic) animation.valueChanged.connect(decoration.setPos) decoration._animation = animation animation.start(QtCore.QAbstractAnimation.DeleteWhenStopped) def dragEnterEvent(self, event): if event.mimeData().hasUrls(): event.accept() else: event.ignore() def dragMoveEvent(self, event): if event.mimeData().hasUrls(): event.accept() else: event.ignore() def dropEvent(self, event): if event.mimeData().hasUrls(): url = event.mimeData().urls()[0] file_path = url.toLocalFile() pixmap = QtGui.QPixmap(file_path) if not pixmap.isNull(): filename = os.path.basename(file_path) item = ResizablePixmapItem(pixmap, file_path) item.original_pixmap = pixmap if not ('rock01' in file_path.lower() or 'rock02' in file_path.lower()): from .display_scaling import DisplayScaling target_max_size = DisplayScaling.scale(192) orig_width = pixmap.width() orig_height = pixmap.height() max_dimension = max(orig_width, orig_height) if max_dimension > target_max_size: scale_factor = target_max_size / max_dimension scaled_pixmap = pixmap.scaled( int(orig_width * scale_factor), int(orig_height * scale_factor), QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation ) item.setPixmap(scaled_pixmap) pos = self.view.mapToScene(event.pos()) item.setPos(pos) self.scene.addItem(item) self.scene.clearSelection() item.setSelected(True) unique_id = str(uuid.uuid4()) item._decoration_id = unique_id if unique_id not in self.awarded_decorations: self.awarded_decorations.add(unique_id) if (hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic is not None and hasattr(self.tamagotchi_logic, 'statistics_window') and self.tamagotchi_logic.statistics_window): self.tamagotchi_logic.statistics_window.award(10) event.accept() def keyPressEvent(self, event): if event.key() == QtCore.Qt.Key_Delete: self.delete_selected_items() elif event.key() == QtCore.Qt.Key_N and event.modifiers() & QtCore.Qt.ShiftModifier: self.direct_create_neuron() def show_preferences(self): """Show the preferences window""" if not hasattr(self, '_preferences_window') or not self._preferences_window: self._preferences_window = PreferencesWindow(self.window) self._preferences_window.show() self._preferences_window.raise_() self._preferences_window.activateWindow() def direct_create_neuron(self): try: if not hasattr(self, 'squid_brain_window') or not self.squid_brain_window: print("ERROR: Brain window not initialized") return brain = self.squid_brain_window.brain_widget import time import random new_name = f"forced_{int(time.time())}" if brain.neuron_positions: x_values = [pos[0] for pos in brain.neuron_positions.values()] y_values = [pos[1] for pos in brain.neuron_positions.values()] center_x = sum(x_values) / len(x_values) center_y = sum(y_values) / len(y_values) else: center_x, center_y = 600, 300 pos_x = center_x + random.randint(-100, 100) pos_y = center_y + random.randint(-100, 100) print(f"Creating neuron {new_name} at ({pos_x}, {pos_y})") brain.neuron_positions[new_name] = (pos_x, pos_y) brain.state[new_name] = 75 if hasattr(brain, 'state_colors'): brain.state_colors[new_name] = (150, 200, 255) excluded = getattr(brain, 'excluded_neurons', []) for existing in list(brain.neuron_positions.keys()): if existing != new_name and existing not in excluded: weight = random.uniform(-0.3, 0.3) brain.weights[(new_name, existing)] = weight brain.weights[(existing, new_name)] = weight * 0.8 if hasattr(brain, 'neurogenesis_data'): if 'new_neurons' not in brain.neurogenesis_data: brain.neurogenesis_data['new_neurons'] = [] brain.neurogenesis_data['new_neurons'].append(new_name) brain.neurogenesis_data['last_neuron_time'] = time.time() if hasattr(brain, 'neurogenesis_highlight'): brain.neurogenesis_highlight = { 'neuron': new_name, 'start_time': time.time(), 'duration': 5.0 } brain.update() print(f"Successfully created neuron: {new_name}") except Exception as e: import traceback print(f"NEUROGENESIS FAILURE:\n{traceback.format_exc()}") def delete_selected_items(self): for item in self.scene.selectedItems(): if isinstance(item, ResizablePixmapItem): is_poop = (hasattr(item, 'category') and item.category == 'poop') or \ (hasattr(item, 'filename') and item.filename and 'poop' in item.filename.lower()) if is_poop: if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic and \ hasattr(self.tamagotchi_logic, 'statistics_window') and self.tamagotchi_logic.statistics_window: self.tamagotchi_logic.statistics_window.award(5) self.show_floating_score(item, "+5") if hasattr(item, '_decoration_id'): self.awarded_decorations.discard(item._decoration_id) self.scene.removeItem(item) self.scene.update() def show_floating_score(self, item, text, color=QtGui.QColor(50, 205, 50)): item_rect = item.sceneBoundingRect() center_x = item_rect.center().x() top_y = item_rect.top() - 30 score_text = QtWidgets.QGraphicsTextItem(text) score_text.setDefaultTextColor(color) from .display_scaling import DisplayScaling font = QtGui.QFont("Arial", DisplayScaling.font_size(16), QtGui.QFont.Bold) score_text.setFont(font) text_width = score_text.boundingRect().width() score_text.setPos(center_x - text_width / 2, top_y) score_text.setZValue(1000) self.scene.addItem(score_text) start_pos = score_text.pos() end_pos = QtCore.QPointF(start_pos.x(), start_pos.y() - 40) pos_animation = QtCore.QVariantAnimation() pos_animation.setStartValue(start_pos) pos_animation.setEndValue(end_pos) pos_animation.setDuration(1000) pos_animation.setEasingCurve(QtCore.QEasingCurve.OutQuad) pos_animation.valueChanged.connect(score_text.setPos) opacity_animation = QtCore.QPropertyAnimation(score_text, b"opacity") opacity_animation.setStartValue(1.0) opacity_animation.setEndValue(0.0) opacity_animation.setDuration(1000) opacity_animation.setEasingCurve(QtCore.QEasingCurve.InQuad) def cleanup(): try: if score_text.scene(): self.scene.removeItem(score_text) except: pass opacity_animation.finished.connect(cleanup) score_text._pos_animation = pos_animation score_text._opacity_animation = opacity_animation pos_animation.start() opacity_animation.start() def setup_menu_bar(self): loc = Localization.instance() self.menu_bar = self.window.menuBar() file_menu = self.menu_bar.addMenu(loc.get("file")) self.new_game_action = QtWidgets.QAction(loc.get("new_game"), self.window) self.load_action = QtWidgets.QAction(loc.get("load_game"), self.window) self.save_action = QtWidgets.QAction(loc.get("save_game"), self.window) file_menu.addAction(self.new_game_action) file_menu.addAction(self.load_action) file_menu.addAction(self.save_action) view_menu = self.menu_bar.addMenu(loc.get("view")) self.brain_designer_action = QtWidgets.QAction(loc.get("brain_designer"), self.window) self.brain_designer_action.setIcon(self.window.style().standardIcon(QtWidgets.QStyle.SP_FileDialogDetailedView)) self.brain_designer_action.triggered.connect(self.open_brain_designer) self.brain_designer_action.setShortcut("Ctrl+Shift+B") self.brain_designer_action.setToolTip("Open the visual neural network designer") view_menu.addAction(self.brain_designer_action) view_menu.addSeparator() self.decorations_action = QtWidgets.QAction(loc.get("decorations"), self.window) self.decorations_action.setCheckable(True) self.decorations_action.triggered.connect(self.toggle_decoration_window) view_menu.addAction(self.decorations_action) self.stats_window_action = QtWidgets.QAction(loc.get("statistics"), self.window) self.stats_window_action.triggered.connect(self.toggle_statistics_window) view_menu.addAction(self.stats_window_action) self.brain_action = QtWidgets.QAction(loc.get("brain_tool"), self.window) self.brain_action.setCheckable(True) self.brain_action.triggered.connect(self.toggle_brain_window) view_menu.addAction(self.brain_action) self.neurogenesis_debug_action = QtWidgets.QAction(loc.get("neuron_lab"), self.window) self.neurogenesis_debug_action.triggered.connect(self.show_neuron_laboratory) view_menu.addAction(self.neurogenesis_debug_action) self.preferences_action = QtWidgets.QAction(loc.get("preferences"), self.window) self.preferences_action.triggered.connect(self.show_preferences) self.preferences_action.setShortcut("Ctrl+P") view_menu.addAction(self.preferences_action) speed_menu = self.menu_bar.addMenu(loc.get("speed")) self.pause_action = QtWidgets.QAction(loc.get("pause"), self.window) self.pause_action.setCheckable(True) self.pause_action.triggered.connect(lambda: self.set_simulation_speed(0)) speed_menu.addAction(self.pause_action) self.normal_speed_action = QtWidgets.QAction(loc.get("normal_speed"), self.window) self.normal_speed_action.setCheckable(True) self.normal_speed_action.triggered.connect(lambda: self.set_simulation_speed(1)) speed_menu.addAction(self.normal_speed_action) self.fast_speed_action = QtWidgets.QAction(loc.get("fast_speed"), self.window) self.fast_speed_action.setCheckable(True) self.fast_speed_action.triggered.connect(lambda: self.set_simulation_speed(2)) speed_menu.addAction(self.fast_speed_action) self.very_fast_speed_action = QtWidgets.QAction(loc.get("very_fast"), self.window) self.very_fast_speed_action.setCheckable(True) self.very_fast_speed_action.triggered.connect(lambda: self.set_simulation_speed(3)) speed_menu.addAction(self.very_fast_speed_action) self.speed_action_group = QtWidgets.QActionGroup(self.window) self.speed_action_group.addAction(self.pause_action) self.speed_action_group.addAction(self.normal_speed_action) self.speed_action_group.addAction(self.fast_speed_action) self.speed_action_group.addAction(self.very_fast_speed_action) actions_menu = self.menu_bar.addMenu(loc.get("actions")) self.feed_action = QtWidgets.QAction(loc.get("feed"), self.window) actions_menu.addAction(self.feed_action) self.clean_action = QtWidgets.QAction(loc.get("clean"), self.window) actions_menu.addAction(self.clean_action) self.medicine_action = QtWidgets.QAction(loc.get("medicine"), self.window) actions_menu.addAction(self.medicine_action) debug_menu = self.menu_bar.addMenu(loc.get("debug")) self.debug_action = QtWidgets.QAction(loc.get("toggle_debug"), self.window) self.debug_action.setCheckable(True) self.debug_action.triggered.connect(self.toggle_debug_mode) debug_menu.addAction(self.debug_action) self.view_cone_action = QtWidgets.QAction(loc.get("toggle_cone"), self.window) self.view_cone_action.setCheckable(True) self.view_cone_action.setShortcut('V') if hasattr(self.tamagotchi_logic, 'connect_view_cone_action'): self.view_cone_action.triggered.connect(self.tamagotchi_logic.connect_view_cone_action) elif hasattr(self, 'tamagotchi_logic') and hasattr(self.tamagotchi_logic, 'squid') and hasattr(self.tamagotchi_logic.squid, 'toggle_view_cone'): self.view_cone_action.triggered.connect(self.tamagotchi_logic.squid.toggle_view_cone) debug_menu.addAction(self.view_cone_action) self.vision_action = QtWidgets.QAction(loc.get("squid_vision"), self.window) self.vision_action.triggered.connect(self.show_vision_window) debug_menu.addAction(self.vision_action) self.neuron_monitor_action = QtWidgets.QAction("Neuron Output Monitor", self.window) self.neuron_monitor_action.triggered.connect(self.show_neuron_output_monitor) debug_menu.addAction(self.neuron_monitor_action) self.rock_test_action = QtWidgets.QAction('Rock test (forced)', self.window) self.rock_test_action.triggered.connect(self.trigger_rock_test) self.experience_buffer_action = QtWidgets.QAction("Neurogenesis Experience Buffer", self.window) self.experience_buffer_action.triggered.connect(self.show_experience_buffer) self.experience_buffer_action.setToolTip("View neurogenesis experience buffer") debug_menu.addAction(self.experience_buffer_action) # ── Export submenu ──────────────────────────────────────────────── debug_menu.addSeparator() export_menu = debug_menu.addMenu("Export…") memories_submenu = export_menu.addMenu("Memories") self.export_stm_action = QtWidgets.QAction("Export STM", self.window) self.export_stm_action.setToolTip("Export short-term memory to /exports folder") self.export_stm_action.triggered.connect(lambda: self.export_memory("stm")) memories_submenu.addAction(self.export_stm_action) self.export_ltm_action = QtWidgets.QAction("Export LTM", self.window) self.export_ltm_action.setToolTip("Export long-term memory to /exports folder") self.export_ltm_action.triggered.connect(lambda: self.export_memory("ltm")) memories_submenu.addAction(self.export_ltm_action) self.export_all_memory_action = QtWidgets.QAction("Export All", self.window) self.export_all_memory_action.setToolTip("Export all memory to /exports folder") self.export_all_memory_action.triggered.connect(lambda: self.export_memory("all")) memories_submenu.addAction(self.export_all_memory_action) weights_submenu = export_menu.addMenu("Weights") self.export_weights_csv_action = QtWidgets.QAction("CSV", self.window) self.export_weights_csv_action.setToolTip("Export weights as CSV to /exports folder") self.export_weights_csv_action.triggered.connect(lambda: self.export_weights("csv")) weights_submenu.addAction(self.export_weights_csv_action) self.export_weights_txt_action = QtWidgets.QAction("TXT", self.window) self.export_weights_txt_action.setToolTip("Export weights as TXT to /exports folder") self.export_weights_txt_action.triggered.connect(lambda: self.export_weights("txt")) weights_submenu.addAction(self.export_weights_txt_action) self.export_neurons_action = QtWidgets.QAction("Neurons", self.window) self.export_neurons_action.setToolTip("Export neuron list to /exports folder") self.export_neurons_action.triggered.connect(self.export_neurons) export_menu.addAction(self.export_neurons_action) # ───────────────────────────────────────────────────────────────── self.plugins_menu = self.menu_bar.addMenu(loc.get("plugins")) self.connect_action_buttons() def show_neuron_output_monitor(self): if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: if hasattr(self.tamagotchi_logic, 'neuron_output_monitor'): # Force the window to be created and shown self.tamagotchi_logic.neuron_output_monitor._ensure_log_window() if self.tamagotchi_logic.neuron_output_monitor.log_window: self.tamagotchi_logic.neuron_output_monitor.log_window.show() self.tamagotchi_logic.neuron_output_monitor.log_window.raise_() else: print("Neuron output monitor not initialized") def show_task_manager(self): if not hasattr(self, '_task_manager') or not self._task_manager: worker = getattr(self.tamagotchi_logic, 'brain_worker', None) self._task_manager = TaskManagerWindow(worker, self.window) self._task_manager.show() self._task_manager.raise_() def show_vision_window(self): if not hasattr(self, 'vision_window') or self.vision_window is None or not self.vision_window.isVisible(): if self.tamagotchi_logic: self.vision_window = VisionWindow(self.tamagotchi_logic, self.window) self.vision_window.show() else: QtWidgets.QMessageBox.warning(self.window, "Error", "Game logic is not yet initialized.") else: self.vision_window.raise_() self.vision_window.activateWindow() def set_simulation_speed(self, speed): if hasattr(self, 'tamagotchi_logic'): is_paused = (speed == 0) self.show_pause_message(is_paused) self.tamagotchi_logic.set_simulation_speed(speed) self.pause_action.setChecked(speed == 0) self.normal_speed_action.setChecked(speed == 1) self.fast_speed_action.setChecked(speed == 2) self.very_fast_speed_action.setChecked(speed == 3) speed_names = ["Paused", "Normal", "Fast", "Very Fast"] self.show_message(f"Simulation speed set to {speed_names[speed]}") else: self.show_message("Game logic not initialized!") def toggle_debug_mode(self): if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic is not None: current_debug = self.tamagotchi_logic.debug_mode else: current_debug = self.debug_mode new_debug_mode = not current_debug if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic is not None: self.tamagotchi_logic._propagating_debug_mode = True self.debug_mode = new_debug_mode if hasattr(self, 'debug_action'): self.debug_action.setChecked(new_debug_mode) if hasattr(self, 'debug_text'): self.debug_text.setVisible(new_debug_mode) self.scene.update() if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic is not None: self.tamagotchi_logic.debug_mode = new_debug_mode if hasattr(self.tamagotchi_logic, 'statistics_window'): self.tamagotchi_logic.statistics_window.set_debug_mode(new_debug_mode) if hasattr(self, 'squid_brain_window'): self.squid_brain_window.debug_mode = new_debug_mode if hasattr(self.squid_brain_window, 'brain_widget'): self.squid_brain_window.brain_widget.debug_mode = new_debug_mode self.squid_brain_window.brain_widget.update() if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic is not None: self.tamagotchi_logic._propagating_debug_mode = False print(f"Debug mode {'enabled' if new_debug_mode else 'disabled'}") try: self.show_message(f"Debug mode {'enabled' if new_debug_mode else 'disabled'}") except TypeError: pass def trigger_rock_test(self): if not hasattr(self.tamagotchi_logic, 'rock_interaction'): self.show_message("Rock interaction system not initialized!") return rocks = [item for item in self.scene.items() if isinstance(item, ResizablePixmapItem) and self.tamagotchi_logic.rock_interaction.is_valid_rock(item)] if not rocks: self.show_message("No rocks found in the tank!") return if not hasattr(self.tamagotchi_logic, 'squid'): self.show_message("Squid not initialized!") return nearest_rock = min(rocks, key=lambda r: math.hypot( r.sceneBoundingRect().center().x() - self.tamagotchi_logic.squid.squid_x, r.sceneBoundingRect().center().y() - self.tamagotchi_logic.squid.squid_y ) ) self.tamagotchi_logic.rock_interaction.start_rock_test(nearest_rock) self.show_message("Rock test initiated") def start_rps_game(self): if hasattr(self, 'tamagotchi_logic'): self.tamagotchi_logic.start_rps_game() else: print("TamagotchiLogic not initialized") def test_rock_interaction(self): if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: if not self.tamagotchi_logic.debug_mode: self.show_message("Enable debug mode first!") return print("[DEBUG] Starting rock interaction test from menu...") self.tamagotchi_logic.test_rock_interaction() else: print("TamagotchiLogic not available for rock testing") self.show_message("Game logic not initialized!") def show_neuron_inspector(self): if not self.squid_brain_window: if hasattr(self, 'tamagotchi_logic') and self.tamagotchi_logic: current_debug_mode = getattr(self.tamagotchi_logic, 'debug_mode', False) if hasattr(self.tamagotchi_logic, 'brain_window') and self.tamagotchi_logic.brain_window: self.squid_brain_window = self.tamagotchi_logic.brain_window else: self.squid_brain_window = SquidBrainWindow(self.tamagotchi_logic, current_debug_mode, show_decorations_callback=self.show_decorations_window) if hasattr(self.tamagotchi_logic, 'set_brain_window'): self.tamagotchi_logic.set_brain_window(self.squid_brain_window) else: self.show_message("Brain Tool is not initialized yet.") return if not self.squid_brain_window.isVisible(): self.squid_brain_window.show() if not hasattr(self.squid_brain_window, 'brain_widget') or not self.squid_brain_window.brain_widget: self.show_message("Brain widget component is not ready.") return if not hasattr(self, 'enhanced_neuron_inspector_instance') or \ not self.enhanced_neuron_inspector_instance or \ not self.enhanced_neuron_inspector_instance.isVisible(): self.enhanced_neuron_inspector_instance = EnhancedNeuronInspector( brain_tool_window=self.squid_brain_window, brain_widget_ref=self.squid_brain_window.brain_widget ) self.enhanced_neuron_inspector_instance.show() self.enhanced_neuron_inspector_instance.raise_() self.enhanced_neuron_inspector_instance.activateWindow() self.enhanced_neuron_inspector_instance.update_neuron_list() if self.enhanced_neuron_inspector_instance.neuron_combo.count() > 0: self.enhanced_neuron_inspector_instance.update_info() def toggle_statistics_window(self): if self.statistics_window is None: self.create_statistics_window() if self.statistics_window is not None: if self.statistics_window.isVisible(): self.statistics_window.hide() else: self.statistics_window.show() else: print("Failed to create statistics window") def create_statistics_window(self): if hasattr(self, 'tamagotchi_logic'): if not hasattr(self.tamagotchi_logic, 'statistics_window'): self.tamagotchi_logic.statistics_window = StatisticsWindow(self.tamagotchi_logic.squid, show_decorations_callback=self.show_decorations_window) self.statistics_window = self.tamagotchi_logic.statistics_window else: print("TamagotchiLogic not initialized") def toggle_brain_window(self, checked): if checked: self.squid_brain_window.show() else: self.squid_brain_window.hide() def toggle_designer_mode(self, checked=None): if not hasattr(self, 'squid_brain_window') or not self.squid_brain_window: self.show_message("Brain Tool not initialized") if hasattr(self, 'designer_mode_action'): self.designer_mode_action.setChecked(False) return if not hasattr(self.squid_brain_window, 'designer_controller') or \ not self.squid_brain_window.designer_controller: self.show_message("Designer mode not available - integration required") if hasattr(self, 'designer_mode_action'): self.designer_mode_action.setChecked(False) return if not self.squid_brain_window.isVisible(): self.squid_brain_window.show() if hasattr(self, 'brain_action'): self.brain_action.setChecked(True) controller = self.squid_brain_window.designer_controller controller.toggle_designer_mode() is_active = controller.designer_mode_active if hasattr(self, 'designer_mode_action'): self.designer_mode_action.setChecked(is_active) status = "enabled" if is_active else "disabled" self.show_message(f"Designer mode {status}") def connect_view_cone_action(self, toggle_function): self.view_cone_action.triggered.connect(toggle_function) def get_decorations_data(self): decorations_data = [] for item in self.scene.items(): if isinstance(item, ResizablePixmapItem): try: pixmap = item.pixmap() buffer = QtCore.QBuffer() buffer.open(QtCore.QIODevice.WriteOnly) pixmap.save(buffer, "PNG") pixmap_data = base64.b64encode(buffer.data()).decode('utf-8') pos = item.pos() pos_tuple = (pos.x(), pos.y()) scale = item.scale() filename = getattr(item, 'filename', 'unknown') decoration_dict = { 'pixmap_data': pixmap_data, 'pos': pos_tuple, 'scale': scale, 'filename': filename } decorations_data.append(decoration_dict) except Exception as e: print(f"Error serializing decoration: {e}") continue print(f"Saved {len(decorations_data)} decoration(s)") return decorations_data def load_decorations_data(self, decorations_data): if not decorations_data: print("No decorations to load") return for item in list(self.scene.items()): if isinstance(item, ResizablePixmapItem): self.scene.removeItem(item) if hasattr(self, 'decoration_window'): self.decoration_window.decoration_items.clear() loaded_count = 0 for decoration_dict in decorations_data: try: pixmap_data = decoration_dict['pixmap_data'] pixmap = QtGui.QPixmap() pixmap.loadFromData( QtCore.QByteArray(base64.b64decode(pixmap_data.encode('utf-8'))) ) filename = decoration_dict.get('filename', 'unknown') item = ResizablePixmapItem(pixmap, filename) pos_tuple = decoration_dict['pos'] item.setPos(QtCore.QPointF(pos_tuple[0], pos_tuple[1])) scale = decoration_dict.get('scale', 1.0) item.setScale(scale) self.scene.addItem(item) if hasattr(self, 'decoration_window'): self.decoration_window.add_decoration_item(item) loaded_count += 1 except Exception as e: print(f"Error loading decoration: {e}") import traceback traceback.print_exc() continue print(f"Loaded {loaded_count} decoration(s)") def get_pixmap_data(self, item): pixmap = item.pixmap() buffer = QtCore.QBuffer() buffer.open(QtCore.QIODevice.WriteOnly) pixmap.save(buffer, "PNG") pixmap_data = buffer.data().toBase64().data().decode() return pixmap_data def closeEvent(self, event): event.ignore() self.hide() if hasattr(self.parent(), 'decorations_action'): self.parent().decorations_action.setChecked(False) def get_rock_items(self): return [item for item in self.scene.items() if isinstance(item, ResizablePixmapItem) and getattr(item, 'can_be_picked_up', False)] def highlight_rock(self, rock, highlight=True): effect = QtWidgets.QGraphicsColorizeEffect() effect.setColor(QtGui.QColor(255, 255, 0)) effect.setStrength(0.7 if highlight else 0.0) rock.setGraphicsEffect(effect if highlight else None) def reset_all_rock_states(self): for rock in self.get_rock_items(): rock.is_being_carried = False self.highlight_rock(rock, False) ================================================ FILE: src/vision.py ================================================ from PyQt5 import QtCore, QtGui, QtWidgets from .brain_base_tab import BrainBaseTab from .localisation import loc class VisionWindow(QtWidgets.QDialog): def __init__(self, tamagotchi_logic, parent=None): super().__init__(parent) self.tamagotchi_logic = tamagotchi_logic self.setWindowTitle(loc("vision_window_title")) self.setMinimumSize(400, 300) # Store original view cone state and enable it for the dialog self.original_view_cone_state = self.tamagotchi_logic.squid.view_cone_visible if not self.original_view_cone_state: self.tamagotchi_logic.squid.toggle_view_cone() self.initialize_ui() # Timer to refresh the view self.update_timer = QtCore.QTimer(self) self.update_timer.timeout.connect(self.update_view) self.update_timer.start(1000) # Update every second def initialize_ui(self): """Initializes the UI for the Vision window.""" layout = QtWidgets.QVBoxLayout(self) # Set layout on the dialog itself layout.setContentsMargins(15, 15, 15, 15) layout.setSpacing(10) # Title title_layout = QtWidgets.QHBoxLayout() title_icon = QtWidgets.QLabel("👁️") title_icon.setStyleSheet("font-size: 28px;") # Reuse existing key "visible_objects" title_label = QtWidgets.QLabel(loc("visible_objects")) title_label.setStyleSheet("font-size: 24px; font-weight: bold; color: #343a40;") title_layout.addWidget(title_icon) title_layout.addWidget(title_label) title_layout.addStretch() layout.addLayout(title_layout) # List widget to display visible objects self.visible_objects_list = QtWidgets.QListWidget() self.visible_objects_list.setStyleSheet(""" QListWidget { background-color: #303030; border: 1px solid #dee2e6; border-radius: 8px; padding: 10px; font-size: 24px; color: white; } QListWidget::item { padding: 5px; } QListWidget::item:hover { background-color: #f1f3f5; } """) layout.addWidget(self.visible_objects_list) # Create a horizontal layout for the buttons button_layout = QtWidgets.QHBoxLayout() # Add the new "Toggle View Cone" button # Reuse existing key "toggle_cone" from Debug menu self.toggle_cone_button = QtWidgets.QPushButton(loc("toggle_cone")) self.toggle_cone_button.clicked.connect(self.toggle_view_cone) button_layout.addWidget(self.toggle_cone_button) # Add a stretch to push the close button to the right button_layout.addStretch() # Add the close button # Reuse existing key "close" self.close_button = QtWidgets.QPushButton(loc("close")) self.close_button.clicked.connect(self.close) button_layout.addWidget(self.close_button) # Add the button layout to the main layout layout.addLayout(button_layout) def toggle_view_cone(self): """Toggles the visibility of the squid's view cone.""" if self.tamagotchi_logic and hasattr(self.tamagotchi_logic, 'squid'): self.tamagotchi_logic.squid.toggle_view_cone() def closeEvent(self, event): """Override the close event to restore the view cone's original state.""" if self.tamagotchi_logic.squid.view_cone_visible != self.original_view_cone_state: self.tamagotchi_logic.squid.toggle_view_cone() super().closeEvent(event) def update_view(self): """The method to update the content, formerly update_from_brain_state.""" if not self.tamagotchi_logic or not hasattr(self.tamagotchi_logic, 'squid'): self.visible_objects_list.clear() self.visible_objects_list.addItem(loc("vis_logic_unavailable")) return squid = self.tamagotchi_logic.squid # Gather all world objects to check for visibility all_objects = [] if hasattr(self.tamagotchi_logic, 'food_items'): all_objects.extend(self.tamagotchi_logic.food_items) if hasattr(self.tamagotchi_logic, 'poop_items'): all_objects.extend(self.tamagotchi_logic.poop_items) if hasattr(self.tamagotchi_logic, 'user_interface') and hasattr(self.tamagotchi_logic.user_interface, 'scene'): all_decorations = [item for item in self.tamagotchi_logic.user_interface.scene.items() if hasattr(item, 'category')] all_objects.extend(all_decorations) # Use the squid's own vision method to get what it can see visible_objects = squid.get_visible_objects(all_objects) self.visible_objects_list.clear() if not visible_objects: self.visible_objects_list.addItem(loc("vis_nothing_in_view")) else: for obj in visible_objects: # Reuse existing "unknown" key obj_name = loc("unknown") # Determine object name based on its attributes if hasattr(obj, 'category') and obj.category: # Attempt to translate category (e.g. 'rock', 'plant') using existing keys obj_name = loc(obj.category, default=obj.category.capitalize()) elif hasattr(obj, 'is_sushi'): # Use existing "sushi" and "food" keys (Cheese is default food) obj_name = loc("sushi") if obj.is_sushi else loc("food") distance = squid.distance_to(obj.pos().x(), obj.pos().y()) dist_label = loc("vis_distance") list_item_text = f"{obj_name} ({dist_label}: {distance:.0f})" self.visible_objects_list.addItem(list_item_text) ================================================ FILE: src/vision_worker.py ================================================ """ vision_worker.py - Background thread for squid vision calculations Handles vision cone calculations, food detection, and object visibility in a separate thread to prevent blocking the main UI. The worker maintains a cache of visible objects and emits signals when visibility changes, allowing the squid to react without polling. Usage: 1. Create VisionWorker with scene reference 2. Connect to visibility signals (food_visibility_changed, etc.) 3. Call update_squid_state() periodically with squid position/direction 4. Worker emits signals when visibility changes """ import time import math from typing import Dict, List, Tuple, Optional, Set, Any from dataclasses import dataclass, field from PyQt5.QtCore import QThread, pyqtSignal, QMutex, QMutexLocker, QWaitCondition, QPointF @dataclass class SquidVisionState: """Snapshot of squid's vision-relevant state""" # Position squid_x: float = 0.0 squid_y: float = 0.0 squid_width: float = 100.0 squid_height: float = 100.0 # View direction current_view_angle: float = 0.0 view_cone_angle: float = math.pi / 2.5 # ~72 degrees # Window bounds window_width: float = 1280.0 window_height: float = 900.0 # Timestamp timestamp: float = field(default_factory=time.time) @dataclass class SceneObject: """Lightweight representation of a scene object for vision calculations""" x: float y: float width: float = 64.0 height: float = 64.0 category: str = 'unknown' is_sushi: bool = False obj_id: int = 0 # For tracking identity @dataclass class VisionResult: """Results of a vision calculation""" visible_food: List[Tuple[float, float]] = field(default_factory=list) visible_plants: List[Tuple[float, float]] = field(default_factory=list) visible_rocks: List[Tuple[float, float]] = field(default_factory=list) visible_poop: List[Tuple[float, float]] = field(default_factory=list) # Cached values for quick access can_see_food: bool = False nearest_food_distance: float = float('inf') nearest_food_position: Optional[Tuple[float, float]] = None # Proximity detection (doesn't require vision cone) nearby_plants: List[Tuple[float, float]] = field(default_factory=list) plant_proximity_value: float = 0.0 timestamp: float = field(default_factory=time.time) class VisionWorker(QThread): """ Background worker for squid vision calculations. Signals: food_visibility_changed: Emitted when food visibility changes - bool: can_see_food - list: visible food positions [(x, y), ...] plant_proximity_changed: Emitted when plant proximity changes - float: proximity value (0-100) - list: nearby plant positions visibility_update: Emitted with full visibility results - VisionResult: Complete vision state threat_detected: Emitted when potential threat enters vision - str: threat type - tuple: position (x, y) """ food_visibility_changed = pyqtSignal(bool, list) plant_proximity_changed = pyqtSignal(float, list) visibility_update = pyqtSignal(object) # VisionResult threat_detected = pyqtSignal(str, tuple) def __init__(self, parent=None): super().__init__(parent) # Thread control self._running = True self._paused = False # Mutex for thread-safe access self._mutex = QMutex() self._condition = QWaitCondition() # Current state self._squid_state: Optional[SquidVisionState] = None self._scene_objects: List[SceneObject] = [] self._state_dirty = False # Previous results for change detection self._last_result: Optional[VisionResult] = None self._last_food_visible = False self._last_plant_proximity = 0.0 # Update frequency control self._update_interval = 1.0 / 20.0 # 20 Hz max self._last_update_time = 0.0 # Performance stats self._calc_count = 0 self._total_calc_time = 0.0 def stop(self): """Stop the worker thread""" self._running = False self._mutex.lock() self._condition.wakeAll() self._mutex.unlock() def pause(self): """Pause vision calculations""" self._paused = True def resume(self): """Resume vision calculations""" self._paused = False self._mutex.lock() self._condition.wakeAll() self._mutex.unlock() def update_squid_state(self, state: SquidVisionState): """ Update the squid's position and view state. Call this from the main thread when the squid moves. """ with QMutexLocker(self._mutex): self._squid_state = state self._state_dirty = True self._condition.wakeOne() def update_scene_objects(self, objects: List[SceneObject]): """ Update the list of scene objects to check visibility for. Call this when objects are added/removed from the scene. """ with QMutexLocker(self._mutex): self._scene_objects = objects.copy() self._state_dirty = True self._condition.wakeOne() def get_last_result(self) -> Optional[VisionResult]: """Get the most recent vision result (thread-safe)""" with QMutexLocker(self._mutex): return self._last_result def get_stats(self) -> Dict[str, Any]: """Get performance statistics""" avg_time = self._total_calc_time / max(1, self._calc_count) return { 'calculation_count': self._calc_count, 'avg_calc_time_ms': avg_time, } def run(self): """Main worker loop""" print("👁 VisionWorker started") while self._running: should_calculate = False squid_state = None objects = None # Check if we need to calculate self._mutex.lock() try: if not self._state_dirty and self._running and not self._paused: # Wait for state change or timeout (100ms) self._condition.wait(self._mutex, 100) if self._state_dirty and not self._paused: current_time = time.time() if current_time - self._last_update_time >= self._update_interval: should_calculate = True squid_state = self._squid_state objects = self._scene_objects.copy() self._state_dirty = False self._last_update_time = current_time finally: self._mutex.unlock() # Perform vision calculation if should_calculate and squid_state: start_time = time.perf_counter() try: result = self._calculate_visibility(squid_state, objects or []) # Update cached result with QMutexLocker(self._mutex): self._last_result = result # Check for changes and emit signals self._check_and_emit_changes(result) # Stats calc_time = (time.perf_counter() - start_time) * 1000 self._calc_count += 1 self._total_calc_time += calc_time except Exception as e: print(f"👁 Vision calculation error: {e}") import traceback traceback.print_exc() print("👁 VisionWorker stopped") def _calculate_visibility(self, squid: SquidVisionState, objects: List[SceneObject]) -> VisionResult: """Calculate what the squid can see""" result = VisionResult(timestamp=time.time()) # Get squid center and bounding box squid_cx = squid.squid_x + squid.squid_width / 2 squid_cy = squid.squid_y + squid.squid_height / 2 # Squid bounding box edges squid_left = squid.squid_x squid_right = squid.squid_x + squid.squid_width squid_top = squid.squid_y squid_bottom = squid.squid_y + squid.squid_height # Vision cone length (diagonal of window) cone_length = math.sqrt(squid.window_width**2 + squid.window_height**2) # Half cone angle for comparison half_cone = squid.view_cone_angle / 2 # Process each object food_items = [] min_food_dist = float('inf') nearest_food = None # Track plant proximity (with bounding box awareness) min_plant_edge_dist = float('inf') plant_is_touching = False for obj in objects: # Get object center obj_cx = obj.x + obj.width / 2 obj_cy = obj.y + obj.height / 2 # Object bounding box edges obj_left = obj.x obj_right = obj.x + obj.width obj_top = obj.y obj_bottom = obj.y + obj.height # Calculate center-to-center distance dx = obj_cx - squid_cx dy = obj_cy - squid_cy distance = math.sqrt(dx*dx + dy*dy) # Check if in vision cone in_cone = False if distance <= cone_length: # Calculate angle to object angle_to_obj = math.atan2(dy, dx) # Calculate angle difference (handle wrap-around) angle_diff = abs(angle_to_obj - squid.current_view_angle) while angle_diff > math.pi: angle_diff = 2 * math.pi - angle_diff in_cone = angle_diff <= half_cone # Categorize visible objects pos = (obj.x, obj.y) if obj.category == 'food': if in_cone: # Prioritize sushi if obj.is_sushi: food_items.insert(0, pos) else: food_items.append(pos) if distance < min_food_dist: min_food_dist = distance nearest_food = pos elif obj.category == 'plant': if in_cone: result.visible_plants.append(pos) # ============================================================ # PLANT PROXIMITY - Uses bounding box distance, not center # ============================================================ # Check if bounding boxes overlap (touching = 100) boxes_overlap = not ( squid_right < obj_left or # squid is left of plant squid_left > obj_right or # squid is right of plant squid_bottom < obj_top or # squid is above plant squid_top > obj_bottom # squid is below plant ) if boxes_overlap: plant_is_touching = True result.nearby_plants.append(pos) else: # Calculate distance between bounding box edges # Find the closest point on the plant rect to the squid rect # Horizontal distance if squid_right < obj_left: hdist = obj_left - squid_right elif squid_left > obj_right: hdist = squid_left - obj_right else: hdist = 0 # Overlapping horizontally # Vertical distance if squid_bottom < obj_top: vdist = obj_top - squid_bottom elif squid_top > obj_bottom: vdist = squid_top - obj_bottom else: vdist = 0 # Overlapping vertically # Edge-to-edge distance edge_dist = math.sqrt(hdist*hdist + vdist*vdist) # Track if within proximity range (300px from edge) if edge_dist < 300: result.nearby_plants.append(pos) min_plant_edge_dist = min(min_plant_edge_dist, edge_dist) elif obj.category == 'rock': if in_cone: result.visible_rocks.append(pos) elif obj.category == 'poop': if in_cone: result.visible_poop.append(pos) # Set food results result.visible_food = food_items result.can_see_food = len(food_items) > 0 result.nearest_food_distance = min_food_dist result.nearest_food_position = nearest_food # ============================================================ # Calculate final plant proximity value # ============================================================ if plant_is_touching: # Bounding boxes overlap = maximum proximity result.plant_proximity_value = 100.0 elif result.nearby_plants: # Scale 0-100 based on edge distance (closer = higher) # At 0 distance (touching) = 100, at 300 distance = 0 max_range = 300.0 result.plant_proximity_value = max(0.0, 100.0 - (min_plant_edge_dist / max_range * 100.0)) else: result.plant_proximity_value = 0.0 return result def _check_and_emit_changes(self, result: VisionResult): """Check for changes and emit appropriate signals""" # Food visibility change if result.can_see_food != self._last_food_visible: self._last_food_visible = result.can_see_food self.food_visibility_changed.emit(result.can_see_food, result.visible_food) # Plant proximity change (with threshold to avoid noise) proximity_diff = abs(result.plant_proximity_value - self._last_plant_proximity) if proximity_diff > 5.0: # 5% threshold self._last_plant_proximity = result.plant_proximity_value self.plant_proximity_changed.emit(result.plant_proximity_value, result.nearby_plants) # Always emit full update self.visibility_update.emit(result) def extract_scene_objects(scene, food_items: List, decorations: Optional[List] = None) -> List[SceneObject]: """ Helper function to extract SceneObject list from Qt scene. Call this from the main thread before updating the vision worker. Args: scene: QGraphicsScene instance food_items: List of food item graphics items decorations: Optional list of decoration items (if not provided, extracts from scene) Returns: List of SceneObject instances """ objects = [] obj_id = 0 # Extract food items for item in food_items: try: pos = item.pos() rect = item.boundingRect() objects.append(SceneObject( x=pos.x(), y=pos.y(), width=rect.width(), height=rect.height(), category='food', is_sushi=getattr(item, 'is_sushi', False), obj_id=obj_id )) obj_id += 1 except: pass # Extract decorations from scene if not provided if decorations is None: # Try multiple import paths to handle package context ResizablePixmapItem = None # Try relative import first (when used within package) try: from .ui import ResizablePixmapItem except ImportError: pass # Try absolute import (when used standalone) if ResizablePixmapItem is None: try: from ui import ResizablePixmapItem except ImportError: pass # Fallback: scan scene items by checking for 'category' attribute if ResizablePixmapItem is not None: decorations = [item for item in scene.items() if isinstance(item, ResizablePixmapItem)] else: # Last resort: use duck typing - any item with 'category' attribute decorations = [item for item in scene.items() if hasattr(item, 'category') and item.category in ('plant', 'rock', 'poop', 'decoration')] for item in decorations: try: pos = item.pos() rect = item.boundingRect() category = getattr(item, 'category', 'unknown') # Skip non-categorized items if category not in ('plant', 'rock', 'poop'): continue objects.append(SceneObject( x=pos.x(), y=pos.y(), width=rect.width(), height=rect.height(), category=category, obj_id=obj_id )) obj_id += 1 except: pass return objects def create_squid_vision_state(squid) -> SquidVisionState: """ Helper function to create SquidVisionState from a Squid instance. Call this from the main thread. """ return SquidVisionState( squid_x=squid.squid_x, squid_y=squid.squid_y, squid_width=squid.squid_width, squid_height=squid.squid_height, current_view_angle=getattr(squid, 'current_view_angle', 0.0), view_cone_angle=getattr(squid, 'view_cone_angle', math.pi / 2.5), window_width=squid.ui.window_width, window_height=squid.ui.window_height, timestamp=time.time() ) ================================================ FILE: translations/de.py ================================================ LANGUAGE_HEADER = "de - Deutsch" translations = { # Core continuous neurons "hunger": "Hunger", "happiness": "Glück", "cleanliness": "Sauberkeit", "sleepiness": "Schläfrigkeit", "satisfaction": "Zufriedenheit", "anxiety": "Angst", "curiosity": "Neugier", # Binary/sensor neurons "can_see_food": "Sieht Futter", "is_eating": "Frisst", "is_sleeping": "Schläft", "is_sick": "Krank", "pursuing_food": "Verfolgt Futter", "is_startled": "Erschrocken", "is_fleeing": "Flüchtet", # Base keys for neurogenesis patterns "novelty": "Neuheit", "stress": "Stress", "reward": "Belohnung", # ===== HAUPTMENÜ ===== "file": "Datei", "new_game": "Neues Spiel", "load_game": "Spiel laden", "save_game": "Spiel speichern", "view": "Ansicht", "speed": "Geschwindigkeit", "pause": "Pause", "actions": "Aktionen", "debug": "Debug", "plugins": "Plugins", # ===== ANSICHT-MENÜ ===== "brain_designer": "Gehirn-Designer", "decorations": "Dekorationen", "statistics": "Statistiken", "brain_tool": "Gehirn-Werkzeug", "neuron_lab": "Neuronen-Labor", "task_manager": "Task-Manager", # ===== GESCHWINDIGKEIT-MENÜ ===== "normal_speed": "Normal (1x)", "fast_speed": "Schnell (2x)", "very_fast": "Sehr schnell (3x)", # ===== DEBUG-MENÜ ===== "toggle_debug": "Debug-Modus umschalten", "toggle_cone": "Sichtkegel umschalten", "squid_vision": "Tintenfisch-Sicht", # ===== AKTIONS-SCHALTFLÄCHEN ===== "feed": "Füttern", "clean": "Reinigen", "medicine": "Medizin", "feed_btn": "FÜTTERN", "clean_btn": "REINIGEN", "medicine_btn": "MEDIZIN", # ===== NACHRICHTEN ===== "feed_msg": "Tintenfisch muss gefüttert werden", "points": "Punkte", "dirty": "SCHMUTZIG", "paused_msg": "SIMULATION PAUSIERT", "paused_sub": "Nutzen Sie das Menü Geschwindigkeit zum Fortfahren", # ===== DIALOGE ===== "yes": "Ja", "no": "Nein", "ok": "OK", "cancel": "Abbrechen", "close": "Schließen", "save": "Speichern", "load": "Laden", "reset": "Zurücksetzen", "apply_changes": "Änderungen übernehmen", "got_it": "Verstanden!", "finish": "Abschließen", "confirm_new_game": "Neues Spiel starten? Aktueller Fortschritt geht verloren.", "confirm_exit": "Sind Sie sicher, dass Sie beenden möchten?", "save_successful": "Spiel erfolgreich gespeichert!", "load_successful": "Spiel erfolgreich geladen!", "error_saving": "Fehler beim Speichern des Spiels.", "error_loading": "Fehler beim Laden des Spiels.", "no_save_found": "Kein Speicherstand gefunden.", "startup": "Start", "show_tutorial_q": "Tutorial anzeigen?", "auto_decline": "(Automatische Ablehnung in {seconds}s)", "tutorial_title": "Tutorial", "tutorial_query": "Möchten Sie das Tutorial sehen?", # ===== ÜBER-REITER ===== "hello": "HALLO", "my_name_is": "mein Name ist", "change_name": "Name ändern", "enter_new_name": "Geben Sie einen neuen Namen für Ihren Tintenfisch ein:", "change_colour": "Farbe ändern", "view_certificate": "Zertifikat ansehen", "care_tips": "Pflegetipps", "care_tips_for": "Pflegetipps für {personality} Tintenfische", "dosidicus_title": "Dosidicus electronicus", "dosidicus_desc": "Ein digitales Haustier im Tamagotchi-Stil mit einem einfachen neuronalen Netzwerk", "string_acronym": "Simulated Tamagotchi Reactions via Inferencing and Neurogenesis (STRINg)", "research_project": "Dies ist ein Forschungsprojekt. Bitte schlagen Sie Funktionen vor.", "version_dosidicus": "Dosidicus-Version:", "version_brain_tool": "Gehirn-Werkzeug-Version:", "version_decision": "Entscheidungs-Engine-Version:", "version_neuro": "Neurogenese-Version:", "created_by": "von", # ===== PERSÖNLICHKEIT ===== "squid_personality": "Tintenfisch-Persönlichkeit", "personality_modifier": "Persönlichkeits-Modifikator", "description": "Beschreibung:", "personality_modifiers": "Persönlichkeits-Modifikatoren:", "care_tips_label": "Pflegetipps:", "personality_note": "Hinweis: Die Persönlichkeit wird zu Beginn eines neuen Spiels zufällig generiert", # Persönlichkeitstypen "personality_timid": "Ängstlich", "personality_adventurous": "Abenteuerlustig", "personality_lazy": "Faul", "personality_energetic": "Energetisch", "personality_introvert": "Introvertiert", "personality_greedy": "Gierig", "personality_stubborn": "Stur", # Persönlichkeitsbeschreibungen "desc_timid": "Ihr Tintenfisch ist ängstlich. Er neigt dazu, leichter erschrocken und besorgt zu sein, besonders in neuen Situationen. Er bevorzugt ruhige Umgebungen und erkundet weniger auf eigene Faust. Er kann jedoch starke Bindungen aufbauen, wenn er sich sicher fühlt.", "desc_adventurous": "Ihr Tintenfisch ist abenteuerlustig. Er liebt es, zu erkunden und neue Dinge auszuprobieren. Er ist oft der Erste, der neue Objekte oder Bereiche untersucht. Dieser Tintenfisch blüht bei Neuheiten auf und langweilt sich in gleichbleibender Umgebung schnell.", "desc_lazy": "Ihr Tintenfisch ist faul. Er bevorzugt einen entspannten Lebensstil und ist weniger aktiv. Er benötigt eventuell extra Ermutigung, kann aber auch sehr zufrieden damit sein, einfach nur herumzuliegen. Er ist ein Experte im Energiesparen!", "desc_energetic": "Ihr Tintenfisch ist energetisch. Er ist immer in Bewegung und voller Tatendrang. Dieser Tintenfisch braucht viel Stimulation und Beschäftigung, um glücklich zu bleiben. Ohne genug Auslastung kann er unruhig werden.", "desc_introvert": "Ihr Tintenfisch ist introvertiert. Er genießt die Einsamkeit und bevorzugt ruhige Orte. Er interagiert zwar mit anderen, braucht aber Zeit für sich, um 'aufzutanken'. Er ist oft beobachtend und bedacht in seinem Handeln.", "desc_greedy": "Ihr Tintenfisch ist gierig. Er ist stark auf Futter und Ressourcen fokussiert. Belohnungen motivieren ihn besonders. Er kann fordernd sein, ist aber auch sehr einfallsreich darin, verstecktes Futter zu finden!", "desc_stubborn": "Ihr Tintenfisch ist stur. Er hat einen starken Willen und feste Vorlieben. Er ist resistenter gegen Veränderungen und braucht länger, um sich an neue Routinen zu gewöhnen. Seine Entschlossenheit hilft ihm jedoch bei der Problemlösung.", # Persönlichkeit Kurz-Modifikatoren "mod_timid": "Höhere Wahrscheinlichkeit für Angstzustände", "mod_adventurous": "Erhöhte Neugier und Erkundungsdrang", "mod_lazy": "Langsamere Bewegung und geringerer Energieverbrauch", "mod_energetic": "Schnellere Bewegung und höheres Aktivitätsniveau", "mod_introvert": "Bevorzugt Einsamkeit und ruhige Umgebungen", "mod_greedy": "Fokussiert auf Futter und Ressourcen", "mod_stubborn": "Frisst nur Lieblingsfutter (Sushi), verweigert evtl. den Schlaf", # Details zu Modifikatoren "modifiers_timid": "- Angst steigt 50% schneller\n- Neugier steigt 50% langsamer\n- Angst sinkt um 50% in der Nähe von Pflanzen", "modifiers_adventurous": "- Neugier steigt 50% schneller", "modifiers_lazy": "- Bewegt sich langsamer\n- Energieverbrauch ist niedriger", "modifiers_energetic": "- Bewegt sich schneller\n- Energieverbrauch ist höher", "modifiers_introvert": "- Bevorzugt ruhige, weniger überfüllte Orte\n- Braucht mehr Zeit allein zum 'Auftanken'", "modifiers_greedy": "- Wird 50% ängstlicher bei Hunger\n- Zufriedenheit steigt beim Fressen stärker an", "modifiers_stubborn": "- Bevorzugt Lieblingsfutter (Sushi)\n- Kann Schlaf verweigern, selbst wenn er müde ist", # Pflegetipps "tips_timid": "- Pflanzen platzieren, um Angst zu mindern\n- Umgebung sauber und ruhig halten\n- Langsam nähern und plötzliche Bewegungen vermeiden\n- Konsistente Routine beibehalten\n- Häufiges Ändern der Fenstergröße vermeiden", "tips_adventurous": "- Regelmäßig neue Objekte oder Deko einführen\n- Vielfältige Futteroptionen anbieten\n- Erkundung durch strategische Futterplatzierung fördern\n- Viel Platz zum Erkunden bieten\n- Natürliche Neugier mit interessanten Gegenständen fördern", "tips_lazy": "- Futter näher an den Ruheplätzen platzieren\n- Umgebung häufiger reinigen\n- Lockmittel verwenden, um Bewegung zu fördern\n- Nicht zu viel Aktivität erwarten - sie entspannen lieber\n- Ruheplätze sauber und komfortabel halten", "tips_energetic": "- Großen, offenen Raum für Bewegung bieten\n- Häufige Fütterungsmöglichkeiten anbieten\n- Interaktive Elemente oder Spiele einführen\n- Umgebung mit variabler Deko stimulierend halten\n- Benötigen mehr Futter aufgrund hohen Energieverbrauchs", "tips_introvert": "- Ruhige, abgeschiedene Bereiche mit Deko schaffen\n- Überfüllung der Umgebung vermeiden\n- Bedürfnis nach Zeit allein respektieren\n- Geschützte Räume mit Pflanzen schaffen\n- Sanft nähern und Freiraum geben", "tips_greedy": "- Verschiedene Futtersorten anbieten (inkl. Sushi)\n- Futter als Belohnung für gewünschtes Verhalten nutzen\n- Vorsicht vor Überfütterung\n- Wird bei Hunger ängstlicher als andere Typen\n- Möglichkeiten zum Sammeln von Gegenständen bieten", "tips_stubborn": "- Immer Sushi bereitstellen (Lieblingsfutter)\n- Geduldig bei Veränderungen sein\n- Positive Verstärkung für gewünschtes Verhalten nutzen\n- Kann anderes Futter bei Hunger verweigern\n- Kann Schlaf widerstehen - ruhige Umgebung schaffen", # ===== ENTSCHEIDUNGEN-REITER ===== "thought_process": "Gedankengang des Tintenfisches", "step": "Schritt", "step1_title": "Die Welt wahrnehmen", "step2_title": "Basis-Triebe berechnen", "step3_title": "Persönlichkeit & Erinnerungen anwenden", "step4_title": "Die finale Entscheidung treffen", "final_action": "Finale Aktion:", "awaiting_thought": "Warte auf den nächsten Gedanken...", "awaiting_decision": "Warte auf Entscheidung...", "sensing_condition": "Der Tintenfisch prüft Zustand und sichtbare Objekte:", "visible_objects": "Sichtbare Objekte", "no_sensory_data": "Keine Sensordaten verfügbar.", "none": "Keine", "no_urges": "Keine Triebe berechnet.", "strongest_urge": "Basierend auf Bedürfnissen ist der stärkste Trieb", "initial_scores": "Anfangswerte:", "personality_memory_adjust": "Persönlichkeit und Erinnerungen passen Triebe an:", "no_adjustments": "Diesmal keine signifikanten Anpassungen.", "final_scores_text": "Nach allen Berechnungen steht das Ergebnis fest. Der höchste Wert bestimmt die Aktion.", "no_final_scores": "Keine Finalwerte verfügbar.", "squid_decided": "Der Tintenfisch hat sich entschieden für", "with_confidence": "mit einem Vertrauenswert von", "score_increased": "erhöht", "score_decreased": "verringert", "score_for": "Der Wert für", "by_amount": "um", # ===== LERNEN-REITER ===== "active_learning_pairs": "Aktive Lernpaare", "hebbian_cycle": "Hebbscher Zyklus", "hebbian_paused": "PAUSIERT", "learning_ready": "Lernsystem bereit", "learning_ready_desc": "Hebbsches Lernen verknüpft Neuronen, die gemeinsam feuern.", "log_cleared": "Protokoll geleert", "log_cleared_desc": "Lernpaare erscheinen hier, wenn neue Verbindungen entstehen.", "hebbian_overview": "Überblick: Hebbsches Lernen", "neurons_fire_together": "Neurons that fire together, wire together", "hebbian_principle": "Dieses Prinzip beschreibt, wie Netzwerke durch Erfahrung lernen.", "hebbian_explanation": "Hebbsches Lernen ist eine einfache Regel: Wenn zwei Neuronen gleichzeitig aktiv sind, stärkt sich ihre Verbindung (Gewichtung). Werden sie getrennt aktiv, schwächt sie sich ab. So entstehen natürliche Assoziationen.", "excitatory_connections": "Erregende Verbindungen", "excitatory_desc": "Positive Gewichte (0.0-1.0) fördern gemeinsame Aktivierung", "inhibitory_connections": "Hemmende Verbindungen", "inhibitory_desc": "Negative Gewichte (-1.0-0.0) hemmen gemeinsame Aktivierung", "very_strong": "Sehr stark", "strong": "Stark", "moderate": "Mittel", "weak": "Schwach", "very_weak": "Sehr schwach", "inhibited": "Gehemmt", # ===== MEMORY TAB ===== "memory": "Gedächtnis", "memories": "Erinnerungen", "short_term_memory": "Kurzzeitgedächtnis", "long_term_memory": "Langzeitgedächtnis", "no_memories": "Noch keine Erinnerungen gespeichert.", "overview": "Übersicht", "memory_stats": "Gedächtnis-Statistiken", "categories": "Kategorien", "time_label": "Zeit:", "important_label": "Wichtig", "unknown": "Unbekannt", "category_label": "Kategorie:", "key_label": "Schlüssel:", "access_count": "Abrufe:", "full_content": "Vollständiger Inhalt:", "effects_label": "Effekte:", "positive": "Positiv", "negative": "Negativ", "neutral": "Neutral", # ===== NETZWERK-REITER ===== "brain_network": "Gehirn-Netzwerk", "neurons": "Neuronen", "connections": "Verbindungen", "activity": "Aktivität", # ===== STATISTIK-FENSTER ===== "status": "Status", "health": "Gesundheit", # ===== NEURONEN-NAMEN ===== "external_stimulus": "Reiz", "plant_proximity": "Pflanzennähe", "layer_name": "Ebene", "layer_input": "Eingang", "layer_output": "Ausgang", "layer_hidden": "Verborgen", # ===== NEUROGENESE LOGS ===== "log_created": "{time} - ein {type} Neuron ({name}) wurde erstellt, da der {type}-Zähler bei {value:.2f} lag", "log_pruned": "{time} - ein Neuron ({name}) wurde entfernt wegen {reason}", "log_stress_detail": "Eine hemmende Verbindung zu ANGST wurde erstellt\nMaximaler Angstwert permanent um 10 reduziert", # Zustands-Anzeigen "playing": "Spielt", "hiding": "Versteckt", "anxious": "Ängstlich", "curious": "Neugierig", # ===== AKTIONEN ===== "eat": "Essen", "sleep": "Schlafen", "play": "Spielen", "explore": "Erkunden", "rest": "Ruhen", "hide": "Verstecken", "wander": "Wandern", "idle": "Untätig", "seek_food": "Futter suchen", "seek_shelter": "Schutz suchen", # ===== OBJEKTE ===== "food": "Futter", "rock": "Fels", "poop": "Haufen", "plant": "Pflanze", "sushi": "Sushi", "decoration": "Dekoration", # ===== TUTORIAL ===== "tutorial_hatched": "Ein Tintenfisch ist geschlüpft! Du musst dich um ihn kümmern.", "tutorial_feed": "Füttere ihn bei Hunger (Aktionsmenü)", "tutorial_clean": "Reinige das Becken, wenn es schmutzig wird", "tutorial_watch": "Beobachte ihn, um seine Persönlichkeit kennenzulernen", "tutorial_neural": "NEURONALES NETZWERK", "tutorial_neural_desc": "Dies ist sein Gehirn. Verhalten wird durch Bedürfnisse (runde Neuronen) gesteuert.\nDas Netzwerk lernt durch Interaktion mit der Umwelt.", "tutorial_satisfaction": "Halte Zufriedenheit hoch und Angst niedrig.", "tutorial_traits": "Dein Tintenfisch entwickelt einzigartige Eigenschaften durch deine Pflege.", # ===== BRAIN DESIGNER ===== "required_only": "Nur Erforderliche", "dosidicus_default": "Dosidicus Standard", "full_sensors": "Volle Sensoren", "the_insomniac": "Der Schlaflose", "the_hyperactive": "Der Hyperaktive", "the_hangry": "Der Hangry", "the_depressive": "Der Depressive", "the_obsessive": "Der Obsessive", "balanced": "Ausgeglichen", "minimal": "Minimal", "dense": "Dicht", "chaotic": "Chaotisch", "calm": "Ruhig", # ===== SPLASH SCREEN ===== "squid_hatched": "EIN TINTENFISCH IST GESCHLÜPFT!", "look_after": "DU MUSST DICH UM IHN KÜMMERN..", # ===== BRAIN TOOL TABS ===== "tab_learning": "Lernen", "tab_decisions": "Entscheidungen", "tab_personality": "Persönlichkeit", "tab_about": "Über", # ===== NEURON INSPECTOR ===== "inspector_title": "Neuronen-Inspektor", "lbl_name": "Name:", "lbl_value": "Aktueller Wert:", "lbl_position": "Position:", "lbl_type": "Typ:", "grp_neurogenesis": "Neurogenese-Details", "lbl_created": "Erstellt am:", "lbl_trigger": "Trigger-Typ:", "lbl_trigger_val": "Trigger-Wert:", "lbl_state": "Zugehöriger Zustand:", "col_connected": "Verbunden mit", "col_weight": "Gewicht", "col_direction": "Richtung", "btn_refresh_data": "Daten aktualisieren", "type_core": "Kern", "type_neuro": "Neurogenese", "type_system": "Systemstatus", "direction_incoming": "Eingehend", "direction_outgoing": "Ausgehend", # ===== TUTORIAL STEPS ===== "next": "Weiter", "tutorial_step1_text": "Ein Tintenfisch ist geschlüpft! Kümmere dich um ihn:\n• Füttern bei Hunger (Aktionsmenü)\n• Becken reinigen bei Schmutz\n• Verhalten beobachten für Persönlichkeit", "tutorial_step2_text": "Das neuronale Netzwerk steuert sein Verhalten durch Bedürfnisse (Neuronen).\nEs passt sich durch Umweltinteraktion an.", "tutorial_step3_text": "Er kann neue Neuronen bei extremen Reizen bilden.\nDas hilft ihm, sich an schwierige Situationen anzupassen.", "tutorial_step4_text": "Gleichzeitiges Feuern stärkt Verbindungen. So lernt er Assoziationen zwischen Reizen und Reaktionen.", "tutorial_step5_text": "Entscheidungen basieren auf Bedürfnissen und Erinnerungen.\nJede Wahl formt sein künftiges Verhalten.", "tutorial_step6_text": "Drücke jederzeit 'D' für Dekorationen.\nPlatziere Gegenstände und beobachte die Reaktion. Jede Deko beeinflusst den Geisteszustand anders. (Mausrad=Größe / ENTF=Löschen)", "tutorial_step7_text": "Zufriedenheit hoch, Angst niedrig halten.\nDein Tintenfisch wird durch deine Erziehung einzigartig.", # ===== NETWORK & LEARNING TABS ===== "stats_neurons": "Neuronen", "stats_connections": "Verbindungen", "stats_health": "Netzwerk-Gesundheit", "emergency_alert": "🚨 Notfall: {name}", "global_cooldown": "Abklingzeit", "style_label": "Stil:", "chk_links": "Links zeigen", "chk_weights": "Gewichte zeigen", "chk_pruning": "Pruning aktiv", "tooltip_brain_designer": "Brain Designer öffnen", "msg_already_open": "Bereits offen", "msg_designer_running": "Brain Designer läuft bereits!", "msg_launch_failed": "Start fehlgeschlagen", "msg_designer_fail": "Konnte Brain Designer nicht starten:\n\n{e}", "msg_missing_brain": "Gehirn fehlt", "msg_cannot_open_lab": "Labor nicht öffenbar: Brain Widget fehlt.", "msg_cannot_open_buffer": "Puffer nicht öffenbar: Brain Widget fehlt.", "msg_no_neurogenesis": "Keine Neurogenese", "msg_neurogenesis_not_init": "Puffer nicht öffenbar: System nicht initialisiert.", "msg_decorations_unavailable": "Deko nicht verfügbar", "msg_decorations_fail": "Deko-Fenster nicht verfügbar.", "func_neurons_title": "Funktionale Neuronen", "count_label": "Anzahl", "avg_utility_label": "Durchschn. Nutzen", "total_activations_label": "Gesamtaktivierungen", "specialisations_label": "Spezialisierungen", "buffer_title": "Neurogenese-Erfahrungspuffer", "buffer_header": "Letzte Erfahrungen", "col_type": "Typ", "col_pattern": "Muster", "col_outcome": "Ergebnis", "col_time": "Zeit", "btn_refresh": "Aktualisieren", "buffer_size": "Puffergröße", "top_patterns": "Top-Muster", "no_patterns": "Noch keine Muster", } ================================================ FILE: translations/en.py ================================================ LANGUAGE_HEADER = "en - English" translations = { # Core continuous neurons "hunger": "Hunger", "happiness": "Happiness", "cleanliness": "Cleanliness", "sleepiness": "Sleepiness", "satisfaction": "Satisfaction", "anxiety": "Anxiety", "curiosity": "Curiosity", # Binary/sensor neurons "can_see_food": "Can See Food", "is_eating": "Eating", "is_sleeping": "Sleeping", "is_sick": "Sick", "pursuing_food": "Pursuing Food", "is_startled": "Startled", "is_fleeing": "Fleeing", # Base keys for neurogenesis patterns "novelty": "Novelty", "stress": "Stress", "reward": "Reward", # ===== MAIN MENU ===== "file": "File", "new_game": "New Game", "load_game": "Load Game", "save_game": "Save Game", "view": "View", "speed": "Speed", "pause": "Pause", "actions": "Actions", "debug": "Debug", "plugins": "Plugins", # ===== VIEW MENU ===== "brain_designer": "Brain Designer", "decorations": "Decorations", "statistics": "Statistics", "brain_tool": "Brain Tool", "neuron_lab": "Neuron Laboratory", "task_manager": "Task Manager", # ===== SPEED MENU ===== "normal_speed": "Normal (1x)", "fast_speed": "Fast (2x)", "very_fast": "Very Fast (3x)", # ===== DEBUG MENU ===== "toggle_debug": "Toggle Debug Mode", "toggle_cone": "Toggle View Cone", "squid_vision": "Squid Vision", # ===== ACTION BUTTONS ===== "feed": "Feed", "clean": "Clean", "medicine": "Medicine", "feed_btn": "FEED", "clean_btn": "CLEAN", "medicine_btn": "MEDICINE", # ===== MESSAGES ===== "feed_msg": "Squid requires feeding", "points": "Points", "dirty": "DIRTY", "paused_msg": "SIMULATION PAUSED", "paused_sub": "Use the Speed menu to resume", # ===== DIALOGS ===== "yes": "Yes", "no": "No", "ok": "OK", "cancel": "Cancel", "close": "Close", "save": "Save", "load": "Load", "reset": "Reset", "apply_changes": "Apply Changes", "got_it": "Got it!", "finish": "Finish", "confirm_new_game": "Start a new game? Current progress will be lost.", "confirm_exit": "Are you sure you want to exit?", "save_successful": "Game saved successfully!", "load_successful": "Game loaded successfully!", "error_saving": "Error saving game.", "error_loading": "Error loading game.", "no_save_found": "No save file found.", "startup": "Startup", "show_tutorial_q": "Show tutorial?", "auto_decline": "(Auto-declining in {seconds}s)", "tutorial_title": "Tutorial", "tutorial_query": "Would you like to see the tutorial?", # ===== ABOUT TAB ===== "hello": "HELLO", "my_name_is": "my name is", "change_name": "Change Name", "enter_new_name": "Enter new name for your squid:", "change_colour": "Change Colour", "view_certificate": "View Certificate", "care_tips": "Care Tips", "care_tips_for": "Care Tips for {personality} Squids", "dosidicus_title": "Dosidicus electronicus", "dosidicus_desc": "A Tamagotchi-style digital pet with a simple neural network", "string_acronym": "Simulated Tamagotchi Reactions via Inferencing and Neurogenesis (STRINg)", "research_project": "This is a research project. Please suggest features.", "version_dosidicus": "Dosidicus version:", "version_brain_tool": "Brain Tool version:", "version_decision": "Decision engine version:", "version_neuro": "Neurogenesis version:", "created_by": "by", # ===== PERSONALITY ===== "squid_personality": "Squid Personality", "personality_modifier": "Personality Modifier", "description": "Description:", "personality_modifiers": "Personality Modifiers:", "care_tips_label": "Care Tips:", "personality_note": "Note: Personality is randomly generated at the start of a new game", # Personality Types "personality_timid": "Timid", "personality_adventurous": "Adventurous", "personality_lazy": "Lazy", "personality_energetic": "Energetic", "personality_introvert": "Introvert", "personality_greedy": "Greedy", "personality_stubborn": "Stubborn", # Personality Descriptions "desc_timid": "Your squid is Timid. It tends to be more easily startled and anxious, especially in new situations. It may prefer quiet, calm environments and might be less likely to explore on its own. However, it can form strong bonds when it feels safe and secure.", "desc_adventurous": "Your squid is Adventurous. It loves to explore and try new things. It's often the first to investigate new objects or areas in its environment. This squid thrives on novelty and might get bored more easily in unchanging surroundings.", "desc_lazy": "Your squid is Lazy. It prefers a relaxed lifestyle and may be less active than other squids. It might need extra encouragement to engage in activities but can be quite content just lounging around. This squid is great at conserving energy!", "desc_energetic": "Your squid is Energetic. It's always on the move, full of life and vigor. This squid needs plenty of stimulation and activities to keep it happy. It might get restless if not given enough opportunity to burn off its excess energy.", "desc_introvert": "Your squid is an Introvert. It enjoys solitude and might prefer quieter, less crowded spaces. While it can interact with others, it may need time alone to 'recharge'. This squid might be more observant and thoughtful in its actions.", "desc_greedy": "Your squid is Greedy. It has a strong focus on food and resources. This squid might be more motivated by treats and rewards than others. While it can be more demanding, it also tends to be resourceful and good at finding hidden treats!", "desc_stubborn": "Your squid is Stubborn. It has a strong will and definite preferences. This squid might be more resistant to change and could take longer to adapt to new routines. However, its determination can also make it persistent in solving problems.", # Personality Short Modifiers "mod_timid": "Higher chance of becoming anxious", "mod_adventurous": "Increased curiosity and exploration", "mod_lazy": "Slower movement and energy consumption", "mod_energetic": "Faster movement and higher activity levels", "mod_introvert": "Prefers solitude and quiet environments", "mod_greedy": "More focused on food and resources", "mod_stubborn": "Only eats favorite food (sushi), may refuse to sleep", # Personality Modifier Details "modifiers_timid": "- Anxiety increases 50% faster\n- Curiosity increases 50% slower\n- Anxiety decreases by 50% when near plants", "modifiers_adventurous": "- Curiosity increases 50% faster", "modifiers_lazy": "- Moves slower\n- Energy consumption is lower", "modifiers_energetic": "- Moves faster\n- Energy consumption is higher", "modifiers_introvert": "- Prefers quieter, less crowded spaces\n- May need more time alone to 'recharge'", "modifiers_greedy": "- Gets 50% more anxious when hungry\n- Satisfaction increases more when eating", "modifiers_stubborn": "- Prefers favorite food (sushi)\n- May refuse to sleep even when tired", # Care Tips "tips_timid": "- Place plants in the environment to reduce anxiety\n- Keep the environment clean and calm\n- Approach slowly and avoid sudden movements\n- Maintain a consistent routine\n- Avoid frequent window resizing which may startle them", "tips_adventurous": "- Regularly introduce new objects or decorations\n- Provide diverse food options\n- Encourage exploration with strategic food placement\n- Allow for lots of exploration space\n- Enable their natural curiosity with interesting items", "tips_lazy": "- Place food closer to the squid's resting spots\n- Clean the environment more frequently\n- Use enticing food to encourage movement\n- Don't expect much activity - they prefer relaxation\n- Ensure their favorite resting spots are clean and comfortable", "tips_energetic": "- Provide a large, open space for movement\n- Offer frequent feeding opportunities\n- Introduce interactive elements or games\n- Keep environment stimulating with varied decorations\n- They need more food due to higher energy consumption", "tips_introvert": "- Create quiet, secluded areas with decorations\n- Avoid overcrowding the environment\n- Respect the squid's need for alone time\n- Create sheltered spaces using plants\n- Approach gently and give space when needed", "tips_greedy": "- Offer a variety of food types, including sushi\n- Use food as a reward for desired behaviors\n- Be cautious not to overfeed\n- Will get more anxious when hungry compared to other types\n- Provide opportunities to collect and arrange items", "tips_stubborn": "- Always have sushi available as it's their favorite food\n- Be patient when introducing changes\n- Use positive reinforcement for desired behaviors\n- This squid may refuse non-sushi foods when hungry\n- May resist sleep even when tired - create calm environments", # ===== DECISIONS TAB ===== "thought_process": "Squid's Thought Process", "step": "Step", "step1_title": "Sensing the World", "step2_title": "Calculating Base Urges", "step3_title": "Applying Personality & Memories", "step4_title": "Making the Final Decision", "final_action": "Final Action:", "awaiting_thought": "Awaiting the squid's next thought...", "awaiting_decision": "Awaiting Decision...", "sensing_condition": "The squid assesses his current condition and visible objects:", "visible_objects": "Visible Objects", "no_sensory_data": "No sensory data available.", "none": "None", "no_urges": "No urges calculated.", "strongest_urge": "Based on needs, the strongest urge is", "initial_scores": "Initial scores:", "personality_memory_adjust": "Personality traits and recent memories then adjust these urges:", "no_adjustments": "No significant adjustments from personality or memory this time.", "final_scores_text": "After all calculations, the final scores are tallied. The highest score determines the action.", "no_final_scores": "No final scores available.", "squid_decided": "The squid has decided", "with_confidence": "with a confidence level of", "score_increased": "increased", "score_decreased": "decreased", "score_for": "The score for", "by_amount": "by", # ===== LEARNING TAB (ORIGINAL) ===== "active_learning_pairs": "Active Learning Pairs", "hebbian_cycle": "Hebbian Cycle", "hebbian_paused": "PAUSED", "learning_ready": "Learning System Ready", "learning_ready_desc": "Hebbian learning will create associations between neurons that activate together.", "log_cleared": "Log Cleared", "log_cleared_desc": "Learning pairs will appear here as your squid's neurons form new connections.", "hebbian_overview": "Hebbian Learning Overview", "neurons_fire_together": "Neurons that fire together, wire together", "hebbian_principle": "This fundamental principle describes how neural networks learn through experience.", "hebbian_explanation": "Hebbian learning is a simple yet powerful rule used in artificial neural networks. When two neurons activate simultaneously, the connection (weight) between them strengthens. If they activate separately, the connection weakens. This allows the network to form associations between related concepts naturally.", "excitatory_connections": "Excitatory Connections", "excitatory_desc": "Positive weights (0.0-1.0) make neurons more likely to activate together", "inhibitory_connections": "Inhibitory Connections", "inhibitory_desc": "Negative weights (-1.0-0.0) make neurons less likely to activate together", "very_strong": "Very Strong", "strong": "Strong", "moderate": "Moderate", "weak": "Weak", "very_weak": "Very Weak", "inhibited": "Inhibited", # ===== MEMORY TAB ===== "memory": "Memory", "memories": "Memories", "short_term_memory": "Short-term Memory", "long_term_memory": "Long-term Memory", "no_memories": "No memories stored yet.", "overview": "Overview", "memory_stats": "Memory Statistics", "categories": "Categories", "time_label": "Time:", "important_label": "Important", "unknown": "Unknown", "category_label": "Category:", "key_label": "Key:", "access_count": "Access count:", "full_content": "Full Content:", "effects_label": "Effects:", "positive": "Positive", "negative": "Negative", "neutral": "Neutral", # ===== NETWORK TAB (ORIGINAL) ===== "brain_network": "Brain Network", "neurons": "Neurons", "connections": "Connections", "activity": "Activity", # ===== STATISTICS WINDOW ===== "status": "Status", "health": "Health", # ===== NEURON NAMES (NEW) ===== "hunger": "Hunger", "happiness": "Happiness", "cleanliness": "Cleanliness", "sleepiness": "Sleepiness", "satisfaction": "Satisfaction", "curiosity": "Curiosity", "anxiety": "Anxiety", "can_see_food": "Can See Food", "is_eating": "Is Eating", "is_sleeping": "Is Sleeping", "is_sick": "Is Sick", "is_fleeing": "Fleeing", "is_startled": "Startled", "pursuing_food": "Pursuing Food", "external_stimulus": "Stimulus", "plant_proximity": "Near Plant", "stress": "Stress", "novelty": "Novelty", "reward": "Reward", # ===== BRAIN WIDGET LAYERS (NEW) ===== "layer_name": "Layer", "layer_input": "Input", "layer_output": "Output", "layer_hidden": "Hidden", # ===== NEUROGENESIS LOGS (NEW) ===== "log_created": "{time} - a {type} neuron ({name}) was created because {type} counter was {value:.2f}", "log_pruned": "{time} - a neuron ({name}) was PRUNED due to {reason}", "log_stress_detail": "An inhibitory connection was made to ANXIETY\nMaximum anxiety value has been permanently reduced by 10", # State Pills "fleeing": "Fleeing!", "startled": "Startled!", "eating": "Eating", "sleeping": "Sleeping", "playing": "Playing", "hiding": "Hiding", "anxious": "Anxious", "curious": "Curious", # ===== COMMON ACTIONS ===== "eat": "Eat", "sleep": "Sleep", "play": "Play", "explore": "Explore", "rest": "Rest", "hide": "Hide", "wander": "Wander", "idle": "Idle", "seek_food": "Seek Food", "seek_shelter": "Seek Shelter", # ===== OBJECTS ===== "food": "Food", "rock": "Rock", "poop": "Poop", "plant": "Plant", "sushi": "Sushi", "decoration": "Decoration", # ===== TUTORIAL ===== "tutorial_hatched": "A squid has hatched and you must look after him!", "tutorial_feed": "Feed him when he's hungry (Actions Menu)", "tutorial_clean": "Clean his tank when it gets dirty", "tutorial_watch": "Watch his behavior to learn about his personality", "tutorial_neural": "NEURAL NETWORK", "tutorial_neural_desc": "This is the squid's neural network. His behaviour is driven by his needs (round neurons).\nThe network adapts and learns as the squid interacts with his environment.", "tutorial_satisfaction": "Keep satisfaction high and anxiety low.", "tutorial_traits": "Your squid will develop unique traits and behaviors based on how you raise him.", # ===== BRAIN DESIGNER (Templates) ===== "designer_title": "Brain Designer", "required_only": "Required Only", "dosidicus_default": "Dosidicus Default", "full_sensors": "Full Sensor Suite", "the_insomniac": "The Insomniac", "the_hyperactive": "The Hyperactive", "the_hangry": "The Hangry", "the_depressive": "The Depressive", "the_obsessive": "The Obsessive", "balanced": "Balanced", "minimal": "Minimal", "dense": "Dense", "chaotic": "Chaotic", "calm": "Calm", # ===== SPLASH SCREEN ===== "squid_hatched": "A SQUID HAS HATCHED!", "look_after": "YOU NEED TO LOOK AFTER HIM..", # ===== BRAIN TOOL TABS ===== "tab_learning": "Learning", "tab_decisions": "Decisions", "tab_personality": "Personality", "tab_about": "About", # ===== NEURON INSPECTOR ===== "inspector_title": "Neuron Inspector", "lbl_name": "Name:", "lbl_value": "Current Value:", "lbl_position": "Position:", "lbl_type": "Type:", "grp_neurogenesis": "Neurogenesis Details", "lbl_created": "Created At:", "lbl_trigger": "Trigger Type:", "lbl_trigger_val": "Trigger Value:", "lbl_state": "Associated State:", "col_connected": "Connected To", "col_weight": "Weight", "col_direction": "Direction", "btn_refresh_data": "Refresh Data", "type_core": "Core", "type_neuro": "Neurogenesis", "type_system": "System Status", "direction_incoming": "Incoming", "direction_outgoing": "Outgoing", # ===== TUTORIAL STEPS ===== "next": "Next", "tutorial_step1_text": "A squid has hatched and you must look after him!\n• Feed him when he's hungry (Actions Menu)\n• Clean his tank when it gets dirty\n• Watch his behavior to learn about his personality", "tutorial_step2_text": "This is the squid's neural network. His behaviour is driven by his needs (neurones).\nThe network adapts and learns as the squid interacts with his environment.", "tutorial_step3_text": "The squid can generate new neurons in response to extreme environmental stimulus.\nThese new neurons help the squid adapt to challenging situations.", "tutorial_step4_text": "When a pair of neurons fire at the same time, their connection strengthens. This allows the squid to learn associations between different stimuli and responses.", "tutorial_step5_text": "The neural network makes decisions based on current needs and past memories.\nEach decision affects the squid's state and shapes future behavior.", "tutorial_step6_text": "Press D at any time to open the Decorations window\nDrag and drop decorations into the environment and see how squid reacts to different things. Each decoration type affects the squid's mental state in unique ways. Click and use the mouse wheel to resize/DEL to delete", "tutorial_step7_text": "Keep satisfaction high and anxiety low.\nYour squid will develop unique traits and behaviors based on how you raise him.", # ===== NETWORK & LEARNING TABS (NEW) ===== "stats_neurons": "Neurons", "stats_connections": "Connections", "stats_health": "Network Health", "emergency_alert": "🚨 Emergency: {name}", "global_cooldown": "Cooldown", "style_label": "Style:", "chk_links": "Show links", "chk_weights": "Show weights", "chk_pruning": "Enable pruning", "tooltip_brain_designer": "Show Brain Designer", "msg_already_open": "Already Open", "msg_designer_running": "Brain Designer is already running!", "msg_launch_failed": "Launch Failed", "msg_designer_fail": "Could not start Brain Designer:\n\n{e}", "msg_missing_brain": "Missing Brain", "msg_cannot_open_lab": "Cannot open Neuron Laboratory: Brain Widget is not available.", "msg_cannot_open_buffer": "Cannot open Experience Buffer: Brain Widget is not available.", "msg_no_neurogenesis": "No Neurogenesis", "msg_neurogenesis_not_init": "Cannot open Experience Buffer: Neurogenesis system is not initialized.", "msg_decorations_unavailable": "Decorations Unavailable", "msg_decorations_fail": "Cannot open Decorations: Window is not available.", "func_neurons_title": "Functional Neurons", "count_label": "Count", "avg_utility_label": "Avg Utility", "total_activations_label": "Total Activations", "specialisations_label": "Specialisations", "buffer_title": "Neurogenesis Experience Buffer", "buffer_header": "Recent Experiences", "col_type": "Type", "col_pattern": "Pattern", "col_outcome": "Outcome", "col_time": "Time", "btn_refresh": "Refresh", "buffer_size": "Buffer size", "top_patterns": "Top patterns", "no_patterns": "No patterns yet", # Learning Tab Educational Content "learning_pairs_tab": "Learning Pairs", "mechanics_tab": "Mechanics", "hebbian_quote": "\"Neurons that fire together, wire together\"", "in_practice_title": "In Practice", "in_practice_text": "In your squid's brain, Hebbian learning helps associate related states like 'hunger' with 'satisfaction' when feeding occurs, or 'curiosity' with 'anxiety' during exploration. These learned associations influence future behavior.", "mechanics_title": "Learning Mechanics", "mechanics_intro": "Hebbian learning updates connection strength (weight) between neurons based on their activity patterns. When neurons activate together, their connection strengthens; when they activate separately, it weakens.", "learning_rule_title": "The Learning Rule", "where_label": "Where:", "delta_w_desc": "Δw = Change in weight between two neurons", "eta_desc": "η (eta) = Learning rate (controls speed of change)", "activation_desc": "x, y = Activation values of the neurons (1 if active, 0 if inactive)", "example_calc_title": "Example Calculation", "scenario_label": "Scenario: 'hunger' and 'satisfaction' both activate", "calc_result": "The weight increases by 0.1, strengthening the connection between these neurons.", "over_time_title": "Over Time", "over_time_text": "Through repeated activations, these small weight changes accumulate. Frequently co-occurring patterns develop strong connections, while rarely occurring patterns develop weak or negative connections. This is how your squid learns from experience!", "str_excitatory": "Strong Excitatory", "weak_excitatory": "Weak Excitatory", "weak_inhibitory": "Weak Inhibitory", "str_inhibitory": "Strong Inhibitory", # ===== SQUID & BRAIN STATISTICS ===== "distance_rollover": "🌊 Distance counter rolled over! Now at {multiplier}x", "time_min": "min", "time_mins": "mins", "time_hr": "hr", "time_hrs": "hrs", "time_fmt_hm": "{hours}h {minutes}m", "stat_squid_age": "Squid Age", "stat_distance": "Distance Swam (pixels)", "stat_cheese": "Cheese Eaten", "stat_sushi": "Sushi Eaten", "stat_poops": "Poops Created", "stat_max_poops": "Max Poops in Tank", "stat_startles": "Times Startled", "stat_ink": "Ink Clouds Created", "stat_colour_change": "Times Colour Changed", "stat_rocks": "Rocks Thrown", "stat_plants": "Plant Interactions", "stat_sleep": "Total Sleep Time (seconds)", "stat_sickness": "Sickness Episodes", "stat_novelty_neurons": "Novelty Neurons Created", "stat_stress_neurons": "Stress Neurons Created", "stat_reward_neurons": "Reward Neurons Created", "stat_current_neurons": "Current Neurons", "reset_stats_title": "Reset Statistics", "reset_stats_msg": "Are you sure you want to reset all statistics?", "export_stats_title": "Export Statistics", "export_file_type": "Text Files (*.txt)", "export_header": "Squid Statistics Export", "export_time": "Export Time", "export_activity_section": "Activity Statistics", "export_end": "End of Statistics", "export_success_title": "Export Successful", "export_success_msg": "Statistics exported to {file_name}", "export_error_title": "Export Error", "export_error_msg": "Error exporting statistics: {error}", # ===== ACHIEVEMENTS (NEW) ===== # Categories "cat_feeding": "Feeding", "cat_neurogenesis": "Neurogenesis", "cat_sleep": "Sleep", "cat_milestones": "Milestones", "cat_exploration": "Exploration", "cat_cleaning": "Cleaning", "cat_health": "Health", "cat_interaction": "Interaction", "cat_ink": "Ink", "cat_memory": "Memory", "cat_emotional": "Emotional", "cat_secret": "Secret", "cat_meta": "Meta", # UI Elements "ui_points": "Points", "ui_unlocked": "Unlocked", "ui_achievement_unlocked": "Achievement Unlocked!", "ui_hidden": "Hidden achievement", "ui_all": "All", "ui_points_gained": "points", # --- Achievements --- # Feeding "ach_first_feeding_name": "First Bite", "ach_first_feeding_desc": "Feed the squid for the first time", "ach_fed_10_times_name": "Regular Meals", "ach_fed_10_times_desc": "Feed the squid 10 times", "ach_fed_50_times_name": "Dedicated Caretaker", "ach_fed_50_times_desc": "Feed the squid 50 times", "ach_fed_100_times_name": "Master Chef", "ach_fed_100_times_desc": "Feed the squid 100 times", "ach_fed_500_times_name": "Culinary Legend", "ach_fed_500_times_desc": "Feed the squid 500 times", # Neurogenesis "ach_first_neuron_name": "Brain Spark", "ach_first_neuron_desc": "Create the first neurogenesis neuron", "ach_neurons_10_name": "Neural Network", "ach_neurons_10_desc": "Create 10 neurons through neurogenesis", "ach_neurons_50_name": "Expanding Mind", "ach_neurons_50_desc": "Create 50 neurons through neurogenesis", "ach_neurons_100_name": "Cerebral Powerhouse", "ach_neurons_100_desc": "Create 100 neurons through neurogenesis", "ach_first_neuron_levelup_name": "Strengthened Synapse", "ach_first_neuron_levelup_desc": "Level up a neuron for the first time", "ach_neuron_max_level_name": "Peak Performance", "ach_neuron_max_level_desc": "Level a neuron to maximum strength", # Sleep "ach_first_sleep_name": "Sweet Dreams", "ach_first_sleep_desc": "The squid wakes from its first sleep", "ach_slept_10_times_name": "Well Rested", "ach_slept_10_times_desc": "The squid has slept 10 times", "ach_dream_state_name": "Deep Dreamer", "ach_dream_state_desc": "Squid entered REM sleep", # Milestones "ach_age_1_hour_name": "One Hour Old", "ach_age_1_hour_desc": "Squid reached 1 hour old", "ach_age_10_hours_name": "Growing Up", "ach_age_10_hours_desc": "Squid reached 10 hours old", "ach_age_24_hours_name": "One Day Wonder", "ach_age_24_hours_desc": "Squid survived for 24 hours", "ach_age_1_week_name": "Week Veteran", "ach_age_1_week_desc": "Squid has lived for one week", "ach_age_1_month_name": "Month Veteran", "ach_age_1_month_desc": "Squid has lived for one month", "ach_happiness_100_name": "Pure Bliss", "ach_happiness_100_desc": "Reach 100% happiness", "ach_all_stats_high_name": "Perfect Balance", "ach_all_stats_high_desc": "All stats above 80% simultaneously", # Cleaning "ach_first_clean_name": "First Scrub", "ach_first_clean_desc": "Clean the tank for the first time", "ach_cleaned_25_times_name": "Spotless Environment", "ach_cleaned_25_times_desc": "Clean the tank 25 times", "ach_germaphobe_name": "Germaphobe", "ach_germaphobe_desc": "Keep cleanliness above 90% for 1 hour straight", # Health "ach_first_medicine_name": "First Aid", "ach_first_medicine_desc": "Give medicine for the first time", "ach_medicine_10_times_name": "Doctor Squid", "ach_medicine_10_times_desc": "Give medicine 10 times", "ach_comeback_kid_name": "Comeback Kid", "ach_comeback_kid_desc": "Recover from critically low health (<20%) to full", # Interaction (Rocks) "ach_first_rock_pickup_name": "Rock Collector", "ach_first_rock_pickup_desc": "Pick up a rock for the first time", "ach_rocks_picked_10_name": "Stone Gatherer", "ach_rocks_picked_10_desc": "Pick up 10 rocks", "ach_rocks_picked_50_name": "Boulder Hoarder", "ach_rocks_picked_50_desc": "Pick up 50 rocks", "ach_first_rock_throw_name": "Skipping Stones", "ach_first_rock_throw_desc": "Throw a rock for the first time", "ach_rocks_thrown_25_name": "Rock Launcher", "ach_rocks_thrown_25_desc": "Throw 25 rocks", "ach_rocks_thrown_100_name": "Catapult Master", "ach_rocks_thrown_100_desc": "Throw 100 rocks", # Interaction (Decor) "ach_first_decoration_push_name": "Interior Decorator", "ach_first_decoration_push_desc": "Push a decoration for the first time", "ach_decorations_pushed_10_name": "Furniture Mover", "ach_decorations_pushed_10_desc": "Push decorations 10 times", "ach_decorations_pushed_50_name": "Feng Shui Master", "ach_decorations_pushed_50_desc": "Push decorations 50 times", "ach_first_plant_interact_name": "Green Thumb", "ach_first_plant_interact_desc": "Interact with a plant for the first time", "ach_plants_interacted_10_name": "Garden Explorer", "ach_plants_interacted_10_desc": "Interact with plants 10 times", "ach_plants_interacted_50_name": "Botanist", "ach_plants_interacted_50_desc": "Interact with plants 50 times", "ach_objects_investigated_25_name": "Curious Inspector", "ach_objects_investigated_25_desc": "Investigate 25 different objects", "ach_objects_investigated_100_name": "Master Detective", "ach_objects_investigated_100_desc": "Investigate 100 different objects", # Exploration (Poop) "ach_first_poop_throw_name": "Mischief Maker", "ach_first_poop_throw_desc": "Squid threw a poop for the first time", # Ink "ach_first_ink_cloud_name": "Smoke Screen", "ach_first_ink_cloud_desc": "Squid releases ink cloud for the first time", "ach_ink_clouds_20_name": "Ink Master", "ach_ink_clouds_20_desc": "Release 20 ink clouds", # Memory "ach_first_memory_name": "First Memory", "ach_first_memory_desc": "Form the first memory", "ach_memory_long_term_name": "Long Term Thinking", "ach_memory_long_term_desc": "Promote a memory to long-term storage", "ach_memories_50_name": "Photographic Memory", "ach_memories_50_desc": "Have 50 memories stored", # Emotional "ach_curiosity_100_name": "Curious George", "ach_curiosity_100_desc": "Curiosity reaches 100%", "ach_zen_master_name": "Zen Master", "ach_zen_master_desc": "Keep anxiety below 10% for 30 minutes", "ach_first_startle_name": "Startled!", "ach_first_startle_desc": "Startle the squid for the first time", "ach_nervous_wreck_name": "Nervous Wreck", "ach_nervous_wreck_desc": "Anxiety reaches 100%", # Secret "ach_night_owl_name": "Night Owl", "ach_night_owl_desc": "Play between midnight and 4 AM", "ach_early_bird_name": "Early Bird", "ach_early_bird_desc": "Play between 5 AM and 7 AM", "ach_weekend_warrior_name": "Weekend Warrior", "ach_weekend_warrior_desc": "Play on both Saturday and Sunday", # Meta "ach_brain_surgeon_name": "Brain Surgeon", "ach_brain_surgeon_desc": "Open the brain visualization tool", "ach_speed_demon_name": "Speed Demon", "ach_speed_demon_desc": "Run simulation at max speed for 10 minutes", "ach_completionist_name": "Completionist", "ach_completionist_desc": "Unlock 30 other achievements", # ===== NEURON LABORATORY ===== "lab_title": "🧠 Neuron Laboratory", "lab_live_refresh": "Live refresh", "lab_unlock_editing": "🔓 Unlock editing", "lab_tab_overview": "📊 Live Overview", "lab_tab_inspector": "🔍 Deep Inspector", "lab_tab_edit": "🔧 Edit Sandbox", "lab_status_ready": "Ready", "lab_status_locked": "🔒 {name} locked at {value}", "lab_status_unlocked": "🔓 {name} unlocked", # Overview Tab "lab_ov_counters": "Counter progress", "lab_ov_newest": "Newest neurogenesis neurons", "lab_ov_limits": "Limits & pruning", "lab_ov_actions": "Quick actions", "lab_force_hebbian": "Force Hebbian cycle", "lab_pruning_enabled": "Pruning enabled:", "lab_none_yet": "None yet", "lab_ago": "{seconds}s ago", # Inspector Tab "lab_pick_neuron": "Pick a neuron to inspect:", "lab_connections_title": "Connections (excitatory vs inhibitory)", "lab_header_partner": "Partner", "lab_header_weight": "Weight", "lab_header_type": "Type", "lab_header_inf": "Influence", "lab_impact_title": "Functional impact simulation", "lab_header_neuron": "Neuron", "lab_header_delta": "Δ Value", "lab_no_connections": "No active connections at the moment", "lab_did_you_know": "Did you know?", "lab_type_excitatory": "Excitatory", "lab_type_inhibitory": "Inhibitory", # Edit Tab "lab_edit_locked_msg": "⚠️ Editing is locked – check 'Unlock editing' in the toolbar.", "lab_edit_header": "Neuron values (drag to change) – click 🔒 to lock", "lab_unlock_title": "Unlock editing?", "lab_unlock_msg": "You can now change neuron values and force creation events. Use responsibly!", # Badges/Influence "lab_inf_tiny": "tiny", "lab_inf_mild": "mild", "lab_inf_mod": "moderate", "lab_inf_strong": "STRONG", # Educational Tips "lab_tip_hunger": "Hunger is a homeostatic drive. High hunger inhibits satisfaction and boosts anxiety.", "lab_tip_happiness": "Happiness is reinforced by reward neurons. It inhibits anxiety and promotes curiosity.", "lab_tip_anxiety": "Anxiety is reduced by stress neurons (inhibitory). High anxiety suppresses curiosity.", "lab_tip_curiosity": "Curiosity spikes when novelty is high. It encourages exploration and reduces anxiety.", "lab_tip_core": "Core neuron – fundamental to survival.", "lab_tip_neuro_default": "Neurogenesis neuron – purpose inferred from birth context.", "lab_tip_neuro_fmt": "Created by {trigger} – specialises in {spec}. Its job is to turn experiences into long-term behaviour.", # ===== VISION WINDOW ===== "vision_window_title": "Squid's Vision", "vis_logic_unavailable": "Squid logic not available.", "vis_nothing_in_view": "Nothing currently in view.", "vis_distance": "distance", # --- Brain Tooltips --- "tooltip_specialization": "Specialization", "tooltip_type": "Type", "tooltip_current": "Current", "tooltip_utility": "Utility", "tooltip_activations": "Activations", "tooltip_last_active": "Last Active", "tooltip_age": "Age", "tooltip_core": "Core", "tooltip_generated": "Generated", "tooltip_functional": "Functional", "tooltip_connections_header": "Connections", "tooltip_connections_stats": "{incoming} in, {outgoing} out", "tooltip_top_incoming": "Top Incoming", "tooltip_top_outgoing": "Top Outgoing", "tooltip_hint": "Double-click to inspect • Right-click for options", # State values "state_on": "ON", "state_off": "OFF", # Time formatting "fmt_s_ago": "{val}s ago", "fmt_m_ago": "{val}m ago", "fmt_h_ago": "{val}h ago", "fmt_s_short": "{val}s", "fmt_m_short": "{val}m", "fmt_h_short": "{val}h", "fmt_d_short": "{val}d", # ===== BRAIN DESIGNER WINDOW UI ===== "designer_window_title": "Brain Designer - Dosidicus-2", "designer_window_title_imported": "Brain Designer - Dosidicus-2 [Imported from Game]", # Tabs "designer_tab_layers": "Layers", "designer_tab_sensors": "Sensors", "designer_tab_props": "Properties", "designer_tab_connections": "Connections", "designer_tab_outputs": "Outputs", # Toolbar "designer_btn_generate": "🎲 Generate Sparse Network", "designer_tooltip_generate": "Generate random connections between core neurons", "designer_btn_neuron": "➕ Neuron", "designer_tooltip_neuron": "Add a new neuron (Shift+N)", "designer_btn_fix": "🔧 Auto-Fix", "designer_tooltip_fix": "Automatically fix orphan neurons and connectivity issues", "designer_btn_validate": "✓ Validate", "designer_tooltip_validate": "Check design for issues", "designer_btn_sync": "🔄 Sync from Game", "designer_tooltip_sync": "Refresh brain state from running Dosidicus game", "designer_btn_clear_conn": "🗑 Clear Connections", "designer_tooltip_clear_conn": "Remove all connections (keeps neurons)", "designer_tooltip_dice": "Instantly generate a random network (no dialog)", # Ticker / Help Bar "designer_help_drag_connect": "💡 Left-Drag from neuron to create connection", "designer_help_ctrl_move": "Ctrl+Drag neuron to move it", "designer_help_pan": "Right-Drag to pan canvas", "designer_help_zoom": "Scroll Wheel to zoom (or adjust weight on connection)", "designer_help_edit_weight": "Double-Click connection to edit weight", "designer_help_select": "Click neuron/connection to select", "designer_help_delete": "Del to delete selected", "designer_help_reverse": "Space to reverse connection direction", "designer_help_keys_weight": "+/- keys to adjust weight (Shift for larger steps)", "designer_help_page_weight": "Page Up/Down to adjust weight (large steps)", "designer_help_add_neuron": "Shift+N to add neuron", "designer_help_save": "Ctrl+S to save", "designer_help_open": "Ctrl+O to open", "designer_help_export": "Ctrl+E to export", "designer_help_new": "Ctrl+N for new design", "designer_help_gen": "Ctrl+G to generate network", "designer_help_dice": "🎲 Dice button for instant random generation", "designer_help_outputs": "Outputs tab to bind neurons to squid behaviors", # Menus "designer_menu_file": "File", "designer_menu_edit": "Edit", "designer_menu_templates": "Templates", "designer_menu_generate": "Generate", # Actions "designer_action_new": "New Design", "designer_action_save": "Save...", "designer_action_export": "Export for Dosidicus...", "designer_action_open": "Open...", "designer_action_gen_sparse": "Generate Sparse Network...", "designer_action_autofix": "Auto-Fix Connectivity", "designer_action_validate": "Validate Design", "designer_action_clear_conn": "Clear All Connections", "designer_action_clear_outputs": "Clear All Output Bindings", # Status Bar "designer_status_neurons": "Neurons: {count}", "designer_status_connections": "Connections: {count}", "designer_status_required": "Required: {ok}", "designer_status_outputs": "Outputs: {count}", "designer_status_selected": "Selected: {source} → {target} (weight: {weight:+.3f})", "designer_status_weight_updated": "Weight updated: {source} → {target} = {weight:+.3f}", "designer_status_deleted": "Deleted connection: {source} → {target}", "designer_status_cleared_conn": "Cleared {count} connections", "designer_status_cleared_out": "Cleared {count} output bindings", "designer_status_generated": "Generated {count} connections using '{style}' preset", "designer_status_random_gen": "🎲 Generated {count} random connections (style: {style})", "designer_status_synced": "✨ Synced: {neurons} neurons, {connections} connections", "designer_status_imported": "✨ Active brain imported from running game", # Dialogs & Messages "designer_msg_game_not_running_title": "Game Not Running", "designer_msg_game_not_running": "The Dosidicus game is no longer running.\n\nStart the game again to sync.", "designer_msg_sync_confirm_title": "Sync from Game", "designer_msg_sync_confirm": "Replace current design with the latest brain state from the game?", "designer_msg_sync_failed_title": "Sync Failed", "designer_msg_sync_failed": "Could not import brain state from game.", "designer_msg_live_import_title": "Live Brain Import", "designer_msg_live_import_header": "🧠 Active brain imported from running game", "designer_msg_live_import_body": "The designer is now showing the exact neural network from your running Dosidicus game.\n\n• {neurons} neurons\n• {connections} connections\n\nChanges made here will NOT affect the running game.", "designer_msg_clear_conn_title": "Clear Connections", "designer_msg_clear_conn_confirm": "Remove all {count} connections?\n\nNeurons will be kept.", "designer_msg_clear_out_title": "Clear Output Bindings", "designer_msg_clear_out_empty": "No output bindings to clear.", "designer_msg_clear_out_confirm": "Remove all {count} output bindings?", "designer_msg_new_design_title": "New Design", "designer_msg_new_design_confirm": "Start a new design? Unsaved changes will be lost.", "designer_msg_autofix_title": "Auto-Fix", "designer_msg_autofix_result": "Created {count} connections:\n\n{details}", "designer_msg_autofix_none": "No issues found.", "designer_msg_save_title": "Save Design", "designer_msg_saved_title": "Saved", "designer_msg_save_success": "Design saved successfully: {msg}", "designer_msg_save_bindings": "\n({count} output bindings included)", "designer_msg_error_title": "Error", "designer_msg_save_fail": "Failed to save design:\n\n{error}", "designer_msg_export_title": "Export", "designer_msg_exported_title": "Exported", "designer_msg_export_success": "Design exported successfully", "designer_msg_export_fail": "Failed to export design:\n\n{error}", "designer_msg_open_title": "Open Design", "designer_msg_open_fail": "Could not load design:\n\n{error}", "designer_msg_load_template_title": "Load Template", "designer_msg_select_template": "Select a template:", "designer_msg_replace_design": "Replace current design?", "designer_msg_status_title": "Design Status", "designer_msg_status_ok": "\n✅ Status: OK", "designer_msg_status_issues": "\n⚠️ ISSUES:\n", "designer_input_weight_title": "Connection Weight", "designer_input_weight_label": "Set weight for {source} → {target}:", # ===== DESIGNER PANELS ===== # Properties Panel "designer_prop_no_selection": "No neuron selected", "designer_prop_no_selection_disabled": "No Selection", "designer_prop_lbl_name": "Name:", "designer_prop_lbl_type": "Type:", "designer_prop_lbl_x": "X:", "designer_prop_lbl_y": "Y:", "designer_prop_btn_delete": "Delete Neuron", # Add Neuron Dialog "designer_add_title": "Add Neuron", "designer_add_grp_type": "Select Neuron Type", "designer_add_btn_custom": "✨ Custom / Plugin Neuron", "designer_add_btn_sensor": "📡 Input Sensor", "designer_add_tooltip_custom": "Create a neuron with a specific name to link with game plugins", "designer_add_grp_sensor": "Select Sensor", "designer_add_grp_custom": "Define Custom Neuron", "designer_add_info_custom": "To affect the squid, the Name must match a plugin ID.
    Example: Name it 'jet_boost' to activate a jetpack plugin.
    ", "designer_add_lbl_id": "Plugin ID / Name:", "designer_add_ph_id": "e.g. turbo_mode", "designer_add_btn_create": "Create Link", "designer_add_all_added": "All sensors added", "designer_add_err_title": "Error", "designer_add_err_exists": "Exists", "designer_add_msg_created": "Created {name}", # Layers Panel "designer_layer_btn_add": "Add Layer", "designer_layer_dlg_title": "New Layer", "designer_layer_dlg_label": "Name:", # Sensors Panel "designer_sensor_header": "Input Sensors:", "designer_sensor_tooltip_refresh": "Refresh sensor list (includes plugin-registered sensors)", "designer_sensor_cat_label": "── {name} ──", # Connections Table "designer_conn_header_source": "Source", "designer_conn_header_target": "Target", "designer_conn_header_weight": "Weight", # ===== OUTPUTS PANEL ===== "designer_output_header": "Output Bindings
    Connect neurons to squid behaviors. When a neuron's activation exceeds the threshold, it triggers the bound action.", "designer_output_btn_add": "➕ Add Binding", "designer_output_btn_edit": "✏️ Edit", "designer_output_btn_remove": "🗑️ Remove", "designer_output_col_neuron": "Neuron", "designer_output_col_behavior": "→ Behavior", "designer_output_col_threshold": "Threshold", "designer_output_col_mode": "Mode", "designer_output_col_enabled": "Enabled", "designer_output_info": "{count} binding(s), {enabled} enabled", "designer_output_err_missing": "⚠️ Neuron not found in design", "designer_output_dlg_remove_title": "Remove Binding", "designer_output_dlg_remove_msg": "Remove binding: {neuron} → {hook}?", # Output Binding Dialog "designer_binding_title_add": "Add Output Binding", "designer_binding_title_edit": "Configure Output Binding", "designer_binding_grp_neuron": "Source Neuron", "designer_binding_lbl_neuron": "Neuron:", "designer_binding_lbl_current": "Current: --", "designer_binding_grp_hook": "Output Behavior", "designer_binding_lbl_trigger": "Trigger:", "designer_binding_grp_settings": "Trigger Settings", "designer_binding_lbl_thresh": "Threshold:", "designer_binding_lbl_mode": "Mode:", "designer_binding_lbl_cool": "Cooldown:", "designer_binding_chk_enabled": "Enabled", "designer_binding_err_neuron": "Please select a neuron", "designer_binding_err_hook": "Please select an output behavior", "designer_binding_err_duplicate": "A binding for {neuron} → {hook} already exists", # Trigger Modes "designer_mode_rising": "Rising Edge (cross threshold going up)", "designer_mode_falling": "Falling Edge (cross threshold going down)", "designer_mode_above": "While Above (continuous while > threshold)", "designer_mode_below": "While Below (continuous while < threshold)", "designer_mode_change": "On Change (any significant change)", # ===== DESIGNER SENSOR DISCOVERY ===== "desc_builtin_sensor": "Built-in sensor: {name}", "desc_vision_food": "Detects food in vision cone", "desc_custom_sensor": "Custom sensor from {plugin}", "desc_builtin": "builtin", "desc_plugin": "plugin", "desc_other": "other", "desc_vision": "vision", # ===== DESIGNER TEMPLATES (Extra Keys) ===== "tmpl_core_name": "🟡 Required Only", "tmpl_core_desc": "8 required neurons", "tmpl_dosidicus_name": "🟡 Dosidicus Default", "tmpl_dosidicus_desc": "Standard layout", "tmpl_full_sensors_name": "🟡 Full Sensor Suite", "tmpl_full_sensors_desc": "All sensors", "tmpl_insomniac_name": "🔴 The Insomniac", "tmpl_insomniac_desc": "Anxiety & Curiosity block sleep", "tmpl_hyperactive_name": "🔴 The Hyperactive", "tmpl_hyperactive_desc": "Noise neurons overwhelm sleepiness", "tmpl_hangry_name": "🔴 The Hangry", "tmpl_hangry_desc": "Hunger causes extreme rage", "tmpl_depressive_name": "🔴 The Depressive", "tmpl_depressive_desc": "Resistant to happiness", "tmpl_obsessive_name": "🔴 The Obsessive", "tmpl_obsessive_desc": "Anxiety/Curiosity feedback loop", "layer_sensors": "Sensors", "layer_core": "Core", "layer_input": "Input", "layer_out": "Out", "layer_racing_mind": "Racing Mind", "layer_state": "State", "layer_vision": "Vision", "layer_noise": "Noise", "layer_output": "Output", "layer_gut_brain": "Gut-Brain", "layer_gray": "Gray", "layer_loop": "Loop", "layer_stats": "Stats", "layer_emotions": "Emotions", # ===== CANVAS CONTEXT MENU / DIALOGS ===== "designer_cnv_del_conn_title": "Delete Connection", "designer_cnv_del_conn_msg": "Are you sure you want to delete the connection:\n{source} → {target}?", "designer_cnv_chk_dont_ask": "Don't ask again", "designer_cnv_btn_del": "Yes, Delete", "designer_cnv_btn_cancel": "Cancel", "designer_cnv_dlg_edit_title": "Edit Connection", "designer_cnv_lbl_conn": "Connection: {source} → {target}", "designer_cnv_lbl_weight": "Weight:", "designer_cnv_info_weight": "Positive = Excitatory (green), Negative = Inhibitory (red)", "designer_cnv_btn_del_conn": "Delete Connection", "designer_cnv_btn_ok": "OK", "designer_cnv_tooltip_invalid": "Invalid connection", } ================================================ FILE: translations/es.py ================================================ LANGUAGE_HEADER = "es - Español" translations = { # Core continuous neurons "hunger": "Hambre", "happiness": "Felicidad", "cleanliness": "Limpieza", "sleepiness": "Somnolencia", "satisfaction": "Satisfacción", "anxiety": "Ansiedad", "curiosity": "Curiosidad", # Binary/sensor neurons "can_see_food": "Puede Ver Comida", "is_eating": "Comiendo", "is_sleeping": "Durmiendo", "is_sick": "Enfermo", "pursuing_food": "Persiguiendo Comida", "is_startled": "Asustado", "is_fleeing": "Huyendo", # Base keys for neurogenesis patterns "novelty": "Novedad", "stress": "Estrés", "reward": "Recompensa", # ===== MENU PRINCIPAL ===== "file": "Archivo", "new_game": "Nuevo Juego", "load_game": "Cargar Juego", "save_game": "Guardar Juego", "view": "Ver", "speed": "Velocidad", "pause": "Pausa", "actions": "Acciones", "debug": "Depurar", "plugins": "Plugins", # ===== MENU VER ===== "brain_designer": "Disenador de Cerebro", "decorations": "Decoraciones", "statistics": "Estadisticas", "brain_tool": "Herramienta Cerebral", "neuron_lab": "Laboratorio de Neuronas", "task_manager": "Administrador de Tareas", # ===== MENU VELOCIDAD ===== "normal_speed": "Normal (1x)", "fast_speed": "Rapido (2x)", "very_fast": "Muy Rapido (3x)", # ===== MENU DEPURACION ===== "toggle_debug": "Alternar Modo Depuracion", "toggle_cone": "Alternar Cono de Vision", "squid_vision": "Vision del Calamar", # ===== BOTONES DE ACCION ===== "feed": "Alimentar", "clean": "Limpiar", "medicine": "Medicina", "feed_btn": "ALIMENTAR", "clean_btn": "LIMPIAR", "medicine_btn": "MEDICINA", # ===== MENSAJES ===== "feed_msg": "El calamar necesita comida", "points": "Puntos", "dirty": "SUCIO", "paused_msg": "SIMULACION EN PAUSA", "paused_sub": "Usa el menu Velocidad para reanudar", # ===== DIALOGOS ===== "yes": "Si", "no": "No", "ok": "Aceptar", "cancel": "Cancelar", "close": "Cerrar", "save": "Guardar", "load": "Cargar", "reset": "Reiniciar", "apply_changes": "Aplicar Cambios", "got_it": "Entendido!", "finish": "Finalizar", "confirm_new_game": "Iniciar un nuevo juego? El progreso actual se perdera.", "confirm_exit": "Estas seguro de que quieres salir?", "save_successful": "Juego guardado exitosamente!", "load_successful": "Juego cargado exitosamente!", "error_saving": "Error al guardar el juego.", "error_loading": "Error al cargar el juego.", "no_save_found": "No se encontro archivo de guardado.", "startup": "Inicio", "show_tutorial_q": "¿Mostrar tutorial?", "auto_decline": "(Autocancelación en {seconds}s)", "tutorial_title": "Tutorial", "tutorial_query": "¿Te gustaría ver el tutorial?", # ===== PESTANA ACERCA DE ===== "hello": "HOLA", "my_name_is": "mi nombre es", "change_name": "Cambiar Nombre", "enter_new_name": "Introduce un nuevo nombre para tu calamar:", "change_colour": "Cambiar Color", "view_certificate": "Ver Certificado", "care_tips": "Consejos de Cuidado", "care_tips_for": "Consejos de Cuidado para Calamares {personality}", "dosidicus_title": "Dosidicus electronicus", "dosidicus_desc": "Una mascota digital estilo Tamagotchi con una red neuronal simple", "string_acronym": "Reacciones de Tamagotchi Simuladas mediante Inferencia y Neurogenesis (STRINg)", "research_project": "Este es un proyecto de investigacion. Por favor, sugiere funciones.", "version_dosidicus": "Versión Dosidicus:", "version_brain_tool": "Versión Herramienta Cerebral:", "version_decision": "Versión Motor de Decisión:", "version_neuro": "Versión Neurogénesis:", "created_by": "por", # ===== PERSONALIDAD ===== "squid_personality": "Personalidad del Calamar", "personality_modifier": "Modificador de Personalidad", "description": "Descripcion:", "personality_modifiers": "Modificadores de Personalidad:", "care_tips_label": "Consejos de Cuidado:", "personality_note": "Nota: La personalidad se genera aleatoriamente al inicio de un nuevo juego", # ===== BRAIN TOOL TABS (NEW - SPANISH) ===== "tab_learning": "Aprendizaje", "tab_decisions": "Decisiones", "tab_personality": "Personalidad", "tab_about": "Acerca de", # ===== NEURON INSPECTOR (NEW - SPANISH) ===== "inspector_title": "Inspector de Neuronas", "lbl_name": "Nombre:", "lbl_value": "Valor Actual:", "lbl_position": "Posición:", "lbl_type": "Tipo:", "grp_neurogenesis": "Detalles de Neurogénesis", "lbl_created": "Creado En:", "lbl_trigger": "Tipo Disparador:", "lbl_trigger_val": "Valor Disparador:", "lbl_state": "Estado Asociado:", "col_connected": "Conectado A", "col_weight": "Peso", "col_direction": "Dirección", "btn_refresh_data": "Actualizar Datos", "type_core": "Núcleo", "type_neuro": "Neurogénesis", "type_system": "Estado Sistema", "direction_incoming": "Entrante", "direction_outgoing": "Saliente", # Tipos de Personalidad "personality_timid": "Timido", "personality_adventurous": "Aventurero", "personality_lazy": "Perezoso", "personality_energetic": "Energico", "personality_introvert": "Introvertido", "personality_greedy": "Gloton", "personality_stubborn": "Terco", # Descripciones de Personalidad "desc_timid": "Tu calamar es Timido. Tiende a asustarse y ponerse ansioso con mas facilidad, especialmente en situaciones nuevas. Puede preferir ambientes tranquilos y calmados y podria ser menos propenso a explorar por su cuenta. Sin embargo, puede formar vinculos fuertes cuando se siente seguro y protegido.", "desc_adventurous": "Tu calamar es Aventurero. Le encanta explorar y probar cosas nuevas. A menudo es el primero en investigar nuevos objetos o areas en su entorno. Este calamar prospera con la novedad y podria aburrirse mas facilmente en entornos sin cambios.", "desc_lazy": "Tu calamar es Perezoso. Prefiere un estilo de vida relajado y puede ser menos activo que otros calamares. Podria necesitar mas estimulo para participar en actividades, pero puede estar bastante contento simplemente descansando. Este calamar es excelente conservando energia!", "desc_energetic": "Tu calamar es Energico. Siempre esta en movimiento, lleno de vida y vigor. Este calamar necesita mucha estimulacion y actividades para mantenerse feliz. Podria ponerse inquieto si no tiene suficientes oportunidades para quemar su exceso de energia.", "desc_introvert": "Tu calamar es Introvertido. Disfruta de la soledad y podria preferir espacios mas tranquilos y menos concurridos. Aunque puede interactuar con otros, puede necesitar tiempo a solas para recargarse. Este calamar podria ser mas observador y reflexivo en sus acciones.", "desc_greedy": "Tu calamar es Gloton. Tiene un fuerte enfoque en la comida y los recursos. Este calamar podria estar mas motivado por golosinas y recompensas que otros. Aunque puede ser mas exigente, tambien tiende a ser ingenioso y bueno encontrando golosinas escondidas.", "desc_stubborn": "Tu calamar es Terco. Tiene una voluntad fuerte y preferencias definidas. Este calamar podria ser mas resistente al cambio y podria tardar mas en adaptarse a nuevas rutinas. Sin embargo, su determinacion tambien puede hacerlo persistente al resolver problemas.", # Modificadores Cortos de Personalidad "mod_timid": "Mayor probabilidad de volverse ansioso", "mod_adventurous": "Mayor curiosidad y exploracion", "mod_lazy": "Movimiento mas lento y menor consumo de energia", "mod_energetic": "Movimiento mas rapido y niveles de actividad mas altos", "mod_introvert": "Prefiere la soledad y ambientes tranquilos", "mod_greedy": "Mas enfocado en comida y recursos", "mod_stubborn": "Solo come su comida favorita (sushi), puede negarse a dormir", # Detalles de Modificadores "modifiers_timid": "- La ansiedad aumenta 50% mas rapido\n- La curiosidad aumenta 50% mas lento\n- La ansiedad disminuye 50% cuando esta cerca de plantas", "modifiers_adventurous": "- La curiosidad aumenta 50% mas rapido", "modifiers_lazy": "- Se mueve mas lento\n- El consumo de energia es menor", "modifiers_energetic": "- Se mueve mas rapido\n- El consumo de energia es mayor", "modifiers_introvert": "- Prefiere espacios mas tranquilos y menos concurridos\n- Puede necesitar mas tiempo a solas para recargarse", "modifiers_greedy": "- Se pone 50% mas ansioso cuando tiene hambre\n- La satisfaccion aumenta mas al comer", "modifiers_stubborn": "- Prefiere su comida favorita (sushi)\n- Puede negarse a dormir incluso cuando esta cansado", # Consejos de Cuidado "tips_timid": "- Coloca plantas en el entorno para reducir la ansiedad\n- Manten el ambiente limpio y tranquilo\n- Acercate lentamente y evita movimientos bruscos\n- Manten una rutina consistente\n- Evita cambiar el tamano de la ventana frecuentemente", "tips_adventurous": "- Introduce nuevos objetos o decoraciones regularmente\n- Ofrece opciones de comida diversas\n- Fomenta la exploracion con ubicacion estrategica de comida\n- Permite mucho espacio para explorar\n- Alimenta su curiosidad natural con objetos interesantes", "tips_lazy": "- Coloca la comida mas cerca de los lugares de descanso\n- Limpia el entorno con mas frecuencia\n- Usa comida tentadora para fomentar el movimiento\n- No esperes mucha actividad - prefieren relajarse\n- Asegurate de que sus lugares de descanso esten comodos", "tips_energetic": "- Proporciona un espacio grande y abierto\n- Ofrece oportunidades frecuentes de alimentacion\n- Introduce elementos o juegos interactivos\n- Manten el entorno estimulante con decoraciones variadas\n- Necesitan mas comida debido al mayor consumo de energia", "tips_introvert": "- Crea areas tranquilas y apartadas con decoraciones\n- Evita saturar el entorno\n- Respeta la necesidad del calamar de estar solo\n- Crea espacios protegidos usando plantas\n- Acercate con suavidad y dale espacio cuando lo necesite", "tips_greedy": "- Ofrece una variedad de tipos de comida, incluyendo sushi\n- Usa la comida como recompensa por comportamientos deseados\n- Ten cuidado de no sobrealimentar\n- Se pondra mas ansioso cuando tenga hambre\n- Proporciona oportunidades para recolectar objetos", "tips_stubborn": "- Siempre ten sushi disponible ya que es su comida favorita\n- Se paciente al introducir cambios\n- Usa refuerzo positivo para comportamientos deseados\n- Puede rechazar comida que no sea sushi\n- Puede resistirse a dormir - crea ambientes tranquilos", # ===== PESTANA DECISIONES ===== "thought_process": "Proceso de Pensamiento del Calamar", "step": "Paso", "step1_title": "Percibiendo el Mundo", "step2_title": "Calculando Impulsos Basicos", "step3_title": "Aplicando Personalidad y Memorias", "step4_title": "Tomando la Decision Final", "final_action": "Accion Final:", "awaiting_thought": "Esperando el proximo pensamiento del calamar...", "awaiting_decision": "Esperando Decision...", "sensing_condition": "El calamar evalua su condicion actual y los objetos visibles:", "visible_objects": "Objetos Visibles", "no_sensory_data": "No hay datos sensoriales disponibles.", "none": "Ninguno", "no_urges": "No se calcularon impulsos.", "strongest_urge": "Basado en las necesidades, el impulso mas fuerte es", "initial_scores": "Puntuaciones iniciales:", "personality_memory_adjust": "Los rasgos de personalidad y memorias recientes ajustan estos impulsos:", "no_adjustments": "Sin ajustes significativos de personalidad o memoria esta vez.", "final_scores_text": "Despues de todos los calculos, se suman las puntuaciones finales. La puntuacion mas alta determina la accion.", "no_final_scores": "No hay puntuaciones finales disponibles.", "squid_decided": "El calamar ha decidido", "with_confidence": "con un nivel de confianza de", "score_increased": "aumento", "score_decreased": "disminuyo", "score_for": "La puntuacion para", "by_amount": "en", # ===== PESTANA APRENDIZAJE (ORIGINAL) ===== "active_learning_pairs": "Pares de Aprendizaje Activos", "hebbian_cycle": "Ciclo Hebbiano", "hebbian_paused": "EN PAUSA", "learning_ready": "Sistema de Aprendizaje Listo", "learning_ready_desc": "El aprendizaje hebbiano creara asociaciones entre neuronas que se activan juntas.", "log_cleared": "Registro Borrado", "log_cleared_desc": "Los pares de aprendizaje apareceran aqui mientras las neuronas forman nuevas conexiones.", "hebbian_overview": "Resumen del Aprendizaje Hebbiano", "neurons_fire_together": "Las neuronas que se activan juntas, se conectan juntas", "hebbian_principle": "Este principio fundamental describe como las redes neuronales aprenden a traves de la experiencia.", "hebbian_explanation": "El aprendizaje hebbiano es una regla simple pero poderosa utilizada en redes neuronales artificiales. Cuando dos neuronas se activan simultaneamente, la conexion entre ellas se fortalece. Si se activan por separado, la conexion se debilita.", "excitatory_connections": "Conexiones Excitatorias", "excitatory_desc": "Los pesos positivos (0.0-1.0) hacen que las neuronas sean mas propensas a activarse juntas", "inhibitory_connections": "Conexiones Inhibitorias", "inhibitory_desc": "Los pesos negativos (-1.0-0.0) hacen que las neuronas sean menos propensas a activarse juntas", "very_strong": "Muy Fuerte", "strong": "Fuerte", "moderate": "Moderado", "weak": "Debil", "very_weak": "Muy Debil", "inhibited": "Inhibido", # ===== PESTANA MEMORIA ===== "memory": "Memoria", "memories": "Memorias", "short_term_memory": "Memoria a Corto Plazo", "long_term_memory": "Memoria a Largo Plazo", "no_memories": "No hay memorias almacenadas todavia.", "overview": "Resumen", "memory_stats": "Estadísticas de Memoria", "categories": "Categorías", "time_label": "Hora:", "important_label": "Importante", "unknown": "Desconocido", "category_label": "Categoría:", "key_label": "Clave:", "access_count": "Conteo de acceso:", "full_content": "Contenido Completo:", "effects_label": "Efectos:", "positive": "Positivo", "negative": "Negativo", "neutral": "Neutral", # ===== PESTANA RED (ORIGINAL) ===== "brain_network": "Red Cerebral", "neurons": "Neuronas", "connections": "Conexiones", "activity": "Actividad", # ===== VENTANA ESTADISTICAS ===== "status": "Estado", "health": "Salud", # ===== NEURON NAMES (NEW - SPANISH) ===== "hunger": "Hambre", "happiness": "Felicidad", "cleanliness": "Limpieza", "sleepiness": "Somnolencia", "satisfaction": "Satisfaccion", "curiosity": "Curiosidad", "anxiety": "Ansiedad", "can_see_food": "Ve Comida", "is_eating": "Comiendo", "is_sleeping": "Durmiendo", "is_sick": "Enfermo", "is_fleeing": "Huyendo", "is_startled": "Asustado", "pursuing_food": "Buscando Comida", "external_stimulus": "Estimulo", "plant_proximity": "Cerca Planta", "stress": "Estres", "novelty": "Novedad", "reward": "Recompensa", # ===== BRAIN WIDGET LAYERS (NEW - SPANISH) ===== "layer_name": "Capa", "layer_input": "Entrada", "layer_output": "Salida", "layer_hidden": "Oculta", # ===== NEUROGENESIS LOGS (NEW - SPANISH) ===== "log_created": "{time} - una neurona {type} ({name}) fue creada porque el contador de {type} era {value:.2f}", "log_pruned": "{time} - una neurona ({name}) fue PODADA debido a {reason}", "log_stress_detail": "Se hizo una conexión inhibitoria a ANSIEDAD\nEl valor máximo de ansiedad se ha reducido permanentemente en 10", # Pildoras de Estado "fleeing": "Huyendo!", "startled": "Asustado!", "eating": "Comiendo", "sleeping": "Durmiendo", "playing": "Jugando", "hiding": "Escondido", "anxious": "Ansioso", "curious": "Curioso", # ===== ACCIONES COMUNES ===== "eat": "Comer", "sleep": "Dormir", "play": "Jugar", "explore": "Explorar", "rest": "Descansar", "hide": "Esconderse", "wander": "Deambular", "idle": "Inactivo", "seek_food": "Buscar Comida", "seek_shelter": "Buscar Refugio", # ===== OBJETOS ===== "food": "Comida", "rock": "Roca", "poop": "Caca", "plant": "Planta", "sushi": "Sushi", "decoration": "Decoración", # ===== TUTORIAL ===== "tutorial_hatched": "Un calamar ha nacido y debes cuidarlo!", "tutorial_feed": "Alimentalo cuando tenga hambre (Menu Acciones)", "tutorial_clean": "Limpia su tanque cuando se ensucie", "tutorial_watch": "Observa su comportamiento para aprender sobre su personalidad", "tutorial_neural": "RED NEURONAL", "tutorial_neural_desc": "Esta es la red neuronal del calamar. Su comportamiento esta impulsado por sus necesidades (neuronas redondas).\nLa red se adapta y aprende mientras el calamar interactua con su entorno.", "tutorial_satisfaction": "Manten la satisfaccion alta y la ansiedad baja.", "tutorial_traits": "Tu calamar desarrollara rasgos y comportamientos unicos segun como lo cries.", # ===== DISENADOR DE CEREBRO ===== "designer_title": "Disenador de Cerebro", "required_only": "Solo Requeridos", "dosidicus_default": "Dosidicus Predeterminado", "full_sensors": "Suite Completa de Sensores", "the_insomniac": "El Insomne", "the_hyperactive": "El Hiperactivo", "the_hangry": "El Hambriento Furioso", "the_depressive": "El Depresivo", "the_obsessive": "El Obsesivo", "balanced": "Equilibrado", "minimal": "Minimo", "dense": "Denso", "chaotic": "Caotico", "calm": "Calmado", # ===== PANTALLA DE INICIO ===== "squid_hatched": "UN CALAMAR HA NACIDO!", "look_after": "NECESITAS CUIDARLO..", # ===== PASOS DEL TUTORIAL ===== "next": "Siguiente", "tutorial_step1_text": "Un calamar ha nacido y debes cuidarlo!\n• Alimentalo cuando tenga hambre (Menu Acciones)\n• Limpia su tanque cuando se ensucie\n• Observa su comportamiento para aprender sobre su personalidad", "tutorial_step2_text": "Esta es la red neuronal del calamar. Su comportamiento esta impulsado por sus necesidades (neuronas).\nLa red se adapta y aprende mientras el calamar interactua con su entorno.", "tutorial_step3_text": "El calamar puede generar nuevas neuronas en respuesta a estimulos ambientales extremos.\nEstas nuevas neuronas ayudan al calamar a adaptarse a situaciones dificiles.", "tutorial_step4_text": "Cuando un par de neuronas se activan al mismo tiempo, su conexion se fortalece. Esto permite al calamar aprender asociaciones entre diferentes estimulos y respuestas.", "tutorial_step5_text": "La red neuronal toma decisiones basadas en necesidades actuales y memorias pasadas.\nCada decision afecta el estado del calamar y moldea su comportamiento futuro.", "tutorial_step6_text": "Presiona D en cualquier momento para abrir la ventana de Decoraciones\nArrastra y suelta decoraciones en el entorno y observa como reacciona el calamar a diferentes cosas. Cada tipo de decoracion afecta el estado mental del calamar de maneras unicas. Haz clic y usa la rueda del raton para redimensionar/SUPR para eliminar", "tutorial_step7_text": "Manten la satisfaccion alta y la ansiedad baja.\nTu calamar desarrollara rasgos y comportamientos unicos segun como lo cries.", # ===== NETWORK & LEARNING TABS (NEW - SPANISH) ===== "stats_neurons": "Neuronas", "stats_connections": "Conexiones", "stats_health": "Salud de Red", "emergency_alert": "🚨 Emergencia: {name}", "global_cooldown": "Enfriamiento", "style_label": "Estilo:", "chk_links": "Ver enlaces", "chk_weights": "Ver pesos", "chk_pruning": "Poda activada", "tooltip_brain_designer": "Abrir Diseñador de Cerebro", "msg_already_open": "Ya abierto", "msg_designer_running": "¡El Diseñador de Cerebro ya se está ejecutando!", "msg_launch_failed": "Error de inicio", "msg_designer_fail": "No se pudo iniciar el Diseñador de Cerebro:\n\n{e}", "msg_missing_brain": "Falta Cerebro", "msg_cannot_open_lab": "No se puede abrir el Laboratorio: Widget Cerebral no disponible.", "msg_cannot_open_buffer": "No se puede abrir Buffer: Widget Cerebral no disponible.", "msg_no_neurogenesis": "Sin Neurogénesis", "msg_neurogenesis_not_init": "No se puede abrir Buffer: Sistema de neurogénesis no iniciado.", "msg_decorations_unavailable": "Decoraciones no disponibles", "msg_decorations_fail": "No se puede abrir Decoraciones: Ventana no disponible.", "func_neurons_title": "Neuronas Funcionales", "count_label": "Cuenta", "avg_utility_label": "Utilidad Media", "total_activations_label": "Activaciones Totales", "specialisations_label": "Especializaciones", "buffer_title": "Buffer de Experiencia de Neurogénesis", "buffer_header": "Experiencias Recientes", "col_type": "Tipo", "col_pattern": "Patrón", "col_outcome": "Resultado", "col_time": "Tiempo", "btn_refresh": "Actualizar", "buffer_size": "Tamaño del buffer", "top_patterns": "Patrones principales", "no_patterns": "Sin patrones aún", # Learning Tab Educational Content (Spanish) "learning_pairs_tab": "Pares de Aprendizaje", "mechanics_tab": "Mecánica", "hebbian_quote": "\"Las neuronas que se activan juntas, se conectan juntas\"", "in_practice_title": "En la Práctica", "in_practice_text": "En el cerebro de tu calamar, el aprendizaje hebbiano ayuda a asociar estados como 'hambre' con 'satisfacción' al comer, o 'curiosidad' con 'ansiedad' al explorar.", "mechanics_title": "Mecánica de Aprendizaje", "mechanics_intro": "El aprendizaje hebbiano actualiza la fuerza de conexión (peso) entre neuronas basándose en su actividad. Si se activan juntas, la conexión se fortalece; si no, se debilita.", "learning_rule_title": "La Regla de Aprendizaje", "where_label": "Donde:", "delta_w_desc": "Δw = Cambio en el peso entre dos neuronas", "eta_desc": "η (eta) = Tasa de aprendizaje (velocidad de cambio)", "activation_desc": "x, y = Valores de activación (1 activo, 0 inactivo)", "example_calc_title": "Ejemplo de Cálculo", "scenario_label": "Escenario: 'hambre' y 'satisfacción' se activan", "calc_result": "El peso aumenta en 0.1, fortaleciendo la conexión.", "over_time_title": "Con el Tiempo", "over_time_text": "A través de activaciones repetidas, estos pequeños cambios se acumulan. Los patrones frecuentes desarrollan conexiones fuertes, permitiendo al calamar aprender de la experiencia.", "str_excitatory": "Excitatorio Fuerte", "weak_excitatory": "Excitatorio Débil", "weak_inhibitory": "Inhibitorio Débil", "str_inhibitory": "Inhibitorio Fuerte", # ===== SQUID & BRAIN STATISTICS (SPANISH) ===== "distance_rollover": "🌊 ¡El contador de distancia se reinició! Ahora en {multiplier}x", "time_min": "min", "time_mins": "mins", "time_hr": "h", "time_hrs": "hs", "time_fmt_hm": "{hours}h {minutes}m", "stat_squid_age": "Edad del Calamar", "stat_distance": "Distancia Nadada (píxeles)", "stat_cheese": "Queso Comido", "stat_sushi": "Sushi Comido", "stat_poops": "Cacas Creadas", "stat_max_poops": "Máx Cacas en Tanque", "stat_startles": "Veces Asustado", "stat_ink": "Nubes de Tinta Creadas", "stat_colour_change": "Veces Cambio de Color", "stat_rocks": "Rocas Lanzadas", "stat_plants": "Interacciones con Plantas", "stat_sleep": "Tiempo Total de Sueño (segundos)", "stat_sickness": "Episodios de Enfermedad", "stat_novelty_neurons": "Neuronas Novedad Creadas", "stat_stress_neurons": "Neuronas Estrés Creadas", "stat_reward_neurons": "Neuronas Recompensa Creadas", "stat_current_neurons": "Neuronas Actuales", "reset_stats_title": "Reiniciar Estadísticas", "reset_stats_msg": "¿Estás seguro de que quieres reiniciar todas las estadísticas?", "export_stats_title": "Exportar Estadísticas", "export_file_type": "Archivos de Texto (*.txt)", "export_header": "Exportación de Estadísticas del Calamar", "export_time": "Hora de Exportación", "export_activity_section": "Estadísticas de Actividad", "export_end": "Fin de Estadísticas", "export_success_title": "Exportación Exitosa", "export_success_msg": "Estadísticas exportadas a {file_name}", "export_error_title": "Error de Exportación", "export_error_msg": "Error al exportar estadísticas: {error}", # ===== ACHIEVEMENTS (NEW - SPANISH) ===== # Categories "cat_feeding": "Alimentación", "cat_neurogenesis": "Neurogénesis", "cat_sleep": "Sueño", "cat_milestones": "Hitos", "cat_exploration": "Exploración", "cat_cleaning": "Limpieza", "cat_health": "Salud", "cat_interaction": "Interacción", "cat_ink": "Tinta", "cat_memory": "Memoria", "cat_emotional": "Emocional", "cat_secret": "Secreto", "cat_meta": "Meta", # UI Elements "ui_points": "Puntos", "ui_unlocked": "Desbloqueado", "ui_achievement_unlocked": "¡Logro Desbloqueado!", "ui_hidden": "Logro oculto", "ui_all": "Todos", "ui_points_gained": "puntos", # --- Achievements --- # Feeding "ach_first_feeding_name": "Primer Bocado", "ach_first_feeding_desc": "Alimenta al calamar por primera vez", "ach_fed_10_times_name": "Comidas Regulares", "ach_fed_10_times_desc": "Alimenta al calamar 10 veces", "ach_fed_50_times_name": "Cuidador Dedicado", "ach_fed_50_times_desc": "Alimenta al calamar 50 veces", "ach_fed_100_times_name": "Chef Maestro", "ach_fed_100_times_desc": "Alimenta al calamar 100 veces", "ach_fed_500_times_name": "Leyenda Culinaria", "ach_fed_500_times_desc": "Alimenta al calamar 500 veces", # Neurogenesis "ach_first_neuron_name": "Chispa Cerebral", "ach_first_neuron_desc": "Crea la primera neurona de neurogénesis", "ach_neurons_10_name": "Red Neuronal", "ach_neurons_10_desc": "Crea 10 neuronas mediante neurogénesis", "ach_neurons_50_name": "Mente en Expansión", "ach_neurons_50_desc": "Crea 50 neuronas mediante neurogénesis", "ach_neurons_100_name": "Potencia Cerebral", "ach_neurons_100_desc": "Crea 100 neuronas mediante neurogénesis", "ach_first_neuron_levelup_name": "Sinapsis Fortalecida", "ach_first_neuron_levelup_desc": "Sube de nivel una neurona por primera vez", "ach_neuron_max_level_name": "Rendimiento Máximo", "ach_neuron_max_level_desc": "Sube una neurona a su fuerza máxima", # Sleep "ach_first_sleep_name": "Dulces Sueños", "ach_first_sleep_desc": "El calamar despierta de su primer sueño", "ach_slept_10_times_name": "Bien Descansado", "ach_slept_10_times_desc": "El calamar ha dormido 10 veces", "ach_dream_state_name": "Soñador Profundo", "ach_dream_state_desc": "El calamar entró en sueño REM", # Milestones "ach_age_1_hour_name": "Una Hora de Vida", "ach_age_1_hour_desc": "El calamar alcanzó 1 hora de edad", "ach_age_10_hours_name": "Creciendo", "ach_age_10_hours_desc": "El calamar alcanzó 10 horas de edad", "ach_age_24_hours_name": "Maravilla de un Día", "ach_age_24_hours_desc": "El calamar sobrevivió 24 horas", "ach_age_1_week_name": "Veterano Semanal", "ach_age_1_week_desc": "El calamar ha vivido una semana", "ach_age_1_month_name": "Veterano Mensual", "ach_age_1_month_desc": "El calamar ha vivido un mes", "ach_happiness_100_name": "Felicidad Pura", "ach_happiness_100_desc": "Alcanza el 100% de felicidad", "ach_all_stats_high_name": "Equilibrio Perfecto", "ach_all_stats_high_desc": "Todas las estadísticas por encima del 80% simultáneamente", # Cleaning "ach_first_clean_name": "Primer Fregado", "ach_first_clean_desc": "Limpia el tanque por primera vez", "ach_cleaned_25_times_name": "Entorno Impecable", "ach_cleaned_25_times_desc": "Limpia el tanque 25 veces", "ach_germaphobe_name": "Germófobo", "ach_germaphobe_desc": "Mantén la limpieza por encima del 90% durante 1 hora seguida", # Health "ach_first_medicine_name": "Primeros Auxilios", "ach_first_medicine_desc": "Administra medicina por primera vez", "ach_medicine_10_times_name": "Doctor Calamar", "ach_medicine_10_times_desc": "Administra medicina 10 veces", "ach_comeback_kid_name": "El Regreso", "ach_comeback_kid_desc": "Recuperarse de salud crítica (<20%) al máximo", # Interaction (Rocks) "ach_first_rock_pickup_name": "Coleccionista de Rocas", "ach_first_rock_pickup_desc": "Recoge una roca por primera vez", "ach_rocks_picked_10_name": "Recolector de Piedras", "ach_rocks_picked_10_desc": "Recoge 10 rocas", "ach_rocks_picked_50_name": "Acaparador de Rocas", "ach_rocks_picked_50_desc": "Recoge 50 rocas", "ach_first_rock_throw_name": "Haciendo Sapito", "ach_first_rock_throw_desc": "Lanza una roca por primera vez", "ach_rocks_thrown_25_name": "Lanzador de Rocas", "ach_rocks_thrown_25_desc": "Lanza 25 rocas", "ach_rocks_thrown_100_name": "Maestro de Catapulta", "ach_rocks_thrown_100_desc": "Lanza 100 rocas", # Interaction (Decor) "ach_first_decoration_push_name": "Decorador de Interiores", "ach_first_decoration_push_desc": "Empuja una decoración por primera vez", "ach_decorations_pushed_10_name": "Mudanzas", "ach_decorations_pushed_10_desc": "Empuja decoraciones 10 veces", "ach_decorations_pushed_50_name": "Maestro del Feng Shui", "ach_decorations_pushed_50_desc": "Empuja decoraciones 50 veces", "ach_first_plant_interact_name": "Mano Verde", "ach_first_plant_interact_desc": "Interactúa con una planta por primera vez", "ach_plants_interacted_10_name": "Explorador de Jardín", "ach_plants_interacted_10_desc": "Interactúa con plantas 10 veces", "ach_plants_interacted_50_name": "Botánico", "ach_plants_interacted_50_desc": "Interactúa con plantas 50 veces", "ach_objects_investigated_25_name": "Inspector Curioso", "ach_objects_investigated_25_desc": "Investiga 25 objetos diferentes", "ach_objects_investigated_100_name": "Detective Maestro", "ach_objects_investigated_100_desc": "Investiga 100 objetos diferentes", # Exploration (Poop) "ach_first_poop_throw_name": "Travieso", "ach_first_poop_throw_desc": "El calamar lanzó caca por primera vez", # Ink "ach_first_ink_cloud_name": "Cortina de Humo", "ach_first_ink_cloud_desc": "El calamar suelta una nube de tinta por primera vez", "ach_ink_clouds_20_name": "Maestro de la Tinta", "ach_ink_clouds_20_desc": "Suelta 20 nubes de tinta", # Memory "ach_first_memory_name": "Primer Recuerdo", "ach_first_memory_desc": "Forma el primer recuerdo", "ach_memory_long_term_name": "Pensamiento a Largo Plazo", "ach_memory_long_term_desc": "Promueve un recuerdo al almacenamiento a largo plazo", "ach_memories_50_name": "Memoria Fotográfica", "ach_memories_50_desc": "Ten 50 recuerdos almacenados", # Emotional "ach_curiosity_100_name": "Jorge el Curioso", "ach_curiosity_100_desc": "La curiosidad alcanza el 100%", "ach_zen_master_name": "Maestro Zen", "ach_zen_master_desc": "Mantén la ansiedad por debajo del 10% durante 30 minutos", "ach_first_startle_name": "¡Sobresalto!", "ach_first_startle_desc": "Asusta al calamar por primera vez", "ach_nervous_wreck_name": "Manojo de Nervios", "ach_nervous_wreck_desc": "La ansiedad alcanza el 100%", # Secret "ach_night_owl_name": "Búho Nocturno", "ach_night_owl_desc": "Juega entre la medianoche y las 4 AM", "ach_early_bird_name": "Madrugador", "ach_early_bird_desc": "Juega entre las 5 AM y las 7 AM", "ach_weekend_warrior_name": "Guerrero de Fin de Semana", "ach_weekend_warrior_desc": "Juega tanto el sábado como el domingo", # Meta "ach_brain_surgeon_name": "Cirujano Cerebral", "ach_brain_surgeon_desc": "Abre la herramienta de visualización cerebral", "ach_speed_demon_name": "Demonio de la Velocidad", "ach_speed_demon_desc": "Ejecuta la simulación a velocidad máxima durante 10 minutos", "ach_completionist_name": "Completista", "ach_completionist_desc": "Desbloquea otros 30 logros", # Additional Log/Debug Messages (Previously Orphaned) "Hebbian learning chosen pairs:": "Pares elegidos por aprendizaje Hebbiano:", "Main thread: Created neuron": "Hilo principal: Neurona creada", "BrainWidget received external BrainWorker": "BrainWidget recibió BrainWorker externo", "Neurogenesis monitoring timer started": "Temporizador de monitoreo de neurogénesis iniciado", "Brain state export enabled for designer sync": "Exportación de estado cerebral habilitada para sincronización con diseñador", "Animation palette built for style:": "Paleta de animación construida para estilo:", "Animation style changed:": "Estilo de animación cambiado:", "Unknown animation style:": "Estilo de animación desconocido:", "Available:": "Disponibles:", # ===== LABORATORIO DE NEURONAS ===== "lab_title": "🧠 Laboratorio de Neuronas", "lab_live_refresh": "Actualización en vivo", "lab_unlock_editing": "🔓 Desbloquear edición", "lab_tab_overview": "📊 Resumen en Vivo", "lab_tab_inspector": "🔍 Inspector Profundo", "lab_tab_edit": "🔧 Área de Edición", "lab_status_ready": "Listo", "lab_status_locked": "🔒 {name} bloqueado en {value}", "lab_status_unlocked": "🔓 {name} desbloqueado", # Pestaña Resumen "lab_ov_counters": "Progreso de contadores", "lab_ov_newest": "Neuronas de neurogénesis más recientes", "lab_ov_limits": "Límites y poda", "lab_ov_actions": "Acciones rápidas", "lab_force_hebbian": "Forzar ciclo Hebbiano", "lab_pruning_enabled": "Poda habilitada:", "lab_none_yet": "Ninguna todavía", "lab_ago": "hace {seconds}s", # Pestaña Inspector "lab_pick_neuron": "Elige una neurona para inspeccionar:", "lab_connections_title": "Conexiones (excitatorias vs inhibitorias)", "lab_header_partner": "Socio", "lab_header_weight": "Peso", "lab_header_type": "Tipo", "lab_header_inf": "Influencia", "lab_impact_title": "Simulación de impacto funcional", "lab_header_neuron": "Neurona", "lab_header_delta": "Valor Δ", "lab_no_connections": "No hay conexiones activas por el momento", "lab_did_you_know": "¿Sabías que?", "lab_type_excitatory": "Excitatoria", "lab_type_inhibitory": "Inhibitoria", # Pestaña Edición "lab_edit_locked_msg": "⚠️ La edición está bloqueada – marca 'Desbloquear edición' en la barra de herramientas.", "lab_edit_header": "Valores neuronales (arrastra para cambiar) – clic 🔒 para bloquear", "lab_unlock_title": "¿Desbloquear edición?", "lab_unlock_msg": "Ahora puedes cambiar valores neuronales y forzar eventos de creación. ¡Úsalo con responsabilidad!", # Badges/Influence "lab_inf_tiny": "diminuto", "lab_inf_mild": "leve", "lab_inf_mod": "moderado", "lab_inf_strong": "FUERTE", # Educational Tips "lab_tip_hunger": "El hambre es un impulso homeostático. El hambre alta inhibe la satisfacción y aumenta la ansiedad.", "lab_tip_happiness": "La felicidad es reforzada por neuronas de recompensa. Inhibe la ansiedad y promueve la curiosidad.", "lab_tip_anxiety": "La ansiedad se reduce con neuronas de estrés (inhibitorias). La ansiedad alta suprime la curiosidad.", "lab_tip_curiosity": "La curiosidad aumenta cuando la novedad es alta. Fomenta la exploración y reduce la ansiedad.", "lab_tip_core": "Neurona núcleo – fundamental para la supervivencia.", "lab_tip_neuro_default": "Neurona de neurogénesis – propósito inferido del contexto de nacimiento.", "lab_tip_neuro_fmt": "Creada por {trigger} – se especializa en {spec}. Su trabajo es convertir experiencias en comportamiento a largo plazo.", # ===== VISION WINDOW ===== "vision_window_title": "Visión del Calamar", "vis_logic_unavailable": "Lógica del calamar no disponible.", "vis_nothing_in_view": "Nada a la vista actualmente.", "vis_distance": "distancia", # --- Brain Tooltips --- "tooltip_specialization": "Especialización", "tooltip_type": "Tipo", "tooltip_current": "Actual", "tooltip_utility": "Utilidad", "tooltip_activations": "Activaciones", "tooltip_last_active": "Última actividad", "tooltip_age": "Edad", "tooltip_core": "Núcleo", "tooltip_generated": "Generada", "tooltip_functional": "Funcional", "tooltip_connections_header": "Conexiones", "tooltip_connections_stats": "{incoming} ent., {outgoing} sal.", "tooltip_top_incoming": "Entradas principales", "tooltip_top_outgoing": "Salidas principales", "tooltip_hint": "Doble clic para inspeccionar • Clic derecho para opciones", # State values "state_on": "ENC", "state_off": "APAG", # Time formatting "fmt_s_ago": "hace {val}s", "fmt_m_ago": "hace {val}m", "fmt_h_ago": "hace {val}h", "fmt_s_short": "{val}s", "fmt_m_short": "{val}m", "fmt_h_short": "{val}h", "fmt_d_short": "{val}d", # ===== BRAIN DESIGNER WINDOW (NEW) ===== "designer_window_title": "Diseñador de Cerebro - Dosidicus-2", "designer_window_title_imported": "Diseñador de Cerebro - Dosidicus-2 [Importado del Juego]", "designer_tab_layers": "Capas", "designer_tab_sensors": "Sensores", "designer_tab_props": "Propiedades", "designer_tab_connections": "Conexiones", "designer_tab_outputs": "Salidas", "designer_btn_generate": "🎲 Generar Red Dispersa", "designer_tooltip_generate": "Generar conexiones aleatorias entre neuronas núcleo", "designer_btn_neuron": "➕ Neurona", "designer_tooltip_neuron": "Añadir una nueva neurona (Mayús+N)", "designer_btn_fix": "🔧 Auto-Reparar", "designer_tooltip_fix": "Reparar automáticamente neuronas huérfanas y problemas de conectividad", "designer_btn_validate": "✓ Validar", "designer_tooltip_validate": "Verificar diseño en busca de problemas", "designer_btn_sync": "🔄 Sincronizar desde Juego", "designer_tooltip_sync": "Actualizar estado cerebral desde el juego Dosidicus en ejecución", "designer_btn_clear_conn": "🗑 Borrar Conexiones", "designer_tooltip_clear_conn": "Eliminar todas las conexiones (mantiene neuronas)", "designer_tooltip_dice": "Generar instantáneamente una red aleatoria (sin diálogo)", # Ticker / Help Bar "designer_help_drag_connect": "💡 Arrastre-Izquierdo desde neurona para crear conexión", "designer_help_ctrl_move": "Ctrl+Arrastre para mover neurona", "designer_help_pan": "Arrastre-Derecho para mover el lienzo", "designer_help_zoom": "Rueda del Ratón para zoom (o ajustar peso en conexión)", "designer_help_edit_weight": "Doble-Clic en conexión para editar peso", "designer_help_select": "Clic en neurona/conexión para seleccionar", "designer_help_delete": "Supr para eliminar selección", "designer_help_reverse": "Espacio para invertir dirección de conexión", "designer_help_keys_weight": "Teclas +/- para ajustar peso (Mayús para pasos más grandes)", "designer_help_page_weight": "Re Pág/Av Pág para ajustar peso (pasos grandes)", "designer_help_add_neuron": "Mayús+N para añadir neurona", "designer_help_save": "Ctrl+S para guardar", "designer_help_open": "Ctrl+O para abrir", "designer_help_export": "Ctrl+E para exportar", "designer_help_new": "Ctrl+N para nuevo diseño", "designer_help_gen": "Ctrl+G para generar red", "designer_help_dice": "🎲 Botón de dado para generación aleatoria instantánea", "designer_help_outputs": "Pestaña Salidas para vincular neuronas a comportamientos", # Menus "designer_menu_file": "Archivo", "designer_menu_edit": "Editar", "designer_menu_templates": "Plantillas", "designer_menu_generate": "Generar", "designer_action_new": "Nuevo Diseño", "designer_action_save": "Guardar...", "designer_action_export": "Exportar para Dosidicus...", "designer_action_open": "Abrir...", "designer_action_gen_sparse": "Generar Red Dispersa...", "designer_action_autofix": "Auto-Reparar Conectividad", "designer_action_validate": "Validar Diseño", "designer_action_clear_conn": "Borrar Todas las Conexiones", "designer_action_clear_outputs": "Borrar Todas las Vinculaciones", # Status Bar "designer_status_neurons": "Neuronas: {count}", "designer_status_connections": "Conexiones: {count}", "designer_status_required": "Requerido: {ok}", "designer_status_outputs": "Salidas: {count}", "designer_status_selected": "Selección: {source} → {target} (peso: {weight:+.3f})", "designer_status_weight_updated": "Peso actualizado: {source} → {target} = {weight:+.3f}", "designer_status_deleted": "Conexión eliminada: {source} → {target}", "designer_status_cleared_conn": "{count} conexiones borradas", "designer_status_cleared_out": "{count} vinculaciones de salida borradas", "designer_status_generated": "Generadas {count} conexiones usando preajuste '{style}'", "designer_status_random_gen": "🎲 Generadas {count} conexiones aleatorias (estilo: {style})", "designer_status_synced": "✨ Sincronizado: {neurons} neuronas, {connections} conexiones", "designer_status_imported": "✨ Cerebro activo importado desde juego en ejecución", # Dialogs & Messages "designer_msg_game_not_running_title": "Juego No Ejecutándose", "designer_msg_game_not_running": "El juego Dosidicus ya no se está ejecutando.\n\nInicia el juego de nuevo para sincronizar.", "designer_msg_sync_confirm_title": "Sincronizar desde Juego", "designer_msg_sync_confirm": "¿Reemplazar diseño actual con el último estado cerebral del juego?", "designer_msg_sync_failed_title": "Sincronización Fallida", "designer_msg_sync_failed": "No se pudo importar estado cerebral desde el juego.", "designer_msg_live_import_title": "Importación Cerebral en Vivo", "designer_msg_live_import_header": "🧠 Cerebro activo importado desde juego en ejecución", "designer_msg_live_import_body": "El diseñador ahora muestra la red neuronal exacta de tu juego Dosidicus.\n\n• {neurons} neuronas\n• {connections} conexiones\n\nLos cambios realizados aquí NO afectarán el juego en ejecución.", "designer_msg_clear_conn_title": "Borrar Conexiones", "designer_msg_clear_conn_confirm": "¿Eliminar todas las {count} conexiones?\n\nLas neuronas se mantendrán.", "designer_msg_clear_out_title": "Borrar Vinculaciones de Salida", "designer_msg_clear_out_empty": "No hay vinculaciones para borrar.", "designer_msg_clear_out_confirm": "¿Eliminar todas las {count} vinculaciones de salida?", "designer_msg_new_design_title": "Nuevo Diseño", "designer_msg_new_design_confirm": "¿Iniciar un nuevo diseño? Los cambios no guardados se perderán.", "designer_msg_autofix_title": "Auto-Reparar", "designer_msg_autofix_result": "Se crearon {count} conexiones:\n\n{details}", "designer_msg_autofix_none": "No se encontraron problemas.", "designer_msg_save_title": "Guardar Diseño", "designer_msg_saved_title": "Guardado", "designer_msg_save_success": "Diseño guardado exitosamente: {msg}", "designer_msg_save_bindings": "\n({count} vinculaciones de salida incluidas)", "designer_msg_error_title": "Error", "designer_msg_save_fail": "Error al guardar diseño:\n\n{error}", "designer_msg_export_title": "Exportar", "designer_msg_exported_title": "Exportado", "designer_msg_export_success": "Diseño exportado exitosamente", "designer_msg_export_fail": "Error al exportar diseño:\n\n{error}", "designer_msg_open_title": "Abrir Diseño", "designer_msg_open_fail": "No se pudo cargar el diseño:\n\n{error}", "designer_msg_load_template_title": "Cargar Plantilla", "designer_msg_select_template": "Selecciona una plantilla:", "designer_msg_replace_design": "¿Reemplazar diseño actual?", "designer_msg_status_title": "Estado del Diseño", "designer_msg_status_ok": "\n✅ Estado: OK", "designer_msg_status_issues": "\n⚠️ PROBLEMAS:\n", "designer_input_weight_title": "Peso de Conexión", "designer_input_weight_label": "Establecer peso para {source} → {target}:", # ===== DESIGNER PANELS ===== # Properties Panel "designer_prop_no_selection": "Ninguna neurona seleccionada", "designer_prop_no_selection_disabled": "Sin Selección", "designer_prop_lbl_name": "Nombre:", "designer_prop_lbl_type": "Tipo:", "designer_prop_lbl_x": "X:", "designer_prop_lbl_y": "Y:", "designer_prop_btn_delete": "Eliminar Neurona", # Add Neuron Dialog "designer_add_title": "Añadir Neurona", "designer_add_grp_type": "Seleccionar Tipo de Neurona", "designer_add_btn_custom": "✨ Neurona Personalizada / Plugin", "designer_add_btn_sensor": "📡 Sensor de Entrada", "designer_add_tooltip_custom": "Crear neurona con nombre específico para enlazar con plugins", "designer_add_grp_sensor": "Seleccionar Sensor", "designer_add_grp_custom": "Definir Neurona Personalizada", "designer_add_info_custom": "Para afectar al calamar, el Nombre debe coincidir con un ID de plugin.
    Ejemplo: Nombre 'jet_boost' para activar un plugin de propulsión.
    ", "designer_add_lbl_id": "ID de Plugin / Nombre:", "designer_add_ph_id": "ej. modo_turbo", "designer_add_btn_create": "Crear Enlace", "designer_add_all_added": "Todos los sensores añadidos", "designer_add_err_title": "Error", "designer_add_err_exists": "Ya existe", "designer_add_msg_created": "Creado {name}", # Layers Panel "designer_layer_btn_add": "Añadir Capa", "designer_layer_dlg_title": "Nueva Capa", "designer_layer_dlg_label": "Nombre:", # Sensors Panel "designer_sensor_header": "Sensores de Entrada:", "designer_sensor_tooltip_refresh": "Actualizar lista de sensores (incluye sensores de plugins)", "designer_sensor_cat_label": "── {name} ──", # Connections Table "designer_conn_header_source": "Fuente", "designer_conn_header_target": "Objetivo", "designer_conn_header_weight": "Peso", # ===== OUTPUTS PANEL ===== "designer_output_header": "Vinculaciones de Salida
    Conecta neuronas a comportamientos. Cuando la activación de una neurona supera el umbral, activa la acción vinculada.", "designer_output_btn_add": "➕ Añadir Vinculación", "designer_output_btn_edit": "✏️ Editar", "designer_output_btn_remove": "🗑️ Eliminar", "designer_output_col_neuron": "Neurona", "designer_output_col_behavior": "→ Comportamiento", "designer_output_col_threshold": "Umbral", "designer_output_col_mode": "Modo", "designer_output_col_enabled": "Habilitado", "designer_output_info": "{count} vinculación(es), {enabled} habilitadas", "designer_output_err_missing": "⚠️ Neurona no encontrada en diseño", "designer_output_dlg_remove_title": "Eliminar Vinculación", "designer_output_dlg_remove_msg": "¿Eliminar vinculación: {neuron} → {hook}?", # Output Binding Dialog "designer_binding_title_add": "Añadir Vinculación de Salida", "designer_binding_title_edit": "Configurar Vinculación de Salida", "designer_binding_grp_neuron": "Neurona de Origen", "designer_binding_lbl_neuron": "Neurona:", "designer_binding_lbl_current": "Actual: --", "designer_binding_grp_hook": "Comportamiento de Salida", "designer_binding_lbl_trigger": "Disparador:", "designer_binding_grp_settings": "Ajustes de Disparo", "designer_binding_lbl_thresh": "Umbral:", "designer_binding_lbl_mode": "Modo:", "designer_binding_lbl_cool": "Enfriamiento:", "designer_binding_chk_enabled": "Habilitado", "designer_binding_err_neuron": "Por favor selecciona una neurona", "designer_binding_err_hook": "Por favor selecciona un comportamiento de salida", "designer_binding_err_duplicate": "Ya existe una vinculación para {neuron} → {hook}", # Trigger Modes "designer_mode_rising": "Flanco de Subida (cruzar umbral subiendo)", "designer_mode_falling": "Flanco de Bajada (cruzar umbral bajando)", "designer_mode_above": "Mientras Encima (continuo mientras > umbral)", "designer_mode_below": "Mientras Debajo (continuo mientras < umbral)", "designer_mode_change": "Al Cambiar (cualquier cambio significativo)", # ===== DESIGNER SENSOR DISCOVERY ===== "desc_builtin_sensor": "Sensor integrado: {name}", "desc_vision_food": "Detecta comida en cono de visión", "desc_custom_sensor": "Sensor personalizado de {plugin}", "desc_builtin": "integrado", "desc_plugin": "plugin", "desc_other": "otro", "desc_vision": "visión", # ===== DESIGNER TEMPLATES ===== "tmpl_core_name": "🟡 Solo Requeridos", "tmpl_core_desc": "8 neuronas requeridas", "tmpl_dosidicus_name": "🟡 Dosidicus Predeterminado", "tmpl_dosidicus_desc": "Diseño estándar", "tmpl_full_sensors_name": "🟡 Suite Completa de Sensores", "tmpl_full_sensors_desc": "Todos los sensores", "tmpl_insomniac_name": "🔴 El Insomne", "tmpl_insomniac_desc": "Ansiedad y Curiosidad bloquean sueño", "tmpl_hyperactive_name": "🔴 El Hiperactivo", "tmpl_hyperactive_desc": "Neuronas de ruido abruman somnolencia", "tmpl_hangry_name": "🔴 El Hambriento Furioso", "tmpl_hangry_desc": "El hambre causa furia extrema", "tmpl_depressive_name": "🔴 El Depresivo", "tmpl_depressive_desc": "Resistente a la felicidad", "tmpl_obsessive_name": "🔴 El Obsesivo", "tmpl_obsessive_desc": "Bucle de retroalimentación Ansiedad/Curiosidad", "layer_sensors": "Sensores", "layer_core": "Núcleo", "layer_input": "Entrada", "layer_out": "Salida", "layer_racing_mind": "Mente Acelerada", "layer_state": "Estado", "layer_vision": "Visión", "layer_noise": "Ruido", "layer_output": "Salida", "layer_gut_brain": "Cerebro-Intestino", "layer_gray": "Gris", "layer_loop": "Bucle", "layer_stats": "Estadísticas", "layer_emotions": "Emociones", # ===== CANVAS CONTEXT MENU / DIALOGS ===== "designer_cnv_del_conn_title": "Eliminar Conexión", "designer_cnv_del_conn_msg": "¿Estás seguro de que quieres eliminar la conexión:\n{source} → {target}?", "designer_cnv_chk_dont_ask": "No preguntar de nuevo", "designer_cnv_btn_del": "Sí, Eliminar", "designer_cnv_btn_cancel": "Cancelar", "designer_cnv_dlg_edit_title": "Editar Conexión", "designer_cnv_lbl_conn": "Conexión: {source} → {target}", "designer_cnv_lbl_weight": "Peso:", "designer_cnv_info_weight": "Positivo = Excitatorio (verde), Negativo = Inhibitorio (rojo)", "designer_cnv_btn_del_conn": "Eliminar Conexión", "designer_cnv_btn_ok": "Aceptar", "designer_cnv_tooltip_invalid": "Conexión inválida", } ================================================ FILE: translations/fr.py ================================================ LANGUAGE_HEADER = "fr - Francais" translations = { # Core continuous neurons "hunger": "Faim", "happiness": "Bonheur", "cleanliness": "Propreté", "sleepiness": "Somnolence", "satisfaction": "Satisfaction", "anxiety": "Anxiété", "curiosity": "Curiosité", # Binary/sensor neurons "can_see_food": "Peut Voir de la Nourriture", "is_eating": "Mange", "is_sleeping": "Dort", "is_sick": "Malade", "pursuing_food": "Poursuit de la Nourriture", "is_startled": "Effrayé", "is_fleeing": "Fuit", # Base keys for neurogenesis patterns "novelty": "Nouveauté", "stress": "Stress", "reward": "Récompense", # ===== MENU PRINCIPAL ===== "file": "Fichier", "new_game": "Nouvelle Partie", "load_game": "Charger Partie", "save_game": "Sauvegarder", "view": "Affichage", "speed": "Vitesse", "pause": "Pause", "actions": "Actions", "debug": "Debogage", "plugins": "Plugins", # ===== MENU AFFICHAGE ===== "brain_designer": "Concepteur de Cerveau", "decorations": "Decorations", "statistics": "Statistiques", "brain_tool": "Outil Cerebral", "neuron_lab": "Laboratoire de Neurones", "task_manager": "Gestionnaire de Taches", # ===== MENU VITESSE ===== "normal_speed": "Normal (1x)", "fast_speed": "Rapide (2x)", "very_fast": "Tres Rapide (3x)", # ===== MENU DEBOGAGE ===== "toggle_debug": "Basculer Mode Debogage", "toggle_cone": "Basculer Cone de Vision", "squid_vision": "Vision du Calmar", # ===== BOUTONS D'ACTION ===== "feed": "Nourrir", "clean": "Nettoyer", "medicine": "Medicament", "feed_btn": "NOURRIR", "clean_btn": "NETTOYER", "medicine_btn": "MEDICAMENT", # ===== MESSAGES ===== "feed_msg": "Le calmar a besoin de nourriture", "points": "Points", "dirty": "SALE", "paused_msg": "SIMULATION EN PAUSE", "paused_sub": "Utilisez le menu Vitesse pour reprendre", # ===== DIALOGUES ===== "yes": "Oui", "no": "Non", "ok": "OK", "cancel": "Annuler", "close": "Fermer", "save": "Sauvegarder", "load": "Charger", "reset": "Reinitialiser", "apply_changes": "Appliquer les Changements", "got_it": "Compris!", "finish": "Terminer", "confirm_new_game": "Commencer une nouvelle partie? La progression actuelle sera perdue.", "confirm_exit": "Etes-vous sur de vouloir quitter?", "save_successful": "Partie sauvegardee avec succes!", "load_successful": "Partie chargee avec succes!", "error_saving": "Erreur lors de la sauvegarde.", "error_loading": "Erreur lors du chargement.", "no_save_found": "Aucun fichier de sauvegarde trouve.", # ===== ONGLET A PROPOS ===== "hello": "BONJOUR", "my_name_is": "je m'appelle", "change_name": "Changer le Nom", "enter_new_name": "Entrez un nouveau nom pour votre calmar:", "change_colour": "Changer la Couleur", "view_certificate": "Voir le Certificat", "care_tips": "Conseils de Soin", "care_tips_for": "Conseils de Soin pour les Calmars {personality}", "dosidicus_title": "Dosidicus electronicus", "dosidicus_desc": "Un animal de compagnie numerique style Tamagotchi avec un reseau neuronal simple", "string_acronym": "Reacciones de Tamagotchi Simuladas mediante Inferencia y Neurogenesis (STRINg)", "research_project": "Ceci est un projet de recherche. Veuillez suggerer des fonctionnalites.", "version_dosidicus": "Version Dosidicus :", "version_brain_tool": "Version Outil Cérébral :", "version_decision": "Version Moteur de Décision :", "version_neuro": "Version Neurogenèse :", "created_by": "par", # ===== BRAIN TOOL TABS ===== "tab_learning": "Apprentissage", "tab_decisions": "Décisions", "tab_personality": "Personnalité", "tab_about": "À Propos", # ===== NEURON INSPECTOR ===== "inspector_title": "Inspecteur de Neurones", "lbl_name": "Nom :", "lbl_value": "Valeur Actuelle :", "lbl_position": "Position :", "lbl_type": "Type :", "grp_neurogenesis": "Détails Neurogenèse", "lbl_created": "Créé Le :", "lbl_trigger": "Type Déclencheur :", "lbl_trigger_val": "Valeur Déclencheur :", "lbl_state": "État Associé :", "col_connected": "Connecté À", "col_weight": "Poids", "col_direction": "Direction", "btn_refresh_data": "Actualiser Données", "type_core": "Noyau", "type_neuro": "Neurogenèse", "type_system": "Statut Système", "direction_incoming": "Entrant", "direction_outgoing": "Sortant", # ===== PERSONNALITE ===== "squid_personality": "Personnalite du Calmar", "personality_modifier": "Modificateur de Personnalite", "description": "Description:", "personality_modifiers": "Modificateurs de Personnalite:", "care_tips_label": "Conseils de Soin:", "personality_note": "Note: La personnalite est generee aleatoirement au debut d'une nouvelle partie", # Types de Personnalite "personality_timid": "Timide", "personality_adventurous": "Aventurier", "personality_lazy": "Paresseux", "personality_energetic": "Energique", "personality_introvert": "Introverti", "personality_greedy": "Gourmand", "personality_stubborn": "Tetu", # Descriptions de Personnalite "desc_timid": "Votre calmar est Timide. Il a tendance a etre plus facilement effraye et anxieux, surtout dans les nouvelles situations. Il peut preferer des environnements calmes et pourrait etre moins enclin a explorer seul. Cependant, il peut former des liens forts quand il se sent en securite.", "desc_adventurous": "Votre calmar est Aventurier. Il adore explorer et essayer de nouvelles choses. Il est souvent le premier a examiner de nouveaux objets dans son environnement. Ce calmar s'epanouit avec la nouveaute et pourrait s'ennuyer facilement dans des environnements sans changement.", "desc_lazy": "Votre calmar est Paresseux. Il prefere un style de lifestyle detendu et peut etre moins actif que les autres calmars. Il pourrait avoir besoin d'encouragement supplementaire pour participer aux activites, mais peut etre tres content de simplement se prelasser. Ce calmar est excellent pour economiser l'energie!", "desc_energetic": "Votre calmar est Energique. Il est toujours en mouvement, plein de vie et de vigueur. Ce calmar a besoin de beaucoup de stimulation et d'activites pour etre heureux. Il pourrait devenir agite s'il n'a pas assez d'opportunites pour depenser son exces d'energie.", "desc_introvert": "Votre calmar est Introverti. Il apprecie la solitude et pourrait preferer des espaces plus calmes et moins bondes. Bien qu'il puisse interagir avec les autres, il peut avoir besoin de temps seul pour se ressourcer. Ce calmar pourrait etre plus observateur et reflechi.", "desc_greedy": "Votre calmar est Gourmand. Il a un fort interet pour la nourriture et les ressources. Ce calmar pourrait etre plus motive par les friandises et les recompenses que les autres. Bien qu'il puisse etre plus exigeant, il tend aussi a etre debrouillard et bon pour trouver des friandises cachees!", "desc_stubborn": "Votre calmar est Tetu. Il a une forte volonte et des preferences definies. Ce calmar pourrait etre plus resistant au changement et pourrait prendre plus de temps a s'adapter aux nouvelles routines. Cependant, sa determination peut aussi le rendre persistant pour resoudre les problemes.", # Modificateurs Courts "mod_timid": "Plus grande chance de devenir anxieux", "mod_adventurous": "Curiosite et exploration accrues", "mod_lazy": "Mouvement plus lent et consommation d'energie reduite", "mod_energetic": "Mouvement plus rapide et niveaux d'activite plus eleves", "mod_introvert": "Prefiere la solitude et les environnements calmes", "mod_greedy": "Plus concentre sur la nourriture et les ressources", "mod_stubborn": "Ne mange que sa nourriture preferee (sushi), peut refuser de dormir", # Details des Modificateurs "modifiers_timid": "- L'anxiete augmente 50% plus vite\n- La curiosite augmente 50% plus lentement\n- L'anxiete diminue de 50% pres des plantes", "modifiers_adventurous": "- La curiosite augmente 50% plus vite", "modifiers_lazy": "- Se deplace plus lentement\n- La consommation d'energie est plus faible", "modifiers_energetic": "- Se deplace plus rapidement\n- La consommation d'energie est plus elevee", "modifiers_introvert": "- Prefere les espaces plus calmes et moins bondes\n- Peut avoir besoin de plus de temps seul", "modifiers_greedy": "- Devient 50% plus anxieux quand il a faim\n- La satisfaction augmente davantage en mangeant", "modifiers_stubborn": "- Prefere sa nourriture preferee (sushi)\n- Peut refuser de dormir meme quand fatigue", # Conseils de Soin "tips_timid": "- Placez des plantes dans l'environnement pour reduire l'anxiete\n- Gardez l'environnement propre et calme\n- Approchez lentement et evitez les mouvements brusques\n- Maintenez une routine constante\n- Evitez de redimensionner la fenetre frequemment", "tips_adventurous": "- Introduisez regulierement de nouveaux objets ou decorations\n- Offrez des options de nourriture diversifiees\n- Encouragez l'exploration avec un placement strategique de la nourriture\n- Permettez beaucoup d'espace pour explorer\n- Nourrissez sa curiosite naturelle avec des objets interessants", "tips_lazy": "- Placez la nourriture plus pres des endroits de repos du calmar\n- Nettoyez l'environnement plus frequemment\n- Utilisez de la nourriture tentante pour encourager le mouvement\n- N'attendez pas beaucoup d'activite - ils preferent la relaxation\n- Assurez-vous que leurs endroits de repos sont confortables", "tips_energetic": "- Fournissez un grand espace ouvert pour le mouvement\n- Offrez des opportunites frequentes de nourriture\n- Introduisez des elements ou jeux interactifs\n- Gardez l'environnement stimulant avec des decorations variees\n- Ils ont besoin de plus de nourriture en raison d'une consommation d'energie plus elevee", "tips_introvert": "- Creez des zones calmes et isolees avec des decorations\n- Evitez de surcharger l'environnement\n- Respectez le besoin du calmar d'etre seul\n- Creez des espaces abrites avec des plantes\n- Approchez doucement et donnez de l'espace quand necessaire", "tips_greedy": "- Offrez une variete de types de nourriture, y compris du sushi\n- Utilisez la nourriture comme recompense pour les comportements souhaites\n- Attention a ne pas suralimenter\n- Deviendra plus anxieux quand il a faim\n- Fournissez des opportunites de collecter des objets", "tips_stubborn": "- Ayez toujours du sushi disponible car c'est sa nourriture preferee\n- Soyez patient lors de l'introduction de changements\n- Utilisez le renforcement positif pour les comportements souhaites\n- Ce calmar peut refuser la nourriture non-sushi\n- Peut resister au sommeil - creez des environnements calmes", # ===== ONGLET DECISIONS ===== "thought_process": "Processus de Pensee du Calmar", "step": "Etape", "step1_title": "Perception du Monde", "step2_title": "Calcul des Pulsions de Base", "step3_title": "Application de la Personnalite et des Souvenirs", "step4_title": "Prise de la Decision Finale", "final_action": "Action Finale:", "awaiting_thought": "En attente de la prochaine pensee du calamar...", "awaiting_decision": "En Attente de Decision...", "sensing_condition": "Le calmar evalue sa condition actuelle et les objets visibles:", "visible_objects": "Objets Visibles", "no_sensory_data": "Aucune donnee sensorielle disponible.", "none": "Aucun", "no_urges": "Aucune pulsion calculee.", "strongest_urge": "Selon les besoins, la pulsion la plus forte est", "initial_scores": "Scores initiaux:", "personality_memory_adjust": "Les traits de personnalite et les souvenirs recents ajustent ces pulsions:", "no_adjustments": "Aucun ajustement significatif de la personnalite ou de la memoire cette fois.", "final_scores_text": "Apres tous les calculs, les scores finaux sont comptabilises. Le score le plus eleve determine l'action.", "no_final_scores": "Aucun score final disponible.", "squid_decided": "Le calmar a decide", "with_confidence": "avec un niveau de confiance de", "score_increased": "a augmente", "score_decreased": "a diminue", "score_for": "Le score pour", "by_amount": "de", # ===== ONGLET APPRENTISSAGE (ORIGINAL) ===== "active_learning_pairs": "Paires d'Apprentissage Actives", "hebbian_cycle": "Cycle Hebbien", "hebbian_paused": "EN PAUSE", "learning_ready": "Systeme d'Apprentissage Pret", "learning_ready_desc": "L'apprentissage hebbien creara des associations entre les neurones qui s'activent ensemble.", "log_cleared": "Journal Efface", "log_cleared_desc": "Les paires d'apprentissage apparaitront ici lorsque les neurones formeront de nouvelles connexions.", "hebbian_overview": "Apercu de l'Apprentissage Hebbien", "neurons_fire_together": "Les neurones qui s'activent ensemble se connectent ensemble", "hebbian_principle": "Ce principe fondamental decrit comment les reseaux neuronaux apprennent par l'experience.", "hebbian_explanation": "L'apprentissage hebbien est une regle simple mais puissante utilisee dans les reseaux neuronaux artificiels. Lorsque deux neurones s'activent simultanement, la connexion entre eux se renforce. S'ils s'activent separement, la connexion s'affaiblit.", "excitatory_connections": "Connexions Excitatrices", "excitatory_desc": "Les poids positifs (0.0-1.0) rendent les neurones plus susceptibles de s'activer ensemble", "inhibitory_connections": "Connexions Inhibitrices", "inhibitory_desc": "Les poids negatifs (-1.0-0.0) rendent les neurones moins susceptibles de s'activer ensemble", "very_strong": "Tres Fort", "strong": "Fort", "moderate": "Modere", "weak": "Faible", "very_weak": "Tres Faible", "inhibited": "Inhibe", # ===== ONGLET MEMOIRE ===== "memory": "Memoire", "memories": "Souvenirs", "short_term_memory": "Memoire a Court Terme", "long_term_memory": "Memoire a Long Terme", "no_memories": "Aucun souvenir stocke pour l'instant.", "overview": "Aperçu", "memory_stats": "Statistiques de Mémoire", "categories": "Catégories", "time_label": "Heure :", "important_label": "Important", "unknown": "Inconnu", "category_label": "Catégorie :", "key_label": "Clé :", "access_count": "Nombre d'accès :", "full_content": "Contenu Complet :", "effects_label": "Effets :", "positive": "Positif", "negative": "Négatif", "neutral": "Neutre", # ===== ONGLET RESEAU (ORIGINAL) ===== "brain_network": "Reseau Cerebral", "neurons": "Neurones", "connections": "Connexions", "activity": "Activite", # ===== FENETRE STATISTIQUES ===== "status": "Statut", "health": "Sante", # ===== NEURON NAMES ===== "hunger": "Faim", "happiness": "Bonheur", "cleanliness": "Proprete", "sleepiness": "Somnolence", "satisfaction": "Satisfaction", "curiosity": "Curiosite", "anxiety": "Anxiete", "can_see_food": "Voit Nourriture", "is_eating": "Mange", "is_sleeping": "Dort", "is_sick": "Malade", "is_fleeing": "Fuit", "is_startled": "Effrayé", "pursuing_food": "Cherche Nourriture", "external_stimulus": "Stimulus", "plant_proximity": "Pres Plante", "stress": "Stress", "novelty": "Nouveaute", "reward": "Recompense", # ===== BRAIN WIDGET LAYERS ===== "layer_name": "Couche", "layer_input": "Entrée", "layer_output": "Sortie", "layer_hidden": "Cachée", # ===== NEUROGENESIS LOGS ===== "log_created": "{time} - une neurone {type} ({name}) a été créée car le compteur {type} était à {value:.2f}", "log_pruned": "{time} - une neurone ({name}) a été ÉLAGUÉE à cause de {reason}", "log_stress_detail": "Une connexion inhibitrice a été faite vers ANXIÉTÉ\nLa valeur maximale d'anxiété a été réduite de façon permanente de 10", # State Pills "fleeing": "En Fuite!", "startled": "Effraye!", "eating": "En Train de Manger", "sleeping": "Endormi", "playing": "En Train de Jouer", "hiding": "Cache", "anxious": "Anxieux", "curious": "Curieux", # ===== ACTIONS COMMUNES ===== "eat": "Manger", "sleep": "Dormir", "play": "Jouer", "explore": "Explorer", "rest": "Se Reposer", "hide": "Se Cacher", "wander": "Errer", "idle": "Inactif", "seek_food": "Chercher Nourriture", "seek_shelter": "Chercher Abri", # ===== OBJETS ===== "food": "Nourriture", "rock": "Rocher", "poop": "Caca", "plant": "Plante", "sushi": "Sushi", "decoration": "Decoration", # ===== TUTORIEL ===== "tutorial_hatched": "Un calamar est ne et vous devez vous en occuper!", "tutorial_feed": "Nourrissez-le quand il a faim (Menu Actions)", "tutorial_clean": "Nettoyez son aquarium quand il devient sale", "tutorial_watch": "Observez son comportement pour decouvrir sa personnalite", "tutorial_neural": "RESEAU NEURONAL", "tutorial_neural_desc": "Ceci est le reseau neuronal du calamar. Son comportement est guide par ses besoins (neurones ronds).\nLe reseau s'adapte et apprend au fur et a mesure que le calmar interagit avec son environnement.", "tutorial_satisfaction": "Gardez la satisfaction haute et l'anxiete basse.", "tutorial_traits": "Votre calmar developpera des traits et des comportements uniques selon la facon dont vous l'elevez.", # ===== CONCEPTEUR DE CERVEAU (PRESETS) ===== "designer_title": "Concepteur de Cerveau", "required_only": "Requis Seulement", "dosidicus_default": "Dosidicus Par Defaut", "full_sensors": "Suite Complete de Capteurs", "the_insomniac": "L'Insomniaque", "the_hyperactive": "L'Hyperactif", "the_hangry": "L'Affame Furieux", "the_depressive": "Le Depressif", "the_obsessive": "L'Obsessif", "balanced": "Equilibre", "minimal": "Minimal", "dense": "Dense", "chaotic": "Chaotique", "calm": "Calme", # ===== ECRAN DE DEMARRAGE ===== "squid_hatched": "UN CALMAR EST NE!", "look_after": "VOUS DEVEZ VOUS EN OCCUPER..", # ===== ETAPES DU TUTORIEL ===== "next": "Suivant", "tutorial_step1_text": "Un calamar est ne et vous devez vous en occuper!\n• Nourrissez-le quand il a faim (Menu Actions)\n• Nettoyez son aquarium quand il devient sale\n• Observez son comportement pour decouvrir sa personnalite", "tutorial_step2_text": "Ceci est le reseau neuronal du calamar. Son comportement est guide par ses besoins (neurones).\nLe reseau s'adapte et apprend au fur et a mesure que le calmar interagit avec son environnement.", "tutorial_step3_text": "Le calamar peut generer de nouveaux neurones en reponse a des stimuli environnementaux extremes.\nCes nouveaux neurones aident le calamar a s'adapter aux situations difficiles.", "tutorial_step4_text": "Quand une paire de neurones s'active en meme temps, leur connexion se renforce. Cela permet au calamar d'apprendre des associations entre differents stimuli et reponses.", "tutorial_step5_text": "Le reseau neuronal prend des decisions basees sur les besoins actuels et les souvenirs passes.\nChaque decision affecte l'etat du calamar et faconne son comportement futur.", "tutorial_step6_text": "Appuyez sur D a tout moment pour ouvrir la fenetre Decorations\nGlissez et deposez des decorations dans l'environnement et observez comment le calmar reagit. Chaque type de decoration affecte l'etat mental du calamar de maniere unique. Cliquez et utilisez la molette pour redimensionner/SUPPR pour supprimer", "tutorial_step7_text": "Gardez la satisfaction haute et l'anxiete basse.\nVotre calmar developpera des traits et comportements uniques selon la facon dont vous l'elevez.", # ===== NETWORK & LEARNING TABS ===== "stats_neurons": "Neurones", "stats_connections": "Connexions", "stats_health": "Sante du Reseau", "emergency_alert": "🚨 Urgence: {name}", "global_cooldown": "Recup", "style_label": "Style:", "chk_links": "Liens", "chk_weights": "Poids", "chk_pruning": "Elagage", "tooltip_brain_designer": "Ouvrir Concepteur de Cerveau", "msg_already_open": "Deja Ouvert", "msg_designer_running": "Le Concepteur de Cerveau est deja en cours d'execution!", "msg_launch_failed": "Echec du lancement", "msg_designer_fail": "Impossible de lancer le Concepteur:\n\n{e}", "msg_missing_brain": "Cerveau Manquant", "msg_cannot_open_lab": "Impossible d'ouvrir le Laboratoire: Widget Cerebral indisponible.", "msg_cannot_open_buffer": "Impossible d'ouvrir le Buffer: Widget Cerebral indisponible.", "msg_no_neurogenesis": "Pas de Neurogenese", "msg_neurogenesis_not_init": "Impossible d'ouvrir le Buffer: Neurogenese non initialisee.", "msg_decorations_unavailable": "Decorations Indisponibles", "msg_decorations_fail": "Impossible d'ouvrir Decorations: Fenetre non disponible.", "func_neurons_title": "Neurones Fonctionnels", "count_label": "Nombre", "avg_utility_label": "Utilite Moyenne", "total_activations_label": "Activations Totales", "specialisations_label": "Specialisations", "buffer_title": "Buffer d'Experience Neurogenese", "buffer_header": "Experiences Recientes", "col_type": "Type", "col_pattern": "Modele", "col_outcome": "Resultat", "col_time": "Temps", "btn_refresh": "Actualiser", "buffer_size": "Taille Buffer", "top_patterns": "Top Modeles", "no_patterns": "Aucun modele encore", # Learning Tab Educational Content "learning_pairs_tab": "Paires d'Apprentissage", "mechanics_tab": "Mecanique", "hebbian_quote": "\"Les neurones qui s'activent ensemble se connectent ensemble\"", "in_practice_title": "En Pratique", "in_practice_text": "Dans le cerveau de votre calamar, l'apprentissage hebbien associe 'faim' et 'satisfaction' lors des repas, ou 'curiosite' et 'anxiete' lors de l'exploration.", "mechanics_title": "Mecanique d'Apprentissage", "mechanics_intro": "L'apprentissage hebbien met a jour la force de connexion (poids) entre les neurones selon leur activite. S'ils s'activent ensemble, le lien se renforce.", "learning_rule_title": "La Règle d'Apprentissage", "where_label": "Ou:", "delta_w_desc": "Δw = Changement de poids entre deux neurones", "eta_desc": "η (eta) = Taux d'apprentissage (vitesse de changement)", "activation_desc": "x, y = Valeurs d'activation (1 actif, 0 inactif)", "example_calc_title": "Exemple de Calcul", "scenario_label": "Scenario: 'faim' et 'satisfaction' s'activent", "calc_result": "Le poids augmente de 0.1, renforçant la connexion.", "over_time_title": "Avec le Temps", "over_time_text": "Par des activations repetees, ces petits changements s'accumulent. Les schemas frequents creent des connexions fortes, permettant au calmar d'apprendre.", "str_excitatory": "Excitateur Fort", "weak_excitatory": "Excitateur Faible", "weak_inhibitory": "Inhibiteur Faible", "str_inhibitory": "Inhibiteur Fort", # ===== SQUID & BRAIN STATISTICS ===== "distance_rollover": "🌊 Compteur de distance réinitialisé ! Maintenant à {multiplier}x", "time_min": "min", "time_mins": "mins", "time_hr": "h", "time_hrs": "h", "time_fmt_hm": "{hours}h {minutes}m", "stat_squid_age": "Age du Calmar", "stat_distance": "Distance Nagée (pixels)", "stat_cheese": "Fromage Mangé", "stat_sushi": "Sushi Mangé", "stat_poops": "Cacas Créés", "stat_max_poops": "Max Cacas dans Réservoir", "stat_startles": "Fois Effrayé", "stat_ink": "Nuages d'Encre Créés", "stat_colour_change": "Fois Changement Couleur", "stat_rocks": "Rochers Lancés", "stat_plants": "Interactions Plantes", "stat_sleep": "Temps Total Sommeil (secondes)", "stat_sickness": "Episodes Maladie", "stat_novelty_neurons": "Neurones Nouveauté Créés", "stat_stress_neurons": "Neurones Stress Créés", "stat_reward_neurons": "Neurones Récompense Créés", "stat_current_neurons": "Neurones Actuels", "reset_stats_title": "Réinitialiser Statistiques", "reset_stats_msg": "Etes-vous sûr de vouloir réinitialiser toutes les statistiques ?", "export_stats_title": "Exporter Statistiques", "export_file_type": "Fichiers Texte (*.txt)", "export_header": "Export Statistiques Calmar", "export_time": "Heure Export", "export_activity_section": "Statistiques Activité", "export_end": "Fin des Statistiques", "export_success_title": "Export Réussi", "export_success_msg": "Statistiques exportées vers {file_name}", "export_error_title": "Erreur Export", "export_error_msg": "Erreur lors de l'export des statistiques : {error}", # ===== ACHIEVEMENTS ===== # Categories "cat_feeding": "Alimentation", "cat_neurogenesis": "Neurogenèse", "cat_sleep": "Sommeil", "cat_milestones": "Jalons", "cat_exploration": "Exploration", "cat_cleaning": "Nettoyage", "cat_health": "Santé", "cat_interaction": "Interaction", "cat_ink": "Encre", "cat_memory": "Mémoire", "cat_emotional": "Émotionnel", "cat_secret": "Secret", "cat_meta": "Méta", # UI Elements "ui_points": "Points", "ui_unlocked": "Débloqué", "ui_achievement_unlocked": "Succès Débloqué !", "ui_hidden": "Succès caché", "ui_all": "Tous", "ui_points_gained": "points", # --- Achievements --- # Feeding "ach_first_feeding_name": "Première Bouchée", "ach_first_feeding_desc": "Nourrissez le calmar pour la première fois", "ach_fed_10_times_name": "Repas Réguliers", "ach_fed_10_times_desc": "Nourrissez le calmar 10 fois", "ach_fed_50_times_name": "Soigneur Dévoué", "ach_fed_50_times_desc": "Nourrissez le calmar 50 fois", "ach_fed_100_times_name": "Chef Étoilé", "ach_fed_100_times_desc": "Nourrissez le calmar 100 fois", "ach_fed_500_times_name": "Légende Culinaire", "ach_fed_500_times_desc": "Nourrissez le calmar 500 fois", # Neurogenesis "ach_first_neuron_name": "Étincelle Cérébrale", "ach_first_neuron_desc": "Créez le premier neurone par neurogenèse", "ach_neurons_10_name": "Réseau Neuronal", "ach_neurons_10_desc": "Créez 10 neurones par neurogenèse", "ach_neurons_50_name": "Esprit en Expansion", "ach_neurons_50_desc": "Créez 50 neurones par neurogenèse", "ach_neurons_100_name": "Puissance Cérébrale", "ach_neurons_100_desc": "Créez 100 neurones par neurogenèse", "ach_first_neuron_levelup_name": "Synapse Renforcée", "ach_first_neuron_levelup_desc": "Améliorez un neurone pour la première fois", "ach_neuron_max_level_name": "Performance Maximale", "ach_neuron_max_level_desc": "Améliorez un neurone à sa puissance maximale", # Sleep "ach_first_sleep_name": "Fais de Beaux Rêves", "ach_first_sleep_desc": "Le calmar se réveille de son premier sommeil", "ach_slept_10_times_name": "Bien Reposé", "ach_slept_10_times_desc": "Le calmar a dormi 10 fois", "ach_dream_state_name": "Rêveur Profond", "ach_dream_state_desc": "Le calmar est entré en sommeil paradoxal", # Milestones "ach_age_1_hour_name": "Une Heure", "ach_age_1_hour_desc": "Le calmar a atteint 1 heure d'âge", "ach_age_10_hours_name": "Ça Grandit", "ach_age_10_hours_desc": "Le calmar a atteint 10 heures d'âge", "ach_age_24_hours_name": "Merveille d'un Jour", "ach_age_24_hours_desc": "Le calmar a survécu 24 heures", "ach_age_1_week_name": "Vétéran de la Semaine", "ach_age_1_week_desc": "Le calmar a vécu une semaine", "ach_age_1_month_name": "Vétéran du Mois", "ach_age_1_month_desc": "Le calmar a vécu un mois", "ach_happiness_100_name": "Bonheur Pur", "ach_happiness_100_desc": "Atteignez 100% de bonheur", "ach_all_stats_high_name": "Équilibre Parfait", "ach_all_stats_high_desc": "Toutes les statistiques au-dessus de 80% simultanément", # Cleaning "ach_first_clean_name": "Premier Nettoyage", "ach_first_clean_desc": "Nettoyez l'aquarium pour la première fois", "ach_cleaned_25_times_name": "Environnement Impeccable", "ach_cleaned_25_times_desc": "Nettoyez l'aquarium 25 fois", "ach_germaphobe_name": "Germophobe", "ach_germaphobe_desc": "Gardez la propreté au-dessus de 90% pendant 1 heure", # Health "ach_first_medicine_name": "Premiers Secours", "ach_first_medicine_desc": "Donnez des médicaments pour la première fois", "ach_medicine_10_times_name": "Docteur Calmar", "ach_medicine_10_times_desc": "Donnez des médicaments 10 fois", "ach_comeback_kid_name": "Le Survivant", "ach_comeback_kid_desc": "Récupérez d'une santé critique (<20%) au maximum", # Interaction (Rocks) "ach_first_rock_pickup_name": "Collectionneur de Pierres", "ach_first_rock_pickup_desc": "Ramassez une pierre pour la première fois", "ach_rocks_picked_10_name": "Ramasseur de Pierres", "ach_rocks_picked_10_desc": "Ramassez 10 pierres", "ach_rocks_picked_50_name": "Amasseur de Rochers", "ach_rocks_picked_50_desc": "Ramassez 50 pierres", "ach_first_rock_throw_name": "Ricochets", "ach_first_rock_throw_desc": "Lancez une pierre pour la première fois", "ach_rocks_thrown_25_name": "Lanceur de Pierres", "ach_rocks_thrown_25_desc": "Lancez 25 pierres", "ach_rocks_thrown_100_name": "Maître de la Catapulte", "ach_rocks_thrown_100_desc": "Lancez 100 pierres", # Interaction (Decor) "ach_first_decoration_push_name": "Décorateur d'Intérieur", "ach_first_decoration_push_desc": "Poussez une décoration pour la première fois", "ach_decorations_pushed_10_name": "Déménageur", "ach_decorations_pushed_10_desc": "Poussez des décorations 10 fois", "ach_decorations_pushed_50_name": "Maître Feng Shui", "ach_decorations_pushed_50_desc": "Poussez des décorations 50 fois", "ach_first_plant_interact_name": "Main Verte", "ach_first_plant_interact_desc": "Interagissez avec une plante pour la première fois", "ach_plants_interacted_10_name": "Explorateur de Jardin", "ach_plants_interacted_10_desc": "Interagissez avec des plantes 10 fois", "ach_plants_interacted_50_name": "Botaniste", "ach_plants_interacted_50_desc": "Interagissez avec des plantes 50 fois", "ach_objects_investigated_25_name": "Inspecteur Curieux", "ach_objects_investigated_25_desc": "Enquêtez sur 25 objets différents", "ach_objects_investigated_100_name": "Maître Détective", "ach_objects_investigated_100_desc": "Enquêtez sur 100 objets différents", # Exploration (Poop) "ach_first_poop_throw_name": "Fouteur de Trouble", "ach_first_poop_throw_desc": "Le calmar a lancé du caca pour la première fois", # Ink "ach_first_ink_cloud_name": "Écran de Fumée", "ach_first_ink_cloud_desc": "Le calmar libère un nuage d'encre pour la première fois", "ach_ink_clouds_20_name": "Maître de l'Encre", "ach_ink_clouds_20_desc": "Libérez 20 nuages d'encre", # Memory "ach_first_memory_name": "Premier Souvenir", "ach_first_memory_desc": "Formez le premier souvenir", "ach_memory_long_term_name": "Vision à Long Terme", "ach_memory_long_term_desc": "Promouvez un souvenir dans la mémoire à long terme", "ach_memories_50_name": "Mémoire Photographique", "ach_memories_50_desc": "Ayez 50 souvenirs stockés", # Emotional "ach_curiosity_100_name": "Georges le Curieux", "ach_curiosity_100_desc": "La curiosité atteint 100%", "ach_zen_master_name": "Maître Zen", "ach_zen_master_desc": "Gardez l'anxiété en dessous de 10% pendant 30 minutes", "ach_first_startle_name": "Sursaut !", "ach_first_startle_desc": "Effrayez le calmar pour la première fois", "ach_nervous_wreck_name": "Épave Nerveuse", "ach_nervous_wreck_desc": "L'anxiété atteint 100%", # Secret "ach_night_owl_name": "Oiseau de Nuit", "ach_night_owl_desc": "Jouez entre minuit et 4h du matin", "ach_early_bird_name": "Lève-tôt", "ach_early_bird_desc": "Jouez entre 5h et 7h du matin", "ach_weekend_warrior_name": "Guerrier du Week-end", "ach_weekend_warrior_desc": "Jouez le samedi et le dimanche", # Meta "ach_brain_surgeon_name": "Chirurgien du Cerveau", "ach_brain_surgeon_desc": "Ouvrez l'outil de visualisation du cerveau", "ach_speed_demon_name": "Démon de la Vitesse", "ach_speed_demon_desc": "Lancez la simulation à vitesse maximale pendant 10 minutes", "ach_completionist_name": "Collectionneur", "ach_completionist_desc": "Débloquez 30 autres succès", # Additional Log/Debug Messages "Hebbian learning chosen pairs:": "Paires choisies par apprentissage Hebbien :", "Main thread: Created neuron": "Thread principal : Neurone créé", "BrainWidget received external BrainWorker": "BrainWidget a reçu un BrainWorker externe", "Neurogenesis monitoring timer started": "Minuteur de surveillance de neurogenèse démarré", "Brain state export enabled for designer sync": "Export d'état cérébral activé pour synchronisation designer", "Animation palette built for style:": "Palette d'animation construite pour le style :", "Animation style changed:": "Style d'animation changé :", "Unknown animation style:": "Style d'animation inconnu :", "Available:": "Disponibles :", # ===== LABORATOIRE DE NEURONES ===== "lab_title": "🧠 Laboratoire de Neurones", "lab_live_refresh": "Actualisation en direct", "lab_unlock_editing": "🔓 Déverrouiller l'édition", "lab_tab_overview": "📊 Aperçu en Direct", "lab_tab_inspector": "🔍 Inspecteur Approfondi", "lab_tab_edit": "🔧 Bac à Sable d'Édition", "lab_status_ready": "Prêt", "lab_status_locked": "🔒 {name} verrouillé à {value}", "lab_status_unlocked": "🔓 {name} déverrouillé", # Onglet Aperçu "lab_ov_counters": "Progression des compteurs", "lab_ov_newest": "Neurones de neurogenèse récents", "lab_ov_limits": "Limites et élagage", "lab_ov_actions": "Actions rapides", "lab_force_hebbian": "Forcer cycle Hebbien", "lab_pruning_enabled": "Élagage activé :", "lab_none_yet": "Aucun pour l'instant", "lab_ago": "il y a {seconds}s", # Onglet Inspecteur "lab_pick_neuron": "Choisissez un neurone à inspecter :", "lab_connections_title": "Connexions (excitatrices vs inhibitrices)", "lab_header_partner": "Partenaire", "lab_header_weight": "Poids", "lab_header_type": "Type", "lab_header_inf": "Influence", "lab_impact_title": "Simulation d'impact fonctionnel", "lab_header_neuron": "Neurone", "lab_header_delta": "Valeur Δ", "lab_no_connections": "Aucune connexion active pour le moment", "lab_did_you_know": "Le saviez-vous ?", "lab_type_excitatory": "Excitateur", "lab_type_inhibitory": "Inhibiteur", # Onglet Édition "lab_edit_locked_msg": "⚠️ L'édition est verrouillée – cochez 'Déverrouiller l'édition' dans la barre d'outils.", "lab_edit_header": "Valeurs neuronales (glisser pour changer) – clic 🔒 pour verrouiller", "lab_unlock_title": "Déverrouiller l'édition ?", "lab_unlock_msg": "Vous pouvez maintenant changer les valeurs et forcer la création. À utiliser avec responsabilité !", # Badges/Influence "lab_inf_tiny": "minuscule", "lab_inf_mild": "léger", "lab_inf_mod": "modéré", "lab_inf_strong": "FORT", # Educational Tips "lab_tip_hunger": "La faim est une pulsion homéostatique. Une faim élevée inhibe la satisfaction et augmente l'anxiété.", "lab_tip_happiness": "Le bonheur est renforcé par les neurones de récompense. Il inhibe l'anxiété et favorise la curiosité.", "lab_tip_anxiety": "L'anxiété est réduite par les neurones de stress (inhibiteurs). Une forte anxiété supprime la curiosité.", "lab_tip_curiosity": "La curiosité augmente quand la nouveauté est élevée. Elle encourage l'exploration et réduit l'anxiété.", "lab_tip_core": "Neurone noyau – fondamental pour la survie.", "lab_tip_neuro_default": "Neurone de neurogenèse – but déduit du contexte de naissance.", "lab_tip_neuro_fmt": "Créé par {trigger} – spécialisé en {spec}. Son travail est de transformer l'expérience en comportement.", # ===== VISION WINDOW ===== "vision_window_title": "Vision du Calmar", "vis_logic_unavailable": "Logique du calmar indisponible.", "vis_nothing_in_view": "Rien en vue actuellement.", "vis_distance": "distance", # --- Brain Tooltips --- "tooltip_specialization": "Spécialisation", "tooltip_type": "Type", "tooltip_current": "Actuel", "tooltip_utility": "Utilité", "tooltip_activations": "Activations", "tooltip_last_active": "Dernière activité", "tooltip_age": "Âge", "tooltip_core": "Noyau", "tooltip_generated": "Générée", "tooltip_functional": "Fonctionnelle", "tooltip_connections_header": "Connexions", "tooltip_connections_stats": "{incoming} entr., {outgoing} sort.", "tooltip_top_incoming": "Entrées principales", "tooltip_top_outgoing": "Sorties principales", "tooltip_hint": "Double-clic pour inspecter • Clic droit pour options", # State values "state_on": "ON", "state_off": "OFF", # Time formatting "fmt_s_ago": "il y a {val}s", "fmt_m_ago": "il y a {val}m", "fmt_h_ago": "il y a {val}h", "fmt_s_short": "{val}s", "fmt_m_short": "{val}m", "fmt_h_short": "{val}h", "fmt_d_short": "{val}j", # ===== BRAIN DESIGNER WINDOW ===== "designer_window_title": "Concepteur de Cerveau - Dosidicus-2", "designer_window_title_imported": "Concepteur de Cerveau - Dosidicus-2 [Importé du Jeu]", "designer_tab_layers": "Couches", "designer_tab_sensors": "Capteurs", "designer_tab_props": "Propriétés", "designer_tab_connections": "Connexions", "designer_tab_outputs": "Sorties", "designer_btn_generate": "🎲 Générer Réseau Dispersé", "designer_tooltip_generate": "Générer des connexions aléatoires entre les neurones noyaux", "designer_btn_neuron": "➕ Neurone", "designer_tooltip_neuron": "Ajouter un nouveau neurone (Maj+N)", "designer_btn_fix": "🔧 Auto-Correction", "designer_tooltip_fix": "Corriger automatiquement les neurones orphelins et les problèmes de connectivité", "designer_btn_validate": "✓ Valider", "designer_tooltip_validate": "Vérifier la conception pour des problèmes", "designer_btn_sync": "🔄 Sync depuis Jeu", "designer_tooltip_sync": "Actualiser l'état du cerveau depuis le jeu Dosidicus en cours", "designer_btn_clear_conn": "🗑 Effacer Connexions", "designer_tooltip_clear_conn": "Supprimer toutes les connexions (garde les neurones)", "designer_tooltip_dice": "Générer instantanément un réseau aléatoire (pas de dialogue)", # Ticker / Help Bar "designer_help_drag_connect": "💡 Glisser-Gauche d'un neurone pour créer une connexion", "designer_help_ctrl_move": "Ctrl+Glisser un neurone pour le déplacer", "designer_help_pan": "Clic-Droit+Glisser pour déplacer le canevas", "designer_help_zoom": "Molette pour zoomer (ou ajuster le poids sur une connexion)", "designer_help_edit_weight": "Double-Clic sur une connexion pour éditer le poids", "designer_help_select": "Clic sur neurone/connexion pour sélectionner", "designer_help_delete": "Suppr pour supprimer la sélection", "designer_help_reverse": "Espace pour inverser la direction de la connexion", "designer_help_keys_weight": "Touches +/- pour ajuster le poids (Maj pour grands pas)", "designer_help_page_weight": "Page Haut/Bas pour ajuster le poids (grands pas)", "designer_help_add_neuron": "Maj+N pour ajouter un neurone", "designer_help_save": "Ctrl+S pour sauvegarder", "designer_help_open": "Ctrl+O pour ouvrir", "designer_help_export": "Ctrl+E pour exporter", "designer_help_new": "Ctrl+N pour nouveau design", "designer_help_gen": "Ctrl+G pour générer réseau", "designer_help_dice": "🎲 Bouton Dé pour génération aléatoire instantanée", "designer_help_outputs": "Onglet Sorties pour lier les neurones aux comportements", # Menus "designer_menu_file": "Fichier", "designer_menu_edit": "Édition", "designer_menu_templates": "Modèles", "designer_menu_generate": "Générer", "designer_action_new": "Nouveau Design", "designer_action_save": "Sauvegarder...", "designer_action_export": "Exporter pour Dosidicus...", "designer_action_open": "Ouvrir...", "designer_action_gen_sparse": "Générer Réseau Dispersé...", "designer_action_autofix": "Auto-Correction Connectivité", "designer_action_validate": "Valider Design", "designer_action_clear_conn": "Effacer Toutes les Connexions", "designer_action_clear_outputs": "Effacer Toutes les Liaisons de Sortie", # Status Bar "designer_status_neurons": "Neurones : {count}", "designer_status_connections": "Connexions : {count}", "designer_status_required": "Requis : {ok}", "designer_status_outputs": "Sorties : {count}", "designer_status_selected": "Sélection : {source} → {target} (poids : {weight:+.3f})", "designer_status_weight_updated": "Poids mis à jour : {source} → {target} = {weight:+.3f}", "designer_status_deleted": "Connexion supprimée : {source} → {target}", "designer_status_cleared_conn": "{count} connexions effacées", "designer_status_cleared_out": "{count} liaisons de sortie effacées", "designer_status_generated": "Généré {count} connexions utilisant le preset '{style}'", "designer_status_random_gen": "🎲 Généré {count} connexions aléatoires (style : {style})", "designer_status_synced": "✨ Synchronisé : {neurons} neurones, {connections} connexions", "designer_status_imported": "✨ Cerveau actif importé du jeu en cours", # Dialogs & Messages "designer_msg_game_not_running_title": "Jeu Non Lancé", "designer_msg_game_not_running": "Le jeu Dosidicus ne tourne plus.\n\nRelancez le jeu pour synchroniser.", "designer_msg_sync_confirm_title": "Synchroniser depuis le Jeu", "designer_msg_sync_confirm": "Remplacer le design actuel par le dernier état du cerveau du jeu ?", "designer_msg_sync_failed_title": "Échec de Synchronisation", "designer_msg_sync_failed": "Impossible d'importer l'état du cerveau depuis le jeu.", "designer_msg_live_import_title": "Import Cerveau en Direct", "designer_msg_live_import_header": "🧠 Cerveau actif importé du jeu en cours", "designer_msg_live_import_body": "Le concepteur affiche maintenant le réseau neuronal exact de votre jeu Dosidicus.\n\n• {neurons} neurones\n• {connections} connexions\n\nLes changements faits ici n'affecteront PAS le jeu en cours.", "designer_msg_clear_conn_title": "Effacer Connexions", "designer_msg_clear_conn_confirm": "Supprimer les {count} connexions ?\n\nLes neurones seront conservés.", "designer_msg_clear_out_title": "Effacer Liaisons de Sortie", "designer_msg_clear_out_empty": "Aucune liaison de sortie à effacer.", "designer_msg_clear_out_confirm": "Supprimer les {count} liaisons de sortie ?", "designer_msg_new_design_title": "Nouveau Design", "designer_msg_new_design_confirm": "Commencer un nouveau design ? Les changements non sauvegardés seront perdus.", "designer_msg_autofix_title": "Auto-Correction", "designer_msg_autofix_result": "Créé {count} connexions :\n\n{details}", "designer_msg_autofix_none": "Aucun problème trouvé.", "designer_msg_save_title": "Sauvegarder Design", "designer_msg_saved_title": "Sauvegardé", "designer_msg_save_success": "Design sauvegardé avec succès : {msg}", "designer_msg_save_bindings": "\n({count} liaisons de sortie incluses)", "designer_msg_error_title": "Erreur", "designer_msg_save_fail": "Échec de la sauvegarde du design :\n\n{error}", "designer_msg_export_title": "Exporter", "designer_msg_exported_title": "Exporté", "designer_msg_export_success": "Design exporté avec succès", "designer_msg_export_fail": "Échec de l'export du design :\n\n{error}", "designer_msg_open_title": "Ouvrir Design", "designer_msg_open_fail": "Impossible de charger le design :\n\n{error}", "designer_msg_load_template_title": "Charger Modèle", "designer_msg_select_template": "Sélectionnez un modèle :", "designer_msg_replace_design": "Remplacer le design actuel ?", "designer_msg_status_title": "Statut du Design", "designer_msg_status_ok": "\n✅ Statut : OK", "designer_msg_status_issues": "\n⚠️ PROBLÈMES :\n", "designer_input_weight_title": "Poids de la Connexion", "designer_input_weight_label": "Définir le poids pour {source} → {target} :", # ===== DESIGNER PANELS ===== # Properties Panel "designer_prop_no_selection": "Aucun neurone sélectionné", "designer_prop_no_selection_disabled": "Aucune Sélection", "designer_prop_lbl_name": "Nom :", "designer_prop_lbl_type": "Type :", "designer_prop_lbl_x": "X :", "designer_prop_lbl_y": "Y :", "designer_prop_btn_delete": "Supprimer Neurone", # Add Neuron Dialog "designer_add_title": "Ajouter Neurone", "designer_add_grp_type": "Sélectionner Type de Neurone", "designer_add_btn_custom": "✨ Custom / Plugin Neurone", "designer_add_btn_sensor": "📡 Capteur d'Entrée", "designer_add_tooltip_custom": "Créer un neurone avec un nom spécifique pour lier avec des plugins du jeu", "designer_add_grp_sensor": "Sélectionner Capteur", "designer_add_grp_custom": "Définir Neurone Personnalisé", "designer_add_info_custom": "Pour affecter le calmar, le Nom doit correspondre à un ID de plugin.
    Exemple: Nommez-le 'jet_boost' pour activer un plugin jetpack.
    ", "designer_add_lbl_id": "ID Plugin / Nom :", "designer_add_ph_id": "ex. mode_turbo", "designer_add_btn_create": "Créer Lien", "designer_add_all_added": "Tous les capteurs ajoutés", "designer_add_err_title": "Erreur", "designer_add_err_exists": "Existe", "designer_add_msg_created": "Créé {name}", # Layers Panel "designer_layer_btn_add": "Ajouter Couche", "designer_layer_dlg_title": "Nouvelle Couche", "designer_layer_dlg_label": "Nom :", # Sensors Panel "designer_sensor_header": "Capteurs d'Entrée :", "designer_sensor_tooltip_refresh": "Actualiser la liste des capteurs (inclut ceux enregistrés par plugin)", "designer_sensor_cat_label": "── {name} ──", # Connections Table "designer_conn_header_source": "Source", "designer_conn_header_target": "Cible", "designer_conn_header_weight": "Poids", # ===== OUTPUTS PANEL ===== "designer_output_header": "Liaisons de Sortie
    Connectez les neurones aux comportements du calmar. Quand l'activation d'un neurone dépasse le seuil, l'action liée est déclenchée.", "designer_output_btn_add": "➕ Ajouter Liaison", "designer_output_btn_edit": "✏️ Éditer", "designer_output_btn_remove": "🗑️ Supprimer", "designer_output_col_neuron": "Neurone", "designer_output_col_behavior": "→ Comportement", "designer_output_col_threshold": "Seuil", "designer_output_col_mode": "Mode", "designer_output_col_enabled": "Activé", "designer_output_info": "{count} liaison(s), {enabled} activée(s)", "designer_output_err_missing": "⚠️ Neurone introuvable dans le design", "designer_output_dlg_remove_title": "Supprimer Liaison", "designer_output_dlg_remove_msg": "Supprimer la liaison : {neuron} → {hook} ?", # Output Binding Dialog "designer_binding_title_add": "Ajouter Liaison de Sortie", "designer_binding_title_edit": "Configurer Liaison de Sortie", "designer_binding_grp_neuron": "Neurone Source", "designer_binding_lbl_neuron": "Neurone :", "designer_binding_lbl_current": "Actuel : --", "designer_binding_grp_hook": "Comportement de Sortie", "designer_binding_lbl_trigger": "Déclencheur :", "designer_binding_grp_settings": "Paramètres de Déclenchement", "designer_binding_lbl_thresh": "Seuil :", "designer_binding_lbl_mode": "Mode :", "designer_binding_lbl_cool": "Refroidissement :", "designer_binding_chk_enabled": "Activé", "designer_binding_err_neuron": "Veuillez sélectionner un neurone", "designer_binding_err_hook": "Veuillez sélectionner un comportement de sortie", "designer_binding_err_duplicate": "Une liaison pour {neuron} → {hook} existe déjà", # Trigger Modes "designer_mode_rising": "Front Montant (traverse le seuil en montant)", "designer_mode_falling": "Front Descendant (traverse le seuil en descendant)", "designer_mode_above": "Tant que Supérieur (continu tant que > seuil)", "designer_mode_below": "Tant que Inférieur (continu tant que < seuil)", "designer_mode_change": "Au Changement (tout changement significatif)", # ===== DESIGNER SENSOR DISCOVERY ===== "desc_builtin_sensor": "Capteur intégré : {name}", "desc_vision_food": "Détecte nourriture dans cône de vision", "desc_custom_sensor": "Capteur personnalisé de {plugin}", "desc_builtin": "intégré", "desc_plugin": "plugin", "desc_other": "autre", "desc_vision": "vision", # ===== DESIGNER TEMPLATES ===== "tmpl_core_name": "🟡 Requis Seulement", "tmpl_core_desc": "8 neurones requis", "tmpl_dosidicus_name": "🟡 Dosidicus Par Défaut", "tmpl_dosidicus_desc": "Disposition standard", "tmpl_full_sensors_name": "🟡 Suite Complète Capteurs", "tmpl_full_sensors_desc": "Tous les capteurs", "tmpl_insomniac_name": "🔴 L'Insomniaque", "tmpl_insomniac_desc": "Anxiété & Curiosité bloquent le sommeil", "tmpl_hyperactive_name": "🔴 L'Hyperactif", "tmpl_hyperactive_desc": "Les neurones de bruit submergent la somnolence", "tmpl_hangry_name": "🔴 L'Affamé Furieux", "tmpl_hangry_desc": "La faim cause une rage extrême", "tmpl_depressive_name": "🔴 Le Dépressif", "tmpl_depressive_desc": "Résistant au bonheur", "tmpl_obsessive_name": "🔴 L'Obsessif", "tmpl_obsessive_desc": "Boucle de rétroaction Anxiété/Curiosité", "layer_sensors": "Capteurs", "layer_core": "Noyau", "layer_input": "Entrée", "layer_out": "Sortie", "layer_racing_mind": "Esprit Galopant", "layer_state": "État", "layer_vision": "Vision", "layer_noise": "Bruit", "layer_output": "Sortie", "layer_gut_brain": "Cerveau-Intestin", "layer_gray": "Gris", "layer_loop": "Boucle", "layer_stats": "Stats", "layer_emotions": "Émotions", # ===== CANVAS CONTEXT MENU / DIALOGS ===== "designer_cnv_del_conn_title": "Supprimer Connexion", "designer_cnv_del_conn_msg": "Êtes-vous sûr de vouloir supprimer la connexion :\n{source} → {target} ?", "designer_cnv_chk_dont_ask": "Ne plus demander", "designer_cnv_btn_del": "Oui, Supprimer", "designer_cnv_btn_cancel": "Annuler", "designer_cnv_dlg_edit_title": "Éditer Connexion", "designer_cnv_lbl_conn": "Connexion : {source} → {target}", "designer_cnv_lbl_weight": "Poids :", "designer_cnv_info_weight": "Positif = Excitateur (vert), Négatif = Inhibiteur (rouge)", "designer_cnv_btn_del_conn": "Supprimer Connexion", "designer_cnv_btn_ok": "OK", "designer_cnv_tooltip_invalid": "Connexion invalide", } ================================================ FILE: translations/ja.py ================================================ LANGUAGE_HEADER = "ja - 日本語" translations = { # Core continuous neurons "hunger": "空腹", "happiness": "幸福", "cleanliness": "清潔さ", "sleepiness": "眠気", "satisfaction": "満足度", "anxiety": "不安", "curiosity": "好奇心", # Binary/sensor neurons "can_see_food": "餌が見える", "is_eating": "食事中", "is_sleeping": "睡眠中", "is_sick": "病気", "pursuing_food": "餌を追跡中", "is_startled": "驚き", "is_fleeing": "逃走中", # Base keys for neurogenesis patterns "novelty": "新規性", "stress": "ストレス", "reward": "報酬", # ===== MAIN MENU ===== "file": "ファイル", "new_game": "新規ゲーム", "load_game": "ロード", "save_game": "セーブ", "view": "表示", "speed": "速度", "pause": "一時停止", "actions": "アクション", "debug": "デバッグ", "plugins": "プラグイン", # ===== VIEW MENU ===== "brain_designer": "脳デザイナー", "decorations": "装飾", "statistics": "統計", "brain_tool": "脳ツール", "neuron_lab": "ニューロン研究所", "task_manager": "タスクマネージャー", # ===== SPEED MENU ===== "normal_speed": "通常 (1x)", "fast_speed": "高速 (2x)", "very_fast": "超高速 (3x)", # ===== DEBUG MENU ===== "toggle_debug": "デバッグモード切替", "toggle_cone": "視界コーン切替", "squid_vision": "イカの視界", # ===== ACTION BUTTONS ===== "feed": "餌やり", "clean": "掃除", "medicine": "薬", "feed_btn": "餌をやる", "clean_btn": "掃除する", "medicine_btn": "薬をやる", # ===== MESSAGES ===== "feed_msg": "イカがお腹を空かせています", "points": "ポイント", "dirty": "汚染", "paused_msg": "シミュレーション停止中", "paused_sub": "再開するには速度メニューを使用してください", # ===== DIALOGS ===== "yes": "はい", "no": "いいえ", "ok": "OK", "cancel": "キャンセル", "close": "閉じる", "save": "保存", "load": "読み込み", "reset": "リセット", "apply_changes": "変更を適用", "got_it": "了解!", "finish": "完了", "confirm_new_game": "新しいゲームを始めますか?現在の進行状況は失われます。", "confirm_exit": "終了してもよろしいですか?", "save_successful": "ゲームを保存しました!", "load_successful": "ゲームを読み込みました!", "error_saving": "保存中にエラーが発生しました。", "error_loading": "読み込み中にエラーが発生しました。", "no_save_found": "セーブデータが見つかりません。", "startup": "起動", "show_tutorial_q": "チュートリアルを表示しますか?", "auto_decline": "({seconds}秒後に自動で閉じます)", "tutorial_title": "チュートリアル", "tutorial_query": "チュートリアルを見ますか?", # ===== ABOUT TAB ===== "hello": "こんにちは", "my_name_is": "私の名前は", "change_name": "名前変更", "enter_new_name": "イカの新しい名前を入力してください:", "change_colour": "色変更", "view_certificate": "証明書を見る", "care_tips": "飼育のヒント", "care_tips_for": "{personality}なイカの飼育ヒント", "dosidicus_title": "Dosidicus electronicae", "dosidicus_desc": "シンプルなニューラルネットワークを持つたまごっち風デジタルペット", "string_acronym": "推論と神経発生によるシミュレートされたたまごっち反応 (STRINg)", "research_project": "これは研究プロジェクトです。機能の提案をお願いします。", "version_dosidicus": "Dosidicus バージョン:", "version_brain_tool": "脳ツール バージョン:", "version_decision": "意思決定エンジン:", "version_neuro": "神経発生エンジン:", "created_by": "作成者", # ===== PERSONALITY ===== "squid_personality": "イカの性格", "personality_modifier": "性格補正", "description": "説明:", "personality_modifiers": "性格による補正:", "care_tips_label": "アドバイス:", "personality_note": "注: 性格は新規ゲーム開始時にランダムに生成されます", # Personality Types "personality_timid": "臆病", "personality_adventurous": "冒険好き", "personality_lazy": "怠け者", "personality_energetic": "エネルギッシュ", "personality_introvert": "内向的", "personality_greedy": "食いしん坊", "personality_stubborn": "頑固", # Personality Descriptions "desc_timid": "あなたのイカは臆病です。新しい状況では驚きやすく、不安になりやすい傾向があります。静かで落ち着いた環境を好み、自分から探索することは少ないかもしれません。しかし、安全だと感じれば強い絆を結ぶことができます。", "desc_adventurous": "あなたのイカは冒険好きです。探索や新しいことを試すのが大好きです。新しい物体や場所を真っ先に調べに行きます。変化のない環境では退屈してしまうかもしれません。", "desc_lazy": "あなたのイカは怠け者です。リラックスした生活を好み、他のイカよりも活動的ではありません。動くには強い動機付けが必要ですが、ただのんびりしているだけで満足することもあります。エネルギーの節約が得意です!", "desc_energetic": "あなたのイカはエネルギッシュです。常に動き回り、活力に満ち溢れています。幸せを保つには多くの刺激と活動が必要です。エネルギーを発散する機会がないと落ち着きがなくなるかもしれません。", "desc_introvert": "あなたのイカは内向的です。孤独を楽しみ、静かで混雑していない場所を好みます。他者と交流することもありますが、「充電」のために一人の時間を必要とします。観察力があり、思慮深い行動をとることがあります。", "desc_greedy": "あなたのイカは食いしん坊です。食べ物や資源に対して強い執着があります。他のイカよりもご褒美に弱いです。要求が多いこともありますが、隠されたおやつを見つけるのが得意です!", "desc_stubborn": "あなたのイカは頑固です。強い意志とはっきりとした好みを持っています。変化に抵抗し、新しいルーチンに慣れるのに時間がかかるかもしれません。しかし、その粘り強さで問題解決に取り組むこともあります。", # Personality Short Modifiers "mod_timid": "不安になりやすい", "mod_adventurous": "好奇心と探索欲が強い", "mod_lazy": "動きが遅く、エネルギー消費が少ない", "mod_energetic": "動きが速く、活動レベルが高い", "mod_introvert": "孤独と静かな環境を好む", "mod_greedy": "食べ物への執着が強い", "mod_stubborn": "好きな食べ物(寿司)しか食べず、寝ないことがある", # Personality Modifier Details "modifiers_timid": "- 不安が50%速く上昇\n- 好奇心が50%遅く上昇\n- 植物の近くで不安が50%減少", "modifiers_adventurous": "- 好奇心が50%速く上昇", "modifiers_lazy": "- 移動速度が遅い\n- エネルギー消費が低い", "modifiers_energetic": "- 移動速度が速い\n- エネルギー消費が高い", "modifiers_introvert": "- 静かな場所を好む\n- 一人の時間が必要", "modifiers_greedy": "- 空腹時に不安が50%増大\n- 食事時の満足度上昇量が大きい", "modifiers_stubborn": "- 寿司を好む\n- 疲れていても睡眠を拒否することがある", # Care Tips "tips_timid": "- 植物を置いて不安を和らげる\n- 環境を清潔で静かに保つ\n- 急な動きを避ける\n- 一定のルーチンを保つ\n- 頻繁なウィンドウサイズ変更を避ける", "tips_adventurous": "- 新しい装飾を定期的に導入する\n- 様々な食べ物を与える\n- 餌を隠して探索を促す\n- 広い探索スペースを確保する", "tips_lazy": "- 休憩場所の近くに餌を置く\n- こまめに掃除する\n- 美味しい餌で運動を促す\n- あまり活発に動くことを期待しない", "tips_energetic": "- 動き回れる広いスペースを用意する\n- 頻繁に食事の機会を与える\n- おもちゃや装飾で刺激を与える\n- エネルギー消費が多いため多くの餌が必要", "tips_introvert": "- 装飾で隠れられる場所を作る\n- 環境を詰め込みすぎない\n- 一人の時間を尊重する\n- 優しく接する", "tips_greedy": "- 寿司を含む多様な食事を与える\n- 良い行動への報酬として餌を使う\n- 食べ過ぎに注意\n- 空腹時は特に不安になりやすい", "tips_stubborn": "- 常に好物の寿司を用意しておく\n- 変化を加えるときは根気よく\n- 褒めて伸ばす\n- 落ち着ける環境を作って睡眠を促す", # ===== DECISIONS TAB ===== "thought_process": "イカの思考プロセス", "step": "ステップ", "step1_title": "世界の認識", "step2_title": "基本的欲求の計算", "step3_title": "性格と記憶の適用", "step4_title": "最終決定", "final_action": "最終アクション:", "awaiting_thought": "次の思考を待機中...", "awaiting_decision": "決定を待機中...", "sensing_condition": "現在の状態と視界内の物体を評価中:", "visible_objects": "視界内の物体", "no_sensory_data": "感覚データなし", "none": "なし", "no_urges": "欲求なし", "strongest_urge": "最も強い欲求は", "initial_scores": "初期スコア:", "personality_memory_adjust": "性格と最近の記憶による調整:", "no_adjustments": "性格や記憶による大きな調整はありません。", "final_scores_text": "最終スコアを集計し、最も高いスコアのアクションを実行します。", "no_final_scores": "最終スコアなし", "squid_decided": "イカの決定:", "with_confidence": "(確信度:", "score_increased": "増加", "score_decreased": "減少", "score_for": "対象:", "by_amount": "量:", # ===== LEARNING TAB (ORIGINAL) ===== "active_learning_pairs": "アクティブな学習ペア", "hebbian_cycle": "ヘブ学習サイクル", "hebbian_paused": "一時停止中", "learning_ready": "学習システム準備完了", "learning_ready_desc": "同時に活性化したニューロン間の結合を強化します。", "log_cleared": "ログ消去済み", "log_cleared_desc": "新しい結合が形成されるとここに表示されます。", "hebbian_overview": "ヘブ学習の概要", "neurons_fire_together": "共に発火するニューロンは結合する", "hebbian_principle": "ニューラルネットワークが経験を通じて学習する基本原理です。", "hebbian_explanation": "2つのニューロンが同時に活性化すると、その間の結合(重み)が強化されます。別々に活性化すると弱まります。", "excitatory_connections": "興奮性結合", "excitatory_desc": "正の重み (0.0-1.0) - 一緒に活性化しやすくなる", "inhibitory_connections": "抑制性結合", "inhibitory_desc": "負の重み (-1.0-0.0) - 相手の活性化を抑える", "very_strong": "非常に強い", "strong": "強い", "moderate": "中程度", "weak": "弱い", "very_weak": "非常に弱い", "inhibited": "抑制", # ===== MEMORY TAB ===== "memory": "記憶", "memories": "記憶リスト", "short_term_memory": "短期記憶", "long_term_memory": "長期記憶", "no_memories": "記憶はまだありません。", "overview": "概要", "memory_stats": "記憶統計", "categories": "カテゴリー", "time_label": "時間:", "important_label": "重要", "unknown": "不明", "category_label": "カテゴリー:", "key_label": "キー:", "access_count": "アクセス数:", "full_content": "内容:", "effects_label": "効果:", "positive": "ポジティブ", "negative": "ネガティブ", "neutral": "中立", # ===== NETWORK TAB (ORIGINAL) ===== "brain_network": "脳ネットワーク", "neurons": "ニューロン", "connections": "結合", "activity": "活動", # ===== STATISTICS WINDOW ===== "status": "ステータス", "health": "健康状態", # ===== NEURON NAMES (NEW) ===== "hunger": "空腹", "happiness": "幸福", "cleanliness": "清潔さ", "sleepiness": "眠気", "satisfaction": "満足", "curiosity": "好奇心", "anxiety": "不安", "can_see_food": "餌視認", "is_eating": "食事中", "is_sleeping": "睡眠中", "is_sick": "病気", "is_fleeing": "逃走中", "is_startled": "驚き", "pursuing_food": "餌追跡", "external_stimulus": "外部刺激", "plant_proximity": "植物近接", "stress": "ストレス", "novelty": "新規性", "reward": "報酬", # ===== BRAIN WIDGET LAYERS (NEW) ===== "layer_name": "層", "layer_input": "入力", "layer_output": "出力", "layer_hidden": "隠れ層", # ===== NEUROGENESIS LOGS (NEW) ===== "log_created": "{time} - {type}ニューロン ({name}) が生成されました ({type}カウンター: {value:.2f})", "log_pruned": "{time} - ニューロン ({name}) が剪定(削除)されました。理由: {reason}", "log_stress_detail": "不安への抑制性結合が形成されました\n最大不安値が永続的に10減少しました", # State Pills "fleeing": "逃走中!", "startled": "びっくり!", "eating": "食事中", "sleeping": "睡眠中", "playing": "遊んでいます", "hiding": "隠れています", "anxious": "不安", "curious": "興味津々", # ===== COMMON ACTIONS ===== "eat": "食べる", "sleep": "寝る", "play": "遊ぶ", "explore": "探索", "rest": "休む", "hide": "隠れる", "wander": "徘徊", "idle": "待機", "seek_food": "餌を探す", "seek_shelter": "避難所を探す", # ===== OBJECTS ===== "food": "餌", "rock": "石", "poop": "フン", "plant": "植物", "sushi": "寿司", "decoration": "装飾", # ===== TUTORIAL ===== "tutorial_hatched": "イカが孵化しました!世話をしてあげましょう。", "tutorial_feed": "お腹が空いたら餌をあげてください(アクションメニュー)", "tutorial_clean": "汚れたら水槽を掃除してください", "tutorial_watch": "行動を観察して性格を理解しましょう", "tutorial_neural": "ニューラルネットワーク", "tutorial_neural_desc": "これはイカの脳です。行動は欲求(丸いニューロン)によって決定されます。\n環境との相互作用を通じてネットワークは適応・学習します。", "tutorial_satisfaction": "満足度を高く、不安を低く保ちましょう。", "tutorial_traits": "育て方によって独自の特性や行動が発達します。", # ===== BRAIN DESIGNER (Templates) ===== "designer_title": "脳デザイナー", "required_only": "必須のみ", "dosidicus_default": "Dosidicus デフォルト", "full_sensors": "全センサー搭載", "the_insomniac": "不眠症", "the_hyperactive": "多動性", "the_hangry": "ハングリー・怒り", "the_depressive": "抑うつ", "the_obsessive": "強迫性", "balanced": "バランス型", "minimal": "最小構成", "dense": "高密度", "chaotic": "カオス", "calm": "平穏", # ===== SPLASH SCREEN ===== "squid_hatched": "イカが孵化しました!", "look_after": "大切に育ててください...", # ===== BRAIN TOOL TABS ===== "tab_learning": "学習", "tab_decisions": "意思決定", "tab_personality": "性格", "tab_about": "情報", # ===== NEURON INSPECTOR ===== "inspector_title": "ニューロンインスペクター", "lbl_name": "名前:", "lbl_value": "現在値:", "lbl_position": "位置:", "lbl_type": "タイプ:", "grp_neurogenesis": "神経発生の詳細", "lbl_created": "作成日時:", "lbl_trigger": "トリガータイプ:", "lbl_trigger_val": "トリガー値:", "lbl_state": "関連状態:", "col_connected": "接続先", "col_weight": "重み", "col_direction": "方向", "btn_refresh_data": "データ更新", "type_core": "コア", "type_neuro": "神経発生", "type_system": "システム状態", "direction_incoming": "入力", "direction_outgoing": "出力", # ===== TUTORIAL STEPS ===== "next": "次へ", "tutorial_step1_text": "イカが孵化しました!世話をしてあげましょう。\n• お腹が空いたら餌をあげる(アクションメニュー)\n• 汚れたら掃除する\n• 行動を観察して性格を知る", "tutorial_step2_text": "これはイカのニューラルネットワークです。行動は欲求(ニューロン)によって決まります。\nネットワークは環境との相互作用を通じて適応し、学習します。", "tutorial_step3_text": "イカは極端な環境刺激に反応して新しいニューロンを生成することがあります。\nこれらは困難な状況に適応するのを助けます。", "tutorial_step4_text": "2つのニューロンが同時に発火すると、その結合が強化されます。これにより刺激と反応の関連付けを学習します。", "tutorial_step5_text": "ニューラルネットワークは現在の欲求と過去の記憶に基づいて決定を下します。\n各決定はイカの状態と将来の行動に影響を与えます。", "tutorial_step6_text": "「D」キーを押すと装飾ウィンドウが開きます。\nドラッグ&ドロップで配置し、イカの反応を見てみましょう。クリック+ホイールでサイズ変更、DELキーで削除できます。", "tutorial_step7_text": "満足度を高く、不安を低く保ってください。\n育て方次第で、あなたのイカは独自の特性を発達させます。", # ===== NETWORK & LEARNING TABS (NEW) ===== "stats_neurons": "ニューロン数", "stats_connections": "結合数", "stats_health": "ネットワーク健全性", "emergency_alert": "🚨 緊急警報: {name}", "global_cooldown": "クールダウン", "style_label": "スタイル:", "chk_links": "リンクを表示", "chk_weights": "重みを表示", "chk_pruning": "剪定を有効化", "tooltip_brain_designer": "脳デザイナーを開く", "msg_already_open": "既に開いています", "msg_designer_running": "脳デザイナーは既に起動しています!", "msg_launch_failed": "起動失敗", "msg_designer_fail": "脳デザイナーを開始できませんでした:\n\n{e}", "msg_missing_brain": "脳が見つかりません", "msg_cannot_open_lab": "ニューロン研究所を開けません: 脳ウィジェットが利用できません。", "msg_cannot_open_buffer": "経験バッファを開けません: 脳ウィジェットが利用できません。", "msg_no_neurogenesis": "神経発生なし", "msg_neurogenesis_not_init": "バッファを開けません: 神経発生システムが初期化されていません。", "msg_decorations_unavailable": "装飾利用不可", "msg_decorations_fail": "装飾を開けません: ウィンドウが利用できません。", "func_neurons_title": "機能的ニューロン", "count_label": "数", "avg_utility_label": "平均有用性", "total_activations_label": "総活性化数", "specialisations_label": "専門化", "buffer_title": "神経発生経験バッファ", "buffer_header": "最近の経験", "col_type": "タイプ", "col_pattern": "パターン", "col_outcome": "結果", "col_time": "時間", "btn_refresh": "更新", "buffer_size": "バッファサイズ", "top_patterns": "トップパターン", "no_patterns": "パターンなし", # Learning Tab Educational Content "learning_pairs_tab": "学習ペア", "mechanics_tab": "メカニズム", "hebbian_quote": "「共に発火するニューロンは、結合する」", "in_practice_title": "実践において", "in_practice_text": "あなたのイカの脳内では、ヘブ学習が「空腹」と食事時の「満足」、あるいは探索時の「好奇心」と「不安」のような状態を関連付けます。", "mechanics_title": "学習の仕組み", "mechanics_intro": "ヘブ学習は、活動パターンに基づいてニューロン間の結合強度(重み)を更新します。同時に活性化すれば強化され、別々なら弱まります。", "learning_rule_title": "学習ルール", "where_label": "定義:", "delta_w_desc": "Δw = 重みの変化量", "eta_desc": "η (eta) = 学習率(変化の速さ)", "activation_desc": "x, y = 活性化値 (1: 活性, 0: 非活性)", "example_calc_title": "計算例", "scenario_label": "シナリオ: 「空腹」と「満足」が両方活性化", "calc_result": "重みが0.1増加し、結合が強化されます。", "over_time_title": "時間の経過とともに", "over_time_text": "繰り返しの活性化により、小さな重みの変化が蓄積します。頻繁に起こるパターンは強い結合を作り、これが学習となります!", "str_excitatory": "強い興奮性", "weak_excitatory": "弱い興奮性", "weak_inhibitory": "弱い抑制性", "str_inhibitory": "強い抑制性", # ===== SQUID & BRAIN STATISTICS ===== "distance_rollover": "🌊 距離カウンターが一周しました! 現在 {multiplier}周目", "time_min": "分", "time_mins": "分", "time_hr": "時間", "time_hrs": "時間", "time_fmt_hm": "{hours}時間 {minutes}分", "stat_squid_age": "イカの年齢", "stat_distance": "泳いだ距離 (px)", "stat_cheese": "食べたチーズ", "stat_sushi": "食べた寿司", "stat_poops": "フンの数", "stat_max_poops": "最大フン蓄積数", "stat_startles": "驚いた回数", "stat_ink": "吐いた墨の数", "stat_colour_change": "色が変わった回数", "stat_rocks": "投げた石", "stat_plants": "植物との接触", "stat_sleep": "総睡眠時間 (秒)", "stat_sickness": "病気になった回数", "stat_novelty_neurons": "新規性ニューロン作成数", "stat_stress_neurons": "ストレスニューロン作成数", "stat_reward_neurons": "報酬ニューロン作成数", "stat_current_neurons": "現在のニューロン数", "reset_stats_title": "統計のリセット", "reset_stats_msg": "本当にすべての統計をリセットしますか?", "export_stats_title": "統計のエクスポート", "export_file_type": "テキストファイル (*.txt)", "export_header": "イカ統計エクスポート", "export_time": "エクスポート日時", "export_activity_section": "活動統計", "export_end": "統計終了", "export_success_title": "エクスポート成功", "export_success_msg": "統計を {file_name} にエクスポートしました", "export_error_title": "エクスポートエラー", "export_error_msg": "エクスポート中にエラーが発生しました: {error}", # ===== ACHIEVEMENTS (NEW) ===== # Categories "cat_feeding": "給餌", "cat_neurogenesis": "神経発生", "cat_sleep": "睡眠", "cat_milestones": "マイルストーン", "cat_exploration": "探索", "cat_cleaning": "掃除", "cat_health": "健康", "cat_interaction": "交流", "cat_ink": "墨", "cat_memory": "記憶", "cat_emotional": "感情", "cat_secret": "秘密", "cat_meta": "メタ", # UI Elements "ui_points": "ポイント", "ui_unlocked": "解除済み", "ui_achievement_unlocked": "実績解除!", "ui_hidden": "隠し実績", "ui_all": "すべて", "ui_points_gained": "ポイント", # --- Achievements --- # Feeding "ach_first_feeding_name": "初めての一口", "ach_first_feeding_desc": "初めてイカに餌をやる", "ach_fed_10_times_name": "定食", "ach_fed_10_times_desc": "10回餌をやる", "ach_fed_50_times_name": "専属シェフ", "ach_fed_50_times_desc": "50回餌をやる", "ach_fed_100_times_name": "巨匠", "ach_fed_100_times_desc": "100回餌をやる", "ach_fed_500_times_name": "伝説の料理人", "ach_fed_500_times_desc": "500回餌をやる", # Neurogenesis "ach_first_neuron_name": "脳のひらめき", "ach_first_neuron_desc": "最初の神経発生ニューロンを作成する", "ach_neurons_10_name": "ニューラルネットワーク", "ach_neurons_10_desc": "神経発生で10個のニューロンを作成", "ach_neurons_50_name": "拡張する精神", "ach_neurons_50_desc": "神経発生で50個のニューロンを作成", "ach_neurons_100_name": "脳力発電所", "ach_neurons_100_desc": "神経発生で100個のニューロンを作成", "ach_first_neuron_levelup_name": "シナプス強化", "ach_first_neuron_levelup_desc": "初めてニューロンをレベルアップさせる", "ach_neuron_max_level_name": "最高性能", "ach_neuron_max_level_desc": "ニューロンを最大レベルまで強化する", # Sleep "ach_first_sleep_name": "良い夢を", "ach_first_sleep_desc": "初めての睡眠から目覚める", "ach_slept_10_times_name": "快眠", "ach_slept_10_times_desc": "10回眠る", "ach_dream_state_name": "深い夢", "ach_dream_state_desc": "レム睡眠に入る", # Milestones "ach_age_1_hour_name": "生後1時間", "ach_age_1_hour_desc": "1時間生存する", "ach_age_10_hours_name": "成長", "ach_age_10_hours_desc": "10時間生存する", "ach_age_24_hours_name": "一日の奇跡", "ach_age_24_hours_desc": "24時間生存する", "ach_age_1_week_name": "週間ベテラン", "ach_age_1_week_desc": "1週間生存する", "ach_age_1_month_name": "月間ベテラン", "ach_age_1_month_desc": "1ヶ月生存する", "ach_happiness_100_name": "純粋な至福", "ach_happiness_100_desc": "幸福度100%に到達", "ach_all_stats_high_name": "完全な調和", "ach_all_stats_high_desc": "全ステータスが同時に80%を超える", # Cleaning "ach_first_clean_name": "最初の一掃き", "ach_first_clean_desc": "初めて水槽を掃除する", "ach_cleaned_25_times_name": "ピカピカの環境", "ach_cleaned_25_times_desc": "25回掃除する", "ach_germaphobe_name": "潔癖症", "ach_germaphobe_desc": "清潔さを1時間以上90%以上に保つ", # Health "ach_first_medicine_name": "応急処置", "ach_first_medicine_desc": "初めて薬を与える", "ach_medicine_10_times_name": "ドクター・スクイッド", "ach_medicine_10_times_desc": "10回薬を与える", "ach_comeback_kid_name": "起死回生", "ach_comeback_kid_desc": "危機的状況(健康<20%)から全快する", # Interaction "ach_first_rock_pickup_name": "石集め", "ach_first_rock_pickup_desc": "初めて石を拾う", "ach_rocks_picked_10_name": "石収集家", "ach_rocks_picked_10_desc": "石を10個拾う", "ach_rocks_picked_50_name": "岩の貯蔵庫", "ach_rocks_picked_50_desc": "石を50個拾う", "ach_first_rock_throw_name": "水切り", "ach_first_rock_throw_desc": "初めて石を投げる", "ach_rocks_thrown_25_name": "投石機", "ach_rocks_thrown_25_desc": "石を25回投げる", "ach_rocks_thrown_100_name": "投石マスター", "ach_rocks_thrown_100_desc": "石を100回投げる", # Decor "ach_first_decoration_push_name": "インテリアコーディネーター", "ach_first_decoration_push_desc": "初めて装飾を押す", "ach_decorations_pushed_10_name": "模様替え", "ach_decorations_pushed_10_desc": "装飾を10回押す", "ach_decorations_pushed_50_name": "風水マスター", "ach_decorations_pushed_50_desc": "装飾を50回押す", "ach_first_plant_interact_name": "緑の指", "ach_first_plant_interact_desc": "初めて植物に触れる", "ach_plants_interacted_10_name": "庭の探検家", "ach_plants_interacted_10_desc": "植物に10回触れる", "ach_plants_interacted_50_name": "植物学者", "ach_plants_interacted_50_desc": "植物に50回触れる", "ach_objects_investigated_25_name": "好奇心旺盛な検査官", "ach_objects_investigated_25_desc": "25種類の物体を調査する", "ach_objects_investigated_100_name": "名探偵", "ach_objects_investigated_100_desc": "100種類の物体を調査する", # Exploration (Poop) "ach_first_poop_throw_name": "いたずらっ子", "ach_first_poop_throw_desc": "初めてフンを投げる", # Ink "ach_first_ink_cloud_name": "煙幕", "ach_first_ink_cloud_desc": "初めて墨を吐く", "ach_ink_clouds_20_name": "インクマスター", "ach_ink_clouds_20_desc": "墨を20回吐く", # Memory "ach_first_memory_name": "最初の記憶", "ach_first_memory_desc": "最初の記憶を形成する", "ach_memory_long_term_name": "長期的思考", "ach_memory_long_term_desc": "記憶を長期記憶に昇格させる", "ach_memories_50_name": "写真的記憶", "ach_memories_50_desc": "50個の記憶を保存する", # Emotional "ach_curiosity_100_name": "おさるのジョージ", "ach_curiosity_100_desc": "好奇心が100%に到達", "ach_zen_master_name": "禅マスター", "ach_zen_master_desc": "不安を10%以下に30分間保つ", "ach_first_startle_name": "びっくり!", "ach_first_startle_desc": "初めてイカを驚かせる", "ach_nervous_wreck_name": "神経衰弱", "ach_nervous_wreck_desc": "不安が100%に到達", # Secret "ach_night_owl_name": "夜更かし", "ach_night_owl_desc": "深夜0時から4時の間にプレイ", "ach_early_bird_name": "早起き", "ach_early_bird_desc": "早朝5時から7時の間にプレイ", "ach_weekend_warrior_name": "週末戦士", "ach_weekend_warrior_desc": "土曜と日曜の両方にプレイ", # Meta "ach_brain_surgeon_name": "脳外科医", "ach_brain_surgeon_desc": "脳の可視化ツールを開く", "ach_speed_demon_name": "スピード狂", "ach_speed_demon_desc": "最高速度で10分間シミュレーションを実行", "ach_completionist_name": "コンプリート", "ach_completionist_desc": "他の実績を30個解除", # ===== NEURON LABORATORY ===== "lab_title": "🧠 ニューロン研究所", "lab_live_refresh": "ライブ更新", "lab_unlock_editing": "🔓 編集ロック解除", "lab_tab_overview": "📊 ライブ概要", "lab_tab_inspector": "🔍 詳細インスペクター", "lab_tab_edit": "🔧 編集サンドボックス", "lab_status_ready": "準備完了", "lab_status_locked": "🔒 {name} ロック中 (値: {value})", "lab_status_unlocked": "🔓 {name} ロック解除", # Overview Tab "lab_ov_counters": "カウンター進行状況", "lab_ov_newest": "最新の神経発生", "lab_ov_limits": "制限と剪定", "lab_ov_actions": "クイックアクション", "lab_force_hebbian": "ヘブ学習を強制実行", "lab_pruning_enabled": "剪定有効:", "lab_none_yet": "まだありません", "lab_ago": "{seconds}秒前", # Inspector Tab "lab_pick_neuron": "調査するニューロンを選択:", "lab_connections_title": "結合 (興奮性 vs 抑制性)", "lab_header_partner": "パートナー", "lab_header_weight": "重み", "lab_header_type": "タイプ", "lab_header_inf": "影響力", "lab_impact_title": "機能的影響シミュレーション", "lab_header_neuron": "ニューロン", "lab_header_delta": "Δ値", "lab_no_connections": "現在アクティブな結合はありません", "lab_did_you_know": "豆知識:", "lab_type_excitatory": "興奮性", "lab_type_inhibitory": "抑制性", # Edit Tab "lab_edit_locked_msg": "⚠️ 編集はロックされています - ツールバーの「編集ロック解除」を確認してください。", "lab_edit_header": "ニューロン値 (ドラッグで変更) - 🔒でロック", "lab_unlock_title": "編集を解除しますか?", "lab_unlock_msg": "ニューロンの値を変更したり、生成イベントを強制したりできます。自己責任で使用してください!", # Badges/Influence "lab_inf_tiny": "極小", "lab_inf_mild": "軽微", "lab_inf_mod": "中程度", "lab_inf_strong": "強力", # Educational Tips "lab_tip_hunger": "空腹は恒常的な欲求です。高い空腹度は満足感を抑制し、不安を高めます。", "lab_tip_happiness": "幸福感は報酬ニューロンによって強化されます。不安を抑制し、好奇心を促進します。", "lab_tip_anxiety": "不安はストレスニューロン(抑制性)によって減少します。高い不安は好奇心を抑制します。", "lab_tip_curiosity": "好奇心は新規性が高いときに急上昇します。探索を促し、不安を軽減します。", "lab_tip_core": "コアニューロン - 生存に不可欠です。", "lab_tip_neuro_default": "神経発生ニューロン - 生成時の状況から目的が推測されます。", "lab_tip_neuro_fmt": "{trigger}により生成 – 専門: {spec}。経験を長期的な行動に変換します。", # ===== VISION WINDOW ===== "vision_window_title": "イカの視界", "vis_logic_unavailable": "ロジック利用不可", "vis_nothing_in_view": "視界に何もありません。", "vis_distance": "距離", # --- Brain Tooltips --- "tooltip_specialization": "専門", "tooltip_type": "タイプ", "tooltip_current": "現在値", "tooltip_utility": "有用性", "tooltip_activations": "活性化数", "tooltip_last_active": "最終活動", "tooltip_age": "年齢", "tooltip_core": "コア", "tooltip_generated": "生成済", "tooltip_functional": "機能的", "tooltip_connections_header": "結合", "tooltip_connections_stats": "入力 {incoming}, 出力 {outgoing}", "tooltip_top_incoming": "主な入力", "tooltip_top_outgoing": "主な出力", "tooltip_hint": "ダブルクリックで詳細 • 右クリックでオプション", # State values "state_on": "オン", "state_off": "オフ", # Time formatting "fmt_s_ago": "{val}秒前", "fmt_m_ago": "{val}分前", "fmt_h_ago": "{val}時間前", "fmt_s_short": "{val}秒", "fmt_m_short": "{val}分", "fmt_h_short": "{val}時間", "fmt_d_short": "{val}日", # ===== BRAIN DESIGNER WINDOW UI ===== "designer_window_title": "脳デザイナー - Dosidicus-2", "designer_window_title_imported": "脳デザイナー - Dosidicus-2 [ゲームからインポート]", # Tabs "designer_tab_layers": "レイヤー", "designer_tab_sensors": "センサー", "designer_tab_props": "プロパティ", "designer_tab_connections": "結合", "designer_tab_outputs": "出力", # Toolbar "designer_btn_generate": "🎲 疎なネットワークを生成", "designer_tooltip_generate": "コアニューロン間にランダムな結合を生成", "designer_btn_neuron": "➕ ニューロン", "designer_tooltip_neuron": "新しいニューロンを追加 (Shift+N)", "designer_btn_fix": "🔧 自動修正", "designer_tooltip_fix": "孤立したニューロンと接続の問題を自動修正", "designer_btn_validate": "✓ 検証", "designer_tooltip_validate": "デザインの問題をチェック", "designer_btn_sync": "🔄 ゲームから同期", "designer_tooltip_sync": "実行中のゲームから脳の状態を更新", "designer_btn_clear_conn": "🗑 結合をクリア", "designer_tooltip_clear_conn": "すべての結合を削除(ニューロンは保持)", "designer_tooltip_dice": "即座にランダムなネットワークを生成(ダイアログなし)", # Ticker / Help Bar "designer_help_drag_connect": "💡 ニューロンから左ドラッグで結合を作成", "designer_help_ctrl_move": "Ctrl+ドラッグでニューロンを移動", "designer_help_pan": "右ドラッグでキャンバスを移動", "designer_help_zoom": "ホイールでズーム(または結合の重みを調整)", "designer_help_edit_weight": "結合をダブルクリックで重みを編集", "designer_help_select": "クリックで選択", "designer_help_delete": "Delで選択項目を削除", "designer_help_reverse": "スペースで結合の向きを反転", "designer_help_keys_weight": "+/-キーで重みを調整 (Shiftで大きく)", "designer_help_page_weight": "Page Up/Downで重みを調整(大きく)", "designer_help_add_neuron": "Shift+Nでニューロン追加", "designer_help_save": "Ctrl+Sで保存", "designer_help_open": "Ctrl+Oで開く", "designer_help_export": "Ctrl+Eでエクスポート", "designer_help_new": "Ctrl+Nで新規作成", "designer_help_gen": "Ctrl+Gでネットワーク生成", "designer_help_dice": "🎲 サイコロで即時ランダム生成", "designer_help_outputs": "出力タブで行動とバインド", # Menus "designer_menu_file": "ファイル", "designer_menu_edit": "編集", "designer_menu_templates": "テンプレート", "designer_menu_generate": "生成", # Actions "designer_action_new": "新規デザイン", "designer_action_save": "保存...", "designer_action_export": "Dosidicus用にエクスポート...", "designer_action_open": "開く...", "designer_action_gen_sparse": "疎なネットワークを生成...", "designer_action_autofix": "接続を自動修正", "designer_action_validate": "デザインを検証", "designer_action_clear_conn": "全ての結合をクリア", "designer_action_clear_outputs": "全ての出力バインドをクリア", # Status Bar "designer_status_neurons": "ニューロン: {count}", "designer_status_connections": "結合: {count}", "designer_status_required": "必須: {ok}", "designer_status_outputs": "出力: {count}", "designer_status_selected": "選択: {source} → {target} (重み: {weight:+.3f})", "designer_status_weight_updated": "重み更新: {source} → {target} = {weight:+.3f}", "designer_status_deleted": "結合削除: {source} → {target}", "designer_status_cleared_conn": "{count}個の結合をクリア", "designer_status_cleared_out": "{count}個の出力バインドをクリア", "designer_status_generated": "'{style}'プリセットで{count}個の結合を生成", "designer_status_random_gen": "🎲 {count}個のランダム結合を生成 (スタイル: {style})", "designer_status_synced": "✨ 同期完了: ニューロン{neurons}, 結合{connections}", "designer_status_imported": "✨ 実行中のゲームから脳をインポートしました", # Dialogs & Messages "designer_msg_game_not_running_title": "ゲームが実行されていません", "designer_msg_game_not_running": "Dosidicusゲームが実行されていません。\n\n同期するにはゲームを起動してください。", "designer_msg_sync_confirm_title": "ゲームから同期", "designer_msg_sync_confirm": "現在のデザインをゲーム内の最新の脳状態で置き換えますか?", "designer_msg_sync_failed_title": "同期失敗", "designer_msg_sync_failed": "ゲームから脳の状態をインポートできませんでした。", "designer_msg_live_import_title": "ライブ脳インポート", "designer_msg_live_import_header": "🧠 実行中のゲームから脳をインポート", "designer_msg_live_import_body": "デザイナーは現在、実行中のDosidicusゲームのニューラルネットワークを表示しています。\n\n• {neurons} ニューロン\n• {connections} 結合\n\nここでの変更は実行中のゲームには影響しません。", "designer_msg_clear_conn_title": "結合のクリア", "designer_msg_clear_conn_confirm": "{count}個の結合をすべて削除しますか?\n\nニューロンは保持されます。", "designer_msg_clear_out_title": "出力バインドのクリア", "designer_msg_clear_out_empty": "クリアする出力バインドがありません。", "designer_msg_clear_out_confirm": "{count}個の出力バインドをすべて削除しますか?", "designer_msg_new_design_title": "新規デザイン", "designer_msg_new_design_confirm": "新しいデザインを開始しますか?保存されていない変更は失われます。", "designer_msg_autofix_title": "自動修正", "designer_msg_autofix_result": "{count}個の結合を作成しました:\n\n{details}", "designer_msg_autofix_none": "問題は見つかりませんでした。", "designer_msg_save_title": "デザインを保存", "designer_msg_saved_title": "保存完了", "designer_msg_save_success": "デザインを保存しました: {msg}", "designer_msg_save_bindings": "\n({count}個の出力バインドを含む)", "designer_msg_error_title": "エラー", "designer_msg_save_fail": "保存に失敗しました:\n\n{error}", "designer_msg_export_title": "エクスポート", "designer_msg_exported_title": "エクスポート完了", "designer_msg_export_success": "デザインを正常にエクスポートしました", "designer_msg_export_fail": "エクスポートに失敗しました:\n\n{error}", "designer_msg_open_title": "デザインを開く", "designer_msg_open_fail": "読み込めませんでした:\n\n{error}", "designer_msg_load_template_title": "テンプレート読み込み", "designer_msg_select_template": "テンプレートを選択:", "designer_msg_replace_design": "現在のデザインを置き換えますか?", "designer_msg_status_title": "デザインステータス", "designer_msg_status_ok": "\n✅ ステータス: OK", "designer_msg_status_issues": "\n⚠️ 問題:\n", "designer_input_weight_title": "結合の重み", "designer_input_weight_label": "{source} → {target} の重みを設定:", # ===== DESIGNER PANELS ===== # Properties Panel "designer_prop_no_selection": "ニューロン未選択", "designer_prop_no_selection_disabled": "選択なし", "designer_prop_lbl_name": "名前:", "designer_prop_lbl_type": "タイプ:", "designer_prop_lbl_x": "X:", "designer_prop_lbl_y": "Y:", "designer_prop_btn_delete": "ニューロン削除", # Add Neuron Dialog "designer_add_title": "ニューロン追加", "designer_add_grp_type": "ニューロンタイプを選択", "designer_add_btn_custom": "✨ カスタム / プラグイン", "designer_add_btn_sensor": "📡 入力センサー", "designer_add_tooltip_custom": "プラグインとリンクするための特定の名前を持つニューロンを作成", "designer_add_grp_sensor": "センサーを選択", "designer_add_grp_custom": "カスタムニューロン定義", "designer_add_info_custom": "イカに影響を与えるには、名前がプラグインIDと一致する必要があります。
    例: 'jet_boost'と名付けてジェットパックプラグインを起動。
    ", "designer_add_lbl_id": "プラグインID / 名前:", "designer_add_ph_id": "例: turbo_mode", "designer_add_btn_create": "リンク作成", "designer_add_all_added": "全センサー追加済み", "designer_add_err_title": "エラー", "designer_add_err_exists": "既に存在します", "designer_add_msg_created": "{name} を作成しました", # Layers Panel "designer_layer_btn_add": "層を追加", "designer_layer_dlg_title": "新しい層", "designer_layer_dlg_label": "名前:", # Sensors Panel "designer_sensor_header": "入力センサー:", "designer_sensor_tooltip_refresh": "センサーリスト更新 (プラグインを含む)", "designer_sensor_cat_label": "── {name} ──", # Connections Table "designer_conn_header_source": "ソース", "designer_conn_header_target": "ターゲット", "designer_conn_header_weight": "重み", # ===== OUTPUTS PANEL ===== "designer_output_header": "出力バインド
    ニューロンを行動に接続します。活性化が閾値を超えると行動がトリガーされます。", "designer_output_btn_add": "➕ バインド追加", "designer_output_btn_edit": "✏️ 編集", "designer_output_btn_remove": "🗑️ 削除", "designer_output_col_neuron": "ニューロン", "designer_output_col_behavior": "→ 行動", "designer_output_col_threshold": "閾値", "designer_output_col_mode": "モード", "designer_output_col_enabled": "有効", "designer_output_info": "{count}バインド, {enabled}有効", "designer_output_err_missing": "⚠️ ニューロンが見つかりません", "designer_output_dlg_remove_title": "バインド削除", "designer_output_dlg_remove_msg": "バインドを削除しますか: {neuron} → {hook}?", # Output Binding Dialog "designer_binding_title_add": "出力バインド追加", "designer_binding_title_edit": "出力バインド設定", "designer_binding_grp_neuron": "ソースニューロン", "designer_binding_lbl_neuron": "ニューロン:", "designer_binding_lbl_current": "現在: --", "designer_binding_grp_hook": "出力行動", "designer_binding_lbl_trigger": "トリガー:", "designer_binding_grp_settings": "トリガー設定", "designer_binding_lbl_thresh": "閾値:", "designer_binding_lbl_mode": "モード:", "designer_binding_lbl_cool": "クールダウン:", "designer_binding_chk_enabled": "有効", "designer_binding_err_neuron": "ニューロンを選択してください", "designer_binding_err_hook": "出力行動を選択してください", "designer_binding_err_duplicate": "{neuron} → {hook} のバインドは既に存在します", # Trigger Modes "designer_mode_rising": "立ち上がり (閾値を超えた瞬間)", "designer_mode_falling": "立ち下がり (閾値を下回った瞬間)", "designer_mode_above": "閾値以上 (継続)", "designer_mode_below": "閾値以下 (継続)", "designer_mode_change": "変化時 (有意な変化)", # ===== DESIGNER SENSOR DISCOVERY ===== "desc_builtin_sensor": "内蔵センサー: {name}", "desc_vision_food": "視界内の餌を検出", "desc_custom_sensor": "{plugin}からのカスタムセンサー", "desc_builtin": "内蔵", "desc_plugin": "プラグイン", "desc_other": "その他", "desc_vision": "視覚", # ===== DESIGNER TEMPLATES (Extra Keys) ===== "tmpl_core_name": "🟡 必須のみ", "tmpl_core_desc": "必須8ニューロン", "tmpl_dosidicus_name": "🟡 Dosidicus デフォルト", "tmpl_dosidicus_desc": "標準レイアウト", "tmpl_full_sensors_name": "🟡 全センサー搭載", "tmpl_full_sensors_desc": "全てのセンサー", "tmpl_insomniac_name": "🔴 不眠症", "tmpl_insomniac_desc": "不安と好奇心が睡眠を阻害", "tmpl_hyperactive_name": "🔴 多動性", "tmpl_hyperactive_desc": "ノイズが眠気を圧倒", "tmpl_hangry_name": "🔴 ハングリー・怒り", "tmpl_hangry_desc": "空腹が激しい怒りを引き起こす", "tmpl_depressive_name": "🔴 抑うつ", "tmpl_depressive_desc": "幸福感に抵抗", "tmpl_obsessive_name": "🔴 強迫性", "tmpl_obsessive_desc": "不安/好奇心のフィードバックループ", "layer_sensors": "センサー", "layer_core": "コア", "layer_input": "入力", "layer_out": "出力", "layer_racing_mind": "思考の暴走", "layer_state": "状態", "layer_vision": "視覚", "layer_noise": "ノイズ", "layer_output": "出力", "layer_gut_brain": "脳腸相関", "layer_gray": "灰白質", "layer_loop": "ループ", "layer_stats": "統計", "layer_emotions": "感情", # ===== CANVAS CONTEXT MENU / DIALOGS ===== "designer_cnv_del_conn_title": "結合削除", "designer_cnv_del_conn_msg": "次の結合を削除してもよろしいですか?\n{source} → {target}", "designer_cnv_chk_dont_ask": "今後確認しない", "designer_cnv_btn_del": "はい、削除", "designer_cnv_btn_cancel": "キャンセル", "designer_cnv_dlg_edit_title": "結合の編集", "designer_cnv_lbl_conn": "結合: {source} → {target}", "designer_cnv_lbl_weight": "重み:", "designer_cnv_info_weight": "正 = 興奮性 (緑), 負 = 抑制性 (赤)", "designer_cnv_btn_del_conn": "結合を削除", "designer_cnv_btn_ok": "OK", "designer_cnv_tooltip_invalid": "無効な結合", } ================================================ FILE: translations/ml.py ================================================ LANGUAGE_HEADER = "ml - Millennial" translations = { # Core continuous neurons "hunger": "Hangry Level", "happiness": "Vibes", "cleanliness": "Aesthetic", "sleepiness": "Nap Cravings", "satisfaction": "Dopamine", "anxiety": "Existential Dread", "curiosity": "FOMO", # Binary/sensor neurons "can_see_food": "Spotted Snacks", "is_eating": "Nomming", "is_sleeping": "Zonked Out", "is_sick": "Not A Vibe", "pursuing_food": "Chasing Snaccs", "is_startled": "Shook", "is_fleeing": "Nope-ing Out", # Base keys for neurogenesis patterns "novelty": "New Shiny Thing", "stress": "Panic", "reward": "Treat Yo Self", # ===== MAIN MENU ===== "file": "Stuff", "new_game": "New Era", "load_game": "Flashback", "save_game": "Save Receipts", "view": "Peep", "speed": "Pacing", "pause": "Hol' Up", "actions": "Do Things", "debug": "Hacker Mode", "plugins": "Mods", # ===== VIEW MENU ===== "brain_designer": "Big Brain Energy", "decorations": "Room Makeover", "statistics": "The Receipts", "brain_tool": "Brain Cell Inspector", "neuron_lab": "The Lab", "task_manager": "Adulting Manager", # ===== SPEED MENU ===== "normal_speed": "Chill (1x)", "fast_speed": "Zoomies (2x)", "very_fast": "Ludicrous Speed (3x)", # ===== DEBUG MENU ===== "toggle_debug": "Toggle Matrix Mode", "toggle_cone": "Show Eye Cone", "squid_vision": "POV Mode", # ===== ACTION BUTTONS ===== "feed": "Noms", "clean": "Tidy", "medicine": "Meds", "feed_btn": "FEED ME", "clean_btn": "CLEAN UP", "medicine_btn": "SELF CARE", # ===== MESSAGES ===== "feed_msg": "Bestie is starving", "points": "Clout", "dirty": "GROSS", "paused_msg": "VIBE CHECK PAUSED", "paused_sub": "Hit the Speed menu to unfreeze time", # ===== DIALOGS ===== "yes": "Yasss", "no": "Nah", "ok": "Kk", "cancel": "Nevermind", "close": "Bye", "save": "Stash It", "load": "Bring It Back", "reset": "Rage Quit", "apply_changes": "Make It Happen", "got_it": "Understood the Assignment", "finish": "Donezo", "confirm_new_game": "Start fresh? You'll lose all your progress rn.", "confirm_exit": "You tryin' to leave?", "save_successful": "Saved to the cloud (jk, local disk).", "load_successful": "We back in business!", "error_saving": "Epic fail saving game.", "error_loading": "Epic fail loading game.", "no_save_found": "No receipts found.", "startup": "Waking Up", "show_tutorial_q": "Need a guide?", "auto_decline": "(Ghosting in {seconds}s)", "tutorial_title": "The 101", "tutorial_query": "Do you need me to explain how this works?", # ===== ABOUT TAB ===== "hello": "SUP", "my_name_is": "call me", "change_name": "Rebrand", "enter_new_name": "New handle for your squid:", "change_colour": "Glow Up", "view_certificate": "Flex Certificate", "care_tips": "Life Hacks", "care_tips_for": "Life Hacks for {personality} Squids", "dosidicus_title": "Dosidicus electronicae", "dosidicus_desc": "Basically a Tamagotchi but with actual AI anxiety.", "string_acronym": "Simulated Tamagotchi Reactions via Inferencing and Neurogenesis (STRINg)", "research_project": "It's for science. Slide into the DMs with feature requests.", "version_dosidicus": "Build:", "version_brain_tool": "Brain Tool:", "version_decision": "Choice Engine:", "version_neuro": "Growth Engine:", "created_by": "built by", # ===== PERSONALITY ===== "squid_personality": "Squid Vibe", "personality_modifier": "Vibe Check", "description": "The Tea:", "personality_modifiers": "Stat Buffs/Debuffs:", "care_tips_label": "Pro Tips:", "personality_note": "Note: Personality is RNG at the start.", # Personality Types "personality_timid": "Smol Bean", "personality_adventurous": "Wanderlust", "personality_lazy": "Potato", "personality_energetic": "No Chill", "personality_introvert": "Loner", "personality_greedy": "Boujee", "personality_stubborn": "Karen", # Personality Descriptions "desc_timid": "Your squid is a Smol Bean. Literally terrified of everything. Needs a safe space and good vibes only. 10/10 would protect.", "desc_adventurous": "Your squid has Wanderlust. Wants to travel the world (or the tank). Gets bored if things aren't lit. Total main character energy.", "desc_lazy": "Your squid is a Potato. Mood: doing absolutely nothing. Energy saving mode is always on. Relatable content.", "desc_energetic": "Your squid has No Chill. Always zooming. Needs constant entertainment or will destroy things. Probably drinks too much coffee.", "desc_introvert": "Your squid is a Loner. 'Social battery drained' is their permanent state. Likes watching from the corner. Weird but valid.", "desc_greedy": "Your squid is Boujee. Wants all the snacks. High maintenance. Treat them like royalty or get roasted.", "desc_stubborn": "Your squid is a Karen. Wants to speak to the manager. Hates change. Will only eat specific sushi. Good luck with this one.", # Personality Short Modifiers "mod_timid": "High anxiety, needs hugs", "mod_adventurous": "Exploring > Everything", "mod_lazy": "Slow moving, stays in bed", "mod_energetic": "Fast af boi", "mod_introvert": "Hates crowds, loves quiet", "mod_greedy": "Hangry all the time", "mod_stubborn": "Picky eater, refuses to sleep", # Personality Modifier Details "modifiers_timid": "- Dread spikes 50% faster\n- FOMO drops 50% slower\n- Plants make everything better", "modifiers_adventurous": "- FOMO spikes 50% faster", "modifiers_lazy": "- Slow motion\n- Burns barely any calories", "modifiers_energetic": "- Zoomies enabled\n- Burns calories like crazy", "modifiers_introvert": "- Needs space\n- Social battery drains fast", "modifiers_greedy": "- Gets super Hangry\n- Food hits different (more dopamine)", "modifiers_stubborn": "- Sushi or starve\n- Insomniac tendencies", # Care Tips "tips_timid": "- Add plants for zen vibes\n- Keep it chill, no loud noises\n- Don't resize the window too fast, it's scary\n- Routine is key", "tips_adventurous": "- Change up the decor often\n- Hide snacks to make them hunt\n- Give them room to roam\n- Don't let them get bored", "tips_lazy": "- Put food right next to their face\n- Keep the place tidy\n- Don't expect them to do cardio\n- Comfy spots are a must", "tips_energetic": "- Needs a big tank for activities\n- Feed often, they burn through it\n- Use interactive toys\n- They will bounce off the walls", "tips_introvert": "- Make hiding spots\n- Don't crowd them with junk\n- Let them vibe alone\n- Plants = privacy", "tips_greedy": "- Sushi is the way to their heart\n- Bribe them with food\n- Watch the weight gain\n- Let them hoard stuff", "tips_stubborn": "- Stock up on sushi\n- Be patient, they're difficult\n- Good luck getting them to sleep\n- Don't force it", # ===== DECISIONS TAB ===== "thought_process": "Squid's Internal Monologue", "step": "Step", "step1_title": "Reading the Room", "step2_title": "Checking the Vibe", "step3_title": "Consulting the Trauma", "step4_title": "Sending It", "final_action": "The Move:", "awaiting_thought": "Head empty, no thoughts...", "awaiting_decision": "Buffering...", "sensing_condition": "Squid is checking the scene:", "visible_objects": "What's visible", "no_sensory_data": "Blind as a bat rn.", "none": "Nada", "no_urges": "Zero motivation.", "strongest_urge": "The biggest mood right now is", "initial_scores": "Base stats:", "personality_memory_adjust": "Personality and PTSD adjusting the score:", "no_adjustments": "Brain is basic, no adjustments.", "final_scores_text": "Final tally. Winner takes all.", "no_final_scores": "No scores, head empty.", "squid_decided": "Squid decided to", "with_confidence": "with a certainty of", "score_increased": "hyped up", "score_decreased": "nerfed", "score_for": "The hype for", "by_amount": "by", # ===== LEARNING TAB (ORIGINAL) ===== "active_learning_pairs": "Active Collabs", "hebbian_cycle": "Hebb Cycle", "hebbian_paused": "ON HOLD", "learning_ready": "Brain is Ready", "learning_ready_desc": "We wiring things together fam.", "log_cleared": "Log Yeeted", "log_cleared_desc": "New connections will drop here.", "hebbian_overview": "The Science Bit", "neurons_fire_together": "Neurons that vibe together, wire together", "hebbian_principle": "Basically how we learn stuff.", "hebbian_explanation": "If two brain cells light up at the same party, they become besties. If they never hang out, they unfollow each other.", "excitatory_connections": "Hype Squad (Excitatory)", "excitatory_desc": "Positive vibes make neurons activate together", "inhibitory_connections": "Haters (Inhibitory)", "inhibitory_desc": "Negative vibes shut that down", "very_strong": "Ride or Die", "strong": "Solid", "moderate": "Casual", "weak": "Meh", "very_weak": "Barely There", "inhibited": "Blocked", # ===== MEMORY TAB ===== "memory": "Core Memories", "memories": "The Vault", "short_term_memory": "Recent Tea", "long_term_memory": "Core Memories", "no_memories": "Clean slate.", "overview": "TL;DR", "memory_stats": "Brain Stats", "categories": "Folders", "time_label": "Timestamp:", "important_label": "Core Memory", "unknown": "???", "category_label": "Tag:", "key_label": "Key:", "access_count": "Views:", "full_content": "Full Deets:", "effects_label": "Impact:", "positive": "W", "negative": "L", "neutral": "Mid", # ===== NETWORK TAB (ORIGINAL) ===== "brain_network": "The Wiring", "neurons": "Brain Cells", "connections": "Links", "activity": "Noise", # ===== STATISTICS WINDOW ===== "status": "Vibe Check", "health": "HP", # ===== NEURON NAMES (NEW) ===== "hunger": "Hangry", "happiness": "Serotonin", "cleanliness": "Clean", "sleepiness": "Tired", "satisfaction": "Satisfied", "curiosity": "Nosy", "anxiety": "Panic", "can_see_food": "Food Spotted", "is_eating": "Munching", "is_sleeping": "Snoozing", "is_sick": "Unwell", "is_fleeing": "Running Away", "is_startled": "Spooked", "pursuing_food": "Hunting", "external_stimulus": "Stimulus", "plant_proximity": "Near Plant", "stress": "Stress", "novelty": "New Stuff", "reward": "Treat", # ===== BRAIN WIDGET LAYERS (NEW) ===== "layer_name": "Layer", "layer_input": "Input", "layer_output": "Output", "layer_hidden": "Hidden", # ===== NEUROGENESIS LOGS (NEW) ===== "log_created": "{time} - spawned a {type} neuron ({name}) cuz {type} hit {value:.2f}", "log_pruned": "{time} - deleted neuron ({name}) cuz {reason}", "log_stress_detail": "Made a hater connection to ANXIETY. Max panic permanently nerfed by 10.", # State Pills "fleeing": "Runing Away!", "startled": "Spooked!", "eating": "Munching", "sleeping": "Zzz", "playing": "Gaming", "hiding": "Lurking", "anxious": "Panicking", "curious": "Investigating", # ===== COMMON ACTIONS ===== "eat": "Consume", "sleep": "Power Nap", "play": "Mess Around", "explore": "Wander", "rest": "Chill", "hide": "Hide", "wander": "Roam", "idle": "AFK", "seek_food": "Find Snacks", "seek_shelter": "Find Safe Spot", # ===== OBJECTS ===== "food": "Snack", "rock": "Rock", "poop": "Trash", "plant": "Fern", "sushi": "Sushi", "decoration": "Decor", # ===== TUTORIAL ===== "tutorial_hatched": "A squid just dropped! You gotta take care of it.", "tutorial_feed": "Feed it when it's hangry (Actions Menu)", "tutorial_clean": "Clean the tank when it's gross", "tutorial_watch": "Watch it to figure out its zodiac sign (personality)", "tutorial_neural": "THE BIG BRAIN", "tutorial_neural_desc": "This is the neural net. It drives behavior based on needs.\nIt learns from your parenting fails.", "tutorial_satisfaction": "Keep dopamine high and panic low.", "tutorial_traits": "Your squid will get weird traits based on how you raise it.", # ===== BRAIN DESIGNER (Templates) ===== "designer_title": "Brain Architect", "required_only": "Bare Minimum", "dosidicus_default": "Factory Settings", "full_sensors": "All The Sensors", "the_insomniac": "Team No Sleep", "the_hyperactive": "Squirrel Mode", "the_hangry": "Perma-Hangry", "the_depressive": "Sad Boi Hour", "the_obsessive": "The Stan", "balanced": "Zen", "minimal": "Potato Mode", "dense": "Big Brain", "chaotic": "Chaos Emerald", "calm": "Lo-fi Beats", # ===== SPLASH SCREEN ===== "squid_hatched": "IT'S ALIVE!", "look_after": "DON'T MESS THIS UP..", # ===== BRAIN TOOL TABS ===== "tab_learning": "Learning", "tab_decisions": "Choices", "tab_personality": "Vibe", "tab_about": "Creds", # ===== NEURON INSPECTOR ===== "inspector_title": "Cell Inspector", "lbl_name": "ID:", "lbl_value": "Current Lvl:", "lbl_position": "Coords:", "lbl_type": "Class:", "grp_neurogenesis": "Origin Story", "lbl_created": "Spawned:", "lbl_trigger": "Trigger:", "lbl_trigger_val": "Trigger Lvl:", "lbl_state": "Linked State:", "col_connected": "Linked To", "col_weight": "Strength", "col_direction": "Flow", "btn_refresh_data": "Refresh", "type_core": "OG", "type_neuro": "Gen Z", "type_system": "System", "direction_incoming": "In", "direction_outgoing": "Out", # ===== TUTORIAL STEPS ===== "next": "Next ->", "tutorial_step1_text": "New squid just dropped!\n• Feed it when hangry\n• Clean the nasty tank\n• Stalk its behavior", "tutorial_step2_text": "This is the brain. Round things are neurons.\nThe network evolves based on vibes.", "tutorial_step3_text": "The squid grows new brain cells when it gets traumatized or super happy.\nEvolution, baby.", "tutorial_step4_text": "Neurons that fire together, wire together. That's how it learns associations.", "tutorial_step5_text": "It decides what to do based on needs and memories.\nEvery decision shapes its future.", "tutorial_step6_text": "Press D for Decor. Drag and drop stuff.\nSee how it reacts. Scroll to resize, DEL to yeet.", "tutorial_step7_text": "Keep dopamine up, panic down.\nGood luck, don't kill it.", # ===== NETWORK & LEARNING TABS (NEW) ===== "stats_neurons": "Cells", "stats_connections": "Links", "stats_health": "Integrity", "emergency_alert": "🚨 RED ALERT: {name}", "global_cooldown": "Cooldown", "style_label": "Aesthetic:", "chk_links": "Show links", "chk_weights": "Show weights", "chk_pruning": "Auto-prune", "tooltip_brain_designer": "Open Designer", "msg_already_open": "Already Open", "msg_designer_running": "Designer is already running fam!", "msg_launch_failed": "Launch Failed", "msg_designer_fail": "Could not start Designer:\n\n{e}", "msg_missing_brain": "No Brain Found", "msg_cannot_open_lab": "Can't open Lab: Brain missing.", "msg_cannot_open_buffer": "Can't open Buffer: Brain missing.", "msg_no_neurogenesis": "Evolution Failed", "msg_neurogenesis_not_init": "Neurogenesis system broke.", "msg_decorations_unavailable": "Shop Closed", "msg_decorations_fail": "Decorations window MIA.", "func_neurons_title": "Working Neurons", "count_label": "Count", "avg_utility_label": "Usefulness", "total_activations_label": "Total Pings", "specialisations_label": "Specs", "buffer_title": "XP Buffer", "buffer_header": "Recent History", "col_type": "Type", "col_pattern": "Pattern", "col_outcome": "Result", "col_time": "Time", "btn_refresh": "F5", "buffer_size": "Buffer size", "top_patterns": "Meta Patterns", "no_patterns": "Nothing yet", # Learning Tab Educational Content "learning_pairs_tab": "Collabs", "mechanics_tab": "How It Works", "hebbian_quote": "\"Neurons that vibe together, wire together\"", "in_practice_title": "IRL", "in_practice_text": "Hebbian learning links states like 'Hangry' and 'Dopamine' when you feed it. It's creating core memories.", "mechanics_title": "The Mechanics", "mechanics_intro": "Connection strength (weight) changes based on activity. Active together = BFFs. Active apart = Unfollowed.", "learning_rule_title": "The Math (Ew)", "where_label": "Key:", "delta_w_desc": "Δw = Change in vibe", "eta_desc": "η = Learning speed", "activation_desc": "x, y = Active or nah (1 or 0)", "example_calc_title": "Example", "scenario_label": "Scenario: 'Hangry' and 'Dopamine' both active", "calc_result": "Connection gets stronger (+0.1).", "over_time_title": "Eventually...", "over_time_text": "Small changes add up. Frequent patterns become habits. This is how it learns!", "str_excitatory": "Super Hype", "weak_excitatory": "Kinda Hype", "weak_inhibitory": "Kinda Hater", "str_inhibitory": "Super Hater", # ===== SQUID & BRAIN STATISTICS ===== "distance_rollover": "🌊 Distance counter looped! {multiplier}x prestige", "time_min": "min", "time_mins": "mins", "time_hr": "hr", "time_hrs": "hrs", "time_fmt_hm": "{hours}h {minutes}m", "stat_squid_age": "Age", "stat_distance": "Steps Taken", "stat_cheese": "Cheese Consumed", "stat_sushi": "Sushi Consumed", "stat_poops": "Messes Made", "stat_max_poops": "Peak Messiness", "stat_startles": "Times Spooked", "stat_ink": "Ink Dumps", "stat_colour_change": "Mood Swings", "stat_rocks": "Rocks Yeeted", "stat_plants": "Plant Interactions", "stat_sleep": "Total Nap Time", "stat_sickness": "Times Sick", "stat_novelty_neurons": "Novelty Cells", "stat_stress_neurons": "Trauma Cells", "stat_reward_neurons": "Treat Cells", "stat_current_neurons": "Total Cells", "reset_stats_title": "Wipe Stats", "reset_stats_msg": "You sure you want to delete all history?", "export_stats_title": "Export Data", "export_file_type": "Text Files (*.txt)", "export_header": "Squid Data Dump", "export_time": "Time", "export_activity_section": "Activity Log", "export_end": "End of File", "export_success_title": "W", "export_success_msg": "Dumped stats to {file_name}", "export_error_title": "L", "export_error_msg": "Export failed: {error}", # ===== ACHIEVEMENTS (NEW) ===== # Categories "cat_feeding": "Noms", "cat_neurogenesis": "Big Brain", "cat_sleep": "Naps", "cat_milestones": "Flexes", "cat_exploration": "Adventure", "cat_cleaning": "Chores", "cat_health": "Wellness", "cat_interaction": "Social", "cat_ink": "Ink", "cat_memory": "Nostalgia", "cat_emotional": "Emo", "cat_secret": "Easter Eggs", "cat_meta": "Meta", # UI Elements "ui_points": "Points", "ui_unlocked": "Unlocked", "ui_achievement_unlocked": "Trophy Unlocked!", "ui_hidden": "Hidden trophy", "ui_all": "All", "ui_points_gained": "pts", # --- Achievements --- # Feeding "ach_first_feeding_name": "First Snack", "ach_first_feeding_desc": "Feed the boi for the first time", "ach_fed_10_times_name": "Snack Regular", "ach_fed_10_times_desc": "Feed 10 times", "ach_fed_50_times_name": "Simp for Squid", "ach_fed_50_times_desc": "Feed 50 times", "ach_fed_100_times_name": "Certified Chef", "ach_fed_100_times_desc": "Feed 100 times", "ach_fed_500_times_name": "Gordon Ramsay", "ach_fed_500_times_desc": "Feed 500 times", # Neurogenesis "ach_first_neuron_name": "Brain Blast", "ach_first_neuron_desc": "Grow your first new neuron", "ach_neurons_10_name": "Double Digits", "ach_neurons_10_desc": "Grow 10 neurons", "ach_neurons_50_name": "Galaxy Brain", "ach_neurons_50_desc": "Grow 50 neurons", "ach_neurons_100_name": "Megamind", "ach_neurons_100_desc": "Grow 100 neurons", "ach_first_neuron_levelup_name": "Level Up", "ach_first_neuron_levelup_desc": "Level up a neuron once", "ach_neuron_max_level_name": "Over 9000", "ach_neuron_max_level_desc": "Max out a neuron", # Sleep "ach_first_sleep_name": "Nap Time", "ach_first_sleep_desc": "Wake up from first nap", "ach_slept_10_times_name": "Sleepyhead", "ach_slept_10_times_desc": "Sleep 10 times", "ach_dream_state_name": "Inception", "ach_dream_state_desc": "Enter REM sleep", # Milestones "ach_age_1_hour_name": "Hour One", "ach_age_1_hour_desc": "Survive 1 hour", "ach_age_10_hours_name": "Growing Up", "ach_age_10_hours_desc": "Survive 10 hours", "ach_age_24_hours_name": "Daily Grind", "ach_age_24_hours_desc": "Survive 24 hours", "ach_age_1_week_name": "Survivor", "ach_age_1_week_desc": "Live for a week", "ach_age_1_month_name": "Boomer", "ach_age_1_month_desc": "Live for a month", "ach_happiness_100_name": "Living Best Life", "ach_happiness_100_desc": "Reach 100% Happiness", "ach_all_stats_high_name": "Thriving", "ach_all_stats_high_desc": "All stats > 80%", # Cleaning "ach_first_clean_name": "Maid Service", "ach_first_clean_desc": "Clean for the first time", "ach_cleaned_25_times_name": "Sparkling", "ach_cleaned_25_times_desc": "Clean 25 times", "ach_germaphobe_name": "Clean Freak", "ach_germaphobe_desc": "Keep clean > 90% for 1 hour", # Health "ach_first_medicine_name": "The Cure", "ach_first_medicine_desc": "Give meds once", "ach_medicine_10_times_name": "Dr. Squid", "ach_medicine_10_times_desc": "Give meds 10 times", "ach_comeback_kid_name": "Clutch", "ach_comeback_kid_desc": "Recover from < 20% health", # Interaction (Rocks) "ach_first_rock_pickup_name": "Cool Rock", "ach_first_rock_pickup_desc": "Pick up a rock", "ach_rocks_picked_10_name": "Geologist", "ach_rocks_picked_10_desc": "Pick up 10 rocks", "ach_rocks_picked_50_name": "Hoarder", "ach_rocks_picked_50_desc": "Pick up 50 rocks", "ach_first_rock_throw_name": "Yeet", "ach_first_rock_throw_desc": "Throw a rock", "ach_rocks_thrown_25_name": "Catapult", "ach_rocks_thrown_25_desc": "Throw 25 rocks", "ach_rocks_thrown_100_name": "Sniper", "ach_rocks_thrown_100_desc": "Throw 100 rocks", # Interaction (Decor) "ach_first_decoration_push_name": "Feng Shui", "ach_first_decoration_push_desc": "Push decor once", "ach_decorations_pushed_10_name": "Mover", "ach_decorations_pushed_10_desc": "Push decor 10 times", "ach_decorations_pushed_50_name": "Pivot!", "ach_decorations_pushed_50_desc": "Push decor 50 times", "ach_first_plant_interact_name": "Plant Parent", "ach_first_plant_interact_desc": "Touch a plant", "ach_plants_interacted_10_name": "Gardener", "ach_plants_interacted_10_desc": "Touch plants 10 times", "ach_plants_interacted_50_name": "Druid", "ach_plants_interacted_50_desc": "Touch plants 50 times", "ach_objects_investigated_25_name": "Inspector", "ach_objects_investigated_25_desc": "Investigate 25 things", "ach_objects_investigated_100_name": "Sherlock", "ach_objects_investigated_100_desc": "Investigate 100 things", # Exploration (Poop) "ach_first_poop_throw_name": "Ew David", "ach_first_poop_throw_desc": "Threw poop. Disgusting.", # Ink "ach_first_ink_cloud_name": "Vape Naysh", "ach_first_ink_cloud_desc": "Ink cloud released", "ach_ink_clouds_20_name": "Octopus Mode", "ach_ink_clouds_20_desc": "Release 20 ink clouds", # Memory "ach_first_memory_name": "First Memory", "ach_first_memory_desc": "Form a memory", "ach_memory_long_term_name": "Core Memory", "ach_memory_long_term_desc": "Create a long-term memory", "ach_memories_50_name": "Elephant", "ach_memories_50_desc": "Store 50 memories", # Emotional "ach_curiosity_100_name": "Curious George", "ach_curiosity_100_desc": "100% Curiosity", "ach_zen_master_name": "Zen Master", "ach_zen_master_desc": "Anxiety < 10% for 30 mins", "ach_first_startle_name": "Jump Scare", "ach_first_startle_desc": "Startle the squid", "ach_nervous_wreck_name": "Panic Attack", "ach_nervous_wreck_desc": "Reach 100% Anxiety", # Secret "ach_night_owl_name": "Night Owl", "ach_night_owl_desc": "Play 12AM - 4AM", "ach_early_bird_name": "Early Bird", "ach_early_bird_desc": "Play 5AM - 7AM", "ach_weekend_warrior_name": "No Life", "ach_weekend_warrior_desc": "Play Sat & Sun", # Meta "ach_brain_surgeon_name": "Brain Surgeon", "ach_brain_surgeon_desc": "Open brain tool", "ach_speed_demon_name": "Speedrun", "ach_speed_demon_desc": "Max speed for 10 mins", "ach_completionist_name": "Completionist", "ach_completionist_desc": "Unlock 30 achievements", # ===== NEURON LABORATORY ===== "lab_title": "🧠 The Lab", "lab_live_refresh": "Live feed", "lab_unlock_editing": "🔓 God Mode", "lab_tab_overview": "📊 Vibes", "lab_tab_inspector": "🔍 Deep Dive", "lab_tab_edit": "🔧 Sandbox", "lab_status_ready": "Ready", "lab_status_locked": "🔒 {name} locked @ {value}", "lab_status_unlocked": "🔓 {name} unlocked", # Overview Tab "lab_ov_counters": "Counters", "lab_ov_newest": "Fresh Spawns", "lab_ov_limits": "Limits & Pruning", "lab_ov_actions": "Quick Actions", "lab_force_hebbian": "Force Hebbian", "lab_pruning_enabled": "Pruning on:", "lab_none_yet": "Empty", "lab_ago": "{seconds}s ago", # Inspector Tab "lab_pick_neuron": "Pick a cell to stalk:", "lab_connections_title": "The Network", "lab_header_partner": "Connected To", "lab_header_weight": "Weight", "lab_header_type": "Type", "lab_header_inf": "Clout", "lab_impact_title": "Simulation", "lab_header_neuron": "Neuron", "lab_header_delta": "Δ Value", "lab_no_connections": "Forever alone (no connections)", "lab_did_you_know": "Fun Fact:", "lab_type_excitatory": "Hype Man", "lab_type_inhibitory": "Hater", # Edit Tab "lab_edit_locked_msg": "⚠️ Read-only. Click 'God Mode' to edit.", "lab_edit_header": "Drag values to mess with its brain", "lab_unlock_title": "Unlock God Mode?", "lab_unlock_msg": "You can now edit brain values directly. Don't break it.", # Badges/Influence "lab_inf_tiny": "smol", "lab_inf_mild": "meh", "lab_inf_mod": "mid", "lab_inf_strong": "CHAD", # Educational Tips "lab_tip_hunger": "Hunger is basic. High hunger = anxiety and zero chill.", "lab_tip_happiness": "Happiness is driven by treats. Kills anxiety.", "lab_tip_anxiety": "Anxiety is the enemy. It stops curiosity.", "lab_tip_curiosity": "Curiosity requires novelty. Encourages exploring.", "lab_tip_core": "OG Neuron - Can't delete this.", "lab_tip_neuro_default": "Generated Neuron - Born from trauma or joy.", "lab_tip_neuro_fmt": "Spawned by {trigger} – Job: {spec}.", # ===== VISION WINDOW ===== "vision_window_title": "Squid POV", "vis_logic_unavailable": "Brain.exe not found.", "vis_nothing_in_view": "Looking at nothing.", "vis_distance": "range", # --- Brain Tooltips --- "tooltip_specialization": "Class", "tooltip_type": "Type", "tooltip_current": "Val", "tooltip_utility": "Utility", "tooltip_activations": "Pings", "tooltip_last_active": "Last Seen", "tooltip_age": "Age", "tooltip_core": "Core", "tooltip_generated": "New", "tooltip_functional": "Func", "tooltip_connections_header": "Links", "tooltip_connections_stats": "{incoming} in, {outgoing} out", "tooltip_top_incoming": "Top Influencers", "tooltip_top_outgoing": "Influencing", "tooltip_hint": "Double-click to stalk • Right-click for menu", # State values "state_on": "ON", "state_off": "OFF", # Time formatting "fmt_s_ago": "{val}s ago", "fmt_m_ago": "{val}m ago", "fmt_h_ago": "{val}h ago", "fmt_s_short": "{val}s", "fmt_m_short": "{val}m", "fmt_h_short": "{val}h", "fmt_d_short": "{val}d", # ===== BRAIN DESIGNER WINDOW UI ===== "designer_window_title": "Brain Architect - Dosidicus-2", "designer_window_title_imported": "Brain Architect [Live Feed]", # Tabs "designer_tab_layers": "Layers", "designer_tab_sensors": "Inputs", "designer_tab_props": "Props", "designer_tab_connections": "Wiring", "designer_tab_outputs": "Outputs", # Toolbar "designer_btn_generate": "🎲 Randomize", "designer_tooltip_generate": "YOLO generate connections", "designer_btn_neuron": "➕ Cell", "designer_tooltip_neuron": "Add neuron (Shift+N)", "designer_btn_fix": "🔧 Auto-Fix", "designer_tooltip_fix": "Fix broken stuff", "designer_btn_validate": "✓ Vibe Check", "designer_tooltip_validate": "Validate Design", "designer_btn_sync": "🔄 Sync Live", "designer_tooltip_sync": "Steal brain from running game", "designer_btn_clear_conn": "🗑 Cut Wires", "designer_tooltip_clear_conn": "Delete all connections", "designer_tooltip_dice": "Roll the dice", # Ticker / Help Bar "designer_help_drag_connect": "💡 Left-Drag to connect", "designer_help_ctrl_move": "Ctrl+Drag to move", "designer_help_pan": "Right-Drag to pan", "designer_help_zoom": "Scroll to zoom", "designer_help_edit_weight": "Dbl-Click connection to edit", "designer_help_select": "Click to select", "designer_help_delete": "Del to yeet", "designer_help_reverse": "Space to reverse", "designer_help_keys_weight": "+/- to adjust weight", "designer_help_page_weight": "PgUp/Dn for big adjustments", "designer_help_add_neuron": "Shift+N new neuron", "designer_help_save": "Ctrl+S save", "designer_help_open": "Ctrl+O open", "designer_help_export": "Ctrl+E export", "designer_help_new": "Ctrl+N new", "designer_help_gen": "Ctrl+G generate", "designer_help_dice": "🎲 Dice for chaos", "designer_help_outputs": "Outputs tab binds behavior", # Menus "designer_menu_file": "File", "designer_menu_edit": "Edit", "designer_menu_templates": "Presets", "designer_menu_generate": "Generate", # Actions "designer_action_new": "New Canvas", "designer_action_save": "Save...", "designer_action_export": "Export to Game...", "designer_action_open": "Open...", "designer_action_gen_sparse": "Generate Network...", "designer_action_autofix": "Fix My Mess", "designer_action_validate": "Validate", "designer_action_clear_conn": "Nuke Connections", "designer_action_clear_outputs": "Nuke Outputs", # Status Bar "designer_status_neurons": "Cells: {count}", "designer_status_connections": "Links: {count}", "designer_status_required": "Valid: {ok}", "designer_status_outputs": "Outs: {count}", "designer_status_selected": "Selected: {source} → {target} (w: {weight:+.3f})", "designer_status_weight_updated": "Updated: {source} → {target} = {weight:+.3f}", "designer_status_deleted": "Deleted: {source} → {target}", "designer_status_cleared_conn": "Nuked {count} links", "designer_status_cleared_out": "Nuked {count} outputs", "designer_status_generated": "Generated {count} links ({style})", "designer_status_random_gen": "🎲 Rolled {count} links ({style})", "designer_status_synced": "✨ Synced: {neurons} cells, {connections} links", "designer_status_imported": "✨ Imported live brain", # Dialogs & Messages "designer_msg_game_not_running_title": "Game Dead", "designer_msg_game_not_running": "Game isn't running.\n\nRestart it to sync.", "designer_msg_sync_confirm_title": "Sync Live Brain", "designer_msg_sync_confirm": "Overwrite everything with the live game brain?", "designer_msg_sync_failed_title": "Sync Failed", "designer_msg_sync_failed": "Import failed.", "designer_msg_live_import_title": "Live Import", "designer_msg_live_import_header": "🧠 Live Brain Imported", "designer_msg_live_import_body": "Showing the live network.\n\n• {neurons} cells\n• {connections} links\n\nChanges here won't affect the live game (sandbox mode).", "designer_msg_clear_conn_title": "Clear Connections", "designer_msg_clear_conn_confirm": "Delete ALL {count} connections?\n\nCells stay.", "designer_msg_clear_out_title": "Clear Outputs", "designer_msg_clear_out_empty": "Nothing to clear.", "designer_msg_clear_out_confirm": "Delete ALL {count} output bindings?", "designer_msg_new_design_title": "New Design", "designer_msg_new_design_confirm": "Start fresh? Unsaved work goes bye-bye.", "designer_msg_autofix_title": "Auto-Fix", "designer_msg_autofix_result": "Fixed {count} things:\n\n{details}", "designer_msg_autofix_none": "Looks clean.", "designer_msg_save_title": "Save", "designer_msg_saved_title": "Saved", "designer_msg_save_success": "Saved to: {msg}", "designer_msg_save_bindings": "\n({count} outputs included)", "designer_msg_error_title": "Error", "designer_msg_save_fail": "Save failed:\n\n{error}", "designer_msg_export_title": "Export", "designer_msg_exported_title": "Exported", "designer_msg_export_success": "Export success", "designer_msg_export_fail": "Export failed:\n\n{error}", "designer_msg_open_title": "Open", "designer_msg_open_fail": "Load failed:\n\n{error}", "designer_msg_load_template_title": "Load Preset", "designer_msg_select_template": "Pick a vibe:", "designer_msg_replace_design": "Overwrite current?", "designer_msg_status_title": "Status", "designer_msg_status_ok": "\n✅ Vibe Check: PASSED", "designer_msg_status_issues": "\n⚠️ ISSUES:\n", "designer_input_weight_title": "Weight", "designer_input_weight_label": "Set weight {source} → {target}:", # ===== DESIGNER PANELS ===== # Properties Panel "designer_prop_no_selection": "Nothing selected", "designer_prop_no_selection_disabled": "Empty", "designer_prop_lbl_name": "Name:", "designer_prop_lbl_type": "Type:", "designer_prop_lbl_x": "X:", "designer_prop_lbl_y": "Y:", "designer_prop_btn_delete": "Delete Cell", # Add Neuron Dialog "designer_add_title": "Add Cell", "designer_add_grp_type": "Cell Type", "designer_add_btn_custom": "✨ Custom / Plugin", "designer_add_btn_sensor": "📡 Input Sensor", "designer_add_tooltip_custom": "Named neuron for plugins", "designer_add_grp_sensor": "Pick Sensor", "designer_add_grp_custom": "Custom Def", "designer_add_info_custom": "Name must match plugin ID to work.
    Ex: 'turbo_mode'.
    ", "designer_add_lbl_id": "ID / Name:", "designer_add_ph_id": "e.g. turbo_mode", "designer_add_btn_create": "Create", "designer_add_all_added": "All inputs added", "designer_add_err_title": "Error", "designer_add_err_exists": "Exists", "designer_add_msg_created": "Created {name}", # Layers Panel "designer_layer_btn_add": "+ Layer", "designer_layer_dlg_title": "New Layer", "designer_layer_dlg_label": "Name:", # Sensors Panel "designer_sensor_header": "Inputs:", "designer_sensor_tooltip_refresh": "Refresh List", "designer_sensor_cat_label": "── {name} ──", # Connections Table "designer_conn_header_source": "Src", "designer_conn_header_target": "Dst", "designer_conn_header_weight": "W", # ===== OUTPUTS PANEL ===== "designer_output_header": "Outputs
    Link brain to actions.", "designer_output_btn_add": "➕ Link", "designer_output_btn_edit": "✏️ Edit", "designer_output_btn_remove": "🗑️ Del", "designer_output_col_neuron": "Cell", "designer_output_col_behavior": "→ Action", "designer_output_col_threshold": "Thresh", "designer_output_col_mode": "Mode", "designer_output_col_enabled": "On", "designer_output_info": "{count} links, {enabled} active", "designer_output_err_missing": "⚠️ Missing cell", "designer_output_dlg_remove_title": "Remove Link", "designer_output_dlg_remove_msg": "Unlink {neuron} → {hook}?", # Output Binding Dialog "designer_binding_title_add": "Add Link", "designer_binding_title_edit": "Edit Link", "designer_binding_grp_neuron": "Source", "designer_binding_lbl_neuron": "Cell:", "designer_binding_lbl_current": "Curr: --", "designer_binding_grp_hook": "Action", "designer_binding_lbl_trigger": "Trigger:", "designer_binding_grp_settings": "Settings", "designer_binding_lbl_thresh": "Threshold:", "designer_binding_lbl_mode": "Mode:", "designer_binding_lbl_cool": "Cooldown:", "designer_binding_chk_enabled": "Active", "designer_binding_err_neuron": "Pick a cell", "designer_binding_err_hook": "Pick an action", "designer_binding_err_duplicate": "Link already exists", # Trigger Modes "designer_mode_rising": "Rising Edge (Goes Up)", "designer_mode_falling": "Falling Edge (Goes Down)", "designer_mode_above": "While Above (Hold)", "designer_mode_below": "While Below (Hold)", "designer_mode_change": "On Change", # ===== DESIGNER SENSOR DISCOVERY ===== "desc_builtin_sensor": "Stock sensor: {name}", "desc_vision_food": "Sees food", "desc_custom_sensor": "Custom: {plugin}", "desc_builtin": "stock", "desc_plugin": "plugin", "desc_other": "misc", "desc_vision": "sight", # ===== DESIGNER TEMPLATES (Extra Keys) ===== "tmpl_core_name": "🟡 Minimum Viable", "tmpl_core_desc": "8 core cells", "tmpl_dosidicus_name": "🟡 Default", "tmpl_dosidicus_desc": "Stock layout", "tmpl_full_sensors_name": "🟡 All Inputs", "tmpl_full_sensors_desc": "Everything plugged in", "tmpl_insomniac_name": "🔴 The Insomniac", "tmpl_insomniac_desc": "Can't sleep won't sleep", "tmpl_hyperactive_name": "🔴 The Zoomer", "tmpl_hyperactive_desc": "Pure noise", "tmpl_hangry_name": "🔴 The Hangry", "tmpl_hangry_desc": "Rage monster", "tmpl_depressive_name": "🔴 The Doomer", "tmpl_depressive_desc": "Sad vibes only", "tmpl_obsessive_name": "🔴 The Stan", "tmpl_obsessive_desc": "Obsessive loop", "layer_sensors": "Sensors", "layer_core": "Core", "layer_input": "Input", "layer_out": "Output", "layer_racing_mind": "Racing Mind", "layer_state": "State", "layer_vision": "Sight", "layer_noise": "Noise", "layer_output": "Out", "layer_gut_brain": "Gut", "layer_gray": "Gray Matter", "layer_loop": "Loop", "layer_stats": "Stats", "layer_emotions": "Feels", # ===== CANVAS CONTEXT MENU / DIALOGS ===== "designer_cnv_del_conn_title": "Delete Link", "designer_cnv_del_conn_msg": "Yeet this connection?\n{source} → {target}", "designer_cnv_chk_dont_ask": "Don't ask again", "designer_cnv_btn_del": "Yeet", "designer_cnv_btn_cancel": "Cancel", "designer_cnv_dlg_edit_title": "Edit Link", "designer_cnv_lbl_conn": "Link: {source} → {target}", "designer_cnv_lbl_weight": "Weight:", "designer_cnv_info_weight": "Green = Hype (Excitatory), Red = Hater (Inhibitory)", "designer_cnv_btn_del_conn": "Delete", "designer_cnv_btn_ok": "Kk", "designer_cnv_tooltip_invalid": "Broken link", } ================================================ FILE: translations/zh.py ================================================ LANGUAGE_HEADER = "zh - 中文" translations = { # Core continuous neurons "hunger": "饥饿感", "happiness": "快乐值", "cleanliness": "清洁度", "sleepiness": "困倦感", "satisfaction": "满足感", "anxiety": "焦虑感", "curiosity": "好奇心", # Binary/sensor neurons "can_see_food": "看见食物", "is_eating": "进食中", "is_sleeping": "睡眠中", "is_sick": "生病中", "pursuing_food": "寻找食物", "is_startled": "受惊", "is_fleeing": "逃跑中", # Base keys for neurogenesis patterns "novelty": "新奇", "stress": "压力", "reward": "奖励", # ===== MAIN MENU ===== "file": "文件", "new_game": "新游戏", "load_game": "读取游戏", "save_game": "保存游戏", "view": "视图", "speed": "速度", "pause": "暂停", "actions": "动作", "debug": "调试", "plugins": "插件", # ===== VIEW MENU ===== "brain_designer": "大脑设计器", "decorations": "装饰品", "statistics": "统计数据", "brain_tool": "大脑工具", "neuron_lab": "神经元实验室", "task_manager": "任务管理器", # ===== SPEED MENU ===== "normal_speed": "正常 (1x)", "fast_speed": "快速 (2x)", "very_fast": "极快 (3x)", # ===== DEBUG MENU ===== "toggle_debug": "切换调试模式", "toggle_cone": "切换视锥显示", "squid_vision": "鱿鱼视觉", # ===== ACTION BUTTONS ===== "feed": "喂食", "clean": "清洁", "medicine": "喂药", "feed_btn": "喂食", "clean_btn": "清洁", "medicine_btn": "药物", # ===== MESSAGES ===== "feed_msg": "鱿鱼需要喂食", "points": "得分", "dirty": "脏乱", "paused_msg": "模拟已暂停", "paused_sub": "使用速度菜单恢复", # ===== DIALOGS ===== "yes": "是", "no": "否", "ok": "确定", "cancel": "取消", "close": "关闭", "save": "保存", "load": "读取", "reset": "重置", "apply_changes": "应用更改", "got_it": "知道了!", "finish": "完成", "confirm_new_game": "开始新游戏?当前进度将会丢失。", "confirm_exit": "确定要退出吗?", "save_successful": "游戏保存成功!", "load_successful": "游戏读取成功!", "error_saving": "保存游戏时出错。", "error_loading": "读取游戏时出错。", "no_save_found": "未找到存档文件。", "startup": "启动", "show_tutorial_q": "显示教程?", "auto_decline": "({seconds}秒后自动拒绝)", "tutorial_title": "教程", "tutorial_query": "你想查看教程吗?", # ===== ABOUT TAB ===== "hello": "你好", "my_name_is": "我的名字是", "change_name": "更改名字", "enter_new_name": "为你的鱿鱼输入新名字:", "change_colour": "更改颜色", "view_certificate": "查看证书", "care_tips": "饲养建议", "care_tips_for": "{personality} 鱿鱼的饲养建议", "dosidicus_title": "电子美洲大赤鱿 (Dosidicus electronicae)", "dosidicus_desc": "一种拥有简单神经网络的电子宠物", "string_acronym": "模拟电子宠物反应与推理及神经发生系统 (STRINg)", "research_project": "这是一个研究项目。请建议新功能。", "version_dosidicus": "Dosidicus 版本:", "version_brain_tool": "大脑工具版本:", "version_decision": "决策引擎版本:", "version_neuro": "神经发生版本:", "created_by": "作者", # ===== PERSONALITY ===== "squid_personality": "鱿鱼个性", "personality_modifier": "个性修正", "description": "描述:", "personality_modifiers": "个性修正值:", "care_tips_label": "饲养建议:", "personality_note": "注:个性是在新游戏开始时随机生成的", # Personality Types "personality_timid": "胆小", "personality_adventurous": "爱冒险", "personality_lazy": "懒惰", "personality_energetic": "精力充沛", "personality_introvert": "内向", "personality_greedy": "贪吃", "personality_stubborn": "固执", # Personality Descriptions "desc_timid": "你的鱿鱼很胆小。它容易受惊和焦虑,尤其是在新环境中。它可能更喜欢安静、平稳的环境,不太愿意独自探索。不过,当它感到安全时,能建立牢固的依赖关系。", "desc_adventurous": "你的鱿鱼很爱冒险。它喜欢探索和尝试新事物。通常是它第一个去调查环境中的新物体或区域。这只鱿鱼喜欢新鲜感,在以此不变的环境中容易感到无聊。", "desc_lazy": "你的鱿鱼很懒惰。它喜欢轻松的生活方式,活动量比其他鱿鱼少。它可能需要额外的鼓励才会参与活动,但只要躺着就会很满足。这只鱿鱼非常擅长保存能量!", "desc_energetic": "你的鱿鱼精力充沛。它总是动个不停,充满活力。这只鱿鱼需要大量的刺激和活动来保持快乐。如果没有足够的机会消耗多余的精力,它可能会变得焦躁不安。", "desc_introvert": "你的鱿鱼是个内向者。它享受孤独,可能更喜欢安静、不拥挤的空间。虽然它可以与其他生物互动,但它需要独处的时间来“充电”。这只鱿鱼在行动上可能更加敏锐和深思熟虑。", "desc_greedy": "你的鱿鱼很贪吃。它非常关注食物和资源。与其他鱿鱼相比,它更容易被零食和奖励所驱动。虽然它可能要求更多,但也往往足智多谋,善于寻找隐藏的食物!", "desc_stubborn": "你的鱿鱼很固执。它意志坚定,有明确的喜好。这只鱿鱼可能更抗拒改变,需要更长的时间来适应新常规。然而,它的决心也使它在解决问题时非常执着。", # Personality Short Modifiers "mod_timid": "变得焦虑的几率更高", "mod_adventurous": "好奇心和探索欲增加", "mod_lazy": "移动较慢,能量消耗较低", "mod_energetic": "移动较快,活动水平较高", "mod_introvert": "更喜欢独处和安静的环境", "mod_greedy": "更专注于食物和资源", "mod_stubborn": "只吃最爱的食物(寿司),可能拒绝睡觉", # Personality Modifier Details "modifiers_timid": "- 焦虑感增加速度快 50%\n- 好奇心增加速度慢 50%\n- 靠近植物时焦虑感减少 50%", "modifiers_adventurous": "- 好奇心增加速度快 50%", "modifiers_lazy": "- 移动速度更慢\n- 能量消耗更低", "modifiers_energetic": "- 移动速度更快\n- 能量消耗更高", "modifiers_introvert": "- 更喜欢安静、不拥挤的空间\n- 可能需要更多独处时间来“充电”", "modifiers_greedy": "- 饥饿时焦虑感增加 50%\n- 进食时满足感增加更多", "modifiers_stubborn": "- 更喜欢最爱的食物(寿司)\n- 即使累了也可能拒绝睡觉", # Care Tips "tips_timid": "- 在环境中放置植物以减少焦虑\n- 保持环境清洁和平静\n- 缓慢接近,避免突然的动作\n- 保持规律的作息\n- 避免频繁调整窗口大小,这可能会吓到它", "tips_adventurous": "- 定期引入新物体或装饰品\n- 提供多样化的食物选择\n- 通过策略性地放置食物鼓励探索\n- 提供充足的探索空间\n- 用有趣的物品满足它的好奇心", "tips_lazy": "- 将食物放在离鱿鱼休息点更近的地方\n- 更频繁地清洁环境\n- 使用诱人的食物鼓励移动\n- 不要期望太多活动——它们喜欢放松\n- 确保它们最喜欢的休息点清洁舒适", "tips_energetic": "- 提供大而开阔的移动空间\n- 提供频繁的喂食机会\n- 引入互动元素或游戏\n- 用各种装饰品保持环境的刺激性\n- 由于能量消耗高,它们需要更多食物", "tips_introvert": "- 用装饰品创造安静、隐蔽的区域\n- 避免环境过度拥挤\n- 尊重鱿鱼独处的需求\n- 利用植物创造庇护空间\n- 温柔接近,必要时给它空间", "tips_greedy": "- 提供多种食物,包括寿司\n- 将食物作为良好行为的奖励\n- 注意不要过度喂食\n- 与其他类型相比,饥饿时会更焦虑\n- 提供收集和排列物品的机会", "tips_stubborn": "- 始终备有寿司,因为这是它们的最爱\n- 引入改变时要有耐心\n- 对期望的行为使用正向强化\n- 饥饿时可能会拒绝非寿司类食物\n- 即使累了也可能抗拒睡眠——需创造平静的环境", # ===== DECISIONS TAB ===== "thought_process": "鱿鱼的思维过程", "step": "步骤", "step1_title": "感知世界", "step2_title": "计算基本冲动", "step3_title": "应用个性与记忆", "step4_title": "做出最终决定", "final_action": "最终动作:", "awaiting_thought": "等待鱿鱼的下一个想法...", "awaiting_decision": "等待决定...", "sensing_condition": "鱿鱼评估当前状况和可见物体:", "visible_objects": "可见物体", "no_sensory_data": "无感官数据可用。", "none": "无", "no_urges": "未计算出冲动。", "strongest_urge": "基于需求,最强烈的冲动是", "initial_scores": "初始得分:", "personality_memory_adjust": "个性特征和近期记忆随后调整这些冲动:", "no_adjustments": "此次没有来自个性或记忆的显著调整。", "final_scores_text": "经过所有计算后,统计最终得分。最高分决定动作。", "no_final_scores": "无最终得分可用。", "squid_decided": "鱿鱼决定", "with_confidence": "置信度为", "score_increased": "增加", "score_decreased": "减少", "score_for": "得分项", "by_amount": "幅度", # ===== LEARNING TAB (ORIGINAL) ===== "active_learning_pairs": "活跃学习对", "hebbian_cycle": "赫布循环 (Hebbian Cycle)", "hebbian_paused": "已暂停", "learning_ready": "学习系统就绪", "learning_ready_desc": "赫布学习将在同时激活的神经元之间建立关联。", "log_cleared": "日志已清除", "log_cleared_desc": "当你的鱿鱼神经元形成新连接时,学习对将显示在这里。", "hebbian_overview": "赫布学习概览", "neurons_fire_together": "一同激发的神经元连在一起 (Neurons that fire together, wire together)", "hebbian_principle": "这一基本原则描述了神经网络如何通过经验进行学习。", "hebbian_explanation": "赫布学习是人工神经网络中使用的一条简单而强大的规则。当两个神经元同时激活时,它们之间的连接(权重)会增强。如果它们分开激活,连接就会减弱。这使得网络能够自然地在相关概念之间形成关联。", "excitatory_connections": "兴奋性连接", "excitatory_desc": "正权重 (0.0-1.0) 使神经元更可能一起激活", "inhibitory_connections": "抑制性连接", "inhibitory_desc": "负权重 (-1.0-0.0) 使神经元不太可能一起激活", "very_strong": "极强", "strong": "强", "moderate": "中等", "weak": "弱", "very_weak": "极弱", "inhibited": "受抑制", # ===== MEMORY TAB ===== "memory": "记忆", "memories": "记忆列表", "short_term_memory": "短期记忆", "long_term_memory": "长期记忆", "no_memories": "尚未存储记忆。", "overview": "概览", "memory_stats": "记忆统计", "categories": "分类", "time_label": "时间:", "important_label": "重要", "unknown": "未知", "category_label": "分类:", "key_label": "键值:", "access_count": "访问次数:", "full_content": "完整内容:", "effects_label": "效果:", "positive": "正面", "negative": "负面", "neutral": "中性", # ===== NETWORK TAB (ORIGINAL) ===== "brain_network": "大脑网络", "neurons": "神经元", "connections": "连接", "activity": "活动", # ===== STATISTICS WINDOW ===== "status": "状态", "health": "健康", # ===== NEURON NAMES (NEW) ===== "hunger": "饥饿感", "happiness": "快乐值", "cleanliness": "清洁度", "sleepiness": "困倦感", "satisfaction": "满足感", "curiosity": "好奇心", "anxiety": "焦虑感", "can_see_food": "看见食物", "is_eating": "进食中", "is_sleeping": "睡眠中", "is_sick": "生病中", "is_fleeing": "逃跑中", "is_startled": "受惊", "pursuing_food": "追逐食物", "external_stimulus": "外部刺激", "plant_proximity": "靠近植物", "stress": "压力", "novelty": "新奇", "reward": "奖励", # ===== BRAIN WIDGET LAYERS (NEW) ===== "layer_name": "层级", "layer_input": "输入层", "layer_output": "输出层", "layer_hidden": "隐藏层", # ===== NEUROGENESIS LOGS (NEW) ===== "log_created": "{time} - 创建了一个 {type} 神经元 ({name}),因为 {type} 计数器达到了 {value:.2f}", "log_pruned": "{time} - 一个神经元 ({name}) 因 {reason} 被修剪", "log_stress_detail": "建立了一个到 焦虑感 的抑制性连接\n最大焦虑值已永久降低 10", # State Pills "fleeing": "逃跑中!", "startled": "受惊!", "eating": "进食", "sleeping": "睡眠", "playing": "玩耍", "hiding": "躲藏", "anxious": "焦虑", "curious": "好奇", # ===== COMMON ACTIONS ===== "eat": "吃", "sleep": "睡", "play": "玩", "explore": "探索", "rest": "休息", "hide": "躲藏", "wander": "闲逛", "idle": "空闲", "seek_food": "寻找食物", "seek_shelter": "寻找庇护", # ===== OBJECTS ===== "food": "食物", "rock": "石头", "poop": "便便", "plant": "植物", "sushi": "寿司", "decoration": "装饰品", # ===== TUTORIAL ===== "tutorial_hatched": "一只鱿鱼孵化了,你必须照顾它!", "tutorial_feed": "当它饿的时候喂它(动作菜单)", "tutorial_clean": "当水箱变脏时进行清洁", "tutorial_watch": "观察它的行为以了解它的个性", "tutorial_neural": "神经网络", "tutorial_neural_desc": "这是鱿鱼的神经网络。它的行为由需求(圆形神经元)驱动。\n网络会随着鱿鱼与环境的互动而适应和学习。", "tutorial_satisfaction": "保持高满足感和低焦虑感。", "tutorial_traits": "你的鱿鱼会根据你的抚养方式发展出独特的特征和行为。", # ===== BRAIN DESIGNER (Templates) ===== "designer_title": "大脑设计器", "required_only": "仅必要项", "dosidicus_default": "Dosidicus 默认", "full_sensors": "全传感器套件", "the_insomniac": "失眠患者", "the_hyperactive": "多动症", "the_hangry": "饿怒症", "the_depressive": "抑郁症", "the_obsessive": "强迫症", "balanced": "平衡型", "minimal": "极简型", "dense": "密集型", "chaotic": "混乱型", "calm": "平静型", # ===== SPLASH SCREEN ===== "squid_hatched": "一只鱿鱼孵化了!", "look_after": "你需要照顾它..", # ===== BRAIN TOOL TABS ===== "tab_learning": "学习", "tab_decisions": "决策", "tab_personality": "个性", "tab_about": "关于", # ===== NEURON INSPECTOR ===== "inspector_title": "神经元检查器", "lbl_name": "名称:", "lbl_value": "当前值:", "lbl_position": "位置:", "lbl_type": "类型:", "grp_neurogenesis": "神经发生详情", "lbl_created": "创建于:", "lbl_trigger": "触发类型:", "lbl_trigger_val": "触发值:", "lbl_state": "关联状态:", "col_connected": "连接到", "col_weight": "权重", "col_direction": "方向", "btn_refresh_data": "刷新数据", "type_core": "核心", "type_neuro": "神经发生", "type_system": "系统状态", "direction_incoming": "传入", "direction_outgoing": "传出", # ===== TUTORIAL STEPS ===== "next": "下一步", "tutorial_step1_text": "一只鱿鱼孵化了,你必须照顾它!\n• 当它饿的时候喂它(动作菜单)\n• 当水箱变脏时进行清洁\n• 观察它的行为以了解它的个性", "tutorial_step2_text": "这是鱿鱼的神经网络。它的行为由需求(神经元)驱动。\n网络会随着鱿鱼与环境的互动而适应和学习。", "tutorial_step3_text": "鱿鱼可以通过产生新的神经元来响应极端的环境刺激。\n这些新神经元帮助鱿鱼适应具有挑战性的情况。", "tutorial_step4_text": "当一对神经元同时激发时,它们的连接会增强。这使得鱿鱼能够学习不同刺激和反应之间的关联。", "tutorial_step5_text": "神经网络根据当前需求和过去的记忆做出决定。\n每一个决定都会影响鱿鱼的状态并塑造未来的行为。", "tutorial_step6_text": "随时按 D 键打开装饰品窗口\n将装饰品拖放到环境中,看看鱿鱼对不同事物的反应。每种装饰类型都会以独特的方式影响鱿鱼的心理状态。点击并使用鼠标滚轮调整大小/按 DEL 删除", "tutorial_step7_text": "保持高满足感和低焦虑感。\n你的鱿鱼会根据你的抚养方式发展出独特的特征和行为。", # ===== NETWORK & LEARNING TABS (NEW) ===== "stats_neurons": "神经元", "stats_connections": "连接数", "stats_health": "网络健康", "emergency_alert": "🚨 紧急情况:{name}", "global_cooldown": "冷却时间", "style_label": "风格:", "chk_links": "显示连线", "chk_weights": "显示权重", "chk_pruning": "启用修剪", "tooltip_brain_designer": "显示大脑设计器", "msg_already_open": "已打开", "msg_designer_running": "大脑设计器已在运行!", "msg_launch_failed": "启动失败", "msg_designer_fail": "无法启动大脑设计器:\n\n{e}", "msg_missing_brain": "缺少大脑", "msg_cannot_open_lab": "无法打开神经元实验室:大脑组件不可用。", "msg_cannot_open_buffer": "无法打开经验缓冲区:大脑组件不可用。", "msg_no_neurogenesis": "无神经发生", "msg_neurogenesis_not_init": "无法打开经验缓冲区:神经发生系统未初始化。", "msg_decorations_unavailable": "装饰品不可用", "msg_decorations_fail": "无法打开装饰品:窗口不可用。", "func_neurons_title": "功能性神经元", "count_label": "计数", "avg_utility_label": "平均效用", "total_activations_label": "总激活数", "specialisations_label": "专精", "buffer_title": "神经发生经验缓冲区", "buffer_header": "近期经历", "col_type": "类型", "col_pattern": "模式", "col_outcome": "结果", "col_time": "时间", "btn_refresh": "刷新", "buffer_size": "缓冲区大小", "top_patterns": "热门模式", "no_patterns": "尚无模式", # Learning Tab Educational Content "learning_pairs_tab": "学习对", "mechanics_tab": "机制", "hebbian_quote": "“一同激发的神经元连在一起”", "in_practice_title": "实践中", "in_practice_text": "在你的鱿鱼大脑中,赫布学习有助于关联相关状态,例如进食时的“饥饿”与“满足”,或探索时的“好奇”与“焦虑”。这些习得的关联会影响未来的行为。", "mechanics_title": "学习机制", "mechanics_intro": "赫布学习根据神经元的活动模式更新它们之间的连接强度(权重)。当神经元一起激活时,它们的连接增强;当它们分开激活时,连接减弱。", "learning_rule_title": "学习规则", "where_label": "其中:", "delta_w_desc": "Δw = 两个神经元之间权重的变化", "eta_desc": "η (eta) = 学习率(控制变化速度)", "activation_desc": "x, y = 神经元的激活值(1 为激活,0 为未激活)", "example_calc_title": "计算示例", "scenario_label": "场景: “饥饿”和“满足”同时激活", "calc_result": "权重增加 0.1,增强了这些神经元之间的连接。", "over_time_title": "随着时间推移", "over_time_text": "通过重复激活,这些微小的权重变化会累积。频繁共同出现的模式会发展出强连接,而罕见出现的模式则形成弱连接或负连接。这就是你的鱿鱼从经验中学习的方式!", "str_excitatory": "强兴奋性", "weak_excitatory": "弱兴奋性", "weak_inhibitory": "弱抑制性", "str_inhibitory": "强抑制性", # ===== SQUID & BRAIN STATISTICS ===== "distance_rollover": "🌊 距离计数器翻转!现在是 {multiplier} 倍", "time_min": "分", "time_mins": "分", "time_hr": "小时", "time_hrs": "小时", "time_fmt_hm": "{hours}小时 {minutes}分", "stat_squid_age": "鱿鱼年龄", "stat_distance": "游动距离 (像素)", "stat_cheese": "吃掉的奶酪", "stat_sushi": "吃掉的寿司", "stat_poops": "产生的便便", "stat_max_poops": "水箱中最大便便数", "stat_startles": "受惊次数", "stat_ink": "喷墨次数", "stat_colour_change": "变色次数", "stat_rocks": "扔出的石头", "stat_plants": "植物互动", "stat_sleep": "总睡眠时间 (秒)", "stat_sickness": "生病次数", "stat_novelty_neurons": "创建的新奇神经元", "stat_stress_neurons": "创建的压力神经元", "stat_reward_neurons": "创建的奖励神经元", "stat_current_neurons": "当前神经元", "reset_stats_title": "重置统计", "reset_stats_msg": "确定要重置所有统计数据吗?", "export_stats_title": "导出统计", "export_file_type": "文本文件 (*.txt)", "export_header": "鱿鱼统计导出", "export_time": "导出时间", "export_activity_section": "活动统计", "export_end": "统计结束", "export_success_title": "导出成功", "export_success_msg": "统计数据已导出至 {file_name}", "export_error_title": "导出错误", "export_error_msg": "导出统计数据时出错:{error}", # ===== ACHIEVEMENTS (NEW) ===== # Categories "cat_feeding": "喂食", "cat_neurogenesis": "神经发生", "cat_sleep": "睡眠", "cat_milestones": "里程碑", "cat_exploration": "探索", "cat_cleaning": "清洁", "cat_health": "健康", "cat_interaction": "互动", "cat_ink": "墨汁", "cat_memory": "记忆", "cat_emotional": "情绪", "cat_secret": "秘密", "cat_meta": "元成就", # UI Elements "ui_points": "点数", "ui_unlocked": "已解锁", "ui_achievement_unlocked": "成就解锁!", "ui_hidden": "隐藏成就", "ui_all": "全部", "ui_points_gained": "点数", # --- Achievements --- # Feeding "ach_first_feeding_name": "第一口", "ach_first_feeding_desc": "第一次喂食鱿鱼", "ach_fed_10_times_name": "按时吃饭", "ach_fed_10_times_desc": "喂食鱿鱼 10 次", "ach_fed_50_times_name": "尽职饲养员", "ach_fed_50_times_desc": "喂食鱿鱼 50 次", "ach_fed_100_times_name": "特级大厨", "ach_fed_100_times_desc": "喂食鱿鱼 100 次", "ach_fed_500_times_name": "烹饪传奇", "ach_fed_500_times_desc": "喂食鱿鱼 500 次", # Neurogenesis "ach_first_neuron_name": "大脑火花", "ach_first_neuron_desc": "创建第一个神经发生神经元", "ach_neurons_10_name": "神经网络", "ach_neurons_10_desc": "通过神经发生创建 10 个神经元", "ach_neurons_50_name": "心智扩张", "ach_neurons_50_desc": "通过神经发生创建 50 个神经元", "ach_neurons_100_name": "脑力发电站", "ach_neurons_100_desc": "通过神经发生创建 100 个神经元", "ach_first_neuron_levelup_name": "突触强化", "ach_first_neuron_levelup_desc": "首次升级一个神经元", "ach_neuron_max_level_name": "巅峰表现", "ach_neuron_max_level_desc": "将一个神经元升级到最大强度", # Sleep "ach_first_sleep_name": "美梦", "ach_first_sleep_desc": "鱿鱼从第一次睡眠中醒来", "ach_slept_10_times_name": "休息充足", "ach_slept_10_times_desc": "鱿鱼睡了 10 次", "ach_dream_state_name": "深度梦想家", "ach_dream_state_desc": "鱿鱼进入了快速眼动(REM)睡眠", # Milestones "ach_age_1_hour_name": "一小时大", "ach_age_1_hour_desc": "鱿鱼活到了 1 小时大", "ach_age_10_hours_name": "茁壮成长", "ach_age_10_hours_desc": "鱿鱼活到了 10 小时大", "ach_age_24_hours_name": "一日奇迹", "ach_age_24_hours_desc": "鱿鱼存活了 24 小时", "ach_age_1_week_name": "每周老兵", "ach_age_1_week_desc": "鱿鱼存活了一周", "ach_age_1_month_name": "每月老兵", "ach_age_1_month_desc": "鱿鱼存活了一个月", "ach_happiness_100_name": "纯粹的极乐", "ach_happiness_100_desc": "快乐值达到 100%", "ach_all_stats_high_name": "完美平衡", "ach_all_stats_high_desc": "所有统计数据同时保持在 80% 以上", # Cleaning "ach_first_clean_name": "第一次擦洗", "ach_first_clean_desc": "第一次清洁水箱", "ach_cleaned_25_times_name": "一尘不染", "ach_cleaned_25_times_desc": "清洁水箱 25 次", "ach_germaphobe_name": "洁癖", "ach_germaphobe_desc": "保持清洁度在 90% 以上持续 1 小时", # Health "ach_first_medicine_name": "急救", "ach_first_medicine_desc": "第一次喂药", "ach_medicine_10_times_name": "鱿鱼医生", "ach_medicine_10_times_desc": "喂药 10 次", "ach_comeback_kid_name": "东山再起", "ach_comeback_kid_desc": "从极低健康值 (<20%) 恢复到满血", # Interaction (Rocks) "ach_first_rock_pickup_name": "岩石收藏家", "ach_first_rock_pickup_desc": "第一次捡起石头", "ach_rocks_picked_10_name": "石头采集者", "ach_rocks_picked_10_desc": "捡起 10 块石头", "ach_rocks_picked_50_name": "巨石囤积者", "ach_rocks_picked_50_desc": "捡起 50 块石头", "ach_first_rock_throw_name": "打水漂", "ach_first_rock_throw_desc": "第一次扔石头", "ach_rocks_thrown_25_name": "岩石发射器", "ach_rocks_thrown_25_desc": "扔出 25 块石头", "ach_rocks_thrown_100_name": "投石机大师", "ach_rocks_thrown_100_desc": "扔出 100 块石头", # Interaction (Decor) "ach_first_decoration_push_name": "室内设计师", "ach_first_decoration_push_desc": "第一次推动装饰品", "ach_decorations_pushed_10_name": "搬家工人", "ach_decorations_pushed_10_desc": "推动装饰品 10 次", "ach_decorations_pushed_50_name": "风水大师", "ach_decorations_pushed_50_desc": "推动装饰品 50 次", "ach_first_plant_interact_name": "园艺新手", "ach_first_plant_interact_desc": "第一次与植物互动", "ach_plants_interacted_10_name": "花园探险家", "ach_plants_interacted_10_desc": "与植物互动 10 次", "ach_plants_interacted_50_name": "植物学家", "ach_plants_interacted_50_desc": "与植物互动 50 次", "ach_objects_investigated_25_name": "好奇的检查员", "ach_objects_investigated_25_desc": "调查 25 种不同的物体", "ach_objects_investigated_100_name": "大侦探", "ach_objects_investigated_100_desc": "调查 100 种不同的物体", # Exploration (Poop) "ach_first_poop_throw_name": "捣蛋鬼", "ach_first_poop_throw_desc": "鱿鱼第一次扔便便", # Ink "ach_first_ink_cloud_name": "烟幕弹", "ach_first_ink_cloud_desc": "鱿鱼第一次释放墨汁云", "ach_ink_clouds_20_name": "墨汁大师", "ach_ink_clouds_20_desc": "释放 20 次墨汁云", # Memory "ach_first_memory_name": "第一份记忆", "ach_first_memory_desc": "形成第一份记忆", "ach_memory_long_term_name": "长远思考", "ach_memory_long_term_desc": "将一份记忆提升到长期存储", "ach_memories_50_name": "过目不忘", "ach_memories_50_desc": "存储 50 份记忆", # Emotional "ach_curiosity_100_name": "好奇宝宝", "ach_curiosity_100_desc": "好奇心达到 100%", "ach_zen_master_name": "禅宗大师", "ach_zen_master_desc": "保持焦虑感低于 10% 持续 30 分钟", "ach_first_startle_name": "吓一跳!", "ach_first_startle_desc": "第一次吓到鱿鱼", "ach_nervous_wreck_name": "神经衰弱", "ach_nervous_wreck_desc": "焦虑感达到 100%", # Secret "ach_night_owl_name": "夜猫子", "ach_night_owl_desc": "在午夜到凌晨 4 点之间游玩", "ach_early_bird_name": "早起的鸟儿", "ach_early_bird_desc": "在凌晨 5 点到 7 点之间游玩", "ach_weekend_warrior_name": "周末战士", "ach_weekend_warrior_desc": "在周六和周日都进行了游玩", # Meta "ach_brain_surgeon_name": "脑外科医生", "ach_brain_surgeon_desc": "打开大脑可视化工具", "ach_speed_demon_name": "速度恶魔", "ach_speed_demon_desc": "以最快速度运行模拟 10 分钟", "ach_completionist_name": "完美主义者", "ach_completionist_desc": "解锁其他 30 个成就", # Additional Log/Debug Messages "Hebbian learning chosen pairs:": "赫布学习选择的对:", "Main thread: Created neuron": "主线程:已创建神经元", "BrainWidget received external BrainWorker": "BrainWidget 接收到外部 BrainWorker", "Neurogenesis monitoring timer started": "神经发生监控计时器已启动", "Brain state export enabled for designer sync": "大脑状态导出已启用以进行设计器同步", "Animation palette built for style:": "动画调色板已构建,样式:", "Animation style changed:": "动画样式已更改:", "Unknown animation style:": "未知动画样式:", "Available:": "可用:", # ===== NEURON LABORATORY ===== "lab_title": "🧠 神经元实验室", "lab_live_refresh": "实时刷新", "lab_unlock_editing": "🔓 解锁编辑", "lab_tab_overview": "📊 实时概览", "lab_tab_inspector": "🔍 深度检查器", "lab_tab_edit": "🔧 编辑沙箱", "lab_status_ready": "就绪", "lab_status_locked": "🔒 {name} 锁定于 {value}", "lab_status_unlocked": "🔓 {name} 已解锁", # Overview Tab "lab_ov_counters": "计数器进度", "lab_ov_newest": "最新神经发生神经元", "lab_ov_limits": "限制与修剪", "lab_ov_actions": "快速操作", "lab_force_hebbian": "强制赫布循环", "lab_pruning_enabled": "启用修剪:", "lab_none_yet": "暂无", "lab_ago": "{seconds}秒前", # Inspector Tab "lab_pick_neuron": "选择要检查的神经元:", "lab_connections_title": "连接(兴奋性 vs 抑制性)", "lab_header_partner": "伙伴", "lab_header_weight": "权重", "lab_header_type": "类型", "lab_header_inf": "影响力", "lab_impact_title": "功能影响模拟", "lab_header_neuron": "神经元", "lab_header_delta": "Δ 值", "lab_no_connections": "目前没有活跃连接", "lab_did_you_know": "你知道吗?", "lab_type_excitatory": "兴奋性", "lab_type_inhibitory": "抑制性", # Edit Tab "lab_edit_locked_msg": "⚠️ 编辑已锁定 – 请勾选工具栏中的“解锁编辑”。", "lab_edit_header": "神经元值(拖动以更改)– 点击 🔒 锁定", "lab_unlock_title": "解锁编辑?", "lab_unlock_msg": "你现在可以更改神经元值并强制创建事件。请谨慎操作!", # Badges/Influence "lab_inf_tiny": "微小", "lab_inf_mild": "轻微", "lab_inf_mod": "中等", "lab_inf_strong": "强劲", # Educational Tips "lab_tip_hunger": "饥饿是一种稳态驱动力。高饥饿感会抑制满足感并加剧焦虑。", "lab_tip_happiness": "快乐感由奖励神经元强化。它抑制焦虑并促进好奇心。", "lab_tip_anxiety": "焦虑感通过压力神经元(抑制性)减少。高焦虑会抑制好奇心。", "lab_tip_curiosity": "当新奇度高时,好奇心会激增。它鼓励探索并减少焦虑。", "lab_tip_core": "核心神经元 – 对生存至关重要。", "lab_tip_neuro_default": "神经发生神经元 – 目的由出生环境推断。", "lab_tip_neuro_fmt": "由 {trigger} 创建 – 专精于 {spec}。它的工作是将经历转化为长期行为。", # ===== VISION WINDOW ===== "vision_window_title": "鱿鱼视觉", "vis_logic_unavailable": "鱿鱼逻辑不可用。", "vis_nothing_in_view": "视野中目前没有任何东西。", "vis_distance": "距离", # --- Brain Tooltips --- "tooltip_specialization": "专精", "tooltip_type": "类型", "tooltip_current": "当前", "tooltip_utility": "效用", "tooltip_activations": "激活次数", "tooltip_last_active": "上次活跃", "tooltip_age": "年龄", "tooltip_core": "核心", "tooltip_generated": "已生成", "tooltip_functional": "功能性", "tooltip_connections_header": "连接", "tooltip_connections_stats": "{incoming} 传入, {outgoing} 传出", "tooltip_top_incoming": "热门传入", "tooltip_top_outgoing": "热门传出", "tooltip_hint": "双击检查 • 右键选项", # State values "state_on": "开启", "state_off": "关闭", # Time formatting "fmt_s_ago": "{val}秒前", "fmt_m_ago": "{val}分前", "fmt_h_ago": "{val}小时前", "fmt_s_short": "{val}秒", "fmt_m_short": "{val}分", "fmt_h_short": "{val}小时", "fmt_d_short": "{val}天", # ===== BRAIN DESIGNER WINDOW UI ===== "designer_window_title": "大脑设计器 - Dosidicus-2", "designer_window_title_imported": "大脑设计器 - Dosidicus-2 [从游戏导入]", # Tabs "designer_tab_layers": "层级", "designer_tab_sensors": "传感器", "designer_tab_props": "属性", "designer_tab_connections": "连接", "designer_tab_outputs": "输出", # Toolbar "designer_btn_generate": "🎲 生成稀疏网络", "designer_tooltip_generate": "在核心神经元之间生成随机连接", "designer_btn_neuron": "➕ 神经元", "designer_tooltip_neuron": "添加新神经元 (Shift+N)", "designer_btn_fix": "🔧 自动修复", "designer_tooltip_fix": "自动修复孤立神经元和连接问题", "designer_btn_validate": "✓ 验证", "designer_tooltip_validate": "检查设计是否存在问题", "designer_btn_sync": "🔄 从游戏同步", "designer_tooltip_sync": "从运行中的 Dosidicus 游戏刷新大脑状态", "designer_btn_clear_conn": "🗑 清除连接", "designer_tooltip_clear_conn": "移除所有连接(保留神经元)", "designer_tooltip_dice": "立即生成随机网络(无对话框)", # Ticker / Help Bar "designer_help_drag_connect": "💡 左键拖动 从神经元创建连接", "designer_help_ctrl_move": "Ctrl+拖动 移动神经元", "designer_help_pan": "右键拖动 平移画布", "designer_help_zoom": "鼠标滚轮 缩放(或调整连接权重)", "designer_help_edit_weight": "双击 连接以编辑权重", "designer_help_select": "点击 神经元/连接以选择", "designer_help_delete": "Del 删除选中项", "designer_help_reverse": "空格键 反转连接方向", "designer_help_keys_weight": "+/- 键调整权重(Shift 加大幅度)", "designer_help_page_weight": "Page Up/Down 调整权重(大幅度)", "designer_help_add_neuron": "Shift+N 添加神经元", "designer_help_save": "Ctrl+S 保存", "designer_help_open": "Ctrl+O 打开", "designer_help_export": "Ctrl+E 导出", "designer_help_new": "Ctrl+N 新建设计", "designer_help_gen": "Ctrl+G 生成网络", "designer_help_dice": "🎲 骰子按钮 立即随机生成", "designer_help_outputs": "输出标签页 将神经元绑定到鱿鱼行为", # Menus "designer_menu_file": "文件", "designer_menu_edit": "编辑", "designer_menu_templates": "模板", "designer_menu_generate": "生成", # Actions "designer_action_new": "新建设计", "designer_action_save": "保存...", "designer_action_export": "导出为 Dosidicus...", "designer_action_open": "打开...", "designer_action_gen_sparse": "生成稀疏网络...", "designer_action_autofix": "自动修复连接", "designer_action_validate": "验证设计", "designer_action_clear_conn": "清除所有连接", "designer_action_clear_outputs": "清除所有输出绑定", # Status Bar "designer_status_neurons": "神经元:{count}", "designer_status_connections": "连接:{count}", "designer_status_required": "必需项:{ok}", "designer_status_outputs": "输出:{count}", "designer_status_selected": "已选:{source} → {target} (权重:{weight:+.3f})", "designer_status_weight_updated": "权重已更新:{source} → {target} = {weight:+.3f}", "designer_status_deleted": "已删除连接:{source} → {target}", "designer_status_cleared_conn": "已清除 {count} 个连接", "designer_status_cleared_out": "已清除 {count} 个输出绑定", "designer_status_generated": "使用 '{style}' 预设生成了 {count} 个连接", "designer_status_random_gen": "🎲 生成了 {count} 个随机连接 (风格:{style})", "designer_status_synced": "✨ 已同步:{neurons} 个神经元,{connections} 个连接", "designer_status_imported": "✨ 已从运行中的游戏导入活跃大脑", # Dialogs & Messages "designer_msg_game_not_running_title": "游戏未运行", "designer_msg_game_not_running": "Dosidicus 游戏不再运行。\n\n请重新开始游戏以进行同步。", "designer_msg_sync_confirm_title": "从游戏同步", "designer_msg_sync_confirm": "用游戏中最新的大脑状态替换当前设计?", "designer_msg_sync_failed_title": "同步失败", "designer_msg_sync_failed": "无法从游戏导入大脑状态。", "designer_msg_live_import_title": "实时大脑导入", "designer_msg_live_import_header": "🧠 已从运行中的游戏导入活跃大脑", "designer_msg_live_import_body": "设计器现在显示的是你运行中的 Dosidicus 游戏的精确神经网络。\n\n• {neurons} 个神经元\n• {connections} 个连接\n\n在此处所做的更改**不会**影响运行中的游戏。", "designer_msg_clear_conn_title": "清除连接", "designer_msg_clear_conn_confirm": "移除所有 {count} 个连接?\n\n神经元将被保留。", "designer_msg_clear_out_title": "清除输出绑定", "designer_msg_clear_out_empty": "没有可清除的输出绑定。", "designer_msg_clear_out_confirm": "移除所有 {count} 个输出绑定?", "designer_msg_new_design_title": "新建设计", "designer_msg_new_design_confirm": "开始新设计?未保存的更改将会丢失。", "designer_msg_autofix_title": "自动修复", "designer_msg_autofix_result": "创建了 {count} 个连接:\n\n{details}", "designer_msg_autofix_none": "未发现问题。", "designer_msg_save_title": "保存设计", "designer_msg_saved_title": "已保存", "designer_msg_save_success": "设计保存成功:{msg}", "designer_msg_save_bindings": "\n(包含 {count} 个输出绑定)", "designer_msg_error_title": "错误", "designer_msg_save_fail": "保存设计失败:\n\n{error}", "designer_msg_export_title": "导出", "designer_msg_exported_title": "已导出", "designer_msg_export_success": "设计导出成功", "designer_msg_export_fail": "导出设计失败:\n\n{error}", "designer_msg_open_title": "打开设计", "designer_msg_open_fail": "无法加载设计:\n\n{error}", "designer_msg_load_template_title": "加载模板", "designer_msg_select_template": "选择一个模板:", "designer_msg_replace_design": "替换当前设计?", "designer_msg_status_title": "设计状态", "designer_msg_status_ok": "\n✅ 状态:正常", "designer_msg_status_issues": "\n⚠️ 问题:\n", "designer_input_weight_title": "连接权重", "designer_input_weight_label": "设置权重 {source} → {target}:", # ===== DESIGNER PANELS ===== # Properties Panel "designer_prop_no_selection": "未选择神经元", "designer_prop_no_selection_disabled": "无选择", "designer_prop_lbl_name": "名称:", "designer_prop_lbl_type": "类型:", "designer_prop_lbl_x": "X:", "designer_prop_lbl_y": "Y:", "designer_prop_btn_delete": "删除神经元", # Add Neuron Dialog "designer_add_title": "添加神经元", "designer_add_grp_type": "选择神经元类型", "designer_add_btn_custom": "✨ 自定义 / 插件神经元", "designer_add_btn_sensor": "📡 输入传感器", "designer_add_tooltip_custom": "创建一个具有特定名称的神经元以链接游戏插件", "designer_add_grp_sensor": "选择传感器", "designer_add_grp_custom": "定义自定义神经元", "designer_add_info_custom": "为了影响鱿鱼,名称必须与插件 ID 匹配。
    例如:命名为 'jet_boost' 以激活喷气背包插件。
    ", "designer_add_lbl_id": "插件 ID / 名称:", "designer_add_ph_id": "例如 turbo_mode", "designer_add_btn_create": "创建链接", "designer_add_all_added": "已添加所有传感器", "designer_add_err_title": "错误", "designer_add_err_exists": "已存在", "designer_add_msg_created": "已创建 {name}", # Layers Panel "designer_layer_btn_add": "添加层级", "designer_layer_dlg_title": "新层级", "designer_layer_dlg_label": "名称:", # Sensors Panel "designer_sensor_header": "输入传感器:", "designer_sensor_tooltip_refresh": "刷新传感器列表(包括插件注册的传感器)", "designer_sensor_cat_label": "── {name} ──", # Connections Table "designer_conn_header_source": "源", "designer_conn_header_target": "目标", "designer_conn_header_weight": "权重", # ===== OUTPUTS PANEL ===== "designer_output_header": "输出绑定
    将神经元连接到鱿鱼行为。当神经元的激活超过阈值时,它会触发绑定的动作。", "designer_output_btn_add": "➕ 添加绑定", "designer_output_btn_edit": "✏️ 编辑", "designer_output_btn_remove": "🗑️ 移除", "designer_output_col_neuron": "神经元", "designer_output_col_behavior": "→ 行为", "designer_output_col_threshold": "阈值", "designer_output_col_mode": "模式", "designer_output_col_enabled": "启用", "designer_output_info": "{count} 个绑定, {enabled} 个已启用", "designer_output_err_missing": "⚠️ 设计中未找到神经元", "designer_output_dlg_remove_title": "移除绑定", "designer_output_dlg_remove_msg": "移除绑定:{neuron} → {hook}?", # Output Binding Dialog "designer_binding_title_add": "添加输出绑定", "designer_binding_title_edit": "配置输出绑定", "designer_binding_grp_neuron": "源神经元", "designer_binding_lbl_neuron": "神经元:", "designer_binding_lbl_current": "当前:--", "designer_binding_grp_hook": "输出行为", "designer_binding_lbl_trigger": "触发器:", "designer_binding_grp_settings": "触发设置", "designer_binding_lbl_thresh": "阈值:", "designer_binding_lbl_mode": "模式:", "designer_binding_lbl_cool": "冷却:", "designer_binding_chk_enabled": "启用", "designer_binding_err_neuron": "请选择一个神经元", "designer_binding_err_hook": "请选择一个输出行为", "designer_binding_err_duplicate": "{neuron} → {hook} 的绑定已存在", # Trigger Modes "designer_mode_rising": "上升沿 (穿过阈值向上)", "designer_mode_falling": "下降沿 (穿过阈值向下)", "designer_mode_above": "高于时 (持续 > 阈值)", "designer_mode_below": "低于时 (持续 < 阈值)", "designer_mode_change": "改变时 (任何显著变化)", # ===== DESIGNER SENSOR DISCOVERY ===== "desc_builtin_sensor": "内置传感器:{name}", "desc_vision_food": "检测视锥内的食物", "desc_custom_sensor": "来自 {plugin} 的自定义传感器", "desc_builtin": "内置", "desc_plugin": "插件", "desc_other": "其他", "desc_vision": "视觉", # ===== DESIGNER TEMPLATES (Extra Keys) ===== "tmpl_core_name": "🟡 仅必要项", "tmpl_core_desc": "8 个必需神经元", "tmpl_dosidicus_name": "🟡 Dosidicus 默认", "tmpl_dosidicus_desc": "标准布局", "tmpl_full_sensors_name": "🟡 全传感器套件", "tmpl_full_sensors_desc": "所有传感器", "tmpl_insomniac_name": "🔴 失眠患者", "tmpl_insomniac_desc": "焦虑和好奇心阻止睡眠", "tmpl_hyperactive_name": "🔴 多动症", "tmpl_hyperactive_desc": "噪音神经元压倒困倦", "tmpl_hangry_name": "🔴 饿怒症", "tmpl_hangry_desc": "饥饿导致极端愤怒", "tmpl_depressive_name": "🔴 抑郁症", "tmpl_depressive_desc": "对快乐有抵抗力", "tmpl_obsessive_name": "🔴 强迫症", "tmpl_obsessive_desc": "焦虑/好奇心反馈循环", "layer_sensors": "传感器", "layer_core": "核心", "layer_input": "输入", "layer_out": "输出", "layer_racing_mind": "思维奔逸", "layer_state": "状态", "layer_vision": "视觉", "layer_noise": "噪音", "layer_output": "输出", "layer_gut_brain": "肠脑", "layer_gray": "灰质", "layer_loop": "循环", "layer_stats": "统计", "layer_emotions": "情绪", # ===== CANVAS CONTEXT MENU / DIALOGS ===== "designer_cnv_del_conn_title": "删除连接", "designer_cnv_del_conn_msg": "确定要删除连接:\n{source} → {target} 吗?", "designer_cnv_chk_dont_ask": "不再询问", "designer_cnv_btn_del": "是,删除", "designer_cnv_btn_cancel": "取消", "designer_cnv_dlg_edit_title": "编辑连接", "designer_cnv_lbl_conn": "连接:{source} → {target}", "designer_cnv_lbl_weight": "权重:", "designer_cnv_info_weight": "正数 = 兴奋性 (绿色),负数 = 抑制性 (红色)", "designer_cnv_btn_del_conn": "删除连接", "designer_cnv_btn_ok": "确定", "designer_cnv_tooltip_invalid": "无效连接", } ================================================ FILE: version ================================================ dosidicus: 2.6.2.0_STRINg2 brain_tool: 23.02.26 decision_engine: 4.0 Dec25 neurogenesis: 3.3_stress_cap