# CLAUDE.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. ## Project overview Perl backend for **The Lacuna Expanse**, a space-empire strategy MMO. Built on Moose (OO), DBIx::Class (ORM), and Plack/PSGI with a JSON::RPC::Dispatcher API layer. ## Repo layout / submodules `missions` is a git submodules (see `.gitmodules`), pointing to `https://tangled.org/tlecommunity.com/missions`. ## Development environment Docker Compose is the supported way to run this stack. Perl dependencies are managed with Carton: `cpanfile` lists them and the committed `cpanfile.snapshot` pins them; the root `Dockerfile` (perl:5.40) runs `carton install --deployment` into `./local`. Setup (from the repo root; `npm` only provides the prettier scripts in `package.json`): 1. `git submodule update --init` 1. `cp etc/lacuna.example.conf etc/lacuna.conf` (gitignored; the example already points at the compose service names) 1. `export LACUNA_MYSQL_ROOT_PASSWORD=lacuna CONTAINER_REGISTRY_URL=`: compose refuses to start without both. `v2-api` pulls from a private registry; without access, set any value and start services by name. 1. `docker compose build server` 1. `docker compose up -d mysql memcached beanstalk` 1. On Linux, `./data/memcached` is created root-owned by the daemon and the `memcached` image runs as uid 11211, so it crash-loops with `failed to open file for mmap: Permission denied` and the tests fail. `sudo chmod 777 data/memcached`, then `docker restart memcached`. 1. `docker exec mysql mysql -uroot -p$LACUNA_MYSQL_ROOT_PASSWORD -e 'create database lacuna'` 1. `docker compose run --rm --no-deps -w /home/lacuna/server/bin/setup server perl init_lacuna.pl` 1. `docker compose up -d --no-deps server` (healthy once `/starman_ping` answers) Services defined in `docker-compose.yml` (no ports are published to the host): - `mysql`: mysql:5.5, root password from `LACUNA_MYSQL_ROOT_PASSWORD`, data in `./data/mysql` - `memcached`: session/cache store, persisted to `./data/memcached` - `beanstalk` (+ `beanstalk-console`): job queue - `phpmyadmin` - `v2-api`: newer TypeScript API, image `${CONTAINER_REGISTRY_URL}/tlecommunity/v2-api` - `server`: the Perl app, runs `bin/start_lacuna.sh` - `server-{building,ship,captcha}-scheduler`: Perl scheduler processes - `server-script-scheduler`: ofelia-based cron, mounts `./schedule.ini` Inside `server`, `bin/start_lacuna.sh` runs `bin/migrate_db.pl` and then `start_server -- starman --workers 7 --preload-app lacuna.psgi`, so restart the container after changing `lib/`. `bin/startdev.sh` is the plackup alternative (`plackup --env development --app lacuna.psgi`). ## Testing Tests are plain `Test::More`/prove style (no `.proverc`; `Test::Class::Moose` is installed but not actually used as a base class). They are **integration tests that hit a live server over HTTP**, not isolated unit tests — via `t/TestHelper.pm` (LWP::UserAgent, plus helpers like `generate_test_empire`, `build_infrastructure`, `clear_all_test_empires`). `t/TestHelper.pm::post` sends its JSON-RPC calls to `$LACUNA_TEST_SERVER_URL` (default `http://localhost:5000/` — the plackup server _inside_ the `server` container), **not** `Lacuna->config->get('server_url')` (which points at the deployed server). So the suite must run inside `server` with the compose stack up. - Single file: `docker exec server prove -Ilib -It t/010_Empire.t` (the container's workdir is the server root) - Full suite: `docker exec -e LACUNA_TEST_SERVER_URL=http://localhost:5000/ server bash bin/run_tests.sh` - `bin/run_tests.sh` (not executable; run it with `bash`) sets `LACUNA_TEST_SERVER_URL`, `cd`s to the server root and runs `prove -Ilib -It` (the `-It` makes `use TestHelper` resolve regardless of each file's own `use lib` line). Its default URL is `http://server:5000/`, which makes the server see the container's network address instead of 127.0.0.1, so `t/010_Empire.t` fails its captcha-seeded empire creation; override it as above. - Test locations: `t/*.t` (numbered, e.g. `t/010_Empire.t`), `t/bugs/*.t` (regression tests, e.g. `t/bugs/0003_PurchaseTooManyShips.t`), `t/long_running/` (excluded from the default `bin/run_tests.sh` run — slow), `t/middleware/` No `.perltidyrc`/`.perlcriticrc` — no enforced style/lint tooling. ## Architecture - **`lib/Lacuna.pm`** — app singleton. Loads `etc/lacuna.conf` via `Config::JSON`; exposes `Lacuna->db`, `->config`, `->cache`, `->queue`. Uses `Module::Find::useall` to load the whole namespace. - **`lib/L.pm`, `lib/LD.pm`, `lib/LR.pm`** — REPL/one-liner dev shortcuts, not runtime app code. `L` is a `Lacuna` subclass for one-liners (`perl -ML -E '...'`); `LD` AUTOLOADs onto `Lacuna->db` (`LD->empire(2)`); `LR` dispatches into `Lacuna::RPC::*` to simulate API calls from the command line (`LR->call(Empire => view_profile => ...)`). ### DB layer `lib/Lacuna/DB.pm` extends `DBIx::Class::Schema` and auto-loads Result classes via `load_namespaces`. Convenience finders (`Lacuna->db->empire`, `->body`, `->building`, ...) wrap `resultset(...)->find/search`. Result classes live under `lib/Lacuna/DB/Result/`: `Empire.pm`, `Building.pm` + `Building/*` (per building type), `Map.pm`/`Map/*`, `Ships.pm`/`Ships/*`, `Fleet/*`, `Spies.pm`, `Alliance.pm`, `Propositions/*`, `Laws/*`, `Log/*`. `Lacuna::DB::Result::Building` is the abstract base, using DBIx::Class `DynamicSubclass` — the `class` DB column picks the blessed subclass. It defines shared columns/timers plus overridable `use constant` defaults (`time_to_build`, `*_consumption`, `*_production`, `controller_class`, ...). Concrete buildings (e.g. `lib/Lacuna/DB/Result/Building/Energy/Fission.pm`) extend an intermediate category class and override constants/hooks via Moose `before`/`around` — a template-method pattern. `controller_class` points each building at its matching RPC class. #### Schema migrations Schema changes are managed with `DBIx::Class::DeploymentHandler` (`Lacuna::DB::Migrate`), independent of the game's own `$Lacuna::VERSION` — `$Lacuna::DB::VERSION` is the schema version, bumped only when a migration is needed. Deploy and upgrade SQL lives under `var/ddl/` (`var/ddl/MySQL/deploy//` for a full install, `.../upgrade/-/` for a diff) and is committed to the repo — it's generated once and then frozen, not regenerated at deploy time. To make a schema change: edit the `Result` class(es), bump `$Lacuna::DB::VERSION` in `lib/Lacuna/DB.pm`, run `perl bin/prepare_db_migration.pl ` (needs a DB connection — run it inside the `server` container) to generate the upgrade SQL under `var/ddl/`, and commit the generated files alongside the code change. - `bin/migrate_db.pl` applies pending upgrades on an existing, *tracked* database, or installs from scratch on a genuinely empty one; idempotent, safe on every boot. Run automatically by `bin/start_lacuna.sh` before the app starts serving. - `bin/check_db_migration.pl` exits non-zero if the database isn't at the version the code expects, without touching it. Run automatically by `bin/startdev.sh`, so a dev server refuses to boot against a stale schema — run `bin/migrate_db.pl` by hand to fix. - `bin/setup/init_lacuna.pl` (fresh dev bootstrap / full star-map reset) uses `Lacuna::DB::Migrate::reinstall`, which drops and redeploys the whole schema from the current `Result` classes rather than applying migrations. **Safety rail:** `bin/migrate_db.pl` distinguishes "no version-tracking table" from "empty database" by checking whether the database has any tables at all. If it has tables but isn't tracked, it refuses to run rather than guessing — install DDL drops and recreates every table, which is only correct for a genuinely empty database, and "no version table" alone doesn't prove that (this is exactly how a September 2026 incident wiped production: the live DB predated this tooling and had no version table, so it was treated as fresh and reinstalled over). A database in that state (predates this tooling, or was just restored from a backup) has to be stamped once, deliberately, by a human who has confirmed what version its schema actually matches: `perl bin/stamp_db_version.pl ` — this only creates the version-tracking table and records that one row, it never touches any other table. To add a new building, follow the checklist in `info/add-a-building.md` (DB::Result class, entry in `Lacuna::DB::Result::Building` class types, RPC class, entry in `bin/lacuna.psgi`, `Lacuna::DB::Result::Medals`, images, and — if buildable — `Lacuna::Constants::BUILDABLE_CLASSES`; plus `SPACE_STATION_MODULES` if it's a station module). ### RPC/API layer `Lacuna::RPC` extends `JSON::RPC::Dispatcher::App`. It resolves sessions (`get_session`, via `Lacuna::Session`), checks body/building/sitter permissions, and rate-limits via `Lacuna->cache`. `lib/Lacuna/RPC/*.pm` = top-level game endpoints (`Empire`, `Body`, `Map`, `Alliance`, `Stats`, ...). `lib/Lacuna/RPC/Building/*.pm` (156 files) = one thin controller per building type, declaring `app_url`/`model_class` and inheriting generic behavior from `lib/Lacuna/RPC/Building.pm`. RPC methods take `($self, $session_id, ...)`, whitelist themselves via `register_rpc_method_names`, and return a hashref including `status => $self->format_status($session)`. Separate from the JSON-RPC API, `lib/Lacuna/Web.pm` (extends `Plack::Component`) does simple path-based dispatch (`/foo/bar` → `www_foo_bar`) for HTML-facing endpoints under `lib/Lacuna/Web/*.pm` (`Admin`, `Pay`, `Announcement`, ...). `bin/lacuna.psgi` is where RPC/Web modules get mounted into the Plack app. ### AI subsystem `lib/Lacuna/AI.pm` — base NPC-empire behavior (colony build-out from a plan list, hourly ticks, spy training, fleet building, attacks via `start_attack`/`attack_with_ships`, repairs). Concrete factions in `lib/Lacuna/AI/{Cult,DeLambert,Diablotin,Jackpot,Saben,Trelvestian}.pm` subclass it with faction-specific tuning. ### Support modules - `Lacuna::Cache` — Memcached::libmemcached wrapper (JSON-serializing get/set/increment/delete, reconnect-on-failure) - `Lacuna::Constants` — game-wide constants (`INFLATION`, `GROWTH`, `FOOD_TYPES`, `ORE_TYPES`, `BUILDABLE_CLASSES`, `SPACE_STATION_MODULES`) - `Lacuna::Queue` / `Lacuna::Queue::Job` — Beanstalk-backed background job queue - `Lacuna::Session` — player session objects backed by the DB ### Docs and test data - `bin/puppet/*.js` — plain JSON data files (not Puppeteer) consumed by companion Perl scripts (`ships_insert.pl`, `station_setup.pl`, `setup_colony.pl`) to bulk-seed ships/buildings/stations for testing.