Repository navigation
A public Conquer server: learn, join and play in seven languages - #1
Merged
Merged
Conversation
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.
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.
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.
Closed
- 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
/): 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/): Conquer compiled to WebAssembly (wasm/), a private practice world in the browser with its own turns.tools/tutorial/video.py, offline TTS: Qwen3-TTS, VoxCPM2; a Castilian narrator for Spanish)./tutorial.html) and player's guide (/guide.html), from real recorded sessions.web/i18n,conquer/i18n, checked bytools/check-i18n.py).Joining and playing
/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.manage-players.sh(add, assign,--found, rename, remove, invite).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;--branchto follow another branch),check-web-login.sh,set-nation-password.sh,offsite-backup.sh(daily encrypted copies to another machine),QUICK-REFERENCE.md.Security
no-new-privilegesand memory/process limits.lesswithout shell escapes.<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.conqsort;conqowner(only one nation could ever be added) andconqpasswd(set a nation's password).Testing
.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.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
sudo ./update-vps.sh; after this merge,sudo ./update-vps.sh --branch masteronce.TURN_SCHEDULE_LABELcan be left empty: the schedule is shown fromTURN_SCHEDULEandTZ.QUICK-REFERENCE.md.🤖 Generated with Claude Code
https://claude.ai/code/session_012PbWLvRqPFczMuZwP12Gte