Compare commits

..

72 Commits

Author SHA1 Message Date
08fe8c9c53 docs(files): record the general.host trap + that a green monitor proves nothing
Propagate the bare-hostname fix (10b86ae) to the docs that still said "three
traps", and write down the meta-lesson it exposed.

The bug was invisible to every check we had: Filestash strips the scheme
before its own Host check, so `curl /` returned 200, the API answered, wrong
passwords were rejected, and the brand-new Uptime Kuma HTTP monitor stayed
green — while the SPA computed `http://https://files...` and no browser could
use the site. An HTTP monitor asserts "server returned 200", which is a much
weaker claim than "the app works".

- CLAUDE.md / README: four traps now, host-scheme first; add the post-change
  check (`/api/config` → origin must be the real https URL).
- 19-routes: login form only renders at ?action=redirect (a bare 303 is
  normal, not a bug); assert origin after config changes.
- 10-uptime-kuma: an HTTP monitor cannot see an SPA break — use a Keyword
  monitor if you want teeth, and don't read green as "users can log in".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:31:46 +02:00
10b86ae23e fix(files): general.host must be a bare hostname, not a URL
The SPA computes its redirect origin as (force_ssl ? "https://" : "http://")
+ general.host. With host set to "https://files.${BASE_DOMAIN}" that produced
"http://https://files.ulicraft.net", so every client-side redirect broke and
the page never routed anywhere.

Server-side this was invisible: SecureOrigin strips the scheme before the
Host check, so `curl /` returned 200, the API answered, and the uptime
monitor stayed green — only real browsers failed. The tell is a
`POST /report?...msg=Redirecting to http://https://...` line in the log.

