Skip to content

A public Conquer server: learn, join and play in seven languages - #1

Merged
vejeta merged 114 commits into
masterfrom
claude/cool-feynman-97nv7c
Oct 2, 2026
Merged

vejeta merged 114 commits into
masterfrom
claude/cool-feynman-97nv7c

Conversation

@vejeta

@vejeta vejeta commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Summary

Turns Conquer Web into a public game server that new players can find, learn and join on their own, in seven languages, and that one person can run safely. Live at https://conquer.vejeta.com.

Finding and learning the game

  • Home page (/): a "war terminal" hero drawn from the real world map, the live game (turn, schedule in each visitor's own time, countdown, nations done, scores chart, world news with share links), how to join, key reference with a drawing of how to move, and a 40-second trailer.
  • Try it now, no account (/try/): Conquer compiled to WebAssembly (wasm/), a private practice world in the browser with its own turns.
  • Narrated videos in 7 languages with captions: the trailer, "Found your nation" (the game's own builder) and "Your first turn" (tools/tutorial/video.py, offline TTS: Qwen3-TTS, VoxCPM2; a Castilian narrator for Spanish).
  • First-turn tutorial (/tutorial.html) and player's guide (/guide.html), from real recorded sessions.
  • Coach beside the terminal: follows the first turn and the nation builder screen by screen (including the late-joiner points screen), with buttons that press each step's keys.
  • Moving: arrow keys and the number pad work in the browser (the game's own keys are y k u / h l / b j n); a drawing explains them.
  • Languages: English, Spanish, German, Portuguese (Brazil), Polish, Russian, Chinese across pages, terminal menu, coach and videos (web/i18n, conquer/i18n, checked by tools/check-i18n.py).

Joining and playing

  • Sign-up with an invite code (/signup.html + conquer-gate, a small Go service): the player creates their own web account (bcrypt, rate-limited, same-origin); the first time they play, the menu opens the game's own nation builder, unchanged, and links the account to the new nation. One nation per account; new nations until turn 5.
  • Personal accounts linked to a nation, administrator accounts for any nation; manage-players.sh (add, assign, --found, rename, remove, invite).
  • Game page with a phone key bar (switches to the builder's keys while it is open) and a terminal that keeps 80x24 without cutting the screen while it is refitted.
  • Player menu, private practice world per player, early turns once every nation is done, hall of fame and seasons (new-season.sh).

Running the server

  • deploy-to-vps.sh (first install, Let's Encrypt with renewal), update-vps.sh (backup, update, rebuild only when the game changed, checks; --branch to follow another branch), check-web-login.sh, set-nation-password.sh, offsite-backup.sh (daily encrypted copies to another machine), QUICK-REFERENCE.md.
  • Turns by cron in the configured time zone; players in the game are warned and saved, the world is backed up first, failures are reported; never overlapping.

Security

  • Game and ttyd run as an unprivileged user; the container drops all capabilities but seven used by the entrypoint, with no-new-privileges and memory/process limits.
  • Apache checks the accounts; identity headers from clients are dropped; ttyd only accepts the proxy; a Content Security Policy that allows the WebAssembly game; less without shell escapes.
  • Fixed an auth bypass on the VPS (a later <Location /> overrode the /play/ password); CI enforces the order.

Engine fixes

  • conquer/patches/: crashes on missing users and long command lines, new worlds limited to 256x256 sectors (the 1024x512 default world broke coordinates; it is now 128x128), the administrator login set at build time.
  • Build prefix for the help files and conqsort; conqowner (only one nation could ever be added) and conqpasswd (set a nation's password).

Testing

  • CI (.github/workflows/ci.yml): ShellCheck, translations, Apache and Dockerfile checks, pytest, and a smoke test of the whole stack: pages, sign-in, sign-up, menu, turns (manual, early, cron, time zone), practice world, the nation builder played for real at turn 1 and later, a new season, least privilege.
  • pytest (tests/): the coach follows every recorded screen in every language; narration and translations are complete; in Chromium, the coach, key bar, terminal size, arrow keys, schedule in local time, the WebAssembly game under the server's security policy, and the sign-up video.

Deploying

  • On the VPS: sudo ./update-vps.sh; after this merge, sudo ./update-vps.sh --branch master once.
  • TURN_SCHEDULE_LABEL can be left empty: the schedule is shown from TURN_SCHEDULE and TZ.
  • Off-site copies: see "Copies on another machine" in QUICK-REFERENCE.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_012PbWLvRqPFczMuZwP12Gte

start-local.sh called a missing setup-local-certs.sh and apache/Dockerfile
copied a non-existent certs/selfsigned directory, so a fresh local setup
could not start. Add the certificate generator, mount certificates at
runtime instead of baking them into the image, and remove the committed
self-signed key from the index (it was already covered by .gitignore).
- Build Conquer with PREFIX=/opt/conquer so the compiled-in data and
  binary paths exist (help files and conqsort were not found before).
- Keep the live world in data/lib on the host, mounted into the
  container and seeded from the default world on first start, so
  rebuilds no longer wipe player progress. World scripts now operate on
  data/lib and keep the same backup archive layout.
- Run turn updates (conqrun -x) from cron with a configurable schedule,
  weekly by default (TURN_SCHEDULE, TZ). Retry while players are logged
  in, since conqrun refuses to update then.
- Replace the bare game prompt with a menu: play, how to join, key
  reference, scores and help, plus last update and schedule. Check the
  terminal is at least 80x24 before starting the game.
- Serve ttyd under /play and add an authenticated health check.
- Add admin helpers: add-nation.sh (conqrun -a) and run-turn.sh.
Players now land on a page explaining the game, how to join, how turns
work and the main keys, with a Play button to /play/. Apache serves the
page at / and proxies only /play/ (HTTP and WebSocket) to ttyd, locally
and on the VPS, where deploy-to-vps.sh installs the page.
rebuild.sh never sourced the env file, so rebuilt containers fell back
to the compose defaults (including default credentials). It also used
an undefined RUNNING_PROD and a non-existent docker-compose.production.yml,
and --quick used 'restart', which does not apply configuration changes.

Detect the environment from running containers or available config,
export the env file, and use 'up -d --force-recreate' for --quick.
Conquer is built with CHECKUSER: nations belong to Unix uids and
'conqrun -a' refuses a second nation for the same uid unless it owns
the god nation. In the container every player and the admin run as
root, while the shipped world's god nation belonged to uid 1000, so
add-nation.sh failed immediately with 'You may only have one nation'.

Add conqowner, built against the game headers, to read and set the god
nation owner, and run it idempotently on container start. It also warns
when a world is larger than the engine supports.
The game prints 'file main.c: line N: nation[..] army[..] ...' repair
messages before the login prompt. Filter them from stderr byte by byte,
since the login prompts are also written there without a newline, and
give a clearer message after a failed login.
The shipped world was 1024x512, but Conquer stores army and capital
coordinates as unsigned char. Positions wrapped around: armies were
dropped into the sea on every start and update, and new nations saw an
empty map because the view centred on the wrong sector.

Generate a new 128x128 world with the default NPC nations, document the
256x256 limit in generate-world.sh and treat an empty .userlog as normal
in health-check.sh.
The game container writes status.json (season, turn, last update,
schedule label and nation scores from 'conquer -s') at startup and after
every turn update. Apache serves it under /status/: through an alias in
the local setup, and on the VPS the container writes it straight into
the site's web root (STATUS_DIR, set by deploy-to-vps.sh).

The landing page renders it as a 'Current game' section and keeps the
section hidden when no status has been published yet.
'Play now' now opens game.html, which embeds the /play/ terminal in a
same-origin frame and adds a key bar (movement pad, Esc, Enter, Space,
Backspace, Ctrl-L redraw and common commands) that types through ttyd's
xterm instance. The bar is shown by default on touch devices, can be
toggled and remembers the choice; a Keyboard button opens the device
keyboard for names and passwords.

Conquer needs an 80x24 screen, so on small screens the page shrinks
the terminal font until it fits (re-applied after ttyd sets its own
font size), and phones in landscape get the keys in a side column.

Framing is limited to the same origin: X-Frame-Options SAMEORIGIN and
CSP frame-ancestors 'self' replace DENY on the VPS.
- Build Conquer and conqowner in a build stage; the runtime image only
  installs what the game, menu and scheduler use (libncurses6, cron,
  tzdata, curl, less), without compilers or git.
- Replace building ttyd from its moving main branch with the official
  static 1.7.7 release binary, verified by SHA-256 (amd64 and arm64;
  falls back to the build container's architecture when TARGETARCH is
  not set by the legacy builder).
- Allow pinning the game sources with CONQUER_REPO/CONQUER_REF.
- Drop the unused credential build arguments (flagged by the BuildKit
  secrets check) and the unused conqueruser account; credentials are
  runtime environment variables.
docker-compose.yml duplicated docker-compose.local.yml with different
defaults (credentials, client limits) and its own Apache config
(extra.conf) that had drifted from local.conf. Remove both duplicates:
the Apache image now bakes in local.conf, which the local compose file
still mounts for quick edits, and the stop/logs scripts and README use
the environment-aware scripts instead of bare docker-compose commands.

Also drop a LoadModule line that the base httpd.conf already loads, and
document the game page and the public status in the README.
@vejeta vejeta changed the title Fix local HTTPS setup and stop tracking the private key Make Conquer Web usable for players: landing page, turns, persistence, mobile Sep 26, 2026
Guard the 'rm -rf "$DOCKER_LIB_DIR"/*' calls with ${VAR:?} so an empty
variable can never expand to /*, split 'local x=$(...)' declarations
so command failures are not masked, and drop an 'ls | grep' listing.
The lint job runs ShellCheck on all scripts and BuildKit's Dockerfile
checks. The smoke job builds and starts the local stack with docker
compose and verifies the landing and game pages, ttyd authentication,
the god nation owner fix, the public status and a full turn update.
The first CI run showed that vejeta/conquer has no 'main' branch (its
default is master), so the image build failed at checkout. Leave
CONQUER_REF empty by default, check out only an explicit ref, and log
the commit being built. Also move CI to actions/checkout@v5 (Node 20
deprecation).
A player who left the game open would block the weekly update for the
whole retry window, since conqrun refuses to update while anyone is in
the game. Every turn update now:

- blocks new games in the menu, warns players in the game every minute
  and, after TURN_GRACE_MINUTES (default 5), ends their session with
  SIGTERM; the game's hangup handler saves their orders, and the menu
  tells them they were disconnected;
- backs up the world to data/backups before running, keeping the last
  TURN_BACKUPS archives (default 10); restore-world.sh finds them;
- records the result, shows a notice in the menu and on the landing
  page when it failed, and optionally posts to TURN_WEBHOOK_URL
  (Slack/Mattermost 'text' and Discord 'content');
- never overlaps another update (flock), and a container restart
  clears a stale 'update running' flag.

The menu also restores the terminal after a game is ended by a signal,
restore-world.sh creates its backup directory when needed, and the CI
smoke test checks the published turn state and the pre-turn backup.
ttyd logs its configuration at its default notice level, including the
basic auth credential (base64 of user:password), so anyone with access
to docker logs or the journal could read it. Run ttyd at log level 3
(errors and warnings; TTYD_LOG_LEVEL to override) and make CI fail if
the credential shows up in the container logs.

Also mark the local self-signed certificate as a leaf (CA:FALSE), which
silences Apache's 'server certificate is a CA certificate' warning.
Everything in the game container ran as root, including ttyd, which is
exposed to the internet. Now:

- The game is compiled as user 'conquer' (the Makefile bakes the build
  user into the binaries as the game administrator), so turn updates
  and nation creation work without root. CONQUER_UID (default 1000)
  sets its uid; setup-environment.sh fills in the invoking user's uid
  (never 0) so data/ stays readable by the host scripts.
- The entrypoint uses root only to prepare the volumes (fixing their
  ownership), the god nation owner and cron, then execs ttyd through
  setpriv as PID 1 under the game user.
- conquer-turn and conquer-status re-exec themselves as the game user
  when started as root (e.g. docker exec), so they never leave
  root-owned files the game cannot write. add-nation.sh and run-turn.sh
  exec as the game user.
- /proc/1/fd/1 cannot be opened once PID 1 is unprivileged, so cron
  jobs log through a FIFO that the entrypoint forwards to the container
  output. Runtime flags moved to /run/conquer.

CI now checks that PID 1 is unprivileged, that data/ belongs to the
game uid, and runs a real cron-scheduled turn update.
All players shared one site username and password, so access could not
be revoked per player and fail2ban could not tell them apart.

- Apache now authenticates /play/ against an htpasswd file with one
  account per player and passes the account name to ttyd in
  X-WEBAUTH-USER, after dropping any client-supplied copy of that
  header. ttyd runs with --auth-header instead of a shared credential
  and refuses requests that did not come through the proxy.
- manage-players.sh adds, resets, removes and lists accounts (bcrypt,
  generated passwords on request); add-nation.sh offers to create the
  player's account right after the nation. TTYD_USERNAME/TTYD_PASSWORD
  now seed the administrator's account on first start (start-local.sh,
  deploy-to-vps.sh).
- The menu shows who is signed in, and an account named like a nation
  opens that nation directly, so the player only types the nation
  password.
- fail2ban's ttyd-auth filter also matches Apache's authentication
  failures (AH01617, AH01618).

On the VPS vhost a later '<Location />' block with 'Require all granted'
would have overridden the password for /play/ (reproduced: 200 without
credentials). That no-op block is removed, the /play/ check is the last
<Location> of each vhost with a warning, and CI fails if another
<Location> follows it. CI also checks wrong passwords, forged headers
and direct access to ttyd.
Add conquer-next-turn, which computes the next run of TURN_SCHEDULE in
the container's time zone (numbers, *, lists, ranges, steps and cron's
day-of-month/day-of-week OR rule; checked against croniter across time
zones, including February 29). The public status publishes it as
next_turn, the landing page shows it in the visitor's local time with a
live countdown, and the player menu shows it next to the schedule.

CI checks that a weekly schedule in Europe/Madrid yields Sunday 20:00
Madrid time.
The public status now includes the last three editions of the game's
newspaper (news<turn-1> and older), parsed into sections and items. The
section listing every captured sector with its coordinates is left out.
The landing page shows the newest edition and keeps older ones
collapsed, with long sections trimmed to a dozen items.

The news JSON reaches awk through the environment, since awk -v would
interpret its backslash escapes.
English stays in the HTML, so the page works without JavaScript; a
Spanish dictionary replaces the marked texts. The language comes from
?lang=, then the visitor's saved choice, then the browser language, and
an EN/ES switch changes it; the texts built by the scripts (countdown,
news headings, notices, dates) follow it. The game itself and its news
stay in English, which the page says.

The status object is no longer kept in a global named 'status':
window.status is a legacy string property, so the scores and news
vanished when switching language.
The previous step leaves the game container scheduled every minute. When
its turn update rewrote status.json while the container was being
recreated, the check took that rewrite for the new container's status
and read the every-minute schedule's next turn (failure on 1828372).
Wait for a schedule label that only the recreated container publishes.
The engine fixes for crashes on unknown users, long -d/-n arguments,
maps larger than 256x256 and the build-time LOGIN are not in the game
repository yet. Ship them as patches and apply them after cloning the
sources: patches already upstream are skipped, and one that no longer
applies fails the build.
Any signed-in account could type any nation name at the game prompt, so
the nation password was the only thing between players. Accounts are now
linked to a nation in data/lib/.players (kept with the world and its
backups): the menu opens that nation directly and refuses accounts
without one. Administrator accounts may open any nation, god included.

manage-players.sh gains --nation/--admin, an assign command and lists
the nation of each account; add-nation.sh links the new nation to the
account it creates. The first administrator account is created as such.
Worlds without an assignment file keep the old open behaviour.
With weekly turns, players who finish their orders early have to wait
for the schedule. When a player quits the game, the menu now asks
whether their orders are done (option 6 changes it later); the banner
and the landing page show how many nations are done. Once every nation
assigned to a player account is done, the menu starts the update right
away, and the schedule stays as the deadline.

conquer-ready keeps the marks in lib/.ready, tagged with the turn so
old marks never count; conquer-turn --if-ready re-checks them under the
update lock, so players finishing at the same time run one update.
TURN_EARLY=off keeps the count without early updates.
After quitting the game, the question whether the orders are done
appeared below the leftover login text and the "quit & save" line.
web/guide.html, linked from the landing page: an invitation to play,
the history of Conquer from its first Usenet post in 1987 to the GPL
release and the browser version, how a turn works, a step-by-step
tutorial with screenshots of a real game (landing page, menu, nation
password, map, reports, marking orders done, an early turn update and
the phone key bar), advice for new rulers and returning veterans,
tournament formats with their turn schedules, and a season plan towards
the 40th anniversary on 26 October 2027.
video.py --video trailer renders web/tutorial/trailer.<language>.mp4 from
narration/trailer/<language>.json, over screens of the first turn; a shot
with a caption shows the screen with a title only (no step, no keys).
The Spanish script is the first one, pending review.
- web/tutorial/found-nation.es.mp4: the nation builder video in Spanish,
  by the Castilian narrator (the sign-up page offers it).
- narration/trailer/<language>.json: the 40-second trailer in the 7
  languages, from the first turn's opening on Usenet.
- The home page shows "Watch the trailer" once a trailer exists in its
  language or in English, and plays it in a dialog with captions.
- The narration tests cover the trailers.
The first turn's video in Spanish, with the new opening (the computer
centres of the universities, a world of epic fantasy), and the 34-second
trailer the home page plays.
vejeta added 2 commits October 1, 2026 01:54
Speech takes most of a render (about 20 minutes a video on a CPU), and a
restart of the machine lost it all. Each sentence is now kept in
~/.cache/conquer-tts/speech, by voice and text; the model is loaded only
when a sentence is missing, and editing one sentence speaks only that one
again.
The three narrated videos in German and Brazilian Portuguese, with the
new texts (a world of epic fantasy, the computer centres of the
universities, the number pad and arrow keys).
The three narrated videos in Russian, and in English the nation builder
video and the trailer (the English first turn follows).
The English first turn with the new texts, and the three narrated videos
in Chinese: builder, first turn, trailer.
The nation builder video and the trailer in Polish (VoxCPM2 narrator);
the Polish first turn follows.
The last of the narrated videos: every language now has the nation
builder video, the first turn and the trailer.
The website now lives on the game server, which follows this branch
(update-vps.sh): one site, with the server's real security policy and
headers. The temporary workflow that published web/ to GitHub Pages is
removed. The pages still work from another static host (server.js), and
the READMEs say so without naming GitHub Pages.
@vejeta vejeta mentioned this pull request Oct 1, 2026
vejeta added 9 commits October 1, 2026 15:30
- rename NAME NEWNAME: the password hash does not depend on the name, so
  the account keeps its password and its nation (or pending founding).
- assign NAME --found: the account founds a new nation in the game's
  builder the next time it plays ("account:+", as after signing up).
The schedule shown to players was free text ("Daily at 20:00 UTC") that
could disagree with the real one: turns ran at 20:00 in Madrid (TZ) while
the page said UTC. Now, when TURN_SCHEDULE_LABEL is empty (the new
default), the entrypoint works the schedule out from TURN_SCHEDULE and TZ
(daily or weekly) for the menu, and status.json says so (schedule_auto,
repeat, tz). The home page then shows it in the visitor's language and
local time, from the next turn's time: "cada día a las 14:00, en tu hora"
in New York. A label, if set, is still shown as written.
The copies update-vps.sh and the turn updates keep stay on the VPS, and
would be lost with it. offsite-backup.sh packs the world (taken inside the
game container under the turn lock, so never halfway through an update),
the web accounts, the settings and the Apache site, optionally encrypts
the copy (openssl AES-256, BACKUP_PASSWORD_FILE), sends it with rsync over
SSH (or to a local directory, another disk) and keeps the newest
BACKUP_KEEP there. --setup-key makes its SSH key, --install-cron sends one
every day, --decrypt opens a copy. It refuses to send a copy without the
world. Tested against an SSH target and a local directory.
A nation founded after turn 1 gets a screen the coach did not know, right
after the class: "N points added for starting late ... PRESS ANY KEY".
The builder coach now has a step for it in the 7 languages; the coach
still moves from the class to the alignment when it does not show.
found-nation-late.steps.json records the builder on a world one turn
later; the coach tests follow both recordings, and the game test plays
the builder at turn 1 and one turn later.
Ending a season took season-end.sh, a backup, a new world and fixing every
account by hand. new-season.sh puts the final scores in the hall of fame,
keeps a copy of the old world (taken under the turn lock) and the
accounts, and runs conquer-new-world in the container: the default world,
or --world FILE.tar.gz, replaces the old one in place; every account
founds a new nation the next time it plays (administrators stay so);
unused invite codes stay; the orders-done marks, turn state and score
history start again. It asks for confirmation (--yes skips it) and is
refused while a turn update runs or someone is in the game. The new world
is checked before the old one is removed.

CI ends the smoke job with a new season: turn 1, accounts back to the
builder, the season in the hall of fame, the old world kept.
The container had Docker's default capabilities. It now drops them all and
keeps only what the entrypoint needs as root, to fix the volumes' owners,
set up cron and drop to the game user: CHOWN, DAC_OVERRIDE, FOWNER,
SETUID, SETGID, KILL, AUDIT_WRITE. no-new-privileges is set, and a
runaway game or a flood of sessions is bounded by CONQUER_PIDS_LIMIT (512)
and CONQUER_MEM_LIMIT (1g). ttyd, the game and the sign-up service run as
the game user with no capability; CI checks that.
A "Play now" table at the top: the live server, the practice world with
no account, how to get an invite, its languages; other servers can be
added with a pull request.
Three frames from the English videos (trailer, nation builder, first
turn), each with a play badge and its length, linking to the video on
conquer.vejeta.com: GitHub does not play videos kept in the repository,
and the site has them in every language with captions. The images are
docs/img/*.jpg, about 100 KB together.
The server follows the branch it has checked out. --branch NAME switches
it once, refusing when the checkout has local changes, and later updates
follow the new branch. After the work branch is squash-merged, "--branch
master" moves the server to master without rebuilding the game, since the
tree is the same.
@vejeta vejeta changed the title Make Conquer Web usable and inviting: landing page, tutorial, try-in-browser, turns, accounts A public Conquer server: learn, join and play in seven languages Oct 2, 2026
@vejeta
vejeta merged commit 18ca280 into master Oct 2, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant