Compare commits

..
471 changed files with 20362 additions and 16753 deletions
+39 -42
View File
@@ -24,7 +24,7 @@ permissions:
jobs:
build-auth:
runs-on: [self-hosted, bazel]
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-auth.outputs.image_tag }}
steps:
@@ -104,12 +104,6 @@ jobs:
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GH_OAUTH_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }}
GH_OAUTH_CLIENT_SECRET: ${{ secrets.GH_OAUTH_CLIENT_SECRET }}
APPLE_SIGNIN_CLIENT_ID: ${{ secrets.APPLE_SIGNIN_CLIENT_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_SIGNIN_KEY_ID: ${{ secrets.APPLE_SIGNIN_KEY_ID }}
APPLE_SIGNIN_PRIVATE_KEY: ${{ secrets.APPLE_SIGNIN_PRIVATE_KEY }}
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
@@ -118,6 +112,16 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Copy update-env script to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
source: "deploy/update-env.sh,deploy/env.template"
target: /opt/eagle0/
strip_components: 1
- name: Deploy auth service to production
uses: appleboy/ssh-action@v1.0.3
with:
@@ -125,58 +129,51 @@ jobs:
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script_stop: true
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GH_OAUTH_CLIENT_ID,GH_OAUTH_CLIENT_SECRET,APPLE_SIGNIN_CLIENT_ID,APPLE_TEAM_ID,APPLE_SIGNIN_KEY_ID,APPLE_SIGNIN_PRIVATE_KEY,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
script: |
set -x
cd /opt/eagle0
# Export env vars for docker compose (appleboy/ssh-action sets them but doesn't export)
export AUTH_IMAGE="${AUTH_IMAGE}"
export DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
export DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
export GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
export GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
export GH_OAUTH_CLIENT_ID="${GH_OAUTH_CLIENT_ID}"
export GH_OAUTH_CLIENT_SECRET="${GH_OAUTH_CLIENT_SECRET}"
export APPLE_SIGNIN_CLIENT_ID="${APPLE_SIGNIN_CLIENT_ID}"
export APPLE_TEAM_ID="${APPLE_TEAM_ID}"
export APPLE_SIGNIN_KEY_ID="${APPLE_SIGNIN_KEY_ID}"
export APPLE_SIGNIN_PRIVATE_KEY="${APPLE_SIGNIN_PRIVATE_KEY}"
export JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
export FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
export FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
export FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
# Update env vars using shared script (preserves vars set by other workflows)
chmod +x update-env.sh
./update-env.sh \
"AUTH_IMAGE=${AUTH_IMAGE}" \
"DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}" \
"JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}" \
"FASTMAIL_API_TOKEN=${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=${FASTMAIL_FROM_NAME}"
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
echo "Deploying auth service: $AUTH_IMAGE"
# Pull the image directly (docker is already logged in)
echo "Pulling Auth image..."
docker pull "${AUTH_IMAGE}" || { echo "ERROR: Failed to pull auth image"; exit 1; }
# Use crane to pull image
echo "Installing 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
echo "Pulling Auth image with crane..."
./crane pull "${AUTH_IMAGE}" auth.tar || { echo "ERROR: Failed to pull auth image"; exit 1; }
echo "Loading Auth image into Docker..."
docker load -i auth.tar
rm auth.tar
rm ./crane
# Only recreate the auth container (not eagle, shardok, etc.)
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate auth
# Wait for health check
sleep 5
# Verify container is using the correct image
echo "=== Verifying auth container image ==="
RUNNING_IMAGE=$(docker inspect auth-server --format '{{.Config.Image}}')
echo "Expected image: ${AUTH_IMAGE}"
echo "Running image: ${RUNNING_IMAGE}"
if [ "$RUNNING_IMAGE" != "$AUTH_IMAGE" ]; then
echo "ERROR: Container is running wrong image!"
echo "Container details:"
docker inspect auth-server --format '{{.Id}} {{.Config.Image}}'
exit 1
fi
# Show container status
docker compose -f docker-compose.prod.yml ps auth
# Verify container is using correct image
echo "=== Verifying auth container image ==="
docker compose -f docker-compose.prod.yml images auth
# Cleanup old images
docker image prune -f
+1 -1
View File
@@ -26,7 +26,7 @@ permissions:
jobs:
test:
runs-on: [self-hosted, bazel]
runs-on: self-hosted
steps:
- name: Checkout repository
+21
View File
@@ -0,0 +1,21 @@
name: Build Protos
on:
pull_request:
paths:
- "src/main/protobuf/**"
permissions:
contents: read
jobs:
build:
runs-on: self-hosted
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Run tests
run: ./scripts/build_protos.sh
+35
View File
@@ -0,0 +1,35 @@
name: Client Presigner
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/client_presigner.yml"
- "src/main/go/net/eagle0/client_download/**"
- "src/main/go/net/eagle0/util/**"
pull_request:
paths:
- ".github/workflows/client_presigner.yml"
- "src/main/go/net/eagle0/client_download/**"
- "src/main/go/net/eagle0/util/**"
permissions:
contents: read
jobs:
client-presigner:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
lfs: false
clean: false
- name: Build Client Presigner
run: bazel build --platforms=@io_bazel_rules_go//go/toolchain:linux_amd64 //src/main/go/net/eagle0/client_download
- name: Archive presigner binary
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: client_download
path: bazel-bin/src/main/go/net/eagle0/client_download/client_download_/client_download
+105 -55
View File
@@ -4,7 +4,7 @@ on:
push:
branches: [ "main" ]
paths:
# Note: C++ changes trigger shardok_arm64_build.yml instead
- 'src/main/cpp/**'
- 'src/main/go/**'
- 'src/main/scala/**'
- 'src/main/protobuf/**'
@@ -22,22 +22,17 @@ on:
default: 'false'
type: boolean
# Only allow one deployment at a time to prevent race conditions
concurrency:
group: docker-build-deploy
cancel-in-progress: false # Don't cancel running deployments, queue new ones
permissions:
contents: read
jobs:
# Single consolidated build job - builds all images with one bazel invocation
# This uses 1 runner slot instead of 4, and Bazel parallelizes internally
# NOTE: Must run on a runner with the bazel label to ensure consistent Bazel cache
build-all:
runs-on: [self-hosted, bazel]
runs-on: self-hosted
outputs:
eagle_image_tag: ${{ steps.push-images.outputs.eagle_image_tag }}
shardok_image_tag: ${{ steps.push-images.outputs.shardok_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:
@@ -52,12 +47,13 @@ jobs:
set -ex
# 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 ==="
# This is much more efficient than 4 separate GitHub Actions jobs
echo "=== Building all Docker images ==="
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//ci:eagle_server_image \
//ci:shardok_server_image \
//ci:admin_server_image \
//ci:jfr_sidecar_image \
//src/main/go/net/eagle0/warmup:warmup_linux_amd64
@@ -68,18 +64,35 @@ jobs:
# Save all image paths before any other bazel command changes bazel-bin symlink
EAGLE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
SHARDOK_PATH=$(readlink -f bazel-bin/ci/shardok_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 "shardok_path=$SHARDOK_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 "Shardok: $SHARDOK_PATH"
echo "Admin: $ADMIN_PATH"
echo "JFR Sidecar: $JFR_PATH"
# Verify Shardok binary is ELF (Linux) format
echo "=== Verifying Shardok binary format ==="
BINARY_TAR="bazel-bin/ci/shardok_binary_layer.tar"
if [ -f "$BINARY_TAR" ]; then
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 is ELF format (Linux)"
else
echo "ERROR: Binary is NOT ELF format!"
exit 1
fi
fi
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
@@ -125,6 +138,14 @@ jobs:
$CRANE copy "$EAGLE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
echo "eagle_image_tag=$EAGLE_TAG" >> $GITHUB_OUTPUT
# Push Shardok image
SHARDOK_IMAGE="${{ steps.build-all.outputs.shardok_path }}"
SHARDOK_TAG="registry.digitalocean.com/eagle0/shardok-server:${GIT_SHA}"
echo "Pushing Shardok: $SHARDOK_TAG"
$CRANE push "$SHARDOK_IMAGE" "$SHARDOK_TAG"
$CRANE copy "$SHARDOK_TAG" "registry.digitalocean.com/eagle0/shardok-server:latest"
echo "shardok_image_tag=$SHARDOK_TAG" >> $GITHUB_OUTPUT
# Push Admin image
ADMIN_IMAGE="${{ steps.build-all.outputs.admin_path }}"
ADMIN_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
@@ -144,12 +165,13 @@ jobs:
echo "=== All images pushed successfully ==="
deploy:
runs-on: [self-hosted, bazel]
runs-on: self-hosted
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-all.outputs.eagle_image_tag }}
SHARDOK_IMAGE: ${{ needs.build-all.outputs.shardok_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 }}
@@ -162,12 +184,6 @@ jobs:
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GH_OAUTH_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }}
GH_OAUTH_CLIENT_SECRET: ${{ secrets.GH_OAUTH_CLIENT_SECRET }}
APPLE_SIGNIN_CLIENT_ID: ${{ secrets.APPLE_SIGNIN_CLIENT_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_SIGNIN_KEY_ID: ${{ secrets.APPLE_SIGNIN_KEY_ID }}
APPLE_SIGNIN_PRIVATE_KEY: ${{ secrets.APPLE_SIGNIN_PRIVATE_KEY }}
SHARDOK_ADDRESS: ${{ secrets.SHARDOK_ADDRESS }}
SHARDOK_AUTH_TOKEN: ${{ secrets.SHARDOK_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
@@ -215,44 +231,63 @@ jobs:
set -ex
cd /opt/eagle0
# Export environment variables for docker compose
# These are passed via heredoc and exported so child processes (docker compose) can access them
export EAGLE_IMAGE="${EAGLE_IMAGE}"
export ADMIN_IMAGE="${ADMIN_IMAGE}"
export JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
export OPENAI_API_KEY="${OPENAI_API_KEY}"
export GPT_MODEL_NAME="${GPT_MODEL_NAME:-gpt-4o}"
export EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3:-false}"
export DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
export DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
export JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
export DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
export DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
export GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
export GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
export GH_OAUTH_CLIENT_ID="${GH_OAUTH_CLIENT_ID}"
export GH_OAUTH_CLIENT_SECRET="${GH_OAUTH_CLIENT_SECRET}"
export APPLE_SIGNIN_CLIENT_ID="${APPLE_SIGNIN_CLIENT_ID}"
export APPLE_TEAM_ID="${APPLE_TEAM_ID}"
export APPLE_SIGNIN_KEY_ID="${APPLE_SIGNIN_KEY_ID}"
export APPLE_SIGNIN_PRIVATE_KEY="${APPLE_SIGNIN_PRIVATE_KEY}"
export SHARDOK_ADDRESS="${SHARDOK_ADDRESS:-shardok:40042}"
export SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
export SENTRY_DSN="${SENTRY_DSN}"
export FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
export FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
export FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
export DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
# 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}"
GPT_MODEL_NAME="${GPT_MODEL_NAME}"
EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3}"
DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
SHARDOK_ADDRESS="${SHARDOK_ADDRESS}"
SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
SENTRY_DSN="${SENTRY_DSN}"
FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
# Check Docker has IPv6 support
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."
fi
# 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}" \
"GPT_MODEL_NAME=\${GPT_MODEL_NAME:-gpt-4o}" \
"EAGLE_ENABLE_S3=\${EAGLE_ENABLE_S3:-false}" \
"DO_SPACES_ACCESS_KEY=\${DO_SPACES_ACCESS_KEY}" \
"DO_SPACES_SECRET_KEY=\${DO_SPACES_SECRET_KEY}" \
"JWT_PRIVATE_KEY=\${JWT_PRIVATE_KEY}" \
"DISCORD_CLIENT_ID=\${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=\${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=\${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=\${GOOGLE_CLIENT_SECRET}" \
"SHARDOK_ADDRESS=\${SHARDOK_ADDRESS:-shardok:40042}" \
"SHARDOK_AUTH_TOKEN=\${SHARDOK_AUTH_TOKEN}" \
"SENTRY_DSN=\${SENTRY_DSN}" \
"FASTMAIL_API_TOKEN=\${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=\${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=\${FASTMAIL_FROM_NAME}"
cd ..
# Login to registry
echo "\$DO_REGISTRY_TOKEN" | docker login registry.digitalocean.com -u "\$DO_REGISTRY_TOKEN" --password-stdin
echo "Using images: \$EAGLE_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
echo "Using images: \$EAGLE_IMAGE, \$SHARDOK_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
# Install crane for pulling OCI images
echo "Installing crane..."
@@ -264,6 +299,9 @@ jobs:
echo "Pulling Eagle image..."
./crane pull "\${EAGLE_IMAGE}" eagle.tar && docker load -i eagle.tar && rm eagle.tar
echo "Pulling Shardok image..."
./crane pull "\${SHARDOK_IMAGE}" shardok.tar && 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
@@ -276,20 +314,32 @@ jobs:
echo "All images pulled successfully"
# Stop local shardok container if running (now runs on Hetzner)
docker stop shardok-server 2>/dev/null || true
docker rm shardok-server 2>/dev/null || true
# Recreate non-Eagle services
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate shardok admin
# Deploy Eagle with blue-green (handles eagle, nginx, admin, jfr-sidecar)
# Note: Shardok runs on Hetzner, deployed separately via shardok_arm64_build.yml
chmod +x /opt/eagle0/scripts/*.sh
[ -f "/opt/eagle0/scripts/bin/warmup" ] && chmod +x /opt/eagle0/scripts/bin/warmup
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}"
# Deploy Eagle with blue-green
if [ -x "/opt/eagle0/scripts/deploy-blue-green.sh" ]; then
echo "Using blue-green deployment for Eagle..."
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}" || {
echo "Blue-green failed, falling back to direct restart"
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle-blue
}
else
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle-blue
fi
# Start jfr-sidecar after eagle-blue
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate jfr-sidecar
# Ensure auth is running
docker compose -f docker-compose.prod.yml up -d auth
# Restart nginx
docker compose -f docker-compose.prod.yml up -d --force-recreate nginx
# Verify
sleep 10
docker compose -f docker-compose.prod.yml ps
+1 -1
View File
@@ -17,7 +17,7 @@ permissions:
jobs:
build-installer:
runs-on: [self-hosted, bazel]
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
+25 -144
View File
@@ -6,18 +6,13 @@ 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/proto/**"
- "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_submit.sh"
- "scripts/notarize_wait.sh"
- "scripts/notarize_mac_app.sh"
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/mac/**"
@@ -25,17 +20,11 @@ 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_submit.sh"
- "scripts/notarize_wait.sh"
- "scripts/notarize_mac_app.sh"
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/mac/**"
@@ -51,34 +40,26 @@ permissions:
contents: read
jobs:
build-and-sign:
runs-on: [self-hosted, macOS, unity-mac]
outputs:
submission_id: ${{ steps.notarize-submit.outputs.submission_id }}
should_deploy: ${{ steps.check-deploy.outputs.should_deploy }}
mac-unity:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
lfs: true
clean: true # Remove untracked files like old SparklePlugin.bundle
clean: false
fetch-depth: 0 # For version numbering from git history
- name: Pull LFS files
run: git lfs pull
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: mac
run: ./ci/github_actions/restore_library.sh
- name: Build Mac Unity
run: ./ci/github_actions/build_unity_mac.sh "/tmp/eagle0/eagle0MAC"
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: mac
run: ./ci/github_actions/persist_library.sh
- name: Inject Sparkle Framework
@@ -89,21 +70,8 @@ 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() && steps.check-deploy.outputs.should_deploy == 'true'
if: success() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing != 'true') || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
@@ -133,124 +101,25 @@ jobs:
rm certificate.p12
- name: Code Sign App
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
if: success() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing != 'true') || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
env:
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
run: |
chmod +x ./scripts/codesign_mac_app.sh
./scripts/codesign_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
- name: Submit for Notarization
id: notarize-submit
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
- name: Notarize App
if: success() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing != 'true') || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_submit.sh
./scripts/notarize_submit.sh "/tmp/eagle0/eagle0MAC/eagle0.app" >> $GITHUB_OUTPUT
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain build.keychain 2>/dev/null || true
- name: Zip signed app for artifact
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
run: |
cd /tmp/eagle0/eagle0MAC
ditto -c -k --keepParent eagle0.app eagle0.app.zip
- name: Upload signed app
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
uses: actions/upload-artifact@v4
with:
name: signed-mac-app
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
retention-days: 1
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_mac.log
path: /tmp/eagle0/editor_mac.log
wait-notarization:
needs: build-and-sign
if: needs.build-and-sign.outputs.should_deploy == 'true'
runs-on: [self-hosted, macOS, notarize]
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
- name: Clean download directory
run: rm -rf /tmp/eagle0/eagle0MAC
- name: Download signed app
uses: actions/download-artifact@v4
with:
name: signed-mac-app
path: /tmp/eagle0/eagle0MAC
- name: Unzip signed app
run: |
cd /tmp/eagle0/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
ls -la /tmp/eagle0/eagle0MAC/eagle0.app/
- name: Wait for Notarization and Staple
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_wait.sh
./scripts/notarize_wait.sh "${{ needs.build-and-sign.outputs.submission_id }}" "/tmp/eagle0/eagle0MAC/eagle0.app"
- name: Zip notarized app for artifact
run: |
cd /tmp/eagle0/eagle0MAC
rm -f eagle0.app.zip
ditto -c -k --keepParent eagle0.app eagle0.app.zip
- name: Upload notarized app
uses: actions/upload-artifact@v4
with:
name: notarized-mac-app
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
retention-days: 1
deploy:
needs: [build-and-sign, wait-notarization]
if: needs.build-and-sign.outputs.should_deploy == 'true'
runs-on: [self-hosted, macOS, unity-mac]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # For version numbering
- name: Clean download directory
run: rm -rf /tmp/eagle0/eagle0MAC
- name: Download notarized app
uses: actions/download-artifact@v4
with:
name: notarized-mac-app
path: /tmp/eagle0/eagle0MAC
- name: Unzip notarized app
run: |
cd /tmp/eagle0/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
chmod +x ./scripts/notarize_mac_app.sh
./scripts/notarize_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
- name: Deploy Mac Build
if: success() && ((github.event_name == 'workflow_dispatch' && github.event.inputs.skip_signing != 'true') || (github.event_name == 'push' && github.ref == 'refs/heads/main'))
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
@@ -271,3 +140,15 @@ 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
+7 -7
View File
@@ -46,26 +46,26 @@ jobs:
for REPO in $REPOS; do
echo "=== Processing repository: ${REPO} ==="
# Get all manifests with their tags and dates using JSON output for reliable parsing
MANIFESTS_JSON=$(doctl registry repository list-manifests "${REPO}" --output json 2>/dev/null || echo "[]")
# Get all manifests with their tags and dates
# Filter out header row and empty lines
MANIFESTS=$(doctl registry repository list-manifests "${REPO}" --format Digest,UpdatedAt,Tags --no-header 2>/dev/null | grep -v '^Digest' | grep -v '^$' || echo "")
if [ "$MANIFESTS_JSON" = "[]" ] || [ -z "$MANIFESTS_JSON" ]; then
if [ -z "$MANIFESTS" ]; then
echo " No manifests found"
continue
fi
# Parse JSON and process each manifest
echo "$MANIFESTS_JSON" | jq -r '.[] | "\(.digest) \(.updated_at) \(.tags // [] | join(","))"' | while read -r DIGEST UPDATED_AT TAGS; do
echo "$MANIFESTS" | while read -r DIGEST UPDATED_AT TAGS; do
# Skip if no digest or if it doesn't look like a valid digest (sha256:...)
if [ -z "$DIGEST" ] || ! echo "$DIGEST" | grep -q '^sha256:'; then
continue
fi
# Parse the date (ISO 8601 format from JSON)
# Parse the date
MANIFEST_DATE=$(date -d "$UPDATED_AT" +%s 2>/dev/null || echo "0")
# Skip protected tags (latest, arm64-latest)
if echo ",$TAGS," | grep -qE ',(latest|arm64-latest),'; then
if echo "$TAGS" | grep -qE '(^|,)(latest|arm64-latest)(,|$)'; then
echo " KEEP: ${DIGEST:0:20}... (protected tag: $TAGS)"
continue
fi
+2 -2
View File
@@ -24,7 +24,7 @@ permissions:
jobs:
build-shardok-arm64:
runs-on: [self-hosted, bazel]
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
steps:
@@ -159,7 +159,7 @@ jobs:
echo "Also tagged as: registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
deploy-hetzner:
runs-on: [self-hosted, bazel]
runs-on: self-hosted
needs: [build-shardok-arm64]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
+1 -1
View File
@@ -28,7 +28,7 @@ permissions:
jobs:
build:
runs-on: [self-hosted, bazel]
runs-on: self-hosted
steps:
- name: Checkout repository
+6 -18
View File
@@ -6,11 +6,7 @@ on:
paths:
- ".github/workflows/unity_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/protobuf/net/eagle0/common/**"
- "src/main/protobuf/net/eagle0/shardok/**"
- "src/main/protobuf/net/eagle0/eagle/api/**"
- "src/main/protobuf/net/eagle0/eagle/common/**"
- "src/main/protobuf/net/eagle0/eagle/views/**"
- "src/main/proto/**"
- "scripts/build_protos.sh"
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
@@ -19,16 +15,12 @@ on:
- "ci/github_actions/persist_library.sh"
- "MODULE.bazel"
- "WORKSPACE"
workflow_dispatch:
- "src/main/proto/net/eagle0/eagle/**/BUILD.bazel"
pull_request:
paths:
- ".github/workflows/unity_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/protobuf/net/eagle0/common/**"
- "src/main/protobuf/net/eagle0/shardok/**"
- "src/main/protobuf/net/eagle0/eagle/api/**"
- "src/main/protobuf/net/eagle0/eagle/common/**"
- "src/main/protobuf/net/eagle0/eagle/views/**"
- "src/main/proto/**"
- "scripts/build_protos.sh"
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
@@ -37,31 +29,27 @@ on:
- "ci/github_actions/persist_library.sh"
- "MODULE.bazel"
- "WORKSPACE"
- "src/main/proto/**/BUILD.bazel"
permissions:
contents: read
jobs:
windows-unity:
runs-on: [self-hosted, macOS, unity-windows]
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
lfs: true
clean: true # Remove untracked files from previous builds
clean: false
- name: Pull lfs files
run: git lfs pull
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: windows
run: ./ci/github_actions/restore_library.sh
- name: Build Windows unity
run: ./ci/github_actions/build_unity.sh "/tmp/eagle0/eagle0WIN"
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: windows
run: ./ci/github_actions/persist_library.sh
- name: Deploy Windows unity
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
+213
View File
@@ -0,0 +1,213 @@
# Deproto Migration Plan
This document tracks the migration from protobuf types to native Scala models inside the Eagle game engine.
## Architectural Decisions
1. **Keep proto for persistence**: Yes - protobuf is used for persisting game state
2. **Keep proto for Shardok communication**: Yes - protobuf is used for Eagle-Shardok gRPC communication
3. **Use Scala views inside the library**: Yes - use native Scala types like `ProvinceView`, `FactionView`, `HeroView`, etc. within the library code
## Recent Completed Work
### GameState Round-Trip Elimination (PRs #4913, #4914, #4915)
Eliminated wasteful Scala→proto→Scala conversions in the hot path:
1. **LLM Pipeline** (#4913): `LlmRequestWithGameState` now uses Scala `GameState` instead of proto. All ~38 prompt generators updated to use Scala model types (`FactionT`, `HeroT`, `ProvinceT`).
2. **ActionWithResultingState Caching** (#4914): Added `precomputedScalaState: Option[GameState]` to cache Scala state when available, avoiding `fromProto()` conversion in `stateAfter()`.
3. **PostResults Simplification** (#4915): Changed `PostResults.gameState` from proto to `Option[GameState]` (Scala), eliminating `toProto()` calls when creating PostResults.
## Migration Pattern
The codebase follows a **Legacy* pattern** for separating proto-dependent and protoless code:
- **Protoless utilities**: `FactionUtils`, `HeroUtils`, `ProvinceUtils`, `ProvinceDistances`, etc.
- **Proto-dependent utilities**: `LegacyFactionUtils`, `LegacyHeroUtils`, `LegacyProvinceUtils`, `LegacyProvinceDistances`, etc.
When migrating a file:
1. Create a `Legacy*` version containing the proto-dependent methods
2. Keep the original file name for protoless methods
3. Update callers to use the appropriate version based on their context
## Migration Status
### Fully Protoless (no proto imports)
**Utilities:**
- [x] `FactionUtils` - has protoless `ownedNeighbors` method
- [x] `ProvinceDistances` - split into protoless + `LegacyProvinceDistances`
- [x] `SwornBrotherChooser` - fully protoless (removed `bestChoiceProto`)
**Command Selectors (all use native GameState):**
- [x] `AllianceOfferCommandSelector`
- [x] `AlmsCommandSelector`
- [x] `AttackCommandChooser`
- [x] `ExpandCommandSelector`
- [x] `HeroGiftCommandSelector`
- [x] `ImproveCommandSelector`
- [x] `MarchTowardProvinceCommandChooser` - in AI folder, uses native GameState (callers convert)
- [x] `OrganizeCommandSelector`
- [x] `RansomOfferHelpers`
- [x] `SeekMoreLeadersCommandChooser` - in AI folder, uses native GameState
- [x] `TruceOfferCommandSelector`
- [x] `TrustForDiplomacy`
**Quest Command Selectors (all protoless):**
- [x] `AllianceQuestCommandChooser`
- [x] `AlmsAcrossRealmQuestCommandChooser`
- [x] `AlmsToProvinceQuestCommandChooser`
- [x] `DismissSpecificVassalCommandChooser`
- [x] `GiveToHeroesAcrossRealmQuestCommandChooser`
- [x] `GiveToHeroesInProvinceQuestCommandChooser`
- [x] `ImproveQuestCommandChooser`
- [x] `QuestCommandChooser`
- [x] `TruceCountQuestCommandChooser`
- [x] `TruceWithFactionQuestCommandChooser`
### Fully Protoless
- [x] `AIClientUtils` - has protoless overloads (`takenHeroIdsForMarchTowardFocus`, `mostPowerfulHeroes`)
- [x] `AttackCommandChooser` - uses Scala `GameState` and `BattalionViewC` (Scala view type)
- [x] `BattalionPower` - has `estimatedPower(BattalionViewC)` for recon data with optional stats
- [x] `CommandChoiceHelpers` - fully protoless, uses Scala `GameState` throughout
- [x] `ProvinceGoldSurplusCalculator` - fully protoless (callers use converters)
- [x] `HeroSelector` - fully protoless (removed dead `minimallyFatiguedHeroesProto`)
### AI Layer ✅ COMPLETE
All AI and command chooser code is now fully protoless:
- [x] `AttackDecisionCommandChooser` - uses Scala GameState
- [x] `CommandChooser` - trait uses Scala GameState
- [x] `FulfillQuestsCommandSelector` - uses Scala GameState
- [x] `MidGameAIClient` - uses Scala GameState internally
### Still Using Proto GameState (Boundary Code)
These files use proto GameState because they're at system boundaries:
**View Filters (client projection):**
- `view_filters/GameStateViewFilter` - has Scala overload, uses Scala sub-filters
- `view_filters/ProvinceViewFilter` - has Scala overloads for some methods
- `view_filters/FactionViewFilter` - has Scala overload
- `view_filters/HeroViewFilter` - has Scala overload
- `view_filters/BattalionNameFilter` - has Scala overload
- `view_filters/BattleFilter` - has Scala overload
**Legacy Utilities (to be deprecated):**
- `LegacyProvinceDistances`, `LegacyFactionUtils`, `LegacyHeroUtils`, etc.
- Used by code that still needs proto GameState
**Persistence/Action System:**
- `ActionResultTApplier`, `ActionResultProtoApplier` - apply results to proto state
- `ActionWithResultingState` - caches both proto and Scala state
**Shardok Interface (gRPC boundary):**
- `ShardokInterfaceGrpcClient`, `ShardokInterfaceProxy` - must use proto for C++ communication
## Next Steps
### Phase 1-3: AI Layer ✅ COMPLETE
The entire AI decision-making layer is now protoless.
### Phase 4: View Filters ✅ COMPLETE
The view_filters package migration is complete:
**Completed:**
- [x] `GameStateViewFilter` - added Scala GameState overload (uses Scala sub-filters)
- [x] `ProvinceViewFilter` - already has Scala overloads for server-side views
- [x] `HumanPlayerClientConnectionState` - updated to pass Scala GameState directly
- [x] `HeroViewFilter` - added Scala overload
- [x] `FactionViewFilter` - added Scala overload
- [x] `Visibility` - added Scala overloads
**Still Using Proto:**
- [x] `BattalionNameFilter` - has Scala overload
- [x] `BattleFilter` - has Scala overload
- [ ] `ActionResultFilter` - uses proto internally (boundary code)
**Strategy:**
1. Add Scala GameState overloads to view filter methods
2. Update callers to pass Scala GameState where available
3. Eventually deprecate proto versions
### Phase 5: Legacy Utility Cleanup (IN PROGRESS)
Remove Legacy* utilities by migrating remaining callers:
1. Identify callers of each Legacy* util
2. Update callers to use protoless versions
3. Delete Legacy* files when no longer needed
**Deleted (no production callers):**
- [x] `LegacyProvinceDistances` - deleted (no callers)
- [x] `LegacyBattalionSuitability` - deleted (no callers)
- [x] `LegacyFoodConsumptionUtils` - deleted (no callers)
- [x] `LegacyHandleRiotUtils` - deleted (no callers)
**Refactored to Thin Wrappers (delegating to protoless versions):**
- [x] `LegacyRansomValidity` - already a thin wrapper delegating to `RansomValidity`
- [x] `LegacyRecruitmentOdds` - refactored to delegate to `RecruitmentOdds`
**Parallel Implementations (proto mirrors protoless):**
- [x] `FactionUtils` / `LegacyFactionUtils` - both have matching APIs; LegacyFactionUtils used by boundary code (24+ callers)
- [x] `HeroUtils` / `LegacyHeroUtils` - both have matching APIs; LegacyHeroUtils used by boundary code (10 callers)
- [x] `ProvinceUtils` / `LegacyProvinceUtils` - both have matching APIs; LegacyProvinceUtils used by boundary code (20 callers: availability factories, view filters)
**Parallel Implementations (awaiting migration of callers):**
- [x] `BattalionUtils` / `LegacyBattalionUtils` - both have matching core methods; LegacyBattalionUtils used by boundary code (4 callers)
- [x] `BattalionViewFilter` / `LegacyBattalionViewFilter` - protoless version exists; Legacy used by view filters, action appliers (3 callers)
- [x] `BattalionTypeFinder` / `LegacyBattalionTypeFinder` - protoless version exists; Legacy used by validators (1 caller: RuntimeValidator)
### Recent Caller Migration
**CheckForFulfilledQuestsAction** - migrated to use protoless `BattalionTypeFinder`:
- Changed `battalionTypes` parameter from proto `Vector[BattalionType]` to Scala `Vector[BattalionType]`
- Updated callers (EngineImpl, EndVassalCommandsPhaseAction) to pass Scala types directly
- Eliminated wasteful `BattalionTypeConverter.toProto()` conversions
**ExpandedUnaffiliatedHeroUtils** - added Scala overload:
- New overload takes Scala `GameState` and `UnaffiliatedHeroT` directly
- Added `UnaffiliatedHeroConverter.unaffiliatedHeroTypeToProto()` helper for efficient enum conversion
- Proto overload retained for backward compatibility
**AvailablePleaseRecruitMeCommandFactory** - eliminated wasteful proto conversions:
- Now uses `ExpandedUnaffiliatedHeroUtils.expandedUnaffiliatedHero(ScalaGameState, UnaffiliatedHeroT)` directly
- Removed `GameStateConverter.toProto()` and `UnaffiliatedHeroConverter.toProto()` calls
- Factory is now fully protoless internally (still returns proto types for API boundary)
**ProvinceViewFilter** - added Scala overload with faction filtering:
- New `filteredProvinceView(province: ProvinceT, gs: ScalaGameState, factionId: FactionId)` overload
- Uses protoless `FactionUtils.hasAlliance`, `Visibility.hasFullVisibility`, and `ProvinceUtils.incomingOthers`
- Handles reconned provinces directly from Scala `FactionT.reconnedProvinces` (already Scala type)
- Added helper methods: `fullProvinceInfoScala`, `maybeIncomingAttackersScala`, `unaffiliatedHeroInfoScala`
- Events still converted to proto at the end (ProvinceView.knownEvents uses proto events)
**GameStateViewFilter** - eliminated GameStateConverter.toProto() call:
- Scala overload now fully protoless internally
- Uses the new ProvinceViewFilter Scala overload with faction filtering
- Converts `battalionTypes` and `chronicleEntries` to proto only at output boundary
## Key Files
### Protoless Model Types
- `src/main/scala/net/eagle0/eagle/model/state/game_state/GameState.scala` - native Scala GameState
- `src/main/scala/net/eagle0/eagle/model/state/province/ProvinceView.scala` - province view type
- `src/main/scala/net/eagle0/eagle/model/state/faction/FactionView.scala` - faction view type
- `src/main/scala/net/eagle0/eagle/model/state/hero/HeroView.scala` - hero view type
### Proto Converters
- `src/main/scala/net/eagle0/eagle/model/proto_converters/game_state/` - converts between proto and Scala types
## Notes
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) is now fully protoless
- Proto GameState is still needed at boundaries: persistence, gRPC to Shardok
- `PerformUnaffiliatedHeroesAction` and the LLM pipeline use protoless `GameState`
- `GameStateViewFilter` Scala overload is now fully protoless internally (converts to proto only at output)
- `ProvinceViewFilter` has Scala overloads for all three modes: no filtering, faction filtering, and withdrawn-from view
+1 -19
View File
@@ -130,13 +130,6 @@ 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
#
@@ -144,7 +137,7 @@ use_repo(apple_cc_configure, "local_config_apple_cc")
bazel_dep(name = "protobuf", version = "29.2", repo_name = "com_google_protobuf")
bazel_dep(name = "grpc", version = "1.71.0")
bazel_dep(name = "grpc-java", version = "1.71.0")
bazel_dep(name = "flatbuffers", version = "25.9.23")
bazel_dep(name = "flatbuffers", version = "25.2.10")
#
# Testing
@@ -300,17 +293,6 @@ 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: DigitalOcean Spaces (public, reliable)
# Fallback: busybox.net (can be unreliable/slow)
+10 -42
View File
@@ -39,11 +39,11 @@
"https://bcr.bazel.build/modules/aspect_rules_js/1.33.1/MODULE.bazel": "db3e7f16e471cf6827059d03af7c21859e7a0d2bc65429a3a11f005d46fc501b",
"https://bcr.bazel.build/modules/aspect_rules_js/1.39.0/MODULE.bazel": "aece421d479e3c31dc3e5f6d49a12acc2700457c03c556650ec7a0ff23fc0d95",
"https://bcr.bazel.build/modules/aspect_rules_js/2.0.0/MODULE.bazel": "b45b507574aa60a92796e3e13c195cd5744b3b8aff516a9c0cb5ae6a048161c5",
"https://bcr.bazel.build/modules/aspect_rules_js/2.3.8/MODULE.bazel": "74bf20a7a6bd5f2be09607fdb4196cfd6f203422ea271752ec2b1afe95426101",
"https://bcr.bazel.build/modules/aspect_rules_js/2.3.8/source.json": "411ec9d79d6f5fe8a083359588c21d01a5b48d88a2cbd334a4c90365015b7836",
"https://bcr.bazel.build/modules/aspect_rules_js/2.1.3/MODULE.bazel": "47cc48eec374d69dced3cf9b9e5926beac2f927441acfb1a3568bbb709b25666",
"https://bcr.bazel.build/modules/aspect_rules_js/2.1.3/source.json": "6b0fe67780c101430be087381b7a79d75eeebe1a1eae6a2cee937713603634ac",
"https://bcr.bazel.build/modules/aspect_rules_lint/0.12.0/MODULE.bazel": "e767c5dbfeb254ec03275a7701b5cfde2c4d2873676804bc7cb27ddff3728fed",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.6.0/MODULE.bazel": "d0045b5eabb012be550a609589b3e5e47eba682344b19cfd9365d4d896ed07df",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.6.0/source.json": "5593e3f1cd0dd5147f7748e163307fd5c2e1077913d6945b58739ad8d770a290",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.4.0/MODULE.bazel": "5b554d5de90d96ee14117527c0519037713dd33884f3212eae391beccb2e94ff",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.4.0/source.json": "9ada3722b716853b6dccdb7b650d8e776a23bc8a190de0c59bd15f21afea6f8a",
"https://bcr.bazel.build/modules/bazel_features/0.1.0/MODULE.bazel": "47011d645b0f949f42ee67f2e8775188a9cf4a0a1528aa2fa4952f2fd00906fd",
"https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b",
"https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd",
@@ -108,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.9.23/MODULE.bazel": "32753ba60bf3bacfe7737c0f3e8e3e55624b19af5d398c485580d57492d145d8",
"https://bcr.bazel.build/modules/flatbuffers/25.9.23/source.json": "a2116f0017f6896353fd3abf65ef2b89b0a257e8a87f395c5000f53934829f31",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/MODULE.bazel": "dab15cafe8512d2c4a8daa44c2d7968c5c79f01e220d40076cdc260bf58605e2",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/source.json": "7eae7ea3eb913b9802426e4d5df11d6c6072a3573a548f8cabf1e965f5cca4d0",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/source.json": "fa7b512dfcb5eafd90ce3959cf42a2a6fe96144ebbb4b3b3928054895f2afac2",
"https://bcr.bazel.build/modules/gazelle/0.27.0/MODULE.bazel": "3446abd608295de6d90b4a8a118ed64a9ce11dcb3dda2dc3290a22056bd20996",
@@ -396,7 +396,7 @@
"@@apple_support~//crosstool:setup.bzl%apple_cc_configure_extension": {
"general": {
"bzlTransitiveDigest": "Z3yAd66IJL0GAZUTSeMOjoHiE1SZPPwiIs/XQui5BvE=",
"usagesDigest": "DqQsfZN5lA8z+nLEEY+EpKGzQ8M73mDm/A8lofDSyus=",
"usagesDigest": "tl3VVeQX3Hzh7FhM2gjnkCwEJpRMlY5S6a850WY/xvc=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -423,7 +423,7 @@
},
"@@aspect_rules_esbuild~//esbuild:extensions.bzl%esbuild": {
"general": {
"bzlTransitiveDigest": "8L5Llfl6uxIWXd5GR+Qmmm04/jxp6TuJH5LFhIZIUCA=",
"bzlTransitiveDigest": "RzTo7pj0Tdkwa3Ld5BAVhGFSvWroST8GAQLuwwC0jdQ=",
"usagesDigest": "iDVoyPxUeADmfK8ssoyG3Ehq1bj6p7A43LpEiE266os=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
@@ -502,7 +502,6 @@
"extra_build_content": "",
"generate_bzl_library_targets": false,
"extract_full_archive": false,
"exclude_package_contents": [],
"system_tar": "auto"
}
},
@@ -527,8 +526,7 @@
"package_visibility": [
"//visibility:public"
],
"replace_package": "",
"exclude_package_contents": []
"replace_package": ""
}
}
},
@@ -548,11 +546,6 @@
"bazel_tools",
"bazel_tools"
],
[
"aspect_bazel_lib~",
"tar.bzl",
"tar.bzl~"
],
[
"aspect_rules_esbuild~",
"aspect_rules_js",
@@ -568,11 +561,6 @@
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"aspect_rules_js~",
"aspect_rules_js",
"aspect_rules_js~"
],
[
"aspect_rules_js~",
"bazel_skylib",
@@ -583,30 +571,10 @@
"bazel_tools",
"bazel_tools"
],
[
"bazel_lib~",
"bazel_skylib",
"bazel_skylib~"
],
[
"bazel_lib~",
"bazel_tools",
"bazel_tools"
],
[
"tar.bzl~",
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"tar.bzl~",
"bazel_skylib",
"bazel_skylib~"
],
[
"tar.bzl~",
"tar.bzl",
"tar.bzl~"
]
]
}
@@ -1214,7 +1182,7 @@
"@@rules_nodejs~//nodejs:extensions.bzl%node": {
"general": {
"bzlTransitiveDigest": "q44Ox2Nwogn6OsO0Xw5lhjkd/xmxkvvpwVOn5P4pmHQ=",
"usagesDigest": "Py5Wgc5kr5fTMe1FKrlFK276B6SodesXp6nw2Fq5XA8=",
"usagesDigest": "WQpLKLujnBfrx9sMWCJgyaK9P04binseT6CGBy3vP4E=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
+3
View File
@@ -9,6 +9,9 @@ COMMIT=$(/usr/bin/git rev-parse --short HEAD)
/bin/echo "build protos"
./scripts/build_protos.sh
/bin/echo "build plugins"
./scripts/build_windows_plugin.sh
git log -3
/bin/echo "build Windows"
+2 -2
View File
@@ -7,8 +7,8 @@ COMMIT=$(/usr/bin/git rev-parse --short HEAD)
/bin/echo "build protos"
./scripts/build_protos.sh
/bin/echo "build Sparkle plugin"
./scripts/build_sparkle_plugin.sh
/bin/echo "build Mac plugin"
./scripts/build_mac_plugin.sh
git log -3
+2 -15
View File
@@ -1,25 +1,12 @@
#!/bin/bash
#
# Persist Unity Library/ cache to persistent storage
#
# Environment variables:
# UNITY_CACHE_PLATFORM - Platform identifier (e.g., "mac", "windows")
# Defaults to "mac" if not set
#
# Note: Library/Bee/ is excluded because it contains DAG files with hardcoded
# file paths that become stale when project files change. This prevents
# "Data at the root level is invalid" XML errors from stale references.
set -uxo pipefail
PLATFORM="${UNITY_CACHE_PLATFORM:-mac}"
CACHE_DIR="/tmp/eagle0/Library-${PLATFORM}"
/bin/echo "persist Library/ to $CACHE_DIR (excluding Bee/)"
/bin/echo "persist Library/"
# rsync may exit with code 23 ("partial transfer due to error") if Unity's
# temporary files vanish during the copy. This is acceptable for a cache.
/usr/bin/rsync -rtlDvq --exclude='Bee/' src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ "$CACHE_DIR/"
/usr/bin/rsync -rtlDvq src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ /tmp/eagle0/Library/
rsync_exit=$?
if [ $rsync_exit -eq 0 ]; then
+3 -12
View File
@@ -1,16 +1,7 @@
#!/bin/bash
#
# Restore Unity Library/ cache from persistent storage
#
# Environment variables:
# UNITY_CACHE_PLATFORM - Platform identifier (e.g., "mac", "windows")
# Defaults to "mac" if not set
set -euxo pipefail
PLATFORM="${UNITY_CACHE_PLATFORM:-mac}"
CACHE_DIR="/tmp/eagle0/Library-${PLATFORM}"
/bin/echo "restore Library/ from $CACHE_DIR"
/bin/mkdir -p "$CACHE_DIR"
/usr/bin/rsync -rtlDvq "$CACHE_DIR/" src/main/csharp/net/eagle0/clients/unity/eagle0/Library/
/bin/echo "restore Library/"
/bin/mkdir -p /tmp/eagle0/Library
/usr/bin/rsync -rtlDvq /tmp/eagle0/Library/ src/main/csharp/net/eagle0/clients/unity/eagle0/Library/
+3 -3
View File
@@ -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 (Hetzner ARM64 server)
SHARDOK_ADDRESS=
# Shardok connection
SHARDOK_ADDRESS=shardok:40042
SHARDOK_AUTH_TOKEN=
# Monitoring
+3 -9
View File
@@ -44,19 +44,13 @@ for arg in "$@"; do
fi
# Remove existing line for this key and add new one
# Use single quotes to avoid issues with special characters in values
# (JSON strings, base64 with /, etc.)
# Escape any single quotes in the value by replacing ' with '\''
ESCAPED_VALUE="${VALUE//\'/\'\\\'\'}"
if grep -q "^${KEY}=" "$ENV_FILE" 2>/dev/null; then
# Key exists, remove old line and add new one (sed with special chars is fragile)
grep -v "^${KEY}=" "$ENV_FILE" > "$ENV_FILE.tmp" && mv "$ENV_FILE.tmp" "$ENV_FILE"
echo "${KEY}='${ESCAPED_VALUE}'" >> "$ENV_FILE"
# Key exists, update it
sed -i "s|^${KEY}=.*|${KEY}=${VALUE}|" "$ENV_FILE"
echo "Updated $KEY"
else
# Key doesn't exist, add it
echo "${KEY}='${ESCAPED_VALUE}'" >> "$ENV_FILE"
echo "${KEY}=${VALUE}" >> "$ENV_FILE"
echo "Added $KEY"
fi
done
+32 -46
View File
@@ -1,13 +1,11 @@
# Docker Compose for production deployment
#
# Local testing:
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:auth_server_load
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load && bazel run //ci:auth_server_load
# Run: docker compose -f docker-compose.prod.yml up
#
# Production deployment:
# Run: docker compose -f docker-compose.prod.yml up -d
#
# Note: Shardok runs on Hetzner ARM64 server, deployed via shardok_arm64_build.yml workflow.
services:
# Blue-green deployment: eagle-blue is the primary (production) instance
@@ -35,7 +33,7 @@ services:
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
# Auth token for Shardok on Hetzner (required)
# Auth token for remote Shardok on Hetzner (only used when shardok address contains .eagle0.net)
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
# Use persistent volume for save data (users, games, etc.)
EAGLE_SAVE_DIR: "/app/saves"
@@ -49,6 +47,7 @@ services:
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
depends_on:
- shardok
- auth
restart: unless-stopped
logging:
@@ -93,10 +92,9 @@ services:
volumes:
- ./saves:/app/saves # Same save directory as blue
- ./archived:/app/archived # Same archive directory as blue
- ./jfr:/app/jfr # JFR recordings (same as blue)
- jvm-tmp:/tmp # Shared with jfr-sidecar-green for JVM attach socket files
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
depends_on:
- shardok
- auth
restart: "no" # Don't auto-restart during deployment
logging:
@@ -133,13 +131,6 @@ services:
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}"
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}"
GH_OAUTH_CLIENT_ID: "${GH_OAUTH_CLIENT_ID:-}"
GH_OAUTH_CLIENT_SECRET: "${GH_OAUTH_CLIENT_SECRET:-}"
# Apple Sign-In credentials
APPLE_SIGNIN_CLIENT_ID: "${APPLE_SIGNIN_CLIENT_ID:-}"
APPLE_TEAM_ID: "${APPLE_TEAM_ID:-}"
APPLE_SIGNIN_KEY_ID: "${APPLE_SIGNIN_KEY_ID:-}"
APPLE_SIGNIN_PRIVATE_KEY: "${APPLE_SIGNIN_PRIVATE_KEY:-}"
# Server base URL for OAuth callbacks
SERVER_BASE_URL: "${SERVER_BASE_URL:-https://prod.eagle0.net}"
# JWT keys - PEM files in volume, bootstrapped from JWK on first run
@@ -149,8 +140,6 @@ services:
FASTMAIL_API_TOKEN: "${FASTMAIL_API_TOKEN:-}"
FASTMAIL_FROM_EMAIL: "${FASTMAIL_FROM_EMAIL:-}"
FASTMAIL_FROM_NAME: "${FASTMAIL_FROM_NAME:-}"
# Require invitation codes for new user registration
REQUIRE_INVITATION_CODE: "true"
# Note: port 40033 is exposed via nginx, not directly
volumes:
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with Eagle
@@ -169,8 +158,30 @@ services:
retries: 3
start_period: 10s
# Note: Shardok runs on Hetzner ARM64 server, not in this docker-compose.
# Configure SHARDOK_ADDRESS to point to the Hetzner instance.
shardok:
image: ${SHARDOK_IMAGE:-registry.digitalocean.com/eagle0/shardok-server:latest}
container_name: shardok-server
mem_limit: 1g
memswap_limit: 1g # Prevent swap, OOM-kill cleanly instead
ports:
- "40042:40042"
- "40052:40052"
environment:
SHARDOK_RESOURCES_PATH: "/app/resources"
SHARDOK_MAPS_PATH: "/app/resources/maps"
SHARDOK_EAGLE_INTERFACE_ADDRESS: "0.0.0.0:40042"
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 40042 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
nginx:
image: nginx:alpine
@@ -185,9 +196,8 @@ services:
- ./certbot/www:/var/www/certbot:ro
- ./auth:/etc/nginx/auth:ro
depends_on:
- eagle-blue
- admin
# Note: nginx connects to eagle via EAGLE_ADDR (default: eagle-blue:40032)
# For blue-green deployments, update EAGLE_ADDR in .env before switching
restart: unless-stopped
logging:
driver: "json-file"
@@ -204,14 +214,14 @@ services:
- "--auth-addr"
- "auth:40033"
- "--jfr-sidecar-addr"
- "${JFR_SIDECAR_ADDR:-jfr-sidecar:8081}" # Can be switched for blue-green
- "jfr-sidecar:8081"
- "--http-port"
- "8080"
# No external port - accessed via nginx at admin.eagle0.net
depends_on:
- eagle-blue
- auth
# Note: admin connects to eagle via EAGLE_ADDR and jfr-sidecar via JFR_SIDECAR_ADDR
# For blue-green deployments, set both in .env before switching
- jfr-sidecar
restart: unless-stopped
logging:
driver: "json-file"
@@ -229,7 +239,6 @@ services:
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
container_name: jfr-sidecar
# Share PID namespace with Eagle to access its JVM via jcmd
# For blue-green: use JFR_SIDECAR_ADDR=jfr-sidecar-green:8081 when green is active
pid: "service:eagle-blue"
volumes:
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
@@ -248,29 +257,6 @@ services:
retries: 3
start_period: 10s
jfr-sidecar-green:
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
container_name: jfr-sidecar-green
profiles: ["blue-green"] # Only started during blue-green deployment
# Share PID namespace with Eagle green instance
pid: "service:eagle-green"
volumes:
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
depends_on:
- eagle-green
restart: "no" # Don't auto-restart during deployment
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "2"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8081/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
certbot:
image: certbot/certbot
container_name: certbot
+299 -196
View File
@@ -33,23 +33,7 @@
---
## Current State (January 2026)
### Summary
| Area | Proto Imports | Status |
|------|---------------|--------|
| AI (`/ai/`) | **0** | ✅ **Complete** |
| Actions (`/library/actions/impl/action/`) | **0** | ✅ **Complete** |
| Commands (`/library/actions/impl/command/`) | **0** | ✅ **Complete** |
| Availability (`/library/actions/availability/`) | **0** | ✅ **Complete** |
| Command Choice Helpers (`/library/util/command_choice_helpers/`) | **0** | ✅ **Complete** |
| View Filters (`/library/util/view_filters/`) | **0** | ✅ **Complete** (except boundary code) |
| LLM Prompt Generators (`/library/actions/llm_prompt_generators/`) | **56** | ⏳ Remaining work (34 files) |
| Other Utilities (`/library/util/`) | **7** | ⏳ Remaining work (2 files) |
| Root Library (`/library/`) | **8** | Boundary code (3 files) |
**Total: ~65 proto imports remaining** (down from 149)
## Current State
### Completed Phases
@@ -59,227 +43,346 @@
| Phase 2: EngineImpl | **Complete** | Holds Scala `GameState` internally |
| Phase 3: GameHistory | **Complete** | `stateAfter` returns Scala GameState |
| Phase 4: ActionResultT | **Complete** | All 59 actions return `ActionResultT` |
| Phase 5: Action Base Classes | **Complete** | All actions converted to T-type base classes |
| Phase 5b-d: RoundPhaseAdvancer | **Complete** | Uses Scala GameState throughout |
| Phase 6: Core Engine | **Complete** | ActionResultApplier, RandomStateSequencer protoless |
| Phase 6b: CommandFactory | **Complete** | Uses Scala SelectedCommand and AvailableCommand |
| Phase 6c: AI Clients | **Complete** | AIClient, command choosers all protoless |
| Phase 6d: CommandSelection | **Complete** | Renamed ScalaCommandSelection → CommandSelection |
| Phase 5: Action Base Classes | **Complete** | All `RandomSequentialResultsAction` and `DeterministicSingleResultAction` converted to T-type base classes |
| Phase 5b: Base Class Cleanup | **Complete** | `RandomSequentialResultsAction` and `DeterministicSingleResultAction` deleted |
| Phase 5c: RoundPhaseAdvancer Actions | **Complete** | All actions called by RoundPhaseAdvancer accept Scala GameState |
| Phase 5d: RoundPhaseAdvancer Itself | **Complete** | RoundPhaseAdvancer.checkForPhaseAdvancement takes Scala GameState |
### Recent Completions (PRs #5326, #5330, #5332, #5333, #5334, #5336, #5341, #5342)
### Phase 5c/5d Progress (Complete)
1. **CommandFactory protoless** - Now accepts/returns Scala `SelectedCommand` types
2. **AI clients protoless** - `AIClient`, `MidGameAIClient`, all command choosers use Scala types
3. **CommandSelection cleanup** - Deleted legacy proto-based `CommandSelection`, renamed `ScalaCommandSelection``CommandSelection`
4. **AvailableCommandsFactory protoless** - Returns Scala `OneProvinceAvailableCommands`
5. **All CommandChoiceHelpers protoless** - All selector/chooser files converted
6. **ChronicleEventGenerator protoless** (PR #5332) - Removed last proto enum import from Actions layer
7. **ProvinceUtils protoless** (PR #5333) - Converted to use Scala `BattalionType`
8. **FactionViewFilter protoless** (PR #5334) - Returns Scala `FactionView`, converts to proto at edge
9. **HeroViewFilter protoless** (PR #5336) - Returns Scala `HeroView`, converts to proto at edge
10. **ProvinceViewFilter protoless** (PR #5341) - `ProvinceView.knownEvents` uses Scala `ProvinceEvent`, converts at edge
11. **StatWithConditionUtils & ProvinceEventUtils protoless** (PR #5342) - Deleted unused proto overloads
12. **IncomingArmyUtils protoless** (PR #5348) - Deleted unused `stats()` method
13. **RansomOfferHelpers deleted** (PR #5351) - Deleted unused dead code file
`RoundPhaseAdvancer.checkForPhaseAdvancement` now accepts Scala `GameState` and `ActionResultApplier` directly (PR #4677).
| Action | PR | Status |
|--------|-----|--------|
| `PrisonerExchangeAction` | #4670 | ✅ Merged |
| `PerformForcedTurnBackAction` | #4671 | ✅ Merged |
| `PerformHeroDeparturesAction` | #4672 | ✅ Merged |
| `RequestFreeForAllBattlesAction` | #4673 | ✅ Merged |
| `EndPlayerCommandsPhaseAction` | #4674 | ✅ Merged |
| `EndDiplomacyResolutionPhaseAction` | #4675 | ✅ Merged |
| `RoundPhaseAdvancer` itself | #4677 | ✅ Merged |
### EngineImpl Progress
| Change | PR | Status |
|--------|-----|--------|
| `recursiveTransform` deleted | #4677 | ✅ Merged |
| `recursiveTransformT` uses `RandomStateTSequencer` | #4677 | ✅ Merged |
### Current Architecture
**ActionResultT Production (100% Complete):**
- All actions produce `ActionResultT`
- Conversion to `ActionResultProto` happens via `ActionResultProtoConverter.toProto()`
- No direct `ActionResultProto` construction outside the converter
**ActionResultProto Consumption (Next Target):**
- `ActionResultProtoApplierImpl` - applies proto results to proto GameState
- `RoundPhaseAdvancer` - calls converter, passes protos to applier
- `InMemoryHistory` / `PersistedHistory` - stores proto results
- Service layer (`GameController`, `GamesManager`, etc.) - uses proto for client communication
---
## Remaining Work
## Phase 6: Migrate to ActionResultT Consumers
### Phase 7: View Filters ✅ Complete
### Objective
| File | Status | Migration Path |
|------|--------|----------------|
| `FactionViewFilter.scala` | ✅ **Complete** | Returns Scala `FactionView` (PR #5334) |
| `HeroViewFilter.scala` | ✅ **Complete** | Returns Scala `HeroView` (PR #5336) |
| `ProvinceViewFilter.scala` | ✅ **Complete** | Returns Scala `ProvinceView` with Scala events (PR #5341) |
| `GameStateViewFilter.scala` | ✅ **Complete** | Returns Scala `GameStateView`, converted at boundary |
| `GameStateViewDiffer.scala` | ✅ **Complete** | Uses Scala diff types internally, converted at boundary |
| `BattleFilter.scala` | Keep | Uses `ShardokBattleView` (boundary code for battles) |
Eliminate internal consumption of `ActionResultProto`. Everything inside the engine should work with `ActionResultT`.
**Pattern established**: Create Scala view type → Create converter → Update filter to return Scala type → Convert at edge.
### Current Flow (Proto-Heavy)
```
Action.execute()
→ ActionResultT
→ ActionResultProtoConverter.toProto()
→ ActionResultProto
→ ActionResultProtoApplierImpl.applyActionResults()
→ GameStateProto
→ GameStateConverter.fromProto()
→ GameStateC
```
### Phase 8: LLM Prompt Generators (~56 proto imports in 34 files)
### Target Flow (T-Types Throughout)
```
Action.execute()
→ ActionResultT
→ ActionResultApplier.applyActionResults()
→ GameStateC
The LLM prompt generators still use proto types for hero/faction/province data:
(Proto conversion only at boundaries)
```
| File Category | Files | Proto Usage |
|---------------|-------|-------------|
| Diplomacy prompts | 12 | `Hero`, `Faction`, `Province` protos |
| Quest prompts | 4 | `Hero`, `Faction` protos |
| Chronicle prompts | 3 | `Hero`, `Faction`, `Province` protos |
| Other prompts | 15 | Various proto types |
### Key Files to Convert
**Migration strategy**: These files generate text for LLM prompts. They can be migrated to accept Scala types (`HeroT`, `FactionT`, `ProvinceT`) with converters at the call sites if needed.
**Tier 1 - Core Applier:****Complete**
```
src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImpl.scala
```
`ActionResultApplier` applies `ActionResultT` directly to Scala `GameState`. The legacy `ActionResultTApplierImpl` wraps it and converts to/from proto for callers that still need proto types.
**Priority**: Medium - These don't block other migrations and are isolated.
**Tier 2 - RoundPhaseAdvancer:****Complete**
```
src/main/scala/net/eagle0/eagle/library/RoundPhaseAdvancer.scala
```
Now accepts Scala `GameState` and `ActionResultApplier`. Only converts to proto lazily for `AvailableCommandsFactory` calls.
### Phase 9: Remaining Utilities (~1 proto import in 1 file)
**Tier 3 - Sequencers:**
```
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateTSequencer.scala
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateProtoSequencer.scala
```
Modify `RandomStateTSequencer` to thread Scala `GameState` throughout (currently converts to proto internally). Then evaluate whether `RandomStateProtoSequencer` is still needed at all.
| File | Proto Usage | Migration Path |
|------|-------------|----------------|
| `ProvinceEventUtils.scala` | ✅ **0** | Deleted unused proto overloads (PR #5342) |
| `StatWithConditionUtils.scala` | ✅ **0** | Deleted unused proto overloads (PR #5342) |
| `MapGenerator.scala` | 1 import | Convert to Scala types |
| `IncomingArmyUtils.scala` | ✅ **0** | Deleted unused `stats()` method (PR #5348) |
| `GameStateViewDiffer.scala` | ✅ **0** | Uses Scala diff types, converts at boundary |
**Current State**: `RandomStateTSequencer` accepts Scala `GameState` via its `apply()` method but internally converts to proto. All callback methods (`withRandomActionResult`, `withActionResults`, etc.) pass `GameStateProto` to callers, forcing actions that use the sequencer to work with proto types internally.
### Phase 10: History APIs 🔄 In Progress
**Target State**: Create a fully protoless sequencer where:
1. `lastState` returns Scala `GameState` (not `lastStateProto`)
2. All callback methods pass Scala `GameState` to callers
3. Actions using the sequencer can be fully protoless
**Completed:**
- `ActionWithResultingState` now has `scalaActionResult` property (lazy converted from proto)
- `ActionResultFilter.includeForPlayer` uses Scala types (`ActionResultT`, `NotificationT`, `ActionResultType`)
- `GameHistory.withNewResultsScala` preserves both Scala `GameState` and `ActionResultT` to avoid re-conversion
**Migration Path**:
1. Add `lastState: GameState` method alongside `lastStateProto` (non-breaking)
2. Add parallel callback methods that pass Scala GameState (e.g., `withScalaActionResult`)
3. Migrate actions one by one to use the new Scala-based callbacks
4. Once all actions migrated, deprecate/remove proto-based callbacks
5. Remove `lastStateProto` once no longer used
**Remaining:**
**RandomStateSequencer Migration Progress** (PR #4679 introduced protoless `RandomStateSequencer`):
| Action | Status |
|--------|--------|
| `TruceTurnBackPhaseAction` | ✅ Migrated (PR #4680) |
| `EndHandleRiotsPhaseAction` | ✅ Migrated (PR #4684) |
| `PerformVassalCommandsPhaseAction` | ✅ Migrated |
| `PerformVassalDefenseDecisionsAction` | ✅ Migrated |
| `EndVassalCommandsPhaseAction` | ✅ Migrated |
| `PerformReconResolutionAction` | ✅ Migrated |
| `NewRoundAction` | ✅ Migrated (PR #4698) |
| `EndBattleAftermathPhaseAction` | ✅ Migrated (PR #4699) |
| `EndDiplomacyResolutionPhaseAction` | ✅ Migrated |
| `PerformUnaffiliatedHeroesAction` | ✅ Migrated |
| `EngineImpl.recursiveTransformT` | ✅ Migrated (PR #4704) |
| `ProtolessSequentialResultsActionWrapper` | ✅ Migrated (PR #4705) |
| `LegacyRandomStateTSequencer` | ✅ **Deleted** (PR #4705) |
**TCommandFactory Extraction** (PR #4684):
To enable lightweight mocking of command creation in tests, `TCommandFactory` trait was extracted from `CommandFactory`. This allows tests to mock just the `makeTCommand` method without pulling in all 40+ command dependencies that `CommandFactory` requires.
- `TCommandFactory` - lightweight trait with just `makeTCommand`
- `CommandFactory extends TCommandFactory` - maintains backward compatibility
- Actions accepting command factories now use `TCommandFactory` type for better testability
**Tier 4 - History APIs:**
```
src/main/scala/net/eagle0/eagle/service/InMemoryHistory.scala
src/main/scala/net/eagle0/eagle/service/PersistedHistory.scala
```
Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versions.
Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versions. `PersistedHistory` converts to proto internally for disk persistence; `InMemoryHistory` doesn't need proto at all.
### ActionResultProto Consumer Inventory
| File | Usage | Status |
|------|-------|--------|
| `ActionResultApplierImpl.scala` | Applies ActionResultT to Scala GameState | ✅ **Complete** |
| `ActionResultTApplierImpl.scala` | Legacy wrapper - converts to/from proto | Keep until all callers migrated |
| `RoundPhaseAdvancer.scala` | Uses Scala GameState | ✅ **Complete** |
| `RandomStateSequencer.scala` | Threads Scala GameState | ✅ **Complete** |
| `VigorXPApplier.scala` | Has both proto and Scala methods | Scala method exists, delete proto method when unused |
| `PerformForcedTurnBackAction.scala` | Fully protoless | ✅ **Complete** |
| `ResolveBattleAction.scala` | ✅ Fully protoless | Complete (PR #5048) |
| `InMemoryHistory.scala` | Stores proto results | Pending - vend Scala types |
| `PersistedHistory.scala` | Stores proto results | Pending - vend Scala types, convert for disk |
| `GameController.scala` | Uses proto for client communication | Keep proto (gRPC boundary) |
### Remaining Proto Usage in Actions
**Progress: 52 of 52 action files (100%) are fully protoless.**
All action files have been migrated to use Scala types:
| Action | Status | Notes |
|--------|--------|-------|
| `ResolveBattleAction` | ✅ Complete | PR #5048 - uses Scala GameState and ActionResultApplier |
| `PerformVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `EndHandleRiotsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `PerformVassalDefenseDecisionsAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `EndVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
**Note:** `NewRoundAction` is now fully protoless after converting `ChronicleEventGenerator` to return Scala `ChronicleEvent` types directly.
**Deleted Dead Code:**
- `UnaffiliatedHeroMovedAction` - Was never called; `PerformUnaffiliatedHeroesAction.heroMovedResult` constructs `ActionResultC` directly
- `HeroBackstoryUpdateActionGenerator.fromGameState` - Dead method that converted proto to Scala; only `apply(GameState)` is used
**Note**: `PerformReconResolutionAction` and `EndBattleAftermathPhaseAction` are now fully protoless after:
1. Migrating `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` to use Scala `ProvinceView`
2. Adding Scala overload of `ProvinceViewFilter.withdrawnFromProvinceView`
### Estimated Effort (Remaining)
| Component | Lines | Complexity | Blocks |
|-----------|-------|------------|--------|
| History API updates | ~100 | Low | - |
| **Total Remaining** | **~100** | | |
**Completed:**
-`ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
-`CommandChoiceHelpers` migrated to Scala types
-`ResolveBattleAction` refactored to use Scala GameState and ActionResultApplier (PR #5048)
### Enum Type Migrations
Proto enums are being converted to Scala sealed traits with converters at boundaries:
| Enum | Scala Type | Status | Notes |
|------|------------|--------|-------|
| `DiplomacyOfferStatus` | `Status` sealed trait | ✅ **Complete** | PR #5093 - `EligibleDiplomacyStatuses` uses Scala types internally |
| `RoundPhase` | `RoundPhase` sealed trait | Partial | Some usages converted |
| `BattalionType` | `BattalionType` sealed trait | Partial | Some usages converted |
**DiplomacyOfferStatus Migration (PR #5093):**
- `EligibleDiplomacyStatuses.scala` now returns `Vector[Status]` instead of `Vector[DiplomacyOfferStatus]`
- Call sites in `AvailableResolve*CommandFactory` files convert to proto via `StatusConverter.toProto` at the boundary
- This pattern should be applied to other proto enums
### CommandChoiceHelpers Migration Status
Several command selectors have already been converted to use Scala types:
| File | Status | Notes |
|------|--------|-------|
| `AttackCommandChooser.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `AlmsCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `FoodConsumptionUtils.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `RoundPhase` |
| `MarchSuppliesHelpers.scala` | ✅ **Protoless** | Uses `BattalionT` |
| `CombatUnitSelector.scala` | ✅ **Protoless** | Uses `HeroT`, `BattalionT`, `BattalionType` |
| `ExpandCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `FactionT` |
| `ImproveCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `HeroT` |
| `OrganizeCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `BattalionT`, `BattalionType` |
| `RansomOfferHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState`, `FactionT` |
| `CommandChoiceHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState` throughout |
| `ProvinceGoldSurplusCalculator.scala` | ✅ **Protoless** | Uses Scala types |
**All CommandChoiceHelpers selectors have been migrated to Scala types.**
### Progress Summary
| Metric | Value |
|--------|-------|
| Action files fully protoless | 52 / 52 (100%) ✅ |
| Proto usages in remaining actions | 0 |
| Next target | See "Next Candidates" section below |
### Next Candidates
Priority candidates for further deproto work:
1. **More Enum Migrations** - Apply the `DiplomacyOfferStatus` pattern to other proto enums:
- Files importing `net.eagle0.eagle.common.round_phase.RoundPhase` (proto) could use Scala `RoundPhase`
- Files importing `net.eagle0.eagle.common.battalion_type.BattalionType` (proto) could use Scala `BattalionType`
2. **AvailableCommandsFactory Files** - Many still use proto `GameState` internally:
- These files build proto `AvailableCommand` messages but could use Scala types for internal logic
- Convert to accept Scala `GameState`, only convert fields to proto when building the response
3. **History APIs** - `InMemoryHistory` and `PersistedHistory`:
- Change to vend Scala `GameState` and `ActionResultT` instead of proto versions
- `PersistedHistory` converts to proto internally for disk persistence
### Validation
- [x] `ActionResultApplier` created and tested
- [x] `RandomStateSequencer` threads Scala GameState throughout
- [x] `RoundPhaseAdvancer` uses T-types internally
- [x] `ProvinceViewFilter` has Scala overload for server-side use (PR #4752)
- [x] `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` use Scala `ProvinceView`
- [x] `ProvinceViewFilter.withdrawnFromProvinceView` has Scala overload
- [x] `CommandChoiceHelpers` uses Scala types ✅
- [x] All action files (52/52) are fully protoless ✅
- [ ] `ProvinceViewFilter` faction-filtered views use Scala types
- [ ] History APIs vend Scala types
- [ ] No `ActionResultProtoConverter.toProto()` calls except at persistence/gRPC boundaries
- [ ] All tests pass
---
## Architecture Summary
## Phase 7: Clean Up Legacy Utilities
### Fully Protoless Areas ✅
### Objective
Remove remaining direct proto imports from utility classes.
- **AI layer** (`/ai/`) - All AI clients and command choosers
- **Actions** (`/library/actions/impl/action/`) - All 52 action files
- **Commands** (`/library/actions/impl/command/`) - CommandFactory and all commands
- **Availability** (`/library/actions/availability/`) - AvailableCommandsFactory and all factories
- **Command helpers** (`/library/util/command_choice_helpers/`) - All selectors and choosers
- **Core types** - `CommandSelection`, `SelectedCommand`, `AvailableCommand`, `OneProvinceAvailableCommands`
- **View types** - `FactionView`, `HeroView`, `ProvinceView` (return Scala, convert at edge)
### Files to Modify
### Proto at Boundaries (Expected) ✅
| File | Status |
|------|--------|
| `CommandChoiceHelpers.scala` | Accepts proto `GameState`; blocks full deproto of `PerformVassalCommandsPhaseAction` and `PerformVassalDefenseDecisionsAction` |
| `LegacyProvinceUtils.scala` | Replace with `ProvinceUtils.scala` - `hasImminentRiot` added (PR #4683) |
| `LegacyFactionUtils.scala` | Replace proto imports with `FactionT` |
| `LegacyUnaffiliatedHeroUtils.scala` | Replace proto imports with Scala models |
| `BattalionTypeLoader.scala` | Keep proto for file loading, convert immediately after |
| `BeastUtils.scala` | **Complete** - now uses Scala `BeastInfo` only |
### View Filters (Partially Complete)
The view filter utilities now have Scala overloads for server-side use:
| File | Status | Notes |
|------|--------|-------|
| `ProvinceViewFilter.scala` | **Partial** | `filteredProvinceView(ProvinceT, ScalaGameState)` added (PR #4752) |
| `ArmyFilter.scala` | **Partial** | `filterArmy(ScalaArmy, Map[BattalionId, BattalionT], Option[FactionId])` added |
| `BattalionViewFilter.scala` | **Complete** | Uses Scala `BattalionT` throughout |
| `GameStateViewFilter.scala` | Pending | Uses proto types throughout |
| `GameStateViewDiffer.scala` | Pending | Works with view protos |
**Unblocked Actions** (PR #4752):
- `EndBattleAftermathPhaseAction` - can now use `filteredProvinceView(province, scalaGameState)`
- `PerformReconResolutionAction` - can now use Scala overload
- `GameStateFactionExtensions` - can now use `updatedReconnedProvinces` with Scala types
**Remaining Work**:
- Faction-filtered `filteredProvinceView(Province, GameState, FactionId)` still uses proto types
- `withdrawnFromProvinceView` still uses proto types
- These are needed for client-facing views with visibility restrictions
---
## Phase 8: Verify Boundaries
### Objective
Confirm protos are used correctly at boundaries — and ONLY there.
### Expected Proto Usage (Keep)
- `EagleServiceImpl.scala` - gRPC boundary
- `GameController.scala` - Client communication
- `GameStateViewFilter.scala` - Converts Scala views to proto for client
- `InMemoryHistory.scala` / `PersistedHistory.scala` - Persistence boundary
- `*Converter.scala` - Explicit conversion utilities
- `PersistedHistory.scala` - Disk persistence
- `*Loader.scala` - File loading utilities
### Remaining Proto Usage
- LLM prompt generators (56 imports in 34 files) - Medium priority
- MapGenerator (1 import) - Low priority
- BattleFilter (1 import) - Keep (boundary)
- History API internals - Low priority
### Expected No Proto Usage (Verify)
- `/library/actions/impl/` - Pure Scala models
- `/library/util/` - Pure Scala models (except loaders)
- `/model/state/` - Pure Scala models
---
## Estimated Remaining Effort
## Open Questions
| Component | Files | Imports | Priority |
|-----------|-------|---------|----------|
| View Filters | 0 | 0 | ✅ Complete |
| LLM Prompt Generators | 34 | 56 | Medium |
| Utility files | 1 | 1 | Low |
| Root Library (boundary) | 3 | 8 | Keep |
| **Total** | **37** | **~65** | |
1. **Persistence Format**: Currently game state is persisted as proto. Should we keep proto for persistence (good for schema evolution) or switch to a different format?
2. **Shardok Integration**: `ResolveBattleAction` communicates with Shardok. Should the Shardok interface use protos (external service) or Scala models?
3. **View Generation**: `GameStateViewDiffer` works with view protos for client updates. Views need Scala models (`ProvinceViewT`, etc.) to allow actions like `EndBattleAftermathPhaseAction` to be fully protoless. The Scala views would be converted to proto only at the gRPC boundary when sending updates to clients.
---
## Success Criteria
### Code Quality
- [x] Zero proto imports in `/ai/`
- [x] Zero proto imports in `/library/actions/impl/command/`
- [x] Zero proto imports in `/library/actions/availability/`
- [x] Zero proto imports in `/library/util/command_choice_helpers/`
- [x] Zero proto imports in `/library/actions/impl/action/`
- [x] Zero proto imports in `/library/util/view_filters/` (except BattleFilter boundary code)
- [ ] Zero proto imports in `/library/actions/llm_prompt_generators/`
- [ ] Zero proto imports in `/library/util/` (except view filters)
- [ ] Zero proto imports in `/library/actions/` (except boundaries)
- [ ] Zero proto imports in `/library/` utilities (except loaders)
- [ ] `GameStateT` used throughout engine internals
- [ ] Proto usage limited to: `EagleServiceImpl`, loaders, converters, persistence
### Architecture
- [x] Clear separation: Scala models (internal) vs Proto (boundaries)
- [x] Converters as the only bridge between domains
- [x] CommandFactory accepts/returns Scala types
- [x] AI layer fully protoless
- [x] FactionViewFilter returns Scala types
- [x] HeroViewFilter returns Scala types
- [x] ProvinceViewFilter returns Scala types
- [x] All view filters return Scala types
- [ ] LLM layer uses Scala types
- [ ] Clear separation: Scala models (internal) vs Proto (boundaries)
- [ ] Converters as the only bridge between domains
- [ ] No "proto creep" into business logic
---
## Open Questions
1. **LLM Prompt Generators**: Should these accept Scala types directly, or is it acceptable to have proto usage here since they're generating text (not core game logic)?
2. ~~**GameStateViewDiffer**: This works with view protos for client updates. Should it remain proto-based since it's generating client-facing data?~~ **Resolved**: GameStateViewDiffer now uses Scala types internally (`GameStateViewDiff`, `ProvinceViewDiff`, etc.) and converts to proto at the boundary in `ActionResultFilter`.
3. **History Serialization**: Keep proto for persistence (good for schema evolution) or consider alternatives?
---
## Proto Import Inventory (Detailed)
**Current: ~65 proto imports across 37 files** (as of 2026-01-14)
### Summary by Directory
| Directory | Files | Imports | Status |
|-----------|-------|---------|--------|
| `ai/` | 0 | 0 | ✅ Clean |
| `actions/availability/` | 0 | 0 | ✅ Clean |
| `actions/impl/action/` | 0 | 0 | ✅ Clean |
| `actions/impl/command/` | 0 | 0 | ✅ Clean |
| `actions/llm_prompt_generators/` | 34 | 56 | LLM request types |
| `util/command_choice_helpers/` | 0 | 0 | ✅ Clean |
| `util/view_filters/` | 1 | 1 | ✅ Clean (except boundary) |
| `util/` (other) | 1 | 1 | ✅ Clean |
| Root (`library/`) | 3 | 9 | Boundary code |
### util/view_filters/ (0 imports, except boundary)
| File | Import | Notes |
|------|--------|-------|
| `GameStateViewFilter.scala` | ✅ **0** | Returns Scala `GameStateView`, converts at edge |
| `GameStateViewDiffer.scala` | ✅ **0** | Uses Scala diff types, converts at edge |
| `BattleFilter.scala` | 1 | Battle view boundary (keep proto) |
### util/ other files (1 import, 1 file)
| File | Imports | Notes |
|------|---------|-------|
| `MapGenerator.scala` | 1 | Map generation |
### Root library/ (8 imports, 3 files)
| File | Imports | Notes |
|------|---------|-------|
| `ActionResultFilter.scala` | 2 | Action result filtering (boundary, uses Scala types internally) |
| `Engine.scala` | 1 | Trait interface |
| `EngineImpl.scala` | 5 | Returns proto for persistence (boundary) |
### actions/llm_prompt_generators/ (56 imports, 34 files)
These files generate LLM prompts and primarily use `internal.generated_text_request.*` types plus some common enums like `DiplomacyOfferStatus` and `BattalionTypeId`.
**Migration strategy**: Can be migrated to Scala types when convenient, but low priority as they're isolated from core game logic.
---
## Migration Pattern
The codebase follows a **Legacy* pattern** for separating proto-dependent and protoless code:
- **Protoless utilities**: `FactionUtils`, `HeroUtils`, `ProvinceUtils`, `ProvinceDistances`, etc.
- **Proto-dependent utilities**: `LegacyFactionUtils`, `LegacyHeroUtils`, `LegacyProvinceUtils`, etc.
When migrating a file:
1. Create a `Legacy*` version containing the proto-dependent methods
2. Keep the original file name for protoless methods
3. Update callers to use the appropriate version based on their context
4. Delete `Legacy*` files when no longer needed
### Deleted Legacy Files (no production callers)
- `LegacyProvinceDistances`
- `LegacyBattalionSuitability`
- `LegacyFoodConsumptionUtils`
- `LegacyHandleRiotUtils`
-181
View File
@@ -1,181 +0,0 @@
# Tutorial Content Guide
This document defines all tutorial content. Edit this to refine the text, then update `TutorialContentDefinitions.cs` to match.
---
## Onboarding Sequence
Shown to first-time players. Guides them through the basics of strategic and tactical gameplay.
| Step | ID | Display | Trigger | Title | Description |
|------|-----|---------|---------|-------|-------------|
| 1 | `welcome` | Modal | Auto (game start) | Welcome to Eagle0 | Command your armies, recruit heroes, and conquer provinces in this turn-based strategy game.<br><br>Let's walk through the basics! |
| 2 | `select_province` | Overlay | Completes on: `province_selected` | The Strategic Map | This is your kingdom. Each colored region is a province.<br><br>Tap a province you control (shown in your color) to see what you can do there. |
| 3 | `province_panel` | Modal | Button click | Province Information | This panel shows province details: its name, terrain, any armies present, and the commands available to you.<br><br>Commands let you move troops, recruit heroes, and more. |
| 4 | `try_march` | Overlay | Completes on: `command_issued` | Issue a Command | Try issuing a March command to move your army to an adjacent province.<br><br>Select a destination and confirm the order. |
| 5 | `turn_cycle` | Modal | Button click | The Turn Cycle | Eagle0 uses simultaneous turns. All players give orders at the same time, then turns resolve together.<br><br>When all players are ready, the server processes everyone's commands and shows the results. |
| 6 | `wait_for_battle` | Hidden | Completes on: `first_battle_available` | *(none)* | *(Invisible step - waits for a battle to become available)* |
| 7 | `battle_intro` | Modal | Button click | Battle Time! | When armies collide, you'll fight tactical battles on a hex grid.<br><br>You command individual units - infantry, cavalry, archers, and heroes with special abilities. |
| 8 | `enter_battle` | Overlay | Completes on: `battle_entered` | Enter the Battle | Tap the Battle button to enter tactical combat. |
| 9 | `tactical_overview` | Modal | Button click | Tactical Combat | Each unit has movement points and attack power. Position your troops wisely!<br><br>Units attack adjacent enemies. Flanking (attacking from multiple sides) deals bonus damage. |
| 10 | `move_unit` | Overlay | Completes on: `battle_action` | Move Your Units | Tap one of your units to select it, then tap a highlighted hex to move there.<br><br>Blue hexes show where you can move. |
| 11 | `attack_enemy` | Overlay | Completes on: `battle_action` | Attack! | Move next to an enemy unit, then tap the enemy to attack.<br><br>Red highlights show valid attack targets. |
| 12 | `end_turn` | Overlay | Completes on: `turn_ended` | End Your Turn | When you've moved all units or want to pass, tap End Turn.<br><br>The enemy will then take their turn. |
| 13 | `complete` | Modal | Button click (no skip) | You're Ready! | You now know the basics of Eagle0!<br><br>Explore diplomacy, recruit powerful heroes, and conquer the realm. Good luck, commander! |
### Notes on Onboarding Flow
- Steps 1-5 cover strategic gameplay
- Step 6 is invisible - just waits for a battle
- Steps 7-12 cover tactical combat
- Step 13 celebrates completion
**Questions to consider:**
- Should we skip tactical tutorial if player skips to first battle themselves?
- Should there be a "skip all" option visible from step 1?
- Is the step order correct for typical first-game flow?
---
## Strategic Contextual Tutorials
Triggered when players encounter features for the first time.
### Diplomacy Introduction
| Field | Value |
|-------|-------|
| ID | `diplomacy_intro` |
| Trigger | `diplomacy_available` (diplomacy commands appear) |
| Display | Modal |
| Title | Diplomacy |
| Description | You can negotiate with other factions!<br><br>Offer alliances, declare war, or propose tribute. Your diplomatic choices shape the realm. |
### Hero Recruitment
| Field | Value |
|-------|-------|
| ID | `hero_recruitment` |
| Trigger | `hero_recruitment_available` (free heroes detected) |
| Display | Modal |
| Title | Heroes Available |
| Description | Free heroes wander the realm seeking a lord to serve.<br><br>Recruit them to lead your armies! Heroes have unique abilities and grow stronger with experience. |
### Weather Control
| Field | Value |
|-------|-------|
| ID | `weather_control` |
| Trigger | `weather_control_available` (weather command appears) |
| Display | Overlay |
| Title | Weather Magic |
| Description | Your mages can influence the weather!<br><br>Rain slows movement, storms disrupt enemies, and clear skies speed your march. |
### Prisoner Management
| Field | Value |
|-------|-------|
| ID | `prisoner_management` |
| Trigger | `prisoner_command_issued` (player uses prisoner command) |
| Display | Modal |
| Title | Prisoners Captured |
| Description | You've captured enemy soldiers!<br><br>You can ransom them for gold, recruit them into your army, or execute them as a warning. |
---
## Tactical Contextual Tutorials
Triggered during battles when players encounter spells, terrain, or abilities.
### Lightning Bolt Spell
| Field | Value |
|-------|-------|
| ID | `spell_lightning` |
| Trigger | `spell_lightning_available` |
| Display | Tooltip |
| Title | Lightning Bolt |
| Description | Your mage can cast Lightning Bolt!<br><br>This spell strikes a single target for heavy damage. Great for eliminating key enemy units. |
### Meteor Strike Spell
| Field | Value |
|-------|-------|
| ID | `spell_meteor` |
| Trigger | `spell_meteor_available` |
| Display | Modal |
| Title | Meteor Strike |
| Description | Meteor is a devastating area spell!<br><br>It takes a turn to cast: first select target, then it lands next turn. Plan ahead! |
### Holy Wave Spell
| Field | Value |
|-------|-------|
| ID | `spell_holywave` |
| Trigger | `spell_holywave_available` |
| Display | Tooltip |
| Title | Holy Wave |
| Description | Holy Wave heals your units and damages undead!<br><br>Position your troops carefully to maximize its effect. |
### Raise Dead Spell
| Field | Value |
|-------|-------|
| ID | `spell_raisedead` |
| Trigger | `spell_raisedead_available` |
| Display | Modal |
| Title | Raise Dead |
| Description | Dark magic can raise fallen soldiers as undead!<br><br>They fight for you, but beware - they may crumble if your necromancer falls. |
### Fire Terrain
| Field | Value |
|-------|-------|
| ID | `terrain_fire` |
| Trigger | `terrain_fire_encountered` (fire damage occurs) |
| Display | Tooltip |
| Title | Fire Hazard |
| Description | Fire spreads across the battlefield!<br><br>Units in burning hexes take damage. Use fire to block enemy routes or avoid it yourself. |
### Water Crossing
| Field | Value |
|-------|-------|
| ID | `terrain_water` |
| Trigger | `terrain_water_encountered` (water crossing attempted) |
| Display | Tooltip |
| Title | Water Crossing |
| Description | Units can cross shallow water, but it's risky.<br><br>Crossing takes extra movement and may fail. Some units swim better than others. |
### Cavalry Charge
| Field | Value |
|-------|-------|
| ID | `ability_charge` |
| Trigger | `ability_charge_available` |
| Display | Overlay |
| Title | Cavalry Charge |
| Description | Your cavalry can Charge!<br><br>Charging deals bonus damage based on distance traveled. Use open terrain for maximum impact. |
---
## Display Modes
| Mode | Description | Use For |
|------|-------------|---------|
| **Modal** | Full popup with dimmed background, blocks interaction | Important concepts, multi-paragraph explanations |
| **Overlay** | Semi-transparent overlay, can highlight UI elements | Guiding player to interact with specific UI |
| **Tooltip** | Small popup near target element | Quick tips, less important info |
| **Hint** | Pulsing dot indicator only | Subtle suggestions |
| **None** | Invisible, just waits for event | Transition steps |
---
## Adding New Tutorials
1. Add entry to this document
2. Update `TutorialContentDefinitions.cs`:
- For onboarding: add to `CreateOnboardingSequence()`
- For contextual: add to `RegisterStrategicTutorials()` or `RegisterTacticalTutorials()`
3. Ensure trigger event exists in `TutorialTriggerRegistry.cs`
4. Test the flow
---
## Content Guidelines
- Keep descriptions to 2-3 short paragraphs max
- Use `<br><br>` for paragraph breaks (renders as newlines in Unity)
- Avoid jargon - explain game terms when first introduced
- Be encouraging, not condescending
- Focus on "what to do" not exhaustive "how it works"
-8
View File
@@ -1,8 +0,0 @@
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"],
)
+9 -19
View File
@@ -3,9 +3,6 @@ events {
}
http {
# Allow large request bodies for game uploads (default is 1MB)
client_max_body_size 50M;
# Logging
log_format grpc_json escape=json '{'
'"time":"$time_iso8601",'
@@ -27,10 +24,11 @@ http {
# This prevents stale IP caching when containers restart
resolver 127.0.0.11 valid=10s ipv6=off;
# Eagle backend - blue-green deployment with variable-based routing
# Uses a variable so nginx only resolves the configured backend (not all backends).
# This allows nginx to start/reload even when the inactive backend is stopped.
# The deploy script updates this map, then recreates nginx.
# Eagle backend address - configured via variable for blue-green deployments
# Using a variable makes nginx resolve the hostname at request time, not config load time
# This allows nginx to reload even when the backend is temporarily down
# 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'
map $host $eagle_backend {
default "eagle-blue:40032";
}
@@ -76,7 +74,7 @@ http {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# gRPC proxy - uses variable for blue-green deployment
# gRPC proxy - uses variable for blue-green deployment support
grpc_pass grpc://$eagle_backend;
# Timeouts for long-running streams
@@ -88,14 +86,13 @@ http {
error_page 502 = /error502grpc;
}
# gRPC proxy for Auth service (routes to Go auth service, not Eagle)
# gRPC proxy for Auth service
location /net.eagle0.eagle.api.auth.Auth {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# Route to auth service directly (not through Eagle)
set $auth_backend "auth:40033";
grpc_pass grpc://$auth_backend;
# gRPC proxy - uses variable for blue-green deployment support
grpc_pass grpc://$eagle_backend;
# Timeouts
grpc_read_timeout 30s;
@@ -112,13 +109,6 @@ http {
proxy_set_header X-Real-IP $remote_addr;
}
# Apple OAuth callback (Apple uses POST with form_post response mode)
location /oauth/apple/callback {
proxy_pass http://auth:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Invitation landing page (proxied to Go auth service)
location /invite/ {
proxy_pass http://auth:8080;
-3
View File
@@ -8,6 +8,3 @@ 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
-30
View File
@@ -1,30 +0,0 @@
#!/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/"
# Convert Info.plist from binary to XML format (Unity requires XML)
/usr/bin/plutil -convert xml1 "$OUTPUT_DIR/SparklePlugin.bundle/Contents/Info.plist"
echo "=== SparklePlugin built successfully ==="
ls -la "$OUTPUT_DIR/SparklePlugin.bundle/"
+56 -192
View File
@@ -2,21 +2,14 @@
#
# Blue-Green Deployment Script for Eagle Server
#
# This script performs a zero-downtime deployment with state consistency:
# 1. Create .deployment_in_progress marker (signals deployment started)
# 2. Start the staging instance (green) with new image
# 3. Run warmup/smoke tests against staging (warms JIT)
# 4. Switch nginx to staging (zero downtime - users immediately route to staging)
# 5. Stop the active instance (blue) - blocks until flush completes
# 6. Create .flush_complete marker (signals disk state is fresh)
#
# The flush marker coordination ensures green never serves stale game data:
# - When users reconnect to green and trigger lazy-load, the code checks for markers
# - If .deployment_in_progress exists, lazy-load WAITS for .flush_complete
# - Once blue's flush completes and marker is created, lazy-load proceeds with fresh data
#
# Key insight: nginx switches to green BEFORE blue stops, achieving zero downtime.
# Users who trigger lazy-load during blue's shutdown will wait for the flush marker.
# This script performs a zero-downtime deployment by:
# 1. Starting the new version on a staging port (green)
# 2. Waiting for it to become healthy
# 3. Running warmup traffic to pre-heat the JIT
# 4. Stopping the old version (blue) - which flushes state to disk
# 5. Telling green to reload games from disk
# 6. Switching nginx to route traffic to green
# 7. Cleaning up
#
# Usage: ./deploy-blue-green.sh [NEW_IMAGE_TAG]
#
@@ -32,9 +25,6 @@ APP_DIR="${APP_DIR:-/opt/eagle0}"
NGINX_CONF="${APP_DIR}/nginx/nginx.conf"
COMPOSE_FILE="${APP_DIR}/docker-compose.prod.yml"
WARMUP_SCRIPT="${SCRIPT_DIR}/warmup-eagle.sh"
SAVES_DIR="${APP_DIR}/saves"
FLUSH_MARKER="${SAVES_DIR}/.flush_complete"
DEPLOYMENT_IN_PROGRESS="${SAVES_DIR}/.deployment_in_progress"
# Colors for output
RED='\033[0;31m'
@@ -46,57 +36,15 @@ log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Marker file operations use docker exec because saves directory is owned by root (Docker).
# We run commands inside a container that has the saves directory mounted.
create_deployment_marker() {
local deploy_id=$1
local container=$2 # Container to use for file operations
docker exec "${container}" rm -f /app/saves/.flush_complete
docker exec "${container}" sh -c "echo '${deploy_id}' > /app/saves/.deployment_in_progress"
}
create_flush_marker() {
local deploy_id=$1
local container=$2 # Container to use for file operations
docker exec "${container}" sh -c "echo '${deploy_id}' > /app/saves/.flush_complete"
docker exec "${container}" rm -f /app/saves/.deployment_in_progress
}
cleanup_markers_on_failure() {
local container=$1 # Container to use for file operations
docker exec "${container}" rm -f /app/saves/.deployment_in_progress 2>/dev/null || true
docker exec "${container}" touch /app/saves/.flush_complete 2>/dev/null || true
}
remove_stale_deployment_marker() {
# Try any running eagle container
local container
container=$(docker ps --filter "name=eagle-" --format "{{.Names}}" | head -1)
if [ -n "${container}" ]; then
docker exec "${container}" rm -f /app/saves/.deployment_in_progress 2>/dev/null || true
fi
}
# Determine which instance is currently running (not from nginx config)
get_running_instance() {
local blue_running green_running
blue_running=$(docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null || echo "false")
green_running=$(docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null || echo "false")
if [ "$blue_running" = "true" ] && [ "$green_running" = "true" ]; then
# Both running - use nginx config to determine primary
if grep -q "server eagle-blue:40032;" "${NGINX_CONF}" | head -1 | grep -qv backup; then
echo "blue"
else
echo "green"
fi
elif [ "$blue_running" = "true" ]; then
# Determine which instance is currently active
get_active_instance() {
if grep -q "eagle-blue:40032" "${NGINX_CONF}"; then
echo "blue"
elif [ "$green_running" = "true" ]; then
elif grep -q "eagle-green:40032" "${NGINX_CONF}"; then
echo "green"
else
# Neither running - default to blue (first deploy or recovery)
echo "none"
log_error "Cannot determine active instance from nginx config"
exit 1
fi
}
@@ -106,12 +54,6 @@ 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
@@ -172,63 +114,46 @@ main() {
local registry="registry.digitalocean.com/eagle0/eagle-server"
local new_image="${registry}:${new_tag}"
# Generate deployment ID for log correlation with server logs
local deploy_id
deploy_id=$(date +%s)
local deploy_start_time=$deploy_id
log_info "========================================="
log_info "Starting blue-green deployment"
log_info "Deployment ID: ${deploy_id}"
log_info "New image: ${new_image}"
log_info "========================================="
cd "${APP_DIR}"
# Determine current active instance (need this before creating marker)
local active=$(get_running_instance)
# Determine current active instance
local active=$(get_active_instance)
local staging
if [ "$active" = "blue" ] || [ "$active" = "none" ]; then
if [ "$active" = "blue" ]; then
staging="green"
active="blue" # Normalize "none" to "blue" for first deploy
else
staging="blue"
fi
# Step 1: Signal deployment in progress
log_info "[DEPLOY:${deploy_id}] Step 1: Signaling deployment in progress..."
# Use active container for marker operations (it's the one currently running)
if [ "$active" != "none" ] && docker ps --filter "name=eagle-${active}" --format "{{.Names}}" | grep -q .; then
create_deployment_marker "${deploy_id}" "eagle-${active}"
log_info "[DEPLOY:${deploy_id}] Deployment marker created via eagle-${active}"
else
log_warn "[DEPLOY:${deploy_id}] No running container to create marker (first deploy?)"
fi
log_info "Active instance: eagle-${active}"
log_info "Staging instance: eagle-${staging}"
# Pull the new image (with retry for intermittent registry issues)
if ! pull_with_retry "${new_image}" 3; then
log_error "Failed to pull new image, aborting deployment"
cleanup_markers_on_failure "eagle-${active}"
exit 1
fi
# Step 2: Start staging instance with new image
log_info "Step 2: Starting eagle-${staging} with new image..."
# Start staging instance with new image
log_info "Starting eagle-${staging} with new image..."
if [ "$staging" = "green" ]; then
EAGLE_IMAGE_NEW="${new_image}" docker compose -f "${COMPOSE_FILE}" --profile blue-green up -d eagle-green jfr-sidecar-green
EAGLE_IMAGE_NEW="${new_image}" docker compose -f "${COMPOSE_FILE}" --profile blue-green up -d eagle-green
else
EAGLE_IMAGE="${new_image}" docker compose -f "${COMPOSE_FILE}" up -d eagle-blue jfr-sidecar
EAGLE_IMAGE="${new_image}" docker compose -f "${COMPOSE_FILE}" up -d eagle-blue
fi
# Wait for staging to be healthy
if ! wait_for_healthy "eagle-${staging}" 90; then
log_error "Staging instance failed health check, aborting deployment"
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
cleanup_markers_on_failure "eagle-${active}"
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
exit 1
fi
# Step 3: Run warmup/smoke test
# Run warmup/smoke test
local staging_port
if [ "$staging" = "green" ]; then
staging_port=40034
@@ -236,12 +161,12 @@ main() {
staging_port=40032
fi
log_info "Step 3: Running warmup against eagle-${staging}..."
log_info "Running warmup against eagle-${staging}..."
if [ -x "${WARMUP_SCRIPT}" ]; then
if ! "${WARMUP_SCRIPT}" "localhost:${staging_port}"; then
log_error "Warmup/smoke test failed, aborting deployment"
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
cleanup_markers_on_failure "eagle-${active}"
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
exit 1
fi
else
@@ -249,98 +174,49 @@ main() {
log_warn "JIT will be cold on first requests"
fi
# Step 4: Switch nginx to staging BEFORE stopping active
# This achieves zero downtime - users immediately route to staging.
# Any lazy-loads will wait for the flush marker (created in step 6).
local nginx_switch_start
nginx_switch_start=$(date +%s)
log_info "[DEPLOY:${deploy_id}] Step 4: Switching nginx to eagle-${staging}..."
# Stop the active instance (this flushes state to disk)
log_info "Stopping eagle-${active} (flushing state to disk)..."
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
# Update nginx config (variable-based routing)
# Tell staging to reload games from disk
log_info "Telling eagle-${staging} to reload games from disk..."
if command -v grpcurl &> /dev/null; then
grpcurl -plaintext -d '{}' "localhost:${staging_port}" net.eagle0.eagle.api.Eagle/ReloadGames || true
else
log_warn "grpcurl not installed, skipping game reload"
log_warn "New instance will use games loaded at startup"
fi
# Switch nginx upstream
log_info "Switching nginx upstream to eagle-${staging}..."
if [ "$staging" = "green" ]; then
sed -i.bak 's/eagle-blue:40032/eagle-green:40032/g' "${NGINX_CONF}"
else
sed -i.bak 's/eagle-green:40032/eagle-blue:40032/g' "${NGINX_CONF}"
fi
# Recreate nginx to pick up new config
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate nginx
# Verify nginx picked up the correct config
local nginx_backend
nginx_backend=$(docker exec nginx grep -o 'eagle-[a-z]*:40032' /etc/nginx/nginx.conf | head -1 || echo "unknown")
if [ "$nginx_backend" = "eagle-${staging}:40032" ]; then
log_info "[DEPLOY:${deploy_id}] Verified: nginx routing to eagle-${staging}"
else
log_error "[DEPLOY:${deploy_id}] nginx config mismatch! Expected eagle-${staging}:40032, got ${nginx_backend}"
exit 1
fi
log_info "[DEPLOY:${deploy_id}] Traffic switched to eagle-${staging} (lazy-loads will wait for flush marker)"
local nginx_switch_end
nginx_switch_end=$(date +%s)
# Step 5: Stop active instance (blocks until exit, ensuring flush completes)
# Users may be lazy-loading on staging during this time - they'll wait for the marker.
local flush_start
flush_start=$(date +%s)
log_info "[DEPLOY:${deploy_id}] Step 5: Stopping eagle-${active} (waiting for flush)..."
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
local flush_end
flush_end=$(date +%s)
local flush_duration=$((flush_end - flush_start))
log_info "[DEPLOY:${deploy_id}] eagle-${active} stopped, flush completed in ${flush_duration}s"
# Step 6: Create flush marker - signals that disk state is fresh
# Any waiting lazy-loads on staging will now proceed with fresh data.
# The Eagle server automatically detects the flush marker update and invalidates any stale cached games.
log_info "[DEPLOY:${deploy_id}] Step 6: Creating flush marker..."
create_flush_marker "${deploy_id}" "eagle-${staging}"
log_info "[DEPLOY:${deploy_id}] Flush marker created - server will auto-invalidate stale cache"
# Update .env for admin service
local env_file="${APP_DIR}/.env"
if [ "$staging" = "green" ]; then
log_info "Updating .env for green instance..."
sed -i.bak '/^EAGLE_ADDR=/d; /^JFR_SIDECAR_ADDR=/d' "${env_file}" 2>/dev/null || true
echo "EAGLE_ADDR=eagle-green:40032" >> "${env_file}"
echo "JFR_SIDECAR_ADDR=jfr-sidecar-green:8081" >> "${env_file}"
else
log_info "Updating .env for blue instance..."
sed -i.bak '/^EAGLE_ADDR=/d; /^JFR_SIDECAR_ADDR=/d' "${env_file}" 2>/dev/null || true
echo "EAGLE_ADDR=eagle-blue:40032" >> "${env_file}"
echo "JFR_SIDECAR_ADDR=jfr-sidecar:8081" >> "${env_file}"
fi
# Restart admin to pick up new .env
log_info "Restarting admin service..."
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate admin
# Reload nginx
log_info "Reloading nginx..."
docker compose -f "${COMPOSE_FILE}" exec nginx nginx -s reload
# Clean up old instance
log_info "Cleaning up old eagle-${active}..."
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}" 2>/dev/null || true
log_info "Removing old eagle-${active} container..."
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}"
# Stop the old jfr-sidecar (it can't attach to removed container anyway)
if [ "$active" = "green" ]; then
docker compose -f "${COMPOSE_FILE}" stop "jfr-sidecar-green" 2>/dev/null || true
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar-green" 2>/dev/null || true
# Update the staging instance's restart policy and image var
# For blue, we need to update EAGLE_IMAGE; for green, update EAGLE_IMAGE_NEW
if [ "$staging" = "blue" ]; then
log_info "Updating EAGLE_IMAGE to ${new_image} for future restarts"
# User should update their .env file
else
docker compose -f "${COMPOSE_FILE}" stop "jfr-sidecar" 2>/dev/null || true
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar" 2>/dev/null || true
log_info "Green is now active. Consider switching to blue on next deployment."
fi
local deploy_end_time
deploy_end_time=$(date +%s)
local total_duration=$((deploy_end_time - deploy_start_time))
local user_wait_window=$((flush_end - nginx_switch_end))
log_info "Deployment complete!"
log_info "Active instance: eagle-${staging}"
log_info ""
log_info "========================================="
log_info "[DEPLOY:${deploy_id}] Deployment complete!"
log_info " Active instance: eagle-${staging}"
log_info " Total duration: ${total_duration}s"
log_info " Flush duration: ${flush_duration}s"
log_info " Max user wait window: ${user_wait_window}s"
log_info "========================================="
log_info "Note: Update your .env file with EAGLE_IMAGE=${new_image}"
log_info " if you want future 'docker compose up' to use this version."
}
# Check for required tools
@@ -364,18 +240,6 @@ check_requirements() {
log_error "docker-compose file not found at ${COMPOSE_FILE}"
exit 1
fi
# Ensure saves directory exists
if [ ! -d "${SAVES_DIR}" ]; then
log_info "Creating saves directory at ${SAVES_DIR}"
mkdir -p "${SAVES_DIR}"
fi
# Clean up any stale deployment-in-progress marker from a previous failed deploy
if [ -f "${DEPLOYMENT_IN_PROGRESS}" ]; then
log_warn "Found stale deployment-in-progress marker, removing it"
remove_stale_deployment_marker
fi
}
# Run
+2 -2
View File
@@ -46,8 +46,8 @@ 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"
# Clean up the zip
rm "$ZIP_PATH"
if [ "$STATUS" != "Accepted" ]; then
echo "=== Notarization failed! Fetching log for details ==="
-56
View File
@@ -1,56 +0,0 @@
#!/usr/bin/env bash
#
# Submit a macOS .app bundle to Apple for notarization (no waiting)
# Usage: notarize_submit.sh <app_path>
# Outputs: submission_id=<id> to stdout (for GitHub Actions)
#
# Environment variables (required):
# APPLE_ID - Apple Developer account email
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
# TEAM_ID - Apple Developer Team ID
set -euo pipefail
APP_PATH="$1"
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH" >&2
exit 1
fi
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
echo "ERROR: Required environment variables not set" >&2
echo " APPLE_ID: ${APPLE_ID:-<not set>}" >&2
echo " APP_SPECIFIC_PASSWORD: ${APP_SPECIFIC_PASSWORD:+<set>}" >&2
echo " TEAM_ID: ${TEAM_ID:-<not set>}" >&2
exit 1
fi
# Create ZIP for notarization submission
ZIP_PATH="${APP_PATH%.app}.zip"
echo "=== Creating ZIP for notarization: $ZIP_PATH ===" >&2
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
echo "=== Submitting to Apple for notarization ===" >&2
SUBMIT_OUTPUT=$(xcrun notarytool submit "$ZIP_PATH" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID" 2>&1)
echo "$SUBMIT_OUTPUT" >&2
# Extract submission ID
SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | grep "id:" | head -1 | awk '{print $2}')
if [ -z "$SUBMISSION_ID" ]; then
echo "ERROR: Failed to get submission ID" >&2
exit 1
fi
# Clean up the zip
rm "$ZIP_PATH"
echo "Submission ID: $SUBMISSION_ID" >&2
# Output for GitHub Actions
echo "submission_id=$SUBMISSION_ID"
-76
View File
@@ -1,76 +0,0 @@
#!/usr/bin/env bash
#
# Wait for Apple notarization to complete and staple the ticket
# Usage: notarize_wait.sh <submission_id> <app_path>
#
# Environment variables (required):
# APPLE_ID - Apple Developer account email
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
# TEAM_ID - Apple Developer Team ID
set -euo pipefail
SUBMISSION_ID="$1"
APP_PATH="$2"
if [ -z "$SUBMISSION_ID" ]; then
echo "ERROR: submission_id is required" >&2
exit 1
fi
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH" >&2
exit 1
fi
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
echo "ERROR: Required environment variables not set" >&2
exit 1
fi
echo "=== Waiting for notarization of submission $SUBMISSION_ID ==="
WAIT_OUTPUT=$(xcrun notarytool wait "$SUBMISSION_ID" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID" 2>&1) || true
echo "$WAIT_OUTPUT"
# Extract status (look for " status:" to avoid matching "Current status:")
STATUS=$(echo "$WAIT_OUTPUT" | grep "^ status:" | awk '{print $2}')
echo "Status: $STATUS"
if [ "$STATUS" != "Accepted" ]; then
echo "=== Notarization failed! Fetching log for details ==="
xcrun notarytool log "$SUBMISSION_ID" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID"
exit 1
fi
echo "=== Stapling notarization ticket to app ==="
# Retry stapling - Apple's CloudKit can have a brief delay after notarization completes
MAX_STAPLE_ATTEMPTS=5
STAPLE_ATTEMPT=1
while [ $STAPLE_ATTEMPT -le $MAX_STAPLE_ATTEMPTS ]; do
echo "Stapling attempt $STAPLE_ATTEMPT/$MAX_STAPLE_ATTEMPTS..."
if xcrun stapler staple "$APP_PATH"; then
echo "Stapling successful"
break
fi
if [ $STAPLE_ATTEMPT -eq $MAX_STAPLE_ATTEMPTS ]; then
echo "ERROR: Stapling failed after $MAX_STAPLE_ATTEMPTS attempts"
exit 1
fi
echo "Stapling failed, waiting 10 seconds before retry..."
sleep 10
STAPLE_ATTEMPT=$((STAPLE_ATTEMPT + 1))
done
echo "=== Verifying notarization ==="
xcrun stapler validate "$APP_PATH"
spctl --assess --type exec -v "$APP_PATH"
echo "Notarization complete: $APP_PATH"
+1 -2
View File
@@ -65,8 +65,7 @@ fi
# If we found the Go tool, use it
if [ -n "${WARMUP_TOOL}" ]; then
log_info "Using Go warmup tool: ${WARMUP_TOOL}"
# Use 5 minute timeout to allow for slow operations on cold JVM
if "${WARMUP_TOOL}" --address="${HOST}" --timeout=300s; then
if "${WARMUP_TOOL}" --address="${HOST}" --timeout=60s; then
log_info "Warmup complete!"
exit 0
else
@@ -20,9 +20,8 @@ auto UnitIdsRequiringWaterCrossing(
fb::HexMapW mapCopy = fb::CopyHexMap(gameState->hex_map());
for (uint32_t index = 0; index < mapCopy->terrain()->size(); index++) {
if (IsWater(mapCopy->terrain()->Get(index)->type())) continue;
// const_cast is safe because we own the mutable buffer (mapCopy)
const_cast<net::eagle0::shardok::storage::fb::Terrain *>(
mapCopy->mutable_terrain()->GetMutableObject(index))
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_fire()
.mutate_present(false);
@@ -165,11 +164,16 @@ auto WaterCrossingTiles(
if (modifier.ice().present() && !modifier.fire().present()) continue;
// Now try adding a bridge to the tile to see if it helps
// const_cast is safe because we own the mutable buffer (mapCopy)
auto *terr = const_cast<net::eagle0::shardok::storage::fb::Terrain *>(
mapCopy->mutable_terrain()->GetMutableObject(index));
terr->mutable_modifier().mutable_bridge().mutate_present(true);
terr->mutable_modifier().mutable_fire().mutate_present(false);
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_bridge()
.mutate_present(true);
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_fire()
.mutate_present(false);
auto hash = ActionPointDistancesCache::GetMapId(mapCopy);
@@ -179,7 +183,11 @@ auto WaterCrossingTiles(
}
// Undo the new bridge for the next iteration of the loop
terr->mutable_modifier().mutable_bridge().mutate_present(false);
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_bridge()
.mutate_present(false);
}
return returnCoords;
@@ -76,13 +76,11 @@ static auto CreateIceClearedMap(const HexMap* map) -> fb::HexMapW {
// Now modify the ice on the mutable copy
auto* mutableMap = mapCopy.Get();
auto* terrainVec = mutableMap->mutable_terrain();
const auto* terrainVec = mutableMap->mutable_terrain();
for (size_t i = 0; i < terrainVec->size(); i++) {
// Only process tiles with ice
// const_cast is safe here because we own the mutable buffer (mapCopy)
if (auto* terrain = const_cast<Terrain*>(terrainVec->GetMutableObject(i));
terrain->modifier().ice().present()) {
if (auto* terrain = terrainVec->GetMutableObject(i); terrain->modifier().ice().present()) {
terrain->mutable_modifier().mutable_ice().mutate_present(false);
terrain->mutable_modifier().mutable_ice().mutate_integrity(0.0f);
}
@@ -22,13 +22,6 @@ using std::unique_ptr;
using ResolvedUnitProto = net::eagle0::shardok::storage::ResolvedUnit;
using GameState = net::eagle0::shardok::storage::fb::GameState;
using GameStateT = net::eagle0::shardok::storage::fb::GameStateT;
using Unit = net::eagle0::shardok::storage::fb::Unit;
// Helper to get a mutable unit from the units vector.
// const_cast is safe because we're accessing through a mutable GameState pointer.
inline auto GetMutableUnit(GameState *state, UnitId unitId) -> Unit * {
return const_cast<Unit *>(state->mutable_units()->GetMutableObject(unitId));
}
using net::eagle0::shardok::storage::fb::DrawType;
using net::eagle0::shardok::storage::fb::VictoryCondition;
using net::eagle0::shardok::storage::fb::VictoryType;
@@ -44,7 +37,7 @@ void ApplyResolvedUnit(
if (unit.has_attached_hero() &&
unit.attached_hero().control_info().controlled_unit_id() != -1) {
const UnitId controlledUnitId = unit.attached_hero().control_info().controlled_unit_id();
auto *controlledUnit = GetMutableUnit(inoutState, controlledUnitId);
auto *controlledUnit = inoutState->mutable_units()->GetMutableObject(controlledUnitId);
internalAssert(controlledUnit->unit_id() == controlledUnitId);
internalAssert(controlledUnit->commanding_unit_id() == unitId);
controlledUnit->mutate_commanding_unit_id(-1);
@@ -54,7 +47,7 @@ void ApplyResolvedUnit(
if (unit.commanding_unit_id() != -1) {
const UnitId commandingUnitId = unit.commanding_unit_id();
auto *commandingUnit = GetMutableUnit(inoutState, commandingUnitId);
auto *commandingUnit = inoutState->mutable_units()->GetMutableObject(commandingUnitId);
internalAssert(commandingUnit->unit_id() == commandingUnitId);
internalAssert(
commandingUnit->attached_hero().control_info().controlled_unit_id() == unitId);
@@ -65,7 +58,7 @@ void ApplyResolvedUnit(
if (status == net::eagle0::shardok::storage::fb::UnitStatus_CAPTURED_UNIT &&
unit.has_attached_hero() && unit.attached_hero().is_vip()) {
for (uint32_t i = 0; i < inoutState->units()->size(); i++) {
auto *playerUnit = GetMutableUnit(inoutState, i);
auto *playerUnit = inoutState->mutable_units()->GetMutableObject(i);
if (playerUnit->player_id() != unit.player_id()) continue;
if (playerUnit->unit_id() == unit.unit_id()) continue;
@@ -77,7 +70,7 @@ void ApplyResolvedUnit(
}
}
GetMutableUnit(inoutState, unitId)->mutate_status(status);
inoutState->mutable_units()->GetMutableObject(unitId)->mutate_status(status);
}
void ApplyResolvedUnit(
@@ -196,7 +189,7 @@ void MutatingAddUnits(GameStateW &mutatingState, const ActionResultProto &result
// We only need to process the units that are being changed
for (const auto &unitBytes : result.changed_units_fb()) {
const auto *unit = (Unit *)unitBytes.data();
auto *mutableUnit = GetMutableUnit(mutatingState.Get(), unit->unit_id());
auto *mutableUnit = mutatingState->mutable_units()->GetMutableObject(unit->unit_id());
if (mutableUnit->status() ==
net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT) {
// Convert this reserved slot to a real unit
@@ -347,7 +340,7 @@ void MutatingApplyResult(
}
// Capture old position before applying changes
auto *mutableUnit = GetMutableUnit(mutatingGameState.Get(), changedUnit->unit_id());
auto *mutableUnit = mutatingGameState->units()->GetMutableObject(changedUnit->unit_id());
const auto oldLocation = mutableUnit->location();
fb::ApplyUnit(mutableUnit, changedUnit, status);
@@ -191,7 +191,6 @@ auto MoveCommand::GetCommandProto() const -> CommandProto {
proto.mutable_action_points()->set_value(pointCost);
proto.mutable_actor()->set_value(moverId);
*proto.mutable_target() = ToCoordsProto(interimTargets.back());
for (const auto& coords : interimTargets) { *proto.add_path() = ToCoordsProto(coords); }
for (const auto& fup : followUpCommandTypes) { proto.add_follow_up_command_types(fup); }
proto.set_will_unhide(willUnhide);
@@ -64,16 +64,8 @@ auto GetTerrain(const HexMap *map, const Coords &coords) -> const Terrain * {
return map->terrain()->Get(coords.row() * map->column_count() + coords.column());
}
auto GetMutableTerrain(HexMap *map, const Coords &coords) -> Terrain * {
// const_cast is safe because we're accessing through a mutable HexMap pointer
return const_cast<Terrain *>(map->mutable_terrain()->GetMutableObject(
coords.row() * map->column_count() + coords.column()));
}
auto GetMutableTerrain(const HexMap *map, const Coords &coords) -> Terrain * {
// const_cast is safe when the underlying buffer is known to be mutable
return const_cast<Terrain *>(
map->terrain()->Get(coords.row() * map->column_count() + coords.column()));
return map->terrain()->GetMutableObject(coords.row() * map->column_count() + coords.column());
}
auto HasForestAccess(
@@ -605,9 +597,7 @@ void MutatingSetTileModifier(
const int row,
const int column,
const TileModifierProto &TileModifierProto) {
// const_cast is safe because we're accessing through a mutable HexMap pointer
auto *terr = const_cast<Terrain *>(
hexMap->mutable_terrain()->GetMutableObject(row * hexMap->column_count() + column));
auto *terr = hexMap->mutable_terrain()->GetMutableObject(row * hexMap->column_count() + column);
if (TileModifierProto.has_bridge()) {
terr->mutable_modifier().mutable_bridge().mutate_present(true);
@@ -82,9 +82,6 @@ auto HasForestAccess(
PlayerId player) -> bool;
auto GetTerrain(const HexMap *map, const Coords &coords) -> const Terrain *;
auto GetMutableTerrain(HexMap *map, const Coords &coords) -> Terrain *;
// Overload for const HexMap - uses const_cast internally. Safe when the underlying buffer is
// mutable.
auto GetMutableTerrain(const HexMap *map, const Coords &coords) -> Terrain *;
auto CoordsAreValid(const HexMap *map, const Coords &coords) -> bool;
@@ -29,13 +29,6 @@ using UnitViewProto = net::eagle0::shardok::api::UnitView;
using Coords = net::eagle0::shardok::storage::fb::Coords;
using Unit = net::eagle0::shardok::storage::fb::Unit;
using GameState = net::eagle0::shardok::storage::fb::GameState;
// Helper to get a mutable unit from the units vector.
// const_cast is safe because we're accessing through a mutable GameState pointer.
inline auto GetMutableUnit(GameState *state, UnitId unitId) -> Unit * {
return const_cast<Unit *>(state->mutable_units()->GetMutableObject(unitId));
}
constexpr int8_t kGuessedHeroStat = 75;
constexpr int8_t kGuessedBattalionStat = 0;
@@ -419,13 +412,16 @@ auto GameStateGuesser::GuessedState(
if (unit->has_attached_hero()) {
UnitId controlledUnitId = unit->attached_hero().control_info().controlled_unit_id();
if (controlledUnitId != -1) {
GetMutableUnit(gsw.Get(), controlledUnitId)->mutate_commanding_unit_id(unitId);
gsw->mutable_units()
->GetMutableObject(controlledUnitId)
->mutate_commanding_unit_id(unitId);
}
}
UnitId commandingUnitId = unit->commanding_unit_id();
if (unit->commanding_unit_id() != -1) {
GetMutableUnit(gsw.Get(), commandingUnitId)
gsw->mutable_units()
->GetMutableObject(commandingUnitId)
->mutable_attached_hero()
.mutable_control_info()
.mutate_controlled_unit_id(unitId);
@@ -167,7 +167,6 @@
<Compile Include="Assets/Eagle/Table Rows/ExtraTroopsRowController.cs" />
<Compile Include="Assets/Auth/JwtAuthInterceptor.cs" />
<Compile Include="Assets/Eagle/CommandPanelController.cs" />
<Compile Include="Assets/Tutorial/Content/TutorialContentDefinitions.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ShatteredArmyDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/TurnHistoryPanelController.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/HeroDepartureDetailsNotificationGenerator.cs" />
@@ -208,7 +207,6 @@
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/RansomRejectedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/FactionsTableController.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/RangeSlider.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialOverlayBuilder.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ExileVassalCommandSelector.cs" />
<Compile Include="Assets/Tutorial/TutorialState.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicWithIconEditor.cs" />
@@ -57,10 +57,20 @@ namespace Auth {
/// Returns both the URL and the state token for polling.
/// Routed to Go auth service.
/// </summary>
/// <param name="provider">OAuth provider (Discord, Google, GitHub, Apple)</param>
/// <returns>Tuple of (URL to open in browser, state token for polling)</returns>
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(OAuthProvider provider) {
/// <param name="provider">Discord or Google</param>
/// <param name="invitationCode">Optional invitation code for new account
/// registration</param> <returns>Tuple of (URL to open in browser, state token for
/// polling)</returns>
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(
OAuthProvider provider,
string invitationCode = null) {
var request = new GetOAuthUrlRequest { Provider = provider };
if (!string.IsNullOrEmpty(invitationCode)) {
request.InvitationCode = invitationCode;
Debug.Log("[AuthClient] Including invitation code in OAuth request");
}
var response = await _authServiceClient.GetOAuthUrlAsync(request);
Debug.Log($"[AuthClient] Got OAuth URL for {provider}, state={response.State}");
@@ -93,6 +103,10 @@ namespace Auth {
case OAuthStatus.Expired:
throw new Exception("OAuth session expired. Please try again.");
case OAuthStatus.InvitationRequired:
Debug.Log("[AuthClient] Server requires invitation code for new account");
return response; // Return so OAuthManager can handle this
case OAuthStatus.Pending:
// Check timeout
if ((DateTime.UtcNow - startTime).TotalMilliseconds > PollTimeoutMs) {
@@ -0,0 +1,141 @@
using System;
using System.IO;
using UnityEngine;
namespace Auth {
/// <summary>
/// Manages invitation codes for new account registration.
/// Reads codes from installer-provided file or allows manual entry.
/// </summary>
public static class InvitationCodeManager {
private const string InvitationFileName = "invitation.json";
private const string PlayerPrefsKey = "InvitationCode";
// Cache the code to avoid repeated file reads
private static string _cachedCode;
private static bool _cacheInitialized;
/// <summary>
/// Get the invitation code, if available.
/// Checks: 1) Cached value, 2) PlayerPrefs, 3) File from installer
/// </summary>
public static string GetInvitationCode() {
if (_cacheInitialized) { return _cachedCode; }
// Check PlayerPrefs first (manual entry takes priority)
string prefsCode = PlayerPrefs.GetString(PlayerPrefsKey, null);
if (!string.IsNullOrEmpty(prefsCode)) {
_cachedCode = prefsCode;
_cacheInitialized = true;
Debug.Log("[InvitationCodeManager] Using invitation code from PlayerPrefs");
return _cachedCode;
}
// Try to read from installer file
string fileCode = ReadFromFile();
if (!string.IsNullOrEmpty(fileCode)) {
_cachedCode = fileCode;
_cacheInitialized = true;
Debug.Log("[InvitationCodeManager] Using invitation code from installer file");
return _cachedCode;
}
_cacheInitialized = true;
return null;
}
/// <summary>
/// Set invitation code manually (e.g., from UI input).
/// </summary>
public static void SetInvitationCode(string code) {
if (string.IsNullOrEmpty(code)) {
PlayerPrefs.DeleteKey(PlayerPrefsKey);
_cachedCode = null;
} else {
PlayerPrefs.SetString(PlayerPrefsKey, code);
_cachedCode = code;
}
_cacheInitialized = true;
PlayerPrefs.Save();
Debug.Log(
$"[InvitationCodeManager] Invitation code {(string.IsNullOrEmpty(code) ? "cleared" : "set")}");
}
/// <summary>
/// Clear the invitation code after successful account creation.
/// </summary>
public static void ClearInvitationCode() {
PlayerPrefs.DeleteKey(PlayerPrefsKey);
_cachedCode = null;
_cacheInitialized = true;
PlayerPrefs.Save();
// Also delete the installer file if it exists
DeleteInstallerFile();
Debug.Log("[InvitationCodeManager] Invitation code cleared");
}
/// <summary>
/// Check if an invitation code is available.
/// </summary>
public static bool HasInvitationCode => !string.IsNullOrEmpty(GetInvitationCode());
private static string ReadFromFile() {
try {
string installDir = GetInstallDirectory();
if (string.IsNullOrEmpty(installDir)) { return null; }
string filePath = Path.Combine(installDir, InvitationFileName);
if (!File.Exists(filePath)) { return null; }
string json = File.ReadAllText(filePath);
// Simple JSON parsing for {"invitation_code":"XXXX"}
var parsed = JsonUtility.FromJson<InvitationFile>(json);
return parsed?.invitation_code;
} catch (Exception ex) {
Debug.LogWarning(
$"[InvitationCodeManager] Failed to read invitation file: {ex.Message}");
return null;
}
}
private static void DeleteInstallerFile() {
try {
string installDir = GetInstallDirectory();
if (string.IsNullOrEmpty(installDir)) { return; }
string filePath = Path.Combine(installDir, InvitationFileName);
if (File.Exists(filePath)) {
File.Delete(filePath);
Debug.Log("[InvitationCodeManager] Deleted installer invitation file");
}
} catch (Exception ex) {
Debug.LogWarning(
$"[InvitationCodeManager] Failed to delete invitation file: {ex.Message}");
}
}
private static string GetInstallDirectory() {
#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
// Windows: %LOCALAPPDATA%\eagle0
string localAppData =
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(localAppData, "eagle0");
#elif UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
// Mac: ~/Library/Application Support/eagle0
string appSupport =
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
return Path.Combine(appSupport, "eagle0");
#else
// Other platforms not yet supported
return null;
#endif
}
[Serializable]
private class InvitationFile {
public string invitation_code;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a2a19f11d1f82412e8e2811b366113ac
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using common;
using Net.Eagle0.Eagle.Api.Auth;
using UnityEngine;
@@ -26,6 +25,7 @@ namespace Auth {
public event Action<string> OnLoginFailed;
public event Action OnLogout;
public event Action<bool> OnNewUserNeedsDisplayName; // true if new user
public event Action OnInvitationRequired; // new user needs invitation code
public bool IsAuthenticated => TokenStorage.HasValidToken;
public string DisplayName => TokenStorage.DisplayName;
@@ -87,11 +87,11 @@ namespace Auth {
_currentLoginProvider = provider; // Track for later storage
try {
// Get OAuth URL and state from server
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider);
// Get invitation code if available (for new account registration)
string invitationCode = InvitationCodeManager.GetInvitationCode();
// Minimize game window so user can see the browser
WindowFocusManager.MinimizeForExternalBrowser();
// Get OAuth URL and state from server
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider, invitationCode);
// Open system browser
Debug.Log($"[OAuthManager] Opening browser: {authUrl}");
@@ -100,8 +100,12 @@ namespace Auth {
// Poll for OAuth completion (server handles the callback)
var response = await _authClient.PollForOAuthCompletionAsync(state);
// Bring game back to foreground now that auth is complete
WindowFocusManager.BringToForeground();
// Handle invitation required status
if (response.Status == OAuthStatus.InvitationRequired) {
Debug.Log("[OAuthManager] Server requires invitation code for new account");
OnInvitationRequired?.Invoke();
throw new Exception("An invitation code is required to create a new account");
}
// Store tokens with provider info
var providerName = provider.ToString().ToLowerInvariant();
@@ -113,7 +117,9 @@ namespace Auth {
response.User.DisplayName ?? "",
providerName);
// Clear invitation code after successful new account creation
if (response.IsNewUser) {
InvitationCodeManager.ClearInvitationCode();
OnNewUserNeedsDisplayName?.Invoke(true);
} else {
OnLoginSuccess?.Invoke(response.User);
@@ -121,9 +127,6 @@ namespace Auth {
return response;
} catch (Exception ex) {
// Bring game back to foreground even on failure
WindowFocusManager.BringToForeground();
Debug.LogError($"[OAuthManager] Login failed: {ex.Message}");
OnLoginFailed?.Invoke(ex.Message);
throw;
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 6ddb90d3140384d50a98a19e72539b62
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,221 @@
using System;
using System.Collections.Generic;
using System.IO;
using common;
using TMPro;
using UnityEngine;
using UnityGoDiceInterface;
public class DiceConfigurationPanelController : MonoBehaviour {
private DiceInterface _diceInterface;
public EventBasedTable diceRollGroup;
public PickerRowController pickerRowPrefab;
public TMP_Text instructionsLabel;
public delegate void ConfigurationCallback(DieInfo? tensDieInfo, DieInfo? onesDieInfo);
private ConfigurationCallback _configurationCallback;
private DieInfo? _tensDieInfo = null;
private DieInfo? _onesDieInfo = null;
private List<DieInfo> _knownDice = new List<DieInfo>();
private HashSet<string> _connectingIdentifiers = new HashSet<string>();
void Awake() {}
public void GetConfiguration(ConfigurationCallback cb) {
if (_diceInterface == null) { _diceInterface = GetComponentInParent<DiceInterface>(); }
_diceInterface.deviceFoundCallback = DeviceFoundCallback;
_diceInterface.connectionCallback = ConnectionCallback;
_diceInterface.connectionFailedCallback = ConnectionFailedCallback;
_diceInterface.disconnectionCallback = DisconnectionCallback;
_diceInterface.listenerStoppedCallback = ListenerStoppedCallback;
_diceInterface.colorCallback = ReceiveColorCallback;
string currentPath = NewLogLocation();
Directory.CreateDirectory(Path.GetDirectoryName(currentPath));
_diceInterface.logger = log => {
MainQueue.Q.Enqueue(() => {
Debug.Log(log);
File.AppendAllText(currentPath, log);
});
};
_diceInterface.StartListening();
_configurationCallback = cb;
_tensDieInfo = null;
_onesDieInfo = null;
instructionsLabel.text = "Looking for dice...";
gameObject.SetActive(true);
}
string NewLogLocation() {
return Path.Combine(
Application.persistentDataPath,
"eagle0",
"Resources",
"DiceConfigurationLogs",
DateTime.Now.ToString("yyyy-MM-dd-HH-mm-ss") + ".log");
}
public void RowClicked(int rowIndex) {
if (_tensDieInfo == null) {
_tensDieInfo = _knownDice[rowIndex];
_knownDice.RemoveAt(rowIndex);
SetUpTable();
} else {
_diceInterface.StopListening();
_onesDieInfo = _knownDice[rowIndex];
_knownDice.RemoveAt(rowIndex);
_knownDice.ForEach(dieInfo => _diceInterface.Disconnect(dieInfo.identifier));
// _diceInterface.deviceFoundCallback = null;
// _diceInterface.connectionCallback = null;
// _diceInterface.disconnectionCallback = null;
// _diceInterface.listenerStoppedCallback = null;
// _diceInterface.rollCallback = null;
// _diceInterface.colorCallback = null;
_configurationCallback(_tensDieInfo, _onesDieInfo);
gameObject.SetActive(false);
}
}
private void OnEnable() {}
private void OnDisable() { diceRollGroup.RowCount = 0; }
void ListenerStoppedCallback() { Debug.Log("Listener stopped!"); }
void SetUpTable() {
diceRollGroup.RowCount = _knownDice.Count;
if (_knownDice.Count == 0) {
instructionsLabel.text = "Looking for dice...";
} else if (_tensDieInfo == null) {
instructionsLabel.text = "Select a TENS die:";
} else {
instructionsLabel.text = "Select an ONES die:";
}
for (int i = 0; i < _knownDice.Count; i++) {
var row = diceRollGroup.ComponentAt<PickerRowController>(i);
row.Text = _knownDice[i].deviceName;
row.TextColor = UnityDieColors.ColorFromDieColor(_knownDice[i].color);
}
}
void ReceiveColorCallback(string identifier, DieColor dieColor) {
MainQueue.Q.Enqueue(() => {
var existingIndex =
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
if (existingIndex >= 0) {
var colorName = dieColor.ToString();
_knownDice[existingIndex] = new DieInfo(
identifier: _knownDice[existingIndex].identifier,
deviceName: colorName,
color: dieColor,
connected: true);
} else {
Debug.Log("This shouldn't happen");
}
SetUpTable();
});
}
void DeviceFoundCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
if (_connectingIdentifiers.Contains(identifier)) { return; }
_connectingIdentifiers.Add(identifier);
var existingIndex =
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
if (existingIndex >= 0) {
_knownDice[existingIndex] =
new DieInfo(identifier, "Unknown", DieColor.DieColorBlack);
} else {
_knownDice.Add(new DieInfo(identifier, "Unknown", DieColor.DieColorBlack));
}
SetUpTable();
_diceInterface.Connect(identifier);
});
}
void ConnectionCallback(string identifier, string deviceName) {
MainQueue.Q.Enqueue(() => {
_connectingIdentifiers.Remove(identifier);
if (_tensDieInfo != null && _tensDieInfo.Value.identifier.Equals(identifier)) {
return;
}
if (_onesDieInfo != null && _onesDieInfo.Value.identifier.Equals(identifier)) {
return;
}
var shortenedName = deviceName.Split('_')[1];
var existingIndex =
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
if (existingIndex >= 0) {
_knownDice[existingIndex] =
new DieInfo(identifier, shortenedName, DieColor.DieColorBlack);
} else {
_knownDice.Add(new DieInfo(identifier, shortenedName, DieColor.DieColorBlack));
}
SetUpTable();
_diceInterface.RequestColor(identifier);
});
}
void ConnectionFailedCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
_connectingIdentifiers.Remove(identifier);
var existingIndex =
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
if (existingIndex >= 0) {
_knownDice.RemoveAt(existingIndex);
SetUpTable();
}
});
}
void DisconnectionCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
_connectingIdentifiers.Remove(identifier);
if (_tensDieInfo is {} tensInfo && tensInfo.identifier.Equals(identifier)) {
_tensDieInfo = null;
_onesDieInfo = null;
} else if (_onesDieInfo is {} onesInfo && onesInfo.identifier.Equals(identifier)) {
_onesDieInfo = null;
}
var existingIndex =
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
if (existingIndex >= 0) { _knownDice.RemoveAt(existingIndex); }
SetUpTable();
_diceInterface.Connect(identifier);
});
}
public void DismissButtonClicked() {
gameObject.SetActive(false);
_configurationCallback(null, null);
}
// Update is called once per frame
void Update() {}
}
@@ -1,11 +1,11 @@
fileFormatVersion: 2
guid: 8a3b5c7d9e1f2a3b4c5d6e7f8a9b0c1d
guid: e694fb18ea1df4034b392352bf3aa04a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,213 @@
#if UNITY_IOS || UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
#define USE_SWIFT_INTERFACE
#elif UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN || ENABLE_WINMD_SUPPORT
#define USE_WINDOWS_INTERFACE
#else
#endif
using System.Collections.Generic;
using UnityEngine;
using UnityGoDiceInterface;
public class DiceInterface : MonoBehaviour {
public struct Commands {
public static readonly byte PulseLed = 16;
public static readonly byte GetColor = 23;
}
readonly NativeDiceInterfaceImports _diceInterfaceImports = new();
private static DiceInterface _singleton = null;
private static Dictionary<string, string> _deviceNames = new Dictionary<string, string>();
public delegate void DeviceFoundCallback(string identifier);
public delegate void ConnectionCallback(string identifier, string deviceName);
public delegate void ConnectionFailedCallback(string identifier);
public delegate void DisconnectionCallback(string identifier);
public delegate void ListenerStoppedCallback();
public delegate void RollCallback(string identifier, sbyte x, sbyte y, sbyte z);
public delegate void ColorCallback(string identifier, DieColor color);
public delegate void LoggerCallback(string log);
public DeviceFoundCallback deviceFoundCallback = null;
public ConnectionCallback connectionCallback = null;
public ConnectionFailedCallback connectionFailedCallback = null;
public DisconnectionCallback disconnectionCallback = null;
public ListenerStoppedCallback listenerStoppedCallback = null;
public RollCallback rollCallback = null;
public ColorCallback colorCallback = null;
public LoggerCallback logger = null;
public void StartListening() { _diceInterfaceImports.StartListening(); }
public void StopListening() { _diceInterfaceImports.StopListening(); }
public void Connect(string identifier) { _diceInterfaceImports.Connect(identifier); }
public void Disconnect(string identifier) { _diceInterfaceImports.Disconnect(identifier); }
public void Reset() {
_diceInterfaceImports.Reset();
_deviceNames.Clear();
}
public void RequestColor(string identifier) {
_diceInterfaceImports.Send(identifier, new List<byte> { Commands.GetColor });
}
public void FlashColor(
string identifier,
byte pulseCount,
byte onTime10ms,
byte offTime10ms,
byte red,
byte green,
byte blue) {
_diceInterfaceImports.Send(
identifier,
new List<byte> {
Commands.PulseLed,
pulseCount,
onTime10ms,
offTime10ms,
red,
green,
blue,
0x01,
0x00
});
}
// Start is called before the first frame update
void Start() {
_singleton = this;
_diceInterfaceImports.SetDeviceConnectedCallback(DeviceConnectedDelegateMessageReceived);
_diceInterfaceImports.SetDeviceConnectionFailedCallback(
DeviceConnectionFailedDelegateMessageReceived);
_diceInterfaceImports.SetDeviceDisconnectedCallback(
DeviceDisconnectedDelegateMessageReceived);
_diceInterfaceImports.SetDeviceFoundCallback(DeviceFoundDelegateMessageReceived);
_diceInterfaceImports.SetDataCallback(DataDelegateMessageReceived);
_diceInterfaceImports.SetListenerStoppedCallback(ListenerStoppedDelegateMessageReceived);
_diceInterfaceImports.SetLoggerCallback(LoggerDelegateMessageReceived);
}
// Update is called once per frame
void Update() {}
private static sbyte[] GetRollVector(List<byte> rawData) {
if (rawData.Count < 1) {
Debug.Log("rollVector: no data");
return null;
}
byte firstByte = rawData[0];
if (firstByte != 83) {
Debug.Log("rollVector: first byte is not 83");
return null;
}
if (rawData.Count != 4) {
Debug.Log("rollVector: data length is not 4");
return null;
}
return new[] { (sbyte)rawData[1], (sbyte)rawData[2], (sbyte)rawData[3] };
}
private static void DeviceFoundDelegateMessageReceived(string identifier, string deviceName) {
if (_singleton != null) {
_deviceNames[identifier] = deviceName;
if (_singleton.deviceFoundCallback != null) {
_singleton.deviceFoundCallback(identifier);
}
}
}
private static void DeviceConnectedDelegateMessageReceived(string identifier) {
if (_singleton != null && _singleton.connectionCallback != null) {
_singleton.connectionCallback(identifier, _deviceNames[identifier]);
}
}
private static void DeviceConnectionFailedDelegateMessageReceived(string identifier) {
if (_singleton != null && _singleton.connectionFailedCallback != null) {
_singleton.connectionFailedCallback(identifier);
}
}
private static void DeviceDisconnectedDelegateMessageReceived(string identifier) {
if (_singleton != null && _singleton.disconnectionCallback != null) {
_singleton.disconnectionCallback(identifier);
}
}
private static void DataDelegateMessageReceived(string identifier, List<byte> byteList) {
if (_singleton != null) {
if (byteList.Count == 0) {
if (_singleton.connectionCallback != null) {
// I don't think this one should still happen
return;
}
} else {
byte firstByte = byteList[0];
switch (firstByte) {
case 82:
// Roll started
break;
case 66:
// Battery level
break;
case (byte)'C':
if (byteList[1] == (byte)'o' && byteList[2] == (byte)'l') {
byte colorRawValue = byteList[3];
_singleton.colorCallback(identifier, (DieColor)colorRawValue);
}
// Color (fetched)
break;
case 83: {
var roll = GetRollVector(byteList);
if (roll != null && _singleton.rollCallback != null) {
_singleton.rollCallback(identifier, roll[0], roll[1], roll[2]);
}
break;
}
case 70:
case 84:
case 77: {
byteList.RemoveAt(0);
var roll = GetRollVector(byteList);
if (roll != null && _singleton.rollCallback != null) {
_singleton.rollCallback(identifier, roll[0], roll[1], roll[2]);
}
break;
}
default: Debug.Log("Not yet handled"); break;
}
}
}
}
private static void ListenerStoppedDelegateMessageReceived() {
if (_singleton != null && _singleton.listenerStoppedCallback != null) {
_singleton.listenerStoppedCallback();
}
}
private static void LoggerDelegateMessageReceived(string log) {
if (_singleton != null && _singleton.logger != null) { _singleton.logger(log); }
}
public void OnDestroy() {
_diceInterfaceImports.SetDeviceConnectedCallback(null);
_diceInterfaceImports.SetDeviceConnectionFailedCallback(null);
_diceInterfaceImports.SetDeviceDisconnectedCallback(null);
_diceInterfaceImports.SetDeviceFoundCallback(null);
_diceInterfaceImports.SetDataCallback(null);
_diceInterfaceImports.SetListenerStoppedCallback(null);
_diceInterfaceImports.SetLoggerCallback(null);
#if UNITY_EDITOR
StopListening();
Reset();
#endif
}
}
@@ -1,11 +1,11 @@
fileFormatVersion: 2
guid: 2b4c6d8e0f1a3b5c7d9e1f3a5b7c9d1e
guid: 75ea5d911cfab4851831d1de4b61f559
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: -100
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,204 @@
using System.Collections.Generic;
namespace UnityGoDiceInterface {
public class DiceVectors {
public struct Vector3 {
public sbyte x;
public sbyte y;
public sbyte z;
public Vector3(sbyte x, sbyte y, sbyte z) {
this.x = x;
this.y = y;
this.z = z;
}
}
public static int D6Value(sbyte x, sbyte y, sbyte z) {
return DieValueForVector(d6, new Vector3(x, y, z));
}
public static int D10Value(sbyte x, sbyte y, sbyte z) {
return d10Transform[DieValueForVector(d20, new Vector3(x, y, z))];
}
private static int sq(int x) { return x * x; }
private static int DieValueForVector(List<Vector3> table, Vector3 vector) {
var closestIndex = -1;
var closestDistance = int.MaxValue;
for (int i = 0; i < table.Count; i++) {
var entry = table[i];
var distance =
sq(entry.x - vector.x) + sq(entry.y - vector.y) + sq(entry.z - vector.z);
if (distance < closestDistance) {
closestDistance = distance;
closestIndex = i;
}
}
return closestIndex + 1;
}
// The private vectors are 0-based, so we need to add 1 to the index
private static List<Vector3> d6 = new List<Vector3> {
new Vector3(-64, 0, 0),
new Vector3(-0, 0, 64),
new Vector3(0, 64, 0),
new Vector3(0, -64, 0),
new Vector3(0, 0, -64),
new Vector3(64, 0, 0)
};
private static List<Vector3> d20 = new List<Vector3> {
new Vector3(-64, 0, -22), new Vector3(42, -42, 40), new Vector3(0, 22, -64),
new Vector3(0, 22, 64), new Vector3(-42, -42, 42), new Vector3(22, 64, 0),
new Vector3(-42, -42, -42), new Vector3(64, 0, -22), new Vector3(-22, 64, 0),
new Vector3(42, -42, -42), new Vector3(-42, 42, 42), new Vector3(22, -64, 0),
new Vector3(-64, 0, 22), new Vector3(42, 42, 42), new Vector3(-22, -64, 0),
new Vector3(42, 42, -42), new Vector3(0, -22, -64), new Vector3(0, -22, 64),
new Vector3(-42, 42, -42), new Vector3(64, 0, 22),
};
// static d24Vectors = {
// new Vector3(20, -60, -20),
// new Vector3(20, 0, 60),
// new Vector3(-40, -40, 40),
// new Vector3(-60, 0, 20),
// new Vector3(40, 20, 40),
// new Vector3(-20, -60, -20),
// new Vector3(20, 60, 20),
// new Vector3(-40, 20, -40),
// new Vector3(-40, 40, 40),
// new Vector3(-20, 0, 60),
// new Vector3(-20, -60, 20),
// new Vector3(60, 0, 20),
// new Vector3(-60, 0, -20),
// new Vector3(20, 60, -20),
// new Vector3(20, 0, -60),
// new Vector3(40, -20, -40),
// new Vector3(-20, 60, -20),
// new Vector3(-40, -40, -40),
// new Vector3(40, -20, 40),
// new Vector3(20, -60, 20),
// new Vector3(60, 0, -20),
// new Vector3(40, 20, -40),
// new Vector3(-20, 0, -60),
// new Vector3(-20, 60, 20),
// }
//
// Transforms from each shell type to according number on shell
// D20 Transforms
private static Dictionary<int, int> d10Transform = new Dictionary<int, int> {
{ 1, 8 }, { 2, 2 }, { 3, 6 }, { 4, 1 }, { 5, 4 }, { 6, 3 }, { 7, 9 },
{ 8, 0 }, { 9, 7 }, { 10, 5 }, { 11, 5 }, { 12, 7 }, { 13, 0 }, { 14, 9 },
{ 15, 3 }, { 16, 4 }, { 17, 1 }, { 18, 6 }, { 19, 2 }, { 20, 8 },
};
//
// static d10XTransform = {
// 1: 80,
// 2: 20,
// 3: 60,
// 4: 10,
// 5: 40,
// 6: 30,
// 7: 90,
// 8: 0,
// 9: 70,
// 10: 50,
// 11: 50,
// 12: 70,
// 13: 0,
// 14: 90,
// 15: 30,
// 16: 40,
// 17: 10,
// 18: 60,
// 19: 20,
// 20: 80,
// }
//
// // D24 Transforms
// static d4Transform = {
// 1: 3,
// 2: 1,
// 3: 4,
// 4: 1,
// 5: 4,
// 6: 4,
// 7: 1,
// 8: 4,
// 9: 2,
// 10: 3,
// 11: 1,
// 12: 1,
// 13: 1,
// 14: 4,
// 15: 2,
// 16: 3,
// 17: 3,
// 18: 2,
// 19: 2,
// 20: 2,
// 21: 4,
// 22: 1,
// 23: 3,
// 24: 2,
// }
//
// static d8Transform = {
// 1: 3,
// 2: 3,
// 3: 6,
// 4: 1,
// 5: 2,
// 6: 8,
// 7: 1,
// 8: 1,
// 9: 4,
// 10: 7,
// 11: 5,
// 12: 5,
// 13: 4,
// 14: 4,
// 15: 2,
// 16: 5,
// 17: 7,
// 18: 7,
// 19: 8,
// 20: 2,
// 21: 8,
// 22: 3,
// 23: 6,
// 24: 6,
// }
//
// static d12Transform = {
// 1: 1,
// 2: 2,
// 3: 3,
// 4: 4,
// 5: 5,
// 6: 6,
// 7: 7,
// 8: 8,
// 9: 9,
// 10: 10,
// 11: 11,
// 12: 12,
// 13: 1,
// 14: 2,
// 15: 3,
// 16: 4,
// 17: 5,
// 18: 6,
// 19: 7,
// 20: 8,
// 21: 9,
// 22: 10,
// 23: 11,
// 24: 12,
// }
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e9d67d9a2f8145a999d04de91c27073d
timeCreated: 1702612480
@@ -0,0 +1,28 @@
namespace UnityGoDiceInterface {
public enum DieColor : byte {
DieColorBlack = 0,
DieColorRed = 1,
DieColorGreen = 2,
DieColorBlue = 3,
DieColorYellow = 4,
DieColorOrange = 5,
}
public struct DieInfo {
public string identifier;
public string deviceName;
public DieColor color;
public bool connected;
public DieInfo(
string identifier,
string deviceName,
DieColor color,
bool connected = false) {
this.identifier = identifier;
this.deviceName = deviceName;
this.color = color;
this.connected = connected;
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 2e1000782bb845399869ca7910eebcd6
timeCreated: 1703172326
@@ -0,0 +1,177 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using AOT;
namespace UnityGoDiceInterface {
public class NativeDiceInterfaceImports {
public delegate void DeviceFoundDelegateMessage(string identifier, string name);
public delegate void DataDelegateMessage(string identifier, List<byte> bytes);
public delegate void DeviceConnectedDelegateMessage(string identifier);
public delegate void DeviceConnectionFailedDelegateMessage(string identifier);
public delegate void DeviceDisconnectedDelegateMessage(string identifier);
public delegate void ListenerStoppedDelegateMessage();
public delegate void LoggerDelegateMessage(string log);
private static DeviceFoundDelegateMessage deviceFoundDelegate;
private static DataDelegateMessage dataDelegate;
private static DeviceConnectedDelegateMessage deviceConnectedDelegate;
private static DeviceConnectionFailedDelegateMessage deviceConnectionFailedDelegate;
private static DeviceDisconnectedDelegateMessage deviceDisconnectedDelegate;
private static ListenerStoppedDelegateMessage listenerStoppedDelegate;
private static LoggerDelegateMessage loggerDelegateMessage;
#if UNITY_IOS
private const string BundleName = "__Internal";
#elif UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
private const string BundleName = "DarwinGodiceBundle";
#elif UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
private const string BundleName = "GoDiceDll.dll";
#endif
private delegate void MonoDeviceFoundDelegateMessage(string identifier, string name);
private delegate void
MonoDataDelegateMessage(string identifier, UInt32 byteCount, IntPtr bytePtr);
private delegate void MonoDeviceConnectedDelegateMessage(string identifier);
private delegate void MonoDeviceConnectionFailedDelegateMessage(string identifier);
private delegate void MonoDeviceDisconnectedDelegateMessage(string identifier);
private delegate void MonoListenerStoppedDelegateMessage();
private delegate void MonoLoggerDelegateMessage(string log);
[DllImport(dllName: BundleName, EntryPoint = "godice_start_listening")]
private static extern void _NativeBridgeStartListening();
[DllImport(dllName: BundleName, EntryPoint = "godice_stop_listening")]
private static extern void _NativeBridgeStopListening();
[DllImport(dllName: BundleName, EntryPoint = "godice_set_callbacks")]
private static extern void _NativeBridgeSetCallbacks(
MonoDeviceFoundDelegateMessage deviceFoundDelegate,
MonoDataDelegateMessage dataDelegate,
MonoDeviceConnectedDelegateMessage deviceConnectedDelegate,
MonoDeviceConnectionFailedDelegateMessage deviceConnectionFailedDelegateMessage,
MonoDeviceDisconnectedDelegateMessage deviceDisconnectedDelegate,
MonoListenerStoppedDelegateMessage listenerStoppedDelegate);
[DllImport(dllName: BundleName, EntryPoint = "godice_connect")]
private static extern void _NativeBridgeConnect(string identifier);
[DllImport(dllName: BundleName, EntryPoint = "godice_disconnect")]
private static extern void _NativeBridgeDisconnect(string identifier);
[DllImport(dllName: BundleName, EntryPoint = "godice_send")]
private static extern void
_NativeBridgeSend(string identifier, UInt32 byteCount, IntPtr bytePtr);
[DllImport(dllName: BundleName, EntryPoint = "godice_set_logger")]
private static extern void _NativeBridgeSetLogger(MonoLoggerDelegateMessage loggerDelegate);
[DllImport(dllName: BundleName, EntryPoint = "godice_reset")]
private static extern void _NativeBridgeReset();
private static List<byte> BytesFromRawPointer(UInt32 byteCount, IntPtr bytes) {
byte[] array = new byte[byteCount];
if (byteCount > 0) { Marshal.Copy(bytes, array, 0, (int)byteCount); }
return new List<byte>(array);
}
/*
* Callback methods
*/
[MonoPInvokeCallback(typeof(MonoDeviceFoundDelegateMessage))]
private static void MonoDeviceFoundMessageReceived(string identifier, string name) {
if (deviceFoundDelegate != null) { deviceFoundDelegate(identifier, name); }
}
[MonoPInvokeCallback(typeof(MonoDataDelegateMessage))]
private static void
MonoDataMessageReceived(string identifier, UInt32 byteCount, IntPtr bytePtr) {
if (dataDelegate != null) {
dataDelegate(identifier, BytesFromRawPointer(byteCount, bytePtr));
}
}
[MonoPInvokeCallback(typeof(MonoDeviceConnectedDelegateMessage))]
private static void MonoDeviceConnected(string identifier) {
if (deviceConnectedDelegate != null) { deviceConnectedDelegate(identifier); }
}
[MonoPInvokeCallback(typeof(MonoDeviceConnectionFailedDelegateMessage))]
private static void MonoDeviceConnectionFailed(string identifier) {
if (deviceConnectionFailedDelegate != null) {
deviceConnectionFailedDelegate(identifier);
}
}
[MonoPInvokeCallback(typeof(MonoDeviceDisconnectedDelegateMessage))]
private static void MonoDeviceDisconnected(string identifier) {
if (deviceDisconnectedDelegate != null) { deviceDisconnectedDelegate(identifier); }
}
[MonoPInvokeCallback(typeof(MonoListenerStoppedDelegateMessage))]
private static void MonoListenerStopped() {
if (listenerStoppedDelegate != null) { listenerStoppedDelegate(); }
}
[MonoPInvokeCallback(typeof(MonoLoggerDelegateMessage))]
private static void MonoLogger(string log) {
if (loggerDelegateMessage != null) { loggerDelegateMessage(log); }
}
public void StartListening() {
_NativeBridgeSetCallbacks(
MonoDeviceFoundMessageReceived,
MonoDataMessageReceived,
MonoDeviceConnected,
MonoDeviceConnectionFailed,
MonoDeviceDisconnected,
MonoListenerStopped);
_NativeBridgeSetLogger(MonoLogger);
_NativeBridgeStartListening();
}
public void StopListening() { _NativeBridgeStopListening(); }
public void Connect(string identifier) { _NativeBridgeConnect(identifier); }
public void Disconnect(string identifier) { _NativeBridgeDisconnect(identifier); }
public void Send(string identifier, List<byte> data) {
byte[] byteArray = data.ToArray();
GCHandle pinnedArray = GCHandle.Alloc(byteArray, GCHandleType.Pinned);
IntPtr pointer = pinnedArray.AddrOfPinnedObject();
_NativeBridgeSend(identifier, (uint)data.Count, pointer);
pinnedArray.Free();
}
public void SetDeviceFoundCallback(DeviceFoundDelegateMessage deviceFound) {
deviceFoundDelegate = deviceFound;
}
public void SetDataCallback(DataDelegateMessage data) { dataDelegate = data; }
public void SetDeviceConnectedCallback(DeviceConnectedDelegateMessage deviceConnected) {
deviceConnectedDelegate = deviceConnected;
}
public void SetDeviceConnectionFailedCallback(
DeviceConnectionFailedDelegateMessage deviceConnectionFailed) {
deviceConnectionFailedDelegate = deviceConnectionFailed;
}
public void SetDeviceDisconnectedCallback(
DeviceDisconnectedDelegateMessage deviceDisconnected) {
deviceDisconnectedDelegate = deviceDisconnected;
}
public void SetListenerStoppedCallback(ListenerStoppedDelegateMessage listenerStopped) {
listenerStoppedDelegate = listenerStopped;
}
public void SetLoggerCallback(LoggerDelegateMessage logger) {
loggerDelegateMessage = logger;
}
public void Reset() { _NativeBridgeReset(); }
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 424476bb5b18c47039d649c28a58a8fa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,447 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &2154262805260441194
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 7181142147825592926}
- component: {fileID: 4917217417751722011}
- component: {fileID: 8876282893591316834}
- component: {fileID: 1837103631940551462}
- component: {fileID: 7123394496182663843}
m_Layer: 0
m_Name: OneDiceResult
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &7181142147825592926
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2154262805260441194}
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:
- {fileID: 145111913694346175}
- {fileID: 6185631331142294830}
m_Father: {fileID: 0}
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: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &4917217417751722011
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2154262805260441194}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 59f8146938fff824cb5fd77236b75775, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Padding:
m_Left: 0
m_Right: 0
m_Top: 0
m_Bottom: 0
m_ChildAlignment: 4
m_Spacing: 0
m_ChildForceExpandWidth: 0
m_ChildForceExpandHeight: 0
m_ChildControlWidth: 1
m_ChildControlHeight: 1
m_ChildScaleWidth: 0
m_ChildScaleHeight: 0
m_ReverseArrangement: 0
--- !u!114 &8876282893591316834
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2154262805260441194}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!114 &1837103631940551462
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2154262805260441194}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 680b7d1179eb34a0fa338286236db2e9, type: 3}
m_Name:
m_EditorClassIdentifier:
nameLabel: {fileID: 9015301821484574050}
d6Label: {fileID: 0}
d10Label: {fileID: 4483020407950697354}
--- !u!222 &7123394496182663843
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2154262805260441194}
m_CullTransparentMesh: 1
--- !u!1 &6943825177879701675
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 6185631331142294830}
- component: {fileID: 4032026062387785650}
- component: {fileID: 4483020407950697354}
- component: {fileID: 742703408719749652}
- component: {fileID: 4292989854150447437}
m_Layer: 0
m_Name: result
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &6185631331142294830
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6943825177879701675}
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: 7181142147825592926}
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 &4032026062387785650
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6943825177879701675}
m_CullTransparentMesh: 1
--- !u!114 &4483020407950697354
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6943825177879701675}
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: 9
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: 4278190080
m_fontColor: {r: 0, g: 0, b: 0, 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: 48
m_fontSizeBase: 48
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 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: 0
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 &742703408719749652
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6943825177879701675}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!114 &4292989854150447437
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6943825177879701675}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &8198927819804470251
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 145111913694346175}
- component: {fileID: 8510898822381381753}
- component: {fileID: 9015301821484574050}
- component: {fileID: 4918688670369599320}
m_Layer: 0
m_Name: Name
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &145111913694346175
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8198927819804470251}
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: 7181142147825592926}
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 &8510898822381381753
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8198927819804470251}
m_CullTransparentMesh: 1
--- !u!114 &9015301821484574050
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8198927819804470251}
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: 'Tens
'
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: 4278190080
m_fontColor: {r: 0, g: 0, b: 0, 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: 16
m_fontSizeBase: 16
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 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: 0
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 &4918688670369599320
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8198927819804470251}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 200
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: 1
m_FlexibleHeight: 1
m_LayoutPriority: 1
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9acf3ba64a8c04e6083aae699248b0c4
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,32 @@
using TMPro;
public class OneDiceRollController : TableRowController {
private string _identifier;
public TMP_Text nameLabel;
public TMP_Text d6Label;
public TMP_Text d10Label;
// Start is called before the first frame update
void Start() {}
// Update is called once per frame
void Update() {}
public void SetUp(string identifier, string deviceName) {
_identifier = identifier;
nameLabel.text = deviceName;
d6Label.text = "?";
d10Label.text = "?";
}
public void SetRoll(string identifier, string d6, string d10) {
_identifier = identifier;
d6Label.text = d6;
d10Label.text = d10;
}
public string GetIdentifier() { return _identifier; }
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 680b7d1179eb34a0fa338286236db2e9
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,281 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &3756143511112798868
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 5260824885446700081}
- component: {fileID: 757087991837136050}
- component: {fileID: 8474069203316727232}
m_Layer: 0
m_Name: Text (TMP)
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &5260824885446700081
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3756143511112798868}
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: 3793282440965013679}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 200, y: 50}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &757087991837136050
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3756143511112798868}
m_CullTransparentMesh: 1
--- !u!114 &8474069203316727232
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3756143511112798868}
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: Orange
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: 4278229493
m_fontColor: {r: 0.9607843, g: 0.6, b: 0, 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: 18
m_fontSizeBase: 18
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 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: 0
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!1 &5800426311821892848
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 3793282440965013679}
- component: {fileID: 6926687573176944590}
- component: {fileID: -2050598564750628712}
- component: {fileID: 2781819584472077043}
- component: {fileID: -2772541110306127347}
- component: {fileID: 5171470299130298381}
m_Layer: 0
m_Name: PickerRow
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &3793282440965013679
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
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:
- {fileID: 5260824885446700081}
m_Father: {fileID: 0}
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: 200, y: 50}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &6926687573176944590
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
m_CullTransparentMesh: 1
--- !u!114 &-2050598564750628712
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: 30
m_PreferredWidth: -1
m_PreferredHeight: 30
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!114 &2781819584472077043
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, 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_Sprite: {fileID: 0}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!114 &-2772541110306127347
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d7b0b988957134dbc9c5e270bd5e9e70, type: 3}
m_Name:
m_EditorClassIdentifier:
nameLabel: {fileID: 8474069203316727232}
--- !u!114 &5171470299130298381
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 876f18cbcbaea4cba9510a977f343c42, type: 3}
m_Name:
m_EditorClassIdentifier:
onClick:
m_PersistentCalls:
m_Calls: []
onRightClick:
m_PersistentCalls:
m_Calls: []
onLeftDown:
m_PersistentCalls:
m_Calls: []
onLeftUp:
m_PersistentCalls:
m_Calls: []
onRightDown:
m_PersistentCalls:
m_Calls: []
onRightUp:
m_PersistentCalls:
m_Calls: []
selectable: 1
rightSelectable: 0
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 9a5c83874e75141728c54f347958d4ae
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,19 @@
using TMPro;
using UnityEngine.UI;
public class PickerRowController : TableRowController {
public TMP_Text nameLabel;
public string Text {
get => nameLabel.text;
set => nameLabel.text = value;
}
override public void ColorRow() { gameObject.GetComponent<Image>().color = BackgroundColor; }
// Start is called before the first frame update
void Start() {}
// Update is called once per frame
void Update() {}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d7b0b988957134dbc9c5e270bd5e9e70
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,9 @@
using Net.Eagle0.Shardok.Api;
namespace UnityGoDiceInterface {
public interface RollFetcher {
public delegate void RollResultCallback(int? result);
public void GetRoll(RollRequest rollType, RollResultCallback cb);
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: aade83941cf241b398049ff3fe0475cb
timeCreated: 1703812608
@@ -0,0 +1,375 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using Net.Eagle0.Shardok.Api;
using Net.Eagle0.Shardok.Common;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UnityGoDiceInterface;
public class RollPanelController : MonoBehaviour, RollFetcher {
public DiceConfigurationPanelController diceConfigurationPanelController;
public Button skipContinueButton;
public TMP_Text skipContinueButtonText;
private DiceInterface _diceInterface;
public TMP_Text tensResultLabel;
public TMP_Text onesResultLabel;
public TMP_Text header;
public TMP_Text reconnectionLabel;
public TMP_Text runningTotalLabel;
private RollFetcher.RollResultCallback _rollResultCallback;
private RollRequest _rollRequest;
private string logLocation;
enum OpenEndedState { None, High, Low, RollComplete }
private OpenEndedState _openEndedState = OpenEndedState.None;
private readonly int _d100OpenEndedHighThreshold = 96;
private readonly int _d100OpenEndedLowThreshold = 5;
private DateTime _lastRollTime = DateTime.MinValue;
private readonly TimeSpan _disconnectionDelay = TimeSpan.FromMinutes(5);
private readonly TimeSpan _displayTime = TimeSpan.FromSeconds(3);
private readonly TimeSpan _openEndedResetTime = TimeSpan.FromSeconds(2);
public void GetRoll(RollRequest rollRequest, RollFetcher.RollResultCallback cb) {
// Check for dice enabled
if (PlayerPrefs.GetInt(SettingsPanelController.UseGoDiceKey, 0) == 0) {
cb(null);
return;
}
skipContinueButton.gameObject.SetActive(true);
if (_diceInterface == null) { _diceInterface = GetComponentInParent<DiceInterface>(); }
_onesResult = null;
_tensResult = null;
_runningTotal = 0;
_openEndedState = OpenEndedState.None;
skipContinueButtonText.text = "Skip";
_rollResultCallback = cb;
_rollRequest = rollRequest;
SetResultLabels();
logLocation = NewLogLocation();
Directory.CreateDirectory(Path.GetDirectoryName(logLocation));
if (string.IsNullOrEmpty(_tensDieInfo.identifier) ||
string.IsNullOrEmpty(_onesDieInfo.identifier)) {
GetConfiguration();
} else {
_diceInterface.deviceFoundCallback = DeviceFoundCallback;
_diceInterface.connectionCallback = ConnectionCallback;
_diceInterface.rollCallback = RollCallback;
_diceInterface.logger = LogFromNative;
// _diceInterface.StartListening();
SetAlphas();
if (!_tensDieInfo.connected) { _diceInterface.Connect(_tensDieInfo.identifier); }
if (!_onesDieInfo.connected) { _diceInterface.Connect(_onesDieInfo.identifier); }
}
gameObject.SetActive(true);
}
private DieInfo _tensDieInfo;
private DieInfo _onesDieInfo;
private int? _tensResult = null;
private int? _onesResult = null;
private int _runningTotal = 0;
private void SetAlphas() {
tensResultLabel.alpha = _tensDieInfo.connected ? 1.0f : 0.25f;
onesResultLabel.alpha = _onesDieInfo.connected ? 1.0f : 0.25f;
}
void OnEnable() {}
private void GetConfiguration() {
diceConfigurationPanelController.GetConfiguration(ConfigurationCallback);
}
public void ConfigureClicked() { GetConfiguration(); }
public void SkipClicked() {
gameObject.SetActive(false);
_rollResultCallback(_runningTotal);
_diceInterface.StopListening();
}
void ConfigurationCallback(DieInfo? tensOpt, DieInfo? onesOpt) {
MainQueue.Q.Enqueue(() => {
if (tensOpt is DieInfo tens && onesOpt is DieInfo ones) {
gameObject.SetActive(true);
_diceInterface.connectionCallback = ConnectionCallback;
_diceInterface.connectionFailedCallback = ConnectionFailedCallback;
_diceInterface.disconnectionCallback = DisconnectionCallback;
_diceInterface.listenerStoppedCallback = ListenerStoppedCallback;
_diceInterface.rollCallback = RollCallback;
_tensDieInfo = tens;
_onesDieInfo = ones;
reconnectionLabel.gameObject.SetActive(false);
if (!_tensDieInfo.connected) _diceInterface.Connect(_tensDieInfo.identifier);
if (!_onesDieInfo.connected) _diceInterface.Connect(_onesDieInfo.identifier);
SetAlphas();
SetResultLabels();
tensResultLabel.color = UnityDieColors.ColorFromDieColor(_tensDieInfo.color);
onesResultLabel.color = UnityDieColors.ColorFromDieColor(_onesDieInfo.color);
} else {
gameObject.SetActive(false);
_rollResultCallback(null);
}
});
}
void DeviceFoundCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
if ((_tensDieInfo.identifier == identifier && !_tensDieInfo.connected) ||
(_onesDieInfo.identifier == identifier && !_onesDieInfo.connected)) {
_diceInterface.Connect(identifier);
}
});
}
void ConnectionCallback(string identifier, string deviceName) {
MainQueue.Q.Enqueue(() => {
if (identifier == _tensDieInfo.identifier) {
_tensDieInfo.connected = true;
} else if (identifier == _onesDieInfo.identifier) {
_onesDieInfo.connected = true;
}
SetAlphas();
if (_tensDieInfo.connected && _onesDieInfo.connected) {
reconnectionLabel.gameObject.SetActive(false);
_diceInterface.StopListening();
}
});
}
void ConnectionFailedCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
if (identifier == _onesDieInfo.identifier || identifier == _tensDieInfo.identifier) {
reconnectionLabel.gameObject.SetActive(true);
_diceInterface.StartListening();
} else {
Debug.Log("Got connection failed for unknown identifier");
}
});
}
void DisconnectionCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
if (identifier == _tensDieInfo.identifier) {
_tensDieInfo.connected = false;
SetAlphas();
_diceInterface.Connect(identifier);
} else if (identifier == _onesDieInfo.identifier) {
_onesDieInfo.connected = false;
SetAlphas();
_diceInterface.Connect(identifier);
} else {
Debug.Log("Got disconnection callback for unknown identifier");
}
});
}
void ListenerStoppedCallback() {
MainQueue.Q.Enqueue(() => { reconnectionLabel.text = "Unable to connect."; });
}
private readonly Dictionary<CommandType, string> _baseHeaderText = new() {
{ CommandType.ArcheryCommand, "Archery Attack" },
{ CommandType.BuildBridgeCommand, "Build Bridge" },
{ CommandType.ChargeCommand, "Charge" },
{ CommandType.BraveWaterCommand, "Brave Water" },
{ CommandType.MeleeCommand, "Melee Attack" }
};
private string BaseHeaderText(CommandType commandType) {
if (_baseHeaderText.ContainsKey(commandType)) { return _baseHeaderText[commandType]; }
return "Action";
}
void SetResultLabels() {
var baseText = BaseHeaderText(_rollRequest.CommandType);
string fullText = "";
if (_openEndedState == OpenEndedState.High) {
fullText = $"You rolled OPEN ENDED HIGH! Keep rolling for {baseText}!";
} else if (_openEndedState == OpenEndedState.Low) {
fullText = $"You rolled OPEN ENDED LOW! Keep rolling for {baseText}!";
} else {
switch (_rollRequest.RollType) {
case RollType.D100: fullText = $"Roll D100 for {baseText}"; break;
case RollType.D100OpenEnded:
fullText = $"Roll Open Ended D100 for {baseText}";
break;
case RollType.D100OpenEndedHigh:
fullText = $"Roll Open Ended High D100 for {baseText}";
break;
case RollType.D100OpenEndedLow:
fullText = $"Roll Open Ended Low D100 for {baseText}";
break;
case RollType.Unspecified:
default: throw new ArgumentException("Invalid roll type");
}
}
if (_rollRequest.Odds is OddsView odds) {
int minRoll = 101 - odds.SuccessChance;
fullText += $" ({minRoll}+ to succeed)";
}
header.text = fullText;
if (_tensResult is int tr) {
tensResultLabel.text = tr.ToString();
} else {
tensResultLabel.text = "?";
}
if (_onesResult is int or) {
onesResultLabel.text = or.ToString();
} else {
onesResultLabel.text = "?";
}
runningTotalLabel.text = $"Running total: {_runningTotal}";
runningTotalLabel.gameObject.SetActive(_runningTotal != 0);
}
void RollCallback(string identifier, sbyte x, sbyte y, sbyte z) {
MainQueue.Q.Enqueue(() => {
if (gameObject.activeSelf) {
var d10Roll = DiceVectors.D10Value(x, y, z);
if (identifier == _tensDieInfo.identifier) {
if (_tensResult == null) { _tensResult = d10Roll; }
} else if (identifier == _onesDieInfo.identifier) {
if (_onesResult == null) { _onesResult = d10Roll; }
}
SetResultLabels();
CheckCompletion();
}
});
}
void HandleOneRoll(int newD100Result) {
if (_openEndedState == OpenEndedState.Low) {
_runningTotal -= newD100Result;
} else {
_runningTotal += newD100Result;
}
// Rolls in the middle always end the open ended state.
if (newD100Result > _d100OpenEndedLowThreshold &&
newD100Result < _d100OpenEndedHighThreshold) {
_openEndedState = OpenEndedState.RollComplete;
return;
}
// For a LOW roll, if we are currently in a None state, move to OpenEnded
// Low so we can roll again, but if we were already there, this ends the roll.
if (newD100Result <= _d100OpenEndedLowThreshold) {
if ((_rollRequest.RollType == RollType.D100OpenEnded ||
_rollRequest.RollType == RollType.D100OpenEndedLow) &&
_openEndedState == OpenEndedState.None) {
_openEndedState = OpenEndedState.Low;
} else {
_openEndedState = OpenEndedState.RollComplete;
}
return;
}
// At this point we know the current roll is high. If we were already in OpenEndedLow,
// stay there and keep subtracting, otherwise move into High.
if (_openEndedState == OpenEndedState.Low) {
_openEndedState = OpenEndedState.Low;
} else {
_openEndedState = OpenEndedState.High;
}
}
void CheckCompletion() {
if (_onesResult is int ones && _tensResult is int tens) {
// FIXME: open ended rolls
var currentResult = tens * 10 + ones;
HandleOneRoll(currentResult);
// TODO: flash the dice here for high rolls
if (_openEndedState == OpenEndedState.RollComplete) {
skipContinueButtonText.text = "Dismiss";
SetResultLabels();
StartCoroutine(AfterDelay(_displayTime, () => {
if (gameObject.activeSelf) {
gameObject.SetActive(false);
_rollResultCallback(_runningTotal);
}
}));
// Disconnect only after _disconnectionDelay time
_lastRollTime = DateTime.Now;
StartCoroutine(AfterDelay(_disconnectionDelay, () => {
if (DateTime.Now - _lastRollTime > _disconnectionDelay) {
_diceInterface.StopListening();
_tensDieInfo.connected = false;
_onesDieInfo.connected = false;
_diceInterface.Disconnect(_tensDieInfo.identifier);
_diceInterface.Disconnect(_onesDieInfo.identifier);
}
}));
} else {
// We keep rolling, so reset the dice and keep going.
StartCoroutine(AfterDelay(_openEndedResetTime, () => {
_tensResult = null;
_onesResult = null;
SetResultLabels();
}));
}
}
}
IEnumerator AfterDelay(TimeSpan timeSpan, Action action) {
yield return new WaitForSeconds(timeSpan.Seconds);
action();
}
void LogFromNative(string log) {
MainQueue.Q.Enqueue(() => {
Debug.Log(log);
File.AppendAllText(logLocation, log);
});
}
string NewLogLocation() {
return Path.Combine(
Application.persistentDataPath,
"eagle0",
"Resources",
"RollPanelLogs",
DateTime.Now.ToString("yyyy-MM-dd-HH-mm-ss") + ".log");
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: db0669ab8b7684b2bb7eb30a0e66f5e8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,18 @@
using System;
using UnityEngine;
namespace UnityGoDiceInterface {
public class UnityDieColors {
public static Color ColorFromDieColor(DieColor dieColor) {
switch (dieColor) {
case DieColor.DieColorBlack: return Color.black;
case DieColor.DieColorRed: return Color.red;
case DieColor.DieColorGreen: return Color.green;
case DieColor.DieColorBlue: return Color.blue;
case DieColor.DieColorYellow: return Color.yellow;
case DieColor.DieColorOrange: return new Color(1.0f, 0.5f, 0.0f);
default: throw new ArgumentOutOfRangeException(nameof(dieColor), dieColor, null);
}
}
}
}
@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d26c35650c67416e86ab5a26906cfb5a
timeCreated: 1703173073
@@ -86,7 +86,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public GameObject oauthPanel;
public Button discordLoginButton;
public Button googleLoginButton;
public Button githubLoginButton;
public TextMeshProUGUI oauthStatusText;
[Header("Stored Accounts")]
@@ -94,7 +93,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public GameObject storedAccountButtonPrefab;
public Sprite discordProviderIcon;
public Sprite googleProviderIcon;
public Sprite githubProviderIcon;
[Header("Display Name Setup")]
public GameObject displayNamePanel;
@@ -102,15 +100,16 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public Button setDisplayNameButton;
public TextMeshProUGUI displayNameErrorText;
[Header("Invitation Code Entry")]
public GameObject invitationCodePanel;
public TMP_InputField invitationCodeField;
public Button submitInvitationCodeButton;
public TextMeshProUGUI invitationCodeErrorText;
[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 connectionBackgroundLayer;
public GameObject gameSelectionPanel;
public GameObject customBattlePanel;
public ErrorHandler errorHandler;
@@ -210,7 +209,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
connectionCanvas.enabled = true;
connectionPanel.gameObject.SetActive(true);
if (connectionBackgroundLayer != null) { connectionBackgroundLayer.SetActive(true); }
gameSelectionPanel.gameObject.SetActive(false);
customBattlePanel.gameObject.SetActive(false);
@@ -219,9 +217,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Initialize OAuth UI
SetupOAuthUI();
// Initialize connection panel environment dropdown
SetupConnectionEnvironmentDropdown();
// Initialize Lobby UI (logout button, etc.)
SetupLobbyUI();
@@ -232,22 +227,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
TryRestoreSession();
}
private void SetupConnectionEnvironmentDropdown() {
if (connectionEnvironmentDropdown == null) return;
connectionEnvironmentDropdown.ClearOptions();
connectionEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
connectionEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
connectionEnvironmentDropdown.onValueChanged.AddListener(OnConnectionEnvironmentChanged);
}
private void OnConnectionEnvironmentChanged(int newEnvironmentIndex) {
// Just save the preference - it will be used on next connection attempt
PlayerPrefs.SetInt(EnvironmentKey, newEnvironmentIndex);
Debug.Log(
$"[ConnectionHandler] Environment changed to {EnvironmentDisplayNames[newEnvironmentIndex]}");
}
private void SetupOAuthUI() {
// Set up OAuth button click handlers for new sign-ins
if (discordLoginButton != null) {
@@ -257,12 +236,12 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
if (googleLoginButton != null) {
googleLoginButton.onClick.AddListener(() => OnOAuthLoginClicked(OAuthProvider.Google));
}
if (githubLoginButton != null) {
githubLoginButton.onClick.AddListener(() => OnOAuthLoginClicked(OAuthProvider.Github));
}
if (setDisplayNameButton != null) {
setDisplayNameButton.onClick.AddListener(OnSetDisplayNameClicked);
}
if (submitInvitationCodeButton != null) {
submitInvitationCodeButton.onClick.AddListener(OnSubmitInvitationCodeClicked);
}
// Subscribe to OAuthManager events
if (OAuthManager.Instance != null) {
@@ -270,6 +249,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
OAuthManager.Instance.OnLoginFailed += OnOAuthLoginFailed;
OAuthManager.Instance.OnNewUserNeedsDisplayName += OnNewUserNeedsDisplayName;
OAuthManager.Instance.OnLogout += OnOAuthLogout;
OAuthManager.Instance.OnInvitationRequired += OnInvitationRequired;
}
// Show auth panel with stored accounts
@@ -279,19 +259,11 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
private void ShowAuthPanel() {
// Hide other panels
if (displayNamePanel != null) displayNamePanel.SetActive(false);
if (invitationCodePanel != null) invitationCodePanel.SetActive(false);
// 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();
}
@@ -324,10 +296,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
if (providerIconTransform != null) {
var providerImage = providerIconTransform.GetComponent<Image>();
if (providerImage != null) {
var providerLower = account.Provider.ToLowerInvariant();
providerImage.sprite = providerLower switch { "google" => googleProviderIcon,
"github" => githubProviderIcon,
_ => discordProviderIcon };
var isGoogle = account.Provider.ToLowerInvariant() == "google";
providerImage.sprite = isGoogle ? googleProviderIcon : discordProviderIcon;
}
}
@@ -438,7 +408,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Return to connection screen
gameSelectionPanel.SetActive(false);
connectionPanel.SetActive(true);
if (connectionBackgroundLayer != null) { connectionBackgroundLayer.SetActive(true); }
// Re-initialize the auth UI
ShowAuthPanel();
@@ -496,11 +465,53 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
MainQueue.Q.Enqueue(() => {
// Show display name panel
if (oauthPanel != null) oauthPanel.SetActive(false);
if (invitationCodePanel != null) invitationCodePanel.SetActive(false);
if (displayNamePanel != null) displayNamePanel.SetActive(true);
if (displayNameErrorText != null) displayNameErrorText.text = "";
});
}
private void OnInvitationRequired() {
MainQueue.Q.Enqueue(() => {
// Show invitation code entry panel
if (oauthPanel != null) oauthPanel.SetActive(false);
if (displayNamePanel != null) displayNamePanel.SetActive(false);
if (invitationCodePanel != null) invitationCodePanel.SetActive(true);
if (invitationCodeErrorText != null) {
invitationCodeErrorText.text =
"An invitation code is required to create a new account.";
}
if (invitationCodeField != null) invitationCodeField.text = "";
});
}
private void OnSubmitInvitationCodeClicked() {
if (invitationCodeField == null) return;
var code = invitationCodeField.text.Trim();
if (string.IsNullOrEmpty(code)) {
if (invitationCodeErrorText != null) {
invitationCodeErrorText.text = "Please enter an invitation code";
}
return;
}
// Save the code and return to login screen to retry
InvitationCodeManager.SetInvitationCode(code);
if (invitationCodeErrorText != null) {
invitationCodeErrorText.text = "Code saved. Please sign in again.";
}
// Return to auth panel after a short delay
MainQueue.Q.Enqueue(() => {
if (oauthStatusText != null) {
oauthStatusText.text = "Invitation code saved. Please sign in to continue.";
}
ShowAuthPanel();
});
}
private async void OnSetDisplayNameClicked() {
if (displayNameField == null || OAuthManager.Instance == null) return;
@@ -657,7 +668,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
fetchedNewGameLeaders.Select(a => a.ImagePath));
connectionPanel.gameObject.SetActive(false);
if (connectionBackgroundLayer != null) { connectionBackgroundLayer.SetActive(false); }
gameSelectionPanel.gameObject.SetActive(true);
// Update lobby status displays
@@ -672,10 +682,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
true);
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
var runningGameItem = listItem.GetComponent<RunningGameItem>();
runningGameItem.SetRunningGame(
runningGame.GameId,
runningGame.Leader,
runningGame.LastPlayedTimestampMillis);
runningGameItem.SetRunningGame(runningGame.GameId, runningGame.Leader);
runningGameItem.GoCallback = this.SelectEagleGame;
runningGameItem.DropCallback = this.DropGame;
}
@@ -20,6 +20,8 @@ using Random = System.Random;
using VictoryCondition = Net.Eagle0.Common.VictoryCondition;
public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
public RollPanelController rollPanelController;
public GameObject yourArmiesArea;
public GameObject aiArmiesArea;
public Canvas shardokCanvas;
@@ -101,7 +103,8 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
players: players,
heroNameTextIds: _heroNames,
heroImages: _heroImages,
battalionNames: new Dictionary<int, string> { { 0, "no name" } });
battalionNames: new Dictionary<int, string> { { 0, "no name" } },
rollFetcher: rollPanelController);
shardokCanvas.gameObject.SetActive(true);
shardokCanvas.GetComponent<ShardokGameController>().SetUpGame(_shardokModel);
@@ -1,164 +1,5 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &246340277068347999
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 5589171621630283209}
- component: {fileID: 9020655488278447847}
- component: {fileID: 5482253986454480250}
- component: {fileID: 6109349435209553009}
m_Layer: 5
m_Name: Last Played
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &5589171621630283209
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 246340277068347999}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 909271087739711220}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &9020655488278447847
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 246340277068347999}
m_CullTransparentMesh: 0
--- !u!114 &5482253986454480250
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 246340277068347999}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: Tars Tarkas
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
m_fontColor: {r: 1, g: 1, b: 1, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 24
m_fontSizeBase: 24
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 1
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 1
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!114 &6109349435209553009
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 246340277068347999}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 100
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &649403511955802327
GameObject:
m_ObjectHideFlags: 0
@@ -253,8 +94,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 24
m_fontSizeBase: 24
m_fontSize: 30
m_fontSizeBase: 30
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -312,9 +153,9 @@ MonoBehaviour:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 450
m_MinHeight: -1
m_MinHeight: 60
m_PreferredWidth: 450
m_PreferredHeight: -1
m_PreferredHeight: 60
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -412,8 +253,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 24
m_fontSizeBase: 24
m_fontSize: 36
m_fontSizeBase: 36
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -473,7 +314,7 @@ MonoBehaviour:
m_MinWidth: 200
m_MinHeight: 35
m_PreferredWidth: -1
m_PreferredHeight: -1
m_PreferredHeight: 35
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -680,10 +521,10 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 40
m_MinHeight: 40
m_PreferredWidth: 40
m_PreferredHeight: 40
m_MinWidth: 0
m_MinHeight: -1
m_PreferredWidth: 50
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -780,8 +621,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 24
m_fontSizeBase: 24
m_fontSize: 36
m_fontSizeBase: 36
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -1044,9 +885,9 @@ MonoBehaviour:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 200
m_MinHeight: 40
m_MinHeight: -1
m_PreferredWidth: 200
m_PreferredHeight: 40
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -1085,7 +926,6 @@ RectTransform:
m_Children:
- {fileID: 2008335870574158510}
- {fileID: 6674238761151365000}
- {fileID: 5589171621630283209}
- {fileID: 1348356180262758599}
- {fileID: 7336928442537586311}
- {fileID: 7341333336313446738}
@@ -1154,7 +994,7 @@ MonoBehaviour:
m_ChildAlignment: 0
m_Spacing: 0
m_ChildForceExpandWidth: 0
m_ChildForceExpandHeight: 0
m_ChildForceExpandHeight: 1
m_ChildControlWidth: 1
m_ChildControlHeight: 1
m_ChildScaleWidth: 0
@@ -1175,7 +1015,6 @@ MonoBehaviour:
item: {fileID: 5643565463360785033}
gameIdField: {fileID: 8058295588038032232}
leaderField: {fileID: 3311450659768657245}
lastPlayedField: {fileID: 5482253986454480250}
goButton: {fileID: 145214844678457394}
dropButton: {fileID: 4081637582368108930}
--- !u!114 &2208043217657811503
@@ -1194,9 +1033,9 @@ MonoBehaviour:
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: 45
m_PreferredHeight: -1
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_FlexibleHeight: 1
m_LayoutPriority: 1
--- !u!1 &7814378479006347708
GameObject:
@@ -9,7 +9,6 @@ public class RunningGameItem : MonoBehaviour {
public GameObject item;
public TextMeshProUGUI gameIdField;
public TextMeshProUGUI leaderField;
public TextMeshProUGUI lastPlayedField;
public Button goButton;
public Button dropButton;
@@ -25,8 +24,7 @@ public class RunningGameItem : MonoBehaviour {
public void DropClicked() { DropCallback?.Invoke(gameId); }
public void
SetRunningGame(long gameId, AvailableLeader leader, long lastPlayedTimestampMillis) {
public void SetRunningGame(long gameId, AvailableLeader leader) {
this.gameId = gameId;
gameIdField.text = string.Format("{0:X}", gameId);
@@ -36,31 +34,5 @@ public class RunningGameItem : MonoBehaviour {
"{0} ({1})",
leaderName,
DisplayNames.ProfessionNames[leader.Profession]);
// Display last played time in user's local timezone
if (lastPlayedField != null) {
if (lastPlayedTimestampMillis > 0) {
var lastPlayed = DateTimeOffset.FromUnixTimeMilliseconds(lastPlayedTimestampMillis)
.LocalDateTime;
var now = DateTime.Now;
var diff = now - lastPlayed;
string timeText;
if (diff.TotalMinutes < 1) {
timeText = "Just now";
} else if (diff.TotalHours < 1) {
timeText = $"{(int)diff.TotalMinutes}m ago";
} else if (diff.TotalDays < 1) {
timeText = $"{(int)diff.TotalHours}h ago";
} else if (diff.TotalDays < 7) {
timeText = $"{(int)diff.TotalDays}d ago";
} else {
timeText = lastPlayed.ToString("MMM d");
}
lastPlayedField.text = timeText;
} else {
lastPlayedField.text = "";
}
}
}
}
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using Eagle0.Tutorial;
using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Views;
using UnityEngine;
@@ -89,9 +88,6 @@ namespace eagle {
_model = model;
_availableCommand = ac;
SetUpUI();
// Trigger tutorial for this command type (fires every time command is selected)
TutorialManager.Instance?.TriggerRegistry?.OnCommandPanelShown(CommandType);
}
}
}
@@ -22,6 +22,8 @@ namespace eagle {
public class EagleGameController : MonoBehaviour {
public SoundManager soundManager;
public RollPanelController rollPanelController;
public TextMeshProUGUI roundStatusLabel;
public TextMeshProUGUI connectionStatusLabel;
private bool _connectionStatusUIInitialized = false;
@@ -246,7 +248,11 @@ namespace eagle {
int? playerId,
PersistentClientConnection persistentClientConnection,
string environmentName = null) {
ModelUpdater = new GameModelUpdater(gameId, playerId, persistentClientConnection) {
ModelUpdater = new GameModelUpdater(
gameId,
playerId,
persistentClientConnection,
rollPanelController) {
UpdateAction = ModelUpdated,
NoteRecipient = (title, text, llmId, pids, displayedHeroes) =>
notificationPanel.AddNote(title, text, llmId, pids, displayedHeroes)
@@ -278,13 +284,11 @@ namespace eagle {
MainQueue.Q.EnqueueForNextUpdate(
() => { _ = ModelUpdater.StartListeningForUpdates(); });
// Initialize tutorial system (onboarding starts after first model update in SwapModel)
// Initialize tutorial system
TutorialManager.Instance?.Initialize(this, null);
// Register command panel for tutorial positioning
if (TutorialManager.Instance?.TargetRegistry != null && commandPanel != null) {
TutorialManager.Instance.TargetRegistry.RegisterTarget(
"CommandPanel",
commandPanel.GetComponent<RectTransform>());
if (TutorialManager.Instance != null &&
!TutorialManager.Instance.State.OnboardingCompleted) {
TutorialManager.Instance.StartOnboarding();
}
#if UNITY_EDITOR
@@ -503,12 +507,6 @@ namespace eagle {
if (Model == null) { return; }
// Start onboarding after first model update (UI panels are now populated)
if (oldModel == null && TutorialManager.Instance != null &&
!TutorialManager.Instance.State.OnboardingCompleted) {
TutorialManager.Instance.StartOnboarding();
}
PrefetchHeadshots(Model);
SetMusic();
@@ -13,6 +13,7 @@ using Net.Eagle0.Eagle.Views;
using Net.Eagle0.Shardok.Common;
using TMPro;
using UnityEngine;
using UnityGoDiceInterface;
using Logger = common.Logger;
using Notification = eagle.Notifications.Notification;
@@ -57,6 +58,8 @@ namespace eagle {
public List<ProvinceId> ProvincesForFaction(FactionId factionId);
public List<ChronicleEntry> ChronicleEntries { get; }
RollFetcher RollFetcher { get; }
}
public class GameModelUpdater : IClientConnectionSubscriber, IGameStateProvider {
@@ -147,6 +150,8 @@ namespace eagle {
private readonly ConcurrentDictionary<string, int> _shardokResultCounts =
new ConcurrentDictionary<string, int>();
private readonly RollFetcher _rollFetcher;
// State synced with server
private struct ConcreteGameModel : IGameModel {
public PlayerId? PlayerId { get; set; }
@@ -215,6 +220,8 @@ namespace eagle {
var faction = MaybeDestroyedFaction(factionId);
textUpdater.SetFactionText(textComponent, faction, this, template, fallbackText);
}
public RollFetcher RollFetcher { get; set; }
}
private ConcreteGameModel _currentModel;
@@ -222,9 +229,11 @@ namespace eagle {
public GameModelUpdater(
GameId eagleGameId,
FactionId? factionId,
PersistentClientConnection pers) {
PersistentClientConnection pers,
RollFetcher rollFetcher) {
GameId = eagleGameId;
PersistentConnection = pers;
this._rollFetcher = rollFetcher;
_currentModel.LastPostedToken = -1;
_currentModel.PlayerId = factionId;
@@ -236,6 +245,8 @@ namespace eagle {
new ConcurrentDictionary<ShardokGameId, ShardokGameModel>();
_currentModel.BattalionTypes = new Dictionary<BattalionTypeId, BattalionType>();
_currentModel.RollFetcher = rollFetcher;
}
private ShardokGameModel MakeGameModel(ShardokGameId shardokGameId) {
@@ -283,7 +294,8 @@ namespace eagle {
kv => kv.Value.ImagePath),
battalionNames: _currentModel.BattalionNames.ToDictionary(
kv => kv.Key,
kv => kv.Value));
kv => kv.Value),
rollFetcher: _rollFetcher);
return shardokModel;
}
@@ -1,7 +1,6 @@
using System.Collections.Generic;
using System.Linq;
using common;
using Eagle0.Tutorial;
using Net.Eagle0.Eagle.Views;
using UnityEngine;
@@ -137,25 +136,6 @@ namespace eagle {
row.Hero = hero;
SetHeroRowSelections(row, hero);
});
// Register hero rows with tutorial system for highlighting
RegisterHeroRowsForTutorial();
}
private void RegisterHeroRowsForTutorial() {
var registry = TutorialManager.Instance?.TargetRegistry;
if (registry == null) return;
// Register first row as WarlordRow (index 0)
var warlordRow = heroesTable.GetRowRectTransform(0);
if (warlordRow != null) { registry.RegisterTarget("WarlordRow", warlordRow); }
// Register rows 1 and 2 as VassalRows
var vassalRow1 = heroesTable.GetRowRectTransform(1);
if (vassalRow1 != null) { registry.RegisterTarget("VassalRow1", vassalRow1); }
var vassalRow2 = heroesTable.GetRowRectTransform(2);
if (vassalRow2 != null) { registry.RegisterTarget("VassalRow2", vassalRow2); }
}
private void SetUpBattalionsTable() {
@@ -1,117 +0,0 @@
fileFormatVersion: 2
guid: 68f43a341139b445688f2326a98d5567
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,117 +0,0 @@
fileFormatVersion: 2
guid: 7bf9379c6210e47b88bbe9c2a359d54f
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,117 +0,0 @@
fileFormatVersion: 2
guid: 564fa5e6e47304264b8d471e1b6f81a8
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapW: 0
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: -1
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,117 +0,0 @@
fileFormatVersion: 2
guid: d3ab99e02ad304404a2d663e3d77db5d
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,117 +0,0 @@
fileFormatVersion: 2
guid: 8bac7b3a2d18d452197ea3aa4e3e4855
TextureImporter:
internalIDToNameTable: []
externalObjects: {}
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
borderMipMap: 0
mipMapsPreserveCoverage: 0
alphaTestReferenceValue: 0.5
mipMapFadeDistanceStart: 1
mipMapFadeDistanceEnd: 3
bumpmap:
convertToNormalMap: 0
externalNormalMap: 0
heightScale: 0.25
normalMapFilter: 0
flipGreenChannel: 0
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMipmapLimit: 0
grayScaleToAlpha: 0
generateCubemap: 6
cubemapConvolution: 0
seamlessCubemap: 0
textureFormat: 1
maxTextureSize: 2048
textureSettings:
serializedVersion: 2
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 100
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
swizzle: 50462976
cookieLightType: 0
platformSettings:
- serializedVersion: 4
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 4
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
ignorePlatformSupport: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites: []
outline: []
customData:
physicsShape: []
bones: []
spriteID:
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
spriteCustomMetadata:
entries: []
nameFileIdTable: {}
mipmapLimitGroupName:
pSDRemoveMatte: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -299,36 +299,6 @@ namespace eagle {
}
}
/// <summary>
/// Remove all pending commands for a specific game.
/// Called when the server confirms a command was processed (SUCCESS or BAD_TOKEN).
/// </summary>
private void RemovePendingCommandsForGame(long gameId) {
lock (this) {
var toRemove = _pendingCommands.Where(cmd => cmd.GameId == gameId).ToList();
foreach (var cmd in toRemove) { _pendingCommands.Remove(cmd); }
if (toRemove.Count > 0) {
_remoteEagleClientLogger.LogLine(
$"[POST] Removed {toRemove.Count} pending command(s) for game {gameId}");
}
}
}
/// <summary>
/// Refresh the subscription for a specific game to get fresh state from the server.
/// Used when a command is rejected (e.g., BAD_TOKEN) and we need current state.
/// </summary>
private void RefreshGameSubscription(long gameId) {
IClientConnectionSubscriber subscriber;
lock (this) { _subscribers.TryGetValue(gameId, out subscriber); }
if (subscriber != null) {
_ = StreamOneGameAsync(subscriber);
} else {
_remoteEagleClientLogger.LogLine(
$"[REFRESH] No subscriber found for game {gameId}");
}
}
public async Task Connect() {
// Prevent concurrent connection attempts
if (_isConnecting) {
@@ -498,13 +468,7 @@ namespace eagle {
await PostRequest(nextCommand);
} else if (eagleToken > providedToken) {
_remoteEagleClientLogger.LogLine(
$"{providedToken} seems to be stale (current token " +
$"{eagleToken}), dropping and refreshing state");
// Server already processed this command and advanced
// the token. Re-subscribe to ensure we have current
// state, especially if turn passed and we need new
// commands.
_ = StreamOneGameAsync(subscriber);
$"{providedToken} seems to be stale, dropping");
} else {
_remoteEagleClientLogger.LogLine(
$"{providedToken} seems to be from the future, adding back to the queue");
@@ -539,10 +503,7 @@ namespace eagle {
await PostRequest(nextCommand);
} else {
_remoteEagleClientLogger.LogLine(
$"Shardok token mismatch: pending={providedShardokToken} " +
$"current={currentShardokToken}, dropping and refreshing state");
// Server processed command, token advanced. Refresh state.
_ = StreamOneGameAsync(subscriber);
$"Shardok token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
}
break;
@@ -567,10 +528,7 @@ namespace eagle {
await PostRequest(nextCommand);
} else {
_remoteEagleClientLogger.LogLine(
$"Shardok placement token mismatch: pending={providedShardokToken} " +
$"current={currentShardokToken}, dropping and refreshing state");
// Server processed command, token advanced. Refresh state.
_ = StreamOneGameAsync(subscriber);
$"Shardok placement token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
}
break;
@@ -700,12 +658,11 @@ namespace eagle {
return true;
});
// IMPORTANT: Do NOT remove from pending here even if write succeeded.
// WriteAsync completing only means data was written to local buffers,
// not that the server received and processed it. The command stays in
// _pendingCommands until we receive PostCommandResponse SUCCESS or
// TryPendingCommands sees the token has advanced (command was processed).
if (!success) {
// Only remove from pending if successfully sent.
// If connection was dead, leave in queue for retry after reconnect.
if (success) {
lock (this) { _pendingCommands.Remove(request); }
} else {
_remoteEagleClientLogger.LogLine(
$"[POST] Command not sent (connection dead), keeping in pending queue for retry");
}
@@ -810,19 +767,12 @@ namespace eagle {
await Task.WhenAny(actionTask, timeoutTask).ConfigureAwait(false);
if (completedTask == timeoutTask) {
// WriteAsync timed out - connection is dead
// WriteAsync timed out - connection is likely dead
LogConnectionEvent(
"write_timeout",
$"WriteAsync timed out after {WriteAsyncTimeoutMs}ms");
_remoteEagleClientLogger.LogLine(
$"[WRITE_TIMEOUT] WriteAsync timed out after {WriteAsyncTimeoutMs}ms, triggering reconnect");
// Dispose the dead connection and schedule reconnect
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
}
ScheduleReconnect("WriteAsyncTimeout");
$"[WRITE_TIMEOUT] WriteAsync timed out after {WriteAsyncTimeoutMs}ms, connection may be dead");
return false;
}
@@ -830,31 +780,15 @@ namespace eagle {
timeoutCts.Cancel();
return await actionTask.ConfigureAwait(false);
} catch (RpcException e) {
_remoteEagleClientLogger.LogLine(
$"[WRITE_ERROR] RpcException: {e.StatusCode} - {e.Message}");
// Connection is broken - dispose and reconnect
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
if (e.StatusCode == StatusCode.Cancelled) {
// This is expected when the connection is closed.
return false;
} else {
throw;
}
if (e.StatusCode != StatusCode.Cancelled) {
// Cancelled is expected during normal shutdown, don't reconnect
ScheduleReconnect($"RpcException-{e.StatusCode}");
}
return false;
} catch (OperationCanceledException) {
// Timeout was triggered
return false;
} catch (Exception e) {
_remoteEagleClientLogger.LogLine(
$"[WRITE_ERROR] Exception: {e.GetType().Name} - {e.Message}");
// Unknown error - dispose and reconnect
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
}
ScheduleReconnect($"WriteException-{e.GetType().Name}");
return false;
}
}
@@ -1139,41 +1073,6 @@ namespace eagle {
}
ackTcs?.TrySetResult(ack);
break;
case UpdateStreamResponse.ResponseDetailsOneofCase.PostCommandResponse:
var postResponse = current.PostCommandResponse;
if (postResponse.Status == PostCommandResponse.Types.Status.Error) {
_remoteEagleClientLogger.LogLine(
$"[POST] Server returned ERROR: {postResponse.ErrorMessage}");
LogConnectionEvent(
"post_command_error",
postResponse.ErrorMessage);
// Disconnect and let normal reconnect flow handle recovery
_streamingCall?.Dispose();
_streamingCall = null;
} else if (
postResponse.Status ==
PostCommandResponse.Types.Status.BadToken) {
_remoteEagleClientLogger.LogLine(
$"[POST] Server returned BAD_TOKEN for game {postResponse.GameId} " +
"- command rejected, refreshing state");
// Server rejected command due to stale token. Remove from
// pending (already processed) and re-subscribe to ensure we
// have current state.
RemovePendingCommandsForGame(postResponse.GameId);
RefreshGameSubscription(postResponse.GameId);
} else if (
postResponse.Status ==
PostCommandResponse.Types.Status.Success) {
_remoteEagleClientLogger.LogLine(
$"[POST] Server confirmed command for game {postResponse.GameId}");
// Command was successfully processed. Remove from pending.
RemovePendingCommandsForGame(postResponse.GameId);
}
// UNKNOWN is benign (old servers that don't set status)
break;
}
@@ -1,13 +0,0 @@
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();
}
}
@@ -1,120 +0,0 @@
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
}
File diff suppressed because it is too large Load Diff
@@ -1,6 +1,3 @@
System.Runtime.CompilerServices.Unsafe/**
!**/*.psd
**/*.dll
# Native plugins built at CI time
DarwinGodiceBundle.bundle/
macOS/SparklePlugin.bundle/
@@ -0,0 +1,33 @@
fileFormatVersion: 2
guid: b5b6db3c9b9e34b82ae9163a61276c8a
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 0
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
Any:
second:
enabled: 0
settings: {}
- first:
Editor: Editor
second:
enabled: 1
settings:
DefaultValueInitialized: true
- first:
Standalone: OSXUniversal
second:
enabled: 1
settings:
CPU: AnyCPU
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,27 @@
fileFormatVersion: 2
guid: 1fc634da07f5047c99dba5ce558b5886
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 0
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
Any:
second:
enabled: 1
settings: {}
- first:
Editor: Editor
second:
enabled: 0
settings:
DefaultValueInitialized: true
userData:
assetBundleName:
assetBundleVariant:
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 3c5d7e9f1a2b4c6d8e0f2a4b6c8d0e2f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -23,6 +23,10 @@ public class SettingsPanelController : MonoBehaviour {
public Slider tileBorderWidthSlider;
public TMP_Text tileBorderWidthLabel;
public Toggle useGoDiceToggle;
public Button testGoDice;
public RollPanelController rollPanelController;
public Toggle tooltipHugsCursorToggle;
public HoveringTooltip hoveringTooltip;
@@ -38,6 +42,7 @@ public class SettingsPanelController : MonoBehaviour {
private const string SoundEffectsVolumeKey = "soundEffectsVolume";
private const string MusicVolumeKey = "musicVolume";
public const string UseGoDiceKey = "useGoDice";
private const string TooltipHugsCursorKey = "tooltipHugsCursor";
private const string AnimationToggleKey = "animationToggle";
@@ -57,6 +62,7 @@ public class SettingsPanelController : MonoBehaviour {
soundEffectsSlider.value = soundEffectsSource.volume;
musicSlider.value = musicSource.volume;
useGoDiceToggle.isOn = PlayerPrefs.GetInt(UseGoDiceKey, 0) == 1;
tooltipHugsCursorToggle.isOn = PlayerPrefs.GetInt(TooltipHugsCursorKey, 0) == 1;
hoveringTooltip.HugsCursor = tooltipHugsCursorToggle.isOn;
@@ -105,6 +111,17 @@ public class SettingsPanelController : MonoBehaviour {
PlayerPrefs.SetFloat(MusicVolumeKey, val);
}
public void OnUseGoDiceToggleChange(bool val) {
PlayerPrefs.SetInt(UseGoDiceKey, val ? 1 : 0);
testGoDice.interactable = val;
}
public void OnTestGoDiceClick() {
rollPanelController.GetRoll(
new RollRequest { RollType = RollType.D100OpenEnded },
(result) => { Console.Out.WriteLine($"Got ${result}"); });
}
public void OnCursorHugToggleChange(bool val) {
PlayerPrefs.SetInt(TooltipHugsCursorKey, val ? 1 : 0);
hoveringTooltip.HugsCursor = val;
@@ -55,21 +55,6 @@ namespace Shardok {
/// <param name="unitType">Type of battalion (determines boot vs hoof prints)</param>
public void
AnimateMove(int sourceCellIndex, int targetCellIndex, BattalionTypeId unitType) {
AnimateMove(new List<int> { sourceCellIndex, targetCellIndex }, unitType);
}
/// <summary>
/// Animate movement along a path of hex cells.
/// </summary>
/// <param name="pathCellIndices">List of cell indices forming the path (including
/// start)</param> <param name="unitType">Type of battalion (determines boot vs hoof
/// prints)</param>
public void AnimateMove(List<int> pathCellIndices, BattalionTypeId unitType) {
if (pathCellIndices == null || pathCellIndices.Count < 2) {
Debug.LogWarning("MoveAnimator: Path must have at least 2 points");
return;
}
if (bootPrintSprite == null && hoofPrintSprite == null) {
Debug.LogWarning("MoveAnimator: No print sprites assigned");
return;
@@ -80,21 +65,19 @@ namespace Shardok {
return;
}
// Convert cell indices to positions
var pathPositions = new List<Vector2>();
foreach (int cellIndex in pathCellIndices) {
Vector2? pos = _hexGrid.GetCellCenterPosition(cellIndex);
if (pos == null) {
Debug.LogWarning($"MoveAnimator: Invalid cell index {cellIndex}");
return;
}
pathPositions.Add(pos.Value);
Vector2? sourcePos = _hexGrid.GetCellCenterPosition(sourceCellIndex);
Vector2? targetPos = _hexGrid.GetCellCenterPosition(targetCellIndex);
if (sourcePos == null || targetPos == null) {
Debug.LogWarning(
$"MoveAnimator: Invalid cell indices {sourceCellIndex} or {targetCellIndex}");
return;
}
bool isMounted = unitType == BattalionTypeId.LightCavalry ||
unitType == BattalionTypeId.HeavyCavalry;
StartCoroutine(SpawnPrintTrailAlongPath(pathPositions, isMounted));
StartCoroutine(SpawnPrintTrail(sourcePos.Value, targetPos.Value, isMounted));
}
/// <summary>
@@ -105,52 +88,39 @@ namespace Shardok {
}
private IEnumerator SpawnPrintTrail(Vector2 source, Vector2 target, bool isMounted) {
yield return SpawnPrintTrailAlongPath(new List<Vector2> { source, target }, isMounted);
}
private IEnumerator SpawnPrintTrailAlongPath(List<Vector2> pathPositions, bool isMounted) {
var prints = new List<GameObject>();
Vector2 direction = (target - source).normalized;
float totalDistance = Vector2.Distance(source, target);
// Calculate rotation angle for prints to face direction of travel
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg - 90f;
Sprite printSprite = isMounted ? (hoofPrintSprite ?? bootPrintSprite)
: (bootPrintSprite ?? hoofPrintSprite);
Color printColor = isMounted ? hoofPrintColor : bootPrintColor;
int totalPrintIndex = 0;
// Spawn prints along the path
for (int i = 0; i < printsPerHex; i++) {
float t = (i + 1f) / (printsPerHex + 1f);
Vector2 position = Vector2.Lerp(source, target, t);
// Spawn prints along each segment of the path
for (int segmentIdx = 0; segmentIdx < pathPositions.Count - 1; segmentIdx++) {
Vector2 source = pathPositions[segmentIdx];
Vector2 target = pathPositions[segmentIdx + 1];
Vector2 direction = (target - source).normalized;
// Alternate left/right for boot prints (flip horizontally)
bool flipX = !isMounted && (i % 2 == 1);
// Calculate rotation angle for prints to face direction of travel
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg - 90f;
// Lateral offset for alternating prints (feet or front/rear hooves)
Vector2 perpendicular = new Vector2(-direction.y, direction.x);
float offset = (i % 2 == 0) ? -lateralOffset : lateralOffset;
position += perpendicular * offset;
// Spawn prints along this segment
for (int i = 0; i < printsPerHex; i++) {
float t = (i + 1f) / (printsPerHex + 1f);
Vector2 position = Vector2.Lerp(source, target, t);
GameObject print = CreatePrint(position, angle, printSprite, printColor, flipX);
prints.Add(print);
// Alternate left/right for boot prints (flip horizontally)
bool flipX = !isMounted && (totalPrintIndex % 2 == 1);
// Start FIFO fade coroutine for this print
var fadeCoroutine =
StartCoroutine(FadePrintFIFO(print, printLingerTime + i * printInterval));
_activeCoroutines.Add(fadeCoroutine);
// Lateral offset for alternating prints (feet or front/rear hooves)
Vector2 perpendicular = new Vector2(-direction.y, direction.x);
float offset = (totalPrintIndex % 2 == 0) ? -lateralOffset : lateralOffset;
position += perpendicular * offset;
GameObject print = CreatePrint(position, angle, printSprite, printColor, flipX);
prints.Add(print);
// Start FIFO fade coroutine for this print
var fadeCoroutine = StartCoroutine(FadePrintFIFO(
print,
printLingerTime + totalPrintIndex * printInterval));
_activeCoroutines.Add(fadeCoroutine);
totalPrintIndex++;
yield return new WaitForSeconds(printInterval);
}
yield return new WaitForSeconds(printInterval);
}
// Wait for all fades to complete
@@ -480,9 +480,6 @@ namespace Shardok {
SetModifiers();
UpdateReserves();
// Notify tutorial system of available commands (for spell/ability tutorials)
TutorialManager.Instance?.TriggerRegistry?.OnTacticalCommandsAvailable(Model);
HandleEnemyStartingPositionOverlays();
endTurnButton.interactable = false;
@@ -1534,15 +1531,13 @@ namespace Shardok {
/// For two-stage sounds (when isOwnCommand=true), plays attempt sound and tracks pending.
/// For single-stage or other player actions, plays full sound.
/// </summary>
/// <param name="movePath">Optional path for move animations (list of grid indices)</param>
void PlayAnimationAndSound(
AnimationType animationType,
int sourceGridIndex,
int targetGridIndex,
UnitViewWithName attackerUnit,
UnitViewWithName defenderUnit,
bool isOwnCommand = false,
List<int> movePath = null) {
bool isOwnCommand = false) {
if (animationType == AnimationType.None) return;
// Handle sound based on whether this is own command with two-stage sounds
@@ -1581,14 +1576,10 @@ namespace Shardok {
break;
case AnimationType.Move:
if (moveAnimator != null && attackerUnit != null) {
if (movePath != null && movePath.Count >= 2) {
moveAnimator.AnimateMove(movePath, attackerUnit.Battalion.Type);
} else {
moveAnimator.AnimateMove(
sourceGridIndex,
targetGridIndex,
attackerUnit.Battalion.Type);
}
moveAnimator.AnimateMove(
sourceGridIndex,
targetGridIndex,
attackerUnit.Battalion.Type);
}
break;
case AnimationType.LightningBolt:
@@ -1906,22 +1897,13 @@ namespace Shardok {
Coords finishCoords = GridIndexToMapCoords(finishGridIndex);
var commandTypes = commandTypeUIManager.CommandTypesForGroup(_displayedCommandGroup);
var executedCommand =
CommandType? executedCommand =
Model.PerformTargetedCommand(startCoords, finishCoords, commandTypes);
if (executedCommand != null) {
var attackerUnit = Model.UnitAtCoords(startCoords);
var defenderUnit = Model.UnitAtCoords(finishCoords);
var animationType = AnimationTypeForCommand(executedCommand.Type);
// Extract path for move animations (prepend start position)
List<int> movePath = null;
if (executedCommand.Path.Count > 0) {
movePath = new List<int> { startGridIndex };
foreach (var coords in executedCommand.Path) {
movePath.Add(MapCoordsToGridIndex(coords));
}
}
var animationType = AnimationTypeForCommand(executedCommand.Value);
if (animationType != AnimationType.None) {
PlayAnimationAndSound(
@@ -1930,8 +1912,7 @@ namespace Shardok {
finishGridIndex,
attackerUnit,
defenderUnit,
isOwnCommand: true,
movePath: movePath);
isOwnCommand: true);
} else if (Model.InSetUp) {
// For commands without specific animations, play generic click in setup
audioClipSource.PlayOneShot(soundManager.GenericClickSound());
@@ -6,6 +6,7 @@ using eagle0;
using UnityEngine;
using Net.Eagle0.Shardok.Api;
using Net.Eagle0.Shardok.Common;
using UnityGoDiceInterface;
using BattalionId = System.Int32;
using UnitId = System.Int32;
using PlayerId = System.Int32;
@@ -132,6 +133,8 @@ public class ShardokGameModel {
private const int StartingHistoryCapacity = 100;
private readonly RollFetcher _rollFetcher;
public ShardokGameModel(
EagleGameId eagleGameId,
ShardokGameId shardokGameId,
@@ -143,11 +146,13 @@ public class ShardokGameModel {
List<PlayerWithHostility> players,
Dictionary<HeroId, String> heroNameTextIds,
Dictionary<HeroId, String> heroImages,
Dictionary<BattalionId, String> battalionNames) {
Dictionary<BattalionId, String> battalionNames,
RollFetcher rollFetcher) {
EagleGameId = eagleGameId;
ShardokGameId = shardokGameId;
History = new List<ActionResultView>(StartingHistoryCapacity);
_persistentClientConnection = persistentClientConnection;
this._rollFetcher = rollFetcher;
PlayerId = playerId;
this.players = new List<PlayerWithHostility>(players);
@@ -269,14 +274,16 @@ public class ShardokGameModel {
return true;
}
public CommandDescriptor
PerformTargetedCommand(Coords start, Coords target, List<CommandType> possibleTypes) {
public CommandType? PerformTargetedCommand(
Coords start,
Coords target,
List<CommandType> possibleTypes) {
List<CommandDescriptor> heroActions = GetCommandsForCoords(start);
foreach (CommandDescriptor action in heroActions) {
if (target.Equals(action.Target) && possibleTypes.Contains(action.Type)) {
PostAction(action);
return action;
return action.Type;
}
}
return null;
@@ -345,14 +352,25 @@ public class ShardokGameModel {
var index = AvailableCommands.IndexOf(action);
AvailableCommands.Clear();
// Physical dice roll support removed - server generates random rolls
_persistentClientConnection.PostShardokCommand(
EagleGameId,
ShardokGameId,
PlayerId,
History.Count(),
index,
null);
if (action.RollRequest != null) {
_rollFetcher.GetRoll(action.RollRequest, roll => {
_persistentClientConnection.PostShardokCommand(
EagleGameId,
ShardokGameId,
PlayerId,
History.Count(),
index,
roll);
});
} else {
_persistentClientConnection.PostShardokCommand(
EagleGameId,
ShardokGameId,
PlayerId,
History.Count(),
index,
null);
}
}
private void PostGameSetupActions() {

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