Set host to the bare hostname and force_ssl=true (which the origin needs to
build https://, and which only emits an HSTS header — it does not redirect,
so it can't loop behind the plain-http nginx→caddy hop).

Verified: origin is now "https://files.ulicraft.net"; login + ls still work.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:27:42 +02:00
cd79b0e540 docs(files): document filestash across plan/README/deploy skill
Fold files./filestash into the docs that carry the service list, the vhost
map and the deploy procedure — README stack table, 00-overview, 02-caddy,
12-build-order, 14-deploy, and the deploy skill.

Two real gaps the filestash deploy exposed, now guardrails in the skill:
- update-all.sh never touches host nginx, so a NEW SUBDOMAIN silently 404s
  after deploy. It needs issue-letsencrypt.sh + render-nginx.sh --install.
- a new service with ${FOO:?} guards fails the WHOLE compose file, so a
  missing .env var means `up` starts nothing — and --hard has already run
  `down`. Check the host's .env BEFORE tearing the stack down.

Also: filestash/config.json is a single-file bind mount re-rendered every
run, so a rolling update-all leaves it on a stale inode (same trap as the
caddy conf) — force-recreate after any FILES_* change.

Correct the cochi divergence section: `git diff HEAD` on the host is now
empty (verified this deploy — the ff-pull succeeded). The documented
docker-compose.yml/package.json divergence was converged; only untracked
dnsmasq/, caddy-root-ca.crt and a stale pack/ remain. The stash-pull-pop
procedure it prescribed is no longer needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:23:18 +02:00
25df9f9398 feat(files): add Filestash player file share at files.${BASE_DOMAIN}
One shared login (htpasswd) for all players, writable. Config is rendered
from a template and mounted read-only, so git stays authoritative — the
admin console loads but cannot save, by design.

Filestash's OIDC/SAML middlewares are enterprise-only, so Keycloak SSO is
out; FILES_AUTH keeps htpasswd/passthrough switchable for later. Auth-off
(passthrough) is only valid once the host is off the public internet —
render-config.sh warns loudly when rendering it.

Three traps, all found by testing against the pinned image:
- the `local` backend is admin-gated: without LOCAL_BACKEND_SECRET in the
  connection params every login fails with "backend error - Not Allowed"
- the htpasswd plugin only accepts $2a$ bcrypt, so a $2y$ hash from
  `htpasswd -B` fails every login silently — use `openssl passwd -6`.
  render-config.sh rejects the wrong format rather than shipping it
- APPLICATION_URL/ADMIN_PASSWORD env make filestash rewrite config.json at
  boot, which fails on the :ro mount — both live in the rendered config

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 18:08:46 +02:00
1958a96acf chore(mods): classify 11 new distribution mods
Distribution added 11 jars. classify-mods.py seeds all as `both`
(none declare CLIENT in neoforge.mods.toml). Hand-flip the two
pure-client cosmetic ones so sync-server-mods.sh drops them:

- ExtremeSoundMuffler: client-side sound muffling UI
- ToastControl: client-side toast popup suppression

Rest stay `both` — Placebo/moonlight/caelus are libs, and
amendments/etched/fishingoverhaul/immersive_paintings/
createornithopterglider/emotecraft add server-side content or sync.

Also map iris as `client`. It is referenced by distribution.json but
absent from the local (gitignored) dist jar dir, so classify-mods.py
never sees it; mapping it keeps mods-sides.json aligned with the
manifest. Client-only, so the sync selection is unchanged.

server-mods/ now 130 jars (was 123).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 20:29:35 +02:00
e45ce210ba docs(mc): document Simple Voice Chat voice_host gotcha + commit prod snapshot
voice_host lives in the mc_data volume and regenerates empty on world wipe,
silently breaking remote voice (secret sent, UDP handshake never completes →
red icon). Document the set/restore steps and the VPN-client false-positive
(Cloudflare One/WARP, FortiClient drop voice UDP). Add a known-good prod
snapshot at server-configs/voicechat-server.properties (voice_host=ulicraft.net).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 23:16:02 +02:00
d12071df04 fix(mc): ONLINE_MODE=true — authlib-injector needs online handshake for skins
online-mode=false made the server skip the online-auth handshake entirely, so
authlib-injector never validated sessions against Drasl: players got offline-hash
UUIDs and no skin textures (everyone Steve). authlib-injector's whole purpose is
to redirect the *online* handshake from Mojang to Drasl, so online-mode must be
true; only the 1.21+ secure profile stays off (ENFORCE_SECURE_PROFILE=false +
Drasl SignPublicKeys=false). Correct the conflation in CLAUDE.md and
plan/05-minecraft.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:34:47 +02:00
a67b9cf59e fix(mc): mount only kubejs source subdirs ro, keep kubejs root writable
Mounting the whole kubejs dir read-only made KubeJS's BaseProperties.save()
throw NPE on boot (it writes config/cache/generated/logs under kubejs/),
so KubeJS never initialised. Bind only the source subdirs (server_scripts,
startup_scripts, data, assets) read-only and let the kubejs root live
writable in mc_data.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 22:01:07 +02:00
d303d23d18 feat(mc): mount distribution kubejs + CustomSkinLoader into server
The minecraft container only received forgemods jars via ./server-mods.
The distribution's files/kubejs (server_scripts, data, startup_scripts)
and CustomSkinLoader never reached the server, so server-side kubejs
recipes/scripts were silently absent.

Bind-mount both from DISTRIBUTION_WEB_ROOT (read-only, to avoid the
server polluting the client distribution with kubejs-generated files).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 21:53:47 +02:00
614e47598b chore(mods): repoint rechiseled (both) + fusion (client) to fixed jars
Distribution replaced the wrong-version jars: rechiseled now
mc1.21 (gate [1.21,1.21.2) — valid for 1.21.1, no longer the broken
mc1.21.11 build) so back to `both`; fusion bumped 1.3.2a→1.3.2b, stays
`client` (resource-pack texture feature). Dropped the two stale entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:39:09 +02:00
69871c8a93 fix(mods): drop rechiseled from server — wrong MC version
rechiseled-1.2.5-neoforge-mc1.21.11.jar targets Minecraft 1.21.11 /
NeoForge 21.11, not this server's 1.21.1 / 21.1.233. As `both` it crashed
the server at pre-load (FATAL ModLoader: requires minecraft 1.21.11). Mark
`client` to exclude it from server-mods so the server boots.

NOTE: the jar is still wrong-version for clients too (same gate) — it must
be removed from / replaced in the distribution to be usable at all.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:36:03 +02:00
015bf7fbe3 chore(mods): classify fusion (client) + rechiseled (both)
Two new distribution mods. rechiseled adds decorative blocks → both
(server needs the block registry). fusion is a client-only resource-pack
texture/model feature (no blocks/logic) → client, so sync drops it from
server-mods.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:31:25 +02:00
d0bc81f0f7 chore(mods): classify ulicraftmod as both
New custom mod ulicraftmod-1.21.1-1.0.0-6.0.10 added to the distribution;
content mod (server + client), so both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 23:07:26 +02:00
35be2e6b76 docs(plan): scrub stale pack subdomain + packwiz from live-arch docs
The pack./packwiz service was removed operationally long ago, but several
plan docs still presented pack. as a live caddy vhost / DNS subdomain and
render-pack.sh as a current tool. Remove those references from the
current-architecture docs (overview, caddy, tooling, uptime-kuma,
letsencrypt, mc-backup); leave the migration log (04-mods.md), superseded
banner (04-packwiz.md), commit history (12-build-order.md), and planned
landing rework (18) intact as deliberate history.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 20:15:22 +02:00
33b9ebc7f6 feat(landing): slim status section, hero, and server-card icon
- Remove the stat-grid from the status section (server card only); prune the
  now-orphaned site.stats, MOD_COUNT_KEY, statLabels (3 locales), and
  .stat-grid/.tile CSS.
- Empty-roster message -> "Nobody online", now localized (status.empty in
  en/es/eu) and wired to both ServerCard instances.
- Flatten the server-card icon (drop the inset emboss bevel on .sc-icon).
- Drop "Solo LAN" (hero metaLan) from the hero meta row; prune metaLan (3 locales).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:45:19 +02:00
b9929da54d chore(mods): add geckolib + terrablender (Yungs worldgen deps)
YUNG's Cave Biomes hard-requires geckolib>=4.7.6 and terrablender>=4.1.0.8;
both now in the distribution. Classified both (server worldgen libs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:21:56 +02:00
36c28362bc chore(mods): sync mods-sides.json with distribution
+21 new (Yungs* + Moogs* worldgen, lootjs, solcarrot, melter,
seamless-loading-screen), -8 removed (FramedBlocks, GnKinetics,
create_vibrant_vaults, createtransmission, interiors, picaxe,
bellsandwhistles, welcomescreen). seamless-loading-screen hand-edited
to client (pure client UI). Worldgen mods stay both — server generates
structures.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:05:44 +02:00
07a4ae469d docs(minecraft): document OP-via-RCON, not itzg OPS env
OPS env resolves names via Mojang lookup, but the server is offline-mode
with Drasl auth (own UUIDs) → wrong UUID, inert op entry. Document the
RCON procedure that uses the cached Drasl UUID from usercache.json.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 15:00:22 +02:00
2c3e4cf385 feat(branding): use goat logo in header on all pages
The home page header already used ulicraft-logo-mini.svg; apply the same
brand mark to the register, account, and fjord page headers (and footers),
replacing the leftover Creeper pixel-art.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:11:09 +02:00
4855447b29 feat(branding): use Ulicraft goat logo in header, ServerCard, and server icon
- landing header + footer brand: swap the Creeper pixel-art for the
  ulicraft-logo-mini.svg goat mark.
- ServerCard fallback icon: same logo (real SLP favicon still overrides on
  a successful ping).
- minecraft: set ICON=/extras/server-icon.png + OVERRIDE_ICON so the
  in-game server-list entry and the SLP favicon use the goat logo. PNG
  ships in ./runtime (mounted /extras).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:59:07 +02:00
14ef545bbb chore: remove pack (packwiz) service and subdomain
The pack service was already gone from compose/caddy and the landing is
off packwiz. Remove the remaining live references: the pack. TLS vhost in
the host nginx template, pack from LE_SUBDOMAINS, and stale docs in README
and CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 09:38:28 +02:00
7222904c91 docs(readme): document the mc-status server-status JSON endpoint
Describe the self-hosted mc-status pinger (mcstatus.io v2 JSON, same-origin via
caddy /api/mcstatus/* → mc-status:8080) that the landing ServerCard reads, with
the apex endpoint and a direct-to-caddy curl example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:27:41 +02:00
8a71709048 docs(deploy): caddy conf bind mounts are inode-pinned — recreate, not reload
Document the gotcha hit fixing distribution CORS: caddy/conf.d/*.caddy are
single-file bind mounts pinned to the host inode at container creation. git pull
rewrites tracked files via atomic rename (new inode), so a running caddy keeps
the old config; `restart`/`caddy reload` reuse the stale inode. Apply conf
changes with `docker compose up -d --force-recreate caddy` (or the --hard
down/up). Added to plan/14-deploy.md and the deploy skill guardrails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:26:42 +02:00
db63aa7d10 fix(caddy): allow CORS on distribution /launcher/* for launcher.json fetch
The landing page (apex origin) fetches launcher.json from the distribution
vhost cross-origin to render download buttons; add Access-Control-Allow-Origin
on /launcher/* so the browser doesn't block it. Public assets, no credentials.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:05:54 +02:00
39b9262ee9 fix(landing): fetch launcher downloads from live launcher.json on load
The build-time download buttons baked stale/placeholder URLs (the host has no
synced launcher.json, so the build fell back to launcher.example.json). Render
a skeleton at build time instead and fetch the live launcher.json client-side
once on page load (no polling) from
https://distribution.${BASE_DOMAIN}/launcher/launcher.json, building per-OS
buttons from files[]. On fetch error the skeleton clears.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 02:03:22 +02:00
037b1777d6 feat(landing): rename crew to "El valle" + source downloads from launcher.json
Rename all "crew" mentions (en/es/eu) to the untranslated proper name
"El valle" across hero, members, and features copy in i18n/ui.ts.

Rewire the homepage launcher download list to the custom-launcher build
output launcher.json (productName/version/files[]) instead of the
never-produced launcher-manifest.json shape. site.ts now reads launcher.json
and normalizes files[] -> internal builds[]; launcher.example.json is the
committed fallback. Drop the stale launcher-manifest.example/schema, update
.gitignore and docs (CLAUDE.md, plan/18).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:55:05 +02:00
1019da66a3 chore(mods): reconcile sides for distribution mod changes
Added picaxe (both). Pruned 5 stale keys for removed jars: LittleFrames,
waterframes, watermedia, watervision (WaterMedia stack dropped from the
pack) and the orphan iris key. sides keys 135→131 = current dist count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:36:25 +02:00
0b098ae06e fix(mods): mark WaterMedia stack client — refuses dedicated server
watermedia throws IllegalEnvironmentException on server-side ("keep it
on client"). Its natives (wm_binaries) and dependents (watervision,
waterframes) are client-render media mods too — flip all four to client
so sync drops them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:26:24 +02:00
db270a9695 fix(mods): mark cwb (Cubes Without Borders) client — crashes dedicated server
cwb declares @Mod(dist=CLIENT); classify-mods.py read its toml side as
BOTH (the dist annotation isn't in the manifest), so it synced to the
server and RuntimeDistCleaner FATAL'd on CubesWithoutBordersImpl. Flip
to client so sync drops it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:23:40 +02:00
4750c0d6c7 feat(mods): classify 59 new distribution jars (Create ecosystem)
Distribution grew 76→135 forgemods; classify-mods.py seeded the 59
new jars into mods-sides.json (57 both, 2 client). Unblocks the server
mod sync — server was stuck on the old 52-jar subset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:21:42 +02:00
220b43d007 docs(deploy): document update-all.sh as the post-pull orchestrator
Rewrite the routine-redeploy section around update-all.sh (rolling vs --hard),
list the steps it runs, note the python>=3.11 requirement for build-modlist on
cochi (default python3 is 3.10), and that www/ + mods.json are generated every
run. The deploy skill = pull + fetch-authlib + ./update-all.sh --hard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 01:06:39 +02:00
37d21cacf9 fix(update-all): run build-modlist.py under python >=3.11
The host's default python3 may predate stdlib tomllib (cochi/Ubuntu 22.04 ships
3.10), so the shebang-resolved python3 failed the version guard and strict-halt
aborted the run. Pick the first python3.11+ interpreter instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 00:05:03 +02:00
3af14d0144 feat(tooling): add update-all.sh post-pull orchestrator; deploy skill uses it
update-all.sh is the single source of truth for the on-host post-pull build +
restart steps: render drasl/nmsr configs, sync server mods, regen the landing
mod list, rebuild www/, then apply via docker compose. Two modes:
- default (rolling): up -d --build, then restart ONLY services whose mounted
  inputs changed since a pre-run snapshot — drasl/nmsr on a config re-render,
  minecraft on a mod change. Minimal downtime. (Cannot detect caddy static-conf
  changes — use --hard for those.)
- --hard: down --remove-orphans && up -d --build (full restart, MC downtime).

Strict halt on any error; landing build sources nvm + pnpm (never npm) and bakes
.env. fetch-authlib stays out (rarely changes) — the deploy skill runs it before
update-all. Refactor the deploy skill to `pull && fetch-authlib && update-all.sh
--hard` so the step list can't drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 00:03:57 +02:00
369b4dc0a0 style(landing): bump roster player name to 18px
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 23:28:01 +02:00
c3b2d89ddc feat(landing): switch heading font to Block Blueprint
Replace Pixelify Sans with Block Blueprint as the heading font (theme.headFont).
Block Blueprint isn't on Google Fonts, so it's vendored straight from 1001fonts
(License: 1001Fonts Free For Personal Use — fine for this private friends'
server) as a TTF, with a hand-written @font-face appended to fonts.css. The
vendor step is added to fetch-fonts.sh (runs after the Google rewrite so it
survives re-runs). Pixelify stays available as a HEAD_FONTS option + the
hard-coded fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:17:52 +02:00
362228f985 feat(landing): 230px fullbody roster, features goat column, trimmed header
- Members roster avatars render 230px tall (full body); NMSR size bumped to 256
  and tiles widened to fit the portrait.
- Features section is now two columns: the 4 feature cards stacked on the left, a
  Minecraft goat image on the right (public/Goat_JE1_BE1.webp). Stacks on narrow
  screens.
- Header nav links trimmed to How to Join + Status; the auth CTA cluster is now
  Register / Account / Play (the old "Log in" button relabeled Account →
  Cuenta/Kontua).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:13:58 +02:00
c8a6c77a8e feat(landing): rework — es default, 2-step join, roster + account polish
- Default language Spanish at root /, English moves to /en/ (eu unchanged):
  getStaticPaths {lang:undefined}->es + LANG_*_PATH maps swapped in ui.ts.
- Section order: hero -> join -> status -> members -> mods -> features.
- Join flow 3 -> 2 steps (drop the Connect/IP step; launcher handles the
  address). Remove the hero IP CopyChip. Header CTA renamed Play/Jugar/Jolastu
  and gains Register + Login links on all four pages.
- Account page: hide the whole login block once authenticated (not just the
  form), add an avatar Refresh button (cache-busted NMSR re-fetch), flatten the
  avatar (no bevel). New i18n key account.refresh.
- Register page: new REGISTRATION_SHOW_INVITE env (default false/hidden) toggles
  the invite-code field independently of the REGISTRATION_MODE backend gate.
- Members roster: drop the `admin` account, render full-body via a new
  ROSTER_AVATAR_MODE (default fullbodyiso); portrait tiles.
- Style: flatten emboss on feature icons (.picon) + player tiles (.roster-tile).
  PixelIcon gains optional image support (/icons/<name>.png|svg).
- Favicon now /favicon.png (committed) on all pages.
- Docs: plan/09-landing.md Assets section (logo/favicon/features-icons drop
  points) + default-language note; .env.example documents the two new vars.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 22:03:18 +02:00
c233b1bfbc docs(drasl): record admin bootstrap + config-key/CORS/NMSR gotchas
Capture what this session learned the hard way, in plan/03-drasl.md and CLAUDE.md:

- Correct admin key is DefaultAdmins (not DefaultAdminUsernames); wrong/unknown
  keys are silently ignored by drasl (logged as "unknown config option").
- CORSAllowOrigins must be top-level, before any [Section], or it's ignored.
- First-admin bootstrap under invite mode is a chicken-egg → temp-flip
  RequireInvite=false, register, revert.
- NMSR needs the auth. host-gateway pin + https mojank endpoints or avatars
  render the default skin (cross-ref plan/19-routes.md).
- Fix the stale example config and mark the admin-bootstrap task done.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:20:54 +02:00
de1ac2ee17 fix(nmsr): render real skins, not default — pin auth. to host over https
Every avatar rendered the default skin. NMSR fetched the player profile from
Drasl fine, but the profile embeds an absolute HTTPS skin URL (Drasl BaseURL is
https), and NMSR could not connect to auth.${BASE_DOMAIN}:443: the mcnet alias
resolves auth. -> caddy, which only listens on :80 internally. Logs showed
`fetch_texture_from_mojang … client error (Connect)` for the skin URL, so NMSR
fell back to the default skin.

Mirror what the minecraft service already does: pin auth.${BASE_DOMAIN} to the
host via extra_hosts so NMSR reaches the host nginx on :443 (real LE cert,
publicly trusted — no private CA needed), and switch the nmsr mojank endpoints
from http:// to https:// so the profile/texture scheme matches the embedded
skin URLs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 21:12:59 +02:00
5d5602fbd7 feat(nmsr): cut avatar refresh lag to ~1min + document HTTP routes
NMSR has no per-request avatar refresh (the `?skin=` override is ignored by this
build), so the only lever for a changed skin to appear is the resolve cache.
Drop resolve_cache_duration 15m -> 60s so account-page skin changes propagate
within ~1 min; texture cache stays 48h (Drasl skin URLs are content-hashed, so a
new skin is always a new URL — never stale). Cost is one extra internal Drasl
profile lookup per avatar per minute, trivial for 4-10 players.

Update the now-stale "~15 min" copy (skinLead + skinPreviewNote, en/es/eu) and
code comments to "~1 min".

Add plan/19-routes.md: a reference for the nmsr / landing / auth HTTP routes
(public via caddy + browser->Drasl + internal service-to-service), indexed in
plan/00-overview.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 20:58:57 +02:00
97347693b6 fix(drasl): correct admin key + CORS placement in config template
Two config keys were silently ignored by drasl (logged as "unknown config
option"), so neither took effect in production:

- `DefaultAdminUsernames` is not a drasl option; the correct top-level key is
  `DefaultAdmins`. With the wrong key, the `admin` user never got promoted —
  admin-only API routes (e.g. GET /players for the registered-players roster)
  returned 403.
- `CORSAllowOrigins` sat after the `[RegistrationNewPlayer]` table header, so
  TOML parsed it as `RegistrationNewPlayer.CORSAllowOrigins` (ignored → no CORS
  headers → landing register/account browser calls blocked). Moved it top-level,
  before any [Section], with a comment warning against re-nesting.

Verified against drasl master configuration.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 18:34:48 +02:00
a138bbfd72 fix(landing): add valid pnpm-workspace.yaml (packages + build approvals)
Host cochi carried an untracked landing/pnpm-workspace.yaml with a bogus
`allowBuilds:` key and no `packages:` field, which made pnpm 11 abort every
command with "packages field missing or empty" — breaking the deploy landing
rebuild. Track a correct file: `packages: ['.']` plus `onlyBuiltDependencies`
for esbuild/sharp. Verified install + astro build clean locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 18:27:30 +02:00
6c7e259fcb feat(landing): join-flow rework, player rosters, account page, mod list
Execute plan/18 (WS1-6) + plan/17.

- WS1: homepage join = 3 steps off packwiz (register -> UlicraftLauncher
  -> join); per-OS downloads from a synced launcher-manifest.json (schema +
  example committed); Fjord moved to its own /fjord page. Drop packwizUrl.
- WS2/3: mc-status gains an isolated /players endpoint (admin login ->
  Drasl GET /players, own client+TTL+token cache, never blocks the status
  path); online + registered rosters render shared name+avatar tiles;
  AVATAR_MODE env (default headiso).
- WS4: /account skin CRUD via browser-direct Drasl (login -> upload ->
  reset), in-memory token, players[0]; register relinks "Manage it here".
- WS5: footer link to status.${BASE_DOMAIN} in every footer.
- WS6: tooling/build-modlist.py generates mods.json + extracted logos from
  the distribution forgemods; ModList.astro renders a flat list; stat tile
  fed from the count.

Manifest/mods loaders read at build with a process.cwd() fallback (the
import.meta.url-only path does not resolve in Astro's bundled build).

New env: AVATAR_MODE, DRASL_ADMIN_USERNAME, DRASL_ADMIN_PASSWORD (mc-status
only). Generated mods.json / launcher-manifest.json / logos are gitignored;
.example.json files document the shapes. Build: 12 pages; mc-status builds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 18:17:14 +02:00
096ef82420 docs(04-mods): record first-cutover deploy gotchas
Document the two crash classes hit on first deploy: NeoForge pin must match
the distribution's version, and client-only mods that declare BOTH crash the
dedicated server with "invalid dist DEDICATED_SERVER" (tomllib can't catch
them — hand-set to client + re-sync). Mark the decommission checklist done;
flag the landing join-flow rework as the one remaining open item. Current
state: 24 client / 52 both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 01:16:48 +02:00
c249172baa fix(mods): mark client-only GUI mods as client in mods-sides.json
Drippy Loading Screen (and other client-only mods declaring BOTH in their
manifest) crashed the dedicated server with "Attempted to load class
net/minecraft/client/gui/screens/Screen for invalid dist DEDICATED_SERVER".
tomllib can't catch these (they declare BOTH), so hand-classify the leaf
client-only mods as `client`: BetterF3, JustEnoughResources, MouseTweaks,
Searchables, TravelersTitles, drippyloadingscreen, entityculling, fancymenu,
konkrete, melody, ponderjs, welcomescreen, yeetusexperimentus. Now 24 client
/ 52 both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:07:50 +02:00
fa8cede6ae docs(roadmap): track post-migration mod follow-ups
Two follow-ups from the packwiz→04-mods migration: curate cosmetic-client
entries in mods-sides.json, and rework the landing join flow off the dead
packwiz pack.toml URL onto the HeliosLauncher/distribution.json flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 00:03:07 +02:00
84196d59d0 fix(minecraft): bump NeoForge pin to 21.1.233 to match distribution
The distribution-fed modset is built against NeoForge 21.1.233
(distribution.json); several mods hard-require it (ferritecore ≥21.1.218,
farmersdelight ≥21.1.219, configured ≥21.1.211). The old 21.1.209 pin made
the server crash in pre-load with "Missing or unsupported mandatory
dependencies: neoforge".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:59:23 +02:00
27237bc7c1 feat(mods): replace packwiz with distribution-fed custom mod volume
Drop packwiz entirely. The server now loads a filtered mod subset from a
local ./server-mods volume instead of fetching a packwiz pack at boot.

New flow:
- tooling/classify-mods.py (tomllib only, no network) reads each jar's
  META-INF/neoforge.mods.toml and seeds mods-sides.json with a side
  (client|server|both; default both). Existing entries are preserved.
- mods-sides.json: committed, keyed by jar filename, hand-editable override
  surface. Initial seed: 65 both, 11 client, 0 server (76 jars).
- tooling/sync-server-mods.sh mirrors the both/server jars from
  $DISTRIBUTION_WEB_ROOT into ./server-mods/ (authoritative; prunes strays;
  halts on a missing jar). Replaces render-pack.sh.
- compose: minecraft mounts ./server-mods:/mods:ro with REMOVE_OLD_MODS=TRUE
  (itzg auto-syncs /mods -> /data/mods). Removed PACKWIZ_URL, the pack.
  extra_host (auth. kept for authlib), and depends_on caddy (drasl kept).

Both classify-mods.py and sync-server-mods.sh resolve their source dir as:
CLI arg / MODS_DIST_DIR env / $DISTRIBUTION_WEB_ROOT (in that order).

Removed: tooling/render-pack.sh + add-custom-mod.sh (packwiz tooling),
pack/ (packwiz source), custom/ (76 jars now sourced from the distribution),
the pack. caddy vhost + its mounts/alias, and the packwiz .gitignore block.

Client distribution is UNCHANGED: HeliosLauncher still installs the full
modset from distribution.json. Docs (CLAUDE.md, plan/04/05/14, runtime)
updated; plan/04-packwiz.md stubbed as superseded by plan/04-mods.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:34:38 +02:00
90c3be7bb5 https auth 2026-06-09 22:13:31 +02:00
d21a7f0e92 feat(landing): live server card backed by self-hosted mc-status
Add a featured live ServerCard to the landing (replaces the static
ServerListPanel in hero + status sections): server favicon, color MOTD,
online/offline pill, players online/max with fill bar, and a player-head
row rendered by our own NMSR from Drasl skins. Progressive enhancement —
SSG skeleton degrades gracefully when JS or the API is unavailable.

Back it with a self-hosted pinger instead of the public api.mcstatus.io:
mcstatus.io's API service is closed-source (only the mcutil library is
open), so docker/mc-status wraps mcutil and re-emits its v2 JSON shape,
keeping the frontend unchanged. The service ignores the path address and
only pings MC_STATUS_TARGET (no SSRF relay), with a 30s TTL cache.

Exposed same-origin via caddy at the apex /api/mcstatus/* path (no new DNS
subdomain or LE cert change, no CORS). Uptime Kuma stays the uptime
history + alerting backend; see plan/15-mc-status.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:08:30 +02:00
df8ffca53e feat(landing): self-serve registration page via Drasl API (B1)
Add a static /register page (en/es/eu) that calls the Drasl REST API v2
directly from the browser: register -> login -> optional skin upload, all
in the pixel design. Guests never touch Drasl's own web UI.

Drasl config gains the pieces this needs:
- CORSAllowOrigins scoped to the apex (the API has no CORS until set;
  never "*").
- [RateLimit] for the now public-facing anonymous POST /users.
- [RegistrationNewPlayer] with RequireInvite driven by a new
  REGISTRATION_MODE (invite|open) .env flag.

REGISTRATION_MODE has two consumers from one key: render-config.sh derives
the TOML boolean for Drasl (drasl is TOML-only, no env), and the landing
build reads it to show/hide the invite-code field. render-config.sh halts
on any value other than invite/open.

Security verified against drasl source: anonymous POST /users cannot set
privileged fields (isAdmin/isLocked/chosenUuid/maxPlayerCount are gated on
callerIsAdmin in CreateUser), so browser-direct registration is safe.

Docs: plan/16-landing-registration.md captures the design + the B1 vs fork
decision; build-order, deploy, and the deploy skill wire REGISTRATION_MODE
and the landing-rebuild requirement (www/ is gitignored, not updated by a
git pull).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 21:55:51 +02:00
067e990306 mods 2026-06-09 18:29:48 +02:00
673202e1e4 fix(compose): pin auth./pack. to host-gateway so HTTPS resolves in-container
Containers don't inherit the host's /etc/hosts; the LAN resolver returns a
dead address (192.168.0.3:443) for the public names, crash-looping the
packwiz install on boot. extra_hosts host-gateway routes HTTPS through the
host's nginx (valid LE cert) -> caddy -> service.

Also add plan/10-uptime-kuma.md (Minecraft monitor: internal + public probes)
and document the HTTPS resolution variant in plan/00-overview.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 04:11:53 +02:00
1ec3a9c1ee internals http 2026-06-09 03:55:54 +02:00
663b87a4e4 change to https 2026-06-09 03:37:48 +02:00
a2821f2bbc feat(tooling): fetch-authlib.sh to download authlib-injector.jar
The server JVM agent (runtime/authlib-injector.jar, mounted at /extras) is
gitignored and was previously fetched by the deleted build-stack.sh prep. A
fresh checkout had no jar, so minecraft crashlooped with "Error opening zip
file or JAR manifest missing". Add a standalone fetch tool: resolves the latest
release asset, downloads, and verifies it's a real zip/jar (PK magic) before
trusting it. Idempotent (skips if valid, --force to re-download). Wire it into
the deploy skill and README launch steps.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 03:06:59 +02:00
cfd60131fb fix(landing): https + correct subdomains, drop dead CA-cert step
The guest landing page advertised http:// service URLs (mixed-content on the
https origin) and the wrong packwiz subdomain:
- authUrl / authlibUrl: http -> https
- packwizUrl: http://packwiz. -> https://pack.  (correct vhost)
- serverAddress: mc. -> apex (connect to ${BASE_DOMAIN}:25565)
- remove caCertUrl + the "trust the local CA" join step (airgap-only, /ca.crt
  no longer exists) and its en/es/eu i18n strings + type.

Also fix the stale http BaseURL example in plan/03-drasl.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 02:56:59 +02:00
95c63e1ee0 fix(drasl): use https BaseURL to match TLS ingress
Public scheme is HTTPS (host nginx terminates TLS in front of caddy). Drasl
builds absolute URLs — texture URLs and the authlib-injector API location —
from BaseURL, so an http:// value caused mixed-content blocking and broken
login on the https origin. Point BaseURL at https://auth.${BASE_DOMAIN}.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 02:53:35 +02:00
46103495fb fix(compose): pre-create pack/custom mountpoint for nested ro bind
The ./custom:/srv/pack/custom mount nests inside the read-only ./pack:/srv/pack
mount. Docker can't mkdir the child mountpoint under a :ro parent, so on a fresh
checkout (no pack/custom/ dir) container init fails with "read-only file system".
Track an empty pack/custom/ so the mountpoint always exists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 02:28:15 +02:00
1709bcd340 docs(claude): rewrite project context for unified stack
Drop stale Keycloak/OIDC, NetBird, AdGuard/.home.local, airgap, and
Prism-specific content. Reflect the current reality: single docker-compose.yml
(drasl, minecraft, mc-backup, caddy, nmsr, uptime-kuma), Drasl password-login
auth (no OIDC), public nginx+LE -> caddy ingress, DNS handled outside the repo,
FjordLauncher for guests. Point to plan/ as the source of truth; keep the
still-valid gotchas (secure-profile, authlib-injector, NeoForge pinning, mod
source) and carry-over comms prefs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 02:16:42 +02:00
67d1f82e6a refactor!: unify into one compose, drop airgap/mirror/dnsmasq/avahi/blessingskin
Collapse the override-file matrix into a single docker-compose.yml holding the
whole stack: drasl, minecraft, mc-backup, caddy, nmsr, uptime-kuma. Bring-up is
now just `docker compose up -d --build`.

Removed features (dead-ends for this deployment):
- airgap / full asset mirror (mirror-airgap.sh, mirror-mods.sh, 20-mirror.caddy,
  caddy local-CA export, /ca.crt)
- dnsmasq + avahi/mDNS + dns-records.sh + mdns-host-setup.sh + ENABLE_MDNS;
  DNS is now handled outside this repo (HOST_LAN_IP dropped)
- Blessing Skin auth variant (compose + 00-core-blessingskin.caddy + BS_* env)
- host-nginx-static variant (docker-compose.nginx.yml, nginx/ulicraft.conf.tmpl)
- build-stack.sh and its run modes (online/airgap/core)

Production ingress is the only path now: host nginx terminates TLS (LE certs)
in front of caddy on a localhost-only port; caddy routes every vhost by Host
header. Launcher downloads move mirror/launcher -> launcher/.

Docs: README, deploy skill, and plan/ rewritten to match; obsolete plan docs
(dnsmasq, blessing-skin, assets-mirror, full-airgap-mirror, dns-and-run-modes)
deleted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 02:14:09 +02:00
ff645de2c8 feat(status): Uptime Kuma monitoring at status.${BASE_DOMAIN}
Add docker-compose.status.yml (louislam/uptime-kuma) layered on the caddy
ingress, joining mcnet so it probes the stack by internal service name and
the public vhosts end-to-end. Host port bound to 127.0.0.1:3001 only; public
access via nginx -> caddy.

- caddy/conf.d/50-status.caddy: status. -> uptime-kuma:3001
- nginx caddy-front template: status. TLS vhost + websocket upgrade headers
  for Kuma's live UI; status. added to the HTTP->HTTPS redirect list
- status added to LE_SUBDOMAINS defaults (.env.example, issue-letsencrypt.sh)
- README: Status monitoring section + recommended monitor list (incl. native
  Minecraft-protocol ping)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:49:39 +02:00
6df885eb13 docs(readme): document NMSR avatar renderer + ingress vhosts
Add an Avatar renderer (NMSR) section: Drasl-backed config, internal mcnet
resolution, localhost-only host port, endpoints, lavapipe note. List nmsr +
distribution as optional layers, refresh the Public TLS vhost list (www/avatar,
HTTPS-only), and update the Layout tree.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:42:00 +02:00
6fac1647aa feat(avatar): NMSR skin renderer at avatar.${BASE_DOMAIN}, Drasl-backed
Add NMSR-as-a-Service (NickAcPT/nmsr-rs) rendering players' skins/avatars,
sourced from Drasl instead of Mojang.

- docker/nmsr/Dockerfile     build from source, pinned to commit 948ba4bc027b
                             (ears feature, lavapipe software Vulkan)
- nmsr/config.toml.tmpl       [mojank] -> http://auth.${BASE_DOMAIN}; Drasl
                              serves the Mojang-compatible routes at its bare
                              BaseURL and embeds absolute skin URLs that NMSR
                              fetches directly. allow_offline_mode_uuids=true.
- docker-compose.nmsr.yml     nmsr service (build), host bind 127.0.0.1:9898
                              only, config mount; mounts the caddy avatar snippet
- caddy/conf.d/40-avatar.caddy avatar.${BASE_DOMAIN} -> reverse_proxy nmsr:8080
- nginx-front + LE_SUBDOMAINS  public TLS vhost (HSTS) + cert for avatar
- render-config.sh / .gitignore render + ignore nmsr/config.toml

Skin resolution stays internal over mcnet (caddy alias -> drasl), no public
round-trip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:40:50 +02:00
a6155819b6 feat(nginx): enforce HTTPS-only with HSTS
Add Strict-Transport-Security (max-age 1y, includeSubDomains) to every TLS
server block in both vhost templates. Combined with the existing port-80
301 redirect, browsers refuse plain HTTP to these names after first visit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:21:17 +02:00
3185fea4c1 feat(nginx): render-nginx.sh + www->apex redirect vhost
Add tooling/render-nginx.sh: pulls BASE_DOMAIN/CADDY_HTTP_PORT from .env,
defaults APP_DIR to the repo checkout, renders a vhost template (default the
caddy-front one) to stdout, or --install writes + symlinks + nginx -t + reloads.
Document it in the README, replacing the manual envsubst one-liner.

Also add the www.${BASE_DOMAIN} canonical 301->apex block and put www in the
LE_SUBDOMAINS default so its cert is issued.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:09:46 +02:00
f3c38da56c fix(nginx): use legacy listen-flag http2 for older nginx
The `http2 on;` directive needs nginx 1.25.1+; older builds reject it. Move
http2 onto the listen line (`listen 443 ssl http2;`) which works on old and
new nginx alike. Applied to both vhost templates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 01:03:39 +02:00
21e6080e78 docs(readme): document host-nginx-in-front-of-caddy public TLS path
Add a section covering cert issuance, the localhost CADDY_HTTP_PORT bind, the
caddy/static/distribution compose bring-up, and rendering/installing
nginx/ulicraft-caddy.conf.tmpl. Note DISTRIBUTION_WEB_ROOT.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:40:02 +02:00
ab3d2c201b feat(ingress): public distribution.${BASE_DOMAIN} static vhost
Serve a static site at distribution.${BASE_DOMAIN} whose web root lives in
another repo (DISTRIBUTION_WEB_ROOT, bind-mounted read-only into caddy):
  - caddy/conf.d/30-distribution.caddy  file_server vhost
  - docker-compose.distribution.yml     mount snippet + external web root
  - issue-letsencrypt.sh / .env.example add distribution to LE_SUBDOMAINS
  - ulicraft-caddy.conf.tmpl            TLS-front block proxying to caddy

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 00:30:27 +02:00
3486546fb3 feat(ingress): configurable caddy port so host nginx can front it
Make the caddy host-published port configurable (CADDY_HTTP_PORT /
CADDY_HTTP_BIND, default 0.0.0.0:80 unchanged) so the machine's own nginx
can terminate TLS and reverse-proxy to caddy on a localhost-only port.

Add nginx/ulicraft-caddy.conf.tmpl: a TLS-terminator vhost set that proxies
apex/auth/pack to caddy by Host header, letting caddy stay the single ingress
for all routing. Alternative to ulicraft.conf.tmpl (nginx-as-static + drasl
proxy). minecraft still reaches caddy internally over http via mcnet aliases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 23:34:42 +02:00
3d52951355 feat(landing): pixel design ported to static Astro + en/es/eu i18n
Adopt the "Carved from stone" pixel design (landing/design/) as the apex
onboarding page, ported from its React/Babel-via-CDN prototype to static
Astro so it works on an offline LAN. Real modded-LAN content replaces the
prototype's fictional public-SMP copy.

- Vendor web fonts locally (tooling/fetch-fonts.sh -> public/fonts/) so the
  page renders without the Google Fonts CDN at party time.
- Port the design system (main.css: mood/hero/bevels) and split markup into
  Astro components (Creeper, PixelIcon, CopyChip, ServerListPanel).
- site.ts holds language-neutral config + theme knobs (mood/hero/headFont/
  dust) that replace the design's in-browser TweaksPanel; LITERALS holds
  never-translated product/in-game terms.
- i18n: English (/), Spanish (/es/), Euskera (/eu/) generated from one
  [...lang].astro via getStaticPaths; copy lives in src/i18n/ui.ts. Nav has
  an EN/ES/EU switcher; html lang + hreflang set per page.
- Status panel shows a static server-list row + honest stat tiles (no fake
  live count). Copy + scroll-reveal are the only client JS.

Build emits to ../www (gitignored). Euskera strings pending a native review.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:18:36 +02:00
5cec2993d7 feat(nginx): serve launcher downloads from apex vhost
Port the /launcher/* static route from caddy's 10-static.caddy to the
nginx apex server (alias -> ./mirror/launcher, autoindex). /ca.crt is
omitted — LE certs are publicly trusted, no guest CA import needed.
The 20-mirror.caddy upstream spoofs stay caddy/air-gap-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 01:08:43 +02:00
176 changed files with 10037 additions and 2850 deletions

View File

@@ -1,6 +1,6 @@
---
name: deploy
description: Redeploy the Ulicraft Minecraft stack to the production host `cochi` — git pull on main then a hard restart (compose down + build-stack.sh --up online). Use when the user says "deploy", "redeploy", "deploy to cochi", "push to prod", "ship it", or "restart the server" for this project. Causes brief Minecraft downtime.
description: Redeploy the Ulicraft Minecraft stack to the production host `cochi` — git pull on main then a hard restart (compose down + up). Use when the user says "deploy", "redeploy", "deploy to cochi", "push to prod", "ship it", or "restart the server" for this project. Causes brief Minecraft downtime.
---
# Deploy Ulicraft to `cochi`
@@ -13,8 +13,9 @@ Redeploy the running stack on the production host. Full reference:
- Host: `cochi` (SSH alias)
- Path: `/home/ubuntu/mc/ulicraft-server-v1`
- Branch: `main`
- Run mode: `online` → compose files `docker-compose.yml` + `docker-compose.static.yml`
- Auth: Drasl (base stack)
- Stack: single unified `docker-compose.yml` (drasl, minecraft, mc-backup,
caddy, nmsr, mc-status, uptime-kuma, filestash)
- Auth: Drasl
- Restart: **hard** (down → up) — players are disconnected for a few minutes
## Procedure
@@ -30,37 +31,96 @@ invoking this skill is the authorization to proceed, but:
already up to date and ask whether to restart anyway (they may want a plain
restart). If `git fetch` fails (host unreachable, auth), STOP and report.
2. **Pull + hard restart** (single SSH session, halts on any error):
**If the incoming commits ADD A SERVICE, check `.env` on the host first.**
A new service with `${FOO:?...}` guards (filestash uses them) fails the
**whole compose file**, not just that service — so a missing var means
`up` refuses to start *anything*, and a `--hard` deploy has already run
`down`. The stack stays down. Check before tearing it down:
```bash
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && grep -c "^FILES_" .env'
```
Same for a new **subdomain**: it needs a cert (`LE_SUBDOMAINS` +
`issue-letsencrypt.sh`) and an nginx vhost — see step 2b, which the deploy
does NOT do for you.
2. **Pull, ensure authlib, then `update-all.sh --hard`** (single SSH session,
halts on any error):
```bash
ssh cochi 'set -e
cd /home/ubuntu/mc/ulicraft-server-v1
git pull --ff-only
docker compose -f docker-compose.yml -f docker-compose.static.yml down --remove-orphans
tooling/build-stack.sh --up online'
tooling/fetch-authlib.sh
./update-all.sh --hard'
```
- `--ff-only` refuses to merge — if the pull is not a fast-forward, STOP and
report (local changes on the host need manual resolution).
- `build-stack.sh --up online` renders configs (needs `.env` complete:
`BASE_DOMAIN`, `HOST_LAN_IP`, `RCON_PASSWORD`) and runs `compose up -d`.
- `fetch-authlib.sh` ensures `runtime/authlib-injector.jar` exists (gitignored;
skips if already valid; NOT part of update-all since it rarely changes).
Missing jar = minecraft crashloops with "Error opening zip file or JAR
manifest missing". Run it before `update-all` so the jar is present when
compose brings minecraft up.
- **`update-all.sh` is the single source of truth** for the post-pull build +
restart steps (render drasl/nmsr configs from `.env`, sync server mods, regen
the landing mod list, rebuild `www/`, then apply via docker compose). It is
also runnable on its own for a lighter rolling update (no `--hard` =
change-detected restart, no world downtime). `--hard` here does
`down --remove-orphans && up -d --build` for the deliberate full restart.
`render-config.sh` halts on an invalid `REGISTRATION_MODE` (must be `invite`
or `open`); a missing distribution jar halts `sync-server-mods.sh`. The
landing rebuild is unconditional (the gitignored `www/` is never updated by
`git pull`), so no separate landing step is needed.
2b. **Only if the deploy changed `nginx/ulicraft-caddy.conf.tmpl`** (a new
subdomain, a new body-size/rate-limit rule, a changed `CADDY_HTTP_PORT`).
`update-all.sh` does **not** touch the host nginx, so a new vhost simply
won't exist and the subdomain 404s / hits the wrong server block:
```bash
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && tooling/render-nginx.sh --install'
```
It runs `nginx -t` before reloading, so a bad render fails safe. The cert
must already exist (`certs/<name>/cert.pem`) or nginx won't load the block —
issue it first with `tooling/issue-letsencrypt.sh` (reads `LE_SUBDOMAINS`).
3. **Verify.**
```bash
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose -f docker-compose.yml -f docker-compose.static.yml ps'
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && docker compose ps'
```
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup` are Up. Tail the minecraft
log briefly and confirm it reaches `Done` (server ready):
Confirm `caddy`, `drasl`, `minecraft`, `mc-backup`, `nmsr`, `mc-status`,
`uptime-kuma`, `filestash` are Up. Tail the minecraft log briefly and confirm
it reaches the ready marker (match `Done (Ns)! For help` — a bare `Done`
also matches unrelated mod-loading lines):
```bash
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 120 docker compose -f docker-compose.yml -f docker-compose.static.yml logs -f minecraft' 2>/dev/null | grep -m1 "Done"
ssh cochi 'cd /home/ubuntu/mc/ulicraft-server-v1 && timeout 180 docker compose logs -f minecraft' 2>/dev/null | grep -m1 'Done ([0-9.]*s)! For help'
```
If a public vhost changed, verify it end-to-end from your workstation rather
than trusting `compose ps` (a container can be Up while nginx never routes to
it): `curl -s -o /dev/null -w '%{http_code}' https://<vhost>/`.
## Guardrails
- Never `git reset --hard` or discard changes on `cochi` without telling the user
first — there may be host-local edits.
- Volumes (`mc_data`, `drasl_state`) persist across `down`; the world is safe. Do
NOT pass `-v`/`--volumes` to `down`.
- **Caddy conf changes need a recreate, not reload/restart.** If the deploy
touched any `caddy/conf.d/*.caddy` or the Caddyfile and you did a *rolling*
`update-all.sh` (not `--hard`), the change WON'T apply: those are single-file
bind mounts pinned to the host inode, and `git pull` gives the file a new inode.
`restart`/`caddy reload` reuse the stale inode. Run
`docker compose up -d --force-recreate caddy`, then verify with
`docker compose exec caddy cat /etc/caddy/conf.d/<file>`. The `--hard` path
(full `down`/`up`) already recreates, so it's unaffected.
- **Same trap for `filestash/config.json`** — also a single-file bind mount, and
it is *re-rendered on every run* by `render-config.sh`, so a rolling
`update-all.sh` leaves filestash reading the stale inode after any `FILES_*`
change (rotating the shared password, flipping `FILES_AUTH`). Apply with
`docker compose up -d --force-recreate filestash`. `--hard` is unaffected.
- **Never let `filestash/config.json` be missing when compose runs.** It is
gitignored and rendered; if it doesn't exist, Docker creates a *directory* at
that path and filestash breaks in a confusing way. `update-all.sh` renders
before `up`, so the normal path is safe — just don't hand-run `docker compose
up` on the host without rendering first. If it happened: `rm -rf
filestash/config.json && tooling/render-config.sh && docker compose up -d
--force-recreate filestash`.
- Volumes (`mc_data`, `drasl_state`, `kuma_data`) persist across `down`; the
world and monitors are safe. Do NOT pass `-v`/`--volumes` to `down`.
- If a step fails, STOP and surface the exact error + the failing command. Do not
improvise recovery on production.
- Blessing Skin variant or `airgap` mode changes the compose file set — if the
user asks for those, follow `plan/14-deploy.md` instead of the fixed commands
above.

View File

@@ -1,20 +1,100 @@
# Copy to .env and fill in real values
# Single base domain for the whole stack; every service is a subdomain of this.
# Use .lan (NOT .local) — .local is intercepted by mDNS on macOS/Linux and
# won't resolve through a normal unicast DNS server.
BASE_DOMAIN=ulicraft.lan
# The Docker host's LAN IP — every service name resolves here.
HOST_LAN_IP=192.168.1.10
# mDNS toggle. true + BASE_DOMAIN=*.local → an Avahi container publishes the
# service names over mDNS (zero-config for macOS/Linux guests; Windows needs
# Bonjour). false → use your own party DNS (records: tooling/dns-records.sh).
# NOTE: mDNS resolves ONLY *.local; it cannot serve the air-gap upstream-host
# spoofs — those always need a real DNS server.
ENABLE_MDNS=false
# DNS is handled OUTSIDE this repo — point ${BASE_DOMAIN} and every subdomain
# (auth. avatar. status. distribution. www.) at the host running nginx.
BASE_DOMAIN=ulicraft.net
RCON_PASSWORD=change-me-to-something-random
# Registration mode (BACKEND gate) for self-hosted accounts (Drasl).
# invite = closed; guests need an admin-minted invite code (recommended on a
# public, internet-present host). Admin mints via POST /drasl/api/v2/invites.
# open = anyone can self-register with username+password (set [RateLimit]!).
# Consumed by render-config.sh (-> Drasl RegistrationNewPlayer.RequireInvite).
# Default: invite. NOTE: this no longer controls the landing invite FIELD — that's
# REGISTRATION_SHOW_INVITE below.
REGISTRATION_MODE=invite
# Show the invite-code FIELD on the landing register form. Read by the landing
# BUILD (data/site.ts) only; default false (hidden). Independent of the backend
# gate above. CAVEAT: if REGISTRATION_MODE=invite (Drasl requires a code) but this
# is false, public self-registration can't supply one — enable both together, or
# mint accounts admin-side. true | false.
REGISTRATION_SHOW_INVITE=false
# NMSR avatar render mode for the landing's avatar tiles. Read by the landing
# BUILD (data/site.ts) only. Avatar URL =
# https://avatar.${BASE_DOMAIN}/<mode>/<uuid>?size=N. e.g. headiso | head | fullbody.
# AVATAR_MODE → ServerCard online row + account preview (heads)
# ROSTER_AVATAR_MODE → Members grid (full body; default fullbodyiso)
AVATAR_MODE=headiso
ROSTER_AVATAR_MODE=fullbodyiso
# Drasl admin creds for the registered-players roster (/api/mcstatus/players).
# Consumed ONLY by the mc-status container (server-side login → sanitized
# [{uuid,name}] list); these NEVER reach the browser. Use a DEDICATED, rotatable
# admin account (Drasl has no read-only role) to limit blast radius. Leave blank
# to disable the roster (the Members grid then just shows its empty state).
DRASL_ADMIN_USERNAME=
DRASL_ADMIN_PASSWORD=
# caddy host-published port. The host's own nginx terminates TLS and
# reverse-proxies to caddy by Host header (nginx/ulicraft-caddy.conf.tmpl), so
# bind caddy to a high localhost-only port — only nginx should reach it.
CADDY_HTTP_PORT=8880
CADDY_HTTP_BIND=127.0.0.1
# distribution.${BASE_DOMAIN} static vhost. Absolute host path to the built
# static site — it lives in ANOTHER repo. Bind-mounted read-only into caddy.
DISTRIBUTION_WEB_ROOT=/home/oier/projects/mc-mods/ulicraft-group/ulicraft-distribution/dist
# ── files.${BASE_DOMAIN} — Filestash player file share ────────────────
# Screenshots / schematics / maps. ONE shared login for all players, writable.
# Full design + gotchas: plan/20-files.md.
# Host path holding the shared files. Keep it OUTSIDE the repo — guest-writable
# data must not live in a git tree we `git pull` into.
FILES_DATA_DIR=/srv/ulicraft-files
# Literal mount flag for FILES_DATA_DIR: rw | ro. `ro` makes the share
# read-only at the KERNEL, regardless of what the Filestash UI thinks. It is a
# mount flag rather than a boolean because compose has no conditionals — the
# value is substituted straight into the volume string.
FILES_MOUNT_MODE=rw
# Auth middleware (rendered into config.json by render-config.sh):
# htpasswd one shared user/pass — the only internet-safe value
# passthrough NO LOGIN. Anyone reaching the vhost is in.
# none alias of passthrough
# passthrough/none on a WRITABLE, internet-reachable share is an open upload
# relay (malware/phishing hosted on your domain, under your cert, with your
# bandwidth — and a domain blocklisting would take auth. and the apex down
# with it). Only set it once this host is unreachable from the internet.
FILES_AUTH=htpasswd
# The shared player credential (FILES_AUTH=htpasswd).
# FILES_PASSWORD_HASH: openssl passwd -6 '<password>'
# MUST be $6$ (sha512). The htpasswd plugin's bcrypt branch only accepts $2a$,
# so a $2y$ hash from `htpasswd -B` fails EVERY login, silently.
# render-config.sh rejects the wrong format rather than shipping it.
FILES_USER=uli
FILES_PASSWORD_HASH=
# Filestash /admin console password — bcrypt, and /admin IS publicly exposed, so
# use a 20+ char generated password. It is the only credential guarding config.
# docker run --rm caddy:alpine caddy hash-password --plaintext '<password>'
FILES_ADMIN_PASSWORD_HASH=
# Session key (openssl rand -hex 16). Must be non-empty: an empty secret_key
# makes filestash rewrite config.json at boot, which fails on the :ro mount.
FILES_SECRET_KEY=
# Unlocks the local storage backend, which filestash admin-gates: it refuses to
# init unless the connection params carry this secret. config.json supplies it
# server-side; players never see it. openssl rand -hex 24.
FILES_LOCAL_SECRET=
# Only needed if using CurseForge-exclusive mods:
# CF_API_KEY=your-curseforge-api-key
@@ -22,7 +102,7 @@ RCON_PASSWORD=change-me-to-something-random
# Only needed to issue real TLS certs. DNS-01 needs no inbound ports.
LE_EMAIL=you@example.com
# space-separated subdomains; apex ${BASE_DOMAIN} is always included
LE_SUBDOMAINS=auth pack
LE_SUBDOMAINS=auth distribution www avatar status files
# OVH API creds (DNS zone write). Create at https://eu.api.ovh.com/createToken/
# OVH_CK can be blank on first run — acme.sh prints an auth URL, then paste it.
OVH_END_POINT=ovh-eu
@@ -30,13 +110,3 @@ OVH_AK=
OVH_AS=
OVH_CK=
# LE_STAGING=1 # use the untrusted staging CA for dry runs
# ── Blessing Skin auth variant (docker-compose.blessingskin.yml) ──────
# Only needed when running the Blessing Skin override instead of Drasl.
BS_DB_NAME=blessingskin
BS_DB_USER=blessingskin
BS_DB_PASSWORD=change-me-bs-db
BS_DB_ROOT_PASSWORD=change-me-bs-root
# Generate once and paste here so it survives container recreates:
# docker run --rm azusamikan/blessing-skin-server-docker:latest php artisan key:generate --show
BS_APP_KEY=base64:replace-with-generated-key

33
.gitignore vendored
View File

@@ -5,16 +5,17 @@ runtime/authlib-injector.jar
# rendered configs (from tooling/render-config.sh)
drasl/config/config.toml
dnsmasq/dnsmasq.conf
# packwiz pack files rendered from pack/**/*.tmpl by tooling/render-pack.sh
# (domain-dependent build output); source of truth is the .tmpl + custom/ jars.
pack/mods/*.pw.toml
pack/index.toml
pack/CustomSkinLoader/CustomSkinLoader.json
nmsr/config.toml
# holds the admin bcrypt hash, the shared player hash, the session key and the
# local-backend secret — only the .tmpl is tracked
filestash/config.json
# vendored / mirrored binaries
mirror/
pack/mods-files/
# server mod subset synced from the distribution repo by
# tooling/sync-server-mods.sh (source of truth is mods-sides.json + $DIST jars)
server-mods/
# vendored binaries
launcher/
# landing page: generated build output + deps
www/
@@ -22,8 +23,18 @@ landing/node_modules/
landing/.astro/
landing/dist/
# exported Caddy CA (per-deploy artifact)
caddy/caddy-root-ca.crt
# launcher download list: the custom-launcher build output launcher.json,
# synced from UlicraftLauncher/dist/launcher.json (launcher.example.json is
# committed as the documented shape)
landing/src/data/launcher.json
# mod catalogue + extracted logos: generated by tooling/build-modlist.py from
# the distribution forgemods (the .example.json is committed as the empty shape)
landing/src/data/mods.json
landing/public/mod-logos/
# compiled mc-status binary (built into the image; stray local `go build` output)
docker/mc-status/mc-status
# Let's Encrypt certs + private keys (issued by tooling/issue-letsencrypt.sh)
certs/

415
CLAUDE.md
View File

@@ -1,280 +1,219 @@
# Minecraft LAN Party Server — Project Context
# Ulicraft Server — Project Context
> Self-hosted modded Minecraft server for a LAN party (~8 players), with
> persistent auth/skin infrastructure designed to outlive the LAN weekend
> and integrate with the existing homelab.
> Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
> (Drasl), a distribution-fed modpack, avatar rendering, a guest landing page, and uptime
> monitoring. Public, internet-present deployment behind the host's nginx + TLS.
**`plan/` is the source of truth for the design.** Start at `plan/00-overview.md`.
This file is a fast orientation + the durable decisions/gotchas; when it disagrees
with `plan/` or the code, those win.
## Goals & Constraints
- **Players**: 410 friends, LAN-based
- **Players**: 410 friends
- **Modpack vibe**: Kitchen-sink (tech + magic + exploration + QoL)
- **Pack approach**: Manually curated (~50100 mods), NOT a forked existing pack
- **Minecraft version**: 1.21.1
- **Mod loader**: NeoForge (NOT classic Forge — see Decisions below)
- **Network**: LAN party context, but stack is persistent (lives past the weekend)
- **Auth**: Self-hosted Yggdrasil-compatible (Drasl) + Keycloak OIDC
- **Skins**: Handled by Drasl
- **Persistence horizon**: Long-term homelab service, not disposable
## Existing Homelab Context (relevant to this project)
- **Keycloak 26.0.7** running in Docker Compose (PostgreSQL 17, production mode,
bind-mounted secrets, xforwarded proxy headers). This is the IdP for everything.
- **NetBird** as the proxy/networking layer for remote access (NOT used for the
LAN party itself — see Decisions).
- **AdGuard Home** as recursive DNS resolver — used here for `*.home.local` LAN records.
- **Multi-VPS topology**: this Minecraft project likely runs on a local box or
beefier homelab node, not a VPS (RAM/CPU requirements).
- Operator timezone: Europe/Madrid.
## Key Decisions (with reasoning)
### NeoForge over classic Forge
Despite the user originally saying "Forge", the 1.21.x kitchen-sink ecosystem
(ATM10, Leaking Kitchen Sink, etc.) is overwhelmingly NeoForge in 2025/2026.
The original Forge team essentially migrated to NeoForge. `itzg/minecraft-server`
supports both via `TYPE=NEOFORGE`. Going with NeoForge 1.21.1.
### Drasl over Ely.by or vanilla offline mode
- **Ely.by**: not self-hosted, Russian-hosted, no control over identity layer.
- **Vanilla offline + no auth server**: works for one weekend but skins break,
no persistent identity, doesn't match homelab philosophy.
- **Drasl**: self-hosted Go service, Yggdrasil-compatible, drop-in Mojang
replacement, supports skins + capes, **has first-class OIDC support with
PKCE** (perfect for Keycloak), actively maintained (current version 3.4.2+).
GPLv3 licensed.
### LAN (not NetBird) for the party itself
NetBird is the homelab's remote access layer, but adding a mesh VPN on top
of a LAN party introduces complexity for guests with no upside. Drasl and
Minecraft live on the LAN; clients reach them via `drasl.home.local` resolved
through AdGuard.
### Manual curation (user choice — flagged as expensive)
User insisted on manual curation despite my pushback that forking ATM10 or
Leaking Kitchen Sink would save weeks of crash-log triage. **Note for future
sessions**: if curation gets painful, the fork-and-trim option is still on
the table — Leaking Kitchen Sink is the closest match to the requested vibe.
### itzg/minecraft-server as base image
The de facto standard. Handles NeoForge installation automatically via
`TYPE=NEOFORGE` + `VERSION` + `NEOFORGE_VERSION`. Supports Modrinth and
CurseForge mod auto-download via `MODRINTH_PROJECTS` and `CURSEFORGE_FILES`.
- **Minecraft version**: 1.21.1, **NeoForge** (not classic Forge)
- **Auth/skins**: Drasl (Yggdrasil-compatible), **password login** — no OIDC
- **DNS**: handled OUTSIDE this repo; point `${BASE_DOMAIN}` + subdomains at the host
- **Persistence horizon**: long-term service, not disposable
## Architecture
```
LAN segment
├── AdGuard Home → resolves drasl.home.local, keycloak.home.local
│ to the Docker host's LAN IP
├── Keycloak (existing, separate compose)
│ realm: homelab
│ client: drasl (confidential, PKCE S256)
└── Minecraft host (this project's compose)
├── drasl :25585 (Yggdrasil API + web UI)
│ └── OIDC → Keycloak
├── minecraft :25565 (server)
│ :24454/udp (Simple Voice Chat, optional)
│ └── -javaagent:authlib-injector.jar=http://drasl.home.local:25585/authlib-injector
│ └── ONLINE_MODE=false (required for authlib-injector)
└── mc-backup (itzg/mc-backup, 6h interval)
└── RCON → minecraft
One unified `docker-compose.yml`. The host's own nginx terminates TLS (Let's
Encrypt) and reverse-proxies every vhost to caddy (published localhost-only) by
Host header. caddy is the internal router; containers reach the stack's own names
via caddy's `mcnet` aliases (`auth.`).
Clients (LAN party guests):
Prism Launcher → authlib-injector account pointing at Drasl
→ joins minecraft:25565
```
Internet ── host nginx (TLS, LE certs, HSTS) ──► caddy (127.0.0.1:8880)
│ routes by Host:
${BASE_DOMAIN} ─► /srv/www landing + /launcher/ downloads
auth.${BASE_DOMAIN} ─► drasl (Yggdrasil API + web UI)
avatar.${BASE_DOMAIN} ─► nmsr (skin/avatar renderer, Drasl-backed)
status.${BASE_DOMAIN} ─► uptime-kuma (status page + monitors)
files.${BASE_DOMAIN} ─► filestash (player file share, ONE shared login, writable)
distribution.${BASE} ─► static site from ANOTHER repo (DISTRIBUTION_WEB_ROOT)
minecraft :25565 (+ 24454/udp Simple Voice Chat)
└ ONLINE_MODE=true (validated against Drasl, NOT offline), -javaagent authlib-injector → http://auth.${BASE_DOMAIN}/authlib-injector
└ mods from ./server-mods volume (/mods, REMOVE_OLD_MODS) — see plan/04-mods.md
mc-backup ── RCON → minecraft (6h interval, prune 14d, world-only)
```
## Auth Flow (end-to-end)
Bring-up: render configs → sync server mods → build landing → fetch launcher → `docker compose up -d --build`.
See `plan/12-build-order.md` and `plan/14-deploy.md`. Deploy to prod host `cochi`
via the `deploy` skill.
1. Guest opens `http://drasl.home.local:25585` in browser.
2. Clicks "Register with Keycloak" → redirected to Keycloak login.
3. Logs in with homelab Keycloak credentials.
4. Returned to Drasl, picks a player name, uploads a skin.
5. Drasl shows them a "Minecraft Token" on their profile page.
6. In Prism Launcher: Settings → Accounts → Add authlib-injector account
with URL `http://drasl.home.local:25585/authlib-injector` and the
Minecraft Token as password.
7. Joins Minecraft server. Server validates session against Drasl (via
authlib-injector JVM agent), Drasl confirms, player enters with their skin.
## Key Decisions
- **NeoForge over Forge** — the 1.21.x kitchen-sink ecosystem is overwhelmingly
NeoForge; the Forge team migrated. `itzg/minecraft-server` via `TYPE=NEOFORGE`.
- **Drasl over Ely.by / vanilla offline** — self-hosted Go service, Yggdrasil
drop-in, skins + capes, actively maintained. Run with **password login**
(no OIDC/Keycloak in this stack).
- **Manual curation** (user choice, flagged expensive) — if it gets painful,
fork-and-trim Leaking Kitchen Sink is still on the table (closest vibe match).
- **itzg/minecraft-server** base image — de-facto standard, auto-installs NeoForge.
- **Distribution-fed mod volume (replaced packwiz)** — the modset's source of
truth is the HeliosLauncher distribution repo (`$DISTRIBUTION_WEB_ROOT`).
Clients install the full set via `distribution.json`. The SERVER loads a
filtered subset (`both`/`server`) from a mounted `./server-mods` volume
(`REMOVE_OLD_MODS=TRUE`). See `plan/04-mods.md`.
## Critical Gotchas
### Minecraft 1.21+ secure profile incompatibility
- Server: must set `enforce-secure-profile=false` (compose: `ENFORCE_SECURE_PROFILE=FALSE`)
- Drasl: must set `SignPublicKeys = false`
- These are linked. The Drasl docs explicitly warn: *"Mixed authentication does
not work with `SignPublicKeys = true` on Minecraft 1.21+."*
### Minecraft 1.21+ secure profile
- Server: `ENFORCE_SECURE_PROFILE=FALSE` (`enforce-secure-profile=false`)
- Drasl: `SignPublicKeys = false`
- Linked. Drasl docs: *"Mixed authentication does not work with
`SignPublicKeys = true` on Minecraft 1.21+."*
- **This is the ONLY auth flag that goes off. `ONLINE_MODE` stays TRUE** — see
next gotcha. Do not conflate "secure profile off" with "offline mode".
### ONLINE_MODE must be TRUE (authlib-injector ≠ offline)
authlib-injector redirects the server's normal **online**-auth handshake from
Mojang to Drasl. `ONLINE_MODE=FALSE` skips that handshake → offline-hash UUIDs,
no Drasl session validation, and **no skins** (everyone joins but everyone is
Steve; `usercache.json` shows offline-hash UUIDs). Keep `ONLINE_MODE=TRUE`.
### authlib-injector JVM agent
- Syntax: `-javaagent:/path/to/authlib-injector.jar=<yggdrasil-api-url>`
- The URL after `=` is the **API root**, which for Drasl is
`<BaseURL>/authlib-injector`.
- **Must match between server and client.** If server uses
`http://drasl.home.local:25585/authlib-injector` and client uses anything
else (e.g. an IP), session validation fails silently with "Invalid session".
- Download from: https://github.com/yushijinhun/authlib-injector/releases
- Mount into the itzg container at `/extras/authlib-injector.jar`.
### Keycloak reachability on LAN day
If Keycloak is only reachable via NetBird or only from the public internet,
**Drasl OIDC registration breaks on LAN-only day**. Options:
1. Expose Keycloak on the LAN (simplest)
2. Pre-register all guests before the party
3. Temporarily disable OIDC and allow password registration during the LAN
(`AllowPasswordLogin = true`, comment out the OIDC block)
### HTTP vs HTTPS for OIDC
Modern browsers warn on plain HTTP for OIDC flows but they work. For a
production-grade setup, front Drasl with Caddy or Traefik using a local CA
cert (smallstep/step-ca pairs well with the existing homelab). For LAN-only
HTTP is fine.
- Syntax: `-javaagent:/extras/authlib-injector.jar=<yggdrasil-api-url>`
- URL after `=` is the **API root** = `<BaseURL>/authlib-injector`. For this
stack: `http://auth.${BASE_DOMAIN}/authlib-injector` (internal, over mcnet).
- **Must match between server and client** (clients use the public
`https://auth.${BASE_DOMAIN}/authlib-injector`), else session validation fails
silently with "Invalid session".
- Releases: https://github.com/yushijinhun/authlib-injector — jar lives in
`runtime/` (mounted at `/extras/authlib-injector.jar`).
### NeoForge version pinning
Mods are picky about exact NeoForge minor versions. The compose currently
pins `NEOFORGE_VERSION: "21.1.209"` as a placeholder. **Verify against
https://projects.neoforged.net/neoforged/neoforge before deploying.** Don't
use `"latest"` for a stable server.
Mods are picky about exact minor versions. Compose pins `NEOFORGE_VERSION:
"21.1.233"` **must match the distribution's NeoForge** (`distribution.json`),
since mods are built against it (e.g. ferritecore≥218, farmersdelight≥219).
**Verify against https://projects.neoforged.net/neoforged/neoforge before
deploying.** Never `"latest"` for a stable server.
### Drasl username vs player name
- **Drasl username** = OIDC user's email (used for web UI login)
- **Player name** = `preferred_username` from OIDC, or user-chosen if
`AllowChoosingPlayerName = true`
- Keycloak users must have email set. Verify the realm's email-as-username
setting and the `preferred_username` mapper are configured.
### Mod source + server filtering
The full modset lives in the distribution repo (`$DISTRIBUTION_WEB_ROOT/servers/
ulicraft-1.21.1/forgemods/required/*.jar`), managed by Nebula. Clients get
everything via `distribution.json`. The server runs a **filtered subset**:
`tooling/classify-mods.py` reads each jar's `neoforge.mods.toml` with tomllib and
seeds `mods-sides.json` (`client|server|both`, default `both`), then
`tooling/sync-server-mods.sh` copies the `both`/`server` jars into `./server-mods`
(mounted at `/mods`). Hard client-only mods (sodium/iris) classify as `client`
automatically; cosmetic-client mods that declare `BOTH` stay `both` until you
hand-edit `mods-sides.json` to `client`. No Modrinth/packwiz, no network at
classify/sync time. See `plan/04-mods.md`.
### Mod source preference
Prefer **Modrinth over CurseForge** for itzg auto-download. CurseForge
requires an API key (`CF_API_KEY`) and has been historically flakier with
the itzg image. Use CF only for mods that are exclusive to it.
### Filestash (files.) — four traps
Full list in `plan/20-files.md`; the ones that cost real time:
- **`general.host` is a BARE HOSTNAME** (`files.${BASE_DOMAIN}`, no scheme) +
`force_ssl: true`. The SPA builds its origin as `scheme + host`, so a URL there
yields `http://https://files...` and every client-side redirect dies. The
server strips the scheme before its own Host check, so **curl and the uptime
monitor stay green while every browser is broken**. Cost a debugging round on
2026-07-14.
- The `local` backend is **admin-gated**: without `LOCAL_BACKEND_SECRET` in the
connection params, every login fails with `backend error - Not Allowed` (looks
like bad credentials, isn't).
- `FILES_PASSWORD_HASH` must be **`$6$`** (`openssl passwd -6`). A `$2y$` bcrypt
from `htpasswd -B` fails every login silently — the plugin only matches `$2a$`.
- `config.json` is rendered + mounted `:ro` (the admin console can't save, on
purpose). Single-file bind mount ⇒ **`--force-recreate` after re-rendering**,
never `restart`.
## Compose Stack
The working compose file is at `./docker-compose.yml`. Key environment vars
documented inline. Adjacent files:
```
.
├── docker-compose.yml
├── .env # RCON_PASSWORD, CF_API_KEY (if needed)
├── drasl/
│ └── config/
│ ├── config.toml # see ./drasl-config.toml example
│ └── keycloak-client-secret # single line, no trailing newline
├── extras/
│ ├── authlib-injector.jar # downloaded from yushijinhun's releases
│ ├── modrinth-mods.txt # one mod slug per line
│ └── cf-mods.txt # only if using CurseForge mods
├── backups/ # mc-backup writes here
└── CLAUDE.md # this file
```
**Verifying an SPA:** a 200 on `/` proves the server is up, not that the app
works. After any filestash config change, check the browser-facing values
(`curl /api/config``origin`) and re-read `docker compose logs filestash`
the broken redirect announced itself there (`msg=Redirecting to http://https://`)
while every other check passed.
### Memory sizing
With ~50100 mods and 8 concurrent players on 1.21.1:
- `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G` is a safe starting point
- Use `USE_AIKAR_FLAGS: TRUE` for the well-known GC tuning
- Monitor with `mc-monitor` (itzg makes one) if you want metrics
~50100 mods, 8 players, 1.21.1: `INIT_MEMORY: 4G`, `MAX_MEMORY: 10G`,
`USE_AIKAR_FLAGS: TRUE`.
### Backups
`itzg/mc-backup` runs alongside, talks to the server via RCON, takes
snapshots every 6h, prunes after 14 days. Backups are world-only (no mod
jars), which is correct — mods are reproducible from the mod list files.
## Config (.env)
## Keycloak Client Configuration
`BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT`/`CADDY_HTTP_BIND`,
`DISTRIBUTION_WEB_ROOT` (source of the modset + caddy `distribution.` mount), the
Let's Encrypt block (`LE_EMAIL`, `LE_SUBDOMAINS`, `OVH_*`). Rendered configs
(drasl, nmsr) come from `*.tmpl` via `tooling/render-config.sh` (substitutes
`${BASE_DOMAIN}` only). Server mods are synced (not rendered) by
`tooling/sync-server-mods.sh`.
In the `homelab` realm (or whatever the realm is called):
## Client Distribution (guests)
1. **Clients → Create client**
- Client type: `OpenID Connect`
- Client ID: `drasl`
- Name: `Drasl Minecraft Auth`
2. **Capability config**
- Client authentication: **ON** (confidential client)
- Authentication flow: **Standard flow** only
- Disable: Direct access grants, Implicit, Service accounts
3. **Login settings**
- Root URL: `http://drasl.home.local:25585`
- Valid redirect URIs: `http://drasl.home.local:25585/web/oidc-callback/Keycloak`
(the `Keycloak` at the end MUST match the `Name = "Keycloak"` in Drasl's
`[[RegistrationOIDC]]` block — case sensitive)
- Web origins: `http://drasl.home.local:25585`
4. **Credentials tab** → copy Client Secret to
`./drasl/config/keycloak-client-secret` (no trailing newline).
5. **Advanced tab** → PKCE Code Challenge Method: `S256`
## Client Distribution (LAN guests)
### Recommended launcher
**Prism Launcher** — open source, cross-platform (Win/Mac/Linux), first-class
authlib-injector support, can import/export instance ZIPs.
### Per-guest one-time setup
1. Install Prism Launcher.
2. Add authlib-injector account:
- URL: `http://drasl.home.local:25585/authlib-injector`
- Username/password: their Drasl credentials (or Minecraft Token if
registered via Keycloak OIDC)
3. Import the modpack instance ZIP.
### What to ship guests
- A Prism instance ZIP (right-click instance → Export Instance)
- A one-page README with the auth URL and import steps
- Optionally `authlib-injector.jar` for guests who refuse Prism
Launcher: **FjordLauncherUnlocked** (Prism fork, first-class authlib-injector),
mirrored at apex `/launcher/`. Per guest:
1. Download from `https://${BASE_DOMAIN}``/launcher/`.
2. Add an authlib-injector account, URL
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
`https://auth.${BASE_DOMAIN}`).
3. The launcher installs the modpack from the distribution (`distribution.json`).
(packwiz and the `pack.` service/subdomain have been fully removed.)
4. Connect to `${BASE_DOMAIN}` (`:25565`).
## Open / Pending Work
These were on the roadmap when we ended the brainstorm:
### Roadmap — mod migration follow-ups (post packwiz→04-mods)
- [ ] **Curate `mods-sides.json` cosmetic-client mods.** tomllib auto-flagged 11
hard client-only mods; the other 65 default `both`, including client-cosmetic
ones (BetterF3, entityculling, MouseTweaks, ponderjs, fancymenu, drippy, melody,
welcomescreen, Searchables, JustEnoughResources, TravelersTitles, yeetus…) that
load harmlessly on the server but waste RAM. Hand-edit them to `client` so
`sync-server-mods.sh` drops them from `./server-mods`. (Also review `appleskin`,
flagged `client` — flip to `both` if server-side food data wanted.)
### Landing rework + mod list (PLANNED — see `plan/18-landing-rework.md`)
Full design settled across WS1WS6; all tasks unchecked, ready to execute.
- [ ] **WS1 — Join flow off packwiz.** Homepage = UlicraftLauncher, 3 steps
(register → download launcher → join); drop `packwizUrl` + packwiz step.
Per-OS downloads from a synced `launcher.json` (shape
`productName/version/files[]`, the build output of the `custom-launcher`
repo; `launcher.example.json` committed as the documented fallback). Fjord
moves to its own `/fjord` page.
- [ ] **WS2/3 — Player rosters.** Online (mc-status, live) + all-registered (new
`mc-status /api/mcstatus/players`, admin-login → Drasl `GET /players`, ~60s
cache). Shared avatar tile, `AVATAR_MODE` env (default `headiso`). Admin creds
→ mc-status only.
- [ ] **WS4 — Account page** (`/account`): skin CRUD via browser-direct Drasl
(reuses `register.astro` skin JS). Skins only, in-memory token, `players[0]`.
- [ ] **WS5 — Footer status link** to `status.${BASE_DOMAIN}` (nav unchanged).
- [ ] **Mod shortlist for the kitchen-sink pack.** Categories to fill:
- Performance (Embeddium/Sodium-equivalent for NeoForge, FerriteCore, ModernFix)
- Tech/automation (Mekanism, Create, Immersive Engineering, AE2)
- Magic (Ars Nouveau, Botania, Iron's Spells 'n Spellbooks)
- Storage (Sophisticated Storage/Backpacks, Functional Storage)
- Exploration (YUNG's structures, Repurposed Structures, biome packs)
- QoL (JEI, Jade, JEI Resources, Inventory Profiles Next, AppleSkin)
- World gen (Tectonic, Terralith — check NeoForge 1.21.1 compat)
- Compat glue (Polymorph for recipe conflicts)
- Map (XaeroMinimap + Xaero World Map)
- Voice (Simple Voice Chat — port 24454/udp already in compose)
- [ ] **Decide on dimension/server-side performance mods** (C2ME, Lithium-equivalent)
- [ ] **Reverse proxy + local TLS** for Drasl (Caddy + step-ca recommended,
given the existing homelab)
- [ ] **DNS record** in AdGuard for `drasl.home.local` → Docker host LAN IP
- [ ] **Keycloak client** creation per the section above
- [ ] **Verify NeoForge version** against current stable before first deploy
- [ ] **Bootstrap admin in Drasl**: leave `AllowPasswordLogin = true` initially,
create the admin account, then optionally disable password login to force OIDC
- [ ] **WS6 — Mod shortlist + list component (`plan/17-mod-shortlist.md`).**
Light gap doc: current 76-jar pack is vanilla+ QoL/perf; **empty** on tech &
magic, thin worldgen, no map; orphan deps `ponderjs`/`Necronomicon`. Landing
mod-list component = auto-generated `mods.json` + extracted logos from the
distribution forgemods (`tooling/build-modlist.py`), flat alphabetical, pretty
names, no categories/links; feeds the "50+" stat tile.
- [ ] Server-side perf mods (C2ME, etc.).
- [x] **Filestash file share** (`files.`, 2026-07-14) — live in prod. One shared
htpasswd login, writable, rendered `:ro` config. See `plan/20-files.md`.
Follow-ups: add the Uptime Kuma HTTP monitor for `files.`; the share is
**outside mc-backup** and unquota'd (accepted — eyeballed).
- [ ] **Verify NeoForge version** against current stable before first deploy.
- [x] **Bootstrap Drasl admin** account (`admin`, 2026-06-10). Key is
`DefaultAdmins` (not `DefaultAdminUsernames`); first admin needs an invite-flip.
See `plan/03-drasl.md` gotchas.
## Reference URLs
- Drasl repo: https://github.com/unmojang/drasl
- Drasl configuration docs: https://github.com/unmojang/drasl/blob/master/doc/configuration.md
- Drasl recipes (example configs): https://github.com/unmojang/drasl/blob/master/doc/recipes.md
- Drasl: https://github.com/unmojang/drasl — config docs:
https://github.com/unmojang/drasl/blob/master/doc/configuration.md
- authlib-injector: https://github.com/yushijinhun/authlib-injector
- itzg/minecraft-server: https://github.com/itzg/docker-minecraft-server
- itzg NeoForge docs: https://github.com/itzg/docker-minecraft-server/blob/master/docs/types-and-platforms/server-types/forge.md
(NeoForge: …/docs/types-and-platforms/server-types/forge.md)
- itzg/mc-backup: https://github.com/itzg/docker-mc-backup
- NeoForge versions: https://projects.neoforged.net/neoforged/neoforge
- Prism Launcher: https://prismlauncher.org/
- Reference modpacks for inspiration (NOT forking):
- ATM10 (Modrinth/CurseForge — ~500 mods, NeoForge 1.21.1)
- Leaking Kitchen Sink (CurseForge — trimmed ~150 mods, NeoForge 1.21.1)
- NMSR: https://github.com/NickAcPT/nmsr-rs
- Uptime Kuma: https://github.com/louislam/uptime-kuma
- FjordLauncherUnlocked: https://github.com/hero-persson/FjordLauncherUnlocked
- Reference packs (inspiration, NOT forking): ATM10, Leaking Kitchen Sink.
## Communication Preferences (carry-over from past sessions)
## Communication Preferences (carry-over)
- Concise and direct
- Diagnose and resolve without demanding detailed reproduction steps
- Prefer iterative single-change updates over large rewrites
- Cautious scripts that halt on ambiguity rather than proceeding silently
- Avoid over-engineered responses when a simple answer suffices
- Spanish or English fine; user is comfortable in both
- Concise and direct; diagnose + resolve without demanding repro steps.
- Prefer iterative single-change updates over large rewrites.
- Cautious scripts that halt on ambiguity rather than proceeding silently.
- Avoid over-engineering when a simple answer suffices.
- Spanish or English both fine.

267
README.md
View File

@@ -1,111 +1,232 @@
# Ulicraft Server
Self-hosted modded Minecraft (NeoForge 1.21.1) for a LAN party. Runs **online**
(internet present) or **fully air-gapped** (offline). Self-hosted auth/skins
(Drasl), a packwiz modpack, a guest landing page, and a transparent mirror of
Mojang/launcher/NeoForge assets so guest laptops need no internet.
Self-hosted modded Minecraft (NeoForge 1.21.1) with self-hosted auth/skins
(Drasl), a distribution-fed modpack, avatar rendering, a guest landing page, and
uptime monitoring. One unified `docker-compose.yml` runs the whole stack behind
the host's nginx + Let's Encrypt.
## Stack
One compose file, one `docker compose up -d`:
| Service | Image | Role |
|---|---|---|
| `minecraft` | itzg/minecraft-server | NeoForge 1.21.1 server, `:25565` |
| `drasl` | unmojang/drasl | Yggdrasil auth + skins (password login) |
| `caddy` | caddy:alpine | ingress: landing, auth/packwiz proxy, asset mirror (`tls internal`) |
| `caddy` | caddy:alpine | internal ingress: routes every vhost by Host header |
| `nmsr` | built from source | skin/avatar renderer at `avatar.${BASE_DOMAIN}` |
| `mc-status` | built from source | live server-status JSON for the landing card |
| `uptime-kuma` | louislam/uptime-kuma | status page at `status.${BASE_DOMAIN}` |
| `filestash` | machines/filestash (digest-pinned) | player file share at `files.${BASE_DOMAIN}` |
| `mc-backup` | itzg/mc-backup | world backups every 6h via RCON |
Optional layers: `avahi` (mDNS `.local`), `dnsmasq` (turnkey DNS) — only if you
don't run your own DNS.
Vhosts (all proxied through the host nginx → caddy):
Config lives in `.env`: `BASE_DOMAIN` (default `ulicraft.lan`), `HOST_LAN_IP`,
`RCON_PASSWORD`, `ENABLE_MDNS`. Services are subdomains: `auth.`, `packwiz.`,
`mc.`, plus the apex landing page.
- apex `${BASE_DOMAIN}` — landing page + `/launcher/` downloads + `/api/mcstatus/*`
- `auth.` — Drasl
- `avatar.` — NMSR
- `status.` — Uptime Kuma
- `files.` — Filestash player file share (one shared login, writable)
- `distribution.` — static site from another repo (`DISTRIBUTION_WEB_ROOT`)
## DNS (pick one)
Config lives in `.env`: `BASE_DOMAIN`, `RCON_PASSWORD`, `CADDY_HTTP_PORT` /
`CADDY_HTTP_BIND`, `DISTRIBUTION_WEB_ROOT`, the `FILES_*` block. See `.env.example`.
Names must resolve to `HOST_LAN_IP`. Use **your own party DNS server** (recommended):
## DNS
```sh
tooling/dns-records.sh online # service names only (internet present)
tooling/dns-records.sh airgap # + Mojang/launcher/NeoForge spoofs (offline)
```
Paste the printed records into your DNS. **Use `.lan`, not `.local`**`.local`
is intercepted by mDNS and won't resolve via unicast DNS.
Alternatives: `ENABLE_MDNS=true` + `BASE_DOMAIN=*.local` (zero-config mDNS,
macOS/Linux native, Windows needs Bonjour) — but mDNS can't serve the air-gap
upstream spoofs. Or layer in `docker-compose.dnsmasq.yml` for a turnkey DNS.
### Simulate the DNS via /etc/hosts (single machine, testing)
No DNS server? Append the records straight to `/etc/hosts` (marker-wrapped):
```sh
tooling/dns-records.sh online hosts | sudo tee -a /etc/hosts # services only
tooling/dns-records.sh airgap hosts | sudo tee -a /etc/hosts # + Mojang spoofs
```
Remove them later:
```sh
sudo sed -i '/# >>> ulicraft/,/# <<< ulicraft/d' /etc/hosts
```
Notes:
- `/etc/hosts` has **no wildcard** — only the listed names resolve.
- `airgap` adds the Mojang/launcher/NeoForge hostnames pointing at the LAN; this
**breaks normal internet access to those domains** while present. Use it only
while offline; for an online box use `online`.
- `mc.<domain>` works (client defaults to :25565); SRV isn't used via `/etc/hosts`.
Handled **outside this repo**. Point `${BASE_DOMAIN}` and every subdomain
(`auth. avatar. status. files. distribution. www.`) at the host running nginx.
## Prerequisites
Docker + Docker Compose; for prep also `pnpm`, `packwiz`, `jq`, `curl`,
`envsubst`, `sha1sum`.
Docker + Docker Compose; for prep also `pnpm`, `jq`, `curl`, `envsubst`.
## Launch
```sh
cp .env.example .env # set BASE_DOMAIN, HOST_LAN_IP, RCON_PASSWORD
cp .env.example .env # set BASE_DOMAIN, RCON_PASSWORD, DISTRIBUTION_WEB_ROOT
tooling/build-stack.sh --prep # ONLINE, once: render, build landing,
# mirror mods+assets, fetch launcher +
# authlib, pre-bake the server volume
# One-time / on change — render configs + build content:
tooling/render-config.sh # drasl + nmsr configs from *.tmpl
tooling/sync-server-mods.sh # filtered server mods → ./server-mods
( cd landing && pnpm install && BASE_DOMAIN="$BASE_DOMAIN" pnpm run build ) # → www/
tooling/fetch-launcher.sh # FjordLauncher assets → launcher/ (served at /launcher/)
tooling/fetch-authlib.sh # authlib-injector.jar → runtime/ (server JVM agent)
tooling/build-stack.sh --up online # internet present, no mirror
tooling/build-stack.sh --up airgap # offline; full asset mirror on :443 (default)
tooling/build-stack.sh --up core # base only (debugging)
docker compose up -d --build # first run installs NeoForge + mods, builds nmsr
docker compose down # stop
```
`--up` prints the DNS records to add for that mode. Down:
`docker compose -f docker-compose.yml -f docker-compose.static.yml -f docker-compose.mirror.yml down`
First boot: itzg installs NeoForge + the synced server mods into the `mc_data` volume;
nmsr does a one-time Rust compile. Watch `docker compose logs -f minecraft` until
`Done`.
## Public TLS (host nginx in front of caddy)
caddy is published on a localhost-only port (`CADDY_HTTP_BIND=127.0.0.1`,
`CADDY_HTTP_PORT=8880`). The host's own nginx terminates TLS with Let's Encrypt
certs and reverse-proxies every vhost to caddy by Host header. HTTPS-only: HTTP
301s to HTTPS and every TLS vhost sends HSTS.
1. **Issue certs** (OVH DNS-01, no inbound ports needed):
```sh
# .env: BASE_DOMAIN, LE_EMAIL, OVH_* creds,
# LE_SUBDOMAINS="auth distribution www avatar status"
tooling/issue-letsencrypt.sh # → certs/<name>/{cert,key}.pem
```
2. **Render + install the nginx vhost** with `tooling/render-nginx.sh`. It pulls
`BASE_DOMAIN` + `CADDY_HTTP_PORT` from `.env`, defaults `APP_DIR` to the repo
checkout (where `certs/` live), and renders `nginx/ulicraft-caddy.conf.tmpl`:
```sh
tooling/render-nginx.sh # preview to stdout (dry run)
tooling/render-nginx.sh --install # write, symlink, nginx -t, reload
```
Override `APP_DIR=/path` if the repo isn't at the cert location. Re-run
whenever you add a subdomain so its server block is rendered.
## Avatar renderer (NMSR)
`avatar.${BASE_DOMAIN}` renders players' skins/avatars via
[NMSR-aas](https://github.com/NickAcPT/nmsr-rs), sourced from **Drasl** (not
Mojang). No upstream image exists, so it's built from source — `docker/nmsr/`
pins a commit; bump `ARG NMSR_REF` to update.
- **Config**: `nmsr/config.toml.tmpl` → rendered to `nmsr/config.toml` by
`tooling/render-config.sh`. `[mojank]` points every Mojang endpoint at
`http://auth.${BASE_DOMAIN}`; Drasl serves the Mojang-compatible routes at its
bare BaseURL and embeds absolute skin URLs that NMSR fetches directly.
`allow_offline_mode_uuids = true` (Drasl issues offline v3 UUIDs).
- **Network**: skin resolution stays internal over `mcnet` (caddy alias
`auth.${BASE_DOMAIN}` → drasl). caddy reverse-proxies `avatar.` → `nmsr:8080`.
- **Endpoints**: e.g. `https://avatar.${BASE_DOMAIN}/skin/<player>`,
`/face/<player>`, `/fullbody/<player>` — by username or UUID.
First build is heavy (Rust compile). Headless rendering uses lavapipe (software
Vulkan); if renders fail, check `docker logs nmsr` for Vulkan device init.
## Status monitoring (Uptime Kuma)
`status.${BASE_DOMAIN}` runs [Uptime Kuma](https://github.com/louislam/uptime-kuma)
— uptime probes for every vhost **and** a native Minecraft-protocol ping (player
count + up/down), plus a public status page. It joins `mcnet`, so monitors can
probe the stack by internal service name. caddy reverse-proxies `status.` →
`uptime-kuma:3001`.
First run, open `https://status.${BASE_DOMAIN}`, create the admin account, and
add monitors. Kuma has no config-as-code — add these once via the UI (data
persists in the `kuma_data` volume):
| Monitor | Type | Target |
|---|---|---|
| Minecraft | Minecraft Server | `minecraft` : `25565` |
| Drasl auth | HTTP(s) | `https://auth.${BASE_DOMAIN}` |
| Landing (apex) | HTTP(s) | `https://${BASE_DOMAIN}` |
| Distribution | HTTP(s) | `https://distribution.${BASE_DOMAIN}` |
| Avatar (NMSR) | HTTP(s) | `https://avatar.${BASE_DOMAIN}` |
| Files (Filestash) | HTTP(s) | `https://files.${BASE_DOMAIN}` (expect 200 — the login page is a 200) |
Internal-name targets (`minecraft`, `drasl:25585`, `nmsr:8080`) isolate "service
down" from "ingress/TLS/DNS down"; public-URL targets test the whole chain.
`status` is in the default `LE_SUBDOMAINS`; the nginx vhost adds websocket
upgrade headers for Kuma's live UI.
## Server status JSON (mc-status)
`mc-status` is a self-hosted SLP→JSON pinger (built from `docker/mc-status`, an
mcutil wrapper) that the landing's `ServerCard` reads for live player count + MOTD.
It emits **mcstatus.io v2** JSON, so the card consumes it unchanged — but
same-origin, with no CORS and no third-party calls. caddy proxies the apex
`/api/mcstatus/*` path to `mc-status:8080` (strips the prefix); the container has
no published port. The pinger only ever probes its configured `MC_STATUS_TARGET`,
so the path after the prefix is ignored.
Endpoint (apex, same-origin):
```
https://${BASE_DOMAIN}/api/mcstatus/v2/status/java/${BASE_DOMAIN}
```
Hitting caddy directly (localhost-only, routes by Host header) — e.g. `ulicraft.net`
on `127.0.0.1:8880`:
```sh
curl -H "Host: ulicraft.net" \
http://127.0.0.1:8880/api/mcstatus/v2/status/java/ulicraft.net
```
Wired in `landing/src/data/site.ts` (`statusApi`) and `caddy/conf.d/10-static.caddy`.
## Player file share (Filestash)
`files.${BASE_DOMAIN}` is a web file share for player media — screenshots,
schematics, maps, guides. **One shared username/password for everybody**
(`FILES_USER` / `FILES_PASSWORD_HASH`), read **and** write. It is *not* a backup
browser and *not* a mod mirror: world snapshots contain every player's inventory
and coordinates, and the modset already ships via `distribution.`.
Full design, deploy steps and gotchas: **`plan/20-files.md`**. The short version:
- Config is `filestash/config.json`, **rendered from a template and mounted
`:ro`** — git is the source of truth. The admin console at `/admin` loads but
**cannot save**; that's deliberate. Change config by editing the `.tmpl`,
re-rendering, and **recreating** the container (`up -d --force-recreate
filestash` — a single-file bind mount pins the inode, so `restart` reads the
stale file).
- Files live in `${FILES_DATA_DIR}` on the host, outside the repo. **Not backed
up** by mc-backup — treat the share as disposable.
- `FILES_MOUNT_MODE=ro|rw` is the literal mount flag: `ro` makes the share
read-only at the *kernel*, whatever the UI thinks.
- `FILES_AUTH=htpasswd|passthrough` — `passthrough` means **no login at all**.
Only valid once the host is off the public internet: an unauthenticated
*writable* share on a public domain is an open upload relay.
Four traps that cost real time (all enforced or documented in the tooling):
- **`general.host` is a bare hostname**, no scheme (`files.${BASE_DOMAIN}`), with
`force_ssl: true`. The SPA builds its redirect origin as `scheme + host`, so a
URL there produces `http://https://files...` and every client-side redirect
breaks. The server strips the scheme before its *own* Host check, so `curl`
and the uptime monitor stay **green while every browser is broken**.
- **`FILES_PASSWORD_HASH` must be `$6$`** (`openssl passwd -6`). The htpasswd
plugin's bcrypt branch only matches `$2a$`, so a `$2y$` hash from
`htpasswd -B` fails *every* login, silently. `render-config.sh` rejects it.
- **The `local` storage backend is admin-gated** — without `FILES_LOCAL_SECRET`
reaching it through the connection params, every login dies with
`backend error - Not Allowed`, which looks like bad credentials and isn't.
- **Don't set `APPLICATION_URL`/`ADMIN_PASSWORD` env** — either makes Filestash
rewrite `config.json` at boot, which fails against the `:ro` mount.
> Verifying this service: a 200 on `/` only proves the server is up. Check the
> browser-facing origin — `curl -s -H 'X-Requested-With: XmlHttpRequest`
> `https://files.${BASE_DOMAIN}/api/config | jq -r .result.origin` must print
> `https://files.${BASE_DOMAIN}` — and re-read `docker compose logs filestash`.
## Join (guests)
1. Open `http://ulicraft.lan`, download FjordLauncherUnlocked for your OS.
2. Add an **authlib-injector** account, URL `http://auth.ulicraft.lan/authlib-injector`
(register/login at `http://auth.ulicraft.lan`).
3. **Air-gap only:** trust the local CA — download `http://ulicraft.lan/ca.crt`
and add it to your OS trust store (so the HTTPS asset mirror is trusted).
4. Import the pack: `http://packwiz.ulicraft.lan/pack.toml`.
5. Connect to **`mc.ulicraft.lan`** (no port needed).
1. Open `https://${BASE_DOMAIN}`, download FjordLauncherUnlocked for your OS.
2. Add an **authlib-injector** account, URL
`https://auth.${BASE_DOMAIN}/authlib-injector` (register/login at
`https://auth.${BASE_DOMAIN}`).
3. The launcher installs the modpack from the distribution automatically.
4. Connect to **`${BASE_DOMAIN}`** (Minecraft, `:25565`).
## Layout
```
docker-compose.yml # core: drasl, minecraft, mc-backup, caddy
.static.yml .mirror.yml # online/airgap layers
.avahi.yml .dnsmasq.yml # optional DNS layers
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets (core/static/mirror)
docker-compose.yml # the whole stack (one file)
caddy/Caddyfile + conf.d/*.caddy # ingress vhost snippets
# (core/static/distribution/avatar/status)
drasl/config/config.toml.tmpl # auth config (rendered)
dnsmasq/dnsmasq.conf.tmpl # optional DNS config (rendered)
docker/avahi/ # mDNS responder image
pack/ # packwiz modpack source
nmsr/config.toml.tmpl # avatar renderer config, Drasl-backed (rendered)
docker/nmsr/ # built-from-source NMSR image
landing/ # Astro site → www/
tooling/ # build-stack, render-config, dns-records,
# mirror-mods, mirror-airgap, fetch-launcher
mirror/ # vendored jars + launcher + asset mirror (gitignored)
plan/ # full design docs (0012)
launcher/ # FjordLauncher assets (gitignored)
server-mods/ # filtered server mod jars (synced, gitignored)
tooling/ # render-config, sync-server-mods, render-nginx,
# issue-letsencrypt, fetch-launcher
nginx/ulicraft-caddy.conf.tmpl # host-nginx TLS vhost template (front of caddy)
plan/ # design docs
```
See `plan/00-overview.md` for the full design.

View File

@@ -1,10 +1,12 @@
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets that are mounted
# in selectively so the stack can run with or without the static site / mirror:
# conf.d/00-core.caddy auth + packwiz (always — core ingress)
# conf.d/10-static.caddy apex landing + launcher downloads (static toggle)
# conf.d/20-mirror.caddy air-gap upstream mirror (tls internal) (mirror toggle)
# auto_https disable_redirects: http:// sites stay plaintext on :80; the mirror
# snippet's hosts still get internal TLS on :443 when that snippet is present.
# Base Caddy config. Vhosts live in conf.d/*.caddy snippets:
# conf.d/00-core.caddy auth
# conf.d/10-static.caddy apex landing + launcher downloads
# conf.d/30-distribution.caddy static site from another repo
# conf.d/40-avatar.caddy nmsr skin/avatar renderer
# conf.d/50-status.caddy uptime-kuma status page
# All snippets are plain http:// on :80 — the host's nginx terminates TLS in
# front (nginx/ulicraft-caddy.conf.tmpl). disable_redirects keeps caddy from
# upgrading to its own https.
{
auto_https disable_redirects
}

View File

@@ -1,14 +0,0 @@
# Core ingress — Blessing Skin variant. Replaces 00-core.caddy when the
# docker-compose.blessingskin.yml override is in play (mounted at the same
# target path). auth.* now points at Blessing Skin instead of Drasl.
#
# Blessing Skin serves BOTH its web UI (/) and the Yggdrasil API (/api/yggdrasil)
# on the same vhost, so a single reverse_proxy covers everything.
http://auth.{$BASE_DOMAIN} {
reverse_proxy blessing-skin:80
}
http://pack.{$BASE_DOMAIN} {
root * /srv/pack
file_server browse
}

View File

@@ -1,9 +1,4 @@
# Core ingress — always mounted. Drasl auth + packwiz pack metadata.
# Core ingress — always mounted. Drasl auth.
http://auth.{$BASE_DOMAIN} {
reverse_proxy drasl:25585
}
http://pack.{$BASE_DOMAIN} {
root * /srv/pack
file_server browse
}

View File

@@ -1,19 +1,18 @@
# Static toggle — apex landing page + launcher downloads.
# Mounted only when the stack runs with static files (build-stack.sh up static|full).
# Apex landing page (/srv/www) + launcher downloads (/srv/launcher).
http://{$BASE_DOMAIN} {
# Caddy's local CA root, so guests can trust the air-gap HTTPS mirror.
# Mounted from ./caddy/caddy-root-ca.crt (exported by build-stack prep;
# the live pki dir is root-only 0600 and can't be served directly).
handle /ca.crt {
root * /srv/ca-pub
file_server
}
# Launcher downloads are a sibling mount (/srv/launcher), not nested under
# the read-only /srv/www. handle_path strips the /launcher prefix.
handle_path /launcher/* {
root * /srv/launcher
file_server browse
}
# Self-hosted Minecraft status JSON (mc-status service). Same-origin, so the
# landing's ServerCard fetch needs no CORS. The <addr> in the path is
# ignored by mc-status — it only ever pings its configured MC_STATUS_TARGET.
handle /api/mcstatus/* {
uri strip_prefix /api/mcstatus
reverse_proxy mc-status:8080
}
handle {
root * /srv/www
file_server

View File

@@ -1,9 +0,0 @@
# Mirror toggle — full client air-gap mirror (see plan/11-full-airgap-mirror.md).
# Byte-exact copies of the real upstream hosts over HTTPS with Caddy's local CA.
# dnsmasq spoofs these hostnames -> our host; {host} roots each at its subtree.
# Mounted only when the stack runs with the mirror (build-stack.sh up mirror|full).
meta.prismlauncher.org, piston-meta.mojang.com, piston-data.mojang.com, libraries.minecraft.net, maven.neoforged.net, resources.download.minecraft.net {
tls internal
root * /srv/mirror/{host}
file_server
}

View File

@@ -0,0 +1,10 @@
# Distribution — static site served from a web root in ANOTHER repo. The host
# path is set by DISTRIBUTION_WEB_ROOT in .env and bind-mounted to
# /srv/distribution by the caddy service in docker-compose.yml.
http://distribution.{$BASE_DOMAIN} {
root * /srv/distribution
# The landing page (apex origin) fetches launcher.json cross-origin to render
# the download buttons. Allow it — launcher assets are fully public, no creds.
header /launcher/* Access-Control-Allow-Origin "*"
file_server browse
}

View File

@@ -0,0 +1,4 @@
# Avatar — NMSR skin/avatar renderer (nmsr service), Drasl-backed.
http://avatar.{$BASE_DOMAIN} {
reverse_proxy nmsr:8080
}

View File

@@ -0,0 +1,4 @@
# Status — Uptime Kuma monitoring + public status page (uptime-kuma service).
http://status.{$BASE_DOMAIN} {
reverse_proxy uptime-kuma:3001
}

View File

@@ -0,0 +1,10 @@
# Files — Filestash player file share (filestash service). Shared login,
# writable. See plan/20-files.md.
#
# Filestash blocks any request whose Host header doesn't match its configured
# `general.host` (https://files.${BASE_DOMAIN}), with "only traffic from X is
# allowed". The host nginx forwards the original Host, and caddy passes it
# through untouched — do NOT rewrite it here or every request 403s.
http://files.{$BASE_DOMAIN} {
reverse_proxy filestash:8334
}

View File

@@ -1,21 +0,0 @@
# Resolve every *.${BASE_DOMAIN} (and the apex) to the Docker host LAN IP.
# Rendered by tooling/render-config.sh -> dnsmasq/dnsmasq.conf (gitignored).
address=/${BASE_DOMAIN}/${HOST_LAN_IP}
no-resolv
# Minecraft SRV record: guests connect to "mc.${BASE_DOMAIN}" with NO port —
# the SRV lookup points the client at port 25565. Target mc.${BASE_DOMAIN}
# resolves to HOST_LAN_IP via the wildcard above.
# Format: srv-host=_service._proto.name,target,port
srv-host=_minecraft._tcp.mc.${BASE_DOMAIN},mc.${BASE_DOMAIN},25565
# Full client air-gap (see plan/11-full-airgap-mirror.md): spoof the real
# upstream hosts -> our Caddy mirror. Auth hosts (session/textures/api.mojang)
# are NOT spoofed — drasl handles those via authlib-injector. Keep this list in
# sync with the proxy-capture step.
address=/meta.prismlauncher.org/${HOST_LAN_IP}
address=/piston-meta.mojang.com/${HOST_LAN_IP}
address=/piston-data.mojang.com/${HOST_LAN_IP}
address=/libraries.minecraft.net/${HOST_LAN_IP}
address=/maven.neoforged.net/${HOST_LAN_IP}
address=/resources.download.minecraft.net/${HOST_LAN_IP}

View File

@@ -1,28 +0,0 @@
# mDNS toggle — publish *.local service names via the HOST's avahi-daemon for
# zero-config guest resolution. Enabled when ENABLE_MDNS=true in .env and only
# meaningful when BASE_DOMAIN=*.local.
#
# Host prerequisites:
# - avahi-daemon running on the host
# - if the host has many interfaces (e.g. lots of docker bridges), restrict
# avahi to the LAN NIC in /etc/avahi/avahi-daemon.conf:
# allow-interfaces=<lan-iface>
# otherwise avahi sees its own announcements across bridges → "Local name
# collision" and publishing fails.
services:
avahi:
build: ./docker/avahi
image: ulicraft-avahi:local
container_name: avahi
restart: unless-stopped
network_mode: host
# AppArmor's docker-default profile blocks the D-Bus publish to host avahi.
security_opt:
- apparmor=unconfined
environment:
BASE_DOMAIN: ${BASE_DOMAIN}
HOST_LAN_IP: ${HOST_LAN_IP}
DBUS_SYSTEM_BUS_ADDRESS: "unix:path=/run/dbus/system_bus_socket"
volumes:
# Talk to the host's avahi-daemon (host must run avahi-daemon).
- /run/dbus/system_bus_socket:/run/dbus/system_bus_socket

View File

@@ -1,105 +0,0 @@
# ──────────────────────────────────────────────────────────────────
# Blessing Skin auth variant — use INSTEAD of Drasl.
# ──────────────────────────────────────────────────────────────────
# Layer this over the base file AND the caddy ingress (caddy lives in its own
# file now; this override only swaps caddy's core conf, so caddy.yml is required):
#
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml \
# -f docker-compose.blessingskin.yml up -d
#
# What it does vs the base stack:
# - drasl → left running but UNUSED + unreachable (Caddy no longer
# routes to it; it has no host port). Compose merges
# depends_on, so it can't be removed from an override —
# idling it is the clean option. `docker compose ... stop
# drasl` after up if you want it down.
# - caddy → auth.* now reverse-proxies blessing-skin:80
# - mariadb → new; Blessing Skin's datastore (no SQLite support)
# - blessing-skin → new; PHP skin server + yggdrasil-api plugin
# - minecraft → authlib-injector now points at /api/yggdrasil,
# ONLINE_MODE=TRUE (real session validation against BSS)
#
# First-run is a WEB WIZARD — see plan/03b-blessing-skin.md. The yggdrasil-api
# plugin must be installed + enabled from the BSS admin panel before the
# /api/yggdrasil endpoint (and thus Minecraft login) works.
#
# DB creds + APP_KEY come from .env (see .env.example: BS_* vars).
# ──────────────────────────────────────────────────────────────────
services:
caddy:
# Swap the core ingress conf for the Blessing Skin one. Compose MERGES
# volumes by container path, so mounting the BSS conf at the SAME target
# (/etc/caddy/conf.d/00-core.caddy) shadows the base 00-core.caddy; all
# other base mounts (pack, custom, static, ca) are kept automatically.
volumes:
- ./caddy/conf.d/00-core-blessingskin.caddy:/etc/caddy/conf.d/00-core.caddy:ro
depends_on:
- blessing-skin
mariadb:
image: mariadb:11
container_name: mariadb
restart: unless-stopped
environment:
MARIADB_DATABASE: ${BS_DB_NAME}
MARIADB_USER: ${BS_DB_USER}
MARIADB_PASSWORD: ${BS_DB_PASSWORD}
MARIADB_ROOT_PASSWORD: ${BS_DB_ROOT_PASSWORD}
TZ: "Europe/Madrid"
volumes:
- bs_db:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
networks:
- mcnet
blessing-skin:
image: azusamikan/blessing-skin-server-docker:latest
container_name: blessing-skin
restart: unless-stopped
# Internal-only — reached via Caddy at auth.${BASE_DOMAIN}.
environment:
APP_URL: "http://auth.${BASE_DOMAIN}"
# Persist APP_KEY across recreates (else sessions/encryption break).
# Generate once: docker run --rm azusamikan/blessing-skin-server-docker:latest php artisan key:generate --show
APP_KEY: ${BS_APP_KEY}
DB_DRIVER: "mysql"
DB_HOST: "mariadb"
DB_PORT: "3306"
DB_DATABASE: ${BS_DB_NAME}
DB_USERNAME: ${BS_DB_USER}
DB_PASSWORD: ${BS_DB_PASSWORD}
TZ: "Europe/Madrid"
volumes:
- bs_storage:/app/storage # uploaded skins/capes + textures
- bs_plugins:/app/plugins # yggdrasil-api plugin lives here
depends_on:
mariadb:
condition: service_healthy
networks:
- mcnet
minecraft:
environment:
# authlib-injector API root for Blessing Skin's yggdrasil-api plugin
# is <site>/api/yggdrasil (NOT /authlib-injector like Drasl).
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/api/yggdrasil"
# authlib-injector needs real auth: online-mode TRUE so the server
# validates sessions against Blessing Skin. (Drasl variant used FALSE.)
ONLINE_MODE: "TRUE"
# Keep FALSE for safety on 1.21+. The yggdrasil-api plugin DOES sign
# profile keys, so you MAY flip this to TRUE if signed chat is wanted.
ENFORCE_SECURE_PROFILE: "FALSE"
# Merged with the base depends_on (drasl, caddy); just adds blessing-skin so
# Minecraft starts after the skin server is up.
depends_on:
- blessing-skin
volumes:
bs_db:
bs_storage:
bs_plugins:

View File

@@ -1,46 +0,0 @@
# ──────────────────────────────────────────────────────────────────
# Caddy ingress — LAN / air-gap path. NOT started with the base stack.
# ──────────────────────────────────────────────────────────────────
# Caddy was moved out of docker-compose.yml so the base stack can run behind
# the host's nginx (production) without it. build-stack.sh adds this file for
# its online/airgap/core modes; static/mirror overrides mount extra vhosts in.
#
# docker compose -f docker-compose.yml -f docker-compose.caddy.yml up -d
#
# Holds the mcnet aliases (auth./pack.${BASE_DOMAIN}) so containers resolve the
# stack's own names internally — this is why minecraft can reach the pack here
# without extra_hosts (the nginx path uses extra_hosts instead).
services:
caddy:
image: caddy:alpine
container_name: caddy
restart: unless-stopped
ports:
- "80:80"
environment:
BASE_DOMAIN: ${BASE_DOMAIN}
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro
- ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro
- ./www:/srv/www:ro
- ./mirror/launcher:/srv/launcher:ro
- ./caddy/caddy-root-ca.crt:/srv/ca-pub/ca.crt:ro
- ./pack:/srv/pack:ro
# Custom (locally-hosted) mod jars live OUTSIDE ./pack so packwiz refresh
# doesn't index them as direct files. Served at pack.${BASE_DOMAIN}/custom/.
- ./custom:/srv/pack/custom:ro
networks:
mcnet:
# The upstream spoof-host aliases live in docker-compose.mirror.yml —
# adding them here would hijack maven.neoforged.net etc. for ALL mcnet
# containers, breaking the ONLINE NeoForge install during pre-bake.
aliases:
- "auth.${BASE_DOMAIN}"
- "pack.${BASE_DOMAIN}"
# Restore the ordering the base file used to have (base drops it so it can run
# caddy-less behind nginx).
minecraft:
depends_on:
- caddy

View File

@@ -1,20 +0,0 @@
# Optional turnkey DNS — only if you DON'T run your own party DNS server.
# Layer on: `docker compose -f docker-compose.yml -f docker-compose.dnsmasq.yml ... up -d`.
# Serves dnsmasq/dnsmasq.conf (rendered from the template, includes the airgap
# spoof records). If you have your own DNS, skip this and use the records from
# `tooling/dns-records.sh` instead.
services:
dnsmasq:
image: jpillora/dnsmasq
container_name: dnsmasq
restart: unless-stopped
ports:
# Bind DNS to the host LAN IP so it doesn't clash with systemd-resolved on :53.
- "${HOST_LAN_IP}:53:53/udp"
- "${HOST_LAN_IP}:53:53/tcp"
volumes:
- ./dnsmasq/dnsmasq.conf:/etc/dnsmasq.conf:ro
networks:
- mcnet
# webproc UI (:8080) can be exposed behind Caddy as dns.${BASE_DOMAIN};
# guard with HTTP_USER / HTTP_PASS.

View File

@@ -1,25 +0,0 @@
# Mirror toggle — full client air-gap upstream mirror (see plan/11).
# Layer on top of the base: `docker compose -f docker-compose.yml -f docker-compose.mirror.yml up -d`
# (or `tooling/build-stack.sh --up mirror`). Adds the mirror vhost snippet, the
# upstream content mount, and publishes :443 for Caddy's `tls internal` certs.
# Populate the mirror first: tooling/mirror-airgap.sh (+ the proxy-capture step).
services:
caddy:
ports:
- "443:443"
volumes:
- ./caddy/conf.d/20-mirror.caddy:/etc/caddy/conf.d/20-mirror.caddy:ro
- ./mirror/upstream:/srv/mirror:ro
networks:
mcnet:
# Spoof the real upstream hosts -> Caddy (air-gap runtime only). Includes
# the base aliases because compose replaces (not merges) the alias list.
aliases:
- "auth.${BASE_DOMAIN}"
- "packwiz.${BASE_DOMAIN}"
- "meta.prismlauncher.org"
- "piston-meta.mojang.com"
- "piston-data.mojang.com"
- "libraries.minecraft.net"
- "maven.neoforged.net"
- "resources.download.minecraft.net"

View File

@@ -1,30 +0,0 @@
# ──────────────────────────────────────────────────────────────────
# Production ingress = the HOST's nginx + Let's Encrypt. No caddy.
# ──────────────────────────────────────────────────────────────────
# docker compose -f docker-compose.yml -f docker-compose.nginx.yml up -d
#
# nginx (on the host, already installed) terminates TLS with the LE certs from
# tooling/issue-letsencrypt.sh and:
# - serves apex + pack.${BASE_DOMAIN} as static files (./www, ./pack, ./custom)
# - reverse-proxies auth.${BASE_DOMAIN} -> 127.0.0.1:25585 (drasl)
# See nginx/ulicraft.conf.tmpl.
#
# This override:
# - publishes drasl on localhost so the host nginx can reach it
# - points the minecraft container at the host (extra_hosts) over HTTPS, so it
# fetches the pack and validates sessions through the same public names the
# LE certs cover (JVM trusts Let's Encrypt out of the box)
services:
drasl:
ports:
- "127.0.0.1:25585:25585"
minecraft:
# Make the public names resolve to the host running nginx.
extra_hosts:
- "auth.${BASE_DOMAIN}:host-gateway"
- "pack.${BASE_DOMAIN}:host-gateway"
environment:
# Use HTTPS now that nginx terminates TLS with a publicly-trusted cert.
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=https://auth.${BASE_DOMAIN}/authlib-injector"
PACKWIZ_URL: "https://pack.${BASE_DOMAIN}/pack.toml"

View File

@@ -1,11 +0,0 @@
# Static toggle — apex landing page + launcher downloads.
# Layer on top of the base: `docker compose -f docker-compose.yml -f docker-compose.static.yml up -d`
# (or `tooling/build-stack.sh --up static`). Adds the static vhost snippet plus
# the www + launcher content mounts. Build www first: cd landing && pnpm run build.
services:
caddy:
volumes:
- ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro
- ./www:/srv/www:ro
- ./mirror/launcher:/srv/launcher:ro
- ./caddy/caddy-root-ca.crt:/srv/ca-pub/ca.crt:ro

View File

@@ -1,31 +1,31 @@
# ──────────────────────────────────────────────────────────────────
# Ulicraft LAN-party stack — Minecraft 1.21.1 NeoForge + Drasl auth
# ──────────────────────────────────────────────────────────────────
# Single source of config: BASE_DOMAIN + HOST_LAN_IP in .env.
# DNS is provided by YOUR party DNS server — see tooling/dns-records.sh for the
# records to add (online vs airgap). A turnkey dnsmasq is available as an
# optional layer: docker-compose.dnsmasq.yml.
# - ingress is layered on, NOT in this base file: caddy (docker-compose.caddy.yml,
# LAN/air-gap, holds mcnet aliases) OR the host's nginx (docker-compose.nginx.yml
# + Let's Encrypt, production)
# - drasl handles auth (password login; NO Keycloak/OIDC in this stack)
# - packwiz pack is served by the ingress at pack.${BASE_DOMAIN}
# - minecraft installs mods via PACKWIZ_URL, authlib-injector -> auth.
# Single, unified compose. One file holds the whole stack:
# drasl auth + skins (Yggdrasil), internal only
# minecraft the server (itzg/NEOFORGE), mods from ./server-mods volume
# mc-backup world backups every 6h via RCON
# caddy internal ingress — routes auth./apex/launcher/avatar.
# /distribution. by Host header (conf.d/*.caddy)
# nmsr skin/avatar renderer at avatar.${BASE_DOMAIN}
# uptime-kuma status monitoring at status.${BASE_DOMAIN}
#
# Bring up: docker compose up -d (render configs first: tooling/render-config.sh)
#
# DNS is handled OUTSIDE this repo — point every service name at the host.
# Production TLS terminates at the HOST's nginx in front of caddy
# (nginx/ulicraft-caddy.conf.tmpl + Let's Encrypt); caddy is published on a
# localhost-only port (CADDY_HTTP_BIND/CADDY_HTTP_PORT in .env). Containers reach
# the stack's own names internally via caddy's mcnet alias (auth.).
# ──────────────────────────────────────────────────────────────────
services:
# NOTE: the HTTP(S) ingress is NOT in this base file.
# - LAN / air-gap: caddy (docker-compose.caddy.yml) — added by build-stack.sh
# - production: the HOST's nginx + Let's Encrypt (docker-compose.nginx.yml
# publishes drasl to localhost; nginx/ulicraft.conf.tmpl)
# Base alone (drasl + minecraft + mc-backup) is not a complete deployment —
# always layer one ingress file on top.
drasl:
image: unmojang/drasl:latest
container_name: drasl
restart: unless-stopped
# Internal-only: no published host port. Reached via Caddy at
# auth.${BASE_DOMAIN} (Caddy holds the network alias on mcnet).
# Internal-only: no published host port. Reached via caddy at
# auth.${BASE_DOMAIN} (caddy holds the network alias on mcnet).
volumes:
- ./drasl/config:/etc/drasl:ro
- drasl_state:/var/lib/drasl
@@ -48,7 +48,9 @@ services:
TYPE: "NEOFORGE"
VERSION: "1.21.1"
# Verify NEOFORGE_VERSION against projects.neoforged.net before deploy.
NEOFORGE_VERSION: "21.1.209" # pin a known-good build; update deliberately
# Must match the distribution's NeoForge (distribution.json → 21.1.233);
# several mods (ferritecore≥218, farmersdelight≥219, configured≥211) need it.
NEOFORGE_VERSION: "21.1.233" # pin a known-good build; update deliberately
# ── Memory / performance ────────────────────────────────────
INIT_MEMORY: "4G"
@@ -56,25 +58,37 @@ services:
USE_AIKAR_FLAGS: "TRUE"
JVM_XX_OPTS: "-XX:+UseG1GC"
# ── Auth: offline mode + authlib-injector pointing at Drasl
ONLINE_MODE: "FALSE"
# mount authlib-injector.jar at /extras/authlib-injector.jar
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=http://auth.${BASE_DOMAIN}/authlib-injector"
# ── Auth: ONLINE mode validated against Drasl via authlib-injector
# MUST be true: authlib-injector redirects the normal online-auth
# handshake from Mojang to Drasl. online-mode=false skips the handshake
# entirely -> offline UUIDs, no session validation, NO SKINS for anyone.
# Secure-profile is the part that must stay off on 1.21+ (see
# ENFORCE_SECURE_PROFILE + Drasl SignPublicKeys=false), NOT online-mode.
ONLINE_MODE: "TRUE"
# Resolves over mcnet to caddy (alias auth.${BASE_DOMAIN}) -> drasl.
JVM_OPTS: "-javaagent:/extras/authlib-injector.jar=https://auth.${BASE_DOMAIN}/authlib-injector"
# ── Server config ───────────────────────────────────────────
DIFFICULTY: "normal"
MODE: "survival"
MOTD: "Uli LAN party"
# Server-list icon (auto-scaled to 64x64). PNG lives in ./runtime (mounted
# /extras); OVERRIDE_ICON re-applies it on every boot. Also surfaces in the
# landing ServerCard via the SLP favicon.
ICON: "/extras/server-icon.png"
OVERRIDE_ICON: "TRUE"
MAX_PLAYERS: "10"
VIEW_DISTANCE: "10"
SIMULATION_DISTANCE: "8"
ENFORCE_SECURE_PROFILE: "FALSE" # required for authlib-injector on 1.21+
ALLOW_FLIGHT: "TRUE" # many tech mods need this
# ── Mod source: packwiz pack served by the ingress ──────────
# http + caddy alias by default; the nginx override switches this to
# https + host-gateway (extra_hosts) for the production path.
PACKWIZ_URL: "http://pack.${BASE_DOMAIN}/pack.toml"
# ── Mod source: filtered subset of the distribution modset ──
# Jars are synced into ./server-mods by tooling/sync-server-mods.sh
# (both/server entries from mods-sides.json) and mounted at /mods below;
# itzg auto-syncs /mods → /data/mods. REMOVE_OLD_MODS makes that mirror
# authoritative so removed mods get pruned. See plan/04-mods.md.
REMOVE_OLD_MODS: "TRUE"
# ── RCON for remote admin ───────────────────────────────────
ENABLE_RCON: "TRUE"
@@ -84,13 +98,29 @@ services:
TZ: "Europe/Madrid"
volumes:
- mc_data:/data
- ./runtime:/extras:ro # authlib-injector.jar lives here
- ./runtime:/extras:ro # authlib-injector.jar lives here
- ./server-mods:/mods:ro # itzg auto-syncs /mods → /data/mods
# Distribution client-override files the server also needs, from the live
# distribution (DISTRIBUTION_WEB_ROOT), same source as caddy. Only the kubejs
# *source* subdirs are bind-mounted ro — kubejs itself writes config/, cache/,
# generated/, logs/ at boot, so the kubejs ROOT must stay writable (lives in
# mc_data). Mounting the whole kubejs dir ro makes BaseProperties.save() NPE
# and kubejs never initialises. CustomSkinLoader is ro (read-only json).
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}/servers/ulicraft-1.21.1/files/kubejs/server_scripts:/data/kubejs/server_scripts:ro
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/startup_scripts:/data/kubejs/startup_scripts:ro
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/data:/data/kubejs/data:ro
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/kubejs/assets:/data/kubejs/assets:ro
- ${DISTRIBUTION_WEB_ROOT}/servers/ulicraft-1.21.1/files/CustomSkinLoader:/data/CustomSkinLoader:ro
depends_on:
- drasl # ingress (caddy/nginx) is layered separately
- drasl # authlib (auth.) must resolve at boot
# Containers don't inherit the host's /etc/hosts; the LAN resolver returns a
# dead address for the public names. Pin auth. to the host so HTTPS to it
# lands on the host's nginx (valid LE cert) -> caddy -> drasl.
extra_hosts:
- "auth.${BASE_DOMAIN}:host-gateway"
networks:
- mcnet
# Optional: backups
mc-backup:
image: itzg/mc-backup
container_name: mc-backup
@@ -109,9 +139,129 @@ services:
networks:
- mcnet
# Internal ingress. Routes every vhost by Host header (conf.d/*.caddy):
# auth. -> drasl, apex -> landing + /launcher,
# avatar. -> nmsr, status. -> uptime-kuma, distribution. -> static site.
# Published on a localhost-only port; the host's nginx terminates TLS in
# front of it (nginx/ulicraft-caddy.conf.tmpl).
caddy:
image: caddy:alpine
container_name: caddy
restart: unless-stopped
ports:
- "${CADDY_HTTP_BIND:-127.0.0.1}:${CADDY_HTTP_PORT:-8880}:80"
environment:
BASE_DOMAIN: ${BASE_DOMAIN}
volumes:
- ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy/conf.d/00-core.caddy:/etc/caddy/conf.d/00-core.caddy:ro
- ./caddy/conf.d/10-static.caddy:/etc/caddy/conf.d/10-static.caddy:ro
- ./caddy/conf.d/30-distribution.caddy:/etc/caddy/conf.d/30-distribution.caddy:ro
- ./caddy/conf.d/40-avatar.caddy:/etc/caddy/conf.d/40-avatar.caddy:ro
- ./caddy/conf.d/50-status.caddy:/etc/caddy/conf.d/50-status.caddy:ro
- ./caddy/conf.d/60-files.caddy:/etc/caddy/conf.d/60-files.caddy:ro
- ./www:/srv/www:ro
- ./launcher:/srv/launcher:ro
# Static site from ANOTHER repo (absolute host path in .env).
- ${DISTRIBUTION_WEB_ROOT:?DISTRIBUTION_WEB_ROOT unset in .env}:/srv/distribution:ro
networks:
mcnet:
# Containers resolve the stack's own public names to caddy internally.
aliases:
- "auth.${BASE_DOMAIN}"
# Avatar/skin renderer — built from source (docker/nmsr/Dockerfile), pulls
# player profiles from Drasl over mcnet. caddy proxies avatar. -> nmsr:8080.
nmsr:
build:
context: ./docker/nmsr
image: ulicraft/nmsr:local
container_name: nmsr
restart: unless-stopped
volumes:
- ./nmsr/config.toml:/nmsr/config.toml:ro
# Drasl embeds absolute HTTPS skin URLs (BaseURL is https) in the profile.
# The mcnet alias resolves auth. -> caddy (http :80 only), so NMSR's https
# skin fetch to auth.:443 finds no listener and every avatar falls back to
# the default skin. Pin auth. to the host (same as the minecraft service) so
# NMSR reaches the host nginx on :443 with the real LE cert.
extra_hosts:
- "auth.${BASE_DOMAIN}:host-gateway"
networks:
- mcnet
# Self-hosted SLP→JSON pinger (built from docker/mc-status, mcutil wrapper).
# Emits mcstatus.io v2 JSON so the landing's ServerCard reads it unchanged.
# No published port — caddy proxies apex /api/mcstatus/* -> mc-status:8080.
# Pings the minecraft service internally over mcnet; result cached 30s.
mc-status:
build:
context: ./docker/mc-status
image: ulicraft/mc-status:local
container_name: mc-status
restart: unless-stopped
environment:
MC_STATUS_TARGET: "minecraft:25565"
MC_STATUS_CACHE_TTL: "30s"
# Registered-players roster (/api/mcstatus/players). mc-status logs into
# the Drasl admin API server-side and exposes a sanitized [{uuid,name}]
# list; these creds NEVER reach the browser. Use a dedicated, rotatable
# admin account. Isolated from the status pinger (own TTL/client).
DRASL_API_URL: "http://drasl:25585/drasl/api/v2"
DRASL_ADMIN_USERNAME: ${DRASL_ADMIN_USERNAME}
DRASL_ADMIN_PASSWORD: ${DRASL_ADMIN_PASSWORD}
MC_STATUS_ROSTER_TTL: "60s"
networks:
- mcnet
# Player file share (screenshots, schematics, maps) at files.${BASE_DOMAIN}.
# ONE shared login for everybody (FILES_AUTH=htpasswd), writable. See
# plan/20-files.md — it documents every non-obvious bit of this service.
#
# Config is rendered read-only from filestash/config.json.tmpl: filestash has
# no env-var config, and its admin console writes config.json back at runtime,
# so pinning it :ro is what keeps git authoritative. The console still loads —
# it just cannot save. That is intentional, not a bug.
filestash:
image: machines/filestash@sha256:8cb09d42470328a02aec6e3861f912addf8a9d54ad63d9c965bcb48df5ad36b1
container_name: filestash
restart: unless-stopped
environment:
# The `local` storage backend is admin-gated in filestash: it refuses to
# init unless the connection params carry this secret (or the admin
# password). config.json's attribute_mapping supplies it server-side, so
# players never see it. Without this, every login dies on "Not Allowed".
LOCAL_BACKEND_SECRET: ${FILES_LOCAL_SECRET:?FILES_LOCAL_SECRET unset in .env}
# Do NOT set APPLICATION_URL / ADMIN_PASSWORD here: either one makes
# filestash rewrite config.json at boot, which fails on the :ro mount.
# Both live in the rendered config instead (general.host / auth.admin).
volumes:
# /app/data/state holds config + sqlite + search index + logs, so the
# DIRECTORY must stay writable. Only config.json is pinned read-only.
- filestash_state:/app/data/state
- ./filestash/config.json:/app/data/state/config/config.json:ro
# The share itself. FILES_MOUNT_MODE is the literal mount flag (ro|rw) —
# `ro` enforces a read-only share at the KERNEL, whatever the UI thinks.
- ${FILES_DATA_DIR:?FILES_DATA_DIR unset in .env}:/data/files:${FILES_MOUNT_MODE:-ro}
networks:
- mcnet
# Status monitoring + public status page. caddy proxies status. -> :3001.
# Joins mcnet so monitors probe the stack by internal service name.
uptime-kuma:
image: louislam/uptime-kuma:1
container_name: uptime-kuma
restart: unless-stopped
volumes:
- kuma_data:/app/data
networks:
- mcnet
volumes:
drasl_state:
mc_data:
kuma_data:
filestash_state:
networks:
mcnet:

View File

@@ -1,9 +0,0 @@
# Publishes the stack's service names on .local via the HOST's avahi-daemon
# (over its D-Bus socket — no second daemon, avoids the :5353 collision).
# Requires the host to run avahi-daemon and the socket to be mounted (see
# docker-compose.avahi.yml). Used when ENABLE_MDNS=true and BASE_DOMAIN=*.local.
FROM alpine:3.20
RUN apk add --no-cache avahi-tools
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]

View File

@@ -1,32 +0,0 @@
#!/bin/sh
# Publish A records for the stack's service names on .local via the HOST's
# avahi-daemon (reached over its mounted D-Bus socket). One avahi-publish per
# name; if any drops the container exits so Docker restarts it.
set -eu
: "${BASE_DOMAIN:?BASE_DOMAIN unset}"
: "${HOST_LAN_IP:?HOST_LAN_IP unset}"
case "$BASE_DOMAIN" in
*.local) ;;
*) echo "WARN: mDNS resolves only .local — BASE_DOMAIN=$BASE_DOMAIN will NOT be discoverable via avahi. Set BASE_DOMAIN=*.local to use mDNS." >&2 ;;
esac
bus="${DBUS_SYSTEM_BUS_ADDRESS:-unix:path=/run/dbus/system_bus_socket}"
sock=${bus#unix:path=}
if [ ! -S "$sock" ]; then
echo "ERROR: host D-Bus socket '$sock' not found. Mount the host's" >&2
echo " /run/dbus/system_bus_socket and ensure avahi-daemon runs on the host." >&2
exit 1
fi
export DBUS_SYSTEM_BUS_ADDRESS="$bus"
pids=""
for n in "$BASE_DOMAIN" "auth.$BASE_DOMAIN" "packwiz.$BASE_DOMAIN" "mc.$BASE_DOMAIN"; do
echo "mDNS publish (host avahi): $n -> $HOST_LAN_IP"
avahi-publish -a "$n" "$HOST_LAN_IP" &
pids="$pids $!"
done
# shellcheck disable=SC2086
wait -n $pids 2>/dev/null || wait

View File

@@ -0,0 +1,15 @@
# mc-status — self-hosted SLP→JSON pinger (mcutil wrapper). Multi-stage:
# build a static binary, ship it on scratch.
FROM golang:1.25-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/mc-status .
FROM scratch
# CA roots so SRV/DNS over the resolver and any TLS lookups work.
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /out/mc-status /mc-status
EXPOSE 8080
ENTRYPOINT ["/mc-status"]

5
docker/mc-status/go.mod Normal file
View File

@@ -0,0 +1,5 @@
module ulicraft/mc-status
go 1.25.0
require github.com/mcstatus-io/mcutil/v4 v4.0.1

2
docker/mc-status/go.sum Normal file
View File

@@ -0,0 +1,2 @@
github.com/mcstatus-io/mcutil/v4 v4.0.1 h1:/AQkHrz7irCU7USGnrH3kneQw80aDQOVdOWc8xu/NUY=
github.com/mcstatus-io/mcutil/v4 v4.0.1/go.mod h1:yC91WInI1U2GAMFWgpPgsAULPVS2o+4JCZbiiWhHwxM=

397
docker/mc-status/main.go Normal file
View File

@@ -0,0 +1,397 @@
// mc-status — a tiny self-hosted Minecraft Server List Ping → JSON service.
//
// It wraps github.com/mcstatus-io/mcutil (the same library that powers
// api.mcstatus.io) and re-emits the result in mcstatus.io's *v2* JSON shape, so
// the landing's ServerCard.astro can talk to it with zero changes — just point
// `statusApi` at this service instead of the public API.
//
// Hardening notes:
// - The address in the request path is IGNORED. We only ever ping the single
// allow-listed MC_STATUS_TARGET. This is deliberate: a path-controlled
// pinger would be an open SSRF relay. The path segment is kept only so the
// URL stays drop-in compatible with api.mcstatus.io/v2/status/java/<addr>.
// - Results are cached in-memory for MC_STATUS_CACHE_TTL so a busy landing
// page can't hammer the Minecraft server with a ping per visitor.
package main
import (
"bytes"
"context"
"encoding/json"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/mcstatus-io/mcutil/v4/status"
)
// --- mcstatus.io v2 response shape (only the fields ServerCard reads) ---
type v2Version struct {
NameRaw string `json:"name_raw"`
NameClean string `json:"name_clean"`
NameHTML string `json:"name_html"`
Protocol int64 `json:"protocol"`
}
type v2Player struct {
UUID string `json:"uuid"`
NameRaw string `json:"name_raw"`
NameClean string `json:"name_clean"`
NameHTML string `json:"name_html"`
}
type v2Players struct {
Online int64 `json:"online"`
Max int64 `json:"max"`
List []v2Player `json:"list"`
}
type v2MOTD struct {
Raw string `json:"raw"`
Clean string `json:"clean"`
HTML string `json:"html"`
}
type v2Response struct {
Online bool `json:"online"`
Host string `json:"host"`
Port uint16 `json:"port"`
Version *v2Version `json:"version,omitempty"`
Players *v2Players `json:"players,omitempty"`
MOTD *v2MOTD `json:"motd,omitempty"`
Icon *string `json:"icon"`
}
// --- tiny TTL cache (single target → single entry) ---
type cache struct {
mu sync.Mutex
payload []byte
expires time.Time
}
func deref[T any](p *T) (v T) {
if p != nil {
v = *p
}
return
}
// --- registered-players roster (Drasl admin) ---
//
// The roster path is DELIBERATELY isolated from the status path: its own
// http.Client (with a timeout), its own TTL cache, and its own cached admin
// token. Drasl being slow or down must never block or break /v2/status — on any
// failure we serve stale-or-empty JSON rather than hanging. Mirrors the status
// cache style above (single entry, mutex-guarded).
// rosterPlayer is the sanitized shape we expose publicly: uuid + name only,
// everything else from Drasl stripped (no emails, capes, admin flags, etc.).
type rosterPlayer struct {
UUID string `json:"uuid"`
Name string `json:"name"`
}
// draslPlayer is the subset of Drasl's GET /players entries we read.
type draslPlayer struct {
UUID string `json:"uuid"`
Name string `json:"name"`
}
// roster holds the cached public payload plus the cached admin token. Both are
// guarded by the same mutex; token reuse avoids a login per request.
type roster struct {
mu sync.Mutex
payload []byte
expires time.Time
token string
apiURL string
username string
password string
ttl time.Duration
client *http.Client
}
// payloadOrEmpty returns the cached payload, or an empty JSON array if nothing
// has ever been fetched. Used so a Drasl failure on the very first request
// still yields valid JSON ("[]") instead of an error.
func (rs *roster) payloadOrEmpty() []byte {
if rs.payload != nil {
return rs.payload
}
return []byte("[]")
}
// fetch logs in (if no token) and lists players, re-logging in once on a 401.
// On any error it returns the previous payload (stale-or-empty), never an error
// to the caller — the handler always serves something.
func (rs *roster) fetch() []byte {
players, err := rs.listPlayers(false)
if err != nil {
log.Printf("roster: list players failed: %v", err)
return rs.payloadOrEmpty()
}
out := make([]rosterPlayer, 0, len(players))
for _, p := range players {
out = append(out, rosterPlayer{UUID: p.UUID, Name: p.Name})
}
b, err := json.Marshal(out)
if err != nil {
return rs.payloadOrEmpty()
}
return b
}
// listPlayers performs GET {apiURL}/players with the cached token, logging in
// first if needed. On 401 it clears the token and retries once (retried=true
// prevents a loop). Caller holds rs.mu.
func (rs *roster) listPlayers(retried bool) ([]draslPlayer, error) {
if rs.token == "" {
if err := rs.login(); err != nil {
return nil, err
}
}
req, err := http.NewRequest(http.MethodGet, rs.apiURL+"/players", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+rs.token)
req.Header.Set("Accept", "application/json")
resp, err := rs.client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized && !retried {
// Token expired/invalid — drop it and re-login once.
rs.token = ""
io.Copy(io.Discard, resp.Body)
return rs.listPlayers(true)
}
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body)
return nil, &httpError{status: resp.StatusCode, op: "list players"}
}
var players []draslPlayer
if err := json.NewDecoder(resp.Body).Decode(&players); err != nil {
return nil, err
}
return players, nil
}
// login posts admin creds to {apiURL}/login and caches the returned apiToken.
// Caller holds rs.mu.
func (rs *roster) login() error {
body, err := json.Marshal(map[string]string{
"username": rs.username,
"password": rs.password,
})
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, rs.apiURL+"/login", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := rs.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
io.Copy(io.Discard, resp.Body)
return &httpError{status: resp.StatusCode, op: "login"}
}
var out struct {
APIToken string `json:"apiToken"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return err
}
if out.APIToken == "" {
return &httpError{status: resp.StatusCode, op: "login (no apiToken)"}
}
rs.token = out.APIToken
return nil
}
type httpError struct {
status int
op string
}
func (e *httpError) Error() string {
return "drasl " + e.op + ": status " + strconv.Itoa(e.status)
}
func main() {
target := envOr("MC_STATUS_TARGET", "minecraft:25565")
listen := envOr("MC_STATUS_LISTEN", ":8080")
ttl, err := time.ParseDuration(envOr("MC_STATUS_CACHE_TTL", "30s"))
if err != nil {
log.Fatalf("invalid MC_STATUS_CACHE_TTL: %v", err)
}
rosterTTL, err := time.ParseDuration(envOr("MC_STATUS_ROSTER_TTL", "60s"))
if err != nil {
log.Fatalf("invalid MC_STATUS_ROSTER_TTL: %v", err)
}
host, port := splitHostPort(target)
c := &cache{}
// Registered-players roster, fed by the Drasl admin API. Isolated from the
// status pinger above (own client + timeout + cache). If the admin creds are
// unset, /players still works — it just always serves "[]".
rs := &roster{
apiURL: strings.TrimRight(envOr("DRASL_API_URL", "http://drasl:25585/drasl/api/v2"), "/"),
username: os.Getenv("DRASL_ADMIN_USERNAME"),
password: os.Getenv("DRASL_ADMIN_PASSWORD"),
ttl: rosterTTL,
client: &http.Client{Timeout: 5 * time.Second},
}
mux := http.NewServeMux()
// Drop-in path: /v2/status/java/<addr> — <addr> is ignored (see file header).
mux.HandleFunc("/v2/status/java/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
c.mu.Lock()
fresh := c.payload != nil && time.Now().Before(c.expires)
if fresh {
payload := c.payload
c.mu.Unlock()
w.Write(payload)
return
}
c.mu.Unlock()
payload := buildPayload(host, port)
c.mu.Lock()
c.payload = payload
c.expires = time.Now().Add(ttl)
c.mu.Unlock()
w.Write(payload)
})
// Registered-players roster — public path /api/mcstatus/players (caddy
// strips the /api/mcstatus prefix → internal /players). Serves a sanitized
// [{uuid,name}] of all Drasl players. Same CORS:* as the status route.
// Isolated from the status path: own TTL cache, own token, own client; on
// any Drasl failure it serves stale-or-empty rather than hanging.
mux.HandleFunc("/players", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*")
rs.mu.Lock()
defer rs.mu.Unlock()
if rs.payload != nil && time.Now().Before(rs.expires) {
w.Write(rs.payload)
return
}
payload := rs.fetch()
rs.payload = payload
rs.expires = time.Now().Add(rs.ttl)
w.Write(payload)
})
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("ok"))
})
log.Printf("mc-status listening on %s, target=%s:%d, ttl=%s, roster-ttl=%s", listen, host, port, ttl, rosterTTL)
log.Fatal(http.ListenAndServe(listen, mux))
}
// buildPayload pings the target and marshals the v2 response. On any ping
// failure it returns a minimal {"online":false} so the frontend can show an
// offline state without erroring.
func buildPayload(host string, port uint16) []byte {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := status.Modern(ctx, host, port)
if err != nil {
b, _ := json.Marshal(v2Response{Online: false, Host: host, Port: port})
return b
}
out := v2Response{
Online: true,
Host: host,
Port: port,
Icon: resp.Favicon,
Version: &v2Version{
NameRaw: resp.Version.Name.Raw,
NameClean: resp.Version.Name.Clean,
NameHTML: resp.Version.Name.HTML,
Protocol: resp.Version.Protocol,
},
MOTD: &v2MOTD{
Raw: resp.MOTD.Raw,
Clean: resp.MOTD.Clean,
HTML: resp.MOTD.HTML,
},
Players: &v2Players{
Online: deref(resp.Players.Online),
Max: deref(resp.Players.Max),
List: make([]v2Player, 0, len(resp.Players.Sample)),
},
}
for _, p := range resp.Players.Sample {
out.Players.List = append(out.Players.List, v2Player{
UUID: p.ID,
NameRaw: p.Name.Raw,
NameClean: p.Name.Clean,
NameHTML: p.Name.HTML,
})
}
b, _ := json.Marshal(out)
return b
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func splitHostPort(target string) (string, uint16) {
host, portStr, found := strings.Cut(target, ":")
if !found {
return target, 25565
}
p, err := strconv.ParseUint(portStr, 10, 16)
if err != nil {
return host, 25565
}
return host, uint16(p)
}

33
docker/nmsr/Dockerfile Normal file
View File

@@ -0,0 +1,33 @@
# NMSR-as-a-Service (https://github.com/NickAcPT/nmsr-rs) — built from source.
# Upstream ships no published image, so we vendor a pinned build. Adapted from
# the upstream Dockerfile but: pinned to a commit (reproducible), and the config
# is bind-mounted at runtime (not COPYed) so it can change without a rebuild.
#
# Pin — bump deliberately:
ARG NMSR_REF=948ba4bc027b
FROM rust:slim-bookworm AS builder
ARG NMSR_REF
WORKDIR /tmp
RUN apt-get update -y \
&& apt-get --no-install-recommends install -y git libssl-dev pkg-config ca-certificates
RUN git clone https://github.com/NickAcPT/nmsr-rs/
WORKDIR /tmp/nmsr-rs
RUN git checkout "${NMSR_REF}"
# target-cpu=native: this image is built on the host that runs it (self-host).
RUN RUSTFLAGS="-Ctarget-cpu=native" \
cargo build --release --bin nmsr-aas --features ears --package nmsr-aas
FROM rust:slim-bookworm
# mesa-vulkan-drivers provides lavapipe (software Vulkan) for headless rendering.
RUN apt-get update -y \
&& apt-get --no-install-recommends install -y mesa-vulkan-drivers ca-certificates
WORKDIR /nmsr
COPY --from=builder /tmp/nmsr-rs/target/release/nmsr-aas /nmsr/nmsr-aas
ENV NMSR_USE_SMAA=1 \
NMSR_SAMPLE_COUNT=1 \
WGPU_BACKEND=vulkan \
RUST_BACKTRACE=1
EXPOSE 8080
# config.toml is bind-mounted by docker-compose.nmsr.yml.
CMD ["/nmsr/nmsr-aas", "-c", "/nmsr/config.toml"]

View File

@@ -1,12 +1,16 @@
# Drasl config — password-login mode (NO Keycloak/OIDC for now).
# TOML only (drasl has no env support). Rendered by tooling/render-config.sh
# -> drasl/config/config.toml (gitignored). Only ${BASE_DOMAIN} is substituted.
# -> drasl/config/config.toml (gitignored). Substitutes ${BASE_DOMAIN} and
# ${REGISTRATION_REQUIRE_INVITE} (derived from REGISTRATION_MODE in .env).
# Reached only via Caddy at auth.${BASE_DOMAIN}; drasl has no published host port.
BaseURL = "http://auth.${BASE_DOMAIN}"
# Public scheme is HTTPS — the host nginx terminates TLS in front of caddy.
# Drasl builds absolute URLs (textures, authlib-injector API location) from this;
# an http:// value triggers mixed-content + broken login on the https origin.
BaseURL = "https://auth.${BASE_DOMAIN}"
Domain = "auth.${BASE_DOMAIN}"
ListenAddress = "0.0.0.0:25585" # internal; Caddy reverse-proxies to it
DefaultAdminUsernames = ["admin"]
DefaultAdmins = ["admin"]
# Password login: admin + guests register a username/password on the web UI.
AllowPasswordLogin = true
@@ -18,5 +22,29 @@ SignPublicKeys = false
# Free-text owner label shown in the web UI (a string, not a table).
ApplicationOwner = "Ulicraft"
# CORS: the landing register/account form (served from the apex) calls the
# Drasl API from the browser. Without this the API has NO CORS headers and the
# browser blocks every cross-origin call. Scope to the apex only — never "*",
# which would let any site script the auth API. Echo backfills AllowMethods
# (incl. PATCH/DELETE) and reflects requested headers (Content-Type/Authorization).
# MUST stay top-level (before any [Section]) — under a table it parses as
# <table>.CORSAllowOrigins and drasl ignores it (silent: no CORS headers).
CORSAllowOrigins = ["https://${BASE_DOMAIN}"]
# New-account registration (username+password). RequireInvite is derived from
# REGISTRATION_MODE in .env by render-config.sh: invite -> true, open -> false.
# When true, non-admin callers (web UI + landing register form) must supply a
# valid invite code; admins bypass. Mint codes via POST /drasl/api/v2/invites
# with an admin api token.
[RegistrationNewPlayer]
Allow = true
RequireInvite = ${REGISTRATION_REQUIRE_INVITE}
# Rate limit the now public-facing API (anonymous POST /users etc). Token
# bucket; admins are exempt. Conservative for a 4-10 player server.
[RateLimit]
RequestsPerSecond = 5
Burst = 10
# OIDC block intentionally omitted for now (no Keycloak).
# [[RegistrationOIDC]] <- future task

View File

@@ -0,0 +1,31 @@
{
"general": {
"name": "Ulicraft Files",
"host": "files.${BASE_DOMAIN}",
"force_ssl": true,
"secret_key": "${FILES_SECRET_KEY}",
"editor": "base",
"display_hidden": false,
"upload_button": true,
"fork_button": false
},
"auth": {
"admin": "${FILES_ADMIN_PASSWORD_HASH}"
},
"middleware": {
"identity_provider": {
"type": "${FILES_IDP_TYPE}",
"params": "${FILES_IDP_PARAMS}"
},
"attribute_mapping": {
"related_backend": "files",
"params": "{\"files\":{\"type\":\"local\",\"path\":\"/data/files/\",\"password\":\"${FILES_LOCAL_SECRET}\"}}"
}
},
"connections": [
{
"label": "files",
"type": "local"
}
]
}

BIN
landing/design/.thumbnail Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html lang="en" data-mood="grass" data-hero="centered" data-head="pixelify">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ulicraft — Java Survival SMP</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Pixelify+Sans:wght@400;500;600;700&family=Press+Start+2P&family=Silkscreen:wght@400;700&family=Space+Grotesk:wght@400;500;600;700&family=VT323&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="root"></div>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
<script type="text/babel" src="tweaks-panel.jsx"></script>
<script type="text/babel" src="app.jsx"></script>
</body>
</html>

434
landing/design/app.jsx Normal file
View File

@@ -0,0 +1,434 @@
// Ulicraft landing — pixel components, live status, copy-IP, scroll reveals, tweaks
const { useState, useEffect, useRef, useCallback } = React;
/* ---------- pixel art helpers ---------- */
// 8x8 creeper face
const CREEPER = [
"00000000",
"01100110",
"01100110",
"00011000",
"00111100",
"00111100",
"00100100",
"00000000",
];
function Creeper() {
const cells = CREEPER.join("").split("");
return (
<div className="creeper" aria-hidden="true">
{cells.map((c, i) => <i key={i} className={c === "1" ? "f" : ""} />)}
</div>
);
}
// 7x7 feature glyphs
const GLYPHS = {
shield: ["0111110","1111111","1111111","1111111","0111110","0011100","0001000"],
diamond:["0001000","0011100","0111110","1111111","0111110","0011100","0001000"],
orb: ["0011100","0111110","1111111","1111111","1111111","0111110","0011100"],
heart: ["0110110","1111111","1111111","1111111","0111110","0011100","0001000"],
};
function PixelIcon({ glyph, gold }) {
const cells = GLYPHS[glyph].join("").split("");
return (
<div className="picon" aria-hidden="true">
{cells.map((c, i) => (
<i key={i} className={c === "1" ? (gold ? "go" : "on") : ""} />
))}
</div>
);
}
/* ---------- copy to clipboard ---------- */
function copyText(text) {
try {
if (navigator.clipboard && window.isSecureContext) {
navigator.clipboard.writeText(text);
return;
}
} catch (e) {}
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch (e) {}
document.body.removeChild(ta);
}
function IpChip({ ip }) {
const [copied, setCopied] = useState(false);
const onCopy = () => {
copyText(ip);
setCopied(true);
clearTimeout(onCopy._t);
onCopy._t = setTimeout(() => setCopied(false), 1600);
};
return (
<div className="ip-chip">
<span className="ip-val"><span className="pin"></span>{ip}</span>
<button className={copied ? "copied" : ""} onClick={onCopy}>
{copied ? "Copied!" : "Copy IP"}
</button>
</div>
);
}
/* ---------- live player count ---------- */
function useLivePlayers(base = 37, max = 100) {
const [n, setN] = useState(base);
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const id = setInterval(() => {
setN((p) => {
const step = Math.floor(Math.random() * 5) - 2;
return Math.max(base - 6, Math.min(max - 4, p + step));
});
}, 2600);
return () => clearInterval(id);
}, [base, max]);
return n;
}
/* ---------- floating pixel dust ---------- */
function Dust({ on }) {
if (!on) return null;
const bits = Array.from({ length: 16 }, (_, i) => i);
return (
<div className="dust" aria-hidden="true">
{bits.map((i) => {
const size = 3 + (i % 3) * 2;
const st = {
position: "absolute",
left: ((i * 61) % 100) + "%",
bottom: "-20px",
width: size + "px",
height: size + "px",
background: i % 4 === 0 ? "var(--gold)" : "var(--accent)",
opacity: 0.18 + (i % 3) * 0.06,
imageRendering: "pixelated",
animation: `rise ${13 + (i % 7) * 3}s linear ${i * 0.7}s infinite`,
};
return <i key={i} style={st} />;
})}
</div>
);
}
/* ---------- server-list status panel ---------- */
function ServerListPanel({ players, max, version }) {
return (
<div className="serverlist">
<div className="icon"><Creeper /></div>
<div className="meta">
<div className="row1">
<span className="title">Ulicraft</span>
<span className="ver">Java {version}</span>
</div>
<p className="motd">
<span className="a"> Season 3</span> · Survival SMP · <span className="g">whitelist open</span>
</p>
</div>
<div className="stat">
<div className="players">
<span className="bars" aria-hidden="true"><i /><i /><i /><i /><i /></span>
<span><b>{players}</b>/{max}</span>
</div>
</div>
</div>
);
}
/* ---------- scroll reveal (fail-safe: visible at rest, .anim added when in view) ---------- */
function useReveal() {
useEffect(() => {
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
let els = [...document.querySelectorAll(".reveal")];
let stop = false;
const tick = () => {
if (stop) return;
const vh = window.innerHeight;
for (let i = els.length - 1; i >= 0; i--) {
const r = els[i].getBoundingClientRect();
if (r.top < vh * 0.9 && r.bottom > 0) {
els[i].classList.add("anim");
els.splice(i, 1);
}
}
if (els.length) requestAnimationFrame(tick);
else stop = true;
};
requestAnimationFrame(tick);
return () => { stop = true; };
}, []);
}
/* ============================================================ */
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
"heroLayout": "centered",
"mood": "grass",
"headFont": "pixelify",
"serverIp": "play.ulicraft.net",
"tagline": "Hardcore survival, built to last.",
"particles": true
}/*EDITMODE-END*/;
const HEAD_FONTS = {
pixelify: "'Pixelify Sans', system-ui, sans-serif",
"8bit": "'Press Start 2P', monospace",
silkscreen: "'Silkscreen', system-ui, sans-serif",
};
const FEATURES = [
{ glyph: "shield", gold: false, h: "Grief-proof claims",
p: "Lock down your base with golden-shovel land claims. Your builds stay yours — even while you're offline." },
{ glyph: "diamond", gold: true, h: "Zero pay-to-win",
p: "The store sells cosmetics and nothing else. Every diamond is earned in-game, never bought." },
{ glyph: "orb", gold: false, h: "A living world",
p: "Seasonal map resets, custom bosses, and community events keep the overworld worth logging into." },
{ glyph: "heart", gold: true, h: "Real community",
p: "A whitelisted, moderated server with an active Discord. Toxicity meets the ban hammer, fast." },
];
function App() {
const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
const players = useLivePlayers(37, 100);
const peak = 84;
const version = "1.21.4";
// guarantee pixel head fonts are downloaded before first paint settles
useEffect(() => {
if (!document.fonts || !document.fonts.load) return;
["600 32px 'Pixelify Sans'", "700 32px 'Pixelify Sans'",
"400 32px 'Press Start 2P'", "400 32px 'Silkscreen'", "700 32px 'Silkscreen'"]
.forEach((f) => document.fonts.load(f).catch(() => {}));
}, []);
// apply mood / hero / font to <html>
useEffect(() => {
const r = document.documentElement;
r.setAttribute("data-mood", t.mood);
r.setAttribute("data-hero", t.heroLayout);
r.setAttribute("data-head", t.headFont);
r.style.setProperty("--font-head", HEAD_FONTS[t.headFont] || HEAD_FONTS.pixelify);
}, [t.mood, t.heroLayout, t.headFont]);
useReveal();
return (
<>
{/* NAV */}
<header className="nav">
<div className="wrap nav-in">
<a className="brand" href="#top">
<Creeper />
<span className="name">ULICRAFT</span>
</a>
<nav className="nav-links">
<a href="#status">Status</a>
<a href="#features">Features</a>
<a href="#join">How to Join</a>
</nav>
<span className="nav-spacer" />
<a className="mc-btn primary sm" href="#join">Play Now</a>
</div>
</header>
{/* HERO */}
<main id="top">
<section className="hero" data-screen-label="Hero">
<Dust on={t.particles} />
<div className="wrap hero-grid">
<div className="hero-logo">
<img src="assets/ulicraft-logo.png" alt="Ulicraft" width="707" height="148" />
</div>
<div className="hero-copy">
<span className="eyebrow">Java Edition · Survival SMP · Season 3</span>
<h1 className="hero-tagline">{t.tagline}</h1>
<p className="hero-sub">
A whitelisted Java SMP with land claims, seasonal events, and zero pay-to-win.
Grab the IP, fire up your launcher, and stake your claim.
</p>
<div className="hero-ip-row">
<IpChip ip={t.serverIp} />
<a className="mc-btn" href="#join">How to Join</a>
</div>
<div className="hero-meta">
<span className="live-pill">
<span className="live-dot" />
<span><b>{players}</b> playing now</span>
</span>
<span>Java {version}</span>
<span className="dot" />
<span>No mods required</span>
</div>
</div>
<div className="hero-status">
<ServerListPanel players={players} max={100} version={version} />
</div>
</div>
</section>
{/* STATUS */}
<section id="status" className="pad" data-screen-label="Status">
<div className="wrap">
<div className="sec-head reveal">
<span className="eyebrow">Server Status</span>
<h2 className="section-title">Live, and waiting for you.</h2>
<p className="lead">This is exactly what you'll see in your multiplayer list.</p>
</div>
<div className="reveal">
<ServerListPanel players={players} max={100} version={version} />
</div>
<div className="stat-grid">
{[
{ k: <>{players}<span className="u">/100</span></>, l: "Players Online" },
{ k: <>{peak}</>, l: "Peak Today" },
{ k: <>99.9<span className="u">%</span></>, l: "Uptime" },
{ k: <>20.0<span className="u"> tps</span></>, l: "Performance" },
].map((s, i) => (
<div className="tile reveal" key={i} style={{ transitionDelay: i * 60 + "ms" }}>
<div className="k">{s.k}</div>
<div className="l">{s.l}</div>
</div>
))}
</div>
</div>
</section>
{/* FEATURES */}
<section id="features" className="pad" data-screen-label="Features" style={{ background: "var(--bg-2)" }}>
<div className="wrap">
<div className="sec-head reveal">
<span className="eyebrow">What makes it Ulicraft</span>
<h2 className="section-title">Built for players who stay.</h2>
<p className="lead">No reset roulette, no whales buying god gear. Just a server that respects your time.</p>
</div>
<div className="feat-grid">
{FEATURES.map((f, i) => (
<div className="feat reveal" key={i} style={{ transitionDelay: (i % 2) * 80 + "ms" }}>
<PixelIcon glyph={f.glyph} gold={f.gold} />
<div>
<h3>{f.h}</h3>
<p>{f.p}</p>
</div>
</div>
))}
</div>
</div>
</section>
{/* HOW TO JOIN */}
<section id="join" className="pad" data-screen-label="How to Join">
<div className="wrap">
<div className="sec-head reveal">
<span className="eyebrow">How to Join · 3 Steps</span>
<h2 className="section-title">From zero to spawning in.</h2>
<p className="lead">Ulicraft runs on Minecraft: Java Edition. Here's the whole path, start to finish.</p>
</div>
<div className="steps">
{/* STEP 1 */}
<div className="step reveal">
<div className="num">1</div>
<div className="body">
<span className="kicker">Account</span>
<h3>Get Minecraft: Java Edition</h3>
<p>
Create a free Microsoft account, then buy and download Minecraft: Java &amp; Bedrock
Edition from minecraft.net. Already own Java Edition? Skip straight to step&nbsp;2.
</p>
<div className="actions">
<a className="mc-btn gold sm" href="https://www.minecraft.net/get-minecraft" target="_blank" rel="noopener">Get Java Edition </a>
<span className="hint">Microsoft account required</span>
</div>
</div>
</div>
{/* STEP 2 */}
<div className="step reveal">
<div className="num">2</div>
<div className="body">
<span className="kicker">Launcher</span>
<h3>Download &amp; set up the launcher</h3>
<p>
Install the official Minecraft Launcher, sign in with your Microsoft account, and select
the <strong style={{ color: "var(--text)" }}>Latest Release ({version})</strong> profile.
</p>
<ul>
<li>Download the launcher for Windows, macOS, or Linux.</li>
<li>Sign in pick <strong style={{ color: "var(--text)" }}>Latest Release</strong> press <strong style={{ color: "var(--text)" }}>Play</strong> once to finish installing.</li>
<li>Optional: allocate 46&nbsp;GB RAM under Installations More Options for smoother play.</li>
</ul>
<div className="actions">
<a className="mc-btn sm" href="https://www.minecraft.net/download" target="_blank" rel="noopener">Download launcher </a>
<span className="hint">No mods or modpack needed</span>
</div>
</div>
</div>
{/* STEP 3 */}
<div className="step reveal">
<div className="num">3</div>
<div className="body">
<span className="kicker">Connect</span>
<h3>Join the Ulicraft server</h3>
<p>
In Minecraft, open <kbd>Multiplayer</kbd> <kbd>Add Server</kbd>. Name it Ulicraft,
paste the address below, hit <kbd>Done</kbd>, then double-click the server to join.
</p>
<div className="actions" style={{ marginBottom: "14px" }}>
<IpChip ip={t.serverIp} />
</div>
<span className="hint">Whitelisted server apply in our Discord first to get added.</span>
</div>
</div>
</div>
</div>
</section>
</main>
{/* FOOTER */}
<footer className="footer">
<div className="wrap footer-in">
<a className="brand" href="#top">
<Creeper />
<span className="name">ULICRAFT</span>
</a>
<div className="footer-links">
<a className="mc-btn primary sm" href="#join">Copy IP &amp; Play</a>
<a className="mc-btn sm" href="#" onClick={(e) => e.preventDefault()}>Discord</a>
</div>
<p className="disc">
Not affiliated with Mojang or Microsoft. Minecraft is a trademark of Mojang AB.
© 2026 Ulicraft.
</p>
</div>
</footer>
{/* TWEAKS */}
<TweaksPanel title="Tweaks">
<TweakSection label="Layout" />
<TweakRadio label="Hero" value={t.heroLayout}
options={[{ value: "centered", label: "Centered" }, { value: "split", label: "Split" }, { value: "spotlight", label: "Spotlight" }]}
onChange={(v) => setTweak("heroLayout", v)} />
<TweakSection label="Mood" />
<TweakRadio label="Palette" value={t.mood}
options={[{ value: "grass", label: "Grass" }, { value: "nether", label: "Nether" }, { value: "end", label: "End" }]}
onChange={(v) => setTweak("mood", v)} />
<TweakSection label="Type" />
<TweakSelect label="Headline font" value={t.headFont}
options={[{ value: "pixelify", label: "Pixelify (readable)" }, { value: "8bit", label: "Press Start (8-bit)" }, { value: "silkscreen", label: "Silkscreen" }]}
onChange={(v) => setTweak("headFont", v)} />
<TweakSection label="Content" />
<TweakText label="Tagline" value={t.tagline} onChange={(v) => setTweak("tagline", v)} />
<TweakText label="Server IP" value={t.serverIp} onChange={(v) => setTweak("serverIp", v)} />
<TweakToggle label="Floating dust" value={t.particles} onChange={(v) => setTweak("particles", v)} />
</TweaksPanel>
</>
);
}
ReactDOM.createRoot(document.getElementById("root")).render(<App />);

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

