Early version
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 3.2 MiB |
|
After Width: | Height: | Size: 4.6 MiB |
@@ -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
|
||||
<hostdev mode='subsystem' type='usb' managed='yes'>
|
||||
<source>
|
||||
<vendor id='0x303a'/>
|
||||
<product id='0x831a'/>
|
||||
</source>
|
||||
</hostdev>
|
||||
```
|
||||
|
||||
**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 <ip>`
|
||||
- `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 <user>
|
||||
# files:cleanup
|
||||
# maintenance:mode --on / --off
|
||||
# maintenance:repair
|
||||
# trashbin:cleanup --all-users
|
||||
# app:list, app:enable <app>, app:disable <app>
|
||||
# security:bruteforce:reset <ip>
|
||||
```
|
||||
|
||||
### 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 <ip>`. 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
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||