Perl game server for TLE Community
server CLAUDE.md
11 kB
Markdown
at main

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
  2. cp etc/lacuna.example.conf etc/lacuna.conf (gitignored; the example already points at the compose service names)
  3. export LACUNA_MYSQL_ROOT_PASSWORD=lacuna CONTAINER_REGISTRY_URL=<registry>: compose refuses to start without both. v2-api pulls from a private registry; without access, set any value and start services by name.
  4. docker compose build server
  5. docker compose up -d mysql memcached beanstalk
  6. 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.
  7. docker exec mysql mysql -uroot -p$LACUNA_MYSQL_ROOT_PASSWORD -e 'create database lacuna'
  8. docker compose run --rm --no-deps -w /home/lacuna/server/bin/setup server perl init_lacuna.pl
  9. 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, cds 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/<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.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 <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 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.