mirror of
https://github.com/nolen777/eagle0.git
synced 2026-07-29 10:15:43 +00:00
Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7b92ae927 | ||
|
|
23ab715ffc | ||
|
|
73f42d5b51 | ||
|
|
3cb61f69f4 | ||
|
|
62fff3c0ea | ||
|
|
ca86926a25 | ||
|
|
1d334c91ae | ||
|
|
64e1e46e0a | ||
|
|
4722a40384 | ||
|
|
6653a14660 | ||
|
|
056571651e | ||
|
|
d8224e7886 | ||
|
|
9cc9a65e4a | ||
|
|
025563b607 | ||
|
|
030f6b2c2e | ||
|
|
1d5dd7b971 | ||
|
|
649e80c4ac | ||
|
|
a1b4e41553 | ||
|
|
1c24474a0b | ||
|
|
26eec6cabc | ||
|
|
d134f40438 | ||
|
|
a638dd26ca | ||
|
|
5e93b0eaa0 | ||
|
|
6940312d3f | ||
|
|
243f0d43e7 | ||
|
|
1ddd015449 | ||
|
|
da85d0983a | ||
|
|
ae574e6ff5 | ||
|
|
c1ac57b929 | ||
|
|
be51daa148 | ||
|
|
97605d0a63 | ||
|
|
9cca922b70 | ||
|
|
6eab9ffc3c | ||
|
|
08eaffb927 | ||
|
|
a2bc55e0f5 | ||
|
|
19861377a6 | ||
|
|
cc70087efc | ||
|
|
b2383c8384 | ||
|
|
03958b6914 | ||
|
|
4cbf3ea39f | ||
|
|
3dbbe74830 | ||
|
|
dab4365f16 | ||
|
|
0b3cd4f1c6 | ||
|
|
49d42a599a | ||
|
|
5ae7d8a9cc | ||
|
|
ee47f7d6bc | ||
|
|
fe3c2d3e79 | ||
|
|
de21646a37 | ||
|
|
040f2d55b9 | ||
|
|
5b32712b0f | ||
|
|
bdec4a0015 | ||
|
|
c2c7b85da5 | ||
|
|
9e287ba3bb | ||
|
|
1a0cfe8578 | ||
|
|
a841720c75 | ||
|
|
064a606d13 |
@@ -105,10 +105,23 @@ 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
|
||||
|
||||
- name: Copy update-env script to server
|
||||
uses: appleboy/scp-action@v0.1.7
|
||||
with:
|
||||
host: ${{ secrets.DO_DROPLET_IP }}
|
||||
username: deploy
|
||||
key: ${{ secrets.DO_SSH_KEY }}
|
||||
source: "deploy/update-env.sh,deploy/env.template"
|
||||
target: /opt/eagle0/
|
||||
strip_components: 1
|
||||
|
||||
- name: Deploy auth service to production
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
@@ -116,34 +129,23 @@ 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
|
||||
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
|
||||
script: |
|
||||
set -x
|
||||
cd /opt/eagle0
|
||||
|
||||
# Update AUTH_IMAGE in .env file (preserve other vars)
|
||||
if [ -f .env ]; then
|
||||
# Remove old AUTH_IMAGE line and add new one
|
||||
grep -v '^AUTH_IMAGE=' .env > .env.tmp || true
|
||||
echo "AUTH_IMAGE=${AUTH_IMAGE}" >> .env.tmp
|
||||
# Also update OAuth credentials
|
||||
grep -v '^DISCORD_CLIENT_ID=' .env.tmp > .env.tmp2 || true
|
||||
echo "DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID:-}" >> .env.tmp2
|
||||
grep -v '^DISCORD_CLIENT_SECRET=' .env.tmp2 > .env.tmp3 || true
|
||||
echo "DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET:-}" >> .env.tmp3
|
||||
grep -v '^GOOGLE_CLIENT_ID=' .env.tmp3 > .env.tmp4 || true
|
||||
echo "GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}" >> .env.tmp4
|
||||
grep -v '^GOOGLE_CLIENT_SECRET=' .env.tmp4 > .env.tmp5 || true
|
||||
echo "GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET:-}" >> .env.tmp5
|
||||
# Update JWT private key (JWK format for auth service bootstrap)
|
||||
grep -v '^JWT_PRIVATE_KEY=' .env.tmp5 > .env || 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."
|
||||
exit 1
|
||||
fi
|
||||
# Update env vars using shared script (preserves vars set by other workflows)
|
||||
chmod +x update-env.sh
|
||||
./update-env.sh \
|
||||
"AUTH_IMAGE=${AUTH_IMAGE}" \
|
||||
"DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}" \
|
||||
"DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}" \
|
||||
"GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" \
|
||||
"GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}" \
|
||||
"JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}" \
|
||||
"FASTMAIL_API_TOKEN=${FASTMAIL_API_TOKEN}" \
|
||||
"FASTMAIL_FROM_EMAIL=${FASTMAIL_FROM_EMAIL}" \
|
||||
"FASTMAIL_FROM_NAME=${FASTMAIL_FROM_NAME}"
|
||||
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
|
||||
+193
-118
@@ -42,6 +42,13 @@ 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"
|
||||
@@ -421,7 +428,7 @@ jobs:
|
||||
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: self-hosted
|
||||
needs: [build-eagle, build-shardok, build-admin, build-jfr-sidecar]
|
||||
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
|
||||
environment: production
|
||||
@@ -443,135 +450,203 @@ jobs:
|
||||
SHARDOK_ADDRESS: ${{ secrets.SHARDOK_ADDRESS }}
|
||||
SHARDOK_AUTH_TOKEN: ${{ secrets.SHARDOK_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
|
||||
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
|
||||
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
|
||||
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
|
||||
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
|
||||
steps:
|
||||
- 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: Build warmup tool
|
||||
run: |
|
||||
bazel build //src/main/go/net/eagle0/warmup:warmup_linux_amd64
|
||||
mkdir -p scripts/bin
|
||||
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
|
||||
|
||||
- name: Copy config files to droplet
|
||||
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"
|
||||
run: |
|
||||
# Create directory structure on remote
|
||||
# Use -p to create parents, and test write access before copying
|
||||
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << 'SETUP_DIRS'
|
||||
set -e
|
||||
mkdir -p /opt/eagle0/scripts/bin /opt/eagle0/nginx /opt/eagle0/deploy
|
||||
|
||||
# Remove existing warmup binary (it may have read-only permissions from bazel)
|
||||
rm -f /opt/eagle0/scripts/bin/warmup
|
||||
SETUP_DIRS
|
||||
|
||||
# Copy files preserving structure
|
||||
scp -i ~/.ssh/deploy_key docker-compose.prod.yml deploy@"$DO_DROPLET_IP":/opt/eagle0/
|
||||
scp -i ~/.ssh/deploy_key nginx/nginx.conf deploy@"$DO_DROPLET_IP":/opt/eagle0/nginx/
|
||||
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
|
||||
scp -i ~/.ssh/deploy_key scripts/bin/warmup deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/bin/
|
||||
scp -i ~/.ssh/deploy_key deploy/env.template deploy/update-env.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/deploy/
|
||||
|
||||
- name: Deploy to production droplet
|
||||
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
|
||||
run: |
|
||||
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << DEPLOY_SCRIPT
|
||||
set -ex
|
||||
cd /opt/eagle0
|
||||
|
||||
# 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"
|
||||
# Environment variables passed via heredoc
|
||||
EAGLE_IMAGE="${EAGLE_IMAGE}"
|
||||
SHARDOK_IMAGE="${SHARDOK_IMAGE}"
|
||||
ADMIN_IMAGE="${ADMIN_IMAGE}"
|
||||
JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
|
||||
OPENAI_API_KEY="${OPENAI_API_KEY}"
|
||||
GPT_MODEL_NAME="${GPT_MODEL_NAME}"
|
||||
EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3}"
|
||||
DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
|
||||
DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
|
||||
JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
|
||||
DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
|
||||
DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
|
||||
GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
|
||||
GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
|
||||
SHARDOK_ADDRESS="${SHARDOK_ADDRESS}"
|
||||
SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
|
||||
SENTRY_DSN="${SENTRY_DSN}"
|
||||
FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
|
||||
FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
|
||||
FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
|
||||
DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
|
||||
|
||||
# Check Docker has IPv6 support for connecting to Hetzner Shardok
|
||||
# (One-time setup: sudo tee /etc/docker/daemon.json <<< '{"ipv6": true, "ip6tables": true, "experimental": true, "fixed-cidr-v6": "fd00::/80"}' && sudo systemctl restart docker)
|
||||
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
|
||||
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
|
||||
echo "Run: sudo tee /etc/docker/daemon.json <<< '{\"ipv6\": true, \"ip6tables\": true, \"experimental\": true, \"fixed-cidr-v6\": \"fd00::/80\"}' && sudo systemctl restart docker"
|
||||
fi
|
||||
|
||||
# Update env vars using shared script (preserves vars set by other workflows)
|
||||
chmod +x update-env.sh
|
||||
./update-env.sh \
|
||||
"EAGLE_IMAGE=\${EAGLE_IMAGE}" \
|
||||
"SHARDOK_IMAGE=\${SHARDOK_IMAGE}" \
|
||||
"ADMIN_IMAGE=\${ADMIN_IMAGE}" \
|
||||
"JFR_SIDECAR_IMAGE=\${JFR_SIDECAR_IMAGE}" \
|
||||
"OPENAI_API_KEY=\${OPENAI_API_KEY}" \
|
||||
"GPT_MODEL_NAME=\${GPT_MODEL_NAME:-gpt-4o}" \
|
||||
"EAGLE_ENABLE_S3=\${EAGLE_ENABLE_S3:-false}" \
|
||||
"DO_SPACES_ACCESS_KEY=\${DO_SPACES_ACCESS_KEY}" \
|
||||
"DO_SPACES_SECRET_KEY=\${DO_SPACES_SECRET_KEY}" \
|
||||
"JWT_PRIVATE_KEY=\${JWT_PRIVATE_KEY}" \
|
||||
"DISCORD_CLIENT_ID=\${DISCORD_CLIENT_ID}" \
|
||||
"DISCORD_CLIENT_SECRET=\${DISCORD_CLIENT_SECRET}" \
|
||||
"GOOGLE_CLIENT_ID=\${GOOGLE_CLIENT_ID}" \
|
||||
"GOOGLE_CLIENT_SECRET=\${GOOGLE_CLIENT_SECRET}" \
|
||||
"SHARDOK_ADDRESS=\${SHARDOK_ADDRESS:-shardok:40042}" \
|
||||
"SHARDOK_AUTH_TOKEN=\${SHARDOK_AUTH_TOKEN}" \
|
||||
"SENTRY_DSN=\${SENTRY_DSN}" \
|
||||
"FASTMAIL_API_TOKEN=\${FASTMAIL_API_TOKEN}" \
|
||||
"FASTMAIL_FROM_EMAIL=\${FASTMAIL_FROM_EMAIL}" \
|
||||
"FASTMAIL_FROM_NAME=\${FASTMAIL_FROM_NAME}"
|
||||
|
||||
# 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..."
|
||||
rm -f crane
|
||||
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
|
||||
chmod +x crane
|
||||
if [ ! -x crane ]; then
|
||||
echo "ERROR: Failed to install crane"
|
||||
ls -la crane || true
|
||||
exit 1
|
||||
fi
|
||||
echo "Crane installed"
|
||||
ls -la crane
|
||||
|
||||
# crane uses Docker config for auth
|
||||
echo "Pulling Eagle image with crane..."
|
||||
./crane pull "\${EAGLE_IMAGE}" eagle.tar || { echo "ERROR: Failed to pull eagle image"; exit 1; }
|
||||
echo "Loading Eagle image into Docker..."
|
||||
docker load -i eagle.tar
|
||||
rm eagle.tar
|
||||
|
||||
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
|
||||
|
||||
# Keep crane for blue-green deploys (don't delete it)
|
||||
echo "Crane kept at ./crane for future blue-green deploys"
|
||||
|
||||
# Also pull other compose images
|
||||
docker pull nginx:alpine || true
|
||||
docker pull certbot/certbot || true
|
||||
|
||||
echo "All images pulled successfully"
|
||||
|
||||
# Recreate non-Eagle services (not auth - managed by auth_build.yml)
|
||||
# Note: jfr-sidecar must start after eagle-blue due to PID namespace sharing
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate shardok admin
|
||||
|
||||
# Deploy Eagle with blue-green (zero-downtime) if scripts are available
|
||||
if [ -x "/opt/eagle0/scripts/deploy-blue-green.sh" ]; then
|
||||
echo "Using blue-green deployment for Eagle..."
|
||||
chmod +x /opt/eagle0/scripts/*.sh
|
||||
# Make warmup binary executable if present
|
||||
if [ -f "/opt/eagle0/scripts/bin/warmup" ]; then
|
||||
chmod +x /opt/eagle0/scripts/bin/warmup
|
||||
fi
|
||||
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
|
||||
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}" || {
|
||||
echo "Blue-green deployment failed, falling back to direct restart"
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle-blue
|
||||
}
|
||||
else
|
||||
echo "Blue-green scripts not found, using direct restart..."
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle-blue
|
||||
fi
|
||||
|
||||
# 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
|
||||
# Start jfr-sidecar after eagle-blue (shares PID namespace with eagle-blue)
|
||||
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate jfr-sidecar
|
||||
|
||||
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
|
||||
# Ensure auth is running (but don't force-recreate it)
|
||||
docker compose -f docker-compose.prod.yml up -d auth
|
||||
|
||||
# Login to registry
|
||||
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
|
||||
# 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
|
||||
|
||||
# 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"
|
||||
# Wait for health checks
|
||||
sleep 10
|
||||
docker compose -f docker-compose.prod.yml ps
|
||||
|
||||
# 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
|
||||
# Verify containers are using correct images
|
||||
echo "=== Verifying container image tags ==="
|
||||
docker compose -f docker-compose.prod.yml images
|
||||
|
||||
# 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
|
||||
# Cleanup stopped containers and old images
|
||||
docker container prune -f
|
||||
docker image prune -f
|
||||
DEPLOY_SCRIPT
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
name: Mac Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/mac_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "src/main/proto/**"
|
||||
- "scripts/build_protos.sh"
|
||||
- "scripts/build_mac_plugin.sh"
|
||||
- "scripts/inject_sparkle.sh"
|
||||
- "scripts/codesign_mac_app.sh"
|
||||
- "scripts/notarize_mac_app.sh"
|
||||
- "ci/github_actions/build_mac.sh"
|
||||
- "ci/github_actions/build_unity_mac.sh"
|
||||
- "ci/mac/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/mac_build.yml"
|
||||
- "src/main/csharp/net/eagle0/clients/unity/**"
|
||||
- "scripts/build_mac_plugin.sh"
|
||||
- "scripts/inject_sparkle.sh"
|
||||
- "scripts/codesign_mac_app.sh"
|
||||
- "scripts/notarize_mac_app.sh"
|
||||
- "ci/github_actions/build_mac.sh"
|
||||
- "ci/github_actions/build_unity_mac.sh"
|
||||
- "ci/mac/**"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skip_notarization:
|
||||
description: 'Skip notarization (for testing)'
|
||||
required: false
|
||||
default: 'false'
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
mac-unity:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: true
|
||||
clean: false
|
||||
fetch-depth: 0 # For version numbering from git history
|
||||
|
||||
- name: Pull LFS files
|
||||
run: git lfs pull
|
||||
|
||||
- name: Restore Library/
|
||||
run: ./ci/github_actions/restore_library.sh
|
||||
|
||||
- name: Build Mac Unity
|
||||
run: ./ci/github_actions/build_unity_mac.sh "/tmp/eagle0/eagle0MAC"
|
||||
|
||||
- name: Persist Library/
|
||||
run: ./ci/github_actions/persist_library.sh
|
||||
|
||||
- name: Inject Sparkle Framework
|
||||
if: success()
|
||||
env:
|
||||
SPARKLE_EDDSA_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }}
|
||||
run: |
|
||||
chmod +x ./scripts/inject_sparkle.sh
|
||||
./scripts/inject_sparkle.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
|
||||
|
||||
- name: Import Code Signing Certificate
|
||||
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
env:
|
||||
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
|
||||
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
|
||||
run: |
|
||||
# Generate random keychain password (only used within this workflow run)
|
||||
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
|
||||
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> $GITHUB_ENV
|
||||
|
||||
# Decode certificate
|
||||
echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12
|
||||
|
||||
# Create temporary keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain || true
|
||||
security default-keychain -s build.keychain
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
|
||||
|
||||
# Import certificate
|
||||
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
|
||||
|
||||
# Allow codesign to access keychain
|
||||
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
|
||||
|
||||
# Clean up
|
||||
rm certificate.p12
|
||||
|
||||
- name: Code Sign App
|
||||
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
env:
|
||||
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
|
||||
run: |
|
||||
chmod +x ./scripts/codesign_mac_app.sh
|
||||
./scripts/codesign_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
|
||||
|
||||
- name: Notarize App
|
||||
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.event.inputs.skip_notarization != 'true'
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
|
||||
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
chmod +x ./scripts/notarize_mac_app.sh
|
||||
./scripts/notarize_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
|
||||
|
||||
- name: Deploy Mac Build
|
||||
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
|
||||
env:
|
||||
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
|
||||
SECRET_KEY: ${{ secrets.SECRET_KEY }}
|
||||
SPARKLE_EDDSA_PRIVATE_KEY: ${{ secrets.SPARKLE_EDDSA_PRIVATE_KEY }}
|
||||
run: |
|
||||
# Write private key to temp file for signing
|
||||
SPARKLE_PRIVATE_KEY_PATH="/tmp/sparkle_private_key"
|
||||
echo "$SPARKLE_EDDSA_PRIVATE_KEY" > "$SPARKLE_PRIVATE_KEY_PATH"
|
||||
chmod 600 "$SPARKLE_PRIVATE_KEY_PATH"
|
||||
|
||||
VERSION=$(git describe --tags --always)
|
||||
BUILD_NUMBER=$(git rev-list --count HEAD)
|
||||
|
||||
bazel run //src/main/go/net/eagle0/build/mac_build_handler:mac_build_handler -- \
|
||||
"/tmp/eagle0/eagle0MAC/eagle0.app" \
|
||||
"$VERSION" \
|
||||
"$BUILD_NUMBER" \
|
||||
"$SPARKLE_PRIVATE_KEY_PATH"
|
||||
|
||||
rm "$SPARKLE_PRIVATE_KEY_PATH"
|
||||
|
||||
- name: Cleanup Keychain
|
||||
if: always()
|
||||
run: |
|
||||
security delete-keychain build.keychain 2>/dev/null || true
|
||||
|
||||
- name: Archive Build Log
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: editor_mac.log
|
||||
path: /tmp/eagle0/editor_mac.log
|
||||
@@ -1,29 +0,0 @@
|
||||
name: Mac History Editor Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- ".github/workflows/mac_history_build.yml"
|
||||
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/mac_history_build.yml"
|
||||
- "src/main/swift/net/eagle0/EagleGameHistoryViewer/**"
|
||||
- "src/main/protobuf/net/eagle0/eagle/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
mac-history-build:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
lfs: false
|
||||
clean: false
|
||||
- name: Build the mac history
|
||||
run: ./ci/github_actions/build_mac_history.sh
|
||||
@@ -1,5 +1,19 @@
|
||||
# CLAUDE.md
|
||||
|
||||
## CRITICAL GIT RULES (NEVER VIOLATE)
|
||||
|
||||
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
|
||||
|
||||
**ALWAYS use this workflow:**
|
||||
1. Create a feature branch from origin/main
|
||||
2. Commit to that branch
|
||||
3. Create a PR with `gh pr create`
|
||||
4. Wait for user to merge
|
||||
|
||||
If you catch yourself about to run `git push origin main` or `git push origin <branch>:main`, STOP. You are about to violate a critical rule. Create a PR instead.
|
||||
|
||||
---
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
+12
-5
@@ -127,8 +127,8 @@ use_repo(
|
||||
#
|
||||
|
||||
bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_apple_support")
|
||||
bazel_dep(name = "rules_apple", version = "3.16.1", repo_name = "build_bazel_rules_apple")
|
||||
bazel_dep(name = "rules_swift", version = "2.3.1", repo_name = "build_bazel_rules_swift")
|
||||
bazel_dep(name = "rules_apple", version = "4.3.3", repo_name = "build_bazel_rules_apple")
|
||||
bazel_dep(name = "rules_swift", version = "2.4.0", repo_name = "build_bazel_rules_swift")
|
||||
|
||||
#
|
||||
# Protocol Buffers & RPC
|
||||
@@ -294,11 +294,15 @@ http_archive(
|
||||
)
|
||||
|
||||
# Busybox static binary for Docker health checks (provides nc, wget, etc.)
|
||||
# https://busybox.net/downloads/binaries/
|
||||
# Primary: GitHub release mirror (reliable)
|
||||
# Fallback: busybox.net (can be unreliable/slow)
|
||||
http_file(
|
||||
name = "busybox_x86_64",
|
||||
sha256 = "6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348",
|
||||
urls = ["https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox"],
|
||||
urls = [
|
||||
"https://github.com/nolen777/eagle0/releases/download/busybox-1.35.0/busybox-1.35.0-x86_64-linux-musl",
|
||||
"https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox",
|
||||
],
|
||||
downloaded_file_path = "busybox",
|
||||
executable = True,
|
||||
)
|
||||
@@ -306,7 +310,10 @@ http_file(
|
||||
http_file(
|
||||
name = "busybox_aarch64",
|
||||
sha256 = "141adb1b625a6f44c4b114f76b4387b4ea4f7ab802b88eb40e0d2f6adcccb1c3",
|
||||
urls = ["https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox"],
|
||||
urls = [
|
||||
# TODO: Upload aarch64 binary to GitHub release when needed
|
||||
"https://busybox.net/downloads/binaries/1.35.0-aarch64-linux-musl/busybox",
|
||||
],
|
||||
downloaded_file_path = "busybox",
|
||||
executable = True,
|
||||
)
|
||||
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
. ./ci/unity_version.sh
|
||||
|
||||
WORKSPACE=$(pwd)
|
||||
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
|
||||
BUILD_DIR=$1
|
||||
LOG_PATH=$2
|
||||
|
||||
echo "Building Mac in $BUILD_DIR"
|
||||
|
||||
echo "Cleaning up $BUILD_DIR"
|
||||
/bin/rm -rf "$BUILD_DIR"
|
||||
/bin/mkdir -p "$BUILD_DIR"
|
||||
|
||||
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
|
||||
-nographics \
|
||||
-batchmode \
|
||||
-quit \
|
||||
-buildOSXUniversalPlayer "$BUILD_DIR/eagle0.app" \
|
||||
-logFile "$LOG_PATH" \
|
||||
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
COMMIT=$(/usr/bin/git rev-parse --short HEAD)
|
||||
|
||||
/bin/echo "build protos"
|
||||
./scripts/build_protos.sh
|
||||
|
||||
/bin/echo "build Mac plugin"
|
||||
./scripts/build_mac_plugin.sh
|
||||
|
||||
git log -3
|
||||
|
||||
/bin/echo "build Mac"
|
||||
LOG_PATH="/tmp/eagle0/editor_mac.log"
|
||||
BUILD_DIR=$1
|
||||
|
||||
./ci/github_actions/build_mac.sh "$BUILD_DIR" "$LOG_PATH"
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<!-- Allow JIT compilation (required for Mono/IL2CPP) -->
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<!-- Allow unsigned executable memory (required for Unity) -->
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<!-- Disable library validation (required for plugins) -->
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<!-- Allow outgoing network connections -->
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -0,0 +1,40 @@
|
||||
# Environment template for production deployment
|
||||
# This file defines all env vars used by docker-compose.prod.yml
|
||||
# Workflows should update their specific vars without overwriting others
|
||||
|
||||
# Container images (managed by respective build workflows)
|
||||
EAGLE_IMAGE=registry.digitalocean.com/eagle0/eagle-server:latest
|
||||
SHARDOK_IMAGE=registry.digitalocean.com/eagle0/shardok-server:latest
|
||||
ADMIN_IMAGE=registry.digitalocean.com/eagle0/admin-server:latest
|
||||
JFR_SIDECAR_IMAGE=registry.digitalocean.com/eagle0/jfr-sidecar:latest
|
||||
AUTH_IMAGE=registry.digitalocean.com/eagle0/auth-server:latest
|
||||
|
||||
# OpenAI / LLM
|
||||
OPENAI_API_KEY=
|
||||
GPT_MODEL_NAME=gpt-4o
|
||||
|
||||
# DigitalOcean Spaces (S3-compatible storage)
|
||||
EAGLE_ENABLE_S3=false
|
||||
DO_SPACES_ACCESS_KEY=
|
||||
DO_SPACES_SECRET_KEY=
|
||||
|
||||
# JWT authentication
|
||||
JWT_PRIVATE_KEY=
|
||||
|
||||
# OAuth providers
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# Shardok connection
|
||||
SHARDOK_ADDRESS=shardok:40042
|
||||
SHARDOK_AUTH_TOKEN=
|
||||
|
||||
# Monitoring
|
||||
SENTRY_DSN=
|
||||
|
||||
# Email (Fastmail JMAP)
|
||||
FASTMAIL_API_TOKEN=
|
||||
FASTMAIL_FROM_EMAIL=
|
||||
FASTMAIL_FROM_NAME=
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/bin/bash
|
||||
# Update .env file without losing other variables
|
||||
# Usage: ./update-env.sh KEY1=value1 KEY2=value2 ...
|
||||
#
|
||||
# This script:
|
||||
# 1. Creates .env from template if it doesn't exist
|
||||
# 2. Updates only the specified KEY=value pairs
|
||||
# 3. Preserves all other existing values
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ENV_FILE="${ENV_FILE:-/opt/eagle0/.env}"
|
||||
TEMPLATE_FILE="${TEMPLATE_FILE:-$SCRIPT_DIR/env.template}"
|
||||
|
||||
# Create .env from template if it doesn't exist
|
||||
if [ ! -f "$ENV_FILE" ]; then
|
||||
if [ -f "$TEMPLATE_FILE" ]; then
|
||||
echo "Creating .env from template..."
|
||||
grep -v '^#' "$TEMPLATE_FILE" | grep -v '^$' > "$ENV_FILE"
|
||||
else
|
||||
echo "Creating empty .env..."
|
||||
touch "$ENV_FILE"
|
||||
fi
|
||||
chmod 600 "$ENV_FILE"
|
||||
fi
|
||||
|
||||
# Process each KEY=VALUE argument
|
||||
for arg in "$@"; do
|
||||
# Skip empty args
|
||||
[ -z "$arg" ] && continue
|
||||
|
||||
# Parse KEY=VALUE
|
||||
KEY="${arg%%=*}"
|
||||
VALUE="${arg#*=}"
|
||||
|
||||
# Skip if no key
|
||||
[ -z "$KEY" ] && continue
|
||||
|
||||
# Skip setting empty values (keeps existing value)
|
||||
if [ -z "$VALUE" ]; then
|
||||
echo "Skipping $KEY (empty value)"
|
||||
continue
|
||||
fi
|
||||
|
||||
# Remove existing line for this key and add new one
|
||||
if grep -q "^${KEY}=" "$ENV_FILE" 2>/dev/null; then
|
||||
# Key exists, update it
|
||||
sed -i "s|^${KEY}=.*|${KEY}=${VALUE}|" "$ENV_FILE"
|
||||
echo "Updated $KEY"
|
||||
else
|
||||
# Key doesn't exist, add it
|
||||
echo "${KEY}=${VALUE}" >> "$ENV_FILE"
|
||||
echo "Added $KEY"
|
||||
fi
|
||||
done
|
||||
|
||||
chmod 600 "$ENV_FILE"
|
||||
echo "Done updating $ENV_FILE"
|
||||
+68
-8
@@ -8,9 +8,13 @@
|
||||
# Run: docker compose -f docker-compose.prod.yml up -d
|
||||
|
||||
services:
|
||||
eagle:
|
||||
# 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:
|
||||
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
|
||||
container_name: eagle-server
|
||||
container_name: eagle-blue
|
||||
command:
|
||||
- "--gpt-model-name"
|
||||
- "${GPT_MODEL_NAME:-gpt-5.1}"
|
||||
@@ -39,7 +43,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-server jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
|
||||
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-blue 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:
|
||||
@@ -58,6 +62,58 @@ 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
|
||||
@@ -80,6 +136,10 @@ 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
|
||||
@@ -136,7 +196,7 @@ services:
|
||||
- ./certbot/www:/var/www/certbot:ro
|
||||
- ./auth:/etc/nginx/auth:ro
|
||||
depends_on:
|
||||
- eagle
|
||||
- eagle-blue
|
||||
- admin
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
@@ -150,7 +210,7 @@ services:
|
||||
container_name: admin-server
|
||||
command:
|
||||
- "--eagle-addr"
|
||||
- "eagle:40032"
|
||||
- "${EAGLE_ADDR:-eagle-blue:40032}" # Can be switched for blue-green
|
||||
- "--auth-addr"
|
||||
- "auth:40033"
|
||||
- "--jfr-sidecar-addr"
|
||||
@@ -159,7 +219,7 @@ services:
|
||||
- "8080"
|
||||
# No external port - accessed via nginx at admin.eagle0.net
|
||||
depends_on:
|
||||
- eagle
|
||||
- eagle-blue
|
||||
- auth
|
||||
- jfr-sidecar
|
||||
restart: unless-stopped
|
||||
@@ -179,11 +239,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"
|
||||
pid: "service:eagle-blue"
|
||||
volumes:
|
||||
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
|
||||
depends_on:
|
||||
- eagle
|
||||
- eagle-blue
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
|
||||
+10
-1
@@ -25,8 +25,10 @@ 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:40032;
|
||||
server eagle-blue:40032;
|
||||
keepalive 100;
|
||||
}
|
||||
|
||||
@@ -106,6 +108,13 @@ http {
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Invitation landing page (proxied to Go auth service)
|
||||
location /invite/ {
|
||||
proxy_pass http://auth:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
|
||||
Executable
+88
@@ -0,0 +1,88 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Code sign a macOS .app bundle for distribution
|
||||
# Usage: codesign_mac_app.sh <app_path> [entitlements_path]
|
||||
#
|
||||
# Environment variables:
|
||||
# SIGNING_IDENTITY - The signing identity (default: "Developer ID Application")
|
||||
# KEYCHAIN_PASSWORD - Password to unlock the build keychain (optional)
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
APP_PATH="$1"
|
||||
ENTITLEMENTS_PATH="${2:-}"
|
||||
SIGNING_IDENTITY="${SIGNING_IDENTITY:-Developer ID Application}"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Unlock keychain if password provided
|
||||
if [ -n "${KEYCHAIN_PASSWORD:-}" ]; then
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain || true
|
||||
fi
|
||||
|
||||
echo "=== Signing nested components first ==="
|
||||
|
||||
# Sign all dylibs
|
||||
find "$APP_PATH" -name "*.dylib" -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing dylib: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign all bundles (plugins)
|
||||
find "$APP_PATH" -name "*.bundle" -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing bundle: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign XPC services (inside Sparkle framework)
|
||||
find "$APP_PATH" -name "*.xpc" -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing XPC service: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign nested apps (like Sparkle's Updater.app)
|
||||
find "$APP_PATH" -path "*/Frameworks/*.app" -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing nested app: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign standalone executables inside frameworks (like Autoupdate)
|
||||
find "$APP_PATH" -path "*/Frameworks/*/Versions/*/Autoupdate" -type f -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing executable: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
# Sign all frameworks (after their contents are signed)
|
||||
find "$APP_PATH" -name "*.framework" -print0 | while IFS= read -r -d '' item; do
|
||||
echo "Signing framework: $item"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$item"
|
||||
done
|
||||
|
||||
echo "=== Signing main app bundle ==="
|
||||
|
||||
if [ -n "$ENTITLEMENTS_PATH" ] && [ -f "$ENTITLEMENTS_PATH" ]; then
|
||||
echo "Using entitlements: $ENTITLEMENTS_PATH"
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--entitlements "$ENTITLEMENTS_PATH" \
|
||||
--sign "$SIGNING_IDENTITY" "$APP_PATH"
|
||||
else
|
||||
codesign --force --verify --verbose --timestamp --options runtime \
|
||||
--sign "$SIGNING_IDENTITY" "$APP_PATH"
|
||||
fi
|
||||
|
||||
echo "=== Verifying signature ==="
|
||||
codesign --verify --verbose=4 "$APP_PATH"
|
||||
|
||||
echo "=== Checking Gatekeeper assessment ==="
|
||||
spctl --assess --type exec -v "$APP_PATH" || echo "Note: Gatekeeper may reject until notarized"
|
||||
|
||||
echo "Code signing complete: $APP_PATH"
|
||||
Executable
+247
@@ -0,0 +1,247 @@
|
||||
#!/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
|
||||
}
|
||||
|
||||
# Pull image with retry using crane (handles OCI/Docker digest mismatch)
|
||||
pull_with_retry() {
|
||||
local image=$1
|
||||
local max_attempts=${2:-3}
|
||||
local attempt=1
|
||||
|
||||
# Use crane if available (handles OCI format correctly)
|
||||
if [ -x "${APP_DIR}/crane" ]; then
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
log_info "Pulling image with crane (attempt ${attempt}/${max_attempts})..."
|
||||
if "${APP_DIR}/crane" pull "${image}" /tmp/image.tar && docker load -i /tmp/image.tar; then
|
||||
rm -f /tmp/image.tar
|
||||
log_info "Image pulled and loaded successfully"
|
||||
return 0
|
||||
fi
|
||||
rm -f /tmp/image.tar
|
||||
log_warn "Pull failed, retrying in 5 seconds..."
|
||||
sleep 5
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
else
|
||||
# Fallback to docker pull if crane not available
|
||||
log_warn "crane not found at ${APP_DIR}/crane, falling back to docker pull"
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
log_info "Pulling image (attempt ${attempt}/${max_attempts})..."
|
||||
if docker pull "${image}"; then
|
||||
log_info "Image pulled successfully"
|
||||
return 0
|
||||
fi
|
||||
log_warn "Pull failed, retrying in 5 seconds..."
|
||||
sleep 5
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
fi
|
||||
log_error "Failed to pull image after ${max_attempts} attempts"
|
||||
return 1
|
||||
}
|
||||
|
||||
# Wait for a container to be healthy
|
||||
wait_for_healthy() {
|
||||
local container=$1
|
||||
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 (with retry for intermittent registry issues)
|
||||
if ! pull_with_retry "${new_image}" 3; then
|
||||
log_error "Failed to pull new image, aborting deployment"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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 "$@"
|
||||
Executable
+93
@@ -0,0 +1,93 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Inject Sparkle framework into a macOS .app bundle for auto-updates
|
||||
# Usage: inject_sparkle.sh <app_path>
|
||||
#
|
||||
# Environment variables (required):
|
||||
# SPARKLE_EDDSA_PUBLIC_KEY - EdDSA public key for verifying updates
|
||||
#
|
||||
# Optional environment variables:
|
||||
# SPARKLE_FEED_URL - Appcast URL (default: https://assets.eagle0.net/mac/appcast.xml)
|
||||
# SPARKLE_VERSION - Sparkle version to use (default: 2.6.4)
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
APP_PATH="$1"
|
||||
SPARKLE_VERSION="${SPARKLE_VERSION:-2.6.4}"
|
||||
SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://assets.eagle0.net/mac/appcast.xml}"
|
||||
SPARKLE_CACHE_DIR="/tmp/sparkle-cache"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${SPARKLE_EDDSA_PUBLIC_KEY:-}" ]; then
|
||||
echo "ERROR: SPARKLE_EDDSA_PUBLIC_KEY environment variable not set"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download Sparkle if not cached
|
||||
SPARKLE_DIR="$SPARKLE_CACHE_DIR/Sparkle-$SPARKLE_VERSION"
|
||||
if [ ! -d "$SPARKLE_DIR/Sparkle.framework" ]; then
|
||||
echo "=== Downloading Sparkle $SPARKLE_VERSION ==="
|
||||
mkdir -p "$SPARKLE_CACHE_DIR"
|
||||
SPARKLE_URL="https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz"
|
||||
curl -L "$SPARKLE_URL" | tar -xJ -C "$SPARKLE_CACHE_DIR"
|
||||
mv "$SPARKLE_CACHE_DIR/Sparkle-$SPARKLE_VERSION" "$SPARKLE_DIR" 2>/dev/null || true
|
||||
# If the extracted directory doesn't match version pattern, it may just be "Sparkle"
|
||||
if [ ! -d "$SPARKLE_DIR" ]; then
|
||||
mkdir -p "$SPARKLE_DIR"
|
||||
mv "$SPARKLE_CACHE_DIR/Sparkle.framework" "$SPARKLE_DIR/" 2>/dev/null || true
|
||||
mv "$SPARKLE_CACHE_DIR/bin" "$SPARKLE_DIR/" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== Injecting Sparkle framework ==="
|
||||
FRAMEWORKS_DIR="$APP_PATH/Contents/Frameworks"
|
||||
mkdir -p "$FRAMEWORKS_DIR"
|
||||
|
||||
# Copy Sparkle framework
|
||||
cp -R "$SPARKLE_DIR/Sparkle.framework" "$FRAMEWORKS_DIR/"
|
||||
|
||||
# Also copy the XPC services if present
|
||||
if [ -d "$SPARKLE_DIR/Sparkle.framework/Versions/B/XPCServices" ]; then
|
||||
echo "Sparkle XPC services present"
|
||||
fi
|
||||
|
||||
echo "=== Updating Info.plist ==="
|
||||
PLIST_PATH="$APP_PATH/Contents/Info.plist"
|
||||
|
||||
# Add Sparkle configuration to Info.plist
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$PLIST_PATH" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Add :SUFeedURL string '$SPARKLE_FEED_URL'" "$PLIST_PATH"
|
||||
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$PLIST_PATH" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string '$SPARKLE_EDDSA_PUBLIC_KEY'" "$PLIST_PATH"
|
||||
|
||||
/usr/libexec/PlistBuddy -c "Delete :SUEnableAutomaticChecks" "$PLIST_PATH" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Add :SUEnableAutomaticChecks bool true" "$PLIST_PATH"
|
||||
|
||||
# Set bundle version from git for Sparkle version comparison
|
||||
VERSION=$(git describe --tags --always 2>/dev/null || echo "1.0.0")
|
||||
BUILD_NUMBER=$(git rev-list --count HEAD 2>/dev/null || echo "1")
|
||||
|
||||
echo "Setting version: $VERSION (build $BUILD_NUMBER)"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST_PATH" 2>/dev/null || \
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string '$VERSION'" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" 2>/dev/null || \
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleVersion string '$BUILD_NUMBER'" "$PLIST_PATH"
|
||||
|
||||
# Add URL scheme for invitation codes (eagle0://invite?code=XXXX)
|
||||
echo "=== Adding URL scheme for invitation codes ==="
|
||||
/usr/libexec/PlistBuddy -c "Delete :CFBundleURLTypes" "$PLIST_PATH" 2>/dev/null || true
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes array" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0 dict" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLName string 'com.Shardok-Games.eagle0'" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes array" "$PLIST_PATH"
|
||||
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string 'eagle0'" "$PLIST_PATH"
|
||||
|
||||
echo "=== Sparkle injection complete ==="
|
||||
echo "App: $APP_PATH"
|
||||
echo "Feed URL: $SPARKLE_FEED_URL"
|
||||
echo "Version: $VERSION (build $BUILD_NUMBER)"
|
||||
Executable
+50
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Notarize a macOS .app bundle with Apple
|
||||
# Usage: notarize_mac_app.sh <app_path>
|
||||
#
|
||||
# Environment variables (required):
|
||||
# APPLE_ID - Apple Developer account email
|
||||
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
|
||||
# TEAM_ID - Apple Developer Team ID
|
||||
|
||||
set -euxo pipefail
|
||||
|
||||
APP_PATH="$1"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
echo "ERROR: App not found at $APP_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
|
||||
echo "ERROR: Required environment variables not set"
|
||||
echo " APPLE_ID: ${APPLE_ID:-<not set>}"
|
||||
echo " APP_SPECIFIC_PASSWORD: ${APP_SPECIFIC_PASSWORD:+<set>}"
|
||||
echo " TEAM_ID: ${TEAM_ID:-<not set>}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create ZIP for notarization submission
|
||||
ZIP_PATH="${APP_PATH%.app}.zip"
|
||||
echo "=== Creating ZIP for notarization: $ZIP_PATH ==="
|
||||
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
|
||||
|
||||
echo "=== Submitting to Apple for notarization ==="
|
||||
xcrun notarytool submit "$ZIP_PATH" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$TEAM_ID" \
|
||||
--wait
|
||||
|
||||
# Clean up the zip
|
||||
rm "$ZIP_PATH"
|
||||
|
||||
echo "=== Stapling notarization ticket to app ==="
|
||||
xcrun stapler staple "$APP_PATH"
|
||||
|
||||
echo "=== Verifying notarization ==="
|
||||
xcrun stapler validate "$APP_PATH"
|
||||
spctl --assess --type exec -v "$APP_PATH"
|
||||
|
||||
echo "Notarization complete: $APP_PATH"
|
||||
Executable
+142
@@ -0,0 +1,142 @@
|
||||
#!/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"
|
||||
@@ -107,6 +107,23 @@ auto AIAttackerStrategySelector::BestAttackerStrategy(
|
||||
alCache,
|
||||
battalionTypeGetter,
|
||||
braveWaterCost));
|
||||
} else if (!defenderPositions.empty()) {
|
||||
// Defenders exist but none are on castles - they're scattering/fleeing.
|
||||
// Chase them down rather than holding empty castles, since eliminating
|
||||
// all defenders also wins the battle via LAST_PLAYER_STANDING.
|
||||
chosenStrategy = AttackUnitsStrategy(GenerateTargetPriorities(
|
||||
Occupants(
|
||||
*gameState->units(),
|
||||
gameState->hex_map()->row_count(),
|
||||
gameState->hex_map()->column_count()),
|
||||
gameState->hex_map(),
|
||||
defenderPositions,
|
||||
attackerPid,
|
||||
attackerUnits,
|
||||
apdCache,
|
||||
alCache,
|
||||
battalionTypeGetter,
|
||||
braveWaterCost));
|
||||
} else {
|
||||
chosenStrategy = HoldCastlesStrategy;
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
<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" />
|
||||
@@ -154,6 +155,7 @@
|
||||
<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" />
|
||||
@@ -205,6 +207,7 @@
|
||||
<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" />
|
||||
@@ -222,8 +225,10 @@
|
||||
<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" />
|
||||
@@ -232,6 +237,7 @@
|
||||
<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" />
|
||||
@@ -315,6 +321,7 @@
|
||||
<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" />
|
||||
@@ -363,7 +370,9 @@
|
||||
<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" />
|
||||
@@ -383,6 +392,7 @@
|
||||
<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,10 +58,19 @@ namespace Auth {
|
||||
/// Routed to Go auth service.
|
||||
/// </summary>
|
||||
/// <param name="provider">Discord or Google</param>
|
||||
/// <returns>Tuple of (URL to open in browser, state token for polling)</returns>
|
||||
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(OAuthProvider provider) {
|
||||
/// <param name="invitationCode">Optional invitation code for new account
|
||||
/// registration</param> <returns>Tuple of (URL to open in browser, state token for
|
||||
/// polling)</returns>
|
||||
public async Task<(string authUrl, string state)> GetOAuthUrlAsync(
|
||||
OAuthProvider provider,
|
||||
string invitationCode = null) {
|
||||
var request = new GetOAuthUrlRequest { Provider = provider };
|
||||
|
||||
if (!string.IsNullOrEmpty(invitationCode)) {
|
||||
request.InvitationCode = invitationCode;
|
||||
Debug.Log("[AuthClient] Including invitation code in OAuth request");
|
||||
}
|
||||
|
||||
var response = await _authServiceClient.GetOAuthUrlAsync(request);
|
||||
|
||||
Debug.Log($"[AuthClient] Got OAuth URL for {provider}, state={response.State}");
|
||||
@@ -94,6 +103,10 @@ namespace Auth {
|
||||
case OAuthStatus.Expired:
|
||||
throw new Exception("OAuth session expired. Please try again.");
|
||||
|
||||
case OAuthStatus.InvitationRequired:
|
||||
Debug.Log("[AuthClient] Server requires invitation code for new account");
|
||||
return response; // Return so OAuthManager can handle this
|
||||
|
||||
case OAuthStatus.Pending:
|
||||
// Check timeout
|
||||
if ((DateTime.UtcNow - startTime).TotalMilliseconds > PollTimeoutMs) {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Auth {
|
||||
/// <summary>
|
||||
/// Manages invitation codes for new account registration.
|
||||
/// Reads codes from installer-provided file or allows manual entry.
|
||||
/// </summary>
|
||||
public static class InvitationCodeManager {
|
||||
private const string InvitationFileName = "invitation.json";
|
||||
private const string PlayerPrefsKey = "InvitationCode";
|
||||
|
||||
// Cache the code to avoid repeated file reads
|
||||
private static string _cachedCode;
|
||||
private static bool _cacheInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// Get the invitation code, if available.
|
||||
/// Checks: 1) Cached value, 2) PlayerPrefs, 3) File from installer
|
||||
/// </summary>
|
||||
public static string GetInvitationCode() {
|
||||
if (_cacheInitialized) { return _cachedCode; }
|
||||
|
||||
// Check PlayerPrefs first (manual entry takes priority)
|
||||
string prefsCode = PlayerPrefs.GetString(PlayerPrefsKey, null);
|
||||
if (!string.IsNullOrEmpty(prefsCode)) {
|
||||
_cachedCode = prefsCode;
|
||||
_cacheInitialized = true;
|
||||
Debug.Log("[InvitationCodeManager] Using invitation code from PlayerPrefs");
|
||||
return _cachedCode;
|
||||
}
|
||||
|
||||
// Try to read from installer file
|
||||
string fileCode = ReadFromFile();
|
||||
if (!string.IsNullOrEmpty(fileCode)) {
|
||||
_cachedCode = fileCode;
|
||||
_cacheInitialized = true;
|
||||
Debug.Log("[InvitationCodeManager] Using invitation code from installer file");
|
||||
return _cachedCode;
|
||||
}
|
||||
|
||||
_cacheInitialized = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set invitation code manually (e.g., from UI input).
|
||||
/// </summary>
|
||||
public static void SetInvitationCode(string code) {
|
||||
if (string.IsNullOrEmpty(code)) {
|
||||
PlayerPrefs.DeleteKey(PlayerPrefsKey);
|
||||
_cachedCode = null;
|
||||
} else {
|
||||
PlayerPrefs.SetString(PlayerPrefsKey, code);
|
||||
_cachedCode = code;
|
||||
}
|
||||
_cacheInitialized = true;
|
||||
PlayerPrefs.Save();
|
||||
Debug.Log(
|
||||
$"[InvitationCodeManager] Invitation code {(string.IsNullOrEmpty(code) ? "cleared" : "set")}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear the invitation code after successful account creation.
|
||||
/// </summary>
|
||||
public static void ClearInvitationCode() {
|
||||
PlayerPrefs.DeleteKey(PlayerPrefsKey);
|
||||
_cachedCode = null;
|
||||
_cacheInitialized = true;
|
||||
PlayerPrefs.Save();
|
||||
|
||||
// Also delete the installer file if it exists
|
||||
DeleteInstallerFile();
|
||||
|
||||
Debug.Log("[InvitationCodeManager] Invitation code cleared");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Check if an invitation code is available.
|
||||
/// </summary>
|
||||
public static bool HasInvitationCode => !string.IsNullOrEmpty(GetInvitationCode());
|
||||
|
||||
private static string ReadFromFile() {
|
||||
try {
|
||||
string installDir = GetInstallDirectory();
|
||||
if (string.IsNullOrEmpty(installDir)) { return null; }
|
||||
|
||||
string filePath = Path.Combine(installDir, InvitationFileName);
|
||||
if (!File.Exists(filePath)) { return null; }
|
||||
|
||||
string json = File.ReadAllText(filePath);
|
||||
// Simple JSON parsing for {"invitation_code":"XXXX"}
|
||||
var parsed = JsonUtility.FromJson<InvitationFile>(json);
|
||||
return parsed?.invitation_code;
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning(
|
||||
$"[InvitationCodeManager] Failed to read invitation file: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteInstallerFile() {
|
||||
try {
|
||||
string installDir = GetInstallDirectory();
|
||||
if (string.IsNullOrEmpty(installDir)) { return; }
|
||||
|
||||
string filePath = Path.Combine(installDir, InvitationFileName);
|
||||
if (File.Exists(filePath)) {
|
||||
File.Delete(filePath);
|
||||
Debug.Log("[InvitationCodeManager] Deleted installer invitation file");
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
Debug.LogWarning(
|
||||
$"[InvitationCodeManager] Failed to delete invitation file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetInstallDirectory() {
|
||||
#if UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
|
||||
// Windows: %LOCALAPPDATA%\eagle0
|
||||
string localAppData =
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
return Path.Combine(localAppData, "eagle0");
|
||||
#elif UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
|
||||
// Mac: ~/Library/Application Support/eagle0
|
||||
string appSupport =
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
|
||||
return Path.Combine(appSupport, "eagle0");
|
||||
#else
|
||||
// Other platforms not yet supported
|
||||
return null;
|
||||
#endif
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private class InvitationFile {
|
||||
public string invitation_code;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a2a19f11d1f82412e8e2811b366113ac
|
||||
@@ -25,6 +25,7 @@ namespace Auth {
|
||||
public event Action<string> OnLoginFailed;
|
||||
public event Action OnLogout;
|
||||
public event Action<bool> OnNewUserNeedsDisplayName; // true if new user
|
||||
public event Action OnInvitationRequired; // new user needs invitation code
|
||||
|
||||
public bool IsAuthenticated => TokenStorage.HasValidToken;
|
||||
public string DisplayName => TokenStorage.DisplayName;
|
||||
@@ -86,8 +87,11 @@ namespace Auth {
|
||||
_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);
|
||||
var (authUrl, state) = await _authClient.GetOAuthUrlAsync(provider, invitationCode);
|
||||
|
||||
// Open system browser
|
||||
Debug.Log($"[OAuthManager] Opening browser: {authUrl}");
|
||||
@@ -96,6 +100,13 @@ 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();
|
||||
TokenStorage.StoreTokens(
|
||||
@@ -106,8 +117,9 @@ namespace Auth {
|
||||
response.User.DisplayName ?? "",
|
||||
providerName);
|
||||
|
||||
// Notify listeners
|
||||
// Clear invitation code after successful new account creation
|
||||
if (response.IsNewUser) {
|
||||
InvitationCodeManager.ClearInvitationCode();
|
||||
OnNewUserNeedsDisplayName?.Invoke(true);
|
||||
} else {
|
||||
OnLoginSuccess?.Invoke(response.User);
|
||||
|
||||
+54
-1
@@ -100,6 +100,12 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
public Button setDisplayNameButton;
|
||||
public TextMeshProUGUI displayNameErrorText;
|
||||
|
||||
[Header("Invitation Code Entry")]
|
||||
public GameObject invitationCodePanel;
|
||||
public TMP_InputField invitationCodeField;
|
||||
public Button submitInvitationCodeButton;
|
||||
public TextMeshProUGUI invitationCodeErrorText;
|
||||
|
||||
[Header("Status Display")]
|
||||
public TextMeshProUGUI connectionStatusText;
|
||||
|
||||
@@ -233,6 +239,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
if (setDisplayNameButton != null) {
|
||||
setDisplayNameButton.onClick.AddListener(OnSetDisplayNameClicked);
|
||||
}
|
||||
if (submitInvitationCodeButton != null) {
|
||||
submitInvitationCodeButton.onClick.AddListener(OnSubmitInvitationCodeClicked);
|
||||
}
|
||||
|
||||
// Subscribe to OAuthManager events
|
||||
if (OAuthManager.Instance != null) {
|
||||
@@ -240,6 +249,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
OAuthManager.Instance.OnLoginFailed += OnOAuthLoginFailed;
|
||||
OAuthManager.Instance.OnNewUserNeedsDisplayName += OnNewUserNeedsDisplayName;
|
||||
OAuthManager.Instance.OnLogout += OnOAuthLogout;
|
||||
OAuthManager.Instance.OnInvitationRequired += OnInvitationRequired;
|
||||
}
|
||||
|
||||
// Show auth panel with stored accounts
|
||||
@@ -247,8 +257,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
}
|
||||
|
||||
private void ShowAuthPanel() {
|
||||
// Hide display name panel
|
||||
// Hide other panels
|
||||
if (displayNamePanel != null) displayNamePanel.SetActive(false);
|
||||
if (invitationCodePanel != null) invitationCodePanel.SetActive(false);
|
||||
|
||||
// Show OAuth panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(true);
|
||||
@@ -454,11 +465,53 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
// Show display name panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(false);
|
||||
if (invitationCodePanel != null) invitationCodePanel.SetActive(false);
|
||||
if (displayNamePanel != null) displayNamePanel.SetActive(true);
|
||||
if (displayNameErrorText != null) displayNameErrorText.text = "";
|
||||
});
|
||||
}
|
||||
|
||||
private void OnInvitationRequired() {
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
// Show invitation code entry panel
|
||||
if (oauthPanel != null) oauthPanel.SetActive(false);
|
||||
if (displayNamePanel != null) displayNamePanel.SetActive(false);
|
||||
if (invitationCodePanel != null) invitationCodePanel.SetActive(true);
|
||||
if (invitationCodeErrorText != null) {
|
||||
invitationCodeErrorText.text =
|
||||
"An invitation code is required to create a new account.";
|
||||
}
|
||||
if (invitationCodeField != null) invitationCodeField.text = "";
|
||||
});
|
||||
}
|
||||
|
||||
private void OnSubmitInvitationCodeClicked() {
|
||||
if (invitationCodeField == null) return;
|
||||
|
||||
var code = invitationCodeField.text.Trim();
|
||||
if (string.IsNullOrEmpty(code)) {
|
||||
if (invitationCodeErrorText != null) {
|
||||
invitationCodeErrorText.text = "Please enter an invitation code";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the code and return to login screen to retry
|
||||
InvitationCodeManager.SetInvitationCode(code);
|
||||
|
||||
if (invitationCodeErrorText != null) {
|
||||
invitationCodeErrorText.text = "Code saved. Please sign in again.";
|
||||
}
|
||||
|
||||
// Return to auth panel after a short delay
|
||||
MainQueue.Q.Enqueue(() => {
|
||||
if (oauthStatusText != null) {
|
||||
oauthStatusText.text = "Invitation code saved. Please sign in to continue.";
|
||||
}
|
||||
ShowAuthPanel();
|
||||
});
|
||||
}
|
||||
|
||||
private async void OnSetDisplayNameClicked() {
|
||||
if (displayNameField == null || OAuthManager.Instance == null) return;
|
||||
|
||||
|
||||
+25
-3
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -158,9 +159,11 @@ namespace eagle {
|
||||
|
||||
public void StopAll() {
|
||||
_newModel = null;
|
||||
ModelUpdater.StopListeningForUpdates();
|
||||
ModelUpdater.UpdateAction = null;
|
||||
ModelUpdater = null;
|
||||
if (ModelUpdater != null) {
|
||||
ModelUpdater.StopListeningForUpdates();
|
||||
ModelUpdater.UpdateAction = null;
|
||||
ModelUpdater = null;
|
||||
}
|
||||
SwapModel();
|
||||
Model = null;
|
||||
chronicleCanvasController.Entries = new List<ChronicleEntry>();
|
||||
@@ -281,6 +284,13 @@ 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
|
||||
@@ -362,6 +372,12 @@ 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) {
|
||||
@@ -477,6 +493,9 @@ 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;
|
||||
@@ -625,6 +644,9 @@ 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); }
|
||||
|
||||
@@ -2992,6 +2992,88 @@ MonoBehaviour:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_IsOn: 1
|
||||
--- !u!1 &16227905
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 16227909}
|
||||
- component: {fileID: 16227908}
|
||||
- component: {fileID: 16227907}
|
||||
- component: {fileID: 16227906}
|
||||
m_Layer: 0
|
||||
m_Name: TutorialManager
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!114 &16227906
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 16227905}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 370b3269832442e196d1b432bdae2f1e, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::Eagle0.Tutorial.TutorialTestSetup
|
||||
EnableTestTutorials: 1
|
||||
ResetProgressOnStart: 0
|
||||
--- !u!114 &16227907
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 16227905}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 52abae862492497cb7a3f8c72f837b37, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::Eagle0.Tutorial.TutorialUIManager
|
||||
ModalPanel: {fileID: 0}
|
||||
OverlayController: {fileID: 0}
|
||||
HintIndicatorPrefab: {fileID: 0}
|
||||
TutorialCanvas: {fileID: 0}
|
||||
UseFallbackUI: 1
|
||||
FallbackFont: {fileID: 12800000, guid: f0f6814ff8ef048bcbfd5002f77188ea, type: 3}
|
||||
--- !u!114 &16227908
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 16227905}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: c529affa73b04d129ec5826ed0ac75e7, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier: Assembly-CSharp::Eagle0.Tutorial.TutorialManager
|
||||
TutorialsEnabled: 1
|
||||
OnboardingSequence: {fileID: 0}
|
||||
UIManager: {fileID: 16227907}
|
||||
DebugLogging: 0
|
||||
--- !u!4 &16227909
|
||||
Transform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 16227905}
|
||||
serializedVersion: 2
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 2560, y: 1440, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 0}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
--- !u!1 &17277548
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -8532,10 +8614,10 @@ RectTransform:
|
||||
- {fileID: 1396438488}
|
||||
m_Father: {fileID: 2130578398}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 333.5, y: -11.255}
|
||||
m_SizeDelta: {x: 200, y: 20}
|
||||
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!114 &75920627
|
||||
MonoBehaviour:
|
||||
@@ -10927,10 +11009,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 2130578398}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 218.5, y: -11.255}
|
||||
m_SizeDelta: {x: 20, y: 20}
|
||||
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!114 &103511974
|
||||
MonoBehaviour:
|
||||
@@ -12298,10 +12380,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 683322975}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 306.5, y: -40}
|
||||
m_SizeDelta: {x: 51, y: 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!114 &114306270
|
||||
MonoBehaviour:
|
||||
@@ -15614,6 +15696,10 @@ MonoBehaviour:
|
||||
displayNameField: {fileID: 1956057658}
|
||||
setDisplayNameButton: {fileID: 700483942}
|
||||
displayNameErrorText: {fileID: 77360322}
|
||||
invitationCodePanel: {fileID: 0}
|
||||
invitationCodeField: {fileID: 0}
|
||||
submitInvitationCodeButton: {fileID: 0}
|
||||
invitationCodeErrorText: {fileID: 0}
|
||||
connectionStatusText: {fileID: 77360322}
|
||||
connectionPanel: {fileID: 596196018}
|
||||
gameSelectionPanel: {fileID: 1102342355}
|
||||
@@ -20624,10 +20710,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 1566150938}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 256, y: -32.504997}
|
||||
m_SizeDelta: {x: 492, y: 45.01}
|
||||
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!114 &171635196
|
||||
MonoBehaviour:
|
||||
@@ -29397,7 +29483,7 @@ RectTransform:
|
||||
m_Father: {fileID: 208702977}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 10, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
@@ -31829,7 +31915,7 @@ RectTransform:
|
||||
m_Father: {fileID: 1396438488}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 20, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
@@ -33967,6 +34053,7 @@ MonoBehaviour:
|
||||
eagleGameController: {fileID: 1965467940}
|
||||
ShardokCanvas: {fileID: 2088078267}
|
||||
connectionHandler: {fileID: 135486720}
|
||||
resetTutorialsButton: {fileID: 0}
|
||||
--- !u!1 &305665272
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -36778,10 +36865,10 @@ RectTransform:
|
||||
- {fileID: 1666727525}
|
||||
m_Father: {fileID: 2039520752}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 321, y: -11.255}
|
||||
m_SizeDelta: {x: 200, y: 20}
|
||||
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!114 &329919723
|
||||
MonoBehaviour:
|
||||
@@ -47131,8 +47218,8 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 1666727525}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0.1, y: 0}
|
||||
m_AnchorMax: {x: 0.1, y: 1}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 20, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
@@ -64015,10 +64102,10 @@ RectTransform:
|
||||
- {fileID: 1375685348}
|
||||
m_Father: {fileID: 1566150938}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 256, y: -215.03}
|
||||
m_SizeDelta: {x: 492, y: 30}
|
||||
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!114 &548637657
|
||||
MonoBehaviour:
|
||||
@@ -71487,10 +71574,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 1566150938}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 10, y: -120.03}
|
||||
m_SizeDelta: {x: 0, y: 10}
|
||||
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!114 &590819392
|
||||
MonoBehaviour:
|
||||
@@ -74595,6 +74682,146 @@ CanvasRenderer:
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 624827069}
|
||||
m_CullTransparentMesh: 0
|
||||
--- !u!1 &625062879
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 625062880}
|
||||
- component: {fileID: 625062882}
|
||||
- component: {fileID: 625062881}
|
||||
m_Layer: 0
|
||||
m_Name: Open Resources
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &625062880
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 625062879}
|
||||
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: 673182551}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 1, y: 1}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 0, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!114 &625062881
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 625062879}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_RaycastTarget: 1
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_text: 'Reset Tutorials
|
||||
|
||||
'
|
||||
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!222 &625062882
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 625062879}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!1 &625252446
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -82452,6 +82679,160 @@ CanvasRenderer:
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 673008387}
|
||||
m_CullTransparentMesh: 0
|
||||
--- !u!1 &673182550
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 673182551}
|
||||
- component: {fileID: 673182555}
|
||||
- component: {fileID: 673182554}
|
||||
- component: {fileID: 673182553}
|
||||
- component: {fileID: 673182552}
|
||||
m_Layer: 0
|
||||
m_Name: Reset Tutorials Button
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &673182551
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 673182550}
|
||||
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: 625062880}
|
||||
m_Father: {fileID: 1566150938}
|
||||
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!114 &673182552
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 673182550}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_IgnoreLayout: 0
|
||||
m_MinWidth: -1
|
||||
m_MinHeight: 30
|
||||
m_PreferredWidth: -1
|
||||
m_PreferredHeight: 30
|
||||
m_FlexibleWidth: 1
|
||||
m_FlexibleHeight: -1
|
||||
m_LayoutPriority: 1
|
||||
--- !u!114 &673182553
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 673182550}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
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: 673182554}
|
||||
m_OnClick:
|
||||
m_PersistentCalls:
|
||||
m_Calls:
|
||||
- m_Target: {fileID: 300036928}
|
||||
m_TargetAssemblyTypeName: SettingsPanelController, Assembly-CSharp
|
||||
m_MethodName: OnResetTutorialsClick
|
||||
m_Mode: 1
|
||||
m_Arguments:
|
||||
m_ObjectArgument: {fileID: 0}
|
||||
m_ObjectArgumentAssemblyTypeName: UnityEngine.Object, UnityEngine
|
||||
m_IntArgument: 0
|
||||
m_FloatArgument: 0
|
||||
m_StringArgument:
|
||||
m_BoolArgument: 0
|
||||
m_CallState: 2
|
||||
--- !u!114 &673182554
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 673182550}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 1, g: 1, b: 1, a: 1}
|
||||
m_RaycastTarget: 1
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_Sprite: {fileID: 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!222 &673182555
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 673182550}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!1 &673307124
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -83590,10 +83971,10 @@ RectTransform:
|
||||
- {fileID: 1119022577}
|
||||
m_Father: {fileID: 1566150938}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 256, y: -354}
|
||||
m_SizeDelta: {x: 492, y: 40}
|
||||
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!114 &683322976
|
||||
MonoBehaviour:
|
||||
@@ -93480,7 +93861,7 @@ GameObject:
|
||||
- component: {fileID: 749366808}
|
||||
- component: {fileID: 749366807}
|
||||
m_Layer: 0
|
||||
m_Name: Button
|
||||
m_Name: Open Resources Button
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
@@ -93501,10 +93882,10 @@ RectTransform:
|
||||
- {fileID: 392813076}
|
||||
m_Father: {fileID: 1566150938}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 256, y: -250.03}
|
||||
m_SizeDelta: {x: 492, y: 30}
|
||||
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!114 &749366807
|
||||
MonoBehaviour:
|
||||
@@ -95360,10 +95741,10 @@ RectTransform:
|
||||
- {fileID: 699949417}
|
||||
m_Father: {fileID: 1566150938}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 256, y: -180.03}
|
||||
m_SizeDelta: {x: 492, y: 30}
|
||||
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!114 &770361456
|
||||
MonoBehaviour:
|
||||
@@ -96170,10 +96551,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 2039520752}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 208.5, y: -11.255}
|
||||
m_SizeDelta: {x: 15, y: 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!114 &779503534
|
||||
MonoBehaviour:
|
||||
@@ -99264,10 +99645,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 2039520752}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 446, y: -11.255}
|
||||
m_SizeDelta: {x: 40, y: 15.01}
|
||||
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!114 &806086202
|
||||
MonoBehaviour:
|
||||
@@ -99806,10 +100187,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 1566150938}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 10, y: -313.27002}
|
||||
m_SizeDelta: {x: 0, y: 31.459991}
|
||||
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!114 &808870206
|
||||
MonoBehaviour:
|
||||
@@ -105263,10 +105644,10 @@ RectTransform:
|
||||
- {fileID: 113544276}
|
||||
m_Father: {fileID: 1590704177}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 333.5, y: -11.255}
|
||||
m_SizeDelta: {x: 200, y: 20}
|
||||
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!114 &858025936
|
||||
MonoBehaviour:
|
||||
@@ -121385,10 +121766,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 1590704177}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 196, y: -11.255}
|
||||
m_SizeDelta: {x: 15, y: 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!114 &972401127
|
||||
MonoBehaviour:
|
||||
@@ -122506,7 +122887,7 @@ RectTransform:
|
||||
m_Father: {fileID: 113544276}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 20, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
@@ -123520,7 +123901,7 @@ RectTransform:
|
||||
m_Father: {fileID: 1425392451}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0.1, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 10, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
@@ -140643,10 +141024,10 @@ RectTransform:
|
||||
- {fileID: 717768291}
|
||||
m_Father: {fileID: 683322975}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 417, y: -20}
|
||||
m_SizeDelta: {x: 150, y: 40}
|
||||
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!114 &1119022578
|
||||
MonoBehaviour:
|
||||
@@ -147583,10 +147964,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 683322975}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 135.5, y: -40}
|
||||
m_SizeDelta: {x: 51, y: 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!114 &1184808158
|
||||
MonoBehaviour:
|
||||
@@ -153109,10 +153490,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 2130578398}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 108.5, y: -11.255}
|
||||
m_SizeDelta: {x: 150, y: 22.51}
|
||||
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!114 &1219993393
|
||||
MonoBehaviour:
|
||||
@@ -174192,10 +174573,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 2130578398}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 196, y: -11.255}
|
||||
m_SizeDelta: {x: 15, y: 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!114 &1390131026
|
||||
MonoBehaviour:
|
||||
@@ -178285,10 +178666,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 2039520752}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 111, y: -11.255}
|
||||
m_SizeDelta: {x: 170, y: 22.51}
|
||||
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!114 &1415802613
|
||||
MonoBehaviour:
|
||||
@@ -178444,10 +178825,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 2130578398}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 448.5, y: -11.255}
|
||||
m_SizeDelta: {x: 20, y: 20}
|
||||
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!114 &1415887397
|
||||
MonoBehaviour:
|
||||
@@ -181904,10 +182285,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 1590704177}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 108.5, y: -11.255}
|
||||
m_SizeDelta: {x: 150, y: 22.51}
|
||||
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!114 &1448655991
|
||||
MonoBehaviour:
|
||||
@@ -196899,10 +197280,10 @@ RectTransform:
|
||||
- {fileID: 1122913432}
|
||||
m_Father: {fileID: 683322975}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 50, y: -20}
|
||||
m_SizeDelta: {x: 100, y: 40}
|
||||
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!114 &1557328375
|
||||
MonoBehaviour:
|
||||
@@ -198649,7 +199030,7 @@ GameObject:
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 2147483647
|
||||
m_IsActive: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &1566150938
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -198670,6 +199051,7 @@ RectTransform:
|
||||
- {fileID: 770361455}
|
||||
- {fileID: 548637656}
|
||||
- {fileID: 749366806}
|
||||
- {fileID: 673182551}
|
||||
- {fileID: 2039520752}
|
||||
- {fileID: 808870205}
|
||||
- {fileID: 683322975}
|
||||
@@ -200241,10 +200623,10 @@ RectTransform:
|
||||
- {fileID: 2123746368}
|
||||
m_Father: {fileID: 1566150938}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 256, y: -98.774994}
|
||||
m_SizeDelta: {x: 492, y: 22.51}
|
||||
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!114 &1590704178
|
||||
MonoBehaviour:
|
||||
@@ -219035,10 +219417,10 @@ RectTransform:
|
||||
- {fileID: 543540660}
|
||||
m_Father: {fileID: 683322975}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 221, y: -20}
|
||||
m_SizeDelta: {x: 100, y: 40}
|
||||
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!114 &1775111780
|
||||
MonoBehaviour:
|
||||
@@ -221290,7 +221672,7 @@ RectTransform:
|
||||
m_Father: {fileID: 1110049512}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 10, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
@@ -247406,10 +247788,10 @@ RectTransform:
|
||||
- {fileID: 806086201}
|
||||
m_Father: {fileID: 1566150938}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 256, y: -281.285}
|
||||
m_SizeDelta: {x: 492, y: 22.51}
|
||||
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!114 &2039520753
|
||||
MonoBehaviour:
|
||||
@@ -252578,10 +252960,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 1590704177}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 218.5, y: -11.255}
|
||||
m_SizeDelta: {x: 20, y: 20}
|
||||
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!114 &2096030393
|
||||
MonoBehaviour:
|
||||
@@ -255937,7 +256319,7 @@ GameObject:
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
m_IsActive: 0
|
||||
--- !u!224 &2109805906
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -255957,7 +256339,7 @@ RectTransform:
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 256, y: -145.03}
|
||||
m_AnchoredPosition: {x: 256, y: -142.85143}
|
||||
m_SizeDelta: {x: 492, y: 30}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!114 &2109805907
|
||||
@@ -257369,10 +257751,10 @@ RectTransform:
|
||||
m_Children: []
|
||||
m_Father: {fileID: 1590704177}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 448.5, y: -11.255}
|
||||
m_SizeDelta: {x: 20, y: 20}
|
||||
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!114 &2123746369
|
||||
MonoBehaviour:
|
||||
@@ -258672,10 +259054,10 @@ RectTransform:
|
||||
- {fileID: 1415887396}
|
||||
m_Father: {fileID: 1566150938}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
m_AnchorMax: {x: 0, y: 1}
|
||||
m_AnchoredPosition: {x: 256, y: -71.265}
|
||||
m_SizeDelta: {x: 492, y: 22.51}
|
||||
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!114 &2130578399
|
||||
MonoBehaviour:
|
||||
@@ -268781,6 +269163,7 @@ SceneRoots:
|
||||
m_Roots:
|
||||
- {fileID: 279614944}
|
||||
- {fileID: 815907755}
|
||||
- {fileID: 16227909}
|
||||
- {fileID: 122332320}
|
||||
- {fileID: 682560790}
|
||||
- {fileID: 1342510189}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Diagnostics;
|
||||
using System.IO;
|
||||
using common;
|
||||
using eagle;
|
||||
using Eagle0.Tutorial;
|
||||
using Net.Eagle0.Shardok.Api;
|
||||
using Shardok;
|
||||
using TMPro;
|
||||
@@ -49,6 +50,8 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
public GameObject ShardokCanvas;
|
||||
public ConnectionHandler connectionHandler;
|
||||
|
||||
public Button resetTutorialsButton;
|
||||
|
||||
void Start() {
|
||||
_active = false;
|
||||
panel.SetActive(false);
|
||||
@@ -148,6 +151,11 @@ public class SettingsPanelController : MonoBehaviour {
|
||||
Process.Start(Path.Combine(Application.persistentDataPath, "eagle0", "Resources"));
|
||||
}
|
||||
|
||||
public void OnResetTutorialsClick() {
|
||||
TutorialManager.Instance?.ResetAllProgress();
|
||||
UnityEngine.Debug.Log("Tutorial progress reset");
|
||||
}
|
||||
|
||||
private void HandleLobby() {
|
||||
eagleGameController.StopAll();
|
||||
eagleGameController.gameObject.SetActive(false);
|
||||
|
||||
@@ -68,14 +68,10 @@ 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,7 +59,7 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? sourcePos = __hexGrid.GetCellLocalPosition(sourceCellIndex);
|
||||
Vector2? sourcePos = __hexGrid.GetCellCenterPosition(sourceCellIndex);
|
||||
Vector2? targetPos = __hexGrid.GetCellCenterPosition(targetCellIndex);
|
||||
|
||||
if (sourcePos == null || targetPos == null) {
|
||||
|
||||
@@ -462,11 +462,14 @@ public class HexGrid : MonoBehaviour {
|
||||
}
|
||||
|
||||
public void SetProfessionImage(int cellIndex, Texture image) {
|
||||
var professionImage = cells[cellIndex].ProfessionImage;
|
||||
if (professionImage == null) return;
|
||||
|
||||
if (image == null) {
|
||||
cells[cellIndex].ProfessionImage.gameObject.SetActive(value: false);
|
||||
professionImage.gameObject.SetActive(value: false);
|
||||
} else {
|
||||
cells[cellIndex].ProfessionImage.texture = image;
|
||||
cells[cellIndex].ProfessionImage.gameObject.SetActive(value: true);
|
||||
professionImage.texture = image;
|
||||
professionImage.gameObject.SetActive(value: true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,11 +478,14 @@ public class HexGrid : MonoBehaviour {
|
||||
}
|
||||
|
||||
public void SetUnitTypeImage(int cellIndex, Texture image) {
|
||||
var unitTypeImage = cells[cellIndex].UnitTypeImage;
|
||||
if (unitTypeImage == null) return;
|
||||
|
||||
if (image == null) {
|
||||
cells[cellIndex].UnitTypeImage.gameObject.SetActive(value: false);
|
||||
unitTypeImage.gameObject.SetActive(value: false);
|
||||
} else {
|
||||
cells[cellIndex].UnitTypeImage.texture = image;
|
||||
cells[cellIndex].UnitTypeImage.gameObject.SetActive(value: true);
|
||||
unitTypeImage.texture = image;
|
||||
unitTypeImage.gameObject.SetActive(value: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,9 +96,22 @@ namespace Shardok {
|
||||
|
||||
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>
|
||||
@@ -122,8 +135,8 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? attackerPos = __hexGrid.GetCellLocalPosition(attackerCellIndex);
|
||||
Vector2? defenderPos = __hexGrid.GetCellLocalPosition(defenderCellIndex);
|
||||
Vector2? attackerPos = __hexGrid.GetCellCenterPosition(attackerCellIndex);
|
||||
Vector2? defenderPos = __hexGrid.GetCellCenterPosition(defenderCellIndex);
|
||||
|
||||
if (attackerPos == null || defenderPos == null) {
|
||||
Debug.LogWarning(
|
||||
@@ -131,10 +144,11 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_activeAnimation != null) { StopCoroutine(_activeAnimation); }
|
||||
if (_activeAnimation != null) {
|
||||
StopCoroutine(_activeAnimation);
|
||||
CleanupWeapons();
|
||||
}
|
||||
|
||||
Debug.Log(
|
||||
$"MeleeAnimator: Starting melee from cell {attackerCellIndex} ({attackerType}) to {defenderCellIndex} ({defenderType})");
|
||||
_activeAnimation = StartCoroutine(
|
||||
AnimateClash(attackerPos.Value, defenderPos.Value, attackerType, defenderType));
|
||||
}
|
||||
@@ -232,9 +246,9 @@ namespace Shardok {
|
||||
WeaponConfig attackerConfig = GetWeaponConfig(attackerType);
|
||||
WeaponConfig defenderConfig = GetWeaponConfig(defenderType);
|
||||
|
||||
// Create weapons with type-specific scaling
|
||||
GameObject attackerWeapon = CreateWeapon(attackerConfig, baseAngle);
|
||||
GameObject defenderWeapon = CreateWeapon(defenderConfig, baseAngle + 180f);
|
||||
// Create weapons with type-specific scaling (stored in class fields for cleanup)
|
||||
_attackerWeapon = CreateWeapon(attackerConfig, baseAngle);
|
||||
_defenderWeapon = CreateWeapon(defenderConfig, baseAngle + 180f);
|
||||
|
||||
bool attackerSwings = attackerConfig.swings;
|
||||
bool defenderSwings = defenderConfig.swings;
|
||||
@@ -259,8 +273,8 @@ 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;
|
||||
@@ -272,7 +286,7 @@ namespace Shardok {
|
||||
|
||||
// Swing toward clash point
|
||||
yield return StartCoroutine(AnimateWeaponSwing(
|
||||
attackerWeapon,
|
||||
_attackerWeapon,
|
||||
attackerStart,
|
||||
attackerClash,
|
||||
attackerBaseAngle,
|
||||
@@ -282,7 +296,7 @@ namespace Shardok {
|
||||
true));
|
||||
|
||||
yield return StartCoroutine(AnimateWeaponSwing(
|
||||
defenderWeapon,
|
||||
_defenderWeapon,
|
||||
defenderStart,
|
||||
defenderClash,
|
||||
defenderBaseAngle,
|
||||
@@ -293,8 +307,8 @@ namespace Shardok {
|
||||
|
||||
// Brief shake at impact
|
||||
yield return StartCoroutine(ImpactShake(
|
||||
attackerWeapon,
|
||||
defenderWeapon,
|
||||
_attackerWeapon,
|
||||
_defenderWeapon,
|
||||
attackerClash,
|
||||
defenderClash,
|
||||
0.05f,
|
||||
@@ -302,7 +316,7 @@ namespace Shardok {
|
||||
|
||||
// Swing back
|
||||
yield return StartCoroutine(AnimateWeaponSwing(
|
||||
attackerWeapon,
|
||||
_attackerWeapon,
|
||||
attackerClash,
|
||||
attackerStart,
|
||||
attackerBaseAngle,
|
||||
@@ -312,7 +326,7 @@ namespace Shardok {
|
||||
false));
|
||||
|
||||
yield return StartCoroutine(AnimateWeaponSwing(
|
||||
defenderWeapon,
|
||||
_defenderWeapon,
|
||||
defenderClash,
|
||||
defenderStart,
|
||||
defenderBaseAngle,
|
||||
@@ -323,8 +337,7 @@ namespace Shardok {
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if (attackerWeapon != null) { Destroy(attackerWeapon); }
|
||||
if (defenderWeapon != null) { Destroy(defenderWeapon); }
|
||||
CleanupWeapons();
|
||||
_activeAnimation = null;
|
||||
}
|
||||
|
||||
@@ -418,6 +431,7 @@ namespace Shardok {
|
||||
StopCoroutine(_activeAnimation);
|
||||
_activeAnimation = null;
|
||||
}
|
||||
CleanupWeapons();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ namespace Shardok {
|
||||
return;
|
||||
}
|
||||
|
||||
Vector2? sourcePos = _hexGrid.GetCellLocalPosition(sourceCellIndex);
|
||||
Vector2? targetPos = _hexGrid.GetCellLocalPosition(targetCellIndex);
|
||||
Vector2? sourcePos = _hexGrid.GetCellCenterPosition(sourceCellIndex);
|
||||
Vector2? targetPos = _hexGrid.GetCellCenterPosition(targetCellIndex);
|
||||
|
||||
if (sourcePos == null || targetPos == null) {
|
||||
Debug.LogWarning(
|
||||
|
||||
+22
-3
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -244,6 +245,8 @@ namespace Shardok {
|
||||
turnHistoryPanel.Clear();
|
||||
this.gameObject.SetActive(false);
|
||||
} else {
|
||||
// Notify tutorial system of turn end
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnTurnEnded();
|
||||
Model.EndTurn();
|
||||
}
|
||||
}
|
||||
@@ -394,8 +397,6 @@ namespace Shardok {
|
||||
|
||||
Model = shardokGameModel;
|
||||
|
||||
Model.UpdateAction = ModelUpdated;
|
||||
|
||||
HexMap map = Model.Map;
|
||||
Texture[] textures = new Texture[map.RowCount * map.ColumnCount];
|
||||
Coords coords = new Coords();
|
||||
@@ -421,10 +422,17 @@ 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
|
||||
@@ -544,6 +552,9 @@ 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);
|
||||
|
||||
@@ -568,7 +579,12 @@ namespace Shardok {
|
||||
if (Model.UnitsById.TryGetValue(
|
||||
historyEntry.Actor.Value,
|
||||
out var actorUnit)) {
|
||||
int sourceIndex = MapCoordsToGridIndex(actorUnit.Location);
|
||||
// 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);
|
||||
|
||||
// Get target - either coords (archery) or unit location (melee)
|
||||
int targetIndex = -1;
|
||||
@@ -1181,6 +1197,9 @@ namespace Shardok {
|
||||
}
|
||||
_selectedGridIndex = selectedIndex;
|
||||
RedrawCommandOverlays(selectedIndex, selectedIndex);
|
||||
|
||||
// Notify tutorial system of unit selection
|
||||
TutorialManager.Instance?.TriggerRegistry?.OnUnitSelected(selectedIndex);
|
||||
}
|
||||
|
||||
public void HandleActionClick(int clickedIndex) {
|
||||
|
||||
@@ -75,6 +75,13 @@ 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; }
|
||||
@@ -402,6 +409,14 @@ 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) {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0deea4d267414bcd9421d74e9c1d0d7e
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f80e1b28dcb84478b8a97821d82dbde7
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 97da9ce2a5d7418998c026d559ad0a13
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ad37a68e71e24b6a88ace636f74020dd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,139 @@
|
||||
# Tutorial System for Eagle0
|
||||
|
||||
## Overview
|
||||
|
||||
A modular tutorial system for the Eagle0 Unity client supporting:
|
||||
- **Onboarding**: Full guided playthrough of first several turns
|
||||
- **Contextual tutorials**: First-encounter and first-attempt triggers with subtle hints
|
||||
- **Mixed UI**: Modals for concepts, overlays for UI guidance, hint indicators
|
||||
- **Local persistence**: PlayerPrefs-based state tracking
|
||||
|
||||
---
|
||||
|
||||
## Current Status
|
||||
|
||||
### Completed
|
||||
- [x] **Phase 1: Foundation** - TutorialState, TutorialManager, TutorialStep/Sequence
|
||||
- [x] **Phase 2: UI (partial)** - IMGUI fallback modal with Stoke font
|
||||
- [x] **Phase 3: Triggers (partial)** - Basic trigger system with hooks in game controllers
|
||||
- [x] **Test setup** - TutorialTestSetup for province selection, battle entry, command triggers
|
||||
- [x] **Settings integration** - Reset Tutorials button in Settings panel
|
||||
|
||||
### Remaining
|
||||
- [ ] **Canvas UI prefabs** - Replace IMGUI fallback with styled Canvas-based modal
|
||||
- [ ] **Overlay system** - TutorialOverlayController for highlighting UI elements
|
||||
- [ ] **Hint indicators** - TutorialHintIndicator for subtle pulsing dots
|
||||
- [ ] **Real tutorial content** - Replace test tutorials with actual onboarding sequence
|
||||
- [ ] **More contextual triggers** - Diplomacy, hero recruitment, spells, terrain, etc.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
TutorialManager (Singleton, DontDestroyOnLoad)
|
||||
├── TutorialState (PlayerPrefs persistence)
|
||||
├── TutorialTriggerRegistry
|
||||
│ ├── OnboardingTriggers
|
||||
│ └── ContextualTriggers
|
||||
└── TutorialUIManager
|
||||
├── TutorialModalPanel (TODO)
|
||||
├── TutorialOverlayController (TODO)
|
||||
├── TutorialHintIndicator (TODO)
|
||||
└── IMGUI Fallback (working)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## File Structure (Current)
|
||||
|
||||
```
|
||||
Assets/Tutorial/
|
||||
├── TutorialManager.cs ✓ Singleton, coordinates everything
|
||||
├── TutorialState.cs ✓ PlayerPrefs persistence
|
||||
├── TutorialTestSetup.cs ✓ Test component for validation
|
||||
├── Content/
|
||||
│ ├── TutorialStep.cs ✓ Step data structure
|
||||
│ └── TutorialSequence.cs ✓ Sequence ScriptableObject
|
||||
├── Triggers/
|
||||
│ └── TutorialTriggerRegistry.cs ✓ Event routing
|
||||
└── UI/
|
||||
├── TutorialUIManager.cs ✓ UI coordination + IMGUI fallback
|
||||
├── TutorialModalPanel.cs ✓ Stub (needs Canvas implementation)
|
||||
├── TutorialOverlayController.cs ✓ Stub
|
||||
└── TutorialHintIndicator.cs ✓ Stub
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration Points (Implemented)
|
||||
|
||||
### EagleGameController.cs
|
||||
- `SetUpGame()`: Initializes TutorialManager, starts onboarding
|
||||
- `SwapModel()`: Calls `OnModelUpdated()`
|
||||
- `ProvinceWasSelected()`: Calls `OnProvinceSelected()`
|
||||
- `PostCommittedCommand()`: Calls `OnCommandIssued()`
|
||||
|
||||
### ShardokGameController.cs
|
||||
- `SetUpGame()`: Initializes TutorialManager for battle
|
||||
- `ModelUpdated()`: Calls `OnBattleAction()`
|
||||
- `OnTurnEnded()`: Calls `OnTurnEnded()`
|
||||
- Unit selection: Calls `OnUnitSelected()`
|
||||
|
||||
### SettingsPanelController.cs
|
||||
- Reset Tutorials button: Calls `TutorialManager.Instance.ResetAllProgress()`
|
||||
|
||||
---
|
||||
|
||||
## Onboarding Flow (Planned)
|
||||
|
||||
| Step | Type | Content | Completion |
|
||||
|------|------|---------|------------|
|
||||
| 1 | Modal | Welcome to Eagle0 | Button click |
|
||||
| 2 | Overlay | Map overview - "Select a province" | Province selected |
|
||||
| 3 | Overlay | Province info panel explanation | Button click |
|
||||
| 4 | Overlay | Command buttons - "Try March" | March issued |
|
||||
| 5 | Modal | Turn cycle explanation | Button click |
|
||||
| 6 | Hidden | Wait for battle available | Model update |
|
||||
| 7 | Modal | Battle introduction | Button click |
|
||||
| 8 | Overlay | "Battle!" button highlight | Battle entered |
|
||||
| 9 | Modal | Tactical combat overview | Button click |
|
||||
| 10 | Overlay | Select and move a unit | Move issued |
|
||||
| 11 | Overlay | Attack an enemy | Attack issued |
|
||||
| 12 | Overlay | End Turn button | Turn ended |
|
||||
| 13 | Modal | Onboarding complete! | Button click |
|
||||
|
||||
---
|
||||
|
||||
## Contextual Tutorials (Planned)
|
||||
|
||||
### Strategic (First Encounter)
|
||||
| ID | Trigger | Display |
|
||||
|----|---------|---------|
|
||||
| `diplomacy_offer` | First diplomacy command available | Modal |
|
||||
| `hero_recruitment` | First recruitment available | Modal |
|
||||
| `province_riot` | First riot in owned province | Modal |
|
||||
| `weather_control` | First weather command available | Overlay |
|
||||
| `prisoner_capture` | First prisoner captured | Modal |
|
||||
|
||||
### Tactical (First Encounter/Attempt)
|
||||
| ID | Trigger | Display |
|
||||
|----|---------|---------|
|
||||
| `spell_lightning` | Lightning spell available | Tooltip |
|
||||
| `spell_meteor` | Meteor spell available | Modal |
|
||||
| `spell_holywave` | Holy Wave available | Tooltip |
|
||||
| `spell_raisedead` | Raise Dead available | Modal |
|
||||
| `terrain_fire` | Fire hex encountered | Tooltip |
|
||||
| `terrain_water` | Water adjacent to unit | Tooltip |
|
||||
| `ability_charge` | Charge command available | Overlay |
|
||||
| `flanking` | Flanking opportunity | Hint |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
1. Add `TutorialTestSetup` component to TutorialManager GameObject
|
||||
2. Assign `Stoke-Regular.ttf` to `FallbackFont` on TutorialUIManager
|
||||
3. Enable `Debug Logging` on TutorialManager for console output
|
||||
4. Play game → select province → see test tutorial modal
|
||||
5. Use Settings → Reset Tutorials to test again
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6e2fbfb348eb443cb91395b3712870da
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
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"); }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 23dab249a548427091dc94baadedcebd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,374 @@
|
||||
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) {
|
||||
Log("StartOnboarding: No onboarding sequence assigned, skipping");
|
||||
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}"); }
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c529affa73b04d129ec5826ed0ac75e7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,186 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2424323130a24ac381f535ed932c34d3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,100 @@
|
||||
using UnityEngine;
|
||||
|
||||
namespace Eagle0.Tutorial {
|
||||
/// <summary>
|
||||
/// Test component that sets up a simple tutorial for end-to-end testing.
|
||||
/// Attach to TutorialManager GameObject to register test tutorials on startup.
|
||||
/// Can be removed once actual tutorial content is created in the Editor.
|
||||
/// </summary>
|
||||
public class TutorialTestSetup : MonoBehaviour {
|
||||
[Header("Test Configuration")]
|
||||
[Tooltip("Enable test tutorials")]
|
||||
public bool EnableTestTutorials = true;
|
||||
|
||||
[Tooltip("Reset tutorial progress on startup (for testing)")]
|
||||
public bool ResetProgressOnStart = false;
|
||||
|
||||
private void Start() {
|
||||
if (!EnableTestTutorials) return;
|
||||
|
||||
var manager = TutorialManager.Instance;
|
||||
if (manager == null) {
|
||||
Debug.LogWarning("TutorialTestSetup: TutorialManager not found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ResetProgressOnStart) { manager.ResetAllProgress(); }
|
||||
|
||||
RegisterTestTutorials(manager);
|
||||
Debug.Log("TutorialTestSetup: Test tutorials registered");
|
||||
}
|
||||
|
||||
private void RegisterTestTutorials(TutorialManager manager) {
|
||||
// Create a simple "first province selected" tutorial
|
||||
var provinceSelectedTutorial = CreateProvinceSelectedTutorial();
|
||||
manager.TriggerRegistry.RegisterTutorial(provinceSelectedTutorial, "province_selected");
|
||||
|
||||
// Create a simple "first battle" tutorial
|
||||
var firstBattleTutorial = CreateFirstBattleTutorial();
|
||||
manager.TriggerRegistry.RegisterTutorial(firstBattleTutorial, "battle_entered");
|
||||
|
||||
// Create a simple "first command" tutorial
|
||||
var firstCommandTutorial = CreateFirstCommandTutorial();
|
||||
manager.TriggerRegistry.RegisterTutorial(firstCommandTutorial, "command_issued");
|
||||
}
|
||||
|
||||
private TutorialSequence CreateProvinceSelectedTutorial() {
|
||||
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
|
||||
sequence.SequenceId = "test_province_selected";
|
||||
sequence.DisplayName = "Province Selection";
|
||||
|
||||
sequence.Steps.Add(new TutorialStep {
|
||||
StepId = "province_selected_intro",
|
||||
Title = "Province Selected!",
|
||||
Description =
|
||||
"You've selected a province. Here you can see information about the province and issue commands to your forces.\n\n(This is a test tutorial to verify the tutorial system is working.)",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
});
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
private TutorialSequence CreateFirstBattleTutorial() {
|
||||
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
|
||||
sequence.SequenceId = "test_first_battle";
|
||||
sequence.DisplayName = "First Battle";
|
||||
|
||||
sequence.Steps.Add(new TutorialStep {
|
||||
StepId = "battle_intro",
|
||||
Title = "Battle Begins!",
|
||||
Description =
|
||||
"You've entered tactical combat. Command your units on the hex grid to defeat the enemy.\n\n(This is a test tutorial to verify the tutorial system is working.)",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
});
|
||||
|
||||
return sequence;
|
||||
}
|
||||
|
||||
private TutorialSequence CreateFirstCommandTutorial() {
|
||||
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
|
||||
sequence.SequenceId = "test_first_command";
|
||||
sequence.DisplayName = "First Command";
|
||||
|
||||
sequence.Steps.Add(new TutorialStep {
|
||||
StepId = "command_issued_intro",
|
||||
Title = "Command Issued!",
|
||||
Description =
|
||||
"You've issued your first command. Commands are processed at the end of each turn.\n\n(This is a test tutorial to verify the tutorial system is working.)",
|
||||
DisplayMode = TutorialDisplayMode.Modal,
|
||||
CompletionType = TutorialCompletionType.ButtonClick,
|
||||
AllowSkip = true
|
||||
});
|
||||
|
||||
return sequence;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 370b3269832442e196d1b432bdae2f1e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a53ad5e16bc649fe9344b1a1c700dbd8
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f3f3016726d6473da721d6e468baab8a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 461d5eac2fdd4d509a15185a42677886
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+278
@@ -0,0 +1,278 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3cf75368a9b643a18f00ff43e51af7c3
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+318
@@ -0,0 +1,318 @@
|
||||
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;
|
||||
|
||||
[Header("Fallback UI")]
|
||||
[Tooltip("Use IMGUI fallback when no ModalPanel is assigned")]
|
||||
public bool UseFallbackUI = true;
|
||||
|
||||
[Tooltip("Font for IMGUI fallback (assign Stoke-Regular)")]
|
||||
public Font FallbackFont;
|
||||
|
||||
// Active hints
|
||||
private Dictionary<string, TutorialHintIndicator> _activeHints =
|
||||
new Dictionary<string, TutorialHintIndicator>();
|
||||
|
||||
// Fallback modal state
|
||||
private bool _fallbackModalVisible;
|
||||
private TutorialStep _fallbackStep;
|
||||
private Action _fallbackOnContinue;
|
||||
private Action _fallbackOnSkip;
|
||||
private int _fallbackCurrentIndex;
|
||||
private int _fallbackTotalSteps;
|
||||
private bool _fallbackIsOnboarding;
|
||||
|
||||
private void Awake() {
|
||||
// Ensure canvas is set up correctly
|
||||
if (TutorialCanvas != null) {
|
||||
TutorialCanvas.sortingOrder = 1000; // Above most game UI
|
||||
}
|
||||
}
|
||||
|
||||
private void OnGUI() {
|
||||
if (!_fallbackModalVisible || _fallbackStep == null) return;
|
||||
|
||||
// Semi-transparent background
|
||||
GUI.color = new Color(0, 0, 0, 0.7f);
|
||||
GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), Texture2D.whiteTexture);
|
||||
GUI.color = Color.white;
|
||||
|
||||
// Modal window
|
||||
float windowWidth = Mathf.Min(1200, Screen.width - 40);
|
||||
float windowHeight = 600;
|
||||
float windowX = (Screen.width - windowWidth) / 2;
|
||||
float windowY = (Screen.height - windowHeight) / 2;
|
||||
|
||||
GUIStyle windowStyle = new GUIStyle(GUI.skin.window);
|
||||
windowStyle.fontSize = 48;
|
||||
if (FallbackFont != null) windowStyle.font = FallbackFont;
|
||||
|
||||
GUI.Window(
|
||||
12345,
|
||||
new Rect(windowX, windowY, windowWidth, windowHeight),
|
||||
DrawFallbackModal,
|
||||
_fallbackStep.Title ?? "Tutorial",
|
||||
windowStyle);
|
||||
}
|
||||
|
||||
private void DrawFallbackModal(int windowId) {
|
||||
GUILayout.Space(60);
|
||||
|
||||
// Description
|
||||
GUIStyle descStyle = new GUIStyle(GUI.skin.label);
|
||||
descStyle.wordWrap = true;
|
||||
descStyle.fontSize = 40;
|
||||
if (FallbackFont != null) descStyle.font = FallbackFont;
|
||||
GUILayout.Label(
|
||||
_fallbackStep.Description ?? "",
|
||||
descStyle,
|
||||
GUILayout.ExpandHeight(true));
|
||||
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
// Progress
|
||||
if (_fallbackTotalSteps > 1) {
|
||||
GUIStyle progressStyle = new GUIStyle(GUI.skin.label);
|
||||
progressStyle.fontSize = 32;
|
||||
if (FallbackFont != null) progressStyle.font = FallbackFont;
|
||||
GUILayout.Label(
|
||||
$"Step {_fallbackCurrentIndex + 1} of {_fallbackTotalSteps}",
|
||||
progressStyle,
|
||||
GUILayout.ExpandWidth(true));
|
||||
}
|
||||
|
||||
GUILayout.Space(30);
|
||||
|
||||
// Button style
|
||||
GUIStyle buttonStyle = new GUIStyle(GUI.skin.button);
|
||||
buttonStyle.fontSize = 36;
|
||||
if (FallbackFont != null) buttonStyle.font = FallbackFont;
|
||||
|
||||
// Buttons
|
||||
GUILayout.BeginHorizontal();
|
||||
|
||||
if (_fallbackStep.AllowSkip) {
|
||||
if (GUILayout.Button(
|
||||
"Skip",
|
||||
buttonStyle,
|
||||
GUILayout.Width(200),
|
||||
GUILayout.Height(80))) {
|
||||
var callback = _fallbackOnSkip;
|
||||
HideFallbackModal();
|
||||
callback?.Invoke();
|
||||
}
|
||||
|
||||
if (_fallbackIsOnboarding) {
|
||||
if (GUILayout.Button(
|
||||
"Skip All",
|
||||
buttonStyle,
|
||||
GUILayout.Width(200),
|
||||
GUILayout.Height(80))) {
|
||||
HideFallbackModal();
|
||||
TutorialManager.Instance?.SkipAllOnboarding();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GUILayout.FlexibleSpace();
|
||||
|
||||
string buttonText =
|
||||
_fallbackCurrentIndex >= _fallbackTotalSteps - 1 ? "Got it!" : "Continue";
|
||||
if (GUILayout.Button(
|
||||
buttonText,
|
||||
buttonStyle,
|
||||
GUILayout.Width(240),
|
||||
GUILayout.Height(80))) {
|
||||
var callback = _fallbackOnContinue;
|
||||
HideFallbackModal();
|
||||
callback?.Invoke();
|
||||
}
|
||||
|
||||
GUILayout.EndHorizontal();
|
||||
GUILayout.Space(30);
|
||||
}
|
||||
|
||||
private void ShowFallbackModal(
|
||||
TutorialStep step,
|
||||
int currentIndex,
|
||||
int totalSteps,
|
||||
Action onContinue,
|
||||
Action onSkip,
|
||||
bool isOnboarding) {
|
||||
_fallbackStep = step;
|
||||
_fallbackCurrentIndex = currentIndex;
|
||||
_fallbackTotalSteps = totalSteps;
|
||||
_fallbackOnContinue = onContinue;
|
||||
_fallbackOnSkip = onSkip;
|
||||
_fallbackIsOnboarding = isOnboarding;
|
||||
_fallbackModalVisible = true;
|
||||
}
|
||||
|
||||
private void HideFallbackModal() {
|
||||
_fallbackModalVisible = false;
|
||||
_fallbackStep = null;
|
||||
_fallbackOnContinue = null;
|
||||
_fallbackOnSkip = null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows a modal dialog for a tutorial step.
|
||||
/// </summary>
|
||||
public void ShowModal(
|
||||
TutorialStep step,
|
||||
int currentIndex,
|
||||
int totalSteps,
|
||||
Action onComplete,
|
||||
Action onSkip,
|
||||
bool isOnboarding) {
|
||||
if (ModalPanel != null) {
|
||||
ModalPanel.Show(step, currentIndex, totalSteps, onComplete, onSkip, isOnboarding);
|
||||
} else if (UseFallbackUI) {
|
||||
ShowFallbackModal(step, currentIndex, totalSteps, onComplete, onSkip, isOnboarding);
|
||||
} else {
|
||||
Debug.LogWarning("TutorialUIManager: No ModalPanel assigned and fallback disabled");
|
||||
onComplete?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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();
|
||||
HideFallbackModal();
|
||||
// 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(); }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 52abae862492497cb7a3f8c72f837b37
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+1
-49
@@ -1,53 +1,5 @@
|
||||
using System;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace EagleInstaller {
|
||||
public class CredentialManager {
|
||||
private const string RegistryKeyPath = @"SOFTWARE\Eagle0\Launcher";
|
||||
private const string ServerUrlValueName = "ServerUrl";
|
||||
private const string DefaultServerUrl = "https://assets.eagle0.net";
|
||||
|
||||
/// <summary>
|
||||
/// Loads saved server URL. Returns default if none found.
|
||||
/// </summary>
|
||||
public static string LoadServerUrl() {
|
||||
try {
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, false)) {
|
||||
if (key != null) {
|
||||
var url = key.GetValue(ServerUrlValueName) as string;
|
||||
if (!string.IsNullOrEmpty(url)) { return url; }
|
||||
}
|
||||
}
|
||||
} catch (Exception) {
|
||||
// If anything fails, return default
|
||||
}
|
||||
return DefaultServerUrl;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves server URL to registry.
|
||||
/// </summary>
|
||||
public static void SaveServerUrl(string serverUrl) {
|
||||
try {
|
||||
using (var key = Registry.CurrentUser.CreateSubKey(RegistryKeyPath)) {
|
||||
key.SetValue(ServerUrlValueName, serverUrl ?? DefaultServerUrl);
|
||||
}
|
||||
} catch (Exception) {
|
||||
// Ignore errors when saving
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clears saved settings.
|
||||
/// </summary>
|
||||
public static void ClearSettings() {
|
||||
try {
|
||||
using (var key = Registry.CurrentUser.OpenSubKey(RegistryKeyPath, true)) {
|
||||
if (key != null) { key.DeleteValue(ServerUrlValueName, false); }
|
||||
}
|
||||
} catch (Exception) {
|
||||
// Ignore errors when clearing
|
||||
}
|
||||
}
|
||||
public const string ServerUrl = "https://assets.eagle0.net";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,9 @@ namespace EagleInstaller {
|
||||
private Label _statusLabel;
|
||||
private Button _exitButton;
|
||||
|
||||
public string ServerUrl { get; private set; }
|
||||
public string ServerUrl => CredentialManager.ServerUrl;
|
||||
|
||||
public MainForm(string serverUrl = null) {
|
||||
ServerUrl = serverUrl ?? CredentialManager.LoadServerUrl();
|
||||
InitializeComponent();
|
||||
}
|
||||
public MainForm() { InitializeComponent(); }
|
||||
|
||||
private void InitializeComponent() {
|
||||
// Form properties
|
||||
|
||||
@@ -11,6 +11,8 @@ 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 {
|
||||
@@ -21,6 +23,34 @@ 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();
|
||||
@@ -43,6 +73,9 @@ namespace EagleInstaller {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract invitation code from args (if provided)
|
||||
string invitationCode = ExtractInvitationCode(args);
|
||||
|
||||
// Create main form with default server URL
|
||||
var mainForm = new MainForm();
|
||||
Logger.Initialize(mainForm);
|
||||
@@ -52,7 +85,7 @@ namespace EagleInstaller {
|
||||
mainForm.CreateControl();
|
||||
|
||||
// Start the installer logic in the background
|
||||
var installerTask = RunInstallerLogic(mainForm);
|
||||
var installerTask = RunInstallerLogic(mainForm, invitationCode);
|
||||
|
||||
// Run the application with the main form
|
||||
Application.Run(mainForm);
|
||||
@@ -61,11 +94,15 @@ namespace EagleInstaller {
|
||||
await installerTask;
|
||||
}
|
||||
|
||||
private static async Task RunInstallerLogic(MainForm mainForm) {
|
||||
private static async Task RunInstallerLogic(MainForm mainForm, string invitationCode) {
|
||||
try {
|
||||
string serverUrl = mainForm.ServerUrl;
|
||||
Logger.WriteLine($"Using server: {serverUrl}");
|
||||
|
||||
if (!string.IsNullOrEmpty(invitationCode)) {
|
||||
Logger.WriteLine("Invitation code provided");
|
||||
}
|
||||
|
||||
EagleUpdater updater = new EagleUpdater(serverUrl);
|
||||
|
||||
// Stage 1: Check if installer itself needs update
|
||||
@@ -93,6 +130,11 @@ namespace EagleInstaller {
|
||||
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);
|
||||
|
||||
@@ -243,7 +285,8 @@ namespace EagleInstaller {
|
||||
Console.WriteLine(" EagleInstaller.exe [OPTIONS]");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("OPTIONS:");
|
||||
Console.WriteLine(" --help, -h Show this help message");
|
||||
Console.WriteLine(" --help, -h Show this help message");
|
||||
Console.WriteLine(" --code=CODE Invitation code for new account registration");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("NORMAL OPERATION:");
|
||||
Console.WriteLine(" Run without arguments to start the normal update process.");
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
manifest_name = eagle0_manifest.txt
|
||||
remote_manifest_prefix = assets/installer/
|
||||
remote_asset_prefix = assets/unity3d/win/
|
||||
remote_manifest_prefix = installer/
|
||||
remote_asset_prefix = unity3d/win/
|
||||
|
||||
@@ -65,16 +65,18 @@ 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
|
||||
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
|
||||
invitationsTemplate *template.Template
|
||||
invitationsRowsTemplate *template.Template
|
||||
loginTemplate *template.Template
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -127,6 +129,16 @@ 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)
|
||||
@@ -185,6 +197,8 @@ 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)
|
||||
@@ -1652,6 +1666,8 @@ func handleUserRoutes(w http.ResponseWriter, r *http.Request) {
|
||||
handleSetUserDisplayName(w, r, userID)
|
||||
case "set-admin":
|
||||
handleSetUserAdmin(w, r, userID)
|
||||
case "delete":
|
||||
handleDeleteUser(w, r, userID)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
@@ -1804,6 +1820,393 @@ func handleSetUserAdmin(w http.ResponseWriter, r *http.Request, userID string) {
|
||||
fmt.Fprintf(w, `<div class="alert alert-success">Admin status %s</div>`, status)
|
||||
}
|
||||
|
||||
func handleDeleteUser(w http.ResponseWriter, r *http.Request, userID string) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := createAdminContext(r)
|
||||
defer cancel()
|
||||
|
||||
resp, err := adminClient.DeleteUser(ctx, &adminpb.DeleteUserRequest{
|
||||
UserId: userID,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to delete user: %v", err)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">Error: %v</div>`, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, resp.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("HX-Trigger", "userUpdated")
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprint(w, `<div class="alert alert-success">User deleted</div>`)
|
||||
}
|
||||
|
||||
// Invitation management handlers
|
||||
|
||||
// InvitationInfo represents an invitation for display
|
||||
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)
|
||||
case "delete":
|
||||
handleDeleteInvitation(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>`)
|
||||
}
|
||||
|
||||
func handleDeleteInvitation(w http.ResponseWriter, r *http.Request, code string) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := createAdminContext(r)
|
||||
defer cancel()
|
||||
|
||||
resp, err := adminClient.DeleteInvitation(ctx, &adminpb.DeleteInvitationRequest{
|
||||
InvitationCode: code,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("Failed to delete invitation: %v", err)
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">Error: %v</div>`, err)
|
||||
return
|
||||
}
|
||||
|
||||
if !resp.Success {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprintf(w, `<div class="alert alert-error">%s</div>`, resp.ErrorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("HX-Trigger", "invitationUpdated")
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
fmt.Fprint(w, `<div class="alert alert-success">Invitation deleted</div>`)
|
||||
}
|
||||
|
||||
// Authentication handlers
|
||||
|
||||
const (
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
{{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}}
|
||||
<button class="btn-small btn-danger"
|
||||
hx-post="/invitations/{{.InvitationCode}}/delete"
|
||||
hx-target="#feedback"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Are you sure you want to permanently delete this invitation?">
|
||||
Delete
|
||||
</button>
|
||||
{{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') ||
|
||||
evt.detail.pathInfo.requestPath.includes('/delete')) {
|
||||
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}}
|
||||
@@ -0,0 +1,61 @@
|
||||
{{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}}
|
||||
<button class="btn-small btn-danger"
|
||||
hx-post="/invitations/{{.InvitationCode}}/delete"
|
||||
hx-target="#feedback"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Are you sure you want to permanently delete this invitation?">
|
||||
Delete
|
||||
</button>
|
||||
{{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,6 +14,7 @@
|
||||
<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">
|
||||
|
||||
@@ -65,6 +65,13 @@
|
||||
onclick="openEditModal('{{.UserID}}', '{{.DisplayName}}', {{.IsAdmin}})">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn-small btn-danger"
|
||||
hx-post="/users/{{.UserID}}/delete"
|
||||
hx-target="#feedback"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Are you sure you want to permanently delete this user? This cannot be undone.">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
@@ -259,6 +266,22 @@ function saveUserChanges() {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: #dc3545;
|
||||
border-color: #dc3545;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background-color: #c82333;
|
||||
border-color: #bd2130;
|
||||
}
|
||||
|
||||
.user-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
#edit-modal article {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,13 @@
|
||||
onclick="openEditModal('{{.UserID}}', '{{.DisplayName}}', {{.IsAdmin}})">
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn-small btn-danger"
|
||||
hx-post="/users/{{.UserID}}/delete"
|
||||
hx-target="#feedback"
|
||||
hx-swap="innerHTML"
|
||||
hx-confirm="Are you sure you want to permanently delete this user? This cannot be undone.">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
|
||||
@@ -5,9 +5,12 @@ 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,13 +16,17 @@ import (
|
||||
// AdminHandler implements the Admin gRPC service
|
||||
type AdminHandler struct {
|
||||
adminpb.UnimplementedAdminServer
|
||||
userService *UserService
|
||||
userService *UserService
|
||||
invitationService *InvitationService
|
||||
emailService *EmailService
|
||||
}
|
||||
|
||||
// NewAdminHandler creates a new AdminHandler
|
||||
func NewAdminHandler(userService *UserService) *AdminHandler {
|
||||
func NewAdminHandler(userService *UserService, invitationService *InvitationService, emailService *EmailService) *AdminHandler {
|
||||
return &AdminHandler{
|
||||
userService: userService,
|
||||
userService: userService,
|
||||
invitationService: invitationService,
|
||||
emailService: emailService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,3 +171,230 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteUser permanently deletes a user
|
||||
func (h *AdminHandler) DeleteUser(ctx context.Context, req *adminpb.DeleteUserRequest) (*adminpb.DeleteUserResponse, error) {
|
||||
adminUserID, err := h.requireAdmin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("[Admin] DeleteUser called by %s for user %s", adminUserID, req.UserId)
|
||||
|
||||
// Prevent self-deletion
|
||||
if req.UserId == adminUserID {
|
||||
return &adminpb.DeleteUserResponse{
|
||||
Success: false,
|
||||
ErrorMessage: "cannot delete your own account",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if err := h.userService.Delete(req.UserId); err != nil {
|
||||
return &adminpb.DeleteUserResponse{
|
||||
Success: false,
|
||||
ErrorMessage: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &adminpb.DeleteUserResponse{
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteInvitation permanently deletes an invitation (only non-pending)
|
||||
func (h *AdminHandler) DeleteInvitation(ctx context.Context, req *adminpb.DeleteInvitationRequest) (*adminpb.DeleteInvitationResponse, error) {
|
||||
adminUserID, err := h.requireAdmin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("[Admin] DeleteInvitation called by %s for code=%s...", adminUserID, req.InvitationCode[:8])
|
||||
|
||||
if err := h.invitationService.Delete(req.InvitationCode); err != nil {
|
||||
return &adminpb.DeleteInvitationResponse{
|
||||
Success: false,
|
||||
ErrorMessage: err.Error(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &adminpb.DeleteInvitationResponse{
|
||||
Success: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -12,6 +13,13 @@ 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)
|
||||
@@ -41,15 +49,17 @@ func extractAccessToken(ctx context.Context) (string, error) {
|
||||
// AuthHandler implements the Auth gRPC service
|
||||
type AuthHandler struct {
|
||||
auth.UnimplementedAuthServer
|
||||
oauthSvc *OAuthService
|
||||
userService *UserService
|
||||
oauthSvc *OAuthService
|
||||
userService *UserService
|
||||
invitationService *InvitationService
|
||||
}
|
||||
|
||||
// NewAuthHandler creates a new auth handler
|
||||
func NewAuthHandler(oauthSvc *OAuthService, userService *UserService) *AuthHandler {
|
||||
func NewAuthHandler(oauthSvc *OAuthService, userService *UserService, invitationService *InvitationService) *AuthHandler {
|
||||
return &AuthHandler{
|
||||
oauthSvc: oauthSvc,
|
||||
userService: userService,
|
||||
oauthSvc: oauthSvc,
|
||||
userService: userService,
|
||||
invitationService: invitationService,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +67,7 @@ func NewAuthHandler(oauthSvc *OAuthService, userService *UserService) *AuthHandl
|
||||
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)
|
||||
authURL, state, err := h.oauthSvc.GetAuthURL(provider, req.ReturnUrl, req.InvitationCode)
|
||||
if err != nil {
|
||||
return nil, status.Errorf(codes.InvalidArgument, "failed to get auth URL: %v", err)
|
||||
}
|
||||
@@ -94,13 +104,49 @@ func (h *AuthHandler) CheckOAuthStatus(ctx context.Context, req *auth.CheckOAuth
|
||||
|
||||
// Success - find or create user locally
|
||||
if result.Success {
|
||||
user, isNewUser := h.userService.FindOrCreateUser(
|
||||
// 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(
|
||||
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 {
|
||||
@@ -134,7 +180,7 @@ func (h *AuthHandler) CheckOAuthStatus(ctx context.Context, req *auth.CheckOAuth
|
||||
AvatarUrl: user.AvatarUrl,
|
||||
Provider: stringToProvider(result.Provider),
|
||||
},
|
||||
IsNewUser: isNewUser,
|
||||
IsNewUser: wasCreated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
// 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
|
||||
macInstallerURL 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"
|
||||
}
|
||||
macInstallerURL := os.Getenv("MAC_INSTALLER_URL")
|
||||
if macInstallerURL == "" {
|
||||
macInstallerURL = "https://assets.eagle0.net/mac/builds/eagle0-latest.zip"
|
||||
}
|
||||
return &InvitationHTTPHandler{
|
||||
invitationService: invitationService,
|
||||
installerURL: installerURL,
|
||||
macInstallerURL: macInstallerURL,
|
||||
}
|
||||
}
|
||||
|
||||
// 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 or /invite/{code}/install.sh
|
||||
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) == 2 && parts[1] == "install.sh" {
|
||||
h.handleInstallSh(w, r, code)
|
||||
} else if len(parts) == 1 {
|
||||
h.handleLandingPage(w, r, code)
|
||||
} else {
|
||||
http.Error(w, "Not found", http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
// isMacUserAgent returns true if the User-Agent indicates a Mac browser
|
||||
func isMacUserAgent(userAgent string) bool {
|
||||
ua := strings.ToLower(userAgent)
|
||||
return strings.Contains(ua, "macintosh") || strings.Contains(ua, "mac os x")
|
||||
}
|
||||
|
||||
// handleLandingPage serves the invitation landing page
|
||||
func (h *InvitationHTTPHandler) handleLandingPage(w http.ResponseWriter, r *http.Request, code string) {
|
||||
invitation := h.invitationService.FindByCode(code)
|
||||
isMac := isMacUserAgent(r.UserAgent())
|
||||
|
||||
data := struct {
|
||||
Valid bool
|
||||
Code string
|
||||
ExpiresAt string
|
||||
ErrorMessage string
|
||||
InstallBatURL string
|
||||
InstallerURL string
|
||||
IsMac bool
|
||||
InstallShURL string
|
||||
MacInstallerURL string
|
||||
}{
|
||||
Code: code,
|
||||
InstallBatURL: fmt.Sprintf("/invite/%s/install.bat", code),
|
||||
InstallerURL: h.installerURL,
|
||||
IsMac: isMac,
|
||||
InstallShURL: fmt.Sprintf("/invite/%s/install.sh", code),
|
||||
MacInstallerURL: h.macInstallerURL,
|
||||
}
|
||||
|
||||
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])
|
||||
}
|
||||
|
||||
// handleInstallSh serves the Mac shell installer script
|
||||
func (h *InvitationHTTPHandler) handleInstallSh(w http.ResponseWriter, r *http.Request, code string) {
|
||||
// Validate the code exists and is valid
|
||||
if !h.invitationService.IsValidCode(code) {
|
||||
http.Error(w, "Invalid or expired invitation code", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate shell script
|
||||
script := fmt.Sprintf(`#!/bin/bash
|
||||
#
|
||||
# Eagle0 Mac Installer
|
||||
# This script downloads Eagle0 and sets up your invitation code.
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
echo ""
|
||||
echo "===================================="
|
||||
echo " Eagle0 Mac Installer"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
|
||||
# Create app support directory
|
||||
APP_SUPPORT_DIR="$HOME/Library/Application Support/eagle0"
|
||||
mkdir -p "$APP_SUPPORT_DIR"
|
||||
|
||||
# Write invitation code
|
||||
echo "Setting up invitation code..."
|
||||
cat > "$APP_SUPPORT_DIR/invitation.json" << 'INVITATION_EOF'
|
||||
{"invitationCode": "%s"}
|
||||
INVITATION_EOF
|
||||
|
||||
echo "Invitation code saved."
|
||||
echo ""
|
||||
|
||||
# Download location
|
||||
DOWNLOAD_DIR="$HOME/Downloads"
|
||||
ZIP_PATH="$DOWNLOAD_DIR/eagle0.zip"
|
||||
APP_PATH="/Applications/eagle0.app"
|
||||
|
||||
echo "Downloading Eagle0..."
|
||||
curl -L -o "$ZIP_PATH" "%s"
|
||||
|
||||
echo ""
|
||||
echo "Extracting to /Applications..."
|
||||
|
||||
# Remove old version if exists
|
||||
if [ -d "$APP_PATH" ]; then
|
||||
echo "Removing previous version..."
|
||||
rm -rf "$APP_PATH"
|
||||
fi
|
||||
|
||||
# Unzip to Applications
|
||||
unzip -q "$ZIP_PATH" -d /Applications/
|
||||
|
||||
# Clean up zip
|
||||
rm "$ZIP_PATH"
|
||||
|
||||
# Remove quarantine attribute (app is notarized but downloaded via curl)
|
||||
xattr -dr com.apple.quarantine "$APP_PATH" 2>/dev/null || true
|
||||
|
||||
echo ""
|
||||
echo "===================================="
|
||||
echo " Installation Complete!"
|
||||
echo "===================================="
|
||||
echo ""
|
||||
echo "Eagle0 has been installed to /Applications/eagle0.app"
|
||||
echo "Your invitation code has been saved."
|
||||
echo ""
|
||||
echo "Starting Eagle0..."
|
||||
open "$APP_PATH"
|
||||
|
||||
`, code, h.macInstallerURL)
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-sh")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=eagle0-install.sh")
|
||||
w.Write([]byte(script))
|
||||
|
||||
log.Printf("[InviteHTTP] Served install.sh 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;
|
||||
}
|
||||
code {
|
||||
background: #e9ecef;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
.other-platform {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
color: #666;
|
||||
}
|
||||
.other-platform a {
|
||||
color: #1a5f7a;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<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>
|
||||
|
||||
{{if .IsMac}}
|
||||
<a href="{{.InstallShURL}}" class="button">Download for Mac</a>
|
||||
|
||||
<div class="instructions">
|
||||
<h3>How to install on Mac:</h3>
|
||||
<ol>
|
||||
<li>Click the "Download for Mac" button above</li>
|
||||
<li>Open Terminal and navigate to your Downloads folder:<br>
|
||||
<code>cd ~/Downloads</code></li>
|
||||
<li>Run the installer script:<br>
|
||||
<code>bash eagle0-install.sh</code></li>
|
||||
<li>Eagle0 will be installed to /Applications and launched automatically</li>
|
||||
</ol>
|
||||
</div>
|
||||
{{else}}
|
||||
<a href="{{.InstallBatURL}}" class="button">Download for Windows</a>
|
||||
|
||||
<div class="instructions">
|
||||
<h3>How to install on Windows:</h3>
|
||||
<ol>
|
||||
<li>Click the "Download for Windows" button above</li>
|
||||
<li>Open the downloaded <strong>eagle0-install.bat</strong> file</li>
|
||||
<li>If Windows shows a security prompt, click "More info" then "Run anyway"</li>
|
||||
<li>The installer will download and start automatically with your invitation code</li>
|
||||
</ol>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .ExpiresAt}}
|
||||
<div class="expires">
|
||||
This invitation expires on {{.ExpiresAt}}.
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="manual-section">
|
||||
{{if .IsMac}}
|
||||
<h3>Alternative: Manual Installation</h3>
|
||||
<p>If the script doesn't work, you can <a href="{{.MacInstallerURL}}">download the ZIP manually</a>, extract it to /Applications, and enter this code when prompted:</p>
|
||||
{{else}}
|
||||
<h3>Alternative: Manual Installation</h3>
|
||||
<p>If the automatic installer doesn't work, you can <a href="{{.InstallerURL}}">download the installer manually</a> and enter this code when prompted:</p>
|
||||
{{end}}
|
||||
<div class="code-box">{{.Code}}</div>
|
||||
</div>
|
||||
|
||||
<div class="other-platform">
|
||||
{{if .IsMac}}
|
||||
<p><small>Looking for Windows? <a href="{{.InstallBatURL}}">Download Windows installer</a></small></p>
|
||||
{{else}}
|
||||
<p><small>Looking for Mac? <a href="{{.InstallShURL}}">Download Mac installer</a></small></p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="error-box">
|
||||
<p>{{.ErrorMessage}}</p>
|
||||
<p>Please contact the person who invited you for a new invitation.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="footer">
|
||||
<p>© 2026 Eagle0</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
@@ -0,0 +1,405 @@
|
||||
// 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
|
||||
}
|
||||
|
||||
// Delete permanently removes an invitation from the database.
|
||||
// Only revoked, expired, or redeemed invitations can be deleted.
|
||||
func (is *InvitationService) Delete(code string) error {
|
||||
is.mu.Lock()
|
||||
defer is.mu.Unlock()
|
||||
|
||||
idx, ok := is.database.CodeIndex[code]
|
||||
if !ok || int(idx) >= len(is.database.Invitations) {
|
||||
return fmt.Errorf("invitation not found")
|
||||
}
|
||||
|
||||
invitation := is.database.Invitations[idx]
|
||||
|
||||
// Only allow deletion of non-pending invitations
|
||||
if invitation.Status == userpb.InvitationStatus_INVITATION_STATUS_PENDING {
|
||||
// Check if actually expired
|
||||
if invitation.ExpiresAt == nil || !invitation.ExpiresAt.AsTime().Before(time.Now()) {
|
||||
return fmt.Errorf("cannot delete a pending invitation (revoke it first)")
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from code index
|
||||
delete(is.database.CodeIndex, code)
|
||||
|
||||
// Remove from invitations slice
|
||||
is.database.Invitations = append(is.database.Invitations[:idx], is.database.Invitations[idx+1:]...)
|
||||
|
||||
// Rebuild code index since array indices have shifted
|
||||
is.database.CodeIndex = make(map[string]int32)
|
||||
for i, inv := range is.database.Invitations {
|
||||
is.database.CodeIndex[inv.InvitationCode] = int32(i)
|
||||
}
|
||||
|
||||
if err := is.save(); err != nil {
|
||||
return fmt.Errorf("failed to save: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[Invitation] Deleted invitation for %s (code %s...)", invitation.Email, code[:8])
|
||||
return nil
|
||||
}
|
||||
@@ -101,14 +101,31 @@ 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)
|
||||
authHandler := NewAuthHandler(oauthSvc, userService, invitationService)
|
||||
|
||||
// Create admin service handler
|
||||
adminHandler := NewAdminHandler(userService)
|
||||
adminHandler := NewAdminHandler(userService, invitationService, emailService)
|
||||
|
||||
// Start gRPC server
|
||||
grpcLis, err := net.Listen("tcp", fmt.Sprintf(":%d", actualGRPCPort))
|
||||
@@ -127,13 +144,17 @@ func main() {
|
||||
}
|
||||
}()
|
||||
|
||||
// Start HTTP server for OAuth callbacks
|
||||
// Start HTTP server for OAuth callbacks and invitation pages
|
||||
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)
|
||||
|
||||
@@ -27,9 +27,10 @@ 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)
|
||||
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
|
||||
}
|
||||
|
||||
// ProviderUserInfo holds user info from an OAuth provider
|
||||
@@ -42,10 +43,11 @@ type ProviderUserInfo struct {
|
||||
|
||||
// OAuthResult represents the result of an OAuth flow
|
||||
type OAuthResult struct {
|
||||
Success bool
|
||||
UserInfo *ProviderUserInfo
|
||||
Provider string
|
||||
Error string
|
||||
Success bool
|
||||
UserInfo *ProviderUserInfo
|
||||
Provider string
|
||||
Error string
|
||||
InvitationCode string // Invitation code from the original request
|
||||
}
|
||||
|
||||
// OAuthService manages OAuth state and handles callbacks
|
||||
@@ -92,7 +94,8 @@ func getCallbackURL() string {
|
||||
|
||||
// GetAuthURL generates an OAuth authorization URL
|
||||
// returnURL is optional - if set, the callback will redirect there instead of showing "close window"
|
||||
func (s *OAuthService) GetAuthURL(provider string, returnURL string) (authURL string, state string, err error) {
|
||||
// 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) {
|
||||
config, ok := s.configs[provider]
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("unsupported provider: %s", provider)
|
||||
@@ -100,12 +103,13 @@ func (s *OAuthService) GetAuthURL(provider string, returnURL string) (authURL st
|
||||
|
||||
state = uuid.New().String()
|
||||
s.pendingOAuth.Store(state, OAuthState{
|
||||
Provider: provider,
|
||||
CreatedAt: time.Now(),
|
||||
ReturnURL: returnURL,
|
||||
Provider: provider,
|
||||
CreatedAt: time.Now(),
|
||||
ReturnURL: returnURL,
|
||||
InvitationCode: invitationCode,
|
||||
})
|
||||
|
||||
log.Printf("OAuth: created state=%s for provider=%s returnURL=%s", state, provider, returnURL)
|
||||
log.Printf("OAuth: created state=%s for provider=%s returnURL=%s hasInvitationCode=%v", state, provider, returnURL, invitationCode != "")
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("client_id", config.ClientID)
|
||||
@@ -206,9 +210,10 @@ 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,
|
||||
Success: true,
|
||||
UserInfo: userInfo,
|
||||
Provider: oauthState.Provider,
|
||||
InvitationCode: oauthState.InvitationCode,
|
||||
})
|
||||
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
// 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,6 +228,22 @@ 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()
|
||||
@@ -450,3 +466,45 @@ func (us *UserService) SetAdmin(userID string, isAdmin bool) error {
|
||||
}
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
// Delete permanently removes a user from the database
|
||||
func (us *UserService) Delete(userID string) error {
|
||||
us.mu.Lock()
|
||||
defer us.mu.Unlock()
|
||||
|
||||
// Find user index
|
||||
var userIndex = -1
|
||||
var user *userpb.User
|
||||
for i, u := range us.database.Users {
|
||||
if u.UserId == userID {
|
||||
userIndex = i
|
||||
user = u
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if userIndex == -1 {
|
||||
return fmt.Errorf("user not found")
|
||||
}
|
||||
|
||||
// Remove from OAuth index
|
||||
for _, identity := range user.OauthIdentities {
|
||||
oauthKey := fmt.Sprintf("%s:%s", identity.Provider, identity.ProviderUserId)
|
||||
delete(us.database.OauthIndex, oauthKey)
|
||||
}
|
||||
|
||||
// Remove from display name index
|
||||
if user.DisplayNameLower != "" {
|
||||
delete(us.database.DisplayNameIndex, user.DisplayNameLower)
|
||||
}
|
||||
|
||||
// Remove from users slice
|
||||
us.database.Users = append(us.database.Users[:userIndex], us.database.Users[userIndex+1:]...)
|
||||
|
||||
if err := us.save(); err != nil {
|
||||
return fmt.Errorf("failed to save: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[UserService] Deleted user %s (%s)", userID, user.DisplayName)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
|
||||
|
||||
go_library(
|
||||
name = "mac_build_handler_lib",
|
||||
srcs = ["mac_build_handler.go"],
|
||||
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/build/mac_build_handler",
|
||||
visibility = ["//visibility:private"],
|
||||
deps = ["//src/main/go/net/eagle0/util/aws"],
|
||||
)
|
||||
|
||||
go_binary(
|
||||
name = "mac_build_handler",
|
||||
embed = [":mac_build_handler_lib"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
@@ -0,0 +1,350 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"crypto/sha256"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/nolen777/eagle0/src/main/go/net/eagle0/util/aws"
|
||||
)
|
||||
|
||||
var bucketName = "eagle0-windows" // Using existing bucket with /mac/ prefix
|
||||
var appcastPath = "mac/appcast.xml"
|
||||
var buildsRoot = "mac/builds/"
|
||||
|
||||
// Sparkle Appcast XML structures
|
||||
type Appcast struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Version string `xml:"version,attr"`
|
||||
Xmlns string `xml:"xmlns:sparkle,attr"`
|
||||
Channel Channel `xml:"channel"`
|
||||
}
|
||||
|
||||
type Channel struct {
|
||||
Title string `xml:"title"`
|
||||
Link string `xml:"link"`
|
||||
Description string `xml:"description"`
|
||||
Language string `xml:"language"`
|
||||
Items []Item `xml:"item"`
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
Title string `xml:"title"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
SparkleVersion string `xml:"sparkle:version"`
|
||||
SparkleShortVersion string `xml:"sparkle:shortVersionString"`
|
||||
Description string `xml:"description,omitempty"`
|
||||
Enclosure Enclosure `xml:"enclosure"`
|
||||
}
|
||||
|
||||
type Enclosure struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Length int64 `xml:"length,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
EdSig string `xml:"sparkle:edSignature,attr"`
|
||||
}
|
||||
|
||||
func zipApp(appPath string, zipPath string) error {
|
||||
zipFile, err := os.Create(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create zip file: %w", err)
|
||||
}
|
||||
defer zipFile.Close()
|
||||
|
||||
zipWriter := zip.NewWriter(zipFile)
|
||||
defer zipWriter.Close()
|
||||
|
||||
appBase := filepath.Base(appPath)
|
||||
|
||||
// Use WalkDir with Lstat to properly handle symlinks
|
||||
err = filepath.WalkDir(appPath, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the relative path from the app's parent directory
|
||||
relPath, err := filepath.Rel(filepath.Dir(appPath), path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Skip if it's the root
|
||||
if relPath == appBase && d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use Lstat to get info without following symlinks
|
||||
info, err := os.Lstat(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if it's a symlink
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
// Read the symlink target
|
||||
linkTarget, err := os.Readlink(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create symlink entry in zip
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = relPath
|
||||
header.Method = zip.Store
|
||||
|
||||
writer, err := zipWriter.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = writer.Write([]byte(linkTarget))
|
||||
return err
|
||||
}
|
||||
|
||||
header, err := zip.FileInfoHeader(info)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use forward slashes and preserve the .app directory structure
|
||||
header.Name = relPath
|
||||
if info.IsDir() {
|
||||
header.Name += "/"
|
||||
} else {
|
||||
header.Method = zip.Deflate
|
||||
}
|
||||
|
||||
// Preserve executable permissions
|
||||
header.SetMode(info.Mode())
|
||||
|
||||
writer, err := zipWriter.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !info.IsDir() {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
_, err = io.Copy(writer, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func signWithSparkle(filePath string, privateKeyPath string) (string, error) {
|
||||
// Use Sparkle's sign_update tool
|
||||
// First, try to find it in the Sparkle cache
|
||||
sparkleSignTool := "/tmp/sparkle-cache/Sparkle-2.6.4/bin/sign_update"
|
||||
|
||||
// If not found, download Sparkle
|
||||
if _, err := os.Stat(sparkleSignTool); os.IsNotExist(err) {
|
||||
log.Println("Sparkle sign_update not found, downloading...")
|
||||
cmd := exec.Command("bash", "-c", `
|
||||
mkdir -p /tmp/sparkle-cache
|
||||
curl -sL https://github.com/sparkle-project/Sparkle/releases/download/2.6.4/Sparkle-2.6.4.tar.xz | tar -xJ -C /tmp/sparkle-cache
|
||||
`)
|
||||
if output, err := cmd.CombinedOutput(); err != nil {
|
||||
return "", fmt.Errorf("failed to download Sparkle: %s: %w", output, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sign the file
|
||||
cmd := exec.Command(sparkleSignTool, filePath, "-s", privateKeyPath)
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign file: %w", err)
|
||||
}
|
||||
|
||||
// sign_update outputs: sparkle:edSignature="<signature>" length="<length>"
|
||||
// We need to extract just the signature
|
||||
signature := strings.TrimSpace(string(output))
|
||||
// Parse out the signature from the output
|
||||
if strings.Contains(signature, "sparkle:edSignature=\"") {
|
||||
start := strings.Index(signature, "sparkle:edSignature=\"") + len("sparkle:edSignature=\"")
|
||||
end := strings.Index(signature[start:], "\"")
|
||||
if end > 0 {
|
||||
signature = signature[start : start+end]
|
||||
}
|
||||
}
|
||||
|
||||
return signature, nil
|
||||
}
|
||||
|
||||
func getFileSize(path string) (int64, error) {
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
func sha256File(path string) (string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("%x", h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func fetchAppcast(bb aws.BucketBasics) (*Appcast, error) {
|
||||
content, err := bb.FetchString(bucketName, appcastPath)
|
||||
if err != nil {
|
||||
// Return empty appcast if doesn't exist
|
||||
return &Appcast{
|
||||
Version: "2.0",
|
||||
Xmlns: "http://www.andymatuschak.org/xml-namespaces/sparkle",
|
||||
Channel: Channel{
|
||||
Title: "Eagle0",
|
||||
Link: "https://assets.eagle0.net/mac/appcast.xml",
|
||||
Description: "Eagle0 game updates",
|
||||
Language: "en",
|
||||
Items: []Item{},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
var appcast Appcast
|
||||
err = xml.Unmarshal([]byte(content), &appcast)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse appcast: %w", err)
|
||||
}
|
||||
|
||||
return &appcast, nil
|
||||
}
|
||||
|
||||
func uploadAppcast(bb aws.BucketBasics, appcast *Appcast) error {
|
||||
output, err := xml.MarshalIndent(appcast, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal appcast: %w", err)
|
||||
}
|
||||
|
||||
xmlContent := xml.Header + string(output)
|
||||
return bb.UploadBytesPublic(bucketName, appcastPath, []byte(xmlContent))
|
||||
}
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 5 {
|
||||
fmt.Println("Usage: mac_build_handler <app_path> <version> <build_number> <sparkle_private_key_path>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
appPath := os.Args[1]
|
||||
version := os.Args[2]
|
||||
buildNumber := os.Args[3]
|
||||
privateKeyPath := os.Args[4]
|
||||
|
||||
if _, err := os.Stat(appPath); os.IsNotExist(err) {
|
||||
log.Fatalf("App not found: %s", appPath)
|
||||
}
|
||||
|
||||
bb, err := aws.NewBucketBasics()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Create ZIP of the app
|
||||
zipFileName := fmt.Sprintf("eagle0-%s.zip", version)
|
||||
zipPath := filepath.Join("/tmp", zipFileName)
|
||||
|
||||
log.Printf("Creating ZIP: %s", zipPath)
|
||||
if err := zipApp(appPath, zipPath); err != nil {
|
||||
log.Fatalf("Failed to create ZIP: %v", err)
|
||||
}
|
||||
|
||||
// Get file size
|
||||
fileSize, err := getFileSize(zipPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get file size: %v", err)
|
||||
}
|
||||
log.Printf("ZIP size: %d bytes", fileSize)
|
||||
|
||||
// Sign the ZIP with Sparkle EdDSA
|
||||
log.Println("Signing ZIP with Sparkle...")
|
||||
signature, err := signWithSparkle(zipPath, privateKeyPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to sign: %v", err)
|
||||
}
|
||||
log.Printf("Signature: %s", signature)
|
||||
|
||||
// Upload ZIP to S3
|
||||
remotePath := buildsRoot + zipFileName
|
||||
log.Printf("Uploading to S3: %s", remotePath)
|
||||
if err := bb.UploadFilePublic(bucketName, remotePath, zipPath); err != nil {
|
||||
log.Fatalf("Failed to upload: %v", err)
|
||||
}
|
||||
|
||||
// Also upload as "latest"
|
||||
latestPath := buildsRoot + "eagle0-latest.zip"
|
||||
log.Printf("Copying to: %s", latestPath)
|
||||
if err := bb.UploadFilePublic(bucketName, latestPath, zipPath); err != nil {
|
||||
log.Fatalf("Failed to upload latest: %v", err)
|
||||
}
|
||||
|
||||
// Update appcast.xml
|
||||
log.Println("Updating appcast.xml...")
|
||||
appcast, err := fetchAppcast(bb)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to fetch appcast: %v", err)
|
||||
}
|
||||
|
||||
// Create new item
|
||||
downloadURL := fmt.Sprintf("https://assets.eagle0.net/%s%s", buildsRoot, zipFileName)
|
||||
newItem := Item{
|
||||
Title: fmt.Sprintf("Version %s", version),
|
||||
PubDate: time.Now().Format(time.RFC1123Z),
|
||||
SparkleVersion: buildNumber,
|
||||
SparkleShortVersion: version,
|
||||
Description: "",
|
||||
Enclosure: Enclosure{
|
||||
URL: downloadURL,
|
||||
Length: fileSize,
|
||||
Type: "application/octet-stream",
|
||||
EdSig: signature,
|
||||
},
|
||||
}
|
||||
|
||||
// Prepend new item (most recent first)
|
||||
appcast.Channel.Items = append([]Item{newItem}, appcast.Channel.Items...)
|
||||
|
||||
// Keep only last 10 versions
|
||||
if len(appcast.Channel.Items) > 10 {
|
||||
appcast.Channel.Items = appcast.Channel.Items[:10]
|
||||
}
|
||||
|
||||
if err := uploadAppcast(bb, appcast); err != nil {
|
||||
log.Fatalf("Failed to upload appcast: %v", err)
|
||||
}
|
||||
|
||||
// Clean up local ZIP
|
||||
os.Remove(zipPath)
|
||||
|
||||
log.Printf("=== Mac build deployed successfully ===")
|
||||
log.Printf("Version: %s (build %s)", version, buildNumber)
|
||||
log.Printf("Download URL: %s", downloadURL)
|
||||
log.Printf("Appcast URL: https://assets.eagle0.net/%s", appcastPath)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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"],
|
||||
)
|
||||
@@ -0,0 +1,470 @@
|
||||
// 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 initial game state with available commands
|
||||
// Note: ActionResultResponse arrives BEFORE SubscriptionAck, so we can't wait for ack first
|
||||
streamingTextCount := 0
|
||||
gotSubscriptionAck := false
|
||||
gameUpdateResp, err := waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
|
||||
// Track subscription ack but don't require it before ActionResultResponse
|
||||
if resp.GetSubscriptionAck() != nil {
|
||||
gotSubscriptionAck = true
|
||||
log.Println(" Subscription acknowledged")
|
||||
}
|
||||
gu := resp.GetGameUpdate()
|
||||
if gu == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check what type of update this is
|
||||
switch {
|
||||
case gu.GetStreamingTextResponse() != nil:
|
||||
streamingTextCount++
|
||||
if streamingTextCount%100 == 0 {
|
||||
log.Printf(" Received %d streaming text updates so far...", streamingTextCount)
|
||||
}
|
||||
case gu.GetShardokActionResultResponse() != nil:
|
||||
log.Printf(" Got ShardokActionResultResponse")
|
||||
case gu.GetErrorResponse() != nil:
|
||||
log.Printf(" Got ErrorResponse: %v", gu.GetErrorResponse())
|
||||
case gu.GetActionResultResponse() != nil:
|
||||
log.Printf(" Got ActionResultResponse!")
|
||||
default:
|
||||
log.Printf(" Got GameUpdate with no recognized content")
|
||||
}
|
||||
|
||||
ar := gu.GetActionResultResponse()
|
||||
if ar == nil {
|
||||
return false
|
||||
}
|
||||
if ar.GetAvailableCommands() == nil {
|
||||
log.Printf(" ActionResultResponse has no AvailableCommands (has %d results)", len(ar.GetActionResultViews()))
|
||||
return false
|
||||
}
|
||||
log.Printf(" Found AvailableCommands with %d provinces", len(ar.GetAvailableCommands().GetCommandsByProvince()))
|
||||
return true
|
||||
})
|
||||
log.Printf(" Total streaming text updates received: %d, got subscription ack: %v", streamingTextCount, gotSubscriptionAck)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get initial game state: %w", err)
|
||||
}
|
||||
|
||||
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
|
||||
var actingHeroID int32
|
||||
|
||||
for pid, provCmds := range commandsByProvince {
|
||||
for _, cmd := range provCmds.GetCommands() {
|
||||
if ic := cmd.GetImproveCommand(); ic != nil {
|
||||
improveCmd = cmd
|
||||
provinceID = pid
|
||||
actingHeroID = ic.GetRecommendedHeroId()
|
||||
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 with recommended hero %d", provinceID, actingHeroID)
|
||||
}
|
||||
|
||||
// Post the Improve command
|
||||
log.Printf(" Posting Improve command with token=%d province=%d heroId=%d...", eagleToken, provinceID, actingHeroID)
|
||||
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: actingHeroID,
|
||||
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 and collecting results...")
|
||||
|
||||
// Track results while waiting for PostCommandResponse
|
||||
// ActionResultResponse arrives as GameUpdate BEFORE PostCommandResponse
|
||||
foundNewRound := false
|
||||
resultCount := 0
|
||||
commandCount := 0
|
||||
postStatus := eagle.PostCommandResponse_UNKNOWN
|
||||
|
||||
_, err = waitForResponse(stream, func(resp *eagle.UpdateStreamResponse) bool {
|
||||
if resp.GetPostCommandResponse() != nil {
|
||||
log.Printf(" Got PostCommandResponse: %v", resp.GetPostCommandResponse().GetStatus())
|
||||
postStatus = resp.GetPostCommandResponse().GetStatus()
|
||||
return true
|
||||
}
|
||||
// Process GameUpdates while waiting - they contain our action results!
|
||||
if gu := resp.GetGameUpdate(); gu != nil {
|
||||
if ar := gu.GetActionResultResponse(); ar != nil {
|
||||
for _, arv := range ar.GetActionResultViews() {
|
||||
resultCount++
|
||||
if arv.GetType() == eaglecommon.ActionResultType_NEW_ROUND_ACTION {
|
||||
foundNewRound = true
|
||||
log.Printf(" Found NEW_ROUND_ACTION (date change)!")
|
||||
}
|
||||
}
|
||||
if ar.GetAvailableCommands() != nil {
|
||||
commandCount = len(ar.GetAvailableCommands().GetCommandsByProvince())
|
||||
log.Printf(" Got %d action results, %d provinces with commands available", len(ar.GetActionResultViews()), commandCount)
|
||||
}
|
||||
}
|
||||
} else if resp.GetSubscriptionAck() != nil {
|
||||
log.Println(" (skipping late SubscriptionAck)")
|
||||
}
|
||||
return false
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get post command response: %w", err)
|
||||
}
|
||||
|
||||
if postStatus == eagle.PostCommandResponse_SUCCESS {
|
||||
log.Println(" Command accepted!")
|
||||
} else if postStatus == eagle.PostCommandResponse_BAD_TOKEN {
|
||||
log.Printf(" Command rejected: bad token")
|
||||
} else {
|
||||
log.Printf(" Command status: %v (continuing anyway - JIT warming up)", postStatus)
|
||||
}
|
||||
|
||||
// Continue collecting any remaining action results with a timeout
|
||||
log.Println(" Collecting any remaining action results...")
|
||||
resultCtx, resultCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer resultCancel()
|
||||
|
||||
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)
|
||||
log.Printf(" Post command status: %v", postStatus)
|
||||
|
||||
// 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")
|
||||
}
|
||||
if commandCount == 0 {
|
||||
return fmt.Errorf("no new commands received after posting command")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
|
||||
load("@grpc//bazel:cc_grpc_library.bzl", "cc_grpc_library")
|
||||
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
|
||||
load("@rules_cc//cc:defs.bzl", "cc_proto_library")
|
||||
@@ -57,16 +56,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "hostility_swift_proto",
|
||||
protos = [":hostility_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
|
||||
"//src/main/protobuf/net/eagle0/eagle/views:__pkg__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "hostility_scala_proto",
|
||||
visibility = [
|
||||
@@ -183,15 +172,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "victory_condition_swift_proto",
|
||||
protos = [":victory_condition_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "victory_condition_scala_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
|
||||
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
|
||||
load("@rules_proto//proto:defs.bzl", "proto_library")
|
||||
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
|
||||
@@ -50,26 +49,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "available_command_swift_proto",
|
||||
protos = [":available_command_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:armed_battalion_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:attack_decision_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:captured_hero_option_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:control_weather_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:diplomacy_option_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:prisoner_management_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:province_orders_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:improvement_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:tribute_amount_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "available_command_scala_proto",
|
||||
visibility = [
|
||||
@@ -108,16 +87,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "command_swift_proto",
|
||||
protos = [":command_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
":available_command_swift_proto",
|
||||
":selected_command_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "command_scala_proto",
|
||||
visibility = [
|
||||
@@ -153,30 +122,6 @@ proto_library(
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "selected_command_swift_proto",
|
||||
protos = [":selected_command_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:armed_battalion_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:attack_decision_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:captured_hero_option_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:control_weather_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:diplomacy_option_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:prisoner_management_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api/command/util:province_orders_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_status_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:improvement_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:tribute_amount_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "selected_command_scala_proto",
|
||||
visibility = [
|
||||
|
||||
@@ -27,6 +27,24 @@ 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) {}
|
||||
|
||||
// Delete a user permanently
|
||||
rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse) {}
|
||||
|
||||
// Delete an invitation (only revoked/expired/redeemed invitations can be deleted)
|
||||
rpc DeleteInvitation(DeleteInvitationRequest) returns (DeleteInvitationResponse) {}
|
||||
}
|
||||
|
||||
// Request to list users
|
||||
@@ -91,3 +109,93 @@ 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;
|
||||
}
|
||||
|
||||
// Delete user request
|
||||
message DeleteUserRequest {
|
||||
string user_id = 1;
|
||||
}
|
||||
|
||||
message DeleteUserResponse {
|
||||
bool success = 1;
|
||||
string error_message = 2;
|
||||
}
|
||||
|
||||
// Delete invitation request (only revoked/expired/redeemed can be deleted)
|
||||
message DeleteInvitationRequest {
|
||||
string invitation_code = 1;
|
||||
}
|
||||
|
||||
message DeleteInvitationResponse {
|
||||
bool success = 1;
|
||||
string error_message = 2;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ 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 {
|
||||
@@ -78,6 +81,7 @@ 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
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
|
||||
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
|
||||
load("@rules_proto//proto:defs.bzl", "proto_library")
|
||||
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
|
||||
|
||||
swift_proto_library(
|
||||
name = "appropriate_battalions_swift_proto",
|
||||
protos = [":appropriate_battalions_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "appropriate_battalions_scala_proto",
|
||||
visibility = [
|
||||
@@ -32,16 +21,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "armed_battalion_swift_proto",
|
||||
protos = [":armed_battalion_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "armed_battalion_scala_proto",
|
||||
visibility = [
|
||||
@@ -63,16 +42,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "army_stats_swift_proto",
|
||||
protos = [":army_stats_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "army_stats_scala_proto",
|
||||
visibility = [
|
||||
@@ -93,18 +62,6 @@ proto_library(
|
||||
deps = ["//src/main/protobuf/net/eagle0/common:hostility_proto"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "attack_decision_type_swift_proto",
|
||||
protos = [":attack_decision_type_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:tribute_amount_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "attack_decision_type_scala_proto",
|
||||
visibility = [
|
||||
@@ -127,16 +84,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "battalion_with_food_cost_swift_proto",
|
||||
protos = [":battalion_with_food_cost_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "battalion_with_food_cost_scala_proto",
|
||||
visibility = [
|
||||
@@ -158,16 +105,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "captured_hero_option_swift_proto",
|
||||
protos = [":captured_hero_option_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "captured_hero_option_scala_proto",
|
||||
visibility = [
|
||||
@@ -189,16 +126,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "control_weather_type_swift_proto",
|
||||
protos = [":control_weather_type_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "control_weather_type_scala_proto",
|
||||
visibility = [
|
||||
@@ -220,18 +147,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "diplomacy_option_swift_proto",
|
||||
protos = [":diplomacy_option_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "diplomacy_option_scala_proto",
|
||||
visibility = [
|
||||
@@ -254,19 +169,6 @@ proto_library(
|
||||
deps = ["//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_proto"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "expanded_combat_unit_swift_proto",
|
||||
protos = [":expanded_combat_unit_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/views:battalion_view_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/views:hero_view_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "expanded_combat_unit_scala_proto",
|
||||
visibility = [
|
||||
@@ -292,20 +194,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "expanded_unaffiliated_hero_swift_proto",
|
||||
protos = [":expanded_unaffiliated_hero_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_quest_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/views:hero_view_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "expanded_unaffiliated_hero_scala_proto",
|
||||
visibility = [
|
||||
@@ -332,16 +220,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "prisoner_management_type_swift_proto",
|
||||
protos = [":prisoner_management_type_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "prisoner_management_type_scala_proto",
|
||||
visibility = [
|
||||
@@ -363,18 +241,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "province_orders_swift_proto",
|
||||
protos = [":province_orders_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "province_orders_scala_proto",
|
||||
visibility = [
|
||||
|
||||
@@ -45,6 +45,9 @@ service Eagle {
|
||||
rpc DeleteGame(DeleteGameRequest) returns (DeleteGameResponse) {}
|
||||
rpc CheckGameExists(CheckGameExistsRequest) returns (CheckGameExistsResponse) {}
|
||||
rpc DownloadGameSave(DownloadGameSaveRequest) returns (stream DownloadGameSaveResponse) {}
|
||||
|
||||
// Server lifecycle management (for blue-green deployments)
|
||||
rpc ReloadGames(ReloadGamesRequest) returns (ReloadGamesResponse) {}
|
||||
}
|
||||
|
||||
message PostCommandRequest {
|
||||
@@ -605,3 +608,12 @@ message DownloadGameSaveRequest {
|
||||
message DownloadGameSaveResponse {
|
||||
bytes chunk = 1; // Chunk of zip file data
|
||||
}
|
||||
|
||||
// Server lifecycle management (for blue-green deployments)
|
||||
message ReloadGamesRequest {}
|
||||
|
||||
message ReloadGamesResponse {
|
||||
bool success = 1;
|
||||
string error_message = 2;
|
||||
int32 games_reloaded = 3; // Number of games that were reloaded from disk
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
|
||||
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
|
||||
load("@rules_proto//proto:defs.bzl", "proto_library")
|
||||
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
|
||||
@@ -8,19 +7,6 @@ package(default_visibility = [
|
||||
"//src/test/scala/net/eagle0:__subpackages__",
|
||||
])
|
||||
|
||||
swift_proto_library(
|
||||
name = "action_result_notification_details_swift_proto",
|
||||
protos = [":action_result_notification_details_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":profession_swift_proto",
|
||||
":unaffiliated_hero_quest_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "action_result_notification_details_scala_proto",
|
||||
deps = [":action_result_notification_details_proto"],
|
||||
@@ -41,15 +27,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "action_result_type_swift_proto",
|
||||
protos = [":action_result_type_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "action_result_type_scala_proto",
|
||||
deps = [":action_result_type_proto"],
|
||||
@@ -65,15 +42,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "battalion_type_swift_proto",
|
||||
protos = [":battalion_type_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "battalion_type_scala_proto",
|
||||
deps = [":battalion_type_proto"],
|
||||
@@ -89,15 +57,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "beast_info_swift_proto",
|
||||
protos = [":beast_info_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "beast_info_scala_proto",
|
||||
deps = [":beast_info_proto"],
|
||||
@@ -113,16 +72,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "chronicle_entry_swift_proto",
|
||||
protos = [":chronicle_entry_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [":date_swift_proto"],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "chronicle_entry_scala_proto",
|
||||
deps = ["chronicle_entry_proto"],
|
||||
@@ -141,12 +90,18 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "combat_unit_swift_proto",
|
||||
protos = [":combat_unit_proto"],
|
||||
scala_proto_library(
|
||||
name = "command_type_scala_proto",
|
||||
deps = [":command_type_proto"],
|
||||
)
|
||||
|
||||
proto_library(
|
||||
name = "command_type_proto",
|
||||
srcs = [
|
||||
"command_type.proto",
|
||||
],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -166,15 +121,6 @@ proto_library(
|
||||
deps = ["@com_google_protobuf//:wrappers_proto"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "date_swift_proto",
|
||||
protos = [":date_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "date_scala_proto",
|
||||
deps = [":date_proto"],
|
||||
@@ -190,19 +136,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "diplomacy_offer_swift_proto",
|
||||
protos = [":diplomacy_offer_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":date_swift_proto",
|
||||
":diplomacy_offer_status_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "diplomacy_offer_scala_proto",
|
||||
visibility = [
|
||||
@@ -226,15 +159,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "diplomacy_offer_status_swift_proto",
|
||||
protos = [":diplomacy_offer_status_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "diplomacy_offer_status_scala_proto",
|
||||
deps = [":diplomacy_offer_status_proto"],
|
||||
@@ -250,15 +174,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "gender_swift_proto",
|
||||
protos = [":gender_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "gender_scala_proto",
|
||||
deps = [":gender_proto"],
|
||||
@@ -272,18 +187,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "hero_backstory_version_swift_proto",
|
||||
protos = [":hero_backstory_version_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":date_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "hero_backstory_version_scala_proto",
|
||||
deps = [":hero_backstory_version_proto"],
|
||||
@@ -300,15 +203,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "improvement_type_swift_proto",
|
||||
protos = [":improvement_type_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "improvement_type_scala_proto",
|
||||
visibility = [
|
||||
@@ -330,15 +224,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "profession_swift_proto",
|
||||
protos = [":profession_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "profession_scala_proto",
|
||||
deps = [":profession_proto"],
|
||||
@@ -354,19 +239,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "province_event_swift_proto",
|
||||
protos = [":province_event_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":beast_info_swift_proto",
|
||||
":date_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "province_event_scala_proto",
|
||||
deps = [":province_event_proto"],
|
||||
@@ -386,15 +258,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "province_order_type_swift_proto",
|
||||
protos = [":province_order_type_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "province_order_type_scala_proto",
|
||||
deps = [":province_order_type_proto"],
|
||||
@@ -410,18 +273,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "recruitment_info_swift_proto",
|
||||
protos = [":recruitment_info_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":unaffiliated_hero_quest_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "recruitment_info_scala_proto",
|
||||
deps = [":recruitment_info_proto"],
|
||||
@@ -438,15 +289,6 @@ proto_library(
|
||||
deps = [":unaffiliated_hero_quest_proto"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "round_phase_swift_proto",
|
||||
protos = [":round_phase_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "round_phase_scala_proto",
|
||||
deps = [":round_phase_proto"],
|
||||
@@ -462,15 +304,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "tribute_amount_swift_proto",
|
||||
protos = [":tribute_amount_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "tribute_amount_scala_proto",
|
||||
visibility = [
|
||||
@@ -494,16 +327,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "unaffiliated_hero_quest_swift_proto",
|
||||
protos = [":unaffiliated_hero_quest_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [":battalion_type_swift_proto"],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "unaffiliated_hero_quest_scala_proto",
|
||||
deps = [":unaffiliated_hero_quest_proto"],
|
||||
@@ -520,16 +343,6 @@ proto_library(
|
||||
deps = [":battalion_type_proto"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "unaffiliated_hero_type_swift_proto",
|
||||
protos = [":unaffiliated_hero_type_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "unaffiliated_hero_type_scala_proto",
|
||||
deps = [":unaffiliated_hero_type_proto"],
|
||||
@@ -555,6 +368,7 @@ go_proto_library(
|
||||
":beast_info_proto",
|
||||
":chronicle_entry_proto",
|
||||
":combat_unit_proto",
|
||||
":command_type_proto",
|
||||
":date_proto",
|
||||
":diplomacy_offer_proto",
|
||||
":diplomacy_offer_status_proto",
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package net.eagle0.eagle.common.command_type;
|
||||
|
||||
option java_multiple_files = true;
|
||||
option java_package = "net.eagle0.eagle.common.command_type";
|
||||
|
||||
// Enum representing the type of a command, without any command-specific data.
|
||||
// Used to track which command type was last executed on a province.
|
||||
enum CommandType {
|
||||
COMMAND_TYPE_UNKNOWN = 0;
|
||||
COMMAND_TYPE_ALMS = 1;
|
||||
COMMAND_TYPE_APPREHEND_OUTLAW = 2;
|
||||
COMMAND_TYPE_ARM_TROOPS = 3;
|
||||
COMMAND_TYPE_ATTACK_DECISION = 4;
|
||||
COMMAND_TYPE_CONTROL_WEATHER = 5;
|
||||
COMMAND_TYPE_DECLINE_QUEST = 6;
|
||||
COMMAND_TYPE_DEFEND = 7;
|
||||
COMMAND_TYPE_DIPLOMACY = 8;
|
||||
COMMAND_TYPE_DIVINE = 9;
|
||||
COMMAND_TYPE_EXILE_VASSAL = 10;
|
||||
COMMAND_TYPE_FEAST = 11;
|
||||
COMMAND_TYPE_FREE_FOR_ALL_DECISION = 12;
|
||||
COMMAND_TYPE_HANDLE_CAPTURED_HERO = 13;
|
||||
COMMAND_TYPE_HANDLE_RIOT_CRACK_DOWN = 14;
|
||||
COMMAND_TYPE_HANDLE_RIOT_DO_NOTHING = 15;
|
||||
COMMAND_TYPE_HANDLE_RIOT_GIVE = 16;
|
||||
COMMAND_TYPE_HERO_GIFT = 17;
|
||||
COMMAND_TYPE_IMPROVE = 18;
|
||||
COMMAND_TYPE_ISSUE_ORDERS = 19;
|
||||
COMMAND_TYPE_MANAGE_PRISONERS = 20;
|
||||
COMMAND_TYPE_MARCH = 21;
|
||||
COMMAND_TYPE_ORGANIZE_TROOPS = 22;
|
||||
COMMAND_TYPE_PLEASE_RECRUIT_ME = 23;
|
||||
COMMAND_TYPE_RECON = 24;
|
||||
COMMAND_TYPE_RECRUIT_HEROES = 25;
|
||||
COMMAND_TYPE_RESOLVE_ALLIANCE_OFFER = 26;
|
||||
COMMAND_TYPE_RESOLVE_BREAK_ALLIANCE = 27;
|
||||
COMMAND_TYPE_RESOLVE_INVITATION = 28;
|
||||
COMMAND_TYPE_RESOLVE_RANSOM_OFFER = 29;
|
||||
COMMAND_TYPE_RESOLVE_TRUCE_OFFER = 30;
|
||||
COMMAND_TYPE_RESOLVE_TRIBUTE = 31;
|
||||
COMMAND_TYPE_REST = 32;
|
||||
COMMAND_TYPE_RETURN = 33;
|
||||
COMMAND_TYPE_SEND_SUPPLIES = 34;
|
||||
COMMAND_TYPE_START_EPIDEMIC = 35;
|
||||
COMMAND_TYPE_SUPPRESS_BEASTS = 36;
|
||||
COMMAND_TYPE_SWEAR_BROTHERHOOD = 37;
|
||||
COMMAND_TYPE_TRADE = 38;
|
||||
COMMAND_TYPE_TRAIN = 39;
|
||||
COMMAND_TYPE_TRAVEL = 40;
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
|
||||
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
|
||||
load("@rules_proto//proto:defs.bzl", "proto_library")
|
||||
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
|
||||
@@ -8,31 +7,6 @@ package(default_visibility = [
|
||||
"//src/test/scala/net/eagle0:__subpackages__",
|
||||
])
|
||||
|
||||
swift_proto_library(
|
||||
name = "action_result_swift_proto",
|
||||
protos = [":action_result_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
":battalion_swift_proto",
|
||||
":changed_faction_swift_proto",
|
||||
":changed_hero_swift_proto",
|
||||
":changed_province_swift_proto",
|
||||
":faction_swift_proto",
|
||||
":hero_swift_proto",
|
||||
":llm_request_swift_proto",
|
||||
":llm_response_swift_proto",
|
||||
":province_swift_proto",
|
||||
":shardok_battle_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:action_result_notification_details_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:chronicle_entry_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "action_result_scala_proto",
|
||||
deps = [":action_result_proto"],
|
||||
@@ -58,24 +32,13 @@ proto_library(
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:chronicle_entry_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:command_type_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:date_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_proto",
|
||||
"@com_google_protobuf//:wrappers_proto",
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "army_swift_proto",
|
||||
protos = [":army_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
":supplies_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:tribute_amount_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "army_scala_proto",
|
||||
deps = [":army_proto"],
|
||||
@@ -94,15 +57,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "battalion_swift_proto",
|
||||
protos = [":battalion_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "battalion_scala_proto",
|
||||
deps = [":battalion_proto"],
|
||||
@@ -116,12 +70,6 @@ proto_library(
|
||||
deps = ["//src/main/protobuf/net/eagle0/eagle/common:battalion_type_proto"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "battle_revelation_swift_proto",
|
||||
protos = [":battle_revelation_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "battle_revelation_scala_proto",
|
||||
deps = [":battle_revelation_proto"],
|
||||
@@ -134,20 +82,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "changed_faction_swift_proto",
|
||||
protos = [":changed_faction_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
":changed_hero_swift_proto",
|
||||
":changed_province_swift_proto",
|
||||
":faction_relationship_swift_proto",
|
||||
":faction_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/views:province_view_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "changed_faction_scala_proto",
|
||||
deps = [
|
||||
@@ -169,16 +103,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "changed_hero_swift_proto",
|
||||
protos = [":changed_hero_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
":event_for_hero_backstory_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:profession_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "changed_hero_scala_proto",
|
||||
deps = ["changed_hero_proto"],
|
||||
@@ -196,23 +120,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "changed_province_swift_proto",
|
||||
protos = [":changed_province_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
":deferred_change_swift_proto",
|
||||
":province_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:improvement_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:province_event_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:army_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:battle_revelation_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:supplies_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:unaffiliated_hero_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "changed_province_scala_proto",
|
||||
deps = ["changed_province_proto"],
|
||||
@@ -237,15 +144,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "client_text_swift_proto",
|
||||
protos = [":client_text_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:llm_request_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "client_text_scala_proto",
|
||||
deps = ["client_text_proto"],
|
||||
@@ -259,12 +157,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "deferred_change_swift_proto",
|
||||
protos = [":deferred_change_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "deferred_change_scala_proto",
|
||||
deps = [":deferred_change_proto"],
|
||||
@@ -275,15 +167,6 @@ proto_library(
|
||||
srcs = ["deferred_change.proto"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "event_for_chronicle_swift_proto",
|
||||
protos = [":event_for_chronicle_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "event_for_chronicle_scala_proto",
|
||||
deps = [":event_for_chronicle_proto"],
|
||||
@@ -298,19 +181,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "event_for_hero_backstory_swift_proto",
|
||||
protos = [":event_for_hero_backstory_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_status_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:profession_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_quest_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/views:battalion_view_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "event_for_hero_backstory_scala_proto",
|
||||
deps = [":event_for_hero_backstory_proto"],
|
||||
@@ -328,19 +198,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "faction_swift_proto",
|
||||
protos = [":faction_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
":faction_relationship_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:action_result_notification_details_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/views:province_view_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "faction_scala_proto",
|
||||
deps = [":faction_proto"],
|
||||
@@ -359,15 +216,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "faction_relationship_swift_proto",
|
||||
protos = [":faction_relationship_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "faction_relationship_scala_proto",
|
||||
deps = [":faction_relationship_proto"],
|
||||
@@ -384,17 +232,6 @@ proto_library(
|
||||
deps = ["//src/main/protobuf/net/eagle0/eagle/common:date_proto"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "game_parameters_swift_proto",
|
||||
protos = [":game_parameters_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "game_parameters_scala_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -413,18 +250,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "game_swift_proto",
|
||||
protos = [":game_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
":action_result_swift_proto",
|
||||
":game_state_swift_proto",
|
||||
":shardok_results_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "game_scala_proto",
|
||||
deps = [":game_proto"],
|
||||
@@ -442,27 +267,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "game_state_swift_proto",
|
||||
protos = [":game_state_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
":battalion_swift_proto",
|
||||
":faction_swift_proto",
|
||||
":hero_swift_proto",
|
||||
":llm_request_swift_proto",
|
||||
":llm_response_swift_proto",
|
||||
":province_swift_proto",
|
||||
":run_status_swift_proto",
|
||||
":shardok_battle_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:action_result_notification_details_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:chronicle_entry_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "game_state_scala_proto",
|
||||
deps = [":game_state_proto"],
|
||||
@@ -489,18 +293,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "hero_swift_proto",
|
||||
protos = [":hero_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
":event_for_hero_backstory_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:gender_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:hero_backstory_version_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:profession_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "hero_scala_proto",
|
||||
deps = [":hero_proto"],
|
||||
@@ -518,13 +310,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "llm_response_swift_proto",
|
||||
protos = [":llm_response_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [":llm_request_swift_proto"],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "llm_response_scala_proto",
|
||||
deps = [":llm_response_proto"],
|
||||
@@ -540,21 +325,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "llm_request_swift_proto",
|
||||
protos = [":llm_request_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
":event_for_chronicle_swift_proto",
|
||||
":event_for_hero_backstory_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_status_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_quest_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:hero_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/views:battalion_view_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "llm_request_scala_proto",
|
||||
deps = [":llm_request_proto"],
|
||||
@@ -578,27 +348,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "province_swift_proto",
|
||||
protos = [":province_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
":army_swift_proto",
|
||||
":battle_revelation_swift_proto",
|
||||
":deferred_change_swift_proto",
|
||||
":supplies_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:improvement_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:province_event_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:tribute_amount_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:unaffiliated_hero_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "province_scala_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -616,8 +365,8 @@ proto_library(
|
||||
":deferred_change_proto",
|
||||
":supplies_proto",
|
||||
":unaffiliated_hero_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:command_type_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:date_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:improvement_type_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:province_event_proto",
|
||||
@@ -626,13 +375,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "run_status_swift_proto",
|
||||
protos = [":run_status_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "run_status_scala_proto",
|
||||
deps = [":run_status_proto"],
|
||||
@@ -645,13 +387,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "running_games_swift_proto",
|
||||
protos = [":running_games_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "running_games_scala_proto",
|
||||
deps = [":running_games_proto"],
|
||||
@@ -662,16 +397,6 @@ proto_library(
|
||||
srcs = ["running_games.proto"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "shardok_battle_swift_proto",
|
||||
protos = [":shardok_battle_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
":army_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/common:victory_condition_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "shardok_battle_scala_proto",
|
||||
deps = [":shardok_battle_proto"],
|
||||
@@ -688,17 +413,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "shardok_results_swift_proto",
|
||||
protos = [":shardok_results_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/shardok/api:action_result_view_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/storage:action_result_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "shardok_results_scala_proto",
|
||||
deps = [":shardok_results_proto"],
|
||||
@@ -716,14 +430,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "supplies_swift_proto",
|
||||
protos = [":supplies_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "supplies_scala_proto",
|
||||
deps = [":supplies_proto"],
|
||||
@@ -737,20 +443,6 @@ proto_library(
|
||||
deps = ["@com_google_protobuf//:wrappers_proto"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "unaffiliated_hero_swift_proto",
|
||||
protos = [":unaffiliated_hero_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:recruitment_info_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_type_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "unaffiliated_hero_scala_proto",
|
||||
deps = [":unaffiliated_hero_proto"],
|
||||
|
||||
@@ -12,6 +12,7 @@ import "src/main/protobuf/net/eagle0/eagle/common/action_result_notification_det
|
||||
import "src/main/protobuf/net/eagle0/eagle/common/action_result_type.proto";
|
||||
import "src/main/protobuf/net/eagle0/eagle/common/battalion_type.proto";
|
||||
import "src/main/protobuf/net/eagle0/eagle/common/chronicle_entry.proto";
|
||||
import "src/main/protobuf/net/eagle0/eagle/common/command_type.proto";
|
||||
import "src/main/protobuf/net/eagle0/eagle/common/date.proto";
|
||||
import "src/main/protobuf/net/eagle0/eagle/common/round_phase.proto";
|
||||
import "src/main/protobuf/net/eagle0/eagle/internal/battalion.proto";
|
||||
@@ -63,7 +64,9 @@ message ActionResult {
|
||||
|
||||
.google.protobuf.Int32Value province_acted = 17;
|
||||
|
||||
.net.eagle0.eagle.api.SelectedCommand last_command_type_for_acting_province = 28;
|
||||
// Deprecated: Use last_command_type_for_acting_province instead. Kept for backwards compatibility with saved games.
|
||||
.net.eagle0.eagle.api.SelectedCommand deprecated_last_command_type_for_acting_province = 28;
|
||||
.net.eagle0.eagle.common.command_type.CommandType last_command_type_for_acting_province = 43;
|
||||
|
||||
.google.protobuf.Int32Value leader = 18;
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ syntax = "proto3";
|
||||
package net.eagle0.eagle.internal;
|
||||
|
||||
import "google/protobuf/wrappers.proto";
|
||||
import "src/main/protobuf/net/eagle0/eagle/api/selected_command.proto";
|
||||
import "src/main/protobuf/net/eagle0/eagle/common/battalion_type.proto";
|
||||
import "src/main/protobuf/net/eagle0/eagle/common/command_type.proto";
|
||||
import "src/main/protobuf/net/eagle0/eagle/common/date.proto";
|
||||
import "src/main/protobuf/net/eagle0/eagle/common/improvement_type.proto";
|
||||
import "src/main/protobuf/net/eagle0/eagle/common/province_event.proto";
|
||||
@@ -104,7 +104,7 @@ message Province {
|
||||
|
||||
.net.eagle0.eagle.common.Date last_beasts_date = 25;
|
||||
.net.eagle0.eagle.common.Date last_riot_date = 35;
|
||||
.net.eagle0.eagle.api.SelectedCommand last_command = 26;
|
||||
.net.eagle0.eagle.common.command_type.CommandType last_command = 26;
|
||||
|
||||
int32 castle_count = 33;
|
||||
int32 hero_cap = 39;
|
||||
|
||||
@@ -64,3 +64,31 @@ message RefreshToken {
|
||||
message RefreshTokenDatabase {
|
||||
repeated RefreshToken tokens = 1;
|
||||
}
|
||||
|
||||
// Invitation status
|
||||
enum InvitationStatus {
|
||||
INVITATION_STATUS_UNSPECIFIED = 0;
|
||||
INVITATION_STATUS_PENDING = 1; // Invitation sent, not yet used
|
||||
INVITATION_STATUS_REDEEMED = 2; // Invitation used to create account
|
||||
INVITATION_STATUS_EXPIRED = 3; // Invitation past expiration date
|
||||
INVITATION_STATUS_REVOKED = 4; // Admin cancelled invitation
|
||||
}
|
||||
|
||||
// Invitation record for restricting new account creation
|
||||
message Invitation {
|
||||
string invitation_code = 1; // Secure random code (base64url, 32+ chars)
|
||||
string email = 2; // Email address invitation was sent to
|
||||
string created_by_user_id = 3; // Admin who created this invitation
|
||||
google.protobuf.Timestamp created_at = 4;
|
||||
google.protobuf.Timestamp expires_at = 5;
|
||||
InvitationStatus status = 6;
|
||||
string redeemed_by_user_id = 7; // User ID that used this invitation
|
||||
google.protobuf.Timestamp redeemed_at = 8;
|
||||
}
|
||||
|
||||
// Container for all invitations
|
||||
message InvitationDatabase {
|
||||
repeated Invitation invitations = 1;
|
||||
// Code -> index in invitations array for fast lookup
|
||||
map<string, int32> code_index = 2;
|
||||
}
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
|
||||
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
|
||||
load("@rules_proto//proto:defs.bzl", "proto_library")
|
||||
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
|
||||
|
||||
swift_proto_library(
|
||||
name = "action_result_view_swift_proto",
|
||||
protos = [":action_result_view_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:action_result_notification_details_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "action_result_view_scala_proto",
|
||||
visibility = [
|
||||
@@ -36,15 +25,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "army_view_swift_proto",
|
||||
protos = [":army_view_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:combat_unit_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "army_view_scala_proto",
|
||||
visibility = [
|
||||
@@ -65,19 +45,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "battalion_view_swift_proto",
|
||||
protos = [":battalion_view_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:__pkg__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "battalion_view_scala_proto",
|
||||
visibility = [
|
||||
@@ -102,15 +69,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "faction_relationship_view_swift_proto",
|
||||
protos = [":faction_relationship_view_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "faction_relationship_view_scala_proto",
|
||||
visibility = [
|
||||
@@ -131,16 +89,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "faction_view_swift_proto",
|
||||
protos = [":faction_view_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
":faction_relationship_view_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "faction_view_scala_proto",
|
||||
visibility = [
|
||||
@@ -163,23 +111,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "game_state_view_swift_proto",
|
||||
protos = [":game_state_view_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
":battalion_view_swift_proto",
|
||||
":faction_view_swift_proto",
|
||||
":hero_view_swift_proto",
|
||||
":province_view_swift_proto",
|
||||
":shardok_battle_view_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:battalion_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:chronicle_entry_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:round_phase_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "game_state_view_scala_proto",
|
||||
visibility = [
|
||||
@@ -208,20 +139,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "hero_view_swift_proto",
|
||||
protos = [":hero_view_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/api:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":stat_with_condition_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:gender_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:profession_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "hero_view_scala_proto",
|
||||
visibility = [
|
||||
@@ -247,13 +164,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "incoming_army_view_swift_proto",
|
||||
protos = [":incoming_army_view_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [":battalion_view_swift_proto"],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "incoming_army_view_scala_proto",
|
||||
visibility = [
|
||||
@@ -270,27 +180,6 @@ proto_library(
|
||||
deps = [":battalion_view_proto"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "province_view_swift_proto",
|
||||
protos = [":province_view_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":army_view_swift_proto",
|
||||
":battalion_view_swift_proto",
|
||||
":incoming_army_view_swift_proto",
|
||||
":stat_with_condition_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:date_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:profession_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:province_event_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/common:recruitment_info_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:unaffiliated_hero_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "province_view_scala_proto",
|
||||
visibility = [
|
||||
@@ -323,13 +212,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "stat_with_condition_swift_proto",
|
||||
protos = [":stat_with_condition_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "stat_with_condition_scala_proto",
|
||||
visibility = [
|
||||
@@ -349,15 +231,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "shardok_battle_view_swift_proto",
|
||||
protos = [":shardok_battle_view_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/common:hostility_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "shardok_battle_view_scala_proto",
|
||||
visibility = [
|
||||
|
||||
@@ -1,23 +1,8 @@
|
||||
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
|
||||
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
|
||||
load("@rules_cc//cc:defs.bzl", "cc_proto_library")
|
||||
load("@rules_proto//proto:defs.bzl", "proto_library")
|
||||
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
|
||||
|
||||
swift_proto_library(
|
||||
name = "action_result_view_swift_proto",
|
||||
protos = [":action_result_view_proto"],
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
":game_state_view_swift_proto",
|
||||
":odds_view_swift_proto",
|
||||
":unit_view_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:action_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:coords_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:game_status_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "action_result_view_scala_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -42,18 +27,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "command_descriptor_swift_proto",
|
||||
protos = [":command_descriptor_proto"],
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
":odds_view_swift_proto",
|
||||
":roll_request_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:command_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:coords_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "command_descriptor_scala_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -82,20 +55,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "game_state_view_swift_proto",
|
||||
protos = [":game_state_view_proto"],
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
":unit_view_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:game_status_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:hex_map_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:player_info_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:tile_modifier_with_coords_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:weather_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
cc_proto_library(
|
||||
name = "game_state_view_cc_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -119,13 +78,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "odds_view_swift_proto",
|
||||
protos = [":odds_view_proto"],
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [":unit_view_swift_proto"],
|
||||
)
|
||||
|
||||
cc_proto_library(
|
||||
name = "odds_view_cc_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -153,16 +105,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "roll_request_swift_proto",
|
||||
protos = [":roll_request_proto"],
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
":odds_view_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:command_type_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
cc_proto_library(
|
||||
name = "roll_request_cc_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -179,17 +121,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "unit_view_swift_proto",
|
||||
protos = [":unit_view_proto"],
|
||||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:command_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:coords_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:hostility_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
cc_proto_library(
|
||||
name = "unit_view_cc_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
|
||||
@@ -1,19 +1,8 @@
|
||||
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
|
||||
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
|
||||
load("@rules_cc//cc:defs.bzl", "cc_proto_library")
|
||||
load("@rules_proto//proto:defs.bzl", "proto_library")
|
||||
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
|
||||
|
||||
swift_proto_library(
|
||||
name = "action_type_swift_proto",
|
||||
protos = [":action_type_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
cc_proto_library(
|
||||
name = "action_type_cc_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -26,16 +15,6 @@ proto_library(
|
||||
visibility = ["//src/main/protobuf/net/eagle0/shardok:__subpackages__"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "command_type_swift_proto",
|
||||
protos = [":command_type_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
cc_proto_library(
|
||||
name = "command_type_cc_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -48,16 +27,6 @@ proto_library(
|
||||
visibility = ["//src/main/protobuf/net/eagle0/shardok:__subpackages__"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "coords_swift_proto",
|
||||
protos = [":coords_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
cc_proto_library(
|
||||
name = "coords_cc_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -70,16 +39,6 @@ proto_library(
|
||||
visibility = ["//src/main/protobuf/net/eagle0/shardok:__subpackages__"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "game_status_swift_proto",
|
||||
protos = [":game_status_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [":victory_condition_swift_proto"],
|
||||
)
|
||||
|
||||
cc_proto_library(
|
||||
name = "game_status_cc_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -93,21 +52,6 @@ proto_library(
|
||||
deps = [":victory_condition_proto"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "hex_map_swift_proto",
|
||||
protos = [":hex_map_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":coords_swift_proto",
|
||||
":hex_map_direction_swift_proto",
|
||||
":terrain_swift_proto",
|
||||
":tile_modifier_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "hex_map_scala_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -134,16 +78,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "hex_map_direction_swift_proto",
|
||||
protos = [":hex_map_direction_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
cc_proto_library(
|
||||
name = "hex_map_direction_cc_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -156,16 +90,6 @@ proto_library(
|
||||
visibility = ["//src/main/protobuf/net/eagle0/shardok:__subpackages__"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "hostility_swift_proto",
|
||||
protos = [":hostility_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
cc_proto_library(
|
||||
name = "hostility_cc_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -178,16 +102,6 @@ proto_library(
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "player_info_swift_proto",
|
||||
protos = [":player_info_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [":victory_condition_swift_proto"],
|
||||
)
|
||||
|
||||
cc_proto_library(
|
||||
name = "player_info_cc_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -205,16 +119,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "terrain_swift_proto",
|
||||
protos = [":terrain_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [":tile_modifier_swift_proto"],
|
||||
)
|
||||
|
||||
cc_proto_library(
|
||||
name = "terrain_cc_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -234,16 +138,6 @@ proto_library(
|
||||
deps = [":tile_modifier_proto"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "tile_modifier_swift_proto",
|
||||
protos = [":tile_modifier_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
cc_proto_library(
|
||||
name = "tile_modifier_cc_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -265,19 +159,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "tile_modifier_with_coords_swift_proto",
|
||||
protos = [":tile_modifier_with_coords_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":coords_swift_proto",
|
||||
":tile_modifier_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
cc_proto_library(
|
||||
name = "tile_modifier_with_coords_cc_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -294,16 +175,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "victory_condition_swift_proto",
|
||||
protos = [":victory_condition_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [],
|
||||
)
|
||||
|
||||
cc_proto_library(
|
||||
name = "victory_condition_cc_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -316,16 +187,6 @@ proto_library(
|
||||
visibility = ["//src/main/protobuf/net/eagle0/shardok:__subpackages__"],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "weather_swift_proto",
|
||||
protos = [":weather_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0:__subpackages__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [":hex_map_direction_swift_proto"],
|
||||
)
|
||||
|
||||
cc_proto_library(
|
||||
name = "weather_cc_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
|
||||
@@ -1,26 +1,8 @@
|
||||
load("@build_bazel_rules_swift//proto:proto.bzl", "swift_proto_library")
|
||||
load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
|
||||
load("@rules_cc//cc:defs.bzl", "cc_proto_library")
|
||||
load("@rules_proto//proto:defs.bzl", "proto_library")
|
||||
load("@rules_scala//scala_proto:scala_proto.bzl", "scala_proto_library")
|
||||
|
||||
swift_proto_library(
|
||||
name = "action_result_swift_proto",
|
||||
protos = [":action_result_proto"],
|
||||
visibility = [
|
||||
"//src/main/protobuf/net/eagle0/eagle/internal:__pkg__",
|
||||
"//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__",
|
||||
],
|
||||
deps = [
|
||||
":odds_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:action_type_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:coords_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:game_status_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:tile_modifier_with_coords_swift_proto",
|
||||
"//src/main/protobuf/net/eagle0/shardok/common:weather_swift_proto",
|
||||
],
|
||||
)
|
||||
|
||||
scala_proto_library(
|
||||
name = "action_result_scala_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
@@ -67,12 +49,6 @@ proto_library(
|
||||
],
|
||||
)
|
||||
|
||||
swift_proto_library(
|
||||
name = "odds_swift_proto",
|
||||
protos = [":odds_proto"],
|
||||
visibility = ["//src/main/swift/net/eagle0/EagleGameHistoryViewer:__subpackages__"],
|
||||
)
|
||||
|
||||
cc_proto_library(
|
||||
name = "odds_cc_proto",
|
||||
visibility = ["//visibility:public"],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user