# Dockerfile for containerized game test client # Used by E2E tests to run multiple game client instances # # Build: make build-test-client # Usage: started by the test broker via Testcontainers (see tests/JunimoServer.Tests/Containers/GameClientContainer.cs) # # NOTE: This Dockerfile duplicates the game-downloader and smapi-builder stages of the main # Dockerfile: the game DLLs build the test-client mod, and the SMAPI build is what a client # installs into the shared game volume when it boots before the server. # Global build arguments ARG SMAPI_VERSION=4.5.2 # ============================================================================ # Stage 1: Build steam-service downloader (same as main Dockerfile) # ============================================================================ FROM mcr.microsoft.com/dotnet/sdk:10.0 AS steam-service-builder WORKDIR /src # Repo-level build props + code-style rules apply inside the image build too COPY ./Directory.Build.props ./CodeStyle.props ./.editorconfig ./ COPY ./tools/steam-service/*.csproj ./ RUN dotnet restore COPY ./tools/steam-service/ . RUN dotnet publish -c Release -o /app --no-restore # ============================================================================ # Stage 2: Download game files (same as main Dockerfile) # ============================================================================ FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS game-downloader COPY --from=steam-service-builder /app /steam-service WORKDIR /steam-service # Download game (skip SDK during build) RUN --mount=type=secret,id=steam_username \ --mount=type=secret,id=steam_password \ --mount=type=secret,id=steam_refresh_token \ if [ -f /run/secrets/steam_username ]; then export STEAM_USERNAME="$(cat /run/secrets/steam_username)"; fi && \ if [ -f /run/secrets/steam_password ]; then export STEAM_PASSWORD="$(cat /run/secrets/steam_password)"; fi && \ if [ -f /run/secrets/steam_refresh_token ]; then export STEAM_REFRESH_TOKEN="$(cat /run/secrets/steam_refresh_token)"; fi && \ export GAME_DIR=/game && \ dotnet SteamService.dll download --skip-sdk # ============================================================================ # Stage 3: Build SMAPI from source with the patch series applied (same as main Dockerfile) # ============================================================================ # The client shares the server's game volume, so it must install the same SMAPI build when it # boots onto an empty volume (init_smapi in docker/rootfs-test-client/startapp.sh). FROM mcr.microsoft.com/dotnet/sdk:10.0 AS smapi-builder ARG SMAPI_VERSION # zip keeps the Unix permission bits inside install.dat (the SDK image has git but no zip) RUN apt-get update && apt-get install -y --no-install-recommends zip && rm -rf /var/lib/apt/lists/* COPY --from=game-downloader /game /game RUN git clone --depth 1 --branch "${SMAPI_VERSION}" https://github.com/Pathoschild/SMAPI.git /smapi # A patch that no longer applies to the pinned upstream fails the build here, not silently at runtime COPY patches/smapi /smapi-patches RUN for patch in /smapi-patches/*.patch; do \ echo "Applying ${patch}" && git -C /smapi apply --check "${patch}" && git -C /smapi apply "${patch}"; \ done COPY --chmod=0755 docker/smapi/build-installer.sh /smapi-build.sh RUN --mount=type=cache,target=/root/.nuget/packages \ /smapi-build.sh /smapi /game /smapi-patches "${SMAPI_VERSION}" /out # ============================================================================ # Stage 4: Build test-client mod using game DLLs # ============================================================================ # SDK 10.0 (not 6.0): ModBuildConfig 4.4.0's analyzer targets Roslyn 4.9, which # the 6.0 SDK's 4.3 compiler rejects (CS9057). The net6.0 TFM still builds fine. FROM mcr.microsoft.com/dotnet/sdk:10.0 AS mod-builder ARG BUILD_CONFIGURATION=Release # Mod compilation resolves StardewModdingAPI.dll from the game folder, so install our build there COPY --from=game-downloader /game /game COPY --from=smapi-builder /out /tmp/smapi RUN /tmp/smapi/internal/linux/SMAPI.Installer --no-prompt --install --game-path /game && \ rm -rf /tmp/smapi # Copy project files first for better layer caching # (.editorconfig: code-style rules enforced at build via EnforceCodeStyleInBuild) COPY ./Directory.Build.props ./CodeStyle.props ./.editorconfig /src/ COPY ./tests/test-client/JunimoTestClient.csproj /src/tests/test-client/ COPY ./mod/JunimoServer.Shared/JunimoServer.Shared.csproj /src/mod/JunimoServer.Shared/ # Restore dependencies RUN --mount=type=cache,target=/root/.nuget/packages \ dotnet restore /src/tests/test-client/JunimoTestClient.csproj # Copy source and build COPY ./tests/test-client /src/tests/test-client COPY ./mod/JunimoServer.Shared /src/mod/JunimoServer.Shared RUN --mount=type=cache,target=/root/.nuget/packages \ dotnet build /src/tests/test-client/JunimoTestClient.csproj \ --configuration ${BUILD_CONFIGURATION} \ /p:GamePath=/game \ /p:EnableModDeploy=false \ -o /output/JunimoTestClient \ && cp /src/tests/test-client/manifest.json /output/JunimoTestClient/manifest.json # ============================================================================ # Stage 5: Final runtime image # ============================================================================ FROM jlesage/baseimage-gui:debian-13-v4.13 AS client ENV APP_NAME="Stardew Test Client" \ USER_ID=1000 \ GROUP_ID=1000 \ HOME=/config \ LANG=en_US.UTF-8 \ LANGUAGE=en_US:en \ LC_ALL=en_US.UTF-8 \ ACCEPT_EULA=Y \ DEBIAN_FRONTEND=noninteractive \ TERMINAL=xterm \ DARK_MODE=1 \ # Display DISPLAY=:0 \ DISPLAY_HEIGHT=720 \ DISPLAY_WIDTH=1280 \ CLIENT_FPS=0 \ # SMAPI SMAPI_USE_CURRENT_SHELL=true \ SMAPI_MODS_PATH=/data/Mods \ # Test client API port (can be overridden per container) JUNIMO_TEST_PORT=5123 # add-pkg (base image helper), not raw apt-get — see docker/Dockerfile for why: the base's # /var/log symlink breaks postinst scripts, and recommends drag in systemd. Cleans apt itself. RUN add-pkg \ ca-certificates \ curl \ libicu76 \ locales \ procps \ scrot \ x11-utils \ xauth \ xz-utils \ mesa-utils \ && echo "en_US.UTF-8 UTF-8" > /etc/locale.gen \ && locale-gen # Install ffmpeg 8.1.2 (BtbN build of the stable 8.1 branch) rather than the distro package, whose # x11grab timing bugs break the test-recording pipeline. Pin month-end autobuilds only: BtbN prunes # daily builds after 14 days (month-end builds live two years), and the "latest" tag drifts. # Must match the server image's ffmpeg pin so both ends of the recording pipeline behave identically. RUN curl -fL -o /tmp/ffmpeg.tar.xz https://github.com/BtbN/FFmpeg-Builds/releases/download/autobuild-2026-06-30-13-34/ffmpeg-n8.1.2-21-gce3c09c101-linux64-gpl-8.1.tar.xz && \ tar -xf /tmp/ffmpeg.tar.xz -C /tmp && \ install -m 0755 /tmp/ffmpeg-n8.1.2-21-gce3c09c101-linux64-gpl-8.1/bin/ffmpeg /usr/local/bin/ffmpeg && \ install -m 0755 /tmp/ffmpeg-n8.1.2-21-gce3c09c101-linux64-gpl-8.1/bin/ffprobe /usr/local/bin/ffprobe && \ rm -rf /tmp/ffmpeg* # System cleanup RUN \ # Fix stuck dbus statoverride rm -f /var/lib/dpkg/statoverride && \ # Prevent unnecessary logs rm -f /etc/cont-init.d/89-info.sh && \ # Prevent overriding OpenGL drivers rm -rf /etc/cont-env.d/LIBGL_DRIVERS_PATH && \ # Pin the runtime HOME so Steam's ~/.steam lookup matches where startapp.sh links # steamclient.so (${HOME}/.steam/sdk64) — see docker/Dockerfile. /config is owned by USER_ID # at startup, so it stays writable for any configured non-root user; set-cont-env is used # because cont-env overrides container env, so HOME can't be supplied from compose. set-cont-env HOME /config # Our SMAPI build, installed into the game volume by startapp.sh COPY --from=smapi-builder /out /opt/smapi # Copy SMAPI config (client-specific settings) COPY docker/rootfs-test-client/data/smapi-config.json /data/smapi-config.json # Declare the xrdb one-shot as expected-to-exit-immediately (0 = no floor, matching the # app/min_running_time the base image ships — verified in the built image's /etc/services.d). # cinit treats a service exiting before min_running_time # (default 500ms) as a failed start and tears the whole container down (exit 1) — and xrdb # (load X resources, exit) only outlives 500ms when it happens to spend that long waiting on # a still-warming Xvnc, a timing race a one-shot must not have to win. # --chmod=0644: cinit READS this value file; an exec bit makes cinit try to run it (fails 126). COPY --chmod=0644 docker/rootfs-test-client/etc/services.d/xrdb/min_running_time /etc/services.d/xrdb/min_running_time # Copy startup script COPY docker/rootfs-test-client/startapp.sh /startapp.sh # Root init hook: takes ownership of the volumes/paths the non-root app user writes, before the # app service drops privileges (runs as root, alphabetically, ahead of services). COPY docker/rootfs-test-client/etc/cont-init.d/50-client-init.sh /etc/cont-init.d/50-client-init.sh RUN chmod +x /startapp.sh /etc/cont-init.d/50-client-init.sh # Copy test-client mod from build stage COPY --from=mod-builder /output/JunimoTestClient /data/Mods/JunimoTestClient # Game files provided via volume mount (shared with server) VOLUME [ "/data/game" ] # Expose test client API port EXPOSE 5123 # Health check - wait for test client API to respond HEALTHCHECK --interval=2s --timeout=2s --start-period=60s --retries=60 \ CMD curl -sf http://localhost:${JUNIMO_TEST_PORT}/ping || exit 1 WORKDIR /data