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/)
# _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
================================================

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
Thought Process Path: A vertical flow-chart that visualizes the decision-making pipeline.
📡 Sensing the World: This step shows the raw inputs the squid is currently processing. This includes his internal stats (hunger, happiness, etc.) and any objects he can see in his environment (food, poop, etc.).
⚖️ Calculating Base Urges: Based on the inputs, the tool calculates the initial "weight" or "urge" for each possible action. The action with the highest initial score is listed as the strongest urge.
🎭 Applying Personality & Memories: The base urges are then modified by the squid's personality and recent memories. For example, a "Timid" squid might have his urge to "explore" reduced, while a "Greedy" squid will have his urge to "eat" increased. This step shows how much each score was adjusted.
✅ Making the Final Decision: This step shows the final, adjusted scores for all possible actions. The action with the highest final score is the one the squid chooses.
Final Action Bar: A fixed bar at the bottom of the tab that prominently displays the squid's final chosen action (e.g., "Eat", "Sleep", "Explore") and his calculated confidence in that decision.
================================================
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
Memory Sub-Tabs:
🧠 Short-Term: Displays recent memories that are still being processed. These are temporary and will decay over time unless they are significant.
📚 Long-Term: Displays memories that have been deemed important enough to be stored permanently. These memories have a lasting impact on the squid's behavior.
📊 Overview: Shows high-level statistics about the memory system, including the total count of short-term and long-term memories and a breakdown of memories by category (e.g., food, interaction, mental\_state).
Memory Cards: Both STM and LTM are displayed as a series of cards. Each card is color-coded to indicate its emotional valence:
Pastel Green: A positive memory (e.g., eating tasty food).
Pastel Red: A negative memory (e.g., being startled).
Pastel Yellow: A neutral memory.
Card Content: Each card displays the memory's category, its formatted content, and the time it was created. Important memories are marked with a "⭐ Important" indicator. Clicking on a short-term memory card can increase its importance, making it more likely to be transferred to long-term memory.
================================================
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
Neural Visualizer: The main canvas displaying the neurons as nodes and their connections as lines. The pulsing and glowing of neurons and links indicate activity and learning events.
Metrics Bar: Located at the top, this bar provides at-a-glance statistics:
Neurons: The total number of neurons currently in the brain.
Connections: The total number of weighted connections between neurons.
Network Health: Overall stability and efficiency of the network, primarily based on average connection strength.
Hebbian Timer: A countdown (e.g., "Hebbian: 25") showing the time remaining until the next Hebbian learning cycle is performed.
Control Checkboxes:
Show links: Toggles the visibility of the lines connecting the neurons.
Show weights: Toggles the display of the numerical weight on each connection line.
Enable pruning: Toggles the automatic removal of old, weak, or unused neurons to maintain network stability. Disabling this can lead to an unconstrained and potentially unstable network.
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
================================================

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
Squid Personality: Displays the name of the determined personality (e.g., Timid, Adventurous, Lazy).
Description: Provides a paragraph explaining the general nature of this personality type.
Personality Modifiers: Lists the specific, hard-coded rules that this personality applies to the squid's brain. For example, it might detail how anxiety increases 50% faster or how curiosity is boosted.
Care Tips: Offers practical advice on how to best care for a squid with this specific personality, helping you keep it happy and healthy.
================================================
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:
`autosave.zip`: This file is used for periodic, automatic saves that occur in the background.
`save_data.zip`: This file is used when the player manually saves their game through the File menu.
Save File Structure
When `save_game()` is called, it bundles data into the following internal JSON files within the zip archive:
game_state.json: Contains general game state information, such as the squid's core stats (hunger, happiness), and other top-level game variables.
brain_state.json: Stores the complete state of the neural network, including neurogenesis data and the `pattern buffer` of learned experiences.
ShortTerm.json: A snapshot of all memories currently in the squid's short-term memory.
LongTerm.json: A snapshot of all memories that have been consolidated into long-term memory.
plugin_data.json: Contains any persistent data that active plugins have chosen to save.
statistics.json: Stores persistant statistics tracked over time such as squid age, distance swam, foods eaten, etc
uuid.txt: Unique squid identifier (128bit number)
------------------------------------------
[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.
pickup_probability: A value from 0.0 to 1.0 representing the chance the squid will decide to pick up a rock it encounters.
throw_probability: A value from 0.0 to 1.0 representing the chance the squid will throw a rock it is currently carrying.
min_carry_duration: The minimum time in seconds the squid will carry a rock before considering throwing it.
max_carry_duration: The maximum time in seconds the squid will carry a rock.
cooldown_after_throw: Time in seconds the squid must wait after throwing a rock before it can throw another.
happiness_boost, satisfaction_boost, anxiety\reduction: The numerical amount that the corresponding stats are changed when a positive rock interaction occurs.
[Neurogenesis]
This is the main section for controlling the creation of new neurons.
enabled: A boolean (True/False) that globally enables or disables the neurogenesis feature.
cooldown: The mandatory waiting period in seconds after a neuron is created before another one can be.
max_neurons: The maximum number of neurons the brain can have. Neurogenesis stops if this limit is reached and pruning is enabled.
These subsections control the specific triggers for creating new neurons. Each has the following parameters:
enabled: Enables or disables this specific pathway for neurogenesis.
threshold: The value the corresponding counter must exceed to trigger neuron creation.
decay_rate: The rate at which the counter's value decreases over time (e.g., 0.80 means the counter retains 80% of its value after a decay cycle).
max_counter: The maximum value the counter can reach.
*_modifier: Personality-specific multipliers that make it easier or harder for certain personalities to trigger neurogenesis (e.g., adventurous_modifier = 1.2 makes adventurous squids 20% more sensitive to novelty).
[Neurogenesis.NeuronProperties]
This section defines the default properties for newly created neurons.
base_activation: The initial activation value for a new neuron.
position_variance: Determines the random offset when placing a new neuron on the brain map.
default_connections: A boolean to enable or disable the automatic creation of pre-wired connections for new neurons.
connection_strength, reciprocal_strength: The initial weights for the default connections that a new neuron forms.
These sections control the visual feedback for neurogenesis.
*_color: Defines the RGB color for each type of new neuron.
*shape: Defines the shape (triangle, square, circle) for each type of new neuron.
highlight_duration, highlight_radius: Controls the size and duration of the visual highlight effect when a neuron is created.
pulse_effect, pulse_speed: Configures the pulsing animation on the new neuron.
================================================
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
================================================

# 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
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`)
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:
Select: Click on any decoration in the tank to select it.
Move: Click and drag a selected item to move it.
Resize: Select an item use mouse wheel to scale it. Note that some items, like rocks, cannot be resized.
Delete: Select an item and press Delete to permanently remove it from the tank.
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.
Feeding Your Squid:
How: Select Actions > Feed from the menu to drop food into the tank.
Why: Hunger is a primary driver of your squid's behavior. A hungry squid will become anxious and its happiness will decrease. Feeding it not only satisfies hunger but also increases happiness and satisfaction, creating a positive memory.
Cleaning the Environment:
How: As your squid lives in its environment, it will produce poop. Select Actions > Clean or click on a poop and press DEL to delete it.
Why: A dirty environment is a major source of stress and anxiety for your squid. Keeping the tank clean is essential for maintaining high happiness and low stress levels.
Administering Medicine:
How: If your squid becomes sick, select Actions > Medicine.
Why: Sickness can negatively impact all of your squid's stats. Promptly administering medicine is crucial for its recovery and overall health.
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.
1. Provide Novelty:
Goal: To create novel neurons that boost curiosity.
How-To: The best way to provide novelty is to regularly change the squid's environment. Open the Decorations window (from the View menu) and drag-and-drop new items like plants or different rocks into the tank. Each new object the squid investigates increases the novelty counter, and when it surpasses its threshold, a new neuron can be born.
2. Provide Rewards:
Goal: To create reward neurons that reinforce happiness and satisfaction.
How-To: Positive reinforcement is key. Consistently feeding the squid when it's hungry, keeping its tank clean, and encouraging play (like interacting with rocks) will increase the reward counter. This teaches the squid which behaviors lead to positive outcomes.
3. Provide Healthy Challenges:
Goal: To create stress neurons that act as coping mechanisms for unpleasant situations.
How-To: While chronic stress is harmful, allowing the squid to experience and overcome small, manageable stressors helps it build resilience. For instance, allowing it to get moderately hungry before feeding it can build the stress counter. When a stress neuron is created, it comes pre-wired with an inhibitory connection to the anxiety neuron, effectively making your squid better at managing anxiety in the future.
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!
================================================
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.
Timed Trigger: The learning cycle is initiated by a timer. The interval for this timer is configurable in config.ini under the \[Hebbian\] section's learning_interval parameter (default every 30000 milliseconds).
Pre-Pruning: Before each learning cycle begins, a pruning function is called to remove extremely weak and old connections from the network. This helps maintain network efficiency by clearing out irrelevant pathways before new learning occurs.
2. The Core Learning Process
The perform_hebbian_learning method executes a precise sequence of steps to update the network's weights.
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.
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.
Update Connection Weight: For each selected pair, the update_connection method is called. This is where the core weight calculation happens:
Base Hebbian Rule: The fundamental change in weight is calculated by multiplying the two neurons' normalized activation values by a learning rate. This reinforces the connection between them.
Dynamic Learning Rate: The learning rate is not static. If one of the neurons in the pair was recently created via neurogenesis, the learning rate is temporarily boosted (e.g., by a factor of 2.0). This allows new, specialized neurons to integrate into the network more quickly and form meaningful connections.
Weight Decay: To prevent runaway weight growth and to help the network "forget" insignificant associations, a small decay factor is applied during the update. This factor, configured via weight_decay in config.ini, slightly reduces the magnitude of the connection's weight during each update.
Clamping: The final calculated weight is always clamped to a range of \[-1.0, 1.0\] to keep it normalized and prevent extreme values from destabilizing the network.
3. Visual Feedback
The learning process is tied directly to the application's user interface to provide clear, real-time feedback.
Activity Log: In the "Learning" tab of the Brain Tool, a log entry is created for each learning event, explicitly stating which neuron pair had its connection strengthened or weakened and by how much.
Network Animation: In the "Network" tab, the connection line between the learning pair will briefly glow or pulse. The color of the pulse indicates whether the weight increased (positive reinforcement) or decreased (negative reinforcement), providing an immediate visual cue of the learning event.
================================================
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
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
The network starts as a single-layer perceptron with 7 core, named neurons. These neurons represent the fundamental emotional and physical states of the squid:
* Circular (Basic Needs): hunger, happiness, cleanliness, sleepiness
* Square (Complex States): satisfaction, anxiety, curiosity
Each neuron's activation value ranges from 0-100.
Unlike a typical deep learning model, this network is not structured into distinct input, hidden, and output layers. Instead, all neurons exist on a single plane and are fully interconnected. Each connection between two neurons has a weight, initialized with a random value between -1 and 1, which represents the strength and nature (excitatory or inhibitory) of their relationship.
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."
Learning Cycle: The learning process is not continuous but occurs in discrete cycles, triggered by a timer (by default, every 30 seconds).
Activation: During a learning cycle, any neuron whose activation value exceeds a predefined threshold (e.g., > 50) is considered "active.".
Weight Update: The system randomly selects a few pairs of currently active neurons. The weight between these pairs is then adjusted according to the Hebbian rule: the change in weight is proportional to the product of the two neurons' activation values multiplied by a learning rate. This strengthens the connection between neurons that are concurrently active.
Weight Decay: To ensure network stability and prevent weights from growing indefinitely, a small weight decay is applied over time, gradually weakening all connections.
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.
Triggers: Neurogenesis is initiated by one of three counters exceeding a set threshold:
Novelty: Increases when the squid encounters new objects or experiences.
Stress: Increases during stressful events.
Reward: Increases when the squid experiences a positive outcome.
Creation Process: When a counter surpasses its threshold and a cooldown period has passed, a new neuron is created.
The neuron is named based on its trigger (e.g., novel\_0, stress\_0).
It is positioned visually on the network graph near other currently active neurons.
Crucially, it is immediately connected to the existing network with a set of default weights. For example, a new 'reward' neuron automatically forms a strong positive connection to 'satisfaction' and 'happiness'.
Dynamic Thresholds: The thresholds required to trigger neurogenesis are not static. They scale upwards as the network grows in size, preventing runaway neuron creation and promoting stability in a mature network.
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.
Connection Pruning: The system can periodically remove connections whose absolute weight falls below a very low threshold, cleaning up insignificant links.
Neuron Pruning: When the network approaches its configured maximum neuron limit, it can trigger the pruning of entire neurons. This process targets newly created (non-core) neurons that have failed to form strong connections or remain largely inactive.
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.

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.
Dynamic and Directional: The View Cone is not static. It is recalculated every frame to match the squid's state. When the squid moves, the cone moves with it. More importantly, when the squid turns to face left, right, or up, the cone rotates accordingly. This ensures the squid can only perceive objects that are genuinely in his line of sight.
Object Detection via Intersection: The system determines what the squid "sees" by performing a continuous series of geometric intersection tests. The logic iterates through every object in the environment (decorations, food, poop, etc.) and checks if that object's bounding box intersects with the squid's View Cone polygon.
Informing the Brain: If an intersection is detected, the object is officially considered "seen." This information is critical as it is fed directly into the squid's Decision Engine. For example, if "food" is seen, the desire to "eat" will likely increase. This allows the squid to make intelligent, context-aware decisions based on his immediate surroundings. The `has_food_visible` and `plant_proximity` inputs feed this system.
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:
Clear the Scene: It first clears its own display to ensure no leftover artifacts from the previous frame.
Get the View Cone: It fetches the squid's current View Cone polygon from the main game logic.
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.
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.
================================================
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/)
⚡ 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 (
);
}
================================================
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
🧠 Brain Name
0Neurons
0Connections
0Custom
📂 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 --ticks10000
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"
"
)
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"""
"""
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"
"
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"
")
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 += "
Category
Count
"
for category, count in sorted(categories.items(), key=lambda x: x[1], reverse=True):
stats_html += f"
"
for level, count in importance_levels.items():
stats_html += f"
{level}
{count}
"
stats_html += "
"
# 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"""
"
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"""
"""
# 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"""
""")
# 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"""
"""
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"""
"""
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"""
{neuron}
{influence:.3f}
"""
influence_stats += "
"
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
""")
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}
")
f.write("""
""")
# Neuron information
neurons = sorted(self.brain_widget.neuron_positions.keys())
f.write("""
Neurons
Total neurons: """ + str(len(neurons)) + """
Neuron
Position
Type
Current Value
""")
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[0]:.1f}, {position[1]:.1f})
{neuron_type}
{value:.1f}
""")
f.write("""
""")
# Connection weights
f.write("""
Connection Weights
Total connections: """ + str(len(self.brain_widget.weights)) + """
Source
Target
Weight
""")
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"""
{source}
{target}
{weight:.3f}
""")
f.write("""
""")
# Simple text-based heatmap
f.write("""
Weight Heatmap (Text Representation)
This is a simplified text representation of the weight matrix.
Source / Target
""")
# Column headers
for neuron in neurons:
f.write(f"
{neuron}
")
f.write("
")
# Rows with data
for src in neurons:
f.write(f"
""")
# 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"
{h_partner}
{h_weight}
{h_type}
{h_inf}
"
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"
{dst}
{w:+.3f}
{badge(typ,'#000',col)}
"
html += f"
{self._influence_badge(w)}
"
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"
"
for partner, delta in impacts.items():
col = "#d4ffd4" if delta > 0 else "#ffd4d4"
html += f"
{partner}
{delta:+.2f}
"
html += "
"
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