Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 30ce6c9512 Fix deploy script to preserve manually set FASTMAIL env vars
The deploy script was unconditionally writing FASTMAIL_API_TOKEN,
FASTMAIL_FROM_EMAIL, and FASTMAIL_FROM_NAME to .env, even when the
GitHub Actions secrets were empty. This overwrote manually-set values.

Now these values are only written if the secrets are non-empty.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 22:26:51 -08:00
487 changed files with 61278 additions and 12810 deletions
+53 -23
View File
@@ -24,7 +24,7 @@ permissions:
jobs:
build-auth:
runs-on: [self-hosted, bazel]
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-auth.outputs.image_tag }}
steps:
@@ -112,16 +112,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Copy update-env script to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
source: "deploy/update-env.sh,deploy/env.template"
target: /opt/eagle0/
strip_components: 1
- name: Deploy auth service to production
uses: appleboy/ssh-action@v1.0.3
with:
@@ -134,18 +124,58 @@ jobs:
set -x
cd /opt/eagle0
# Update env vars using shared script (preserves vars set by other workflows)
chmod +x update-env.sh
./update-env.sh \
"AUTH_IMAGE=${AUTH_IMAGE}" \
"DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}" \
"JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}" \
"FASTMAIL_API_TOKEN=${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=${FASTMAIL_FROM_NAME}"
# Update AUTH_IMAGE in .env file (preserve other vars)
if [ -f .env ]; then
# Remove old AUTH_IMAGE line and add new one
grep -v '^AUTH_IMAGE=' .env > .env.tmp || true
echo "AUTH_IMAGE=${AUTH_IMAGE}" >> .env.tmp
# Also update OAuth credentials
grep -v '^DISCORD_CLIENT_ID=' .env.tmp > .env.tmp2 || true
echo "DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}" >> .env.tmp2
grep -v '^DISCORD_CLIENT_SECRET=' .env.tmp2 > .env.tmp3 || true
echo "DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}" >> .env.tmp3
grep -v '^GOOGLE_CLIENT_ID=' .env.tmp3 > .env.tmp4 || true
echo "GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}" >> .env.tmp4
grep -v '^GOOGLE_CLIENT_SECRET=' .env.tmp4 > .env.tmp5 || true
echo "GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}" >> .env.tmp5
# Update JWT private key (JWK format for auth service bootstrap)
grep -v '^JWT_PRIVATE_KEY=' .env.tmp5 > .env.tmp6 || true
echo "JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}" >> .env.tmp6
# Update Fastmail JMAP credentials for email sending (only if set)
# Debug: show whether env vars are set (not their values)
echo "FASTMAIL_API_TOKEN set: $([ -n "${FASTMAIL_API_TOKEN:-}" ] && echo 'yes' || echo 'no')"
echo "FASTMAIL_FROM_EMAIL set: $([ -n "${FASTMAIL_FROM_EMAIL:-}" ] && echo 'yes' || echo 'no')"
echo "FASTMAIL_FROM_NAME set: $([ -n "${FASTMAIL_FROM_NAME:-}" ] && echo 'yes' || echo 'no')"
cp .env.tmp6 .env.tmp7
if [ -n "${FASTMAIL_API_TOKEN:-}" ]; then
grep -v '^FASTMAIL_API_TOKEN=' .env.tmp7 > .env.tmp7a || true
# Use printf with %s to avoid shell interpretation of special chars
printf 'FASTMAIL_API_TOKEN=%s\n' "${FASTMAIL_API_TOKEN}" >> .env.tmp7a
mv .env.tmp7a .env.tmp7
fi
cp .env.tmp7 .env.tmp8
if [ -n "${FASTMAIL_FROM_EMAIL:-}" ]; then
grep -v '^FASTMAIL_FROM_EMAIL=' .env.tmp8 > .env.tmp8a || true
printf 'FASTMAIL_FROM_EMAIL=%s\n' "${FASTMAIL_FROM_EMAIL}" >> .env.tmp8a
mv .env.tmp8a .env.tmp8
fi
cp .env.tmp8 .env
if [ -n "${FASTMAIL_FROM_NAME:-}" ]; then
grep -v '^FASTMAIL_FROM_NAME=' .env > .env.tmp9 || true
printf 'FASTMAIL_FROM_NAME=%s\n' "${FASTMAIL_FROM_NAME}" >> .env.tmp9
mv .env.tmp9 .env
fi
# Show what ended up in .env for these vars (masked)
echo "Resulting .env FASTMAIL entries:"
grep '^FASTMAIL' .env | sed 's/=.*/=***/' || echo "(none)"
rm -f .env.tmp .env.tmp2 .env.tmp3 .env.tmp4 .env.tmp5 .env.tmp6 .env.tmp7 .env.tmp8
chmod 600 .env
else
echo "ERROR: .env file not found. Run main deploy first."
exit 1
fi
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
+1 -1
View File
@@ -26,7 +26,7 @@ permissions:
jobs:
test:
runs-on: [self-hosted, bazel]
runs-on: self-hosted
steps:
- name: Checkout repository
+21
View File
@@ -0,0 +1,21 @@
name: Build Protos
on:
pull_request:
paths:
- "src/main/protobuf/**"
permissions:
contents: read
jobs:
build:
runs-on: self-hosted
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Run tests
run: ./scripts/build_protos.sh
+35
View File
@@ -0,0 +1,35 @@
name: Client Presigner
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/client_presigner.yml"
- "src/main/go/net/eagle0/client_download/**"
- "src/main/go/net/eagle0/util/**"
pull_request:
paths:
- ".github/workflows/client_presigner.yml"
- "src/main/go/net/eagle0/client_download/**"
- "src/main/go/net/eagle0/util/**"
permissions:
contents: read
jobs:
client-presigner:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
lfs: false
clean: false
- name: Build Client Presigner
run: bazel build --platforms=@io_bazel_rules_go//go/toolchain:linux_amd64 //src/main/go/net/eagle0/client_download
- name: Archive presigner binary
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: client_download
path: bazel-bin/src/main/go/net/eagle0/client_download/client_download_/client_download
+464 -153
View File
@@ -4,7 +4,7 @@ on:
push:
branches: [ "main" ]
paths:
# Note: C++ changes trigger shardok_arm64_build.yml instead
- 'src/main/cpp/**'
- 'src/main/go/**'
- 'src/main/scala/**'
- 'src/main/protobuf/**'
@@ -22,63 +22,293 @@ on:
default: 'false'
type: boolean
# Only allow one deployment at a time to prevent race conditions
concurrency:
group: docker-build-deploy
cancel-in-progress: false # Don't cancel running deployments, queue new ones
permissions:
contents: read
jobs:
# Single consolidated build job - builds all images with one bazel invocation
# This uses 1 runner slot instead of 4, and Bazel parallelizes internally
# NOTE: Must run on a runner with the bazel label to ensure consistent Bazel cache
build-all:
runs-on: [self-hosted, bazel]
build-eagle:
runs-on: self-hosted
outputs:
eagle_image_tag: ${{ steps.push-images.outputs.eagle_image_tag }}
admin_image_tag: ${{ steps.push-images.outputs.admin_image_tag }}
jfr_sidecar_image_tag: ${{ steps.push-images.outputs.jfr_sidecar_image_tag }}
image_tag: ${{ steps.push-eagle.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build all Docker images
id: build-all
- name: Build Eagle Docker image
id: build-eagle
run: |
set -ex
bazel build --platforms=//:linux_x86_64 //ci:eagle_server_image
# Build ALL images in a single bazel command - Bazel parallelizes internally
# Note: Shardok is built separately for ARM64 and deployed to Hetzner
echo "=== Building Docker images ==="
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//ci:eagle_server_image \
//ci:admin_server_image \
//ci:jfr_sidecar_image \
//src/main/go/net/eagle0/warmup:warmup_linux_amd64
# Also build the warmup tool for Linux
bazel build //src/main/go/net/eagle0/warmup:warmup_linux_amd64
# Copy warmup binary to scripts/ for deployment
# Copy warmup binary to scripts/ so it gets deployed with other scripts
mkdir -p scripts/bin
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
# Save all image paths before any other bazel command changes bazel-bin symlink
EAGLE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
ADMIN_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
JFR_PATH=$(readlink -f bazel-bin/ci/jfr_sidecar_image)
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
echo "eagle_path=$EAGLE_PATH" >> $GITHUB_OUTPUT
echo "admin_path=$ADMIN_PATH" >> $GITHUB_OUTPUT
echo "jfr_path=$JFR_PATH" >> $GITHUB_OUTPUT
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
# Also set for current directory in case Bazel uses different home
mkdir -p .docker
cp ~/.docker/config.json .docker/
echo "=== Image paths ==="
echo "Eagle: $EAGLE_PATH"
echo "Admin: $ADMIN_PATH"
echo "JFR Sidecar: $JFR_PATH"
- name: Push Eagle image to DO registry
id: push-eagle
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
# Use cross-compiled image path from build step
EAGLE_IMAGE="${{ steps.build-eagle.outputs.image_path }}"
echo "Using Eagle image: $EAGLE_IMAGE"
if [ -z "$EAGLE_IMAGE" ] || [ ! -d "$EAGLE_IMAGE" ]; then
echo "ERROR: Eagle image not found at: $EAGLE_IMAGE"
exit 1
fi
# Debug: show OCI layout contents
echo "=== OCI Layout Contents ==="
cat "$EAGLE_IMAGE/index.json"
echo ""
echo "=== Blobs ==="
ls -la "$EAGLE_IMAGE/blobs/sha256/" | head -20
# Verify OCI layout consistency before pushing
echo "=== Verifying OCI layout consistency ==="
for digest in $(cat "$EAGLE_IMAGE/index.json" | grep -o '"sha256:[^"]*"' | tr -d '"'); do
blob_path="$EAGLE_IMAGE/blobs/${digest/://}"
if [ ! -f "$blob_path" ]; then
echo "ERROR: Blob not found: $blob_path"
exit 1
fi
actual_digest="sha256:$(shasum -a 256 "$blob_path" | cut -d' ' -f1)"
if [ "$digest" != "$actual_digest" ]; then
echo "ERROR: Digest mismatch for $blob_path"
echo " Index says: $digest"
echo " Actual: $actual_digest"
exit 1
fi
echo "✓ Verified: $digest"
done
# Build the push target to get crane in runfiles
bazel build //ci:eagle_server_push
# Use crane directly for push (avoids OCI->Docker digest mismatch)
CRANE="bazel-bin/ci/push_eagle_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
echo "Pushing eagle image: $IMAGE_TAG"
$CRANE push "$EAGLE_IMAGE" "$IMAGE_TAG"
# Verify push by checking what's in the registry
echo "=== Verifying push ==="
$CRANE manifest "$IMAGE_TAG" | head -50
PUSHED_DIGEST=$($CRANE digest "$IMAGE_TAG")
echo "Registry reports digest: $PUSHED_DIGEST"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience (but deploy won't use it)
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
build-shardok:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Shardok binary (cross-compile for Linux)
run: |
set -ex
# Step 1: Build JUST the binary with cross-compilation
# We need --extra_toolchains to force the Linux toolchain to be used
# because toolchains_llvm registers with dev_dependency=True
echo "=== Building shardok-server binary for linux-x86_64 ==="
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//src/main/cpp/net/eagle0/shardok:shardok-server
# Step 2: Check the binary directly from bazel-bin
# bazel-bin is a symlink that points to the correct output directory
LINUX_BIN="bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server"
echo "=== Checking binary at: $LINUX_BIN ==="
if [ ! -f "$LINUX_BIN" ]; then
echo "ERROR: Binary not found at $LINUX_BIN"
exit 1
fi
# Debug: show what bazel-bin points to
echo "bazel-bin symlink target: $(readlink bazel-bin || echo 'not a symlink')"
# Step 3: Verify it's ELF (Linux) not Mach-O (macOS)
echo "=== Verifying binary format ==="
MAGIC=$(head -c 4 "$LINUX_BIN" | xxd -p)
echo "Binary magic bytes: $MAGIC"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary is ELF format (Linux)"
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
echo ""
echo "Debug info:"
echo "- bazel-bin points to: $(readlink bazel-bin)"
file "$LINUX_BIN" || true
exit 1
else
echo "WARNING: Unknown binary format: $MAGIC"
file "$LINUX_BIN" || true
fi
- name: Build Shardok Docker image
id: build-shardok
run: |
set -ex
# Build the OCI image with cross-compilation flags
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//ci:shardok_server_image
# The image is output to bazel-bin which is a symlink.
# Resolve it now before any other bazel commands change where it points.
IMAGE_PATH=$(readlink -f bazel-bin/ci/shardok_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
# Verify the binary inside the tar layer is ELF
echo "=== Verifying binary in image tar ==="
BINARY_TAR="bazel-bin/ci/shardok_binary_layer.tar"
if [ -f "$BINARY_TAR" ]; then
echo "Checking binary in $BINARY_TAR"
# Extract just the first 4 bytes of the binary from the tar
MAGIC=$(tar -xOf "$BINARY_TAR" app/shardok-server 2>/dev/null | head -c 4 | xxd -p)
echo "Binary magic in tar: $MAGIC"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary in tar is ELF format (Linux)"
else
echo "ERROR: Binary in tar is NOT ELF format!"
echo "This means pkg_tar is packaging the wrong binary."
exit 1
fi
else
echo "WARNING: Could not find $BINARY_TAR"
fi
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
# Also set for current directory in case Bazel uses different home
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Shardok image to DO registry
id: push-shardok
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
# Use cross-compiled image path from build step
CROSS_IMAGE="${{ steps.build-shardok.outputs.image_path }}"
echo "Using cross-compiled image: $CROSS_IMAGE"
if [ -z "$CROSS_IMAGE" ] || [ ! -d "$CROSS_IMAGE" ]; then
echo "ERROR: Cross-compiled image not found at: $CROSS_IMAGE"
exit 1
fi
# Get crane from Eagle push target (which doesn't need cross-compilation)
# This gives us a macOS crane binary we can actually run.
# We can't build shardok_server_push with platform flags because it would
# download a Linux crane that can't run on macOS.
bazel build //ci:eagle_server_push
# Find the Darwin crane binary (may be a symlink)
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
if [ -z "$CRANE" ]; then
# Fallback to any crane
CRANE=$(find "$RUNFILES" -name crane 2>/dev/null | head -1)
fi
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
echo "ERROR: crane not found. Listing runfiles:"
find "$RUNFILES" -name crane 2>/dev/null || true
exit 1
fi
echo "Using crane: $CRANE"
# Push the cross-compiled image with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/shardok-server:${GIT_SHA}"
echo "Pushing shardok image: $IMAGE_TAG"
$CRANE push "$CROSS_IMAGE" "$IMAGE_TAG"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience (but deploy won't use it)
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/shardok-server:latest"
build-admin:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-admin.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Admin Server Docker image
id: build-admin
run: |
set -ex
# Build admin server image (Go binary has explicit goos/goarch in BUILD.bazel)
bazel build //ci:admin_server_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
@@ -91,67 +321,122 @@ jobs:
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push all images to DO registry
id: push-images
- name: Push Admin image to DO registry
id: push-admin
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
GIT_SHA=$(git rev-parse --short=8 HEAD)
ADMIN_IMAGE="${{ steps.build-admin.outputs.image_path }}"
echo "Using Admin image: $ADMIN_IMAGE"
# Get crane from push target runfiles
bazel build //ci:eagle_server_push
CRANE="bazel-bin/ci/push_eagle_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
if [ ! -e "$CRANE" ]; then
# Fallback: find any Darwin crane
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
fi
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
echo "ERROR: crane not found"
if [ -z "$ADMIN_IMAGE" ] || [ ! -d "$ADMIN_IMAGE" ]; then
echo "ERROR: Admin image not found at: $ADMIN_IMAGE"
exit 1
fi
# Build the push target to get crane in runfiles
bazel build //ci:admin_server_push
# Use crane directly for push
CRANE="bazel-bin/ci/push_admin_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push Eagle image
EAGLE_IMAGE="${{ steps.build-all.outputs.eagle_path }}"
EAGLE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
echo "Pushing Eagle: $EAGLE_TAG"
$CRANE push "$EAGLE_IMAGE" "$EAGLE_TAG"
$CRANE copy "$EAGLE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
echo "eagle_image_tag=$EAGLE_TAG" >> $GITHUB_OUTPUT
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
echo "Pushing admin image: $IMAGE_TAG"
$CRANE push "$ADMIN_IMAGE" "$IMAGE_TAG"
# Push Admin image
ADMIN_IMAGE="${{ steps.build-all.outputs.admin_path }}"
ADMIN_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
echo "Pushing Admin: $ADMIN_TAG"
$CRANE push "$ADMIN_IMAGE" "$ADMIN_TAG"
$CRANE copy "$ADMIN_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
echo "admin_image_tag=$ADMIN_TAG" >> $GITHUB_OUTPUT
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Push JFR Sidecar image
JFR_IMAGE="${{ steps.build-all.outputs.jfr_path }}"
JFR_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
echo "Pushing JFR Sidecar: $JFR_TAG"
$CRANE push "$JFR_IMAGE" "$JFR_TAG"
$CRANE copy "$JFR_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
echo "jfr_sidecar_image_tag=$JFR_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
echo "=== All images pushed successfully ==="
build-jfr-sidecar:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-jfr-sidecar.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build JFR Sidecar Docker image
id: build-jfr-sidecar
run: |
set -ex
# Build JFR sidecar image (Go binary has explicit goos/goarch in BUILD.bazel)
bazel build //ci:jfr_sidecar_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/jfr_sidecar_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push JFR Sidecar image to DO registry
id: push-jfr-sidecar
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
JFR_IMAGE="${{ steps.build-jfr-sidecar.outputs.image_path }}"
echo "Using JFR Sidecar image: $JFR_IMAGE"
if [ -z "$JFR_IMAGE" ] || [ ! -d "$JFR_IMAGE" ]; then
echo "ERROR: JFR Sidecar image not found at: $JFR_IMAGE"
exit 1
fi
# Build the push target to get crane in runfiles
bazel build //ci:jfr_sidecar_push
# Use crane directly for push
CRANE="bazel-bin/ci/push_jfr_sidecar_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
echo "Pushing JFR sidecar image: $IMAGE_TAG"
$CRANE push "$JFR_IMAGE" "$IMAGE_TAG"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
deploy:
runs-on: [self-hosted, bazel]
needs: [build-all]
runs-on: self-hosted
needs: [build-eagle, build-shardok, build-admin, build-jfr-sidecar]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
env:
EAGLE_IMAGE: ${{ needs.build-all.outputs.eagle_image_tag }}
ADMIN_IMAGE: ${{ needs.build-all.outputs.admin_image_tag }}
JFR_SIDECAR_IMAGE: ${{ needs.build-all.outputs.jfr_sidecar_image_tag }}
EAGLE_IMAGE: ${{ needs.build-eagle.outputs.image_tag }}
SHARDOK_IMAGE: ${{ needs.build-shardok.outputs.image_tag }}
ADMIN_IMAGE: ${{ needs.build-admin.outputs.image_tag }}
JFR_SIDECAR_IMAGE: ${{ needs.build-jfr-sidecar.outputs.image_tag }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
@@ -165,9 +450,6 @@ jobs:
SHARDOK_ADDRESS: ${{ secrets.SHARDOK_ADDRESS }}
SHARDOK_AUTH_TOKEN: ${{ secrets.SHARDOK_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
steps:
@@ -181,27 +463,12 @@ jobs:
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Build warmup tool
run: |
bazel build //src/main/go/net/eagle0/warmup:warmup_linux_amd64
mkdir -p scripts/bin
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
- name: Copy config files to droplet
run: |
# Create directory structure on remote
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << 'SETUP_DIRS'
set -e
mkdir -p /opt/eagle0/scripts/bin /opt/eagle0/nginx /opt/eagle0/deploy
rm -f /opt/eagle0/scripts/bin/warmup
SETUP_DIRS
# Copy files
scp -i ~/.ssh/deploy_key docker-compose.prod.yml deploy@"$DO_DROPLET_IP":/opt/eagle0/
scp -i ~/.ssh/deploy_key nginx/nginx.conf deploy@"$DO_DROPLET_IP":/opt/eagle0/nginx/
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
scp -i ~/.ssh/deploy_key scripts/bin/warmup deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/bin/
scp -i ~/.ssh/deploy_key deploy/env.template deploy/update-env.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/deploy/
scp -i ~/.ssh/deploy_key \
docker-compose.prod.yml nginx/nginx.conf \
scripts/deploy-blue-green.sh scripts/warmup-eagle.sh scripts/bin/warmup \
deploy@"$DO_DROPLET_IP":/opt/eagle0/
- name: Deploy to production droplet
run: |
@@ -211,6 +478,7 @@ jobs:
# Environment variables passed via heredoc
EAGLE_IMAGE="${EAGLE_IMAGE}"
SHARDOK_IMAGE="${SHARDOK_IMAGE}"
ADMIN_IMAGE="${ADMIN_IMAGE}"
JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
OPENAI_API_KEY="${OPENAI_API_KEY}"
@@ -226,87 +494,130 @@ jobs:
SHARDOK_ADDRESS="${SHARDOK_ADDRESS}"
SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
SENTRY_DSN="${SENTRY_DSN}"
FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
# Check Docker has IPv6 support
# Check Docker has IPv6 support for connecting to Hetzner Shardok
# (One-time setup: sudo tee /etc/docker/daemon.json <<< '{"ipv6": true, "ip6tables": true, "experimental": true, "fixed-cidr-v6": "fd00::/80"}' && sudo systemctl restart docker)
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
echo "Run: sudo tee /etc/docker/daemon.json <<< '{\"ipv6\": true, \"ip6tables\": true, \"experimental\": true, \"fixed-cidr-v6\": \"fd00::/80\"}' && sudo systemctl restart docker"
fi
# Update env vars
chmod +x deploy/update-env.sh
cd deploy && ./update-env.sh \
"EAGLE_IMAGE=\${EAGLE_IMAGE}" \
"ADMIN_IMAGE=\${ADMIN_IMAGE}" \
"JFR_SIDECAR_IMAGE=\${JFR_SIDECAR_IMAGE}" \
"OPENAI_API_KEY=\${OPENAI_API_KEY}" \
"GPT_MODEL_NAME=\${GPT_MODEL_NAME:-gpt-4o}" \
"EAGLE_ENABLE_S3=\${EAGLE_ENABLE_S3:-false}" \
"DO_SPACES_ACCESS_KEY=\${DO_SPACES_ACCESS_KEY}" \
"DO_SPACES_SECRET_KEY=\${DO_SPACES_SECRET_KEY}" \
"JWT_PRIVATE_KEY=\${JWT_PRIVATE_KEY}" \
"DISCORD_CLIENT_ID=\${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=\${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=\${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=\${GOOGLE_CLIENT_SECRET}" \
"SHARDOK_ADDRESS=\${SHARDOK_ADDRESS:-shardok:40042}" \
"SHARDOK_AUTH_TOKEN=\${SHARDOK_AUTH_TOKEN}" \
"SENTRY_DSN=\${SENTRY_DSN}" \
"FASTMAIL_API_TOKEN=\${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=\${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=\${FASTMAIL_FROM_NAME}"
cd ..
# Write env vars to .env file for docker-compose
# Preserve AUTH_IMAGE if it exists (managed by auth_build.yml)
EXISTING_AUTH_IMAGE=""
if [ -f .env ]; then
EXISTING_AUTH_IMAGE=\$(grep '^AUTH_IMAGE=' .env | cut -d= -f2- || true)
fi
rm -f .env 2>/dev/null || true
cat > .env << EOF
EAGLE_IMAGE=\${EAGLE_IMAGE}
SHARDOK_IMAGE=\${SHARDOK_IMAGE}
ADMIN_IMAGE=\${ADMIN_IMAGE}
JFR_SIDECAR_IMAGE=\${JFR_SIDECAR_IMAGE}
AUTH_IMAGE=\${EXISTING_AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
OPENAI_API_KEY=\${OPENAI_API_KEY:-}
GPT_MODEL_NAME=\${GPT_MODEL_NAME:-gpt-4o}
EAGLE_ENABLE_S3=\${EAGLE_ENABLE_S3:-false}
DO_SPACES_ACCESS_KEY=\${DO_SPACES_ACCESS_KEY:-}
DO_SPACES_SECRET_KEY=\${DO_SPACES_SECRET_KEY:-}
JWT_PRIVATE_KEY=\${JWT_PRIVATE_KEY:-}
DISCORD_CLIENT_ID=\${DISCORD_CLIENT_ID:-}
DISCORD_CLIENT_SECRET=\${DISCORD_CLIENT_SECRET:-}
GOOGLE_CLIENT_ID=\${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET=\${GOOGLE_CLIENT_SECRET:-}
SHARDOK_ADDRESS=\${SHARDOK_ADDRESS:-shardok:40042}
SHARDOK_AUTH_TOKEN=\${SHARDOK_AUTH_TOKEN:-}
SENTRY_DSN=\${SENTRY_DSN:-}
EOF
chmod 600 .env
# Login to registry
echo "\$DO_REGISTRY_TOKEN" | docker login registry.digitalocean.com -u "\$DO_REGISTRY_TOKEN" --password-stdin
echo "Using images: \$EAGLE_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
# Use exact image tags passed from build jobs (no :latest fallback)
# Note: AUTH_IMAGE is managed separately by auth_build.yml
echo "Using images: \$EAGLE_IMAGE, \$SHARDOK_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
# Install crane for pulling OCI images
# Use crane to pull images (handles OCI format correctly) then load into Docker
# This avoids digest mismatch from DO registry's OCI->Docker format conversion
echo "Installing crane..."
rm -f crane
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
chmod +x crane
# Pull and load all images
echo "Pulling Eagle image..."
./crane pull "\${EAGLE_IMAGE}" eagle.tar && docker load -i eagle.tar && rm eagle.tar
# crane uses Docker config for auth
echo "Pulling Eagle image with crane..."
./crane pull "\${EAGLE_IMAGE}" eagle.tar || { echo "ERROR: Failed to pull eagle image"; exit 1; }
echo "Loading Eagle image into Docker..."
docker load -i eagle.tar
rm eagle.tar
echo "Pulling Admin image..."
./crane pull "\${ADMIN_IMAGE}" admin.tar && docker load -i admin.tar && rm admin.tar
echo "Pulling Shardok image with crane..."
./crane pull "\${SHARDOK_IMAGE}" shardok.tar || { echo "ERROR: Failed to pull shardok image"; exit 1; }
echo "Loading Shardok image into Docker..."
docker load -i shardok.tar
rm shardok.tar
echo "Pulling JFR Sidecar image..."
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar && docker load -i jfr-sidecar.tar && rm jfr-sidecar.tar
echo "Pulling Admin image with crane..."
./crane pull "\${ADMIN_IMAGE}" admin.tar || { echo "ERROR: Failed to pull admin image"; exit 1; }
echo "Loading Admin image into Docker..."
docker load -i admin.tar
rm admin.tar
# Pull other compose images
echo "Pulling JFR Sidecar image with crane..."
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar || { echo "ERROR: Failed to pull jfr-sidecar image"; exit 1; }
echo "Loading JFR Sidecar image into Docker..."
docker load -i jfr-sidecar.tar
rm jfr-sidecar.tar
rm ./crane
# Also pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
echo "All images pulled successfully"
# Stop local shardok container if running (now runs on Hetzner)
docker stop shardok-server 2>/dev/null || true
docker rm shardok-server 2>/dev/null || true
# Recreate non-Eagle services (not auth - managed by auth_build.yml)
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate shardok admin jfr-sidecar
# Deploy Eagle with blue-green (handles eagle, nginx, admin, jfr-sidecar)
# Note: Shardok runs on Hetzner, deployed separately via shardok_arm64_build.yml
chmod +x /opt/eagle0/scripts/*.sh
[ -f "/opt/eagle0/scripts/bin/warmup" ] && chmod +x /opt/eagle0/scripts/bin/warmup
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}"
# Deploy Eagle with blue-green (zero-downtime) if scripts are available
if [ -x "/opt/eagle0/scripts/deploy-blue-green.sh" ]; then
echo "Using blue-green deployment for Eagle..."
chmod +x /opt/eagle0/scripts/*.sh
# Make warmup binary executable if present
if [ -f "/opt/eagle0/scripts/bin/warmup" ]; then
chmod +x /opt/eagle0/scripts/bin/warmup
fi
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}" || {
echo "Blue-green deployment failed, falling back to direct restart"
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle-blue
}
else
echo "Blue-green scripts not found, using direct restart..."
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle-blue
fi
# Ensure auth is running
# Ensure auth is running (but don't force-recreate it)
docker compose -f docker-compose.prod.yml up -d auth
# Verify
# Restart nginx to pick up new container IPs
# (nginx caches DNS at startup, so it needs restart after eagle/shardok)
docker compose -f docker-compose.prod.yml up -d --force-recreate nginx
# Cleanup orphaned containers
docker compose -f docker-compose.prod.yml up -d --remove-orphans
# Wait for health checks
sleep 10
docker compose -f docker-compose.prod.yml ps
# Verify containers are using correct images
echo "=== Verifying container image tags ==="
docker compose -f docker-compose.prod.yml images
# Cleanup
docker container prune -f
# Cleanup old images
docker image prune -f
DEPLOY_SCRIPT
+6 -35
View File
@@ -10,14 +10,13 @@ on:
paths:
- ".github/workflows/installer_build.yml"
- "src/main/csharp/net/eagle0/clients/win/installer/**"
workflow_dispatch:
permissions:
contents: read
jobs:
build-installer:
runs-on: [self-hosted, bazel]
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
@@ -30,19 +29,6 @@ jobs:
with:
dotnet-version: '8.0.x'
- name: Inject manifest public key
env:
MANIFEST_PUBLIC_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }}
run: |
if [ -n "$MANIFEST_PUBLIC_KEY" ]; then
CONFIG_FILE="src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/configuration.txt"
echo "manifest_public_key = $MANIFEST_PUBLIC_KEY" >> "$CONFIG_FILE"
echo "Injected manifest public key into configuration.txt"
cat "$CONFIG_FILE"
else
echo "MANIFEST_PUBLIC_KEY not set, skipping injection"
fi
- name: Restore dependencies
run: dotnet restore src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj
@@ -82,30 +68,15 @@ jobs:
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
run: |
# Create installer manifest content
# Create installer manifest content
INSTALLER_SHA=$(sha256sum ./installer-output/EagleInstaller.exe | cut -d' ' -f1)
echo "installer_version=$INSTALLER_SHA" > /tmp/installer_manifest.txt
echo "installer_url=installer/EagleInstaller.exe" >> /tmp/installer_manifest.txt
echo "=== Installer manifest content ==="
cat /tmp/installer_manifest.txt
echo "=================================="
# Write signing key to temp file (if available)
SIGNING_ARGS=""
if [ -n "$MANIFEST_SIGNING_KEY" ]; then
echo "$MANIFEST_SIGNING_KEY" > /tmp/manifest_signing_key
chmod 600 /tmp/manifest_signing_key
SIGNING_ARGS="/tmp/manifest_signing_key"
echo "Manifest signing key available"
else
echo "Warning: MANIFEST_SIGNING_KEY not set, manifest will be unsigned"
fi
# Update the unified manifest (with optional signing)
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /tmp/installer_manifest.txt $SIGNING_ARGS
# Cleanup
rm -f /tmp/manifest_signing_key
# Update the unified manifest
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /tmp/installer_manifest.txt
-249
View File
@@ -1,249 +0,0 @@
name: Mac Build
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/mac_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/protobuf/net/eagle0/common/**"
- "src/main/protobuf/net/eagle0/shardok/**"
- "src/main/protobuf/net/eagle0/eagle/api/**"
- "src/main/protobuf/net/eagle0/eagle/common/**"
- "src/main/protobuf/net/eagle0/eagle/views/**"
- "src/main/go/net/eagle0/build/mac_build_handler/**"
- "scripts/build_protos.sh"
- "scripts/build_mac_plugin.sh"
- "scripts/codesign_mac_app.sh"
- "scripts/notarize_submit.sh"
- "scripts/notarize_wait.sh"
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/mac/**"
pull_request:
paths:
- ".github/workflows/mac_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/protobuf/net/eagle0/common/**"
- "src/main/protobuf/net/eagle0/shardok/**"
- "src/main/protobuf/net/eagle0/eagle/api/**"
- "src/main/protobuf/net/eagle0/eagle/common/**"
- "src/main/protobuf/net/eagle0/eagle/views/**"
- "src/main/go/net/eagle0/build/mac_build_handler/**"
- "scripts/build_mac_plugin.sh"
- "scripts/codesign_mac_app.sh"
- "scripts/notarize_submit.sh"
- "scripts/notarize_wait.sh"
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/mac/**"
workflow_dispatch:
inputs:
skip_signing:
description: 'Skip code signing, notarization, and deploy (build only)'
required: false
default: 'false'
type: boolean
permissions:
contents: read
jobs:
build-and-sign:
runs-on: [self-hosted, macOS, unity-mac]
outputs:
submission_id: ${{ steps.notarize-submit.outputs.submission_id }}
should_deploy: ${{ steps.check-deploy.outputs.should_deploy }}
steps:
- uses: actions/checkout@v4
with:
lfs: true
clean: true # Remove untracked files like old SparklePlugin.bundle
fetch-depth: 0 # For version numbering from git history
- name: Pull LFS files
run: git lfs pull
- name: Restore Library/
run: ./ci/github_actions/restore_library.sh
- name: Build Mac Unity
run: ./ci/github_actions/build_unity_mac.sh "/tmp/eagle0/eagle0MAC"
- name: Persist Library/
run: ./ci/github_actions/persist_library.sh
- name: Check if should deploy
id: check-deploy
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.event.inputs.skip_signing }}" == "true" ]]; then
echo "should_deploy=false" >> $GITHUB_OUTPUT
elif [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "should_deploy=true" >> $GITHUB_OUTPUT
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "should_deploy=true" >> $GITHUB_OUTPUT
else
echo "should_deploy=false" >> $GITHUB_OUTPUT
fi
- name: Import Code Signing Certificate
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
run: |
# Generate random keychain password (only used within this workflow run)
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> $GITHUB_ENV
# Decode certificate
echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12
# Delete any existing keychain from previous runs
security delete-keychain build.keychain 2>/dev/null || true
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Import certificate
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
# Allow codesign to access keychain
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# Clean up
rm certificate.p12
- name: Code Sign App
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
env:
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
run: |
chmod +x ./scripts/codesign_mac_app.sh
./scripts/codesign_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
- name: Submit for Notarization
id: notarize-submit
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_submit.sh
./scripts/notarize_submit.sh "/tmp/eagle0/eagle0MAC/eagle0.app" >> $GITHUB_OUTPUT
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain build.keychain 2>/dev/null || true
- name: Zip signed app for artifact
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
run: |
cd /tmp/eagle0/eagle0MAC
ditto -c -k --keepParent eagle0.app eagle0.app.zip
- name: Upload signed app
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
uses: actions/upload-artifact@v4
with:
name: signed-mac-app
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
retention-days: 1
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_mac.log
path: /tmp/eagle0/editor_mac.log
wait-notarization:
needs: build-and-sign
if: needs.build-and-sign.outputs.should_deploy == 'true'
runs-on: [self-hosted, macOS, notarize]
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
- name: Clean download directory
run: rm -rf /tmp/eagle0/eagle0MAC
- name: Download signed app
uses: actions/download-artifact@v4
with:
name: signed-mac-app
path: /tmp/eagle0/eagle0MAC
- name: Unzip signed app
run: |
cd /tmp/eagle0/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
ls -la /tmp/eagle0/eagle0MAC/eagle0.app/
- name: Wait for Notarization and Staple
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_wait.sh
./scripts/notarize_wait.sh "${{ needs.build-and-sign.outputs.submission_id }}" "/tmp/eagle0/eagle0MAC/eagle0.app"
- name: Zip notarized app for artifact
run: |
cd /tmp/eagle0/eagle0MAC
rm -f eagle0.app.zip
ditto -c -k --keepParent eagle0.app eagle0.app.zip
- name: Upload notarized app
uses: actions/upload-artifact@v4
with:
name: notarized-mac-app
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
retention-days: 1
deploy:
needs: [build-and-sign, wait-notarization]
if: needs.build-and-sign.outputs.should_deploy == 'true'
runs-on: [self-hosted, macOS, unity-mac]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # For version numbering
- name: Clean download directory
run: rm -rf /tmp/eagle0/eagle0MAC
- name: Download notarized app
uses: actions/download-artifact@v4
with:
name: notarized-mac-app
path: /tmp/eagle0/eagle0MAC
- name: Unzip notarized app
run: |
cd /tmp/eagle0/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
- name: Deploy Mac Build
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: |
VERSION=$(git describe --tags --always)
BUILD_NUMBER=$(git rev-list --count HEAD)
bazel run //src/main/go/net/eagle0/build/mac_build_handler:mac_build_handler -- \
"/tmp/eagle0/eagle0MAC/eagle0.app" \
"$VERSION" \
"$BUILD_NUMBER"
+29
View File
@@ -0,0 +1,29 @@
name: Mac History Editor Build
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/mac_history_build.yml"
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
- "src/main/protobuf/net/eagle0/eagle/**"
pull_request:
paths:
- ".github/workflows/mac_history_build.yml"
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
- "src/main/protobuf/net/eagle0/eagle/**"
permissions:
contents: read
jobs:
mac-history-build:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
lfs: false
clean: false
- name: Build the mac history
run: ./ci/github_actions/build_mac_history.sh
+2 -2
View File
@@ -24,7 +24,7 @@ permissions:
jobs:
build-shardok-arm64:
runs-on: [self-hosted, bazel]
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
steps:
@@ -159,7 +159,7 @@ jobs:
echo "Also tagged as: registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
deploy-hetzner:
runs-on: [self-hosted, bazel]
runs-on: self-hosted
needs: [build-shardok-arm64]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
+1 -1
View File
@@ -28,7 +28,7 @@ permissions:
jobs:
build:
runs-on: [self-hosted, bazel]
runs-on: self-hosted
steps:
- name: Checkout repository
+6 -30
View File
@@ -6,11 +6,7 @@ on:
paths:
- ".github/workflows/unity_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/protobuf/net/eagle0/common/**"
- "src/main/protobuf/net/eagle0/shardok/**"
- "src/main/protobuf/net/eagle0/eagle/api/**"
- "src/main/protobuf/net/eagle0/eagle/common/**"
- "src/main/protobuf/net/eagle0/eagle/views/**"
- "src/main/proto/**"
- "scripts/build_protos.sh"
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
@@ -19,16 +15,12 @@ on:
- "ci/github_actions/persist_library.sh"
- "MODULE.bazel"
- "WORKSPACE"
workflow_dispatch:
- "src/main/proto/net/eagle0/eagle/**/BUILD.bazel"
pull_request:
paths:
- ".github/workflows/unity_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/protobuf/net/eagle0/common/**"
- "src/main/protobuf/net/eagle0/shardok/**"
- "src/main/protobuf/net/eagle0/eagle/api/**"
- "src/main/protobuf/net/eagle0/eagle/common/**"
- "src/main/protobuf/net/eagle0/eagle/views/**"
- "src/main/proto/**"
- "scripts/build_protos.sh"
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
@@ -37,13 +29,14 @@ on:
- "ci/github_actions/persist_library.sh"
- "MODULE.bazel"
- "WORKSPACE"
- "src/main/proto/**/BUILD.bazel"
permissions:
contents: read
jobs:
windows-unity:
runs-on: [self-hosted, macOS, unity-windows]
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
@@ -70,24 +63,7 @@ jobs:
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
run: |
# Write signing key to temp file (if available)
SIGNING_ARGS=""
if [ -n "$MANIFEST_SIGNING_KEY" ]; then
echo "$MANIFEST_SIGNING_KEY" > /tmp/manifest_signing_key
chmod 600 /tmp/manifest_signing_key
SIGNING_ARGS="/tmp/manifest_signing_key"
echo "Manifest signing key available"
else
echo "Warning: MANIFEST_SIGNING_KEY not set, manifest will be unsigned"
fi
# Update the unified manifest (with optional signing)
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /tmp/unity_manifest.txt $SIGNING_ARGS
# Cleanup
rm -f /tmp/manifest_signing_key
run: bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /tmp/unity_manifest.txt
- name: Archive build log
if: success() || failure()
uses: actions/upload-artifact@v4
-1
View File
@@ -38,4 +38,3 @@ scripts/refresh_name_layers/refresh_name_layers.zip
api_keys.txt
src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/Packages/com.unity.dedicated-server/
node_modules/
-18
View File
@@ -1,23 +1,5 @@
# CLAUDE.md
## CRITICAL GIT RULES (NEVER VIOLATE)
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
**NEVER merge PRs.** You create PRs. The user merges them. No exceptions.
**ALWAYS use this workflow:**
1. Create a feature branch from origin/main
2. Commit to that branch
3. Create a PR with `gh pr create`
4. Wait for user to merge (DO NOT run `gh pr merge`)
If you catch yourself about to run `git push origin main` or `git push origin <branch>:main`, STOP. You are about to violate a critical rule. Create a PR instead.
If you catch yourself about to run `gh pr merge`, STOP. Only the user merges PRs.
---
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
-353
View File
@@ -211,356 +211,3 @@ Remove Legacy* utilities by migrating remaining callers:
- `PerformUnaffiliatedHeroesAction` and the LLM pipeline use protoless `GameState`
- `GameStateViewFilter` Scala overload is now fully protoless internally (converts to proto only at output)
- `ProvinceViewFilter` has Scala overloads for all three modes: no filtering, faction filtering, and withdrawn-from view
---
## Proto Import Inventory (library/)
**Total: 149 proto imports across 51 files** (as of 2026-01-13)
### Proto Dependencies by BUILD.bazel (unique deps)
#### library/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:action_result_type_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:round_phase_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:action_result_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:game_state_view_scala_proto`
- `//src/main/protobuf/net/eagle0/shardok/api:action_result_view_scala_proto`
- `//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_scala_proto`
- `//src/main/protobuf/net/eagle0/shardok/storage:action_result_scala_proto`
#### library/actions/impl/action/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:action_result_type_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:province_order_type_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_quest_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
#### library/actions/impl/common/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
#### library/actions/llm_prompt_generators/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:event_for_chronicle_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto`
#### library/actions/llm_request_generators/diplomacy_llm_helpers/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:province_scala_proto`
#### library/settings/loaders/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto`
#### library/util/BUILD.bazel
- `//src/main/protobuf/net/eagle0/common:hostility_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api/command/util:army_stats_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:date_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:province_event_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:round_phase_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:army_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:faction_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:hero_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:province_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:battalion_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:faction_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:game_state_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:hero_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:stat_with_condition_scala_proto`
#### library/util/command_choice_helpers/BUILD.bazel
- `//src/main/protobuf/net/eagle0/common:hostility_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api/command/util:armed_battalion_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api/command/util:battalion_with_food_cost_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:combat_unit_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_scala_proto`
#### library/util/command_choice_helpers/quest_command_selectors/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:improvement_type_scala_proto`
#### library/util/faction_utils/BUILD.bazel
- `//src/main/protobuf/net/eagle0/common:hostility_scala_proto`
#### library/util/quest_creation/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto`
#### library/util/quest_fulfillment/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:unaffiliated_hero_scala_proto`
#### library/util/view_filters/BUILD.bazel
- `//src/main/protobuf/net/eagle0/common:hostility_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:province_event_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:army_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:faction_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:hero_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:province_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:shardok_battle_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:army_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:battalion_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:faction_relationship_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:faction_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:game_state_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:hero_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:incoming_army_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:shardok_battle_view_scala_proto`
---
### Summary by Directory
| Directory | Files | Imports | Status |
|-----------|-------|---------|--------|
| `actions/availability/` | 0 | 0 | ✅ Clean |
| `actions/impl/action/` | 2 | 3 | Boundary code |
| `actions/impl/common/` | 1 | 2 | Boundary code |
| `actions/llm_prompt_generators/` | 24 | 46 | LLM request types |
| `actions/llm_request_generators/` | 1 | 2 | LLM request types |
| `settings/loaders/` | 1 | 1 | Loader (expected) |
| `util/` | 9 | 20 | Mixed - cleanup candidates |
| `util/command_choice_helpers/` | 17 | 57 | API types |
| `util/view_filters/` | 1 | 1 | View boundary |
| Root (`library/`) | 3 | 5 | Boundary code |
---
### Detailed Inventory
#### Root library/ (5 imports, 3 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ActionResultFilter.scala` | 3 | `common.action_result_notification_details.Notification` | Action result filtering |
| `ActionResultFilter.scala` | 4 | `common.action_result_type.ActionResultType` | |
| `ActionResultFilter.scala` | 5 | `common.action_result_type.ActionResultType.*` | |
| `Engine.scala` | 6 | `internal.action_result.ActionResult` | Trait interface |
| `EngineImpl.scala` | 8 | `internal.action_result.ActionResult` | Returns proto for persistence |
#### actions/impl/action/ (3 imports, 2 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ChronicleEventGenerator.scala` | 3 | `common.action_result_type.ActionResultType.FACTION_DESTROYED` | Single enum value |
| `PerformVassalCommandsPhaseAction.scala` | 5 | `api.available_command.{AvailableCommand, RestAvailableCommand}` | Commands from factory |
| `PerformVassalDefenseDecisionsAction.scala` | 4 | `api.available_command.AvailableCommand` | Commands from factory |
#### actions/impl/common/ (2 imports, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ActionWithResultingState.scala` | 3 | `internal.action_result.ActionResult` | Caches both proto and Scala |
| `ActionWithResultingState.scala` | 4 | `internal.game_state.GameState` | |
#### actions/llm_prompt_generators/ (46 imports, 24 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `AllianceOfferMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.AllianceOfferMessage` | LLM request type |
| `AllianceResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `AllianceResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `AllianceResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.AllianceOfferResolutionMessage` | |
| `BattalionDescriptions.scala` | 3 | `common.battalion_type.BattalionTypeId` | |
| `BattalionDescriptions.scala` | 4 | `internal.battalion.Battalion` | |
| `BreakAllianceMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.BreakAllianceMessage` | |
| `BreakAllianceResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `BreakAllianceResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `BreakAllianceResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.BreakAllianceResolutionMessage` | |
| `CapturedHeroMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.{...}` | |
| `ChronicleEventTextGenerator.scala` | 4 | `internal.event_for_chronicle.{...}` | |
| `ChronicleEventTextGenerator.scala` | 31 | `internal.event_for_chronicle.ShatteredArmyEvent.Reason.{...}` | |
| `ChronicleUpdatePromptGenerator.scala` | 4 | `common.date.Date` | |
| `ChronicleUpdatePromptGenerator.scala` | 5 | `internal.event_for_chronicle.EventForChronicle` | |
| `ChronicleUpdatePromptGenerator.scala` | 6 | `internal.generated_text_request.ChronicleUpdateMessage` | |
| `DivineMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.DivineMessage` | |
| `ExileVassalPromptGenerator.scala` | 4 | `internal.generated_text_request.ExileVassalMessage` | |
| `GeneratorUtilities.scala` | 13 | `common.date.Date` | |
| `HandleCapturedHeroPleaPromptGenerator.scala` | 4 | `internal.generated_text_request.HandleCapturedHeroPlea` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 9 | `internal.event_for_hero_backstory.{...}` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 37 | `internal.event_for_hero_backstory.FoughtInBattleBackstoryEvent.UnitStatus` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 38 | `internal.event_for_hero_backstory.FoughtInBattleBackstoryEvent.UnitStatus.{...}` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 47 | `internal.generated_text_request.HeroBackstoryUpdateRequest` | |
| `HeroDeparturePromptGenerator.scala` | 4 | `internal.generated_text_request.HeroDepartureMessage` | |
| `HeroInitialBackstoryPromptGenerator.scala` | 4 | `internal.generated_text_request.HeroInitialBackstoryRequest` | |
| `InvitationMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.InvitationMessage` | |
| `InvitationResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `InvitationResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `InvitationResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.InvitationResolutionMessage` | |
| `NewFactionHeadPromptGenerator.scala` | 4 | `internal.generated_text_request.NewFactionHeadMessage` | |
| `PleaseRecruitMePromptGenerator.scala` | 4 | `internal.generated_text_request.PleaseRecruitMeMessage` | |
| `PrisonerExecutedPromptGenerator.scala` | 4 | `internal.generated_text_request.PrisonerExecutedMessage` | |
| `PrisonerExiledPromptGenerator.scala` | 4 | `internal.generated_text_request.PrisonerExiledMessage` | |
| `PrisonerReleasedPromptGenerator.scala` | 4 | `internal.generated_text_request.PrisonerReleasedMessage` | |
| `PrisonerReturnedPromptGenerator.scala` | 4 | `internal.generated_text_request.PrisonerReturnedMessage` | |
| `ProfessionGainedPromptGenerator.scala` | 4 | `internal.generated_text_request.ProfessionGainedMessage` | |
| `QuestEndedGeneratorUtilities.scala` | 5 | `common.battalion_type.BattalionTypeId` | |
| `QuestEndedGeneratorUtilities.scala` | 6 | `common.unaffiliated_hero_quest.{...}` | |
| `QuestEndedGeneratorUtilities.scala` | 29 | `common.unaffiliated_hero_quest.QuestDetails.Empty` | |
| `QuestFailedPromptGenerator.scala` | 4 | `internal.generated_text_request.QuestFailedMessage` | |
| `QuestFulfilledPromptGenerator.scala` | 4 | `internal.generated_text_request.QuestFulfilledMessage` | |
| `RansomOfferMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.RansomOfferMessage` | |
| `RansomResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `RansomResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `RansomResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.RansomResolutionMessage` | |
| `RecruitmentRefusedPromptGenerator.scala` | 4 | `internal.generated_text_request.RecruitmentRefusedMessage` | |
| `SuppressBeastsPromptGenerator.scala` | 4 | `internal.generated_text_request.{...}` | |
| `SwearBrotherhoodPromptGenerator.scala` | 3 | `common.gender.Gender.{...}` | |
| `SwearBrotherhoodPromptGenerator.scala` | 4 | `internal.generated_text_request.SwearBrotherhoodMessage` | |
| `TruceOfferMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.TruceOfferMessage` | |
| `TruceResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `TruceResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `TruceResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.TruceResolutionMessage` | |
#### actions/llm_request_generators/ (2 imports, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `DiplomacyResolutionLlmRequestGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `DiplomacyResolutionLlmRequestGenerator.scala` | 5 | `internal.generated_text_request.{...}` | |
#### settings/loaders/ (1 import, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `BattalionTypeLoader.scala` | 8 | `common.battalion_type.{BattalionType, BattalionTypeId}` | Loads from file, converts immediately |
#### util/ (20 imports, 9 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ArmyUtils.scala` | 3 | `internal.army.{Army, HostileArmyGroup, MovingArmy}` | Has Scala overloads |
| `ArmyUtils.scala` | 4 | `internal.game_state.GameState` | |
| `CommandSelection.scala` | 4 | `api.available_command.AvailableCommand` | Wraps proto command |
| `CommandSelection.scala` | 5 | `api.selected_command.SelectedCommand` | |
| `DateProtoUtils.scala` | 5 | `common.date.Date` | Conversion utility |
| `GameStateViewDiffer.scala` | 3 | `common.round_phase.NewRoundPhase` | View diff for client |
| `IDable.scala` | 4 | `internal.battalion.Battalion` | Test helper only |
| `IDable.scala` | 5 | `internal.faction.Faction` | |
| `IDable.scala` | 6 | `internal.hero.Hero` | |
| `IDable.scala` | 7 | `internal.province.Province` | |
| `IncomingArmyUtils.scala` | 4 | `api.command.util.army_stats.ArmyStats` | Returns proto type |
| `IncomingArmyUtils.scala` | 5 | `internal.army.{Army, MovingArmy}` | Has Scala overloads |
| `IncomingArmyUtils.scala` | 6 | `internal.game_state.GameState` | |
| `IncomingArmyUtils.scala` | 7 | `internal.province.Province` | |
| `MapGenerator.scala` | 6 | `internal.province.{Neighbor, Province}` | Map generation |
| `ProvinceEventUtils.scala` | 3 | `common.beast_info.BeastInfo` | |
| `ProvinceEventUtils.scala` | 4 | `common.province_event.*` | |
#### util/command_choice_helpers/ (57 imports, 17 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `AllianceOfferCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, DiplomacyAvailableCommand}` | |
| `AllianceOfferCommandSelector.scala` | 5 | `api.command.util.diplomacy_option.{AllianceOption, DiplomacyOption}` | |
| `AllianceOfferCommandSelector.scala` | 6 | `api.selected_command.DiplomacySelectedCommand` | |
| `AlmsCommandSelector.scala` | 4 | `api.available_command.{AlmsAvailableCommand, AvailableCommand}` | |
| `AlmsCommandSelector.scala` | 5 | `api.selected_command.AlmsSelectedCommand` | |
| `AttackCommandChooser.scala` | 4 | `api.available_command.*` | |
| `AttackCommandChooser.scala` | 5 | `api.selected_command.*` | |
| `AttackCommandChooser.scala` | 6 | `common.combat_unit.CombatUnit` | |
| `AttackDecisionCommandChooser.scala` | 5 | `api.available_command.{AttackDecisionAvailableCommand, AvailableCommand}` | |
| `AttackDecisionCommandChooser.scala` | 6 | `api.command.util.attack_decision_type.{...}` | |
| `AttackDecisionCommandChooser.scala` | 12 | `api.selected_command.AttackDecisionSelectedCommand` | |
| `AttackDecisionCommandChooser.scala` | 13 | `common.tribute_amount.TributeAmount` | |
| `AvailableCommandSelector.scala` | 6 | `api.available_command.AvailableCommand` | |
| `CombatUnitSelector.scala` | 5 | `common.combat_unit.CombatUnit` | |
| `CommandChoiceHelpers.scala` | 5 | `api.available_command.*` | |
| `CommandChoiceHelpers.scala` | 6 | `api.command.util.armed_battalion.ArmedBattalion` | |
| `CommandChoiceHelpers.scala` | 7 | `api.command.util.attack_decision_type.{AdvanceDecision, WithdrawDecision}` | |
| `CommandChoiceHelpers.scala` | 8 | `api.command.util.captured_hero_option.CapturedHeroOption.{...}` | |
| `CommandChoiceHelpers.scala` | 13 | `api.command.util.diplomacy_option.RansomOfferOption` | |
| `CommandChoiceHelpers.scala` | 14 | `api.selected_command.*` | |
| `CommandChoiceHelpers.scala` | 15 | `api.selected_command.OrganizeTroopsSelectedCommand.{ChangedBattalion, NewBattalion}` | |
| `CommandChoiceHelpers.scala` | 16 | `common.battalion_type.BattalionTypeId` | |
| `CommandChoiceHelpers.scala` | 17 | `common.combat_unit.CombatUnit` | |
| `CommandChoiceHelpers.scala` | 18 | `common.improvement_type.ImprovementType.INFRASTRUCTURE` | |
| `CommandChoiceHelpers.scala` | 19 | `common.tribute_amount.TributeAmount` | |
| `CommandChooser.scala` | 6 | `api.available_command.AvailableCommand` | |
| `ExileVassalCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, ExileVassalAvailableCommand}` | |
| `ExileVassalCommandSelector.scala` | 5 | `api.selected_command.ExileVassalSelectedCommand` | |
| `ExpandCommandSelector.scala` | 7 | `api.available_command.{AvailableCommand, MarchAvailableCommand, MarchCommandFromOneProvince}` | |
| `ExpandCommandSelector.scala` | 8 | `api.selected_command.MarchSelectedCommand` | |
| `ExpandCommandSelector.scala` | 9 | `common.combat_unit.CombatUnit` | |
| `FulfillQuestsCommandSelector.scala` | 4 | `api.available_command.AvailableCommand` | |
| `HeroGiftCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, HeroGiftAvailableCommand}` | |
| `HeroGiftCommandSelector.scala` | 5 | `api.selected_command.HeroGiftSelectedCommand` | |
| `ImproveCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, ImproveAvailableCommand}` | |
| `ImproveCommandSelector.scala` | 5 | `api.selected_command.ImproveSelectedCommand` | |
| `ImproveCommandSelector.scala` | 6 | `common.improvement_type.ImprovementType` | |
| `ImproveCommandSelector.scala` | 7 | `common.improvement_type.ImprovementType.DEVASTATION` | |
| `OrganizeCommandSelector.scala` | 5 | `api.available_command.{AvailableCommand, OrganizeTroopsAvailableCommand}` | |
| `OrganizeCommandSelector.scala` | 6 | `api.selected_command.OrganizeTroopsSelectedCommand` | |
| `OrganizeCommandSelector.scala` | 7 | `api.selected_command.OrganizeTroopsSelectedCommand.{ChangedBattalion, NewBattalion}` | |
| `OrganizeCommandSelector.scala` | 8 | `common.battalion_type.BattalionTypeId` | |
| `RansomOfferHelpers.scala` | 3 | `common.diplomacy_offer.RansomOfferDetails` | |
| `RansomOfferHelpers.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `RansomOfferHelpers.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `SwearBrotherhoodCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, SwearBrotherhoodAvailableCommand}` | |
| `SwearBrotherhoodCommandSelector.scala` | 5 | `api.selected_command.SwearBrotherhoodSelectedCommand` | |
| `TruceOfferCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, DiplomacyAvailableCommand}` | |
| `TruceOfferCommandSelector.scala` | 5 | `api.command.util.diplomacy_option.{DiplomacyOption, TruceOption}` | |
| `TruceOfferCommandSelector.scala` | 6 | `api.selected_command.DiplomacySelectedCommand` | |
#### util/command_choice_helpers/quest_command_selectors/ (12 imports, 10 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `AllianceQuestCommandChooser.scala` | 4 | `api.available_command.{AvailableCommand, DiplomacyAvailableCommand}` | |
| `AllianceQuestCommandChooser.scala` | 5 | `api.command.util.diplomacy_option.AllianceOption` | |
| `AlmsAcrossRealmQuestCommandChooser.scala` | 2 | `api.available_command.AvailableCommand` | |
| `AlmsToProvinceQuestCommandChooser.scala` | 3 | `api.available_command.AvailableCommand` | |
| `DismissSpecificVassalCommandChooser.scala` | 5 | `api.available_command.AvailableCommand` | |
| `GiveToHeroesAcrossRealmQuestCommandChooser.scala` | 2 | `api.available_command.AvailableCommand` | |
| `GiveToHeroesInProvinceQuestCommandChooser.scala` | 4 | `api.available_command.AvailableCommand` | |
| `ImproveQuestCommandChooser.scala` | 3 | `api.available_command.AvailableCommand` | |
| `ImproveQuestCommandChooser.scala` | 4 | `common.improvement_type.ImprovementType.{...}` | |
| `QuestCommandChooser.scala` | 4 | `api.available_command.AvailableCommand` | |
| `TruceCountQuestCommandChooser.scala` | 4 | `api.available_command.AvailableCommand` | |
| `TruceWithFactionQuestCommandChooser.scala` | 3 | `api.available_command.AvailableCommand` | |
#### util/view_filters/ (1 import, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ProvinceViewFilter.scala` | 3 | `common.province_event.ProvinceEvent` | For knownEvents field |
#### util/province/ (1 import, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ProvinceUtils.scala` | 4 | `common.battalion_type.BattalionType` | Single proto reference |
---
### Cleanup Candidates (Priority Order)
1. **Delete proto overloads where Scala overloads exist and proto unused**
- `IncomingArmyUtils.scala` - verify all main callers use Scala overloads
- `ArmyUtils.scala` - verify all main callers use Scala overloads
2. **Move test-only utilities to test code**
- `IDable.scala` - only used by tests (except StartGameActionResultUtils)
3. **Create Scala versions of common enums**
- `DiplomacyOfferStatus` → already done in availability package
- `ImprovementType` → used by ImproveCommandSelector, CommandChoiceHelpers
- `BattalionTypeId` → used by multiple command selectors
4. **Large refactoring (requires Scala AvailableCommand/SelectedCommand)**
- `command_choice_helpers/` package (57 imports) - uses proto API command types
- Would need Scala versions of AvailableCommand and SelectedCommand hierarchies
+13 -20
View File
@@ -13,8 +13,8 @@ AWS_SDK_VERSION = "2.28.1"
# Core Build Tools
#
bazel_dep(name = "bazel_skylib", version = "1.9.0")
bazel_dep(name = "rules_pkg", version = "1.2.0")
bazel_dep(name = "bazel_skylib", version = "1.8.1")
bazel_dep(name = "rules_pkg", version = "1.1.0")
#
# Language Support - Scala
@@ -102,8 +102,8 @@ sysroot(
# Language Support - Go
#
bazel_dep(name = "rules_go", version = "0.59.0", repo_name = "io_bazel_rules_go")
bazel_dep(name = "gazelle", version = "0.47.0", repo_name = "bazel_gazelle")
bazel_dep(name = "rules_go", version = "0.56.1", repo_name = "io_bazel_rules_go")
bazel_dep(name = "gazelle", version = "0.45.0", repo_name = "bazel_gazelle")
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.23.3")
@@ -127,8 +127,8 @@ use_repo(
#
bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_apple_support")
bazel_dep(name = "rules_apple", version = "4.3.3", repo_name = "build_bazel_rules_apple")
bazel_dep(name = "rules_swift", version = "2.4.0", repo_name = "build_bazel_rules_swift")
bazel_dep(name = "rules_apple", version = "3.16.1", repo_name = "build_bazel_rules_apple")
bazel_dep(name = "rules_swift", version = "2.3.1", repo_name = "build_bazel_rules_swift")
#
# Protocol Buffers & RPC
@@ -137,7 +137,7 @@ bazel_dep(name = "rules_swift", version = "2.4.0", repo_name = "build_bazel_rule
bazel_dep(name = "protobuf", version = "29.2", repo_name = "com_google_protobuf")
bazel_dep(name = "grpc", version = "1.71.0")
bazel_dep(name = "grpc-java", version = "1.71.0")
bazel_dep(name = "flatbuffers", version = "25.9.23")
bazel_dep(name = "flatbuffers", version = "25.2.10")
#
# Testing
@@ -149,8 +149,8 @@ bazel_dep(name = "googletest", version = "1.17.0")
# Container Images (OCI)
#
bazel_dep(name = "rules_oci", version = "2.2.7")
bazel_dep(name = "aspect_bazel_lib", version = "2.22.4")
bazel_dep(name = "rules_oci", version = "2.2.6")
bazel_dep(name = "aspect_bazel_lib", version = "2.16.0")
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
@@ -186,7 +186,7 @@ use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17",
# Java/Scala Dependencies
#
bazel_dep(name = "rules_jvm_external", version = "6.9")
bazel_dep(name = "rules_jvm_external", version = "6.3")
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
maven.install(
@@ -294,15 +294,11 @@ http_archive(
)
# Busybox static binary for Docker health checks (provides nc, wget, etc.)
# Primary: DigitalOcean Spaces (public, reliable)
# Fallback: busybox.net (can be unreliable/slow)
# https://busybox.net/downloads/binaries/
http_file(
name = "busybox_x86_64",
sha256 = "6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348",
urls = [
"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/busybox/busybox-1.35.0-x86_64-linux-musl",
"https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox",
],
urls = ["https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox"],
downloaded_file_path = "busybox",
executable = True,
)
@@ -310,10 +306,7 @@ http_file(
http_file(
name = "busybox_aarch64",
sha256 = "141adb1b625a6f44c4b114f76b4387b4ea4f7ab802b88eb40e0d2f6adcccb1c3",
urls = [
# TODO: Upload aarch64 binary to GitHub release when needed
"https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox",
],
urls = ["https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox"],
downloaded_file_path = "busybox",
executable = True,
)
+30 -119
View File
@@ -27,9 +27,9 @@
"https://bcr.bazel.build/modules/aspect_bazel_lib/1.40.3/MODULE.bazel": "668e6bcb4d957fc0e284316dba546b705c8d43c857f87119619ee83c4555b859",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.11.0/MODULE.bazel": "cb1ba9f9999ed0bc08600c221f532c1ddd8d217686b32ba7d45b0713b5131452",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.16.0/MODULE.bazel": "852f9ebbda017572a7c113a2434592dd3b2f55cd9a0faea3d4be5a09a59e4900",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.4/MODULE.bazel": "a05cbd9bc16712a58dc27ffe0dceaefd0da59a9bd87a227379b2a934b26a39ab",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.4/source.json": "9780bc57f521968ee82b7c3e85b7d0c71518fb7ce83ed7a9e5077ce20923207b",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/source.json": "ffab9254c65ba945f8369297ad97ca0dec213d3adc6e07877e23a48624a8b456",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.2/MODULE.bazel": "780d1a6522b28f5edb7ea09630748720721dfe27690d65a2d33aa7509de77e07",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.7/MODULE.bazel": "491f8681205e31bb57892d67442ce448cda4f472a8e6b3dc062865e29a64f89c",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838",
@@ -39,11 +39,11 @@
"https://bcr.bazel.build/modules/aspect_rules_js/1.33.1/MODULE.bazel": "db3e7f16e471cf6827059d03af7c21859e7a0d2bc65429a3a11f005d46fc501b",
"https://bcr.bazel.build/modules/aspect_rules_js/1.39.0/MODULE.bazel": "aece421d479e3c31dc3e5f6d49a12acc2700457c03c556650ec7a0ff23fc0d95",
"https://bcr.bazel.build/modules/aspect_rules_js/2.0.0/MODULE.bazel": "b45b507574aa60a92796e3e13c195cd5744b3b8aff516a9c0cb5ae6a048161c5",
"https://bcr.bazel.build/modules/aspect_rules_js/2.3.8/MODULE.bazel": "74bf20a7a6bd5f2be09607fdb4196cfd6f203422ea271752ec2b1afe95426101",
"https://bcr.bazel.build/modules/aspect_rules_js/2.3.8/source.json": "411ec9d79d6f5fe8a083359588c21d01a5b48d88a2cbd334a4c90365015b7836",
"https://bcr.bazel.build/modules/aspect_rules_js/2.1.3/MODULE.bazel": "47cc48eec374d69dced3cf9b9e5926beac2f927441acfb1a3568bbb709b25666",
"https://bcr.bazel.build/modules/aspect_rules_js/2.1.3/source.json": "6b0fe67780c101430be087381b7a79d75eeebe1a1eae6a2cee937713603634ac",
"https://bcr.bazel.build/modules/aspect_rules_lint/0.12.0/MODULE.bazel": "e767c5dbfeb254ec03275a7701b5cfde2c4d2873676804bc7cb27ddff3728fed",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.6.0/MODULE.bazel": "d0045b5eabb012be550a609589b3e5e47eba682344b19cfd9365d4d896ed07df",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.6.0/source.json": "5593e3f1cd0dd5147f7748e163307fd5c2e1077913d6945b58739ad8d770a290",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.4.0/MODULE.bazel": "5b554d5de90d96ee14117527c0519037713dd33884f3212eae391beccb2e94ff",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.4.0/source.json": "9ada3722b716853b6dccdb7b650d8e776a23bc8a190de0c59bd15f21afea6f8a",
"https://bcr.bazel.build/modules/bazel_features/0.1.0/MODULE.bazel": "47011d645b0f949f42ee67f2e8775188a9cf4a0a1528aa2fa4952f2fd00906fd",
"https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b",
"https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd",
@@ -57,15 +57,12 @@
"https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65",
"https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d",
"https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9",
"https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87",
"https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e",
"https://bcr.bazel.build/modules/bazel_features/1.38.0/MODULE.bazel": "f9b8a9c890ebd216b4049fd12a31d3c2602e3403c7af636b04fbbd7453edc9c9",
"https://bcr.bazel.build/modules/bazel_features/1.38.0/source.json": "31ba776c122b54a2885e23651642e32f087a87bf025465f8040751894b571277",
"https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
"https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b",
"https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
"https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d",
"https://bcr.bazel.build/modules/bazel_lib/3.0.0/source.json": "895f21909c6fba01d7c17914bb6c8e135982275a1b18cdaa4e62272217ef1751",
"https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8",
"https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e",
"https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686",
@@ -80,8 +77,7 @@
"https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67",
"https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7",
"https://bcr.bazel.build/modules/bazel_skylib/1.9.0/source.json": "7ad77c1e8c1b84222d9b3f3cae016a76639435744c19330b0b37c0a3c9da7dc0",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb",
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/MODULE.bazel": "fd1f9432ca04c947e91b500df69ce7c5b6dbfe1bc45ab1820338205dae3383a6",
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/source.json": "5d68545f224904745a3cabd35aea6bc2b6cc5a78b7f49f3f69660eab2eeeb273",
"https://bcr.bazel.build/modules/boringssl/0.0.0-20211025-d4f1ab9/MODULE.bazel": "6ee6353f8b1a701fe2178e1d925034294971350b6d3ac37e67e5a7d463267834",
@@ -108,8 +104,8 @@
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20241214-918efc9/MODULE.bazel": "24e05f6f52f37be63a795192848555a2c8c855e7814dbc1ed419fb04a7005464",
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20250128-4de3c74/MODULE.bazel": "1fe72489212c530086e3ffb0e018b2bfef4663200ca03571570f9f006bef1d75",
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20250128-4de3c74/source.json": "028519164a2e24563f4b43d810fdedc702daed90e71e7042d45ba82ad807b46f",
"https://bcr.bazel.build/modules/flatbuffers/25.9.23/MODULE.bazel": "32753ba60bf3bacfe7737c0f3e8e3e55624b19af5d398c485580d57492d145d8",
"https://bcr.bazel.build/modules/flatbuffers/25.9.23/source.json": "a2116f0017f6896353fd3abf65ef2b89b0a257e8a87f395c5000f53934829f31",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/MODULE.bazel": "dab15cafe8512d2c4a8daa44c2d7968c5c79f01e220d40076cdc260bf58605e2",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/source.json": "7eae7ea3eb913b9802426e4d5df11d6c6072a3573a548f8cabf1e965f5cca4d0",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/source.json": "fa7b512dfcb5eafd90ce3959cf42a2a6fe96144ebbb4b3b3928054895f2afac2",
"https://bcr.bazel.build/modules/gazelle/0.27.0/MODULE.bazel": "3446abd608295de6d90b4a8a118ed64a9ce11dcb3dda2dc3290a22056bd20996",
@@ -119,8 +115,8 @@
"https://bcr.bazel.build/modules/gazelle/0.34.0/MODULE.bazel": "abdd8ce4d70978933209db92e436deb3a8b737859e9354fb5fd11fb5c2004c8a",
"https://bcr.bazel.build/modules/gazelle/0.36.0/MODULE.bazel": "e375d5d6e9a6ca59b0cb38b0540bc9a05b6aa926d322f2de268ad267a2ee74c0",
"https://bcr.bazel.build/modules/gazelle/0.37.0/MODULE.bazel": "d1327ba0907d0275ed5103bfbbb13518f6c04955b402213319d0d6c0ce9839d4",
"https://bcr.bazel.build/modules/gazelle/0.47.0/MODULE.bazel": "b61bb007c4efad134aa30ee7f4a8e2a39b22aa5685f005edaa022fbd1de43ebc",
"https://bcr.bazel.build/modules/gazelle/0.47.0/source.json": "aeb2e5df14b7fb298625d75d08b9c65bdb0b56014c5eb89da9e5dd0572280ae6",
"https://bcr.bazel.build/modules/gazelle/0.45.0/MODULE.bazel": "ecd19ebe9f8e024e1ccffb6d997cc893a974bcc581f1ae08f386bdd448b10687",
"https://bcr.bazel.build/modules/gazelle/0.45.0/source.json": "111d182facc5f5e80f0b823d5f077b74128f40c3fd2eccc89a06f34191bd3392",
"https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb",
"https://bcr.bazel.build/modules/google_benchmark/1.8.4/MODULE.bazel": "c6d54a11dcf64ee63545f42561eda3fd94c1b5f5ebe1357011de63ae33739d5e",
"https://bcr.bazel.build/modules/google_benchmark/1.8.5/MODULE.bazel": "9ba9b31b984022828a950e3300410977eda2e35df35584c6b0b2d0c2e52766b7",
@@ -180,7 +176,6 @@
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/MODULE.bazel": "b3925269f63561b8b880ae7cf62ccf81f6ece55b62cd791eda9925147ae116ec",
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/source.json": "da1cb1add160f5e5074b7272e9db6fd8f1b3336c15032cd0a653af9d2f484aed",
"https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92",
"https://bcr.bazel.build/modules/package_metadata/0.0.3/MODULE.bazel": "77890552ecea9e284b5424c9de827a58099348763a4359e975c359a83d4faa83",
"https://bcr.bazel.build/modules/package_metadata/0.0.5/MODULE.bazel": "ef4f9439e3270fdd6b9fd4dbc3d2f29d13888e44c529a1b243f7a31dfbc2e8e4",
"https://bcr.bazel.build/modules/package_metadata/0.0.5/source.json": "2326db2f6592578177751c3e1f74786b79382cd6008834c9d01ec865b9126a85",
"https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5",
@@ -234,9 +229,9 @@
"https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8",
"https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e",
"https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a",
"https://bcr.bazel.build/modules/rules_apple/3.16.1/MODULE.bazel": "8294474defa70af2534a558ab905c083d69203344145e6f7d544d5098611ec7d",
"https://bcr.bazel.build/modules/rules_apple/3.16.1/source.json": "9190fd9d34a5d048bfbba8a530a57f2c2bf3f61e5634a9ab0b6ab005458857f9",
"https://bcr.bazel.build/modules/rules_apple/3.5.1/MODULE.bazel": "3d1bbf65ad3692003d36d8a29eff54d4e5c1c5f4bfb60f79e28646a924d9101c",
"https://bcr.bazel.build/modules/rules_apple/4.3.3/MODULE.bazel": "c5c2c4adeeac5f3f2f9b7f16abfa8be7ffefa596171d0d92bed4cae9ade0a498",
"https://bcr.bazel.build/modules/rules_apple/4.3.3/source.json": "3cb1d69c8243ffcc42ecbf84ae8b9cccd7b1e2f091b0aee5a3e9c9a45267f312",
"https://bcr.bazel.build/modules/rules_buf/0.1.1/MODULE.bazel": "6189aec18a4f7caff599ad41b851ab7645d4f1e114aa6431acf9b0666eb92162",
"https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647",
"https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002",
@@ -251,8 +246,6 @@
"https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e",
"https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5",
"https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513",
"https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0",
"https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8",
"https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8",
"https://bcr.bazel.build/modules/rules_cc/0.2.14/source.json": "55d0a4587c5592fad350f6e698530f4faf0e7dd15e69d43f8d87e220c78bea54",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.10.1/MODULE.bazel": "b9527010e5fef060af92b6724edb3691970a5b1f76f74b21d39f7d433641be60",
@@ -270,8 +263,8 @@
"https://bcr.bazel.build/modules/rules_go/0.48.0/MODULE.bazel": "d00ebcae0908ee3f5e6d53f68677a303d6d59a77beef879598700049c3980a03",
"https://bcr.bazel.build/modules/rules_go/0.50.1/MODULE.bazel": "b91a308dc5782bb0a8021ad4330c81fea5bda77f96b9e4c117b9b9c8f6665ee0",
"https://bcr.bazel.build/modules/rules_go/0.53.0/MODULE.bazel": "a4ed760d3ac0dbc0d7b967631a9a3fd9100d28f7d9fcf214b4df87d4bfff5f9a",
"https://bcr.bazel.build/modules/rules_go/0.59.0/MODULE.bazel": "b7e43e7414a3139a7547d1b4909b29085fbe5182b6c58cbe1ed4c6272815aeae",
"https://bcr.bazel.build/modules/rules_go/0.59.0/source.json": "1df17bb7865cfc029492c30163cee891d0dd8658ea0d5bfdf252c4b6db5c1ef6",
"https://bcr.bazel.build/modules/rules_go/0.56.1/MODULE.bazel": "d5b835c548ac917345f1780cd2da52edc1130a908fe091c92096895303ae78a0",
"https://bcr.bazel.build/modules/rules_go/0.56.1/source.json": "0c902f7272e8d4e47e459af97be472bc19dadbbe6023a0719d1adce8483ac75a",
"https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74",
"https://bcr.bazel.build/modules/rules_java/5.1.0/MODULE.bazel": "324b6478b0343a3ce7a9add8586ad75d24076d6d43d2f622990b9c1cfd8a1b15",
"https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86",
@@ -299,8 +292,7 @@
"https://bcr.bazel.build/modules/rules_jvm_external/6.0/MODULE.bazel": "37c93a5a78d32e895d52f86a8d0416176e915daabd029ccb5594db422e87c495",
"https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4",
"https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0",
"https://bcr.bazel.build/modules/rules_jvm_external/6.9/MODULE.bazel": "07c5db05527db7744a54fcffd653e1550d40e0540207a7f7e6d0a4de5bef8274",
"https://bcr.bazel.build/modules/rules_jvm_external/6.9/source.json": "b12970214f3cc144b26610caeb101fa622d910f1ab3d98f0bae1058edbd00bd4",
"https://bcr.bazel.build/modules/rules_jvm_external/6.3/source.json": "6f5f5a5a4419ae4e37c35a5bb0a6ae657ed40b7abc5a5189111b47fcebe43197",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5",
@@ -314,12 +306,12 @@
"https://bcr.bazel.build/modules/rules_nodejs/6.3.0/MODULE.bazel": "45345e4aba35dd6e4701c1eebf5a4e67af4ed708def9ebcdc6027585b34ee52d",
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/MODULE.bazel": "b66eadebd10f1f1b25f52f95ab5213a57e82c37c3f656fcd9a57ad04d2264ce7",
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/source.json": "45bd343155bdfed2543f0e39b80ff3f6840efc31975da4b5795797f4c94147ad",
"https://bcr.bazel.build/modules/rules_oci/2.2.7/MODULE.bazel": "f6150e4b224d459f7f6523ef65967464ca4efdd266c7fbf2f5a2a51011957e0c",
"https://bcr.bazel.build/modules/rules_oci/2.2.7/source.json": "b099f02af330f47f19dc67fc9300ef6e1937a8c86882690db0e7a2fcea8c7f6b",
"https://bcr.bazel.build/modules/rules_oci/2.2.6/MODULE.bazel": "2ba6ddd679269e00aeffe9ca04faa2d0ca4129650982c9246d0d459fe2da47d9",
"https://bcr.bazel.build/modules/rules_oci/2.2.6/source.json": "94e7decb8f95d9465b0bbea71c65064cd16083be1350c7468f131818641dc4a5",
"https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc",
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff",
"https://bcr.bazel.build/modules/rules_pkg/1.2.0/MODULE.bazel": "c7db3c2b407e673c7a39e3625dc05dc9f12d6682cbd82a3a5924a13b491eda7e",
"https://bcr.bazel.build/modules/rules_pkg/1.2.0/source.json": "9062e00845bf91a4247465d371baa837adf9b6ff44c542f73ba084f07667e1dc",
"https://bcr.bazel.build/modules/rules_pkg/1.1.0/MODULE.bazel": "9db8031e71b6ef32d1846106e10dd0ee2deac042bd9a2de22b4761b0c3036453",
"https://bcr.bazel.build/modules/rules_pkg/1.1.0/source.json": "fef768df13a92ce6067e1cd0cdc47560dace01354f1d921cfb1d632511f7d608",
"https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06",
"https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7",
"https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483",
@@ -342,8 +334,7 @@
"https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c",
"https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7",
"https://bcr.bazel.build/modules/rules_python/1.0.0/MODULE.bazel": "898a3d999c22caa585eb062b600f88654bf92efb204fa346fb55f6f8edffca43",
"https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13",
"https://bcr.bazel.build/modules/rules_python/1.3.0/source.json": "25932f917cd279c7baefa6cb1d3fa8750a7a29de522024449b19af6eab51f4a0",
"https://bcr.bazel.build/modules/rules_python/1.0.0/source.json": "b0162a65c6312e45e7912e39abd1a7f8856c2c7e41ecc9b6dc688a6f6400a917",
"https://bcr.bazel.build/modules/rules_rust/0.45.1/MODULE.bazel": "a69d0db3a958fab2c6520961e1b2287afcc8b36690fd31bbc4f6f7391397150d",
"https://bcr.bazel.build/modules/rules_scala/7.1.1/MODULE.bazel": "b1f80c52ae49b27d41b9291d8b328b69247de2b7596d35d09afe6147b82cf562",
"https://bcr.bazel.build/modules/rules_scala/7.1.1/source.json": "5038cb231d4020c5965c920681cf961a7bf137b40315025e40f3a7b6a0ac1f0f",
@@ -354,8 +345,8 @@
"https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca",
"https://bcr.bazel.build/modules/rules_swift/1.18.0/MODULE.bazel": "a6aba73625d0dc64c7b4a1e831549b6e375fbddb9d2dde9d80c9de6ec45b24c9",
"https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046",
"https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd",
"https://bcr.bazel.build/modules/rules_swift/2.4.0/source.json": "a6577f57f9febbdc015a01f2a8f3487422032f134d6c61d18ed8e8ca3b9acc7c",
"https://bcr.bazel.build/modules/rules_swift/2.3.1/MODULE.bazel": "0b42093600d9226bcbdb31fb86d25d4204293d716fdbb2e50a1852547032a660",
"https://bcr.bazel.build/modules/rules_swift/2.3.1/source.json": "87d28609c37d2061db2f6fc3aae8ab7fbda9adf556cd88fbd0c7d520b8d81391",
"https://bcr.bazel.build/modules/stardoc/0.5.0/MODULE.bazel": "f9f1f46ba8d9c3362648eea571c6f9100680efc44913618811b58cc9c02cd678",
"https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8",
"https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c",
@@ -369,7 +360,6 @@
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/source.json": "32bd87e5f4d7acc57c5b2ff7c325ae3061d5e242c0c4c214ae87e0f1c13e54cb",
"https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468",
"https://bcr.bazel.build/modules/tar.bzl/0.5.1/MODULE.bazel": "7c2eb3dcfc53b0f3d6f9acdfd911ca803eaf92aadf54f8ca6e4c1f3aee288351",
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/MODULE.bazel": "a3584b4edcfafcabd9b0ef9819808f05b372957bbdff41601429d5fd0aac2e7c",
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/source.json": "4a620381df075a16cb3a7ed57bd1d05f7480222394c64a20fa51bdb636fda658",
"https://bcr.bazel.build/modules/toolchains_llvm/1.6.0/MODULE.bazel": "39603859cafb1c6830160fcd6370552e836790e6abb2bfb8d13bff53c0c10a64",
@@ -396,7 +386,7 @@
"@@apple_support~//crosstool:setup.bzl%apple_cc_configure_extension": {
"general": {
"bzlTransitiveDigest": "Z3yAd66IJL0GAZUTSeMOjoHiE1SZPPwiIs/XQui5BvE=",
"usagesDigest": "tl3VVeQX3Hzh7FhM2gjnkCwEJpRMlY5S6a850WY/xvc=",
"usagesDigest": "TOb4CUri5UsTKxgIDTNzR0ddIc21eYLCRIm+jqQmjlg=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -423,7 +413,7 @@
},
"@@aspect_rules_esbuild~//esbuild:extensions.bzl%esbuild": {
"general": {
"bzlTransitiveDigest": "8L5Llfl6uxIWXd5GR+Qmmm04/jxp6TuJH5LFhIZIUCA=",
"bzlTransitiveDigest": "8jv3p0xDR/oitFeH8y0+Y5xlyrUbfsTRlc9TSwYkwl8=",
"usagesDigest": "iDVoyPxUeADmfK8ssoyG3Ehq1bj6p7A43LpEiE266os=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
@@ -502,7 +492,6 @@
"extra_build_content": "",
"generate_bzl_library_targets": false,
"extract_full_archive": false,
"exclude_package_contents": [],
"system_tar": "auto"
}
},
@@ -527,17 +516,11 @@
"package_visibility": [
"//visibility:public"
],
"replace_package": "",
"exclude_package_contents": []
"replace_package": ""
}
}
},
"recordedRepoMappingEntries": [
[
"aspect_bazel_lib~",
"bazel_lib",
"bazel_lib~"
],
[
"aspect_bazel_lib~",
"bazel_skylib",
@@ -548,11 +531,6 @@
"bazel_tools",
"bazel_tools"
],
[
"aspect_bazel_lib~",
"tar.bzl",
"tar.bzl~"
],
[
"aspect_rules_esbuild~",
"aspect_rules_js",
@@ -568,11 +546,6 @@
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"aspect_rules_js~",
"aspect_rules_js",
"aspect_rules_js~"
],
[
"aspect_rules_js~",
"bazel_skylib",
@@ -582,31 +555,6 @@
"aspect_rules_js~",
"bazel_tools",
"bazel_tools"
],
[
"bazel_lib~",
"bazel_skylib",
"bazel_skylib~"
],
[
"bazel_lib~",
"bazel_tools",
"bazel_tools"
],
[
"tar.bzl~",
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"tar.bzl~",
"bazel_skylib",
"bazel_skylib~"
],
[
"tar.bzl~",
"tar.bzl",
"tar.bzl~"
]
]
}
@@ -1214,7 +1162,7 @@
"@@rules_nodejs~//nodejs:extensions.bzl%node": {
"general": {
"bzlTransitiveDigest": "q44Ox2Nwogn6OsO0Xw5lhjkd/xmxkvvpwVOn5P4pmHQ=",
"usagesDigest": "Py5Wgc5kr5fTMe1FKrlFK276B6SodesXp6nw2Fq5XA8=",
"usagesDigest": "WQpLKLujnBfrx9sMWCJgyaK9P04binseT6CGBy3vP4E=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -1344,8 +1292,8 @@
},
"@@rules_oci~//oci:extensions.bzl%oci": {
"general": {
"bzlTransitiveDigest": "AOLP47LtVHSKSDiukosQymx543OwcgeoQP666wwuj3o=",
"usagesDigest": "3Xsv1/UEV8MOARW4BZScPw3Gxtx19OQ5EXOqcL1p9bI=",
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
"usagesDigest": "39yHQmifPoGf+JSYpnQSnJGugxbrFVge/+ENmUiqZ6M=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -1632,43 +1580,6 @@
]
}
},
"@@rules_python~//python/uv:uv.bzl%uv": {
"general": {
"bzlTransitiveDigest": "Xpqjnjzy6zZ90Es9Wa888ZLHhn7IsNGbph/e6qoxzw8=",
"usagesDigest": "qI5PVlIum/YAnGJg5oXGHzDkMFWt2aNSUZY4G8PBbic=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"uv": {
"bzlFile": "@@rules_python~//python/uv/private:uv_toolchains_repo.bzl",
"ruleClassName": "uv_toolchains_repo",
"attributes": {
"toolchain_type": "'@@rules_python~//python/uv:uv_toolchain_type'",
"toolchain_names": [
"none"
],
"toolchain_implementations": {
"none": "'@@rules_python~//python:none'"
},
"toolchain_compatible_with": {
"none": [
"@platforms//:incompatible"
]
},
"toolchain_target_settings": {}
}
}
},
"recordedRepoMappingEntries": [
[
"rules_python~",
"platforms",
"platforms"
]
]
}
},
"@@rules_scala~//scala/extensions:config.bzl%scala_config": {
"general": {
"bzlTransitiveDigest": "TdBxhkZTM7VU6teIFS+KoonKU7wmb5BL7leCWWx7yX8=",
@@ -5145,7 +5056,7 @@
"@@rules_swift~//swift:extensions.bzl%non_module_deps": {
"general": {
"bzlTransitiveDigest": "PAIMhc1bVKfcyoHeg0xO8LMS9KN5yzbsMGwa5O2ifJM=",
"usagesDigest": "l2vIL7SL4tJqHIVLWd78Y/ym+r0II64lmvoX8o+0Bb0=",
"usagesDigest": "A3fzk5iHsrLdI3PokT1bHIdeJ2j9tc09H3/3Old6IfU=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
set -euxo pipefail
. ./ci/unity_version.sh
WORKSPACE=$(pwd)
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
BUILD_DIR=$1
LOG_PATH=$2
echo "Building Mac in $BUILD_DIR"
echo "Cleaning up $BUILD_DIR"
/bin/rm -rf "$BUILD_DIR"
/bin/mkdir -p "$BUILD_DIR"
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-nographics \
-batchmode \
-quit \
-buildOSXUniversalPlayer "$BUILD_DIR/eagle0.app" \
-logFile "$LOG_PATH" \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
-19
View File
@@ -1,19 +0,0 @@
#!/bin/bash
set -euxo pipefail
COMMIT=$(/usr/bin/git rev-parse --short HEAD)
/bin/echo "build protos"
./scripts/build_protos.sh
/bin/echo "build Mac plugin"
./scripts/build_mac_plugin.sh
git log -3
/bin/echo "build Mac"
LOG_PATH="/tmp/eagle0/editor_mac.log"
BUILD_DIR=$1
./ci/github_actions/build_mac.sh "$BUILD_DIR" "$LOG_PATH"
+2 -16
View File
@@ -1,20 +1,6 @@
#!/bin/bash
set -uxo pipefail
set -euxo pipefail
/bin/echo "persist Library/"
# rsync may exit with code 23 ("partial transfer due to error") if Unity's
# temporary files vanish during the copy. This is acceptable for a cache.
/usr/bin/rsync -rtlDvq src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ /tmp/eagle0/Library/
rsync_exit=$?
if [ $rsync_exit -eq 0 ]; then
exit 0
elif [ $rsync_exit -eq 23 ]; then
echo "Warning: rsync exited with 23 (some files vanished during copy). This is expected for Unity temp files."
exit 0
else
echo "Error: rsync failed with exit code $rsync_exit"
exit $rsync_exit
fi
/usr/bin/rsync -rtlDvq src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ /tmp/eagle0/Library/
-18
View File
@@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Allow JIT compilation (required for Mono/IL2CPP) -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- Allow unsigned executable memory (required for Unity) -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Disable library validation (required for plugins) -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Allow outgoing network connections -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
-40
View File
@@ -1,40 +0,0 @@
# Environment template for production deployment
# This file defines all env vars used by docker-compose.prod.yml
# Workflows should update their specific vars without overwriting others
# Container images (managed by respective build workflows)
# Note: Shardok runs on Hetzner, deployed via shardok_arm64_build.yml
EAGLE_IMAGE=registry.digitalocean.com/eagle0/eagle-server:latest
ADMIN_IMAGE=registry.digitalocean.com/eagle0/admin-server:latest
JFR_SIDECAR_IMAGE=registry.digitalocean.com/eagle0/jfr-sidecar:latest
AUTH_IMAGE=registry.digitalocean.com/eagle0/auth-server:latest
# OpenAI / LLM
OPENAI_API_KEY=
GPT_MODEL_NAME=gpt-4o
# DigitalOcean Spaces (S3-compatible storage)
EAGLE_ENABLE_S3=false
DO_SPACES_ACCESS_KEY=
DO_SPACES_SECRET_KEY=
# JWT authentication
JWT_PRIVATE_KEY=
# OAuth providers
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Shardok connection (Hetzner ARM64 server)
SHARDOK_ADDRESS=
SHARDOK_AUTH_TOKEN=
# Monitoring
SENTRY_DSN=
# Email (Fastmail JMAP)
FASTMAIL_API_TOKEN=
FASTMAIL_FROM_EMAIL=
FASTMAIL_FROM_NAME=
-59
View File
@@ -1,59 +0,0 @@
#!/bin/bash
# Update .env file without losing other variables
# Usage: ./update-env.sh KEY1=value1 KEY2=value2 ...
#
# This script:
# 1. Creates .env from template if it doesn't exist
# 2. Updates only the specified KEY=value pairs
# 3. Preserves all other existing values
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="${ENV_FILE:-/opt/eagle0/.env}"
TEMPLATE_FILE="${TEMPLATE_FILE:-$SCRIPT_DIR/env.template}"
# Create .env from template if it doesn't exist
if [ ! -f "$ENV_FILE" ]; then
if [ -f "$TEMPLATE_FILE" ]; then
echo "Creating .env from template..."
grep -v '^#' "$TEMPLATE_FILE" | grep -v '^$' > "$ENV_FILE"
else
echo "Creating empty .env..."
touch "$ENV_FILE"
fi
chmod 600 "$ENV_FILE"
fi
# Process each KEY=VALUE argument
for arg in "$@"; do
# Skip empty args
[ -z "$arg" ] && continue
# Parse KEY=VALUE
KEY="${arg%%=*}"
VALUE="${arg#*=}"
# Skip if no key
[ -z "$KEY" ] && continue
# Skip setting empty values (keeps existing value)
if [ -z "$VALUE" ]; then
echo "Skipping $KEY (empty value)"
continue
fi
# Remove existing line for this key and add new one
if grep -q "^${KEY}=" "$ENV_FILE" 2>/dev/null; then
# Key exists, update it
sed -i "s|^${KEY}=.*|${KEY}=${VALUE}|" "$ENV_FILE"
echo "Updated $KEY"
else
# Key doesn't exist, add it
echo "${KEY}=${VALUE}" >> "$ENV_FILE"
echo "Added $KEY"
fi
done
chmod 600 "$ENV_FILE"
echo "Done updating $ENV_FILE"
+32 -39
View File
@@ -1,13 +1,11 @@
# Docker Compose for production deployment
#
# Local testing:
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:auth_server_load
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load && bazel run //ci:auth_server_load
# Run: docker compose -f docker-compose.prod.yml up
#
# Production deployment:
# Run: docker compose -f docker-compose.prod.yml up -d
#
# Note: Shardok runs on Hetzner ARM64 server, deployed via shardok_arm64_build.yml workflow.
services:
# Blue-green deployment: eagle-blue is the primary (production) instance
@@ -35,7 +33,7 @@ services:
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
# Auth token for Shardok on Hetzner (required)
# Auth token for remote Shardok on Hetzner (only used when shardok address contains .eagle0.net)
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
# Use persistent volume for save data (users, games, etc.)
EAGLE_SAVE_DIR: "/app/saves"
@@ -49,6 +47,7 @@ services:
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
depends_on:
- shardok
- auth
restart: unless-stopped
logging:
@@ -93,10 +92,9 @@ services:
volumes:
- ./saves:/app/saves # Same save directory as blue
- ./archived:/app/archived # Same archive directory as blue
- ./jfr:/app/jfr # JFR recordings (same as blue)
- jvm-tmp:/tmp # Shared with jfr-sidecar-green for JVM attach socket files
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
depends_on:
- shardok
- auth
restart: "no" # Don't auto-restart during deployment
logging:
@@ -142,8 +140,6 @@ services:
FASTMAIL_API_TOKEN: "${FASTMAIL_API_TOKEN:-}"
FASTMAIL_FROM_EMAIL: "${FASTMAIL_FROM_EMAIL:-}"
FASTMAIL_FROM_NAME: "${FASTMAIL_FROM_NAME:-}"
# Require invitation codes for new user registration
REQUIRE_INVITATION_CODE: "true"
# Note: port 40033 is exposed via nginx, not directly
volumes:
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with Eagle
@@ -162,8 +158,30 @@ services:
retries: 3
start_period: 10s
# Note: Shardok runs on Hetzner ARM64 server, not in this docker-compose.
# Configure SHARDOK_ADDRESS to point to the Hetzner instance.
shardok:
image: ${SHARDOK_IMAGE:-registry.digitalocean.com/eagle0/shardok-server:latest}
container_name: shardok-server
mem_limit: 1g
memswap_limit: 1g # Prevent swap, OOM-kill cleanly instead
ports:
- "40042:40042"
- "40052:40052"
environment:
SHARDOK_RESOURCES_PATH: "/app/resources"
SHARDOK_MAPS_PATH: "/app/resources/maps"
SHARDOK_EAGLE_INTERFACE_ADDRESS: "0.0.0.0:40042"
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 40042 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
nginx:
image: nginx:alpine
@@ -178,9 +196,8 @@ services:
- ./certbot/www:/var/www/certbot:ro
- ./auth:/etc/nginx/auth:ro
depends_on:
- eagle-blue
- admin
# Note: nginx connects to eagle via EAGLE_ADDR (default: eagle-blue:40032)
# For blue-green deployments, update EAGLE_ADDR in .env before switching
restart: unless-stopped
logging:
driver: "json-file"
@@ -197,14 +214,14 @@ services:
- "--auth-addr"
- "auth:40033"
- "--jfr-sidecar-addr"
- "${JFR_SIDECAR_ADDR:-jfr-sidecar:8081}" # Can be switched for blue-green
- "jfr-sidecar:8081"
- "--http-port"
- "8080"
# No external port - accessed via nginx at admin.eagle0.net
depends_on:
- eagle-blue
- auth
# Note: admin connects to eagle via EAGLE_ADDR and jfr-sidecar via JFR_SIDECAR_ADDR
# For blue-green deployments, set both in .env before switching
- jfr-sidecar
restart: unless-stopped
logging:
driver: "json-file"
@@ -222,7 +239,6 @@ services:
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
container_name: jfr-sidecar
# Share PID namespace with Eagle to access its JVM via jcmd
# For blue-green: use JFR_SIDECAR_ADDR=jfr-sidecar-green:8081 when green is active
pid: "service:eagle-blue"
volumes:
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
@@ -241,29 +257,6 @@ services:
retries: 3
start_period: 10s
jfr-sidecar-green:
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
container_name: jfr-sidecar-green
profiles: ["blue-green"] # Only started during blue-green deployment
# Share PID namespace with Eagle green instance
pid: "service:eagle-green"
volumes:
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
depends_on:
- eagle-green
restart: "no" # Don't auto-restart during deployment
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "2"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8081/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
certbot:
image: certbot/certbot
container_name: certbot
-181
View File
@@ -1,181 +0,0 @@
# Tutorial Content Guide
This document defines all tutorial content. Edit this to refine the text, then update `TutorialContentDefinitions.cs` to match.
---
## Onboarding Sequence
Shown to first-time players. Guides them through the basics of strategic and tactical gameplay.
| Step | ID | Display | Trigger | Title | Description |
|------|-----|---------|---------|-------|-------------|
| 1 | `welcome` | Modal | Auto (game start) | Welcome to Eagle0 | Command your armies, recruit heroes, and conquer provinces in this turn-based strategy game.<br><br>Let's walk through the basics! |
| 2 | `select_province` | Overlay | Completes on: `province_selected` | The Strategic Map | This is your kingdom. Each colored region is a province.<br><br>Tap a province you control (shown in your color) to see what you can do there. |
| 3 | `province_panel` | Modal | Button click | Province Information | This panel shows province details: its name, terrain, any armies present, and the commands available to you.<br><br>Commands let you move troops, recruit heroes, and more. |
| 4 | `try_march` | Overlay | Completes on: `command_issued` | Issue a Command | Try issuing a March command to move your army to an adjacent province.<br><br>Select a destination and confirm the order. |
| 5 | `turn_cycle` | Modal | Button click | The Turn Cycle | Eagle0 uses simultaneous turns. All players give orders at the same time, then turns resolve together.<br><br>When all players are ready, the server processes everyone's commands and shows the results. |
| 6 | `wait_for_battle` | Hidden | Completes on: `first_battle_available` | *(none)* | *(Invisible step - waits for a battle to become available)* |
| 7 | `battle_intro` | Modal | Button click | Battle Time! | When armies collide, you'll fight tactical battles on a hex grid.<br><br>You command individual units - infantry, cavalry, archers, and heroes with special abilities. |
| 8 | `enter_battle` | Overlay | Completes on: `battle_entered` | Enter the Battle | Tap the Battle button to enter tactical combat. |
| 9 | `tactical_overview` | Modal | Button click | Tactical Combat | Each unit has movement points and attack power. Position your troops wisely!<br><br>Units attack adjacent enemies. Flanking (attacking from multiple sides) deals bonus damage. |
| 10 | `move_unit` | Overlay | Completes on: `battle_action` | Move Your Units | Tap one of your units to select it, then tap a highlighted hex to move there.<br><br>Blue hexes show where you can move. |
| 11 | `attack_enemy` | Overlay | Completes on: `battle_action` | Attack! | Move next to an enemy unit, then tap the enemy to attack.<br><br>Red highlights show valid attack targets. |
| 12 | `end_turn` | Overlay | Completes on: `turn_ended` | End Your Turn | When you've moved all units or want to pass, tap End Turn.<br><br>The enemy will then take their turn. |
| 13 | `complete` | Modal | Button click (no skip) | You're Ready! | You now know the basics of Eagle0!<br><br>Explore diplomacy, recruit powerful heroes, and conquer the realm. Good luck, commander! |
### Notes on Onboarding Flow
- Steps 1-5 cover strategic gameplay
- Step 6 is invisible - just waits for a battle
- Steps 7-12 cover tactical combat
- Step 13 celebrates completion
**Questions to consider:**
- Should we skip tactical tutorial if player skips to first battle themselves?
- Should there be a "skip all" option visible from step 1?
- Is the step order correct for typical first-game flow?
---
## Strategic Contextual Tutorials
Triggered when players encounter features for the first time.
### Diplomacy Introduction
| Field | Value |
|-------|-------|
| ID | `diplomacy_intro` |
| Trigger | `diplomacy_available` (diplomacy commands appear) |
| Display | Modal |
| Title | Diplomacy |
| Description | You can negotiate with other factions!<br><br>Offer alliances, declare war, or propose tribute. Your diplomatic choices shape the realm. |
### Hero Recruitment
| Field | Value |
|-------|-------|
| ID | `hero_recruitment` |
| Trigger | `hero_recruitment_available` (free heroes detected) |
| Display | Modal |
| Title | Heroes Available |
| Description | Free heroes wander the realm seeking a lord to serve.<br><br>Recruit them to lead your armies! Heroes have unique abilities and grow stronger with experience. |
### Weather Control
| Field | Value |
|-------|-------|
| ID | `weather_control` |
| Trigger | `weather_control_available` (weather command appears) |
| Display | Overlay |
| Title | Weather Magic |
| Description | Your mages can influence the weather!<br><br>Rain slows movement, storms disrupt enemies, and clear skies speed your march. |
### Prisoner Management
| Field | Value |
|-------|-------|
| ID | `prisoner_management` |
| Trigger | `prisoner_command_issued` (player uses prisoner command) |
| Display | Modal |
| Title | Prisoners Captured |
| Description | You've captured enemy soldiers!<br><br>You can ransom them for gold, recruit them into your army, or execute them as a warning. |
---
## Tactical Contextual Tutorials
Triggered during battles when players encounter spells, terrain, or abilities.
### Lightning Bolt Spell
| Field | Value |
|-------|-------|
| ID | `spell_lightning` |
| Trigger | `spell_lightning_available` |
| Display | Tooltip |
| Title | Lightning Bolt |
| Description | Your mage can cast Lightning Bolt!<br><br>This spell strikes a single target for heavy damage. Great for eliminating key enemy units. |
### Meteor Strike Spell
| Field | Value |
|-------|-------|
| ID | `spell_meteor` |
| Trigger | `spell_meteor_available` |
| Display | Modal |
| Title | Meteor Strike |
| Description | Meteor is a devastating area spell!<br><br>It takes a turn to cast: first select target, then it lands next turn. Plan ahead! |
### Holy Wave Spell
| Field | Value |
|-------|-------|
| ID | `spell_holywave` |
| Trigger | `spell_holywave_available` |
| Display | Tooltip |
| Title | Holy Wave |
| Description | Holy Wave heals your units and damages undead!<br><br>Position your troops carefully to maximize its effect. |
### Raise Dead Spell
| Field | Value |
|-------|-------|
| ID | `spell_raisedead` |
| Trigger | `spell_raisedead_available` |
| Display | Modal |
| Title | Raise Dead |
| Description | Dark magic can raise fallen soldiers as undead!<br><br>They fight for you, but beware - they may crumble if your necromancer falls. |
### Fire Terrain
| Field | Value |
|-------|-------|
| ID | `terrain_fire` |
| Trigger | `terrain_fire_encountered` (fire damage occurs) |
| Display | Tooltip |
| Title | Fire Hazard |
| Description | Fire spreads across the battlefield!<br><br>Units in burning hexes take damage. Use fire to block enemy routes or avoid it yourself. |
### Water Crossing
| Field | Value |
|-------|-------|
| ID | `terrain_water` |
| Trigger | `terrain_water_encountered` (water crossing attempted) |
| Display | Tooltip |
| Title | Water Crossing |
| Description | Units can cross shallow water, but it's risky.<br><br>Crossing takes extra movement and may fail. Some units swim better than others. |
### Cavalry Charge
| Field | Value |
|-------|-------|
| ID | `ability_charge` |
| Trigger | `ability_charge_available` |
| Display | Overlay |
| Title | Cavalry Charge |
| Description | Your cavalry can Charge!<br><br>Charging deals bonus damage based on distance traveled. Use open terrain for maximum impact. |
---
## Display Modes
| Mode | Description | Use For |
|------|-------------|---------|
| **Modal** | Full popup with dimmed background, blocks interaction | Important concepts, multi-paragraph explanations |
| **Overlay** | Semi-transparent overlay, can highlight UI elements | Guiding player to interact with specific UI |
| **Tooltip** | Small popup near target element | Quick tips, less important info |
| **Hint** | Pulsing dot indicator only | Subtle suggestions |
| **None** | Invisible, just waits for event | Transition steps |
---
## Adding New Tutorials
1. Add entry to this document
2. Update `TutorialContentDefinitions.cs`:
- For onboarding: add to `CreateOnboardingSequence()`
- For contextual: add to `RegisterStrategicTutorials()` or `RegisterTacticalTutorials()`
3. Ensure trigger event exists in `TutorialTriggerRegistry.cs`
4. Test the flow
---
## Content Guidelines
- Keep descriptions to 2-3 short paragraphs max
- Use `<br><br>` for paragraph breaks (renders as newlines in Unity)
- Avoid jargon - explain game terms when first introduced
- Be encouraging, not condescending
- Focus on "what to do" not exhaustive "how it works"
+11 -22
View File
@@ -3,9 +3,6 @@ events {
}
http {
# Allow large request bodies for game uploads (default is 1MB)
client_max_body_size 50M;
# Logging
log_format grpc_json escape=json '{'
'"time":"$time_iso8601",'
@@ -27,12 +24,12 @@ http {
# This prevents stale IP caching when containers restart
resolver 127.0.0.11 valid=10s ipv6=off;
# Eagle backend - blue-green deployment with variable-based routing
# Uses a variable so nginx only resolves the configured backend (not all backends).
# This allows nginx to start/reload even when the inactive backend is stopped.
# The deploy script updates this map, then recreates nginx.
map $host $eagle_backend {
default "eagle-blue:40032";
# Upstream for Eagle gRPC server
# For blue-green deployments, this is switched between eagle-blue:40032 and eagle-green:40032
# by scripts/deploy-blue-green.sh, then nginx is reloaded with 'nginx -s reload'
upstream eagle_grpc {
server eagle-blue:40032;
keepalive 100;
}
# HTTP server for Let's Encrypt challenge and redirect
@@ -76,8 +73,8 @@ http {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# gRPC proxy - uses variable for blue-green deployment
grpc_pass grpc://$eagle_backend;
# gRPC proxy
grpc_pass grpc://eagle_grpc;
# Timeouts for long-running streams
grpc_read_timeout 1200s;
@@ -88,14 +85,13 @@ http {
error_page 502 = /error502grpc;
}
# gRPC proxy for Auth service (routes to Go auth service, not Eagle)
# gRPC proxy for Auth service
location /net.eagle0.eagle.api.auth.Auth {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# Route to auth service directly (not through Eagle)
set $auth_backend "auth:40033";
grpc_pass grpc://$auth_backend;
# gRPC proxy
grpc_pass grpc://eagle_grpc;
# Timeouts
grpc_read_timeout 30s;
@@ -112,13 +108,6 @@ http {
proxy_set_header X-Real-IP $remote_addr;
}
# Invitation landing page (proxied to Go auth service)
location /invite/ {
proxy_pass http://auth:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Health check endpoint
location /health {
access_log off;
-88
View File
@@ -1,88 +0,0 @@
#!/usr/bin/env bash
#
# Code sign a macOS .app bundle for distribution
# Usage: codesign_mac_app.sh <app_path> [entitlements_path]
#
# Environment variables:
# SIGNING_IDENTITY - The signing identity (default: "Developer ID Application")
# KEYCHAIN_PASSWORD - Password to unlock the build keychain (optional)
set -euxo pipefail
APP_PATH="$1"
ENTITLEMENTS_PATH="${2:-}"
SIGNING_IDENTITY="${SIGNING_IDENTITY:-Developer ID Application}"
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH"
exit 1
fi
# Unlock keychain if password provided
if [ -n "${KEYCHAIN_PASSWORD:-}" ]; then
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain || true
fi
echo "=== Signing nested components first ==="
# Sign all dylibs
find "$APP_PATH" -name "*.dylib" -print0 | while IFS= read -r -d '' item; do
echo "Signing dylib: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign all bundles (plugins)
find "$APP_PATH" -name "*.bundle" -print0 | while IFS= read -r -d '' item; do
echo "Signing bundle: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign XPC services (inside Sparkle framework)
find "$APP_PATH" -name "*.xpc" -print0 | while IFS= read -r -d '' item; do
echo "Signing XPC service: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign nested apps (like Sparkle's Updater.app)
find "$APP_PATH" -path "*/Frameworks/*.app" -print0 | while IFS= read -r -d '' item; do
echo "Signing nested app: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign standalone executables inside frameworks (like Autoupdate)
find "$APP_PATH" -path "*/Frameworks/*/Versions/*/Autoupdate" -type f -print0 | while IFS= read -r -d '' item; do
echo "Signing executable: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign all frameworks (after their contents are signed)
find "$APP_PATH" -name "*.framework" -print0 | while IFS= read -r -d '' item; do
echo "Signing framework: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
echo "=== Signing main app bundle ==="
if [ -n "$ENTITLEMENTS_PATH" ] && [ -f "$ENTITLEMENTS_PATH" ]; then
echo "Using entitlements: $ENTITLEMENTS_PATH"
codesign --force --verify --verbose --timestamp --options runtime \
--entitlements "$ENTITLEMENTS_PATH" \
--sign "$SIGNING_IDENTITY" "$APP_PATH"
else
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$APP_PATH"
fi
echo "=== Verifying signature ==="
codesign --verify --verbose=4 "$APP_PATH"
echo "=== Checking Gatekeeper assessment ==="
spctl --assess --type exec -v "$APP_PATH" || echo "Note: Gatekeeper may reject until notarized"
echo "Code signing complete: $APP_PATH"
+59 -235
View File
@@ -2,21 +2,14 @@
#
# Blue-Green Deployment Script for Eagle Server
#
# This script performs a zero-downtime deployment with state consistency:
# 1. Create .deployment_in_progress marker (signals deployment started)
# 2. Start the staging instance (green) with new image
# 3. Run warmup/smoke tests against staging (warms JIT)
# 4. Switch nginx to staging (zero downtime - users immediately route to staging)
# 5. Stop the active instance (blue) - blocks until flush completes
# 6. Create .flush_complete marker (signals disk state is fresh)
#
# The flush marker coordination ensures green never serves stale game data:
# - When users reconnect to green and trigger lazy-load, the code checks for markers
# - If .deployment_in_progress exists, lazy-load WAITS for .flush_complete
# - Once blue's flush completes and marker is created, lazy-load proceeds with fresh data
#
# Key insight: nginx switches to green BEFORE blue stops, achieving zero downtime.
# Users who trigger lazy-load during blue's shutdown will wait for the flush marker.
# This script performs a zero-downtime deployment by:
# 1. Starting the new version on a staging port (green)
# 2. Waiting for it to become healthy
# 3. Running warmup traffic to pre-heat the JIT
# 4. Stopping the old version (blue) - which flushes state to disk
# 5. Telling green to reload games from disk
# 6. Switching nginx to route traffic to green
# 7. Cleaning up
#
# Usage: ./deploy-blue-green.sh [NEW_IMAGE_TAG]
#
@@ -32,9 +25,6 @@ APP_DIR="${APP_DIR:-/opt/eagle0}"
NGINX_CONF="${APP_DIR}/nginx/nginx.conf"
COMPOSE_FILE="${APP_DIR}/docker-compose.prod.yml"
WARMUP_SCRIPT="${SCRIPT_DIR}/warmup-eagle.sh"
SAVES_DIR="${APP_DIR}/saves"
FLUSH_MARKER="${SAVES_DIR}/.flush_complete"
DEPLOYMENT_IN_PROGRESS="${SAVES_DIR}/.deployment_in_progress"
# Colors for output
RED='\033[0;31m'
@@ -46,104 +36,18 @@ log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Marker file operations use docker exec because saves directory is owned by root (Docker).
# We run commands inside a container that has the saves directory mounted.
create_deployment_marker() {
local deploy_id=$1
local container=$2 # Container to use for file operations
docker exec "${container}" rm -f /app/saves/.flush_complete
docker exec "${container}" sh -c "echo '${deploy_id}' > /app/saves/.deployment_in_progress"
}
create_flush_marker() {
local deploy_id=$1
local container=$2 # Container to use for file operations
docker exec "${container}" sh -c "echo '${deploy_id}' > /app/saves/.flush_complete"
docker exec "${container}" rm -f /app/saves/.deployment_in_progress
}
cleanup_markers_on_failure() {
local container=$1 # Container to use for file operations
docker exec "${container}" rm -f /app/saves/.deployment_in_progress 2>/dev/null || true
docker exec "${container}" touch /app/saves/.flush_complete 2>/dev/null || true
}
remove_stale_deployment_marker() {
# Try any running eagle container
local container
container=$(docker ps --filter "name=eagle-" --format "{{.Names}}" | head -1)
if [ -n "${container}" ]; then
docker exec "${container}" rm -f /app/saves/.deployment_in_progress 2>/dev/null || true
fi
}
# Determine which instance is currently running (not from nginx config)
get_running_instance() {
local blue_running green_running
blue_running=$(docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null || echo "false")
green_running=$(docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null || echo "false")
if [ "$blue_running" = "true" ] && [ "$green_running" = "true" ]; then
# Both running - use nginx config to determine primary
if grep -q "server eagle-blue:40032;" "${NGINX_CONF}" | head -1 | grep -qv backup; then
echo "blue"
else
echo "green"
fi
elif [ "$blue_running" = "true" ]; then
# Determine which instance is currently active
get_active_instance() {
if grep -q "eagle-blue:40032" "${NGINX_CONF}"; then
echo "blue"
elif [ "$green_running" = "true" ]; then
elif grep -q "eagle-green:40032" "${NGINX_CONF}"; then
echo "green"
else
# Neither running - default to blue (first deploy or recovery)
echo "none"
log_error "Cannot determine active instance from nginx config"
exit 1
fi
}
# Pull image with retry using crane (handles OCI/Docker digest mismatch)
pull_with_retry() {
local image=$1
local max_attempts=${2:-3}
local attempt=1
# Skip pull if image already exists locally (e.g., CI already pulled it)
if docker image inspect "${image}" &>/dev/null; then
log_info "Image ${image} already exists locally, skipping pull"
return 0
fi
# Use crane if available (handles OCI format correctly)
if [ -x "${APP_DIR}/crane" ]; then
while [ $attempt -le $max_attempts ]; do
log_info "Pulling image with crane (attempt ${attempt}/${max_attempts})..."
if "${APP_DIR}/crane" pull "${image}" /tmp/image.tar && docker load -i /tmp/image.tar; then
rm -f /tmp/image.tar
log_info "Image pulled and loaded successfully"
return 0
fi
rm -f /tmp/image.tar
log_warn "Pull failed, retrying in 5 seconds..."
sleep 5
attempt=$((attempt + 1))
done
else
# Fallback to docker pull if crane not available
log_warn "crane not found at ${APP_DIR}/crane, falling back to docker pull"
while [ $attempt -le $max_attempts ]; do
log_info "Pulling image (attempt ${attempt}/${max_attempts})..."
if docker pull "${image}"; then
log_info "Image pulled successfully"
return 0
fi
log_warn "Pull failed, retrying in 5 seconds..."
sleep 5
attempt=$((attempt + 1))
done
fi
log_error "Failed to pull image after ${max_attempts} attempts"
return 1
}
# Wait for a container to be healthy
wait_for_healthy() {
local container=$1
@@ -172,63 +76,44 @@ main() {
local registry="registry.digitalocean.com/eagle0/eagle-server"
local new_image="${registry}:${new_tag}"
# Generate deployment ID for log correlation with server logs
local deploy_id
deploy_id=$(date +%s)
local deploy_start_time=$deploy_id
log_info "========================================="
log_info "Starting blue-green deployment"
log_info "Deployment ID: ${deploy_id}"
log_info "New image: ${new_image}"
log_info "========================================="
cd "${APP_DIR}"
# Determine current active instance (need this before creating marker)
local active=$(get_running_instance)
# Determine current active instance
local active=$(get_active_instance)
local staging
if [ "$active" = "blue" ] || [ "$active" = "none" ]; then
if [ "$active" = "blue" ]; then
staging="green"
active="blue" # Normalize "none" to "blue" for first deploy
else
staging="blue"
fi
# Step 1: Signal deployment in progress
log_info "[DEPLOY:${deploy_id}] Step 1: Signaling deployment in progress..."
# Use active container for marker operations (it's the one currently running)
if [ "$active" != "none" ] && docker ps --filter "name=eagle-${active}" --format "{{.Names}}" | grep -q .; then
create_deployment_marker "${deploy_id}" "eagle-${active}"
log_info "[DEPLOY:${deploy_id}] Deployment marker created via eagle-${active}"
else
log_warn "[DEPLOY:${deploy_id}] No running container to create marker (first deploy?)"
fi
log_info "Active instance: eagle-${active}"
log_info "Staging instance: eagle-${staging}"
# Pull the new image (with retry for intermittent registry issues)
if ! pull_with_retry "${new_image}" 3; then
log_error "Failed to pull new image, aborting deployment"
cleanup_markers_on_failure "eagle-${active}"
exit 1
fi
# Pull the new image
log_info "Pulling new image..."
docker pull "${new_image}"
# Step 2: Start staging instance with new image
log_info "Step 2: Starting eagle-${staging} with new image..."
# Start staging instance with new image
log_info "Starting eagle-${staging} with new image..."
if [ "$staging" = "green" ]; then
EAGLE_IMAGE_NEW="${new_image}" docker compose -f "${COMPOSE_FILE}" --profile blue-green up -d eagle-green jfr-sidecar-green
EAGLE_IMAGE_NEW="${new_image}" docker compose -f "${COMPOSE_FILE}" --profile blue-green up -d eagle-green
else
EAGLE_IMAGE="${new_image}" docker compose -f "${COMPOSE_FILE}" up -d eagle-blue jfr-sidecar
EAGLE_IMAGE="${new_image}" docker compose -f "${COMPOSE_FILE}" up -d eagle-blue
fi
# Wait for staging to be healthy
if ! wait_for_healthy "eagle-${staging}" 90; then
log_error "Staging instance failed health check, aborting deployment"
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
cleanup_markers_on_failure "eagle-${active}"
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
exit 1
fi
# Step 3: Run warmup/smoke test
# Run warmup/smoke test
local staging_port
if [ "$staging" = "green" ]; then
staging_port=40034
@@ -236,12 +121,12 @@ main() {
staging_port=40032
fi
log_info "Step 3: Running warmup against eagle-${staging}..."
log_info "Running warmup against eagle-${staging}..."
if [ -x "${WARMUP_SCRIPT}" ]; then
if ! "${WARMUP_SCRIPT}" "localhost:${staging_port}"; then
log_error "Warmup/smoke test failed, aborting deployment"
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
cleanup_markers_on_failure "eagle-${active}"
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
exit 1
fi
else
@@ -249,98 +134,49 @@ main() {
log_warn "JIT will be cold on first requests"
fi
# Step 4: Switch nginx to staging BEFORE stopping active
# This achieves zero downtime - users immediately route to staging.
# Any lazy-loads will wait for the flush marker (created in step 6).
local nginx_switch_start
nginx_switch_start=$(date +%s)
log_info "[DEPLOY:${deploy_id}] Step 4: Switching nginx to eagle-${staging}..."
# Stop the active instance (this flushes state to disk)
log_info "Stopping eagle-${active} (flushing state to disk)..."
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
# Update nginx config (variable-based routing)
# Tell staging to reload games from disk
log_info "Telling eagle-${staging} to reload games from disk..."
if command -v grpcurl &> /dev/null; then
grpcurl -plaintext -d '{}' "localhost:${staging_port}" net.eagle0.eagle.api.Eagle/ReloadGames || true
else
log_warn "grpcurl not installed, skipping game reload"
log_warn "New instance will use games loaded at startup"
fi
# Switch nginx upstream
log_info "Switching nginx upstream to eagle-${staging}..."
if [ "$staging" = "green" ]; then
sed -i.bak 's/eagle-blue:40032/eagle-green:40032/g' "${NGINX_CONF}"
else
sed -i.bak 's/eagle-green:40032/eagle-blue:40032/g' "${NGINX_CONF}"
fi
# Recreate nginx to pick up new config
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate nginx
# Verify nginx picked up the correct config
local nginx_backend
nginx_backend=$(docker exec nginx grep -o 'eagle-[a-z]*:40032' /etc/nginx/nginx.conf | head -1 || echo "unknown")
if [ "$nginx_backend" = "eagle-${staging}:40032" ]; then
log_info "[DEPLOY:${deploy_id}] Verified: nginx routing to eagle-${staging}"
else
log_error "[DEPLOY:${deploy_id}] nginx config mismatch! Expected eagle-${staging}:40032, got ${nginx_backend}"
exit 1
fi
log_info "[DEPLOY:${deploy_id}] Traffic switched to eagle-${staging} (lazy-loads will wait for flush marker)"
local nginx_switch_end
nginx_switch_end=$(date +%s)
# Step 5: Stop active instance (blocks until exit, ensuring flush completes)
# Users may be lazy-loading on staging during this time - they'll wait for the marker.
local flush_start
flush_start=$(date +%s)
log_info "[DEPLOY:${deploy_id}] Step 5: Stopping eagle-${active} (waiting for flush)..."
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
local flush_end
flush_end=$(date +%s)
local flush_duration=$((flush_end - flush_start))
log_info "[DEPLOY:${deploy_id}] eagle-${active} stopped, flush completed in ${flush_duration}s"
# Step 6: Create flush marker - signals that disk state is fresh
# Any waiting lazy-loads on staging will now proceed with fresh data.
# The Eagle server automatically detects the flush marker update and invalidates any stale cached games.
log_info "[DEPLOY:${deploy_id}] Step 6: Creating flush marker..."
create_flush_marker "${deploy_id}" "eagle-${staging}"
log_info "[DEPLOY:${deploy_id}] Flush marker created - server will auto-invalidate stale cache"
# Update .env for admin service
local env_file="${APP_DIR}/.env"
if [ "$staging" = "green" ]; then
log_info "Updating .env for green instance..."
sed -i.bak '/^EAGLE_ADDR=/d; /^JFR_SIDECAR_ADDR=/d' "${env_file}" 2>/dev/null || true
echo "EAGLE_ADDR=eagle-green:40032" >> "${env_file}"
echo "JFR_SIDECAR_ADDR=jfr-sidecar-green:8081" >> "${env_file}"
else
log_info "Updating .env for blue instance..."
sed -i.bak '/^EAGLE_ADDR=/d; /^JFR_SIDECAR_ADDR=/d' "${env_file}" 2>/dev/null || true
echo "EAGLE_ADDR=eagle-blue:40032" >> "${env_file}"
echo "JFR_SIDECAR_ADDR=jfr-sidecar:8081" >> "${env_file}"
fi
# Restart admin to pick up new .env
log_info "Restarting admin service..."
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate admin
# Reload nginx
log_info "Reloading nginx..."
docker compose -f "${COMPOSE_FILE}" exec nginx nginx -s reload
# Clean up old instance
log_info "Cleaning up old eagle-${active}..."
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}" 2>/dev/null || true
log_info "Removing old eagle-${active} container..."
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}"
# Stop the old jfr-sidecar (it can't attach to removed container anyway)
if [ "$active" = "green" ]; then
docker compose -f "${COMPOSE_FILE}" stop "jfr-sidecar-green" 2>/dev/null || true
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar-green" 2>/dev/null || true
# Update the staging instance's restart policy and image var
# For blue, we need to update EAGLE_IMAGE; for green, update EAGLE_IMAGE_NEW
if [ "$staging" = "blue" ]; then
log_info "Updating EAGLE_IMAGE to ${new_image} for future restarts"
# User should update their .env file
else
docker compose -f "${COMPOSE_FILE}" stop "jfr-sidecar" 2>/dev/null || true
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar" 2>/dev/null || true
log_info "Green is now active. Consider switching to blue on next deployment."
fi
local deploy_end_time
deploy_end_time=$(date +%s)
local total_duration=$((deploy_end_time - deploy_start_time))
local user_wait_window=$((flush_end - nginx_switch_end))
log_info "Deployment complete!"
log_info "Active instance: eagle-${staging}"
log_info ""
log_info "========================================="
log_info "[DEPLOY:${deploy_id}] Deployment complete!"
log_info " Active instance: eagle-${staging}"
log_info " Total duration: ${total_duration}s"
log_info " Flush duration: ${flush_duration}s"
log_info " Max user wait window: ${user_wait_window}s"
log_info "========================================="
log_info "Note: Update your .env file with EAGLE_IMAGE=${new_image}"
log_info " if you want future 'docker compose up' to use this version."
}
# Check for required tools
@@ -364,18 +200,6 @@ check_requirements() {
log_error "docker-compose file not found at ${COMPOSE_FILE}"
exit 1
fi
# Ensure saves directory exists
if [ ! -d "${SAVES_DIR}" ]; then
log_info "Creating saves directory at ${SAVES_DIR}"
mkdir -p "${SAVES_DIR}"
fi
# Clean up any stale deployment-in-progress marker from a previous failed deploy
if [ -f "${DEPLOYMENT_IN_PROGRESS}" ]; then
log_warn "Found stale deployment-in-progress marker, removing it"
remove_stale_deployment_marker
fi
}
# Run
-41
View File
@@ -1,41 +0,0 @@
// +build ignore
// Script to generate Ed25519 key pair for manifest signing.
// Run with: go run scripts/generate_manifest_keys.go
//
// This will output:
// - Private key (base64): Store as MANIFEST_SIGNING_KEY GitHub secret
// - Public key (base64): Embed in EagleInstaller for verification
package main
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"fmt"
"log"
)
func main() {
// Generate Ed25519 key pair
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
log.Fatalf("Failed to generate key pair: %v", err)
}
// Encode keys as base64
privateKeyB64 := base64.StdEncoding.EncodeToString(privateKey)
publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey)
fmt.Println("=== Ed25519 Key Pair for Manifest Signing ===")
fmt.Println()
fmt.Println("PRIVATE KEY (store as GitHub secret MANIFEST_SIGNING_KEY):")
fmt.Println(privateKeyB64)
fmt.Println()
fmt.Println("PUBLIC KEY (embed in EagleInstaller.cs for verification):")
fmt.Println(publicKeyB64)
fmt.Println()
fmt.Printf("Private key size: %d bytes\n", len(privateKey))
fmt.Printf("Public key size: %d bytes\n", len(publicKey))
}
-84
View File
@@ -1,84 +0,0 @@
#!/usr/bin/env bash
#
# Notarize a macOS .app bundle with Apple
# Usage: notarize_mac_app.sh <app_path>
#
# Environment variables (required):
# APPLE_ID - Apple Developer account email
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
# TEAM_ID - Apple Developer Team ID
set -euxo pipefail
APP_PATH="$1"
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH"
exit 1
fi
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
echo "ERROR: Required environment variables not set"
echo " APPLE_ID: ${APPLE_ID:-<not set>}"
echo " APP_SPECIFIC_PASSWORD: ${APP_SPECIFIC_PASSWORD:+<set>}"
echo " TEAM_ID: ${TEAM_ID:-<not set>}"
exit 1
fi
# Create ZIP for notarization submission
ZIP_PATH="${APP_PATH%.app}.zip"
echo "=== Creating ZIP for notarization: $ZIP_PATH ==="
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
echo "=== Submitting to Apple for notarization ==="
SUBMIT_OUTPUT=$(xcrun notarytool submit "$ZIP_PATH" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID" \
--wait 2>&1) || true
echo "$SUBMIT_OUTPUT"
# Extract submission ID and status (look for " status:" to avoid matching "Current status:")
SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | grep "id:" | head -1 | awk '{print $2}')
STATUS=$(echo "$SUBMIT_OUTPUT" | grep "^ status:" | awk '{print $2}')
echo "Submission ID: $SUBMISSION_ID"
echo "Status: $STATUS"
# Clean up the zip (use -f to avoid failure if already deleted)
rm -f "$ZIP_PATH"
if [ "$STATUS" != "Accepted" ]; then
echo "=== Notarization failed! Fetching log for details ==="
xcrun notarytool log "$SUBMISSION_ID" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID"
exit 1
fi
echo "=== Stapling notarization ticket to app ==="
# Retry stapling - Apple's CloudKit can have a brief delay after notarization completes
MAX_STAPLE_ATTEMPTS=5
STAPLE_ATTEMPT=1
while [ $STAPLE_ATTEMPT -le $MAX_STAPLE_ATTEMPTS ]; do
echo "Stapling attempt $STAPLE_ATTEMPT/$MAX_STAPLE_ATTEMPTS..."
if xcrun stapler staple "$APP_PATH"; then
echo "Stapling successful"
break
fi
if [ $STAPLE_ATTEMPT -eq $MAX_STAPLE_ATTEMPTS ]; then
echo "ERROR: Stapling failed after $MAX_STAPLE_ATTEMPTS attempts"
exit 1
fi
echo "Stapling failed, waiting 10 seconds before retry..."
sleep 10
STAPLE_ATTEMPT=$((STAPLE_ATTEMPT + 1))
done
echo "=== Verifying notarization ==="
xcrun stapler validate "$APP_PATH"
spctl --assess --type exec -v "$APP_PATH"
echo "Notarization complete: $APP_PATH"
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env bash
#
# Submit a macOS .app bundle to Apple for notarization (no waiting)
# Usage: notarize_submit.sh <app_path>
# Outputs: submission_id=<id> to stdout (for GitHub Actions)
#
# Environment variables (required):
# APPLE_ID - Apple Developer account email
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
# TEAM_ID - Apple Developer Team ID
set -euo pipefail
APP_PATH="$1"
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH" >&2
exit 1
fi
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
echo "ERROR: Required environment variables not set" >&2
echo " APPLE_ID: ${APPLE_ID:-<not set>}" >&2
echo " APP_SPECIFIC_PASSWORD: ${APP_SPECIFIC_PASSWORD:+<set>}" >&2
echo " TEAM_ID: ${TEAM_ID:-<not set>}" >&2
exit 1
fi
# Create ZIP for notarization submission
ZIP_PATH="${APP_PATH%.app}.zip"
echo "=== Creating ZIP for notarization: $ZIP_PATH ===" >&2
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
echo "=== Submitting to Apple for notarization ===" >&2
SUBMIT_OUTPUT=$(xcrun notarytool submit "$ZIP_PATH" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID" 2>&1)
echo "$SUBMIT_OUTPUT" >&2
# Extract submission ID
SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | grep "id:" | head -1 | awk '{print $2}')
if [ -z "$SUBMISSION_ID" ]; then
echo "ERROR: Failed to get submission ID" >&2
exit 1
fi
# Clean up the zip
rm "$ZIP_PATH"
echo "Submission ID: $SUBMISSION_ID" >&2
# Output for GitHub Actions
echo "submission_id=$SUBMISSION_ID"
-76
View File
@@ -1,76 +0,0 @@
#!/usr/bin/env bash
#
# Wait for Apple notarization to complete and staple the ticket
# Usage: notarize_wait.sh <submission_id> <app_path>
#
# Environment variables (required):
# APPLE_ID - Apple Developer account email
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
# TEAM_ID - Apple Developer Team ID
set -euo pipefail
SUBMISSION_ID="$1"
APP_PATH="$2"
if [ -z "$SUBMISSION_ID" ]; then
echo "ERROR: submission_id is required" >&2
exit 1
fi
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH" >&2
exit 1
fi
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
echo "ERROR: Required environment variables not set" >&2
exit 1
fi
echo "=== Waiting for notarization of submission $SUBMISSION_ID ==="
WAIT_OUTPUT=$(xcrun notarytool wait "$SUBMISSION_ID" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID" 2>&1) || true
echo "$WAIT_OUTPUT"
# Extract status (look for " status:" to avoid matching "Current status:")
STATUS=$(echo "$WAIT_OUTPUT" | grep "^ status:" | awk '{print $2}')
echo "Status: $STATUS"
if [ "$STATUS" != "Accepted" ]; then
echo "=== Notarization failed! Fetching log for details ==="
xcrun notarytool log "$SUBMISSION_ID" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID"
exit 1
fi
echo "=== Stapling notarization ticket to app ==="
# Retry stapling - Apple's CloudKit can have a brief delay after notarization completes
MAX_STAPLE_ATTEMPTS=5
STAPLE_ATTEMPT=1
while [ $STAPLE_ATTEMPT -le $MAX_STAPLE_ATTEMPTS ]; do
echo "Stapling attempt $STAPLE_ATTEMPT/$MAX_STAPLE_ATTEMPTS..."
if xcrun stapler staple "$APP_PATH"; then
echo "Stapling successful"
break
fi
if [ $STAPLE_ATTEMPT -eq $MAX_STAPLE_ATTEMPTS ]; then
echo "ERROR: Stapling failed after $MAX_STAPLE_ATTEMPTS attempts"
exit 1
fi
echo "Stapling failed, waiting 10 seconds before retry..."
sleep 10
STAPLE_ATTEMPT=$((STAPLE_ATTEMPT + 1))
done
echo "=== Verifying notarization ==="
xcrun stapler validate "$APP_PATH"
spctl --assess --type exec -v "$APP_PATH"
echo "Notarization complete: $APP_PATH"
+1 -2
View File
@@ -65,8 +65,7 @@ fi
# If we found the Go tool, use it
if [ -n "${WARMUP_TOOL}" ]; then
log_info "Using Go warmup tool: ${WARMUP_TOOL}"
# Use 5 minute timeout to allow for slow operations on cold JVM
if "${WARMUP_TOOL}" --address="${HOST}" --timeout=300s; then
if "${WARMUP_TOOL}" --address="${HOST}" --timeout=60s; then
log_info "Warmup complete!"
exit 0
else
@@ -107,23 +107,6 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
alCache,
battalionTypeGetter,
braveWaterCost));
} else if (!defenderPositions.empty()) {
// Defenders exist but none are on castles - they're scattering/fleeing.
// Chase them down rather than holding empty castles, since eliminating
// all defenders also wins the battle via LAST_PLAYER_STANDING.
chosenStrategy = AttackUnitsStrategy(GenerateTargetPriorities(
Occupants(
*gameState->units(),
gameState->hex_map()->row_count(),
gameState->hex_map()->column_count()),
gameState->hex_map(),
defenderPositions,
attackerPid,
attackerUnits,
apdCache,
alCache,
battalionTypeGetter,
braveWaterCost));
} else {
chosenStrategy = HoldCastlesStrategy;
}
@@ -20,9 +20,8 @@ auto UnitIdsRequiringWaterCrossing(
fb::HexMapW mapCopy = fb::CopyHexMap(gameState->hex_map());
for (uint32_t index = 0; index < mapCopy->terrain()->size(); index++) {
if (IsWater(mapCopy->terrain()->Get(index)->type())) continue;
// const_cast is safe because we own the mutable buffer (mapCopy)
const_cast<net::eagle0::shardok::storage::fb::Terrain *>(
mapCopy->mutable_terrain()->GetMutableObject(index))
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_fire()
.mutate_present(false);
@@ -165,11 +164,16 @@ auto WaterCrossingTiles(
if (modifier.ice().present() && !modifier.fire().present()) continue;
// Now try adding a bridge to the tile to see if it helps
// const_cast is safe because we own the mutable buffer (mapCopy)
auto *terr = const_cast<net::eagle0::shardok::storage::fb::Terrain *>(
mapCopy->mutable_terrain()->GetMutableObject(index));
terr->mutable_modifier().mutable_bridge().mutate_present(true);
terr->mutable_modifier().mutable_fire().mutate_present(false);
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_bridge()
.mutate_present(true);
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_fire()
.mutate_present(false);
auto hash = ActionPointDistancesCache::GetMapId(mapCopy);
@@ -179,7 +183,11 @@ auto WaterCrossingTiles(
}
// Undo the new bridge for the next iteration of the loop
terr->mutable_modifier().mutable_bridge().mutate_present(false);
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_bridge()
.mutate_present(false);
}
return returnCoords;
@@ -76,13 +76,11 @@ static auto CreateIceClearedMap(const HexMap* map) -> fb::HexMapW {
// Now modify the ice on the mutable copy
auto* mutableMap = mapCopy.Get();
auto* terrainVec = mutableMap->mutable_terrain();
const auto* terrainVec = mutableMap->mutable_terrain();
for (size_t i = 0; i < terrainVec->size(); i++) {
// Only process tiles with ice
// const_cast is safe here because we own the mutable buffer (mapCopy)
if (auto* terrain = const_cast<Terrain*>(terrainVec->GetMutableObject(i));
terrain->modifier().ice().present()) {
if (auto* terrain = terrainVec->GetMutableObject(i); terrain->modifier().ice().present()) {
terrain->mutable_modifier().mutable_ice().mutate_present(false);
terrain->mutable_modifier().mutable_ice().mutate_integrity(0.0f);
}
@@ -22,13 +22,6 @@ using std::unique_ptr;
using ResolvedUnitProto = net::eagle0::shardok::storage::ResolvedUnit;
using GameState = net::eagle0::shardok::storage::fb::GameState;
using GameStateT = net::eagle0::shardok::storage::fb::GameStateT;
using Unit = net::eagle0::shardok::storage::fb::Unit;
// Helper to get a mutable unit from the units vector.
// const_cast is safe because we're accessing through a mutable GameState pointer.
inline auto GetMutableUnit(GameState *state, UnitId unitId) -> Unit * {
return const_cast<Unit *>(state->mutable_units()->GetMutableObject(unitId));
}
using net::eagle0::shardok::storage::fb::DrawType;
using net::eagle0::shardok::storage::fb::VictoryCondition;
using net::eagle0::shardok::storage::fb::VictoryType;
@@ -44,7 +37,7 @@ void ApplyResolvedUnit(
if (unit.has_attached_hero() &&
unit.attached_hero().control_info().controlled_unit_id() != -1) {
const UnitId controlledUnitId = unit.attached_hero().control_info().controlled_unit_id();
auto *controlledUnit = GetMutableUnit(inoutState, controlledUnitId);
auto *controlledUnit = inoutState->mutable_units()->GetMutableObject(controlledUnitId);
internalAssert(controlledUnit->unit_id() == controlledUnitId);
internalAssert(controlledUnit->commanding_unit_id() == unitId);
controlledUnit->mutate_commanding_unit_id(-1);
@@ -54,7 +47,7 @@ void ApplyResolvedUnit(
if (unit.commanding_unit_id() != -1) {
const UnitId commandingUnitId = unit.commanding_unit_id();
auto *commandingUnit = GetMutableUnit(inoutState, commandingUnitId);
auto *commandingUnit = inoutState->mutable_units()->GetMutableObject(commandingUnitId);
internalAssert(commandingUnit->unit_id() == commandingUnitId);
internalAssert(
commandingUnit->attached_hero().control_info().controlled_unit_id() == unitId);
@@ -65,7 +58,7 @@ void ApplyResolvedUnit(
if (status == net::eagle0::shardok::storage::fb::UnitStatus_CAPTURED_UNIT &&
unit.has_attached_hero() && unit.attached_hero().is_vip()) {
for (uint32_t i = 0; i < inoutState->units()->size(); i++) {
auto *playerUnit = GetMutableUnit(inoutState, i);
auto *playerUnit = inoutState->mutable_units()->GetMutableObject(i);
if (playerUnit->player_id() != unit.player_id()) continue;
if (playerUnit->unit_id() == unit.unit_id()) continue;
@@ -77,7 +70,7 @@ void ApplyResolvedUnit(
}
}
GetMutableUnit(inoutState, unitId)->mutate_status(status);
inoutState->mutable_units()->GetMutableObject(unitId)->mutate_status(status);
}
void ApplyResolvedUnit(
@@ -196,7 +189,7 @@ void MutatingAddUnits(GameStateW &mutatingState, const ActionResultProto &result
// We only need to process the units that are being changed
for (const auto &unitBytes : result.changed_units_fb()) {
const auto *unit = (Unit *)unitBytes.data();
auto *mutableUnit = GetMutableUnit(mutatingState.Get(), unit->unit_id());
auto *mutableUnit = mutatingState->mutable_units()->GetMutableObject(unit->unit_id());
if (mutableUnit->status() ==
net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT) {
// Convert this reserved slot to a real unit
@@ -347,7 +340,7 @@ void MutatingApplyResult(
}
// Capture old position before applying changes
auto *mutableUnit = GetMutableUnit(mutatingGameState.Get(), changedUnit->unit_id());
auto *mutableUnit = mutatingGameState->units()->GetMutableObject(changedUnit->unit_id());
const auto oldLocation = mutableUnit->location();
fb::ApplyUnit(mutableUnit, changedUnit, status);
@@ -191,7 +191,6 @@ auto MoveCommand::GetCommandProto() const -> CommandProto {
proto.mutable_action_points()->set_value(pointCost);
proto.mutable_actor()->set_value(moverId);
*proto.mutable_target() = ToCoordsProto(interimTargets.back());
for (const auto& coords : interimTargets) { *proto.add_path() = ToCoordsProto(coords); }
for (const auto& fup : followUpCommandTypes) { proto.add_follow_up_command_types(fup); }
proto.set_will_unhide(willUnhide);
@@ -64,16 +64,8 @@ auto GetTerrain(const HexMap *map, const Coords &coords) -> const Terrain * {
return map->terrain()->Get(coords.row() * map->column_count() + coords.column());
}
auto GetMutableTerrain(HexMap *map, const Coords &coords) -> Terrain * {
// const_cast is safe because we're accessing through a mutable HexMap pointer
return const_cast<Terrain *>(map->mutable_terrain()->GetMutableObject(
coords.row() * map->column_count() + coords.column()));
}
auto GetMutableTerrain(const HexMap *map, const Coords &coords) -> Terrain * {
// const_cast is safe when the underlying buffer is known to be mutable
return const_cast<Terrain *>(
map->terrain()->Get(coords.row() * map->column_count() + coords.column()));
return map->terrain()->GetMutableObject(coords.row() * map->column_count() + coords.column());
}
auto HasForestAccess(
@@ -605,9 +597,7 @@ void MutatingSetTileModifier(
const int row,
const int column,
const TileModifierProto &TileModifierProto) {
// const_cast is safe because we're accessing through a mutable HexMap pointer
auto *terr = const_cast<Terrain *>(
hexMap->mutable_terrain()->GetMutableObject(row * hexMap->column_count() + column));
auto *terr = hexMap->mutable_terrain()->GetMutableObject(row * hexMap->column_count() + column);
if (TileModifierProto.has_bridge()) {
terr->mutable_modifier().mutable_bridge().mutate_present(true);
@@ -82,9 +82,6 @@ auto HasForestAccess(
PlayerId player) -> bool;
auto GetTerrain(const HexMap *map, const Coords &coords) -> const Terrain *;
auto GetMutableTerrain(HexMap *map, const Coords &coords) -> Terrain *;
// Overload for const HexMap - uses const_cast internally. Safe when the underlying buffer is
// mutable.
auto GetMutableTerrain(const HexMap *map, const Coords &coords) -> Terrain *;
auto CoordsAreValid(const HexMap *map, const Coords &coords) -> bool;
@@ -29,13 +29,6 @@ using UnitViewProto = net::eagle0::shardok::api::UnitView;
using Coords = net::eagle0::shardok::storage::fb::Coords;
using Unit = net::eagle0::shardok::storage::fb::Unit;
using GameState = net::eagle0::shardok::storage::fb::GameState;
// Helper to get a mutable unit from the units vector.
// const_cast is safe because we're accessing through a mutable GameState pointer.
inline auto GetMutableUnit(GameState *state, UnitId unitId) -> Unit * {
return const_cast<Unit *>(state->mutable_units()->GetMutableObject(unitId));
}
constexpr int8_t kGuessedHeroStat = 75;
constexpr int8_t kGuessedBattalionStat = 0;
@@ -419,13 +412,16 @@ auto GameStateGuesser::GuessedState(
if (unit->has_attached_hero()) {
UnitId controlledUnitId = unit->attached_hero().control_info().controlled_unit_id();
if (controlledUnitId != -1) {
GetMutableUnit(gsw.Get(), controlledUnitId)->mutate_commanding_unit_id(unitId);
gsw->mutable_units()
->GetMutableObject(controlledUnitId)
->mutate_commanding_unit_id(unitId);
}
}
UnitId commandingUnitId = unit->commanding_unit_id();
if (unit->commanding_unit_id() != -1) {
GetMutableUnit(gsw.Get(), commandingUnitId)
gsw->mutable_units()
->GetMutableObject(commandingUnitId)
->mutable_attached_hero()
.mutable_control_info()
.mutate_controlled_unit_id(unitId);
@@ -126,7 +126,6 @@
<Compile Include="Assets/Modern UI Pack/Scripts/Notification/NotificationManagerEditor.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicEditor.cs" />
<Compile Include="Assets/ConnectionHandler/RunningGameItem.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialCanvasBuilder.cs" />
<Compile Include="Assets/Shardok/Table Rows/ArmyRowController.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/PrisonerExchangeDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/Notification.cs" />
@@ -167,7 +166,6 @@
<Compile Include="Assets/Eagle/Table Rows/ExtraTroopsRowController.cs" />
<Compile Include="Assets/Auth/JwtAuthInterceptor.cs" />
<Compile Include="Assets/Eagle/CommandPanelController.cs" />
<Compile Include="Assets/Tutorial/Content/TutorialContentDefinitions.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ShatteredArmyDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/TurnHistoryPanelController.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/HeroDepartureDetailsNotificationGenerator.cs" />
@@ -208,7 +206,6 @@
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/RansomRejectedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/FactionsTableController.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/RangeSlider.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialOverlayBuilder.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ExileVassalCommandSelector.cs" />
<Compile Include="Assets/Tutorial/TutorialState.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicWithIconEditor.cs" />
@@ -388,7 +385,6 @@
<Compile Include="Assets/Eagle/Table Rows/IncomingArmyTableRow.cs" />
<Compile Include="Assets/ConnectionHandler/CreateGameItem.cs" />
<Compile Include="Assets/Eagle/EagleGameController.cs" />
<Compile Include="Assets/Tutorial/TutorialTestSetup.cs" />
<Compile Include="Assets/Eagle/Notifications/FailedSwearBrotherhoodNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/FireEffectAnimator.cs" />
<Compile Include="Assets/common/GUIUtils/EventBasedTable.cs" />
@@ -122,13 +122,8 @@ namespace Auth {
string localAppData =
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(localAppData, "eagle0");
#elif UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
// Mac: ~/Library/Application Support/eagle0
string appSupport =
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
return Path.Combine(appSupport, "eagle0");
#else
// Other platforms not yet supported
// Other platforms don't use the Windows installer
return null;
#endif
}
@@ -109,10 +109,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
[Header("Status Display")]
public TextMeshProUGUI connectionStatusText;
[Header("Connection Panel Environment")]
[Tooltip("Environment dropdown in connection panel (fallback if lobby unreachable)")]
public TMP_Dropdown connectionEnvironmentDropdown;
public GameObject connectionPanel;
public GameObject gameSelectionPanel;
public GameObject customBattlePanel;
@@ -221,9 +217,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Initialize OAuth UI
SetupOAuthUI();
// Initialize connection panel environment dropdown
SetupConnectionEnvironmentDropdown();
// Initialize Lobby UI (logout button, etc.)
SetupLobbyUI();
@@ -234,22 +227,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
TryRestoreSession();
}
private void SetupConnectionEnvironmentDropdown() {
if (connectionEnvironmentDropdown == null) return;
connectionEnvironmentDropdown.ClearOptions();
connectionEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
connectionEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
connectionEnvironmentDropdown.onValueChanged.AddListener(OnConnectionEnvironmentChanged);
}
private void OnConnectionEnvironmentChanged(int newEnvironmentIndex) {
// Just save the preference - it will be used on next connection attempt
PlayerPrefs.SetInt(EnvironmentKey, newEnvironmentIndex);
Debug.Log(
$"[ConnectionHandler] Environment changed to {EnvironmentDisplayNames[newEnvironmentIndex]}");
}
private void SetupOAuthUI() {
// Set up OAuth button click handlers for new sign-ins
if (discordLoginButton != null) {
@@ -287,15 +264,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Show OAuth panel
if (oauthPanel != null) oauthPanel.SetActive(true);
// Sync connection environment dropdown with saved preference
if (connectionEnvironmentDropdown != null) {
connectionEnvironmentDropdown.onValueChanged.RemoveListener(
OnConnectionEnvironmentChanged);
connectionEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
connectionEnvironmentDropdown.onValueChanged.AddListener(
OnConnectionEnvironmentChanged);
}
// Refresh stored account buttons
RefreshStoredAccountButtons();
}
@@ -714,10 +682,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
true);
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
var runningGameItem = listItem.GetComponent<RunningGameItem>();
runningGameItem.SetRunningGame(
runningGame.GameId,
runningGame.Leader,
runningGame.LastPlayedTimestampMillis);
runningGameItem.SetRunningGame(runningGame.GameId, runningGame.Leader);
runningGameItem.GoCallback = this.SelectEagleGame;
runningGameItem.DropCallback = this.DropGame;
}
@@ -1,164 +1,5 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &246340277068347999
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 5589171621630283209}
- component: {fileID: 9020655488278447847}
- component: {fileID: 5482253986454480250}
- component: {fileID: 6109349435209553009}
m_Layer: 5
m_Name: Last Played
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &5589171621630283209
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 246340277068347999}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 909271087739711220}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &9020655488278447847
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 246340277068347999}
m_CullTransparentMesh: 0
--- !u!114 &5482253986454480250
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 246340277068347999}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: Tars Tarkas
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
m_fontColor: {r: 1, g: 1, b: 1, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 24
m_fontSizeBase: 24
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 1
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 1
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!114 &6109349435209553009
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 246340277068347999}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 100
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &649403511955802327
GameObject:
m_ObjectHideFlags: 0
@@ -253,8 +94,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 24
m_fontSizeBase: 24
m_fontSize: 30
m_fontSizeBase: 30
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -312,9 +153,9 @@ MonoBehaviour:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 450
m_MinHeight: -1
m_MinHeight: 60
m_PreferredWidth: 450
m_PreferredHeight: -1
m_PreferredHeight: 60
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -412,8 +253,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 24
m_fontSizeBase: 24
m_fontSize: 36
m_fontSizeBase: 36
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -473,7 +314,7 @@ MonoBehaviour:
m_MinWidth: 200
m_MinHeight: 35
m_PreferredWidth: -1
m_PreferredHeight: -1
m_PreferredHeight: 35
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -680,10 +521,10 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 40
m_MinHeight: 40
m_PreferredWidth: 40
m_PreferredHeight: 40
m_MinWidth: 0
m_MinHeight: -1
m_PreferredWidth: 50
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -780,8 +621,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 24
m_fontSizeBase: 24
m_fontSize: 36
m_fontSizeBase: 36
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -1044,9 +885,9 @@ MonoBehaviour:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 200
m_MinHeight: 40
m_MinHeight: -1
m_PreferredWidth: 200
m_PreferredHeight: 40
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -1085,7 +926,6 @@ RectTransform:
m_Children:
- {fileID: 2008335870574158510}
- {fileID: 6674238761151365000}
- {fileID: 5589171621630283209}
- {fileID: 1348356180262758599}
- {fileID: 7336928442537586311}
- {fileID: 7341333336313446738}
@@ -1154,7 +994,7 @@ MonoBehaviour:
m_ChildAlignment: 0
m_Spacing: 0
m_ChildForceExpandWidth: 0
m_ChildForceExpandHeight: 0
m_ChildForceExpandHeight: 1
m_ChildControlWidth: 1
m_ChildControlHeight: 1
m_ChildScaleWidth: 0
@@ -1175,7 +1015,6 @@ MonoBehaviour:
item: {fileID: 5643565463360785033}
gameIdField: {fileID: 8058295588038032232}
leaderField: {fileID: 3311450659768657245}
lastPlayedField: {fileID: 5482253986454480250}
goButton: {fileID: 145214844678457394}
dropButton: {fileID: 4081637582368108930}
--- !u!114 &2208043217657811503
@@ -1194,9 +1033,9 @@ MonoBehaviour:
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: 45
m_PreferredHeight: -1
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_FlexibleHeight: 1
m_LayoutPriority: 1
--- !u!1 &7814378479006347708
GameObject:
@@ -9,7 +9,6 @@ public class RunningGameItem : MonoBehaviour {
public GameObject item;
public TextMeshProUGUI gameIdField;
public TextMeshProUGUI leaderField;
public TextMeshProUGUI lastPlayedField;
public Button goButton;
public Button dropButton;
@@ -25,8 +24,7 @@ public class RunningGameItem : MonoBehaviour {
public void DropClicked() { DropCallback?.Invoke(gameId); }
public void
SetRunningGame(long gameId, AvailableLeader leader, long lastPlayedTimestampMillis) {
public void SetRunningGame(long gameId, AvailableLeader leader) {
this.gameId = gameId;
gameIdField.text = string.Format("{0:X}", gameId);
@@ -36,31 +34,5 @@ public class RunningGameItem : MonoBehaviour {
"{0} ({1})",
leaderName,
DisplayNames.ProfessionNames[leader.Profession]);
// Display last played time in user's local timezone
if (lastPlayedField != null) {
if (lastPlayedTimestampMillis > 0) {
var lastPlayed = DateTimeOffset.FromUnixTimeMilliseconds(lastPlayedTimestampMillis)
.LocalDateTime;
var now = DateTime.Now;
var diff = now - lastPlayed;
string timeText;
if (diff.TotalMinutes < 1) {
timeText = "Just now";
} else if (diff.TotalHours < 1) {
timeText = $"{(int)diff.TotalMinutes}m ago";
} else if (diff.TotalDays < 1) {
timeText = $"{(int)diff.TotalHours}h ago";
} else if (diff.TotalDays < 7) {
timeText = $"{(int)diff.TotalDays}d ago";
} else {
timeText = lastPlayed.ToString("MMM d");
}
lastPlayedField.text = timeText;
} else {
lastPlayedField.text = "";
}
}
}
}
@@ -299,36 +299,6 @@ namespace eagle {
}
}
/// <summary>
/// Remove all pending commands for a specific game.
/// Called when the server confirms a command was processed (SUCCESS or BAD_TOKEN).
/// </summary>
private void RemovePendingCommandsForGame(long gameId) {
lock (this) {
var toRemove = _pendingCommands.Where(cmd => cmd.GameId == gameId).ToList();
foreach (var cmd in toRemove) { _pendingCommands.Remove(cmd); }
if (toRemove.Count > 0) {
_remoteEagleClientLogger.LogLine(
$"[POST] Removed {toRemove.Count} pending command(s) for game {gameId}");
}
}
}
/// <summary>
/// Refresh the subscription for a specific game to get fresh state from the server.
/// Used when a command is rejected (e.g., BAD_TOKEN) and we need current state.
/// </summary>
private void RefreshGameSubscription(long gameId) {
IClientConnectionSubscriber subscriber;
lock (this) { _subscribers.TryGetValue(gameId, out subscriber); }
if (subscriber != null) {
_ = StreamOneGameAsync(subscriber);
} else {
_remoteEagleClientLogger.LogLine(
$"[REFRESH] No subscriber found for game {gameId}");
}
}
public async Task Connect() {
// Prevent concurrent connection attempts
if (_isConnecting) {
@@ -498,13 +468,7 @@ namespace eagle {
await PostRequest(nextCommand);
} else if (eagleToken > providedToken) {
_remoteEagleClientLogger.LogLine(
$"{providedToken} seems to be stale (current token " +
$"{eagleToken}), dropping and refreshing state");
// Server already processed this command and advanced
// the token. Re-subscribe to ensure we have current
// state, especially if turn passed and we need new
// commands.
_ = StreamOneGameAsync(subscriber);
$"{providedToken} seems to be stale, dropping");
} else {
_remoteEagleClientLogger.LogLine(
$"{providedToken} seems to be from the future, adding back to the queue");
@@ -539,10 +503,7 @@ namespace eagle {
await PostRequest(nextCommand);
} else {
_remoteEagleClientLogger.LogLine(
$"Shardok token mismatch: pending={providedShardokToken} " +
$"current={currentShardokToken}, dropping and refreshing state");
// Server processed command, token advanced. Refresh state.
_ = StreamOneGameAsync(subscriber);
$"Shardok token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
}
break;
@@ -567,10 +528,7 @@ namespace eagle {
await PostRequest(nextCommand);
} else {
_remoteEagleClientLogger.LogLine(
$"Shardok placement token mismatch: pending={providedShardokToken} " +
$"current={currentShardokToken}, dropping and refreshing state");
// Server processed command, token advanced. Refresh state.
_ = StreamOneGameAsync(subscriber);
$"Shardok placement token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
}
break;
@@ -700,12 +658,11 @@ namespace eagle {
return true;
});
// IMPORTANT: Do NOT remove from pending here even if write succeeded.
// WriteAsync completing only means data was written to local buffers,
// not that the server received and processed it. The command stays in
// _pendingCommands until we receive PostCommandResponse SUCCESS or
// TryPendingCommands sees the token has advanced (command was processed).
if (!success) {
// Only remove from pending if successfully sent.
// If connection was dead, leave in queue for retry after reconnect.
if (success) {
lock (this) { _pendingCommands.Remove(request); }
} else {
_remoteEagleClientLogger.LogLine(
$"[POST] Command not sent (connection dead), keeping in pending queue for retry");
}
@@ -1116,41 +1073,6 @@ namespace eagle {
}
ackTcs?.TrySetResult(ack);
break;
case UpdateStreamResponse.ResponseDetailsOneofCase.PostCommandResponse:
var postResponse = current.PostCommandResponse;
if (postResponse.Status == PostCommandResponse.Types.Status.Error) {
_remoteEagleClientLogger.LogLine(
$"[POST] Server returned ERROR: {postResponse.ErrorMessage}");
LogConnectionEvent(
"post_command_error",
postResponse.ErrorMessage);
// Disconnect and let normal reconnect flow handle recovery
_streamingCall?.Dispose();
_streamingCall = null;
} else if (
postResponse.Status ==
PostCommandResponse.Types.Status.BadToken) {
_remoteEagleClientLogger.LogLine(
$"[POST] Server returned BAD_TOKEN for game {postResponse.GameId} " +
"- command rejected, refreshing state");
// Server rejected command due to stale token. Remove from
// pending (already processed) and re-subscribe to ensure we
// have current state.
RemovePendingCommandsForGame(postResponse.GameId);
RefreshGameSubscription(postResponse.GameId);
} else if (
postResponse.Status ==
PostCommandResponse.Types.Status.Success) {
_remoteEagleClientLogger.LogLine(
$"[POST] Server confirmed command for game {postResponse.GameId}");
// Command was successfully processed. Remove from pending.
RemovePendingCommandsForGame(postResponse.GameId);
}
// UNKNOWN is benign (old servers that don't set status)
break;
}
File diff suppressed because it is too large Load Diff
@@ -3,7 +3,6 @@ using System.Diagnostics;
using System.IO;
using common;
using eagle;
using Eagle0.Tutorial;
using Net.Eagle0.Shardok.Api;
using Shardok;
using TMPro;
@@ -50,8 +49,6 @@ public class SettingsPanelController : MonoBehaviour {
public GameObject ShardokCanvas;
public ConnectionHandler connectionHandler;
public Button resetTutorialsButton;
void Start() {
_active = false;
panel.SetActive(false);
@@ -151,11 +148,6 @@ public class SettingsPanelController : MonoBehaviour {
Process.Start(Path.Combine(Application.persistentDataPath, "eagle0", "Resources"));
}
public void OnResetTutorialsClick() {
TutorialManager.Instance?.ResetAllProgress();
UnityEngine.Debug.Log("Tutorial progress reset");
}
private void HandleLobby() {
eagleGameController.StopAll();
eagleGameController.gameObject.SetActive(false);
@@ -55,21 +55,6 @@ namespace Shardok {
/// <param name="unitType">Type of battalion (determines boot vs hoof prints)</param>
public void
AnimateMove(int sourceCellIndex, int targetCellIndex, BattalionTypeId unitType) {
AnimateMove(new List<int> { sourceCellIndex, targetCellIndex }, unitType);
}
/// <summary>
/// Animate movement along a path of hex cells.
/// </summary>
/// <param name="pathCellIndices">List of cell indices forming the path (including
/// start)</param> <param name="unitType">Type of battalion (determines boot vs hoof
/// prints)</param>
public void AnimateMove(List<int> pathCellIndices, BattalionTypeId unitType) {
if (pathCellIndices == null || pathCellIndices.Count < 2) {
Debug.LogWarning("MoveAnimator: Path must have at least 2 points");
return;
}
if (bootPrintSprite == null && hoofPrintSprite == null) {
Debug.LogWarning("MoveAnimator: No print sprites assigned");
return;
@@ -80,21 +65,19 @@ namespace Shardok {
return;
}
// Convert cell indices to positions
var pathPositions = new List<Vector2>();
foreach (int cellIndex in pathCellIndices) {
Vector2? pos = _hexGrid.GetCellCenterPosition(cellIndex);
if (pos == null) {
Debug.LogWarning($"MoveAnimator: Invalid cell index {cellIndex}");
return;
}
pathPositions.Add(pos.Value);
Vector2? sourcePos = _hexGrid.GetCellCenterPosition(sourceCellIndex);
Vector2? targetPos = _hexGrid.GetCellCenterPosition(targetCellIndex);
if (sourcePos == null || targetPos == null) {
Debug.LogWarning(
$"MoveAnimator: Invalid cell indices {sourceCellIndex} or {targetCellIndex}");
return;
}
bool isMounted = unitType == BattalionTypeId.LightCavalry ||
unitType == BattalionTypeId.HeavyCavalry;
StartCoroutine(SpawnPrintTrailAlongPath(pathPositions, isMounted));
StartCoroutine(SpawnPrintTrail(sourcePos.Value, targetPos.Value, isMounted));
}
/// <summary>
@@ -105,52 +88,39 @@ namespace Shardok {
}
private IEnumerator SpawnPrintTrail(Vector2 source, Vector2 target, bool isMounted) {
yield return SpawnPrintTrailAlongPath(new List<Vector2> { source, target }, isMounted);
}
private IEnumerator SpawnPrintTrailAlongPath(List<Vector2> pathPositions, bool isMounted) {
var prints = new List<GameObject>();
Vector2 direction = (target - source).normalized;
float totalDistance = Vector2.Distance(source, target);
// Calculate rotation angle for prints to face direction of travel
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg - 90f;
Sprite printSprite = isMounted ? (hoofPrintSprite ?? bootPrintSprite)
: (bootPrintSprite ?? hoofPrintSprite);
Color printColor = isMounted ? hoofPrintColor : bootPrintColor;
int totalPrintIndex = 0;
// Spawn prints along the path
for (int i = 0; i < printsPerHex; i++) {
float t = (i + 1f) / (printsPerHex + 1f);
Vector2 position = Vector2.Lerp(source, target, t);
// Spawn prints along each segment of the path
for (int segmentIdx = 0; segmentIdx < pathPositions.Count - 1; segmentIdx++) {
Vector2 source = pathPositions[segmentIdx];
Vector2 target = pathPositions[segmentIdx + 1];
Vector2 direction = (target - source).normalized;
// Alternate left/right for boot prints (flip horizontally)
bool flipX = !isMounted && (i % 2 == 1);
// Calculate rotation angle for prints to face direction of travel
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg - 90f;
// Lateral offset for alternating prints (feet or front/rear hooves)
Vector2 perpendicular = new Vector2(-direction.y, direction.x);
float offset = (i % 2 == 0) ? -lateralOffset : lateralOffset;
position += perpendicular * offset;
// Spawn prints along this segment
for (int i = 0; i < printsPerHex; i++) {
float t = (i + 1f) / (printsPerHex + 1f);
Vector2 position = Vector2.Lerp(source, target, t);
GameObject print = CreatePrint(position, angle, printSprite, printColor, flipX);
prints.Add(print);
// Alternate left/right for boot prints (flip horizontally)
bool flipX = !isMounted && (totalPrintIndex % 2 == 1);
// Start FIFO fade coroutine for this print
var fadeCoroutine =
StartCoroutine(FadePrintFIFO(print, printLingerTime + i * printInterval));
_activeCoroutines.Add(fadeCoroutine);
// Lateral offset for alternating prints (feet or front/rear hooves)
Vector2 perpendicular = new Vector2(-direction.y, direction.x);
float offset = (totalPrintIndex % 2 == 0) ? -lateralOffset : lateralOffset;
position += perpendicular * offset;
GameObject print = CreatePrint(position, angle, printSprite, printColor, flipX);
prints.Add(print);
// Start FIFO fade coroutine for this print
var fadeCoroutine = StartCoroutine(FadePrintFIFO(
print,
printLingerTime + totalPrintIndex * printInterval));
_activeCoroutines.Add(fadeCoroutine);
totalPrintIndex++;
yield return new WaitForSeconds(printInterval);
}
yield return new WaitForSeconds(printInterval);
}
// Wait for all fades to complete
@@ -480,9 +480,6 @@ namespace Shardok {
SetModifiers();
UpdateReserves();
// Notify tutorial system of available commands (for spell/ability tutorials)
TutorialManager.Instance?.TriggerRegistry?.OnTacticalCommandsAvailable(Model);
HandleEnemyStartingPositionOverlays();
endTurnButton.interactable = false;
@@ -1534,15 +1531,13 @@ namespace Shardok {
/// For two-stage sounds (when isOwnCommand=true), plays attempt sound and tracks pending.
/// For single-stage or other player actions, plays full sound.
/// </summary>
/// <param name="movePath">Optional path for move animations (list of grid indices)</param>
void PlayAnimationAndSound(
AnimationType animationType,
int sourceGridIndex,
int targetGridIndex,
UnitViewWithName attackerUnit,
UnitViewWithName defenderUnit,
bool isOwnCommand = false,
List<int> movePath = null) {
bool isOwnCommand = false) {
if (animationType == AnimationType.None) return;
// Handle sound based on whether this is own command with two-stage sounds
@@ -1581,14 +1576,10 @@ namespace Shardok {
break;
case AnimationType.Move:
if (moveAnimator != null && attackerUnit != null) {
if (movePath != null && movePath.Count >= 2) {
moveAnimator.AnimateMove(movePath, attackerUnit.Battalion.Type);
} else {
moveAnimator.AnimateMove(
sourceGridIndex,
targetGridIndex,
attackerUnit.Battalion.Type);
}
moveAnimator.AnimateMove(
sourceGridIndex,
targetGridIndex,
attackerUnit.Battalion.Type);
}
break;
case AnimationType.LightningBolt:
@@ -1906,22 +1897,13 @@ namespace Shardok {
Coords finishCoords = GridIndexToMapCoords(finishGridIndex);
var commandTypes = commandTypeUIManager.CommandTypesForGroup(_displayedCommandGroup);
var executedCommand =
CommandType? executedCommand =
Model.PerformTargetedCommand(startCoords, finishCoords, commandTypes);
if (executedCommand != null) {
var attackerUnit = Model.UnitAtCoords(startCoords);
var defenderUnit = Model.UnitAtCoords(finishCoords);
var animationType = AnimationTypeForCommand(executedCommand.Type);
// Extract path for move animations (prepend start position)
List<int> movePath = null;
if (executedCommand.Path.Count > 0) {
movePath = new List<int> { startGridIndex };
foreach (var coords in executedCommand.Path) {
movePath.Add(MapCoordsToGridIndex(coords));
}
}
var animationType = AnimationTypeForCommand(executedCommand.Value);
if (animationType != AnimationType.None) {
PlayAnimationAndSound(
@@ -1930,8 +1912,7 @@ namespace Shardok {
finishGridIndex,
attackerUnit,
defenderUnit,
isOwnCommand: true,
movePath: movePath);
isOwnCommand: true);
} else if (Model.InSetUp) {
// For commands without specific animations, play generic click in setup
audioClipSource.PlayOneShot(soundManager.GenericClickSound());
@@ -274,14 +274,16 @@ public class ShardokGameModel {
return true;
}
public CommandDescriptor
PerformTargetedCommand(Coords start, Coords target, List<CommandType> possibleTypes) {
public CommandType? PerformTargetedCommand(
Coords start,
Coords target,
List<CommandType> possibleTypes) {
List<CommandDescriptor> heroActions = GetCommandsForCoords(start);
foreach (CommandDescriptor action in heroActions) {
if (target.Equals(action.Target) && possibleTypes.Contains(action.Type)) {
PostAction(action);
return action;
return action.Type;
}
}
return null;
@@ -1,310 +0,0 @@
using System.Collections.Generic;
using UnityEngine;
namespace Eagle0.Tutorial {
/// <summary>
/// Defines all tutorial content programmatically.
/// This keeps content in code for easy version control and review.
/// Call RegisterAll() during TutorialManager initialization.
/// </summary>
public static class TutorialContentDefinitions {
/// <summary>
/// Creates and registers all tutorial sequences with the registry.
/// </summary>
public static void RegisterAll(TutorialManager manager, TutorialTriggerRegistry registry) {
// Create and assign onboarding sequence
var onboarding = CreateOnboardingSequence();
manager.OnboardingSequence = onboarding;
// Register strategic contextual tutorials
RegisterStrategicTutorials(registry);
// Register tactical contextual tutorials
RegisterTacticalTutorials(registry);
}
// ========== ONBOARDING SEQUENCE (13 steps) ==========
private static TutorialSequence CreateOnboardingSequence() {
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
sequence.SequenceId = "onboarding";
sequence.DisplayName = "Welcome to Eagle0";
sequence.IsOnboarding = true;
sequence.Steps = new List<TutorialStep> {
// Step 1: Welcome
new TutorialStep {
StepId = "welcome",
Title = "Welcome to Eagle0",
Description =
"Command your armies, recruit heroes, and conquer provinces in this turn-based strategy game.\n\nLet's walk through the basics!",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 2: Map overview - select a province
new TutorialStep {
StepId = "select_province",
Title = "The Strategic Map",
Description =
"This is your kingdom. Each colored region is a province.\n\nTap a province you control (shown in your color) to see what you can do there.",
DisplayMode = TutorialDisplayMode.Overlay,
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "province_selected",
AllowSkip = true
},
// Step 3: Province info panel
new TutorialStep {
StepId = "province_panel",
Title = "Province Information",
Description =
"This panel shows province details: its name, terrain, any armies present, and the commands available to you.\n\nCommands let you move troops, recruit heroes, and more.",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 4: Try March command
new TutorialStep {
StepId = "try_march",
Title = "Issue a Command",
Description =
"Try issuing a March command to move your army to an adjacent province.\n\nSelect a destination and confirm the order.",
DisplayMode = TutorialDisplayMode.Overlay,
// TODO: Add TargetGameObjectPath once we know the actual UI hierarchy
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "command_issued",
AllowSkip = true
},
// Step 5: Turn cycle explanation
new TutorialStep {
StepId = "turn_cycle",
Title = "The Turn Cycle",
Description =
"Eagle0 uses simultaneous turns. All players give orders at the same time, then turns resolve together.\n\nWhen all players are ready, the server processes everyone's commands and shows the results.",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 6: Hidden wait for battle
new TutorialStep {
StepId = "wait_for_battle",
Title = "",
Description = "",
DisplayMode = TutorialDisplayMode.None,
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "first_battle_available",
AllowSkip = true
},
// Step 7: Battle introduction
new TutorialStep {
StepId = "battle_intro",
Title = "Battle Time!",
Description =
"When armies collide, you'll fight tactical battles on a hex grid.\n\nYou command individual units - infantry, cavalry, archers, and heroes with special abilities.",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 8: Battle button highlight
new TutorialStep {
StepId = "enter_battle",
Title = "Enter the Battle",
Description = "Tap the Battle button to enter tactical combat.",
DisplayMode = TutorialDisplayMode.Overlay,
// TODO: Add TargetGameObjectPath once we know the actual UI hierarchy
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "battle_entered",
AllowSkip = true
},
// Step 9: Tactical overview
new TutorialStep {
StepId = "tactical_overview",
Title = "Tactical Combat",
Description =
"Each unit has movement points and attack power. Position your troops wisely!\n\nUnits attack adjacent enemies. Flanking (attacking from multiple sides) deals bonus damage.",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 10: Move a unit
new TutorialStep {
StepId = "move_unit",
Title = "Move Your Units",
Description =
"Tap one of your units to select it, then tap a highlighted hex to move there.\n\nBlue hexes show where you can move.",
DisplayMode = TutorialDisplayMode.Overlay,
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "battle_action",
AllowSkip = true
},
// Step 11: Attack an enemy
new TutorialStep {
StepId = "attack_enemy",
Title = "Attack!",
Description =
"Move next to an enemy unit, then tap the enemy to attack.\n\nRed highlights show valid attack targets.",
DisplayMode = TutorialDisplayMode.Overlay,
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "battle_action",
AllowSkip = true
},
// Step 12: End turn
new TutorialStep {
StepId = "end_turn",
Title = "End Your Turn",
Description =
"When you've moved all units or want to pass, tap End Turn.\n\nThe enemy will then take their turn.",
DisplayMode = TutorialDisplayMode.Overlay,
// TODO: Add TargetGameObjectPath once we know the actual UI hierarchy
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "turn_ended",
AllowSkip = true
},
// Step 13: Completion
new TutorialStep {
StepId = "complete",
Title = "You're Ready!",
Description =
"You now know the basics of Eagle0!\n\nExplore diplomacy, recruit powerful heroes, and conquer the realm. Good luck, commander!",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = false // Don't allow skipping the final step
}
};
return sequence;
}
// ========== STRATEGIC CONTEXTUAL TUTORIALS ==========
private static void RegisterStrategicTutorials(TutorialTriggerRegistry registry) {
// Diplomacy introduction
var diplomacy = CreateSingleStepTutorial(
"diplomacy_intro",
"Diplomacy",
"You can negotiate with other factions!\n\nOffer alliances, declare war, or propose tribute. Your diplomatic choices shape the realm.",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(diplomacy, "diplomacy_available");
// Hero recruitment
var heroRecruitment = CreateSingleStepTutorial(
"hero_recruitment",
"Heroes Available",
"Free heroes wander the realm seeking a lord to serve.\n\nRecruit them to lead your armies! Heroes have unique abilities and grow stronger with experience.",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(heroRecruitment, "hero_recruitment_available");
// Weather control
var weatherControl = CreateSingleStepTutorial(
"weather_control",
"Weather Magic",
"Your mages can influence the weather!\n\nRain slows movement, storms disrupt enemies, and clear skies speed your march.",
TutorialDisplayMode.Overlay);
registry.RegisterTutorial(weatherControl, "weather_control_available");
// Prisoner management
var prisoners = CreateSingleStepTutorial(
"prisoner_management",
"Prisoners Captured",
"You've captured enemy soldiers!\n\nYou can ransom them for gold, recruit them into your army, or execute them as a warning.",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(prisoners, "prisoner_command_issued");
}
// ========== TACTICAL CONTEXTUAL TUTORIALS ==========
private static void RegisterTacticalTutorials(TutorialTriggerRegistry registry) {
// Lightning spell
var lightning = CreateSingleStepTutorial(
"spell_lightning",
"Lightning Bolt",
"Your mage can cast Lightning Bolt!\n\nThis spell strikes a single target for heavy damage. Great for eliminating key enemy units.",
TutorialDisplayMode.Tooltip);
registry.RegisterTutorial(lightning, "spell_lightning_available");
// Meteor spell
var meteor = CreateSingleStepTutorial(
"spell_meteor",
"Meteor Strike",
"Meteor is a devastating area spell!\n\nIt takes a turn to cast: first select target, then it lands next turn. Plan ahead!",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(meteor, "spell_meteor_available");
// Holy Wave spell
var holyWave = CreateSingleStepTutorial(
"spell_holywave",
"Holy Wave",
"Holy Wave heals your units and damages undead!\n\nPosition your troops carefully to maximize its effect.",
TutorialDisplayMode.Tooltip);
registry.RegisterTutorial(holyWave, "spell_holywave_available");
// Raise Dead spell
var raiseDead = CreateSingleStepTutorial(
"spell_raisedead",
"Raise Dead",
"Dark magic can raise fallen soldiers as undead!\n\nThey fight for you, but beware - they may crumble if your necromancer falls.",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(raiseDead, "spell_raisedead_available");
// Fire terrain
var fireTerrain = CreateSingleStepTutorial(
"terrain_fire",
"Fire Hazard",
"Fire spreads across the battlefield!\n\nUnits in burning hexes take damage. Use fire to block enemy routes or avoid it yourself.",
TutorialDisplayMode.Tooltip);
registry.RegisterTutorial(fireTerrain, "terrain_fire_encountered");
// Water terrain
var waterTerrain = CreateSingleStepTutorial(
"terrain_water",
"Water Crossing",
"Units can cross shallow water, but it's risky.\n\nCrossing takes extra movement and may fail. Some units swim better than others.",
TutorialDisplayMode.Tooltip);
registry.RegisterTutorial(waterTerrain, "terrain_water_encountered");
// Charge ability
var charge = CreateSingleStepTutorial(
"ability_charge",
"Cavalry Charge",
"Your cavalry can Charge!\n\nCharging deals bonus damage based on distance traveled. Use open terrain for maximum impact.",
TutorialDisplayMode.Overlay);
registry.RegisterTutorial(charge, "ability_charge_available");
}
// ========== HELPER METHODS ==========
private static TutorialSequence CreateSingleStepTutorial(
string id,
string title,
string description,
TutorialDisplayMode displayMode) {
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
sequence.SequenceId = id;
sequence.DisplayName = title;
sequence.IsOnboarding = false;
sequence.Steps = new List<TutorialStep> { new TutorialStep {
StepId = id + "_step",
Title = title,
Description = description,
DisplayMode = displayMode,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
} };
return sequence;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 3a768fdf067694657a5d24b935064f88
@@ -1,192 +0,0 @@
# Tutorial System for Eagle0
## Overview
A modular tutorial system for the Eagle0 Unity client supporting:
- **Onboarding**: Full guided playthrough of first several turns
- **Contextual tutorials**: First-encounter and first-attempt triggers with subtle hints
- **Mixed UI**: Modals for concepts, overlays for UI guidance, hint indicators
- **Local persistence**: PlayerPrefs-based state tracking
---
## Current Status
### Completed
- [x] **Phase 1: Foundation** - TutorialState, TutorialManager, TutorialStep/Sequence
- [x] **Phase 2: UI** - Canvas-based modal with runtime construction
- TutorialCanvasBuilder creates full Canvas UI at runtime (no prefab needed)
- Fantasy RPG color scheme (dark purple panel, gold accents)
- Title, description, icon, progress bar, buttons
- IMGUI fallback still available if needed
- [x] **Phase 3: Triggers** - Event-based trigger system with game controller hooks
- [x] **Test setup** - TutorialTestSetup with welcome intro, battle, command tutorials
- [x] **Settings integration** - Reset Tutorials button in Settings panel
- [x] **Game entry trigger** - Welcome tutorial shows when entering a game (not lobby)
- [x] **Font assignment** - Stoke-Regular-SDF assigned to TutorialUIManager.CanvasFont
- [x] **Overlay system** - TutorialOverlayController with runtime UI construction
- TutorialOverlayBuilder creates overlay UI at runtime (no prefab needed)
- Background dimmer, highlight frame with gold border
- Tooltip with title, description, continue button
- Pulsing animation on highlight frame
- Test tutorial includes overlay step for End Turn button
### Future Work
- [ ] **Lobby tutorial helper** - Separate tutorial for lobby/game selection
- [ ] **Hint indicators** - TutorialHintIndicator for subtle pulsing dots
- [ ] **Real tutorial content** - Replace test tutorials with actual onboarding sequence
- [ ] **More contextual triggers** - Diplomacy, hero recruitment, spells, terrain, etc.
---
## Architecture
```
TutorialManager (Singleton, DontDestroyOnLoad)
├── TutorialState (PlayerPrefs persistence)
├── TutorialTriggerRegistry
│ ├── OnboardingTriggers
│ └── ContextualTriggers
└── TutorialUIManager
├── TutorialCanvasBuilder (runtime Canvas construction)
├── TutorialModalPanel (wired up by builder)
├── TutorialOverlayBuilder (runtime overlay construction)
├── TutorialOverlayController (wired up by builder)
├── TutorialHintIndicator (stub)
└── IMGUI Fallback (working)
```
---
## File Structure
```
Assets/Tutorial/
├── TutorialManager.cs ✓ Singleton, coordinates everything
├── TutorialState.cs ✓ PlayerPrefs persistence
├── TutorialTestSetup.cs ✓ Test tutorials (welcome, battle, command)
├── TUTORIAL_PLAN.md ✓ This document
├── Content/
│ ├── TutorialStep.cs ✓ Step data structure
│ └── TutorialSequence.cs ✓ Sequence ScriptableObject
├── Triggers/
│ └── TutorialTriggerRegistry.cs ✓ Event routing
└── UI/
├── TutorialUIManager.cs ✓ UI coordination + auto-build Canvas/Overlay
├── TutorialCanvasBuilder.cs ✓ Runtime Canvas UI construction
├── TutorialModalPanel.cs ✓ Canvas-based modal panel controller
├── TutorialOverlayBuilder.cs ✓ Runtime overlay UI construction
├── TutorialOverlayController.cs ✓ Overlay with highlight + tooltip
└── TutorialHintIndicator.cs Stub (future)
```
---
## Unity Setup
### Required Inspector Assignments
1. **TutorialUIManager.CanvasFont**`Stoke-Regular-SDF` (TextMeshPro font)
2. **TutorialUIManager.FallbackFont**`Stoke-Regular` (for IMGUI backup)
### No Prefab Required
The `TutorialCanvasBuilder` creates the entire Canvas UI hierarchy at runtime:
- Canvas with ScreenSpaceOverlay, sorting order 1000
- Modal blocker (dark semi-transparent background)
- Panel with vertical layout (title, description, progress, buttons)
- All buttons wired to TutorialModalPanel handlers
---
## Integration Points
### EagleGameController.cs
- `SetUpGame()`: Calls `TutorialManager.Instance.Initialize(this, null)` → triggers "game_started" event
- `SwapModel()`: Calls `OnModelUpdated()`
- `ProvinceWasSelected()`: Calls `OnProvinceSelected()`
- `PostCommittedCommand()`: Calls `OnCommandIssued()`
### ShardokGameController.cs
- `SetUpGame()`: Calls `TutorialManager.Instance.Initialize(null, this)`
- `ModelUpdated()`: Calls `OnBattleAction()`
- `OnTurnEnded()`: Calls `OnTurnEnded()`
- Unit selection: Calls `OnUnitSelected()`
### SettingsPanelController.cs
- Reset Tutorials button: Calls `TutorialManager.Instance.ResetAllProgress()`
---
## Test Tutorials (Current)
| ID | Trigger Event | Title | Type | When |
|----|---------------|-------|------|------|
| `test_intro` | `game_started` | Welcome to Eagle0! | Modal | Entering a game |
| `test_intro` | (step 2) | End Turn | Overlay | After welcome modal |
| `test_first_battle` | `battle_entered` | Battle Begins! | Modal | First tactical battle |
| `test_first_command` | `command_issued` | Command Issued! | Modal | First strategic command |
---
## Onboarding Flow (Planned)
| Step | Type | Content | Completion |
|------|------|---------|------------|
| 1 | Modal | Welcome to Eagle0 | Button click |
| 2 | Overlay | Map overview - "Select a province" | Province selected |
| 3 | Overlay | Province info panel explanation | Button click |
| 4 | Overlay | Command buttons - "Try March" | March issued |
| 5 | Modal | Turn cycle explanation | Button click |
| 6 | Hidden | Wait for battle available | Model update |
| 7 | Modal | Battle introduction | Button click |
| 8 | Overlay | "Battle!" button highlight | Battle entered |
| 9 | Modal | Tactical combat overview | Button click |
| 10 | Overlay | Select and move a unit | Move issued |
| 11 | Overlay | Attack an enemy | Attack issued |
| 12 | Overlay | End Turn button | Turn ended |
| 13 | Modal | Onboarding complete! | Button click |
---
## Contextual Tutorials (Planned)
### Strategic (First Encounter)
| ID | Trigger | Display |
|----|---------|---------|
| `diplomacy_offer` | First diplomacy command available | Modal |
| `hero_recruitment` | First recruitment available | Modal |
| `province_riot` | First riot in owned province | Modal |
| `weather_control` | First weather command available | Overlay |
| `prisoner_capture` | First prisoner captured | Modal |
### Tactical (First Encounter/Attempt)
| ID | Trigger | Display |
|----|---------|---------|
| `spell_lightning` | Lightning spell available | Tooltip |
| `spell_meteor` | Meteor spell available | Modal |
| `spell_holywave` | Holy Wave available | Tooltip |
| `spell_raisedead` | Raise Dead available | Modal |
| `terrain_fire` | Fire hex encountered | Tooltip |
| `terrain_water` | Water adjacent to unit | Tooltip |
| `ability_charge` | Charge command available | Overlay |
| `flanking` | Flanking opportunity | Hint |
### Lobby (Future)
| ID | Trigger | Display |
|----|---------|---------|
| `lobby_welcome` | First time in lobby | Modal |
| `lobby_join_game` | Viewing game list | Overlay |
| `lobby_create_game` | Create game button | Tooltip |
---
## Testing
1. Ensure TutorialManager GameObject exists with:
- TutorialManager component
- TutorialUIManager component (assign fonts!)
- TutorialTestSetup component
2. Enable `Debug Logging` on TutorialManager for console output
3. Play game → enter a game → see "Welcome to Eagle0!" modal
4. Click "Continue" → see overlay highlighting End Turn button
5. Click "Got it" → overlay closes
6. Use Settings → Reset Tutorials to test again
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 69183de61f7d440a29b4a4270395a180
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,9 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using eagle;
using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Shardok.Api;
using Net.Eagle0.Shardok.Common;
using Shardok;
using UnityEngine;
@@ -96,51 +92,10 @@ namespace Eagle0.Tutorial {
}
}
// Check available commands for strategic tutorials
CheckStrategicCommandsAvailable(model, previousModel);
// Check for province state changes
CheckProvinceStateChanges(model, previousModel);
// Check for free heroes (recruitment opportunity)
CheckHeroRecruitmentAvailable(model, previousModel);
}
private void CheckStrategicCommandsAvailable(IGameModel model, IGameModel previousModel) {
if (model.AvailableCommandsByProvince == null) return;
var allCommands =
model.AvailableCommandsByProvince.Values.SelectMany(opac => opac.Commands)
.ToList();
// Check for diplomacy commands
bool hasDiplomacy = allCommands.Any(cmd => cmd.DiplomacyCommand != null);
if (hasDiplomacy && !_manager.State.HasCompletedTutorial("diplomacy_intro")) {
OnGameEvent("diplomacy_available");
}
// Check for weather control
bool hasWeatherControl =
allCommands.Any(cmd => cmd.ControlWeatherAvailableCommand != null);
if (hasWeatherControl && !_manager.State.HasCompletedTutorial("weather_control")) {
OnGameEvent("weather_control_available");
}
}
private void CheckProvinceStateChanges(IGameModel model, IGameModel previousModel) {
// Note: Riot status is not exposed in ProvinceView, so we can't detect riots here.
// Province state change detection can be added when the view exposes relevant fields.
}
private void CheckHeroRecruitmentAvailable(IGameModel model, IGameModel previousModel) {
if (model.Heroes == null) return;
// Check for free heroes (heroes with no faction)
var freeHeroes =
model.Heroes.Values.Where(h => h.FactionId == null || h.FactionId == 0);
if (freeHeroes.Any() && !_manager.State.HasCompletedTutorial("hero_recruitment")) {
OnGameEvent("hero_recruitment_available");
}
// Check for diplomacy offers
// Check for riots
// Check for hero recruitment opportunities
// etc. - These would check model state changes
}
/// <summary>
@@ -158,18 +113,8 @@ namespace Eagle0.Tutorial {
// Track first command for onboarding
OnGameEvent("command_issued", command);
// Check specific command types
if (command is SelectedCommand selectedCommand) {
if (selectedCommand.DiplomacyCommand != null) {
OnGameEvent("diplomacy_command_issued", selectedCommand);
}
if (selectedCommand.ControlWeatherSelectedCommand != null) {
OnGameEvent("weather_command_issued", selectedCommand);
}
if (selectedCommand.ManagePrisonersCommand != null) {
OnGameEvent("prisoner_command_issued", selectedCommand);
}
}
// Could also check command type for specific tutorials
// e.g., first diplomacy command, first weather control, etc.
}
// ========== Tactical Layer Events ==========
@@ -187,62 +132,8 @@ namespace Eagle0.Tutorial {
public void OnBattleAction(object actionResult) {
OnGameEvent("battle_action", actionResult);
// Check for specific action types
if (actionResult is ActionResultView result) { CheckTacticalActionType(result); }
}
private void CheckTacticalActionType(ActionResultView result) {
switch (result.Type) {
// Spell tutorials
case ActionType.LightningBolt: OnGameEvent("spell_lightning_cast", result); break;
case ActionType.MeteorStart:
case ActionType.MeteorTarget:
case ActionType.MeteorCast: OnGameEvent("spell_meteor_cast", result); break;
case ActionType.HolyWave:
case ActionType.HolyWaveDamage: OnGameEvent("spell_holywave_cast", result); break;
case ActionType.RaisedUndead:
case ActionType.FailedRaiseUndead:
OnGameEvent("spell_raisedead_cast", result);
break;
// Combat tutorials
case ActionType.ChargeAttack: OnGameEvent("ability_charge_used", result); break;
// Terrain tutorials
case ActionType.FireDamage:
case ActionType.FireSpread: OnGameEvent("terrain_fire_encountered", result); break;
case ActionType.CrossedWater:
case ActionType.CrossWaterFailed:
OnGameEvent("terrain_water_encountered", result);
break;
}
}
/// <summary>
/// Called when available commands change in tactical view.
/// Used to detect when special abilities become available.
/// </summary>
public void OnTacticalCommandsAvailable(ShardokGameModel model) {
if (model?.AvailableCommands == null) return;
// Check for spell availability
foreach (var cmd in model.AvailableCommands) {
switch (cmd.Type) {
case CommandType.LightningBoltCommand:
OnGameEvent("spell_lightning_available");
break;
case CommandType.MeteorStartCommand:
OnGameEvent("spell_meteor_available");
break;
case CommandType.HolyWaveCommand:
OnGameEvent("spell_holywave_available");
break;
case CommandType.RaiseDeadCommand:
OnGameEvent("spell_raisedead_available");
break;
case CommandType.ChargeCommand: OnGameEvent("ability_charge_available"); break;
}
}
// Check for specific action types that need tutorials
// e.g., first spell cast, first charge, etc.
}
/// <summary>
@@ -69,9 +69,6 @@ namespace Eagle0.Tutorial {
_state = TutorialState.Load();
_triggerRegistry = new TutorialTriggerRegistry(this);
// Register all tutorial content
TutorialContentDefinitions.RegisterAll(this, _triggerRegistry);
Log("TutorialManager initialized");
}
@@ -86,9 +83,6 @@ namespace Eagle0.Tutorial {
public void Initialize(EagleGameController eagle, ShardokGameController shardok) {
_triggerRegistry?.Initialize(eagle, shardok);
Log($"TutorialManager initialized with controllers - Eagle: {eagle != null}, Shardok: {shardok != null}");
// Trigger game_started event when entering a game (not lobby)
if (eagle != null) { _triggerRegistry?.OnGameEvent("game_started"); }
}
/// <summary>
@@ -106,7 +100,7 @@ namespace Eagle0.Tutorial {
}
if (OnboardingSequence == null) {
Log("StartOnboarding: No onboarding sequence assigned, skipping");
Debug.LogWarning("TutorialManager: No onboarding sequence assigned");
return;
}
@@ -149,13 +143,6 @@ namespace Eagle0.Tutorial {
if (!TutorialsEnabled || _state.AllTutorialsDisabled) return;
if (_state.HasCompletedTutorial(tutorialId)) return;
// Don't interrupt an active tutorial with a contextual one
// (onboarding can be interrupted by explicit StartSequence calls)
if (_activeSequence != null) {
Log($"TriggerContextualTutorial: Skipping '{tutorialId}' - another tutorial is active");
return;
}
// Look up the tutorial sequence by ID
var sequence = _triggerRegistry?.GetSequenceById(tutorialId);
if (sequence != null) { StartSequence(sequence); }
@@ -1,118 +0,0 @@
using UnityEngine;
namespace Eagle0.Tutorial {
/// <summary>
/// Test component that sets up a simple tutorial for end-to-end testing.
/// Attach to TutorialManager GameObject to register test tutorials on startup.
/// Can be removed once actual tutorial content is created in the Editor.
/// </summary>
public class TutorialTestSetup : MonoBehaviour {
[Header("Test Configuration")]
[Tooltip("Enable test tutorials")]
public bool EnableTestTutorials = true;
[Tooltip("Reset tutorial progress on startup (for testing)")]
public bool ResetProgressOnStart = false;
private void Start() {
if (!EnableTestTutorials) return;
var manager = TutorialManager.Instance;
if (manager == null) {
Debug.LogWarning("TutorialTestSetup: TutorialManager not found");
return;
}
if (ResetProgressOnStart) { manager.ResetAllProgress(); }
RegisterTestTutorials(manager);
Debug.Log("TutorialTestSetup: Test tutorials registered");
}
private void RegisterTestTutorials(TutorialManager manager) {
// Create welcome/intro tutorial that shows on game start
var introTutorial = CreateIntroTutorial();
manager.TriggerRegistry.RegisterTutorial(introTutorial, "game_started");
// Create a simple "first battle" tutorial
var firstBattleTutorial = CreateFirstBattleTutorial();
manager.TriggerRegistry.RegisterTutorial(firstBattleTutorial, "battle_entered");
// Create a simple "first command" tutorial
var firstCommandTutorial = CreateFirstCommandTutorial();
manager.TriggerRegistry.RegisterTutorial(firstCommandTutorial, "command_issued");
// Note: "game_started" event is triggered by EagleGameController.SetUpGame()
// via TutorialManager.Initialize(), not here
}
private TutorialSequence CreateIntroTutorial() {
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
sequence.SequenceId = "test_intro";
sequence.DisplayName = "Welcome";
// Step 1: Welcome modal
sequence.Steps.Add(new TutorialStep {
StepId = "welcome",
Title = "Welcome to Eagle0!",
Description =
"Eagle0 is a strategy game where you command armies, manage provinces, and engage in tactical combat.\n\nClick on provinces to view their details and issue commands. When battles occur, you'll fight them out on a hex-based tactical map.\n\nGood luck, Commander!",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = false
});
// Step 2: Overlay test - highlight the End Turn button
sequence.Steps.Add(new TutorialStep {
StepId = "end_turn_highlight",
Title = "End Turn",
Description =
"When you're ready to advance to the next turn, click the End Turn button.\n\n(This is a test of the overlay tutorial system.)",
DisplayMode = TutorialDisplayMode.Overlay,
TargetGameObjectPath = "EagleCanvas/EndTurnButton",
HighlightOffset = new Vector2(0, 150),
HighlightPulsing = true,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
});
return sequence;
}
private TutorialSequence CreateFirstBattleTutorial() {
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
sequence.SequenceId = "test_first_battle";
sequence.DisplayName = "First Battle";
sequence.Steps.Add(new TutorialStep {
StepId = "battle_intro",
Title = "Battle Begins!",
Description =
"You've entered tactical combat. Command your units on the hex grid to defeat the enemy.\n\n(This is a test tutorial to verify the tutorial system is working.)",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
});
return sequence;
}
private TutorialSequence CreateFirstCommandTutorial() {
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
sequence.SequenceId = "test_first_command";
sequence.DisplayName = "First Command";
sequence.Steps.Add(new TutorialStep {
StepId = "command_issued_intro",
Title = "Command Issued!",
Description =
"You've issued your first command. Commands are processed at the end of each turn.\n\n(This is a test tutorial to verify the tutorial system is working.)",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
});
return sequence;
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 370b3269832442e196d1b432bdae2f1e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,475 +0,0 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Eagle0.Tutorial {
/// <summary>
/// Builds the tutorial Canvas UI programmatically at runtime.
/// Creates a complete TutorialModalPanel with all required UI elements.
/// </summary>
public static class TutorialCanvasBuilder {
// Colors matching the game's fantasy RPG style
private static readonly Color PanelBackgroundColor = new Color(0.15f, 0.12f, 0.2f, 0.95f);
private static readonly Color BorderColor = new Color(0.6f, 0.5f, 0.3f, 1f);
private static readonly Color TitleColor = new Color(1f, 0.9f, 0.7f, 1f);
private static readonly Color TextColor = new Color(0.9f, 0.85f, 0.8f, 1f);
private static readonly Color ButtonColor = new Color(0.4f, 0.3f, 0.2f, 1f);
private static readonly Color ButtonHoverColor = new Color(0.5f, 0.4f, 0.25f, 1f);
private static readonly Color ButtonTextColor = new Color(1f, 0.95f, 0.85f, 1f);
private static readonly Color ModalBlockerColor = new Color(0f, 0f, 0f, 0.75f);
private static readonly Color ProgressBarColor = new Color(0.6f, 0.5f, 0.3f, 1f);
private static readonly Color ProgressBackgroundColor = new Color(0.2f, 0.15f, 0.1f, 1f);
/// <summary>
/// Creates a complete tutorial Canvas with modal panel.
/// Returns the Canvas GameObject and sets up the TutorialModalPanel component.
/// </summary>
/// <param name="font">TextMeshPro font to use (assign Stoke-Regular-SDF in
/// inspector)</param>
public static Canvas BuildTutorialCanvas(TMP_FontAsset font) {
// Create Canvas
GameObject canvasObj = new GameObject("TutorialCanvas");
Canvas canvas = canvasObj.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 1000;
CanvasScaler scaler = canvasObj.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.matchWidthOrHeight = 0.5f;
canvasObj.AddComponent<GraphicRaycaster>();
// Create Modal Blocker
GameObject blockerObj = CreateModalBlocker(canvasObj.transform);
// Create Panel Container
GameObject panelContainer = CreatePanelContainer(canvasObj.transform, font);
// Add TutorialModalPanel component and wire it up
TutorialModalPanel modalPanel = canvasObj.AddComponent<TutorialModalPanel>();
WireUpModalPanel(modalPanel, blockerObj, panelContainer, font);
// Set up button listeners (Awake runs before fields are wired, so do it here)
SetupButtonListeners(modalPanel);
// Start with everything hidden
blockerObj.SetActive(false);
panelContainer.SetActive(false);
return canvas;
}
private static GameObject CreateModalBlocker(Transform parent) {
GameObject blocker = new GameObject("ModalBlocker");
blocker.transform.SetParent(parent, false);
RectTransform rect = blocker.AddComponent<RectTransform>();
rect.anchorMin = Vector2.zero;
rect.anchorMax = Vector2.one;
rect.offsetMin = Vector2.zero;
rect.offsetMax = Vector2.zero;
Image image = blocker.AddComponent<Image>();
image.color = ModalBlockerColor;
image.raycastTarget = true;
return blocker;
}
private static GameObject CreatePanelContainer(Transform parent, TMP_FontAsset font) {
// Panel Container (centered)
GameObject panel = new GameObject("PanelContainer");
panel.transform.SetParent(parent, false);
RectTransform panelRect = panel.AddComponent<RectTransform>();
panelRect.anchorMin = new Vector2(0.5f, 0.5f);
panelRect.anchorMax = new Vector2(0.5f, 0.5f);
panelRect.sizeDelta = new Vector2(800, 550);
panelRect.anchoredPosition = Vector2.zero;
// Panel Background
Image panelBg = panel.AddComponent<Image>();
panelBg.color = PanelBackgroundColor;
panelBg.raycastTarget = true;
// Add outline/border effect
Outline outline = panel.AddComponent<Outline>();
outline.effectColor = BorderColor;
outline.effectDistance = new Vector2(3, 3);
// Add vertical layout group
VerticalLayoutGroup layout = panel.AddComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(40, 40, 30, 30);
layout.spacing = 20;
layout.childAlignment = TextAnchor.UpperCenter;
layout.childControlHeight = false;
layout.childControlWidth = true;
layout.childForceExpandHeight = false;
layout.childForceExpandWidth = true;
// Title
CreateTitle(panel.transform, font);
// Icon (placeholder - starts hidden)
CreateIcon(panel.transform);
// Description
CreateDescription(panel.transform, font);
// Flexible spacer to push buttons to bottom
CreateFlexibleSpacer(panel.transform);
// Progress section
CreateProgressSection(panel.transform, font);
// Button row
CreateButtonRow(panel.transform, font);
return panel;
}
private static void CreateTitle(Transform parent, TMP_FontAsset font) {
GameObject titleObj = new GameObject("TitleText");
titleObj.transform.SetParent(parent, false);
RectTransform rect = titleObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(720, 60);
TextMeshProUGUI text = titleObj.AddComponent<TextMeshProUGUI>();
text.text = "Tutorial";
text.fontSize = 42;
text.fontStyle = FontStyles.Bold;
text.color = TitleColor;
text.alignment = TextAlignmentOptions.Center;
if (font != null) text.font = font;
LayoutElement layoutElement = titleObj.AddComponent<LayoutElement>();
layoutElement.preferredHeight = 60;
}
private static void CreateIcon(Transform parent) {
GameObject iconObj = new GameObject("IconImage");
iconObj.transform.SetParent(parent, false);
RectTransform rect = iconObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(100, 100);
Image image = iconObj.AddComponent<Image>();
image.color = Color.white;
image.preserveAspect = true;
LayoutElement layoutElement = iconObj.AddComponent<LayoutElement>();
layoutElement.preferredHeight = 100;
layoutElement.preferredWidth = 100;
// Start hidden
iconObj.SetActive(false);
}
private static void CreateDescription(Transform parent, TMP_FontAsset font) {
GameObject descObj = new GameObject("DescriptionText");
descObj.transform.SetParent(parent, false);
RectTransform rect = descObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(720, 200);
TextMeshProUGUI text = descObj.AddComponent<TextMeshProUGUI>();
text.text = "";
text.fontSize = 26;
text.color = TextColor;
text.alignment = TextAlignmentOptions.TopLeft;
text.enableWordWrapping = true;
text.overflowMode = TextOverflowModes.Overflow; // Don't truncate
if (font != null) text.font = font;
LayoutElement layoutElement = descObj.AddComponent<LayoutElement>();
layoutElement.preferredHeight = 200;
layoutElement.minHeight = 100;
}
private static void CreateSpacer(Transform parent, float height) {
GameObject spacer = new GameObject("Spacer");
spacer.transform.SetParent(parent, false);
spacer.AddComponent<RectTransform>();
LayoutElement layoutElement = spacer.AddComponent<LayoutElement>();
layoutElement.preferredHeight = height;
}
private static void CreateFlexibleSpacer(Transform parent) {
GameObject spacer = new GameObject("FlexibleSpacer");
spacer.transform.SetParent(parent, false);
spacer.AddComponent<RectTransform>();
LayoutElement layoutElement = spacer.AddComponent<LayoutElement>();
layoutElement.flexibleHeight = 1;
}
private static void CreateProgressSection(Transform parent, TMP_FontAsset font) {
GameObject progressSection = new GameObject("ProgressSection");
progressSection.transform.SetParent(parent, false);
RectTransform rect = progressSection.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(720, 50);
VerticalLayoutGroup layout = progressSection.AddComponent<VerticalLayoutGroup>();
layout.spacing = 8;
layout.childAlignment = TextAnchor.MiddleCenter;
layout.childControlHeight = false;
layout.childControlWidth = true;
LayoutElement layoutElement = progressSection.AddComponent<LayoutElement>();
layoutElement.preferredHeight = 50;
// Progress Text
GameObject progressTextObj = new GameObject("ProgressText");
progressTextObj.transform.SetParent(progressSection.transform, false);
RectTransform textRect = progressTextObj.AddComponent<RectTransform>();
textRect.sizeDelta = new Vector2(720, 24);
TextMeshProUGUI progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
progressText.text = "Step 1 of 1";
progressText.fontSize = 20;
progressText.color = TextColor;
progressText.alignment = TextAlignmentOptions.Center;
if (font != null) progressText.font = font;
LayoutElement textLayout = progressTextObj.AddComponent<LayoutElement>();
textLayout.preferredHeight = 24;
// Progress Slider
CreateProgressSlider(progressSection.transform);
}
private static void CreateProgressSlider(Transform parent) {
GameObject sliderObj = new GameObject("ProgressSlider");
sliderObj.transform.SetParent(parent, false);
RectTransform sliderRect = sliderObj.AddComponent<RectTransform>();
sliderRect.sizeDelta = new Vector2(400, 12);
Slider slider = sliderObj.AddComponent<Slider>();
slider.minValue = 0;
slider.maxValue = 1;
slider.value = 0;
slider.interactable = false;
LayoutElement sliderLayout = sliderObj.AddComponent<LayoutElement>();
sliderLayout.preferredHeight = 12;
sliderLayout.preferredWidth = 400;
// Background
GameObject bgObj = new GameObject("Background");
bgObj.transform.SetParent(sliderObj.transform, false);
RectTransform bgRect = bgObj.AddComponent<RectTransform>();
bgRect.anchorMin = Vector2.zero;
bgRect.anchorMax = Vector2.one;
bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero;
Image bgImage = bgObj.AddComponent<Image>();
bgImage.color = ProgressBackgroundColor;
// Fill Area
GameObject fillArea = new GameObject("Fill Area");
fillArea.transform.SetParent(sliderObj.transform, false);
RectTransform fillAreaRect = fillArea.AddComponent<RectTransform>();
fillAreaRect.anchorMin = Vector2.zero;
fillAreaRect.anchorMax = Vector2.one;
fillAreaRect.offsetMin = Vector2.zero;
fillAreaRect.offsetMax = Vector2.zero;
// Fill
GameObject fill = new GameObject("Fill");
fill.transform.SetParent(fillArea.transform, false);
RectTransform fillRect = fill.AddComponent<RectTransform>();
fillRect.anchorMin = Vector2.zero;
fillRect.anchorMax = new Vector2(0, 1);
fillRect.offsetMin = Vector2.zero;
fillRect.offsetMax = Vector2.zero;
Image fillImage = fill.AddComponent<Image>();
fillImage.color = ProgressBarColor;
// Wire up slider
slider.fillRect = fillRect;
}
private static void SetupButtonListeners(TutorialModalPanel panel) {
// Button listeners need to be set up after wiring since Awake runs before fields exist
if (panel.ContinueButton != null) {
panel.ContinueButton.onClick.RemoveAllListeners();
panel.ContinueButton.onClick.AddListener(panel.OnContinueClicked);
}
if (panel.SkipButton != null) {
panel.SkipButton.onClick.RemoveAllListeners();
panel.SkipButton.onClick.AddListener(panel.OnSkipClicked);
}
if (panel.SkipAllButton != null) {
panel.SkipAllButton.onClick.RemoveAllListeners();
panel.SkipAllButton.onClick.AddListener(panel.OnSkipAllClicked);
}
}
private static void CreateButtonRow(Transform parent, TMP_FontAsset font) {
GameObject buttonRow = new GameObject("ButtonRow");
buttonRow.transform.SetParent(parent, false);
RectTransform rect = buttonRow.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(720, 60);
HorizontalLayoutGroup layout = buttonRow.AddComponent<HorizontalLayoutGroup>();
layout.spacing = 20;
layout.childAlignment = TextAnchor.MiddleCenter;
layout.childControlHeight = false;
layout.childControlWidth = false;
layout.childForceExpandHeight = false;
layout.childForceExpandWidth = false;
LayoutElement layoutElement = buttonRow.AddComponent<LayoutElement>();
layoutElement.preferredHeight = 60;
// Skip Button (left)
CreateButton(buttonRow.transform, "SkipButton", "Skip", 150, 50, font);
// Skip All Button
CreateButton(buttonRow.transform, "SkipAllButton", "Skip All", 150, 50, font);
// Spacer
GameObject spacer = new GameObject("ButtonSpacer");
spacer.transform.SetParent(buttonRow.transform, false);
spacer.AddComponent<RectTransform>();
LayoutElement spacerLayout = spacer.AddComponent<LayoutElement>();
spacerLayout.flexibleWidth = 1;
// Continue Button (right)
CreateButton(buttonRow.transform, "ContinueButton", "Continue", 180, 50, font);
}
private static GameObject CreateButton(
Transform parent,
string name,
string text,
float width,
float height,
TMP_FontAsset font) {
GameObject buttonObj = new GameObject(name);
buttonObj.transform.SetParent(parent, false);
RectTransform rect = buttonObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(width, height);
Image image = buttonObj.AddComponent<Image>();
image.color = ButtonColor;
Button button = buttonObj.AddComponent<Button>();
ColorBlock colors = button.colors;
colors.normalColor = ButtonColor;
colors.highlightedColor = ButtonHoverColor;
colors.pressedColor = ButtonHoverColor;
colors.selectedColor = ButtonHoverColor;
button.colors = colors;
// Add outline
Outline outline = buttonObj.AddComponent<Outline>();
outline.effectColor = BorderColor;
outline.effectDistance = new Vector2(2, 2);
LayoutElement layoutElement = buttonObj.AddComponent<LayoutElement>();
layoutElement.preferredWidth = width;
layoutElement.preferredHeight = height;
// Button text
GameObject textObj = new GameObject("Text");
textObj.transform.SetParent(buttonObj.transform, false);
RectTransform textRect = textObj.AddComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = Vector2.zero;
textRect.offsetMax = Vector2.zero;
TextMeshProUGUI buttonText = textObj.AddComponent<TextMeshProUGUI>();
buttonText.text = text;
buttonText.fontSize = 24;
buttonText.color = ButtonTextColor;
buttonText.alignment = TextAlignmentOptions.Center;
if (font != null) buttonText.font = font;
return buttonObj;
}
private static void WireUpModalPanel(
TutorialModalPanel panel,
GameObject blocker,
GameObject panelContainer,
TMP_FontAsset font) {
panel.ModalBlocker = blocker;
panel.PanelContainer = panelContainer;
// Find and wire up components
Transform containerTransform = panelContainer.transform;
// Title
Transform titleTransform = containerTransform.Find("TitleText");
if (titleTransform != null) {
panel.TitleText = titleTransform.GetComponent<TextMeshProUGUI>();
}
// Icon
Transform iconTransform = containerTransform.Find("IconImage");
if (iconTransform != null) { panel.IconImage = iconTransform.GetComponent<Image>(); }
// Description
Transform descTransform = containerTransform.Find("DescriptionText");
if (descTransform != null) {
panel.DescriptionText = descTransform.GetComponent<TextMeshProUGUI>();
}
// Progress Section
Transform progressSection = containerTransform.Find("ProgressSection");
if (progressSection != null) {
Transform progressTextTransform = progressSection.Find("ProgressText");
if (progressTextTransform != null) {
panel.ProgressText = progressTextTransform.GetComponent<TextMeshProUGUI>();
}
Transform sliderTransform = progressSection.Find("ProgressSlider");
if (sliderTransform != null) {
panel.ProgressSlider = sliderTransform.GetComponent<Slider>();
}
}
// Button Row
Transform buttonRow = containerTransform.Find("ButtonRow");
if (buttonRow != null) {
Transform skipTransform = buttonRow.Find("SkipButton");
if (skipTransform != null) {
panel.SkipButton = skipTransform.GetComponent<Button>();
}
Transform skipAllTransform = buttonRow.Find("SkipAllButton");
if (skipAllTransform != null) {
panel.SkipAllButton = skipAllTransform.GetComponent<Button>();
}
Transform continueTransform = buttonRow.Find("ContinueButton");
if (continueTransform != null) {
panel.ContinueButton = continueTransform.GetComponent<Button>();
Transform continueTextTransform = continueTransform.Find("Text");
if (continueTextTransform != null) {
panel.ContinueButtonText =
continueTextTransform.GetComponent<TextMeshProUGUI>();
}
}
}
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 98467fdbaf584fbb928a373340b542d7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -112,8 +112,7 @@ namespace Eagle0.Tutorial {
ProgressSlider.gameObject.SetActive(totalSteps > 1);
}
// Show panel - ensure all parent containers are active first
ActivateParents();
// Show panel
if (ModalBlocker != null) { ModalBlocker.SetActive(true); }
if (PanelContainer != null) { PanelContainer.SetActive(true); }
gameObject.SetActive(true);
@@ -122,17 +121,6 @@ namespace Eagle0.Tutorial {
if (PanelAnimator != null) { PanelAnimator.SetTrigger("Show"); }
}
/// <summary>
/// Activates all parent GameObjects to ensure this object can be active in hierarchy.
/// </summary>
private void ActivateParents() {
Transform parent = transform.parent;
while (parent != null) {
if (!parent.gameObject.activeSelf) { parent.gameObject.SetActive(true); }
parent = parent.parent;
}
}
/// <summary>
/// Hides the modal panel.
/// </summary>
@@ -147,31 +135,19 @@ namespace Eagle0.Tutorial {
_onSkip = null;
}
/// <summary>
/// Called when Continue button is clicked.
/// Public to allow external setup of button listeners.
/// </summary>
public void OnContinueClicked() {
private void OnContinueClicked() {
var callback = _onContinue;
Hide();
callback?.Invoke();
}
/// <summary>
/// Called when Skip button is clicked.
/// Public to allow external setup of button listeners.
/// </summary>
public void OnSkipClicked() {
private void OnSkipClicked() {
var callback = _onSkip;
Hide();
callback?.Invoke();
}
/// <summary>
/// Called when Skip All button is clicked.
/// Public to allow external setup of button listeners.
/// </summary>
public void OnSkipAllClicked() {
private void OnSkipAllClicked() {
Hide();
TutorialManager.Instance?.SkipAllOnboarding();
}
@@ -1,391 +0,0 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Eagle0.Tutorial {
/// <summary>
/// Builds the tutorial overlay UI programmatically at runtime.
/// Creates highlight frame, tooltip, and dimmer components for TutorialOverlayController.
/// </summary>
public static class TutorialOverlayBuilder {
// Colors matching the game's fantasy RPG style (same as TutorialCanvasBuilder)
private static readonly Color DimmerColor = new Color(0f, 0f, 0f, 0.7f);
private static readonly Color HighlightBorderColor =
new Color(1f, 0.85f, 0.4f, 1f); // Gold
private static readonly Color TooltipBackgroundColor = new Color(0.15f, 0.12f, 0.2f, 0.95f);
private static readonly Color TooltipBorderColor = new Color(0.6f, 0.5f, 0.3f, 1f);
private static readonly Color TitleColor = new Color(1f, 0.9f, 0.7f, 1f);
private static readonly Color TextColor = new Color(0.9f, 0.85f, 0.8f, 1f);
private static readonly Color ButtonColor = new Color(0.4f, 0.3f, 0.2f, 1f);
private static readonly Color ButtonHoverColor = new Color(0.5f, 0.4f, 0.25f, 1f);
private static readonly Color ButtonTextColor = new Color(1f, 0.95f, 0.85f, 1f);
private static readonly Color ArrowColor = new Color(1f, 0.85f, 0.4f, 1f); // Gold arrow
/// <summary>
/// Builds the overlay UI components and returns the configured controller.
/// </summary>
/// <param name="parent">Parent transform (typically the TutorialCanvas)</param>
/// <param name="font">TextMeshPro font to use</param>
/// <returns>The configured TutorialOverlayController</returns>
public static TutorialOverlayController BuildOverlayUI(
Transform parent,
TMP_FontAsset font) {
// Create overlay container - this will be the controller's gameObject
// so show/hide works via gameObject.SetActive()
GameObject overlayContainer = new GameObject("OverlayContainer");
overlayContainer.transform.SetParent(parent, false);
RectTransform containerRect = overlayContainer.AddComponent<RectTransform>();
containerRect.anchorMin = Vector2.zero;
containerRect.anchorMax = Vector2.one;
containerRect.offsetMin = Vector2.zero;
containerRect.offsetMax = Vector2.zero;
// Add the controller component to this container
TutorialOverlayController controller =
overlayContainer.AddComponent<TutorialOverlayController>();
// Create background dimmer
GameObject dimmer = CreateBackgroundDimmer(overlayContainer.transform);
controller.BackgroundDimmer = dimmer.GetComponent<Image>();
// Create highlight frame
GameObject highlightFrame = CreateHighlightFrame(overlayContainer.transform);
controller.HighlightFrame = highlightFrame.GetComponent<RectTransform>();
// Create tooltip container
GameObject tooltip = CreateTooltipContainer(overlayContainer.transform, font);
controller.TooltipContainer = tooltip.GetComponent<RectTransform>();
// Wire up tooltip components
WireUpTooltipComponents(controller, tooltip, font);
// Start hidden
overlayContainer.SetActive(false);
Debug.Log("TutorialOverlayBuilder: Built overlay UI at runtime");
return controller;
}
private static GameObject CreateBackgroundDimmer(Transform parent) {
GameObject dimmer = new GameObject("BackgroundDimmer");
dimmer.transform.SetParent(parent, false);
RectTransform rect = dimmer.AddComponent<RectTransform>();
rect.anchorMin = Vector2.zero;
rect.anchorMax = Vector2.one;
rect.offsetMin = Vector2.zero;
rect.offsetMax = Vector2.zero;
Image image = dimmer.AddComponent<Image>();
image.color = DimmerColor;
image.raycastTarget = true;
return dimmer;
}
private static GameObject CreateHighlightFrame(Transform parent) {
// Create highlight frame with border effect
GameObject frame = new GameObject("HighlightFrame");
frame.transform.SetParent(parent, false);
RectTransform rect = frame.AddComponent<RectTransform>();
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.sizeDelta = new Vector2(200, 100); // Default size, will be adjusted
// Create border using 4 edge images
CreateFrameBorder(
frame.transform,
"TopBorder",
new Vector2(0, 0.5f),
new Vector2(1, 0.5f),
new Vector2(0, -2),
new Vector2(0, 2),
true);
CreateFrameBorder(
frame.transform,
"BottomBorder",
new Vector2(0, -0.5f),
new Vector2(1, -0.5f),
new Vector2(0, -2),
new Vector2(0, 2),
true);
CreateFrameBorder(
frame.transform,
"LeftBorder",
new Vector2(-0.5f, 0),
new Vector2(-0.5f, 1),
new Vector2(-2, 0),
new Vector2(2, 0),
false);
CreateFrameBorder(
frame.transform,
"RightBorder",
new Vector2(0.5f, 0),
new Vector2(0.5f, 1),
new Vector2(-2, 0),
new Vector2(2, 0),
false);
// Add corner decorations
CreateCornerDecoration(
frame.transform,
"TopLeftCorner",
new Vector2(0, 1),
new Vector2(-8, 8));
CreateCornerDecoration(
frame.transform,
"TopRightCorner",
new Vector2(1, 1),
new Vector2(8, 8));
CreateCornerDecoration(
frame.transform,
"BottomLeftCorner",
new Vector2(0, 0),
new Vector2(-8, -8));
CreateCornerDecoration(
frame.transform,
"BottomRightCorner",
new Vector2(1, 0),
new Vector2(8, -8));
return frame;
}
private static void CreateFrameBorder(
Transform parent,
string name,
Vector2 anchorMin,
Vector2 anchorMax,
Vector2 offsetMin,
Vector2 offsetMax,
bool horizontal) {
GameObject border = new GameObject(name);
border.transform.SetParent(parent, false);
RectTransform rect = border.AddComponent<RectTransform>();
if (horizontal) {
rect.anchorMin = new Vector2(0, anchorMin.y + 0.5f);
rect.anchorMax = new Vector2(1, anchorMax.y + 0.5f);
rect.offsetMin = new Vector2(0, offsetMin.y);
rect.offsetMax = new Vector2(0, offsetMax.y);
} else {
rect.anchorMin = new Vector2(anchorMin.x + 0.5f, 0);
rect.anchorMax = new Vector2(anchorMax.x + 0.5f, 1);
rect.offsetMin = new Vector2(offsetMin.x, 0);
rect.offsetMax = new Vector2(offsetMax.x, 0);
}
Image image = border.AddComponent<Image>();
image.color = HighlightBorderColor;
image.raycastTarget = false;
}
private static void
CreateCornerDecoration(Transform parent, string name, Vector2 anchor, Vector2 offset) {
GameObject corner = new GameObject(name);
corner.transform.SetParent(parent, false);
RectTransform rect = corner.AddComponent<RectTransform>();
rect.anchorMin = anchor;
rect.anchorMax = anchor;
rect.sizeDelta = new Vector2(16, 16);
rect.anchoredPosition = offset;
Image image = corner.AddComponent<Image>();
image.color = HighlightBorderColor;
image.raycastTarget = false;
}
private static GameObject CreateTooltipContainer(Transform parent, TMP_FontAsset font) {
GameObject tooltip = new GameObject("TooltipContainer");
tooltip.transform.SetParent(parent, false);
RectTransform rect = tooltip.AddComponent<RectTransform>();
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.sizeDelta = new Vector2(400, 200);
rect.anchoredPosition = new Vector2(0, -150); // Below center by default
// Background
Image bg = tooltip.AddComponent<Image>();
bg.color = TooltipBackgroundColor;
bg.raycastTarget = true;
// Border
Outline outline = tooltip.AddComponent<Outline>();
outline.effectColor = TooltipBorderColor;
outline.effectDistance = new Vector2(2, 2);
// Layout
VerticalLayoutGroup layout = tooltip.AddComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(20, 20, 15, 15);
layout.spacing = 10;
layout.childAlignment = TextAnchor.UpperCenter;
layout.childControlHeight = false;
layout.childControlWidth = true;
layout.childForceExpandHeight = false;
layout.childForceExpandWidth = true;
// Content size fitter to auto-size based on content
ContentSizeFitter fitter = tooltip.AddComponent<ContentSizeFitter>();
fitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
// Title
CreateTooltipTitle(tooltip.transform, font);
// Description
CreateTooltipDescription(tooltip.transform, font);
// Continue button
CreateTooltipButton(tooltip.transform, font);
// Arrow pointer (separate from tooltip, positioned independently)
CreateArrowPointer(parent);
return tooltip;
}
private static void CreateTooltipTitle(Transform parent, TMP_FontAsset font) {
GameObject titleObj = new GameObject("TooltipTitle");
titleObj.transform.SetParent(parent, false);
RectTransform rect = titleObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(360, 35);
TextMeshProUGUI text = titleObj.AddComponent<TextMeshProUGUI>();
text.text = "";
text.fontSize = 28;
text.fontStyle = FontStyles.Bold;
text.color = TitleColor;
text.alignment = TextAlignmentOptions.Center;
text.enableWordWrapping = true;
if (font != null) text.font = font;
LayoutElement layout = titleObj.AddComponent<LayoutElement>();
layout.preferredHeight = 35;
layout.minWidth = 200;
}
private static void CreateTooltipDescription(Transform parent, TMP_FontAsset font) {
GameObject descObj = new GameObject("TooltipDescription");
descObj.transform.SetParent(parent, false);
RectTransform rect = descObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(360, 80);
TextMeshProUGUI text = descObj.AddComponent<TextMeshProUGUI>();
text.text = "";
text.fontSize = 20;
text.color = TextColor;
text.alignment = TextAlignmentOptions.TopLeft;
text.enableWordWrapping = true;
text.overflowMode = TextOverflowModes.Overflow;
if (font != null) text.font = font;
LayoutElement layout = descObj.AddComponent<LayoutElement>();
layout.preferredHeight = 80;
layout.minWidth = 200;
layout.flexibleHeight = 1;
}
private static void CreateTooltipButton(Transform parent, TMP_FontAsset font) {
GameObject buttonObj = new GameObject("ContinueButton");
buttonObj.transform.SetParent(parent, false);
RectTransform rect = buttonObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(140, 40);
Image image = buttonObj.AddComponent<Image>();
image.color = ButtonColor;
Button button = buttonObj.AddComponent<Button>();
ColorBlock colors = button.colors;
colors.normalColor = ButtonColor;
colors.highlightedColor = ButtonHoverColor;
colors.pressedColor = ButtonHoverColor;
colors.selectedColor = ButtonHoverColor;
button.colors = colors;
Outline outline = buttonObj.AddComponent<Outline>();
outline.effectColor = TooltipBorderColor;
outline.effectDistance = new Vector2(1, 1);
LayoutElement layout = buttonObj.AddComponent<LayoutElement>();
layout.preferredWidth = 140;
layout.preferredHeight = 40;
// Button text
GameObject textObj = new GameObject("Text");
textObj.transform.SetParent(buttonObj.transform, false);
RectTransform textRect = textObj.AddComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = Vector2.zero;
textRect.offsetMax = Vector2.zero;
TextMeshProUGUI buttonText = textObj.AddComponent<TextMeshProUGUI>();
buttonText.text = "Got it";
buttonText.fontSize = 20;
buttonText.color = ButtonTextColor;
buttonText.alignment = TextAlignmentOptions.Center;
if (font != null) buttonText.font = font;
}
private static void CreateArrowPointer(Transform parent) {
// Create arrow pointing from tooltip to target
GameObject arrow = new GameObject("ArrowPointer");
arrow.transform.SetParent(parent, false);
RectTransform rect = arrow.AddComponent<RectTransform>();
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.sizeDelta = new Vector2(30, 30);
Image image = arrow.AddComponent<Image>();
image.color = ArrowColor;
image.raycastTarget = false;
// Note: In a real implementation, you'd use a triangle sprite
// For now, we'll use a rotated square as a simple arrow indicator
}
private static void WireUpTooltipComponents(
TutorialOverlayController controller,
GameObject tooltip,
TMP_FontAsset font) {
Transform tooltipTransform = tooltip.transform;
// Title
Transform titleTransform = tooltipTransform.Find("TooltipTitle");
if (titleTransform != null) {
controller.TooltipTitle = titleTransform.GetComponent<TextMeshProUGUI>();
}
// Description
Transform descTransform = tooltipTransform.Find("TooltipDescription");
if (descTransform != null) {
controller.TooltipDescription = descTransform.GetComponent<TextMeshProUGUI>();
}
// Continue button
Transform buttonTransform = tooltipTransform.Find("ContinueButton");
if (buttonTransform != null) {
controller.ContinueButton = buttonTransform.GetComponent<Button>();
// Wire up click listener
controller.ContinueButton.onClick.RemoveAllListeners();
controller.ContinueButton.onClick.AddListener(controller.OnContinueClicked);
}
// Arrow pointer (sibling of tooltip)
Transform arrowTransform = tooltip.transform.parent.Find("ArrowPointer");
if (arrowTransform != null) {
controller.ArrowPointer = arrowTransform.GetComponent<Image>();
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: d58277ecfc2d84d30a7ca71b8ccb994e
@@ -65,8 +65,8 @@ namespace Eagle0.Tutorial {
if (ContinueButton != null) { ContinueButton.onClick.AddListener(OnContinueClicked); }
// Note: Don't hide here - let the builder or manual setup handle initial visibility.
// When built at runtime, TutorialOverlayBuilder sets inactive after wiring up.
// Start hidden
gameObject.SetActive(false);
}
/// <summary>
@@ -100,8 +100,7 @@ namespace Eagle0.Tutorial {
CenterTooltip();
}
// Show - ensure all parent containers are active first
ActivateParents();
// Show
gameObject.SetActive(true);
StartCoroutine(FadeIn());
@@ -124,8 +123,6 @@ namespace Eagle0.Tutorial {
if (TooltipContainer != null) { TooltipContainer.gameObject.SetActive(false); }
if (BackgroundDimmer != null) { BackgroundDimmer.gameObject.SetActive(false); }
// Ensure all parent containers are active first
ActivateParents();
gameObject.SetActive(true);
_pulseCoroutine = StartCoroutine(PulseHighlight());
}
@@ -153,15 +150,7 @@ namespace Eagle0.Tutorial {
_pulseCoroutine = null;
}
// If we can start coroutines, fade out nicely
if (gameObject.activeInHierarchy) {
StartCoroutine(FadeOut());
} else {
// Otherwise just deactivate immediately
gameObject.SetActive(false);
_currentTarget = null;
_onComplete = null;
}
StartCoroutine(FadeOut());
}
private void PositionHighlight(Transform target) {
@@ -272,11 +261,7 @@ namespace Eagle0.Tutorial {
}
}
/// <summary>
/// Called when the continue button is clicked.
/// Made public for external button wiring.
/// </summary>
public void OnContinueClicked() {
private void OnContinueClicked() {
var callback = _onComplete;
HideOverlay();
callback?.Invoke();
@@ -289,16 +274,5 @@ namespace Eagle0.Tutorial {
PositionHighlight(_currentTarget);
}
}
/// <summary>
/// Activates all parent GameObjects to ensure this object can be active in hierarchy.
/// </summary>
private void ActivateParents() {
Transform parent = transform.parent;
while (parent != null) {
if (!parent.gameObject.activeSelf) { parent.gameObject.SetActive(true); }
parent = parent.parent;
}
}
}
}
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
namespace Eagle0.Tutorial {
@@ -23,212 +22,17 @@ namespace Eagle0.Tutorial {
[Tooltip("Canvas for tutorial UI (should be above game UI)")]
public Canvas TutorialCanvas;
[Header("Fonts")]
[Tooltip("TextMeshPro font for Canvas UI (assign Stoke-Regular-SDF)")]
public TMP_FontAsset CanvasFont;
[Header("Fallback UI")]
[Tooltip("Use IMGUI fallback when Canvas UI cannot be built")]
public bool UseFallbackUI = true;
[Tooltip("Font for IMGUI fallback (assign Stoke-Regular)")]
public Font FallbackFont;
[Header("Runtime Options")]
[Tooltip("Auto-build Canvas UI at runtime if ModalPanel is not assigned")]
public bool AutoBuildCanvasUI = true;
// Active hints
private Dictionary<string, TutorialHintIndicator> _activeHints =
new Dictionary<string, TutorialHintIndicator>();
// Fallback modal state
private bool _fallbackModalVisible;
private TutorialStep _fallbackStep;
private Action _fallbackOnContinue;
private Action _fallbackOnSkip;
private int _fallbackCurrentIndex;
private int _fallbackTotalSteps;
private bool _fallbackIsOnboarding;
private void Awake() {
// Auto-build Canvas UI if needed
if (ModalPanel == null && AutoBuildCanvasUI) { BuildCanvasUI(); }
// Auto-build overlay UI if needed
if (OverlayController == null && AutoBuildCanvasUI && TutorialCanvas != null) {
BuildOverlayUI();
}
// Ensure canvas is set up correctly
if (TutorialCanvas != null) {
TutorialCanvas.sortingOrder = 1000; // Above most game UI
}
}
/// <summary>
/// Builds the Canvas-based tutorial UI at runtime.
/// Called automatically if AutoBuildCanvasUI is true and ModalPanel is not assigned.
/// </summary>
private void BuildCanvasUI() {
TutorialCanvas = TutorialCanvasBuilder.BuildTutorialCanvas(CanvasFont);
if (TutorialCanvas != null) {
// Make canvas a child of this object so it persists with TutorialManager
TutorialCanvas.transform.SetParent(transform, false);
// Get the modal panel that was created
ModalPanel = TutorialCanvas.GetComponent<TutorialModalPanel>();
Debug.Log("TutorialUIManager: Built Canvas UI at runtime");
} else {
Debug.LogWarning("TutorialUIManager: Failed to build Canvas UI");
}
}
/// <summary>
/// Builds the overlay UI components at runtime.
/// Called automatically if AutoBuildCanvasUI is true and OverlayController is not assigned.
/// </summary>
private void BuildOverlayUI() {
if (TutorialCanvas == null) {
Debug.LogWarning(
"TutorialUIManager: Cannot build overlay UI without TutorialCanvas");
return;
}
// Build the UI elements and get the controller
OverlayController =
TutorialOverlayBuilder.BuildOverlayUI(TutorialCanvas.transform, CanvasFont);
Debug.Log("TutorialUIManager: Built overlay UI at runtime");
}
private void OnGUI() {
if (!_fallbackModalVisible || _fallbackStep == null) return;
// Semi-transparent background
GUI.color = new Color(0, 0, 0, 0.7f);
GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), Texture2D.whiteTexture);
GUI.color = Color.white;
// Modal window
float windowWidth = Mathf.Min(1200, Screen.width - 40);
float windowHeight = 600;
float windowX = (Screen.width - windowWidth) / 2;
float windowY = (Screen.height - windowHeight) / 2;
GUIStyle windowStyle = new GUIStyle(GUI.skin.window);
windowStyle.fontSize = 48;
if (FallbackFont != null) windowStyle.font = FallbackFont;
GUI.Window(
12345,
new Rect(windowX, windowY, windowWidth, windowHeight),
DrawFallbackModal,
_fallbackStep.Title ?? "Tutorial",
windowStyle);
}
private void DrawFallbackModal(int windowId) {
GUILayout.Space(60);
// Description
GUIStyle descStyle = new GUIStyle(GUI.skin.label);
descStyle.wordWrap = true;
descStyle.fontSize = 40;
if (FallbackFont != null) descStyle.font = FallbackFont;
GUILayout.Label(
_fallbackStep.Description ?? "",
descStyle,
GUILayout.ExpandHeight(true));
GUILayout.FlexibleSpace();
// Progress
if (_fallbackTotalSteps > 1) {
GUIStyle progressStyle = new GUIStyle(GUI.skin.label);
progressStyle.fontSize = 32;
if (FallbackFont != null) progressStyle.font = FallbackFont;
GUILayout.Label(
$"Step {_fallbackCurrentIndex + 1} of {_fallbackTotalSteps}",
progressStyle,
GUILayout.ExpandWidth(true));
}
GUILayout.Space(30);
// Button style
GUIStyle buttonStyle = new GUIStyle(GUI.skin.button);
buttonStyle.fontSize = 36;
if (FallbackFont != null) buttonStyle.font = FallbackFont;
// Buttons
GUILayout.BeginHorizontal();
if (_fallbackStep.AllowSkip) {
if (GUILayout.Button(
"Skip",
buttonStyle,
GUILayout.Width(200),
GUILayout.Height(80))) {
var callback = _fallbackOnSkip;
HideFallbackModal();
callback?.Invoke();
}
if (_fallbackIsOnboarding) {
if (GUILayout.Button(
"Skip All",
buttonStyle,
GUILayout.Width(200),
GUILayout.Height(80))) {
HideFallbackModal();
TutorialManager.Instance?.SkipAllOnboarding();
}
}
}
GUILayout.FlexibleSpace();
string buttonText =
_fallbackCurrentIndex >= _fallbackTotalSteps - 1 ? "Got it!" : "Continue";
if (GUILayout.Button(
buttonText,
buttonStyle,
GUILayout.Width(240),
GUILayout.Height(80))) {
var callback = _fallbackOnContinue;
HideFallbackModal();
callback?.Invoke();
}
GUILayout.EndHorizontal();
GUILayout.Space(30);
}
private void ShowFallbackModal(
TutorialStep step,
int currentIndex,
int totalSteps,
Action onContinue,
Action onSkip,
bool isOnboarding) {
_fallbackStep = step;
_fallbackCurrentIndex = currentIndex;
_fallbackTotalSteps = totalSteps;
_fallbackOnContinue = onContinue;
_fallbackOnSkip = onSkip;
_fallbackIsOnboarding = isOnboarding;
_fallbackModalVisible = true;
}
private void HideFallbackModal() {
_fallbackModalVisible = false;
_fallbackStep = null;
_fallbackOnContinue = null;
_fallbackOnSkip = null;
}
/// <summary>
/// Shows a modal dialog for a tutorial step.
/// </summary>
@@ -239,18 +43,13 @@ namespace Eagle0.Tutorial {
Action onComplete,
Action onSkip,
bool isOnboarding) {
if (ModalPanel != null) {
// Ensure canvas is active
if (TutorialCanvas != null && !TutorialCanvas.gameObject.activeSelf) {
TutorialCanvas.gameObject.SetActive(true);
}
ModalPanel.Show(step, currentIndex, totalSteps, onComplete, onSkip, isOnboarding);
} else if (UseFallbackUI) {
ShowFallbackModal(step, currentIndex, totalSteps, onComplete, onSkip, isOnboarding);
} else {
Debug.LogWarning("TutorialUIManager: No ModalPanel assigned and fallback disabled");
if (ModalPanel == null) {
Debug.LogWarning("TutorialUIManager: No ModalPanel assigned");
onComplete?.Invoke();
return;
}
ModalPanel.Show(step, currentIndex, totalSteps, onComplete, onSkip, isOnboarding);
}
/// <summary>
@@ -349,7 +148,6 @@ namespace Eagle0.Tutorial {
public void HideAll() {
ModalPanel?.Hide();
OverlayController?.HideOverlay();
HideFallbackModal();
// Note: Don't hide hints automatically - they persist until dismissed
}
@@ -136,6 +136,9 @@
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\common\unaffiliated_hero_type.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\">
<Link>src\main\protobuf\net\eagle0\eagle\common\unaffiliated_hero_type.proto</Link>
</Protobuf>
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\internal\unaffiliated_hero.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\">
<Link>src\main\protobuf\net\eagle0\eagle\internal\unaffiliated_hero.proto</Link>
</Protobuf>
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\common\chronicle_entry.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\">
<Link>src\main\protobuf\net\eagle0\eagle\common\chronicle_entry.proto</Link>
</Protobuf>
@@ -14,11 +14,6 @@
<EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup>
<ItemGroup>
<!-- Ed25519 signature verification for manifest -->
<PackageReference Include="NSec.Cryptography" Version="24.4.0" />
</ItemGroup>
<ItemGroup>
<None Remove="AWSSDK.S3" />
</ItemGroup>
@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Threading.Tasks;
using System.Threading;
@@ -11,9 +10,6 @@ namespace EagleInstaller {
internal class UpdaterFailureException
(string message) : Exception(message);
internal class ManifestSecurityException
(string message) : Exception(message);
public class InstallerUpdateInfo {
public bool InstallerNeedsUpdate { get; set; }
public string NewInstallerVersion { get; set; }
@@ -70,105 +66,14 @@ namespace EagleInstaller {
using (StringReader sr = new StringReader(configText)) {
string line;
while ((line = sr.ReadLine()) != null) {
// Skip empty lines and comments
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("#")) {
continue;
}
var idx = line.IndexOf(" = ");
if (idx > 0) {
var key = line.Substring(0, idx);
var value = line.Substring(idx + 3);
dict[key] = value;
}
var components = line.Split(" = ");
dict.Add(components[0], components[1]);
}
}
return dict;
}
// Ed25519 public key for manifest signature verification (base64 encoded)
// Generated with: go run scripts/generate_manifest_keys.go
// This key should be updated when a new key pair is generated
private static readonly string ManifestPublicKeyB64 =
ConfigFileEntries.TryGetValue("manifest_public_key", out var key) ? key : null;
/// <summary>
/// Verifies the Ed25519 signature on a manifest.
/// Returns the manifest content (without signature line) if valid.
/// Throws ManifestSecurityException if signature is missing, invalid, or cannot be
/// verified.
/// </summary>
private static string VerifyManifestSignature(string manifestText) {
if (string.IsNullOrEmpty(manifestText)) {
throw new ManifestSecurityException("Manifest is empty");
}
// Require public key to be configured
if (string.IsNullOrEmpty(ManifestPublicKeyB64)) {
Logger.WriteError(
"SECURITY ERROR: No public key configured for manifest verification");
throw new ManifestSecurityException(
"No public key configured for manifest verification");
}
// Check for signature line at start: # signature=<base64>
var lines = manifestText.Split('\n');
if (lines.Length == 0 || !lines[0].TrimStart().StartsWith("# signature=")) {
Logger.WriteError("SECURITY ERROR: Manifest is not signed");
throw new ManifestSecurityException("Manifest is not signed");
}
// Extract signature
string signatureLine = lines[0];
string signatureB64 = signatureLine.Substring(signatureLine.IndexOf('=') + 1).Trim();
// Content is everything after the signature line
string content = string.Join('\n', lines.Skip(1));
try {
byte[] signatureBytes = Convert.FromBase64String(signatureB64);
byte[] publicKeyBytes = Convert.FromBase64String(ManifestPublicKeyB64);
byte[] contentBytes = System.Text.Encoding.UTF8.GetBytes(content);
// Verify using Ed25519
bool valid = VerifyEd25519Signature(publicKeyBytes, contentBytes, signatureBytes);
if (valid) {
Logger.WriteLine("Manifest signature verified successfully");
return content;
} else {
Logger.WriteError("SECURITY ERROR: Manifest signature is INVALID!");
Logger.WriteError("This could indicate a compromised or tampered manifest.");
throw new ManifestSecurityException("Manifest signature verification failed");
}
} catch (ManifestSecurityException) {
throw; // Re-throw security exceptions
} catch (Exception e) {
Logger.WriteError(
$"SECURITY ERROR: Failed to verify manifest signature: {e.Message}");
throw new ManifestSecurityException(
$"Manifest signature verification error: {e.Message}");
}
}
/// <summary>
/// Verifies an Ed25519 signature using the NSec library.
/// </summary>
private static bool
VerifyEd25519Signature(byte[] publicKeyBytes, byte[] data, byte[] signature) {
try {
var algorithm = NSec.Cryptography.SignatureAlgorithm.Ed25519;
var publicKey = NSec.Cryptography.PublicKey.Import(
algorithm,
publicKeyBytes,
NSec.Cryptography.KeyBlobFormat.RawPublicKey);
return algorithm.Verify(publicKey, data, signature);
} catch (Exception e) {
Logger.WriteError($"Ed25519 verification error: {e.Message}");
return false;
}
}
public EagleUpdater(string serverUrl) {
if (string.IsNullOrEmpty(serverUrl)) {
throw new ArgumentNullException(nameof(serverUrl));
@@ -341,11 +246,7 @@ namespace EagleInstaller {
response.EnsureSuccessStatusCode();
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
using StreamReader reader = new StreamReader(responseStream);
string rawManifest = await reader.ReadToEndAsync();
// Verify signature and extract content (strips signature line if present)
// Throws ManifestSecurityException if public key is configured and verification fails
return VerifyManifestSignature(rawManifest);
return await reader.ReadToEndAsync();
}
static Dictionary<String, String> ShasFromText(string text) {
@@ -376,7 +277,6 @@ namespace EagleInstaller {
async Task<FetchAttempt>
FetchAndWriteOne(string remotePath, string localDir, string expectedSha) {
string writePath = WritePathFromRemotePath(remotePath, localDir);
string tempPath = writePath + ".downloading";
Directory.CreateDirectory(Path.GetDirectoryName(writePath));
@@ -388,67 +288,41 @@ namespace EagleInstaller {
response.EnsureSuccessStatusCode();
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
await using FileStream fileStream = File.Create(writePath);
using MemoryStream memStream = new MemoryStream();
await responseStream.CopyToAsync(memStream);
memStream.Position = 0;
// Stream directly to disk while computing SHA256 incrementally
using var sha = System.Security.Cryptography.SHA256.Create();
await using (FileStream fileStream = File.Create(tempPath)) {
var buffer = new byte[81920]; // 80KB buffer
int bytesRead;
while ((bytesRead = await responseStream.ReadAsync(buffer, 0, buffer.Length)) >
0) {
// Write to disk
await fileStream.WriteAsync(buffer, 0, bytesRead);
// Update hash incrementally
sha.TransformBlock(buffer, 0, bytesRead, null, 0);
}
}
// Finalize hash computation
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
string computedSha =
BitConverter.ToString(sha.Hash).Replace("-", string.Empty).ToLower();
var sha = System.Security.Cryptography.SHA256.Create();
string computedSha = BitConverter.ToString(sha.ComputeHash(memStream))
.Replace("-", string.Empty)
.ToLower();
if (computedSha.Equals(expectedSha)) {
// SHA matches - rename temp file to final location
if (File.Exists(writePath)) { File.Delete(writePath); }
File.Move(tempPath, writePath);
Logger.WriteLine($"✓ Verified and saved: {remotePath}");
memStream.Position = 0;
await memStream.CopyToAsync(fileStream);
return new FetchAttempt {
Success = true,
Kvp = new KeyValuePair<String, String>(remotePath, computedSha)
};
} else {
// SHA mismatch - delete temp file
try {
File.Delete(tempPath);
} catch {}
Logger.WriteError($"✗ SHA mismatch for {remotePath}");
return new FetchAttempt() { Success = false };
}
} catch (TaskCanceledException e) {
// Clean up temp file on failure
try {
File.Delete(tempPath);
} catch {}
Logger.WriteError($"Download cancelled: {e.Message}");
return new FetchAttempt() { Success = false };
} catch (HttpRequestException e) {
try {
File.Delete(tempPath);
} catch {}
Logger.WriteError($"Network error: {e.Message}");
return new FetchAttempt() { Success = false };
} catch (Exception e) {
try {
File.Delete(tempPath);
} catch {}
Logger.WriteError($"Download failed: {e.Message}");
return new FetchAttempt() { Success = false };
} finally { Pool.Release(); }
}
public async Task<bool> DownloadAndLaunchNewInstaller(
string installerUrl,
string expectedSha) {
public async Task<bool> DownloadAndLaunchNewInstaller(string installerUrl) {
try {
Logger.UpdateStatus("Downloading new installer...");
@@ -498,28 +372,6 @@ namespace EagleInstaller {
}
}
// Verify SHA256 before launching
Logger.UpdateStatus("Verifying installer integrity...");
string computedSha;
using (var sha256 = System.Security.Cryptography.SHA256.Create()) using (
var stream = File.OpenRead(newInstallerPath)) {
var hashBytes = sha256.ComputeHash(stream);
computedSha =
BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLower();
}
if (!computedSha.Equals(expectedSha, StringComparison.OrdinalIgnoreCase)) {
Logger.WriteError($"SECURITY ERROR: Installer SHA mismatch!");
Logger.WriteError($" Expected: {expectedSha}");
Logger.WriteError($" Got: {computedSha}");
// Delete the corrupted/tampered file
try {
File.Delete(newInstallerPath);
} catch {}
return false;
}
Logger.WriteLine($"✓ Installer verified: SHA256 matches");
// Wait a moment to ensure file handles are released
await Task.Delay(500);
@@ -110,8 +110,7 @@ namespace EagleInstaller {
if (installerUpdateInfo.InstallerNeedsUpdate) {
Logger.UpdateStatus("Installer update required...");
bool downloadSuccess = await updater.DownloadAndLaunchNewInstaller(
installerUpdateInfo.NewInstallerUrl,
installerUpdateInfo.NewInstallerVersion);
installerUpdateInfo.NewInstallerUrl);
if (downloadSuccess) {
Logger.UpdateStatus("New installer launched. Exiting current version.");
@@ -1,8 +1,3 @@
manifest_name = eagle0_manifest.txt
remote_manifest_prefix = installer/
remote_asset_prefix = unity3d/win/
# Ed25519 public key for manifest signature verification (base64)
# Generate key pair with: go run scripts/generate_manifest_keys.go
# Copy the public key here after generating
# manifest_public_key = YOUR_PUBLIC_KEY_HERE
@@ -1666,8 +1666,6 @@ func handleUserRoutes(w http.ResponseWriter, r *http.Request) {
handleSetUserDisplayName(w, r, userID)
case "set-admin":
handleSetUserAdmin(w, r, userID)
case "delete":
handleDeleteUser(w, r, userID)
default:
http.NotFound(w, r)
}
@@ -1820,38 +1818,6 @@ func handleSetUserAdmin(w http.ResponseWriter, r *http.Request, userID string) {
fmt.Fprintf(w, `<div class="alert alert-success">Admin status %s</div>`, status)
}
func handleDeleteUser(w http.ResponseWriter, r *http.Request, userID string) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := createAdminContext(r)
defer cancel()
resp, err := adminClient.DeleteUser(ctx, &adminpb.DeleteUserRequest{
UserId: userID,
})
if err != nil {
log.Printf("Failed to delete user: %v", err)
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusInternalServerError)
fmt.Fprintf(w, `<div class="alert alert-error">Error: %v</div>`, err)
return
}
if !resp.Success {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, resp.ErrorMessage)
return
}
w.Header().Set("HX-Trigger", "userUpdated")
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<div class="alert alert-success">User deleted</div>`)
}
// Invitation management handlers
// InvitationInfo represents an invitation for display
@@ -2005,8 +1971,6 @@ func handleInvitationRoutes(w http.ResponseWriter, r *http.Request) {
handleResendInvitation(w, r, code)
case "revoke":
handleRevokeInvitation(w, r, code)
case "delete":
handleDeleteInvitation(w, r, code)
default:
http.NotFound(w, r)
}
@@ -2177,36 +2141,6 @@ func handleRevokeInvitation(w http.ResponseWriter, r *http.Request, code string)
fmt.Fprint(w, `<div class="alert alert-success">Invitation revoked</div>`)
}
func handleDeleteInvitation(w http.ResponseWriter, r *http.Request, code string) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := createAdminContext(r)
defer cancel()
resp, err := adminClient.DeleteInvitation(ctx, &adminpb.DeleteInvitationRequest{
InvitationCode: code,
})
if err != nil {
log.Printf("Failed to delete invitation: %v", err)
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">Error: %v</div>`, err)
return
}
if !resp.Success {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, resp.ErrorMessage)
return
}
w.Header().Set("HX-Trigger", "invitationUpdated")
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<div class="alert alert-success">Invitation deleted</div>`)
}
// Authentication handlers
const (
@@ -109,192 +109,29 @@ function cancelUpload() {
</script>
{{if .Games}}
<div id="batch-actions" class="batch-actions" style="display: none; margin-bottom: 1rem; padding: 0.75rem; background: #f8f9fa; border-radius: 4px;">
<span id="selected-count">0</span> game(s) selected
<button class="btn-small btn-danger" onclick="showBatchDeleteModal()" style="margin-left: 1rem;">Delete Selected</button>
<button class="btn-small btn-secondary" onclick="clearSelection()" style="margin-left: 0.5rem;">Clear</button>
</div>
{{range .Games}}
<article class="game-card">
<div style="display: flex; align-items: flex-start; gap: 0.75rem;">
<input type="checkbox" class="game-checkbox" data-game-id="{{.GameID}}" onchange="updateSelection()" style="margin-top: 0.3rem; width: 18px; height: 18px;">
<div style="flex: 1;">
<h3>
<a href="/games/{{.GameID}}">Game {{.GameID}}</a>
<span class="status-badge {{if eq .RunStatus "Running"}}running{{else}}finished{{end}}">
{{.RunStatus}}
</span>
</h3>
<div class="meta">
Round {{.CurrentRound}} &bull; {{.ActionCount}} actions
</div>
<div class="players">
{{range .Players}}
<span class="player-badge {{if .IsHuman}}human{{else}}ai{{end}}">
{{.FactionName}}{{if .IsHuman}} ({{.UserName}}){{end}}
</span>
{{end}}
</div>
<div class="actions">
<a href="/games/{{.GameID}}" role="button" class="outline">View History</a>
<button class="btn-small btn-danger" onclick="showDeleteModal('{{.GameID}}')">Delete</button>
</div>
</div>
<h3>
<a href="/games/{{.GameID}}">Game {{.GameID}}</a>
<span class="status-badge {{if eq .RunStatus "Running"}}running{{else}}finished{{end}}">
{{.RunStatus}}
</span>
</h3>
<div class="meta">
Round {{.CurrentRound}} &bull; {{.ActionCount}} actions
</div>
<div class="players">
{{range .Players}}
<span class="player-badge {{if .IsHuman}}human{{else}}ai{{end}}">
{{.FactionName}}{{if .IsHuman}} ({{.UserName}}){{end}}
</span>
{{end}}
</div>
<div class="actions">
<a href="/games/{{.GameID}}" role="button" class="outline">View History</a>
</div>
</article>
{{end}}
<!-- Delete Game Modal -->
<dialog id="delete-game-modal">
<form method="dialog">
<h3>Delete Game</h3>
<p>Are you sure you want to delete game <strong id="delete-game-id-display"></strong>?</p>
<div class="form-group">
<label>
<input type="checkbox" id="delete-save-files" name="delete_save_files">
Also delete save files from disk
</label>
</div>
<p class="warning" style="color: #e74c3c; font-size: 0.9em;">
<strong>Warning:</strong> This action cannot be undone.
</p>
<input type="hidden" id="delete-game-id" name="game_id">
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="document.getElementById('delete-game-modal').close()">Cancel</button>
<button type="button" class="btn-danger" onclick="submitDeleteGame()">Delete</button>
</div>
</form>
</dialog>
<div id="delete-result"></div>
<!-- Batch Delete Modal -->
<dialog id="batch-delete-modal">
<form method="dialog">
<h3>Delete Multiple Games</h3>
<p>Are you sure you want to delete <strong id="batch-delete-count"></strong> game(s)?</p>
<div class="form-group">
<label>
<input type="checkbox" id="batch-delete-save-files" name="delete_save_files">
Also delete save files from disk
</label>
</div>
<p class="warning" style="color: #e74c3c; font-size: 0.9em;">
<strong>Warning:</strong> This action cannot be undone.
</p>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="document.getElementById('batch-delete-modal').close()">Cancel</button>
<button type="button" class="btn-danger" onclick="submitBatchDelete()">Delete All</button>
</div>
</form>
</dialog>
<script>
function getSelectedGameIds() {
var checkboxes = document.querySelectorAll('.game-checkbox:checked');
var ids = [];
checkboxes.forEach(function(cb) {
ids.push(cb.getAttribute('data-game-id'));
});
return ids;
}
function updateSelection() {
var selectedIds = getSelectedGameIds();
var batchActions = document.getElementById('batch-actions');
var selectedCount = document.getElementById('selected-count');
if (selectedIds.length > 0) {
batchActions.style.display = 'block';
selectedCount.textContent = selectedIds.length;
} else {
batchActions.style.display = 'none';
}
}
function clearSelection() {
var checkboxes = document.querySelectorAll('.game-checkbox');
checkboxes.forEach(function(cb) {
cb.checked = false;
});
updateSelection();
}
function showBatchDeleteModal() {
var selectedIds = getSelectedGameIds();
if (selectedIds.length === 0) {
alert('No games selected');
return;
}
document.getElementById('batch-delete-count').textContent = selectedIds.length;
document.getElementById('batch-delete-save-files').checked = false;
document.getElementById('batch-delete-modal').showModal();
}
function submitBatchDelete() {
var selectedIds = getSelectedGameIds();
var deleteSaveFiles = document.getElementById('batch-delete-save-files').checked;
document.getElementById('batch-delete-modal').close();
document.getElementById('delete-result').innerHTML = '<div class="alert">Deleting ' + selectedIds.length + ' game(s)...</div>';
var promises = selectedIds.map(function(gameId) {
return fetch('/games/' + gameId + '/delete', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'delete_save_files=' + (deleteSaveFiles ? 'true' : 'false')
});
});
Promise.all(promises).then(function(responses) {
var failed = responses.filter(function(r) { return !r.ok; });
if (failed.length === 0) {
window.location.reload();
} else {
document.getElementById('delete-result').innerHTML =
'<div class="alert alert-error">' + failed.length + ' of ' + selectedIds.length + ' deletions failed. Refresh to see current state.</div>';
}
}).catch(function(error) {
document.getElementById('delete-result').innerHTML =
'<div class="alert alert-error">Batch delete failed: ' + error + '</div>';
});
}
function showDeleteModal(gameId) {
document.getElementById('delete-game-id').value = gameId;
document.getElementById('delete-game-id-display').textContent = gameId;
document.getElementById('delete-save-files').checked = false;
document.getElementById('delete-game-modal').showModal();
}
function submitDeleteGame() {
const gameId = document.getElementById('delete-game-id').value;
const deleteSaveFiles = document.getElementById('delete-save-files').checked;
fetch('/games/' + gameId + '/delete', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'delete_save_files=' + (deleteSaveFiles ? 'true' : 'false')
}).then(function(response) {
if (response.ok) {
// Reload page to show updated list
window.location.reload();
} else {
return response.text().then(function(text) {
document.getElementById('delete-result').innerHTML =
'<div class="alert alert-error">' + text + '</div>';
});
}
}).catch(function(error) {
document.getElementById('delete-result').innerHTML =
'<div class="alert alert-error">Delete failed: ' + error + '</div>';
});
document.getElementById('delete-game-modal').close();
}
</script>
{{else}}
<div class="empty-state">
<p>No games currently running.</p>
@@ -83,13 +83,7 @@
Copy Code
</button>
{{else}}
<button class="btn-small btn-danger"
hx-post="/invitations/{{.InvitationCode}}/delete"
hx-target="#feedback"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to permanently delete this invitation?">
Delete
</button>
<span class="no-actions">-</span>
{{end}}
</td>
</tr>
@@ -158,8 +152,7 @@ document.body.addEventListener('htmx:afterRequest', function(evt) {
}, 1500);
}
} else if (evt.detail.pathInfo.requestPath.includes('/revoke') ||
evt.detail.pathInfo.requestPath.includes('/resend') ||
evt.detail.pathInfo.requestPath.includes('/delete')) {
evt.detail.pathInfo.requestPath.includes('/resend')) {
if (evt.detail.successful) {
htmx.trigger(document.getElementById('invitations-table-body'), 'invitationUpdated');
}
@@ -32,13 +32,7 @@
Copy Code
</button>
{{else}}
<button class="btn-small btn-danger"
hx-post="/invitations/{{.InvitationCode}}/delete"
hx-target="#feedback"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to permanently delete this invitation?">
Delete
</button>
<span class="no-actions">-</span>
{{end}}
</td>
</tr>
@@ -26,20 +26,6 @@
<main class="container">
{{template "content" .}}
</main>
<!-- JFR Stop Dialog -->
<dialog id="jfr-stop-dialog">
<article style="max-width: 400px;">
<h3>Stop JFR Recording</h3>
<p>Download the recording before stopping?</p>
<footer style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button class="secondary outline" onclick="jfrStopCancel()">Cancel</button>
<button class="secondary" onclick="jfrStopOnly()">Stop Only</button>
<button onclick="jfrStopWithDownload()">Download & Stop</button>
</footer>
</article>
</dialog>
<script>
// Handle JFR status response and update controls
document.body.addEventListener('htmx:afterRequest', function(evt) {
@@ -68,7 +54,7 @@
if (isRecording) {
container.innerHTML = `
<span class="jfr-status jfr-on">JFR: ON</span>
<button onclick="stopJfr()" class="btn-small secondary">Stop</button>
<button hx-post="/jfr/stop" hx-swap="none" class="btn-small secondary">Stop</button>
<a href="/jfr/download" class="btn-small">Download</a>
`;
} else {
@@ -79,31 +65,6 @@
}
htmx.process(container);
}
function stopJfr() {
// Show dialog to offer download before stopping
const dialog = document.getElementById('jfr-stop-dialog');
dialog.showModal();
}
function jfrStopWithDownload() {
document.getElementById('jfr-stop-dialog').close();
// Trigger download in new tab, then stop
window.open('/jfr/download', '_blank');
// Small delay to ensure download starts before stopping
setTimeout(function() {
htmx.ajax('POST', '/jfr/stop', {target: '#jfr-controls', swap: 'none'});
}, 500);
}
function jfrStopOnly() {
document.getElementById('jfr-stop-dialog').close();
htmx.ajax('POST', '/jfr/stop', {target: '#jfr-controls', swap: 'none'});
}
function jfrStopCancel() {
document.getElementById('jfr-stop-dialog').close();
}
</script>
</body>
</html>
@@ -65,13 +65,6 @@
onclick="openEditModal('{{.UserID}}', '{{.DisplayName}}', {{.IsAdmin}})">
Edit
</button>
<button class="btn-small btn-danger"
hx-post="/users/{{.UserID}}/delete"
hx-target="#feedback"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to permanently delete this user? This cannot be undone.">
Delete
</button>
</td>
</tr>
{{end}}
@@ -266,22 +259,6 @@ function saveUserChanges() {
font-size: 0.85rem;
}
.btn-danger {
background-color: #dc3545;
border-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
border-color: #bd2130;
}
.user-actions {
display: flex;
gap: 0.25rem;
}
#edit-modal article {
max-width: 500px;
}
@@ -30,13 +30,6 @@
onclick="openEditModal('{{.UserID}}', '{{.DisplayName}}', {{.IsAdmin}})">
Edit
</button>
<button class="btn-small btn-danger"
hx-post="/users/{{.UserID}}/delete"
hx-target="#feedback"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to permanently delete this user? This cannot be undone.">
Delete
</button>
</td>
</tr>
{{end}}
@@ -350,51 +350,3 @@ func (h *AdminHandler) internalStatusToAdmin(status userpb.InvitationStatus) adm
return adminpb.InvitationStatus_INVITATION_STATUS_UNSPECIFIED
}
}
// DeleteUser permanently deletes a user
func (h *AdminHandler) DeleteUser(ctx context.Context, req *adminpb.DeleteUserRequest) (*adminpb.DeleteUserResponse, error) {
adminUserID, err := h.requireAdmin(ctx)
if err != nil {
return nil, err
}
log.Printf("[Admin] DeleteUser called by %s for user %s", adminUserID, req.UserId)
// Prevent self-deletion
if req.UserId == adminUserID {
return &adminpb.DeleteUserResponse{
Success: false,
ErrorMessage: "cannot delete your own account",
}, nil
}
if err := h.userService.Delete(req.UserId); err != nil {
return &adminpb.DeleteUserResponse{
Success: false,
ErrorMessage: err.Error(),
}, nil
}
return &adminpb.DeleteUserResponse{
Success: true,
}, nil
}
// DeleteInvitation permanently deletes an invitation (only non-pending)
func (h *AdminHandler) DeleteInvitation(ctx context.Context, req *adminpb.DeleteInvitationRequest) (*adminpb.DeleteInvitationResponse, error) {
adminUserID, err := h.requireAdmin(ctx)
if err != nil {
return nil, err
}
log.Printf("[Admin] DeleteInvitation called by %s for code=%s...", adminUserID, req.InvitationCode[:8])
if err := h.invitationService.Delete(req.InvitationCode); err != nil {
return &adminpb.DeleteInvitationResponse{
Success: false,
ErrorMessage: err.Error(),
}, nil
}
return &adminpb.DeleteInvitationResponse{
Success: true,
}, nil
}
@@ -17,7 +17,6 @@ import (
type InvitationHTTPHandler struct {
invitationService *InvitationService
installerURL string
macInstallerURL string
}
// NewInvitationHTTPHandler creates a new invitation HTTP handler
@@ -26,14 +25,9 @@ func NewInvitationHTTPHandler(invitationService *InvitationService) *InvitationH
if installerURL == "" {
installerURL = "https://assets.eagle0.net/installer/EagleInstaller.exe"
}
macInstallerURL := os.Getenv("MAC_INSTALLER_URL")
if macInstallerURL == "" {
macInstallerURL = "https://assets.eagle0.net/mac/builds/eagle0-latest.zip"
}
return &InvitationHTTPHandler{
invitationService: invitationService,
installerURL: installerURL,
macInstallerURL: macInstallerURL,
}
}
@@ -44,7 +38,7 @@ func (h *InvitationHTTPHandler) RegisterRoutes() {
// handleInvite routes requests to the appropriate handler
func (h *InvitationHTTPHandler) handleInvite(w http.ResponseWriter, r *http.Request) {
// Parse path: /invite/{code} or /invite/{code}/install.bat or /invite/{code}/install.sh
// Parse path: /invite/{code} or /invite/{code}/install.bat
path := strings.TrimPrefix(r.URL.Path, "/invite/")
parts := strings.Split(path, "/")
@@ -57,8 +51,6 @@ func (h *InvitationHTTPHandler) handleInvite(w http.ResponseWriter, r *http.Requ
if len(parts) == 2 && parts[1] == "install.bat" {
h.handleInstallBat(w, r, code)
} else if len(parts) == 2 && parts[1] == "install.command" {
h.handleInstallCommand(w, r, code)
} else if len(parts) == 1 {
h.handleLandingPage(w, r, code)
} else {
@@ -66,34 +58,21 @@ func (h *InvitationHTTPHandler) handleInvite(w http.ResponseWriter, r *http.Requ
}
}
// isMacUserAgent returns true if the User-Agent indicates a Mac browser
func isMacUserAgent(userAgent string) bool {
ua := strings.ToLower(userAgent)
return strings.Contains(ua, "macintosh") || strings.Contains(ua, "mac os x")
}
// handleLandingPage serves the invitation landing page
func (h *InvitationHTTPHandler) handleLandingPage(w http.ResponseWriter, r *http.Request, code string) {
invitation := h.invitationService.FindByCode(code)
isMac := isMacUserAgent(r.UserAgent())
data := struct {
Valid bool
Code string
ExpiresAt string
ErrorMessage string
InstallBatURL string
InstallerURL string
IsMac bool
InstallShURL string
MacInstallerURL string
Valid bool
Code string
ExpiresAt string
ErrorMessage string
InstallBatURL string
InstallerURL string
}{
Code: code,
InstallBatURL: fmt.Sprintf("/invite/%s/install.bat", code),
InstallerURL: h.installerURL,
IsMac: isMac,
InstallShURL: fmt.Sprintf("/invite/%s/install.command", code),
MacInstallerURL: h.macInstallerURL,
Code: code,
InstallBatURL: fmt.Sprintf("/invite/%s/install.bat", code),
InstallerURL: h.installerURL,
}
if invitation == nil {
@@ -181,92 +160,6 @@ timeout /t 5
log.Printf("[InviteHTTP] Served install.bat for code %s...", code[:8])
}
// handleInstallCommand serves the Mac installer as a double-clickable .command file
func (h *InvitationHTTPHandler) handleInstallCommand(w http.ResponseWriter, r *http.Request, code string) {
// Validate the code exists and is valid
if !h.invitationService.IsValidCode(code) {
http.Error(w, "Invalid or expired invitation code", http.StatusNotFound)
return
}
// Generate shell script (.command files are double-clickable on macOS)
script := fmt.Sprintf(`#!/bin/bash
#
# Eagle0 Mac Installer
# Double-click this file to install Eagle0.
#
set -e
echo ""
echo "===================================="
echo " Eagle0 Mac Installer"
echo "===================================="
echo ""
# Create app support directory
APP_SUPPORT_DIR="$HOME/Library/Application Support/eagle0"
mkdir -p "$APP_SUPPORT_DIR"
# Write invitation code
echo "Setting up invitation code..."
cat > "$APP_SUPPORT_DIR/invitation.json" << 'INVITATION_EOF'
{"invitationCode": "%s"}
INVITATION_EOF
echo "Invitation code saved."
echo ""
# Download location
DOWNLOAD_DIR="$HOME/Downloads"
ZIP_PATH="$DOWNLOAD_DIR/eagle0.zip"
APP_PATH="/Applications/eagle0.app"
echo "Downloading Eagle0..."
curl -L -o "$ZIP_PATH" "%s"
echo ""
echo "Extracting to /Applications..."
# Remove old version if exists
if [ -d "$APP_PATH" ]; then
echo "Removing previous version..."
rm -rf "$APP_PATH"
fi
# Unzip to Applications
unzip -q "$ZIP_PATH" -d /Applications/
# Clean up zip
rm "$ZIP_PATH"
# Remove quarantine attribute (app is notarized but downloaded via curl)
xattr -dr com.apple.quarantine "$APP_PATH" 2>/dev/null || true
echo ""
echo "===================================="
echo " Installation Complete!"
echo "===================================="
echo ""
echo "Eagle0 has been installed to /Applications/eagle0.app"
echo "Your invitation code has been saved."
echo ""
echo "Starting Eagle0..."
open "$APP_PATH"
echo ""
echo "You can close this window."
read -p "Press Enter to exit..."
`, code, h.macInstallerURL)
w.Header().Set("Content-Type", "application/x-sh")
w.Header().Set("Content-Disposition", "attachment; filename=eagle0-install.command")
w.Write([]byte(script))
log.Printf("[InviteHTTP] Served install.command for code %s...", code[:8])
}
var landingPageTemplate = template.Must(template.New("landing").Parse(`<!DOCTYPE html>
<html>
<head>
@@ -384,21 +277,6 @@ var landingPageTemplate = template.Must(template.New("landing").Parse(`<!DOCTYPE
font-size: 12px;
margin-top: 30px;
}
code {
background: #e9ecef;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
font-size: 13px;
}
.other-platform {
margin-top: 20px;
text-align: center;
color: #666;
}
.other-platform a {
color: #1a5f7a;
}
</style>
</head>
<body>
@@ -416,31 +294,17 @@ var landingPageTemplate = template.Must(template.New("landing").Parse(`<!DOCTYPE
<div class="content">
<p>You've been invited to play Eagle0, a strategic turn-based game with tactical hex-based combat.</p>
{{if .IsMac}}
<a href="{{.InstallShURL}}" class="button">Download for Mac</a>
<a href="{{.InstallBatURL}}" class="button">Download & Install</a>
<div class="instructions">
<h3>How to install on Mac:</h3>
<h3>How to install:</h3>
<ol>
<li>Click the "Download for Mac" button above</li>
<li>Open your Downloads folder and double-click <strong>eagle0-install.command</strong></li>
<li>If macOS asks for permission, click "Open"</li>
<li>Eagle0 will be installed to /Applications and launched automatically</li>
</ol>
</div>
{{else}}
<a href="{{.InstallBatURL}}" class="button">Download for Windows</a>
<div class="instructions">
<h3>How to install on Windows:</h3>
<ol>
<li>Click the "Download for Windows" button above</li>
<li>Click the "Download & Install" button above</li>
<li>Open the downloaded <strong>eagle0-install.bat</strong> file</li>
<li>If Windows shows a security prompt, click "More info" then "Run anyway"</li>
<li>The installer will download and start automatically with your invitation code</li>
</ol>
</div>
{{end}}
{{if .ExpiresAt}}
<div class="expires">
@@ -449,23 +313,10 @@ var landingPageTemplate = template.Must(template.New("landing").Parse(`<!DOCTYPE
{{end}}
<div class="manual-section">
{{if .IsMac}}
<h3>Alternative: Manual Installation</h3>
<p>If the script doesn't work, you can <a href="{{.MacInstallerURL}}">download the ZIP manually</a>, extract it to /Applications, and enter this code when prompted:</p>
{{else}}
<h3>Alternative: Manual Installation</h3>
<p>If the automatic installer doesn't work, you can <a href="{{.InstallerURL}}">download the installer manually</a> and enter this code when prompted:</p>
{{end}}
<div class="code-box">{{.Code}}</div>
</div>
<div class="other-platform">
{{if .IsMac}}
<p><small>Looking for Windows? <a href="{{.InstallBatURL}}">Download Windows installer</a></small></p>
{{else}}
<p><small>Looking for Mac? <a href="{{.InstallShURL}}">Download Mac installer</a></small></p>
{{end}}
</div>
</div>
{{else}}
<div class="error-box">
@@ -362,44 +362,3 @@ func (is *InvitationService) GetStats() (pending, redeemed, expired, revoked int
}
return
}
// Delete permanently removes an invitation from the database.
// Only revoked, expired, or redeemed invitations can be deleted.
func (is *InvitationService) Delete(code string) error {
is.mu.Lock()
defer is.mu.Unlock()
idx, ok := is.database.CodeIndex[code]
if !ok || int(idx) >= len(is.database.Invitations) {
return fmt.Errorf("invitation not found")
}
invitation := is.database.Invitations[idx]
// Only allow deletion of non-pending invitations
if invitation.Status == userpb.InvitationStatus_INVITATION_STATUS_PENDING {
// Check if actually expired
if invitation.ExpiresAt == nil || !invitation.ExpiresAt.AsTime().Before(time.Now()) {
return fmt.Errorf("cannot delete a pending invitation (revoke it first)")
}
}
// Remove from code index
delete(is.database.CodeIndex, code)
// Remove from invitations slice
is.database.Invitations = append(is.database.Invitations[:idx], is.database.Invitations[idx+1:]...)
// Rebuild code index since array indices have shifted
is.database.CodeIndex = make(map[string]int32)
for i, inv := range is.database.Invitations {
is.database.CodeIndex[inv.InvitationCode] = int32(i)
}
if err := is.save(); err != nil {
return fmt.Errorf("failed to save: %w", err)
}
log.Printf("[Invitation] Deleted invitation for %s (code %s...)", invitation.Email, code[:8])
return nil
}
@@ -466,45 +466,3 @@ func (us *UserService) SetAdmin(userID string, isAdmin bool) error {
}
return fmt.Errorf("user not found")
}
// Delete permanently removes a user from the database
func (us *UserService) Delete(userID string) error {
us.mu.Lock()
defer us.mu.Unlock()
// Find user index
var userIndex = -1
var user *userpb.User
for i, u := range us.database.Users {
if u.UserId == userID {
userIndex = i
user = u
break
}
}
if userIndex == -1 {
return fmt.Errorf("user not found")
}
// Remove from OAuth index
for _, identity := range user.OauthIdentities {
oauthKey := fmt.Sprintf("%s:%s", identity.Provider, identity.ProviderUserId)
delete(us.database.OauthIndex, oauthKey)
}
// Remove from display name index
if user.DisplayNameLower != "" {
delete(us.database.DisplayNameIndex, user.DisplayNameLower)
}
// Remove from users slice
us.database.Users = append(us.database.Users[:userIndex], us.database.Users[userIndex+1:]...)
if err := us.save(); err != nil {
return fmt.Errorf("failed to save: %w", err)
}
log.Printf("[UserService] Deleted user %s (%s)", userID, user.DisplayName)
return nil
}
@@ -1,363 +0,0 @@
package main
import (
"archive/zip"
"crypto/sha256"
"encoding/xml"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/nolen777/eagle0/src/main/go/net/eagle0/util/aws"
)
var bucketName = "eagle0-windows" // Using existing bucket with /mac/ prefix
var appcastPath = "mac/appcast.xml"
var buildsRoot = "mac/builds/"
// Sparkle Appcast XML structures
type Appcast struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
Xmlns string `xml:"xmlns:sparkle,attr"`
Channel Channel `xml:"channel"`
}
type Channel struct {
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
Language string `xml:"language"`
Items []Item `xml:"item"`
}
type Item struct {
Title string `xml:"title"`
PubDate string `xml:"pubDate"`
SparkleVersion string `xml:"sparkle:version"`
SparkleShortVersion string `xml:"sparkle:shortVersionString"`
Description string `xml:"description,omitempty"`
Enclosure Enclosure `xml:"enclosure"`
}
type Enclosure struct {
URL string `xml:"url,attr"`
Length int64 `xml:"length,attr"`
Type string `xml:"type,attr"`
EdSig string `xml:"sparkle:edSignature,attr"`
}
func zipApp(appPath string, zipPath string) error {
zipFile, err := os.Create(zipPath)
if err != nil {
return fmt.Errorf("failed to create zip file: %w", err)
}
defer zipFile.Close()
zipWriter := zip.NewWriter(zipFile)
defer zipWriter.Close()
appBase := filepath.Base(appPath)
// Use WalkDir with Lstat to properly handle symlinks
err = filepath.WalkDir(appPath, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
// Get the relative path from the app's parent directory
relPath, err := filepath.Rel(filepath.Dir(appPath), path)
if err != nil {
return err
}
// Skip if it's the root
if relPath == appBase && d.IsDir() {
return nil
}
// Use Lstat to get info without following symlinks
info, err := os.Lstat(path)
if err != nil {
return err
}
// Check if it's a symlink
if info.Mode()&os.ModeSymlink != 0 {
// Read the symlink target
linkTarget, err := os.Readlink(path)
if err != nil {
return err
}
// Create symlink entry in zip
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
header.Name = relPath
header.Method = zip.Store
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return err
}
_, err = writer.Write([]byte(linkTarget))
return err
}
header, err := zip.FileInfoHeader(info)
if err != nil {
return err
}
// Use forward slashes and preserve the .app directory structure
header.Name = relPath
if info.IsDir() {
header.Name += "/"
} else {
header.Method = zip.Deflate
}
// Preserve executable permissions
header.SetMode(info.Mode())
writer, err := zipWriter.CreateHeader(header)
if err != nil {
return err
}
if !info.IsDir() {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(writer, file)
if err != nil {
return err
}
}
return nil
})
return err
}
func signWithSparkle(filePath string, privateKeyPath string) (string, error) {
// Use Sparkle's sign_update tool
// First, try to find it in the Sparkle cache
sparkleSignTool := "/tmp/sparkle-cache/Sparkle-2.6.4/bin/sign_update"
// If not found, download Sparkle
if _, err := os.Stat(sparkleSignTool); os.IsNotExist(err) {
log.Println("Sparkle sign_update not found, downloading...")
cmd := exec.Command("bash", "-c", `
mkdir -p /tmp/sparkle-cache
curl -sL https://github.com/sparkle-project/Sparkle/releases/download/2.6.4/Sparkle-2.6.4.tar.xz | tar -xJ -C /tmp/sparkle-cache
`)
if output, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("failed to download Sparkle: %s: %w", output, err)
}
}
// Sign the file using -f to specify private key file path
// Note: -s is deprecated and expects key as string argument, not file path
cmd := exec.Command(sparkleSignTool, filePath, "-f", privateKeyPath)
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("failed to sign file: %s: %w", string(output), err)
}
// sign_update outputs: sparkle:edSignature="<signature>" length="<length>"
// We need to extract just the signature
signature := strings.TrimSpace(string(output))
// Parse out the signature from the output
if strings.Contains(signature, "sparkle:edSignature=\"") {
start := strings.Index(signature, "sparkle:edSignature=\"") + len("sparkle:edSignature=\"")
end := strings.Index(signature[start:], "\"")
if end > 0 {
signature = signature[start : start+end]
}
}
return signature, nil
}
func getFileSize(path string) (int64, error) {
info, err := os.Stat(path)
if err != nil {
return 0, err
}
return info.Size(), nil
}
func sha256File(path string) (string, error) {
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return "", err
}
return fmt.Sprintf("%x", h.Sum(nil)), nil
}
func fetchAppcast(bb aws.BucketBasics) (*Appcast, error) {
content, err := bb.FetchString(bucketName, appcastPath)
if err != nil {
// Return empty appcast if doesn't exist
return &Appcast{
Version: "2.0",
Xmlns: "http://www.andymatuschak.org/xml-namespaces/sparkle",
Channel: Channel{
Title: "Eagle0",
Link: "https://assets.eagle0.net/mac/appcast.xml",
Description: "Eagle0 game updates",
Language: "en",
Items: []Item{},
},
}, nil
}
var appcast Appcast
err = xml.Unmarshal([]byte(content), &appcast)
if err != nil {
return nil, fmt.Errorf("failed to parse appcast: %w", err)
}
return &appcast, nil
}
func uploadAppcast(bb aws.BucketBasics, appcast *Appcast) error {
output, err := xml.MarshalIndent(appcast, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal appcast: %w", err)
}
xmlContent := xml.Header + string(output)
return bb.UploadBytesPublic(bucketName, appcastPath, []byte(xmlContent))
}
func main() {
if len(os.Args) < 4 {
fmt.Println("Usage: mac_build_handler <app_path> <version> <build_number> [sparkle_private_key_path]")
os.Exit(1)
}
appPath := os.Args[1]
version := os.Args[2]
buildNumber := os.Args[3]
privateKeyPath := ""
if len(os.Args) >= 5 {
privateKeyPath = os.Args[4]
}
if _, err := os.Stat(appPath); os.IsNotExist(err) {
log.Fatalf("App not found: %s", appPath)
}
bb, err := aws.NewBucketBasics()
if err != nil {
log.Fatal(err)
}
// Create ZIP of the app
zipFileName := fmt.Sprintf("eagle0-%s.zip", version)
zipPath := filepath.Join("/tmp", zipFileName)
log.Printf("Creating ZIP: %s", zipPath)
if err := zipApp(appPath, zipPath); err != nil {
log.Fatalf("Failed to create ZIP: %v", err)
}
// Get file size
fileSize, err := getFileSize(zipPath)
if err != nil {
log.Fatalf("Failed to get file size: %v", err)
}
log.Printf("ZIP size: %d bytes", fileSize)
// Sign the ZIP with Sparkle EdDSA (optional)
var signature string
if privateKeyPath != "" {
log.Println("Signing ZIP with Sparkle...")
signature, err = signWithSparkle(zipPath, privateKeyPath)
if err != nil {
log.Fatalf("Failed to sign: %v", err)
}
log.Printf("Signature: %s", signature)
} else {
log.Println("Skipping Sparkle signing (no private key provided)")
}
// Upload ZIP to S3
remotePath := buildsRoot + zipFileName
log.Printf("Uploading to S3: %s", remotePath)
if err := bb.UploadFilePublic(bucketName, remotePath, zipPath); err != nil {
log.Fatalf("Failed to upload: %v", err)
}
// Also upload as "latest"
latestPath := buildsRoot + "eagle0-latest.zip"
log.Printf("Copying to: %s", latestPath)
if err := bb.UploadFilePublic(bucketName, latestPath, zipPath); err != nil {
log.Fatalf("Failed to upload latest: %v", err)
}
// Update appcast.xml (only if Sparkle signing was done)
downloadURL := fmt.Sprintf("https://assets.eagle0.net/%s%s", buildsRoot, zipFileName)
if privateKeyPath != "" {
log.Println("Updating appcast.xml...")
appcast, err := fetchAppcast(bb)
if err != nil {
log.Fatalf("Failed to fetch appcast: %v", err)
}
// Create new item
newItem := Item{
Title: fmt.Sprintf("Version %s", version),
PubDate: time.Now().Format(time.RFC1123Z),
SparkleVersion: buildNumber,
SparkleShortVersion: version,
Description: "",
Enclosure: Enclosure{
URL: downloadURL,
Length: fileSize,
Type: "application/octet-stream",
EdSig: signature,
},
}
// Prepend new item (most recent first)
appcast.Channel.Items = append([]Item{newItem}, appcast.Channel.Items...)
// Keep only last 10 versions
if len(appcast.Channel.Items) > 10 {
appcast.Channel.Items = appcast.Channel.Items[:10]
}
if err := uploadAppcast(bb, appcast); err != nil {
log.Fatalf("Failed to upload appcast: %v", err)
}
} else {
log.Println("Skipping appcast.xml update (no Sparkle signing)")
}
// Clean up local ZIP
os.Remove(zipPath)
log.Printf("=== Mac build deployed successfully ===")
log.Printf("Version: %s (build %s)", version, buildNumber)
log.Printf("Download URL: %s", downloadURL)
log.Printf("Appcast URL: https://assets.eagle0.net/%s", appcastPath)
}
@@ -1,15 +1,12 @@
package main
import (
"crypto/ed25519"
"encoding/base64"
"fmt"
"github.com/nolen777/eagle0/src/main/go/net/eagle0/util/aws"
"log"
"os"
"strings"
"time"
"github.com/nolen777/eagle0/src/main/go/net/eagle0/util/aws"
)
const bucketName = "eagle0-windows"
@@ -67,7 +64,7 @@ func (mm *ManifestManager) parseManifest(content string) map[string]string {
return sections
}
func (mm *ManifestManager) buildManifest(sections map[string]string, privateKey ed25519.PrivateKey) string {
func (mm *ManifestManager) buildManifest(sections map[string]string) string {
var manifest strings.Builder
manifest.WriteString(fmt.Sprintf("# Generated: %s\n", time.Now().Format(time.RFC3339)))
@@ -93,21 +90,10 @@ func (mm *ManifestManager) buildManifest(sections map[string]string, privateKey
}
}
// The content to sign is everything we've built so far
contentToSign := manifest.String()
// If we have a private key, sign the manifest
if privateKey != nil {
signature := ed25519.Sign(privateKey, []byte(contentToSign))
signatureB64 := base64.StdEncoding.EncodeToString(signature)
// Prepend signature line at the very beginning
return fmt.Sprintf("# signature=%s\n%s", signatureB64, contentToSign)
}
return contentToSign
return manifest.String()
}
func (mm *ManifestManager) UpdateSection(sectionName, sectionContent string, privateKey ed25519.PrivateKey) error {
func (mm *ManifestManager) UpdateSection(sectionName, sectionContent string) error {
log.Printf("Updating manifest section: %s", sectionName)
// Fetch existing manifest
@@ -117,14 +103,14 @@ func (mm *ManifestManager) UpdateSection(sectionName, sectionContent string, pri
existing = ""
}
// Parse sections (strips out old signature line if present)
// Parse sections
sections := mm.parseManifest(existing)
// Update the specified section
sections[sectionName] = sectionContent
// Build new manifest (with signature if key provided)
newManifest := mm.buildManifest(sections, privateKey)
// Build new manifest
newManifest := mm.buildManifest(sections)
// Upload updated manifest with public ACL
err = mm.bucketBasics.UploadBytesPublic(bucketName, manifestPath, []byte(newManifest))
@@ -132,36 +118,13 @@ func (mm *ManifestManager) UpdateSection(sectionName, sectionContent string, pri
return fmt.Errorf("failed to upload manifest: %v", err)
}
if privateKey != nil {
log.Printf("Successfully updated and signed manifest section: %s", sectionName)
} else {
log.Printf("Successfully updated manifest section: %s (unsigned)", sectionName)
}
log.Printf("Successfully updated manifest section: %s", sectionName)
return nil
}
func loadPrivateKey(keyPath string) (ed25519.PrivateKey, error) {
keyData, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("failed to read private key file: %v", err)
}
// Key should be base64-encoded 64-byte Ed25519 private key
keyBytes, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(keyData)))
if err != nil {
return nil, fmt.Errorf("failed to decode private key: %v", err)
}
if len(keyBytes) != ed25519.PrivateKeySize {
return nil, fmt.Errorf("invalid private key size: expected %d bytes, got %d", ed25519.PrivateKeySize, len(keyBytes))
}
return ed25519.PrivateKey(keyBytes), nil
}
func main() {
if len(os.Args) < 3 || len(os.Args) > 4 {
log.Fatal("Usage: manifest_manager <section_name> <section_content_file> [private_key_file]")
if len(os.Args) != 3 {
log.Fatal("Usage: manifest_manager <section_name> <section_content_file>")
}
sectionName := os.Args[1]
@@ -173,16 +136,6 @@ func main() {
log.Fatalf("Failed to read content file: %v", err)
}
// Load private key if provided
var privateKey ed25519.PrivateKey
if len(os.Args) == 4 {
privateKey, err = loadPrivateKey(os.Args[3])
if err != nil {
log.Fatalf("Failed to load private key: %v", err)
}
log.Println("Ed25519 signing key loaded")
}
// Create manifest manager
mm, err := NewManifestManager()
if err != nil {
@@ -190,7 +143,7 @@ func main() {
}
// Update the section
err = mm.UpdateSection(sectionName, string(content), privateKey)
err = mm.UpdateSection(sectionName, string(content))
if err != nil {
log.Fatalf("Failed to update manifest: %v", err)
}
@@ -1,15 +1,15 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "mac_build_handler_lib",
srcs = ["mac_build_handler.go"],
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/build/mac_build_handler",
name = "client_download_lib",
srcs = ["client_download.go"],
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/client_download",
visibility = ["//visibility:private"],
deps = ["//src/main/go/net/eagle0/util/aws"],
)
go_binary(
name = "mac_build_handler",
embed = [":mac_build_handler_lib"],
name = "client_download",
embed = [":client_download_lib"],
visibility = ["//visibility:public"],
)
@@ -0,0 +1,58 @@
package main
import (
"github.com/nolen777/eagle0/src/main/go/net/eagle0/util/aws"
"net/http"
"strings"
"time"
)
func getAsset(w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path
var bucketName string
var destinationRoot string
var fileName string
components := strings.SplitN(urlPath, "/", 3)
if len(components) < 3 {
http.Error(w, "Invalid URL path", http.StatusBadRequest)
return
}
baseComponent := components[1] // Get the first component of the path
fileName = components[2] // Get the path after the first "/"
// Check if the URL path starts with "/unity3d/"
switch baseComponent {
case "unity3d":
bucketName = "eagle0-windows"
destinationRoot = "unity3d/"
case "installer":
bucketName = "eagle0-windows"
destinationRoot = "installer/"
case "headshots":
bucketName = "eagle0-headshots"
destinationRoot = ""
default:
// If the URL path does not start with either, return an error
http.Error(w, "Invalid URL path", http.StatusBadRequest)
}
// Create a presigned URL for the file
url, err := aws.GetPresignedURL(bucketName, destinationRoot+fileName, 10*time.Minute)
if err != nil {
http.Error(w, "Error generating presigned URL", http.StatusInternalServerError)
return
}
// Redirect the client to the presigned URL
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
func main() {
http.HandleFunc("GET /", getAsset)
err := http.ListenAndServe(":3333", nil)
if err != nil {
panic(err)
}
}
+32 -1
View File
@@ -21,6 +21,10 @@ type BucketBasics struct {
S3Client *s3.Client
}
type Presigner struct {
PresignClient *s3.PresignClient
}
func readConfig() (map[string]string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
@@ -86,7 +90,16 @@ func NewBucketBasics() (BucketBasics, error) {
}, nil
}
// FetchBytes gets an object from a bucket and returns it as bytes.
func NewPresigner() (Presigner, error) {
bucketBasics, err := NewBucketBasics()
if err != nil {
return Presigner{}, err
}
presignClient := s3.NewPresignClient(bucketBasics.S3Client)
return Presigner{PresignClient: presignClient}, nil
}
// DownloadFile gets an object from a bucket and stores it in a local file.
func (basics BucketBasics) FetchBytes(bucketName string, objectKey string) ([]byte, error) {
result, err := basics.S3Client.GetObject(basics.ctx, &s3.GetObjectInput{
Bucket: aws.String(bucketName),
@@ -183,3 +196,21 @@ func (basics BucketBasics) UploadBytesWithACL(bucketName string, objectKey strin
return nil
}
func GetPresignedURL(bucketName string, objectKey string, duration time.Duration) (string, error) {
basics, err := NewBucketBasics()
if err != nil {
return "", err
}
presigner, err := NewPresigner()
if err != nil {
return "", err
}
req, _ := presigner.PresignClient.PresignGetObject(basics.ctx, &s3.GetObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
}, s3.WithPresignExpires(duration))
return req.URL, nil
}
+29 -89
View File
@@ -136,50 +136,24 @@ func runWarmup(ctx context.Context) error {
return fmt.Errorf("failed to send stream game request: %w", err)
}
// Wait for subscription ack
_, err = waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
return resp.GetSubscriptionAck() != nil
})
if err != nil {
return fmt.Errorf("failed to get subscription ack: %w", err)
}
log.Println(" Subscription acknowledged")
// Wait for initial game state with available commands
// Note: ActionResultResponse arrives BEFORE SubscriptionAck, so we can't wait for ack first
streamingTextCount := 0
gotSubscriptionAck := false
gameUpdateResp, err := waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
// Track subscription ack but don't require it before ActionResultResponse
if resp.GetSubscriptionAck() != nil {
gotSubscriptionAck = true
log.Println(" Subscription acknowledged")
}
gu := resp.GetGameUpdate()
if gu == nil {
return false
}
// Check what type of update this is
switch {
case gu.GetStreamingTextResponse() != nil:
streamingTextCount++
if streamingTextCount%100 == 0 {
log.Printf(" Received %d streaming text updates so far...", streamingTextCount)
}
case gu.GetShardokActionResultResponse() != nil:
log.Printf(" Got ShardokActionResultResponse")
case gu.GetErrorResponse() != nil:
log.Printf(" Got ErrorResponse: %v", gu.GetErrorResponse())
case gu.GetActionResultResponse() != nil:
log.Printf(" Got ActionResultResponse!")
default:
log.Printf(" Got GameUpdate with no recognized content")
}
ar := gu.GetActionResultResponse()
if ar == nil {
return false
}
if ar.GetAvailableCommands() == nil {
log.Printf(" ActionResultResponse has no AvailableCommands (has %d results)", len(ar.GetActionResultViews()))
return false
}
log.Printf(" Found AvailableCommands with %d provinces", len(ar.GetAvailableCommands().GetCommandsByProvince()))
return true
return ar != nil && ar.GetAvailableCommands() != nil
})
log.Printf(" Total streaming text updates received: %d, got subscription ack: %v", streamingTextCount, gotSubscriptionAck)
if err != nil {
return fmt.Errorf("failed to get initial game state: %w", err)
}
@@ -200,14 +174,12 @@ func runWarmup(ctx context.Context) error {
var improveCmd *eagle.AvailableCommand
var provinceID int32
var actingHeroID int32
for pid, provCmds := range commandsByProvince {
for _, cmd := range provCmds.GetCommands() {
if ic := cmd.GetImproveCommand(); ic != nil {
if cmd.GetImproveCommand() != nil {
improveCmd = cmd
provinceID = pid
actingHeroID = ic.GetRecommendedHeroId()
break
}
}
@@ -228,11 +200,11 @@ func runWarmup(ctx context.Context) error {
}
// We'll still try to post an Improve command even if not explicitly available
} else {
log.Printf(" Found Improve command for province %d with recommended hero %d", provinceID, actingHeroID)
log.Printf(" Found Improve command for province %d", provinceID)
}
// Post the Improve command
log.Printf(" Posting Improve command with token=%d province=%d heroId=%d...", eagleToken, provinceID, actingHeroID)
log.Println(" Posting Improve command...")
if err := stream.Send(&eagle.UpdateStreamRequest{
RequestDetails: &eagle.UpdateStreamRequest_PostCommandRequest{
PostCommandRequest: &eagle.PostCommandRequest{
@@ -246,7 +218,7 @@ func runWarmup(ctx context.Context) error {
SealedValue: &eagle.SelectedCommand_ImproveCommand{
ImproveCommand: &eagle.ImproveSelectedCommand{
ImprovementType: eaglecommon.ImprovementType_ECONOMY,
ActingHeroId: actingHeroID,
ActingHeroId: 0, // Default acting hero
LockType: false,
},
},
@@ -261,58 +233,32 @@ func runWarmup(ctx context.Context) error {
}
// Step 5: Verify we get ActionResults
log.Println("Step 5: Waiting for command response and collecting results...")
log.Println("Step 5: Waiting for command response...")
// Track results while waiting for PostCommandResponse
// ActionResultResponse arrives as GameUpdate BEFORE PostCommandResponse
foundNewRound := false
resultCount := 0
commandCount := 0
postStatus := eagle.PostCommandResponse_UNKNOWN
_, err = waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
if resp.GetPostCommandResponse() != nil {
log.Printf(" Got PostCommandResponse: %v", resp.GetPostCommandResponse().GetStatus())
postStatus = resp.GetPostCommandResponse().GetStatus()
return true
}
// Process GameUpdates while waiting - they contain our action results!
if gu := resp.GetGameUpdate(); gu != nil {
if ar := gu.GetActionResultResponse(); ar != nil {
for _, arv := range ar.GetActionResultViews() {
resultCount++
if arv.GetType() == eaglecommon.ActionResultType_NEW_ROUND_ACTION {
foundNewRound = true
log.Printf(" Found NEW_ROUND_ACTION (date change)!")
}
}
if ar.GetAvailableCommands() != nil {
commandCount = len(ar.GetAvailableCommands().GetCommandsByProvince())
log.Printf(" Got %d action results, %d provinces with commands available", len(ar.GetActionResultViews()), commandCount)
}
}
} else if resp.GetSubscriptionAck() != nil {
log.Println(" (skipping late SubscriptionAck)")
}
return false
// Wait for post command response
postResp, err := waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
return resp.GetPostCommandResponse() != nil
})
if err != nil {
return fmt.Errorf("failed to get post command response: %w", err)
}
if postStatus == eagle.PostCommandResponse_SUCCESS {
log.Println(" Command accepted!")
} else if postStatus == eagle.PostCommandResponse_BAD_TOKEN {
log.Printf(" Command rejected: bad token")
if postResp.GetPostCommandResponse().GetStatus() != eagle.PostCommandResponse_SUCCESS {
log.Printf(" Command was rejected: %v", postResp.GetPostCommandResponse().GetStatus())
// Continue anyway - the JIT is still warming up
} else {
log.Printf(" Command status: %v (continuing anyway - JIT warming up)", postStatus)
log.Println(" Command accepted!")
}
// Continue collecting any remaining action results with a timeout
log.Println(" Collecting any remaining action results...")
// Collect action results with a timeout
log.Println(" Collecting action results...")
resultCtx, resultCancel := context.WithTimeout(ctx, 10*time.Second)
defer resultCancel()
foundNewRound := false
resultCount := 0
commandCount := 0
for {
select {
case <-resultCtx.Done():
@@ -360,7 +306,6 @@ done:
log.Printf(" Total action results received: %d", resultCount)
log.Printf(" Found NEW_ROUND_ACTION (date change): %v", foundNewRound)
log.Printf(" New commands available: %d provinces", commandCount)
log.Printf(" Post command status: %v", postStatus)
// Step 6: Drop the game to clean up
log.Println("Step 6: Dropping test game...")
@@ -388,18 +333,13 @@ done:
if resultCount == 0 {
return fmt.Errorf("no action results received")
}
if commandCount == 0 {
return fmt.Errorf("no new commands received after posting command")
}
return nil
}
// waitForResponse waits for a response matching the predicate
// Use 180s timeout because CreateGame can be very slow on cold JVM (JIT not warmed up)
// In production we've seen CreateGame take >90s on first boot
func waitForResponse(stream eagle.Eagle_StreamUpdatesClient, matches func(*eagle.UpdateStreamResponse) bool) (*eagle.UpdateStreamResponse, error) {
return waitForResponseWithTimeout(stream, matches, 180*time.Second)
return waitForResponseWithTimeout(stream, matches, 30*time.Second)
}
func waitForResponseWithTimeout(stream eagle.Eagle_StreamUpdatesClient, matches func(*eagle.UpdateStreamResponse) bool, timeout time.Duration) (*eagle.UpdateStreamResponse, error) {
@@ -1,3 +1,4 @@
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
load("@grpc//bazel:cc_grpc_library.bzl", "cc_grpc_library")
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_cc//cc:defs.bzl", "cc_proto_library")
@@ -56,6 +57,16 @@ proto_library(
],
)
swift_proto_library(
name = "hostility_swift_proto",
protos = [":hostility_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/protobuf/net/eagle0/eagle/views:__pkg__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "hostility_scala_proto",
visibility = [
@@ -172,6 +183,15 @@ proto_library(
],
)
swift_proto_library(
name = "victory_condition_swift_proto",
protos = [":victory_condition_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "victory_condition_scala_proto",
visibility = ["//visibility:public"],
@@ -1,3 +1,4 @@
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_proto//proto:defs.bzl", "proto_library")
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
@@ -49,6 +50,26 @@ proto_library(
],
)
swift_proto_library(
name = "available_command_swift_proto",
protos = [":available_command_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api/command/util:armed_battalion_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:attack_decision_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:captured_hero_option_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:control_weather_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:diplomacy_option_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:prisoner_management_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:province_orders_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:improvement_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:tribute_amount_swift_proto",
],
)
scala_proto_library(
name = "available_command_scala_proto",
visibility = [
@@ -87,6 +108,16 @@ proto_library(
],
)
swift_proto_library(
name = "command_swift_proto",
protos = [":command_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":available_command_swift_proto",
":selected_command_swift_proto",
],
)
scala_proto_library(
name = "command_scala_proto",
visibility = [
@@ -122,6 +153,30 @@ proto_library(
visibility = ["//visibility:public"],
)
swift_proto_library(
name = "selected_command_swift_proto",
protos = [":selected_command_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api/command/util:armed_battalion_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:attack_decision_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:captured_hero_option_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:control_weather_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:diplomacy_option_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:prisoner_management_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api/command/util:province_orders_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_status_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:improvement_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:tribute_amount_swift_proto",
],
)
scala_proto_library(
name = "selected_command_scala_proto",
visibility = [
@@ -39,12 +39,6 @@ service Admin {
// Resend invitation email
rpc ResendInvitation(ResendInvitationRequest) returns (ResendInvitationResponse) {}
// Delete a user permanently
rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse) {}
// Delete an invitation (only revoked/expired/redeemed invitations can be deleted)
rpc DeleteInvitation(DeleteInvitationRequest) returns (DeleteInvitationResponse) {}
}
// Request to list users
@@ -179,23 +173,3 @@ message ResendInvitationResponse {
bool success = 1;
string error_message = 2;
}
// Delete user request
message DeleteUserRequest {
string user_id = 1;
}
message DeleteUserResponse {
bool success = 1;
string error_message = 2;
}
// Delete invitation request (only revoked/expired/redeemed can be deleted)
message DeleteInvitationRequest {
string invitation_code = 1;
}
message DeleteInvitationResponse {
bool success = 1;
string error_message = 2;
}
@@ -1,7 +1,18 @@
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_proto//proto:defs.bzl", "proto_library")
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
swift_proto_library(
name = "appropriate_battalions_swift_proto",
protos = [":appropriate_battalions_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
scala_proto_library(
name = "appropriate_battalions_scala_proto",
visibility = [
@@ -21,6 +32,16 @@ proto_library(
],
)
swift_proto_library(
name = "armed_battalion_swift_proto",
protos = [":armed_battalion_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
scala_proto_library(
name = "armed_battalion_scala_proto",
visibility = [
@@ -42,6 +63,16 @@ proto_library(
],
)
swift_proto_library(
name = "army_stats_swift_proto",
protos = [":army_stats_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
scala_proto_library(
name = "army_stats_scala_proto",
visibility = [
@@ -62,6 +93,18 @@ proto_library(
deps = ["//src/main/protobuf/net/eagle0/common:hostility_proto"],
)
swift_proto_library(
name = "attack_decision_type_swift_proto",
protos = [":attack_decision_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:tribute_amount_swift_proto",
],
)
scala_proto_library(
name = "attack_decision_type_scala_proto",
visibility = [
@@ -84,6 +127,16 @@ proto_library(
],
)
swift_proto_library(
name = "battalion_with_food_cost_swift_proto",
protos = [":battalion_with_food_cost_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
scala_proto_library(
name = "battalion_with_food_cost_scala_proto",
visibility = [
@@ -105,6 +158,16 @@ proto_library(
],
)
swift_proto_library(
name = "captured_hero_option_swift_proto",
protos = [":captured_hero_option_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
scala_proto_library(
name = "captured_hero_option_scala_proto",
visibility = [
@@ -126,6 +189,16 @@ proto_library(
],
)
swift_proto_library(
name = "control_weather_type_swift_proto",
protos = [":control_weather_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
scala_proto_library(
name = "control_weather_type_scala_proto",
visibility = [
@@ -147,6 +220,18 @@ proto_library(
],
)
swift_proto_library(
name = "diplomacy_option_swift_proto",
protos = [":diplomacy_option_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
],
)
scala_proto_library(
name = "diplomacy_option_scala_proto",
visibility = [
@@ -169,6 +254,19 @@ proto_library(
deps = ["//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_proto"],
)
swift_proto_library(
name = "expanded_combat_unit_swift_proto",
protos = [":expanded_combat_unit_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/views:battalion_view_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/views:hero_view_swift_proto",
],
)
scala_proto_library(
name = "expanded_combat_unit_scala_proto",
visibility = [
@@ -194,6 +292,20 @@ proto_library(
],
)
swift_proto_library(
name = "expanded_unaffiliated_hero_swift_proto",
protos = [":expanded_unaffiliated_hero_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_quest_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/views:hero_view_swift_proto",
],
)
scala_proto_library(
name = "expanded_unaffiliated_hero_scala_proto",
visibility = [
@@ -220,6 +332,16 @@ proto_library(
],
)
swift_proto_library(
name = "prisoner_management_type_swift_proto",
protos = [":prisoner_management_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
scala_proto_library(
name = "prisoner_management_type_scala_proto",
visibility = [
@@ -241,6 +363,18 @@ proto_library(
],
)
swift_proto_library(
name = "province_orders_swift_proto",
protos = [":province_orders_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
],
)
scala_proto_library(
name = "province_orders_scala_proto",
visibility = [
@@ -45,6 +45,9 @@ service Eagle {
rpc DeleteGame(DeleteGameRequest) returns (DeleteGameResponse) {}
rpc CheckGameExists(CheckGameExistsRequest) returns (CheckGameExistsResponse) {}
rpc DownloadGameSave(DownloadGameSaveRequest) returns (stream DownloadGameSaveResponse) {}
// Server lifecycle management (for blue-green deployments)
rpc ReloadGames(ReloadGamesRequest) returns (ReloadGamesResponse) {}
}
message PostCommandRequest {
@@ -58,11 +61,8 @@ message PostCommandResponse {
UNKNOWN = 0;
SUCCESS = 1;
BAD_TOKEN = 2;
ERROR = 3;
}
Status status = 1;
string error_message = 2; // Only set if status is ERROR
int64 game_id = 3; // Echo back game_id from request for targeted refresh
}
message EnterLobbyRequest {
@@ -266,8 +266,6 @@ message GameInfo {
int64 game_id = 1;
.google.protobuf.Int32Value faction_id = 2;
AvailableLeader leader = 3;
// Timestamp (millis since epoch) of when this user last took a turn
int64 last_played_timestamp_millis = 4;
}
message EagleCommand {
@@ -610,3 +608,12 @@ message DownloadGameSaveRequest {
message DownloadGameSaveResponse {
bytes chunk = 1; // Chunk of zip file data
}
// Server lifecycle management (for blue-green deployments)
message ReloadGamesRequest {}
message ReloadGamesResponse {
bool success = 1;
string error_message = 2;
int32 games_reloaded = 3; // Number of games that were reloaded from disk
}
@@ -1,3 +1,4 @@
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_proto//proto:defs.bzl", "proto_library")
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
@@ -7,6 +8,19 @@ package(default_visibility = [
"//src/test/scala/net/eagle0:__subpackages__",
])
swift_proto_library(
name = "action_result_notification_details_swift_proto",
protos = [":action_result_notification_details_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
":profession_swift_proto",
":unaffiliated_hero_quest_swift_proto",
],
)
scala_proto_library(
name = "action_result_notification_details_scala_proto",
deps = [":action_result_notification_details_proto"],
@@ -27,6 +41,15 @@ proto_library(
],
)
swift_proto_library(
name = "action_result_type_swift_proto",
protos = [":action_result_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "action_result_type_scala_proto",
deps = [":action_result_type_proto"],
@@ -42,6 +65,15 @@ proto_library(
],
)
swift_proto_library(
name = "battalion_type_swift_proto",
protos = [":battalion_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "battalion_type_scala_proto",
deps = [":battalion_type_proto"],
@@ -57,6 +89,15 @@ proto_library(
],
)
swift_proto_library(
name = "beast_info_swift_proto",
protos = [":beast_info_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "beast_info_scala_proto",
deps = [":beast_info_proto"],
@@ -72,6 +113,16 @@ proto_library(
],
)
swift_proto_library(
name = "chronicle_entry_swift_proto",
protos = [":chronicle_entry_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [":date_swift_proto"],
)
scala_proto_library(
name = "chronicle_entry_scala_proto",
deps = ["chronicle_entry_proto"],
@@ -90,18 +141,12 @@ proto_library(
],
)
scala_proto_library(
name = "command_type_scala_proto",
deps = [":command_type_proto"],
)
proto_library(
name = "command_type_proto",
srcs = [
"command_type.proto",
],
swift_proto_library(
name = "combat_unit_swift_proto",
protos = [":combat_unit_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
@@ -121,6 +166,15 @@ proto_library(
deps = ["@com_google_protobuf//:wrappers_proto"],
)
swift_proto_library(
name = "date_swift_proto",
protos = [":date_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "date_scala_proto",
deps = [":date_proto"],
@@ -136,6 +190,19 @@ proto_library(
],
)
swift_proto_library(
name = "diplomacy_offer_swift_proto",
protos = [":diplomacy_offer_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
":date_swift_proto",
":diplomacy_offer_status_swift_proto",
],
)
scala_proto_library(
name = "diplomacy_offer_scala_proto",
visibility = [
@@ -159,6 +226,15 @@ proto_library(
],
)
swift_proto_library(
name = "diplomacy_offer_status_swift_proto",
protos = [":diplomacy_offer_status_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "diplomacy_offer_status_scala_proto",
deps = [":diplomacy_offer_status_proto"],
@@ -174,6 +250,15 @@ proto_library(
],
)
swift_proto_library(
name = "gender_swift_proto",
protos = [":gender_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "gender_scala_proto",
deps = [":gender_proto"],
@@ -187,6 +272,18 @@ proto_library(
],
)
swift_proto_library(
name = "hero_backstory_version_swift_proto",
protos = [":hero_backstory_version_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
":date_swift_proto",
],
)
scala_proto_library(
name = "hero_backstory_version_scala_proto",
deps = [":hero_backstory_version_proto"],
@@ -203,6 +300,15 @@ proto_library(
],
)
swift_proto_library(
name = "improvement_type_swift_proto",
protos = [":improvement_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "improvement_type_scala_proto",
visibility = [
@@ -224,6 +330,15 @@ proto_library(
],
)
swift_proto_library(
name = "profession_swift_proto",
protos = [":profession_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "profession_scala_proto",
deps = [":profession_proto"],
@@ -239,6 +354,19 @@ proto_library(
],
)
swift_proto_library(
name = "province_event_swift_proto",
protos = [":province_event_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
":beast_info_swift_proto",
":date_swift_proto",
],
)
scala_proto_library(
name = "province_event_scala_proto",
deps = [":province_event_proto"],
@@ -258,6 +386,15 @@ proto_library(
],
)
swift_proto_library(
name = "province_order_type_swift_proto",
protos = [":province_order_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "province_order_type_scala_proto",
deps = [":province_order_type_proto"],
@@ -273,6 +410,18 @@ proto_library(
],
)
swift_proto_library(
name = "recruitment_info_swift_proto",
protos = [":recruitment_info_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
":unaffiliated_hero_quest_swift_proto",
],
)
scala_proto_library(
name = "recruitment_info_scala_proto",
deps = [":recruitment_info_proto"],
@@ -289,6 +438,15 @@ proto_library(
deps = [":unaffiliated_hero_quest_proto"],
)
swift_proto_library(
name = "round_phase_swift_proto",
protos = [":round_phase_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "round_phase_scala_proto",
deps = [":round_phase_proto"],
@@ -304,6 +462,15 @@ proto_library(
],
)
swift_proto_library(
name = "tribute_amount_swift_proto",
protos = [":tribute_amount_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
)
scala_proto_library(
name = "tribute_amount_scala_proto",
visibility = [
@@ -327,6 +494,16 @@ proto_library(
],
)
swift_proto_library(
name = "unaffiliated_hero_quest_swift_proto",
protos = [":unaffiliated_hero_quest_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [":battalion_type_swift_proto"],
)
scala_proto_library(
name = "unaffiliated_hero_quest_scala_proto",
deps = [":unaffiliated_hero_quest_proto"],
@@ -343,6 +520,16 @@ proto_library(
deps = [":battalion_type_proto"],
)
swift_proto_library(
name = "unaffiliated_hero_type_swift_proto",
protos = [":unaffiliated_hero_type_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [],
)
scala_proto_library(
name = "unaffiliated_hero_type_scala_proto",
deps = [":unaffiliated_hero_type_proto"],
@@ -368,7 +555,6 @@ go_proto_library(
":beast_info_proto",
":chronicle_entry_proto",
":combat_unit_proto",
":command_type_proto",
":date_proto",
":diplomacy_offer_proto",
":diplomacy_offer_status_proto",
@@ -1,52 +0,0 @@
syntax = "proto3";
package net.eagle0.eagle.common.command_type;
option java_multiple_files = true;
option java_package = "net.eagle0.eagle.common.command_type";
// Enum representing the type of a command, without any command-specific data.
// Used to track which command type was last executed on a province.
enum CommandType {
COMMAND_TYPE_UNKNOWN = 0;
COMMAND_TYPE_ALMS = 1;
COMMAND_TYPE_APPREHEND_OUTLAW = 2;
COMMAND_TYPE_ARM_TROOPS = 3;
COMMAND_TYPE_ATTACK_DECISION = 4;
COMMAND_TYPE_CONTROL_WEATHER = 5;
COMMAND_TYPE_DECLINE_QUEST = 6;
COMMAND_TYPE_DEFEND = 7;
COMMAND_TYPE_DIPLOMACY = 8;
COMMAND_TYPE_DIVINE = 9;
COMMAND_TYPE_EXILE_VASSAL = 10;
COMMAND_TYPE_FEAST = 11;
COMMAND_TYPE_FREE_FOR_ALL_DECISION = 12;
COMMAND_TYPE_HANDLE_CAPTURED_HERO = 13;
COMMAND_TYPE_HANDLE_RIOT_CRACK_DOWN = 14;
COMMAND_TYPE_HANDLE_RIOT_DO_NOTHING = 15;
COMMAND_TYPE_HANDLE_RIOT_GIVE = 16;
COMMAND_TYPE_HERO_GIFT = 17;
COMMAND_TYPE_IMPROVE = 18;
COMMAND_TYPE_ISSUE_ORDERS = 19;
COMMAND_TYPE_MANAGE_PRISONERS = 20;
COMMAND_TYPE_MARCH = 21;
COMMAND_TYPE_ORGANIZE_TROOPS = 22;
COMMAND_TYPE_PLEASE_RECRUIT_ME = 23;
COMMAND_TYPE_RECON = 24;
COMMAND_TYPE_RECRUIT_HEROES = 25;
COMMAND_TYPE_RESOLVE_ALLIANCE_OFFER = 26;
COMMAND_TYPE_RESOLVE_BREAK_ALLIANCE = 27;
COMMAND_TYPE_RESOLVE_INVITATION = 28;
COMMAND_TYPE_RESOLVE_RANSOM_OFFER = 29;
COMMAND_TYPE_RESOLVE_TRUCE_OFFER = 30;
COMMAND_TYPE_RESOLVE_TRIBUTE = 31;
COMMAND_TYPE_REST = 32;
COMMAND_TYPE_RETURN = 33;
COMMAND_TYPE_SEND_SUPPLIES = 34;
COMMAND_TYPE_START_EPIDEMIC = 35;
COMMAND_TYPE_SUPPRESS_BEASTS = 36;
COMMAND_TYPE_SWEAR_BROTHERHOOD = 37;
COMMAND_TYPE_TRADE = 38;
COMMAND_TYPE_TRAIN = 39;
COMMAND_TYPE_TRAVEL = 40;
}
@@ -1,3 +1,4 @@
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_proto//proto:defs.bzl", "proto_library")
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
@@ -7,6 +8,31 @@ package(default_visibility = [
"//src/test/scala/net/eagle0:__subpackages__",
])
swift_proto_library(
name = "action_result_swift_proto",
protos = [":action_result_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":battalion_swift_proto",
":changed_faction_swift_proto",
":changed_hero_swift_proto",
":changed_province_swift_proto",
":faction_swift_proto",
":hero_swift_proto",
":llm_request_swift_proto",
":llm_response_swift_proto",
":province_swift_proto",
":shardok_battle_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_notification_details_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:chronicle_entry_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_swift_proto",
],
)
scala_proto_library(
name = "action_result_scala_proto",
deps = [":action_result_proto"],
@@ -32,13 +58,24 @@ proto_library(
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_proto",
"//src/main/protobuf/net/eagle0/eagle/common:chronicle_entry_proto",
"//src/main/protobuf/net/eagle0/eagle/common:command_type_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_proto",
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_proto",
"@com_google_protobuf//:wrappers_proto",
],
)
swift_proto_library(
name = "army_swift_proto",
protos = [":army_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":supplies_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:tribute_amount_swift_proto",
],
)
scala_proto_library(
name = "army_scala_proto",
deps = [":army_proto"],
@@ -57,6 +94,15 @@ proto_library(
],
)
swift_proto_library(
name = "battalion_swift_proto",
protos = [":battalion_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
],
)
scala_proto_library(
name = "battalion_scala_proto",
deps = [":battalion_proto"],
@@ -70,6 +116,12 @@ proto_library(
deps = ["//src/main/protobuf/net/eagle0/eagle/common:battalion_type_proto"],
)
swift_proto_library(
name = "battle_revelation_swift_proto",
protos = [":battle_revelation_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
)
scala_proto_library(
name = "battle_revelation_scala_proto",
deps = [":battle_revelation_proto"],
@@ -82,6 +134,20 @@ proto_library(
],
)
swift_proto_library(
name = "changed_faction_swift_proto",
protos = [":changed_faction_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":changed_hero_swift_proto",
":changed_province_swift_proto",
":faction_relationship_swift_proto",
":faction_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/views:province_view_swift_proto",
],
)
scala_proto_library(
name = "changed_faction_scala_proto",
deps = [
@@ -103,6 +169,16 @@ proto_library(
],
)
swift_proto_library(
name = "changed_hero_swift_proto",
protos = [":changed_hero_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":event_for_hero_backstory_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:profession_swift_proto",
],
)
scala_proto_library(
name = "changed_hero_scala_proto",
deps = ["changed_hero_proto"],
@@ -120,6 +196,23 @@ proto_library(
],
)
swift_proto_library(
name = "changed_province_swift_proto",
protos = [":changed_province_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":deferred_change_swift_proto",
":province_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:improvement_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_event_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:army_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:battle_revelation_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:supplies_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:unaffiliated_hero_swift_proto",
],
)
scala_proto_library(
name = "changed_province_scala_proto",
deps = ["changed_province_proto"],
@@ -144,6 +237,15 @@ proto_library(
],
)
swift_proto_library(
name = "client_text_swift_proto",
protos = [":client_text_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_swift_proto",
],
)
scala_proto_library(
name = "client_text_scala_proto",
deps = ["client_text_proto"],
@@ -157,6 +259,12 @@ proto_library(
],
)
swift_proto_library(
name = "deferred_change_swift_proto",
protos = [":deferred_change_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
)
scala_proto_library(
name = "deferred_change_scala_proto",
deps = [":deferred_change_proto"],
@@ -167,6 +275,15 @@ proto_library(
srcs = ["deferred_change.proto"],
)
swift_proto_library(
name = "event_for_chronicle_swift_proto",
protos = [":event_for_chronicle_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
],
)
scala_proto_library(
name = "event_for_chronicle_scala_proto",
deps = [":event_for_chronicle_proto"],
@@ -181,6 +298,19 @@ proto_library(
],
)
swift_proto_library(
name = "event_for_hero_backstory_swift_proto",
protos = [":event_for_hero_backstory_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_status_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:profession_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_quest_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/views:battalion_view_swift_proto",
],
)
scala_proto_library(
name = "event_for_hero_backstory_scala_proto",
deps = [":event_for_hero_backstory_proto"],
@@ -198,6 +328,19 @@ proto_library(
],
)
swift_proto_library(
name = "faction_swift_proto",
protos = [":faction_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":faction_relationship_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_notification_details_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/views:province_view_swift_proto",
],
)
scala_proto_library(
name = "faction_scala_proto",
deps = [":faction_proto"],
@@ -216,6 +359,15 @@ proto_library(
],
)
swift_proto_library(
name = "faction_relationship_swift_proto",
protos = [":faction_relationship_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
],
)
scala_proto_library(
name = "faction_relationship_scala_proto",
deps = [":faction_relationship_proto"],
@@ -232,6 +384,17 @@ proto_library(
deps = ["//src/main/protobuf/net/eagle0/eagle/common:date_proto"],
)
swift_proto_library(
name = "game_parameters_swift_proto",
protos = [":game_parameters_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_swift_proto",
],
)
scala_proto_library(
name = "game_parameters_scala_proto",
visibility = ["//visibility:public"],
@@ -250,6 +413,18 @@ proto_library(
],
)
swift_proto_library(
name = "game_swift_proto",
protos = [":game_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":action_result_swift_proto",
":game_state_swift_proto",
":shardok_results_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
],
)
scala_proto_library(
name = "game_scala_proto",
deps = [":game_proto"],
@@ -267,6 +442,27 @@ proto_library(
],
)
swift_proto_library(
name = "game_state_swift_proto",
protos = [":game_state_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":battalion_swift_proto",
":faction_swift_proto",
":hero_swift_proto",
":llm_request_swift_proto",
":llm_response_swift_proto",
":province_swift_proto",
":run_status_swift_proto",
":shardok_battle_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_notification_details_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:chronicle_entry_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_swift_proto",
],
)
scala_proto_library(
name = "game_state_scala_proto",
deps = [":game_state_proto"],
@@ -293,6 +489,18 @@ proto_library(
],
)
swift_proto_library(
name = "hero_swift_proto",
protos = [":hero_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":event_for_hero_backstory_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:gender_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:hero_backstory_version_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:profession_swift_proto",
],
)
scala_proto_library(
name = "hero_scala_proto",
deps = [":hero_proto"],
@@ -310,6 +518,13 @@ proto_library(
],
)
swift_proto_library(
name = "llm_response_swift_proto",
protos = [":llm_response_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [":llm_request_swift_proto"],
)
scala_proto_library(
name = "llm_response_scala_proto",
deps = [":llm_response_proto"],
@@ -325,6 +540,21 @@ proto_library(
],
)
swift_proto_library(
name = "llm_request_swift_proto",
protos = [":llm_request_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":event_for_chronicle_swift_proto",
":event_for_hero_backstory_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_status_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_quest_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:hero_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/views:battalion_view_swift_proto",
],
)
scala_proto_library(
name = "llm_request_scala_proto",
deps = [":llm_request_proto"],
@@ -348,6 +578,27 @@ proto_library(
],
)
swift_proto_library(
name = "province_swift_proto",
protos = [":province_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":army_swift_proto",
":battle_revelation_swift_proto",
":deferred_change_swift_proto",
":supplies_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:improvement_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_event_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:tribute_amount_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:unaffiliated_hero_swift_proto",
],
)
scala_proto_library(
name = "province_scala_proto",
visibility = ["//visibility:public"],
@@ -365,8 +616,8 @@ proto_library(
":deferred_change_proto",
":supplies_proto",
":unaffiliated_hero_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_proto",
"//src/main/protobuf/net/eagle0/eagle/common:command_type_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_proto",
"//src/main/protobuf/net/eagle0/eagle/common:improvement_type_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_event_proto",
@@ -375,6 +626,13 @@ proto_library(
],
)
swift_proto_library(
name = "run_status_swift_proto",
protos = [":run_status_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [],
)
scala_proto_library(
name = "run_status_scala_proto",
deps = [":run_status_proto"],
@@ -387,6 +645,13 @@ proto_library(
],
)
swift_proto_library(
name = "running_games_swift_proto",
protos = [":running_games_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [],
)
scala_proto_library(
name = "running_games_scala_proto",
deps = [":running_games_proto"],
@@ -397,6 +662,16 @@ proto_library(
srcs = ["running_games.proto"],
)
swift_proto_library(
name = "shardok_battle_swift_proto",
protos = [":shardok_battle_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":army_swift_proto",
"//src/main/protobuf/net/eagle0/common:victory_condition_swift_proto",
],
)
scala_proto_library(
name = "shardok_battle_scala_proto",
deps = [":shardok_battle_proto"],
@@ -413,6 +688,17 @@ proto_library(
],
)
swift_proto_library(
name = "shardok_results_swift_proto",
protos = [":shardok_results_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/shardok/api:action_result_view_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_swift_proto",
"//src/main/protobuf/net/eagle0/shardok/storage:action_result_swift_proto",
],
)
scala_proto_library(
name = "shardok_results_scala_proto",
deps = [":shardok_results_proto"],
@@ -430,6 +716,14 @@ proto_library(
],
)
swift_proto_library(
name = "supplies_swift_proto",
protos = [":supplies_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
],
)
scala_proto_library(
name = "supplies_scala_proto",
deps = [":supplies_proto"],
@@ -443,6 +737,20 @@ proto_library(
deps = ["@com_google_protobuf//:wrappers_proto"],
)
swift_proto_library(
name = "unaffiliated_hero_swift_proto",
protos = [":unaffiliated_hero_proto"],
visibility = [
"//src/main/protobuf/net/eagle0:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:recruitment_info_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_type_swift_proto",
],
)
scala_proto_library(
name = "unaffiliated_hero_scala_proto",
deps = [":unaffiliated_hero_proto"],
@@ -12,7 +12,6 @@ import "src/main/protobuf/net/eagle0/eagle/common/action_result_notification_det
import "src/main/protobuf/net/eagle0/eagle/common/action_result_type.proto";
import "src/main/protobuf/net/eagle0/eagle/common/battalion_type.proto";
import "src/main/protobuf/net/eagle0/eagle/common/chronicle_entry.proto";
import "src/main/protobuf/net/eagle0/eagle/common/command_type.proto";
import "src/main/protobuf/net/eagle0/eagle/common/date.proto";
import "src/main/protobuf/net/eagle0/eagle/common/round_phase.proto";
import "src/main/protobuf/net/eagle0/eagle/internal/battalion.proto";
@@ -64,9 +63,7 @@ message ActionResult {
.google.protobuf.Int32Value province_acted = 17;
// Deprecated: Use last_command_type_for_acting_province instead. Kept for backwards compatibility with saved games.
.net.eagle0.eagle.api.SelectedCommand deprecated_last_command_type_for_acting_province = 28;
.net.eagle0.eagle.common.command_type.CommandType last_command_type_for_acting_province = 43;
.net.eagle0.eagle.api.SelectedCommand last_command_type_for_acting_province = 28;
.google.protobuf.Int32Value leader = 18;
@@ -7,8 +7,8 @@ syntax = "proto3";
package net.eagle0.eagle.internal;
import "google/protobuf/wrappers.proto";
import "src/main/protobuf/net/eagle0/eagle/api/selected_command.proto";
import "src/main/protobuf/net/eagle0/eagle/common/battalion_type.proto";
import "src/main/protobuf/net/eagle0/eagle/common/command_type.proto";
import "src/main/protobuf/net/eagle0/eagle/common/date.proto";
import "src/main/protobuf/net/eagle0/eagle/common/improvement_type.proto";
import "src/main/protobuf/net/eagle0/eagle/common/province_event.proto";
@@ -104,7 +104,7 @@ message Province {
.net.eagle0.eagle.common.Date last_beasts_date = 25;
.net.eagle0.eagle.common.Date last_riot_date = 35;
.net.eagle0.eagle.common.command_type.CommandType last_command = 26;
.net.eagle0.eagle.api.SelectedCommand last_command = 26;
int32 castle_count = 33;
int32 hero_cap = 39;
@@ -15,8 +15,6 @@ message RunningGame {
int64 game_id = 1;
map<string, int32> user_to_pid = 2;
bool ongoing = 3;
// Timestamp (millis since epoch) of when each user last took a turn
map<string, int64> last_played_by_user = 4;
}
message RunningGames {
@@ -1,7 +1,18 @@
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
load("@rules_proto//proto:defs.bzl", "proto_library")
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
swift_proto_library(
name = "action_result_view_swift_proto",
protos = [":action_result_view_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:action_result_notification_details_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_swift_proto",
],
)
scala_proto_library(
name = "action_result_view_scala_proto",
visibility = [
@@ -25,6 +36,15 @@ proto_library(
],
)
swift_proto_library(
name = "army_view_swift_proto",
protos = [":army_view_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_swift_proto",
],
)
scala_proto_library(
name = "army_view_scala_proto",
visibility = [
@@ -45,6 +65,19 @@ proto_library(
],
)
swift_proto_library(
name = "battalion_view_swift_proto",
protos = [":battalion_view_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/protobuf/net/eagle0/eagle/internal:__pkg__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
],
)
scala_proto_library(
name = "battalion_view_scala_proto",
visibility = [
@@ -69,6 +102,15 @@ proto_library(
],
)
swift_proto_library(
name = "faction_relationship_view_swift_proto",
protos = [":faction_relationship_view_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
],
)
scala_proto_library(
name = "faction_relationship_view_scala_proto",
visibility = [
@@ -89,6 +131,16 @@ proto_library(
],
)
swift_proto_library(
name = "faction_view_swift_proto",
protos = [":faction_view_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":faction_relationship_view_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
],
)
scala_proto_library(
name = "faction_view_scala_proto",
visibility = [
@@ -111,6 +163,23 @@ proto_library(
],
)
swift_proto_library(
name = "game_state_view_swift_proto",
protos = [":game_state_view_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
":battalion_view_swift_proto",
":faction_view_swift_proto",
":hero_view_swift_proto",
":province_view_swift_proto",
":shardok_battle_view_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:chronicle_entry_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_swift_proto",
],
)
scala_proto_library(
name = "game_state_view_scala_proto",
visibility = [
@@ -139,6 +208,20 @@ proto_library(
],
)
swift_proto_library(
name = "hero_view_swift_proto",
protos = [":hero_view_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
":stat_with_condition_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:gender_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:profession_swift_proto",
],
)
scala_proto_library(
name = "hero_view_scala_proto",
visibility = [
@@ -164,6 +247,13 @@ proto_library(
],
)
swift_proto_library(
name = "incoming_army_view_swift_proto",
protos = [":incoming_army_view_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [":battalion_view_swift_proto"],
)
scala_proto_library(
name = "incoming_army_view_scala_proto",
visibility = [
@@ -180,6 +270,27 @@ proto_library(
deps = [":battalion_view_proto"],
)
swift_proto_library(
name = "province_view_swift_proto",
protos = [":province_view_proto"],
visibility = [
"//src/main/protobuf/net/eagle0/eagle:__subpackages__",
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
],
deps = [
":army_view_swift_proto",
":battalion_view_swift_proto",
":incoming_army_view_swift_proto",
":stat_with_condition_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:profession_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_event_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/common:recruitment_info_swift_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:unaffiliated_hero_swift_proto",
],
)
scala_proto_library(
name = "province_view_scala_proto",
visibility = [
@@ -212,6 +323,13 @@ proto_library(
],
)
swift_proto_library(
name = "stat_with_condition_swift_proto",
protos = [":stat_with_condition_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [],
)
scala_proto_library(
name = "stat_with_condition_scala_proto",
visibility = [
@@ -231,6 +349,15 @@ proto_library(
],
)
swift_proto_library(
name = "shardok_battle_view_swift_proto",
protos = [":shardok_battle_view_proto"],
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
deps = [
"//src/main/protobuf/net/eagle0/common:hostility_swift_proto",
],
)
scala_proto_library(
name = "shardok_battle_view_scala_proto",
visibility = [

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