diff --git a/assets/crops/r0_c0.png b/assets/crops/r0_c0.png new file mode 100644 index 0000000..80b6a03 Binary files /dev/null and b/assets/crops/r0_c0.png differ diff --git a/assets/crops/r0_c1.png b/assets/crops/r0_c1.png new file mode 100644 index 0000000..25358ab Binary files /dev/null and b/assets/crops/r0_c1.png differ diff --git a/assets/crops/r0_c2.png b/assets/crops/r0_c2.png new file mode 100644 index 0000000..a2184ea Binary files /dev/null and b/assets/crops/r0_c2.png differ diff --git a/assets/crops/r1_c0.png b/assets/crops/r1_c0.png new file mode 100644 index 0000000..368d772 Binary files /dev/null and b/assets/crops/r1_c0.png differ diff --git a/assets/crops/r1_c1.png b/assets/crops/r1_c1.png new file mode 100644 index 0000000..60ec83e Binary files /dev/null and b/assets/crops/r1_c1.png differ diff --git a/assets/crops/r1_c2.png b/assets/crops/r1_c2.png new file mode 100644 index 0000000..657664a Binary files /dev/null and b/assets/crops/r1_c2.png differ diff --git a/assets/crops/r2_c0.png b/assets/crops/r2_c0.png new file mode 100644 index 0000000..e1ae921 Binary files /dev/null and b/assets/crops/r2_c0.png differ diff --git a/assets/crops/r2_c1.png b/assets/crops/r2_c1.png new file mode 100644 index 0000000..339d91c Binary files /dev/null and b/assets/crops/r2_c1.png differ diff --git a/assets/crops/r2_c2.png b/assets/crops/r2_c2.png new file mode 100644 index 0000000..24aed32 Binary files /dev/null and b/assets/crops/r2_c2.png differ diff --git a/assets/game_board.png b/assets/game_board.png new file mode 100644 index 0000000..bbc16a3 Binary files /dev/null and b/assets/game_board.png differ diff --git a/assets/game_board.svg b/assets/game_board.svg new file mode 100644 index 0000000..1676408 --- /dev/null +++ b/assets/game_board.svg @@ -0,0 +1,47 @@ + + + + diff --git a/homelab-config.md b/homelab-config.md new file mode 100644 index 0000000..458b087 --- /dev/null +++ b/homelab-config.md @@ -0,0 +1,541 @@ +# Bumblesloth Homelab — System Reference + +> **Purpose:** This file is shared project knowledge for all homelab-related conversations. It describes the full infrastructure so that any discussion (game servers, cloud, networking, etc.) has the context it needs. + +--- + +## Hardware + +| Component | Detail | +|-----------|--------| +| Machine | HP ProDesk 600 G6 SFF | +| CPU | Intel i5-10500 (6 cores / 12 threads) | +| RAM | 16 GB DDR4 UDIMM (288-pin desktop, not SO-DIMM) | +| Storage | 512 GB NVMe SSD (single drive) | +| GPU | Integrated Intel UHD 630 (no dedicated GPU) | +| Expansion | 1× free PCIe x16 slot, 1× 3.5" bay | +| Form factor | Small Form Factor (SFF) — limits internal expansion | + +--- + +## Operating System + +- **OS:** Ubuntu Server 24.04 LTS (HWE kernel, minimized install, no snaps) +- **User:** `bumblesloth` +- **Hostname:** `slothhive` +- **IPv6:** Disabled system-wide (`/etc/sysctl.d/99-disable-ipv6.conf`) and in Docker (`daemon.json`) +--- + +## Network Configuration + +### Local Network +| Setting | Value | +|---------|-------| +| IP | `192.168.178.30` (DHCP with static lease via FRITZ!Box MAC binding) | +| Subnet | `192.168.178.x` | +| Netplan config | `/etc/netplan/50-cloud-init.yaml` (dhcp4: no — eno1 enslaved to br0) + `/etc/netplan/99-bridge.yaml` | +| Network bridge | `br0` — bridges `eno1` for VM LAN access; inherits `eno1` MAC (`e8:d8:d1:d3:05:bd`) | + +### Router +| Setting | Value | +|---------|-------| +| Current router | FRITZ!Box 5690 Pro (Deutsche Telekom fiber, SFP module) | +| DHCP DNS | AdGuard Home pushed via DHCP to all LAN devices (`192.168.178.30`) | +| DynDNS | Configured directly on FRITZ!Box — auto-updates ipv64.net on IP change | + +### Public Access +| Setting | Value | +|---------|-------| +| Public IP | Dynamic (DynDNS managed) | +| DynDNS provider | ipv64.net | +| Base domain | `bumblesloth.ipv64.net` | +| A record | `bumblesloth.ipv64.net` → public IP (auto-updated by FRITZ!Box) | + +### DNS — AdGuard Home +- **Wildcard rewrite:** `*.bumblesloth.ipv64.net → 192.168.178.30` (covers all subdomains on LAN) +- **Desktop DNS (CachyOS):** received automatically via FRITZ!Box DHCP — no manual override needed +- All devices on LAN receive AdGuard Home as DNS via FRITZ!Box DHCP +### DNS Records (CNAME → bumblesloth.ipv64.net) +| Subdomain | Service | +|-----------|---------| +| `cloud.bumblesloth.ipv64.net` | Nextcloud | +| `photos.bumblesloth.ipv64.net` | Immich | +| `actual-budget.bumblesloth.ipv64.net` | Actual Budget | +| `adguard.bumblesloth.ipv64.net` | AdGuard Home | +| `mealie.bumblesloth.ipv64.net` | Mealie | +| `git.bumblesloth.ipv64.net` | Gitea | +| `foundry.bumblesloth.ipv64.net` | Foundry VTT | +| `matrix.bumblesloth.ipv64.net` | Matrix/Synapse | +| `jellyfin.bumblesloth.ipv64.net` | Jellyfin | +| `paperless.bumblesloth.ipv64.net` | Paperless-NGX | +| `homeassistant.bumblesloth.ipv64.net` | Home Assistant | + +### Firewall (UFW) +``` +22/tcp — SSH +80/tcp — HTTP (Nginx Proxy Manager) +443/tcp — HTTPS (Nginx Proxy Manager) +222/tcp — Gitea SSH +53/tcp+udp — AdGuard Home DNS +2456:2458/udp — Valheim +15636/udp — Enshrouded (query) +15637/udp — Enshrouded (game) +ufw route allow in on br0 — VM bridge traffic (required for KVM VM LAN access) +ufw route allow out on br0 — VM bridge traffic (required for KVM VM LAN access) +``` + +### Router Port Forwarding +| Port(s) | Protocol | Target | Service | +|---------|----------|--------|---------| +| 80 | TCP | 192.168.178.30 | NPM (HTTP) | +| 443 | TCP | 192.168.178.30 | NPM (HTTPS) | +| 222 | TCP | 192.168.178.30 | Gitea SSH | +| 2456–2458 | UDP | 192.168.178.30 | Valheim | +| 15636 | UDP | 192.168.178.30 | Enshrouded (query) | +| 15637 | UDP | 192.168.178.30 | Enshrouded (game) | + +### Remote Access +- **Tailscale** mesh VPN installed on slothhive, iPhone, and MacBook; MagicDNS enabled for hostname-based access +- **CachyOS desktop:** `/etc/hosts` entry `192.168.178.30 slothhive` for local SSH +--- + +## KVM Virtualisation + +Home Assistant OS runs as a KVM virtual machine on slothhive, alongside Docker services. + +### Installation +```bash +sudo apt install qemu-kvm libvirt-daemon-system libvirt-clients virtinst bridge-utils ovmf +sudo usermod -aG libvirt bumblesloth +``` + +### Network Bridge +The VM needs a bridge interface to get its own LAN IP. Configured via `/etc/netplan/99-bridge.yaml`: +```yaml +network: + version: 2 + ethernets: + eno1: + dhcp4: false + bridges: + br0: + interfaces: [eno1] + dhcp4: true + macaddress: e8:d8:d1:d3:05:bd + parameters: + stp: false + forward-delay: 0 +``` +`/etc/netplan/50-cloud-init.yaml` sets `eno1` to `dhcp4: false` to avoid conflict. + +### Bridge Kernel Settings +``` +/etc/sysctl.d/99-bridge.conf: + net.bridge.bridge-nf-call-iptables = 0 + net.bridge.bridge-nf-call-ip6tables = 0 + +/etc/modules-load.d/br_netfilter.conf: + br_netfilter + +/etc/sysctl.d/99-ipforward.conf: + net.ipv4.ip_forward = 1 +``` + +### Common virsh Commands +```bash +virsh list --all # List all VMs and state +virsh start hass # Start HA VM +virsh shutdown hass # Graceful shutdown +virsh destroy hass # Force stop +virsh reboot hass # Reboot +virsh autostart hass # Enable autostart on boot +virsh dominfo hass # VM details +virsh domifaddr hass # VM IP address +virsh attach-device hass --file device.xml --persistent # Attach USB device +``` + +--- + +## Docker Infrastructure + +### Installation +- Docker CE from official Docker repository (not distro packages) +- Docker Compose plugin (`docker compose`, not `docker-compose`) +- User `bumblesloth` in `docker` and `www-data` groups +### Networks +| Network | Type | Purpose | +|---------|------|---------| +| `proxy-net` | External, user-defined | Connects NPM to all web-facing services | +| `nc-internal` | Bridge (Nextcloud stack) | Isolates Nextcloud DB/Redis | +| `paperless-internal` | Bridge (Paperless stack) | Isolates Paperless DB | +| `matrix-internal` | Bridge (Matrix stack) | Isolates Synapse DB | +| `default` (per stack) | Auto-created | Internal communication within a compose stack | + +> **Security model:** Services behind NPM on `proxy-net` don't need UFW rules or host port exposure — traffic flows 443/80 → NPM → container internally. + +### Directory Structure +``` +~/homelab/ +├── nginx-proxy-manager/ # Reverse proxy + SSL +│ ├── docker-compose.yml +│ ├── data/ +│ └── letsencrypt/ +├── adguard-home/ # DNS resolver + ad blocking +│ └── docker-compose.yml +├── nextcloud/ # File sync & collaboration +│ ├── docker-compose.yml +│ ├── app-data/ +│ ├── db-data/ # MariaDB data +│ └── user-data/ +├── immich/ # Photo & video management +│ ├── docker-compose.yml +│ ├── .env +│ ├── library/ +│ └── postgres/ +├── paperless/ # Document management +│ ├── docker-compose.yml +│ └── db-data/ # PostgreSQL data +├── jellyfin/ # Media server +│ └── docker-compose.yml +├── mealie/ # Recipe manager +│ └── docker-compose.yml +├── gitea/ # Self-hosted Git +│ ├── docker-compose.yml +│ └── data/ +├── actual-budget/ # Personal finance +│ ├── docker-compose.yml +│ └── data/ +├── foundry/ # Foundry VTT +│ └── docker-compose.yml +├── matrix/ # Matrix/Synapse chat +│ └── docker-compose.yml +├── enshrouded/ # Enshrouded game server +│ ├── docker-compose.yml +│ └── enshrouded-data/ +├── valheim/ # Valheim game server +│ ├── docker-compose.yml +│ ├── config/ +│ │ ├── worlds_local/ +│ │ ├── backups/ +│ │ └── bepinex/ +│ │ └── plugins/ +│ └── data/ +└── cups/ # AirPrint bridge for Epson ET-2810 + └── docker-compose.yml +``` + +### Homelab Management Script +`/usr/local/bin/homelab` — supports `up`, `down`, `status` + +**Design:** AdGuard Home and NPM are hardcoded as first/last. All other Docker services live in the `STACKS` array. The Home Assistant VM is managed separately via `virsh` commands within the same script. + +**Startup order:** adguard-home → nginx-proxy-manager (3s delay) → HA VM → STACKS in order +**Shutdown order:** STACKS in reverse → HA VM → nginx-proxy-manager → adguard-home + +### Auto-start on Boot +- **Docker services:** Systemd service at `/etc/systemd/system/homelab.service` — enabled, runs `homelab up` after Docker is ready on every boot. +- **HA VM:** `virsh autostart hass` — VM starts independently on boot via libvirt. +--- + +## Services + +### Nginx Proxy Manager +| Setting | Value | +|---------|-------| +| Container | `jc21/nginx-proxy-manager:latest` | +| Ports | 80 (HTTP), 443 (HTTPS), 81 (admin, local only) | +| Network | `proxy-net` | +| Admin UI | `http://192.168.178.30:81` | +| Features | SSL via Let's Encrypt, Force SSL, HTTP/2, Block Common Exploits, WebSockets | + +All web services are proxied through NPM. Game servers use direct UDP port forwarding, not NPM. + +### AdGuard Home +| Setting | Value | +|---------|-------| +| Role | LAN DNS resolver, ad/tracker blocking | +| URL | `https://adguard.bumblesloth.ipv64.net` | +| Admin UI | `http://192.168.178.30:3000` | +| Port | 53 (bound to host) | +| Location | `~/homelab/adguard-home/` | +| Network | `proxy-net` | +| Wildcard rewrite | `*.bumblesloth.ipv64.net → 192.168.178.30` | + +### Home Assistant +| Setting | Value | +|---------|-------| +| Role | Home automation, smart home integration | +| URL | `https://homeassistant.bumblesloth.ipv64.net` | +| Type | **KVM Virtual Machine** (not Docker) — runs Home Assistant OS 18.0 | +| VM name | `hass` | +| VM IP | `192.168.178.43` (DHCP via FRITZ!Box) | +| VM disk | `/var/lib/libvirt/images/haos.img` (64 GB) | +| VM RAM | 4096 MB | +| VM vCPUs | 2 | +| NPM forward | `192.168.178.43:8123` (by IP, not container name) | +| NPM features | WebSockets enabled, Force SSL | +| Autostart | `virsh autostart hass` | + +**ZBT-2 USB Passthrough:** +| Setting | Value | +|---------|-------| +| Device | Nabu Casa ZBT-2 Thread/Matter border router | +| USB ID | `303a:831a` | +| Host path | `/dev/serial/by-id/usb-Nabu_Casa_ZBT-2_94A990D07314-if00` → `/dev/ttyACM0` | +| Passthrough method | `virsh attach-device hass ~/zbt2.xml --persistent` | +| Mode | Thread border router (OpenThread Border Router add-on in HA OS) | + +**ZBT-2 passthrough XML (`~/zbt2.xml`):** +```xml + + + + + + +``` + +**Integrations configured:** +- LG webOS TV (`192.168.178.22`) — added manually +- iPhone Companion app — connected via `https://homeassistant.bumblesloth.ipv64.net` +- ZBT-2 Thread border router — OpenThread Border Router add-on +**Notes:** +- HA OS runs as a KVM VM because the OpenThread Border Router add-on requires HA OS (not available in HA Container) +- VM uses `br0` bridge for LAN access — gets its own IP directly from FRITZ!Box DHCP +- NPM proxies to VM by IP (`192.168.178.43`) since the VM is not on `proxy-net` +- `trusted_proxies` config not needed in HA OS (handled differently than HA Container) +- mDNS auto-discovery doesn't work across Docker bridge — iOS Companion app must use manual URL +### Nextcloud +| Setting | Value | +|---------|-------| +| Role | Documents, file sync, collaboration | +| URL | `https://cloud.bumblesloth.ipv64.net` | +| Containers | `nextcloud-app` (Nextcloud), `nextcloud-db` (MariaDB), `nextcloud-redis` (Redis) | +| Networks | `proxy-net`, `nc-internal` | +| NPM forward | `nextcloud-app:80` | +| PHP memory | 1024M (`/usr/local/etc/php/conf.d/memory-limit.ini`) | + +**Users:** root (admin, no data), kevin + +**Cron:** +``` +*/5 * * * * docker exec -u www-data nextcloud-app php -f /var/www/html/cron.php +``` +Background jobs set to "Cron" in admin settings. + +**Notes:** +- iOS/macOS CalDAV login can trigger brute-force protection; reset with `occ security:bruteforce:reset ` +- `bumblesloth` user is in `www-data` group for host-level access to user-data directory +- Passwords in docker-compose.yml contain `$` special characters — escaped as `$$` to prevent Docker Compose variable interpolation warnings +### Immich +| Setting | Value | +|---------|-------| +| Role | Photo & video management, AI tagging, face recognition | +| URL | `https://photos.bumblesloth.ipv64.net` | +| Containers | `immich_server`, `immich_machine_learning`, `immich_redis`, `immich_postgres` | +| Networks | `proxy-net` + `default` (server), `default` only (others) | +| NPM forward | `immich_server:2283` | + +**Users:** Kevin, mother + +### Paperless-NGX +| Setting | Value | +|---------|-------| +| Role | Document management, OCR, tagging | +| URL | `https://paperless.bumblesloth.ipv64.net` | +| Containers | `paperless`, `paperless-db` (PostgreSQL), `paperless-redis`, `paperless-gotenberg`, `paperless-tika`, `paperless-gpt`, `ollama` | +| Networks | `proxy-net`, `paperless-internal` | +| NPM forward | `paperless:8000` | + +**Backup:** PostgreSQL dump + document export → rsync to USB (`/mnt/backup-usb`, btrfs) nightly at 2 AM +**Manual sync to Proton Drive:** Fish function `paperless-backup` on CachyOS desktop (rclone automation pending upstream fix — PRs #9080/#9081) + +**OCR workflow:** +- Automated metadata (title, tags, date) via paperless-gpt + `qwen2.5:7b` +- Manual OCR for difficult documents via Open WebUI + `llama3.2-vision:11b`; PDFs converted with `pdftoppm` (poppler) +- Claude (vision) as fallback for manual transcription +### Jellyfin +| Setting | Value | +|---------|-------| +| Role | Media streaming | +| URL | `https://jellyfin.bumblesloth.ipv64.net` | +| Network | `proxy-net` | +| NPM forward | `jellyfin:8096` | +| Hardware accel | Intel QSV via `/dev/dri` (Intel UHD 630) | +| Media path | `/srv/media/` (host mount) | + +### Mealie +| Setting | Value | +|---------|-------| +| Role | Recipe manager | +| URL | `https://mealie.bumblesloth.ipv64.net` | +| Network | `proxy-net` | +| Backend | SQLite | + +### Gitea +| Setting | Value | +|---------|-------| +| Role | Self-hosted Git | +| URL | `https://git.bumblesloth.ipv64.net` | +| Network | `proxy-net` | +| Backend | SQLite | +| SSH | Port 222 (UFW + FRITZ!Box port forwarding configured) | +| NPM forward | `gitea:3000` | +| Features | WebSockets enabled, Block Common Exploits enabled | + +### Actual Budget +| Setting | Value | +|---------|-------| +| Role | Personal finance (replaced Firefly III) | +| URL | `https://actual-budget.bumblesloth.ipv64.net` | +| Network | `proxy-net` | +| NPM forward | `actual-budget:5006` | + +### Foundry VTT +| Setting | Value | +|---------|-------| +| Role | TTRPG virtual tabletop | +| URL | `https://foundry.bumblesloth.ipv64.net` | +| Network | `proxy-net` | +| NPM | WebSockets must be enabled in NPM proxy host | + +### Matrix / Synapse +| Setting | Value | +|---------|-------| +| Role | Private self-hosted chat | +| URL | `https://matrix.bumblesloth.ipv64.net` | +| Server name | `matrix.bumblesloth.ipv64.net` (permanent — chosen to avoid .well-known delegation) | +| User IDs | `@kevin:matrix.bumblesloth.ipv64.net` | +| Containers | `synapse`, `matrix-db` (PostgreSQL), `coturn` | +| Networks | `proxy-net`, `matrix-internal` | +| NPM | WebSockets must be enabled | +| Federation | Disabled | +| Registration | Manual only | + +### Enshrouded (Game Server) +| Setting | Value | +|---------|-------| +| Image | `mornedhels/enshrouded-server:latest` | +| Container | `enshrouded` | +| Ports | UDP 15636 (query), UDP 15637 (game) | +| Backups | Every 6 hours, max 4 kept | +| Max players | 8 | +| Update cron | Mondays at 4 AM | + +**Force update:** `docker compose exec enshrouded supervisorctl start enshrouded-force-update` +**Note:** Server version may lag behind client after patches — Keen Games publishes dedicated server updates separately. Use force update command if version mismatch occurs. + +### Valheim (Game Server) +| Setting | Value | +|---------|-------| +| Image | `ghcr.io/lloesche/valheim-server` | +| Container | `valheim` | +| Ports | UDP 2456–2458 | +| Mod framework | BepInEx (disabled — glibc incompatibility; vanilla only) | +| Auto-update | Only when idle | +| Backups | Every 6 hours, max 10 kept, max 7 days | +| Public | false (private server) | +| World | `BumbleWorld` (in `./config/worlds_local/`) | + +**World switching:** Change `WORLD_NAME` in docker-compose.yml, run `docker compose down && docker compose up -d`. +**Adding mods:** Drop `.dll` files in `./config/bepinex/plugins/`, restart container. + +### CUPS / AirPrint Bridge +| Setting | Value | +|---------|-------| +| Role | AirPrint bridge for Epson ET-2810 printer | +| Image | `drpsychick/airprint-bridge:latest` | +| Container | `cups` | +| Location | `~/homelab/cups/` | + +--- + +## Common Commands + +### Docker +```bash +docker ps # List running containers +docker compose up -d # Start stack (from its directory) +docker compose down # Stop stack +docker compose logs -f # Follow logs +docker compose restart # Restart stack +docker stats # Live resource usage +``` + +### Homelab Script +```bash +homelab up # Start all services in order +homelab down # Stop all services in reverse order +homelab status # Show status of all stacks + HA VM +``` + +### KVM / Home Assistant VM +```bash +virsh list --all # Show all VMs and state +virsh start hass # Start HA VM +virsh shutdown hass # Graceful shutdown +virsh reboot hass # Reboot VM +virsh domifaddr hass # Show VM IP address +virsh dominfo hass # Show VM details +virsh attach-device hass ~/zbt2.xml --persistent # Attach ZBT-2 USB dongle +``` + +### Enshrouded +```bash +docker compose exec enshrouded supervisorctl start enshrouded-force-update # Force game update +docker compose exec enshrouded supervisorctl restart enshrouded-server # Restart server +``` + +### Nextcloud (OCC) +```bash +docker exec -u www-data nextcloud-app php occ [command] +# Useful commands: +# user:list, user:add +# files:scan --all / files:scan +# files:cleanup +# maintenance:mode --on / --off +# maintenance:repair +# trashbin:cleanup --all-users +# app:list, app:enable , app:disable +# security:bruteforce:reset +``` + +### System +```bash +df -h / # Disk usage +htop # System resources +sudo ufw status # Firewall rules +crontab -l # Scheduled tasks +rsync -avh --progress # File transfers to slothhive over SSH +``` + +--- + +## Known Issues & Notes + +1. **IPv6 disabled** — Docker Hub pulls fail over IPv6. Disabled system-wide in sysctl and in Docker daemon.json. +2. **BepInEx glibc error** — BepInEx doorstop requires GLIBC 2.33+; container has older version. Valheim runs vanilla. +3. **Nextcloud brute force** — iOS/macOS app login can trigger rate limiting. Reset with `occ security:bruteforce:reset `. Use app passwords if 2FA is enabled. +4. **Hairpin NAT** — Accessing public-facing services via domain from inside the LAN may not work on some routers. AdGuard wildcard rewrite handles this on the LAN. +5. **Single SSD** — All data on one 512 GB NVMe. Future plan: NAS for bulk storage, keep databases on SSD. +6. **Proton Drive rclone automation** — Blocked by upstream bug (PRs #9080/#9081 unmerged). Paperless backups synced manually via `paperless-backup` Fish function on CachyOS desktop. +7. **Matrix server name is permanent** — `matrix.bumblesloth.ipv64.net` used directly; changing it would require a full rebuild. +8. **WebSockets** — Must be explicitly enabled in NPM for Foundry VTT and Matrix/Synapse (and Home Assistant). +9. **DDR4 UDIMM (288-pin)** — HP ProDesk 600 G6 SFF requires standard desktop DIMMs, not SO-DIMM. Important for future RAM upgrades. +10. **DynDNS auto-update** — FRITZ!Box handles updates automatically, but after a power outage verify the public IP matches ipv64.net if services are unreachable externally. +11. **Nextcloud DB passwords** — Contain `$` special characters; escaped as `$$` in docker-compose.yml to avoid Docker Compose variable interpolation warnings. +12. **HA OS requires KVM VM** — The OpenThread Border Router add-on (required for ZBT-2 Thread mode) is only available in HA OS, not HA Container. KVM VM coexists with Docker on the same host without conflict. +13. **HA VM network** — VM gets its IP via DHCP from FRITZ!Box through the `br0` bridge. NPM proxies to the VM by IP (`192.168.178.43`), not container name. If the VM IP changes, update NPM proxy host. +14. **br0 bridge required for VM** — KVM VM LAN access requires `br0` network bridge. UFW must allow routed traffic on `br0` (`ufw route allow in/out on br0`). Bridge netfilter must be disabled (`bridge-nf-call-iptables = 0`) to avoid Docker iptables conflicts. +15. **HA VM disk size** — VM disk expanded to 64 GB (from default 32 GB) to accommodate HA Core Docker image download during initial setup. +16. **mDNS limitations** — Auto-discovery (for iOS Companion app, LG TV, etc.) does not work across Docker bridge or into the KVM VM from LAN devices. Use manual IP/URL for all HA integrations. +--- + +## Future Plans + +- [ ] Proton Drive rclone automation once upstream PRs merge +- [ ] NAS integration (NFS mount for bulk media/backup storage) +- [ ] Resolve BepInEx GLIBC compatibility for Valheim modding +- [ ] Navidrome (music streaming) +- [ ] Confirm macOS CalDAV resolution (brute force reset + app password) +- [ ] Add IKEA Matter over Thread devices via ZBT-2 border router diff --git a/shared/board.js b/shared/board.js new file mode 100644 index 0000000..e9bca7f --- /dev/null +++ b/shared/board.js @@ -0,0 +1,76 @@ +/** + * Life Journey — Phase 1 board. + * + * A small representative subset of the full hand-drawn board (assets/game_board.png): + * a Career-vs-Education fork, a Relationship-vs-Investment fork, a High-Risk-vs-Safe + * fork, and a Finish. Same shape as the full sketch — more spaces can be inserted into + * any branch array later (repointing one `next`) without touching the reducer. + * + * Space shape: { id, type, label, next?, choices?, cash?, flavor? } + * type: 'start' | 'event' | 'money' | 'choice' | 'finish' + * next: id of the following space (absent on 'choice' and 'finish' spaces) + * choices: [id, id] of the branches offered by a 'choice' space + * cash: fixed integer delta applied on landing (absent/0 for pure flavor spaces) + */ + +const spaceList = [ + { id: 'start', type: 'start', label: 'Start', next: 'crossroads' }, + { id: 'crossroads', type: 'choice', label: 'Crossroads', choices: ['career_1', 'edu_1'] }, + + { id: 'career_1', type: 'event', label: 'High School Grad', next: 'career_2' }, + { id: 'career_2', type: 'event', label: 'New Job', next: 'career_3' }, + { id: 'career_3', type: 'money', label: 'Paycheck', cash: 300, next: 'career_4' }, + { id: 'career_4', type: 'money', label: 'Workplace Drama', cash: -150, next: 'career_5' }, + { id: 'career_5', type: 'money', label: 'Performance Review', cash: 250, next: 'join_1' }, + + { id: 'edu_1', type: 'event', label: 'College Enrolled', next: 'edu_2' }, + { id: 'edu_2', type: 'money', label: 'Study Abroad', cash: -100, next: 'edu_3' }, + { id: 'edu_3', type: 'money', label: 'Student Loan', cash: -300, next: 'edu_4' }, + { id: 'edu_4', type: 'money', label: 'Scholarship', cash: 400, next: 'edu_5' }, + { id: 'edu_5', type: 'event', label: 'Graduation', next: 'join_1' }, + + { id: 'join_1', type: 'event', label: 'Adulting Begins', next: 'life_crossroads' }, + { id: 'life_crossroads', type: 'choice', label: 'Life Crossroads', choices: ['relationship_1', 'investment_1'] }, + + { id: 'relationship_1', type: 'event', label: 'New City', next: 'relationship_2' }, + { id: 'relationship_2', type: 'event', label: 'Dinner Party', next: 'relationship_3' }, + { id: 'relationship_3', type: 'money', label: 'Wedding', cash: -200, next: 'join_2' }, + + { id: 'investment_1', type: 'money', label: 'Side Investment', cash: -150, next: 'investment_2' }, + { id: 'investment_2', type: 'money', label: 'Market Move', cash: 350, next: 'investment_3' }, + { id: 'investment_3', type: 'money', label: '401K Contribution', cash: -100, flavor: 'Future savings', next: 'join_2' }, + + { id: 'join_2', type: 'event', label: 'Settling Down', next: 'high_risk_choice' }, + { id: 'high_risk_choice', type: 'choice', label: 'One Last Fork', choices: ['high_risk_1', 'safe_1'] }, + + { id: 'high_risk_1', type: 'money', label: 'Startup Gamble', cash: 500, next: 'high_risk_2' }, + { id: 'high_risk_2', type: 'money', label: 'Bankruptcy', cash: -400, flavor: 'Ouch.', next: 'retirement_party' }, + + { id: 'safe_1', type: 'money', label: 'Steady Savings', cash: 100, next: 'safe_2' }, + { id: 'safe_2', type: 'money', label: 'Modest Raise', cash: 150, next: 'retirement_party' }, + + { id: 'retirement_party', type: 'event', label: 'Retirement Party', next: 'finish' }, + { id: 'finish', type: 'finish', label: 'Finish' }, +]; + +export const board = { + id: 'phase1-demo', + startSpaceId: 'start', + spaces: Object.fromEntries(spaceList.map((space) => [space.id, space])), +}; + +/** + * Advance from `startId` by up to `steps` spaces, stopping immediately on arrival + * at a 'choice' or 'finish' space even if pips remain (they're discarded). + */ +export function walkForward(startId, steps) { + let current = startId; + for (let i = 0; i < steps; i++) { + const space = board.spaces[current]; + if (!space.next) break; // sitting on a choice/finish space already — nowhere to advance + current = space.next; + const landed = board.spaces[current]; + if (landed.type === 'choice' || landed.type === 'finish') break; + } + return current; +} diff --git a/shared/game.js b/shared/game.js new file mode 100644 index 0000000..651b5af --- /dev/null +++ b/shared/game.js @@ -0,0 +1,173 @@ +/** + * Life Journey — pure game reducer. + * + * reduce(state, action) -> newState is the ONLY way state changes, and it never + * generates randomness itself: the die value is generated by the server and + * travels inside the ROLL action payload, so server and client can both apply + * the exact same action through this exact same function and land on identical + * state. Illegal transitions throw rather than no-op — the server is expected + * to gate intents with getLegalIntents()/isPlayersTurn() before ever + * constructing an action, so a throw here means that gate was bypassed. + */ + +import { board, walkForward } from './board.js'; + +const DEFAULT_CONFIG = { minPlayers: 2, maxPlayers: 6, diceSides: 6, startingCash: 0 }; +const LOG_LIMIT = 50; + +export function createInitialState(config = {}) { + return { + boardId: board.id, + config: { ...DEFAULT_CONFIG, ...config }, + status: 'lobby', + turnOrder: [], + turnIndex: 0, + currentTurn: null, + winnerId: null, + players: {}, + log: [], + }; +} + +export function reduce(state, action) { + switch (action.type) { + case 'JOIN': + return applyJoin(state, action); + case 'START_GAME': + return applyStart(state, action); + case 'ROLL': + return applyRoll(state, action); + case 'CHOOSE': + return applyChoose(state, action); + default: + throw new Error(`Unknown action type: ${action.type}`); + } +} + +/** What intents (if any) `playerId` may legally send right now — the single + * source of truth used by both server-side validation and client-side UI. */ +export function getLegalIntents(state, playerId) { + const player = state.players[playerId]; + if (!player) return []; + if (state.status === 'lobby') { + const count = Object.keys(state.players).length; + return count >= state.config.minPlayers ? ['REQUEST_START'] : []; + } + if (state.status !== 'active' || state.currentTurn !== playerId) return []; + return player.pendingChoice ? ['REQUEST_CHOOSE'] : ['REQUEST_ROLL']; +} + +export function isPlayersTurn(state, playerId) { + return state.status === 'active' && state.currentTurn === playerId; +} + +// --- internals ------------------------------------------------------------- + +function assertLobby(state) { + if (state.status !== 'lobby') throw new Error('Game is not in lobby'); +} + +function assertActive(state) { + if (state.status !== 'active') throw new Error('Game is not active'); +} + +function assertPlayersTurn(state, playerId) { + if (state.currentTurn !== playerId) throw new Error('Not your turn'); +} + +function applyJoin(state, { playerId, name, seat, color }) { + assertLobby(state); + if (state.players[playerId]) throw new Error('Player already joined'); + if (Object.keys(state.players).length >= state.config.maxPlayers) { + throw new Error('Game is full'); + } + if (Object.values(state.players).some((p) => p.seat === seat)) { + throw new Error('Seat already taken'); + } + const player = { + id: playerId, + name, + seat, + color, + position: board.startSpaceId, + cash: state.config.startingCash, + pendingChoice: null, + finished: false, + }; + return { ...state, players: { ...state.players, [playerId]: player } }; +} + +function applyStart(state) { + assertLobby(state); + const players = Object.values(state.players).sort((a, b) => a.seat - b.seat); + if (players.length < state.config.minPlayers) { + throw new Error('Not enough players to start'); + } + const turnOrder = players.map((p) => p.id); + return { ...state, status: 'active', turnOrder, turnIndex: 0, currentTurn: turnOrder[0] }; +} + +function applyRoll(state, { playerId, value }) { + assertActive(state); + assertPlayersTurn(state, playerId); + const player = state.players[playerId]; + if (player.pendingChoice) throw new Error('Resolve pending choice before rolling'); + const landed = walkForward(player.position, value); + return landOn(state, playerId, landed, { type: 'ROLL', value }); +} + +function applyChoose(state, { playerId, spaceId }) { + assertActive(state); + assertPlayersTurn(state, playerId); + const player = state.players[playerId]; + const pending = player.pendingChoice; + if (!pending) throw new Error('No pending choice'); + if (!pending.options.includes(spaceId)) throw new Error('Illegal choice'); + return landOn(state, playerId, spaceId, { type: 'CHOOSE' }); +} + +/** Shared landing logic for both a ROLL's terminal space and a CHOOSE's + * resolved branch: apply the space's effect, then either leave the turn + * open (choice pending), end the game (finish), or advance to the next player. */ +function landOn(state, playerId, spaceId, logMeta) { + const space = board.spaces[spaceId]; + const cashDelta = space.cash ?? 0; + const priorPlayer = state.players[playerId]; + const nextPlayer = { + ...priorPlayer, + position: spaceId, + cash: priorPlayer.cash + cashDelta, + pendingChoice: space.type === 'choice' ? { atSpace: spaceId, options: space.choices } : null, + finished: space.type === 'finish', + }; + + let newState = { ...state, players: { ...state.players, [playerId]: nextPlayer } }; + newState.log = appendLog(state.log, { + playerId, + landedOn: spaceId, + label: space.label, + cashDelta, + ...logMeta, + }); + + if (space.type === 'finish') { + newState.status = 'finished'; + newState.winnerId = playerId; + newState.currentTurn = null; + return newState; + } + + if (nextPlayer.pendingChoice) return newState; // turn stays with this player + + return advanceTurn(newState); +} + +function advanceTurn(state) { + const turnIndex = (state.turnIndex + 1) % state.turnOrder.length; + return { ...state, turnIndex, currentTurn: state.turnOrder[turnIndex] }; +} + +function appendLog(log, entry) { + const next = [...log, entry]; + return next.length > LOG_LIMIT ? next.slice(next.length - LOG_LIMIT) : next; +} diff --git a/shared/game.test.js b/shared/game.test.js new file mode 100644 index 0000000..7eb5b08 --- /dev/null +++ b/shared/game.test.js @@ -0,0 +1,97 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { createInitialState, reduce, getLegalIntents, isPlayersTurn } from './game.js'; + +function join(state, playerId, name, seat) { + return reduce(state, { type: 'JOIN', playerId, name, seat, color: '#000' }); +} + +test('lobby: join validation', () => { + let state = createInitialState(); + state = join(state, 'p1', 'Alice', 1); + assert.throws(() => join(state, 'p1', 'Alice again', 2), /already joined/); + assert.throws(() => join(state, 'p2', 'Bob', 1), /Seat already taken/); + assert.throws(() => reduce(state, { type: 'START_GAME' }), /Not enough players/); +}); + +test('full game: both choice points, race to finish', () => { + let state = createInitialState(); + state = join(state, 'p1', 'Alice', 1); + state = join(state, 'p2', 'Bob', 2); + state = reduce(state, { type: 'START_GAME' }); + + assert.equal(state.status, 'active'); + assert.deepEqual(state.turnOrder, ['p1', 'p2']); + assert.equal(state.currentTurn, 'p1'); + assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_ROLL']); + assert.deepEqual(getLegalIntents(state, 'p2'), []); + assert.equal(isPlayersTurn(state, 'p2'), false); + + // Not p2's turn yet. + assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', value: 3 }), /Not your turn/); + + // p1 rolls onto the first choice space; movement stops immediately even + // though only 1 of the roll's pips was needed. + state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 3 }); + assert.equal(state.players.p1.position, 'crossroads'); + assert.deepEqual(state.players.p1.pendingChoice, { atSpace: 'crossroads', options: ['career_1', 'edu_1'] }); + assert.equal(state.currentTurn, 'p1', 'turn stays with the player until they choose'); + assert.deepEqual(getLegalIntents(state, 'p1'), ['REQUEST_CHOOSE']); + + assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p1', value: 2 }), /Resolve pending choice/); + assert.throws(() => reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'finish' }), /Illegal choice/); + + state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'career_1' }); + assert.equal(state.players.p1.position, 'career_1'); + assert.equal(state.players.p1.pendingChoice, null); + assert.equal(state.currentTurn, 'p2', 'choosing resolves the turn'); + + // p2 takes the education branch. + state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 3 }); + assert.equal(state.players.p2.position, 'crossroads'); + state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'edu_1' }); + assert.equal(state.players.p2.position, 'edu_1'); + assert.equal(state.currentTurn, 'p1'); + + // p1: career_1 -> life_crossroads is exactly 6 steps; only the landed + // space's cash effect applies, not spaces merely passed through. + state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 6 }); + assert.equal(state.players.p1.position, 'life_crossroads'); + assert.equal(state.players.p1.cash, 0, 'passed-through Paycheck/Drama/Review do not apply'); + state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'investment_1' }); + assert.equal(state.players.p1.position, 'investment_1'); + assert.equal(state.players.p1.cash, -150); + assert.equal(state.currentTurn, 'p2'); + + // p2: edu_1 -> life_crossroads is also exactly 6 steps. + state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 6 }); + assert.equal(state.players.p2.position, 'life_crossroads'); + state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'relationship_1' }); + assert.equal(state.players.p2.position, 'relationship_1'); + assert.equal(state.currentTurn, 'p1'); + + // p1: investment_1 -> high_risk_choice is exactly 4 steps. + state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 4 }); + assert.equal(state.players.p1.position, 'high_risk_choice'); + state = reduce(state, { type: 'CHOOSE', playerId: 'p1', spaceId: 'safe_1' }); + assert.equal(state.players.p1.cash, -50); // -150 + 100 + assert.equal(state.currentTurn, 'p2'); + + // p2: relationship_1 -> high_risk_choice is also exactly 4 steps. + state = reduce(state, { type: 'ROLL', playerId: 'p2', value: 4 }); + assert.equal(state.players.p2.position, 'high_risk_choice'); + state = reduce(state, { type: 'CHOOSE', playerId: 'p2', spaceId: 'high_risk_1' }); + assert.equal(state.players.p2.cash, 500); + assert.equal(state.currentTurn, 'p1'); + + // p1: safe_1 -> finish is exactly 3 steps. First arrival ends the game. + assert.equal(state.status, 'active'); + state = reduce(state, { type: 'ROLL', playerId: 'p1', value: 3 }); + assert.equal(state.players.p1.position, 'finish'); + assert.equal(state.status, 'finished'); + assert.equal(state.winnerId, 'p1'); + assert.equal(state.currentTurn, null); + assert.deepEqual(getLegalIntents(state, 'p2'), []); + + assert.throws(() => reduce(state, { type: 'ROLL', playerId: 'p2', value: 1 }), /not active/); +});