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):
git submodule update --initcp etc/lacuna.example.conf etc/lacuna.conf(gitignored; the example already points at the compose service names)export LACUNA_MYSQL_ROOT_PASSWORD=lacuna CONTAINER_REGISTRY_URL=<registry>: compose refuses to start without both.v2-apipulls from a private registry; without access, set any value and start services by name.docker compose build serverdocker compose up -d mysql memcached beanstalk- On Linux,
./data/memcachedis created root-owned by the daemon and thememcachedimage runs as uid 11211, so it crash-loops withfailed to open file for mmap: Permission deniedand the tests fail.sudo chmod 777 data/memcached, thendocker restart memcached. docker exec mysql mysql -uroot -p$LACUNA_MYSQL_ROOT_PASSWORD -e 'create database lacuna'docker compose run --rm --no-deps -w /home/lacuna/server/bin/setup server perl init_lacuna.pldocker compose up -d --no-deps server(healthy once/starman_pinganswers)
Services defined in docker-compose.yml (no ports are published to the host):
mysql: mysql:5.5, root password fromLACUNA_MYSQL_ROOT_PASSWORD, data in./data/mysqlmemcached: session/cache store, persisted to./data/memcachedbeanstalk(+beanstalk-console): job queuephpmyadminv2-api: newer TypeScript API, image${CONTAINER_REGISTRY_URL}/tlecommunity/v2-apiserver: the Perl app, runsbin/start_lacuna.shserver-{building,ship,captcha}-scheduler: Perl scheduler processesserver-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 withbash) setsLACUNA_TEST_SERVER_URL,cds to the server root and runsprove -Ilib -It(the-Itmakesuse TestHelperresolve regardless of each file's ownuse libline). Its default URL ishttp://server:5000/, which makes the server see the container's network address instead of 127.0.0.1, sot/010_Empire.tfails 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 defaultbin/run_tests.shrun — slow),t/middleware/
No .perltidyrc/.perlcriticrc — no enforced style/lint tooling.
Architecture #
lib/Lacuna.pm— app singleton. Loadsetc/lacuna.confviaConfig::JSON; exposesLacuna->db,->config,->cache,->queue. UsesModule::Find::useallto load the whole namespace.lib/L.pm,lib/LD.pm,lib/LR.pm— REPL/one-liner dev shortcuts, not runtime app code.Lis aLacunasubclass for one-liners (perl -ML -E '...');LDAUTOLOADs ontoLacuna->db(LD->empire(2));LRdispatches intoLacuna::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/<version>/ for a full install, .../upgrade/<old>-<new>/
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 <old> <new> (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.plapplies pending upgrades on an existing, tracked database, or installs from scratch on a genuinely empty one; idempotent, safe on every boot. Run automatically bybin/start_lacuna.shbefore the app starts serving.bin/check_db_migration.plexits non-zero if the database isn't at the version the code expects, without touching it. Run automatically bybin/startdev.sh, so a dev server refuses to boot against a stale schema — runbin/migrate_db.plby hand to fix.bin/setup/init_lacuna.pl(fresh dev bootstrap / full star-map reset) usesLacuna::DB::Migrate::reinstall, which drops and redeploys the whole schema from the currentResultclasses 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 <version> — 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 queueLacuna::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.