Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 ad974357f4 Update deproto plan: add DiplomacyOfferStatus and next candidates
- Document completed DiplomacyOfferStatus enum migration (PR #5093)
- Add Enum Type Migrations section tracking proto enum conversions
- Add Next Candidates section with priority items for future work

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 05:36:47 -08:00
adminandClaude Opus 4.5 d9dfb5cbb2 Deproto: Use Scala Status in EligibleDiplomacyStatuses
Convert EligibleDiplomacyStatuses to use Scala Status types internally,
with conversion to proto at the call sites where needed for building
AvailableCommand proto messages.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-08 21:40:31 -08:00
225 changed files with 12743 additions and 22769 deletions
+4 -36
View File
@@ -105,9 +105,6 @@ jobs:
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -119,7 +116,7 @@ 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,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
script: |
set -x
cd /opt/eagle0
@@ -139,38 +136,9 @@ jobs:
grep -v '^GOOGLE_CLIENT_SECRET=' .env.tmp4 > .env.tmp5 || true
echo "GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}" >> .env.tmp5
# Update JWT private key (JWK format for auth service bootstrap)
grep -v '^JWT_PRIVATE_KEY=' .env.tmp5 > .env.tmp6 || true
echo "JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}" >> .env.tmp6
# Update Fastmail JMAP credentials for email sending (only if set)
# Debug: show whether env vars are set (not their values)
echo "FASTMAIL_API_TOKEN set: $([ -n "${FASTMAIL_API_TOKEN:-}" ] && echo 'yes' || echo 'no')"
echo "FASTMAIL_FROM_EMAIL set: $([ -n "${FASTMAIL_FROM_EMAIL:-}" ] && echo 'yes' || echo 'no')"
echo "FASTMAIL_FROM_NAME set: $([ -n "${FASTMAIL_FROM_NAME:-}" ] && echo 'yes' || echo 'no')"
cp .env.tmp6 .env.tmp7
if [ -n "${FASTMAIL_API_TOKEN:-}" ]; then
grep -v '^FASTMAIL_API_TOKEN=' .env.tmp7 > .env.tmp7a || true
# Use printf with %s to avoid shell interpretation of special chars
printf 'FASTMAIL_API_TOKEN=%s\n' "${FASTMAIL_API_TOKEN}" >> .env.tmp7a
mv .env.tmp7a .env.tmp7
fi
cp .env.tmp7 .env.tmp8
if [ -n "${FASTMAIL_FROM_EMAIL:-}" ]; then
grep -v '^FASTMAIL_FROM_EMAIL=' .env.tmp8 > .env.tmp8a || true
printf 'FASTMAIL_FROM_EMAIL=%s\n' "${FASTMAIL_FROM_EMAIL}" >> .env.tmp8a
mv .env.tmp8a .env.tmp8
fi
cp .env.tmp8 .env
if [ -n "${FASTMAIL_FROM_NAME:-}" ]; then
grep -v '^FASTMAIL_FROM_NAME=' .env > .env.tmp9 || true
printf 'FASTMAIL_FROM_NAME=%s\n' "${FASTMAIL_FROM_NAME}" >> .env.tmp9
mv .env.tmp9 .env
fi
# Show what ended up in .env for these vars (masked)
echo "Resulting .env FASTMAIL entries:"
grep '^FASTMAIL' .env | sed 's/=.*/=***/' || echo "(none)"
rm -f .env.tmp .env.tmp2 .env.tmp3 .env.tmp4 .env.tmp5 .env.tmp6 .env.tmp7 .env.tmp8
grep -v '^JWT_PRIVATE_KEY=' .env.tmp5 > .env || true
echo "JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}" >> .env
rm -f .env.tmp .env.tmp2 .env.tmp3 .env.tmp4 .env.tmp5
chmod 600 .env
else
echo "ERROR: .env file not found. Run main deploy first."
+8 -8
View File
@@ -53,26 +53,26 @@ jobs:
run: |
# Upload sysroot tarball to DO Spaces
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.tar.xz \
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
# Upload sha256 file
aws s3 cp tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256 \
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.sha256 \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
echo ""
echo "=== AMD64 Sysroot uploaded ==="
echo "URL: https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz"
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)"
echo ""
echo "Update MODULE.bazel with:"
echo "sysroot("
echo " name = \"linux_sysroot\","
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_amd64_sysroot.sha256)\","
echo " urls = [\"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_amd64_sysroot.tar.xz\"],"
echo ")"
build-sysroot-arm64:
@@ -114,24 +114,24 @@ jobs:
run: |
# Upload sysroot tarball to DO Spaces
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.tar.xz \
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz \
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
# Upload sha256 file
aws s3 cp tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256 \
s3://eagle0-sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.sha256 \
s3://eagle0-windows/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.sha256 \
--endpoint-url https://sfo3.digitaloceanspaces.com \
--acl public-read
echo ""
echo "=== ARM64 Sysroot uploaded ==="
echo "URL: https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz"
echo "URL: https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz"
echo "SHA256: $(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)"
echo ""
echo "Update MODULE.bazel with:"
echo "sysroot("
echo " name = \"linux_sysroot_arm64\","
echo " sha256 = \"$(cat tools/sysroot/output/ubuntu_noble_arm64_sysroot.sha256)\","
echo " urls = [\"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz\"],"
echo " urls = [\"https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/${{ inputs.version }}/ubuntu_noble_arm64_sysroot.tar.xz\"],"
echo ")"
+118 -164
View File
@@ -42,13 +42,6 @@ jobs:
set -ex
bazel build --platforms=//:linux_x86_64 //ci:eagle_server_image
# Also build the warmup tool for Linux
bazel build //src/main/go/net/eagle0/warmup:warmup_linux_amd64
# Copy warmup binary to scripts/ so it gets deployed with other scripts
mkdir -p scripts/bin
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
echo "Image path: $IMAGE_PATH"
@@ -428,7 +421,7 @@ jobs:
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
deploy:
runs-on: self-hosted
runs-on: ubuntu-latest
needs: [build-eagle, build-shardok, build-admin, build-jfr-sidecar]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
@@ -450,174 +443,135 @@ jobs:
SHARDOK_ADDRESS: ${{ secrets.SHARDOK_ADDRESS }}
SHARDOK_AUTH_TOKEN: ${{ secrets.SHARDOK_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DO_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Copy config files to droplet
run: |
scp -i ~/.ssh/deploy_key \
docker-compose.prod.yml nginx/nginx.conf \
scripts/deploy-blue-green.sh scripts/warmup-eagle.sh scripts/bin/warmup \
deploy@"$DO_DROPLET_IP":/opt/eagle0/
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
source: "docker-compose.prod.yml,nginx/nginx.conf"
target: "/opt/eagle0"
- name: Deploy to production droplet
run: |
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << DEPLOY_SCRIPT
set -ex
cd /opt/eagle0
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script_stop: true
envs: EAGLE_IMAGE,SHARDOK_IMAGE,ADMIN_IMAGE,JFR_SIDECAR_IMAGE,OPENAI_API_KEY,GPT_MODEL_NAME,EAGLE_ENABLE_S3,DO_SPACES_ACCESS_KEY,DO_SPACES_SECRET_KEY,JWT_PRIVATE_KEY,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,SHARDOK_ADDRESS,SHARDOK_AUTH_TOKEN,SENTRY_DSN
script: |
set -x
cd /opt/eagle0
# 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}"
DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
# Check Docker has IPv6 support for connecting to Hetzner Shardok
# (One-time setup: sudo tee /etc/docker/daemon.json <<< '{"ipv6": true, "ip6tables": true, "experimental": true, "fixed-cidr-v6": "fd00::/80"}' && sudo systemctl restart docker)
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
echo "Run: sudo tee /etc/docker/daemon.json <<< '{\"ipv6\": true, \"ip6tables\": true, \"experimental\": true, \"fixed-cidr-v6\": \"fd00::/80\"}' && sudo systemctl restart docker"
fi
# Write env vars to .env file for docker-compose
# Preserve AUTH_IMAGE if it exists (managed by auth_build.yml)
EXISTING_AUTH_IMAGE=""
if [ -f .env ]; then
EXISTING_AUTH_IMAGE=\$(grep '^AUTH_IMAGE=' .env | cut -d= -f2- || true)
fi
rm -f .env 2>/dev/null || true
cat > .env << EOF
EAGLE_IMAGE=\${EAGLE_IMAGE}
SHARDOK_IMAGE=\${SHARDOK_IMAGE}
ADMIN_IMAGE=\${ADMIN_IMAGE}
JFR_SIDECAR_IMAGE=\${JFR_SIDECAR_IMAGE}
AUTH_IMAGE=\${EXISTING_AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
OPENAI_API_KEY=\${OPENAI_API_KEY:-}
GPT_MODEL_NAME=\${GPT_MODEL_NAME:-gpt-4o}
EAGLE_ENABLE_S3=\${EAGLE_ENABLE_S3:-false}
DO_SPACES_ACCESS_KEY=\${DO_SPACES_ACCESS_KEY:-}
DO_SPACES_SECRET_KEY=\${DO_SPACES_SECRET_KEY:-}
JWT_PRIVATE_KEY=\${JWT_PRIVATE_KEY:-}
DISCORD_CLIENT_ID=\${DISCORD_CLIENT_ID:-}
DISCORD_CLIENT_SECRET=\${DISCORD_CLIENT_SECRET:-}
GOOGLE_CLIENT_ID=\${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET=\${GOOGLE_CLIENT_SECRET:-}
SHARDOK_ADDRESS=\${SHARDOK_ADDRESS:-shardok:40042}
SHARDOK_AUTH_TOKEN=\${SHARDOK_AUTH_TOKEN:-}
SENTRY_DSN=\${SENTRY_DSN:-}
EOF
chmod 600 .env
# Login to registry
echo "\$DO_REGISTRY_TOKEN" | docker login registry.digitalocean.com -u "\$DO_REGISTRY_TOKEN" --password-stdin
# Use exact image tags passed from build jobs (no :latest fallback)
# Note: AUTH_IMAGE is managed separately by auth_build.yml
echo "Using images: \$EAGLE_IMAGE, \$SHARDOK_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
# Use crane to pull images (handles OCI format correctly) then load into Docker
# This avoids digest mismatch from DO registry's OCI->Docker format conversion
echo "Installing crane..."
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
# crane uses Docker config for auth
echo "Pulling Eagle image with crane..."
./crane pull "\${EAGLE_IMAGE}" eagle.tar || { echo "ERROR: Failed to pull eagle image"; exit 1; }
echo "Loading Eagle image into Docker..."
docker load -i eagle.tar
rm eagle.tar
echo "Pulling Shardok image with crane..."
./crane pull "\${SHARDOK_IMAGE}" shardok.tar || { echo "ERROR: Failed to pull shardok image"; exit 1; }
echo "Loading Shardok image into Docker..."
docker load -i shardok.tar
rm shardok.tar
echo "Pulling Admin image with crane..."
./crane pull "\${ADMIN_IMAGE}" admin.tar || { echo "ERROR: Failed to pull admin image"; exit 1; }
echo "Loading Admin image into Docker..."
docker load -i admin.tar
rm admin.tar
echo "Pulling JFR Sidecar image with crane..."
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar || { echo "ERROR: Failed to pull jfr-sidecar image"; exit 1; }
echo "Loading JFR Sidecar image into Docker..."
docker load -i jfr-sidecar.tar
rm jfr-sidecar.tar
rm ./crane
# Also pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
echo "All images pulled successfully"
# Recreate non-Eagle services (not auth - managed by auth_build.yml)
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate shardok admin jfr-sidecar
# Deploy Eagle with blue-green (zero-downtime) if scripts are available
if [ -x "/opt/eagle0/scripts/deploy-blue-green.sh" ]; then
echo "Using blue-green deployment for Eagle..."
chmod +x /opt/eagle0/scripts/*.sh
# Make warmup binary executable if present
if [ -f "/opt/eagle0/scripts/bin/warmup" ]; then
chmod +x /opt/eagle0/scripts/bin/warmup
# Check Docker has IPv6 support for connecting to Hetzner Shardok
# (One-time setup: sudo tee /etc/docker/daemon.json <<< '{"ipv6": true, "ip6tables": true, "experimental": true, "fixed-cidr-v6": "fd00::/80"}' && sudo systemctl restart docker)
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
echo "Run: sudo tee /etc/docker/daemon.json <<< '{\"ipv6\": true, \"ip6tables\": true, \"experimental\": true, \"fixed-cidr-v6\": \"fd00::/80\"}' && sudo systemctl restart docker"
fi
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}" || {
echo "Blue-green deployment failed, falling back to direct restart"
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle-blue
}
else
echo "Blue-green scripts not found, using direct restart..."
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle-blue
fi
# Ensure auth is running (but don't force-recreate it)
docker compose -f docker-compose.prod.yml up -d auth
# Write env vars to .env file for docker-compose
# Preserve AUTH_IMAGE if it exists (managed by auth_build.yml)
EXISTING_AUTH_IMAGE=""
if [ -f .env ]; then
EXISTING_AUTH_IMAGE=$(grep '^AUTH_IMAGE=' .env | cut -d= -f2- || true)
fi
# Restart nginx to pick up new container IPs
# (nginx caches DNS at startup, so it needs restart after eagle/shardok)
docker compose -f docker-compose.prod.yml up -d --force-recreate nginx
rm -f .env 2>/dev/null || true
cat > .env << EOF
EAGLE_IMAGE=${EAGLE_IMAGE}
SHARDOK_IMAGE=${SHARDOK_IMAGE}
ADMIN_IMAGE=${ADMIN_IMAGE}
JFR_SIDECAR_IMAGE=${JFR_SIDECAR_IMAGE}
AUTH_IMAGE=${EXISTING_AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
OPENAI_API_KEY=${OPENAI_API_KEY:-}
GPT_MODEL_NAME=${GPT_MODEL_NAME:-gpt-4o}
EAGLE_ENABLE_S3=${EAGLE_ENABLE_S3:-false}
DO_SPACES_ACCESS_KEY=${DO_SPACES_ACCESS_KEY:-}
DO_SPACES_SECRET_KEY=${DO_SPACES_SECRET_KEY:-}
JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY:-}
DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}
DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}
GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}
SHARDOK_ADDRESS=${SHARDOK_ADDRESS:-shardok:40042}
SHARDOK_AUTH_TOKEN=${SHARDOK_AUTH_TOKEN:-}
SENTRY_DSN=${SENTRY_DSN:-}
EOF
chmod 600 .env
# Cleanup orphaned containers
docker compose -f docker-compose.prod.yml up -d --remove-orphans
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
# Wait for health checks
sleep 10
docker compose -f docker-compose.prod.yml ps
# Use exact image tags passed from build jobs (no :latest fallback)
# Note: AUTH_IMAGE is managed separately by auth_build.yml
echo "Using images: $EAGLE_IMAGE, $SHARDOK_IMAGE, $ADMIN_IMAGE, $JFR_SIDECAR_IMAGE"
# Verify containers are using correct images
echo "=== Verifying container image tags ==="
docker compose -f docker-compose.prod.yml images
# Use crane to pull images (handles OCI format correctly) then load into Docker
# This avoids digest mismatch from DO registry's OCI->Docker format conversion
echo "Installing crane..."
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
# Cleanup old images
docker image prune -f
DEPLOY_SCRIPT
# crane uses Docker config for auth
echo "Pulling Eagle image with crane..."
./crane pull "${EAGLE_IMAGE}" eagle.tar || { echo "ERROR: Failed to pull eagle image"; exit 1; }
echo "Loading Eagle image into Docker..."
docker load -i eagle.tar
rm eagle.tar
echo "Pulling Shardok image with crane..."
./crane pull "${SHARDOK_IMAGE}" shardok.tar || { echo "ERROR: Failed to pull shardok image"; exit 1; }
echo "Loading Shardok image into Docker..."
docker load -i shardok.tar
rm shardok.tar
echo "Pulling Admin image with crane..."
./crane pull "${ADMIN_IMAGE}" admin.tar || { echo "ERROR: Failed to pull admin image"; exit 1; }
echo "Loading Admin image into Docker..."
docker load -i admin.tar
rm admin.tar
echo "Pulling JFR Sidecar image with crane..."
./crane pull "${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar || { echo "ERROR: Failed to pull jfr-sidecar image"; exit 1; }
echo "Loading JFR Sidecar image into Docker..."
docker load -i jfr-sidecar.tar
rm jfr-sidecar.tar
rm ./crane
# Also pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
echo "All images pulled successfully"
# Recreate only the services we're updating (not auth - managed by auth_build.yml)
# This prevents restarting auth service during every Eagle deploy
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle shardok admin jfr-sidecar
# Ensure auth is running (but don't force-recreate it)
docker compose -f docker-compose.prod.yml up -d auth
# Restart nginx to pick up new container IPs
# (nginx caches DNS at startup, so it needs restart after eagle/shardok)
docker compose -f docker-compose.prod.yml up -d --force-recreate nginx
# Cleanup orphaned containers
docker compose -f docker-compose.prod.yml up -d --remove-orphans
# Wait for health checks
sleep 10
docker compose -f docker-compose.prod.yml ps
# Verify containers are using correct images
echo "=== Verifying container image tags ==="
docker compose -f docker-compose.prod.yml images
# Cleanup old images
docker image prune -f
+4 -6
View File
@@ -40,15 +40,13 @@ jobs:
echo ""
# List of repositories to clean
# Use tail to skip header row in case --no-header doesn't work
REPOS=$(doctl registry repository list-v2 --format Name --no-header | grep -v '^Name$' | grep -v '^$')
REPOS=$(doctl registry repository list-v2 --format Name --no-header)
for REPO in $REPOS; do
echo "=== Processing repository: ${REPO} ==="
# 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 "")
MANIFESTS=$(doctl registry repository list-manifests "${REPO}" --format Digest,UpdatedAt,Tags --no-header 2>/dev/null || echo "")
if [ -z "$MANIFESTS" ]; then
echo " No manifests found"
@@ -56,8 +54,8 @@ jobs:
fi
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
# Skip if no digest
if [ -z "$DIGEST" ]; then
continue
fi
+33 -45
View File
@@ -159,63 +159,51 @@ jobs:
echo "Also tagged as: registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
deploy-hetzner:
runs-on: self-hosted
runs-on: ubuntu-latest
needs: [build-shardok-arm64]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
env:
SHARDOK_IMAGE: ${{ needs.build-shardok-arm64.outputs.image_tag }}
steps:
- name: Setup SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.HETZNER_SSH_KEY }}" > ~/.ssh/hetzner_deploy
chmod 600 ~/.ssh/hetzner_deploy
# Add host key to known_hosts to avoid prompt
ssh-keyscan -H ${{ secrets.HETZNER_IP }} >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Deploy to Hetzner
run: |
ssh -i ~/.ssh/hetzner_deploy -o StrictHostKeyChecking=accept-new deploy@${{ secrets.HETZNER_IP }} << 'ENDSSH'
set -ex
cd /opt/eagle0
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.HETZNER_IP }}
username: deploy
key: ${{ secrets.HETZNER_SSH_KEY }}
protocol: tcp6
script_stop: true
envs: SHARDOK_IMAGE
script: |
set -ex
cd /opt/eagle0
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
echo "Deploying Shardok ARM64: ${{ needs.build-shardok-arm64.outputs.image_tag }}"
echo "Deploying Shardok ARM64: $SHARDOK_IMAGE"
# Pull the new image
docker pull "${{ needs.build-shardok-arm64.outputs.image_tag }}"
# Pull the new image
docker pull "$SHARDOK_IMAGE"
# Stop and remove any container using port 40042 or named shardok*
docker ps -q --filter "publish=40042" | xargs -r docker stop
docker ps -aq --filter "name=shardok" | xargs -r docker rm -f
docker ps -aq --filter "publish=40042" | xargs -r docker rm -f
# Stop and remove existing container
docker stop shardok-ai 2>/dev/null || true
docker rm shardok-ai 2>/dev/null || true
# Run new container
docker run -d \
--name shardok-ai \
--restart unless-stopped \
-p 40042:40042 \
-v /opt/eagle0/data:/data \
-v /etc/shardok:/etc/shardok:ro \
-v /etc/letsencrypt:/etc/letsencrypt:ro \
-v /usr/local/share/eagle0:/usr/local/share/eagle0:ro \
-e SHARDOK_RESOURCES_PATH=/app/resources \
-e SHARDOK_MAPS_PATH=/app/resources/maps \
"${{ needs.build-shardok-arm64.outputs.image_tag }}"
# Run new container
docker run -d \
--name shardok-ai \
--restart unless-stopped \
-p 40042:40042 \
-v /opt/eagle0/data:/data \
"$SHARDOK_IMAGE"
# Wait and verify
sleep 5
docker ps | grep shardok-ai
# Wait and verify
sleep 5
docker ps | grep shardok-ai
# Cleanup old images
docker image prune -f
# Cleanup old images
docker image prune -f
echo "=== Hetzner deployment complete ==="
ENDSSH
- name: Cleanup SSH key
if: always()
run: rm -f ~/.ssh/hetzner_deploy
echo "=== Hetzner deployment complete ==="
+2 -2
View File
@@ -88,14 +88,14 @@ sysroot = use_repo_rule("@toolchains_llvm//toolchain:sysroot.bzl", "sysroot")
sysroot(
name = "linux_sysroot",
sha256 = "a06475004fe8003ae7ccb4fe1d5511feb9b27cce4a8826eb1dfd686ed83f3dba",
urls = ["https://eagle0-sysroot.sfo3.digitaloceanspaces.com/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v3/ubuntu_noble_amd64_sysroot.tar.xz"],
)
# ARM64 sysroot
sysroot(
name = "linux_sysroot_arm64",
sha256 = "87469137737e09bc73855007dab835477eb10a7b3ce3f725f93f64e25747f3f9",
urls = ["https://eagle0-sysroot.sfo3.digitaloceanspaces.com/v4/ubuntu_noble_arm64_sysroot.tar.xz"],
urls = ["https://eagle0-windows.sfo3.digitaloceanspaces.com/sysroot/v4/ubuntu_noble_arm64_sysroot.tar.xz"],
)
#
+8 -68
View File
@@ -8,13 +8,9 @@
# Run: docker compose -f docker-compose.prod.yml up -d
services:
# Blue-green deployment: eagle-blue is the primary (production) instance
# eagle-green is the staging instance for zero-downtime deployments
# See scripts/deploy-blue-green.sh for deployment workflow
eagle-blue:
eagle:
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
container_name: eagle-blue
container_name: eagle-server
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
@@ -43,7 +39,7 @@ services:
volumes:
- ./saves:/app/saves # Game saves and user database
- ./archived:/app/archived # Archived completed games
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-blue jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
- 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:
@@ -62,58 +58,6 @@ services:
retries: 3
start_period: 30s
eagle-green:
image: ${EAGLE_IMAGE_NEW:-registry.digitalocean.com/eagle0/eagle-server:latest}
container_name: eagle-green
profiles: ["blue-green"] # Only started during blue-green deployment
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "${SHARDOK_ADDRESS:-shardok:40042}"
- "--auth-service-url"
- "auth:40033"
ports:
- "40034:40032" # Different host port for staging
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
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:-}"
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
EAGLE_SAVE_DIR: "/app/saves"
EAGLE_ARCHIVE_DIR: "/app/archived"
SENTRY_DSN: "${SENTRY_DSN:-}"
SENTRY_ENVIRONMENT: "production"
volumes:
- ./saves:/app/saves # Same save directory as blue
- ./archived:/app/archived # Same archive directory as blue
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
depends_on:
- shardok
- auth
restart: "no" # Don't auto-restart during deployment
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 40032 || exit 1"]
interval: 10s
timeout: 5s
retries: 6
start_period: 60s
# Backward compatibility alias - for scripts that reference 'eagle' service
eagle:
extends:
service: eagle-blue
auth:
image: ${AUTH_IMAGE:-registry.digitalocean.com/eagle0/auth-server:latest}
container_name: auth-server
@@ -136,10 +80,6 @@ services:
# JWT keys - PEM files in volume, bootstrapped from JWK on first run
JWT_KEYS_PATH: "/etc/eagle0/keys"
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
# Fastmail JMAP API for sending invitation emails
FASTMAIL_API_TOKEN: "${FASTMAIL_API_TOKEN:-}"
FASTMAIL_FROM_EMAIL: "${FASTMAIL_FROM_EMAIL:-}"
FASTMAIL_FROM_NAME: "${FASTMAIL_FROM_NAME:-}"
# Note: port 40033 is exposed via nginx, not directly
volumes:
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with Eagle
@@ -196,7 +136,7 @@ services:
- ./certbot/www:/var/www/certbot:ro
- ./auth:/etc/nginx/auth:ro
depends_on:
- eagle-blue
- eagle
- admin
restart: unless-stopped
logging:
@@ -210,7 +150,7 @@ services:
container_name: admin-server
command:
- "--eagle-addr"
- "${EAGLE_ADDR:-eagle-blue:40032}" # Can be switched for blue-green
- "eagle:40032"
- "--auth-addr"
- "auth:40033"
- "--jfr-sidecar-addr"
@@ -219,7 +159,7 @@ services:
- "8080"
# No external port - accessed via nginx at admin.eagle0.net
depends_on:
- eagle-blue
- eagle
- auth
- jfr-sidecar
restart: unless-stopped
@@ -239,11 +179,11 @@ 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
pid: "service:eagle-blue"
pid: "service:eagle"
volumes:
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
depends_on:
- eagle-blue
- eagle
restart: unless-stopped
logging:
driver: "json-file"
+1 -3
View File
@@ -25,10 +25,8 @@ http {
resolver 127.0.0.11 valid=10s ipv6=off;
# Upstream for Eagle gRPC server
# For blue-green deployments, this is switched between eagle-blue:40032 and eagle-green:40032
# by scripts/deploy-blue-green.sh, then nginx is reloaded with 'nginx -s reload'
upstream eagle_grpc {
server eagle-blue:40032;
server eagle:40032;
keepalive 100;
}
-207
View File
@@ -1,207 +0,0 @@
#!/bin/bash
#
# Blue-Green Deployment Script for Eagle Server
#
# This script performs a zero-downtime deployment by:
# 1. Starting the new version on a staging port (green)
# 2. Waiting for it to become healthy
# 3. Running warmup traffic to pre-heat the JIT
# 4. Stopping the old version (blue) - which flushes state to disk
# 5. Telling green to reload games from disk
# 6. Switching nginx to route traffic to green
# 7. Cleaning up
#
# Usage: ./deploy-blue-green.sh [NEW_IMAGE_TAG]
#
# Example:
# ./deploy-blue-green.sh latest
# ./deploy-blue-green.sh sha-abc123
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
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"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Determine which instance is currently active
get_active_instance() {
if grep -q "eagle-blue:40032" "${NGINX_CONF}"; then
echo "blue"
elif grep -q "eagle-green:40032" "${NGINX_CONF}"; then
echo "green"
else
log_error "Cannot determine active instance from nginx config"
exit 1
fi
}
# Wait for a container to be healthy
wait_for_healthy() {
local container=$1
local max_attempts=${2:-60}
local attempt=1
log_info "Waiting for ${container} to become healthy..."
while [ $attempt -le $max_attempts ]; do
health=$(docker inspect --format='{{.State.Health.Status}}' "${container}" 2>/dev/null || echo "unknown")
if [ "$health" = "healthy" ]; then
log_info "${container} is healthy"
return 0
fi
echo -n "."
sleep 2
attempt=$((attempt + 1))
done
echo ""
log_error "${container} did not become healthy after $((max_attempts * 2)) seconds"
return 1
}
# Main deployment logic
main() {
local new_tag="${1:-latest}"
local registry="registry.digitalocean.com/eagle0/eagle-server"
local new_image="${registry}:${new_tag}"
log_info "Starting blue-green deployment"
log_info "New image: ${new_image}"
cd "${APP_DIR}"
# Determine current active instance
local active=$(get_active_instance)
local staging
if [ "$active" = "blue" ]; then
staging="green"
else
staging="blue"
fi
log_info "Active instance: eagle-${active}"
log_info "Staging instance: eagle-${staging}"
# Pull the new image
log_info "Pulling new image..."
docker pull "${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
else
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}"
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
exit 1
fi
# Run warmup/smoke test
local staging_port
if [ "$staging" = "green" ]; then
staging_port=40034
else
staging_port=40032
fi
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}"
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
exit 1
fi
else
log_warn "Warmup script not found at ${WARMUP_SCRIPT}, skipping warmup"
log_warn "JIT will be cold on first requests"
fi
# Stop the active instance (this flushes state to disk)
log_info "Stopping eagle-${active} (flushing state to disk)..."
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
# 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
# Reload nginx
log_info "Reloading nginx..."
docker compose -f "${COMPOSE_FILE}" exec nginx nginx -s reload
# Clean up old instance
log_info "Removing old eagle-${active} container..."
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}"
# Update the staging instance's restart policy and image var
# For blue, we need to update EAGLE_IMAGE; for green, update EAGLE_IMAGE_NEW
if [ "$staging" = "blue" ]; then
log_info "Updating EAGLE_IMAGE to ${new_image} for future restarts"
# User should update their .env file
else
log_info "Green is now active. Consider switching to blue on next deployment."
fi
log_info "Deployment complete!"
log_info "Active instance: eagle-${staging}"
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
check_requirements() {
if ! command -v docker &> /dev/null; then
log_error "docker is required but not installed"
exit 1
fi
if ! command -v sed &> /dev/null; then
log_error "sed is required but not installed"
exit 1
fi
if [ ! -f "${NGINX_CONF}" ]; then
log_error "nginx config not found at ${NGINX_CONF}"
exit 1
fi
if [ ! -f "${COMPOSE_FILE}" ]; then
log_error "docker-compose file not found at ${COMPOSE_FILE}"
exit 1
fi
}
# Run
check_requirements
main "$@"
-142
View File
@@ -1,142 +0,0 @@
#!/bin/bash
#
# Warmup Script for Eagle Server
#
# This script warms up the JIT compiler before switching traffic to a new instance.
# It uses the Go warmup tool which:
# 1. Creates a test game via bidirectional streaming
# 2. Posts an Improve command
# 3. Verifies action results and new commands
# 4. Cleans up the test game
#
# Usage: ./warmup-eagle.sh HOST:PORT
#
# Example:
# ./warmup-eagle.sh localhost:40032
# ./warmup-eagle.sh localhost:40034
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
HOST="${1:-localhost:40032}"
log_info "Warming up Eagle server at ${HOST}..."
# Try to find the Go warmup tool
WARMUP_TOOL=""
# Check if we're in the project directory with bazel
if [ -f "${PROJECT_ROOT}/WORKSPACE" ] || [ -f "${PROJECT_ROOT}/WORKSPACE.bazel" ]; then
# Try to find the pre-built binary
BAZEL_BIN="${PROJECT_ROOT}/bazel-bin/src/main/go/net/eagle0/warmup/warmup_/warmup"
if [ -x "${BAZEL_BIN}" ]; then
WARMUP_TOOL="${BAZEL_BIN}"
fi
fi
# Check for the warmup tool in common locations (for deployed environments)
if [ -z "${WARMUP_TOOL}" ]; then
for path in \
"${SCRIPT_DIR}/bin/warmup" \
"/opt/eagle0/scripts/bin/warmup" \
"/opt/eagle0/bin/warmup" \
"/usr/local/bin/eagle-warmup" \
"${SCRIPT_DIR}/warmup"; do
if [ -x "${path}" ]; then
WARMUP_TOOL="${path}"
break
fi
done
fi
# If we found the Go tool, use it
if [ -n "${WARMUP_TOOL}" ]; then
log_info "Using Go warmup tool: ${WARMUP_TOOL}"
if "${WARMUP_TOOL}" --address="${HOST}" --timeout=60s; then
log_info "Warmup complete!"
exit 0
else
log_error "Go warmup tool failed"
exit 1
fi
fi
# Fallback to grpcurl-based warmup
log_warn "Go warmup tool not found, falling back to grpcurl"
# Check for grpcurl
if ! command -v grpcurl &> /dev/null; then
log_error "Neither Go warmup tool nor grpcurl is available"
log_error "Build the warmup tool with: bazel build //src/main/go/net/eagle0/warmup"
log_error "Or install grpcurl: brew install grpcurl (macOS)"
exit 1
fi
# Warmup iterations
WARMUP_ITERATIONS=3
# 1. Call GetRunningGames multiple times - this exercises the gRPC layer and basic game access
log_info "Warming up GetRunningGames..."
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
RESULT=$(grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetRunningGames 2>&1) || true
if echo "$RESULT" | grep -q "games\|{}"; then
echo -n "."
else
log_error "GetRunningGames failed on iteration $i"
exit 1
fi
done
echo " done"
# 2. Call GetSettings - exercises settings loading
log_info "Warming up GetSettings..."
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
if grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetSettings > /dev/null 2>&1; then
echo -n "."
else
log_warn "GetSettings failed on iteration $i (non-fatal)"
fi
done
echo " done"
# 3. Call AddSettings with empty list - exercises settings path
log_info "Warming up AddSettings..."
for i in $(seq 1 ${WARMUP_ITERATIONS}); do
if grpcurl -plaintext -d '{"settings": []}' "${HOST}" net.eagle0.eagle.api.Eagle/AddSettings > /dev/null 2>&1; then
echo -n "."
else
log_warn "AddSettings failed on iteration $i (non-fatal)"
fi
done
echo " done"
# Final health check
log_info "Verifying server health..."
if grpcurl -plaintext -d '{}' "${HOST}" net.eagle0.eagle.api.Eagle/GetRunningGames > /dev/null 2>&1; then
log_info "Health check passed"
else
log_error "Health check failed"
exit 1
fi
log_info ""
log_info "Warmup complete (basic mode - bidirectional streaming warmup not available)!"
log_info "The JIT should be warmed for:"
log_info " - gRPC layer and protobuf parsing"
log_info " - Settings loading and management"
log_info ""
log_warn "Note: For full warmup including game creation and command processing,"
log_warn " build and use the Go warmup tool: bazel build //src/main/go/net/eagle0/warmup"
@@ -108,7 +108,6 @@
<Compile Include="Assets/Eagle/CommandSelectors/FreeForAllDecisionCommandSelector.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerButtonEditor.cs" />
<Compile Include="Assets/Eagle/CommandWarningPanelController.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialModalPanel.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Context Menu/ContextMenuManager.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerProgressBarLoopEditor.cs" />
<Compile Include="Assets/Eagle/BattalionUtils.cs" />
@@ -155,7 +154,6 @@
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ProvinceHeldDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Bluetooth/DieInfo.cs" />
<Compile Include="Assets/common/ResourceFetcher.cs" />
<Compile Include="Assets/Tutorial/Content/TutorialStep.cs" />
<Compile Include="Assets/Auth/AuthClient.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/TruceRejectedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/RiotSuppressedNotificationGenerator.cs" />
@@ -207,7 +205,6 @@
<Compile Include="Assets/Eagle/FactionsTableController.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/RangeSlider.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ExileVassalCommandSelector.cs" />
<Compile Include="Assets/Tutorial/TutorialState.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicWithIconEditor.cs" />
<Compile Include="Assets/Bluetooth/DiceVectors.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerProgressBarLoop.cs" />
@@ -225,10 +222,8 @@
<Compile Include="Assets/GUI Pro Kit Fantasy RPG/Extensions/UIParticle/UIParticleSystem.cs" />
<Compile Include="Assets/Shardok/SoundManager.cs" />
<Compile Include="Assets/Shardok/HexMesh.cs" />
<Compile Include="Assets/Tutorial/Content/TutorialSequence.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/InvitationRejectedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/DiplomacyCommandSelector.cs" />
<Compile Include="Assets/Tutorial/Triggers/TutorialTriggerRegistry.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ResolveBreakAllianceCommandSelector.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ResolveAllianceCommandSelector.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/WithdrewForTruceDetailsNotificationGenerator.cs" />
@@ -237,7 +232,6 @@
<Compile Include="Assets/Eagle/CustomFileLogger.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/InvitationAcceptedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/ProvinceEventsNotificationGenerator.cs" />
<Compile Include="Assets/Tutorial/TutorialManager.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/FeastCommandSelector.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Animated Icon/AnimatedIconHandler.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/GenericNotificationGenerator.cs" />
@@ -321,7 +315,6 @@
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerContextMenu.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/TruceAmbassadorImprisonedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicWithIcon.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialHintIndicator.cs" />
<Compile Include="Assets/Bluetooth/UnityDieColors.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/OrganizeTroopsCommandSelector.cs" />
<Compile Include="Assets/Shardok/ExtinguishAnimator.cs" />
@@ -370,9 +363,7 @@
<Compile Include="Assets/Terrain Hexes/Example Scene/BasicHexArranger.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ManagePrisonersCommandSelector.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/SendSuppliesCommandSelector.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialUIManager.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Window/WindowManager.cs" />
<Compile Include="Assets/Auth/InvitationCodeManager.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/CapturedHeroExiledDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/SuppressBeastsSucceededNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/ReservesTableController.cs" />
@@ -392,7 +383,6 @@
<Compile Include="Assets/Eagle/Notifications/FactionDestroyedNotificationGenerator.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/UI Manager/UIManagerNotification.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/HandleCapturedHeroesCommandSelector.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialOverlayController.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ARNNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/AttackDecisionCommandSelector.cs" />
<Compile Include="Assets/Eagle/Table Rows/AvailableHeroTableRow.cs" />
@@ -58,19 +58,10 @@ namespace Auth {
/// Routed to Go auth service.
/// </summary>
/// <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) {
/// <returns>Tuple of (URL to open in browser, state token for polling)</returns>
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(OAuthProvider provider) {
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}");
@@ -103,10 +94,6 @@ 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) {
@@ -183,16 +170,18 @@ namespace Auth {
/// <summary>
/// Logout - invalidates refresh token on server.
/// Does NOT clear local tokens (OAuthManager handles that decision).
/// Routed to Eagle.
/// </summary>
public async Task LogoutAsync() {
try {
await _eagleClient.LogoutAsync(new LogoutRequest());
Debug.Log("[AuthClient] Server logout successful");
} catch (Exception ex) {
Debug.LogWarning($"[AuthClient] Logout RPC failed (may be expected): {ex.Message}");
}
// Clear local tokens regardless of server response
TokenStorage.Clear();
Debug.Log("[AuthClient] Logged out, tokens cleared");
}
public void Dispose() {
@@ -1,136 +0,0 @@
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");
#else
// Other platforms don't use the Windows installer
return null;
#endif
}
[Serializable]
private class InvitationFile {
public string invitation_code;
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: a2a19f11d1f82412e8e2811b366113ac
@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Net.Eagle0.Eagle.Api.Auth;
using UnityEngine;
@@ -10,7 +9,6 @@ namespace Auth {
/// - Opens system browser for OAuth consent
/// - Polls server for OAuth completion (no deep links needed)
/// - Token storage and refresh
/// - Multi-account support
/// </summary>
public class OAuthManager : MonoBehaviour {
public static OAuthManager Instance { get; private set; }
@@ -18,14 +16,12 @@ namespace Auth {
private AuthClient _authClient;
private string _currentAuthServiceUrl;
private string _currentEagleUrl;
private OAuthProvider _currentLoginProvider; // Track provider during login
// Events for UI updates
public event Action<UserInfo> OnLoginSuccess;
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;
@@ -84,14 +80,9 @@ namespace Auth {
/// </summary>
public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
EnsureConfigured();
_currentLoginProvider = provider; // Track for later storage
try {
// Get invitation code if available (for new account registration)
string invitationCode = InvitationCodeManager.GetInvitationCode();
// Get OAuth URL and state from server
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider, invitationCode);
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider);
// Open system browser
Debug.Log($"[OAuthManager] Opening browser: {authUrl}");
@@ -100,26 +91,16 @@ namespace Auth {
// Poll for OAuth completion (server handles the callback)
var response = await _authClient.PollForOAuthCompletionAsync(state);
// 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();
// Store tokens
TokenStorage.StoreTokens(
response.AccessToken,
response.RefreshToken,
response.ExpiresAt,
response.User.UserId,
response.User.DisplayName ?? "",
providerName);
response.User.DisplayName ?? "");
// Clear invitation code after successful new account creation
// Notify listeners
if (response.IsNewUser) {
InvitationCodeManager.ClearInvitationCode();
OnNewUserNeedsDisplayName?.Invoke(true);
} else {
OnLoginSuccess?.Invoke(response.User);
@@ -175,15 +156,6 @@ namespace Auth {
// Validate session with server
try {
var response = await _authClient.GetCurrentUserAsync();
// Check if user still needs to set display name (e.g., closed app before setting
// it)
if (string.IsNullOrEmpty(response.User.DisplayName)) {
Debug.Log("[OAuthManager] Session restored but user needs display name");
OnNewUserNeedsDisplayName?.Invoke(false); // false = not a brand new user
return true;
}
Debug.Log($"[OAuthManager] Session restored for {response.User.DisplayName}");
OnLoginSuccess?.Invoke(response.User);
return true;
@@ -206,89 +178,16 @@ namespace Auth {
}
/// <summary>
/// Logout - clears current session but preserves stored tokens for quick re-login.
/// Logout and clear stored tokens.
/// </summary>
public async Task LogoutAsync() {
// Notify server of logout if connected
// LogoutAsync can work even without server connection - just clear local tokens
if (_authClient != null) {
try {
await _authClient.LogoutAsync();
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] Server logout failed: {ex.Message}");
}
await _authClient.LogoutAsync();
} else {
TokenStorage.Clear();
}
// Clear current account selection but keep tokens stored
TokenStorage.ClearCurrentAccount();
OnLogout?.Invoke();
}
/// <summary>
/// Get all stored accounts for display in UI.
/// </summary>
public IReadOnlyList<StoredAccount> GetStoredAccounts() {
return TokenStorage.GetAllAccounts();
}
/// <summary>
/// Select and connect with a stored account.
/// If token is expired or about to expire, refreshes it first.
/// </summary>
public async Task<bool> ConnectWithStoredAccountAsync(StoredAccount account) {
EnsureConfigured();
// Select this account as current
TokenStorage.SelectAccount(account.AccountKey);
// Check if token needs refresh
if (!account.HasValidToken || account.NeedsRefresh) {
if (account.HasRefreshToken) {
try {
await _authClient.RefreshTokenAsync();
Debug.Log($"[OAuthManager] Refreshed token for {account.DisplayName}");
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] Token refresh failed: {ex.Message}");
// Token refresh failed - need to re-authenticate
// Parse provider from account key
var provider = account.Provider.ToLowerInvariant() == "google"
? OAuthProvider.Google
: OAuthProvider.Discord;
OnLoginFailed?.Invoke("Session expired. Please sign in again.");
return false;
}
} else {
OnLoginFailed?.Invoke("Session expired. Please sign in again.");
return false;
}
}
// Validate session with server
try {
var response = await _authClient.GetCurrentUserAsync();
// Check if user still needs to set display name
if (string.IsNullOrEmpty(response.User.DisplayName)) {
Debug.Log("[OAuthManager] Stored account needs display name");
OnNewUserNeedsDisplayName?.Invoke(false);
return true;
}
Debug.Log(
$"[OAuthManager] Connected with stored account: {response.User.DisplayName}");
OnLoginSuccess?.Invoke(response.User);
return true;
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] Session validation failed: {ex.Message}");
OnLoginFailed?.Invoke("Session invalid. Please sign in again.");
return false;
}
}
/// <summary>
/// Remove a stored account permanently.
/// </summary>
public void RemoveStoredAccount(StoredAccount account) {
TokenStorage.RemoveAccount(account.AccountKey);
}
}
}
@@ -1,63 +1,27 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Auth {
/// <summary>
/// Represents a stored OAuth account with tokens.
/// </summary>
[Serializable]
public class StoredAccount {
public string Provider; // "discord" or "google"
public string UserId; // OAuth provider user ID
public string DisplayName; // User's display name
public string AccessToken;
public string RefreshToken;
public long ExpiresAt; // Unix timestamp
public string AccountKey => $"{Provider}:{UserId}";
public bool HasValidToken => !string.IsNullOrEmpty(AccessToken) &&
ExpiresAt > DateTimeOffset.UtcNow.ToUnixTimeSeconds();
public bool HasRefreshToken => !string.IsNullOrEmpty(RefreshToken);
public bool NeedsRefresh {
get {
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return ExpiresAt > 0 && ExpiresAt - now < 300; // 5 minutes
}
}
}
/// <summary>
/// Container for all stored accounts, serialized to JSON.
/// </summary>
[Serializable]
public class StoredAccountsData {
public List<StoredAccount> Accounts = new();
public string CurrentAccountKey; // Provider:UserId of active account
}
/// <summary>
/// Secure storage for OAuth tokens using Unity's PlayerPrefs.
/// Supports multiple accounts - tokens are preserved on logout.
/// Values are cached in memory to allow access from background threads.
/// In production, consider using platform-specific secure storage
/// (Keychain on iOS, Keystore on Android).
/// </summary>
public static class TokenStorage {
private const string AccountsDataKey = "eagle0_accounts_data";
private const string AccessTokenKey = "eagle0_access_token";
private const string RefreshTokenKey = "eagle0_refresh_token";
private const string ExpiresAtKey = "eagle0_expires_at";
private const string UserIdKey = "eagle0_user_id";
private const string DisplayNameKey = "eagle0_display_name";
// Legacy keys for migration
private const string LegacyAccessTokenKey = "eagle0_access_token";
private const string LegacyRefreshTokenKey = "eagle0_refresh_token";
private const string LegacyExpiresAtKey = "eagle0_expires_at";
private const string LegacyUserIdKey = "eagle0_user_id";
private const string LegacyDisplayNameKey = "eagle0_display_name";
// In-memory cache
private static StoredAccountsData _accountsData;
// In-memory cache for thread-safe access from background threads
// PlayerPrefs can only be accessed from the main thread
private static string _cachedAccessToken;
private static string _cachedRefreshToken;
private static long _cachedExpiresAt;
private static string _cachedUserId;
private static string _cachedDisplayName;
private static bool _cacheInitialized = false;
/// <summary>
@@ -65,245 +29,147 @@ namespace Auth {
/// Call this early in app startup (e.g., in Awake or Start).
/// </summary>
public static void InitializeCache() {
var json = PlayerPrefs.GetString(AccountsDataKey, "");
if (!string.IsNullOrEmpty(json)) {
try {
_accountsData = JsonUtility.FromJson<StoredAccountsData>(json);
} catch (Exception ex) {
Debug.LogWarning($"[TokenStorage] Failed to parse accounts data: {ex.Message}");
_accountsData = new StoredAccountsData();
}
} else {
_accountsData = new StoredAccountsData();
// Try to migrate legacy single-account data
MigrateLegacyData();
}
_cachedAccessToken = PlayerPrefs.GetString(AccessTokenKey, null);
_cachedRefreshToken = PlayerPrefs.GetString(RefreshTokenKey, null);
_cachedExpiresAt =
long.TryParse(PlayerPrefs.GetString(ExpiresAtKey, "0"), out var val) ? val : 0;
_cachedUserId = PlayerPrefs.GetString(UserIdKey, null);
_cachedDisplayName = PlayerPrefs.GetString(DisplayNameKey, null);
_cacheInitialized = true;
Debug.Log(
$"[TokenStorage] Cache initialized, {_accountsData.Accounts.Count} accounts stored");
$"[TokenStorage] Cache initialized, hasToken={!string.IsNullOrEmpty(_cachedAccessToken)}");
}
private static void MigrateLegacyData() {
var legacyAccessToken = PlayerPrefs.GetString(LegacyAccessTokenKey, "");
if (string.IsNullOrEmpty(legacyAccessToken)) return;
var legacyRefreshToken = PlayerPrefs.GetString(LegacyRefreshTokenKey, "");
var legacyExpiresAt =
long.TryParse(PlayerPrefs.GetString(LegacyExpiresAtKey, "0"), out var val) ? val
: 0;
var legacyUserId = PlayerPrefs.GetString(LegacyUserIdKey, "");
var legacyDisplayName = PlayerPrefs.GetString(LegacyDisplayNameKey, "");
if (!string.IsNullOrEmpty(legacyUserId)) {
// Assume discord for legacy data (most common)
var account = new StoredAccount {
Provider = "discord",
UserId = legacyUserId,
DisplayName = legacyDisplayName,
AccessToken = legacyAccessToken,
RefreshToken = legacyRefreshToken,
ExpiresAt = legacyExpiresAt
};
_accountsData.Accounts.Add(account);
_accountsData.CurrentAccountKey = account.AccountKey;
SaveAccountsData();
// Clear legacy keys
PlayerPrefs.DeleteKey(LegacyAccessTokenKey);
PlayerPrefs.DeleteKey(LegacyRefreshTokenKey);
PlayerPrefs.DeleteKey(LegacyExpiresAtKey);
PlayerPrefs.DeleteKey(LegacyUserIdKey);
PlayerPrefs.DeleteKey(LegacyDisplayNameKey);
PlayerPrefs.Save();
Debug.Log($"[TokenStorage] Migrated legacy account: {account.DisplayName}");
}
}
private static void SaveAccountsData() {
var json = JsonUtility.ToJson(_accountsData);
PlayerPrefs.SetString(AccountsDataKey, json);
PlayerPrefs.Save();
}
/// <summary>
/// Get all stored accounts.
/// </summary>
public static IReadOnlyList<StoredAccount> GetAllAccounts() {
EnsureInitialized();
return _accountsData.Accounts.AsReadOnly();
}
/// <summary>
/// Get the currently active account, or null if none selected.
/// </summary>
public static StoredAccount CurrentAccount {
public static bool HasValidToken {
get {
EnsureInitialized();
if (string.IsNullOrEmpty(_accountsData.CurrentAccountKey)) return null;
return _accountsData.Accounts.Find(
a => a.AccountKey == _accountsData.CurrentAccountKey);
var token = AccessToken;
var expiresAt = ExpiresAt;
return !string.IsNullOrEmpty(token) &&
expiresAt > DateTimeOffset.UtcNow.ToUnixTimeSeconds();
}
}
public static bool HasRefreshToken => !string.IsNullOrEmpty(RefreshToken);
public static string AccessToken {
get {
if (!_cacheInitialized) {
Debug.LogWarning(
"[TokenStorage] Cache not initialized, returning cached value anyway");
}
return _cachedAccessToken;
}
private
set {
_cachedAccessToken = value;
PlayerPrefs.SetString(AccessTokenKey, value ?? "");
}
}
public static string RefreshToken {
get => _cachedRefreshToken;
private
set {
_cachedRefreshToken = value;
PlayerPrefs.SetString(RefreshTokenKey, value ?? "");
}
}
public static long ExpiresAt {
get => _cachedExpiresAt;
private
set {
_cachedExpiresAt = value;
PlayerPrefs.SetString(ExpiresAtKey, value.ToString());
}
}
public static string UserId {
get => _cachedUserId;
private
set {
_cachedUserId = value;
PlayerPrefs.SetString(UserIdKey, value ?? "");
}
}
public static string DisplayName {
get => _cachedDisplayName;
private
set {
_cachedDisplayName = value;
PlayerPrefs.SetString(DisplayNameKey, value ?? "");
}
}
/// <summary>
/// Select an account as the current active account.
/// </summary>
public static void SelectAccount(string accountKey) {
EnsureInitialized();
var account = _accountsData.Accounts.Find(a => a.AccountKey == accountKey);
if (account != null) {
_accountsData.CurrentAccountKey = accountKey;
SaveAccountsData();
Debug.Log($"[TokenStorage] Selected account: {account.DisplayName}");
}
}
/// <summary>
/// Clear the current account selection (logout without deleting tokens).
/// </summary>
public static void ClearCurrentAccount() {
EnsureInitialized();
_accountsData.CurrentAccountKey = null;
SaveAccountsData();
Debug.Log("[TokenStorage] Cleared current account selection");
}
/// <summary>
/// Remove a specific account and its tokens.
/// </summary>
public static void RemoveAccount(string accountKey) {
EnsureInitialized();
var removed = _accountsData.Accounts.RemoveAll(a => a.AccountKey == accountKey);
if (_accountsData.CurrentAccountKey == accountKey) {
_accountsData.CurrentAccountKey = null;
}
if (removed > 0) {
SaveAccountsData();
Debug.Log($"[TokenStorage] Removed account: {accountKey}");
}
}
// Compatibility properties that reference the current account
public static bool HasValidToken => CurrentAccount?.HasValidToken ?? false;
public static bool HasRefreshToken => CurrentAccount?.HasRefreshToken ?? false;
public static string AccessToken => CurrentAccount?.AccessToken;
public static string RefreshToken => CurrentAccount?.RefreshToken;
public static long ExpiresAt => CurrentAccount?.ExpiresAt ?? 0;
public static string UserId => CurrentAccount?.UserId;
public static string DisplayName => CurrentAccount?.DisplayName;
public static bool NeedsRefresh => CurrentAccount?.NeedsRefresh ?? false;
/// <summary>
/// Store tokens for an account. Creates new or updates existing.
/// Store tokens received from OAuth exchange.
/// </summary>
public static void StoreTokens(
string accessToken,
string refreshToken,
long expiresAt,
string userId,
string displayName,
string provider = "discord") {
EnsureInitialized();
string displayName) {
AccessToken = accessToken;
RefreshToken = refreshToken;
ExpiresAt = expiresAt;
UserId = userId;
DisplayName = displayName;
PlayerPrefs.Save();
var accountKey = $"{provider}:{userId}";
var existingAccount = _accountsData.Accounts.Find(a => a.AccountKey == accountKey);
if (existingAccount != null) {
existingAccount.AccessToken = accessToken;
existingAccount.RefreshToken = refreshToken;
existingAccount.ExpiresAt = expiresAt;
existingAccount.DisplayName = displayName;
} else {
var newAccount = new StoredAccount {
Provider = provider,
UserId = userId,
DisplayName = displayName,
AccessToken = accessToken,
RefreshToken = refreshToken,
ExpiresAt = expiresAt
};
_accountsData.Accounts.Add(newAccount);
}
// Set as current account
_accountsData.CurrentAccountKey = accountKey;
SaveAccountsData();
Debug.Log($"[TokenStorage] Stored tokens for {displayName} ({provider})");
Debug.Log(
$"[TokenStorage] Stored tokens for user {displayName} (expires at {expiresAt})");
}
/// <summary>
/// Update access token after refresh for the current account.
/// Update access token after refresh.
/// </summary>
public static void UpdateAccessToken(string accessToken, long expiresAt) {
EnsureInitialized();
var account = CurrentAccount;
if (account != null) {
account.AccessToken = accessToken;
account.ExpiresAt = expiresAt;
SaveAccountsData();
Debug.Log($"[TokenStorage] Updated access token (expires at {expiresAt})");
}
AccessToken = accessToken;
ExpiresAt = expiresAt;
PlayerPrefs.Save();
Debug.Log($"[TokenStorage] Updated access token (expires at {expiresAt})");
}
/// <summary>
/// Update access token for a specific account (by key).
/// </summary>
public static void
UpdateAccessTokenForAccount(string accountKey, string accessToken, long expiresAt) {
EnsureInitialized();
var account = _accountsData.Accounts.Find(a => a.AccountKey == accountKey);
if (account != null) {
account.AccessToken = accessToken;
account.ExpiresAt = expiresAt;
SaveAccountsData();
Debug.Log(
$"[TokenStorage] Updated access token for {account.DisplayName} (expires at {expiresAt})");
}
}
/// <summary>
/// Update display name for the current account.
/// Update display name after user sets it.
/// </summary>
public static void UpdateDisplayName(string displayName) {
EnsureInitialized();
var account = CurrentAccount;
if (account != null) {
account.DisplayName = displayName;
SaveAccountsData();
}
}
/// <summary>
/// Clear all stored accounts (full reset).
/// </summary>
public static void ClearAll() {
_accountsData = new StoredAccountsData();
PlayerPrefs.DeleteKey(AccountsDataKey);
DisplayName = displayName;
PlayerPrefs.Save();
Debug.Log("[TokenStorage] Cleared all accounts");
}
/// <summary>
/// Legacy Clear method - now just clears current selection, not tokens.
/// Clear all stored tokens (logout).
/// </summary>
public static void Clear() { ClearCurrentAccount(); }
public static void Clear() {
_cachedAccessToken = null;
_cachedRefreshToken = null;
_cachedExpiresAt = 0;
_cachedUserId = null;
_cachedDisplayName = null;
private static void EnsureInitialized() {
if (!_cacheInitialized) {
Debug.LogWarning("[TokenStorage] Cache not initialized, initializing now");
InitializeCache();
PlayerPrefs.DeleteKey(AccessTokenKey);
PlayerPrefs.DeleteKey(RefreshTokenKey);
PlayerPrefs.DeleteKey(ExpiresAtKey);
PlayerPrefs.DeleteKey(UserIdKey);
PlayerPrefs.DeleteKey(DisplayNameKey);
PlayerPrefs.Save();
Debug.Log("[TokenStorage] Cleared all tokens");
}
/// <summary>
/// Check if token needs refresh (expires within 5 minutes).
/// </summary>
public static bool NeedsRefresh {
get {
var expiresAt = ExpiresAt;
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
return expiresAt > 0 && expiresAt - now < 300; // 5 minutes
}
}
}
@@ -80,6 +80,9 @@ public class AuthInterceptor : Interceptor {
};
public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public TMP_Dropdown environmentDropdown;
public TMP_InputField nameField;
public TMP_InputField passwordField;
public TMP_Dropdown resolutionDropdown;
[Header("OAuth UI")]
@@ -88,23 +91,16 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public Button googleLoginButton;
public TextMeshProUGUI oauthStatusText;
[Header("Stored Accounts")]
public GameObject storedAccountsContainer;
public GameObject storedAccountButtonPrefab;
public Sprite discordProviderIcon;
public Sprite googleProviderIcon;
[Header("Display Name Setup")]
public GameObject displayNamePanel;
public TMP_InputField displayNameField;
public Button setDisplayNameButton;
public TextMeshProUGUI displayNameErrorText;
[Header("Invitation Code Entry")]
public GameObject invitationCodePanel;
public TMP_InputField invitationCodeField;
public Button submitInvitationCodeButton;
public TextMeshProUGUI invitationCodeErrorText;
[Header("Legacy Auth (for testing)")]
public GameObject legacyAuthPanel;
public Button useLegacyAuthButton;
public TextMeshProUGUI useLegacyAuthButtonText;
[Header("Status Display")]
public TextMeshProUGUI connectionStatusText;
@@ -118,7 +114,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public Button logoutButton;
public Button customBattleButton;
public Button cancelCustomBattleButton;
public TMP_Dropdown lobbyEnvironmentDropdown;
public TextMeshProUGUI lobbyEnvironmentText;
public TextMeshProUGUI lobbyUserText;
public GameObject runningGamesListArea;
@@ -146,7 +142,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
private CancellationTokenSource _cancellationTokenSource;
private readonly Object pendingReplyLock = new Object();
private int _connectedEnvironmentIndex = -1; // -1 means not connected
private List<GameObject> _storedAccountButtons = new List<GameObject>();
private bool _useOAuth = false; // Default to legacy until server-side OAuth is ready
public ClientPregeneratedText clientPregeneratedText;
@@ -170,7 +166,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
: null;
private string GetUrlFromEnvironment() {
int envIndex = PlayerPrefs.GetInt(EnvironmentKey, 0);
int envIndex = environmentDropdown.value;
string prefix = EnvironmentOptions[envIndex];
return prefix + BaseDomain;
}
@@ -203,6 +199,14 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
resolutionDropdown.AddOptions(resolutions.Select(r => $"{r.width} x {r.height}").ToList());
resolutionDropdown.value = resolutions.ToList().IndexOf(currentResolution);
// Set up environment dropdown
environmentDropdown.ClearOptions();
environmentDropdown.AddOptions(EnvironmentDisplayNames);
environmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
nameField.text = PlayerPrefs.GetString(NameKey, "sample_name");
passwordField.text = PlayerPrefs.GetString(PasswordKey, "sample_password");
connectionCanvas.gameObject.SetActive(true);
eagleCanvas.gameObject.SetActive(false);
shardokCanvas.gameObject.SetActive(false);
@@ -228,7 +232,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
private void SetupOAuthUI() {
// Set up OAuth button click handlers for new sign-ins
// Set up OAuth button click handlers
if (discordLoginButton != null) {
discordLoginButton.onClick.AddListener(
() => OnOAuthLoginClicked(OAuthProvider.Discord));
@@ -239,8 +243,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
if (setDisplayNameButton != null) {
setDisplayNameButton.onClick.AddListener(OnSetDisplayNameClicked);
}
if (submitInvitationCodeButton != null) {
submitInvitationCodeButton.onClick.AddListener(OnSubmitInvitationCodeClicked);
if (useLegacyAuthButton != null) {
useLegacyAuthButton.onClick.AddListener(OnUseLegacyAuthClicked);
}
// Subscribe to OAuthManager events
@@ -249,83 +253,26 @@ 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
// Show appropriate panel based on auth state
ShowAuthPanel();
}
private void ShowAuthPanel() {
// Hide other panels
// Hide all auth panels first
if (oauthPanel != null) oauthPanel.SetActive(false);
if (displayNamePanel != null) displayNamePanel.SetActive(false);
if (invitationCodePanel != null) invitationCodePanel.SetActive(false);
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(false);
// Show OAuth panel
if (oauthPanel != null) oauthPanel.SetActive(true);
// Refresh stored account buttons
RefreshStoredAccountButtons();
}
private void RefreshStoredAccountButtons() {
// Clear existing buttons
foreach (var btn in _storedAccountButtons) {
if (btn != null) Destroy(btn);
}
_storedAccountButtons.Clear();
if (storedAccountsContainer == null || storedAccountButtonPrefab == null) return;
// Get stored accounts
var accounts = OAuthManager.Instance?.GetStoredAccounts();
if (accounts == null || accounts.Count == 0) return;
// Create button for each stored account
foreach (var account in accounts) {
var buttonObj =
Instantiate(storedAccountButtonPrefab, storedAccountsContainer.transform);
buttonObj.transform.localScale = Vector3.one;
// Set button text (just display name, icon shows provider)
var buttonText = buttonObj.GetComponentInChildren<TextMeshProUGUI>();
if (buttonText != null) { buttonText.text = account.DisplayName; }
// Set provider icon (look for child named "ProviderIcon")
var providerIconTransform = buttonObj.transform.Find("ProviderIcon");
if (providerIconTransform != null) {
var providerImage = providerIconTransform.GetComponent<Image>();
if (providerImage != null) {
var isGoogle = account.Provider.ToLowerInvariant() == "google";
providerImage.sprite = isGoogle ? googleProviderIcon : discordProviderIcon;
}
}
// Set click handler
var button = buttonObj.GetComponent<Button>();
if (button != null) {
var accountCopy = account; // Capture for lambda
button.onClick.AddListener(() => OnStoredAccountClicked(accountCopy));
}
_storedAccountButtons.Add(buttonObj);
}
}
private async void OnStoredAccountClicked(StoredAccount account) {
// Configure OAuthManager with URLs
OAuthManager.Instance.SetServerUrls(GetAuthServiceUrl(), GetEagleUrl());
if (oauthStatusText != null) {
oauthStatusText.text = $"Connecting as {account.DisplayName}...";
}
var success = await OAuthManager.Instance.ConnectWithStoredAccountAsync(account);
if (success) {
// OnLoginSuccess will handle connection to lobby
// Show appropriate panel based on _useOAuth
if (_useOAuth) {
if (oauthPanel != null) oauthPanel.SetActive(true);
if (useLegacyAuthButtonText != null)
useLegacyAuthButtonText.text = "Use Classic Sign-in";
} else {
// Failed - might need re-auth, refresh the buttons
RefreshStoredAccountButtons();
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(true);
if (useLegacyAuthButtonText != null) useLegacyAuthButtonText.text = "Use OAuth Sign-in";
}
}
@@ -335,56 +282,22 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
if (cancelCustomBattleButton != null) {
cancelCustomBattleButton.onClick.AddListener(CancelCustomBattle);
}
// Set up environment dropdown
if (lobbyEnvironmentDropdown != null) {
lobbyEnvironmentDropdown.ClearOptions();
lobbyEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
lobbyEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
}
}
private void UpdateLobbyStatusDisplays() {
// Update environment dropdown selection
if (lobbyEnvironmentDropdown != null && _connectedEnvironmentIndex >= 0) {
// Temporarily remove listener to avoid triggering reconnect
lobbyEnvironmentDropdown.onValueChanged.RemoveListener(OnLobbyEnvironmentChanged);
lobbyEnvironmentDropdown.value = _connectedEnvironmentIndex;
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
// Show environment (prod/qa)
if (lobbyEnvironmentText != null) {
lobbyEnvironmentText.text = ConnectedEnvironmentName ?? "";
}
// Show current user from OAuth
if (lobbyUserText != null) { lobbyUserText.text = TokenStorage.DisplayName ?? ""; }
}
private void OnLobbyEnvironmentChanged(int newEnvironmentIndex) {
if (newEnvironmentIndex == _connectedEnvironmentIndex) return;
Debug.Log(
$"[ConnectionHandler] Switching environment from {ConnectedEnvironmentName} to {EnvironmentDisplayNames[newEnvironmentIndex]}");
// Disconnect from current environment
_persistentClientConnection?.Dispose();
eagleConnection?.Dispose();
_httpClient?.Dispose();
_persistentClientConnection = null;
eagleConnection = null;
_httpClient = null;
// Update environment index and reconnect
_connectedEnvironmentIndex = newEnvironmentIndex;
PlayerPrefs.SetInt(EnvironmentKey, newEnvironmentIndex);
// Clear current game lists while reconnecting
foreach (Transform row in runningGamesListArea.transform) { Destroy(row.gameObject); }
foreach (Transform row in availableGamesListArea.transform) { Destroy(row.gameObject); }
// Reconnect to new environment
_createConnection();
RequestMaps();
StartListeningForLobbyUpdates();
// Show current user - prefer OAuth display name, fall back to classic login name
if (lobbyUserText != null) {
if (_useOAuth && !string.IsNullOrEmpty(TokenStorage.DisplayName)) {
lobbyUserText.text = TokenStorage.DisplayName;
} else {
lobbyUserText.text = nameField.text;
}
}
}
private async void OnLogoutClicked() {
@@ -465,53 +378,11 @@ 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;
@@ -539,7 +410,27 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
});
}
private void ConnectWithOAuth() { _internalConnectEagle(); }
private void OnUseLegacyAuthClicked() {
_useOAuth = !_useOAuth; // Toggle instead of just setting false
if (_useOAuth) {
// Show OAuth panel
if (oauthPanel != null) oauthPanel.SetActive(true);
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(false);
if (useLegacyAuthButtonText != null)
useLegacyAuthButtonText.text = "Use Classic Sign-in";
} else {
// Show legacy panel
if (oauthPanel != null) oauthPanel.SetActive(false);
if (legacyAuthPanel != null) legacyAuthPanel.SetActive(true);
if (useLegacyAuthButtonText != null) useLegacyAuthButtonText.text = "Use OAuth Sign-in";
}
}
private void ConnectWithOAuth() {
_useOAuth = true;
_internalConnectEagle();
}
private float _statusUpdateTimer = 0f;
private const float StatusUpdateInterval = 0.5f;
@@ -743,7 +634,10 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
private void _createConnection() {
_connectedEnvironmentIndex = PlayerPrefs.GetInt(EnvironmentKey, 0);
_connectedEnvironmentIndex = environmentDropdown.value;
PlayerPrefs.SetInt(EnvironmentKey, _connectedEnvironmentIndex);
PlayerPrefs.SetString(NameKey, nameField.text);
PlayerPrefs.SetString(PasswordKey, passwordField.text);
// Dispose existing connections before creating new ones
_httpClient?.Dispose();
@@ -756,18 +650,25 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
string url = GetUrlFromEnvironment();
// OAuth JWT-based connection
if (!TokenStorage.HasValidToken) {
Debug.LogError("[ConnectionHandler] No valid token available for connection");
return;
if (_useOAuth && TokenStorage.HasValidToken) {
// Use JWT-based connection
eagleConnection = EagleConnection.CreateWithJwt(url);
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TokenStorage.AccessToken);
} else {
// Legacy Basic Auth connection
PlayerPrefs.SetString(NameKey, nameField.text);
PlayerPrefs.SetString(PasswordKey, passwordField.text);
eagleConnection = new EagleConnection(nameField.text, passwordField.text, url);
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Basic", eagleConnection.authHeader);
}
eagleConnection = EagleConnection.CreateWithJwt(url);
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TokenStorage.AccessToken);
_persistentClientConnection = new PersistentClientConnection(
eagleConnection.EagleGrpcClient,
eagleConnection.credentials,
@@ -1,117 +0,0 @@
fileFormatVersion: 2
guid: af9f637b3a1f6433f819a0bcdb326453
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,427 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &1262787222422545696
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 6242087783640990751}
- component: {fileID: 5444015614922696687}
- component: {fileID: 8871991993173924918}
- component: {fileID: 6965305925514130928}
m_Layer: 5
m_Name: ProviderIcon
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &6242087783640990751
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1262787222422545696}
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: 157686408557551303}
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 &5444015614922696687
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1262787222422545696}
m_CullTransparentMesh: 1
--- !u!114 &8871991993173924918
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1262787222422545696}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: 80
m_PreferredHeight: 50
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!114 &6965305925514130928
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1262787222422545696}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
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: 21300000, guid: b6b68c8c37d5cf1419267a80c426ac83, type: 3}
m_Type: 0
m_PreserveAspect: 1
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!1 &5166012670666351313
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 157686408557551303}
- component: {fileID: 3634401029247236684}
- component: {fileID: 7790188191322585408}
- component: {fileID: 646268409010340088}
- component: {fileID: 3097176747511135857}
- component: {fileID: 7691398338434978342}
m_Layer: 5
m_Name: StoredAccountButton
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &157686408557551303
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5166012670666351313}
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: 7974514965529143181}
- {fileID: 6242087783640990751}
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: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &3634401029247236684
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5166012670666351313}
m_CullTransparentMesh: 1
--- !u!114 &7790188191322585408
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5166012670666351313}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
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: 10905, guid: 0000000000000000f000000000000000, type: 0}
m_Type: 1
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 &646268409010340088
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5166012670666351313}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Button
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 7790188191322585408}
m_OnClick:
m_PersistentCalls:
m_Calls: []
--- !u!114 &3097176747511135857
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5166012670666351313}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: 400
m_PreferredHeight: 60
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!114 &7691398338434978342
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5166012670666351313}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 30649d3a9faa99c48a7b1166b86bf2a0, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.HorizontalLayoutGroup
m_Padding:
m_Left: 0
m_Right: 0
m_Top: 0
m_Bottom: 0
m_ChildAlignment: 5
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!1 &5617793748733146591
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 7974514965529143181}
- component: {fileID: 4513536456760004536}
- component: {fileID: 6210669843299902677}
- component: {fileID: 572895541873054588}
m_Layer: 5
m_Name: Label
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &7974514965529143181
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5617793748733146591}
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: 157686408557551303}
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 &4513536456760004536
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5617793748733146591}
m_CullTransparentMesh: 1
--- !u!114 &6210669843299902677
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5617793748733146591}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
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: nolen (dan@danielcrosby.net)
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: 4281479730
m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, 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_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: 0
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: 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 &572895541873054588
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5617793748733146591}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: 60
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 6440904d10a244010b3a91e0aa247749
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Linq;
using common;
using common.GUIUtils;
using Eagle0.Tutorial;
using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Common;
using Net.Eagle0.Eagle.Views;
@@ -159,11 +158,9 @@ namespace eagle {
public void StopAll() {
_newModel = null;
if (ModelUpdater != null) {
ModelUpdater.StopListeningForUpdates();
ModelUpdater.UpdateAction = null;
ModelUpdater = null;
}
ModelUpdater.StopListeningForUpdates();
ModelUpdater.UpdateAction = null;
ModelUpdater = null;
SwapModel();
Model = null;
chronicleCanvasController.Entries = new List<ChronicleEntry>();
@@ -284,13 +281,6 @@ namespace eagle {
MainQueue.Q.EnqueueForNextUpdate(
() => { _ = ModelUpdater.StartListeningForUpdates(); });
// Initialize tutorial system
TutorialManager.Instance?.Initialize(this, null);
if (TutorialManager.Instance != null &&
!TutorialManager.Instance.State.OnboardingCompleted) {
TutorialManager.Instance.StartOnboarding();
}
#if UNITY_EDITOR
EditorApplication.playModeStateChanged += (state) => OnPlayModeStateChanged(state);
#endif
@@ -372,12 +362,6 @@ namespace eagle {
SetNextActiveProvinceButton();
_dominionPanelController.ForceUpdate();
SetMusic();
// Notify tutorial system of province selection
if (pid.HasValue) {
TutorialManager.Instance?.TriggerRegistry?.OnProvinceSelected(
Model.Provinces[pid.Value]);
}
}
private void PrefetchHeadshotForHeroes(List<HeroView> heroViews) {
@@ -493,9 +477,6 @@ namespace eagle {
var oldModel = Model;
Model = _newModel;
// Notify tutorial system of model change
TutorialManager.Instance?.TriggerRegistry?.OnModelUpdated(Model, oldModel);
provinceInfoPanelController.Model = _newModel;
freeHeroesTableController.Model = _newModel;
movingArmiesTableController.Model = _newModel;
@@ -644,9 +625,6 @@ namespace eagle {
}
private void PostCommittedCommand(ProvinceId provinceId, SelectedCommand selectedCommand) {
// Notify tutorial system of command
TutorialManager.Instance?.TriggerRegistry?.OnCommandIssued(selectedCommand);
ModelUpdater.PostCommand(provinceId: provinceId, command: selectedCommand)
.ContinueWith(response => {
if (response.IsFaulted) { errorHandler.Add(response.Exception); }
@@ -6,7 +6,7 @@ TextureImporter:
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
@@ -37,13 +37,13 @@ TextureImporter:
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
@@ -52,9 +52,9 @@ TextureImporter:
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 8
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
@@ -100,7 +100,7 @@ TextureImporter:
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
spriteID:
internalID: 0
vertices: []
indices:
@@ -6,7 +6,7 @@ TextureImporter:
serializedVersion: 13
mipmaps:
mipMapMode: 0
enableMipMap: 0
enableMipMap: 1
sRGBTexture: 1
linearTexture: 0
fadeOut: 0
@@ -37,13 +37,13 @@ TextureImporter:
filterMode: 1
aniso: 1
mipBias: 0
wrapU: 1
wrapV: 1
wrapU: 0
wrapV: 0
wrapW: 0
nPOTScale: 0
nPOTScale: 1
lightmap: 0
compressionQuality: 50
spriteMode: 1
spriteMode: 0
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
@@ -52,9 +52,9 @@ TextureImporter:
spriteBorder: {x: 0, y: 0, z: 0, w: 0}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
alphaIsTransparency: 0
spriteTessellationDetail: -1
textureType: 8
textureType: 0
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
@@ -100,7 +100,7 @@ TextureImporter:
customData:
physicsShape: []
bones: []
spriteID: 5e97eb03825dee720800000000000000
spriteID:
internalID: 0
vertices: []
indices:
File diff suppressed because it is too large Load Diff
@@ -4,7 +4,6 @@ using System.IO;
using common;
using eagle;
using Net.Eagle0.Shardok.Api;
using Shardok;
using TMPro;
using UnityEditor;
using UnityEngine;
@@ -29,9 +28,6 @@ public class SettingsPanelController : MonoBehaviour {
public Toggle tooltipHugsCursorToggle;
public HoveringTooltip hoveringTooltip;
public Toggle showAnimationTestsToggle;
public AnimationTestController animationTestController;
public Slider autoScrollSpeedSlider;
public TMP_Text autoScrollSpeedLabel;
@@ -43,7 +39,6 @@ public class SettingsPanelController : MonoBehaviour {
private const string MusicVolumeKey = "musicVolume";
public const string UseGoDiceKey = "useGoDice";
private const string TooltipHugsCursorKey = "tooltipHugsCursor";
private const string AnimationToggleKey = "animationToggle";
public EagleGameController eagleGameController;
public GameObject ShardokCanvas;
@@ -64,9 +59,6 @@ public class SettingsPanelController : MonoBehaviour {
tooltipHugsCursorToggle.isOn = PlayerPrefs.GetInt(TooltipHugsCursorKey, 0) == 1;
hoveringTooltip.HugsCursor = tooltipHugsCursorToggle.isOn;
showAnimationTestsToggle.isOn = PlayerPrefs.GetInt(AnimationToggleKey, 0) == 1;
animationTestController.gameObject.SetActive(showAnimationTestsToggle.isOn);
if (autoScrollSpeedSlider != null) {
autoScrollSpeedSlider.value = AutoScrollingText.GlobalScrollSpeedMultiplier;
UpdateAutoScrollSpeedLabel(autoScrollSpeedSlider.value);
@@ -124,11 +116,6 @@ public class SettingsPanelController : MonoBehaviour {
hoveringTooltip.HugsCursor = val;
}
public void OnAnimationTestToggleChange(bool val) {
PlayerPrefs.SetInt(AnimationToggleKey, val ? 1 : 0);
animationTestController.gameObject.SetActive(val);
}
public void OnAutoScrollSpeedSliderChange(float val) {
AutoScrollingText.GlobalScrollSpeedMultiplier = val;
UpdateAutoScrollSpeedLabel(val);
@@ -1,169 +0,0 @@
fileFormatVersion: 2
guid: 32f7f3d380ec1413590676a13c22324c
TextureImporter:
internalIDToNameTable:
- first:
213: 7482667652216324306
second: Square
externalObjects: {}
serializedVersion: 11
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
isReadable: 0
streamingMipmaps: 0
streamingMipmapsPriority: 0
vTOnly: 0
ignoreMasterTextureLimit: 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: 1
nPOTScale: 0
lightmap: 0
compressionQuality: 50
spriteMode: 2
spriteExtrude: 1
spriteMeshType: 1
alignment: 0
spritePivot: {x: 0.5, y: 0.5}
spritePixelsToUnits: 256
spriteBorder: {x: 4, y: 4, z: 4, w: 4}
spriteGenerateFallbackPhysicsShape: 1
alphaUsage: 1
alphaIsTransparency: 1
spriteTessellationDetail: 0
textureType: 8
textureShape: 1
singleChannelComponent: 0
flipbookRows: 1
flipbookColumns: 1
maxTextureSizeSet: 0
compressionQualitySet: 0
textureFormatSet: 0
ignorePngGamma: 0
applyGammaDecoding: 0
platformSettings:
- serializedVersion: 3
buildTarget: DefaultTexturePlatform
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Standalone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: iPhone
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
- serializedVersion: 3
buildTarget: Android
maxTextureSize: 2048
resizeAlgorithm: 0
textureFormat: -1
textureCompression: 1
compressionQuality: 50
crunchedCompression: 0
allowsAlphaSplitting: 0
overridden: 0
androidETC2FallbackOverride: 0
forceMaximumCompressionQuality_BC6H_BC7: 0
spriteSheet:
serializedVersion: 2
sprites:
- serializedVersion: 2
name: Triangle
rect:
serializedVersion: 2
x: 0
y: 0
width: 256
height: 256
alignment: 9
pivot: {x: 0.5, y: 0.28866667}
border: {x: 0, y: 0, z: 0, w: 0}
outline:
- - {x: 0, y: 93.7025033688}
- {x: -128, y: -128}
- {x: 128, y: -128}
physicsShape:
- - {x: 0, y: 93.7025033688}
- {x: -128, y: -128}
- {x: 128, y: -128}
tessellationDetail: 0
bones: []
spriteID: 2d009a6b596c7d760800000000000000
internalID: 7482667652216324306
vertices: []
indices:
edges: []
weights: []
outline: []
physicsShape:
- - {x: -128, y: 128}
- {x: -128, y: -128}
- {x: 128, y: -128}
- {x: 128, y: 128}
bones: []
spriteID: 5e97eb03825dee720800000000000000
internalID: 0
vertices: []
indices:
edges: []
weights: []
secondaryTextures: []
nameFileIdTable:
Triangle: 7482667652216324306
spritePackingTag:
pSDRemoveMatte: 0
pSDShowRemoveMatteOption: 0
userData:
assetBundleName:
assetBundleVariant:
@@ -1,117 +0,0 @@
fileFormatVersion: 2
guid: 7fee21f21c4f146f69dd2eae14a94b4b
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: bfa8cdc5e73d54ed1876c17a47ff7346
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: bd23b09c6f27b4754a769858a2020668
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: 3fe839b58f5fd46d695ece8ccf2412a5
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,4 +1,3 @@
using System.Collections;
using UnityEngine;
namespace Shardok {
@@ -126,30 +125,13 @@ namespace Shardok {
Debug.Log("Melee sequence complete!");
}
public void TestMove() { StartCoroutine(TestMoveSequence()); }
private IEnumerator TestMoveSequence() {
Debug.Log($"Testing Move (Infantry): {sourceCell} -> {targetCell}");
public void TestMove() {
Debug.Log($"Testing Move: {sourceCell} -> {targetCell}");
if (_moveAnimator != null) {
_moveAnimator.AnimateMove(
sourceCell,
targetCell,
Net.Eagle0.Shardok.Api.BattalionView.Types.BattalionTypeId.LightInfantry);
_moveAnimator.AnimateMove(sourceCell, targetCell);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.Move);
} else {
Debug.LogWarning("MoveAnimator not available");
yield break;
}
yield return new WaitForSeconds(1.5f);
Debug.Log($"Testing Move (Cavalry): {sourceCell} -> {targetCell}");
if (_moveAnimator != null) {
_moveAnimator.AnimateMove(
sourceCell,
targetCell,
Net.Eagle0.Shardok.Api.BattalionView.Types.BattalionTypeId.LightCavalry);
PlaySound(Net.Eagle0.Shardok.Common.ActionType.Move);
}
}
@@ -59,8 +59,8 @@ namespace Shardok {
return;
}
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
Vector2? sourcePos = __hexGrid.GetCellLocalPosition(sourceCellIndex);
Vector2? targetPos = __hexGrid.GetCellLocalPosition(targetCellIndex);
if (sourcePos == null || targetPos == null) {
Debug.LogWarning(
@@ -68,10 +68,14 @@ namespace Shardok {
return;
}
Debug.Log(
$"ArrowVolleyAnimator: Starting volley from cell {sourceCellIndex} to {targetCellIndex}");
StartCoroutine(SpawnArrowVolley(sourcePos.Value, targetPos.Value));
}
private IEnumerator SpawnArrowVolley(Vector2 sourcePosition, Vector2 targetPosition) {
Debug.Log(
$"ArrowVolleyAnimator: Spawning {arrowCount} arrows from {sourcePosition} to {targetPosition}");
var arrows = new List<GameObject>();
for (int i = 0; i < arrowCount; i++) {
@@ -59,8 +59,8 @@ namespace Shardok {
return;
}
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
Vector2? sourcePos = __hexGrid.GetCellLocalPosition(sourceCellIndex);
Vector2? targetPos = __hexGrid.GetCellLocalPosition(targetCellIndex);
if (sourcePos == null || targetPos == null) {
Debug.LogWarning(
@@ -7,8 +7,7 @@ using static Net.Eagle0.Shardok.Api.BattalionView.Types;
namespace Shardok {
/// <summary>
/// Animates charge attacks between two hexes.
/// Shows attacker's weapon thrusting toward defender with accelerating motion
/// and a violent flash on impact.
/// Similar to MeleeAnimator but with more dramatic motion for charging units.
/// </summary>
public class ChargeAnimator : MonoBehaviour {
[Header("Weapon Sprites")]
@@ -30,41 +29,28 @@ namespace Shardok {
[Tooltip("Bone/claw sprite for undead charge")]
public Sprite boneSprite;
[Header("Impact Flash Settings")]
[Tooltip("Sprite for impact flash")]
public Sprite flashSprite;
[Tooltip("Color of impact flash")]
public Color flashColor = new Color(1f, 1f, 0.8f, 0.9f);
[Tooltip("Scale of impact flash")]
public float flashScale = 35f;
[Tooltip("Duration of flash")]
public float flashDuration = 0.1f;
[Header("Animation Settings")]
[Tooltip("Base scale of weapon sprites")]
public float weaponScale = 25.0f;
[Tooltip("Number of thrusts")]
public int thrustCount = 2;
[Tooltip("Duration of the charge thrust in seconds")]
public float chargeDuration = 0.15f;
[Tooltip("Duration of each thrust (slow to fast)")]
public float thrustDuration = 0.25f;
[Tooltip("Number of impact cycles")]
public int impactCount = 2;
[Tooltip("Duration of pullback between thrusts")]
public float pullbackDuration = 0.15f;
[Tooltip("Distance weapons travel toward each other (in local units)")]
public float thrustDistance = 35f;
[Tooltip("Starting position offset from midpoint (in local units)")]
public float startOffset = 60f;
[Tooltip("End position offset from midpoint (in local units)")]
public float endOffset = 15f;
[Tooltip("Offset from center point along attack axis (in local units)")]
public float weaponOffset = 50f;
[Tooltip("Base rotation offset for diagonal sprites (degrees)")]
public float baseRotationOffset = -45f;
[Tooltip("Amount of random shake (in local units)")]
public float shakeIntensity = 12f;
private HexGrid __hexGrid;
private Coroutine _activeAnimation;
@@ -73,6 +59,10 @@ namespace Shardok {
/// <summary>
/// Starts a charge animation between two hex cells.
/// </summary>
/// <param name="attackerCellIndex">Grid index of the charging hex</param>
/// <param name="defenderCellIndex">Grid index of the defending hex</param>
/// <param name="attackerType">Battalion type of the attacker</param>
/// <param name="defenderType">Battalion type of the defender</param>
public void AnimateCharge(
int attackerCellIndex,
int defenderCellIndex,
@@ -89,8 +79,8 @@ namespace Shardok {
return;
}
Vector2? attackerPos = __hexGrid.GetCellCenterPosition(attackerCellIndex);
Vector2? defenderPos = __hexGrid.GetCellCenterPosition(defenderCellIndex);
Vector2? attackerPos = __hexGrid.GetCellLocalPosition(attackerCellIndex);
Vector2? defenderPos = __hexGrid.GetCellLocalPosition(defenderCellIndex);
if (attackerPos == null || defenderPos == null) {
Debug.LogWarning(
@@ -100,148 +90,238 @@ namespace Shardok {
if (_activeAnimation != null) { StopCoroutine(_activeAnimation); }
_activeAnimation = StartCoroutine(
AnimateChargeThrusts(attackerPos.Value, defenderPos.Value, attackerType));
Debug.Log(
$"ChargeAnimator: Starting charge from cell {attackerCellIndex} ({attackerType}) to {defenderCellIndex} ({defenderType})");
_activeAnimation = StartCoroutine(AnimateChargeClash(
attackerPos.Value,
defenderPos.Value,
attackerType,
defenderType));
}
private Sprite GetWeaponSprite(BattalionTypeId type) {
private struct WeaponConfig {
public Sprite sprite;
public float scaleMultiplier;
public float violenceMultiplier;
}
private WeaponConfig GetWeaponConfig(BattalionTypeId type) {
switch (type) {
case BattalionTypeId.LightInfantry: return swordSprite;
case BattalionTypeId.HeavyInfantry: return maceSprite ?? swordSprite;
case BattalionTypeId.LightInfantry:
return new WeaponConfig {
sprite = swordSprite,
scaleMultiplier = 1.0f,
violenceMultiplier = 1.2f
};
case BattalionTypeId.HeavyInfantry:
return new WeaponConfig {
sprite = maceSprite ?? swordSprite,
scaleMultiplier = 1.2f,
violenceMultiplier = 1.4f
};
case BattalionTypeId.LightCavalry:
return smallSpearSprite ?? largeLanceSprite ?? swordSprite;
return new WeaponConfig {
sprite = smallSpearSprite ?? largeLanceSprite ?? swordSprite,
scaleMultiplier = 1.1f,
violenceMultiplier = 1.6f
};
case BattalionTypeId.HeavyCavalry:
return largeLanceSprite ?? smallSpearSprite ?? swordSprite;
case BattalionTypeId.Longbowmen: return daggerSprite ?? swordSprite;
case BattalionTypeId.Undead: return boneSprite ?? swordSprite;
default: return swordSprite;
return new WeaponConfig {
sprite = largeLanceSprite ?? smallSpearSprite ?? swordSprite,
scaleMultiplier = 1.4f,
violenceMultiplier = 2.0f
};
case BattalionTypeId.Longbowmen:
return new WeaponConfig {
sprite = daggerSprite ?? swordSprite,
scaleMultiplier = 0.8f,
violenceMultiplier = 1.0f
};
case BattalionTypeId.Undead:
return new WeaponConfig {
sprite = boneSprite ?? swordSprite,
scaleMultiplier = 1.0f,
violenceMultiplier = 1.3f
};
default:
return new WeaponConfig {
sprite = swordSprite,
scaleMultiplier = 1.0f,
violenceMultiplier = 1.2f
};
}
}
private float GetScaleMultiplier(BattalionTypeId type) {
switch (type) {
case BattalionTypeId.HeavyInfantry: return 1.2f;
case BattalionTypeId.LightCavalry: return 1.1f;
case BattalionTypeId.HeavyCavalry: return 1.4f;
case BattalionTypeId.Longbowmen: return 0.8f;
default: return 1.0f;
}
}
private IEnumerator AnimateChargeThrusts(
private IEnumerator AnimateChargeClash(
Vector2 attackerPos,
Vector2 defenderPos,
BattalionTypeId attackerType) {
// Calculate direction toward defender
BattalionTypeId attackerType,
BattalionTypeId defenderType) {
// Calculate midpoint and direction
Vector2 midpoint = (attackerPos + defenderPos) / 2f;
Vector2 direction = (defenderPos - attackerPos).normalized;
float baseAngle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
// Create attacker weapon
Sprite weaponSprite = GetWeaponSprite(attackerType);
float scale = weaponScale * GetScaleMultiplier(attackerType);
// Get weapon configs
WeaponConfig attackerConfig = GetWeaponConfig(attackerType);
WeaponConfig defenderConfig = GetWeaponConfig(defenderType);
// Create weapons
GameObject attackerWeapon = CreateWeapon(attackerConfig, baseAngle);
GameObject defenderWeapon = CreateWeapon(defenderConfig, baseAngle + 180f);
float attackerViolence = shakeIntensity * attackerConfig.violenceMultiplier;
float defenderViolence = shakeIntensity * defenderConfig.violenceMultiplier;
// Starting positions offset from midpoint (further back for charge buildup)
Vector3 attackerStart = new Vector3(
midpoint.x - direction.x * weaponOffset,
midpoint.y - direction.y * weaponOffset,
10f);
Vector3 defenderStart = new Vector3(
midpoint.x + direction.x * weaponOffset,
midpoint.y + direction.y * weaponOffset,
10f);
// Clash point (closer to midpoint)
Vector3 attackerClash = new Vector3(
midpoint.x - direction.x * (weaponOffset - thrustDistance),
midpoint.y - direction.y * (weaponOffset - thrustDistance),
10f);
Vector3 defenderClash = new Vector3(
midpoint.x + direction.x * (weaponOffset - thrustDistance),
midpoint.y + direction.y * (weaponOffset - thrustDistance),
10f);
attackerWeapon.transform.localPosition = attackerStart;
defenderWeapon.transform.localPosition = defenderStart;
// Animate impact cycles
for (int i = 0; i < impactCount; i++) {
// Charge thrust toward clash point
yield return StartCoroutine(AnimateWeaponThrust(
attackerWeapon,
attackerStart,
attackerClash,
chargeDuration / 2f,
true,
attackerViolence));
yield return StartCoroutine(AnimateWeaponThrust(
defenderWeapon,
defenderStart,
defenderClash,
chargeDuration / 2f,
true,
defenderViolence));
// Violent impact shake
float impactViolence = Mathf.Max(attackerViolence, defenderViolence);
yield return StartCoroutine(ImpactShake(
attackerWeapon,
defenderWeapon,
attackerClash,
defenderClash,
0.08f,
impactViolence));
// Pull back
yield return StartCoroutine(AnimateWeaponThrust(
attackerWeapon,
attackerClash,
attackerStart,
chargeDuration / 2f,
false,
attackerViolence * 0.5f));
yield return StartCoroutine(AnimateWeaponThrust(
defenderWeapon,
defenderClash,
defenderStart,
chargeDuration / 2f,
false,
defenderViolence * 0.5f));
}
// Cleanup
if (attackerWeapon != null) { Destroy(attackerWeapon); }
if (defenderWeapon != null) { Destroy(defenderWeapon); }
_activeAnimation = null;
}
private GameObject CreateWeapon(WeaponConfig config, float angle) {
GameObject weapon = new GameObject("ChargeWeapon");
weapon.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer renderer = weapon.AddComponent<SpriteRenderer>();
renderer.sprite = weaponSprite;
renderer.sprite = config.sprite;
renderer.sortingLayerName = "Effects";
weapon.transform.localRotation = Quaternion.Euler(0, 0, baseAngle + baseRotationOffset);
weapon.transform.localRotation = Quaternion.Euler(0, 0, angle + baseRotationOffset);
float scale = weaponScale * config.scaleMultiplier;
weapon.transform.localScale = new Vector3(scale, scale, scale);
// Calculate positions
Vector3 startPos = new Vector3(
midpoint.x - direction.x * startOffset,
midpoint.y - direction.y * startOffset,
10f);
Vector3 endPos = new Vector3(
midpoint.x + direction.x * endOffset,
midpoint.y + direction.y * endOffset,
10f);
weapon.transform.localPosition = startPos;
// Animate thrusts
for (int i = 0; i < thrustCount; i++) {
// Thrust forward - starts slow, accelerates
float elapsed = 0f;
while (elapsed < thrustDuration) {
if (weapon == null) yield break;
float t = elapsed / thrustDuration;
// Cubic ease-in for accelerating thrust
float easeT = t * t * t;
weapon.transform.localPosition = Vector3.Lerp(startPos, endPos, easeT);
elapsed += Time.deltaTime;
yield return null;
}
weapon.transform.localPosition = endPos;
// Impact flash
if (flashSprite != null) {
yield return StartCoroutine(ShowImpactFlash(endPos, direction));
}
// Pullback - smooth ease out
elapsed = 0f;
while (elapsed < pullbackDuration) {
if (weapon == null) yield break;
float t = elapsed / pullbackDuration;
// Ease out for smooth pullback
float easeT = t * (2f - t);
weapon.transform.localPosition = Vector3.Lerp(endPos, startPos, easeT);
elapsed += Time.deltaTime;
yield return null;
}
weapon.transform.localPosition = startPos;
// Brief pause between thrusts
if (i < thrustCount - 1) { yield return new WaitForSeconds(0.05f); }
}
// Cleanup
if (weapon != null) { Destroy(weapon); }
_activeAnimation = null;
return weapon;
}
private IEnumerator ShowImpactFlash(Vector3 position, Vector2 direction) {
GameObject flash = new GameObject("ChargeFlash");
flash.transform.SetParent(__hexGrid.gridCanvas.transform, false);
flash.transform.localPosition = position;
SpriteRenderer renderer = flash.AddComponent<SpriteRenderer>();
renderer.sprite = flashSprite;
renderer.sortingLayerName = "Effects";
renderer.color = flashColor;
private IEnumerator AnimateWeaponThrust(
GameObject weapon,
Vector3 fromPos,
Vector3 toPos,
float duration,
bool isCharging,
float violence) {
float elapsed = 0f;
while (elapsed < flashDuration) {
float t = elapsed / flashDuration;
// Quick expand then fade
float expandT = t < 0.3f ? t / 0.3f : 1f;
float scale = flashScale * (0.5f + 0.5f * expandT);
flash.transform.localScale = Vector3.one * scale;
while (elapsed < duration) {
if (weapon == null) { yield break; }
// Fade out
Color c = flashColor;
c.a = flashColor.a * (1f - t);
renderer.color = c;
float t = elapsed / duration;
// More aggressive ease for charge, smoother for retreat
float smoothT = isCharging ? t * t * t : t * (2f - t);
// Position with shake
Vector3 shake = new Vector3(
Random.Range(-violence, violence),
Random.Range(-violence, violence),
0f);
weapon.transform.localPosition = Vector3.Lerp(fromPos, toPos, smoothT) + shake;
elapsed += Time.deltaTime;
yield return null;
}
Destroy(flash);
if (weapon != null) { weapon.transform.localPosition = toPos; }
}
private IEnumerator ImpactShake(
GameObject weapon1,
GameObject weapon2,
Vector3 pos1,
Vector3 pos2,
float duration,
float violence) {
float elapsed = 0f;
float intensity = violence * 2.5f; // Extra intense for charge impact
while (elapsed < duration) {
if (weapon1 == null || weapon2 == null) { yield break; }
Vector3 shake1 = new Vector3(
Random.Range(-intensity, intensity),
Random.Range(-intensity, intensity),
0f);
Vector3 shake2 = new Vector3(
Random.Range(-intensity, intensity),
Random.Range(-intensity, intensity),
0f);
weapon1.transform.localPosition = pos1 + shake1;
weapon2.transform.localPosition = pos2 + shake2;
elapsed += Time.deltaTime;
yield return null;
}
}
/// <summary>
@@ -12,13 +12,7 @@ namespace Shardok {
public Sprite dropletSprite;
[Tooltip("Number of droplets")]
public int dropletCount = 25;
[Tooltip("Minimum droplet scale")]
public float dropletScaleMin = 5f;
[Tooltip("Maximum droplet scale")]
public float dropletScaleMax = 15f;
public int dropletCount = 12;
[Tooltip("Color of water")]
public Color waterColor = new Color(0.3f, 0.6f, 0.9f, 0.8f);
@@ -28,29 +22,23 @@ namespace Shardok {
public Sprite steamSprite;
[Tooltip("Number of steam puffs")]
public int steamCount = 8;
public int steamCount = 5;
[Tooltip("Color of steam")]
public Color steamColor = new Color(0.9f, 0.9f, 0.9f, 0.6f);
[Header("Animation Settings")]
[Tooltip("Height water falls from")]
public float fallHeight = 100f;
public float fallHeight = 60f;
[Tooltip("Spread radius of the water")]
public float waterSpread = 40f;
public float waterSpread = 25f;
[Tooltip("Duration of the water fall")]
public float fallDuration = 0.5f;
[Tooltip("Time spread for staggered droplet launch")]
public float launchSpread = 0.25f;
[Tooltip("Horizontal chaos during fall")]
public float fallChaos = 15f;
public float fallDuration = 0.3f;
[Tooltip("Duration of the steam effect")]
public float steamDuration = 0.5f;
public float steamDuration = 0.4f;
private HexGrid __hexGrid;
@@ -84,7 +72,6 @@ namespace Shardok {
// Create droplets
GameObject[] droplets = new GameObject[dropletCount];
Vector2[] dropletOffsets = new Vector2[dropletCount];
Vector2[] dropletChaos = new Vector2[dropletCount];
float[] dropletDelays = new float[dropletCount];
for (int i = 0; i < dropletCount; i++) {
@@ -97,23 +84,17 @@ namespace Shardok {
renderer.sprite = dropletSprite;
renderer.sortingLayerName = "Effects";
renderer.color = waterColor;
droplet.transform.localScale =
Vector3.one * Random.Range(dropletScaleMin, dropletScaleMax);
droplet.transform.localScale = Vector3.one * Random.Range(4f, 8f);
float angle = Random.Range(0f, Mathf.PI * 2f);
float radius = waterSpread * Random.Range(0f, 1f);
dropletOffsets[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
dropletDelays[i] = Random.Range(0f, launchSpread);
dropletDelays[i] = Random.Range(0f, fallDuration * 0.3f);
// Random horizontal chaos direction for each droplet
float chaosAngle = Random.Range(0f, Mathf.PI * 2f);
dropletChaos[i] =
new Vector2(Mathf.Cos(chaosAngle), Mathf.Sin(chaosAngle)) * fallChaos;
// Start above with some horizontal spread
// Start above
droplet.transform.localPosition = new Vector3(
target.x + dropletOffsets[i].x * 0.3f,
target.y + fallHeight + Random.Range(-10f, 10f),
target.x + dropletOffsets[i].x,
target.y + fallHeight + dropletOffsets[i].y * 0.5f,
5f);
droplets[i] = droplet;
@@ -121,27 +102,23 @@ namespace Shardok {
// Animate falling water
float elapsed = 0f;
float totalDuration = fallDuration + launchSpread;
while (elapsed < totalDuration) {
while (elapsed < fallDuration) {
float t = elapsed / fallDuration;
for (int i = 0; i < droplets.Length; i++) {
if (droplets[i] == null) continue;
float dropletT = Mathf.Clamp01(
(elapsed - dropletDelays[i]) /
(fallDuration - dropletDelays[i] * 0.5f));
(elapsed - dropletDelays[i]) / (fallDuration - dropletDelays[i]));
if (dropletT <= 0) continue;
// Accelerating fall
float fallT = dropletT * dropletT;
// Add chaotic horizontal movement (sine wave pattern)
float chaosT = Mathf.Sin(dropletT * Mathf.PI * 2f) * (1f - dropletT);
Vector2 chaos = dropletChaos[i] * chaosT;
float x = target.x + dropletOffsets[i].x + chaos.x;
float y = target.y + fallHeight * (1f - fallT);
droplets[i].transform.localPosition = new Vector3(x, y, 5f);
droplets[i].transform.localPosition =
new Vector3(target.x + dropletOffsets[i].x, y, 5f);
}
elapsed += Time.deltaTime;
@@ -462,14 +462,11 @@ public class HexGrid : MonoBehaviour {
}
public void SetProfessionImage(int cellIndex, Texture image) {
var professionImage = cells[cellIndex].ProfessionImage;
if (professionImage == null) return;
if (image == null) {
professionImage.gameObject.SetActive(value: false);
cells[cellIndex].ProfessionImage.gameObject.SetActive(value: false);
} else {
professionImage.texture = image;
professionImage.gameObject.SetActive(value: true);
cells[cellIndex].ProfessionImage.texture = image;
cells[cellIndex].ProfessionImage.gameObject.SetActive(value: true);
}
}
@@ -478,14 +475,11 @@ public class HexGrid : MonoBehaviour {
}
public void SetUnitTypeImage(int cellIndex, Texture image) {
var unitTypeImage = cells[cellIndex].UnitTypeImage;
if (unitTypeImage == null) return;
if (image == null) {
unitTypeImage.gameObject.SetActive(value: false);
cells[cellIndex].UnitTypeImage.gameObject.SetActive(value: false);
} else {
unitTypeImage.texture = image;
unitTypeImage.gameObject.SetActive(value: true);
cells[cellIndex].UnitTypeImage.texture = image;
cells[cellIndex].UnitTypeImage.gameObject.SetActive(value: true);
}
}
@@ -18,11 +18,11 @@ namespace Shardok {
[Tooltip("Mace/hammer sprite for heavy infantry")]
public Sprite maceSprite;
[Tooltip("Falchion sprite for light cavalry")]
public Sprite falchionSprite;
[Tooltip("Small spear sprite for light cavalry")]
public Sprite smallSpearSprite;
[Tooltip("Two-handed sword sprite for heavy cavalry")]
public Sprite twoHandedSwordSprite;
[Tooltip("Large spear/lance sprite for heavy cavalry")]
public Sprite largeSpearSprite;
[Tooltip("Dagger sprite for longbowmen")]
public Sprite daggerSprite;
@@ -30,57 +30,15 @@ namespace Shardok {
[Tooltip("Bone/fist sprite for undead")]
public Sprite boneSprite;
[Header("Weapon Orientation - Sword")]
[Tooltip("Base rotation offset for sword (degrees)")]
public float swordRotationOffset = -45f;
[Tooltip("Flip sword horizontally")]
public bool swordFlipHorizontal = false;
[Header("Weapon Orientation - Mace")]
[Tooltip("Base rotation offset for mace (degrees)")]
public float maceRotationOffset = -45f;
[Tooltip("Flip mace horizontally")]
public bool maceFlipHorizontal = false;
[Header("Weapon Orientation - Falchion")]
[Tooltip("Base rotation offset for falchion (degrees)")]
public float falchionRotationOffset = -45f;
[Tooltip("Flip falchion horizontally")]
public bool falchionFlipHorizontal = false;
[Header("Weapon Orientation - Two-Handed Sword")]
[Tooltip("Base rotation offset for two-handed sword (degrees)")]
public float twoHandedSwordRotationOffset = -45f;
[Tooltip("Flip two-handed sword horizontally")]
public bool twoHandedSwordFlipHorizontal = false;
[Header("Weapon Orientation - Dagger")]
[Tooltip("Base rotation offset for dagger (degrees)")]
public float daggerRotationOffset = -45f;
[Tooltip("Flip dagger horizontally")]
public bool daggerFlipHorizontal = false;
[Header("Weapon Orientation - Bone")]
[Tooltip("Base rotation offset for bone (degrees)")]
public float boneRotationOffset = -45f;
[Tooltip("Flip bone horizontally")]
public bool boneFlipHorizontal = false;
[Header("Animation Settings")]
[Tooltip("Base scale of weapon sprites")]
public float weaponScale = 20.0f;
[Tooltip("Duration of a single swing cycle in seconds")]
public float clashDuration = 0.3f;
[Tooltip("Duration of a single clash cycle in seconds")]
public float clashDuration = 0.12f;
[Tooltip("Number of swing cycles")]
public int clashCount = 2;
[Tooltip("Number of clash cycles")]
public int clashCount = 3;
[Tooltip("Distance weapons travel toward each other (in local units)")]
public float thrustDistance = 25f;
@@ -88,30 +46,20 @@ namespace Shardok {
[Tooltip("Offset from center point along attack axis (in local units)")]
public float weaponOffset = 40f;
[Tooltip("Swing arc angle for swinging weapons (degrees)")]
public float swingArc = 90f;
[Tooltip("Base rotation offset for diagonal sprites (degrees)")]
public float baseRotationOffset = -45f;
[Tooltip("Amount of shake at impact only (in local units)")]
public float shakeIntensity = 3f;
[Tooltip("Swing arc angle for swinging weapons (degrees)")]
public float swingArc = 60f;
[Tooltip("Amount of random shake (in local units)")]
public float shakeIntensity = 8f;
private HexGrid __hexGrid;
private Coroutine _activeAnimation;
private GameObject _attackerWeapon;
private GameObject _defenderWeapon;
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
private void CleanupWeapons() {
if (_attackerWeapon != null) {
Destroy(_attackerWeapon);
_attackerWeapon = null;
}
if (_defenderWeapon != null) {
Destroy(_defenderWeapon);
_defenderWeapon = null;
}
}
/// <summary>
/// Starts a melee animation between two adjacent hex cells.
/// </summary>
@@ -124,8 +72,8 @@ namespace Shardok {
int defenderCellIndex,
BattalionTypeId attackerType,
BattalionTypeId defenderType) {
if (swordSprite == null && maceSprite == null && falchionSprite == null &&
twoHandedSwordSprite == null && daggerSprite == null && boneSprite == null) {
if (swordSprite == null && maceSprite == null && smallSpearSprite == null &&
largeSpearSprite == null && daggerSprite == null && boneSprite == null) {
Debug.LogWarning("MeleeAnimator: No weapon sprites assigned");
return;
}
@@ -135,8 +83,8 @@ namespace Shardok {
return;
}
Vector2? attackerPos = __hexGrid.GetCellCenterPosition(attackerCellIndex);
Vector2? defenderPos = __hexGrid.GetCellCenterPosition(defenderCellIndex);
Vector2? attackerPos = __hexGrid.GetCellLocalPosition(attackerCellIndex);
Vector2? defenderPos = __hexGrid.GetCellLocalPosition(defenderCellIndex);
if (attackerPos == null || defenderPos == null) {
Debug.LogWarning(
@@ -144,11 +92,10 @@ namespace Shardok {
return;
}
if (_activeAnimation != null) {
StopCoroutine(_activeAnimation);
CleanupWeapons();
}
if (_activeAnimation != null) { StopCoroutine(_activeAnimation); }
Debug.Log(
$"MeleeAnimator: Starting melee from cell {attackerCellIndex} ({attackerType}) to {defenderCellIndex} ({defenderType})");
_activeAnimation = StartCoroutine(
AnimateClash(attackerPos.Value, defenderPos.Value, attackerType, defenderType));
}
@@ -157,8 +104,7 @@ namespace Shardok {
public Sprite sprite;
public bool swings; // true = swing arc, false = thrust
public float scaleMultiplier;
public float rotationOffset;
public bool flipHorizontal;
public float violenceMultiplier; // shake intensity multiplier
}
private WeaponConfig GetWeaponConfig(BattalionTypeId type) {
@@ -168,66 +114,49 @@ namespace Shardok {
sprite = swordSprite,
swings = true,
scaleMultiplier = 1.0f,
rotationOffset = swordRotationOffset,
flipHorizontal = swordFlipHorizontal
violenceMultiplier = 0.3f
};
case BattalionTypeId.HeavyInfantry:
return new WeaponConfig {
sprite = maceSprite ?? swordSprite,
swings = true,
scaleMultiplier = 1.2f,
rotationOffset =
maceSprite != null ? maceRotationOffset : swordRotationOffset,
flipHorizontal =
maceSprite != null ? maceFlipHorizontal : swordFlipHorizontal
violenceMultiplier = 0.4f
};
case BattalionTypeId.LightCavalry:
return new WeaponConfig {
sprite = falchionSprite ?? swordSprite,
swings = true,
scaleMultiplier = 1.0f,
rotationOffset = falchionSprite != null ? falchionRotationOffset
: swordRotationOffset,
flipHorizontal = falchionSprite != null ? falchionFlipHorizontal
: swordFlipHorizontal
sprite = smallSpearSprite ?? largeSpearSprite ?? swordSprite,
swings = false,
scaleMultiplier = 0.9f,
violenceMultiplier = 1.0f
};
case BattalionTypeId.HeavyCavalry:
return new WeaponConfig {
sprite = twoHandedSwordSprite ?? swordSprite,
swings = true,
sprite = largeSpearSprite ?? smallSpearSprite ?? swordSprite,
swings = false,
scaleMultiplier = 1.4f,
rotationOffset = twoHandedSwordSprite != null ? twoHandedSwordRotationOffset
: swordRotationOffset,
flipHorizontal = twoHandedSwordSprite != null ? twoHandedSwordFlipHorizontal
: swordFlipHorizontal
violenceMultiplier = 1.8f
};
case BattalionTypeId.Longbowmen:
return new WeaponConfig {
sprite = daggerSprite ?? swordSprite,
swings = false,
scaleMultiplier = 0.7f,
rotationOffset =
daggerSprite != null ? daggerRotationOffset : swordRotationOffset,
flipHorizontal =
daggerSprite != null ? daggerFlipHorizontal : swordFlipHorizontal
violenceMultiplier = 0.8f
};
case BattalionTypeId.Undead:
return new WeaponConfig {
sprite = boneSprite ?? swordSprite,
swings = true,
scaleMultiplier = 1.0f,
rotationOffset =
boneSprite != null ? boneRotationOffset : swordRotationOffset,
flipHorizontal =
boneSprite != null ? boneFlipHorizontal : swordFlipHorizontal
violenceMultiplier = 0.5f
};
default:
return new WeaponConfig {
sprite = swordSprite,
swings = true,
scaleMultiplier = 1.0f,
rotationOffset = swordRotationOffset,
flipHorizontal = swordFlipHorizontal
violenceMultiplier = 0.3f
};
}
}
@@ -246,12 +175,14 @@ namespace Shardok {
WeaponConfig attackerConfig = GetWeaponConfig(attackerType);
WeaponConfig defenderConfig = GetWeaponConfig(defenderType);
// Create weapons with type-specific scaling (stored in class fields for cleanup)
_attackerWeapon = CreateWeapon(attackerConfig, baseAngle);
_defenderWeapon = CreateWeapon(defenderConfig, baseAngle + 180f);
// Create weapons with type-specific scaling
GameObject attackerWeapon = CreateWeapon(attackerConfig, baseAngle);
GameObject defenderWeapon = CreateWeapon(defenderConfig, baseAngle + 180f);
bool attackerSwings = attackerConfig.swings;
bool defenderSwings = defenderConfig.swings;
float attackerViolence = shakeIntensity * attackerConfig.violenceMultiplier;
float defenderViolence = shakeIntensity * defenderConfig.violenceMultiplier;
// Starting positions offset from midpoint
Vector3 attackerStart = new Vector3(
@@ -273,71 +204,77 @@ namespace Shardok {
midpoint.y + direction.y * (weaponOffset - thrustDistance),
10f);
_attackerWeapon.transform.localPosition = attackerStart;
_defenderWeapon.transform.localPosition = defenderStart;
attackerWeapon.transform.localPosition = attackerStart;
defenderWeapon.transform.localPosition = defenderStart;
float attackerBaseAngle = baseAngle + attackerConfig.rotationOffset;
float defenderBaseAngle = baseAngle + 180f + defenderConfig.rotationOffset;
float attackerBaseAngle = baseAngle + baseRotationOffset;
float defenderBaseAngle = baseAngle + 180f + baseRotationOffset;
// Animate swing cycles with alternating swing directions
// Animate clash cycles with alternating swing directions
for (int i = 0; i < clashCount; i++) {
// Alternate swing direction each cycle
float swingDirection = (i % 2 == 0) ? 1f : -1f;
// Swing toward clash point
// Swing/thrust toward clash point
yield return StartCoroutine(AnimateWeaponSwing(
_attackerWeapon,
attackerWeapon,
attackerStart,
attackerClash,
attackerBaseAngle,
attackerSwings ? -swingArc * swingDirection : 0f,
attackerSwings ? 0f : 0f,
clashDuration / 2f,
true));
true,
attackerViolence));
yield return StartCoroutine(AnimateWeaponSwing(
_defenderWeapon,
defenderWeapon,
defenderStart,
defenderClash,
defenderBaseAngle,
defenderSwings ? swingArc * swingDirection : 0f,
defenderSwings ? 0f : 0f,
clashDuration / 2f,
true));
true,
defenderViolence));
// Brief shake at impact
// Brief violent shake at impact (use max violence of both)
float impactViolence = Mathf.Max(attackerViolence, defenderViolence);
yield return StartCoroutine(ImpactShake(
_attackerWeapon,
_defenderWeapon,
attackerWeapon,
defenderWeapon,
attackerClash,
defenderClash,
0.05f,
shakeIntensity));
impactViolence));
// Swing back
// Swing/pull back
yield return StartCoroutine(AnimateWeaponSwing(
_attackerWeapon,
attackerWeapon,
attackerClash,
attackerStart,
attackerBaseAngle,
0f,
attackerSwings ? -swingArc * swingDirection : 0f,
clashDuration / 2f,
false));
false,
attackerViolence));
yield return StartCoroutine(AnimateWeaponSwing(
_defenderWeapon,
defenderWeapon,
defenderClash,
defenderStart,
defenderBaseAngle,
0f,
defenderSwings ? swingArc * swingDirection : 0f,
clashDuration / 2f,
false));
false,
defenderViolence));
}
// Cleanup
CleanupWeapons();
if (attackerWeapon != null) { Destroy(attackerWeapon); }
if (defenderWeapon != null) { Destroy(defenderWeapon); }
_activeAnimation = null;
}
@@ -349,10 +286,9 @@ namespace Shardok {
renderer.sprite = config.sprite;
renderer.sortingLayerName = "Effects";
weapon.transform.localRotation = Quaternion.Euler(0, 0, angle + config.rotationOffset);
weapon.transform.localRotation = Quaternion.Euler(0, 0, angle + baseRotationOffset);
float scale = weaponScale * config.scaleMultiplier;
float xScale = config.flipHorizontal ? -scale : scale;
weapon.transform.localScale = new Vector3(xScale, scale, scale);
weapon.transform.localScale = new Vector3(scale, scale, scale);
return weapon;
}
@@ -365,20 +301,25 @@ namespace Shardok {
float fromAngleOffset,
float toAngleOffset,
float duration,
bool isAttacking) {
bool isAttacking,
float violence) {
float elapsed = 0f;
while (elapsed < duration) {
if (weapon == null) { yield break; }
float t = elapsed / duration;
// Smooth ease for deliberate swing motion
// Aggressive ease for attack, smoother for retreat
float smoothT = isAttacking ? t * t * (3f - 2f * t) : t * (2f - t);
// Clean position interpolation - no shake during swing
weapon.transform.localPosition = Vector3.Lerp(fromPos, toPos, smoothT);
// Position with shake (scaled by violence)
Vector3 shake = new Vector3(
Random.Range(-violence, violence),
Random.Range(-violence, violence),
0f);
weapon.transform.localPosition = Vector3.Lerp(fromPos, toPos, smoothT) + shake;
// Rotation arc for swinging weapons
// Rotation (for swinging weapons)
float currentAngle =
Mathf.Lerp(baseAngle + fromAngleOffset, baseAngle + toAngleOffset, smoothT);
weapon.transform.localRotation = Quaternion.Euler(0, 0, currentAngle);
@@ -431,7 +372,6 @@ namespace Shardok {
StopCoroutine(_activeAnimation);
_activeAnimation = null;
}
CleanupWeapons();
}
}
}
@@ -28,102 +28,40 @@ namespace Shardok {
[Tooltip("Sprite for trail particles")]
public Sprite trailSprite;
[Tooltip("Number of trail particles in continuous trail")]
public int trailParticleCount = 20;
[Tooltip("Number of trail particles")]
public int trailParticleCount = 8;
[Tooltip("Scale of trail particles")]
public float trailScale = 50f;
public float trailScale = 8f;
[Tooltip("Color of trail at front (hottest)")]
public Color trailColorHot = new Color(1f, 0.9f, 0.6f, 0.9f);
[Tooltip("Color of trail at back (cooler)")]
public Color trailColorCool = new Color(1f, 0.4f, 0.1f, 0.6f);
[Tooltip("Trail spread width")]
public float trailSpread = 12f;
[Tooltip("Color of trail")]
public Color trailColor = new Color(1f, 0.7f, 0.3f, 0.8f);
[Header("Explosion Settings")]
[Tooltip("Sprite for explosion")]
public Sprite explosionSprite;
[Tooltip("Initial scale of explosion")]
public float explosionStartScale = 20f;
public float explosionStartScale = 15f;
[Tooltip("Final scale of explosion")]
public float explosionEndScale = 250f;
public float explosionEndScale = 50f;
[Tooltip("Color of explosion core")]
public Color explosionColor = new Color(1f, 0.6f, 0.2f, 1f);
[Tooltip("Color of explosion")]
public Color explosionColor = new Color(1f, 0.4f, 0.1f, 1f);
[Tooltip("Number of fire sparks")]
public int sparkCount = 8;
[Tooltip("Number of sparks")]
public int sparkCount = 6;
[Tooltip("Spark spread distance")]
public float sparkSpread = 50f;
[Header("Debris Settings")]
[Tooltip("Sprite for rock debris")]
public Sprite debrisSprite;
[Tooltip("Number of debris rocks")]
public int debrisCount = 6;
[Tooltip("Scale of debris rocks")]
public float debrisScale = 6f;
[Tooltip("Debris spread distance")]
public float debrisSpread = 60f;
[Tooltip("Color of debris")]
public Color debrisColor = new Color(0.4f, 0.3f, 0.2f, 1f);
[Header("Meteor Rotation")]
[Tooltip("Rotation speed in degrees per second")]
public float rotationSpeed = 360f;
[Header("Charging Settings (MeteorStart)")]
[Tooltip("Sprite for charging glow")]
public Sprite chargeSprite;
[Tooltip("Color of charging effect")]
public Color chargeColor = new Color(1f, 0.6f, 0.2f, 0.7f);
[Tooltip("Starting scale of charge glow")]
public float chargeStartScale = 5f;
[Tooltip("Ending scale of charge glow")]
public float chargeEndScale = 25f;
[Tooltip("Duration of charge animation")]
public float chargeDuration = 0.5f;
[Header("Targeting Settings (MeteorTarget)")]
[Tooltip("Sprite for target indicator")]
public Sprite targetSprite;
[Tooltip("Color of target indicator")]
public Color targetColor = new Color(1f, 0.3f, 0.1f, 0.6f);
[Tooltip("Scale of target indicator")]
public float targetScale = 30f;
[Tooltip("Duration of target animation")]
public float targetDuration = 0.4f;
[Header("Cancel Settings (MeteorCancel)")]
[Tooltip("Color of fizzle effect")]
public Color cancelColor = new Color(0.5f, 0.5f, 0.5f, 0.8f);
[Tooltip("Duration of cancel animation")]
public float cancelDuration = 0.3f;
public float sparkSpread = 40f;
[Header("Animation Settings")]
[Tooltip("Duration of meteor fall")]
public float fallDuration = 0.8f;
public float fallDuration = 0.4f;
[Tooltip("Duration of explosion")]
public float explosionDuration = 0.6f;
public float explosionDuration = 0.5f;
private HexGrid __hexGrid;
@@ -149,315 +87,6 @@ namespace Shardok {
StartCoroutine(AnimateMeteor(targetPos.Value));
}
/// <summary>
/// Animates meteor charging effect at the caster's position.
/// Shows a growing fiery glow gathering energy.
/// </summary>
public void AnimateMeteorStart(int sourceCellIndex) {
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("MeteorAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
if (sourcePos == null) {
Debug.LogWarning($"MeteorAnimator: Invalid cell index {sourceCellIndex}");
return;
}
StartCoroutine(AnimateCharge(sourcePos.Value));
}
/// <summary>
/// Animates meteor targeting indicator at destination.
/// Shows a pulsing circle/crosshairs marking the target.
/// </summary>
public void AnimateMeteorTarget(int targetCellIndex) {
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("MeteorAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
if (targetPos == null) {
Debug.LogWarning($"MeteorAnimator: Invalid cell index {targetCellIndex}");
return;
}
StartCoroutine(AnimateTarget(targetPos.Value));
}
/// <summary>
/// Animates meteor cancel/fizzle effect at the caster's position.
/// Shows the charging energy dissipating.
/// </summary>
public void AnimateMeteorCancel(int sourceCellIndex) {
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("MeteorAnimator: No _hexGrid or gridCanvas available");
return;
}
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
if (sourcePos == null) {
Debug.LogWarning($"MeteorAnimator: Invalid cell index {sourceCellIndex}");
return;
}
StartCoroutine(AnimateCancel(sourcePos.Value));
}
private IEnumerator AnimateCharge(Vector2 source) {
Sprite sprite = chargeSprite ?? explosionSprite;
if (sprite == null) yield break;
// Create charging glow
GameObject glow = new GameObject("MeteorCharge");
glow.transform.SetParent(__hexGrid.gridCanvas.transform, false);
glow.transform.localPosition = new Vector3(source.x, source.y, 5f);
SpriteRenderer renderer = glow.AddComponent<SpriteRenderer>();
renderer.sprite = sprite;
renderer.sortingLayerName = "Effects";
renderer.color = chargeColor;
// Create rising particles
int particleCount = 5;
GameObject[] particles = new GameObject[particleCount];
Vector2[] particleOffsets = new Vector2[particleCount];
for (int i = 0; i < particleCount; i++) {
if (trailSprite == null) break;
GameObject particle = new GameObject($"ChargeParticle_{i}");
particle.transform.SetParent(__hexGrid.gridCanvas.transform, false);
SpriteRenderer pRenderer = particle.AddComponent<SpriteRenderer>();
pRenderer.sprite = trailSprite;
pRenderer.sortingLayerName = "Effects";
pRenderer.color = chargeColor;
particle.transform.localScale = Vector3.one * trailScale;
float angle = (float)i / particleCount * Mathf.PI * 2f;
particleOffsets[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * 20f;
particles[i] = particle;
}
// Animation
float elapsed = 0f;
while (elapsed < chargeDuration) {
float t = elapsed / chargeDuration;
float easeT = t * (2f - t); // Ease out
// Grow glow
float scale = Mathf.Lerp(chargeStartScale, chargeEndScale, easeT);
glow.transform.localScale = Vector3.one * scale;
// Pulse alpha
Color c = chargeColor;
c.a = chargeColor.a * (0.7f + 0.3f * Mathf.Sin(elapsed * 12f));
renderer.color = c;
// Particles spiral inward and upward
for (int i = 0; i < particles.Length; i++) {
if (particles[i] == null) continue;
float particleT = (t + (float)i / particleCount) % 1f;
float radius = 20f * (1f - particleT);
float angle =
particleT * Mathf.PI * 4f + (float)i / particleCount * Mathf.PI * 2f;
Vector2 offset = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius;
particles[i].transform.localPosition = new Vector3(
source.x + offset.x,
source.y + offset.y + particleT * 15f,
4f);
var pRenderer = particles[i].GetComponent<SpriteRenderer>();
if (pRenderer != null) {
Color pc = chargeColor;
pc.a = chargeColor.a * (1f - particleT * 0.5f);
pRenderer.color = pc;
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Brief flash at end
glow.transform.localScale = Vector3.one * chargeEndScale * 1.2f;
Color flashColor = chargeColor;
flashColor.a = 1f;
renderer.color = flashColor;
yield return new WaitForSeconds(0.05f);
// Cleanup
Destroy(glow);
foreach (var p in particles) {
if (p != null) Destroy(p);
}
}
private IEnumerator AnimateTarget(Vector2 target) {
Sprite sprite = targetSprite ?? explosionSprite;
if (sprite == null) yield break;
// Create target indicator
GameObject indicator = new GameObject("MeteorTarget");
indicator.transform.SetParent(__hexGrid.gridCanvas.transform, false);
indicator.transform.localPosition = new Vector3(target.x, target.y, 5f);
SpriteRenderer renderer = indicator.AddComponent<SpriteRenderer>();
renderer.sprite = sprite;
renderer.sortingLayerName = "Effects";
renderer.color = targetColor;
// Create outer ring that contracts
GameObject ring = null;
SpriteRenderer ringRenderer = null;
if (sprite != null) {
ring = new GameObject("TargetRing");
ring.transform.SetParent(__hexGrid.gridCanvas.transform, false);
ring.transform.localPosition = new Vector3(target.x, target.y, 6f);
ringRenderer = ring.AddComponent<SpriteRenderer>();
ringRenderer.sprite = sprite;
ringRenderer.sortingLayerName = "Effects";
Color ringColor = targetColor;
ringColor.a = targetColor.a * 0.5f;
ringRenderer.color = ringColor;
}
// Animation - indicator appears, ring contracts to it
float elapsed = 0f;
while (elapsed < targetDuration) {
float t = elapsed / targetDuration;
// Main indicator pulses
float pulse = 0.8f + 0.2f * Mathf.Sin(elapsed * 15f);
indicator.transform.localScale = Vector3.one * targetScale * pulse;
Color c = targetColor;
c.a = targetColor.a * pulse;
renderer.color = c;
// Ring contracts inward
if (ring != null) {
float ringScale = targetScale * (2f - t);
ring.transform.localScale = Vector3.one * ringScale;
Color rc = targetColor;
rc.a = targetColor.a * 0.5f * (1f - t);
ringRenderer.color = rc;
}
elapsed += Time.deltaTime;
yield return null;
}
// Final pulse and fade
float fadeDuration = 0.15f;
elapsed = 0f;
while (elapsed < fadeDuration) {
float t = elapsed / fadeDuration;
indicator.transform.localScale = Vector3.one * targetScale * (1f + t * 0.3f);
Color c = targetColor;
c.a = targetColor.a * (1f - t);
renderer.color = c;
elapsed += Time.deltaTime;
yield return null;
}
// Cleanup
Destroy(indicator);
if (ring != null) Destroy(ring);
}
private IEnumerator AnimateCancel(Vector2 source) {
Sprite sprite = chargeSprite ?? explosionSprite;
if (sprite == null) yield break;
// Create fizzling glow (starts at charge end size)
GameObject glow = new GameObject("MeteorCancel");
glow.transform.SetParent(__hexGrid.gridCanvas.transform, false);
glow.transform.localPosition = new Vector3(source.x, source.y, 5f);
glow.transform.localScale = Vector3.one * chargeEndScale;
SpriteRenderer renderer = glow.AddComponent<SpriteRenderer>();
renderer.sprite = sprite;
renderer.sortingLayerName = "Effects";
renderer.color = cancelColor;
// Create dispersing particles
int particleCount = 6;
GameObject[] particles = new GameObject[particleCount];
Vector2[] directions = new Vector2[particleCount];
for (int i = 0; i < particleCount; i++) {
if (trailSprite == null) break;
GameObject particle = new GameObject($"CancelParticle_{i}");
particle.transform.SetParent(__hexGrid.gridCanvas.transform, false);
particle.transform.localPosition = new Vector3(source.x, source.y, 4f);
SpriteRenderer pRenderer = particle.AddComponent<SpriteRenderer>();
pRenderer.sprite = trailSprite;
pRenderer.sortingLayerName = "Effects";
pRenderer.color = cancelColor;
particle.transform.localScale = Vector3.one * trailScale;
float angle = (float)i / particleCount * Mathf.PI * 2f + Random.Range(-0.2f, 0.2f);
directions[i] = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
particles[i] = particle;
}
// Animation - shrink and fade while particles disperse
float elapsed = 0f;
while (elapsed < cancelDuration) {
float t = elapsed / cancelDuration;
// Shrink and fade glow
float scale = chargeEndScale * (1f - t * 0.7f);
glow.transform.localScale = Vector3.one * scale;
Color c = cancelColor;
c.a = cancelColor.a * (1f - t);
renderer.color = c;
// Particles drift outward and fade
for (int i = 0; i < particles.Length; i++) {
if (particles[i] == null) continue;
Vector2 pos = source + directions[i] * 30f * t;
particles[i].transform.localPosition = new Vector3(pos.x, pos.y, 4f);
var pRenderer = particles[i].GetComponent<SpriteRenderer>();
if (pRenderer != null) {
Color pc = cancelColor;
pc.a = cancelColor.a * (1f - t);
pRenderer.color = pc;
particles[i].transform.localScale =
Vector3.one * trailScale * (1f - t * 0.5f);
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Cleanup
Destroy(glow);
foreach (var p in particles) {
if (p != null) Destroy(p);
}
}
private IEnumerator AnimateMeteor(Vector2 target) {
List<GameObject> objects = new List<GameObject>();
@@ -466,24 +95,25 @@ namespace Shardok {
Vector2 fallDirection = new Vector2(Mathf.Sin(angleRad), Mathf.Cos(angleRad));
Vector2 startPos = target + fallDirection * fallDistance;
// Create meteor rock
// Create meteor
GameObject meteor = null;
SpriteRenderer meteorRenderer = null;
float currentRotation = 0f;
if (meteorSprite != null) {
meteor = new GameObject("Meteor");
meteor.transform.SetParent(__hexGrid.gridCanvas.transform, false);
meteorRenderer = meteor.AddComponent<SpriteRenderer>();
SpriteRenderer meteorRenderer = meteor.AddComponent<SpriteRenderer>();
meteorRenderer.sprite = meteorSprite;
meteorRenderer.sortingLayerName = "Effects";
meteorRenderer.color = meteorColor;
meteor.transform.localScale = Vector3.one * meteorScale;
meteor.transform.localPosition = new Vector3(startPos.x, startPos.y, 3f);
// Rotate meteor to face direction of travel
float rotAngle = Mathf.Atan2(-fallDirection.x, -fallDirection.y) * Mathf.Rad2Deg;
meteor.transform.localRotation = Quaternion.Euler(0, 0, rotAngle);
objects.Add(meteor);
}
// Pre-create trail particles (continuous trail behind meteor)
// Trail particles
List<TrailParticle> trailParticles = new List<TrailParticle>();
for (int i = 0; i < trailParticleCount; i++) {
if (trailSprite == null) break;
@@ -494,75 +124,58 @@ namespace Shardok {
SpriteRenderer renderer = particle.AddComponent<SpriteRenderer>();
renderer.sprite = trailSprite;
renderer.sortingLayerName = "Effects";
particle.transform.localPosition = new Vector3(startPos.x, startPos.y, 4f);
// Particles further back are smaller and cooler colored
float trailT = (float)i / trailParticleCount;
float particleScale = trailScale * (1f - trailT * 0.6f);
particle.transform.localScale = Vector3.one * particleScale;
Color particleColor = Color.Lerp(trailColorHot, trailColorCool, trailT);
renderer.color = particleColor;
// Random offset perpendicular to fall direction
float perpOffset = Random.Range(-trailSpread, trailSpread) * trailT;
renderer.color = trailColor;
particle.transform.localScale = Vector3.one * trailScale;
particle.SetActive(false);
trailParticles.Add(new TrailParticle {
gameObject = particle,
delay = trailT * 0.3f, // How far behind the meteor this particle trails
offset = perpOffset
delay = (float)i / trailParticleCount * 0.15f,
offset = Random.Range(-5f, 5f)
});
objects.Add(particle);
}
// Fall phase with rotation and continuous trail
// Fall phase
float elapsed = 0f;
int nextTrailIndex = 0;
while (elapsed < fallDuration) {
float t = elapsed / fallDuration;
// Ease-in for accelerating fall (starts slow, speeds up)
float easeT = t * t;
float easeT = t * t; // Accelerating fall
Vector2 currentPos = Vector2.Lerp(startPos, target, easeT);
// Update meteor position and rotation
if (meteor != null) {
meteor.transform.localPosition = new Vector3(currentPos.x, currentPos.y, 3f);
// Continuous rotation
currentRotation += rotationSpeed * Time.deltaTime;
meteor.transform.localRotation = Quaternion.Euler(0, 0, currentRotation);
// Slight glow intensification as it approaches
Color c = meteorColor;
c.r = Mathf.Min(1f, meteorColor.r + t * 0.2f);
c.g = Mathf.Min(1f, meteorColor.g + t * 0.1f);
meteorRenderer.color = c;
}
// Update trail particles to follow behind meteor
Vector2 perpDir = new Vector2(-fallDirection.y, fallDirection.x);
for (int i = 0; i < trailParticles.Count; i++) {
var tp = trailParticles[i];
if (tp.gameObject == null) continue;
// Spawn trail particles
while (nextTrailIndex < trailParticles.Count &&
elapsed >= trailParticles[nextTrailIndex].delay) {
var tp = trailParticles[nextTrailIndex];
tp.gameObject.SetActive(true);
tp.spawnPosition =
currentPos + new Vector2(tp.offset, tp.offset * 0.5f) * fallDirection.x;
tp.gameObject.transform.localPosition =
new Vector3(tp.spawnPosition.x, tp.spawnPosition.y, 4f);
nextTrailIndex++;
}
// Calculate position along the trail (behind the meteor)
float trailT = (float)i / trailParticleCount;
float trailDistance = trailT * 50f; // How far behind
// Position is behind meteor along fall direction
Vector2 trailPos = currentPos + fallDirection * trailDistance;
// Add perpendicular wobble
trailPos += perpDir * tp.offset * Mathf.Sin(elapsed * 8f + i);
tp.gameObject.transform.localPosition = new Vector3(trailPos.x, trailPos.y, 4f);
// Flicker effect
var renderer = tp.gameObject.GetComponent<SpriteRenderer>();
if (renderer != null) {
Color baseColor = Color.Lerp(trailColorHot, trailColorCool, trailT);
baseColor.a *= 0.8f + 0.2f * Mathf.Sin(elapsed * 15f + i * 0.5f);
renderer.color = baseColor;
// Fade trail particles
foreach (var tp in trailParticles) {
if (!tp.gameObject.activeSelf) continue;
float age = elapsed - tp.delay;
if (age > 0) {
float fadeT = Mathf.Clamp01(age / 0.2f);
var renderer = tp.gameObject.GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = trailColor;
c.a = trailColor.a * (1f - fadeT);
renderer.color = c;
tp.gameObject.transform.localScale =
Vector3.one * trailScale * (1f - fadeT * 0.5f);
}
}
}
@@ -577,7 +190,7 @@ namespace Shardok {
}
objects.Clear();
// Explosion phase - bigger and with debris
// Explosion phase
GameObject explosion = null;
SpriteRenderer explosionRenderer = null;
@@ -590,11 +203,10 @@ namespace Shardok {
explosionRenderer.sprite = explosionSprite;
explosionRenderer.sortingLayerName = "Effects";
explosionRenderer.color = explosionColor;
explosion.transform.localScale = Vector3.one * explosionStartScale;
objects.Add(explosion);
}
// Fire sparks (fast moving, fiery)
// Sparks
List<SparkParticle> sparks = new List<SparkParticle>();
for (int i = 0; i < sparkCount; i++) {
if (trailSprite == null) break;
@@ -613,61 +225,27 @@ namespace Shardok {
sparks.Add(new SparkParticle {
gameObject = spark,
direction = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)),
speed = sparkSpread* Random.Range(0.8f, 1.4f)
speed = sparkSpread* Random.Range(0.7f, 1.3f)
});
objects.Add(spark);
}
// Rock debris (slower, with gravity arc, rotating)
List<DebrisParticle> debris = new List<DebrisParticle>();
Sprite actualDebrisSprite = debrisSprite ?? meteorSprite;
if (actualDebrisSprite != null) {
for (int i = 0; i < debrisCount; i++) {
GameObject rock = new GameObject($"Debris_{i}");
rock.transform.SetParent(__hexGrid.gridCanvas.transform, false);
rock.transform.localPosition = new Vector3(target.x, target.y, 3f);
SpriteRenderer renderer = rock.AddComponent<SpriteRenderer>();
renderer.sprite = actualDebrisSprite;
renderer.sortingLayerName = "Effects";
renderer.color = debrisColor;
float randomScale = debrisScale * Random.Range(0.6f, 1.4f);
rock.transform.localScale = Vector3.one * randomScale;
// Debris flies outward in all directions
float angle =
(float)i / debrisCount * Mathf.PI * 2f + Random.Range(-0.4f, 0.4f);
Vector2 dir = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle));
debris.Add(new DebrisParticle {
gameObject = rock,
direction = dir,
speed = debrisSpread * Random.Range(0.6f, 1.2f),
rotationSpeed = Random.Range(-400f, 400f),
currentRotation = Random.Range(0f, 360f)
});
objects.Add(rock);
}
}
// Explosion animation
elapsed = 0f;
while (elapsed < explosionDuration) {
float t = elapsed / explosionDuration;
// Expand and fade explosion with initial flash
// Expand and fade explosion
if (explosion != null) {
float flashT = t < 0.1f ? 1f + (0.1f - t) * 5f : 1f;
float scale = Mathf.Lerp(explosionStartScale, explosionEndScale, t) * flashT;
float scale = Mathf.Lerp(explosionStartScale, explosionEndScale, t);
explosion.transform.localScale = Vector3.one * scale;
Color c = explosionColor;
c.a = (1f - t * t); // Slower fade at start
c.a = 1f - t;
explosionRenderer.color = c;
}
// Animate fire sparks (fast, straight lines)
// Animate sparks
foreach (var spark in sparks) {
if (spark.gameObject == null) continue;
@@ -680,31 +258,7 @@ namespace Shardok {
c.a = 1f - t;
renderer.color = c;
spark.gameObject.transform.localScale =
Vector3.one * trailScale * 1.5f * (1f - t * 0.6f);
}
}
// Animate debris (flies outward in all directions, rotating)
for (int di = 0; di < debris.Count; di++) {
var d = debris[di];
if (d.gameObject == null) continue;
// Debris flies outward, slowing down over time
float easeT = t * (2f - t); // Ease out - fast start, slow end
Vector2 pos = target + d.direction * d.speed * easeT;
d.gameObject.transform.localPosition = new Vector3(pos.x, pos.y, 3f);
// Spin the debris
d.currentRotation += d.rotationSpeed * Time.deltaTime;
d.gameObject.transform.localRotation =
Quaternion.Euler(0, 0, d.currentRotation);
debris[di] = d; // Write back modified struct
var renderer = d.gameObject.GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = debrisColor;
c.a = 1f - t * 0.8f; // Debris fades slower
renderer.color = c;
Vector3.one * trailScale * 1.5f * (1f - t * 0.5f);
}
}
@@ -730,13 +284,5 @@ namespace Shardok {
public Vector2 direction;
public float speed;
}
private struct DebrisParticle {
public GameObject gameObject;
public Vector2 direction;
public float speed;
public float rotationSpeed;
public float currentRotation;
}
}
}
@@ -1,72 +1,41 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using static Net.Eagle0.Shardok.Api.BattalionView.Types;
namespace Shardok {
/// <summary>
/// Animates unit movement between hex cells using a trail of footprints/hoofprints.
/// Boot prints for infantry, horseshoe prints for cavalry.
/// Prints appear sequentially then fade out FIFO.
/// Animates unit movement from source to target hex with smooth interpolation.
/// Creates a temporary indicator that slides across the grid.
/// </summary>
public class MoveAnimator : MonoBehaviour {
[Header("Print Sprites")]
[Tooltip("Boot print sprite (will be flipped for left/right)")]
public Sprite bootPrintSprite;
[Tooltip("Paired horseshoe prints sprite")]
public Sprite hoofPrintSprite;
[Header("Animation Settings")]
[Tooltip("Number of prints between hex centers")]
public int printsPerHex = 5;
[Tooltip("Duration of the movement in seconds")]
public float moveDuration = 0.3f;
[Tooltip("Time between each print appearing (seconds)")]
public float printInterval = 0.06f;
[Tooltip("Optional sprite to show during movement")]
public Sprite moveIndicatorSprite;
[Tooltip("How long each print stays fully visible before fading (seconds)")]
public float printLingerTime = 0.3f;
[Tooltip("Scale of the movement indicator")]
public float indicatorScale = 15f;
[Tooltip("Duration of each print's fade out (seconds)")]
public float fadeDuration = 0.4f;
[Tooltip("Height offset for arc movement (0 = straight line)")]
public float arcHeight = 0.05f;
[Tooltip("Scale of print sprites")]
public float printScale = 1.5f;
private HexGrid __hexGrid;
[Tooltip("Lateral offset for alternating prints")]
public float lateralOffset = 6f;
[Tooltip("Color tint for boot prints")]
public Color bootPrintColor = new Color(0.3f, 0.2f, 0.1f, 0.8f);
[Tooltip("Color tint for hoof prints")]
public Color hoofPrintColor = new Color(0.25f, 0.15f, 0.05f, 0.8f);
private HexGrid _hexGrid;
private readonly List<Coroutine> _activeCoroutines = new List<Coroutine>();
private void Start() { _hexGrid = FindObjectOfType<HexGrid>(); }
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
/// <summary>
/// Animate movement from source to target hex.
/// Starts a move animation between two hex cells.
/// Multiple animations can run simultaneously.
/// </summary>
/// <param name="sourceCellIndex">Starting hex cell index</param>
/// <param name="targetCellIndex">Destination hex cell index</param>
/// <param name="unitType">Type of battalion (determines boot vs hoof prints)</param>
public void
AnimateMove(int sourceCellIndex, int targetCellIndex, BattalionTypeId unitType) {
if (bootPrintSprite == null && hoofPrintSprite == null) {
Debug.LogWarning("MoveAnimator: No print sprites assigned");
public void AnimateMove(int sourceCellIndex, int targetCellIndex) {
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("MoveAnimator: No _hexGrid or gridCanvas available");
return;
}
if (_hexGrid == null || _hexGrid.gridCanvas == null) {
Debug.LogWarning("MoveAnimator: No HexGrid or gridCanvas available");
return;
}
Vector2? sourcePos = _hexGrid.GetCellCenterPosition(sourceCellIndex);
Vector2? targetPos = _hexGrid.GetCellCenterPosition(targetCellIndex);
Vector2? sourcePos = __hexGrid.GetCellLocalPosition(sourceCellIndex);
Vector2? targetPos = __hexGrid.GetCellLocalPosition(targetCellIndex);
if (sourcePos == null || targetPos == null) {
Debug.LogWarning(
@@ -74,122 +43,45 @@ namespace Shardok {
return;
}
bool isMounted = unitType == BattalionTypeId.LightCavalry ||
unitType == BattalionTypeId.HeavyCavalry;
StartCoroutine(SpawnPrintTrail(sourcePos.Value, targetPos.Value, isMounted));
StartCoroutine(AnimateMovement(sourcePos.Value, targetPos.Value));
}
/// <summary>
/// Overload for when unit type is not available - defaults to boot prints.
/// </summary>
public void AnimateMove(int sourceCellIndex, int targetCellIndex) {
AnimateMove(sourceCellIndex, targetCellIndex, BattalionTypeId.LightInfantry);
}
private IEnumerator AnimateMovement(Vector2 source, Vector2 target) {
// Create movement indicator if sprite assigned
GameObject indicator = null;
if (moveIndicatorSprite != null) {
indicator = new GameObject("MoveIndicator");
indicator.transform.SetParent(__hexGrid.gridCanvas.transform, false);
private IEnumerator SpawnPrintTrail(Vector2 source, Vector2 target, bool isMounted) {
var prints = new List<GameObject>();
Vector2 direction = (target - source).normalized;
float totalDistance = Vector2.Distance(source, target);
// 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;
// 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);
// Alternate left/right for boot prints (flip horizontally)
bool flipX = !isMounted && (i % 2 == 1);
// 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;
GameObject print = CreatePrint(position, angle, printSprite, printColor, flipX);
prints.Add(print);
// Start FIFO fade coroutine for this print
var fadeCoroutine =
StartCoroutine(FadePrintFIFO(print, printLingerTime + i * printInterval));
_activeCoroutines.Add(fadeCoroutine);
yield return new WaitForSeconds(printInterval);
var renderer = indicator.AddComponent<SpriteRenderer>();
renderer.sprite = moveIndicatorSprite;
renderer.sortingLayerName = "Effects";
indicator.transform.localScale = Vector3.one * indicatorScale;
}
// Wait for all fades to complete
float totalWaitTime = printLingerTime + fadeDuration + 0.1f;
yield return new WaitForSeconds(totalWaitTime);
// Cleanup any remaining prints
foreach (var print in prints) {
if (print != null) { Destroy(print); }
}
_activeCoroutines.Clear();
}
private GameObject
CreatePrint(Vector2 position, float angle, Sprite sprite, Color color, bool flipX) {
GameObject print = new GameObject("Footprint");
print.transform.SetParent(_hexGrid.gridCanvas.transform, false);
SpriteRenderer renderer = print.AddComponent<SpriteRenderer>();
renderer.sprite = sprite;
renderer.sortingLayerName = "Effects";
renderer.sortingOrder = -1; // Below other effects
renderer.color = color;
print.transform.localPosition = new Vector3(position.x, position.y, 0);
print.transform.localRotation = Quaternion.Euler(0, 0, angle);
Vector3 scale = new Vector3(flipX ? -printScale : printScale, printScale, printScale);
print.transform.localScale = scale;
return print;
}
private IEnumerator FadePrintFIFO(GameObject print, float delay) {
// Wait before starting fade (FIFO - earlier prints fade first)
yield return new WaitForSeconds(delay);
if (print == null) yield break;
SpriteRenderer renderer = print.GetComponent<SpriteRenderer>();
if (renderer == null) yield break;
Color startColor = renderer.color;
float elapsed = 0f;
float distance = Vector2.Distance(source, target);
while (elapsed < fadeDuration) {
if (print == null || renderer == null) yield break;
while (elapsed < moveDuration) {
float t = elapsed / moveDuration;
float smoothT = t * t * (3f - 2f * t); // Smooth step
float t = elapsed / fadeDuration;
Color newColor = startColor;
newColor.a = Mathf.Lerp(startColor.a, 0f, t);
renderer.color = newColor;
Vector3 pos = Vector3.Lerp(
new Vector3(source.x, source.y, 5f),
new Vector3(target.x, target.y, 5f),
smoothT);
// Add slight arc on Y axis for visible height in top-down view
float arc = 4f * arcHeight * distance * t * (1f - t);
pos.y += arc;
if (indicator != null) { indicator.transform.localPosition = pos; }
elapsed += Time.deltaTime;
yield return null;
}
if (print != null) { Destroy(print); }
}
/// <summary>
/// Cancels all active print animations.
/// </summary>
public void CancelAllAnimations() {
foreach (var coroutine in _activeCoroutines) {
if (coroutine != null) { StopCoroutine(coroutine); }
}
_activeCoroutines.Clear();
if (indicator != null) { Destroy(indicator); }
}
}
}
@@ -3,9 +3,8 @@ using UnityEngine;
namespace Shardok {
/// <summary>
/// Animates scouting action with an eye at source, a vision cone extending
/// toward target, and a glow that travels with the cone tip and grows to
/// cover the target hex and its neighbors.
/// Animates scouting action with an eye/binoculars effect and scanning beam.
/// Creates a sweeping vision cone from source toward target.
/// </summary>
public class ScoutAnimator : MonoBehaviour {
[Header("Eye Settings")]
@@ -19,40 +18,34 @@ namespace Shardok {
public float eyeScale = 15f;
[Header("Vision Cone Settings")]
[Tooltip("Sprite for vision cone/beam (triangle pointing right)")]
[Tooltip("Sprite for vision cone/beam")]
public Sprite coneSprite;
[Tooltip("Color of the vision cone")]
public Color coneColor = new Color(1f, 1f, 0.5f, 0.3f);
[Tooltip("Width of the vision cone at base")]
public float coneWidth = 30f;
[Tooltip("Length of the vision cone")]
public float coneLength = 60f;
[Tooltip("Rotation offset for cone sprite (degrees). +90 for upward-pointing triangle")]
public float coneRotationOffset = 90f;
[Tooltip("Width of the vision cone")]
public float coneWidth = 40f;
[Header("Glow Settings")]
[Tooltip("Sprite for the traveling glow (circle)")]
public Sprite glowSprite;
[Header("Scan Lines Settings")]
[Tooltip("Sprite for scan line particles")]
public Sprite scanSprite;
[Tooltip("Color of the glow")]
public Color glowColor = new Color(1f, 1f, 0.7f, 0.5f);
[Tooltip("Number of scan lines")]
public int scanLineCount = 3;
[Tooltip("Starting scale of glow (at source)")]
public float glowStartScale = 10f;
[Tooltip("Final scale of glow in hex radii (2.5 = covers 7-hex area)")]
public float glowEndRadii = 2.5f;
[Tooltip("Color of scan lines")]
public Color scanColor = new Color(1f, 1f, 0.8f, 0.6f);
[Header("Animation Settings")]
[Tooltip("Duration of cone extension")]
public float extendDuration = 0.5f;
[Tooltip("Duration of the scan sweep")]
public float scanDuration = 0.6f;
[Tooltip("Duration the glow holds at target")]
public float holdDuration = 0.4f;
[Tooltip("Duration of fade out")]
public float fadeDuration = 0.3f;
[Tooltip("Sweep angle in degrees")]
public float sweepAngle = 30f;
private HexGrid __hexGrid;
@@ -62,6 +55,11 @@ namespace Shardok {
/// Starts a scout animation from source toward target hex.
/// </summary>
public void AnimateScout(int sourceCellIndex, int targetCellIndex) {
if (eyeSprite == null && coneSprite == null) {
Debug.LogWarning("ScoutAnimator: No sprites assigned");
return;
}
if (__hexGrid == null || __hexGrid.gridCanvas == null) {
Debug.LogWarning("ScoutAnimator: No _hexGrid or gridCanvas available");
return;
@@ -76,62 +74,59 @@ namespace Shardok {
return;
}
float hexRadius = __hexGrid.GetHexInnerRadius();
float glowEndScale = hexRadius * glowEndRadii * 2f;
StartCoroutine(AnimateScoutEffect(sourcePos.Value, targetPos.Value, glowEndScale));
StartCoroutine(AnimateScoutEffect(sourcePos.Value, targetPos.Value));
}
private IEnumerator AnimateScoutEffect(Vector2 source, Vector2 target, float glowEndScale) {
private IEnumerator AnimateScoutEffect(Vector2 source, Vector2 target) {
Vector2 direction = (target - source).normalized;
float baseAngle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg;
float distance = Vector2.Distance(source, target);
// Create eye at source
GameObject eye = null;
SpriteRenderer eyeRenderer = null;
if (eyeSprite != null) {
eye = new GameObject("ScoutEye");
eye.transform.SetParent(__hexGrid.gridCanvas.transform, false);
eye.transform.localPosition = new Vector3(source.x, source.y, 5f);
eye.transform.localScale = Vector3.zero;
eyeRenderer = eye.AddComponent<SpriteRenderer>();
eyeRenderer.sprite = eyeSprite;
eyeRenderer.sortingLayerName = "Effects";
eyeRenderer.color = eyeColor;
SpriteRenderer renderer = eye.AddComponent<SpriteRenderer>();
renderer.sprite = eyeSprite;
renderer.sortingLayerName = "Effects";
renderer.color = eyeColor;
}
// Create vision cone
GameObject cone = null;
SpriteRenderer coneRenderer = null;
if (coneSprite != null) {
cone = new GameObject("VisionCone");
cone.transform.SetParent(__hexGrid.gridCanvas.transform, false);
cone.transform.localPosition = new Vector3(source.x, source.y, 6f);
cone.transform.localRotation =
Quaternion.Euler(0, 0, baseAngle + coneRotationOffset);
cone.transform.localScale = new Vector3(coneWidth, 0f, 1f);
cone.transform.localRotation = Quaternion.Euler(0, 0, baseAngle - sweepAngle);
cone.transform.localScale = new Vector3(0f, coneWidth, 1f);
coneRenderer = cone.AddComponent<SpriteRenderer>();
coneRenderer.sprite = coneSprite;
coneRenderer.sortingLayerName = "Effects";
coneRenderer.color = coneColor;
SpriteRenderer renderer = cone.AddComponent<SpriteRenderer>();
renderer.sprite = coneSprite;
renderer.sortingLayerName = "Effects";
renderer.color = coneColor;
}
// Create traveling glow
GameObject glow = null;
SpriteRenderer glowRenderer = null;
if (glowSprite != null) {
glow = new GameObject("ScoutGlow");
glow.transform.SetParent(__hexGrid.gridCanvas.transform, false);
glow.transform.localPosition = new Vector3(source.x, source.y, 7f);
glow.transform.localScale = Vector3.one * glowStartScale;
// Create scan lines
GameObject[] scanLines = new GameObject[scanLineCount];
glowRenderer = glow.AddComponent<SpriteRenderer>();
glowRenderer.sprite = glowSprite;
glowRenderer.sortingLayerName = "Effects";
glowRenderer.color = glowColor;
for (int i = 0; i < scanLineCount; i++) {
if (scanSprite == null) break;
GameObject scanLine = new GameObject($"ScanLine_{i}");
scanLine.transform.SetParent(__hexGrid.gridCanvas.transform, false);
scanLine.transform.localPosition = new Vector3(source.x, source.y, 4f);
scanLine.transform.localScale = Vector3.one * 5f;
SpriteRenderer renderer = scanLine.AddComponent<SpriteRenderer>();
renderer.sprite = scanSprite;
renderer.sortingLayerName = "Effects";
renderer.color = scanColor;
scanLines[i] = scanLine;
}
// Eye appearance phase
@@ -142,7 +137,7 @@ namespace Shardok {
float t = elapsed / appearDuration;
if (eye != null) {
float scale = eyeScale * t * (2f - t);
float scale = eyeScale * t * (2f - t); // Overshoot slightly
eye.transform.localScale = Vector3.one * scale;
}
@@ -152,90 +147,89 @@ namespace Shardok {
if (eye != null) { eye.transform.localScale = Vector3.one * eyeScale; }
// Cone extension phase - cone extends, glow travels and grows
// Scan sweep phase
elapsed = 0f;
while (elapsed < extendDuration) {
float t = elapsed / extendDuration;
float easeT = t * (2f - t); // Ease out
while (elapsed < scanDuration) {
float t = elapsed / scanDuration;
float sweepT = Mathf.Sin(t * Mathf.PI); // Smooth ease in-out
// Extend cone - Y axis is length after rotation, X is width
// Rotate cone through sweep
if (cone != null) {
float length = distance * easeT;
Vector2 conePos = source + direction * (length * 0.5f);
cone.transform.localPosition = new Vector3(conePos.x, conePos.y, 6f);
cone.transform.localScale =
new Vector3(coneWidth * (1f - easeT * 0.3f), length, 1f);
float currentAngle = baseAngle - sweepAngle + sweepAngle * 2f * t;
cone.transform.localRotation = Quaternion.Euler(0, 0, currentAngle);
Color c = coneColor;
c.a = coneColor.a * (0.8f + 0.2f * Mathf.Sin(elapsed * 15f));
coneRenderer.color = c;
// Extend cone
float length = coneLength * Mathf.Min(t * 3f, 1f);
cone.transform.localScale = new Vector3(length, coneWidth, 1f);
// Pulse alpha
var coneRenderer = cone.GetComponent<SpriteRenderer>();
if (coneRenderer != null) {
Color c = coneColor;
c.a = coneColor.a * (0.7f + sweepT * 0.3f);
coneRenderer.color = c;
}
}
// Move and grow glow at cone tip
if (glow != null) {
Vector2 glowPos = source + direction * distance * easeT;
glow.transform.localPosition = new Vector3(glowPos.x, glowPos.y, 7f);
// Animate scan lines along the cone
for (int i = 0; i < scanLines.Length; i++) {
if (scanLines[i] == null) continue;
float glowScale = Mathf.Lerp(glowStartScale, glowEndScale, easeT);
glow.transform.localScale = Vector3.one * glowScale;
float lineT = (t + (float)i / scanLineCount * 0.3f) % 1f;
float lineAngle =
(baseAngle - sweepAngle + sweepAngle * 2f * t) * Mathf.Deg2Rad;
Vector2 lineDir = new Vector2(Mathf.Cos(lineAngle), Mathf.Sin(lineAngle));
Color c = glowColor;
c.a = glowColor.a * (0.7f + 0.3f * Mathf.Sin(elapsed * 12f));
glowRenderer.color = c;
Vector2 pos = source + lineDir * coneLength * lineT;
scanLines[i].transform.localPosition = new Vector3(pos.x, pos.y, 4f);
var renderer = scanLines[i].GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = scanColor;
c.a = scanColor.a * (1f - lineT) * sweepT;
renderer.color = c;
}
}
elapsed += Time.deltaTime;
yield return null;
}
// Ensure final positions
if (cone != null) {
Vector2 conePos = source + direction * (distance * 0.5f);
cone.transform.localPosition = new Vector3(conePos.x, conePos.y, 6f);
cone.transform.localScale = new Vector3(coneWidth * 0.7f, distance, 1f);
}
if (glow != null) {
glow.transform.localPosition = new Vector3(target.x, target.y, 7f);
glow.transform.localScale = Vector3.one * glowEndScale;
}
// Hold phase - glow pulses at target
// Fade out
float fadeDuration = 0.2f;
elapsed = 0f;
while (elapsed < holdDuration) {
float t = elapsed / holdDuration;
if (glowRenderer != null) {
Color c = glowColor;
c.a = glowColor.a * (0.8f + 0.2f * Mathf.Sin(elapsed * 10f));
glowRenderer.color = c;
}
elapsed += Time.deltaTime;
yield return null;
}
// Fade out phase
elapsed = 0f;
while (elapsed < fadeDuration) {
float t = elapsed / fadeDuration;
if (eyeRenderer != null) {
Color c = eyeColor;
c.a = eyeColor.a * (1f - t);
eyeRenderer.color = c;
if (eye != null) {
var renderer = eye.GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = eyeColor;
c.a = eyeColor.a * (1f - t);
renderer.color = c;
}
eye.transform.localScale = Vector3.one * eyeScale * (1f - t * 0.3f);
}
if (coneRenderer != null) {
Color c = coneColor;
c.a = coneColor.a * (1f - t);
coneRenderer.color = c;
if (cone != null) {
var renderer = cone.GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = coneColor;
c.a = coneColor.a * (1f - t);
renderer.color = c;
}
}
if (glowRenderer != null) {
Color c = glowColor;
c.a = glowColor.a * (1f - t);
glowRenderer.color = c;
foreach (var scanLine in scanLines) {
if (scanLine == null) continue;
var renderer = scanLine.GetComponent<SpriteRenderer>();
if (renderer != null) {
Color c = scanColor;
c.a = scanColor.a * (1f - t);
renderer.color = c;
}
}
elapsed += Time.deltaTime;
@@ -245,7 +239,9 @@ namespace Shardok {
// Cleanup
if (eye != null) { Destroy(eye); }
if (cone != null) { Destroy(cone); }
if (glow != null) { Destroy(glow); }
foreach (var scanLine in scanLines) {
if (scanLine != null) { Destroy(scanLine); }
}
}
}
}
@@ -5,7 +5,6 @@ using System.Globalization;
using System.Linq;
using common;
using eagle0;
using Eagle0.Tutorial;
using Net.Eagle0.Shardok.Api;
using Net.Eagle0.Shardok.Common;
using TMPro;
@@ -36,10 +35,7 @@ namespace Shardok {
Fear,
// Magic
LightningBolt,
MeteorStart,
MeteorTarget,
MeteorCast,
MeteorCancel,
HolyWave,
RaiseDead,
Control,
@@ -245,8 +241,6 @@ namespace Shardok {
turnHistoryPanel.Clear();
this.gameObject.SetActive(false);
} else {
// Notify tutorial system of turn end
TutorialManager.Instance?.TriggerRegistry?.OnTurnEnded();
Model.EndTurn();
}
}
@@ -397,6 +391,8 @@ namespace Shardok {
Model = shardokGameModel;
Model.UpdateAction = ModelUpdated;
HexMap map = Model.Map;
Texture[] textures = new Texture[map.RowCount * map.ColumnCount];
Coords coords = new Coords();
@@ -422,17 +418,10 @@ namespace Shardok {
hexGrid.SetUp();
hexGrid.gameObject.SetActive(true);
// Set UpdateAction after hexGrid is ready to avoid race condition
Model.UpdateAction = ModelUpdated;
SetHeroLabels();
MainQueue.Q.EnqueueForNextUpdate(() => { ModelUpdated(); });
// Initialize tutorial system for tactical combat
TutorialManager.Instance?.Initialize(null, this);
TutorialManager.Instance?.TriggerRegistry?.OnBattleEntered(Model);
#if UNITY_EDITOR
EditorApplication.playModeStateChanged += (state) => OnPlayModeStateChanged(state);
#endif
@@ -552,9 +541,6 @@ namespace Shardok {
var historyEntry = Model.History[i];
turnHistoryPanel.AddLine(GetActionResultDescription(historyEntry));
// Notify tutorial system of battle action
TutorialManager.Instance?.TriggerRegistry?.OnBattleAction(historyEntry);
ActionType type = historyEntry.Type;
AnimationType animationType = AnimationTypeForAction(type);
@@ -579,12 +565,7 @@ namespace Shardok {
if (Model.UnitsById.TryGetValue(
historyEntry.Actor.Value,
out var actorUnit)) {
// For Move actions, use stored source coords (unit's pre-move location)
// For other actions, use current unit location
int sourceIndex =
Model.MoveSourceCoords.TryGetValue(i, out var sourceCoords)
? MapCoordsToGridIndex(sourceCoords)
: MapCoordsToGridIndex(actorUnit.Location);
int sourceIndex = MapCoordsToGridIndex(actorUnit.Location);
// Get target - either coords (archery) or unit location (melee)
int targetIndex = -1;
@@ -1197,9 +1178,6 @@ namespace Shardok {
}
_selectedGridIndex = selectedIndex;
RedrawCommandOverlays(selectedIndex, selectedIndex);
// Notify tutorial system of unit selection
TutorialManager.Instance?.TriggerRegistry?.OnUnitSelected(selectedIndex);
}
public void HandleActionClick(int clickedIndex) {
@@ -1365,8 +1343,8 @@ namespace Shardok {
case CommandType.MoveCommand: return AnimationType.Move;
// Magic
case CommandType.LightningBoltCommand: return AnimationType.LightningBolt;
case CommandType.MeteorStartCommand: return AnimationType.MeteorStart;
case CommandType.MeteorTargetCommand: return AnimationType.MeteorTarget;
case CommandType.MeteorStartCommand:
case CommandType.MeteorTargetCommand: return AnimationType.MeteorCast;
case CommandType.HolyWaveCommand: return AnimationType.HolyWave;
case CommandType.RaiseDeadCommand: return AnimationType.RaiseDead;
case CommandType.ControlCommand: return AnimationType.Control;
@@ -1405,10 +1383,9 @@ namespace Shardok {
case ActionType.FearAttack: return AnimationType.Fear;
// Magic
case ActionType.LightningBolt: return AnimationType.LightningBolt;
case ActionType.MeteorStart: return AnimationType.MeteorStart;
case ActionType.MeteorTarget: return AnimationType.MeteorTarget;
case ActionType.MeteorStart:
case ActionType.MeteorTarget:
case ActionType.MeteorCast: return AnimationType.MeteorCast;
case ActionType.MeteorCancel: return AnimationType.MeteorCancel;
case ActionType.HolyWave:
case ActionType.HolyWaveDamage: return AnimationType.HolyWave;
case ActionType.RaisedUndead: return AnimationType.RaiseDead;
@@ -1448,10 +1425,7 @@ namespace Shardok {
case AnimationType.Fear: return ActionType.FearAttack;
// Magic
case AnimationType.LightningBolt: return ActionType.LightningBolt;
case AnimationType.MeteorStart: return ActionType.MeteorStart;
case AnimationType.MeteorTarget: return ActionType.MeteorTarget;
case AnimationType.MeteorCast: return ActionType.MeteorCast;
case AnimationType.MeteorCancel: return ActionType.MeteorCancel;
case AnimationType.HolyWave: return ActionType.HolyWave;
case AnimationType.RaiseDead: return ActionType.RaisedUndead;
case AnimationType.Control: return ActionType.Controlled;
@@ -1575,11 +1549,8 @@ namespace Shardok {
}
break;
case AnimationType.Move:
if (moveAnimator != null && attackerUnit != null) {
moveAnimator.AnimateMove(
sourceGridIndex,
targetGridIndex,
attackerUnit.Battalion.Type);
if (moveAnimator != null) {
moveAnimator.AnimateMove(sourceGridIndex, targetGridIndex);
}
break;
case AnimationType.LightningBolt:
@@ -1597,26 +1568,11 @@ namespace Shardok {
raiseDeadAnimator.AnimateRaiseDead(targetGridIndex);
}
break;
case AnimationType.MeteorStart:
if (meteorAnimator != null) {
meteorAnimator.AnimateMeteorStart(sourceGridIndex);
}
break;
case AnimationType.MeteorTarget:
if (meteorAnimator != null) {
meteorAnimator.AnimateMeteorTarget(targetGridIndex);
}
break;
case AnimationType.MeteorCast:
if (meteorAnimator != null) {
meteorAnimator.AnimateMeteorCast(targetGridIndex);
}
break;
case AnimationType.MeteorCancel:
if (meteorAnimator != null) {
meteorAnimator.AnimateMeteorCancel(sourceGridIndex);
}
break;
case AnimationType.HolyWave:
if (holyWaveAnimator != null) {
var undeadCells = FindNearbyUndeadCells(sourceGridIndex, 2);
@@ -1738,11 +1694,8 @@ namespace Shardok {
}
break;
case AnimationType.Move:
if (moveAnimator != null && attackerUnit != null) {
moveAnimator.AnimateMove(
sourceGridIndex,
targetGridIndex,
attackerUnit.Battalion.Type);
if (moveAnimator != null) {
moveAnimator.AnimateMove(sourceGridIndex, targetGridIndex);
}
break;
case AnimationType.LightningBolt:
@@ -1760,26 +1713,11 @@ namespace Shardok {
raiseDeadAnimator.AnimateRaiseDead(targetGridIndex);
}
break;
case AnimationType.MeteorStart:
if (meteorAnimator != null) {
meteorAnimator.AnimateMeteorStart(sourceGridIndex);
}
break;
case AnimationType.MeteorTarget:
if (meteorAnimator != null) {
meteorAnimator.AnimateMeteorTarget(targetGridIndex);
}
break;
case AnimationType.MeteorCast:
if (meteorAnimator != null) {
meteorAnimator.AnimateMeteorCast(targetGridIndex);
}
break;
case AnimationType.MeteorCancel:
if (meteorAnimator != null) {
meteorAnimator.AnimateMeteorCancel(sourceGridIndex);
}
break;
case AnimationType.HolyWave:
if (holyWaveAnimator != null) {
var undeadCells = FindNearbyUndeadCells(sourceGridIndex, 2);
@@ -75,13 +75,6 @@ public class ShardokGameModel {
ReserveUnitsById.Values.FirstOrDefault(u => u.Location.Equals(location));
public List<ActionResultView> History { get; private set; }
/// <summary>
/// Tracks the source coordinates for Move actions, keyed by history index.
/// Used for animations since the model is fully updated before animations play.
/// </summary>
public Dictionary<int, Coords> MoveSourceCoords { get; } = new();
public PlayerId PlayerId { get; private set; }
public GameStatus GameStatus { get; private set; }
public RoundId CurrentRound { get; private set; }
@@ -409,14 +402,6 @@ public class ShardokGameModel {
}
private void HandleNewHistoryEntry(ActionResultView entry) {
// For Move actions, record the source coordinates BEFORE applying the diff
// This is needed for animations since all diffs are applied before animations play
if (entry.Type == ActionType.Move && entry.Actor != null) {
if (UnitsById.TryGetValue(entry.Actor.Value, out var actorUnit)) {
MoveSourceCoords[History.Count] = actorUnit.Location;
}
}
History.Add(entry);
var gsvDiff = entry.GameStateViewDiff;
if (gsvDiff == null) {
@@ -43,18 +43,6 @@ namespace Shardok {
[Tooltip("Rotation arc of the swing (degrees)")]
public float swingArc = 45f;
[Tooltip("Base rotation offset for hammer (degrees)")]
public float hammerBaseRotation = 180f;
[Tooltip("Flip hammer horizontally")]
public bool hammerFlipHorizontal = true;
[Tooltip("Base rotation offset for alternate tool (degrees)")]
public float alternateBaseRotation = 0f;
[Tooltip("Flip alternate tool horizontally")]
public bool alternateFlipHorizontal = false;
private HexGrid __hexGrid;
private void Start() { __hexGrid = FindObjectOfType<HexGrid>(); }
@@ -91,11 +79,9 @@ namespace Shardok {
}
private IEnumerator AnimateStrikes(Vector2 target, bool useBridgeStyle) {
bool useAlternate = useBridgeStyle && alternateToolSprite != null;
Sprite toolSprite =
useAlternate ? alternateToolSprite : hammerSprite ?? alternateToolSprite;
float baseRotation = useAlternate ? alternateBaseRotation : hammerBaseRotation;
bool flipHorizontal = useAlternate ? alternateFlipHorizontal : hammerFlipHorizontal;
Sprite toolSprite = useBridgeStyle && alternateToolSprite != null
? alternateToolSprite
: hammerSprite ?? alternateToolSprite;
// Create tool
GameObject tool = new GameObject("Tool");
@@ -104,8 +90,7 @@ namespace Shardok {
SpriteRenderer toolRenderer = tool.AddComponent<SpriteRenderer>();
toolRenderer.sprite = toolSprite;
toolRenderer.sortingLayerName = "Effects";
float xScale = flipHorizontal ? -toolScale : toolScale;
tool.transform.localScale = new Vector3(xScale, toolScale, toolScale);
tool.transform.localScale = Vector3.one * toolScale;
// Starting position (raised above target)
Vector3 strikePoint = new Vector3(target.x, target.y, 5f);
@@ -115,7 +100,7 @@ namespace Shardok {
for (int i = 0; i < strikeCount; i++) {
// Raise tool
tool.transform.localPosition = raisePoint;
tool.transform.localRotation = Quaternion.Euler(0, 0, baseRotation - swingArc);
tool.transform.localRotation = Quaternion.Euler(0, 0, -swingArc);
float elapsed = 0f;
float downDuration = strikeDuration * 0.4f;
@@ -127,14 +112,14 @@ namespace Shardok {
tool.transform.localPosition = Vector3.Lerp(raisePoint, strikePoint, easeT);
float angle = Mathf.Lerp(-swingArc, 0f, easeT);
tool.transform.localRotation = Quaternion.Euler(0, 0, baseRotation + angle);
tool.transform.localRotation = Quaternion.Euler(0, 0, angle);
elapsed += Time.deltaTime;
yield return null;
}
tool.transform.localPosition = strikePoint;
tool.transform.localRotation = Quaternion.Euler(0, 0, baseRotation);
tool.transform.localRotation = Quaternion.Euler(0, 0, 0);
// Spawn sparks on impact
yield return StartCoroutine(SpawnSparks(target));
@@ -152,7 +137,7 @@ namespace Shardok {
tool.transform.localPosition = Vector3.Lerp(strikePoint, raisePoint, easeT);
float angle = Mathf.Lerp(0f, -swingArc, easeT);
tool.transform.localRotation = Quaternion.Euler(0, 0, baseRotation + angle);
tool.transform.localRotation = Quaternion.Euler(0, 0, angle);
elapsed += Time.deltaTime;
yield return null;
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 0deea4d267414bcd9421d74e9c1d0d7e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: f80e1b28dcb84478b8a97821d82dbde7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,102 +0,0 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Events;
namespace Eagle0.Tutorial {
/// <summary>
/// Ordered collection of tutorial steps forming a complete tutorial.
/// Can be used for onboarding or contextual tutorials.
/// Create instances via Assets > Create > Eagle0 > Tutorial Sequence.
/// </summary>
[CreateAssetMenu(fileName = "TutorialSequence", menuName = "Eagle0/Tutorial Sequence")]
public class TutorialSequence : ScriptableObject {
[Header("Identity")]
[Tooltip("Unique identifier for this tutorial sequence")]
public string SequenceId;
[Tooltip("Display name shown to user")]
public string DisplayName;
[Tooltip("Whether this is the main onboarding sequence")]
public bool IsOnboarding;
[Header("Steps")]
[Tooltip("Ordered list of tutorial steps")]
public List<TutorialStep> Steps = new List<TutorialStep>();
[Header("Prerequisites")]
[Tooltip("Tutorial IDs that must be completed before this one can trigger")]
public List<string> RequiredCompletedTutorials = new List<string>();
[Header("Events")]
[Tooltip("Called when this sequence starts")]
public UnityEvent OnSequenceStart;
[Tooltip("Called when this sequence completes normally")]
public UnityEvent OnSequenceComplete;
[Tooltip("Called when user skips this sequence")]
public UnityEvent OnSequenceSkipped;
/// <summary>
/// Gets a step by index, or null if out of range.
/// </summary>
public TutorialStep GetStep(int index) {
if (index < 0 || index >= Steps.Count) return null;
return Steps[index];
}
/// <summary>
/// Gets the index of a step by its ID, or -1 if not found.
/// </summary>
public int IndexOfStep(string stepId) {
if (string.IsNullOrEmpty(stepId)) return -1;
for (int i = 0; i < Steps.Count; i++) {
if (Steps[i].StepId == stepId) return i;
}
return -1;
}
/// <summary>
/// Total number of steps in this sequence.
/// </summary>
public int StepCount => Steps?.Count ?? 0;
/// <summary>
/// Checks if all prerequisites are met.
/// </summary>
public bool ArePrerequisitesMet(TutorialState state) {
if (RequiredCompletedTutorials == null || RequiredCompletedTutorials.Count == 0) {
return true;
}
foreach (var reqId in RequiredCompletedTutorials) {
if (!state.HasCompletedTutorial(reqId)) { return false; }
}
return true;
}
#if UNITY_EDITOR
/// <summary>
/// Editor validation to catch common issues.
/// </summary>
private void OnValidate() {
// Ensure SequenceId is set
if (string.IsNullOrEmpty(SequenceId)) { SequenceId = name; }
// Check for duplicate step IDs
var seenIds = new HashSet<string>();
foreach (var step in Steps) {
if (!string.IsNullOrEmpty(step.StepId)) {
if (!seenIds.Add(step.StepId)) {
Debug.LogWarning(
$"TutorialSequence '{SequenceId}': Duplicate step ID '{step.StepId}'");
}
}
}
}
#endif
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 97da9ce2a5d7418998c026d559ad0a13
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,103 +0,0 @@
using System;
using UnityEngine;
namespace Eagle0.Tutorial {
/// <summary>
/// How the tutorial step should be displayed to the user.
/// </summary>
public enum TutorialDisplayMode {
/// <summary>Full modal window blocking game interaction (for important concepts).</summary>
Modal,
/// <summary>Semi-transparent overlay highlighting a specific UI element.</summary>
Overlay,
/// <summary>Small tooltip positioned near target element.</summary>
Tooltip,
/// <summary>Subtle pulsing indicator only (no text popup).</summary>
Hint,
/// <summary>Invisible step for waiting on game events.</summary>
None
}
/// <summary>
/// How the tutorial step is completed/advanced.
/// </summary>
public enum TutorialCompletionType {
/// <summary>User clicks Continue/Got it button.</summary>
ButtonClick,
/// <summary>Specific game event triggers completion.</summary>
GameEvent,
/// <summary>User interacts with the highlighted UI element.</summary>
UIInteraction,
/// <summary>Auto-advances after a delay.</summary>
Timer,
/// <summary>Custom condition checked each frame.</summary>
Condition
}
/// <summary>
/// Single step within a tutorial sequence.
/// Supports multiple display modes and completion conditions.
/// </summary>
[Serializable]
public class TutorialStep {
[Header("Identity")]
[Tooltip("Unique identifier for this step")]
public string StepId;
[Header("Display Content")]
[Tooltip("Title shown in modal or tooltip")]
public string Title;
[Tooltip("Main description text")]
[TextArea(3, 10)]
public string Description;
[Tooltip("Optional icon/image to display")]
public Sprite Icon;
[Tooltip("How this step should be displayed")]
public TutorialDisplayMode DisplayMode = TutorialDisplayMode.Modal;
[Header("UI Targeting")]
[Tooltip("Path to GameObject to highlight (e.g., 'Canvas/CommandPanel/MarchButton')")]
public string TargetGameObjectPath;
[Tooltip("Offset from target element for tooltip/arrow positioning")]
public Vector2 HighlightOffset;
[Tooltip("Whether the highlight should pulse")]
public bool HighlightPulsing = true;
[Header("Completion")]
[Tooltip("How this step is completed")]
public TutorialCompletionType CompletionType = TutorialCompletionType.ButtonClick;
[Tooltip("Event ID to wait for (when CompletionType is GameEvent)")]
public string CompletionEventId;
[Tooltip("Delay in seconds before auto-advance (when CompletionType is Timer)")]
public float AutoAdvanceDelay;
[Header("Flow Control")]
[Tooltip("Whether user can skip this step")]
public bool AllowSkip = true;
[Tooltip("Jump to specific step ID instead of sequential (empty = next step)")]
public string NextStepOverride;
[Header("Audio")]
[Tooltip("Optional narration audio")]
public AudioClip NarrationClip;
[Tooltip("Optional sound effect when step appears")]
public AudioClip EffectClip;
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: ad37a68e71e24b6a88ace636f74020dd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 6e2fbfb348eb443cb91395b3712870da
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,149 +0,0 @@
using System.Collections.Generic;
using eagle;
using Shardok;
using UnityEngine;
namespace Eagle0.Tutorial {
/// <summary>
/// Registry of tutorial triggers that respond to game events.
/// Integrates with game controllers to detect first-encounter situations.
/// </summary>
public class TutorialTriggerRegistry {
private readonly TutorialManager _manager;
private EagleGameController _eagleController;
private ShardokGameController _shardokController;
// Registered contextual tutorials by ID
private Dictionary<string, TutorialSequence> _tutorialSequences =
new Dictionary<string, TutorialSequence>();
// Event-to-tutorial mapping
private Dictionary<string, List<string>> _eventTriggers =
new Dictionary<string, List<string>>();
public TutorialTriggerRegistry(TutorialManager manager) { _manager = manager; }
/// <summary>
/// Initialize with game controller references.
/// </summary>
public void Initialize(EagleGameController eagle, ShardokGameController shardok) {
if (eagle != null) { _eagleController = eagle; }
if (shardok != null) { _shardokController = shardok; }
}
/// <summary>
/// Registers a tutorial sequence that can be triggered by events.
/// </summary>
public void RegisterTutorial(TutorialSequence sequence, params string[] triggerEvents) {
if (sequence == null || string.IsNullOrEmpty(sequence.SequenceId)) return;
_tutorialSequences[sequence.SequenceId] = sequence;
foreach (var eventId in triggerEvents) {
if (!_eventTriggers.ContainsKey(eventId)) {
_eventTriggers[eventId] = new List<string>();
}
_eventTriggers[eventId].Add(sequence.SequenceId);
}
Debug.Log(
$"TutorialTriggerRegistry: Registered '{sequence.SequenceId}' for events: {string.Join(", ", triggerEvents)}");
}
/// <summary>
/// Gets a tutorial sequence by ID.
/// </summary>
public TutorialSequence GetSequenceById(string tutorialId) {
_tutorialSequences.TryGetValue(tutorialId, out var sequence);
return sequence;
}
/// <summary>
/// Called when a game event occurs.
/// Checks for tutorials that should be triggered.
/// </summary>
public void OnGameEvent(string eventId, object context = null) {
if (!_eventTriggers.TryGetValue(eventId, out var tutorialIds)) return;
foreach (var tutorialId in tutorialIds) {
if (_manager.State.HasCompletedTutorial(tutorialId)) continue;
var sequence = GetSequenceById(tutorialId);
if (sequence != null && sequence.ArePrerequisitesMet(_manager.State)) {
_manager.TriggerContextualTutorial(tutorialId);
break; // Only trigger one tutorial per event
}
}
}
// ========== Strategic Layer Events ==========
/// <summary>
/// Called when the game model is updated.
/// </summary>
public void OnModelUpdated(IGameModel model, IGameModel previousModel) {
if (model == null) return;
// Check for first battle available
if (model.RunningShardokGameModels?.Count > 0) {
if (previousModel?.RunningShardokGameModels == null ||
previousModel.RunningShardokGameModels.Count == 0) {
OnGameEvent("first_battle_available");
}
}
// Check for diplomacy offers
// Check for riots
// Check for hero recruitment opportunities
// etc. - These would check model state changes
}
/// <summary>
/// Called when a province is selected.
/// </summary>
public void OnProvinceSelected(object province) {
// Track first province selection for onboarding
OnGameEvent("province_selected", province);
}
/// <summary>
/// Called when a command is issued.
/// </summary>
public void OnCommandIssued(object command) {
// Track first command for onboarding
OnGameEvent("command_issued", command);
// Could also check command type for specific tutorials
// e.g., first diplomacy command, first weather control, etc.
}
// ========== Tactical Layer Events ==========
/// <summary>
/// Called when entering a battle.
/// </summary>
public void OnBattleEntered(object battleModel) {
OnGameEvent("battle_entered", battleModel);
}
/// <summary>
/// Called when a battle action result is received.
/// </summary>
public void OnBattleAction(object actionResult) {
OnGameEvent("battle_action", actionResult);
// Check for specific action types that need tutorials
// e.g., first spell cast, first charge, etc.
}
/// <summary>
/// Called when a unit is selected in tactical view.
/// </summary>
public void OnUnitSelected(int cellIndex) { OnGameEvent("unit_selected", cellIndex); }
/// <summary>
/// Called when turn ends in tactical combat.
/// </summary>
public void OnTurnEnded() { OnGameEvent("turn_ended"); }
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 23dab249a548427091dc94baadedcebd
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,374 +0,0 @@
using System;
using System.Collections;
using eagle;
using Shardok;
using UnityEngine;
namespace Eagle0.Tutorial {
/// <summary>
/// Central singleton managing tutorial state, triggers, and UI display.
/// Hooks into EagleGameController and ShardokGameController for game events.
/// </summary>
public class TutorialManager : MonoBehaviour {
public static TutorialManager Instance { get; private set; }
[Header("Configuration")]
[Tooltip("Whether tutorials are enabled")]
public bool TutorialsEnabled = true;
[Tooltip("The main onboarding tutorial sequence")]
public TutorialSequence OnboardingSequence;
[Header("UI References")]
[Tooltip("Reference to the tutorial UI manager")]
public TutorialUIManager UIManager;
[Header("Debug")]
[Tooltip("Enable verbose logging")]
public bool DebugLogging;
// State
private TutorialState _state;
public TutorialState State => _state;
// Trigger registry
private TutorialTriggerRegistry _triggerRegistry;
public TutorialTriggerRegistry TriggerRegistry => _triggerRegistry;
// Active sequence tracking
private TutorialSequence _activeSequence;
private int _currentStepIndex;
private Coroutine _activeStepCoroutine;
/// <summary>
/// Whether we're currently in an onboarding sequence.
/// </summary>
public bool IsOnboarding => _activeSequence != null && _activeSequence.IsOnboarding;
/// <summary>
/// Whether any tutorial sequence is currently active.
/// </summary>
public bool IsSequenceActive => _activeSequence != null;
/// <summary>
/// Current step being displayed, or null if none.
/// </summary>
public TutorialStep CurrentStep => _activeSequence?.GetStep(_currentStepIndex);
private void Awake() {
// Singleton setup
if (Instance != null && Instance != this) {
Debug.LogWarning("TutorialManager: Duplicate instance destroyed");
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
// Initialize state
_state = TutorialState.Load();
_triggerRegistry = new TutorialTriggerRegistry(this);
Log("TutorialManager initialized");
}
private void OnDestroy() {
if (Instance == this) { Instance = null; }
}
/// <summary>
/// Initialize with game controller references.
/// Call from EagleGameController.SetUpGame() and ShardokGameController.SetUpGame().
/// </summary>
public void Initialize(EagleGameController eagle, ShardokGameController shardok) {
_triggerRegistry?.Initialize(eagle, shardok);
Log($"TutorialManager initialized with controllers - Eagle: {eagle != null}, Shardok: {shardok != null}");
}
/// <summary>
/// Starts the main onboarding tutorial sequence.
/// </summary>
public void StartOnboarding() {
if (!TutorialsEnabled || _state.AllTutorialsDisabled) {
Log("StartOnboarding: Tutorials disabled, skipping");
return;
}
if (_state.OnboardingCompleted) {
Log("StartOnboarding: Already completed, skipping");
return;
}
if (OnboardingSequence == null) {
Debug.LogWarning("TutorialManager: No onboarding sequence assigned");
return;
}
StartSequence(OnboardingSequence, _state.OnboardingStepReached);
}
/// <summary>
/// Starts a specific tutorial sequence.
/// </summary>
public void StartSequence(TutorialSequence sequence, int startStep = 0) {
if (sequence == null) return;
if (!TutorialsEnabled || _state.AllTutorialsDisabled) {
Log($"StartSequence: Tutorials disabled, skipping '{sequence.SequenceId}'");
return;
}
// Check prerequisites
if (!sequence.ArePrerequisitesMet(_state)) {
Log($"StartSequence: Prerequisites not met for '{sequence.SequenceId}'");
return;
}
// Stop any active sequence
if (_activeSequence != null) { StopCurrentSequence(skipped: true); }
_activeSequence = sequence;
_currentStepIndex = Mathf.Clamp(startStep, 0, sequence.StepCount - 1);
Log($"Starting tutorial sequence '{sequence.SequenceId}' at step {_currentStepIndex}");
sequence.OnSequenceStart?.Invoke();
ShowCurrentStep();
}
/// <summary>
/// Triggers a contextual tutorial by ID if not already seen.
/// </summary>
public void TriggerContextualTutorial(string tutorialId) {
if (!TutorialsEnabled || _state.AllTutorialsDisabled) return;
if (_state.HasCompletedTutorial(tutorialId)) return;
// Look up the tutorial sequence by ID
var sequence = _triggerRegistry?.GetSequenceById(tutorialId);
if (sequence != null) { StartSequence(sequence); }
}
/// <summary>
/// Advances to the next step in the current sequence.
/// </summary>
public void AdvanceStep() {
if (_activeSequence == null) return;
var currentStep = CurrentStep;
string nextStepId = currentStep?.NextStepOverride;
// Determine next step index
int nextIndex;
if (!string.IsNullOrEmpty(nextStepId)) {
nextIndex = _activeSequence.IndexOfStep(nextStepId);
if (nextIndex < 0) nextIndex = _currentStepIndex + 1;
} else {
nextIndex = _currentStepIndex + 1;
}
// Check if sequence is complete
if (nextIndex >= _activeSequence.StepCount) {
CompleteCurrentSequence();
return;
}
_currentStepIndex = nextIndex;
// Track onboarding progress
if (_activeSequence.IsOnboarding) { _state.SetOnboardingStep(_currentStepIndex); }
Log($"Advanced to step {_currentStepIndex}: '{CurrentStep?.StepId}'");
ShowCurrentStep();
}
/// <summary>
/// Skips the current tutorial step.
/// </summary>
public void SkipCurrentStep() {
if (_activeSequence == null) return;
var step = CurrentStep;
if (step != null && !step.AllowSkip) {
Log("SkipCurrentStep: Current step does not allow skipping");
return;
}
AdvanceStep();
}
/// <summary>
/// Skips all remaining steps in the current sequence.
/// </summary>
public void SkipCurrentSequence() {
if (_activeSequence == null) return;
StopCurrentSequence(skipped: true);
}
/// <summary>
/// Skips the entire onboarding and marks it complete.
/// </summary>
public void SkipAllOnboarding() {
_state.CompleteOnboarding();
if (_activeSequence != null && _activeSequence.IsOnboarding) {
StopCurrentSequence(skipped: true);
}
}
/// <summary>
/// Disables all tutorials permanently (until re-enabled).
/// </summary>
public void DisableAllTutorials() {
_state.DisableAllTutorials();
if (_activeSequence != null) { StopCurrentSequence(skipped: true); }
}
/// <summary>
/// Checks if a specific tutorial has been seen.
/// </summary>
public bool HasSeenTutorial(string tutorialId) {
return _state.HasCompletedTutorial(tutorialId);
}
/// <summary>
/// Checks if a hint should be shown (not dismissed).
/// </summary>
public bool ShouldShowHint(string hintId) {
if (!TutorialsEnabled || _state.AllTutorialsDisabled) return false;
return !_state.HasDismissedHint(hintId);
}
/// <summary>
/// Called when a game event occurs that might trigger or complete a tutorial step.
/// </summary>
public void OnGameEvent(string eventId, object context = null) {
Log($"Game event: '{eventId}'");
// Check if this completes the current step
var currentStep = CurrentStep;
if (currentStep != null &&
currentStep.CompletionType == TutorialCompletionType.GameEvent &&
currentStep.CompletionEventId == eventId) {
AdvanceStep();
return;
}
// Check triggers for contextual tutorials
_triggerRegistry?.OnGameEvent(eventId, context);
}
/// <summary>
/// Resets all tutorial progress (for testing).
/// </summary>
public void ResetAllProgress() {
StopCurrentSequence(skipped: true);
_state.Reset();
Log("All tutorial progress reset");
}
private void ShowCurrentStep() {
var step = CurrentStep;
if (step == null) {
CompleteCurrentSequence();
return;
}
// Stop any previous step coroutine
if (_activeStepCoroutine != null) {
StopCoroutine(_activeStepCoroutine);
_activeStepCoroutine = null;
}
// Hide previous UI
UIManager?.HideAll();
// Handle different display modes
switch (step.DisplayMode) {
case TutorialDisplayMode.Modal:
UIManager?.ShowModal(
step,
_currentStepIndex,
_activeSequence.StepCount,
OnStepComplete,
OnStepSkip,
_activeSequence.IsOnboarding);
break;
case TutorialDisplayMode.Overlay:
UIManager?.ShowOverlay(step, OnStepComplete);
break;
case TutorialDisplayMode.Tooltip:
UIManager?.ShowTooltip(step, OnStepComplete);
break;
case TutorialDisplayMode.Hint:
UIManager?.ShowHint(step.StepId, step.TargetGameObjectPath, step.Description);
// Hints don't block - advance immediately
AdvanceStep();
break;
case TutorialDisplayMode.None:
// Invisible step - just wait for completion event
break;
}
// Handle timer-based completion
if (step.CompletionType == TutorialCompletionType.Timer && step.AutoAdvanceDelay > 0) {
_activeStepCoroutine = StartCoroutine(AutoAdvanceAfterDelay(step.AutoAdvanceDelay));
}
// Play audio if present
if (step.EffectClip != null) {
// TODO: Play via audio manager
}
}
private IEnumerator AutoAdvanceAfterDelay(float delay) {
yield return new WaitForSeconds(delay);
AdvanceStep();
}
private void OnStepComplete() { AdvanceStep(); }
private void OnStepSkip() { SkipCurrentStep(); }
private void CompleteCurrentSequence() {
if (_activeSequence == null) return;
var sequence = _activeSequence;
Log($"Completed tutorial sequence '{sequence.SequenceId}'");
// Mark as completed
_state.MarkTutorialCompleted(sequence.SequenceId);
if (sequence.IsOnboarding) { _state.CompleteOnboarding(); }
sequence.OnSequenceComplete?.Invoke();
_activeSequence = null;
_currentStepIndex = 0;
UIManager?.HideAll();
}
private void StopCurrentSequence(bool skipped) {
if (_activeSequence == null) return;
var sequence = _activeSequence;
Log($"Stopped tutorial sequence '{sequence.SequenceId}' (skipped: {skipped})");
if (skipped) { sequence.OnSequenceSkipped?.Invoke(); }
if (_activeStepCoroutine != null) {
StopCoroutine(_activeStepCoroutine);
_activeStepCoroutine = null;
}
_activeSequence = null;
_currentStepIndex = 0;
UIManager?.HideAll();
}
private void Log(string message) {
if (DebugLogging) { Debug.Log($"[Tutorial] {message}"); }
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: c529affa73b04d129ec5826ed0ac75e7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,186 +0,0 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Eagle0.Tutorial {
/// <summary>
/// Persistence layer for tutorial completion state.
/// Uses PlayerPrefs with JSON serialization for complex state.
/// </summary>
[Serializable]
public class TutorialState {
private const string PrefsKey = "Eagle0_TutorialState";
private const int CurrentVersion = 1;
// Persisted data
public int Version = CurrentVersion;
public bool OnboardingCompleted;
public int OnboardingStepReached;
public List<string> CompletedTutorials = new List<string>();
public List<string> DismissedHints = new List<string>();
public bool AllTutorialsDisabled;
// Runtime sets for faster lookup (populated from lists on load)
[NonSerialized]
private HashSet<string> _completedTutorialsSet;
[NonSerialized]
private HashSet<string> _dismissedHintsSet;
/// <summary>
/// Loads tutorial state from PlayerPrefs, or creates new state if none exists.
/// </summary>
public static TutorialState Load() {
if (!PlayerPrefs.HasKey(PrefsKey)) {
Debug.Log("TutorialState: No saved state found, creating new");
return new TutorialState();
}
try {
string json = PlayerPrefs.GetString(PrefsKey);
var state = JsonUtility.FromJson<TutorialState>(json);
// Handle version migration if needed
if (state.Version < CurrentVersion) { state = MigrateState(state); }
state.BuildRuntimeSets();
Debug.Log(
$"TutorialState: Loaded state - Onboarding: {state.OnboardingCompleted}, Completed: {state.CompletedTutorials.Count}");
return state;
} catch (Exception e) {
Debug.LogWarning(
$"TutorialState: Failed to load state, creating new. Error: {e.Message}");
return new TutorialState();
}
}
/// <summary>
/// Saves current state to PlayerPrefs.
/// </summary>
public void Save() {
try {
string json = JsonUtility.ToJson(this);
PlayerPrefs.SetString(PrefsKey, json);
PlayerPrefs.Save();
Debug.Log("TutorialState: State saved");
} catch (Exception e) {
Debug.LogError($"TutorialState: Failed to save state. Error: {e.Message}");
}
}
/// <summary>
/// Resets all tutorial state (for testing or user request).
/// </summary>
public void Reset() {
OnboardingCompleted = false;
OnboardingStepReached = 0;
CompletedTutorials.Clear();
DismissedHints.Clear();
AllTutorialsDisabled = false;
BuildRuntimeSets();
Save();
Debug.Log("TutorialState: State reset");
}
/// <summary>
/// Marks a specific tutorial as completed.
/// </summary>
public void MarkTutorialCompleted(string tutorialId) {
if (string.IsNullOrEmpty(tutorialId)) return;
EnsureRuntimeSets();
if (_completedTutorialsSet.Add(tutorialId)) {
CompletedTutorials.Add(tutorialId);
Save();
Debug.Log($"TutorialState: Marked tutorial '{tutorialId}' as completed");
}
}
/// <summary>
/// Marks a hint as dismissed (won't show again).
/// </summary>
public void MarkHintDismissed(string hintId) {
if (string.IsNullOrEmpty(hintId)) return;
EnsureRuntimeSets();
if (_dismissedHintsSet.Add(hintId)) {
DismissedHints.Add(hintId);
Save();
Debug.Log($"TutorialState: Marked hint '{hintId}' as dismissed");
}
}
/// <summary>
/// Checks if a tutorial has been completed.
/// </summary>
public bool HasCompletedTutorial(string tutorialId) {
if (string.IsNullOrEmpty(tutorialId)) return false;
EnsureRuntimeSets();
return _completedTutorialsSet.Contains(tutorialId);
}
/// <summary>
/// Checks if a hint has been dismissed.
/// </summary>
public bool HasDismissedHint(string hintId) {
if (string.IsNullOrEmpty(hintId)) return false;
EnsureRuntimeSets();
return _dismissedHintsSet.Contains(hintId);
}
/// <summary>
/// Marks the onboarding as completed.
/// </summary>
public void CompleteOnboarding() {
OnboardingCompleted = true;
Save();
Debug.Log("TutorialState: Onboarding completed");
}
/// <summary>
/// Updates the furthest onboarding step reached.
/// </summary>
public void SetOnboardingStep(int step) {
if (step > OnboardingStepReached) {
OnboardingStepReached = step;
Save();
}
}
/// <summary>
/// Disables all tutorials (user preference).
/// </summary>
public void DisableAllTutorials() {
AllTutorialsDisabled = true;
Save();
Debug.Log("TutorialState: All tutorials disabled");
}
/// <summary>
/// Re-enables tutorials.
/// </summary>
public void EnableTutorials() {
AllTutorialsDisabled = false;
Save();
Debug.Log("TutorialState: Tutorials re-enabled");
}
private void BuildRuntimeSets() {
_completedTutorialsSet = new HashSet<string>(CompletedTutorials ?? new List<string>());
_dismissedHintsSet = new HashSet<string>(DismissedHints ?? new List<string>());
}
private void EnsureRuntimeSets() {
if (_completedTutorialsSet == null || _dismissedHintsSet == null) {
BuildRuntimeSets();
}
}
private static TutorialState MigrateState(TutorialState oldState) {
// Future: Handle migrations between versions
Debug.Log(
$"TutorialState: Migrating from version {oldState.Version} to {CurrentVersion}");
oldState.Version = CurrentVersion;
return oldState;
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 2424323130a24ac381f535ed932c34d3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: a53ad5e16bc649fe9344b1a1c700dbd8
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,146 +0,0 @@
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace Eagle0.Tutorial {
/// <summary>
/// Small pulsing dot indicator for subtle hints.
/// Attaches to UI elements to draw attention without interrupting gameplay.
/// Shows tooltip on hover, triggers full tutorial on click.
/// </summary>
public class TutorialHintIndicator : MonoBehaviour,
IPointerEnterHandler,
IPointerExitHandler,
IPointerClickHandler {
[Header("Appearance")]
[Tooltip("The dot/indicator image")]
public Image DotImage;
[Tooltip("Normal color of the dot")]
public Color NormalColor = new Color(1f, 0.8f, 0f, 0.9f);
[Tooltip("Pulse highlight color")]
public Color PulseColor = new Color(1f, 1f, 0.5f, 1f);
[Tooltip("Size of the indicator")]
public float Size = 20f;
[Header("Animation")]
[Tooltip("Pulse animation period in seconds")]
public float PulsePeriod = 1.0f;
[Tooltip("Pulse scale multiplier")]
public float PulseScale = 1.3f;
// Runtime state
[HideInInspector]
public string HintId;
private string _tooltipText;
private Transform _targetTransform;
private RectTransform _rectTransform;
private float _pulseTimer;
private bool _isHovered;
private void Awake() {
_rectTransform = GetComponent<RectTransform>();
if (_rectTransform == null) {
_rectTransform = gameObject.AddComponent<RectTransform>();
}
// Set up image if not assigned
if (DotImage == null) {
DotImage = GetComponent<Image>();
if (DotImage == null) { DotImage = gameObject.AddComponent<Image>(); }
}
// Default to circle sprite or solid color
DotImage.color = NormalColor;
}
/// <summary>
/// Initialize the hint indicator.
/// </summary>
public void Initialize(string hintId, Transform target, string tooltipText) {
HintId = hintId;
_targetTransform = target;
_tooltipText = tooltipText;
// Set size
_rectTransform.sizeDelta = new Vector2(Size, Size);
// Position near target
UpdatePosition();
}
private void Update() {
// Pulse animation
_pulseTimer += Time.deltaTime;
float t = (Mathf.Sin(_pulseTimer * Mathf.PI * 2f / PulsePeriod) + 1f) / 2f;
if (!_isHovered) {
// Pulse color
DotImage.color = Color.Lerp(NormalColor, PulseColor, t);
// Pulse scale
float scale = 1f + (PulseScale - 1f) * t;
transform.localScale = Vector3.one * scale;
}
// Follow target
if (_targetTransform != null) { UpdatePosition(); }
}
private void UpdatePosition() {
if (_targetTransform == null) return;
RectTransform targetRect = _targetTransform as RectTransform;
if (targetRect == null) { targetRect = _targetTransform.GetComponent<RectTransform>(); }
if (targetRect != null) {
// Position at top-right corner of target
Vector3[] corners = new Vector3[4];
targetRect.GetWorldCorners(corners);
Vector3 topRight = corners[2]; // Top-right corner
transform.position = topRight + new Vector3(-Size / 2, -Size / 2, 0);
}
}
public void OnPointerEnter(PointerEventData eventData) {
_isHovered = true;
// Highlight
DotImage.color = PulseColor;
transform.localScale = Vector3.one * PulseScale;
// Show tooltip
if (!string.IsNullOrEmpty(_tooltipText)) {
// Find HoveringTooltip in scene and show text
var tooltip = FindObjectOfType<HoveringTooltip>();
if (tooltip != null) { tooltip.Text = _tooltipText; }
}
}
public void OnPointerExit(PointerEventData eventData) {
_isHovered = false;
// Reset appearance (will resume pulsing in Update)
// Hide tooltip
var tooltip = FindObjectOfType<HoveringTooltip>();
if (tooltip != null) { tooltip.Text = ""; }
}
public void OnPointerClick(PointerEventData eventData) {
// Trigger the full tutorial for this hint
TutorialManager.Instance?.TriggerContextualTutorial(HintId);
// Mark hint as dismissed
TutorialManager.Instance?.State.MarkHintDismissed(HintId);
// Destroy this indicator
Destroy(gameObject);
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: f3f3016726d6473da721d6e468baab8a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,155 +0,0 @@
using System;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Eagle0.Tutorial {
/// <summary>
/// Modal dialog panel for displaying tutorial content.
/// Shows title, description, icon, and action buttons.
/// </summary>
public class TutorialModalPanel : MonoBehaviour {
[Header("Content")]
[Tooltip("Title text")]
public TextMeshProUGUI TitleText;
[Tooltip("Description text")]
public TextMeshProUGUI DescriptionText;
[Tooltip("Icon image")]
public Image IconImage;
[Header("Buttons")]
[Tooltip("Continue/Got it button")]
public Button ContinueButton;
[Tooltip("Text on continue button")]
public TextMeshProUGUI ContinueButtonText;
[Tooltip("Skip this step button")]
public Button SkipButton;
[Tooltip("Skip all tutorials button (onboarding only)")]
public Button SkipAllButton;
[Header("Progress")]
[Tooltip("Progress text (e.g., 'Step 3 of 7')")]
public TextMeshProUGUI ProgressText;
[Tooltip("Progress slider/bar")]
public Slider ProgressSlider;
[Header("Background")]
[Tooltip("Modal blocker/dimmer")]
public GameObject ModalBlocker;
[Tooltip("Panel container")]
public GameObject PanelContainer;
[Header("Animation")]
[Tooltip("Animator for panel animations")]
public Animator PanelAnimator;
// Callbacks
private Action _onContinue;
private Action _onSkip;
private void Awake() {
// Set up button listeners
if (ContinueButton != null) { ContinueButton.onClick.AddListener(OnContinueClicked); }
if (SkipButton != null) { SkipButton.onClick.AddListener(OnSkipClicked); }
if (SkipAllButton != null) { SkipAllButton.onClick.AddListener(OnSkipAllClicked); }
// Start hidden
Hide();
}
/// <summary>
/// Shows the modal with tutorial step content.
/// </summary>
public void Show(
TutorialStep step,
int currentIndex,
int totalSteps,
Action onContinue,
Action onSkip,
bool isOnboarding) {
_onContinue = onContinue;
_onSkip = onSkip;
// Set content
if (TitleText != null) { TitleText.text = step.Title ?? ""; }
if (DescriptionText != null) { DescriptionText.text = step.Description ?? ""; }
if (IconImage != null) {
if (step.Icon != null) {
IconImage.sprite = step.Icon;
IconImage.gameObject.SetActive(true);
} else {
IconImage.gameObject.SetActive(false);
}
}
// Set button text
if (ContinueButtonText != null) {
ContinueButtonText.text = currentIndex >= totalSteps - 1 ? "Got it!" : "Continue";
}
// Show/hide skip buttons
if (SkipButton != null) { SkipButton.gameObject.SetActive(step.AllowSkip); }
if (SkipAllButton != null) {
SkipAllButton.gameObject.SetActive(isOnboarding && step.AllowSkip);
}
// Set progress
if (ProgressText != null) {
ProgressText.text = $"Step {currentIndex + 1} of {totalSteps}";
ProgressText.gameObject.SetActive(totalSteps > 1);
}
if (ProgressSlider != null) {
ProgressSlider.value = (float)(currentIndex + 1) / totalSteps;
ProgressSlider.gameObject.SetActive(totalSteps > 1);
}
// Show panel
if (ModalBlocker != null) { ModalBlocker.SetActive(true); }
if (PanelContainer != null) { PanelContainer.SetActive(true); }
gameObject.SetActive(true);
// Trigger animation
if (PanelAnimator != null) { PanelAnimator.SetTrigger("Show"); }
}
/// <summary>
/// Hides the modal panel.
/// </summary>
public void Hide() {
if (PanelAnimator != null) { PanelAnimator.SetTrigger("Hide"); }
if (ModalBlocker != null) { ModalBlocker.SetActive(false); }
if (PanelContainer != null) { PanelContainer.SetActive(false); }
gameObject.SetActive(false);
_onContinue = null;
_onSkip = null;
}
private void OnContinueClicked() {
var callback = _onContinue;
Hide();
callback?.Invoke();
}
private void OnSkipClicked() {
var callback = _onSkip;
Hide();
callback?.Invoke();
}
private void OnSkipAllClicked() {
Hide();
TutorialManager.Instance?.SkipAllOnboarding();
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 461d5eac2fdd4d509a15185a42677886
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,278 +0,0 @@
using System;
using System.Collections;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Eagle0.Tutorial {
/// <summary>
/// Semi-transparent overlay that highlights specific UI elements.
/// Dims the rest of the screen and shows explanatory text.
/// </summary>
public class TutorialOverlayController : MonoBehaviour {
[Header("Background")]
[Tooltip("Semi-transparent background that dims the screen")]
public Image BackgroundDimmer;
[Tooltip("Background dimmer alpha")]
public float DimmerAlpha = 0.7f;
[Header("Highlight")]
[Tooltip("The highlight frame/border around target element")]
public RectTransform HighlightFrame;
[Tooltip("Padding around the highlighted element")]
public float HighlightPadding = 10f;
[Tooltip("Highlight border color")]
public Color HighlightColor = Color.yellow;
[Header("Tooltip")]
[Tooltip("Container for tooltip content")]
public RectTransform TooltipContainer;
[Tooltip("Tooltip title text")]
public TextMeshProUGUI TooltipTitle;
[Tooltip("Tooltip description text")]
public TextMeshProUGUI TooltipDescription;
[Tooltip("Arrow/pointer image")]
public Image ArrowPointer;
[Tooltip("Continue button in tooltip")]
public Button ContinueButton;
[Header("Animation")]
[Tooltip("Duration of fade in/out")]
public float FadeDuration = 0.3f;
[Tooltip("Pulse animation period")]
public float PulsePeriod = 1.5f;
[Tooltip("Pulse scale amount")]
public float PulseAmount = 0.05f;
// State
private Transform _currentTarget;
private Action _onComplete;
private Coroutine _pulseCoroutine;
private CanvasGroup _canvasGroup;
private void Awake() {
_canvasGroup = GetComponent<CanvasGroup>();
if (_canvasGroup == null) { _canvasGroup = gameObject.AddComponent<CanvasGroup>(); }
if (ContinueButton != null) { ContinueButton.onClick.AddListener(OnContinueClicked); }
// Start hidden
gameObject.SetActive(false);
}
/// <summary>
/// Shows the overlay highlighting a target element.
/// </summary>
public void ShowOverlay(
Transform target,
string title,
string description,
Vector2 offset,
bool pulsing,
Action onComplete) {
_currentTarget = target;
_onComplete = onComplete;
// Set content
if (TooltipTitle != null) {
TooltipTitle.text = title ?? "";
TooltipTitle.gameObject.SetActive(!string.IsNullOrEmpty(title));
}
if (TooltipDescription != null) { TooltipDescription.text = description ?? ""; }
// Position elements
if (target != null) {
PositionHighlight(target);
PositionTooltip(target, offset);
} else {
// No target - center the tooltip
if (HighlightFrame != null) { HighlightFrame.gameObject.SetActive(false); }
if (ArrowPointer != null) { ArrowPointer.gameObject.SetActive(false); }
CenterTooltip();
}
// Show
gameObject.SetActive(true);
StartCoroutine(FadeIn());
// Start pulsing if requested
if (pulsing && target != null) { _pulseCoroutine = StartCoroutine(PulseHighlight()); }
}
/// <summary>
/// Shows only the highlight without tooltip (for emphasis).
/// </summary>
public void HighlightOnly(Transform target) {
_currentTarget = target;
if (target != null) {
PositionHighlight(target);
if (HighlightFrame != null) { HighlightFrame.gameObject.SetActive(true); }
}
// Hide tooltip elements
if (TooltipContainer != null) { TooltipContainer.gameObject.SetActive(false); }
if (BackgroundDimmer != null) { BackgroundDimmer.gameObject.SetActive(false); }
gameObject.SetActive(true);
_pulseCoroutine = StartCoroutine(PulseHighlight());
}
/// <summary>
/// Clears highlight without hiding overlay.
/// </summary>
public void ClearHighlight() {
if (_pulseCoroutine != null) {
StopCoroutine(_pulseCoroutine);
_pulseCoroutine = null;
}
if (HighlightFrame != null) { HighlightFrame.gameObject.SetActive(false); }
_currentTarget = null;
}
/// <summary>
/// Hides the overlay completely.
/// </summary>
public void HideOverlay() {
if (_pulseCoroutine != null) {
StopCoroutine(_pulseCoroutine);
_pulseCoroutine = null;
}
StartCoroutine(FadeOut());
}
private void PositionHighlight(Transform target) {
if (HighlightFrame == null || target == null) return;
RectTransform targetRect = target as RectTransform;
if (targetRect == null) { targetRect = target.GetComponent<RectTransform>(); }
if (targetRect != null) {
// Get target bounds in screen space
Vector3[] corners = new Vector3[4];
targetRect.GetWorldCorners(corners);
// Convert to canvas space
Canvas canvas = GetComponentInParent<Canvas>();
if (canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay) {
Camera cam = canvas.worldCamera ?? Camera.main;
for (int i = 0; i < 4; i++) { corners[i] = cam.WorldToScreenPoint(corners[i]); }
}
// Calculate bounds
float minX = Mathf.Min(corners[0].x, corners[1].x, corners[2].x, corners[3].x);
float maxX = Mathf.Max(corners[0].x, corners[1].x, corners[2].x, corners[3].x);
float minY = Mathf.Min(corners[0].y, corners[1].y, corners[2].y, corners[3].y);
float maxY = Mathf.Max(corners[0].y, corners[1].y, corners[2].y, corners[3].y);
// Position and size highlight
Vector2 center = new Vector2((minX + maxX) / 2, (minY + maxY) / 2);
Vector2 size = new Vector2(
maxX - minX + HighlightPadding * 2,
maxY - minY + HighlightPadding * 2);
HighlightFrame.position = center;
HighlightFrame.sizeDelta = size;
HighlightFrame.gameObject.SetActive(true);
}
}
private void PositionTooltip(Transform target, Vector2 offset) {
if (TooltipContainer == null) return;
TooltipContainer.gameObject.SetActive(true);
if (target != null) {
RectTransform targetRect = target as RectTransform;
if (targetRect == null) { targetRect = target.GetComponent<RectTransform>(); }
if (targetRect != null) {
// Position tooltip near target with offset
Vector3 targetPos = targetRect.position;
TooltipContainer.position = targetPos + new Vector3(offset.x, offset.y, 0);
// Position arrow to point at target
if (ArrowPointer != null) {
ArrowPointer.gameObject.SetActive(true);
// Arrow rotation based on relative position could be added here
}
}
}
}
private void CenterTooltip() {
if (TooltipContainer == null) return;
TooltipContainer.gameObject.SetActive(true);
TooltipContainer.anchoredPosition = Vector2.zero;
if (ArrowPointer != null) { ArrowPointer.gameObject.SetActive(false); }
}
private IEnumerator FadeIn() {
float elapsed = 0f;
while (elapsed < FadeDuration) {
elapsed += Time.deltaTime;
float t = elapsed / FadeDuration;
if (_canvasGroup != null) { _canvasGroup.alpha = t; }
yield return null;
}
if (_canvasGroup != null) { _canvasGroup.alpha = 1f; }
}
private IEnumerator FadeOut() {
float elapsed = 0f;
while (elapsed < FadeDuration) {
elapsed += Time.deltaTime;
float t = 1f - (elapsed / FadeDuration);
if (_canvasGroup != null) { _canvasGroup.alpha = t; }
yield return null;
}
gameObject.SetActive(false);
_currentTarget = null;
_onComplete = null;
}
private IEnumerator PulseHighlight() {
if (HighlightFrame == null) yield break;
Vector3 baseScale = HighlightFrame.localScale;
float elapsed = 0f;
while (true) {
elapsed += Time.deltaTime;
float t = (Mathf.Sin(elapsed * Mathf.PI * 2f / PulsePeriod) + 1f) / 2f;
float scale = 1f + t * PulseAmount;
HighlightFrame.localScale = baseScale * scale;
yield return null;
}
}
private void OnContinueClicked() {
var callback = _onComplete;
HideOverlay();
callback?.Invoke();
}
private void Update() {
// Follow target if it moves
if (_currentTarget != null && HighlightFrame != null &&
HighlightFrame.gameObject.activeSelf) {
PositionHighlight(_currentTarget);
}
}
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 3cf75368a9b643a18f00ff43e51af7c3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,174 +0,0 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace Eagle0.Tutorial {
/// <summary>
/// Manages all tutorial UI elements and their presentation.
/// Coordinates between modal dialogs, overlays, and hint indicators.
/// </summary>
public class TutorialUIManager : MonoBehaviour {
[Header("UI Components")]
[Tooltip("Modal dialog panel for important tutorials")]
public TutorialModalPanel ModalPanel;
[Tooltip("Overlay controller for highlighting UI elements")]
public TutorialOverlayController OverlayController;
[Tooltip("Prefab for hint indicator dots")]
public TutorialHintIndicator HintIndicatorPrefab;
[Header("Canvas")]
[Tooltip("Canvas for tutorial UI (should be above game UI)")]
public Canvas TutorialCanvas;
// Active hints
private Dictionary<string, TutorialHintIndicator> _activeHints =
new Dictionary<string, TutorialHintIndicator>();
private void Awake() {
// Ensure canvas is set up correctly
if (TutorialCanvas != null) {
TutorialCanvas.sortingOrder = 1000; // Above most game UI
}
}
/// <summary>
/// Shows a modal dialog for a tutorial step.
/// </summary>
public void ShowModal(
TutorialStep step,
int currentIndex,
int totalSteps,
Action onComplete,
Action onSkip,
bool isOnboarding) {
if (ModalPanel == null) {
Debug.LogWarning("TutorialUIManager: No ModalPanel assigned");
onComplete?.Invoke();
return;
}
ModalPanel.Show(step, currentIndex, totalSteps, onComplete, onSkip, isOnboarding);
}
/// <summary>
/// Shows an overlay highlighting a specific UI element.
/// </summary>
public void ShowOverlay(TutorialStep step, Action onComplete) {
if (OverlayController == null) {
Debug.LogWarning("TutorialUIManager: No OverlayController assigned");
onComplete?.Invoke();
return;
}
// Find target element
Transform target = null;
if (!string.IsNullOrEmpty(step.TargetGameObjectPath)) {
var targetObj = GameObject.Find(step.TargetGameObjectPath);
if (targetObj != null) {
target = targetObj.transform;
} else {
Debug.LogWarning(
$"TutorialUIManager: Could not find target '{step.TargetGameObjectPath}'");
}
}
OverlayController.ShowOverlay(
target,
step.Title,
step.Description,
step.HighlightOffset,
step.HighlightPulsing,
onComplete);
}
/// <summary>
/// Shows a tooltip near a target element.
/// </summary>
public void ShowTooltip(TutorialStep step, Action onComplete) {
// For now, use overlay with smaller presentation
// Could be enhanced to use a dedicated tooltip component
ShowOverlay(step, onComplete);
}
/// <summary>
/// Shows a hint indicator on a UI element.
/// </summary>
public void ShowHint(string hintId, string targetPath, string tooltipText) {
if (HintIndicatorPrefab == null) {
Debug.LogWarning("TutorialUIManager: No HintIndicatorPrefab assigned");
return;
}
// Don't show duplicate hints
if (_activeHints.ContainsKey(hintId)) return;
// Find target
Transform target = null;
if (!string.IsNullOrEmpty(targetPath)) {
var targetObj = GameObject.Find(targetPath);
if (targetObj != null) { target = targetObj.transform; }
}
if (target == null) {
Debug.LogWarning($"TutorialUIManager: Could not find hint target '{targetPath}'");
return;
}
// Create hint indicator
var hint = Instantiate(HintIndicatorPrefab, TutorialCanvas.transform);
hint.Initialize(hintId, target, tooltipText);
_activeHints[hintId] = hint;
}
/// <summary>
/// Hides a specific hint.
/// </summary>
public void HideHint(string hintId) {
if (_activeHints.TryGetValue(hintId, out var hint)) {
if (hint != null) { Destroy(hint.gameObject); }
_activeHints.Remove(hintId);
}
}
/// <summary>
/// Hides all active hints.
/// </summary>
public void HideAllHints() {
foreach (var hint in _activeHints.Values) {
if (hint != null) { Destroy(hint.gameObject); }
}
_activeHints.Clear();
}
/// <summary>
/// Hides all tutorial UI.
/// </summary>
public void HideAll() {
ModalPanel?.Hide();
OverlayController?.HideOverlay();
// Note: Don't hide hints automatically - they persist until dismissed
}
/// <summary>
/// Highlights a specific UI element (without showing tutorial content).
/// </summary>
public void HighlightElement(string gameObjectPath) {
if (OverlayController == null) return;
Transform target = null;
if (!string.IsNullOrEmpty(gameObjectPath)) {
var targetObj = GameObject.Find(gameObjectPath);
if (targetObj != null) { target = targetObj.transform; }
}
if (target != null) { OverlayController.HighlightOnly(target); }
}
/// <summary>
/// Clears any active highlight.
/// </summary>
public void ClearHighlight() { OverlayController?.ClearHighlight(); }
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 52abae862492497cb7a3f8c72f837b37
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,5 +1,144 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Microsoft.Win32;
namespace EagleInstaller {
public class CredentialManager {
public const string ServerUrl = "https://assets.eagle0.net";
public class Credentials {
public string Username { get; set; }
public string Password { get; set; }
public string ServerUrl { get; set; }
}
}
public class CredentialManager {
private const string RegistryKeyPath = @"SOFTWARE\Eagle0\Launcher";
private const string UsernameValueName = "Username";
private const string ServerUrlValueName = "ServerUrl";
private const string CredentialFileName = "eagle0_credentials.dat";
private static readonly string CredentialFilePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"eagle0",
CredentialFileName);
/// <summary>
/// Loads saved credentials. Returns null if no valid credentials are found.
/// </summary>
public static Credentials LoadCredentials() {
try {
// Try to load from registry (username and server URL)
string username = null;
string serverUrl = null;
using (var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, false)) {
if (key != null) {
username = key.GetValue(UsernameValueName) as string;
serverUrl = key.GetValue(ServerUrlValueName) as string;
}
}
if (string.IsNullOrEmpty(username)) { return null; }
// Try to load encrypted password from file
string password = LoadEncryptedPassword();
if (string.IsNullOrEmpty(password)) { return null; }
return new Credentials {
Username = username,
Password = password,
ServerUrl = serverUrl ?? "https://eagle0.net"
};
} catch (Exception) {
// If anything fails, return null to prompt for credentials
return null;
}
}
/// <summary>
/// Saves credentials to registry (username/server) and encrypted file (password).
/// </summary>
public static void SaveCredentials(Credentials credentials) {
try {
// Save username and server URL to registry
using (var key = Registry.CurrentUser.CreateSubKey(RegistryKeyPath)) {
key.SetValue(UsernameValueName, credentials.Username);
key.SetValue(ServerUrlValueName, credentials.ServerUrl);
}
// Save encrypted password to file
SaveEncryptedPassword(credentials.Password);
} catch (Exception ex) {
throw new Exception($"Failed to save credentials: {ex.Message}", ex);
}
}
/// <summary>
/// Clears all saved credentials.
/// </summary>
public static void ClearCredentials() {
try {
// Clear registry entries
using (var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, true)) {
if (key != null) {
key.DeleteValue(UsernameValueName, false);
key.DeleteValue(ServerUrlValueName, false);
}
}
// Delete password file
if (File.Exists(CredentialFilePath)) { File.Delete(CredentialFilePath); }
} catch (Exception) {
// Ignore errors when clearing
}
}
private static string LoadEncryptedPassword() {
try {
if (!File.Exists(CredentialFilePath)) { return null; }
byte[] encryptedData = File.ReadAllBytes(CredentialFilePath);
byte[] decryptedData = ProtectedData.Unprotect(
encryptedData,
null,
DataProtectionScope.CurrentUser);
return Encoding.UTF8.GetString(decryptedData);
} catch (Exception) { return null; }
}
private static void SaveEncryptedPassword(string password) {
try {
Directory.CreateDirectory(Path.GetDirectoryName(CredentialFilePath));
byte[] passwordBytes = Encoding.UTF8.GetBytes(password);
byte[] encryptedData =
ProtectedData.Protect(passwordBytes, null, DataProtectionScope.CurrentUser);
File.WriteAllBytes(CredentialFilePath, encryptedData);
} catch (Exception ex) {
throw new Exception($"Failed to save encrypted password: {ex.Message}", ex);
}
}
/// <summary>
/// Saves credentials in the same format used by the Unity client for compatibility.
/// </summary>
public static void SaveCredentialsForUnityClient(Credentials credentials) {
try {
// Unity uses PlayerPrefs which on Windows maps to registry entries
// We'll save to the same location the Unity client expects
const string unityRegistryPath =
@"SOFTWARE\Unity\UnityEditor\CompanyName\ProductName";
using (var key = Registry.CurrentUser.CreateSubKey(unityRegistryPath)) {
key.SetValue("urlKey", credentials.ServerUrl);
key.SetValue("nameKey", credentials.Username);
key.SetValue("passwordKey", credentials.Password);
}
} catch (Exception) {
// If Unity credential sharing fails, continue anyway
// The main launcher credentials are still saved
}
}
}
}
@@ -1,8 +1,11 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using System.Threading.Tasks;
using System.Threading;
@@ -10,6 +13,9 @@ namespace EagleInstaller {
internal class UpdaterFailureException
(string message) : Exception(message);
internal class AuthenticationFailedException
(string message) : Exception(message);
public class InstallerUpdateInfo {
public bool InstallerNeedsUpdate { get; set; }
public string NewInstallerVersion { get; set; }
@@ -31,6 +37,8 @@ namespace EagleInstaller {
private readonly HttpClient _httpClient;
private static readonly SemaphoreSlim Pool = new(DownloadSlots, DownloadSlots);
// Current credentials and service URL
private Credentials CurrentCredentials { get; set; }
private Uri ServiceUrl { get; set; }
private struct FetchAttempt {
@@ -74,12 +82,27 @@ namespace EagleInstaller {
return dict;
}
public EagleUpdater(string serverUrl) {
if (string.IsNullOrEmpty(serverUrl)) {
throw new ArgumentNullException(nameof(serverUrl));
}
ServiceUrl = new Uri(serverUrl);
public EagleUpdater(Credentials credentials) {
CurrentCredentials =
credentials ?? throw new ArgumentNullException(nameof(credentials));
ServiceUrl = new Uri(CurrentCredentials.ServerUrl);
_httpClient = new HttpClient();
SetAuthentication(CurrentCredentials.Username, CurrentCredentials.Password);
}
/// <summary>
/// Updates the authentication credentials for future requests.
/// </summary>
public void UpdateCredentials(Credentials newCredentials) {
CurrentCredentials = newCredentials;
ServiceUrl = new Uri(CurrentCredentials.ServerUrl);
SetAuthentication(CurrentCredentials.Username, CurrentCredentials.Password);
}
private void SetAuthentication(string username, string password) {
_httpClient.DefaultRequestHeaders.Authorization =
AuthenticationHeader(username, password);
}
private List<string> Discrepancies(
@@ -233,6 +256,15 @@ namespace EagleInstaller {
return existingFiles;
}
private static AuthenticationHeaderValue AuthenticationHeader(
string name,
string password) {
return new AuthenticationHeaderValue(
"Basic",
Convert.ToBase64String(
System.Text.Encoding.ASCII.GetBytes($"{name}:{password}")));
}
static async Task<string> LocalManifestText(string existingManifestPath) {
if (!File.Exists(existingManifestPath)) return null;
@@ -243,6 +275,11 @@ namespace EagleInstaller {
using HttpResponseMessage response =
await _httpClient.GetAsync(new Uri(ServiceUrl, RemoteManifestName));
if (response.StatusCode == HttpStatusCode.Unauthorized) {
throw new AuthenticationFailedException(
"Authentication failed while fetching manifest. Invalid username or password.");
}
response.EnsureSuccessStatusCode();
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
using StreamReader reader = new StreamReader(responseStream);
@@ -286,6 +323,11 @@ namespace EagleInstaller {
using HttpResponseMessage response =
await _httpClient.GetAsync(new Uri(ServiceUrl, RemotePrefix + remotePath));
if (response.StatusCode == HttpStatusCode.Unauthorized) {
throw new AuthenticationFailedException(
$"Authentication failed while downloading {remotePath}. Invalid username or password.");
}
response.EnsureSuccessStatusCode();
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
await using FileStream fileStream = File.Create(writePath);
@@ -337,6 +379,11 @@ namespace EagleInstaller {
using HttpResponseMessage response = await _httpClient.GetAsync(
new Uri(ServiceUrl, "assets/" + installerUrl));
if (response.StatusCode == HttpStatusCode.Unauthorized) {
throw new AuthenticationFailedException(
"Authentication failed while downloading new installer. Invalid username or password.");
}
response.EnsureSuccessStatusCode();
long? totalBytes = response.Content.Headers.ContentLength;
@@ -0,0 +1,212 @@
using System;
using System.Drawing;
using System.Windows.Forms;
namespace EagleInstaller {
public partial class LoginDialog : Form {
public Credentials Credentials { get; private set; }
public bool SaveCredentials { get; private set; }
private TextBox usernameTextBox;
private TextBox passwordTextBox;
private TextBox serverUrlTextBox;
private CheckBox saveCredentialsCheckBox;
private Button okButton;
private Button cancelButton;
private Label titleLabel;
private Label usernameLabel;
private Label passwordLabel;
private Label serverLabel;
public LoginDialog(string title = "Eagle0 Login", Credentials existingCredentials = null) {
InitializeComponent();
this.Text = title;
titleLabel.Text = title;
if (existingCredentials != null) {
usernameTextBox.Text = existingCredentials.Username ?? "";
passwordTextBox.Text = existingCredentials.Password ?? "";
serverUrlTextBox.Text = existingCredentials.ServerUrl ?? "https://eagle0.net";
} else {
serverUrlTextBox.Text = "https://eagle0.net";
}
// Default to saving credentials
saveCredentialsCheckBox.Checked = true;
}
private void InitializeComponent() {
// Form properties
this.Size = new Size(400, 280);
this.StartPosition = FormStartPosition.CenterScreen;
this.FormBorderStyle = FormBorderStyle.FixedDialog;
this.MaximizeBox = false;
this.MinimizeBox = false;
this.ShowInTaskbar = false;
// Title label
titleLabel = new Label {
Text = "Eagle0 Login",
Font = new Font("Microsoft Sans Serif", 12F, FontStyle.Bold),
Location = new Point(20, 20),
Size = new Size(350, 25),
TextAlign = ContentAlignment.MiddleCenter
};
// Username label and textbox
usernameLabel = new Label {
Text = "Username:",
Location = new Point(20, 60),
Size = new Size(80, 23),
TextAlign = ContentAlignment.MiddleLeft
};
usernameTextBox = new TextBox {
Location = new Point(110, 60),
Size = new Size(250, 23),
TabIndex = 0
};
// Password label and textbox
passwordLabel = new Label {
Text = "Password:",
Location = new Point(20, 90),
Size = new Size(80, 23),
TextAlign = ContentAlignment.MiddleLeft
};
passwordTextBox = new TextBox {
Location = new Point(110, 90),
Size = new Size(250, 23),
UseSystemPasswordChar = true,
TabIndex = 1
};
// Server URL label and textbox
serverLabel = new Label {
Text = "Server:",
Location = new Point(20, 120),
Size = new Size(80, 23),
TextAlign = ContentAlignment.MiddleLeft
};
serverUrlTextBox = new TextBox {
Location = new Point(110, 120),
Size = new Size(250, 23),
TabIndex = 2
};
// Save credentials checkbox
saveCredentialsCheckBox = new CheckBox {
Text = "Remember credentials",
Location = new Point(20, 160),
Size = new Size(200, 23),
TabIndex = 3
};
// OK button
okButton = new Button {
Text = "OK",
Location = new Point(200, 200),
Size = new Size(80, 30),
TabIndex = 4,
DialogResult = DialogResult.OK
};
okButton.Click += OkButton_Click;
// Cancel button
cancelButton = new Button {
Text = "Cancel",
Location = new Point(290, 200),
Size = new Size(80, 30),
TabIndex = 5,
DialogResult = DialogResult.Cancel
};
// Add controls to form
this.Controls.AddRange(new Control[] {
titleLabel,
usernameLabel,
usernameTextBox,
passwordLabel,
passwordTextBox,
serverLabel,
serverUrlTextBox,
saveCredentialsCheckBox,
okButton,
cancelButton
});
// Set default button and cancel button
this.AcceptButton = okButton;
this.CancelButton = cancelButton;
// Focus on username field
this.ActiveControl = usernameTextBox;
}
private void OkButton_Click(object sender, EventArgs e) {
// Validate input
if (string.IsNullOrWhiteSpace(usernameTextBox.Text)) {
MessageBox.Show(
"Username is required.",
"Validation Error",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
usernameTextBox.Focus();
return;
}
if (string.IsNullOrWhiteSpace(passwordTextBox.Text)) {
MessageBox.Show(
"Password is required.",
"Validation Error",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
passwordTextBox.Focus();
return;
}
if (string.IsNullOrWhiteSpace(serverUrlTextBox.Text)) {
MessageBox.Show(
"Server URL is required.",
"Validation Error",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
serverUrlTextBox.Focus();
return;
}
// Create credentials object
Credentials = new Credentials {
Username = usernameTextBox.Text.Trim(),
Password = passwordTextBox.Text,
ServerUrl = serverUrlTextBox.Text.Trim()
};
// Normalize server URL
if (!Credentials.ServerUrl.StartsWith("http://") &&
!Credentials.ServerUrl.StartsWith("https://")) {
Credentials.ServerUrl = "https://" + Credentials.ServerUrl;
}
SaveCredentials = saveCredentialsCheckBox.Checked;
this.DialogResult = DialogResult.OK;
}
/// <summary>
/// Shows the login dialog and returns the entered credentials, or null if cancelled.
/// </summary>
public static (Credentials credentials, bool saveCredentials) ShowLoginDialog(
string title = "Eagle0 Login",
Credentials existingCredentials = null) {
using (var dialog = new LoginDialog(title, existingCredentials)) {
if (dialog.ShowDialog() == DialogResult.OK) {
return (dialog.Credentials, dialog.SaveCredentials);
}
return (null, false);
}
}
}
}
@@ -4,14 +4,29 @@ using System.Windows.Forms;
namespace EagleInstaller {
public partial class MainForm : Form {
private Panel _loginPanel;
private Panel _progressPanel;
private TextBox _progressTextBox;
private Label _statusLabel;
private Button _changeCredentialsButton;
private Button _exitButton;
public string ServerUrl => CredentialManager.ServerUrl;
// Login controls
private TextBox _usernameTextBox;
private TextBox _passwordTextBox;
private TextBox _serverUrlTextBox;
private CheckBox _saveCredentialsCheckBox;
private Button _loginButton;
private Button _cancelButton;
public MainForm() { InitializeComponent(); }
public Credentials EnteredCredentials { get; private set; }
public bool SaveCredentials { get; private set; }
public bool LoginCancelled { get; private set; }
public MainForm() {
InitializeComponent();
ShowLoginView();
}
private void InitializeComponent() {
// Form properties
@@ -22,15 +37,114 @@ namespace EagleInstaller {
this.MaximizeBox = false;
this.MinimizeBox = true;
// Create login panel
CreateLoginPanel();
// Create progress panel
CreateProgressPanel();
// Add panel to form
// Add panels to form
this.Controls.Add(_loginPanel);
this.Controls.Add(_progressPanel);
}
private void CreateLoginPanel() {
_loginPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(20) };
// Title
var titleLabel = new Label {
Text = "Eagle0 Login",
Font = new Font("Microsoft Sans Serif", 14F, FontStyle.Bold),
Location = new Point(20, 20),
Size = new Size(540, 30),
TextAlign = ContentAlignment.MiddleCenter
};
// Username
var usernameLabel = new Label {
Text = "Username:",
Location = new Point(50, 80),
Size = new Size(100, 23)
};
_usernameTextBox = new TextBox {
Location = new Point(160, 80),
Size = new Size(300, 23),
TabIndex = 0
};
// Password
var passwordLabel = new Label {
Text = "Password:",
Location = new Point(50, 110),
Size = new Size(100, 23)
};
_passwordTextBox = new TextBox {
Location = new Point(160, 110),
Size = new Size(300, 23),
UseSystemPasswordChar = true,
TabIndex = 1
};
// Server
var serverLabel = new Label {
Text = "Server:",
Location = new Point(50, 140),
Size = new Size(100, 23)
};
_serverUrlTextBox = new TextBox {
Location = new Point(160, 140),
Size = new Size(300, 23),
TabIndex = 2
};
// Save credentials checkbox
_saveCredentialsCheckBox = new CheckBox {
Text = "Remember credentials",
Location = new Point(50, 180),
Size = new Size(200, 23),
Checked = true,
TabIndex = 3
};
// Buttons
_loginButton = new Button {
Text = "Login",
Location = new Point(280, 220),
Size = new Size(80, 30),
TabIndex = 4
};
_loginButton.Click += LoginButton_Click;
_cancelButton = new Button {
Text = "Cancel",
Location = new Point(380, 220),
Size = new Size(80, 30),
TabIndex = 5
};
_cancelButton.Click += CancelButton_Click;
// Add controls to login panel
_loginPanel.Controls.AddRange(new Control[] {
titleLabel,
usernameLabel,
_usernameTextBox,
passwordLabel,
_passwordTextBox,
serverLabel,
_serverUrlTextBox,
_saveCredentialsCheckBox,
_loginButton,
_cancelButton
});
// Set form defaults
this.AcceptButton = _loginButton;
this.CancelButton = _cancelButton;
}
private void CreateProgressPanel() {
_progressPanel = new Panel { Dock = DockStyle.Fill, Padding = new Padding(10) };
_progressPanel =
new Panel { Dock = DockStyle.Fill, Padding = new Padding(10), Visible = false };
// Title and status
var titleLabel = new Label {
@@ -50,7 +164,7 @@ namespace EagleInstaller {
// Progress text box with scrollbar
_progressTextBox = new TextBox {
Location = new Point(10, 75),
Size = new Size(560, 310),
Size = new Size(560, 280),
Multiline = true,
ReadOnly = true,
ScrollBars = ScrollBars.Vertical,
@@ -60,17 +174,52 @@ namespace EagleInstaller {
TabIndex = 0
};
// Exit button
// Control buttons
_changeCredentialsButton = new Button {
Text = "Change Credentials",
Location = new Point(350, 370),
Size = new Size(130, 30),
Enabled = false
};
_changeCredentialsButton.Click += ChangeCredentialsButton_Click;
_exitButton = new Button {
Text = "Exit",
Location = new Point(490, 395),
Location = new Point(490, 370),
Size = new Size(80, 30)
};
_exitButton.Click += ExitButton_Click;
// Add controls to progress panel
_progressPanel.Controls.AddRange(
new Control[] { titleLabel, _statusLabel, _progressTextBox, _exitButton });
_progressPanel.Controls.AddRange(new Control[] {
titleLabel,
_statusLabel,
_progressTextBox,
_changeCredentialsButton,
_exitButton
});
}
public void ShowLoginView() {
_loginPanel.Visible = true;
_progressPanel.Visible = false;
this.ActiveControl = _usernameTextBox;
}
public void ShowProgressView() {
_loginPanel.Visible = false;
_progressPanel.Visible = true;
_changeCredentialsButton.Enabled = true;
}
public void SetLoginCredentials(Credentials credentials) {
if (credentials != null) {
_usernameTextBox.Text = credentials.Username ?? "";
_passwordTextBox.Text = credentials.Password ?? "";
_serverUrlTextBox.Text = credentials.ServerUrl ?? "https://eagle0.net";
} else {
_serverUrlTextBox.Text = "https://eagle0.net";
}
}
public void UpdateStatus(string status) {
@@ -92,6 +241,72 @@ namespace EagleInstaller {
_progressTextBox.ScrollToCaret();
}
private void LoginButton_Click(object sender, EventArgs e) {
// Validate input
if (string.IsNullOrWhiteSpace(_usernameTextBox.Text)) {
MessageBox.Show(
"Username is required.",
"Validation Error",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
_usernameTextBox.Focus();
return;
}
if (string.IsNullOrWhiteSpace(_passwordTextBox.Text)) {
MessageBox.Show(
"Password is required.",
"Validation Error",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
_passwordTextBox.Focus();
return;
}
if (string.IsNullOrWhiteSpace(_serverUrlTextBox.Text)) {
MessageBox.Show(
"Server URL is required.",
"Validation Error",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
_serverUrlTextBox.Focus();
return;
}
// Create credentials
var serverUrl = _serverUrlTextBox.Text.Trim();
if (!serverUrl.StartsWith("http://") && !serverUrl.StartsWith("https://")) {
serverUrl = "https://" + serverUrl;
}
EnteredCredentials = new Credentials {
Username = _usernameTextBox.Text.Trim(),
Password = _passwordTextBox.Text,
ServerUrl = serverUrl
};
SaveCredentials = _saveCredentialsCheckBox.Checked;
LoginCancelled = false;
ShowProgressView();
}
private void CancelButton_Click(object sender, EventArgs e) {
LoginCancelled = true;
this.Close();
}
private void ChangeCredentialsButton_Click(object sender, EventArgs e) { ShowLoginView(); }
private void ExitButton_Click(object sender, EventArgs e) { this.Close(); }
public static MainForm CreateMainForm(
string title = "Eagle0 Login",
Credentials existingCredentials = null) {
var form = new MainForm();
form.Text = title;
form.SetLoginCredentials(existingCredentials);
return form;
}
}
}
}
@@ -1,4 +1,4 @@
using System;
using System;
using System.Diagnostics;
using System.Drawing;
using System.IO;
@@ -11,8 +11,6 @@ namespace EagleInstaller {
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"eagle0");
private static readonly string ExeLocation = Path.Combine(FileLocation, "eagle0.exe");
private static readonly string InvitationFilePath =
Path.Combine(FileLocation, "invitation.json");
private static void CleanupTempInstallerFiles() {
try {
@@ -23,34 +21,6 @@ namespace EagleInstaller {
}
}
private static string ExtractInvitationCode(string[] args) {
foreach (string arg in args) {
// Support --code=XXXX format
if (arg.StartsWith("--code=", StringComparison.OrdinalIgnoreCase)) {
return arg.Substring(7);
}
// Support -code=XXXX format
if (arg.StartsWith("-code=", StringComparison.OrdinalIgnoreCase)) {
return arg.Substring(6);
}
}
return null;
}
private static void SaveInvitationCode(string code) {
try {
// Ensure directory exists
Directory.CreateDirectory(FileLocation);
// Write simple JSON with the invitation code
string json = $"{{\"invitation_code\":\"{code}\"}}";
File.WriteAllText(InvitationFilePath, json);
Logger.WriteLine($"Saved invitation code to {InvitationFilePath}");
} catch (Exception ex) {
Logger.WriteError($"Failed to save invitation code: {ex.Message}");
}
}
static async Task Main(string[] args) {
// Enable visual styles for Windows Forms
Application.EnableVisualStyles();
@@ -65,6 +35,14 @@ namespace EagleInstaller {
return;
}
// Check if Shift key is held during launch to force login
bool shiftKeyHeld = (Control.ModifierKeys & Keys.Shift) == Keys.Shift;
// Check for credential management flags
bool forceLogin =
shiftKeyHeld || (args.Length > 0 &&
(args[0] == "--login" || args[0] == "--change-credentials"));
bool clearCredentials = args.Length > 0 && args[0] == "--clear-credentials";
bool showHelp = args.Length > 0 && (args[0] == "--help" || args[0] == "-h");
// Show help if requested
@@ -73,11 +51,16 @@ namespace EagleInstaller {
return;
}
// Extract invitation code from args (if provided)
string invitationCode = ExtractInvitationCode(args);
// Handle clear credentials flag
if (clearCredentials) {
Console.WriteLine("Clearing saved credentials...");
CredentialManager.ClearCredentials();
Console.WriteLine("Credentials cleared successfully.");
return;
}
// Create main form with default server URL
var mainForm = new MainForm();
// Create main form
var mainForm = MainForm.CreateMainForm();
Logger.Initialize(mainForm);
// Show the form and ensure handle is created
@@ -85,7 +68,7 @@ namespace EagleInstaller {
mainForm.CreateControl();
// Start the installer logic in the background
var installerTask = RunInstallerLogic(mainForm, invitationCode);
var installerTask = RunInstallerLogic(mainForm, forceLogin);
// Run the application with the main form
Application.Run(mainForm);
@@ -94,54 +77,84 @@ namespace EagleInstaller {
await installerTask;
}
private static async Task RunInstallerLogic(MainForm mainForm, string invitationCode) {
private static async Task RunInstallerLogic(MainForm mainForm, bool forceLogin) {
try {
string serverUrl = mainForm.ServerUrl;
Logger.WriteLine($"Using server: {serverUrl}");
if (!string.IsNullOrEmpty(invitationCode)) {
Logger.WriteLine("Invitation code provided");
// Get credentials (load saved or prompt user)
Credentials credentials = await GetValidCredentials(mainForm, forceLogin);
if (credentials == null) {
Logger.WriteError("No valid credentials provided. Exiting.");
return;
}
EagleUpdater updater = new EagleUpdater(serverUrl);
// Switch to progress view
SafeInvoke(mainForm, () => mainForm.ShowProgressView());
// Stage 1: Check if installer itself needs update
var installerUpdateInfo = await updater.CheckForInstallerUpdate();
if (installerUpdateInfo.InstallerNeedsUpdate) {
Logger.UpdateStatus("Installer update required...");
bool downloadSuccess = await updater.DownloadAndLaunchNewInstaller(
installerUpdateInfo.NewInstallerUrl);
if (downloadSuccess) {
Logger.UpdateStatus("New installer launched. Exiting current version.");
EagleUpdater updater = new EagleUpdater(credentials);
// Wait a moment to ensure new installer starts before we exit
await Task.Delay(1000);
bool credentialsAreValid = false;
while (!credentialsAreValid) {
try {
// Stage 1: Check if installer itself needs update
var installerUpdateInfo = await updater.CheckForInstallerUpdate();
if (installerUpdateInfo.InstallerNeedsUpdate) {
Logger.UpdateStatus("Installer update required...");
bool downloadSuccess = await updater.DownloadAndLaunchNewInstaller(
installerUpdateInfo.NewInstallerUrl);
if (downloadSuccess) {
Logger.UpdateStatus(
"New installer launched. Exiting current version.");
// Exit the application directly
Application.Exit();
return; // Exit this version
// Wait a moment to ensure new installer starts before we exit
await Task.Delay(1000);
// Exit the application directly
Application.Exit();
return; // Exit this version
}
Logger.WriteError(
"Failed to download new installer, continuing with current version...");
}
// Stage 2: Proceed with normal game update
bool success = await updater.MaybePerformUpdateWithRetries(FileLocation);
if (success) {
Logger.UpdateStatus("Launching Eagle0...");
Process.Start(ExeLocation);
// Exit the installer after launching the game
Application.Exit();
return;
} else {
Logger.WriteError("Update failed!");
credentialsAreValid = true; // Don't retry on non-auth failures
}
} catch (AuthenticationFailedException ex) {
Logger.WriteError($"Authentication failed: {ex.Message}");
// Clear saved credentials since they're invalid
CredentialManager.ClearCredentials();
// Show login view again
SafeInvoke(mainForm, () => mainForm.ShowLoginView());
// Wait for new credentials
credentials = await GetValidCredentials(
mainForm,
true,
"Authentication Failed - Please re-enter credentials");
if (credentials == null) {
Logger.WriteError("No valid credentials provided. Exiting.");
return;
}
// Switch back to progress view
SafeInvoke(mainForm, () => mainForm.ShowProgressView());
// Update updater with new credentials
updater.UpdateCredentials(credentials);
}
Logger.WriteError(
"Failed to download new installer, continuing with current version...");
}
// Stage 2: Proceed with normal game update
bool success = await updater.MaybePerformUpdateWithRetries(FileLocation);
if (success) {
// Save invitation code before launching game (if provided)
if (!string.IsNullOrEmpty(invitationCode)) {
SaveInvitationCode(invitationCode);
}
Logger.UpdateStatus("Launching Eagle0...");
Process.Start(ExeLocation);
// Exit the installer after launching the game
Application.Exit();
} else {
Logger.WriteError("Update failed!");
}
} catch (Exception ex) { Logger.WriteError($"Unexpected error: {ex.Message}"); }
}
@@ -278,6 +291,88 @@ namespace EagleInstaller {
} finally { progressForm?.Close(); }
}
private static async Task<Credentials> GetValidCredentials(
MainForm mainForm,
bool forceLogin = false,
string title = "Eagle0 Login") {
Credentials savedCredentials = null;
// Try to load saved credentials first (unless forced to login)
if (!forceLogin) {
savedCredentials = CredentialManager.LoadCredentials();
if (savedCredentials != null) {
Logger.WriteLine(
$"Found saved credentials for user: {savedCredentials.Username}");
// Return saved credentials immediately for automatic login
return savedCredentials;
}
}
// If no saved credentials or force login, show login form
Logger.WriteLine("No saved credentials found. Please enter your login information.");
// Set the form title and pre-fill credentials if forcing login
SafeInvoke(mainForm, () => {
mainForm.Text = title;
if (forceLogin && savedCredentials != null) {
mainForm.SetLoginCredentials(savedCredentials);
}
});
// Wait for user to enter credentials through the form
return await WaitForCredentials(mainForm);
}
private static async Task<Credentials> WaitForCredentials(MainForm mainForm) {
var tcs = new TaskCompletionSource<Credentials>();
// Set up event handler to capture when login is completed
EventHandler loginCompleted = null;
loginCompleted = (sender, e) => {
var form = sender as MainForm;
if (form is { EnteredCredentials : not null }) {
var credentials = form.EnteredCredentials;
var saveCredentials = form.SaveCredentials;
if (saveCredentials) {
try {
CredentialManager.SaveCredentials(credentials);
CredentialManager.SaveCredentialsForUnityClient(credentials);
Logger.WriteLine("Credentials saved successfully.");
} catch (Exception ex) {
Logger.WriteError($"Failed to save credentials: {ex.Message}");
}
}
tcs.SetResult(credentials);
} else if (form != null && form.LoginCancelled) {
tcs.SetResult(null);
}
};
// Monitor for form changes (this is a simplified approach)
var monitorTask = Task.Run(async () => {
while (!tcs.Task.IsCompleted) {
await Task.Delay(100);
if (mainForm.EnteredCredentials != null || mainForm.LoginCancelled) {
loginCompleted(mainForm, EventArgs.Empty);
break;
}
}
});
return await tcs.Task;
}
private static void SafeInvoke(Control control, Action action) {
if (control.IsHandleCreated && control.InvokeRequired) {
control.Invoke(action);
} else if (control.IsHandleCreated) {
action();
}
}
private static void ShowHelp() {
Console.WriteLine("Eagle0 Installer");
Console.WriteLine();
@@ -285,12 +380,19 @@ namespace EagleInstaller {
Console.WriteLine(" EagleInstaller.exe [OPTIONS]");
Console.WriteLine();
Console.WriteLine("OPTIONS:");
Console.WriteLine(" --help, -h Show this help message");
Console.WriteLine(" --code=CODE Invitation code for new account registration");
Console.WriteLine(" --help, -h Show this help message");
Console.WriteLine(" --login, --change-credentials Force credential input dialog");
Console.WriteLine(" --clear-credentials Clear saved credentials and exit");
Console.WriteLine();
Console.WriteLine("NORMAL OPERATION:");
Console.WriteLine(" Run without arguments to start the normal update process.");
Console.WriteLine(" Downloads game files from assets.eagle0.net CDN.");
Console.WriteLine(" If credentials are saved, automatic login will occur.");
Console.WriteLine(" Hold Shift during launch to force credential input dialog.");
Console.WriteLine();
Console.WriteLine("EXAMPLES:");
Console.WriteLine(" EagleInstaller.exe # Normal operation");
Console.WriteLine(" EagleInstaller.exe --login # Force new login");
Console.WriteLine(" EagleInstaller.exe --clear-credentials # Clear saved login");
}
}
}
@@ -1,3 +1,3 @@
manifest_name = eagle0_manifest.txt
remote_manifest_prefix = installer/
remote_asset_prefix = unity3d/win/
remote_manifest_prefix = assets/installer/
remote_asset_prefix = assets/unity3d/win/
@@ -65,18 +65,16 @@ var (
grpcClient eagle.EagleClient
adminClient adminpb.AdminClient
authClient authpb.AuthClient
gamesTemplate *template.Template
gameDetailTemplate *template.Template
historyRowsTemplate *template.Template
actionDetailTemplate *template.Template
gameStateTemplate *template.Template
settingsTemplate *template.Template
settingsRowsTemplate *template.Template
usersTemplate *template.Template
usersRowsTemplate *template.Template
invitationsTemplate *template.Template
invitationsRowsTemplate *template.Template
loginTemplate *template.Template
gamesTemplate *template.Template
gameDetailTemplate *template.Template
historyRowsTemplate *template.Template
actionDetailTemplate *template.Template
gameStateTemplate *template.Template
settingsTemplate *template.Template
settingsRowsTemplate *template.Template
usersTemplate *template.Template
usersRowsTemplate *template.Template
loginTemplate *template.Template
)
func main() {
@@ -129,16 +127,6 @@ func main() {
log.Fatalf("Failed to parse users rows template: %v", err)
}
invitationsTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/invitations.html")
if err != nil {
log.Fatalf("Failed to parse invitations template: %v", err)
}
invitationsRowsTemplate, err = template.ParseFS(templatesFS, "templates/invitations_rows.html")
if err != nil {
log.Fatalf("Failed to parse invitations rows template: %v", err)
}
loginTemplate, err = template.ParseFS(templatesFS, "templates/layout.html", "templates/login.html")
if err != nil {
log.Fatalf("Failed to parse login template: %v", err)
@@ -197,8 +185,6 @@ func main() {
http.HandleFunc("/settings/", requireAuth(handleSettingsRoutes))
http.HandleFunc("/users", requireAuth(handleUsersPage))
http.HandleFunc("/users/", requireAuth(handleUserRoutes))
http.HandleFunc("/invitations", requireAuth(handleInvitationsPage))
http.HandleFunc("/invitations/", requireAuth(handleInvitationRoutes))
http.HandleFunc("/login", handleLoginPage)
http.HandleFunc("/login/start", handleLoginStart)
http.HandleFunc("/login/complete", handleLoginComplete)
@@ -1818,329 +1804,6 @@ func handleSetUserAdmin(w http.ResponseWriter, r *http.Request, userID string) {
fmt.Fprintf(w, `<div class="alert alert-success">Admin status %s</div>`, status)
}
// Invitation management handlers
// InvitationInfo represents an invitation for display
type InvitationInfo struct {
InvitationCode string
InvitationCodeShort string
Email string
StatusName string
CreatedByUserID string
CreatedByDisplayName string
CreatedAt string
ExpiresAt string
RedeemedByUserID string
RedeemedByDisplayName string
}
// InvitationsPageData is the data for the invitations page
type InvitationsPageData struct {
Title string
Invitations []InvitationInfo
EmailFilter string
StatusFilter int
TotalCount int
BuildInfo BuildInfo
}
// InvitationsRowsData is the data for the invitations rows partial
type InvitationsRowsData struct {
Invitations []InvitationInfo
EmailFilter string
StatusFilter int
HasMore bool
NextOffset int
}
func statusToName(status adminpb.InvitationStatus) string {
switch status {
case adminpb.InvitationStatus_INVITATION_STATUS_PENDING:
return "pending"
case adminpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
return "redeemed"
case adminpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
return "expired"
case adminpb.InvitationStatus_INVITATION_STATUS_REVOKED:
return "revoked"
default:
return "unknown"
}
}
func convertInvitationInfo(inv *adminpb.InvitationInfo) InvitationInfo {
createdAt := ""
if inv.CreatedAt != nil {
createdAt = inv.CreatedAt.AsTime().Format("2006-01-02 15:04")
}
expiresAt := ""
if inv.ExpiresAt != nil {
expiresAt = inv.ExpiresAt.AsTime().Format("2006-01-02 15:04")
}
shortCode := inv.InvitationCode
if len(shortCode) > 8 {
shortCode = shortCode[:8]
}
return InvitationInfo{
InvitationCode: inv.InvitationCode,
InvitationCodeShort: shortCode,
Email: inv.Email,
StatusName: statusToName(inv.Status),
CreatedByUserID: inv.CreatedByUserId,
CreatedByDisplayName: inv.CreatedByDisplayName,
CreatedAt: createdAt,
ExpiresAt: expiresAt,
RedeemedByUserID: inv.RedeemedByUserId,
RedeemedByDisplayName: inv.RedeemedByDisplayName,
}
}
func handleInvitationsPage(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
emailFilter := r.URL.Query().Get("filter")
statusFilter := 0
if s := r.URL.Query().Get("status_filter"); s != "" {
if v, err := strconv.Atoi(s); err == nil {
statusFilter = v
}
}
ctx, cancel := createAdminContext(r)
defer cancel()
resp, err := adminClient.ListInvitations(ctx, &adminpb.ListInvitationsRequest{
EmailFilter: emailFilter,
StatusFilter: adminpb.InvitationStatus(statusFilter),
Offset: 0,
Limit: 50,
})
if err != nil {
log.Printf("Failed to list invitations: %v", err)
http.Error(w, fmt.Sprintf("Failed to list invitations: %v", err), http.StatusInternalServerError)
return
}
var invitations []InvitationInfo
for _, inv := range resp.Invitations {
invitations = append(invitations, convertInvitationInfo(inv))
}
data := InvitationsPageData{
Title: "Invitations",
Invitations: invitations,
EmailFilter: emailFilter,
StatusFilter: statusFilter,
TotalCount: int(resp.TotalCount),
BuildInfo: getBuildInfo(),
}
w.Header().Set("Content-Type", "text/html")
if err := invitationsTemplate.ExecuteTemplate(w, "layout.html", data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
func handleInvitationRoutes(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/invitations/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
http.Redirect(w, r, "/invitations", http.StatusFound)
return
}
switch parts[0] {
case "search":
handleInvitationsSearch(w, r)
case "create":
handleCreateInvitation(w, r)
default:
// Assume it's an invitation code with an action
if len(parts) >= 2 {
code := parts[0]
action := parts[1]
switch action {
case "resend":
handleResendInvitation(w, r, code)
case "revoke":
handleRevokeInvitation(w, r, code)
default:
http.NotFound(w, r)
}
} else {
http.NotFound(w, r)
}
}
}
func handleInvitationsSearch(w http.ResponseWriter, r *http.Request) {
emailFilter := r.URL.Query().Get("filter")
statusFilter := 0
if s := r.URL.Query().Get("status_filter"); s != "" {
if v, err := strconv.Atoi(s); err == nil {
statusFilter = v
}
}
offset := 0
if o := r.URL.Query().Get("offset"); o != "" {
if v, err := strconv.Atoi(o); err == nil {
offset = v
}
}
ctx, cancel := createAdminContext(r)
defer cancel()
resp, err := adminClient.ListInvitations(ctx, &adminpb.ListInvitationsRequest{
EmailFilter: emailFilter,
StatusFilter: adminpb.InvitationStatus(statusFilter),
Offset: int32(offset),
Limit: 50,
})
if err != nil {
log.Printf("Failed to list invitations: %v", err)
http.Error(w, fmt.Sprintf("Failed to list invitations: %v", err), http.StatusInternalServerError)
return
}
var invitations []InvitationInfo
for _, inv := range resp.Invitations {
invitations = append(invitations, convertInvitationInfo(inv))
}
nextOffset := offset + len(invitations)
hasMore := nextOffset < int(resp.TotalCount)
data := InvitationsRowsData{
Invitations: invitations,
EmailFilter: emailFilter,
StatusFilter: statusFilter,
HasMore: hasMore,
NextOffset: nextOffset,
}
w.Header().Set("Content-Type", "text/html")
if err := invitationsRowsTemplate.Execute(w, data); err != nil {
log.Printf("Template error: %v", err)
http.Error(w, "Template error", http.StatusInternalServerError)
}
}
func handleCreateInvitation(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
if err := r.ParseForm(); err != nil {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">Invalid form data: %v</div>`, err)
return
}
email := r.FormValue("email")
expiresInDays := int32(30)
if e := r.FormValue("expires_in_days"); e != "" {
if v, err := strconv.Atoi(e); err == nil {
expiresInDays = int32(v)
}
}
if email == "" {
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<div class="alert alert-error">Email address is required</div>`)
return
}
ctx, cancel := createAdminContext(r)
defer cancel()
resp, err := adminClient.CreateInvitation(ctx, &adminpb.CreateInvitationRequest{
Email: email,
ExpiresInDays: expiresInDays,
})
if err != nil {
log.Printf("Failed to create invitation: %v", err)
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">Error: %v</div>`, err)
return
}
if !resp.Success {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, resp.ErrorMessage)
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-success">Invitation created and sent to %s</div>`, email)
}
func handleResendInvitation(w http.ResponseWriter, r *http.Request, code string) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := createAdminContext(r)
defer cancel()
resp, err := adminClient.ResendInvitation(ctx, &adminpb.ResendInvitationRequest{
InvitationCode: code,
})
if err != nil {
log.Printf("Failed to resend invitation: %v", err)
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">Error: %v</div>`, err)
return
}
if !resp.Success {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, resp.ErrorMessage)
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<div class="alert alert-success">Invitation email resent</div>`)
}
func handleRevokeInvitation(w http.ResponseWriter, r *http.Request, code string) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
ctx, cancel := createAdminContext(r)
defer cancel()
resp, err := adminClient.RevokeInvitation(ctx, &adminpb.RevokeInvitationRequest{
InvitationCode: code,
})
if err != nil {
log.Printf("Failed to revoke invitation: %v", err)
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">Error: %v</div>`, err)
return
}
if !resp.Success {
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, resp.ErrorMessage)
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprint(w, `<div class="alert alert-success">Invitation revoked</div>`)
}
// Authentication handlers
const (
@@ -1,302 +0,0 @@
{{define "content"}}
<div class="page-header">
<h1>Invitations</h1>
<span>{{.TotalCount}} invitation{{if ne .TotalCount 1}}s{{end}}</span>
</div>
<div class="invitation-controls">
<div class="search-controls">
<input type="text"
name="filter"
placeholder="Search by email..."
value="{{.EmailFilter}}"
hx-get="/invitations/search"
hx-trigger="input changed delay:300ms, keyup[key=='Enter']"
hx-target="#invitations-table-body"
hx-swap="innerHTML"
hx-include="[name='status_filter']"
class="search-input">
<select name="status_filter"
hx-get="/invitations/search"
hx-trigger="change"
hx-target="#invitations-table-body"
hx-swap="innerHTML"
hx-include="[name='filter']"
class="status-filter">
<option value="0" {{if eq .StatusFilter 0}}selected{{end}}>All Status</option>
<option value="1" {{if eq .StatusFilter 1}}selected{{end}}>Pending</option>
<option value="2" {{if eq .StatusFilter 2}}selected{{end}}>Redeemed</option>
<option value="3" {{if eq .StatusFilter 3}}selected{{end}}>Expired</option>
<option value="4" {{if eq .StatusFilter 4}}selected{{end}}>Revoked</option>
</select>
</div>
<button class="btn-primary" onclick="openCreateModal()">Create Invitation</button>
</div>
<div id="feedback" class="feedback-area"></div>
{{if .Invitations}}
<table class="invitations-table">
<thead>
<tr>
<th>Email</th>
<th>Status</th>
<th>Created By</th>
<th>Created</th>
<th>Expires</th>
<th>Redeemed By</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="invitations-table-body" hx-trigger="invitationUpdated from:body" hx-get="/invitations/search?filter={{.EmailFilter}}&status_filter={{.StatusFilter}}" hx-swap="innerHTML">
{{range .Invitations}}
<tr class="invitation-row status-{{.StatusName}}" id="invitation-{{.InvitationCodeShort}}">
<td class="invitation-email">{{.Email}}</td>
<td class="invitation-status">
<span class="status-badge {{.StatusName}}">{{.StatusName}}</span>
</td>
<td class="invitation-created-by">
{{if .CreatedByDisplayName}}{{.CreatedByDisplayName}}{{else}}{{.CreatedByUserID}}{{end}}
</td>
<td class="invitation-created">{{.CreatedAt}}</td>
<td class="invitation-expires">{{.ExpiresAt}}</td>
<td class="invitation-redeemed-by">
{{if .RedeemedByDisplayName}}{{.RedeemedByDisplayName}}{{else if .RedeemedByUserID}}{{.RedeemedByUserID}}{{else}}-{{end}}
</td>
<td class="invitation-actions">
{{if eq .StatusName "pending"}}
<button class="btn-small"
hx-post="/invitations/{{.InvitationCode}}/resend"
hx-target="#feedback"
hx-swap="innerHTML">
Resend
</button>
<button class="btn-small btn-danger"
hx-post="/invitations/{{.InvitationCode}}/revoke"
hx-target="#feedback"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to revoke this invitation?">
Revoke
</button>
<button class="btn-small btn-secondary"
onclick="copyInvitationCode('{{.InvitationCode}}')">
Copy Code
</button>
{{else}}
<span class="no-actions">-</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-state">
<p>No invitations found.</p>
</div>
{{end}}
<!-- Create Invitation Modal -->
<dialog id="create-modal">
<article>
<header>
<h3>Create Invitation</h3>
<button aria-label="Close" class="close" onclick="document.getElementById('create-modal').close()"></button>
</header>
<form id="create-form" hx-post="/invitations/create" hx-target="#create-feedback" hx-swap="innerHTML">
<label for="create-email">Email Address</label>
<input type="email" id="create-email" name="email" placeholder="user@example.com" required>
<label for="create-expires">Expires In (days)</label>
<input type="number" id="create-expires" name="expires_in_days" value="30" min="1" max="365">
<small>Default: 30 days</small>
<div id="create-feedback"></div>
</form>
<footer>
<button type="button" class="secondary" onclick="document.getElementById('create-modal').close()">Cancel</button>
<button type="submit" form="create-form">Create & Send</button>
</footer>
</article>
</dialog>
<script>
function openCreateModal() {
document.getElementById('create-email').value = '';
document.getElementById('create-expires').value = '30';
document.getElementById('create-feedback').innerHTML = '';
document.getElementById('create-modal').showModal();
}
function copyInvitationCode(code) {
navigator.clipboard.writeText(code).then(() => {
document.getElementById('feedback').innerHTML = '<div class="alert alert-success">Invitation code copied to clipboard</div>';
setTimeout(() => {
document.getElementById('feedback').innerHTML = '';
}, 3000);
}).catch(err => {
console.error('Failed to copy:', err);
document.getElementById('feedback').innerHTML = '<div class="alert alert-error">Failed to copy code</div>';
});
}
// Handle successful invitation creation
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (evt.detail.pathInfo.requestPath === '/invitations/create' && evt.detail.successful) {
const response = evt.detail.xhr.responseText;
if (response.includes('alert-success')) {
// Close modal and refresh table
setTimeout(() => {
document.getElementById('create-modal').close();
htmx.trigger(document.getElementById('invitations-table-body'), 'invitationUpdated');
}, 1500);
}
} else if (evt.detail.pathInfo.requestPath.includes('/revoke') ||
evt.detail.pathInfo.requestPath.includes('/resend')) {
if (evt.detail.successful) {
htmx.trigger(document.getElementById('invitations-table-body'), 'invitationUpdated');
}
}
});
</script>
<style>
.invitation-controls {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
gap: 1rem;
}
.search-controls {
display: flex;
gap: 0.5rem;
flex: 1;
}
.search-input {
max-width: 300px;
}
.status-filter {
max-width: 150px;
}
.invitations-table {
width: 100%;
border-collapse: collapse;
}
.invitations-table th, .invitations-table td {
padding: 0.5rem;
text-align: left;
border-bottom: 1px solid var(--pico-muted-border-color);
}
.status-badge {
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: bold;
text-transform: uppercase;
}
.status-badge.pending {
background-color: #ffc107;
color: #000;
}
.status-badge.redeemed {
background-color: #28a745;
color: white;
}
.status-badge.expired {
background-color: #6c757d;
color: white;
}
.status-badge.revoked {
background-color: #dc3545;
color: white;
}
.invitation-row.status-redeemed,
.invitation-row.status-expired,
.invitation-row.status-revoked {
opacity: 0.7;
}
.invitation-actions {
display: flex;
gap: 0.25rem;
}
.btn-small {
padding: 0.25rem 0.5rem;
font-size: 0.8rem;
}
.btn-danger {
background-color: #dc3545;
border-color: #dc3545;
}
.btn-danger:hover {
background-color: #c82333;
border-color: #bd2130;
}
.btn-secondary {
background-color: #6c757d;
border-color: #6c757d;
}
.btn-primary {
background-color: #007bff;
border-color: #007bff;
color: white;
padding: 0.5rem 1rem;
cursor: pointer;
border-radius: 4px;
}
.no-actions {
color: var(--pico-muted-color);
}
.feedback-area {
margin-bottom: 1rem;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 4px;
margin-bottom: 0.5rem;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.alert-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
#create-modal article {
max-width: 500px;
}
#create-modal small {
display: block;
margin-top: -0.5rem;
margin-bottom: 1rem;
color: var(--pico-muted-color);
}
</style>
{{end}}
@@ -1,55 +0,0 @@
{{range .Invitations}}
<tr class="invitation-row status-{{.StatusName}}" id="invitation-{{.InvitationCodeShort}}">
<td class="invitation-email">{{.Email}}</td>
<td class="invitation-status">
<span class="status-badge {{.StatusName}}">{{.StatusName}}</span>
</td>
<td class="invitation-created-by">
{{if .CreatedByDisplayName}}{{.CreatedByDisplayName}}{{else}}{{.CreatedByUserID}}{{end}}
</td>
<td class="invitation-created">{{.CreatedAt}}</td>
<td class="invitation-expires">{{.ExpiresAt}}</td>
<td class="invitation-redeemed-by">
{{if .RedeemedByDisplayName}}{{.RedeemedByDisplayName}}{{else if .RedeemedByUserID}}{{.RedeemedByUserID}}{{else}}-{{end}}
</td>
<td class="invitation-actions">
{{if eq .StatusName "pending"}}
<button class="btn-small"
hx-post="/invitations/{{.InvitationCode}}/resend"
hx-target="#feedback"
hx-swap="innerHTML">
Resend
</button>
<button class="btn-small btn-danger"
hx-post="/invitations/{{.InvitationCode}}/revoke"
hx-target="#feedback"
hx-swap="innerHTML"
hx-confirm="Are you sure you want to revoke this invitation?">
Revoke
</button>
<button class="btn-small btn-secondary"
onclick="copyInvitationCode('{{.InvitationCode}}')">
Copy Code
</button>
{{else}}
<span class="no-actions">-</span>
{{end}}
</td>
</tr>
{{else}}
<tr>
<td colspan="7" class="empty-state">No invitations found.</td>
</tr>
{{end}}
{{if .HasMore}}
<tr>
<td colspan="7" class="load-more">
<button hx-get="/invitations/search?filter={{.EmailFilter}}&status_filter={{.StatusFilter}}&offset={{.NextOffset}}"
hx-target="closest tr"
hx-swap="outerHTML"
class="btn-small">
Load More
</button>
</td>
</tr>
{{end}}
@@ -14,7 +14,6 @@
<li class="brand">Eagle Admin <span class="build-info">{{if .BuildInfo.GitCommit}}({{.BuildInfo.GitCommit}}{{if .BuildInfo.BuildTime}}, {{.BuildInfo.BuildTime}}{{end}}){{end}}</span></li>
<li><a href="/">Games</a></li>
<li><a href="/users">Users</a></li>
<li><a href="/invitations">Invitations</a></li>
<li><a href="/settings">Settings</a></li>
<li><a href="/health">Health</a></li>
<li id="jfr-controls" hx-get="/jfr/status" hx-trigger="load" hx-swap="innerHTML">
@@ -5,12 +5,9 @@ go_library(
srcs = [
"admin_handlers.go",
"handlers.go",
"invitation_handlers.go",
"invitations.go",
"jwt.go",
"main.go",
"oauth.go",
"sendgrid.go",
"users.go",
],
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/authservice",
@@ -16,17 +16,13 @@ import (
// AdminHandler implements the Admin gRPC service
type AdminHandler struct {
adminpb.UnimplementedAdminServer
userService *UserService
invitationService *InvitationService
emailService *EmailService
userService *UserService
}
// NewAdminHandler creates a new AdminHandler
func NewAdminHandler(userService *UserService, invitationService *InvitationService, emailService *EmailService) *AdminHandler {
func NewAdminHandler(userService *UserService) *AdminHandler {
return &AdminHandler{
userService: userService,
invitationService: invitationService,
emailService: emailService,
userService: userService,
}
}
@@ -171,182 +167,3 @@ func (h *AdminHandler) userToAdminInfo(user *userpb.User) *adminpb.AdminUserInfo
return info
}
// ============== Invitation Management ==============
// CreateInvitation creates a new invitation and sends email
func (h *AdminHandler) CreateInvitation(ctx context.Context, req *adminpb.CreateInvitationRequest) (*adminpb.CreateInvitationResponse, error) {
adminUserID, err := h.requireAdmin(ctx)
if err != nil {
return nil, err
}
log.Printf("[Admin] CreateInvitation called by %s for email=%s", adminUserID, req.Email)
// Create invitation
invitation, err := h.invitationService.CreateInvitation(req.Email, adminUserID, int(req.ExpiresInDays))
if err != nil {
return &adminpb.CreateInvitationResponse{
Success: false,
ErrorMessage: err.Error(),
}, nil
}
// Send invitation email
if err := h.emailService.SendInvitation(invitation); err != nil {
log.Printf("[Admin] Failed to send invitation email: %v", err)
// Don't fail the request - invitation was created, just email failed
// We can add a flag to indicate email send status if needed
}
return &adminpb.CreateInvitationResponse{
Success: true,
Invitation: h.invitationToInfo(invitation),
}, nil
}
// ListInvitations returns a paginated list of invitations
func (h *AdminHandler) ListInvitations(ctx context.Context, req *adminpb.ListInvitationsRequest) (*adminpb.ListInvitationsResponse, error) {
adminUserID, err := h.requireAdmin(ctx)
if err != nil {
return nil, err
}
log.Printf("[Admin] ListInvitations called by %s, status=%v, email=%q, offset=%d, limit=%d",
adminUserID, req.StatusFilter, req.EmailFilter, req.Offset, req.Limit)
// Convert admin proto status to internal proto status
internalStatus := h.adminStatusToInternal(req.StatusFilter)
invitations, totalCount := h.invitationService.ListInvitations(
internalStatus,
req.EmailFilter,
int(req.Offset),
int(req.Limit),
)
var result []*adminpb.InvitationInfo
for _, inv := range invitations {
result = append(result, h.invitationToInfo(inv))
}
return &adminpb.ListInvitationsResponse{
Invitations: result,
TotalCount: int32(totalCount),
}, nil
}
// RevokeInvitation revokes an invitation
func (h *AdminHandler) RevokeInvitation(ctx context.Context, req *adminpb.RevokeInvitationRequest) (*adminpb.RevokeInvitationResponse, error) {
adminUserID, err := h.requireAdmin(ctx)
if err != nil {
return nil, err
}
log.Printf("[Admin] RevokeInvitation called by %s for code=%s", adminUserID, req.InvitationCode[:8]+"...")
if err := h.invitationService.Revoke(req.InvitationCode); err != nil {
return &adminpb.RevokeInvitationResponse{
Success: false,
ErrorMessage: err.Error(),
}, nil
}
return &adminpb.RevokeInvitationResponse{
Success: true,
}, nil
}
// ResendInvitation resends the invitation email
func (h *AdminHandler) ResendInvitation(ctx context.Context, req *adminpb.ResendInvitationRequest) (*adminpb.ResendInvitationResponse, error) {
adminUserID, err := h.requireAdmin(ctx)
if err != nil {
return nil, err
}
log.Printf("[Admin] ResendInvitation called by %s for code=%s", adminUserID, req.InvitationCode[:8]+"...")
// Find the invitation
invitation := h.invitationService.FindByCode(req.InvitationCode)
if invitation == nil {
return &adminpb.ResendInvitationResponse{
Success: false,
ErrorMessage: "invitation not found",
}, nil
}
// Check if invitation is still pending
if !h.invitationService.IsValidCode(req.InvitationCode) {
return &adminpb.ResendInvitationResponse{
Success: false,
ErrorMessage: "invitation is no longer valid (expired, redeemed, or revoked)",
}, nil
}
// Resend email
if err := h.emailService.SendInvitation(invitation); err != nil {
return &adminpb.ResendInvitationResponse{
Success: false,
ErrorMessage: "failed to send email: " + err.Error(),
}, nil
}
return &adminpb.ResendInvitationResponse{
Success: true,
}, nil
}
// invitationToInfo converts an internal Invitation to admin InvitationInfo
func (h *AdminHandler) invitationToInfo(inv *userpb.Invitation) *adminpb.InvitationInfo {
info := &adminpb.InvitationInfo{
InvitationCode: inv.GetInvitationCode(),
Email: inv.GetEmail(),
CreatedByUserId: inv.GetCreatedByUserId(),
CreatedAt: inv.GetCreatedAt(),
ExpiresAt: inv.GetExpiresAt(),
Status: h.internalStatusToAdmin(inv.GetStatus()),
RedeemedByUserId: inv.GetRedeemedByUserId(),
RedeemedAt: inv.GetRedeemedAt(),
}
// Resolve display names
if inv.GetCreatedByUserId() != "" {
if user := h.userService.FindByUserID(inv.GetCreatedByUserId()); user != nil {
info.CreatedByDisplayName = user.GetDisplayName()
}
}
if inv.GetRedeemedByUserId() != "" {
if user := h.userService.FindByUserID(inv.GetRedeemedByUserId()); user != nil {
info.RedeemedByDisplayName = user.GetDisplayName()
}
}
return info
}
// Convert between admin and internal proto status enums
func (h *AdminHandler) adminStatusToInternal(status adminpb.InvitationStatus) userpb.InvitationStatus {
switch status {
case adminpb.InvitationStatus_INVITATION_STATUS_PENDING:
return userpb.InvitationStatus_INVITATION_STATUS_PENDING
case adminpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
return userpb.InvitationStatus_INVITATION_STATUS_REDEEMED
case adminpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
return userpb.InvitationStatus_INVITATION_STATUS_EXPIRED
case adminpb.InvitationStatus_INVITATION_STATUS_REVOKED:
return userpb.InvitationStatus_INVITATION_STATUS_REVOKED
default:
return userpb.InvitationStatus_INVITATION_STATUS_UNSPECIFIED
}
}
func (h *AdminHandler) internalStatusToAdmin(status userpb.InvitationStatus) adminpb.InvitationStatus {
switch status {
case userpb.InvitationStatus_INVITATION_STATUS_PENDING:
return adminpb.InvitationStatus_INVITATION_STATUS_PENDING
case userpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
return adminpb.InvitationStatus_INVITATION_STATUS_REDEEMED
case userpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
return adminpb.InvitationStatus_INVITATION_STATUS_EXPIRED
case userpb.InvitationStatus_INVITATION_STATUS_REVOKED:
return adminpb.InvitationStatus_INVITATION_STATUS_REVOKED
default:
return adminpb.InvitationStatus_INVITATION_STATUS_UNSPECIFIED
}
}
+8 -54
View File
@@ -3,7 +3,6 @@ package main
import (
"context"
"log"
"os"
"strings"
"time"
@@ -13,13 +12,6 @@ import (
"google.golang.org/grpc/status"
)
// isInvitationRequired returns true if new users must provide invitation codes.
// Controlled by REQUIRE_INVITATION_CODE env var (defaults to false).
func isInvitationRequired() bool {
val := os.Getenv("REQUIRE_INVITATION_CODE")
return val == "true" || val == "1"
}
// extractAccessToken extracts the JWT access token from gRPC metadata
func extractAccessToken(ctx context.Context) (string, error) {
md, ok := metadata.FromIncomingContext(ctx)
@@ -49,17 +41,15 @@ func extractAccessToken(ctx context.Context) (string, error) {
// AuthHandler implements the Auth gRPC service
type AuthHandler struct {
auth.UnimplementedAuthServer
oauthSvc *OAuthService
userService *UserService
invitationService *InvitationService
oauthSvc *OAuthService
userService *UserService
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(oauthSvc *OAuthService, userService *UserService, invitationService *InvitationService) *AuthHandler {
func NewAuthHandler(oauthSvc *OAuthService, userService *UserService) *AuthHandler {
return &AuthHandler{
oauthSvc: oauthSvc,
userService: userService,
invitationService: invitationService,
oauthSvc: oauthSvc,
userService: userService,
}
}
@@ -67,7 +57,7 @@ func NewAuthHandler(oauthSvc *OAuthService, userService *UserService, invitation
func (h *AuthHandler) GetOAuthUrl(ctx context.Context, req *auth.GetOAuthUrlRequest) (*auth.GetOAuthUrlResponse, error) {
provider := providerToString(req.Provider)
authURL, state, err := h.oauthSvc.GetAuthURL(provider, req.ReturnUrl, req.InvitationCode)
authURL, state, err := h.oauthSvc.GetAuthURL(provider, req.ReturnUrl)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to get auth URL: %v", err)
}
@@ -104,49 +94,13 @@ func (h *AuthHandler) CheckOAuthStatus(ctx context.Context, req *auth.CheckOAuth
// Success - find or create user locally
if result.Success {
// Check if user already exists (for grandfathering)
existingUser := h.userService.FindByOAuthIdentity(result.Provider, result.UserInfo.ID)
isNewUser := existingUser == nil
// For new users, validate invitation code (if feature is enabled)
if isNewUser && isInvitationRequired() {
invitationCode := result.InvitationCode
// If no invitation code provided, require one
if invitationCode == "" {
log.Printf("New user %s attempted login without invitation code", result.UserInfo.Email)
return &auth.CheckOAuthStatusResponse{
Status: auth.OAuthStatus_OAUTH_STATUS_INVITATION_REQUIRED,
ErrorMessage: "An invitation code is required to create a new account",
}, nil
}
// Validate invitation code (don't redeem yet - we'll do that after user creation succeeds)
if !h.invitationService.IsValidCode(invitationCode) {
log.Printf("New user %s provided invalid invitation code", result.UserInfo.Email)
return &auth.CheckOAuthStatusResponse{
Status: auth.OAuthStatus_OAUTH_STATUS_FAILED,
ErrorMessage: "Invalid or expired invitation code",
}, nil
}
}
// Find or create user
user, wasCreated := h.userService.FindOrCreateUser(
user, isNewUser := h.userService.FindOrCreateUser(
result.Provider,
result.UserInfo.ID,
result.UserInfo.Email,
result.UserInfo.AvatarURL,
)
// If user was created (new user), redeem the invitation
if wasCreated && result.InvitationCode != "" {
if _, err := h.invitationService.ValidateAndRedeem(result.InvitationCode, user.UserId); err != nil {
// Log the error but don't fail - user is already created
log.Printf("Warning: failed to redeem invitation code for new user %s: %v", user.UserId, err)
}
}
// Create JWT tokens
accessToken, err := CreateAccessToken(user.UserId, user.DisplayName, user.IsAdmin)
if err != nil {
@@ -180,7 +134,7 @@ func (h *AuthHandler) CheckOAuthStatus(ctx context.Context, req *auth.CheckOAuth
AvatarUrl: user.AvatarUrl,
Provider: stringToProvider(result.Provider),
},
IsNewUser: wasCreated,
IsNewUser: isNewUser,
}, nil
}
@@ -1,334 +0,0 @@
// Package main provides HTTP handlers for invitation landing pages.
package main
import (
"fmt"
"html/template"
"log"
"net/http"
"os"
"strings"
"time"
userpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal/user"
)
// InvitationHTTPHandler handles HTTP requests for invitation pages
type InvitationHTTPHandler struct {
invitationService *InvitationService
installerURL string
}
// NewInvitationHTTPHandler creates a new invitation HTTP handler
func NewInvitationHTTPHandler(invitationService *InvitationService) *InvitationHTTPHandler {
installerURL := os.Getenv("INSTALLER_DOWNLOAD_URL")
if installerURL == "" {
installerURL = "https://assets.eagle0.net/installer/EagleInstaller.exe"
}
return &InvitationHTTPHandler{
invitationService: invitationService,
installerURL: installerURL,
}
}
// RegisterRoutes registers the invitation HTTP routes
func (h *InvitationHTTPHandler) RegisterRoutes() {
http.HandleFunc("/invite/", h.handleInvite)
}
// handleInvite routes requests to the appropriate handler
func (h *InvitationHTTPHandler) handleInvite(w http.ResponseWriter, r *http.Request) {
// Parse path: /invite/{code} or /invite/{code}/install.bat
path := strings.TrimPrefix(r.URL.Path, "/invite/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
http.Error(w, "Invalid invitation URL", http.StatusBadRequest)
return
}
code := parts[0]
if len(parts) == 2 && parts[1] == "install.bat" {
h.handleInstallBat(w, r, code)
} else if len(parts) == 1 {
h.handleLandingPage(w, r, code)
} else {
http.Error(w, "Not found", http.StatusNotFound)
}
}
// handleLandingPage serves the invitation landing page
func (h *InvitationHTTPHandler) handleLandingPage(w http.ResponseWriter, r *http.Request, code string) {
invitation := h.invitationService.FindByCode(code)
data := struct {
Valid bool
Code string
ExpiresAt string
ErrorMessage string
InstallBatURL string
InstallerURL string
}{
Code: code,
InstallBatURL: fmt.Sprintf("/invite/%s/install.bat", code),
InstallerURL: h.installerURL,
}
if invitation == nil {
data.Valid = false
data.ErrorMessage = "This invitation code is invalid or does not exist."
} else {
switch invitation.Status {
case userpb.InvitationStatus_INVITATION_STATUS_PENDING:
// Check if expired by date
if invitation.ExpiresAt != nil && invitation.ExpiresAt.AsTime().Before(time.Now()) {
data.Valid = false
data.ErrorMessage = "This invitation has expired."
} else {
data.Valid = true
if invitation.ExpiresAt != nil {
data.ExpiresAt = invitation.ExpiresAt.AsTime().Format("January 2, 2006")
}
}
case userpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
data.Valid = false
data.ErrorMessage = "This invitation has already been used."
case userpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
data.Valid = false
data.ErrorMessage = "This invitation has expired."
case userpb.InvitationStatus_INVITATION_STATUS_REVOKED:
data.Valid = false
data.ErrorMessage = "This invitation has been revoked."
default:
data.Valid = false
data.ErrorMessage = "This invitation is not valid."
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := landingPageTemplate.Execute(w, data); err != nil {
log.Printf("[InviteHTTP] Failed to render landing page: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// handleInstallBat serves the Windows batch installer script
func (h *InvitationHTTPHandler) handleInstallBat(w http.ResponseWriter, r *http.Request, code string) {
// Validate the code exists and is valid
if !h.invitationService.IsValidCode(code) {
http.Error(w, "Invalid or expired invitation code", http.StatusNotFound)
return
}
// Generate batch script
script := fmt.Sprintf(`@echo off
echo.
echo ====================================
echo Eagle0 Installer
echo ====================================
echo.
echo Downloading Eagle0 installer...
echo.
curl -L -o "%%TEMP%%\EagleInstaller.exe" "%s"
if %%ERRORLEVEL%% neq 0 (
echo.
echo Failed to download installer. Please check your internet connection.
echo You can also download manually from: %s
echo.
pause
exit /b 1
)
echo.
echo Starting installer...
echo.
start "" "%%TEMP%%\EagleInstaller.exe" --code=%s
echo.
echo The installer should now be running.
echo You can close this window.
echo.
timeout /t 5
`, h.installerURL, h.installerURL, code)
w.Header().Set("Content-Type", "application/x-bat")
w.Header().Set("Content-Disposition", "attachment; filename=eagle0-install.bat")
w.Write([]byte(script))
log.Printf("[InviteHTTP] Served install.bat for code %s...", code[:8])
}
var landingPageTemplate = template.Must(template.New("landing").Parse(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Eagle0 Invitation</title>
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 600px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
min-height: 100vh;
}
.container {
background: white;
border-radius: 12px;
padding: 40px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-top: 40px;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.logo {
font-size: 48px;
font-weight: bold;
color: #1a5f7a;
margin-bottom: 10px;
}
h1 {
font-size: 24px;
color: #333;
margin: 0;
}
.error-box {
background: #fee;
border: 1px solid #fcc;
border-radius: 8px;
padding: 20px;
text-align: center;
color: #c00;
}
.content {
text-align: center;
}
.button {
display: inline-block;
background: #1a5f7a;
color: white !important;
text-decoration: none;
padding: 16px 32px;
border-radius: 8px;
font-weight: bold;
font-size: 18px;
margin: 20px 0;
transition: background 0.2s;
}
.button:hover {
background: #154d62;
}
.instructions {
background: #f8f9fa;
border-radius: 8px;
padding: 20px;
margin: 20px 0;
text-align: left;
}
.instructions h3 {
margin-top: 0;
color: #1a5f7a;
}
.instructions ol {
margin: 0;
padding-left: 20px;
}
.instructions li {
margin: 10px 0;
}
.code-box {
background: #e9ecef;
border: 1px solid #ced4da;
border-radius: 4px;
padding: 12px;
font-family: monospace;
font-size: 14px;
word-break: break-all;
margin: 15px 0;
}
.manual-section {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
}
.manual-section h3 {
color: #666;
font-size: 14px;
}
.expires {
color: #856404;
background: #fff3cd;
padding: 10px;
border-radius: 4px;
margin-top: 20px;
font-size: 14px;
}
.footer {
text-align: center;
color: #999;
font-size: 12px;
margin-top: 30px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<div class="logo">Eagle0</div>
{{if .Valid}}
<h1>You've been invited!</h1>
{{else}}
<h1>Invitation Error</h1>
{{end}}
</div>
{{if .Valid}}
<div class="content">
<p>You've been invited to play Eagle0, a strategic turn-based game with tactical hex-based combat.</p>
<a href="{{.InstallBatURL}}" class="button">Download & Install</a>
<div class="instructions">
<h3>How to install:</h3>
<ol>
<li>Click the "Download & Install" button above</li>
<li>Open the downloaded <strong>eagle0-install.bat</strong> file</li>
<li>If Windows shows a security prompt, click "More info" then "Run anyway"</li>
<li>The installer will download and start automatically with your invitation code</li>
</ol>
</div>
{{if .ExpiresAt}}
<div class="expires">
This invitation expires on {{.ExpiresAt}}.
</div>
{{end}}
<div class="manual-section">
<h3>Alternative: Manual Installation</h3>
<p>If the automatic installer doesn't work, you can <a href="{{.InstallerURL}}">download the installer manually</a> and enter this code when prompted:</p>
<div class="code-box">{{.Code}}</div>
</div>
</div>
{{else}}
<div class="error-box">
<p>{{.ErrorMessage}}</p>
<p>Please contact the person who invited you for a new invitation.</p>
</div>
{{end}}
<div class="footer">
<p>&copy; 2026 Eagle0</p>
</div>
</div>
</body>
</html>
`))
@@ -1,364 +0,0 @@
// Package main provides invitation management for the auth service.
package main
import (
"crypto/rand"
"encoding/base64"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"sync"
"time"
"google.golang.org/protobuf/proto"
"google.golang.org/protobuf/types/known/timestamppb"
userpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal/user"
)
const (
// InvitationCodeLength is the number of random bytes for invitation codes
InvitationCodeLength = 32
// DefaultExpirationDays is the default number of days until invitation expires
DefaultExpirationDays = 30
)
// InvitationService manages invitation persistence
type InvitationService struct {
mu sync.RWMutex
database *userpb.InvitationDatabase
dataPath string
lastModTime time.Time
}
// NewInvitationService creates a new invitation service with persistence
func NewInvitationService(dataDir string) (*InvitationService, error) {
// Ensure data directory exists
if err := os.MkdirAll(dataDir, 0755); err != nil {
return nil, fmt.Errorf("failed to create data directory: %w", err)
}
dataPath := filepath.Join(dataDir, "invitations.pb")
is := &InvitationService{
dataPath: dataPath,
database: &userpb.InvitationDatabase{
CodeIndex: make(map[string]int32),
},
}
// Try to load existing data
if err := is.loadFrom(dataPath); err == nil {
if info, err := os.Stat(dataPath); err == nil {
is.lastModTime = info.ModTime()
}
log.Printf("Loaded %d invitations from %s", len(is.database.Invitations), dataPath)
} else if !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to load invitation database: %w", err)
} else {
log.Printf("Starting with empty invitation database at %s", dataPath)
}
return is, nil
}
// loadFrom reads the database from a specific path
func (is *InvitationService) loadFrom(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return err
}
db := &userpb.InvitationDatabase{}
if err := proto.Unmarshal(data, db); err != nil {
return fmt.Errorf("failed to unmarshal database: %w", err)
}
// Ensure map is initialized
if db.CodeIndex == nil {
db.CodeIndex = make(map[string]int32)
}
is.database = db
return nil
}
// save writes the database to disk using atomic rename
func (is *InvitationService) save() error {
data, err := proto.Marshal(is.database)
if err != nil {
return fmt.Errorf("failed to marshal database: %w", err)
}
dir := filepath.Dir(is.dataPath)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory: %w", err)
}
tmpPath := is.dataPath + ".tmp"
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
return fmt.Errorf("failed to write temp file: %w", err)
}
if err := os.Rename(tmpPath, is.dataPath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("failed to rename temp file: %w", err)
}
if info, err := os.Stat(is.dataPath); err == nil {
is.lastModTime = info.ModTime()
}
return nil
}
// generateCode generates a cryptographically secure invitation code
func generateCode() (string, error) {
b := make([]byte, InvitationCodeLength)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("failed to generate random bytes: %w", err)
}
// Use URL-safe base64 encoding without padding
return base64.RawURLEncoding.EncodeToString(b), nil
}
// CreateInvitation creates a new invitation and returns it
func (is *InvitationService) CreateInvitation(email, createdByUserID string, expiresInDays int) (*userpb.Invitation, error) {
is.mu.Lock()
defer is.mu.Unlock()
// Validate email (basic check)
email = strings.TrimSpace(email)
if email == "" || !strings.Contains(email, "@") {
return nil, fmt.Errorf("invalid email address")
}
// Use default expiration if not specified
if expiresInDays <= 0 {
expiresInDays = DefaultExpirationDays
}
// Generate unique code
code, err := generateCode()
if err != nil {
return nil, err
}
now := time.Now()
expiresAt := now.AddDate(0, 0, expiresInDays)
invitation := &userpb.Invitation{
InvitationCode: code,
Email: strings.ToLower(email),
CreatedByUserId: createdByUserID,
CreatedAt: timestamppb.New(now),
ExpiresAt: timestamppb.New(expiresAt),
Status: userpb.InvitationStatus_INVITATION_STATUS_PENDING,
RedeemedByUserId: "",
RedeemedAt: nil,
}
// Add to database
idx := int32(len(is.database.Invitations))
is.database.Invitations = append(is.database.Invitations, invitation)
is.database.CodeIndex[code] = idx
if err := is.save(); err != nil {
return nil, fmt.Errorf("failed to save invitation: %w", err)
}
log.Printf("[Invitation] Created invitation for %s by user %s (expires %s)", email, createdByUserID, expiresAt.Format(time.RFC3339))
return invitation, nil
}
// FindByCode looks up an invitation by its code
func (is *InvitationService) FindByCode(code string) *userpb.Invitation {
is.mu.RLock()
defer is.mu.RUnlock()
if idx, ok := is.database.CodeIndex[code]; ok {
if int(idx) < len(is.database.Invitations) {
return is.database.Invitations[idx]
}
}
return nil
}
// ValidateAndRedeem validates an invitation code and marks it as redeemed
// Returns the invitation if valid, or an error describing why it's invalid
func (is *InvitationService) ValidateAndRedeem(code, redeemedByUserID string) (*userpb.Invitation, error) {
is.mu.Lock()
defer is.mu.Unlock()
idx, ok := is.database.CodeIndex[code]
if !ok || int(idx) >= len(is.database.Invitations) {
return nil, fmt.Errorf("invalid invitation code")
}
invitation := is.database.Invitations[idx]
// Check status
switch invitation.Status {
case userpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
return nil, fmt.Errorf("invitation has already been used")
case userpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
return nil, fmt.Errorf("invitation has expired")
case userpb.InvitationStatus_INVITATION_STATUS_REVOKED:
return nil, fmt.Errorf("invitation has been revoked")
case userpb.InvitationStatus_INVITATION_STATUS_PENDING:
// Check if expired by date
if invitation.ExpiresAt != nil && invitation.ExpiresAt.AsTime().Before(time.Now()) {
invitation.Status = userpb.InvitationStatus_INVITATION_STATUS_EXPIRED
is.database.Invitations[idx] = invitation
is.save() // Best effort save
return nil, fmt.Errorf("invitation has expired")
}
default:
return nil, fmt.Errorf("invitation is not valid")
}
// Mark as redeemed
now := timestamppb.Now()
invitation.Status = userpb.InvitationStatus_INVITATION_STATUS_REDEEMED
invitation.RedeemedByUserId = redeemedByUserID
invitation.RedeemedAt = now
is.database.Invitations[idx] = invitation
if err := is.save(); err != nil {
return nil, fmt.Errorf("failed to save redemption: %w", err)
}
log.Printf("[Invitation] Code %s redeemed by user %s", code[:8]+"...", redeemedByUserID)
return invitation, nil
}
// IsValidCode checks if a code is valid (pending and not expired)
// This is a non-mutating check for pre-validation
func (is *InvitationService) IsValidCode(code string) bool {
is.mu.RLock()
defer is.mu.RUnlock()
idx, ok := is.database.CodeIndex[code]
if !ok || int(idx) >= len(is.database.Invitations) {
return false
}
invitation := is.database.Invitations[idx]
if invitation.Status != userpb.InvitationStatus_INVITATION_STATUS_PENDING {
return false
}
if invitation.ExpiresAt != nil && invitation.ExpiresAt.AsTime().Before(time.Now()) {
return false
}
return true
}
// Revoke revokes an invitation (prevents it from being used)
func (is *InvitationService) Revoke(code string) error {
is.mu.Lock()
defer is.mu.Unlock()
idx, ok := is.database.CodeIndex[code]
if !ok || int(idx) >= len(is.database.Invitations) {
return fmt.Errorf("invitation not found")
}
invitation := is.database.Invitations[idx]
if invitation.Status == userpb.InvitationStatus_INVITATION_STATUS_REDEEMED {
return fmt.Errorf("cannot revoke an already-redeemed invitation")
}
invitation.Status = userpb.InvitationStatus_INVITATION_STATUS_REVOKED
is.database.Invitations[idx] = invitation
if err := is.save(); err != nil {
return fmt.Errorf("failed to save revocation: %w", err)
}
log.Printf("[Invitation] Code %s revoked", code[:8]+"...")
return nil
}
// ListInvitations returns invitations with optional filtering and pagination
func (is *InvitationService) ListInvitations(statusFilter userpb.InvitationStatus, emailFilter string, offset, limit int) ([]*userpb.Invitation, int) {
is.mu.RLock()
defer is.mu.RUnlock()
// Default and cap limit
if limit <= 0 {
limit = 50
}
if limit > 100 {
limit = 100
}
// Update expired status for pending invitations
now := time.Now()
for i, inv := range is.database.Invitations {
if inv.Status == userpb.InvitationStatus_INVITATION_STATUS_PENDING {
if inv.ExpiresAt != nil && inv.ExpiresAt.AsTime().Before(now) {
is.database.Invitations[i].Status = userpb.InvitationStatus_INVITATION_STATUS_EXPIRED
}
}
}
// Filter invitations
emailFilterLower := strings.ToLower(emailFilter)
var filtered []*userpb.Invitation
for _, inv := range is.database.Invitations {
// Status filter (0 = all)
if statusFilter != userpb.InvitationStatus_INVITATION_STATUS_UNSPECIFIED && inv.Status != statusFilter {
continue
}
// Email filter
if emailFilter != "" && !strings.Contains(inv.Email, emailFilterLower) {
continue
}
filtered = append(filtered, inv)
}
totalCount := len(filtered)
// Apply pagination
if offset >= len(filtered) {
return nil, totalCount
}
end := offset + limit
if end > len(filtered) {
end = len(filtered)
}
return filtered[offset:end], totalCount
}
// GetStats returns invitation statistics
func (is *InvitationService) GetStats() (pending, redeemed, expired, revoked int) {
is.mu.RLock()
defer is.mu.RUnlock()
now := time.Now()
for _, inv := range is.database.Invitations {
switch inv.Status {
case userpb.InvitationStatus_INVITATION_STATUS_PENDING:
// Check if actually expired
if inv.ExpiresAt != nil && inv.ExpiresAt.AsTime().Before(now) {
expired++
} else {
pending++
}
case userpb.InvitationStatus_INVITATION_STATUS_REDEEMED:
redeemed++
case userpb.InvitationStatus_INVITATION_STATUS_EXPIRED:
expired++
case userpb.InvitationStatus_INVITATION_STATUS_REVOKED:
revoked++
}
}
return
}
+3 -24
View File
@@ -101,31 +101,14 @@ func main() {
log.Printf("Legacy migration path configured: %s", actualLegacyDataDir)
}
// Create invitation service for invitation-based registration
invitationService, err := NewInvitationService(actualDataDir)
if err != nil {
log.Fatalf("Failed to create invitation service: %v", err)
}
log.Printf("Invitation service initialized")
// Log invitation requirement status
if os.Getenv("REQUIRE_INVITATION_CODE") == "true" || os.Getenv("REQUIRE_INVITATION_CODE") == "1" {
log.Printf("Invitation codes REQUIRED for new accounts (REQUIRE_INVITATION_CODE=true)")
} else {
log.Printf("Invitation codes NOT required for new accounts (set REQUIRE_INVITATION_CODE=true to enable)")
}
// Create email service for sending invitation emails
emailService := NewEmailService()
// Create OAuth service
oauthSvc := NewOAuthService(getOAuthConfigs())
// Create auth service handler
authHandler := NewAuthHandler(oauthSvc, userService, invitationService)
authHandler := NewAuthHandler(oauthSvc, userService)
// Create admin service handler
adminHandler := NewAdminHandler(userService, invitationService, emailService)
adminHandler := NewAdminHandler(userService)
// Start gRPC server
grpcLis, err := net.Listen("tcp", fmt.Sprintf(":%d", actualGRPCPort))
@@ -144,17 +127,13 @@ func main() {
}
}()
// Start HTTP server for OAuth callbacks and invitation pages
// Start HTTP server for OAuth callbacks
http.HandleFunc("/oauth/callback", oauthSvc.HandleCallback)
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "OK")
})
// Register invitation landing page routes
inviteHandler := NewInvitationHTTPHandler(invitationService)
inviteHandler.RegisterRoutes()
log.Printf("HTTP server listening on port %d", actualHTTPPort)
if err := http.ListenAndServe(fmt.Sprintf(":%d", actualHTTPPort), nil); err != nil {
log.Fatalf("HTTP server failed: %v", err)
+15 -20
View File
@@ -27,10 +27,9 @@ type OAuthProviderConfig struct {
// OAuthState represents a pending OAuth session
type OAuthState struct {
Provider string
CreatedAt time.Time
ReturnURL string // Optional: URL to redirect to after callback (for web clients)
InvitationCode string // Optional: Invitation code for new user registration
Provider string
CreatedAt time.Time
ReturnURL string // Optional: URL to redirect to after callback (for web clients)
}
// ProviderUserInfo holds user info from an OAuth provider
@@ -43,11 +42,10 @@ type ProviderUserInfo struct {
// OAuthResult represents the result of an OAuth flow
type OAuthResult struct {
Success bool
UserInfo *ProviderUserInfo
Provider string
Error string
InvitationCode string // Invitation code from the original request
Success bool
UserInfo *ProviderUserInfo
Provider string
Error string
}
// OAuthService manages OAuth state and handles callbacks
@@ -94,8 +92,7 @@ func getCallbackURL() string {
// GetAuthURL generates an OAuth authorization URL
// returnURL is optional - if set, the callback will redirect there instead of showing "close window"
// invitationCode is optional - if set, it will be stored with the OAuth state for new user validation
func (s *OAuthService) GetAuthURL(provider string, returnURL string, invitationCode string) (authURL string, state string, err error) {
func (s *OAuthService) GetAuthURL(provider string, returnURL string) (authURL string, state string, err error) {
config, ok := s.configs[provider]
if !ok {
return "", "", fmt.Errorf("unsupported provider: %s", provider)
@@ -103,13 +100,12 @@ func (s *OAuthService) GetAuthURL(provider string, returnURL string, invitationC
state = uuid.New().String()
s.pendingOAuth.Store(state, OAuthState{
Provider: provider,
CreatedAt: time.Now(),
ReturnURL: returnURL,
InvitationCode: invitationCode,
Provider: provider,
CreatedAt: time.Now(),
ReturnURL: returnURL,
})
log.Printf("OAuth: created state=%s for provider=%s returnURL=%s hasInvitationCode=%v", state, provider, returnURL, invitationCode != "")
log.Printf("OAuth: created state=%s for provider=%s returnURL=%s", state, provider, returnURL)
params := url.Values{}
params.Set("client_id", config.ClientID)
@@ -210,10 +206,9 @@ func (s *OAuthService) HandleCallback(w http.ResponseWriter, r *http.Request) {
// Mark as completed
s.pendingOAuth.Delete(state)
s.completedOAuth.Store(state, OAuthResult{
Success: true,
UserInfo: userInfo,
Provider: oauthState.Provider,
InvitationCode: oauthState.InvitationCode,
Success: true,
UserInfo: userInfo,
Provider: oauthState.Provider,
})
log.Printf("OAuth callback: SUCCESS state=%s user=%s returnURL=%s", state, userInfo.Username, oauthState.ReturnURL)
@@ -36,7 +36,7 @@ func TestGetAuthURL(t *testing.T) {
svc := NewOAuthService(configs)
url, state, err := svc.GetAuthURL("discord", "", "")
url, state, err := svc.GetAuthURL("discord", "")
if err != nil {
t.Fatalf("GetAuthURL failed: %v", err)
}
@@ -58,7 +58,7 @@ func TestGetAuthURL_UnknownProvider(t *testing.T) {
configs := map[string]OAuthProviderConfig{}
svc := NewOAuthService(configs)
_, _, err := svc.GetAuthURL("unknown", "", "")
_, _, err := svc.GetAuthURL("unknown", "")
if err == nil {
t.Error("Expected error for unknown provider")
}
@@ -76,7 +76,7 @@ func TestGetAuthURL_EmptyClientID(t *testing.T) {
}
svc := NewOAuthService(configs)
url, state, err := svc.GetAuthURL("discord", "", "")
url, state, err := svc.GetAuthURL("discord", "")
// No error expected - the provider exists, it just has empty client ID
if err != nil {
t.Errorf("Unexpected error: %v", err)
@@ -105,7 +105,7 @@ func TestIsPending(t *testing.T) {
}
// Create a pending state
_, state, _ := svc.GetAuthURL("discord", "", "")
_, state, _ := svc.GetAuthURL("discord", "")
if !svc.IsPending(state) {
t.Error("State should be pending after GetAuthURL")
@@ -122,7 +122,7 @@ func TestCheckStatus_Pending(t *testing.T) {
}
svc := NewOAuthService(configs)
_, state, _ := svc.GetAuthURL("discord", "", "")
_, state, _ := svc.GetAuthURL("discord", "")
result := svc.CheckStatus(state)
if result.Success {
@@ -157,7 +157,7 @@ func TestCheckStatus_Completed(t *testing.T) {
svc := NewOAuthService(configs)
// Create a pending state
_, state, _ := svc.GetAuthURL("discord", "", "")
_, state, _ := svc.GetAuthURL("discord", "")
// Simulate completion by directly storing in completedOAuth
testResult := OAuthResult{
@@ -194,7 +194,7 @@ func TestCheckStatus_Error(t *testing.T) {
svc := NewOAuthService(configs)
// Create a pending state
_, state, _ := svc.GetAuthURL("discord", "", "")
_, state, _ := svc.GetAuthURL("discord", "")
// Simulate error by storing error result
errorResult := OAuthResult{
@@ -223,7 +223,7 @@ func TestPendingOAuthExpiry(t *testing.T) {
}
svc := NewOAuthService(configs)
_, state, _ := svc.GetAuthURL("discord", "", "")
_, state, _ := svc.GetAuthURL("discord", "")
// Verify the pending entry has a creation time
value, ok := svc.pendingOAuth.Load(state)
@@ -296,9 +296,9 @@ func TestMultiplePendingStates(t *testing.T) {
svc := NewOAuthService(configs)
// Create multiple pending states
_, state1, _ := svc.GetAuthURL("discord", "", "")
_, state2, _ := svc.GetAuthURL("discord", "", "")
_, state3, _ := svc.GetAuthURL("discord", "", "")
_, state1, _ := svc.GetAuthURL("discord", "")
_, state2, _ := svc.GetAuthURL("discord", "")
_, state3, _ := svc.GetAuthURL("discord", "")
// All should be unique
if state1 == state2 || state2 == state3 || state1 == state3 {
@@ -1,455 +0,0 @@
// Package main provides email sending via Fastmail JMAP API for the auth service.
package main
import (
"bytes"
"encoding/json"
"fmt"
"html/template"
"io"
"log"
"net/http"
"os"
"time"
userpb "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/internal/user"
)
const (
fastmailSessionURL = "https://api.fastmail.com/jmap/session"
fastmailAPIURL = "https://api.fastmail.com/jmap/api/"
)
// EmailService handles sending emails via Fastmail JMAP API
type EmailService struct {
apiToken string
fromEmail string
fromName string
installerURL string
serverBaseURL string
httpClient *http.Client
enabled bool
// JMAP session info (populated on first send)
accountID string
identityID string
draftsID string
}
// NewEmailService creates a new email service
// Returns a disabled service if FASTMAIL_API_TOKEN is not set
func NewEmailService() *EmailService {
apiToken := os.Getenv("FASTMAIL_API_TOKEN")
fromEmail := os.Getenv("FASTMAIL_FROM_EMAIL")
fromName := os.Getenv("FASTMAIL_FROM_NAME")
installerURL := os.Getenv("INSTALLER_DOWNLOAD_URL")
serverBaseURL := os.Getenv("SERVER_BASE_URL")
if fromEmail == "" {
fromEmail = "noreply@eagle0.net"
}
if fromName == "" {
fromName = "Eagle0 Game"
}
if installerURL == "" {
installerURL = "https://assets.eagle0.net/installer/EagleInstaller.exe"
}
if serverBaseURL == "" {
serverBaseURL = "https://prod.eagle0.net"
}
enabled := apiToken != ""
if !enabled {
log.Printf("WARNING: FASTMAIL_API_TOKEN not set - email sending disabled")
} else {
log.Printf("Email service initialized (JMAP: api.fastmail.com, from: %s <%s>)", fromName, fromEmail)
}
return &EmailService{
apiToken: apiToken,
fromEmail: fromEmail,
fromName: fromName,
installerURL: installerURL,
serverBaseURL: serverBaseURL,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
enabled: enabled,
}
}
// IsEnabled returns true if email sending is configured
func (e *EmailService) IsEnabled() bool {
return e.enabled
}
// SendInvitation sends an invitation email
func (e *EmailService) SendInvitation(invitation *userpb.Invitation) error {
if !e.enabled {
log.Printf("[Email] Would send invitation to %s (email disabled)", invitation.Email)
return nil
}
// Initialize JMAP session if needed
if e.accountID == "" {
if err := e.initSession(); err != nil {
return fmt.Errorf("failed to initialize JMAP session: %w", err)
}
}
// Build invite page URL
invitePageURL := fmt.Sprintf("%s/invite/%s", e.serverBaseURL, invitation.InvitationCode)
// Build email content
subject := "You've been invited to play Eagle0!"
htmlContent, err := e.buildInvitationHTML(invitation, invitePageURL)
if err != nil {
return fmt.Errorf("failed to build email content: %w", err)
}
textContent := e.buildInvitationText(invitation, invitePageURL)
// Send via JMAP
if err := e.sendEmail(invitation.Email, subject, htmlContent, textContent); err != nil {
return fmt.Errorf("failed to send email: %w", err)
}
log.Printf("[Email] Sent invitation email to %s", invitation.Email)
return nil
}
// initSession fetches JMAP session info (account ID, identity ID, drafts mailbox ID)
func (e *EmailService) initSession() error {
// Get session
req, err := http.NewRequest("GET", fastmailSessionURL, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+e.apiToken)
resp, err := e.httpClient.Do(req)
if err != nil {
return fmt.Errorf("session request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("session request failed: %d: %s", resp.StatusCode, string(body))
}
var session struct {
PrimaryAccounts map[string]string `json:"primaryAccounts"`
}
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
return fmt.Errorf("failed to decode session: %w", err)
}
e.accountID = session.PrimaryAccounts["urn:ietf:params:jmap:mail"]
if e.accountID == "" {
return fmt.Errorf("no mail account found in session")
}
log.Printf("[Email] JMAP account ID: %s", e.accountID)
// Get identity ID and drafts mailbox ID
if err := e.fetchIdentityAndDrafts(); err != nil {
return err
}
return nil
}
// fetchIdentityAndDrafts gets the identity ID and drafts mailbox ID
func (e *EmailService) fetchIdentityAndDrafts() error {
payload := map[string]interface{}{
"using": []string{
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:submission",
},
"methodCalls": []interface{}{
[]interface{}{"Identity/get", map[string]interface{}{"accountId": e.accountID}, "0"},
[]interface{}{"Mailbox/query", map[string]interface{}{
"accountId": e.accountID,
"filter": map[string]string{"role": "drafts"},
}, "1"},
},
}
body, err := json.Marshal(payload)
if err != nil {
return err
}
req, err := http.NewRequest("POST", fastmailAPIURL, bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+e.apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := e.httpClient.Do(req)
if err != nil {
return fmt.Errorf("JMAP request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("JMAP request failed: %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
MethodResponses [][]interface{} `json:"methodResponses"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("failed to decode JMAP response: %w", err)
}
// Extract identity ID from first response
if len(result.MethodResponses) > 0 {
if data, ok := result.MethodResponses[0][1].(map[string]interface{}); ok {
if list, ok := data["list"].([]interface{}); ok && len(list) > 0 {
if identity, ok := list[0].(map[string]interface{}); ok {
if id, ok := identity["id"].(string); ok {
e.identityID = id
}
// Use the identity's email if we don't have one configured
if email, ok := identity["email"].(string); ok && e.fromEmail == "noreply@eagle0.net" {
e.fromEmail = email
}
}
}
}
}
// Extract drafts mailbox ID from second response
if len(result.MethodResponses) > 1 {
if data, ok := result.MethodResponses[1][1].(map[string]interface{}); ok {
if ids, ok := data["ids"].([]interface{}); ok && len(ids) > 0 {
if id, ok := ids[0].(string); ok {
e.draftsID = id
}
}
}
}
if e.identityID == "" {
return fmt.Errorf("failed to get identity ID")
}
if e.draftsID == "" {
return fmt.Errorf("failed to get drafts mailbox ID")
}
log.Printf("[Email] JMAP identity: %s, drafts: %s", e.identityID, e.draftsID)
return nil
}
func (e *EmailService) buildInvitationHTML(invitation *userpb.Invitation, invitePageURL string) (string, error) {
const htmlTemplate = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Eagle0 Invitation</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.header { text-align: center; padding: 20px 0; }
.logo { font-size: 32px; font-weight: bold; color: #1a5f7a; }
.content { background: #f8f9fa; border-radius: 8px; padding: 30px; margin: 20px 0; }
.button { display: inline-block; background: #1a5f7a; color: white !important; text-decoration: none; padding: 14px 28px; border-radius: 6px; font-weight: bold; margin: 20px 0; }
.button:hover { background: #154d62; }
.footer { text-align: center; color: #6c757d; font-size: 12px; margin-top: 30px; }
.expires { color: #856404; background: #fff3cd; padding: 10px; border-radius: 4px; margin-top: 15px; }
</style>
</head>
<body>
<div class="header">
<div class="logo">Eagle0</div>
</div>
<div class="content">
<h2>You've been invited!</h2>
<p>Someone has invited you to join Eagle0, a strategic turn-based game with tactical hex-based combat.</p>
<p style="text-align: center;">
<a href="{{.InvitePageURL}}" class="button">Accept Invitation</a>
</p>
<p style="text-align: center; color: #666;">Click the button above to download and install Eagle0.</p>
<div class="expires">
This invitation expires on {{.ExpiresAt}}.
</div>
<p style="margin-top: 20px; font-size: 12px; color: #999;">
If the button doesn't work, copy this link into your browser:<br>
<a href="{{.InvitePageURL}}" style="color: #1a5f7a;">{{.InvitePageURL}}</a>
</p>
</div>
<div class="footer">
<p>This is an automated message. If you didn't expect this invitation, you can safely ignore it.</p>
</div>
</body>
</html>`
tmpl, err := template.New("invitation").Parse(htmlTemplate)
if err != nil {
return "", err
}
var expiresAt string
if invitation.ExpiresAt != nil {
expiresAt = invitation.ExpiresAt.AsTime().Format("January 2, 2006")
} else {
expiresAt = "N/A"
}
data := map[string]string{
"InvitePageURL": invitePageURL,
"ExpiresAt": expiresAt,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return "", err
}
return buf.String(), nil
}
func (e *EmailService) buildInvitationText(invitation *userpb.Invitation, invitePageURL string) string {
var expiresAt string
if invitation.ExpiresAt != nil {
expiresAt = invitation.ExpiresAt.AsTime().Format("January 2, 2006")
} else {
expiresAt = "N/A"
}
return fmt.Sprintf(`You've been invited to play Eagle0!
Someone has invited you to join Eagle0, a strategic turn-based game with tactical hex-based combat.
Click here to accept your invitation and download the game:
%s
This invitation expires on %s.
---
This is an automated message. If you didn't expect this invitation, you can safely ignore it.
`, invitePageURL, expiresAt)
}
// sendEmail sends an email via Fastmail JMAP API
func (e *EmailService) sendEmail(to, subject, htmlContent, textContent string) error {
// Create email and submit in one request
payload := map[string]interface{}{
"using": []string{
"urn:ietf:params:jmap:core",
"urn:ietf:params:jmap:mail",
"urn:ietf:params:jmap:submission",
},
"methodCalls": []interface{}{
[]interface{}{"Email/set", map[string]interface{}{
"accountId": e.accountID,
"create": map[string]interface{}{
"draft": map[string]interface{}{
"from": []map[string]string{
{"name": e.fromName, "email": e.fromEmail},
},
"to": []map[string]string{
{"email": to},
},
"subject": subject,
"mailboxIds": map[string]bool{e.draftsID: true},
"keywords": map[string]bool{"$draft": true},
"bodyValues": map[string]interface{}{
"text": map[string]interface{}{
"charset": "utf-8",
"value": textContent,
},
"html": map[string]interface{}{
"charset": "utf-8",
"value": htmlContent,
},
},
"textBody": []map[string]string{
{"partId": "text", "type": "text/plain"},
},
"htmlBody": []map[string]string{
{"partId": "html", "type": "text/html"},
},
},
},
}, "0"},
[]interface{}{"EmailSubmission/set", map[string]interface{}{
"accountId": e.accountID,
"onSuccessDestroyEmail": []string{"#sendIt"},
"create": map[string]interface{}{
"sendIt": map[string]interface{}{
"emailId": "#draft",
"identityId": e.identityID,
},
},
}, "1"},
},
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal request: %w", err)
}
req, err := http.NewRequest("POST", fastmailAPIURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+e.apiToken)
req.Header.Set("Content-Type", "application/json")
resp, err := e.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
respBody, _ := io.ReadAll(resp.Body)
return fmt.Errorf("JMAP API error: %d: %s", resp.StatusCode, string(respBody))
}
// Check for errors in response
var result struct {
MethodResponses [][]interface{} `json:"methodResponses"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
// Check Email/set response for errors
if len(result.MethodResponses) > 0 {
if data, ok := result.MethodResponses[0][1].(map[string]interface{}); ok {
if notCreated, ok := data["notCreated"].(map[string]interface{}); ok {
if draft, ok := notCreated["draft"].(map[string]interface{}); ok {
if desc, ok := draft["description"].(string); ok {
return fmt.Errorf("failed to create email: %s", desc)
}
}
}
}
}
// Check EmailSubmission/set response for errors
if len(result.MethodResponses) > 1 {
if data, ok := result.MethodResponses[1][1].(map[string]interface{}); ok {
if notCreated, ok := data["notCreated"].(map[string]interface{}); ok {
if sendIt, ok := notCreated["sendIt"].(map[string]interface{}); ok {
if desc, ok := sendIt["description"].(string); ok {
return fmt.Errorf("failed to send email: %s", desc)
}
}
}
}
}
return nil
}
@@ -228,22 +228,6 @@ func (us *UserService) FindByUserID(userID string) *userpb.User {
return nil
}
// FindByOAuthIdentity finds a user by their OAuth provider and provider user ID
func (us *UserService) FindByOAuthIdentity(provider, providerUserID string) *userpb.User {
us.mu.RLock()
defer us.mu.RUnlock()
oauthKey := fmt.Sprintf("%s:%s", provider, providerUserID)
if userID, ok := us.database.OauthIndex[oauthKey]; ok {
for _, user := range us.database.Users {
if user.UserId == userID {
return user
}
}
}
return nil
}
// FindByDisplayName finds a user by display name (case-insensitive)
func (us *UserService) FindByDisplayName(displayName string) *userpb.User {
us.mu.RLock()
@@ -55,8 +55,8 @@ func main() {
log.Fatalf("Failed to create AWS client: %v", err)
}
// Upload installer to S3 with public ACL
err = bucketBasics.UploadFilePublic(bucketName, installerKey, installerPath)
// Upload installer to S3
err = bucketBasics.UploadFile(bucketName, installerKey, installerPath)
if err != nil {
log.Fatalf("Failed to upload installer: %v", err)
}
@@ -73,8 +73,8 @@ func main() {
log.Fatalf("Failed to create version file: %v", err)
}
// Upload version file to S3 with public ACL
err = bucketBasics.UploadFilePublic(bucketName, versionKey, tempVersionFile)
// Upload version file to S3
err = bucketBasics.UploadFile(bucketName, versionKey, tempVersionFile)
if err != nil {
log.Fatalf("Failed to upload version file: %v", err)
}
@@ -112,8 +112,8 @@ func (mm *ManifestManager) UpdateSection(sectionName, sectionContent string) err
// Build new manifest
newManifest := mm.buildManifest(sections)
// Upload updated manifest with public ACL
err = mm.bucketBasics.UploadBytesPublic(bucketName, manifestPath, []byte(newManifest))
// Upload updated manifest
err = mm.bucketBasics.UploadBytes(bucketName, manifestPath, []byte(newManifest))
if err != nil {
return fmt.Errorf("failed to upload manifest: %v", err)
}
@@ -118,7 +118,7 @@ func syncToS3(bb aws.BucketBasics, localDir string, s3Root string, localShas map
}
if !objectMatches {
err = bb.UploadFilePublic(bucketName, destinationPath, filePath)
err = bb.UploadFile(bucketName, destinationPath, filePath)
if err != nil {
return err
}
@@ -156,7 +156,7 @@ func handleDir(tempDir string, outputManifestPath string) {
log.Fatal(err)
}
err = bb.UploadBytesPublic(bucketName, manifestPath, []byte(localManifest))
err = bb.UploadBytes(bucketName, manifestPath, []byte(localManifest))
if err != nil {
log.Fatal(err)
}
+4 -31
View File
@@ -130,30 +130,16 @@ func (basics BucketBasics) FetchString(bucketName string, objectKey string) (str
}
func (basics BucketBasics) UploadFile(bucketName string, objectKey string, fileName string) error {
return basics.UploadFileWithACL(bucketName, objectKey, fileName, "")
}
func (basics BucketBasics) UploadFilePublic(bucketName string, objectKey string, fileName string) error {
return basics.UploadFileWithACL(bucketName, objectKey, fileName, "public-read")
}
func (basics BucketBasics) UploadFileWithACL(bucketName string, objectKey string, fileName string, acl string) error {
file, err := os.Open(fileName)
if err != nil {
return err
}
defer file.Close()
input := &s3.PutObjectInput{
_, err = basics.S3Client.PutObject(basics.ctx, &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
Body: file,
}
if acl != "" {
input.ACL = types.ObjectCannedACL(acl)
}
_, err = basics.S3Client.PutObject(basics.ctx, input)
})
if err != nil {
return err
}
@@ -167,24 +153,11 @@ func (basics BucketBasics) UploadFileWithACL(bucketName string, objectKey string
}
func (basics BucketBasics) UploadBytes(bucketName string, objectKey string, data []byte) error {
return basics.UploadBytesWithACL(bucketName, objectKey, data, "")
}
func (basics BucketBasics) UploadBytesPublic(bucketName string, objectKey string, data []byte) error {
return basics.UploadBytesWithACL(bucketName, objectKey, data, "public-read")
}
func (basics BucketBasics) UploadBytesWithACL(bucketName string, objectKey string, data []byte, acl string) error {
input := &s3.PutObjectInput{
_, err := basics.S3Client.PutObject(basics.ctx, &s3.PutObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
Body: bytes.NewReader(data),
}
if acl != "" {
input.ACL = types.ObjectCannedACL(acl)
}
_, err := basics.S3Client.PutObject(basics.ctx, input)
})
if err != nil {
return err
}
-32
View File
@@ -1,32 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "warmup_lib",
srcs = ["main.go"],
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/warmup",
visibility = ["//visibility:private"],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:api_go_proto", # keep
"//src/main/protobuf/net/eagle0/eagle/common:common_go_proto",
"@org_golang_google_grpc//:grpc",
"@org_golang_google_grpc//credentials/insecure",
"@org_golang_google_grpc//metadata",
],
)
go_binary(
name = "warmup",
embed = [":warmup_lib"],
pure = "on",
visibility = ["//visibility:public"],
)
# Linux x86_64 target for Docker deployment
go_binary(
name = "warmup_linux_amd64",
embed = [":warmup_lib"],
goarch = "amd64",
goos = "linux",
pure = "on",
visibility = ["//visibility:public"],
)
-412
View File
@@ -1,412 +0,0 @@
// Package main implements a warmup tool for Eagle server.
//
// This tool warms up the JIT by:
// 1. Entering the lobby to get available leaders
// 2. Creating a new game with 2 players (1 human, 1 AI)
// 3. Starting a bidirectional stream for that game
// 4. Posting an Improve command when available
// 5. Verifying that we get ActionResults including NEW_ROUND_ACTION
//
// Usage:
//
// warmup --address=localhost:40032
package main
import (
"context"
"flag"
"fmt"
"io"
"log"
"time"
eagle "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/api/eagle"
eaglecommon "github.com/nolen777/eagle0/src/main/protobuf/net/eagle0/eagle/common"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
var (
address = flag.String("address", "localhost:40032", "Eagle server address (host:port)")
timeout = flag.Duration("timeout", 60*time.Second, "Overall timeout for warmup")
)
func main() {
flag.Parse()
log.SetFlags(log.Ltime | log.Lmicroseconds)
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
if err := runWarmup(ctx); err != nil {
log.Fatalf("Warmup failed: %v", err)
}
log.Println("Warmup completed successfully!")
}
func runWarmup(ctx context.Context) error {
log.Printf("Connecting to Eagle server at %s...", *address)
conn, err := grpc.NewClient(*address, grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
return fmt.Errorf("failed to connect: %w", err)
}
defer conn.Close()
client := eagle.NewEagleClient(conn)
// Add warmup user header to context
md := metadata.Pairs("x-warmup-user", "warmup-tool")
ctx = metadata.NewOutgoingContext(ctx, md)
// Start the bidirectional stream
log.Println("Starting StreamUpdates...")
stream, err := client.StreamUpdates(ctx)
if err != nil {
return fmt.Errorf("failed to start stream: %w", err)
}
// Step 1: Enter the lobby to get available leaders
log.Println("Step 1: Entering lobby...")
if err := stream.Send(&eagle.UpdateStreamRequest{
RequestDetails: &eagle.UpdateStreamRequest_EnterLobbyRequest{
EnterLobbyRequest: &eagle.EnterLobbyRequest{},
},
}); err != nil {
return fmt.Errorf("failed to send lobby request: %w", err)
}
lobbyResp, err := waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
return resp.GetLobbyResponse() != nil
})
if err != nil {
return fmt.Errorf("failed to get lobby response: %w", err)
}
lobby := lobbyResp.GetLobbyResponse()
newGameOptions := lobby.GetNewGameOptions()
if newGameOptions == nil || len(newGameOptions.GetAvailableLeaders()) == 0 {
return fmt.Errorf("no available leaders in lobby response")
}
leader := newGameOptions.GetAvailableLeaders()[0]
log.Printf(" Found leader: %s", leader.GetNameTextId())
// Step 2: Create a new game
log.Println("Step 2: Creating game...")
if err := stream.Send(&eagle.UpdateStreamRequest{
RequestDetails: &eagle.UpdateStreamRequest_CreateGameRequest{
CreateGameRequest: &eagle.CreateGameRequest{
DesiredLeaderTextId: leader.GetNameTextId(),
TotalPlayerCount: 2,
HumanPlayerCount: 1,
},
},
}); err != nil {
return fmt.Errorf("failed to send create game request: %w", err)
}
createResp, err := waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
return resp.GetCreateGameResponse() != nil
})
if err != nil {
return fmt.Errorf("failed to get create game response: %w", err)
}
gameResp := createResp.GetCreateGameResponse()
if gameResp.GetResult() != eagle.JoinGameResult_SUCCESS_JOIN_GAME_RESULT {
return fmt.Errorf("failed to create game: %v", gameResp.GetResult())
}
gameID := gameResp.GetGameId()
log.Printf(" Created game: %d", gameID)
// Step 3: Subscribe to the game stream
log.Println("Step 3: Subscribing to game...")
if err := stream.Send(&eagle.UpdateStreamRequest{
RequestDetails: &eagle.UpdateStreamRequest_StreamGameRequest_{
StreamGameRequest: &eagle.UpdateStreamRequest_StreamGameRequest{
GameId: gameID,
UnfilteredResultCount: 0,
},
},
}); err != nil {
return fmt.Errorf("failed to send stream game request: %w", err)
}
// Wait for subscription ack
_, err = waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
return resp.GetSubscriptionAck() != nil
})
if err != nil {
return fmt.Errorf("failed to get subscription ack: %w", err)
}
log.Println(" Subscription acknowledged")
// Wait for initial game state with available commands
gameUpdateResp, err := waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
gu := resp.GetGameUpdate()
if gu == nil {
return false
}
ar := gu.GetActionResultResponse()
return ar != nil && ar.GetAvailableCommands() != nil
})
if err != nil {
return fmt.Errorf("failed to get initial game state: %w", err)
}
gameUpdate := gameUpdateResp.GetGameUpdate()
actionResult := gameUpdate.GetActionResultResponse()
availableCommands := actionResult.GetAvailableCommands()
// Get the token from available commands
eagleToken := availableCommands.GetToken()
// Get commands by province from map
commandsByProvince := availableCommands.GetCommandsByProvince()
log.Printf(" Got game state with %d provinces having commands", len(commandsByProvince))
// Step 4: Find an Improve command and post it
log.Println("Step 4: Looking for Improve command...")
var improveCmd *eagle.AvailableCommand
var provinceID int32
for pid, provCmds := range commandsByProvince {
for _, cmd := range provCmds.GetCommands() {
if cmd.GetImproveCommand() != nil {
improveCmd = cmd
provinceID = pid
break
}
}
if improveCmd != nil {
break
}
}
if improveCmd == nil {
log.Println(" No Improve command available, looking for any command...")
// Try to find any province with commands
for pid, provCmds := range commandsByProvince {
if len(provCmds.GetCommands()) > 0 {
provinceID = pid
log.Printf(" Found command group for province %d with %d commands", pid, len(provCmds.GetCommands()))
break
}
}
// We'll still try to post an Improve command even if not explicitly available
} else {
log.Printf(" Found Improve command for province %d", provinceID)
}
// Post the Improve command
log.Println(" Posting Improve command...")
if err := stream.Send(&eagle.UpdateStreamRequest{
RequestDetails: &eagle.UpdateStreamRequest_PostCommandRequest{
PostCommandRequest: &eagle.PostCommandRequest{
GameId: gameID,
Command: &eagle.UniversalCommand{
SealedValue: &eagle.UniversalCommand_EagleCommand{
EagleCommand: &eagle.EagleCommand{
ProvinceId: provinceID,
EagleToken: eagleToken,
Command: &eagle.SelectedCommand{
SealedValue: &eagle.SelectedCommand_ImproveCommand{
ImproveCommand: &eagle.ImproveSelectedCommand{
ImprovementType: eaglecommon.ImprovementType_ECONOMY,
ActingHeroId: 0, // Default acting hero
LockType: false,
},
},
},
},
},
},
},
},
}); err != nil {
return fmt.Errorf("failed to send post command request: %w", err)
}
// Step 5: Verify we get ActionResults
log.Println("Step 5: Waiting for command response...")
// Wait for post command response
postResp, err := waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
return resp.GetPostCommandResponse() != nil
})
if err != nil {
return fmt.Errorf("failed to get post command response: %w", err)
}
if postResp.GetPostCommandResponse().GetStatus() != eagle.PostCommandResponse_SUCCESS {
log.Printf(" Command was rejected: %v", postResp.GetPostCommandResponse().GetStatus())
// Continue anyway - the JIT is still warming up
} else {
log.Println(" Command accepted!")
}
// Collect action results with a timeout
log.Println(" Collecting action results...")
resultCtx, resultCancel := context.WithTimeout(ctx, 10*time.Second)
defer resultCancel()
foundNewRound := false
resultCount := 0
commandCount := 0
for {
select {
case <-resultCtx.Done():
log.Println(" Timeout waiting for more results (this is OK)")
goto done
default:
}
resp, err := receiveWithTimeout(stream, 2*time.Second)
if err != nil {
if err == errTimeout {
log.Println(" No more results received")
goto done
}
return fmt.Errorf("error receiving: %w", err)
}
gu := resp.GetGameUpdate()
if gu == nil {
continue
}
ar := gu.GetActionResultResponse()
if ar == nil {
continue
}
for _, arv := range ar.GetActionResultViews() {
resultCount++
if arv.GetType() == eaglecommon.ActionResultType_NEW_ROUND_ACTION {
foundNewRound = true
log.Printf(" Found NEW_ROUND_ACTION (date change)!")
}
}
if ar.GetAvailableCommands() != nil {
commandCount = len(ar.GetAvailableCommands().GetCommandsByProvince())
log.Printf(" Got %d action results, %d provinces with commands available", len(ar.GetActionResultViews()), commandCount)
}
}
done:
// Verify results
log.Println("Verification:")
log.Printf(" Total action results received: %d", resultCount)
log.Printf(" Found NEW_ROUND_ACTION (date change): %v", foundNewRound)
log.Printf(" New commands available: %d provinces", commandCount)
// Step 6: Drop the game to clean up
log.Println("Step 6: Dropping test game...")
if err := stream.Send(&eagle.UpdateStreamRequest{
RequestDetails: &eagle.UpdateStreamRequest_DropGameRequest{
DropGameRequest: &eagle.DropGameRequest{
GameId: gameID,
},
},
}); err != nil {
log.Printf("Warning: failed to send drop game request: %v", err)
} else {
// Wait briefly for drop response
_, err := waitForResponseWithTimeout(stream, func(resp *eagle.UpdateStreamResponse) bool {
return resp.GetDropGameResponse() != nil
}, 5*time.Second)
if err != nil {
log.Printf("Warning: failed to get drop game response: %v", err)
} else {
log.Println(" Game dropped successfully")
}
}
// Report success based on what we achieved
if resultCount == 0 {
return fmt.Errorf("no action results received")
}
return nil
}
// waitForResponse waits for a response matching the predicate
func waitForResponse(stream eagle.Eagle_StreamUpdatesClient, matches func(*eagle.UpdateStreamResponse) bool) (*eagle.UpdateStreamResponse, error) {
return waitForResponseWithTimeout(stream, matches, 30*time.Second)
}
func waitForResponseWithTimeout(stream eagle.Eagle_StreamUpdatesClient, matches func(*eagle.UpdateStreamResponse) bool, timeout time.Duration) (*eagle.UpdateStreamResponse, error) {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
remaining := time.Until(deadline)
resp, err := receiveWithTimeout(stream, remaining)
if err != nil {
if err == errTimeout {
return nil, fmt.Errorf("timeout waiting for response")
}
log.Printf(" [DEBUG] Error receiving: %v", err)
return nil, err
}
// Log what type of response we got
switch resp.GetResponseDetails().(type) {
case *eagle.UpdateStreamResponse_LobbyResponse:
log.Printf(" [DEBUG] Got LobbyResponse")
case *eagle.UpdateStreamResponse_CreateGameResponse:
log.Printf(" [DEBUG] Got CreateGameResponse")
case *eagle.UpdateStreamResponse_GameUpdate:
log.Printf(" [DEBUG] Got GameUpdate")
case *eagle.UpdateStreamResponse_SubscriptionAck:
log.Printf(" [DEBUG] Got SubscriptionAck")
case *eagle.UpdateStreamResponse_PostCommandResponse:
log.Printf(" [DEBUG] Got PostCommandResponse")
case *eagle.UpdateStreamResponse_DropGameResponse:
log.Printf(" [DEBUG] Got DropGameResponse")
default:
log.Printf(" [DEBUG] Got unknown response type: %T", resp.GetResponseDetails())
}
if matches(resp) {
return resp, nil
}
}
return nil, fmt.Errorf("timeout waiting for response")
}
var errTimeout = fmt.Errorf("receive timeout")
func receiveWithTimeout(stream eagle.Eagle_StreamUpdatesClient, timeout time.Duration) (*eagle.UpdateStreamResponse, error) {
type result struct {
resp *eagle.UpdateStreamResponse
err error
}
ch := make(chan result, 1)
go func() {
resp, err := stream.Recv()
ch <- result{resp, err}
}()
select {
case r := <-ch:
if r.err != nil {
if r.err == io.EOF {
return nil, fmt.Errorf("stream closed by server")
}
return nil, r.err
}
return r.resp, nil
case <-time.After(timeout):
return nil, errTimeout
}
}
@@ -27,18 +27,6 @@ service Admin {
// Toggle admin status for a user
rpc SetUserAdmin(SetUserAdminRequest) returns (SetUserAdminResponse) {}
// Create a new invitation and send email
rpc CreateInvitation(CreateInvitationRequest) returns (CreateInvitationResponse) {}
// List all invitations with optional filtering
rpc ListInvitations(ListInvitationsRequest) returns (ListInvitationsResponse) {}
// Revoke an invitation (prevents it from being used)
rpc RevokeInvitation(RevokeInvitationRequest) returns (RevokeInvitationResponse) {}
// Resend invitation email
rpc ResendInvitation(ResendInvitationRequest) returns (ResendInvitationResponse) {}
}
// Request to list users
@@ -103,73 +91,3 @@ message SetUserAdminResponse {
bool success = 1;
string error_message = 2;
}
// ============== Invitation Management ==============
// Invitation status for admin view
enum InvitationStatus {
INVITATION_STATUS_UNSPECIFIED = 0;
INVITATION_STATUS_PENDING = 1;
INVITATION_STATUS_REDEEMED = 2;
INVITATION_STATUS_EXPIRED = 3;
INVITATION_STATUS_REVOKED = 4;
}
// Invitation info for admin view
message InvitationInfo {
string invitation_code = 1;
string email = 2;
string created_by_user_id = 3;
string created_by_display_name = 4; // Resolved for display
google.protobuf.Timestamp created_at = 5;
google.protobuf.Timestamp expires_at = 6;
InvitationStatus status = 7;
string redeemed_by_user_id = 8;
string redeemed_by_display_name = 9; // Resolved for display
google.protobuf.Timestamp redeemed_at = 10;
}
// Create invitation request
message CreateInvitationRequest {
string email = 1; // Email to send invitation to
int32 expires_in_days = 2; // Days until expiration (default 30 if 0)
}
message CreateInvitationResponse {
bool success = 1;
string error_message = 2;
InvitationInfo invitation = 3;
}
// List invitations request
message ListInvitationsRequest {
InvitationStatus status_filter = 1; // Optional: filter by status (0 = all)
string email_filter = 2; // Optional: filter by email
int32 offset = 3;
int32 limit = 4; // Default 50, max 100
}
message ListInvitationsResponse {
repeated InvitationInfo invitations = 1;
int32 total_count = 2;
}
// Revoke invitation request
message RevokeInvitationRequest {
string invitation_code = 1;
}
message RevokeInvitationResponse {
bool success = 1;
string error_message = 2;
}
// Resend invitation email request
message ResendInvitationRequest {
string invitation_code = 1;
}
message ResendInvitationResponse {
bool success = 1;
string error_message = 2;
}
@@ -48,9 +48,6 @@ message GetOAuthUrlRequest {
// If set, the auth service will redirect here after OAuth instead of
// showing "close this window" message.
string return_url = 2;
// Optional: Invitation code for new account creation.
// Required for new users; existing users are grandfathered and don't need this.
string invitation_code = 3;
}
message GetOAuthUrlResponse {
@@ -81,7 +78,6 @@ enum OAuthStatus {
OAUTH_STATUS_SUCCESS = 2; // OAuth complete, tokens available
OAUTH_STATUS_FAILED = 3; // OAuth failed (user denied, etc.)
OAUTH_STATUS_EXPIRED = 4; // State token expired (typically 10 minutes)
OAUTH_STATUS_INVITATION_REQUIRED = 5; // New user but no valid invitation code provided
}
// User information returned from auth endpoints
@@ -45,9 +45,6 @@ service Eagle {
rpc DeleteGame(DeleteGameRequest) returns (DeleteGameResponse) {}
rpc CheckGameExists(CheckGameExistsRequest) returns (CheckGameExistsResponse) {}
rpc DownloadGameSave(DownloadGameSaveRequest) returns (stream DownloadGameSaveResponse) {}
// Server lifecycle management (for blue-green deployments)
rpc ReloadGames(ReloadGamesRequest) returns (ReloadGamesResponse) {}
}
message PostCommandRequest {
@@ -608,12 +605,3 @@ message DownloadGameSaveRequest {
message DownloadGameSaveResponse {
bytes chunk = 1; // Chunk of zip file data
}
// Server lifecycle management (for blue-green deployments)
message ReloadGamesRequest {}
message ReloadGamesResponse {
bool success = 1;
string error_message = 2;
int32 games_reloaded = 3; // Number of games that were reloaded from disk
}

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