mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 09:55:41 +00:00
Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99e331b40b | ||
|
|
0d832ea8f2 | ||
|
|
e296a31033 | ||
|
|
239d626ac1 | ||
|
|
bb1f3929c3 | ||
|
|
8fb218d8ee | ||
|
|
c85973edda | ||
|
|
ae970a6693 | ||
|
|
6df165cc5a | ||
|
|
c26b531792 | ||
|
|
4682784737 | ||
|
|
139eb89edb | ||
|
|
c6c31cd28e | ||
|
|
d5665c9570 | ||
|
|
0719d01819 | ||
|
|
a4d7ac4283 | ||
|
|
cf16c594a2 | ||
|
|
1073a208de | ||
|
|
e96c0704ed | ||
|
|
ef1a447431 | ||
|
|
f874231e04 | ||
|
|
5acb4c4f14 | ||
|
|
2dc6f4d0bb | ||
|
|
bbef0c1430 | ||
|
|
f929672f83 | ||
|
|
7eb362a09b | ||
|
|
e4b7d8e8a8 | ||
|
|
a56bea82f1 | ||
|
|
8bc22baa7c | ||
|
|
2f31d94313 | ||
|
|
7b46295e17 | ||
|
|
6c154e827c | ||
|
|
040b0cf580 | ||
|
|
3ae3f6ba16 | ||
|
|
6da0636731 | ||
|
|
53ab903568 | ||
|
|
e7745cc9ec | ||
|
|
a3f185b6f6 | ||
|
|
8f3bee853f | ||
|
|
b4234debce | ||
|
|
7f562de2a8 | ||
|
|
bc5d86fb1e | ||
|
|
10a747114a | ||
|
|
a94a48a4bf | ||
|
|
945511153a | ||
|
|
d131aa50f0 | ||
|
|
d0d84e98e0 | ||
|
|
0e3f1bcb87 | ||
|
|
37fe57be71 | ||
|
|
1a147b0a19 | ||
|
|
37a9a29eaf | ||
|
|
0ff7d51f2c | ||
|
|
0354fefdca | ||
|
|
6fb14ef9af | ||
|
|
eaf00de74d | ||
|
|
f2743fe9cc | ||
|
|
0bdc4d46ce | ||
|
|
0e9dc4121f | ||
|
|
e9281f66fe | ||
|
|
336d008f26 | ||
|
|
5a56f71c4c | ||
|
|
6e66b88929 | ||
|
|
eee10dc209 | ||
|
|
caa843f763 | ||
|
|
bb6de75ad0 | ||
|
|
ad418c8848 | ||
|
|
093316b1c6 | ||
|
|
55ad8a8278 | ||
|
|
0361464db1 | ||
|
|
31649c73a7 | ||
|
|
c24509e0b2 | ||
|
|
ca2a13e17b | ||
|
|
65a9cf1f97 | ||
|
|
dd9d6d046d | ||
|
|
738dd12c7c | ||
|
|
1d98df6cbc | ||
|
|
f74f61c18e | ||
|
|
88bd7ade50 | ||
|
|
99721d963a | ||
|
|
4296d5046a | ||
|
|
353eb3907c | ||
|
|
ded06ba815 | ||
|
|
70392b109a | ||
|
|
821e3f1a4a | ||
|
|
ebe819a5a1 | ||
|
|
3e868d0305 | ||
|
|
e46867333c | ||
|
|
2d59d4ff51 | ||
|
|
d493182184 | ||
|
|
8ce4e4c4a9 | ||
|
|
9729110ea8 | ||
|
|
2224e78a36 | ||
|
|
007a57eea2 | ||
|
|
3febf2a6cd | ||
|
|
26204cc879 | ||
|
|
7032260a31 |
@@ -24,7 +24,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build-auth:
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, bazel]
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-auth.outputs.image_tag }}
|
||||
steps:
|
||||
|
||||
@@ -26,7 +26,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, bazel]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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
|
||||
@@ -1,35 +0,0 @@
|
||||
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
|
||||
+104
-444
@@ -4,7 +4,7 @@ on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/main/cpp/**'
|
||||
# Note: C++ changes trigger shardok_arm64_build.yml instead
|
||||
- 'src/main/go/**'
|
||||
- 'src/main/scala/**'
|
||||
- 'src/main/protobuf/**'
|
||||
@@ -22,37 +22,63 @@ 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:
|
||||
build-eagle:
|
||||
runs-on: self-hosted
|
||||
# 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]
|
||||
outputs:
|
||||
image_tag: ${{ steps.push-eagle.outputs.image_tag }}
|
||||
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 }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
|
||||
- name: Build Eagle Docker image
|
||||
id: build-eagle
|
||||
- name: Build all Docker images
|
||||
id: build-all
|
||||
run: |
|
||||
set -ex
|
||||
bazel build --platforms=//:linux_x86_64 //ci:eagle_server_image
|
||||
|
||||
# Also build the warmup tool for Linux
|
||||
bazel build //src/main/go/net/eagle0/warmup:warmup_linux_amd64
|
||||
# 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
|
||||
|
||||
# Copy warmup binary to scripts/ so it gets deployed with other scripts
|
||||
# Copy warmup binary to scripts/ for deployment
|
||||
mkdir -p scripts/bin
|
||||
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
|
||||
|
||||
# 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
|
||||
# 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)
|
||||
|
||||
echo "eagle_path=$EAGLE_PATH" >> $GITHUB_OUTPUT
|
||||
echo "admin_path=$ADMIN_PATH" >> $GITHUB_OUTPUT
|
||||
echo "jfr_path=$JFR_PATH" >> $GITHUB_OUTPUT
|
||||
|
||||
echo "=== Image paths ==="
|
||||
echo "Eagle: $EAGLE_PATH"
|
||||
echo "Admin: $ADMIN_PATH"
|
||||
echo "JFR Sidecar: $JFR_PATH"
|
||||
|
||||
- name: Login to DigitalOcean Container Registry
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
@@ -62,381 +88,70 @@ jobs:
|
||||
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 Eagle image to DO registry
|
||||
id: push-eagle
|
||||
- name: Push all images to DO registry
|
||||
id: push-images
|
||||
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"
|
||||
GIT_SHA=$(git rev-parse --short=8 HEAD)
|
||||
|
||||
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
|
||||
# Get crane from push target 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
|
||||
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
|
||||
|
||||
# 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
|
||||
echo "ERROR: crane not found"
|
||||
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"
|
||||
# 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
|
||||
|
||||
# Output the full image tag for deploy step
|
||||
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
|
||||
# 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
|
||||
|
||||
# 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"
|
||||
# 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
|
||||
|
||||
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')
|
||||
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 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
|
||||
|
||||
ADMIN_IMAGE="${{ steps.build-admin.outputs.image_path }}"
|
||||
echo "Using Admin image: $ADMIN_IMAGE"
|
||||
|
||||
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 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"
|
||||
|
||||
# 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/admin-server:latest"
|
||||
|
||||
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"
|
||||
echo "=== All images pushed successfully ==="
|
||||
|
||||
deploy:
|
||||
runs-on: self-hosted
|
||||
needs: [build-eagle, build-shardok, build-admin, build-jfr-sidecar]
|
||||
runs-on: [self-hosted, bazel]
|
||||
needs: [build-all]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
env:
|
||||
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 }}
|
||||
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 }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
|
||||
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
|
||||
@@ -475,16 +190,13 @@ jobs:
|
||||
- name: Copy config files to droplet
|
||||
run: |
|
||||
# Create directory structure on remote
|
||||
# Use -p to create parents, and test write access before copying
|
||||
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
|
||||
|
||||
# Remove existing warmup binary (it may have read-only permissions from bazel)
|
||||
rm -f /opt/eagle0/scripts/bin/warmup
|
||||
SETUP_DIRS
|
||||
|
||||
# Copy files preserving structure
|
||||
# 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/
|
||||
@@ -499,7 +211,6 @@ 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}"
|
||||
@@ -520,18 +231,15 @@ jobs:
|
||||
FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
|
||||
DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
|
||||
|
||||
# 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)
|
||||
# Check Docker has IPv6 support
|
||||
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 using shared script (preserves vars set by other workflows)
|
||||
chmod +x update-env.sh
|
||||
./update-env.sh \
|
||||
# Update env vars
|
||||
chmod +x deploy/update-env.sh
|
||||
cd deploy && ./update-env.sh \
|
||||
"EAGLE_IMAGE=\${EAGLE_IMAGE}" \
|
||||
"SHARDOK_IMAGE=\${SHARDOK_IMAGE}" \
|
||||
"ADMIN_IMAGE=\${ADMIN_IMAGE}" \
|
||||
"JFR_SIDECAR_IMAGE=\${JFR_SIDECAR_IMAGE}" \
|
||||
"OPENAI_API_KEY=\${OPENAI_API_KEY}" \
|
||||
@@ -550,103 +258,55 @@ jobs:
|
||||
"FASTMAIL_API_TOKEN=\${FASTMAIL_API_TOKEN}" \
|
||||
"FASTMAIL_FROM_EMAIL=\${FASTMAIL_FROM_EMAIL}" \
|
||||
"FASTMAIL_FROM_NAME=\${FASTMAIL_FROM_NAME}"
|
||||
cd ..
|
||||
|
||||
# Login to registry
|
||||
echo "\$DO_REGISTRY_TOKEN" | docker login registry.digitalocean.com -u "\$DO_REGISTRY_TOKEN" --password-stdin
|
||||
|
||||
# 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"
|
||||
echo "Using images: \$EAGLE_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
|
||||
|
||||
# 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
|
||||
# Install crane for pulling OCI images
|
||||
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
|
||||
if [ ! -x crane ]; then
|
||||
echo "ERROR: Failed to install crane"
|
||||
ls -la crane || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Crane installed"
|
||||
ls -la crane
|
||||
|
||||
# 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
|
||||
# Pull and load all images
|
||||
echo "Pulling Eagle image..."
|
||||
./crane pull "\${EAGLE_IMAGE}" eagle.tar && docker load -i eagle.tar && rm eagle.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 Admin image..."
|
||||
./crane pull "\${ADMIN_IMAGE}" admin.tar && docker load -i admin.tar && rm admin.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
|
||||
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 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
|
||||
|
||||
# Keep crane for blue-green deploys (don't delete it)
|
||||
echo "Crane kept at ./crane for future blue-green deploys"
|
||||
|
||||
# Also pull other compose images
|
||||
# Pull other compose images
|
||||
docker pull nginx:alpine || true
|
||||
docker pull certbot/certbot || true
|
||||
|
||||
echo "All images pulled successfully"
|
||||
|
||||
# Recreate non-Eagle services (not auth - managed by auth_build.yml)
|
||||
# Note: jfr-sidecar must start after eagle-blue due to PID namespace sharing
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate shardok admin
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# 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}"
|
||||
|
||||
# Start jfr-sidecar after eagle-blue (shares PID namespace with eagle-blue)
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate jfr-sidecar
|
||||
|
||||
# Ensure auth is running (but don't force-recreate it)
|
||||
# Ensure auth is running
|
||||
docker compose -f docker-compose.prod.yml up -d auth
|
||||
|
||||
# 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
|
||||
|
||||
# Wait for health checks
|
||||
# Verify
|
||||
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 stopped containers and old images
|
||||
# Cleanup
|
||||
docker container prune -f
|
||||
docker image prune -f
|
||||
DEPLOY_SCRIPT
|
||||
|
||||
@@ -10,13 +10,14 @@ 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
|
||||
runs-on: [self-hosted, bazel]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -29,6 +30,19 @@ 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
|
||||
|
||||
@@ -68,15 +82,30 @@ 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 "=================================="
|
||||
|
||||
# Update the unified manifest
|
||||
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /tmp/installer_manifest.txt
|
||||
|
||||
# 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
|
||||
+146
-27
@@ -6,12 +6,18 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/mac_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "src/main/proto/**"
|
||||
- "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/inject_sparkle.sh"
|
||||
- "scripts/codesign_mac_app.sh"
|
||||
- "scripts/notarize_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/**"
|
||||
@@ -19,17 +25,24 @@ on:
|
||||
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/inject_sparkle.sh"
|
||||
- "scripts/codesign_mac_app.sh"
|
||||
- "scripts/notarize_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_notarization:
|
||||
description: 'Skip notarization (for testing)'
|
||||
skip_signing:
|
||||
description: 'Skip code signing, notarization, and deploy (build only)'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
@@ -38,8 +51,11 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
mac-unity:
|
||||
runs-on: self-hosted
|
||||
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
|
||||
@@ -68,8 +84,21 @@ jobs:
|
||||
chmod +x ./scripts/inject_sparkle.sh
|
||||
./scripts/inject_sparkle.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
|
||||
|
||||
- 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() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
|
||||
env:
|
||||
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
|
||||
@@ -81,8 +110,11 @@ jobs:
|
||||
# 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 || true
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
|
||||
@@ -96,25 +128,124 @@ jobs:
|
||||
rm certificate.p12
|
||||
|
||||
- name: Code Sign App
|
||||
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
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: Notarize App
|
||||
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.event.inputs.skip_notarization != 'true'
|
||||
- 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_mac_app.sh
|
||||
./scripts/notarize_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
|
||||
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
|
||||
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
@@ -135,15 +266,3 @@ jobs:
|
||||
"$SPARKLE_PRIVATE_KEY_PATH"
|
||||
|
||||
rm "$SPARKLE_PRIVATE_KEY_PATH"
|
||||
|
||||
- name: Cleanup Keychain
|
||||
if: always()
|
||||
run: |
|
||||
security delete-keychain build.keychain 2>/dev/null || true
|
||||
|
||||
- name: Archive Build Log
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: editor_mac.log
|
||||
path: /tmp/eagle0/editor_mac.log
|
||||
|
||||
@@ -24,7 +24,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build-shardok-arm64:
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, bazel]
|
||||
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
|
||||
runs-on: [self-hosted, bazel]
|
||||
needs: [build-shardok-arm64]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
|
||||
@@ -28,7 +28,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: self-hosted
|
||||
runs-on: [self-hosted, bazel]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -6,7 +6,11 @@ on:
|
||||
paths:
|
||||
- ".github/workflows/unity_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "src/main/proto/**"
|
||||
- "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/**"
|
||||
- "scripts/build_protos.sh"
|
||||
- "scripts/build_plugins.sh"
|
||||
- "scripts/build_windows_plugin.sh"
|
||||
@@ -15,12 +19,16 @@ on:
|
||||
- "ci/github_actions/persist_library.sh"
|
||||
- "MODULE.bazel"
|
||||
- "WORKSPACE"
|
||||
- "src/main/proto/net/eagle0/eagle/**/BUILD.bazel"
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/unity_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "src/main/proto/**"
|
||||
- "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/**"
|
||||
- "scripts/build_protos.sh"
|
||||
- "scripts/build_plugins.sh"
|
||||
- "scripts/build_windows_plugin.sh"
|
||||
@@ -29,14 +37,13 @@ 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
|
||||
runs-on: [self-hosted, macOS, unity-windows]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -63,7 +70,24 @@ jobs:
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
run: bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /tmp/unity_manifest.txt
|
||||
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
|
||||
- name: Archive build log
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -38,3 +38,4 @@ 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/
|
||||
|
||||
@@ -4,14 +4,18 @@
|
||||
|
||||
**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
|
||||
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.
|
||||
|
||||
+28
-10
@@ -13,8 +13,8 @@ AWS_SDK_VERSION = "2.28.1"
|
||||
# Core Build Tools
|
||||
#
|
||||
|
||||
bazel_dep(name = "bazel_skylib", version = "1.8.1")
|
||||
bazel_dep(name = "rules_pkg", version = "1.1.0")
|
||||
bazel_dep(name = "bazel_skylib", version = "1.9.0")
|
||||
bazel_dep(name = "rules_pkg", version = "1.2.0")
|
||||
|
||||
#
|
||||
# Language Support - Scala
|
||||
@@ -102,8 +102,8 @@ sysroot(
|
||||
# Language Support - Go
|
||||
#
|
||||
|
||||
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")
|
||||
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")
|
||||
|
||||
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
|
||||
go_sdk.download(version = "1.23.3")
|
||||
@@ -130,6 +130,13 @@ bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_a
|
||||
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")
|
||||
|
||||
# Register Apple CC toolchain for Objective-C compilation
|
||||
apple_cc_configure = use_extension(
|
||||
"@build_bazel_apple_support//crosstool:setup.bzl",
|
||||
"apple_cc_configure_extension",
|
||||
)
|
||||
use_repo(apple_cc_configure, "local_config_apple_cc")
|
||||
|
||||
#
|
||||
# Protocol Buffers & RPC
|
||||
#
|
||||
@@ -137,7 +144,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.2.10")
|
||||
bazel_dep(name = "flatbuffers", version = "25.9.23")
|
||||
|
||||
#
|
||||
# Testing
|
||||
@@ -149,8 +156,8 @@ bazel_dep(name = "googletest", version = "1.17.0")
|
||||
# Container Images (OCI)
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_oci", version = "2.2.6")
|
||||
bazel_dep(name = "aspect_bazel_lib", version = "2.16.0")
|
||||
bazel_dep(name = "rules_oci", version = "2.2.7")
|
||||
bazel_dep(name = "aspect_bazel_lib", version = "2.22.4")
|
||||
|
||||
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
|
||||
|
||||
@@ -186,7 +193,7 @@ use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17",
|
||||
# Java/Scala Dependencies
|
||||
#
|
||||
|
||||
bazel_dep(name = "rules_jvm_external", version = "6.3")
|
||||
bazel_dep(name = "rules_jvm_external", version = "6.9")
|
||||
|
||||
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
|
||||
maven.install(
|
||||
@@ -293,14 +300,25 @@ http_archive(
|
||||
],
|
||||
)
|
||||
|
||||
# Sparkle framework for macOS auto-updates
|
||||
SPARKLE_VERSION = "2.6.4"
|
||||
|
||||
http_archive(
|
||||
name = "sparkle",
|
||||
build_file = "@//external:BUILD.sparkle",
|
||||
sha256 = "50612a06038abc931f16011d7903b8326a362c1074dabccb718404ce8e585f0b",
|
||||
strip_prefix = "",
|
||||
url = "https://github.com/sparkle-project/Sparkle/releases/download/%s/Sparkle-%s.tar.xz" % (SPARKLE_VERSION, SPARKLE_VERSION),
|
||||
)
|
||||
|
||||
# Busybox static binary for Docker health checks (provides nc, wget, etc.)
|
||||
# Primary: GitHub release mirror (reliable)
|
||||
# Primary: DigitalOcean Spaces (public, reliable)
|
||||
# Fallback: busybox.net (can be unreliable/slow)
|
||||
http_file(
|
||||
name = "busybox_x86_64",
|
||||
sha256 = "6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348",
|
||||
urls = [
|
||||
"https://github.com/nolen777/eagle0/releases/download/busybox-1.35.0/busybox-1.35.0-x86_64-linux-musl",
|
||||
"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",
|
||||
],
|
||||
downloaded_file_path = "busybox",
|
||||
|
||||
Generated
+119
-30
@@ -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.19.3/source.json": "ffab9254c65ba945f8369297ad97ca0dec213d3adc6e07877e23a48624a8b456",
|
||||
"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.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.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_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_lint/0.12.0/MODULE.bazel": "e767c5dbfeb254ec03275a7701b5cfde2c4d2873676804bc7cb27ddff3728fed",
|
||||
"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/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/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,12 +57,15 @@
|
||||
"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",
|
||||
@@ -77,7 +80,8 @@
|
||||
"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.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb",
|
||||
"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_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",
|
||||
@@ -104,8 +108,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.2.10/MODULE.bazel": "dab15cafe8512d2c4a8daa44c2d7968c5c79f01e220d40076cdc260bf58605e2",
|
||||
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/source.json": "7eae7ea3eb913b9802426e4d5df11d6c6072a3573a548f8cabf1e965f5cca4d0",
|
||||
"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/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",
|
||||
@@ -115,8 +119,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.45.0/MODULE.bazel": "ecd19ebe9f8e024e1ccffb6d997cc893a974bcc581f1ae08f386bdd448b10687",
|
||||
"https://bcr.bazel.build/modules/gazelle/0.45.0/source.json": "111d182facc5f5e80f0b823d5f077b74128f40c3fd2eccc89a06f34191bd3392",
|
||||
"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/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",
|
||||
@@ -176,6 +180,7 @@
|
||||
"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",
|
||||
@@ -229,9 +234,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",
|
||||
@@ -246,6 +251,8 @@
|
||||
"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",
|
||||
@@ -263,8 +270,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.56.1/MODULE.bazel": "d5b835c548ac917345f1780cd2da52edc1130a908fe091c92096895303ae78a0",
|
||||
"https://bcr.bazel.build/modules/rules_go/0.56.1/source.json": "0c902f7272e8d4e47e459af97be472bc19dadbbe6023a0719d1adce8483ac75a",
|
||||
"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_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",
|
||||
@@ -292,7 +299,8 @@
|
||||
"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.3/source.json": "6f5f5a5a4419ae4e37c35a5bb0a6ae657ed40b7abc5a5189111b47fcebe43197",
|
||||
"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_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",
|
||||
@@ -306,12 +314,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.6/MODULE.bazel": "2ba6ddd679269e00aeffe9ca04faa2d0ca4129650982c9246d0d459fe2da47d9",
|
||||
"https://bcr.bazel.build/modules/rules_oci/2.2.6/source.json": "94e7decb8f95d9465b0bbea71c65064cd16083be1350c7468f131818641dc4a5",
|
||||
"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_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.1.0/MODULE.bazel": "9db8031e71b6ef32d1846106e10dd0ee2deac042bd9a2de22b4761b0c3036453",
|
||||
"https://bcr.bazel.build/modules/rules_pkg/1.1.0/source.json": "fef768df13a92ce6067e1cd0cdc47560dace01354f1d921cfb1d632511f7d608",
|
||||
"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_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",
|
||||
@@ -334,7 +342,8 @@
|
||||
"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.0.0/source.json": "b0162a65c6312e45e7912e39abd1a7f8856c2c7e41ecc9b6dc688a6f6400a917",
|
||||
"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_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",
|
||||
@@ -345,8 +354,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.3.1/MODULE.bazel": "0b42093600d9226bcbdb31fb86d25d4204293d716fdbb2e50a1852547032a660",
|
||||
"https://bcr.bazel.build/modules/rules_swift/2.3.1/source.json": "87d28609c37d2061db2f6fc3aae8ab7fbda9adf556cd88fbd0c7d520b8d81391",
|
||||
"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/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",
|
||||
@@ -360,6 +369,7 @@
|
||||
"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",
|
||||
@@ -386,7 +396,7 @@
|
||||
"@@apple_support~//crosstool:setup.bzl%apple_cc_configure_extension": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "Z3yAd66IJL0GAZUTSeMOjoHiE1SZPPwiIs/XQui5BvE=",
|
||||
"usagesDigest": "TOb4CUri5UsTKxgIDTNzR0ddIc21eYLCRIm+jqQmjlg=",
|
||||
"usagesDigest": "DqQsfZN5lA8z+nLEEY+EpKGzQ8M73mDm/A8lofDSyus=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
@@ -413,7 +423,7 @@
|
||||
},
|
||||
"@@aspect_rules_esbuild~//esbuild:extensions.bzl%esbuild": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "8jv3p0xDR/oitFeH8y0+Y5xlyrUbfsTRlc9TSwYkwl8=",
|
||||
"bzlTransitiveDigest": "8L5Llfl6uxIWXd5GR+Qmmm04/jxp6TuJH5LFhIZIUCA=",
|
||||
"usagesDigest": "iDVoyPxUeADmfK8ssoyG3Ehq1bj6p7A43LpEiE266os=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
@@ -492,6 +502,7 @@
|
||||
"extra_build_content": "",
|
||||
"generate_bzl_library_targets": false,
|
||||
"extract_full_archive": false,
|
||||
"exclude_package_contents": [],
|
||||
"system_tar": "auto"
|
||||
}
|
||||
},
|
||||
@@ -516,11 +527,17 @@
|
||||
"package_visibility": [
|
||||
"//visibility:public"
|
||||
],
|
||||
"replace_package": ""
|
||||
"replace_package": "",
|
||||
"exclude_package_contents": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"recordedRepoMappingEntries": [
|
||||
[
|
||||
"aspect_bazel_lib~",
|
||||
"bazel_lib",
|
||||
"bazel_lib~"
|
||||
],
|
||||
[
|
||||
"aspect_bazel_lib~",
|
||||
"bazel_skylib",
|
||||
@@ -531,6 +548,11 @@
|
||||
"bazel_tools",
|
||||
"bazel_tools"
|
||||
],
|
||||
[
|
||||
"aspect_bazel_lib~",
|
||||
"tar.bzl",
|
||||
"tar.bzl~"
|
||||
],
|
||||
[
|
||||
"aspect_rules_esbuild~",
|
||||
"aspect_rules_js",
|
||||
@@ -546,6 +568,11 @@
|
||||
"aspect_bazel_lib",
|
||||
"aspect_bazel_lib~"
|
||||
],
|
||||
[
|
||||
"aspect_rules_js~",
|
||||
"aspect_rules_js",
|
||||
"aspect_rules_js~"
|
||||
],
|
||||
[
|
||||
"aspect_rules_js~",
|
||||
"bazel_skylib",
|
||||
@@ -555,6 +582,31 @@
|
||||
"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~"
|
||||
]
|
||||
]
|
||||
}
|
||||
@@ -1162,7 +1214,7 @@
|
||||
"@@rules_nodejs~//nodejs:extensions.bzl%node": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "q44Ox2Nwogn6OsO0Xw5lhjkd/xmxkvvpwVOn5P4pmHQ=",
|
||||
"usagesDigest": "WQpLKLujnBfrx9sMWCJgyaK9P04binseT6CGBy3vP4E=",
|
||||
"usagesDigest": "Py5Wgc5kr5fTMe1FKrlFK276B6SodesXp6nw2Fq5XA8=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
@@ -1292,8 +1344,8 @@
|
||||
},
|
||||
"@@rules_oci~//oci:extensions.bzl%oci": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
|
||||
"usagesDigest": "39yHQmifPoGf+JSYpnQSnJGugxbrFVge/+ENmUiqZ6M=",
|
||||
"bzlTransitiveDigest": "AOLP47LtVHSKSDiukosQymx543OwcgeoQP666wwuj3o=",
|
||||
"usagesDigest": "3Xsv1/UEV8MOARW4BZScPw3Gxtx19OQ5EXOqcL1p9bI=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
@@ -1580,6 +1632,43 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"@@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=",
|
||||
@@ -5056,7 +5145,7 @@
|
||||
"@@rules_swift~//swift:extensions.bzl%non_module_deps": {
|
||||
"general": {
|
||||
"bzlTransitiveDigest": "PAIMhc1bVKfcyoHeg0xO8LMS9KN5yzbsMGwa5O2ifJM=",
|
||||
"usagesDigest": "A3fzk5iHsrLdI3PokT1bHIdeJ2j9tc09H3/3Old6IfU=",
|
||||
"usagesDigest": "l2vIL7SL4tJqHIVLWd78Y/ym+r0II64lmvoX8o+0Bb0=",
|
||||
"recordedFileInputs": {},
|
||||
"recordedDirentsInputs": {},
|
||||
"envVariables": {},
|
||||
|
||||
@@ -1,6 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
set -uxo pipefail
|
||||
|
||||
/bin/echo "persist Library/"
|
||||
/usr/bin/rsync -rtlDvq src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ /tmp/eagle0/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
|
||||
+3
-3
@@ -3,8 +3,8 @@
|
||||
# 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
|
||||
SHARDOK_IMAGE=registry.digitalocean.com/eagle0/shardok-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
|
||||
@@ -27,8 +27,8 @@ DISCORD_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Shardok connection
|
||||
SHARDOK_ADDRESS=shardok:40042
|
||||
# Shardok connection (Hetzner ARM64 server)
|
||||
SHARDOK_ADDRESS=
|
||||
SHARDOK_AUTH_TOKEN=
|
||||
|
||||
# Monitoring
|
||||
|
||||
+39
-32
@@ -1,11 +1,13 @@
|
||||
# Docker Compose for production deployment
|
||||
#
|
||||
# Local testing:
|
||||
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load && bazel run //ci:auth_server_load
|
||||
# Build images: bazel run //ci:eagle_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
|
||||
@@ -33,7 +35,7 @@ services:
|
||||
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
|
||||
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
|
||||
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
|
||||
# Auth token for remote Shardok on Hetzner (only used when shardok address contains .eagle0.net)
|
||||
# Auth token for Shardok on Hetzner (required)
|
||||
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
|
||||
# Use persistent volume for save data (users, games, etc.)
|
||||
EAGLE_SAVE_DIR: "/app/saves"
|
||||
@@ -47,7 +49,6 @@ 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:
|
||||
@@ -92,9 +93,10 @@ 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:
|
||||
@@ -140,6 +142,8 @@ 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
|
||||
@@ -158,30 +162,8 @@ services:
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
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
|
||||
# Note: Shardok runs on Hetzner ARM64 server, not in this docker-compose.
|
||||
# Configure SHARDOK_ADDRESS to point to the Hetzner instance.
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
@@ -196,8 +178,9 @@ 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"
|
||||
@@ -214,14 +197,14 @@ services:
|
||||
- "--auth-addr"
|
||||
- "auth:40033"
|
||||
- "--jfr-sidecar-addr"
|
||||
- "jfr-sidecar:8081"
|
||||
- "${JFR_SIDECAR_ADDR:-jfr-sidecar:8081}" # Can be switched for blue-green
|
||||
- "--http-port"
|
||||
- "8080"
|
||||
# No external port - accessed via nginx at admin.eagle0.net
|
||||
depends_on:
|
||||
- eagle-blue
|
||||
- auth
|
||||
- jfr-sidecar
|
||||
# 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
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
@@ -239,6 +222,7 @@ 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
|
||||
@@ -257,6 +241,29 @@ 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
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# 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"
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
load("@build_bazel_rules_apple//apple:apple.bzl", "apple_dynamic_framework_import")
|
||||
|
||||
# Import pre-built Sparkle framework
|
||||
apple_dynamic_framework_import(
|
||||
name = "Sparkle",
|
||||
framework_imports = glob(["Sparkle.framework/**"]),
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
+15
-11
@@ -3,6 +3,9 @@ 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",'
|
||||
@@ -24,12 +27,12 @@ http {
|
||||
# This prevents stale IP caching when containers restart
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
||||
# 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;
|
||||
# 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";
|
||||
}
|
||||
|
||||
# HTTP server for Let's Encrypt challenge and redirect
|
||||
@@ -73,8 +76,8 @@ http {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# gRPC proxy
|
||||
grpc_pass grpc://eagle_grpc;
|
||||
# gRPC proxy - uses variable for blue-green deployment
|
||||
grpc_pass grpc://$eagle_backend;
|
||||
|
||||
# Timeouts for long-running streams
|
||||
grpc_read_timeout 1200s;
|
||||
@@ -85,13 +88,14 @@ http {
|
||||
error_page 502 = /error502grpc;
|
||||
}
|
||||
|
||||
# gRPC proxy for Auth service
|
||||
# gRPC proxy for Auth service (routes to Go auth service, not Eagle)
|
||||
location /net.eagle0.eagle.api.auth.Auth {
|
||||
# Rate limiting
|
||||
limit_req zone=grpc_limit burst=50 nodelay;
|
||||
|
||||
# gRPC proxy
|
||||
grpc_pass grpc://eagle_grpc;
|
||||
# Route to auth service directly (not through Eagle)
|
||||
set $auth_backend "auth:40033";
|
||||
grpc_pass grpc://$auth_backend;
|
||||
|
||||
# Timeouts
|
||||
grpc_read_timeout 30s;
|
||||
|
||||
@@ -8,3 +8,6 @@ ZIP_LOCATION=$(bazel cquery --config=mactools --output=files @net_eagle0_unity_g
|
||||
/usr/bin/unzip -o $ZIP_LOCATION -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
|
||||
|
||||
/usr/bin/plutil -convert xml1 src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/DarwinGodiceBundle.bundle/Contents/Info.plist
|
||||
|
||||
/bin/echo "building sparkle plugin"
|
||||
./scripts/build_sparkle_plugin.sh
|
||||
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build the SparklePlugin native library for Unity using Bazel
|
||||
#
|
||||
# Usage: build_sparkle_plugin.sh [output_dir]
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
|
||||
OUTPUT_DIR="${1:-$PROJECT_ROOT/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/macOS}"
|
||||
|
||||
echo "=== Building SparklePlugin with Bazel ==="
|
||||
|
||||
bazel build --config=mactools //src/main/objc/net/eagle0/sparkle:SparklePlugin
|
||||
|
||||
# Get the zip path from bazel
|
||||
ZIP_PATH=$(bazel cquery --config=mactools --output=files //src/main/objc/net/eagle0/sparkle:SparklePlugin 2>/dev/null)
|
||||
|
||||
echo "=== Extracting SparklePlugin.bundle ==="
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
rm -rf "$OUTPUT_DIR/SparklePlugin.bundle"
|
||||
unzip -o "$ZIP_PATH" -d "$OUTPUT_DIR/"
|
||||
|
||||
echo "=== SparklePlugin built successfully ==="
|
||||
ls -la "$OUTPUT_DIR/SparklePlugin.bundle/"
|
||||
+194
-58
@@ -2,14 +2,21 @@
|
||||
#
|
||||
# Blue-Green Deployment Script for Eagle Server
|
||||
#
|
||||
# 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
|
||||
# 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.
|
||||
#
|
||||
# Usage: ./deploy-blue-green.sh [NEW_IMAGE_TAG]
|
||||
#
|
||||
@@ -25,6 +32,9 @@ 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'
|
||||
@@ -36,15 +46,57 @@ log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
# Determine which instance is currently active
|
||||
get_active_instance() {
|
||||
if grep -q "eagle-blue:40032" "${NGINX_CONF}"; then
|
||||
# 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
|
||||
echo "blue"
|
||||
elif grep -q "eagle-green:40032" "${NGINX_CONF}"; then
|
||||
elif [ "$green_running" = "true" ]; then
|
||||
echo "green"
|
||||
else
|
||||
log_error "Cannot determine active instance from nginx config"
|
||||
exit 1
|
||||
# Neither running - default to blue (first deploy or recovery)
|
||||
echo "none"
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -54,6 +106,12 @@ pull_with_retry() {
|
||||
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
|
||||
@@ -114,46 +172,63 @@ 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
|
||||
local active=$(get_active_instance)
|
||||
# Determine current active instance (need this before creating marker)
|
||||
local active=$(get_running_instance)
|
||||
local staging
|
||||
if [ "$active" = "blue" ]; then
|
||||
if [ "$active" = "blue" ] || [ "$active" = "none" ]; then
|
||||
staging="green"
|
||||
active="blue" # Normalize "none" to "blue" for first deploy
|
||||
else
|
||||
staging="blue"
|
||||
fi
|
||||
|
||||
log_info "Active instance: eagle-${active}"
|
||||
log_info "Staging instance: eagle-${staging}"
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
# Start staging instance with new image
|
||||
log_info "Starting eagle-${staging} with new image..."
|
||||
# Step 2: Start staging instance with new image
|
||||
log_info "Step 2: 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
|
||||
EAGLE_IMAGE_NEW="${new_image}" docker compose -f "${COMPOSE_FILE}" --profile blue-green up -d eagle-green jfr-sidecar-green
|
||||
else
|
||||
EAGLE_IMAGE="${new_image}" docker compose -f "${COMPOSE_FILE}" up -d eagle-blue
|
||||
EAGLE_IMAGE="${new_image}" docker compose -f "${COMPOSE_FILE}" up -d eagle-blue jfr-sidecar
|
||||
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}"
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
|
||||
cleanup_markers_on_failure "eagle-${active}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run warmup/smoke test
|
||||
# Step 3: Run warmup/smoke test
|
||||
local staging_port
|
||||
if [ "$staging" = "green" ]; then
|
||||
staging_port=40034
|
||||
@@ -161,12 +236,12 @@ main() {
|
||||
staging_port=40032
|
||||
fi
|
||||
|
||||
log_info "Running warmup against eagle-${staging}..."
|
||||
log_info "Step 3: 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}"
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
|
||||
cleanup_markers_on_failure "eagle-${active}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
@@ -174,49 +249,98 @@ main() {
|
||||
log_warn "JIT will be cold on first requests"
|
||||
fi
|
||||
|
||||
# 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}"
|
||||
# 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}..."
|
||||
|
||||
# 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}..."
|
||||
# Update nginx config (variable-based routing)
|
||||
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
|
||||
|
||||
# Reload nginx
|
||||
log_info "Reloading nginx..."
|
||||
docker compose -f "${COMPOSE_FILE}" exec nginx nginx -s reload
|
||||
# Recreate nginx to pick up new config
|
||||
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate nginx
|
||||
|
||||
# Clean up old instance
|
||||
log_info "Removing old eagle-${active} container..."
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}"
|
||||
|
||||
# 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
|
||||
# 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_info "Green is now active. Consider switching to blue on next deployment."
|
||||
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
|
||||
|
||||
log_info "Deployment complete!"
|
||||
log_info "Active instance: eagle-${staging}"
|
||||
# Restart admin to pick up new .env
|
||||
log_info "Restarting admin service..."
|
||||
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate admin
|
||||
|
||||
# Clean up old instance
|
||||
log_info "Cleaning up old eagle-${active}..."
|
||||
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}" 2>/dev/null || true
|
||||
|
||||
# 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
|
||||
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
|
||||
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 ""
|
||||
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."
|
||||
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 "========================================="
|
||||
}
|
||||
|
||||
# Check for required tools
|
||||
@@ -240,6 +364,18 @@ 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
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// +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))
|
||||
}
|
||||
@@ -31,17 +31,51 @@ echo "=== Creating ZIP for notarization: $ZIP_PATH ==="
|
||||
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
|
||||
|
||||
echo "=== Submitting to Apple for notarization ==="
|
||||
xcrun notarytool submit "$ZIP_PATH" \
|
||||
SUBMIT_OUTPUT=$(xcrun notarytool submit "$ZIP_PATH" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$TEAM_ID" \
|
||||
--wait
|
||||
--wait 2>&1) || true
|
||||
|
||||
# Clean up the zip
|
||||
rm "$ZIP_PATH"
|
||||
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 ==="
|
||||
xcrun stapler staple "$APP_PATH"
|
||||
# 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"
|
||||
|
||||
Executable
+56
@@ -0,0 +1,56 @@
|
||||
#!/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"
|
||||
Executable
+76
@@ -0,0 +1,76 @@
|
||||
#!/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"
|
||||
@@ -65,7 +65,8 @@ fi
|
||||
# If we found the Go tool, use it
|
||||
if [ -n "${WARMUP_TOOL}" ]; then
|
||||
log_info "Using Go warmup tool: ${WARMUP_TOOL}"
|
||||
if "${WARMUP_TOOL}" --address="${HOST}" --timeout=60s; then
|
||||
# Use 5 minute timeout to allow for slow operations on cold JVM
|
||||
if "${WARMUP_TOOL}" --address="${HOST}" --timeout=300s; then
|
||||
log_info "Warmup complete!"
|
||||
exit 0
|
||||
else
|
||||
|
||||
@@ -20,8 +20,9 @@ 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;
|
||||
mapCopy->mutable_terrain()
|
||||
->GetMutableObject(index)
|
||||
// const_cast is safe because we own the mutable buffer (mapCopy)
|
||||
const_cast<net::eagle0::shardok::storage::fb::Terrain *>(
|
||||
mapCopy->mutable_terrain()->GetMutableObject(index))
|
||||
->mutable_modifier()
|
||||
.mutable_fire()
|
||||
.mutate_present(false);
|
||||
@@ -164,16 +165,11 @@ auto WaterCrossingTiles(
|
||||
if (modifier.ice().present() && !modifier.fire().present()) continue;
|
||||
|
||||
// Now try adding a bridge to the tile to see if it helps
|
||||
mapCopy->mutable_terrain()
|
||||
->GetMutableObject(index)
|
||||
->mutable_modifier()
|
||||
.mutable_bridge()
|
||||
.mutate_present(true);
|
||||
mapCopy->mutable_terrain()
|
||||
->GetMutableObject(index)
|
||||
->mutable_modifier()
|
||||
.mutable_fire()
|
||||
.mutate_present(false);
|
||||
// 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);
|
||||
|
||||
auto hash = ActionPointDistancesCache::GetMapId(mapCopy);
|
||||
|
||||
@@ -183,11 +179,7 @@ auto WaterCrossingTiles(
|
||||
}
|
||||
|
||||
// Undo the new bridge for the next iteration of the loop
|
||||
mapCopy->mutable_terrain()
|
||||
->GetMutableObject(index)
|
||||
->mutable_modifier()
|
||||
.mutable_bridge()
|
||||
.mutate_present(false);
|
||||
terr->mutable_modifier().mutable_bridge().mutate_present(false);
|
||||
}
|
||||
|
||||
return returnCoords;
|
||||
|
||||
+4
-2
@@ -76,11 +76,13 @@ static auto CreateIceClearedMap(const HexMap* map) -> fb::HexMapW {
|
||||
|
||||
// Now modify the ice on the mutable copy
|
||||
auto* mutableMap = mapCopy.Get();
|
||||
const auto* terrainVec = mutableMap->mutable_terrain();
|
||||
auto* terrainVec = mutableMap->mutable_terrain();
|
||||
|
||||
for (size_t i = 0; i < terrainVec->size(); i++) {
|
||||
// Only process tiles with ice
|
||||
if (auto* terrain = terrainVec->GetMutableObject(i); terrain->modifier().ice().present()) {
|
||||
// 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()) {
|
||||
terrain->mutable_modifier().mutable_ice().mutate_present(false);
|
||||
terrain->mutable_modifier().mutable_ice().mutate_integrity(0.0f);
|
||||
}
|
||||
|
||||
+13
-6
@@ -22,6 +22,13 @@ 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;
|
||||
@@ -37,7 +44,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 = inoutState->mutable_units()->GetMutableObject(controlledUnitId);
|
||||
auto *controlledUnit = GetMutableUnit(inoutState, controlledUnitId);
|
||||
internalAssert(controlledUnit->unit_id() == controlledUnitId);
|
||||
internalAssert(controlledUnit->commanding_unit_id() == unitId);
|
||||
controlledUnit->mutate_commanding_unit_id(-1);
|
||||
@@ -47,7 +54,7 @@ void ApplyResolvedUnit(
|
||||
if (unit.commanding_unit_id() != -1) {
|
||||
const UnitId commandingUnitId = unit.commanding_unit_id();
|
||||
|
||||
auto *commandingUnit = inoutState->mutable_units()->GetMutableObject(commandingUnitId);
|
||||
auto *commandingUnit = GetMutableUnit(inoutState, commandingUnitId);
|
||||
internalAssert(commandingUnit->unit_id() == commandingUnitId);
|
||||
internalAssert(
|
||||
commandingUnit->attached_hero().control_info().controlled_unit_id() == unitId);
|
||||
@@ -58,7 +65,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 = inoutState->mutable_units()->GetMutableObject(i);
|
||||
auto *playerUnit = GetMutableUnit(inoutState, i);
|
||||
if (playerUnit->player_id() != unit.player_id()) continue;
|
||||
if (playerUnit->unit_id() == unit.unit_id()) continue;
|
||||
|
||||
@@ -70,7 +77,7 @@ void ApplyResolvedUnit(
|
||||
}
|
||||
}
|
||||
|
||||
inoutState->mutable_units()->GetMutableObject(unitId)->mutate_status(status);
|
||||
GetMutableUnit(inoutState, unitId)->mutate_status(status);
|
||||
}
|
||||
|
||||
void ApplyResolvedUnit(
|
||||
@@ -189,7 +196,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 = mutatingState->mutable_units()->GetMutableObject(unit->unit_id());
|
||||
auto *mutableUnit = GetMutableUnit(mutatingState.Get(), unit->unit_id());
|
||||
if (mutableUnit->status() ==
|
||||
net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT) {
|
||||
// Convert this reserved slot to a real unit
|
||||
@@ -340,7 +347,7 @@ void MutatingApplyResult(
|
||||
}
|
||||
|
||||
// Capture old position before applying changes
|
||||
auto *mutableUnit = mutatingGameState->units()->GetMutableObject(changedUnit->unit_id());
|
||||
auto *mutableUnit = GetMutableUnit(mutatingGameState.Get(), changedUnit->unit_id());
|
||||
const auto oldLocation = mutableUnit->location();
|
||||
|
||||
fb::ApplyUnit(mutableUnit, changedUnit, status);
|
||||
|
||||
@@ -191,6 +191,7 @@ 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,8 +64,16 @@ 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 * {
|
||||
return map->terrain()->GetMutableObject(coords.row() * map->column_count() + coords.column());
|
||||
// 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()));
|
||||
}
|
||||
|
||||
auto HasForestAccess(
|
||||
@@ -597,7 +605,9 @@ void MutatingSetTileModifier(
|
||||
const int row,
|
||||
const int column,
|
||||
const TileModifierProto &TileModifierProto) {
|
||||
auto *terr = hexMap->mutable_terrain()->GetMutableObject(row * hexMap->column_count() + column);
|
||||
// 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));
|
||||
|
||||
if (TileModifierProto.has_bridge()) {
|
||||
terr->mutable_modifier().mutable_bridge().mutate_present(true);
|
||||
|
||||
@@ -82,6 +82,9 @@ 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,6 +29,13 @@ 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;
|
||||
@@ -412,16 +419,13 @@ auto GameStateGuesser::GuessedState(
|
||||
if (unit->has_attached_hero()) {
|
||||
UnitId controlledUnitId = unit->attached_hero().control_info().controlled_unit_id();
|
||||
if (controlledUnitId != -1) {
|
||||
gsw->mutable_units()
|
||||
->GetMutableObject(controlledUnitId)
|
||||
->mutate_commanding_unit_id(unitId);
|
||||
GetMutableUnit(gsw.Get(), controlledUnitId)->mutate_commanding_unit_id(unitId);
|
||||
}
|
||||
}
|
||||
|
||||
UnitId commandingUnitId = unit->commanding_unit_id();
|
||||
if (unit->commanding_unit_id() != -1) {
|
||||
gsw->mutable_units()
|
||||
->GetMutableObject(commandingUnitId)
|
||||
GetMutableUnit(gsw.Get(), commandingUnitId)
|
||||
->mutable_attached_hero()
|
||||
.mutable_control_info()
|
||||
.mutate_controlled_unit_id(unitId);
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
<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" />
|
||||
@@ -166,6 +167,7 @@
|
||||
<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" />
|
||||
@@ -206,6 +208,7 @@
|
||||
<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" />
|
||||
@@ -385,6 +388,7 @@
|
||||
<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" />
|
||||
|
||||
+36
-1
@@ -109,6 +109,10 @@ 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;
|
||||
@@ -217,6 +221,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
// Initialize OAuth UI
|
||||
SetupOAuthUI();
|
||||
|
||||
// Initialize connection panel environment dropdown
|
||||
SetupConnectionEnvironmentDropdown();
|
||||
|
||||
// Initialize Lobby UI (logout button, etc.)
|
||||
SetupLobbyUI();
|
||||
|
||||
@@ -227,6 +234,22 @@ 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) {
|
||||
@@ -264,6 +287,15 @@ 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();
|
||||
}
|
||||
@@ -682,7 +714,10 @@ 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);
|
||||
runningGameItem.SetRunningGame(
|
||||
runningGame.GameId,
|
||||
runningGame.Leader,
|
||||
runningGame.LastPlayedTimestampMillis);
|
||||
runningGameItem.GoCallback = this.SelectEagleGame;
|
||||
runningGameItem.DropCallback = this.DropGame;
|
||||
}
|
||||
|
||||
+179
-18
@@ -1,5 +1,164 @@
|
||||
%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
|
||||
@@ -94,8 +253,8 @@ MonoBehaviour:
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 30
|
||||
m_fontSizeBase: 30
|
||||
m_fontSize: 24
|
||||
m_fontSizeBase: 24
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
@@ -153,9 +312,9 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 450
|
||||
m_MinHeight: 60
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 450
|
||||
m_PreferredHeight: 60
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
@@ -253,8 +412,8 @@ MonoBehaviour:
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 36
|
||||
m_fontSizeBase: 36
|
||||
m_fontSize: 24
|
||||
m_fontSizeBase: 24
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
@@ -314,7 +473,7 @@ MonoBehaviour:
|
||||
m_MinWidth: 200
|
||||
m_MinHeight: 35
|
||||
m_PreferredWidth: -1
|
||||
m_PreferredHeight: 35
|
||||
m_PreferredHeight: -1
|
||||
m_FlexibleWidth: 1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
@@ -521,10 +680,10 @@ MonoBehaviour:
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 0
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: 50
|
||||
m_PreferredHeight: -1
|
||||
m_MinWidth: 40
|
||||
m_MinHeight: 40
|
||||
m_PreferredWidth: 40
|
||||
m_PreferredHeight: 40
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
@@ -621,8 +780,8 @@ MonoBehaviour:
|
||||
m_faceColor:
|
||||
serializedVersion: 2
|
||||
rgba: 4294967295
|
||||
m_fontSize: 36
|
||||
m_fontSizeBase: 36
|
||||
m_fontSize: 24
|
||||
m_fontSizeBase: 24
|
||||
m_fontWeight: 400
|
||||
m_enableAutoSizing: 0
|
||||
m_fontSizeMin: 18
|
||||
@@ -885,9 +1044,9 @@ MonoBehaviour:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: 200
|
||||
m_MinHeight: -1
|
||||
m_MinHeight: 40
|
||||
m_PreferredWidth: 200
|
||||
m_PreferredHeight: -1
|
||||
m_PreferredHeight: 40
|
||||
m_FlexibleWidth: -1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
@@ -926,6 +1085,7 @@ RectTransform:
|
||||
m_Children:
|
||||
- {fileID: 2008335870574158510}
|
||||
- {fileID: 6674238761151365000}
|
||||
- {fileID: 5589171621630283209}
|
||||
- {fileID: 1348356180262758599}
|
||||
- {fileID: 7336928442537586311}
|
||||
- {fileID: 7341333336313446738}
|
||||
@@ -994,7 +1154,7 @@ MonoBehaviour:
|
||||
m_ChildAlignment: 0
|
||||
m_Spacing: 0
|
||||
m_ChildForceExpandWidth: 0
|
||||
m_ChildForceExpandHeight: 1
|
||||
m_ChildForceExpandHeight: 0
|
||||
m_ChildControlWidth: 1
|
||||
m_ChildControlHeight: 1
|
||||
m_ChildScaleWidth: 0
|
||||
@@ -1015,6 +1175,7 @@ MonoBehaviour:
|
||||
item: {fileID: 5643565463360785033}
|
||||
gameIdField: {fileID: 8058295588038032232}
|
||||
leaderField: {fileID: 3311450659768657245}
|
||||
lastPlayedField: {fileID: 5482253986454480250}
|
||||
goButton: {fileID: 145214844678457394}
|
||||
dropButton: {fileID: 4081637582368108930}
|
||||
--- !u!114 &2208043217657811503
|
||||
@@ -1033,9 +1194,9 @@ MonoBehaviour:
|
||||
m_MinWidth: -1
|
||||
m_MinHeight: -1
|
||||
m_PreferredWidth: -1
|
||||
m_PreferredHeight: -1
|
||||
m_PreferredHeight: 45
|
||||
m_FlexibleWidth: 1
|
||||
m_FlexibleHeight: 1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!1 &7814378479006347708
|
||||
GameObject:
|
||||
|
||||
+29
-1
@@ -9,6 +9,7 @@ public class RunningGameItem : MonoBehaviour {
|
||||
public GameObject item;
|
||||
public TextMeshProUGUI gameIdField;
|
||||
public TextMeshProUGUI leaderField;
|
||||
public TextMeshProUGUI lastPlayedField;
|
||||
public Button goButton;
|
||||
public Button dropButton;
|
||||
|
||||
@@ -24,7 +25,8 @@ public class RunningGameItem : MonoBehaviour {
|
||||
|
||||
public void DropClicked() { DropCallback?.Invoke(gameId); }
|
||||
|
||||
public void SetRunningGame(long gameId, AvailableLeader leader) {
|
||||
public void
|
||||
SetRunningGame(long gameId, AvailableLeader leader, long lastPlayedTimestampMillis) {
|
||||
this.gameId = gameId;
|
||||
|
||||
gameIdField.text = string.Format("{0:X}", gameId);
|
||||
@@ -34,5 +36,31 @@ 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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+86
-8
@@ -299,6 +299,36 @@ 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) {
|
||||
@@ -468,7 +498,13 @@ namespace eagle {
|
||||
await PostRequest(nextCommand);
|
||||
} else if (eagleToken > providedToken) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"{providedToken} seems to be stale, dropping");
|
||||
$"{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);
|
||||
} else {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"{providedToken} seems to be from the future, adding back to the queue");
|
||||
@@ -503,7 +539,10 @@ namespace eagle {
|
||||
await PostRequest(nextCommand);
|
||||
} else {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"Shardok token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
|
||||
$"Shardok token mismatch: pending={providedShardokToken} " +
|
||||
$"current={currentShardokToken}, dropping and refreshing state");
|
||||
// Server processed command, token advanced. Refresh state.
|
||||
_ = StreamOneGameAsync(subscriber);
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -528,7 +567,10 @@ namespace eagle {
|
||||
await PostRequest(nextCommand);
|
||||
} else {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"Shardok placement token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
|
||||
$"Shardok placement token mismatch: pending={providedShardokToken} " +
|
||||
$"current={currentShardokToken}, dropping and refreshing state");
|
||||
// Server processed command, token advanced. Refresh state.
|
||||
_ = StreamOneGameAsync(subscriber);
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -658,11 +700,12 @@ namespace eagle {
|
||||
return true;
|
||||
});
|
||||
|
||||
// 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 {
|
||||
// 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) {
|
||||
_remoteEagleClientLogger.LogLine(
|
||||
$"[POST] Command not sent (connection dead), keeping in pending queue for retry");
|
||||
}
|
||||
@@ -1073,6 +1116,41 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes Sparkle auto-updater at app startup.
|
||||
/// Uses RuntimeInitializeOnLoadMethod to ensure early initialization.
|
||||
/// </summary>
|
||||
public static class SparkleInitializer {
|
||||
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
|
||||
private static void Initialize() {
|
||||
Debug.Log("SparkleInitializer: Initializing Sparkle at startup");
|
||||
SparkleUpdater.Initialize();
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2b4c6d8e0f1a3b5c7d9e1f3a5b7c9d1e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: -100
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// C# wrapper for the native SparklePlugin on macOS.
|
||||
/// Provides auto-update functionality via the Sparkle framework.
|
||||
/// On non-macOS platforms, all methods are no-ops.
|
||||
/// </summary>
|
||||
public static class SparkleUpdater {
|
||||
#if UNITY_STANDALONE_OSX && !UNITY_EDITOR
|
||||
private const string PluginName = "SparklePlugin";
|
||||
|
||||
[DllImport(PluginName)]
|
||||
private static extern void SparklePlugin_Initialize();
|
||||
|
||||
[DllImport(PluginName)]
|
||||
private static extern void SparklePlugin_CheckForUpdates();
|
||||
|
||||
[DllImport(PluginName)]
|
||||
private static extern void SparklePlugin_CheckForUpdatesInBackground();
|
||||
|
||||
[DllImport(PluginName)]
|
||||
private static extern int SparklePlugin_IsCheckingForUpdates();
|
||||
|
||||
[DllImport(PluginName)]
|
||||
private static extern int SparklePlugin_GetAutomaticallyChecksForUpdates();
|
||||
|
||||
[DllImport(PluginName)]
|
||||
private static extern void SparklePlugin_SetAutomaticallyChecksForUpdates(int enabled);
|
||||
|
||||
private static bool _initialized = false;
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the Sparkle updater. Should be called once at app startup.
|
||||
/// This starts automatic background update checks based on Info.plist settings.
|
||||
/// </summary>
|
||||
public static void Initialize() {
|
||||
if (_initialized) {
|
||||
Debug.Log("SparkleUpdater: Already initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
Debug.Log("SparkleUpdater: Initializing Sparkle");
|
||||
try {
|
||||
SparklePlugin_Initialize();
|
||||
_initialized = true;
|
||||
Debug.Log("SparkleUpdater: Initialization complete");
|
||||
} catch (System.Exception e) {
|
||||
Debug.LogError($"SparkleUpdater: Failed to initialize: {e.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually check for updates. Shows the update UI to the user.
|
||||
/// </summary>
|
||||
public static void CheckForUpdates() {
|
||||
if (!_initialized) {
|
||||
Debug.LogWarning("SparkleUpdater: Not initialized");
|
||||
return;
|
||||
}
|
||||
SparklePlugin_CheckForUpdates();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check for updates silently in the background.
|
||||
/// Only shows UI if an update is found.
|
||||
/// </summary>
|
||||
public static void CheckForUpdatesInBackground() {
|
||||
if (!_initialized) {
|
||||
Debug.LogWarning("SparkleUpdater: Not initialized");
|
||||
return;
|
||||
}
|
||||
SparklePlugin_CheckForUpdatesInBackground();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if an update check is currently in progress.
|
||||
/// </summary>
|
||||
public static bool IsCheckingForUpdates {
|
||||
get {
|
||||
if (!_initialized) return false;
|
||||
return SparklePlugin_IsCheckingForUpdates() != 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets whether automatic update checks are enabled.
|
||||
/// </summary>
|
||||
public static bool AutomaticallyChecksForUpdates {
|
||||
get {
|
||||
if (!_initialized) return false;
|
||||
return SparklePlugin_GetAutomaticallyChecksForUpdates() != 0;
|
||||
}
|
||||
set {
|
||||
if (!_initialized) return;
|
||||
SparklePlugin_SetAutomaticallyChecksForUpdates(value? 1: 0);
|
||||
}
|
||||
}
|
||||
|
||||
#else
|
||||
// Stub implementations for non-macOS platforms and Editor
|
||||
|
||||
public static void Initialize() { Debug.Log("SparkleUpdater: Not available on this platform"); }
|
||||
|
||||
public static void CheckForUpdates() {
|
||||
Debug.Log("SparkleUpdater: Not available on this platform");
|
||||
}
|
||||
|
||||
public static void CheckForUpdatesInBackground() {
|
||||
Debug.Log("SparkleUpdater: Not available on this platform");
|
||||
}
|
||||
|
||||
public static bool IsCheckingForUpdates => false;
|
||||
|
||||
public static bool AutomaticallyChecksForUpdates {
|
||||
get => false;
|
||||
set {}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8a3b5c7d9e1f2a3b4c5d6e7f8a9b0c1d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
System.Runtime.CompilerServices.Unsafe/**
|
||||
!**/*.psd
|
||||
**/*.dll
|
||||
# Native plugins built at CI time
|
||||
DarwinGodiceBundle.bundle/
|
||||
macOS/SparklePlugin.bundle/
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3c5d7e9f1a2b4c6d8e0f2a4b6c8d0e2f
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4d6e8f0a2b3c5d7e9f1a3b5c7d9e1f3a
|
||||
PluginImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
iconMap: {}
|
||||
executionOrder: {}
|
||||
defineConstraints: []
|
||||
isPreloaded: 0
|
||||
isOverridable: 0
|
||||
isExplicitlyReferenced: 0
|
||||
validateReferences: 1
|
||||
platformData:
|
||||
- first:
|
||||
: Any
|
||||
second:
|
||||
enabled: 0
|
||||
settings:
|
||||
Exclude Editor: 1
|
||||
Exclude Linux64: 1
|
||||
Exclude OSXUniversal: 0
|
||||
Exclude Win: 1
|
||||
Exclude Win64: 1
|
||||
- first:
|
||||
Any:
|
||||
second:
|
||||
enabled: 0
|
||||
settings: {}
|
||||
- first:
|
||||
Editor: Editor
|
||||
second:
|
||||
enabled: 0
|
||||
settings:
|
||||
CPU: AnyCPU
|
||||
DefaultValueInitialized: true
|
||||
OS: AnyOS
|
||||
- first:
|
||||
Standalone: OSXUniversal
|
||||
second:
|
||||
enabled: 1
|
||||
settings:
|
||||
CPU: AnyCPU
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -55,6 +55,21 @@ 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;
|
||||
@@ -65,19 +80,21 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
// 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);
|
||||
}
|
||||
|
||||
bool isMounted = unitType == BattalionTypeId.LightCavalry ||
|
||||
unitType == BattalionTypeId.HeavyCavalry;
|
||||
|
||||
StartCoroutine(SpawnPrintTrail(sourcePos.Value, targetPos.Value, isMounted));
|
||||
StartCoroutine(SpawnPrintTrailAlongPath(pathPositions, isMounted));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -88,39 +105,52 @@ namespace Shardok {
|
||||
}
|
||||
|
||||
private IEnumerator SpawnPrintTrail(Vector2 source, Vector2 target, bool isMounted) {
|
||||
var prints = new List<GameObject>();
|
||||
Vector2 direction = (target - source).normalized;
|
||||
float totalDistance = Vector2.Distance(source, target);
|
||||
yield return SpawnPrintTrailAlongPath(new List<Vector2> { source, target }, isMounted);
|
||||
}
|
||||
|
||||
// Calculate rotation angle for prints to face direction of travel
|
||||
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg - 90f;
|
||||
private IEnumerator SpawnPrintTrailAlongPath(List<Vector2> pathPositions, bool isMounted) {
|
||||
var prints = new List<GameObject>();
|
||||
|
||||
Sprite printSprite = isMounted ? (hoofPrintSprite ?? bootPrintSprite)
|
||||
: (bootPrintSprite ?? hoofPrintSprite);
|
||||
Color printColor = isMounted ? hoofPrintColor : bootPrintColor;
|
||||
|
||||
// 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);
|
||||
int totalPrintIndex = 0;
|
||||
|
||||
// Alternate left/right for boot prints (flip horizontally)
|
||||
bool flipX = !isMounted && (i % 2 == 1);
|
||||
// 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;
|
||||
|
||||
// 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;
|
||||
// Calculate rotation angle for prints to face direction of travel
|
||||
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg - 90f;
|
||||
|
||||
GameObject print = CreatePrint(position, angle, printSprite, printColor, flipX);
|
||||
prints.Add(print);
|
||||
// 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);
|
||||
|
||||
// Start FIFO fade coroutine for this print
|
||||
var fadeCoroutine =
|
||||
StartCoroutine(FadePrintFIFO(print, printLingerTime + i * printInterval));
|
||||
_activeCoroutines.Add(fadeCoroutine);
|
||||
// Alternate left/right for boot prints (flip horizontally)
|
||||
bool flipX = !isMounted && (totalPrintIndex % 2 == 1);
|
||||
|
||||
yield return new WaitForSeconds(printInterval);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for all fades to complete
|
||||
|
||||
+27
-8
@@ -480,6 +480,9 @@ namespace Shardok {
|
||||
SetModifiers();
|
||||
UpdateReserves();
|
||||
|
||||
// Notify tutorial system of available commands (for spell/ability tutorials)
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnTacticalCommandsAvailable(Model);
|
||||
|
||||
HandleEnemyStartingPositionOverlays();
|
||||
|
||||
endTurnButton.interactable = false;
|
||||
@@ -1531,13 +1534,15 @@ 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) {
|
||||
bool isOwnCommand = false,
|
||||
List<int> movePath = null) {
|
||||
if (animationType == AnimationType.None) return;
|
||||
|
||||
// Handle sound based on whether this is own command with two-stage sounds
|
||||
@@ -1576,10 +1581,14 @@ namespace Shardok {
|
||||
break;
|
||||
case AnimationType.Move:
|
||||
if (moveAnimator != null && attackerUnit != null) {
|
||||
moveAnimator.AnimateMove(
|
||||
sourceGridIndex,
|
||||
targetGridIndex,
|
||||
attackerUnit.Battalion.Type);
|
||||
if (movePath != null && movePath.Count >= 2) {
|
||||
moveAnimator.AnimateMove(movePath, attackerUnit.Battalion.Type);
|
||||
} else {
|
||||
moveAnimator.AnimateMove(
|
||||
sourceGridIndex,
|
||||
targetGridIndex,
|
||||
attackerUnit.Battalion.Type);
|
||||
}
|
||||
}
|
||||
break;
|
||||
case AnimationType.LightningBolt:
|
||||
@@ -1897,13 +1906,22 @@ namespace Shardok {
|
||||
Coords finishCoords = GridIndexToMapCoords(finishGridIndex);
|
||||
|
||||
var commandTypes = commandTypeUIManager.CommandTypesForGroup(_displayedCommandGroup);
|
||||
CommandType? executedCommand =
|
||||
var executedCommand =
|
||||
Model.PerformTargetedCommand(startCoords, finishCoords, commandTypes);
|
||||
|
||||
if (executedCommand != null) {
|
||||
var attackerUnit = Model.UnitAtCoords(startCoords);
|
||||
var defenderUnit = Model.UnitAtCoords(finishCoords);
|
||||
var animationType = AnimationTypeForCommand(executedCommand.Value);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
if (animationType != AnimationType.None) {
|
||||
PlayAnimationAndSound(
|
||||
@@ -1912,7 +1930,8 @@ namespace Shardok {
|
||||
finishGridIndex,
|
||||
attackerUnit,
|
||||
defenderUnit,
|
||||
isOwnCommand: true);
|
||||
isOwnCommand: true,
|
||||
movePath: movePath);
|
||||
} else if (Model.InSetUp) {
|
||||
// For commands without specific animations, play generic click in setup
|
||||
audioClipSource.PlayOneShot(soundManager.GenericClickSound());
|
||||
|
||||
@@ -274,16 +274,14 @@ public class ShardokGameModel {
|
||||
return true;
|
||||
}
|
||||
|
||||
public CommandType? PerformTargetedCommand(
|
||||
Coords start,
|
||||
Coords target,
|
||||
List<CommandType> possibleTypes) {
|
||||
public CommandDescriptor
|
||||
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.Type;
|
||||
return action;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
+310
@@ -0,0 +1,310 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3a768fdf067694657a5d24b935064f88
|
||||
@@ -14,14 +14,25 @@ A modular tutorial system for the Eagle0 Unity client supporting:
|
||||
|
||||
### Completed
|
||||
- [x] **Phase 1: Foundation** - TutorialState, TutorialManager, TutorialStep/Sequence
|
||||
- [x] **Phase 2: UI (partial)** - IMGUI fallback modal with Stoke font
|
||||
- [x] **Phase 3: Triggers (partial)** - Basic trigger system with hooks in game controllers
|
||||
- [x] **Test setup** - TutorialTestSetup for province selection, battle entry, command triggers
|
||||
- [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
|
||||
|
||||
### Remaining
|
||||
- [ ] **Canvas UI prefabs** - Replace IMGUI fallback with styled Canvas-based modal
|
||||
- [ ] **Overlay system** - TutorialOverlayController for highlighting UI elements
|
||||
### 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.
|
||||
@@ -37,45 +48,65 @@ TutorialManager (Singleton, DontDestroyOnLoad)
|
||||
│ ├── OnboardingTriggers
|
||||
│ └── ContextualTriggers
|
||||
└── TutorialUIManager
|
||||
├── TutorialModalPanel (TODO)
|
||||
├── TutorialOverlayController (TODO)
|
||||
├── TutorialHintIndicator (TODO)
|
||||
├── 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 (Current)
|
||||
## File Structure
|
||||
|
||||
```
|
||||
Assets/Tutorial/
|
||||
├── TutorialManager.cs ✓ Singleton, coordinates everything
|
||||
├── TutorialState.cs ✓ PlayerPrefs persistence
|
||||
├── TutorialTestSetup.cs ✓ Test component for validation
|
||||
├── 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 + IMGUI fallback
|
||||
├── TutorialModalPanel.cs ✓ Stub (needs Canvas implementation)
|
||||
├── TutorialOverlayController.cs ✓ Stub
|
||||
└── TutorialHintIndicator.cs ✓ Stub
|
||||
├── 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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Points (Implemented)
|
||||
## 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()`: Initializes TutorialManager, starts onboarding
|
||||
- `SetUpGame()`: Calls `TutorialManager.Instance.Initialize(this, null)` → triggers "game_started" event
|
||||
- `SwapModel()`: Calls `OnModelUpdated()`
|
||||
- `ProvinceWasSelected()`: Calls `OnProvinceSelected()`
|
||||
- `PostCommittedCommand()`: Calls `OnCommandIssued()`
|
||||
|
||||
### ShardokGameController.cs
|
||||
- `SetUpGame()`: Initializes TutorialManager for battle
|
||||
- `SetUpGame()`: Calls `TutorialManager.Instance.Initialize(null, this)`
|
||||
- `ModelUpdated()`: Calls `OnBattleAction()`
|
||||
- `OnTurnEnded()`: Calls `OnTurnEnded()`
|
||||
- Unit selection: Calls `OnUnitSelected()`
|
||||
@@ -85,6 +116,17 @@ Assets/Tutorial/
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
@@ -128,12 +170,23 @@ Assets/Tutorial/
|
||||
| `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. Add `TutorialTestSetup` component to TutorialManager GameObject
|
||||
2. Assign `Stoke-Regular.ttf` to `FallbackFont` on TutorialUIManager
|
||||
3. Enable `Debug Logging` on TutorialManager for console output
|
||||
4. Play game → select province → see test tutorial modal
|
||||
5. Use Settings → Reset Tutorials to test again
|
||||
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
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 69183de61f7d440a29b4a4270395a180
|
||||
TextScriptImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+117
-8
@@ -1,5 +1,9 @@
|
||||
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;
|
||||
|
||||
@@ -92,10 +96,51 @@ namespace Eagle0.Tutorial {
|
||||
}
|
||||
}
|
||||
|
||||
// Check for diplomacy offers
|
||||
// Check for riots
|
||||
// Check for hero recruitment opportunities
|
||||
// etc. - These would check model state changes
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -113,8 +158,18 @@ namespace Eagle0.Tutorial {
|
||||
// Track first command for onboarding
|
||||
OnGameEvent("command_issued", command);
|
||||
|
||||
// Could also check command type for specific tutorials
|
||||
// e.g., first diplomacy command, first weather control, etc.
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Tactical Layer Events ==========
|
||||
@@ -132,8 +187,62 @@ namespace Eagle0.Tutorial {
|
||||
public void OnBattleAction(object actionResult) {
|
||||
OnGameEvent("battle_action", actionResult);
|
||||
|
||||
// Check for specific action types that need tutorials
|
||||
// e.g., first spell cast, first charge, etc.
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -69,6 +69,9 @@ namespace Eagle0.Tutorial {
|
||||
_state = TutorialState.Load();
|
||||
_triggerRegistry = new TutorialTriggerRegistry(this);
|
||||
|
||||
// Register all tutorial content
|
||||
TutorialContentDefinitions.RegisterAll(this, _triggerRegistry);
|
||||
|
||||
Log("TutorialManager initialized");
|
||||
}
|
||||
|
||||
@@ -83,6 +86,9 @@ 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>
|
||||
@@ -143,6 +149,13 @@ 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); }
|
||||
|
||||
+27
-9
@@ -30,9 +30,9 @@ namespace Eagle0.Tutorial {
|
||||
}
|
||||
|
||||
private void RegisterTestTutorials(TutorialManager manager) {
|
||||
// Create a simple "first province selected" tutorial
|
||||
var provinceSelectedTutorial = CreateProvinceSelectedTutorial();
|
||||
manager.TriggerRegistry.RegisterTutorial(provinceSelectedTutorial, "province_selected");
|
||||
// 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();
|
||||
@@ -41,20 +41,38 @@ namespace Eagle0.Tutorial {
|
||||
// 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 CreateProvinceSelectedTutorial() {
|
||||
private TutorialSequence CreateIntroTutorial() {
|
||||
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
|
||||
sequence.SequenceId = "test_province_selected";
|
||||
sequence.DisplayName = "Province Selection";
|
||||
sequence.SequenceId = "test_intro";
|
||||
sequence.DisplayName = "Welcome";
|
||||
|
||||
// Step 1: Welcome modal
|
||||
sequence.Steps.Add(new TutorialStep {
|
||||
StepId = "province_selected_intro",
|
||||
Title = "Province Selected!",
|
||||
StepId = "welcome",
|
||||
Title = "Welcome to Eagle0!",
|
||||
Description =
|
||||
"You've selected a province. Here you can see information about the province and issue commands to your forces.\n\n(This is a test tutorial to verify the tutorial system is working.)",
|
||||
"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
|
||||
});
|
||||
|
||||
|
||||
+475
@@ -0,0 +1,475 @@
|
||||
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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 98467fdbaf584fbb928a373340b542d7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+28
-4
@@ -112,7 +112,8 @@ namespace Eagle0.Tutorial {
|
||||
ProgressSlider.gameObject.SetActive(totalSteps > 1);
|
||||
}
|
||||
|
||||
// Show panel
|
||||
// Show panel - ensure all parent containers are active first
|
||||
ActivateParents();
|
||||
if (ModalBlocker != null) { ModalBlocker.SetActive(true); }
|
||||
if (PanelContainer != null) { PanelContainer.SetActive(true); }
|
||||
gameObject.SetActive(true);
|
||||
@@ -121,6 +122,17 @@ 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>
|
||||
@@ -135,19 +147,31 @@ namespace Eagle0.Tutorial {
|
||||
_onSkip = null;
|
||||
}
|
||||
|
||||
private void OnContinueClicked() {
|
||||
/// <summary>
|
||||
/// Called when Continue button is clicked.
|
||||
/// Public to allow external setup of button listeners.
|
||||
/// </summary>
|
||||
public void OnContinueClicked() {
|
||||
var callback = _onContinue;
|
||||
Hide();
|
||||
callback?.Invoke();
|
||||
}
|
||||
|
||||
private void OnSkipClicked() {
|
||||
/// <summary>
|
||||
/// Called when Skip button is clicked.
|
||||
/// Public to allow external setup of button listeners.
|
||||
/// </summary>
|
||||
public void OnSkipClicked() {
|
||||
var callback = _onSkip;
|
||||
Hide();
|
||||
callback?.Invoke();
|
||||
}
|
||||
|
||||
private void OnSkipAllClicked() {
|
||||
/// <summary>
|
||||
/// Called when Skip All button is clicked.
|
||||
/// Public to allow external setup of button listeners.
|
||||
/// </summary>
|
||||
public void OnSkipAllClicked() {
|
||||
Hide();
|
||||
TutorialManager.Instance?.SkipAllOnboarding();
|
||||
}
|
||||
|
||||
+391
@@ -0,0 +1,391 @@
|
||||
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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d58277ecfc2d84d30a7ca71b8ccb994e
|
||||
+31
-5
@@ -65,8 +65,8 @@ namespace Eagle0.Tutorial {
|
||||
|
||||
if (ContinueButton != null) { ContinueButton.onClick.AddListener(OnContinueClicked); }
|
||||
|
||||
// Start hidden
|
||||
gameObject.SetActive(false);
|
||||
// Note: Don't hide here - let the builder or manual setup handle initial visibility.
|
||||
// When built at runtime, TutorialOverlayBuilder sets inactive after wiring up.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -100,7 +100,8 @@ namespace Eagle0.Tutorial {
|
||||
CenterTooltip();
|
||||
}
|
||||
|
||||
// Show
|
||||
// Show - ensure all parent containers are active first
|
||||
ActivateParents();
|
||||
gameObject.SetActive(true);
|
||||
StartCoroutine(FadeIn());
|
||||
|
||||
@@ -123,6 +124,8 @@ 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());
|
||||
}
|
||||
@@ -150,7 +153,15 @@ namespace Eagle0.Tutorial {
|
||||
_pulseCoroutine = null;
|
||||
}
|
||||
|
||||
StartCoroutine(FadeOut());
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
private void PositionHighlight(Transform target) {
|
||||
@@ -261,7 +272,11 @@ namespace Eagle0.Tutorial {
|
||||
}
|
||||
}
|
||||
|
||||
private void OnContinueClicked() {
|
||||
/// <summary>
|
||||
/// Called when the continue button is clicked.
|
||||
/// Made public for external button wiring.
|
||||
/// </summary>
|
||||
public void OnContinueClicked() {
|
||||
var callback = _onComplete;
|
||||
HideOverlay();
|
||||
callback?.Invoke();
|
||||
@@ -274,5 +289,16 @@ 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+59
-1
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Eagle0.Tutorial {
|
||||
@@ -22,13 +23,21 @@ 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 no ModalPanel is assigned")]
|
||||
[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>();
|
||||
@@ -43,12 +52,57 @@ namespace Eagle0.Tutorial {
|
||||
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;
|
||||
|
||||
@@ -186,6 +240,10 @@ namespace Eagle0.Tutorial {
|
||||
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);
|
||||
|
||||
@@ -136,9 +136,6 @@
|
||||
<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,6 +14,11 @@
|
||||
<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,6 +3,7 @@ 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;
|
||||
|
||||
@@ -10,6 +11,9 @@ 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; }
|
||||
@@ -66,14 +70,105 @@ namespace EagleInstaller {
|
||||
using (StringReader sr = new StringReader(configText)) {
|
||||
string line;
|
||||
while ((line = sr.ReadLine()) != null) {
|
||||
var components = line.Split(" = ");
|
||||
dict.Add(components[0], components[1]);
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -246,7 +341,11 @@ namespace EagleInstaller {
|
||||
response.EnsureSuccessStatusCode();
|
||||
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
|
||||
using StreamReader reader = new StreamReader(responseStream);
|
||||
return await reader.ReadToEndAsync();
|
||||
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);
|
||||
}
|
||||
|
||||
static Dictionary<String, String> ShasFromText(string text) {
|
||||
@@ -277,6 +376,7 @@ 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));
|
||||
|
||||
@@ -288,41 +388,67 @@ 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;
|
||||
|
||||
var sha = System.Security.Cryptography.SHA256.Create();
|
||||
string computedSha = BitConverter.ToString(sha.ComputeHash(memStream))
|
||||
.Replace("-", string.Empty)
|
||||
.ToLower();
|
||||
// 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();
|
||||
|
||||
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) {
|
||||
public async Task<bool> DownloadAndLaunchNewInstaller(
|
||||
string installerUrl,
|
||||
string expectedSha) {
|
||||
try {
|
||||
Logger.UpdateStatus("Downloading new installer...");
|
||||
|
||||
@@ -372,6 +498,28 @@ 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,7 +110,8 @@ namespace EagleInstaller {
|
||||
if (installerUpdateInfo.InstallerNeedsUpdate) {
|
||||
Logger.UpdateStatus("Installer update required...");
|
||||
bool downloadSuccess = await updater.DownloadAndLaunchNewInstaller(
|
||||
installerUpdateInfo.NewInstallerUrl);
|
||||
installerUpdateInfo.NewInstallerUrl,
|
||||
installerUpdateInfo.NewInstallerVersion);
|
||||
if (downloadSuccess) {
|
||||
Logger.UpdateStatus("New installer launched. Exiting current version.");
|
||||
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
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
|
||||
|
||||
@@ -109,29 +109,192 @@ 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">
|
||||
<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}} • {{.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 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}} • {{.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>
|
||||
</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>
|
||||
|
||||
@@ -26,6 +26,20 @@
|
||||
<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) {
|
||||
@@ -54,7 +68,7 @@
|
||||
if (isRecording) {
|
||||
container.innerHTML = `
|
||||
<span class="jfr-status jfr-on">JFR: ON</span>
|
||||
<button hx-post="/jfr/stop" hx-swap="none" class="btn-small secondary">Stop</button>
|
||||
<button onclick="stopJfr()" class="btn-small secondary">Stop</button>
|
||||
<a href="/jfr/download" class="btn-small">Download</a>
|
||||
`;
|
||||
} else {
|
||||
@@ -65,6 +79,31 @@
|
||||
}
|
||||
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>
|
||||
|
||||
@@ -57,8 +57,8 @@ 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.sh" {
|
||||
h.handleInstallSh(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 {
|
||||
@@ -92,7 +92,7 @@ func (h *InvitationHTTPHandler) handleLandingPage(w http.ResponseWriter, r *http
|
||||
InstallBatURL: fmt.Sprintf("/invite/%s/install.bat", code),
|
||||
InstallerURL: h.installerURL,
|
||||
IsMac: isMac,
|
||||
InstallShURL: fmt.Sprintf("/invite/%s/install.sh", code),
|
||||
InstallShURL: fmt.Sprintf("/invite/%s/install.command", code),
|
||||
MacInstallerURL: h.macInstallerURL,
|
||||
}
|
||||
|
||||
@@ -181,19 +181,19 @@ timeout /t 5
|
||||
log.Printf("[InviteHTTP] Served install.bat for code %s...", code[:8])
|
||||
}
|
||||
|
||||
// handleInstallSh serves the Mac shell installer script
|
||||
func (h *InvitationHTTPHandler) handleInstallSh(w http.ResponseWriter, r *http.Request, code string) {
|
||||
// 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
|
||||
// Generate shell script (.command files are double-clickable on macOS)
|
||||
script := fmt.Sprintf(`#!/bin/bash
|
||||
#
|
||||
# Eagle0 Mac Installer
|
||||
# This script downloads Eagle0 and sets up your invitation code.
|
||||
# Double-click this file to install Eagle0.
|
||||
#
|
||||
|
||||
set -e
|
||||
@@ -254,13 +254,17 @@ 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.sh")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=eagle0-install.command")
|
||||
w.Write([]byte(script))
|
||||
|
||||
log.Printf("[InviteHTTP] Served install.sh for code %s...", code[:8])
|
||||
log.Printf("[InviteHTTP] Served install.command for code %s...", code[:8])
|
||||
}
|
||||
|
||||
var landingPageTemplate = template.Must(template.New("landing").Parse(`<!DOCTYPE html>
|
||||
@@ -419,10 +423,8 @@ var landingPageTemplate = template.Must(template.New("landing").Parse(`<!DOCTYPE
|
||||
<h3>How to install on Mac:</h3>
|
||||
<ol>
|
||||
<li>Click the "Download for Mac" button above</li>
|
||||
<li>Open Terminal and navigate to your Downloads folder:<br>
|
||||
<code>cd ~/Downloads</code></li>
|
||||
<li>Run the installer script:<br>
|
||||
<code>bash eagle0-install.sh</code></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>
|
||||
|
||||
@@ -167,11 +167,12 @@ func signWithSparkle(filePath string, privateKeyPath string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sign the file
|
||||
cmd := exec.Command(sparkleSignTool, filePath, "-s", privateKeyPath)
|
||||
output, err := cmd.Output()
|
||||
// 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: %w", err)
|
||||
return "", fmt.Errorf("failed to sign file: %s: %w", string(output), err)
|
||||
}
|
||||
|
||||
// sign_update outputs: sparkle:edSignature="<signature>" length="<length>"
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
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"
|
||||
@@ -64,7 +67,7 @@ func (mm *ManifestManager) parseManifest(content string) map[string]string {
|
||||
return sections
|
||||
}
|
||||
|
||||
func (mm *ManifestManager) buildManifest(sections map[string]string) string {
|
||||
func (mm *ManifestManager) buildManifest(sections map[string]string, privateKey ed25519.PrivateKey) string {
|
||||
var manifest strings.Builder
|
||||
|
||||
manifest.WriteString(fmt.Sprintf("# Generated: %s\n", time.Now().Format(time.RFC3339)))
|
||||
@@ -90,10 +93,21 @@ func (mm *ManifestManager) buildManifest(sections map[string]string) string {
|
||||
}
|
||||
}
|
||||
|
||||
return manifest.String()
|
||||
// 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
|
||||
}
|
||||
|
||||
func (mm *ManifestManager) UpdateSection(sectionName, sectionContent string) error {
|
||||
func (mm *ManifestManager) UpdateSection(sectionName, sectionContent string, privateKey ed25519.PrivateKey) error {
|
||||
log.Printf("Updating manifest section: %s", sectionName)
|
||||
|
||||
// Fetch existing manifest
|
||||
@@ -103,14 +117,14 @@ func (mm *ManifestManager) UpdateSection(sectionName, sectionContent string) err
|
||||
existing = ""
|
||||
}
|
||||
|
||||
// Parse sections
|
||||
// Parse sections (strips out old signature line if present)
|
||||
sections := mm.parseManifest(existing)
|
||||
|
||||
// Update the specified section
|
||||
sections[sectionName] = sectionContent
|
||||
|
||||
// Build new manifest
|
||||
newManifest := mm.buildManifest(sections)
|
||||
// Build new manifest (with signature if key provided)
|
||||
newManifest := mm.buildManifest(sections, privateKey)
|
||||
|
||||
// Upload updated manifest with public ACL
|
||||
err = mm.bucketBasics.UploadBytesPublic(bucketName, manifestPath, []byte(newManifest))
|
||||
@@ -118,13 +132,36 @@ func (mm *ManifestManager) UpdateSection(sectionName, sectionContent string) err
|
||||
return fmt.Errorf("failed to upload manifest: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("Successfully updated manifest section: %s", sectionName)
|
||||
if privateKey != nil {
|
||||
log.Printf("Successfully updated and signed manifest section: %s", sectionName)
|
||||
} else {
|
||||
log.Printf("Successfully updated manifest section: %s (unsigned)", 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 {
|
||||
log.Fatal("Usage: manifest_manager <section_name> <section_content_file>")
|
||||
if len(os.Args) < 3 || len(os.Args) > 4 {
|
||||
log.Fatal("Usage: manifest_manager <section_name> <section_content_file> [private_key_file]")
|
||||
}
|
||||
|
||||
sectionName := os.Args[1]
|
||||
@@ -136,6 +173,16 @@ 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 {
|
||||
@@ -143,7 +190,7 @@ func main() {
|
||||
}
|
||||
|
||||
// Update the section
|
||||
err = mm.UpdateSection(sectionName, string(content))
|
||||
err = mm.UpdateSection(sectionName, string(content), privateKey)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to update manifest: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
|
||||
|
||||
go_library(
|
||||
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 = "client_download",
|
||||
embed = [":client_download_lib"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
@@ -1,58 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -21,10 +21,6 @@ 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 {
|
||||
@@ -90,16 +86,7 @@ func NewBucketBasics() (BucketBasics, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
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.
|
||||
// FetchBytes gets an object from a bucket and returns it as bytes.
|
||||
func (basics BucketBasics) FetchBytes(bucketName string, objectKey string) ([]byte, error) {
|
||||
result, err := basics.S3Client.GetObject(basics.ctx, &s3.GetObjectInput{
|
||||
Bucket: aws.String(bucketName),
|
||||
@@ -196,21 +183,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -396,8 +396,10 @@ done:
|
||||
}
|
||||
|
||||
// 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, 30*time.Second)
|
||||
return waitForResponseWithTimeout(stream, matches, 180*time.Second)
|
||||
}
|
||||
|
||||
func waitForResponseWithTimeout(stream eagle.Eagle_StreamUpdatesClient, matches func(*eagle.UpdateStreamResponse) bool, timeout time.Duration) (*eagle.UpdateStreamResponse, error) {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
load("@build_bazel_rules_apple//apple:macos.bzl", "macos_bundle")
|
||||
|
||||
# Native Sparkle plugin for Unity
|
||||
# This plugin initializes Sparkle auto-updater at runtime
|
||||
objc_library(
|
||||
name = "sparkle_plugin_lib",
|
||||
srcs = ["SparklePlugin.m"],
|
||||
copts = [
|
||||
"-fmodules",
|
||||
"-fobjc-arc",
|
||||
"-fvisibility=default", # Export symbols for Unity P/Invoke
|
||||
],
|
||||
sdk_frameworks = ["Foundation"],
|
||||
deps = ["@sparkle//:Sparkle"],
|
||||
)
|
||||
|
||||
macos_bundle(
|
||||
name = "SparklePlugin",
|
||||
bundle_id = "net.eagle0.SparklePlugin",
|
||||
bundle_name = "SparklePlugin",
|
||||
infoplists = ["Info.plist"],
|
||||
linkopts = [
|
||||
# Export symbols for Unity P/Invoke
|
||||
"-exported_symbol",
|
||||
"_SparklePlugin_Initialize",
|
||||
"-exported_symbol",
|
||||
"_SparklePlugin_CheckForUpdates",
|
||||
"-exported_symbol",
|
||||
"_SparklePlugin_CheckForUpdatesInBackground",
|
||||
"-exported_symbol",
|
||||
"_SparklePlugin_IsCheckingForUpdates",
|
||||
"-exported_symbol",
|
||||
"_SparklePlugin_GetAutomaticallyChecksForUpdates",
|
||||
"-exported_symbol",
|
||||
"_SparklePlugin_SetAutomaticallyChecksForUpdates",
|
||||
],
|
||||
minimum_os_version = "10.13",
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [":sparkle_plugin_lib"],
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
<?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>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>SparklePlugin</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>net.eagle0.SparklePlugin</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>SparklePlugin</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>BNDL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,86 @@
|
||||
//
|
||||
// SparklePlugin.m
|
||||
// Native macOS plugin for Unity to initialize Sparkle auto-updates
|
||||
//
|
||||
// Copyright 2026 Dan Crosby
|
||||
//
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <Sparkle/Sparkle.h>
|
||||
|
||||
// The updater controller - retained for the lifetime of the app
|
||||
static SPUStandardUpdaterController *updaterController = nil;
|
||||
|
||||
// Export macro for Unity P/Invoke
|
||||
#define EXPORT __attribute__((visibility("default")))
|
||||
|
||||
// Called from Unity to initialize Sparkle
|
||||
// This should be called once at app startup
|
||||
EXPORT void SparklePlugin_Initialize(void) {
|
||||
if (updaterController != nil) {
|
||||
NSLog(@"SparklePlugin: Already initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"SparklePlugin: Initializing Sparkle auto-updater");
|
||||
|
||||
// Create the updater controller with automatic update checking enabled
|
||||
// The feed URL and public key are read from Info.plist (SUFeedURL, SUPublicEDKey)
|
||||
updaterController = [[SPUStandardUpdaterController alloc]
|
||||
initWithStartingUpdater:YES
|
||||
updaterDelegate:nil
|
||||
userDriverDelegate:nil];
|
||||
|
||||
if (updaterController != nil) {
|
||||
NSLog(@"SparklePlugin: Sparkle initialized successfully");
|
||||
} else {
|
||||
NSLog(@"SparklePlugin: Failed to initialize Sparkle");
|
||||
}
|
||||
}
|
||||
|
||||
// Manually trigger an update check (shows UI)
|
||||
EXPORT void SparklePlugin_CheckForUpdates(void) {
|
||||
if (updaterController == nil) {
|
||||
NSLog(@"SparklePlugin: Not initialized, call Initialize first");
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"SparklePlugin: Checking for updates");
|
||||
[updaterController checkForUpdates:nil];
|
||||
}
|
||||
|
||||
// Check for updates silently in background (no UI unless update found)
|
||||
EXPORT void SparklePlugin_CheckForUpdatesInBackground(void) {
|
||||
if (updaterController == nil) {
|
||||
NSLog(@"SparklePlugin: Not initialized, call Initialize first");
|
||||
return;
|
||||
}
|
||||
|
||||
NSLog(@"SparklePlugin: Checking for updates in background");
|
||||
[[updaterController updater] checkForUpdatesInBackground];
|
||||
}
|
||||
|
||||
// Returns 1 if an update check is in progress, 0 otherwise
|
||||
EXPORT int SparklePlugin_IsCheckingForUpdates(void) {
|
||||
if (updaterController == nil) {
|
||||
return 0;
|
||||
}
|
||||
return [[updaterController updater] sessionInProgress] ? 1 : 0;
|
||||
}
|
||||
|
||||
// Returns 1 if automatic update checks are enabled, 0 otherwise
|
||||
EXPORT int SparklePlugin_GetAutomaticallyChecksForUpdates(void) {
|
||||
if (updaterController == nil) {
|
||||
return 0;
|
||||
}
|
||||
return [[updaterController updater] automaticallyChecksForUpdates] ? 1 : 0;
|
||||
}
|
||||
|
||||
// Enable or disable automatic update checks
|
||||
EXPORT void SparklePlugin_SetAutomaticallyChecksForUpdates(int enabled) {
|
||||
if (updaterController == nil) {
|
||||
NSLog(@"SparklePlugin: Not initialized");
|
||||
return;
|
||||
}
|
||||
[[updaterController updater] setAutomaticallyChecksForUpdates:(enabled != 0)];
|
||||
}
|
||||
@@ -45,9 +45,6 @@ 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 {
|
||||
@@ -61,8 +58,11 @@ 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,6 +266,8 @@ 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 {
|
||||
@@ -608,12 +610,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ 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 {
|
||||
|
||||
@@ -26,6 +26,9 @@ message CommandDescriptor {
|
||||
|
||||
.net.eagle0.shardok.common.Coords target = 5;
|
||||
repeated .net.eagle0.shardok.common.CommandType follow_up_command_types = 6;
|
||||
|
||||
// Full path for multi-step commands like Move (all hexes traversed, in order)
|
||||
repeated .net.eagle0.shardok.common.Coords path = 12;
|
||||
bool will_unhide = 7;
|
||||
.net.eagle0.shardok.api.OddsView odds = 8;
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import scala.collection.Map
|
||||
|
||||
import net.eagle0.common.{FunctionalRandom, RandomState}
|
||||
import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
|
||||
import net.eagle0.eagle.api.available_command.AvailableCommand
|
||||
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
|
||||
import net.eagle0.eagle.api.available_command.AvailableCommand as ProtoAvailableCommand
|
||||
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands as ProtoOneProvinceAvailableCommands
|
||||
import net.eagle0.eagle.api.selected_command.SelectedCommand
|
||||
import net.eagle0.eagle.library.util.*
|
||||
import net.eagle0.eagle.library.util.command_choice_helpers.{
|
||||
@@ -14,6 +14,7 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
|
||||
CommandChooser
|
||||
}
|
||||
import net.eagle0.eagle.library.Engine
|
||||
import net.eagle0.eagle.model.proto_converters.command.available.OneProvinceAvailableCommandsConverter
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
|
||||
case class AIClientWithSelectedCommand(
|
||||
@@ -45,22 +46,26 @@ case class AIClient(
|
||||
engine: Engine,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[AIClientWithSelectedCommand] = {
|
||||
val maybeCommandsMap = engine
|
||||
.getAvailablePlayerCommands(factionId)
|
||||
val scalaCommandsMap = engine.getAvailablePlayerCommands(factionId)
|
||||
|
||||
if maybeCommandsMap.isEmpty || maybeCommandsMap.get.commandsByProvince.isEmpty
|
||||
then RandomState(AIClientWithSelectedCommand(this, None), functionalRandom)
|
||||
else
|
||||
if scalaCommandsMap.isEmpty then RandomState(AIClientWithSelectedCommand(this, None), functionalRandom)
|
||||
else {
|
||||
// Convert to proto for command choosers (temporary until command choosers are migrated to Scala types)
|
||||
val protoCommandsMap = scalaCommandsMap.map {
|
||||
case (pid, scalaCmds) =>
|
||||
pid -> OneProvinceAvailableCommandsConverter.toProto(scalaCmds, engine.currentState, factionId)
|
||||
}
|
||||
chooseFrom(
|
||||
maybeCommandsMap.get.commandsByProvince,
|
||||
protoCommandsMap,
|
||||
engine.currentState,
|
||||
functionalRandom
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private def chooseMidGameCommandFrom(
|
||||
gameState: GameState,
|
||||
oneProvinceAvailableCommands: OneProvinceAvailableCommands,
|
||||
oneProvinceAvailableCommands: ProtoOneProvinceAvailableCommands,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[AIClientWithSelectedCommand] =
|
||||
MidGameAIClient.chosenMidGameCommand(
|
||||
@@ -113,7 +118,7 @@ case class AIClient(
|
||||
}
|
||||
|
||||
private def chooseFrom(
|
||||
acs: Map[ProvinceId, OneProvinceAvailableCommands],
|
||||
acs: Map[ProvinceId, ProtoOneProvinceAvailableCommands],
|
||||
gs: GameState,
|
||||
functionalRandom: FunctionalRandom
|
||||
): RandomState[AIClientWithSelectedCommand] = {
|
||||
@@ -186,7 +191,7 @@ case class AIClient(
|
||||
(
|
||||
fid: FactionId,
|
||||
gameState: GameState,
|
||||
acs: Vector[AvailableCommand],
|
||||
acs: Vector[ProtoAvailableCommand],
|
||||
functionalRandom
|
||||
) =>
|
||||
RandomState(
|
||||
|
||||
@@ -24,6 +24,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:alms_command_selector",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:attack_decision_command_chooser",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/command/available:one_province_available_commands_converter",
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -3,12 +3,12 @@ package net.eagle0.eagle.library
|
||||
import net.eagle0.eagle.common.action_result_notification_details.Notification
|
||||
import net.eagle0.eagle.common.action_result_type.ActionResultType
|
||||
import net.eagle0.eagle.common.action_result_type.ActionResultType.*
|
||||
import net.eagle0.eagle.common.round_phase.RoundPhase
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
|
||||
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
|
||||
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
|
||||
import net.eagle0.eagle.library.util.view_filters.GameStateViewFilter
|
||||
import net.eagle0.eagle.library.util.GameStateViewDiffer
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.RoundPhase
|
||||
import net.eagle0.eagle.views.action_result_view.ActionResultView
|
||||
import net.eagle0.eagle.views.game_state_view.GameStateViewDiff
|
||||
import net.eagle0.eagle.FactionId
|
||||
@@ -49,7 +49,7 @@ object ActionResultFilter {
|
||||
result.actionResult.affectedPlayers.contains(fid) ||
|
||||
UNIVERSALLY_VISIBLE_TYPES.contains(result.actionResult.`type`) ||
|
||||
result.actionResult.province.exists(pid =>
|
||||
result.gameState
|
||||
result.scalaGameState
|
||||
.provinces(pid)
|
||||
.rulingFactionId
|
||||
.contains(fid)
|
||||
@@ -81,12 +81,12 @@ object ActionResultFilter {
|
||||
)
|
||||
|
||||
private val allowOpponentActionPhases: Vector[RoundPhase] = Vector(
|
||||
RoundPhase.HERO_DEPARTURES,
|
||||
RoundPhase.UNCONTESTED_CONQUEST,
|
||||
RoundPhase.BATTLE_RESOLUTION,
|
||||
RoundPhase.BATTLE_AFTERMATH, // MIGHT BE WRONG
|
||||
RoundPhase.ATTACK_DECISION, // MIGHT BE WRONG
|
||||
RoundPhase.DIPLOMACY_RESOLUTION // IS WRONG
|
||||
RoundPhase.HeroDepartures,
|
||||
RoundPhase.UncontestedConquest,
|
||||
RoundPhase.BattleResolution,
|
||||
RoundPhase.BattleAftermath, // MIGHT BE WRONG
|
||||
RoundPhase.AttackDecision, // MIGHT BE WRONG
|
||||
RoundPhase.DiplomacyResolution // IS WRONG
|
||||
)
|
||||
|
||||
private def filteredGameStateDiff(
|
||||
@@ -95,8 +95,7 @@ object ActionResultFilter {
|
||||
factionId: Option[FactionId]
|
||||
): Option[GameStateViewDiff] =
|
||||
GameStateViewDiffer.diff(
|
||||
before = GameStateViewFilter
|
||||
.filteredGameState(gs = before, factionId = factionId),
|
||||
before = GameStateViewFilter.filteredGameState(gs = before, factionId = factionId),
|
||||
after = GameStateViewFilter.filteredGameState(gs = after, factionId = factionId)
|
||||
)
|
||||
|
||||
@@ -106,10 +105,11 @@ object ActionResultFilter {
|
||||
factionId: Option[FactionId]
|
||||
): Option[ActionResultView] = {
|
||||
val actionResult = result.actionResult
|
||||
val factions = startingState.factions.values.toVector
|
||||
|
||||
val gsDiff = filteredGameStateDiff(
|
||||
before = startingState,
|
||||
after = result.gameState,
|
||||
after = result.scalaGameState,
|
||||
factionId = factionId
|
||||
)
|
||||
|
||||
@@ -121,10 +121,10 @@ object ActionResultFilter {
|
||||
factionId.exists { askingFid =>
|
||||
!result.actionResult.player.exists(targetFid =>
|
||||
targetFid == askingFid ||
|
||||
LegacyFactionUtils.hasAlliance(
|
||||
FactionUtils.hasAlliance(
|
||||
askingFid,
|
||||
targetFid,
|
||||
startingState
|
||||
factions
|
||||
)
|
||||
)
|
||||
} &&
|
||||
@@ -169,7 +169,7 @@ object ActionResultFilter {
|
||||
factionId = factionId
|
||||
)
|
||||
|
||||
(res.gameState, acc ++ maybeNewRes)
|
||||
(res.scalaGameState, acc ++ maybeNewRes)
|
||||
}
|
||||
._2
|
||||
|
||||
|
||||
@@ -11,17 +11,19 @@ scala_library(
|
||||
"//src/test/scala/net/eagle0/eagle/service/controller:__pkg__",
|
||||
],
|
||||
exports = [
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update",
|
||||
],
|
||||
deps = [
|
||||
":game_history",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_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/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
|
||||
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update",
|
||||
@@ -43,16 +45,12 @@ scala_library(
|
||||
":engine",
|
||||
":game_history",
|
||||
":round_phase_advancer",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:command_scala_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_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/scala/net/eagle0/common:functional_random",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier_impl",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier_impl",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/availability",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action:check_for_fulfilled_quests_action",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action:hero_backstory_update_action_generator",
|
||||
@@ -63,7 +61,7 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/hero_generator",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/validations:scala_runtime_validator",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/validations:runtime_validator",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
|
||||
@@ -74,8 +72,6 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters:action_result_proto_converter",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_converter",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_converter",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/command/available:one_province_available_commands_converter",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/command/selected:selected_command_converter",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/command:command_type",
|
||||
@@ -99,14 +95,15 @@ scala_library(
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_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/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util:game_state_view_differ",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:game_state_view_filter",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -181,7 +178,6 @@ scala_library(
|
||||
"//src/main/scala/net/eagle0/common:functional_random",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library:game_history",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier_impl",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/availability",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action:end_attack_decision_phase_action",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action:end_battle_aftermath_phase_action",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
package net.eagle0.eagle.library
|
||||
|
||||
import scala.collection.immutable.SortedMap
|
||||
|
||||
import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
|
||||
import net.eagle0.eagle.api.command.AvailableCommands
|
||||
import net.eagle0.eagle.api.selected_command.SelectedCommand
|
||||
import net.eagle0.eagle.internal.action_result.ActionResult
|
||||
import net.eagle0.eagle.model.state.command.available.OneProvinceAvailableCommands
|
||||
import net.eagle0.eagle.model.state.command.selected.SelectedCommand
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.shardok_interface.{BattleResolution, BattleUpdate}
|
||||
import net.eagle0.eagle.views.action_result_view.ActionResultView
|
||||
@@ -37,9 +39,14 @@ trait Engine {
|
||||
|
||||
def updated: EngineAndResults
|
||||
|
||||
/**
|
||||
* Returns available commands using native Scala types. Callers that need proto types for gRPC should convert using
|
||||
* OneProvinceAvailableCommandsConverter.
|
||||
*/
|
||||
def getAvailablePlayerCommands(
|
||||
fid: FactionId
|
||||
): Option[AvailableCommands]
|
||||
): SortedMap[ProvinceId, OneProvinceAvailableCommands]
|
||||
|
||||
def tokenForFaction(fid: FactionId): Long
|
||||
|
||||
def resolveBattle(battleResolution: BattleResolution): EngineAndResults
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
package net.eagle0.eagle.library
|
||||
|
||||
import scala.annotation.tailrec
|
||||
import scala.collection.immutable.SortedMap
|
||||
|
||||
import net.eagle0.common.SeededRandom
|
||||
import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
|
||||
import net.eagle0.eagle.api.command.AvailableCommands
|
||||
import net.eagle0.eagle.api.selected_command.SelectedCommand
|
||||
import net.eagle0.eagle.internal.action_result.ActionResult
|
||||
import net.eagle0.eagle.library.actions.applier.{ActionResultApplierImpl, ActionResultWithResultingState}
|
||||
import net.eagle0.eagle.library.actions.availability.AvailableCommandsFactory
|
||||
@@ -15,18 +14,15 @@ import net.eagle0.eagle.library.actions.impl.action.{
|
||||
ResolveBattleAction
|
||||
}
|
||||
import net.eagle0.eagle.library.actions.impl.command.{AvailableCommandTypeMap, CommandFactory}
|
||||
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
|
||||
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
|
||||
import net.eagle0.eagle.library.util.hero_generator.HeroGenerator
|
||||
import net.eagle0.eagle.library.util.validations.ScalaRuntimeValidator
|
||||
import net.eagle0.eagle.library.util.validations.RuntimeValidator
|
||||
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
|
||||
import net.eagle0.eagle.library.EngineImpl.{appliedResults, appliedResultsScala, withUpdateChecks}
|
||||
import net.eagle0.eagle.library.EngineImpl.{appliedResults, withUpdateChecks}
|
||||
import net.eagle0.eagle.model.action_result.ActionResultT
|
||||
import net.eagle0.eagle.model.proto_converters.command.available.OneProvinceAvailableCommandsConverter
|
||||
import net.eagle0.eagle.model.proto_converters.command.selected.SelectedCommandConverter
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand as ScalaAvailableCommand
|
||||
import net.eagle0.eagle.model.state.command.CommandType
|
||||
import net.eagle0.eagle.model.state.command.available.OneProvinceAvailableCommands
|
||||
import net.eagle0.eagle.model.state.command.selected.SelectedCommand
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.shardok_interface.{BattleResolution, BattleUpdate}
|
||||
import net.eagle0.eagle.views.action_result_view.ActionResultView
|
||||
@@ -50,7 +46,7 @@ object EngineImpl {
|
||||
engineAndResultsImpl.recursiveTransformScala(eng =>
|
||||
RoundPhaseAdvancer.checkForPhaseAdvancement(
|
||||
currentState = eng.currentState,
|
||||
actionResultApplier = ActionResultApplierImpl(Some(ScalaRuntimeValidator)),
|
||||
actionResultApplier = ActionResultApplierImpl(Some(RuntimeValidator)),
|
||||
history = eng.history,
|
||||
availableCommandsFactory = eng.availableCommandsFactory,
|
||||
heroGenerator = eng.heroGenerator,
|
||||
@@ -88,21 +84,6 @@ object EngineImpl {
|
||||
}
|
||||
|
||||
def appliedResults(
|
||||
engine: EngineImpl,
|
||||
results: Vector[ActionWithResultingState]
|
||||
): EngineAndResults = withUpdateChecks(
|
||||
EngineAndResultsImpl(
|
||||
engine = engine.copy(
|
||||
currentState = results.lastOption
|
||||
.map(awrs => GameStateConverter.fromProto(awrs.gameState))
|
||||
.getOrElse(engine.currentState),
|
||||
history = engine.history.withNewResults(results)
|
||||
),
|
||||
results = results.map(_.actionResult)
|
||||
)
|
||||
)
|
||||
|
||||
def appliedResultsScala(
|
||||
engine: EngineImpl,
|
||||
results: Vector[ActionResultWithResultingState]
|
||||
): EngineAndResults = withUpdateChecks(
|
||||
@@ -136,7 +117,7 @@ final case class EngineAndResultsImpl(
|
||||
|
||||
if goResults.isEmpty then EngineAndResultsImpl(eng, acc)
|
||||
else
|
||||
appliedResultsScala(eng, goResults) match {
|
||||
appliedResults(eng, goResults) match {
|
||||
case EngineAndResultsImpl(eng2, res) =>
|
||||
go(eng2, acc ++ res)
|
||||
}
|
||||
@@ -148,7 +129,7 @@ final case class EngineAndResultsImpl(
|
||||
def recursiveTransformT(
|
||||
f: EngineImpl => Vector[ActionResultT]
|
||||
): EngineAndResultsImpl = recursiveTransformScala { eng =>
|
||||
val actionResultApplier = ActionResultApplierImpl(Some(ScalaRuntimeValidator))
|
||||
val actionResultApplier = ActionResultApplierImpl(Some(RuntimeValidator))
|
||||
RandomStateSequencer(
|
||||
initialState = eng.currentState,
|
||||
actionResultApplier = actionResultApplier,
|
||||
@@ -196,7 +177,7 @@ case class EngineImpl(
|
||||
.filterForPlayer(
|
||||
results = history.since(knownUnfilteredCount),
|
||||
factionId = factionId,
|
||||
startingState = GameStateConverter.toProto(history.stateAfter(knownUnfilteredCount))
|
||||
startingState = history.stateAfter(knownUnfilteredCount)
|
||||
),
|
||||
unfilteredCountAfter = history.count
|
||||
)
|
||||
@@ -207,31 +188,15 @@ case class EngineImpl(
|
||||
|
||||
def getAvailablePlayerCommands(
|
||||
fid: FactionId
|
||||
): Option[AvailableCommands] = {
|
||||
val scalaCommandsByProvince =
|
||||
availableCommandsFactory.availablePlayerCommands(currentState, fid)
|
||||
|
||||
Option.when(scalaCommandsByProvince.nonEmpty) {
|
||||
val currentStateProto = GameStateConverter.toProto(currentState)
|
||||
val protoCommandsByProvince = scalaCommandsByProvince.map {
|
||||
case (pid, scalaCmds) =>
|
||||
pid -> OneProvinceAvailableCommandsConverter.toProto(scalaCmds, currentStateProto, fid)
|
||||
}
|
||||
AvailableCommands(
|
||||
token = tokenForFaction(fid),
|
||||
commandsByProvince = protoCommandsByProvince,
|
||||
suggestedProvinceId = AvailableCommandsFactory
|
||||
.suggestedProvinceId(fid, scalaCommandsByProvince, currentState)
|
||||
)
|
||||
}
|
||||
}
|
||||
): SortedMap[ProvinceId, OneProvinceAvailableCommands] =
|
||||
availableCommandsFactory.availablePlayerCommands(currentState, fid)
|
||||
|
||||
def tokenForFaction(fid: FactionId): Long =
|
||||
currentState.factionCommandCounts.getOrElse(fid, 0).toLong
|
||||
|
||||
def resolveBattle(battleResolution: BattleResolution): EngineAndResults = {
|
||||
val actionResultApplier = ActionResultApplierImpl(Some(ScalaRuntimeValidator))
|
||||
appliedResultsScala(
|
||||
val actionResultApplier = ActionResultApplierImpl(Some(RuntimeValidator))
|
||||
appliedResults(
|
||||
engine = this,
|
||||
results = ResolveBattleAction(
|
||||
startingGameState = currentState,
|
||||
@@ -305,13 +270,10 @@ case class EngineImpl(
|
||||
|
||||
val scalaCommandsForProvince = scalaCommandsByProvince(selectedProvinceId)
|
||||
|
||||
// Convert proto SelectedCommand to Scala
|
||||
val scalaSelectedCommand = SelectedCommandConverter.fromProto(selectedCommand)
|
||||
|
||||
// Match using CommandType
|
||||
val scalaAvailableCommandOpt = AvailableCommandTypeMap.matchingAvailableCommand(
|
||||
scalaCommandsForProvince.commands,
|
||||
scalaSelectedCommand
|
||||
selectedCommand
|
||||
)
|
||||
commandRequire(
|
||||
scalaAvailableCommandOpt.isDefined,
|
||||
@@ -319,10 +281,10 @@ case class EngineImpl(
|
||||
)
|
||||
val scalaAvailableCommand = scalaAvailableCommandOpt.get
|
||||
|
||||
val commandType = scalaSelectedCommand.commandType
|
||||
val commandType = selectedCommand.commandType
|
||||
val sequencer = RandomStateSequencer(
|
||||
initialState = this.currentState,
|
||||
actionResultApplier = ActionResultApplierImpl(Some(ScalaRuntimeValidator)),
|
||||
actionResultApplier = ActionResultApplierImpl(Some(RuntimeValidator)),
|
||||
functionalRandom = SeededRandom(this.currentState.randomSeed)
|
||||
).withTCommandAndLastCommand(
|
||||
commandGen = gs =>
|
||||
@@ -330,7 +292,7 @@ case class EngineImpl(
|
||||
actingFactionId = factionId,
|
||||
gameState = gs,
|
||||
availableCommand = scalaAvailableCommand,
|
||||
selectedCommand = scalaSelectedCommand
|
||||
selectedCommand = selectedCommand
|
||||
),
|
||||
commandType = commandType
|
||||
).withProtolessSequentialResultsAction(gs => HeroBackstoryUpdateActionGenerator(gs))
|
||||
@@ -347,7 +309,7 @@ case class EngineImpl(
|
||||
s"Result with type ${firstResult.map(_.actionResultType).getOrElse("unknown")} did not have a player set"
|
||||
)
|
||||
|
||||
appliedResultsScala(
|
||||
appliedResults(
|
||||
engine = this,
|
||||
results = sequencer.resultsWithStates.newValue
|
||||
)
|
||||
|
||||
+9
-9
@@ -1,6 +1,6 @@
|
||||
package net.eagle0.eagle.library.actions.applier
|
||||
|
||||
import net.eagle0.eagle.library.util.validations.ScalaValidator
|
||||
import net.eagle0.eagle.library.util.validations.Validator
|
||||
import net.eagle0.eagle.model.action_result.ActionResultT
|
||||
import net.eagle0.eagle.model.state.battalion.BattalionT
|
||||
import net.eagle0.eagle.model.state.date.Date
|
||||
@@ -18,30 +18,30 @@ import GameStateExtensions.*
|
||||
* This uses extension methods on GameState to apply each type of change.
|
||||
*/
|
||||
object ActionResultApplierImpl {
|
||||
def apply(validator: Option[ScalaValidator]): ActionResultApplierImpl = new ActionResultApplierImpl(validator)
|
||||
def apply(validator: Option[Validator]): ActionResultApplierImpl = new ActionResultApplierImpl(validator)
|
||||
|
||||
// Type class for single-argument validation
|
||||
trait Validatable[T]:
|
||||
def validate(v: ScalaValidator, value: T): T
|
||||
def validate(v: Validator, value: T): T
|
||||
|
||||
given Validatable[HeroT] with
|
||||
def validate(v: ScalaValidator, value: HeroT): HeroT = v.validate(value)
|
||||
def validate(v: Validator, value: HeroT): HeroT = v.validate(value)
|
||||
|
||||
given Validatable[GameState] with
|
||||
def validate(v: ScalaValidator, value: GameState): GameState = v.validate(value)
|
||||
def validate(v: Validator, value: GameState): GameState = v.validate(value)
|
||||
|
||||
given Validatable[ActionResultT] with
|
||||
def validate(v: ScalaValidator, value: ActionResultT): ActionResultT = v.validate(value)
|
||||
def validate(v: Validator, value: ActionResultT): ActionResultT = v.validate(value)
|
||||
|
||||
// Type class for validation with GameState context
|
||||
trait ValidatableWithGameState[T]:
|
||||
def validate(v: ScalaValidator, value: T, gs: GameState): T
|
||||
def validate(v: Validator, value: T, gs: GameState): T
|
||||
|
||||
given ValidatableWithGameState[BattalionT] with
|
||||
def validate(v: ScalaValidator, value: BattalionT, gs: GameState): BattalionT = v.validate(value, gs)
|
||||
def validate(v: Validator, value: BattalionT, gs: GameState): BattalionT = v.validate(value, gs)
|
||||
}
|
||||
|
||||
class ActionResultApplierImpl(validator: Option[ScalaValidator]) extends ActionResultApplier {
|
||||
class ActionResultApplierImpl(validator: Option[Validator]) extends ActionResultApplier {
|
||||
import ActionResultApplierImpl.{Validatable, ValidatableWithGameState, given}
|
||||
|
||||
// Generic single-argument validate
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
package net.eagle0.eagle.library.actions.applier
|
||||
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.model.action_result.ActionResultT
|
||||
|
||||
case class ActionResultTWithResultingState(
|
||||
actionResult: ActionResultT,
|
||||
resultingState: GameState
|
||||
)
|
||||
|
||||
trait ActionResultTApplier {
|
||||
def applyActionResults(
|
||||
startingState: GameState,
|
||||
results: Iterable[ActionResultT]
|
||||
): Vector[ActionResultTWithResultingState]
|
||||
|
||||
def applyActionResult(
|
||||
startingState: GameState,
|
||||
result: ActionResultT
|
||||
): ActionResultTWithResultingState
|
||||
}
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
package net.eagle0.eagle.library.actions.applier
|
||||
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.library.util.validations.ScalaValidator
|
||||
import net.eagle0.eagle.model.action_result.ActionResultT
|
||||
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
|
||||
|
||||
object ActionResultTApplierImpl {
|
||||
|
||||
/** Creates an ActionResultTApplierImpl with no validation (for tests) */
|
||||
def apply(): ActionResultTApplierImpl =
|
||||
new ActionResultTApplierImpl(new ActionResultApplierImpl(None))
|
||||
|
||||
def apply(validator: ScalaValidator): ActionResultTApplierImpl =
|
||||
new ActionResultTApplierImpl(new ActionResultApplierImpl(Some(validator)))
|
||||
|
||||
def apply(baseApplier: ActionResultApplier): ActionResultTApplierImpl =
|
||||
new ActionResultTApplierImpl(baseApplier)
|
||||
}
|
||||
|
||||
class ActionResultTApplierImpl(baseApplier: ActionResultApplier) extends ActionResultTApplier {
|
||||
override def applyActionResults(
|
||||
startingState: GameState,
|
||||
results: Iterable[ActionResultT]
|
||||
): Vector[ActionResultTWithResultingState] = baseApplier
|
||||
.applyActionResults(
|
||||
GameStateConverter.fromProto(startingState),
|
||||
results
|
||||
)
|
||||
.zip(results)
|
||||
.map {
|
||||
case (actionWithResultingState, actionResult) =>
|
||||
ActionResultTWithResultingState(
|
||||
actionResult = actionResult,
|
||||
resultingState = GameStateConverter.toProto(actionWithResultingState.resultingState)
|
||||
)
|
||||
}
|
||||
|
||||
override def applyActionResult(
|
||||
startingState: GameState,
|
||||
result: ActionResultT
|
||||
): ActionResultTWithResultingState =
|
||||
applyActionResults(startingState, Vector(result)).head
|
||||
}
|
||||
@@ -198,66 +198,10 @@ scala_library(
|
||||
deps = [
|
||||
":action_result_applier",
|
||||
":game_state_extensions",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/validations:scala_validator",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/validations:validator",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/date",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "action_result_trait_applier",
|
||||
srcs = ["ActionResultTApplier.scala"],
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_hero_trait",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province/concrete",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result/types",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
|
||||
],
|
||||
)
|
||||
|
||||
scala_library(
|
||||
name = "action_result_trait_applier_impl",
|
||||
srcs = ["ActionResultTApplierImpl.scala"],
|
||||
visibility = [
|
||||
"//src/main/scala/net/eagle0/eagle:__subpackages__",
|
||||
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
|
||||
],
|
||||
exports = [
|
||||
":action_result_applier",
|
||||
":action_result_trait_applier",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/validations:runtime_validator",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/validations:scala_validator",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
deps = [
|
||||
":action_result_applier",
|
||||
":action_result_applier_impl",
|
||||
":action_result_trait_applier",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
|
||||
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
|
||||
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
|
||||
"//src/main/scala/net/eagle0/eagle/library/util/validations:scala_validator",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_hero_trait",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
|
||||
"//src/main/scala/net/eagle0/eagle/model/action_result/types",
|
||||
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
|
||||
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
|
||||
],
|
||||
)
|
||||
|
||||
+4
-70
@@ -1,25 +1,16 @@
|
||||
package net.eagle0.eagle.library.actions.availability
|
||||
|
||||
import net.eagle0.eagle.api.available_command.ResolveAllianceOfferAvailableCommand
|
||||
import net.eagle0.eagle.common.diplomacy_offer.{AllianceOfferDetails, DiplomacyOffer as DiplomacyOfferProto}
|
||||
import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.DIPLOMACY_OFFER_STATUS_UNRESOLVED
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.model.proto_converters.diplomacy_offer.status.StatusConverter
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand.DiplomacyOfferInfo
|
||||
import net.eagle0.eagle.model.state.diplomacy_offer.status.Unresolved
|
||||
import net.eagle0.eagle.model.state.diplomacy_offer.AllianceOffer
|
||||
import net.eagle0.eagle.model.state.game_state.GameState as ScalaGameState
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
object AvailableResolveAllianceOfferCommandFactory {
|
||||
|
||||
// ==================== Scala overload ====================
|
||||
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand.DiplomacyOfferInfo
|
||||
|
||||
/** Scala overload - returns pure Scala type */
|
||||
def availableCommand(
|
||||
gameState: ScalaGameState,
|
||||
gameState: GameState,
|
||||
factionId: FactionId
|
||||
): Option[AvailableCommand] = {
|
||||
val unresolvedOffers = gameState
|
||||
@@ -48,61 +39,4 @@ object AvailableResolveAllianceOfferCommandFactory {
|
||||
)
|
||||
end if
|
||||
}
|
||||
|
||||
// ==================== Proto overload ====================
|
||||
|
||||
private def unresolvedAllianceOffers(
|
||||
gameState: GameState,
|
||||
factionId: FactionId
|
||||
): Vector[DiplomacyOfferProto] =
|
||||
gameState
|
||||
.factions(factionId)
|
||||
.incomingDiplomacyOffers
|
||||
.filter(_.status == DIPLOMACY_OFFER_STATUS_UNRESOLVED)
|
||||
.collect { diploOffer =>
|
||||
diploOffer.offerDetails match {
|
||||
case _: AllianceOfferDetails => diploOffer
|
||||
}
|
||||
}
|
||||
.toVector
|
||||
|
||||
def availableCommand(
|
||||
gameState: GameState,
|
||||
factionId: FactionId
|
||||
): Option[ResolveAllianceOfferAvailableCommand] =
|
||||
unresolvedAllianceOffers(gameState, factionId) match {
|
||||
case items if items.isEmpty => None
|
||||
case offers =>
|
||||
Some(
|
||||
ResolveAllianceOfferAvailableCommand(
|
||||
offers = offers.collect {
|
||||
case DiplomacyOfferProto(
|
||||
originatingFactionId,
|
||||
_ /* targetFactionId */,
|
||||
messengerHeroId,
|
||||
messengerOriginProvinceId,
|
||||
status,
|
||||
_ /* eligibleStatuses */,
|
||||
offerTextId: String,
|
||||
AllianceOfferDetails(_ /* unknownFieldSet */ ),
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
DiplomacyOfferProto(
|
||||
originatingFactionId = originatingFactionId,
|
||||
targetFactionId = factionId,
|
||||
offerDetails = AllianceOfferDetails(),
|
||||
messengerHeroId = messengerHeroId,
|
||||
messengerOriginProvinceId = messengerOriginProvinceId,
|
||||
status = status,
|
||||
eligibleStatuses = (EligibleDiplomacyStatuses.acceptRejectStatuses ++ EligibleDiplomacyStatuses
|
||||
.maybeImprisonStatus(
|
||||
actingFactionId = factionId,
|
||||
gameState = gameState
|
||||
)).map(StatusConverter.toProto),
|
||||
offerTextId = offerTextId
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-77
@@ -1,29 +1,16 @@
|
||||
package net.eagle0.eagle.library.actions.availability
|
||||
|
||||
import net.eagle0.eagle.api.available_command.ResolveBreakAllianceAvailableCommand
|
||||
import net.eagle0.eagle.common.diplomacy_offer.{BreakAllianceDetails, DiplomacyOffer as DiplomacyOfferProto}
|
||||
import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus
|
||||
import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.{
|
||||
DIPLOMACY_OFFER_STATUS_ACCEPTED,
|
||||
DIPLOMACY_OFFER_STATUS_UNRESOLVED
|
||||
}
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.model.proto_converters.diplomacy_offer.status.StatusConverter
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand.DiplomacyOfferInfo
|
||||
import net.eagle0.eagle.model.state.diplomacy_offer.status.{Accepted, Unresolved}
|
||||
import net.eagle0.eagle.model.state.diplomacy_offer.BreakAlliance
|
||||
import net.eagle0.eagle.model.state.game_state.GameState as ScalaGameState
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
object AvailableResolveBreakAllianceCommandFactory {
|
||||
|
||||
// ==================== Scala overload ====================
|
||||
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand.DiplomacyOfferInfo
|
||||
|
||||
/** Scala overload - returns pure Scala type */
|
||||
def availableCommand(
|
||||
gameState: ScalaGameState,
|
||||
gameState: GameState,
|
||||
factionId: FactionId
|
||||
): Option[AvailableCommand] = {
|
||||
val unresolvedOffers = gameState
|
||||
@@ -52,64 +39,4 @@ object AvailableResolveBreakAllianceCommandFactory {
|
||||
)
|
||||
end if
|
||||
}
|
||||
|
||||
// ==================== Proto overload ====================
|
||||
|
||||
private def unresolvedBreakAllianceOffers(
|
||||
gameState: GameState,
|
||||
factionId: FactionId
|
||||
): Vector[DiplomacyOfferProto] =
|
||||
gameState
|
||||
.factions(factionId)
|
||||
.incomingDiplomacyOffers
|
||||
.filter(_.status == DIPLOMACY_OFFER_STATUS_UNRESOLVED)
|
||||
.collect { diploOffer =>
|
||||
diploOffer.offerDetails match {
|
||||
case _: BreakAllianceDetails => diploOffer
|
||||
}
|
||||
}
|
||||
.toVector
|
||||
|
||||
def availableCommand(
|
||||
gameState: GameState,
|
||||
factionId: FactionId
|
||||
): Option[ResolveBreakAllianceAvailableCommand] =
|
||||
unresolvedBreakAllianceOffers(gameState, factionId) match {
|
||||
case items if items.isEmpty => None
|
||||
case offers =>
|
||||
Some(
|
||||
ResolveBreakAllianceAvailableCommand(
|
||||
offers = offers.collect {
|
||||
case DiplomacyOfferProto(
|
||||
originatingFactionId: FactionId,
|
||||
_ /* targetFactionId */,
|
||||
messengerHeroId: FactionId,
|
||||
messengerOriginProvinceId: FactionId,
|
||||
status: DiplomacyOfferStatus,
|
||||
_ /* eligibleStatuses */,
|
||||
offerTextId: String,
|
||||
details: BreakAllianceDetails,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
DiplomacyOfferProto(
|
||||
originatingFactionId = originatingFactionId,
|
||||
targetFactionId = factionId,
|
||||
offerDetails = details,
|
||||
messengerHeroId = messengerHeroId,
|
||||
messengerOriginProvinceId = messengerOriginProvinceId,
|
||||
status = status,
|
||||
eligibleStatuses = Vector(
|
||||
DIPLOMACY_OFFER_STATUS_ACCEPTED
|
||||
) ++ EligibleDiplomacyStatuses
|
||||
.maybeImprisonStatus(
|
||||
actingFactionId = factionId,
|
||||
gameState = gameState
|
||||
)
|
||||
.map(StatusConverter.toProto),
|
||||
offerTextId = offerTextId
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-95
@@ -1,27 +1,17 @@
|
||||
package net.eagle0.eagle.library.actions.availability
|
||||
|
||||
import net.eagle0.eagle.api.available_command.ResolveInvitationAvailableCommand
|
||||
import net.eagle0.eagle.common.diplomacy_offer.{DiplomacyOffer as DiplomacyOfferProto, InvitationDetails}
|
||||
import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.{
|
||||
DIPLOMACY_OFFER_STATUS_ACCEPTED,
|
||||
DIPLOMACY_OFFER_STATUS_UNRESOLVED
|
||||
}
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
|
||||
import net.eagle0.eagle.model.proto_converters.diplomacy_offer.status.StatusConverter
|
||||
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand.DiplomacyOfferInfo
|
||||
import net.eagle0.eagle.model.state.diplomacy_offer.{DiplomacyOffer, Invitation}
|
||||
import net.eagle0.eagle.model.state.diplomacy_offer.status.{Accepted, Unresolved}
|
||||
import net.eagle0.eagle.model.state.game_state.GameState as ScalaGameState
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.model.state.province.ProvinceT
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
object AvailableResolveInvitationCommandFactory {
|
||||
|
||||
// ==================== Scala overload ====================
|
||||
|
||||
private def alreadyAcceptedInvitationScala(
|
||||
private def alreadyAcceptedInvitation(
|
||||
incomingDiplomacyOffers: Iterable[DiplomacyOffer]
|
||||
): Boolean =
|
||||
incomingDiplomacyOffers.exists {
|
||||
@@ -29,16 +19,15 @@ object AvailableResolveInvitationCommandFactory {
|
||||
case _ => false
|
||||
}
|
||||
|
||||
private def invitationIsActionableScala(
|
||||
private def invitationIsActionable(
|
||||
invitation: Invitation,
|
||||
provinces: Iterable[ProvinceT]
|
||||
): Boolean =
|
||||
invitation.status == Unresolved &&
|
||||
FactionUtils.provinceCount(invitation.originatingFactionId, provinces) > 0
|
||||
|
||||
/** Scala overload - returns pure Scala type */
|
||||
def availableCommand(
|
||||
gameState: ScalaGameState,
|
||||
gameState: GameState,
|
||||
factionId: FactionId
|
||||
): Option[AvailableCommand] = {
|
||||
val allInvitations = gameState
|
||||
@@ -47,10 +36,10 @@ object AvailableResolveInvitationCommandFactory {
|
||||
.collect { case inv: Invitation => inv }
|
||||
|
||||
val actionableInvitations =
|
||||
allInvitations.filter(inv => invitationIsActionableScala(inv, gameState.provinces.values))
|
||||
allInvitations.filter(inv => invitationIsActionable(inv, gameState.provinces.values))
|
||||
|
||||
Option.when(
|
||||
actionableInvitations.nonEmpty && !alreadyAcceptedInvitationScala(allInvitations)
|
||||
actionableInvitations.nonEmpty && !alreadyAcceptedInvitation(allInvitations)
|
||||
) {
|
||||
AvailableCommand.ResolveInvitationAvailable(
|
||||
invitations = actionableInvitations.map { invitation =>
|
||||
@@ -68,81 +57,4 @@ object AvailableResolveInvitationCommandFactory {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Proto overload ====================
|
||||
|
||||
private def alreadyAcceptedInvitation(
|
||||
incomingDiplomacyOffers: Seq[DiplomacyOfferProto]
|
||||
): Boolean =
|
||||
incomingDiplomacyOffers.exists {
|
||||
case DiplomacyOfferProto(
|
||||
_ /* originatingFactionId */,
|
||||
_ /* targetFactionId */,
|
||||
_ /* messengerHeroId */,
|
||||
_ /* messengerOriginProvinceId */,
|
||||
DIPLOMACY_OFFER_STATUS_ACCEPTED,
|
||||
_ /* eligibleStatuses */,
|
||||
_ /* offerMessage */,
|
||||
InvitationDetails(_ /* unknownFieldSet */ ),
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
true
|
||||
case _ => false
|
||||
}
|
||||
|
||||
private def invitationIsActionable(
|
||||
diplomacyOffer: DiplomacyOfferProto,
|
||||
gameState: GameState
|
||||
): Boolean = diplomacyOffer match {
|
||||
case DiplomacyOfferProto(
|
||||
originatingFactionId,
|
||||
_ /* targetFactionId */,
|
||||
_ /* messengerHeroId */,
|
||||
_ /* messengerOriginProvinceId */,
|
||||
DIPLOMACY_OFFER_STATUS_UNRESOLVED,
|
||||
_ /* eligibleStatuses */,
|
||||
_ /* offerMessage */,
|
||||
InvitationDetails(_ /* unknownFieldSet */ ),
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
LegacyFactionUtils
|
||||
.provinceCount(originatingFactionId, gameState) > 0
|
||||
case _ => false
|
||||
}
|
||||
|
||||
def availableCommand(
|
||||
gameState: GameState,
|
||||
factionId: FactionId
|
||||
): Option[ResolveInvitationAvailableCommand] = {
|
||||
val allInvitations = gameState
|
||||
.factions(factionId)
|
||||
.incomingDiplomacyOffers
|
||||
.filter { diploOffer =>
|
||||
diploOffer.offerDetails match {
|
||||
case InvitationDetails(_ /* unknownFieldSet */ ) => true
|
||||
case _ => false
|
||||
}
|
||||
}
|
||||
|
||||
val actionableInvitations =
|
||||
allInvitations.filter(inv => invitationIsActionable(inv, gameState))
|
||||
|
||||
Option.when(
|
||||
actionableInvitations.nonEmpty && !alreadyAcceptedInvitation(
|
||||
allInvitations
|
||||
)
|
||||
) {
|
||||
ResolveInvitationAvailableCommand(
|
||||
invitations = actionableInvitations.map {
|
||||
_.withEligibleStatuses(
|
||||
(EligibleDiplomacyStatuses.acceptRejectStatuses ++ EligibleDiplomacyStatuses
|
||||
.maybeImprisonStatus(
|
||||
actingFactionId = factionId,
|
||||
gameState = gameState
|
||||
)).map(StatusConverter.toProto)
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-108
@@ -1,37 +1,18 @@
|
||||
package net.eagle0.eagle.library.actions.availability
|
||||
|
||||
import net.eagle0.eagle.api.available_command.ResolveRansomOfferAvailableCommand
|
||||
import net.eagle0.eagle.common.diplomacy_offer.{
|
||||
DiplomacyOffer as DiplomacyOfferProto,
|
||||
HostageOfferedInExchange as HostageOfferedInExchangeProto,
|
||||
InvitationDetails,
|
||||
PrisonerOfferedInExchange as PrisonerOfferedInExchangeProto,
|
||||
PrisonerToBeRansomed as PrisonerToBeRansomedProto,
|
||||
RansomOfferDetails as RansomOfferDetailsProto
|
||||
}
|
||||
import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.{
|
||||
DIPLOMACY_OFFER_STATUS_REJECTED,
|
||||
DIPLOMACY_OFFER_STATUS_UNRESOLVED
|
||||
}
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.library.util.ransom_validity.RansomValidity
|
||||
import net.eagle0.eagle.library.util.LegacyRansomValidity
|
||||
import net.eagle0.eagle.library.EagleInternalException
|
||||
import net.eagle0.eagle.model.proto_converters.diplomacy_offer.status.StatusConverter
|
||||
import net.eagle0.eagle.model.state.diplomacy_offer.{DiplomacyOffer, Invitation, RansomOffer}
|
||||
import net.eagle0.eagle.model.state.diplomacy_offer.{Invitation, RansomOffer}
|
||||
import net.eagle0.eagle.model.state.diplomacy_offer.status.{Rejected, Unresolved}
|
||||
import net.eagle0.eagle.model.state.game_state.GameState as ScalaGameState
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
object AvailableResolveRansomOfferCommandFactory {
|
||||
|
||||
// ==================== Scala overload ====================
|
||||
|
||||
// Don't show an offer if the same faction has an outstanding offer that trades away the prisoner to be ransomed,
|
||||
// or either the offering or targeted faction has an incoming invitation that must be resolved first
|
||||
private def hasOutstandingConflictScala(
|
||||
private def hasOutstandingConflict(
|
||||
offer: RansomOffer,
|
||||
gameState: ScalaGameState
|
||||
gameState: GameState
|
||||
): Boolean = {
|
||||
val prisonerHeroId = offer.prisonerToBeRansomed.prisonerHeroId
|
||||
|
||||
@@ -69,9 +50,8 @@ object AvailableResolveRansomOfferCommandFactory {
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand.DiplomacyOfferInfo
|
||||
|
||||
/** Scala overload - returns pure Scala type */
|
||||
def availableCommand(
|
||||
gameState: ScalaGameState,
|
||||
gameState: GameState,
|
||||
factionId: FactionId
|
||||
): Option[AvailableCommand] = {
|
||||
val provinces = gameState.provinces.values.toVector
|
||||
@@ -80,7 +60,7 @@ object AvailableResolveRansomOfferCommandFactory {
|
||||
.incomingDiplomacyOffers
|
||||
.collect { case ro: RansomOffer => ro }
|
||||
.filter(offer => RansomValidity.isStillValid(offer, provinces))
|
||||
.filter(offer => !hasOutstandingConflictScala(offer, gameState))
|
||||
.filter(offer => !hasOutstandingConflict(offer, gameState))
|
||||
.filter(_.status == Unresolved) match {
|
||||
case items if items.isEmpty => None
|
||||
case offers =>
|
||||
@@ -98,86 +78,4 @@ object AvailableResolveRansomOfferCommandFactory {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Proto overload ====================
|
||||
|
||||
// Don't show an offer if the same faction has an outstanding offer that trades away the prisoner to be ransomed,
|
||||
// or either the offering or targeted faction has an incoming invitation that must be resolved first
|
||||
private def hasOutstandingConflict(
|
||||
offer: DiplomacyOfferProto,
|
||||
gs: GameState
|
||||
): Boolean = offer.offerDetails match {
|
||||
case RansomOfferDetailsProto(
|
||||
Some(prisonerToBeRansomed),
|
||||
_,
|
||||
_,
|
||||
_,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
gs.factions.values
|
||||
.flatMap(_.incomingDiplomacyOffers)
|
||||
.exists { diploOffer =>
|
||||
diploOffer.offerDetails match {
|
||||
case RansomOfferDetailsProto(
|
||||
_,
|
||||
prisonersOffered,
|
||||
_,
|
||||
_,
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
prisonersOffered
|
||||
.exists(
|
||||
_.heroId == prisonerToBeRansomed.prisonerHeroId
|
||||
) && diploOffer.status != DIPLOMACY_OFFER_STATUS_REJECTED
|
||||
case _ => false
|
||||
}
|
||||
} || gs
|
||||
.factions(offer.originatingFactionId)
|
||||
.incomingDiplomacyOffers
|
||||
.filter(_.status != DIPLOMACY_OFFER_STATUS_REJECTED)
|
||||
.exists { invitation =>
|
||||
invitation.offerDetails match {
|
||||
case InvitationDetails(_ /* unknownFieldSet */ ) => true
|
||||
case _ => false
|
||||
}
|
||||
} || gs
|
||||
.factions(offer.targetFactionId)
|
||||
.incomingDiplomacyOffers
|
||||
.filter(_.status != DIPLOMACY_OFFER_STATUS_REJECTED)
|
||||
.exists {
|
||||
_.offerDetails match {
|
||||
case InvitationDetails(_ /* unknownFieldSet */ ) => true
|
||||
case _ => false
|
||||
}
|
||||
}
|
||||
case _ => throw new EagleInternalException("Not a ransom offer")
|
||||
}
|
||||
|
||||
def availableCommand(
|
||||
gameState: GameState,
|
||||
factionId: FactionId
|
||||
): Option[ResolveRansomOfferAvailableCommand] =
|
||||
gameState
|
||||
.factions(factionId)
|
||||
.incomingDiplomacyOffers
|
||||
.collect { diplomacyOffer =>
|
||||
diplomacyOffer.offerDetails match {
|
||||
case _: RansomOfferDetailsProto => diplomacyOffer
|
||||
}
|
||||
}
|
||||
.filter(offer => LegacyRansomValidity.isStillValid(offer, gameState))
|
||||
.filter(offer => !hasOutstandingConflict(offer, gameState))
|
||||
.filter(_.status == DIPLOMACY_OFFER_STATUS_UNRESOLVED) match {
|
||||
case items if items.isEmpty => None
|
||||
case offers: Seq[DiplomacyOffer] =>
|
||||
Some(
|
||||
ResolveRansomOfferAvailableCommand(
|
||||
offers = offers.map {
|
||||
_.withEligibleStatuses(
|
||||
EligibleDiplomacyStatuses.acceptRejectStatuses.map(StatusConverter.toProto)
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-75
@@ -1,25 +1,16 @@
|
||||
package net.eagle0.eagle.library.actions.availability
|
||||
|
||||
import net.eagle0.eagle.api.available_command.ResolveTruceOfferAvailableCommand
|
||||
import net.eagle0.eagle.common.diplomacy_offer.{DiplomacyOffer as DiplomacyOfferProto, TruceOfferDetails}
|
||||
import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.DIPLOMACY_OFFER_STATUS_UNRESOLVED
|
||||
import net.eagle0.eagle.internal.game_state.GameState
|
||||
import net.eagle0.eagle.library.settings.TruceMonths
|
||||
import net.eagle0.eagle.model.proto_converters.date.DateConverter
|
||||
import net.eagle0.eagle.model.proto_converters.diplomacy_offer.status.StatusConverter
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand.DiplomacyOfferInfo
|
||||
import net.eagle0.eagle.model.state.diplomacy_offer.status.Unresolved
|
||||
import net.eagle0.eagle.model.state.diplomacy_offer.TruceOffer
|
||||
import net.eagle0.eagle.model.state.game_state.GameState as ScalaGameState
|
||||
import net.eagle0.eagle.model.state.game_state.GameState
|
||||
import net.eagle0.eagle.FactionId
|
||||
|
||||
object AvailableResolveTruceOfferCommandFactory {
|
||||
import net.eagle0.eagle.library.util.DateProtoUtils.*
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand
|
||||
import net.eagle0.eagle.model.state.command.available.AvailableCommand.DiplomacyOfferInfo
|
||||
|
||||
/** Scala overload - returns pure Scala type */
|
||||
def availableCommand(
|
||||
gameState: ScalaGameState,
|
||||
gameState: GameState,
|
||||
factionId: FactionId
|
||||
): Option[AvailableCommand] = {
|
||||
val unresolvedOffers = gameState
|
||||
@@ -48,66 +39,4 @@ object AvailableResolveTruceOfferCommandFactory {
|
||||
)
|
||||
end if
|
||||
}
|
||||
|
||||
// ==================== Proto overload ====================
|
||||
|
||||
private def unresolvedTruceOffers(
|
||||
gameState: GameState,
|
||||
factionId: FactionId
|
||||
): Vector[DiplomacyOfferProto] =
|
||||
gameState
|
||||
.factions(factionId)
|
||||
.incomingDiplomacyOffers
|
||||
.filter(_.status == DIPLOMACY_OFFER_STATUS_UNRESOLVED)
|
||||
.collect { diploOffer =>
|
||||
diploOffer.offerDetails match {
|
||||
case _: TruceOfferDetails => diploOffer
|
||||
}
|
||||
}
|
||||
.toVector
|
||||
|
||||
def availableCommand(
|
||||
gameState: GameState,
|
||||
factionId: FactionId
|
||||
): Option[ResolveTruceOfferAvailableCommand] =
|
||||
unresolvedTruceOffers(gameState, factionId) match {
|
||||
case items if items.isEmpty => None
|
||||
case offers =>
|
||||
Some(
|
||||
ResolveTruceOfferAvailableCommand(
|
||||
offers = offers.collect {
|
||||
case DiplomacyOfferProto(
|
||||
originatingFactionId,
|
||||
_ /* targetFactionId */,
|
||||
messengerHeroId,
|
||||
messengerOriginProvinceId,
|
||||
status,
|
||||
eligibleStatuses,
|
||||
offerTextId,
|
||||
TruceOfferDetails(_, _),
|
||||
_ /* unknownFieldSet */
|
||||
) =>
|
||||
DiplomacyOfferProto(
|
||||
originatingFactionId = originatingFactionId,
|
||||
targetFactionId = factionId,
|
||||
offerDetails = TruceOfferDetails(
|
||||
endDate = Some(
|
||||
gameState.currentDate.get.addMonths(TruceMonths.intValue)
|
||||
)
|
||||
),
|
||||
messengerHeroId = messengerHeroId,
|
||||
messengerOriginProvinceId = messengerOriginProvinceId,
|
||||
status = status,
|
||||
eligibleStatuses = (EligibleDiplomacyStatuses.acceptRejectStatuses ++ EligibleDiplomacyStatuses
|
||||
.maybeImprisonStatus(
|
||||
actingFactionId = factionId,
|
||||
gameState = gameState
|
||||
)
|
||||
.toVector).map(StatusConverter.toProto),
|
||||
offerTextId = offerTextId
|
||||
)
|
||||
}.toVector
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user