579
landing/design/styles.css Normal file
View File

@@ -0,0 +1,579 @@
/* ============================================================
ULICRAFT — "Carved from stone"
Design system: dark overworld, MC-GUI bevels, pixel type
============================================================ */
/* ---- Mood palettes (data-mood on <html>) ---- */
:root,
:root[data-mood="grass"] {
--bg: oklch(0.165 0.014 150);
--bg-2: oklch(0.205 0.016 150);
--surface: oklch(0.235 0.017 150);
--surface-2: oklch(0.285 0.018 150);
--slot: oklch(0.185 0.014 150);
--bevel-hi: oklch(0.40 0.018 150);
--bevel-lo: oklch(0.115 0.012 150);
--line: oklch(0.33 0.016 150);
--accent: oklch(0.72 0.16 145); /* grass */
--accent-hi: oklch(0.80 0.15 145);
--accent-lo: oklch(0.55 0.15 145);
--accent-ink: oklch(0.17 0.05 150); /* text on accent */
--gold: oklch(0.82 0.135 85);
--glow: oklch(0.72 0.16 145 / 0.30);
--text: oklch(0.95 0.008 110);
--muted: oklch(0.72 0.012 145);
--dim: oklch(0.55 0.012 145);
}
:root[data-mood="nether"] {
--bg: oklch(0.165 0.018 35);
--bg-2: oklch(0.205 0.022 32);
--surface: oklch(0.235 0.026 32);
--surface-2: oklch(0.285 0.030 32);
--slot: oklch(0.185 0.020 32);
--bevel-hi: oklch(0.42 0.040 35);
--bevel-lo: oklch(0.115 0.016 32);
--line: oklch(0.34 0.030 32);
--accent: oklch(0.64 0.19 32); /* nether red */
--accent-hi: oklch(0.72 0.18 38);
--accent-lo: oklch(0.50 0.17 30);
--accent-ink: oklch(0.16 0.04 32);
--gold: oklch(0.83 0.14 75);
--glow: oklch(0.64 0.19 32 / 0.34);
--text: oklch(0.95 0.010 60);
--muted: oklch(0.74 0.020 45);
--dim: oklch(0.56 0.020 40);
}
:root[data-mood="end"] {
--bg: oklch(0.155 0.018 305);
--bg-2: oklch(0.195 0.022 305);
--surface: oklch(0.225 0.026 305);
--surface-2: oklch(0.275 0.030 305);
--slot: oklch(0.175 0.020 305);
--bevel-hi: oklch(0.42 0.040 305);
--bevel-lo: oklch(0.110 0.016 305);
--line: oklch(0.34 0.030 305);
--accent: oklch(0.74 0.135 175); /* end teal */
--accent-hi: oklch(0.82 0.13 175);
--accent-lo: oklch(0.58 0.13 178);
--accent-ink: oklch(0.16 0.04 200);
--gold: oklch(0.84 0.13 95);
--glow: oklch(0.70 0.16 300 / 0.34);
--text: oklch(0.96 0.010 300);
--muted: oklch(0.76 0.020 300);
--dim: oklch(0.58 0.020 300);
}
/* ---- Fonts (switchable head face via --font-head) ---- */
:root {
--font-head: 'Pixelify Sans', system-ui, sans-serif;
--font-body: 'Space Grotesk', system-ui, sans-serif;
--font-pixel: 'Press Start 2P', monospace; /* tiny eyebrow labels */
--font-mono: 'VT323', monospace; /* IP / terminal */
--maxw: 1160px;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
font-size: 17px;
line-height: 1.6;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
/* faint pixel-grid stone texture over everything */
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
background-image:
repeating-linear-gradient(0deg, oklch(1 0 0 / 0.018) 0 1px, transparent 1px 4px),
repeating-linear-gradient(90deg, oklch(0 0 0 / 0.06) 0 1px, transparent 1px 4px);
background-size: 4px 4px, 4px 4px;
mix-blend-mode: overlay;
opacity: 0.5;
}
img { display: block; max-width: 100%; }
::selection { background: var(--accent); color: var(--accent-ink); }
/* ---- Typography helpers ---- */
.eyebrow {
font-family: var(--font-pixel);
font-size: clamp(9px, 1.1vw, 11px);
letter-spacing: 0.12em;
color: var(--accent);
text-transform: uppercase;
line-height: 1.8;
}
h1, h2, h3 {
font-family: var(--font-head);
font-weight: 600;
line-height: 1.02;
letter-spacing: 0.01em;
margin: 0;
}
.section-title {
font-size: clamp(34px, 5vw, 58px);
}
.lead { color: var(--muted); }
.mono { font-family: var(--font-mono); }
/* ---- Layout ---- */
.wrap { width: min(var(--maxw), calc(100% - 48px)); margin-inline: auto; }
section { position: relative; z-index: 1; }
.pad { padding-block: clamp(64px, 9vw, 130px); }
.sec-head { max-width: 640px; margin-bottom: 48px; }
.sec-head .eyebrow { display: block; margin-bottom: 14px; }
.sec-head p { margin: 16px 0 0; font-size: 18px; }
/* ============================================================
MINECRAFT-STYLE BUTTON
============================================================ */
.mc-btn {
--b: var(--surface-2);
position: relative;
display: inline-flex;
align-items: center;
gap: 10px;
font-family: var(--font-head);
font-size: 17px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--text);
background: var(--b);
border: 0;
padding: 15px 24px 17px;
cursor: pointer;
text-decoration: none;
box-shadow:
inset 2px 2px 0 var(--bevel-hi),
inset -2px -2px 0 var(--bevel-lo),
0 4px 0 oklch(0 0 0 / 0.5),
0 8px 18px oklch(0 0 0 / 0.4);
transition: transform .08s ease, filter .12s ease;
image-rendering: pixelated;
text-shadow: 0 2px 0 oklch(0 0 0 / 0.35);
white-space: nowrap;
}
.mc-btn:hover { filter: brightness(1.12); }
.mc-btn:active {
transform: translateY(4px);
box-shadow:
inset 2px 2px 0 var(--bevel-hi),
inset -2px -2px 0 var(--bevel-lo),
0 0 0 oklch(0 0 0 / 0.5),
0 2px 8px oklch(0 0 0 / 0.4);
}
.mc-btn.primary {
--b: var(--accent);
--bevel-hi: var(--accent-hi);
--bevel-lo: var(--accent-lo);
color: var(--accent-ink);
text-shadow: 0 2px 0 oklch(1 0 0 / 0.22);
}
.mc-btn.gold {
--b: var(--gold);
--bevel-hi: oklch(0.90 0.10 90);
--bevel-lo: oklch(0.66 0.13 75);
color: oklch(0.20 0.05 80);
text-shadow: 0 2px 0 oklch(1 0 0 / 0.25);
}
.mc-btn.sm { font-size: 14px; padding: 10px 16px 12px; }
/* ---- Copy-IP chip ---- */
.ip-chip {
display: inline-flex;
align-items: stretch;
background: var(--slot);
box-shadow:
inset 2px 2px 0 var(--bevel-lo),
inset -2px -2px 0 var(--bevel-hi);
overflow: hidden;
}
.ip-chip .ip-val {
display: flex;
align-items: center;
gap: 10px;
padding: 0 18px;
font-family: var(--font-mono);
font-size: 28px;
line-height: 1;
color: var(--gold);
letter-spacing: 0.02em;
}
.ip-chip .ip-val .pin { color: var(--dim); font-size: 22px; }
.ip-chip button {
border: 0;
cursor: pointer;
padding: 14px 18px;
font-family: var(--font-head);
font-weight: 600;
font-size: 15px;
background: var(--accent);
color: var(--accent-ink);
box-shadow: inset 2px 2px 0 var(--accent-hi), inset -2px -2px 0 var(--accent-lo);
transition: filter .12s ease;
white-space: nowrap;
}
.ip-chip button:hover { filter: brightness(1.1); }
.ip-chip button.copied { background: var(--gold); color: oklch(0.20 0.05 80); }
/* ============================================================
NAV
============================================================ */
.nav {
position: sticky;
top: 0;
z-index: 50;
background: oklch(0.165 0.014 150 / 0.72);
-webkit-backdrop-filter: blur(14px) saturate(140%);
backdrop-filter: blur(14px) saturate(140%);
border-bottom: 1px solid var(--line);
}
.nav-in {
display: flex;
align-items: center;
gap: 24px;
height: 68px;
}
.brand { display: flex; align-items: center; gap: 12px; text-decoration: none; color: var(--text); }
.brand .name {
font-family: var(--font-head);
font-weight: 600;
font-size: 22px;
letter-spacing: 0.02em;
}
.nav-links { display: flex; gap: 6px; margin-left: 12px; }
.nav-links a {
color: var(--muted);
text-decoration: none;
font-size: 15px;
font-weight: 500;
padding: 8px 12px;
transition: color .12s ease, background .12s ease;
}
.nav-links a:hover { color: var(--text); background: var(--surface); }
.nav-spacer { flex: 1; }
/* ---- Creeper pixel mark ---- */
.creeper {
display: grid;
grid-template-columns: repeat(8, 1fr);
grid-template-rows: repeat(8, 1fr);
width: 34px;
height: 34px;
background: var(--accent);
box-shadow: inset 2px 2px 0 var(--accent-hi), inset -2px -2px 0 var(--accent-lo);
image-rendering: pixelated;
}
.creeper i { background: transparent; }
.creeper i.f { background: oklch(0.16 0.03 150); }
/* ============================================================
HERO
============================================================ */
.hero {
position: relative;
padding-top: clamp(48px, 7vw, 90px);
padding-bottom: clamp(56px, 8vw, 110px);
overflow: hidden;
}
.hero::before { /* spotlight glow behind logo */
content: "";
position: absolute;
top: -10%;
left: 50%;
width: 1100px;
height: 760px;
transform: translateX(-50%);
background: radial-gradient(closest-side, var(--glow), transparent 72%);
pointer-events: none;
filter: blur(8px);
}
.hero-grid { position: relative; display: grid; gap: clamp(36px, 5vw, 64px); align-items: center; }
/* layout: centered (default) */
:root[data-hero="centered"] .hero-grid { grid-template-columns: 1fr; justify-items: center; text-align: center; }
:root[data-hero="centered"] .hero-cta { justify-content: center; }
:root[data-hero="centered"] .hero-meta { justify-content: center; }
:root[data-hero="centered"] .hero-copy { max-width: 720px; }
:root[data-hero="centered"] .hero-status { display: none; }
/* layout: split (copy left, live panel right) */
:root[data-hero="split"] .hero-grid { grid-template-columns: 1.1fr 0.9fr; }
:root[data-hero="split"] .hero-logo { max-width: 560px; }
:root[data-hero="split"] .hero-logo img { margin-inline: 0; }
/* layout: spotlight (giant logo, minimal text, centered) */
:root[data-hero="spotlight"] .hero-grid { grid-template-columns: 1fr; justify-items: center; text-align: center; }
:root[data-hero="spotlight"] .hero-cta { justify-content: center; }
:root[data-hero="spotlight"] .hero-meta { justify-content: center; }
:root[data-hero="spotlight"] .hero-logo { max-width: 900px; }
:root[data-hero="spotlight"] .hero-tagline { font-size: clamp(20px, 2.4vw, 28px); }
:root[data-hero="spotlight"] .hero-status { display: none; }
:root[data-hero="spotlight"] .hero-features-note { display: none; }
.hero-logo { width: 100%; max-width: 760px; }
.hero-logo img {
width: 100%;
image-rendering: pixelated;
filter: drop-shadow(0 8px 0 oklch(0 0 0 / 0.45)) drop-shadow(0 18px 30px oklch(0 0 0 / 0.55));
margin-inline: auto;
}
.hero-copy { display: flex; flex-direction: column; gap: 22px; }
.hero-tagline {
font-family: var(--font-head);
font-weight: 600;
font-size: clamp(24px, 3vw, 38px);
line-height: 1.08;
letter-spacing: 0.01em;
text-wrap: balance;
}
.hero-tagline .hl { color: var(--accent); }
.hero-sub { color: var(--muted); font-size: 18px; max-width: 52ch; margin: 0; }
.hero-cta { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
.hero-meta { display: flex; flex-wrap: wrap; gap: 10px 22px; align-items: center; color: var(--dim); font-size: 14.5px; }
.hero-meta .dot { width: 6px; height: 6px; background: var(--dim); }
.hero-ip-row { display: flex; flex-wrap: wrap; gap: 14px; align-items: center; }
/* status pill */
.live-pill {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 7px 14px 7px 11px;
background: var(--slot);
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
font-size: 14px;
color: var(--muted);
font-weight: 500;
}
.live-dot {
width: 9px; height: 9px; background: var(--accent);
box-shadow: 0 0 0 3px var(--glow);
animation: pulse 2.2s ease-in-out infinite;
}
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.4} }
@media (prefers-reduced-motion: reduce){ .live-dot{ animation:none } }
.live-pill b { color: var(--text); font-variant-numeric: tabular-nums; }
/* ============================================================
SERVER-LIST STATUS PANEL
============================================================ */
.serverlist {
display: flex;
gap: 18px;
align-items: center;
background: var(--slot);
padding: 16px;
box-shadow:
inset 2px 2px 0 var(--bevel-hi),
inset -2px -2px 0 var(--bevel-lo),
0 8px 24px oklch(0 0 0 / 0.4);
}
.serverlist .icon {
width: 72px; height: 72px; flex-shrink: 0;
display: grid; place-items: center;
background: var(--bg-2);
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
}
.serverlist .icon .creeper { width: 48px; height: 48px; }
.serverlist .meta { flex: 1; min-width: 0; }
.serverlist .row1 { display: flex; align-items: baseline; gap: 12px; flex-wrap: nowrap; }
.serverlist .title { font-family: var(--font-head); font-weight: 600; font-size: 22px; white-space: nowrap; }
.serverlist .ver { color: var(--dim); font-family: var(--font-mono); font-size: 17px; white-space: nowrap; }
.serverlist .motd { margin: 6px 0 0; color: var(--muted); font-size: 15px; }
.serverlist .motd .g { color: var(--gold); }
.serverlist .motd .a { color: var(--accent); }
.serverlist .stat { text-align: right; flex-shrink: 0; }
.serverlist .players { font-variant-numeric: tabular-nums; font-size: 15px; color: var(--muted); display:flex; align-items:center; gap:8px; justify-content:flex-end; }
.serverlist .players b { color: var(--text); }
/* signal bars */
.bars { display: inline-flex; align-items: flex-end; gap: 2px; height: 16px; }
.bars i { width: 4px; background: var(--accent); image-rendering: pixelated; }
.bars i:nth-child(1){height:25%}
.bars i:nth-child(2){height:45%}
.bars i:nth-child(3){height:65%}
.bars i:nth-child(4){height:85%}
.bars i:nth-child(5){height:100%}
/* stat tiles */
.stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 16px;
margin-top: 28px;
}
.tile {
background: var(--surface);
padding: 22px;
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
}
.tile .k { font-family: var(--font-head); font-weight: 600; font-size: 38px; color: var(--text); line-height: 1; font-variant-numeric: tabular-nums; }
.tile .k .u { color: var(--accent); font-size: 22px; }
.tile .l { margin-top: 8px; color: var(--dim); font-size: 13px; letter-spacing: 0.04em; text-transform: uppercase; font-family: var(--font-pixel); font-size: 9px; line-height: 1.8; }
/* ============================================================
FEATURES
============================================================ */
.feat-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 18px;
}
.feat {
display: flex;
gap: 18px;
background: var(--surface);
padding: 26px;
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
transition: transform .14s ease, filter .14s ease;
}
.feat:hover { transform: translateY(-3px); filter: brightness(1.06); }
.feat h3 { font-size: 21px; margin-bottom: 8px; }
.feat p { margin: 0; color: var(--muted); font-size: 16px; }
/* pixel icon */
.picon {
width: 52px; height: 52px; flex-shrink: 0;
display: grid;
grid-template-columns: repeat(7, 1fr);
grid-template-rows: repeat(7, 1fr);
background: var(--slot);
padding: 4px;
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
image-rendering: pixelated;
}
.picon i { background: transparent; }
.picon i.on { background: var(--accent); }
.picon i.go { background: var(--gold); }
/* ============================================================
HOW TO JOIN — steps
============================================================ */
.steps { display: flex; flex-direction: column; gap: 20px; }
.step {
display: grid;
grid-template-columns: 92px 1fr;
gap: 28px;
background: var(--surface);
padding: 30px;
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
}
.step .num {
width: 92px; height: 92px;
display: grid; place-items: center;
background: var(--slot);
font-family: var(--font-head);
font-weight: 600;
font-size: 46px;
color: var(--accent);
box-shadow: inset 2px 2px 0 var(--bevel-lo), inset -2px -2px 0 var(--bevel-hi);
}
.step .body { padding-top: 2px; }
.step .kicker { font-family: var(--font-pixel); font-size: 9px; letter-spacing: 0.1em; color: var(--dim); text-transform: uppercase; }
.step h3 { font-size: 26px; margin: 10px 0 12px; }
.step p { margin: 0 0 16px; color: var(--muted); font-size: 16.5px; max-width: 64ch; }
.step .actions { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; }
.step ul { margin: 0 0 16px; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 9px; }
.step ul li { display: flex; gap: 12px; color: var(--muted); font-size: 16px; }
.step ul li::before { content: ""; width: 10px; height: 10px; margin-top: 8px; flex-shrink: 0; background: var(--accent); }
.step .hint { color: var(--dim); font-size: 14px; }
.step kbd {
font-family: var(--font-mono); font-size: 18px; line-height: 1;
background: var(--bg-2); color: var(--text);
padding: 4px 8px; box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
}
/* ============================================================
FOOTER
============================================================ */
.footer { border-top: 1px solid var(--line); background: var(--bg-2); }
.footer-in { display: flex; flex-wrap: wrap; gap: 32px; align-items: center; justify-content: space-between; padding-block: 40px; }
.footer .brand .name { font-size: 20px; }
.footer-links { display: flex; gap: 8px; flex-wrap: wrap; }
.footer .disc { color: var(--dim); font-size: 13px; max-width: 46ch; }
/* ---- floating dust ---- */
.dust { position: absolute; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
@keyframes rise {
0% { transform: translateY(0) translateX(0); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { transform: translateY(-110vh) translateX(24px); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) { .dust { display: none; } }
.hero-grid { z-index: 1; }
/* ---- head-font: Press Start 2P is wide/tall — scale down ---- */
:root[data-head="8bit"] .section-title { font-size: clamp(20px, 3vw, 38px); line-height: 1.25; }
:root[data-head="8bit"] .hero-tagline { font-size: clamp(15px, 2vw, 26px); line-height: 1.4; }
:root[data-head="8bit"] .brand .name { font-size: 15px; }
:root[data-head="8bit"] .mc-btn { font-size: 12px; }
:root[data-head="8bit"] .mc-btn.sm { font-size: 11px; }
:root[data-head="8bit"] .step h3 { font-size: 17px; line-height: 1.35; }
:root[data-head="8bit"] .step .num { font-size: 30px; }
:root[data-head="8bit"] .feat h3 { font-size: 15px; line-height: 1.4; }
:root[data-head="8bit"] .serverlist .title { font-size: 16px; }
:root[data-head="8bit"] .tile .k { font-size: 26px; }
:root[data-head="8bit"] .ip-chip button { font-size: 12px; }
:root[data-head="8bit"] .live-pill { font-size: 12px; }
/* ---- head-font: Silkscreen is small-cap-ish — nudge ---- */
:root[data-head="silkscreen"] .section-title { letter-spacing: 0; }
/* ============================================================
SCROLL REVEAL (fail-safe: visible at rest; animates only when JS adds .anim)
============================================================ */
.reveal { will-change: opacity, transform; }
@keyframes reveal-in {
from { opacity: 0; transform: translateY(22px); }
to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: no-preference) {
.reveal.anim { animation: reveal-in .6s cubic-bezier(.2,.7,.3,1) both; }
}
/* ============================================================
RESPONSIVE
============================================================ */
@media (max-width: 880px) {
:root[data-hero="split"] .hero-grid { grid-template-columns: 1fr; }
:root[data-hero="split"] .hero-status { display: block; }
.stat-grid { grid-template-columns: repeat(2, 1fr); }
.feat-grid { grid-template-columns: 1fr; }
.nav-links { display: none; }
.serverlist { flex-wrap: wrap; }
.serverlist .stat { text-align: left; }
}
@media (max-width: 560px) {
.step { grid-template-columns: 1fr; gap: 18px; }
.step .num { width: 64px; height: 64px; font-size: 32px; }
.ip-chip .ip-val { font-size: 22px; }
.stat-grid { grid-template-columns: 1fr 1fr; }
}

View File

@@ -0,0 +1,541 @@
// @ds-adherence-ignore -- omelette starter scaffold (raw elements/hex/px by design)
/* BEGIN USAGE */
// tweaks-panel.jsx
// Reusable Tweaks shell + form-control helpers.
// Exports (to window): useTweaks, TweaksPanel, TweakSection, TweakRow, TweakSlider,
// TweakToggle, TweakRadio, TweakSelect, TweakText, TweakNumber, TweakColor, TweakButton.
//
// Owns the host protocol (listens for __activate_edit_mode / __deactivate_edit_mode,
// posts __edit_mode_available / __edit_mode_set_keys / __edit_mode_dismissed) so
// individual prototypes don't re-roll it. Ships a consistent set of controls so you
// don't hand-draw <input type="range">, segmented radios, steppers, etc.
//
// Usage (in an HTML file that loads React + Babel):
//
// const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
// "primaryColor": "#D97757",
// "palette": ["#D97757", "#29261b", "#f6f4ef"],
// "fontSize": 16,
// "density": "regular",
// "dark": false
// }/*EDITMODE-END*/;
//
// function App() {
// const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
// return (
// <div style={{ fontSize: t.fontSize, color: t.primaryColor }}>
// Hello
// <TweaksPanel>
// <TweakSection label="Typography" />
// <TweakSlider label="Font size" value={t.fontSize} min={10} max={32} unit="px"
// onChange={(v) => setTweak('fontSize', v)} />
// <TweakRadio label="Density" value={t.density}
// options={['compact', 'regular', 'comfy']}
// onChange={(v) => setTweak('density', v)} />
// <TweakSection label="Theme" />
// <TweakColor label="Primary" value={t.primaryColor}
// options={['#D97757', '#2A6FDB', '#1F8A5B', '#7A5AE0']}
// onChange={(v) => setTweak('primaryColor', v)} />
// <TweakColor label="Palette" value={t.palette}
// options={[['#D97757', '#29261b', '#f6f4ef'],
// ['#475569', '#0f172a', '#f1f5f9']]}
// onChange={(v) => setTweak('palette', v)} />
// <TweakToggle label="Dark mode" value={t.dark}
// onChange={(v) => setTweak('dark', v)} />
// </TweaksPanel>
// </div>
// );
// }
//
// TweakRadio is the segmented control for 23 short options (auto-falls-back to
// TweakSelect past ~16/~10 chars per label); reach for TweakSelect directly when
// options are many or long. For color tweaks always curate 3-4 options rather than
// a free picker; an option can also be a whole 25 color palette (the stored value
// is the array). The Tweak* controls are a floor, not a ceiling — build custom
// controls inside the panel if a tweak calls for UI they don't cover.
/* END USAGE */
// ─────────────────────────────────────────────────────────────────────────────
const __TWEAKS_STYLE = `
.twk-panel{position:fixed;right:16px;bottom:16px;z-index:2147483646;width:280px;
max-height:calc(100vh - 32px);display:flex;flex-direction:column;
transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom right;
background:rgba(250,249,247,.78);color:#29261b;
-webkit-backdrop-filter:blur(24px) saturate(160%);backdrop-filter:blur(24px) saturate(160%);
border:.5px solid rgba(255,255,255,.6);border-radius:14px;
box-shadow:0 1px 0 rgba(255,255,255,.5) inset,0 12px 40px rgba(0,0,0,.18);
font:11.5px/1.4 ui-sans-serif,system-ui,-apple-system,sans-serif;overflow:hidden}
.twk-hd{display:flex;align-items:center;justify-content:space-between;
padding:10px 8px 10px 14px;cursor:move;user-select:none}
.twk-hd b{font-size:12px;font-weight:600;letter-spacing:.01em}
.twk-x{appearance:none;border:0;background:transparent;color:rgba(41,38,27,.55);
width:22px;height:22px;border-radius:6px;cursor:default;font-size:13px;line-height:1}
.twk-x:hover{background:rgba(0,0,0,.06);color:#29261b}
.twk-body{padding:2px 14px 14px;display:flex;flex-direction:column;gap:10px;
overflow-y:auto;overflow-x:hidden;min-height:0;
scrollbar-width:thin;scrollbar-color:rgba(0,0,0,.15) transparent}
.twk-body::-webkit-scrollbar{width:8px}
.twk-body::-webkit-scrollbar-track{background:transparent;margin:2px}
.twk-body::-webkit-scrollbar-thumb{background:rgba(0,0,0,.15);border-radius:4px;
border:2px solid transparent;background-clip:content-box}
.twk-body::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.25);
border:2px solid transparent;background-clip:content-box}
.twk-row{display:flex;flex-direction:column;gap:5px}
.twk-row-h{flex-direction:row;align-items:center;justify-content:space-between;gap:10px}
.twk-lbl{display:flex;justify-content:space-between;align-items:baseline;
color:rgba(41,38,27,.72)}
.twk-lbl>span:first-child{font-weight:500}
.twk-val{color:rgba(41,38,27,.5);font-variant-numeric:tabular-nums}
.twk-sect{font-size:10px;font-weight:600;letter-spacing:.06em;text-transform:uppercase;
color:rgba(41,38,27,.45);padding:10px 0 0}
.twk-sect:first-child{padding-top:0}
.twk-field{appearance:none;box-sizing:border-box;width:100%;min-width:0;height:26px;padding:0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;
background:rgba(255,255,255,.6);color:inherit;font:inherit;outline:none}
.twk-field:focus{border-color:rgba(0,0,0,.25);background:rgba(255,255,255,.85)}
select.twk-field{padding-right:22px;
background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path fill='rgba(0,0,0,.5)' d='M0 0h10L5 6z'/></svg>");
background-repeat:no-repeat;background-position:right 8px center}
.twk-slider{appearance:none;-webkit-appearance:none;width:100%;height:4px;margin:6px 0;
border-radius:999px;background:rgba(0,0,0,.12);outline:none}
.twk-slider::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;
width:14px;height:14px;border-radius:50%;background:#fff;
border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-slider::-moz-range-thumb{width:14px;height:14px;border-radius:50%;
background:#fff;border:.5px solid rgba(0,0,0,.12);box-shadow:0 1px 3px rgba(0,0,0,.2);cursor:default}
.twk-seg{position:relative;display:flex;padding:2px;border-radius:8px;
background:rgba(0,0,0,.06);user-select:none}
.twk-seg-thumb{position:absolute;top:2px;bottom:2px;border-radius:6px;
background:rgba(255,255,255,.9);box-shadow:0 1px 2px rgba(0,0,0,.12);
transition:left .15s cubic-bezier(.3,.7,.4,1),width .15s}
.twk-seg.dragging .twk-seg-thumb{transition:none}
.twk-seg button{appearance:none;position:relative;z-index:1;flex:1;border:0;
background:transparent;color:inherit;font:inherit;font-weight:500;min-height:22px;
border-radius:6px;cursor:default;padding:4px 6px;line-height:1.2;
overflow-wrap:anywhere}
.twk-toggle{position:relative;width:32px;height:18px;border:0;border-radius:999px;
background:rgba(0,0,0,.15);transition:background .15s;cursor:default;padding:0}
.twk-toggle[data-on="1"]{background:#34c759}
.twk-toggle i{position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:50%;
background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform .15s}
.twk-toggle[data-on="1"] i{transform:translateX(14px)}
.twk-num{display:flex;align-items:center;box-sizing:border-box;min-width:0;height:26px;padding:0 0 0 8px;
border:.5px solid rgba(0,0,0,.1);border-radius:7px;background:rgba(255,255,255,.6)}
.twk-num-lbl{font-weight:500;color:rgba(41,38,27,.6);cursor:ew-resize;
user-select:none;padding-right:8px}
.twk-num input{flex:1;min-width:0;height:100%;border:0;background:transparent;
font:inherit;font-variant-numeric:tabular-nums;text-align:right;padding:0 8px 0 0;
outline:none;color:inherit;-moz-appearance:textfield}
.twk-num input::-webkit-inner-spin-button,.twk-num input::-webkit-outer-spin-button{
-webkit-appearance:none;margin:0}
.twk-num-unit{padding-right:8px;color:rgba(41,38,27,.45)}
.twk-btn{appearance:none;height:26px;padding:0 12px;border:0;border-radius:7px;
background:rgba(0,0,0,.78);color:#fff;font:inherit;font-weight:500;cursor:default}
.twk-btn:hover{background:rgba(0,0,0,.88)}
.twk-btn.secondary{background:rgba(0,0,0,.06);color:inherit}
.twk-btn.secondary:hover{background:rgba(0,0,0,.1)}
.twk-swatch{appearance:none;-webkit-appearance:none;width:56px;height:22px;
border:.5px solid rgba(0,0,0,.1);border-radius:6px;padding:0;cursor:default;
background:transparent;flex-shrink:0}
.twk-swatch::-webkit-color-swatch-wrapper{padding:0}
.twk-swatch::-webkit-color-swatch{border:0;border-radius:5.5px}
.twk-swatch::-moz-color-swatch{border:0;border-radius:5.5px}
.twk-chips{display:flex;gap:6px}
.twk-chip{position:relative;appearance:none;flex:1;min-width:0;height:46px;
padding:0;border:0;border-radius:6px;overflow:hidden;cursor:default;
box-shadow:0 0 0 .5px rgba(0,0,0,.12),0 1px 2px rgba(0,0,0,.06);
transition:transform .12s cubic-bezier(.3,.7,.4,1),box-shadow .12s}
.twk-chip:hover{transform:translateY(-1px);
box-shadow:0 0 0 .5px rgba(0,0,0,.18),0 4px 10px rgba(0,0,0,.12)}
.twk-chip[data-on="1"]{box-shadow:0 0 0 1.5px rgba(0,0,0,.85),
0 2px 6px rgba(0,0,0,.15)}
.twk-chip>span{position:absolute;top:0;bottom:0;right:0;width:34%;
display:flex;flex-direction:column;box-shadow:-1px 0 0 rgba(0,0,0,.1)}
.twk-chip>span>i{flex:1;box-shadow:0 -1px 0 rgba(0,0,0,.1)}
.twk-chip>span>i:first-child{box-shadow:none}
.twk-chip svg{position:absolute;top:6px;left:6px;width:13px;height:13px;
filter:drop-shadow(0 1px 1px rgba(0,0,0,.3))}
`;
// ── useTweaks ───────────────────────────────────────────────────────────────
// Single source of truth for tweak values. setTweak persists via the host
// (__edit_mode_set_keys → host rewrites the EDITMODE block on disk).
function useTweaks(defaults) {
const [values, setValues] = React.useState(defaults);
// Accepts either setTweak('key', value) or setTweak({ key: value, ... }) so a
// useState-style call doesn't write a "[object Object]" key into the persisted
// JSON block.
const setTweak = React.useCallback((keyOrEdits, val) => {
const edits = typeof keyOrEdits === 'object' && keyOrEdits !== null
? keyOrEdits : { [keyOrEdits]: val };
setValues((prev) => ({ ...prev, ...edits }));
window.parent.postMessage({ type: '__edit_mode_set_keys', edits }, '*');
// Same-window signal so in-page listeners (deck-stage rail thumbnails)
// can react — the parent message only reaches the host, not peers.
window.dispatchEvent(new CustomEvent('tweakchange', { detail: edits }));
}, []);
return [values, setTweak];
}
// ── TweaksPanel ─────────────────────────────────────────────────────────────
// Floating shell. Registers the protocol listener BEFORE announcing
// availability — if the announce ran first, the host's activate could land
// before our handler exists and the toolbar toggle would silently no-op.
// The close button posts __edit_mode_dismissed so the host's toolbar toggle
// flips off in lockstep; the host echoes __deactivate_edit_mode back which
// is what actually hides the panel.
function TweaksPanel({ title = 'Tweaks', children }) {
const [open, setOpen] = React.useState(false);
const dragRef = React.useRef(null);
const offsetRef = React.useRef({ x: 16, y: 16 });
const PAD = 16;
const clampToViewport = React.useCallback(() => {
const panel = dragRef.current;
if (!panel) return;
const w = panel.offsetWidth, h = panel.offsetHeight;
const maxRight = Math.max(PAD, window.innerWidth - w - PAD);
const maxBottom = Math.max(PAD, window.innerHeight - h - PAD);
offsetRef.current = {
x: Math.min(maxRight, Math.max(PAD, offsetRef.current.x)),
y: Math.min(maxBottom, Math.max(PAD, offsetRef.current.y)),
};
panel.style.right = offsetRef.current.x + 'px';
panel.style.bottom = offsetRef.current.y + 'px';
}, []);
React.useEffect(() => {
if (!open) return;
clampToViewport();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', clampToViewport);
return () => window.removeEventListener('resize', clampToViewport);
}
const ro = new ResizeObserver(clampToViewport);
ro.observe(document.documentElement);
return () => ro.disconnect();
}, [open, clampToViewport]);
React.useEffect(() => {
const onMsg = (e) => {
const t = e?.data?.type;
if (t === '__activate_edit_mode') setOpen(true);
else if (t === '__deactivate_edit_mode') setOpen(false);
};
window.addEventListener('message', onMsg);
window.parent.postMessage({ type: '__edit_mode_available' }, '*');
return () => window.removeEventListener('message', onMsg);
}, []);
const dismiss = () => {
setOpen(false);
window.parent.postMessage({ type: '__edit_mode_dismissed' }, '*');
};
const onDragStart = (e) => {
const panel = dragRef.current;
if (!panel) return;
const r = panel.getBoundingClientRect();
const sx = e.clientX, sy = e.clientY;
const startRight = window.innerWidth - r.right;
const startBottom = window.innerHeight - r.bottom;
const move = (ev) => {
offsetRef.current = {
x: startRight - (ev.clientX - sx),
y: startBottom - (ev.clientY - sy),
};
clampToViewport();
};
const up = () => {
window.removeEventListener('mousemove', move);
window.removeEventListener('mouseup', up);
};
window.addEventListener('mousemove', move);
window.addEventListener('mouseup', up);
};
if (!open) return null;
return (
<>
<style>{__TWEAKS_STYLE}</style>
<div ref={dragRef} className="twk-panel" data-omelette-chrome=""
style={{ right: offsetRef.current.x, bottom: offsetRef.current.y }}>
<div className="twk-hd" onMouseDown={onDragStart}>
<b>{title}</b>
<button className="twk-x" aria-label="Close tweaks"
onMouseDown={(e) => e.stopPropagation()}
onClick={dismiss}></button>
</div>
<div className="twk-body">
{children}
</div>
</div>
</>
);
}
// ── Layout helpers ──────────────────────────────────────────────────────────
function TweakSection({ label, children }) {
return (
<>
<div className="twk-sect">{label}</div>
{children}
</>
);
}
function TweakRow({ label, value, children, inline = false }) {
return (
<div className={inline ? 'twk-row twk-row-h' : 'twk-row'}>
<div className="twk-lbl">
<span>{label}</span>
{value != null && <span className="twk-val">{value}</span>}
</div>
{children}
</div>
);
}
// ── Controls ────────────────────────────────────────────────────────────────
function TweakSlider({ label, value, min = 0, max = 100, step = 1, unit = '', onChange }) {
return (
<TweakRow label={label} value={`${value}${unit}`}>
<input type="range" className="twk-slider" min={min} max={max} step={step}
value={value} onChange={(e) => onChange(Number(e.target.value))} />
</TweakRow>
);
}
function TweakToggle({ label, value, onChange }) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<button type="button" className="twk-toggle" data-on={value ? '1' : '0'}
role="switch" aria-checked={!!value}
onClick={() => onChange(!value)}><i /></button>
</div>
);
}
function TweakRadio({ label, value, options, onChange }) {
const trackRef = React.useRef(null);
const [dragging, setDragging] = React.useState(false);
// The active value is read by pointer-move handlers attached for the lifetime
// of a drag — ref it so a stale closure doesn't fire onChange for every move.
const valueRef = React.useRef(value);
valueRef.current = value;
// Segments wrap mid-word once per-segment width runs out. The track is
// ~248px (280 panel 28 body pad 4 seg pad), each button loses 12px
// to its own padding, and 11.5px system-ui averages ~6.3px/char — so 2
// options fit ~16 chars each, 3 fit ~10. Past that (or >3 options), fall
// back to a dropdown rather than wrap.
const labelLen = (o) => String(typeof o === 'object' ? o.label : o).length;
const maxLen = options.reduce((m, o) => Math.max(m, labelLen(o)), 0);
const fitsAsSegments = maxLen <= ({ 2: 16, 3: 10 }[options.length] ?? 0);
if (!fitsAsSegments) {
// <select> emits strings — map back to the original option value so the
// fallback stays type-preserving (numbers, booleans) like the segment path.
const resolve = (s) => {
const m = options.find((o) => String(typeof o === 'object' ? o.value : o) === s);
return m === undefined ? s : typeof m === 'object' ? m.value : m;
};
return <TweakSelect label={label} value={value} options={options}
onChange={(s) => onChange(resolve(s))} />;
}
const opts = options.map((o) => (typeof o === 'object' ? o : { value: o, label: o }));
const idx = Math.max(0, opts.findIndex((o) => o.value === value));
const n = opts.length;
const segAt = (clientX) => {
const r = trackRef.current.getBoundingClientRect();
const inner = r.width - 4;
const i = Math.floor(((clientX - r.left - 2) / inner) * n);
return opts[Math.max(0, Math.min(n - 1, i))].value;
};
const onPointerDown = (e) => {
setDragging(true);
const v0 = segAt(e.clientX);
if (v0 !== valueRef.current) onChange(v0);
const move = (ev) => {
if (!trackRef.current) return;
const v = segAt(ev.clientX);
if (v !== valueRef.current) onChange(v);
};
const up = () => {
setDragging(false);
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<TweakRow label={label}>
<div ref={trackRef} role="radiogroup" onPointerDown={onPointerDown}
className={dragging ? 'twk-seg dragging' : 'twk-seg'}>
<div className="twk-seg-thumb"
style={{ left: `calc(2px + ${idx} * (100% - 4px) / ${n})`,
width: `calc((100% - 4px) / ${n})` }} />
{opts.map((o) => (
<button key={o.value} type="button" role="radio" aria-checked={o.value === value}>
{o.label}
</button>
))}
</div>
</TweakRow>
);
}
function TweakSelect({ label, value, options, onChange }) {
return (
<TweakRow label={label}>
<select className="twk-field" value={value} onChange={(e) => onChange(e.target.value)}>
{options.map((o) => {
const v = typeof o === 'object' ? o.value : o;
const l = typeof o === 'object' ? o.label : o;
return <option key={v} value={v}>{l}</option>;
})}
</select>
</TweakRow>
);
}
function TweakText({ label, value, placeholder, onChange }) {
return (
<TweakRow label={label}>
<input className="twk-field" type="text" value={value} placeholder={placeholder}
onChange={(e) => onChange(e.target.value)} />
</TweakRow>
);
}
function TweakNumber({ label, value, min, max, step = 1, unit = '', onChange }) {
const clamp = (n) => {
if (min != null && n < min) return min;
if (max != null && n > max) return max;
return n;
};
const startRef = React.useRef({ x: 0, val: 0 });
const onScrubStart = (e) => {
e.preventDefault();
startRef.current = { x: e.clientX, val: value };
const decimals = (String(step).split('.')[1] || '').length;
const move = (ev) => {
const dx = ev.clientX - startRef.current.x;
const raw = startRef.current.val + dx * step;
const snapped = Math.round(raw / step) * step;
onChange(clamp(Number(snapped.toFixed(decimals))));
};
const up = () => {
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
};
return (
<div className="twk-num">
<span className="twk-num-lbl" onPointerDown={onScrubStart}>{label}</span>
<input type="number" value={value} min={min} max={max} step={step}
onChange={(e) => onChange(clamp(Number(e.target.value)))} />
{unit && <span className="twk-num-unit">{unit}</span>}
</div>
);
}
// Relative-luminance contrast pick — checkmarks drawn over a swatch need to
// read on both #111 and #fafafa without per-option configuration. Hex input
// only (#rgb / #rrggbb); named or rgb()/hsl() colors fall through to "light".
function __twkIsLight(hex) {
const h = String(hex).replace('#', '');
const x = h.length === 3 ? h.replace(/./g, (c) => c + c) : h.padEnd(6, '0');
const n = parseInt(x.slice(0, 6), 16);
if (Number.isNaN(n)) return true;
const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
return r * 299 + g * 587 + b * 114 > 148000;
}
const __TwkCheck = ({ light }) => (
<svg viewBox="0 0 14 14" aria-hidden="true">
<path d="M3 7.2 5.8 10 11 4.2" fill="none" strokeWidth="2.2"
strokeLinecap="round" strokeLinejoin="round"
stroke={light ? 'rgba(0,0,0,.78)' : '#fff'} />
</svg>
);
// TweakColor — curated color/palette picker. Each option is either a single
// hex string or an array of 1-5 hex strings; the card adapts — a lone color
// renders solid, a palette renders colors[0] as the hero (left ~2/3) with the
// rest stacked in a sharp column on the right. onChange emits the
// option in the shape it was passed (string stays string, array stays array).
// Without options it falls back to the native color input for back-compat.
function TweakColor({ label, value, options, onChange }) {
if (!options || !options.length) {
return (
<div className="twk-row twk-row-h">
<div className="twk-lbl"><span>{label}</span></div>
<input type="color" className="twk-swatch" value={value}
onChange={(e) => onChange(e.target.value)} />
</div>
);
}
// Native <input type=color> emits lowercase hex per the HTML spec, so
// compare case-insensitively. String() guards JSON.stringify(undefined),
// which returns the primitive undefined (no .toLowerCase).
const key = (o) => String(JSON.stringify(o)).toLowerCase();
const cur = key(value);
return (
<TweakRow label={label}>
<div className="twk-chips" role="radiogroup">
{options.map((o, i) => {
const colors = Array.isArray(o) ? o : [o];
const [hero, ...rest] = colors;
const sup = rest.slice(0, 4);
const on = key(o) === cur;
return (
<button key={i} type="button" className="twk-chip" role="radio"
aria-checked={on} data-on={on ? '1' : '0'}
aria-label={colors.join(', ')} title={colors.join(' · ')}
style={{ background: hero }}
onClick={() => onChange(o)}>
{sup.length > 0 && (
<span>
{sup.map((c, j) => <i key={j} style={{ background: c }} />)}
</span>
)}
{on && <__TwkCheck light={__twkIsLight(hero)} />}
</button>
);
})}
</div>
</TweakRow>
);
}
function TweakButton({ label, onClick, secondary = false }) {
return (
<button type="button" className={secondary ? 'twk-btn secondary' : 'twk-btn'}
onClick={onClick}>{label}</button>
);
}
Object.assign(window, {
useTweaks, TweaksPanel, TweakSection, TweakRow,
TweakSlider, TweakToggle, TweakRadio, TweakSelect,
TweakText, TweakNumber, TweakColor, TweakButton,
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,5 @@
packages:
- '.'
onlyBuiltDependencies:
- esbuild
- sharp

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
landing/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

View File

@@ -0,0 +1,280 @@
/* cyrillic */
@font-face {
font-family: 'Pixelify Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnkEi1lR.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin-ext */
@font-face {
font-family: 'Pixelify Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnMEi1lR.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Pixelify Sans';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9Jn0Eiw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic */
@font-face {
font-family: 'Pixelify Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnkEi1lR.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* latin-ext */
@font-face {
font-family: 'Pixelify Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9JnMEi1lR.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Pixelify Sans';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/CHylV-3HFUT7aC4iv1TxGDR9Jn0Eiw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* cyrillic-ext */
@font-face {
font-family: 'Press Start 2P';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nYivN04w.woff2) format('woff2');
unicode-range: U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F;
}
/* cyrillic */
@font-face {
font-family: 'Press Start 2P';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nRivN04w.woff2) format('woff2');
unicode-range: U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116;
}
/* greek */
@font-face {
font-family: 'Press Start 2P';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nWivN04w.woff2) format('woff2');
unicode-range: U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF;
}
/* latin-ext */
@font-face {
font-family: 'Press Start 2P';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nbivN04w.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Press Start 2P';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/e3t4euO8T-267oIAQAu6jDQyK3nVivM.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Silkscreen';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/m8JXjfVPf62XiF7kO-i9YL1la1OD.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Silkscreen';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/m8JXjfVPf62XiF7kO-i9YLNlaw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* latin-ext */
@font-face {
font-family: 'Silkscreen';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmKi2Oud.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Silkscreen';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/m8JUjfVPf62XiF7kO-i9aAhAfmyi2A.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* vietnamese */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* vietnamese */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* vietnamese */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* vietnamese */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb54C-s0.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'Space Grotesk';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url(/fonts/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* vietnamese */
@font-face {
font-family: 'VT323';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/pxiKyp0ihIEF2isQFJXGdg.woff2) format('woff2');
unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
}
/* latin-ext */
@font-face {
font-family: 'VT323';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/pxiKyp0ihIEF2isRFJXGdg.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
/* latin */
@font-face {
font-family: 'VT323';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/pxiKyp0ihIEF2isfFJU.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* Block Blueprint — vendored from 1001fonts (NOT on Google Fonts; appended by
fetch-fonts.sh after the Google rewrite). License: 1001Fonts Free For Personal
Use. Single weight, TTF (no woff2 toolchain on the build box). */
@font-face {
font-family: 'Block Blueprint';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url(/fonts/BlockBlueprint.ttf) format('truetype');
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="64"
height="64"
viewBox="0 0 9.677 9.667"
version="1.1"
id="svg3"
sodipodi:docname="mojang.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs3" />
<sodipodi:namedview
id="namedview3"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="2.0467427"
inkscape:cx="-92.830429"
inkscape:cy="96.006206"
inkscape:window-width="1920"
inkscape:window-height="992"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g3" />
<path
d="M1.54 2.844c.314-.76 1.31-.46 1.954-.528.785-.083 1.503.272 2.1.758l.164-.9c.327.345.587.756.964 1.052.28.254.655-.342.86-.013.42.864.408 1.86.54 2.795l-.788-.373C6.9 4.17 5.126 3.052 3.656 3.685c-1.294.592-1.156 2.65.06 3.255 1.354.703 2.953.51 4.405.292-.07.42-.34.87-.834.816l-4.95.002c-.5.055-.886-.413-.838-.89l.04-4.315z"
fill="#fff"
id="path3" />
<g
id="g3"
transform="matrix(0.33310606,0,0,0.33310606,8.7658015,-1.332)">
<circle
style="fill:#283c63;fill-opacity:1;stroke:none;stroke-width:4.73305"
id="path1-9"
r="14.345329"
cy="18.66217"
cx="-11.946259" />
<path
id="logo"
d="m -16.05726,28.400159 c -0.04546,-0.07393 -0.04146,-0.951547 0.009,-1.949545 l 0.09191,-1.81518 -0.526472,-0.542457 c -0.672825,-0.692806 -1.103893,-0.809189 -1.679816,-0.453545 -0.538461,0.333165 -0.830668,0.335663 -1.972023,0.01948 -0.862137,-0.23926 -1.268729,-0.459538 -1.120876,-0.607391 0.03746,-0.03797 0.539459,-0.211288 1.113883,-0.385613 1.338658,-0.405094 1.827167,-0.597401 1.740754,-0.684314 -0.03847,-0.03746 -0.615382,0.08842 -1.283213,0.280718 -1.289707,0.370628 -1.760235,0.354645 -1.986009,-0.06693 -0.109385,-0.204296 -0.07793,-0.311188 0.153347,-0.526972 0.159839,-0.148851 0.329669,-0.270729 0.376622,-0.270729 0.04745,0 0.407591,-0.182817 0.801196,-0.406593 l 0.714784,-0.406592 -0.627871,0.07393 c -0.554444,0.06494 -0.62787,0.04396 -0.62787,-0.177322 0,-0.497002 0.182316,-0.679319 1.807687,-1.811185 2.161834,-1.50599 2.788706,-2.085909 3.82167,-3.534457 0.472526,-0.662836 1.121376,-1.3966 1.441555,-1.630866 0.416083,-0.304693 0.627371,-0.578419 0.741756,-0.959537 0.174825,-0.584415 0.423575,-0.866132 0.643355,-0.730268 0.07793,0.04795 0.376124,0.554444 0.663335,1.124871 0.495503,0.985014 0.792705,1.382116 0.792705,1.057441 0,-0.08142 -0.279719,-0.693305 -0.620877,-1.359636 -0.56843,-1.107891 -0.770727,-1.918578 -0.537462,-2.151844 0.143857,-0.143855 0.308192,0.03196 1.558938,1.673323 1.3971002,1.833162 1.6083883,2.205789 1.7192766,3.026966 0.076421,0.567931 0.084919,0.57792 0.1873128,0.217782 0.07543,-0.264735 0.041463,-0.515983 -0.1143866,-0.842656 -0.1218821,-0.255744 -0.1923077,-0.494004 -0.1568436,-0.52947 0.076922,-0.07692 3.2342583,1.562934 4.8366523,2.511483 1.3516446,0.800198 1.8501452,1.238759 1.8501452,1.62637 0,0.467532 -1.0164816,2.36463 -1.8831125,3.514478 -1.1038935,1.465529 -3.3566354,3.276715 -4.0799095,3.280711 -0.2612379,0.0015 -2.1603357,-1.960035 -2.2577377,-2.332162 -0.04496,-0.173826 0.17882,-0.701797 0.63936,-1.505991 0.6992989,-1.221275 0.9160807,-1.784211 0.4735251,-1.227769 l -0.8541441,1.073923 c -0.469529,0.590908 -0.860637,0.910587 -1.561435,1.278718 -0.513984,0.270729 -0.969029,0.580419 -1.010487,0.689309 -0.04146,0.108396 0,0.510987 0.09141,0.894104 0.132368,0.551947 0.133367,0.803694 0.006,1.211286 -0.253246,0.809189 -1.090407,2.56343 -1.223774,2.56343 -0.06593,0 -0.0889,-0.104396 -0.05095,-0.231267 0.03747,-0.127373 0.104397,-0.40959 0.147852,-0.627872 0.07543,-0.376622 0.05095,-0.363135 -0.489509,0.264236 -0.742257,0.863135 -1.41708,1.519976 -1.561435,1.519976 -0.06293,0 -0.152347,-0.06044 -0.197802,-0.134865 z m 3.54145,-11.877094 c 0.213785,-0.214286 -0.03447,-0.411587 -0.516982,-0.411587 -0.567931,0 -0.974523,0.202296 -0.974523,0.485512 0,0.177822 0.110388,0.198801 0.683815,0.130869 0.375623,-0.04446 0.739259,-0.136863 0.80769,-0.204794 z m 2.467027,-4.450039 c -0.167332,-0.201798 0.631368,-0.91908 1.5134836,-1.359638 1.1278687,-0.563436 2.9155763,-0.601397 3.9170734,-0.08341 0.3346641,0.172825 0.9740234,0.845152 0.8866115,0.932564 -0.019979,0.01998 -0.3626379,-0.07642 -0.7617364,-0.214285 -1.3331643,-0.460038 -3.0534394,-0.26973 -4.6588303,0.514484 -0.5889094,0.288211 -0.7927059,0.335664 -0.8966018,0.210289 z m -0.83716,-0.906592 c -0.262737,-0.321178 -0.478521,-0.643355 -0.478521,-0.716281 0,-0.204296 1.587908,-1.2002978 2.160335,-1.3746227 0.5724257,-0.173826 1.252744,-0.2857133 2.3086856,-0.2857133 1.0559416,0 1.7432527,0.1608381 2.547446,0.5429553 0.8121863,0.3861131 2.468526,1.7382577 2.1448505,1.7507447 -0.085909,0.0035 -0.650848,-0.243257 -1.2557409,-0.547452 -1.0034949,-0.504993 -1.2057923,-0.5584397 -2.3261694,-0.6108863 -1.6183763,-0.07543 -2.2127818,0.1048983 -3.4750167,1.0559403 -0.5614371,0.423076 -1.0489481,0.769229 -1.0839131,0.769229 -0.03497,0 -0.27872,-0.262735 -0.541956,-0.584413 z"
style="fill:#bbbbbb;fill-opacity:1;stroke:none;stroke-width:0.499498;stroke-opacity:1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -0,0 +1,17 @@
---
// MC-GUI slot + copy button. variant "ip" = big gold mono (server address);
// "url" = smaller, wraps (auth / authlib URLs). Copy handled by the global
// [data-copy] script in [...lang].astro (and the equivalent on /fjord);
// data-label / data-copied drive the (localized) button text + feedback.
interface Props {
value: string;
variant?: "ip" | "url";
label?: string;
copiedLabel?: string;
}
const { value, variant = "ip", label = "Copy", copiedLabel = "Copied!" } = Astro.props;
---
<div class:list={["ip-chip", variant === "url" && "url"]}>
<span class="ip-val"><span class="pin">▸</span>{value}</span>
<button type="button" data-copy={value} data-label={label} data-copied={copiedLabel}>{label}</button>
</div>

View File

@@ -0,0 +1,17 @@
---
// 8x8 creeper face — pure markup, styled by .creeper in main.css.
const CREEPER = [
"00000000",
"01100110",
"01100110",
"00011000",
"00111100",
"00111100",
"00100100",
"00000000",
];
const cells = CREEPER.join("").split("");
---
<div class="creeper" aria-hidden="true">
{cells.map((c) => <i class={c === "1" ? "f" : undefined} />)}
</div>

View File

@@ -0,0 +1,84 @@
---
// Installed-pack mod list (the "Mods" section). Build-time baked from the
// auto-generated catalogue (tooling/build-modlist.py → src/data/mods.json),
// loaded via ./data/mods.ts (resilient: missing file → empty list, build never
// fails). Flat alphabetical grid: extracted logo (or a pixel placeholder) +
// pretty name + version. description is exposed as a tooltip. No categories, no
// external links. Tiles share the MC-GUI bevel treatment used by .feat.
import { MODS } from "../data/mods";
interface Props {
emptyLabel?: string;
}
const { emptyLabel = "Mod list not generated yet." } = Astro.props;
const mods = MODS.mods;
---
{mods.length === 0 ? (
<p class="mods-empty">{emptyLabel}</p>
) : (
<div class="mod-grid">
{mods.map((m) => (
<div class="mod" title={m.description || m.name}>
<div class="mod-logo">
{m.logo ? (
<img src={m.logo} alt="" width="40" height="40" loading="lazy" />
) : (
<span class="mod-noicon" aria-hidden="true">{m.name.slice(0, 1).toUpperCase()}</span>
)}
</div>
<div class="mod-meta">
<span class="mod-name">{m.name}</span>
{m.version && <span class="mod-ver">{m.version}</span>}
</div>
</div>
))}
</div>
)}
<style>
.mod-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 12px;
}
.mod {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: var(--surface);
box-shadow: inset 2px 2px 0 var(--bevel-hi), inset -2px -2px 0 var(--bevel-lo);
transition: transform .14s ease, filter .14s ease;
}
.mod:hover { transform: translateY(-2px); filter: brightness(1.06); }
.mod-logo {
width: 40px; height: 40px; flex-shrink: 0;
display: grid; place-items: center;
background: var(--bg-2);
box-shadow: inset 1px 1px 0 var(--bevel-lo), inset -1px -1px 0 var(--bevel-hi);
}
.mod-logo img { width: 40px; height: 40px; image-rendering: pixelated; }
.mod-noicon {
font-family: var(--font-head);
font-weight: 600;
font-size: 20px;
color: var(--accent);
line-height: 1;
}
.mod-meta { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.mod-name {
font-size: 15px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.mod-ver {
font-family: var(--font-mono);
font-size: 12px;
color: var(--dim);
font-variant-numeric: tabular-nums;
}
.mods-empty { margin: 0; color: var(--dim); font-size: 14px; }
</style>

View File

@@ -0,0 +1,27 @@
---
// Feature icon. Default: a 7x7 procedural glyph (styled by .picon in main.css).
// Optional: pass `img` (an absolute path like "/icons/foo.png|svg" under
// landing/public/) to render a custom image instead of a glyph.
interface Props {
glyph: "shield" | "diamond" | "orb" | "heart";
gold?: boolean;
img?: string; // custom icon path in /public/icons/ — overrides the glyph
}
const { glyph, gold = false, img } = Astro.props;
const GLYPHS: Record<Props["glyph"], string[]> = {
shield: ["0111110", "1111111", "1111111", "1111111", "0111110", "0011100", "0001000"],
diamond: ["0001000", "0011100", "0111110", "1111111", "0111110", "0011100", "0001000"],
orb: ["0011100", "0111110", "1111111", "1111111", "1111111", "0111110", "0011100"],
heart: ["0110110", "1111111", "1111111", "1111111", "0111110", "0011100", "0001000"],
};
const cells = GLYPHS[glyph].join("").split("");
const onClass = gold ? "go" : "on";
---
{img ? (
<img class="picon-img" src={img} alt="" aria-hidden="true" width="52" height="52" />
) : (
<div class="picon" aria-hidden="true">
{cells.map((c) => <i class={c === "1" ? onClass : undefined} />)}
</div>
)}

View File

@@ -0,0 +1,91 @@
---
// Registered-players roster (the "Members" section). Progressive enhancement,
// same shape as ServerCard:
// SSG → renders an empty skeleton (a single .roster grid + an empty-state
// line) so the page is valid with JS off.
// Client → fetches OUR mc-status roster endpoint (site.status.rosterApi,
// same-origin via caddy /api/mcstatus/* → internal /players). It
// returns a sanitized [{uuid,name}] of every Drasl player (admin login
// is server-side in mc-status — no admin token reaches the page). Each
// player renders as a SHARED avatar tile (.roster-tile, styled in
// main.css alongside ServerCard's online tiles).
//
// Empty/error → hide the grid and show the "no members yet" line (data-empty).
import { site } from "../data/site";
interface Props {
// Empty-state copy (passed from the page for i18n; English default).
emptyLabel?: string;
}
const { emptyLabel = "No members yet." } = Astro.props;
const { status, avatarUrl, rosterAvatarMode } = site;
---
<div
class="roster-block"
data-api={status.rosterApi}
data-avatar={avatarUrl}
data-mode={rosterAvatarMode}
data-empty={emptyLabel}
>
<div class="roster" aria-label="Registered players"></div>
<p class="roster-empty" hidden></p>
</div>
<script>
async function hydrate(el: HTMLElement) {
const { api, avatar, mode, empty } = el.dataset;
if (!api || !avatar || !mode) return;
const grid = el.querySelector<HTMLElement>(".roster");
const emptyEl = el.querySelector<HTMLElement>(".roster-empty");
if (!grid) return;
const showEmpty = () => {
grid.hidden = true;
if (emptyEl) {
emptyEl.textContent = empty ?? "No members yet.";
emptyEl.hidden = false;
}
};
let data: Array<{ uuid?: string; name?: string }>;
try {
const r = await fetch(api, { headers: { Accept: "application/json" } });
if (!r.ok) return showEmpty();
data = await r.json();
} catch {
return showEmpty();
}
// Drop the service `admin` account from the public members grid.
const players = Array.isArray(data)
? data.filter((p) => p && p.uuid && p.name?.toLowerCase() !== "admin")
: [];
if (players.length === 0) return showEmpty();
grid.innerHTML = "";
for (const p of players) {
grid.appendChild(rosterTile(p.uuid!, p.name ?? "", avatar, mode));
}
}
// Shared avatar tile (avatar + name) — identical markup to ServerCard's
// online tiles, so .roster / .roster-tile (global, main.css) style both.
function rosterTile(uuid: string, name: string, avatar: string, mode: string) {
const tile = document.createElement("div");
tile.className = "roster-tile";
const img = document.createElement("img");
img.loading = "lazy";
img.alt = name;
img.src = `${avatar}/${mode}/${uuid}?size=256`;
const label = document.createElement("span");
label.className = "roster-name";
label.textContent = name;
label.title = name;
tile.append(img, label);
return tile;
}
document.querySelectorAll<HTMLElement>(".roster-block").forEach(hydrate);
</script>

Some files were not shown because too many files have changed in this diff Show More