Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 de22df3848 Remove C++ tutorial battle controller (will be in separate PR)
The Shardok TutorialBattleController and related proto changes will be
handled in a separate C++ PR for the tactical layer integration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 18:03:25 -08:00
adminandClaude Opus 4.5 e281f3b012 Add tutorial battle system documentation
Document the tutorial battle system design including:
- Battle configuration (defender vs attacker setup)
- Implementation architecture across all 6 phases
- File summary of new and modified files
- Remaining implementation tasks
- Testing checklist
- Configuration reference

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 15:41:59 -08:00
adminandClaude Opus 4.5 9695db2536 Add tutorial battle setup in NewGameCreation
When tutorial mode is enabled and a tutorialBattle config exists in game
parameters, set up the battle by:
- Adding a hostile army group with Attacking status from the attacker faction
- Creating a defending army for the player faction
- Adding both to the target province (Onmaa)

The battle will be automatically created when the game advances to the
BattleRequest phase via RequestBattlesAction.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 10:53:02 -08:00
adminandClaude Opus 4.5 aaba34e7de Add tutorial battle system with narrative screens and scripted flee
Implement tutorial battle system for first-time players:
- Narrative intro screens before battle with story text
- TutorialBattleController C++ component for scripted flee logic
- Flee triggers: player loses 1 unit OR 5 rounds pass
- Tutorial action types for enemy fled and reinforcements arrived
- Tutorial battle config in game parameters proto

Unity client changes:
- NarrativeScreenController for displaying story screens
- EagleGameController integration to show narratives before battle
- Tutorial content definitions for battle events
- Fix CreateGame delegate signature mismatch

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 10:42:31 -08:00
adminandClaude Opus 4.5 9e37236fe3 Add tutorial scenario system for first-time players
Implement a guided tutorial scenario for new users with:
- Separate tutorial_game_parameters.json with simplified setup
- King (Bregos Fyar) controls 5 provinces (37, 40, 32, 6, 11)
- Weak rival (Norfolk) with 1 hero at province 13
- Player spawns at province 14 (Onmaa) with 4 heroes
- Unaffiliated hero in player's province with DefeatFactionQuest

Server changes:
- Add is_tutorial_mode to CreateGameRequest proto
- GameParametersUtils: Add tutorial parameters loading
- EagleServiceImpl: Use parametersFor() based on tutorial mode
- GamesManager: Pass isTutorialMode through game creation
- NewGameCreation: Force player spawn location, add extra heroes,
  create quest hero in tutorial mode

Client changes:
- ConnectionHandler: Pass isTutorialMode=true in AutoCreateFirstGame

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 10:17:48 -08:00
880 changed files with 30438 additions and 53190 deletions
+1 -7
View File
@@ -5,10 +5,6 @@ common --ui_event_filters=-INFO
common --enable_bzlmod
# Use pre-built protoc binary instead of compiling from source
common --incompatible_enable_proto_toolchain_resolution
common --@com_google_protobuf//bazel/toolchains:prefer_prebuilt_protoc=true
# Don't use toolchains_llvm for the swift app build
common:mactools --ignore_dev_dependency
@@ -46,7 +42,5 @@ common --tool_java_language_version=25
common --tool_java_runtime_version=remotejdk_25
# Workspace status for build stamping (git commit, timestamp)
# Only targets with stamp=1 will use these values; we intentionally
# do NOT set "common --stamp" so non-stamped targets can share the
# remote cache across CI workflows.
common --workspace_status_command=tools/workspace_status.sh
common --stamp
-16
View File
@@ -11,19 +11,3 @@ src/main/csharp/**/GUI[[:space:]]Pro[[:space:]]Kit*/**/*.ttf !filter !diff !merg
src/main/csharp/**/Modern[[:space:]]UI[[:space:]]Pack/**/*.ttf !filter !diff !merge
*.herodata filter=lfs diff=lfs merge=lfs -text
*.ogg filter=lfs diff=lfs merge=lfs -text
# Unity Smart Merge - use UnityYAMLMerge for Unity files
# Requires configuring unityyamlmerge in ~/.gitconfig:
# [merge]
# tool = unityyamlmerge
# [mergetool "unityyamlmerge"]
# trustExitCode = false
# cmd = '/Applications/Unity/Hub/Editor/*/Unity.app/Contents/Tools/UnityYAMLMerge' merge -p "$BASE" "$REMOTE" "$LOCAL" "$MERGED"
*.unity merge=unityyamlmerge
*.prefab merge=unityyamlmerge
*.asset merge=unityyamlmerge
*.mat merge=unityyamlmerge
*.anim merge=unityyamlmerge
*.controller merge=unityyamlmerge
*.physicsMaterial2D merge=unityyamlmerge
*.physicMaterial merge=unityyamlmerge
+1 -2
View File
@@ -41,8 +41,7 @@ jobs:
set -ex
# Build auth server image (Go binary has explicit goos/goarch in BUILD.bazel)
# --stamp is needed for authservice x_defs (git commit, build time)
bazel build --stamp //ci:auth_server_image
bazel build //ci:auth_server_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/auth_server_image)
@@ -13,8 +13,6 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Show disk usage before cleanup
run: |
-22
View File
@@ -23,10 +23,6 @@ on:
- '!src/main/csharp/**'
- '!src/test/csharp/**'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
permissions:
contents: read
@@ -34,34 +30,16 @@ jobs:
lint:
runs-on: [self-hosted, bazel]
steps:
- name: Clean workspace
run: |
git sparse-checkout disable 2>/dev/null || true
git config --local core.sparseCheckout false 2>/dev/null || true
git config --local --unset extensions.worktreeConfig 2>/dev/null || true
rm -f .git/info/sparse-checkout .git/config.worktree 2>/dev/null || true
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Check BUILD.bazel dependencies
run: ./scripts/check_build_deps.sh --strict
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: 'lts/*'
- name: Check JavaScript syntax
run: node --check src/main/go/net/eagle0/admin_server/static/map_editor.js
test:
runs-on: [self-hosted, bazel]
steps:
- name: Clean workspace
run: |
git sparse-checkout disable 2>/dev/null || true
git config --local core.sparseCheckout false 2>/dev/null || true
git config --local --unset extensions.worktreeConfig 2>/dev/null || true
rm -f .git/info/sparse-checkout .git/config.worktree 2>/dev/null || true
- name: Checkout repository
uses: actions/checkout@v4
with:
+54 -116
View File
@@ -31,16 +31,13 @@ on:
default: 'false'
type: boolean
# Only allow one deployment at a time; queue new ones (never cancel a running deploy).
# The build-all job checks if it's still the latest commit on main and skips if not,
# so intermediate commits don't waste time building when a newer one is already queued.
# Only allow one deployment at a time to prevent race conditions
concurrency:
group: docker-build-deploy
cancel-in-progress: false
cancel-in-progress: false # Don't cancel running deployments, queue new ones
permissions:
contents: read
actions: read
jobs:
# Single consolidated build job - builds all images with one bazel invocation
@@ -53,43 +50,20 @@ jobs:
admin_image_tag: ${{ steps.push-images.outputs.admin_image_tag }}
jfr_sidecar_image_tag: ${{ steps.push-images.outputs.jfr_sidecar_image_tag }}
steps:
- name: Skip if superseded
id: check-latest
run: |
# Check if there's a newer run of this workflow waiting in the queue.
# If so, skip this build — the queued run will deploy a newer commit.
QUEUED=$(curl -s -H "Authorization: Bearer $GH_TOKEN" \
"https://api.github.com/repos/${{ github.repository }}/actions/workflows/docker_build.yml/runs?status=queued" \
| python3 -c "import sys,json; runs=json.load(sys.stdin).get('workflow_runs',[]); print(len([r for r in runs if r['run_number'] > ${{ github.run_number }}]))")
if [ "$QUEUED" -gt 0 ]; then
echo "::notice::Skipping build — $QUEUED newer run(s) queued for this workflow"
echo "skip=true" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ github.token }}
- name: Checkout repository
if: steps.check-latest.outputs.skip != 'true'
uses: actions/checkout@v4
with:
lfs: false
- name: Fetch LFS files needed for admin server
if: steps.check-latest.outputs.skip != 'true'
run: git lfs pull --include="src/main/go/net/eagle0/admin_server/static/tiles/*"
- name: Build all Docker images
if: steps.check-latest.outputs.skip != 'true'
id: build-all
run: |
set -ex
# Build ALL images in a single bazel command - Bazel parallelizes internally
# Note: Shardok is built separately for ARM64 and deployed to Hetzner
# --stamp is needed for admin_server x_defs (git commit, build time)
echo "=== Building Docker images ==="
bazel build \
--stamp \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//ci:eagle_server_image \
@@ -115,16 +89,8 @@ jobs:
echo "Admin: $ADMIN_PATH"
echo "JFR Sidecar: $JFR_PATH"
- name: Upload warmup binary
if: steps.check-latest.outputs.skip != 'true'
uses: actions/upload-artifact@v4
with:
name: warmup-binary
path: scripts/bin/warmup
retention-days: 1
- name: Login to DigitalOcean Container Registry
if: steps.check-latest.outputs.skip != 'true' && (github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true'))
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
@@ -144,59 +110,40 @@ jobs:
GIT_SHA=$(git rev-parse --short=8 HEAD)
# Get crane (cached across runs to avoid rebuilding bazel target)
CRANE_VERSION="v0.20.2"
CRANE_DIR="${HOME}/.local/bin"
CRANE="${CRANE_DIR}/crane"
if [ ! -x "$CRANE" ] || ! "$CRANE" version 2>/dev/null | grep -q "0.20.2"; then
echo "Installing crane ${CRANE_VERSION}..."
mkdir -p "$CRANE_DIR"
curl -sL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Darwin_arm64.tar.gz" | tar xzf - -C "$CRANE_DIR" crane
chmod +x "$CRANE"
else
echo "Using cached crane at $CRANE"
fi
# Get crane from push target runfiles
bazel build //ci:eagle_server_push
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
# Set up image paths and tags
EAGLE_IMAGE="${{ steps.build-all.outputs.eagle_path }}"
EAGLE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
ADMIN_IMAGE="${{ steps.build-all.outputs.admin_path }}"
ADMIN_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
JFR_IMAGE="${{ steps.build-all.outputs.jfr_path }}"
JFR_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
# Push all three images in parallel
echo "Pushing Eagle: $EAGLE_TAG"
$CRANE push "$EAGLE_IMAGE" "$EAGLE_TAG" &
PID_EAGLE=$!
echo "Pushing Admin: $ADMIN_TAG"
$CRANE push "$ADMIN_IMAGE" "$ADMIN_TAG" &
PID_ADMIN=$!
echo "Pushing JFR Sidecar: $JFR_TAG"
$CRANE push "$JFR_IMAGE" "$JFR_TAG" &
PID_JFR=$!
# Wait for all pushes to complete
PUSH_FAILED=0
wait $PID_EAGLE || PUSH_FAILED=1
wait $PID_ADMIN || PUSH_FAILED=1
wait $PID_JFR || PUSH_FAILED=1
if [ $PUSH_FAILED -ne 0 ]; then
echo "ERROR: One or more image pushes failed"
if [ -z "$CRANE" ] || [ ! -x "$CRANE" ]; then
echo "ERROR: crane not found in runfiles"
find "$RUNFILES" -name crane 2>/dev/null || echo "No crane found at all"
exit 1
fi
echo "All images pushed, tagging :latest..."
# Tag :latest in parallel
$CRANE copy "$EAGLE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest" &
$CRANE copy "$ADMIN_TAG" "registry.digitalocean.com/eagle0/admin-server:latest" &
$CRANE copy "$JFR_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest" &
wait
echo "Using crane: $CRANE"
# Push Eagle image
EAGLE_IMAGE="${{ steps.build-all.outputs.eagle_path }}"
EAGLE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
echo "Pushing Eagle: $EAGLE_TAG"
$CRANE push "$EAGLE_IMAGE" "$EAGLE_TAG"
$CRANE copy "$EAGLE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
echo "eagle_image_tag=$EAGLE_TAG" >> $GITHUB_OUTPUT
# Push Admin image
ADMIN_IMAGE="${{ steps.build-all.outputs.admin_path }}"
ADMIN_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
echo "Pushing Admin: $ADMIN_TAG"
$CRANE push "$ADMIN_IMAGE" "$ADMIN_TAG"
$CRANE copy "$ADMIN_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
echo "admin_image_tag=$ADMIN_TAG" >> $GITHUB_OUTPUT
# Push JFR Sidecar image
JFR_IMAGE="${{ steps.build-all.outputs.jfr_path }}"
JFR_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
echo "Pushing JFR Sidecar: $JFR_TAG"
$CRANE push "$JFR_IMAGE" "$JFR_TAG"
$CRANE copy "$JFR_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
echo "jfr_sidecar_image_tag=$JFR_TAG" >> $GITHUB_OUTPUT
echo "=== All images pushed successfully ==="
@@ -242,12 +189,9 @@ jobs:
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
GITHUB_TOKEN_FOR_ADMIN: ${{ secrets.ADMIN_GITHUB_TOKEN }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Setup SSH key
run: |
@@ -256,11 +200,11 @@ jobs:
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Download warmup binary
uses: actions/download-artifact@v4
with:
name: warmup-binary
path: scripts/bin/
- name: Build warmup tool
run: |
bazel build //ci:warmup_tar
mkdir -p scripts/bin
tar -xf bazel-bin/ci/warmup_tar.tar -C scripts/bin --strip-components=1
- name: Copy config files to droplet
run: |
@@ -367,7 +311,6 @@ jobs:
export FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
export DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
export NOTIFY_SECRET="${NOTIFY_SECRET}"
export GITHUB_TOKEN="${GITHUB_TOKEN_FOR_ADMIN}"
# Check Docker has IPv6 support
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
@@ -379,34 +322,29 @@ jobs:
echo "Using images: \$EAGLE_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
# Install crane for pulling OCI images (cached across deploys)
CRANE_VERSION="v0.20.2"
if [ ! -x crane ] || ! ./crane version 2>/dev/null | grep -q "0.20.2"; then
echo "Installing crane \${CRANE_VERSION}..."
rm -f crane
curl -sL "https://github.com/google/go-containerregistry/releases/download/\${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" | tar xzf - crane
chmod +x crane
else
echo "Using cached crane"
fi
# Install crane for pulling OCI images
echo "Installing crane..."
rm -f crane
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
chmod +x crane
# Pull all images in parallel, then load sequentially
echo "Pulling all images in parallel..."
./crane pull "\${EAGLE_IMAGE}" eagle.tar &
./crane pull "\${ADMIN_IMAGE}" admin.tar &
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar &
docker pull nginx:alpine &
docker pull certbot/certbot &
wait
# Pull and load all images
echo "Pulling Eagle image..."
./crane pull "\${EAGLE_IMAGE}" eagle.tar && docker load -i eagle.tar && rm eagle.tar
echo "Loading images..."
docker load -i eagle.tar && rm eagle.tar
docker load -i admin.tar && rm admin.tar
echo "Pulling Admin image..."
./crane pull "\${ADMIN_IMAGE}" admin.tar && docker load -i admin.tar && rm admin.tar
# Tag as :latest locally so docker-compose fallback uses correct image
docker tag "\${ADMIN_IMAGE}" registry.digitalocean.com/eagle0/admin-server:latest
docker load -i jfr-sidecar.tar && rm jfr-sidecar.tar
echo "All images pulled and loaded successfully"
echo "Pulling JFR Sidecar image..."
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar && docker load -i jfr-sidecar.tar && rm jfr-sidecar.tar
# Pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
echo "All images pulled successfully"
# =================================================================
# Verify Shardok connectivity before proceeding with deployment
+11 -5
View File
@@ -1,7 +1,17 @@
name: Eagle Build
on:
# Main pushes are covered by docker_build.yml which builds the same target
push:
branches: [ "main" ]
paths:
- 'src/main/scala/**'
- 'src/main/protobuf/net/eagle0/eagle/**'
- 'src/main/protobuf/net/eagle0/common/**'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- '.github/workflows/eagle_build.yml'
pull_request:
paths:
- 'src/main/scala/**'
@@ -13,10 +23,6 @@ on:
- '.bazelrc'
- '.github/workflows/eagle_build.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
permissions:
contents: read
+12 -21
View File
@@ -20,10 +20,6 @@ on:
- "ci/github_actions/upload_addressables.sh"
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
@@ -50,12 +46,7 @@ jobs:
- uses: actions/checkout@v4
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: false # Library/ persists between runs on self-hosted runners
- name: Clean stale files
run: |
git clean -ffd
rm -rf src/main/csharp/net/eagle0/clients/unity/eagle0/Library/Bee/
clean: true
- name: Fetch LFS files
run: |
@@ -65,9 +56,20 @@ jobs:
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh ios
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/restore_library.sh
- name: Build iOS Addressables
run: ./ci/github_actions/build_ios_addressables.sh "$EAGLE0_BUILD_DIR/editor_ios_addressables.log"
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
env:
@@ -75,17 +77,6 @@ jobs:
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: ./ci/github_actions/upload_addressables.sh iOS
- name: Purge CDN cache for iOS addressables
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
env:
DO_CDN_PAT: ${{ secrets.DO_CDN_PAT }}
run: |
curl -s -X DELETE "https://api.digitalocean.com/v2/cdn/endpoints/8c98df29-6f0c-4704-8e82-ca40a2b81aa8/cache" \
-H "Authorization: Bearer $DO_CDN_PAT" \
-H "Content-Type: application/json" \
-d '{"files": ["addressables/iOS/*"]}' \
--fail || echo "Warning: CDN purge failed (non-fatal)"
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
+72 -17
View File
@@ -11,6 +11,7 @@ on:
permissions:
contents: read
actions: write
env:
# Runner-specific build directory to allow parallel builds on multiple runners
@@ -20,7 +21,9 @@ env:
jobs:
build-unity:
runs-on: [self-hosted, macOS, testflight]
runs-on: [self-hosted, macOS, unity-mac]
outputs:
xcode_project_path: ${{ steps.build.outputs.xcode_project_path }}
steps:
- name: Prune stale PR refs
@@ -37,14 +40,9 @@ jobs:
- uses: actions/checkout@v4
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: false # Library/ persists between runs on self-hosted runners
clean: true
fetch-depth: 0
- name: Clean stale files
run: |
git clean -ffd
rm -rf src/main/csharp/net/eagle0/clients/unity/eagle0/Library/Bee/
- name: Fetch LFS files
run: |
git lfs install
@@ -53,10 +51,22 @@ jobs:
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh ios
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/restore_library.sh
- name: Build iOS Unity Project
id: build
run: |
./ci/github_actions/build_unity_ios.sh "$EAGLE0_BUILD_DIR/eagle0iOS"
echo "xcode_project_path=$EAGLE0_BUILD_DIR/eagle0iOS" >> $GITHUB_OUTPUT
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success()
@@ -65,16 +75,18 @@ jobs:
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: ./ci/github_actions/upload_addressables.sh iOS
- name: Purge CDN cache for iOS addressables
if: success()
env:
DO_CDN_PAT: ${{ secrets.DO_CDN_PAT }}
- name: Zip Xcode project for artifact
run: |
curl -s -X DELETE "https://api.digitalocean.com/v2/cdn/endpoints/8c98df29-6f0c-4704-8e82-ca40a2b81aa8/cache" \
-H "Authorization: Bearer $DO_CDN_PAT" \
-H "Content-Type: application/json" \
-d '{"files": ["addressables/iOS/*"]}' \
--fail || echo "Warning: CDN purge failed (non-fatal)"
cd $EAGLE0_BUILD_DIR
# Use tar for speed - Xcode projects have many small files
tar -czf eagle0iOS.tar.gz eagle0iOS
- name: Upload Xcode project
uses: actions/upload-artifact@v4
with:
name: xcode-project-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0iOS.tar.gz
retention-days: 1
- name: Archive Build Log
if: success() || failure()
@@ -83,12 +95,37 @@ jobs:
name: editor_ios.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_ios.log
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
archive-and-upload:
needs: build-unity
runs-on: [self-hosted, macOS, testflight]
runs-on: [self-hosted, macOS, unity-mac]
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
ci
scripts
- name: Clean download directory
run: rm -rf $EAGLE0_BUILD_DIR/eagle0iOS
- name: Download Xcode project
uses: actions/download-artifact@v4
with:
name: xcode-project-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}
- name: Extract Xcode project
run: |
cd $EAGLE0_BUILD_DIR
tar -xzf eagle0iOS.tar.gz
rm eagle0iOS.tar.gz
ls -la eagle0iOS/
- name: Install Signing Certificate
env:
IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }}
@@ -181,3 +218,21 @@ jobs:
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
cleanup:
needs: [build-unity, archive-and-upload]
if: always()
runs-on: ubuntu-latest
steps:
- name: Delete intermediate artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
artifact_name="xcode-project-${{ github.run_id }}"
echo "Deleting artifact: $artifact_name"
artifact_id=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \
-q ".artifacts[] | select(.name == \"$artifact_name\") | .id")
if [ -n "$artifact_id" ]; then
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" || true
fi
+50 -58
View File
@@ -25,19 +25,25 @@ on:
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "ci/mac/**"
pull_request:
# On PRs, only build Mac when Mac-specific files change. Shared C#/proto
# changes are covered by the Windows build — if it passes, Mac will too.
paths:
- ".github/workflows/mac_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/protobuf/net/eagle0/common/**"
- "src/main/protobuf/net/eagle0/shardok/**"
- "src/main/protobuf/net/eagle0/eagle/api/**"
- "src/main/protobuf/net/eagle0/eagle/common/**"
- "src/main/protobuf/net/eagle0/eagle/views/**"
- "src/main/go/net/eagle0/build/mac_build_handler/**"
- "scripts/build_mac_plugin.sh"
- "scripts/build_sparkle_plugin.sh"
- "scripts/inject_sparkle.sh"
- "scripts/codesign_mac_app.sh"
- "scripts/notarize_submit.sh"
- "scripts/notarize_wait.sh"
- "ci/github_actions/build_mac.sh"
- "ci/github_actions/build_unity_mac.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "ci/mac/**"
workflow_dispatch:
inputs:
@@ -47,10 +53,6 @@ on:
default: 'false'
type: boolean
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
actions: write # Required to delete artifacts after deploy
@@ -83,30 +85,9 @@ jobs:
- uses: actions/checkout@v4
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: false # Library/ persists between runs on self-hosted runners
clean: true # Remove untracked files like old SparklePlugin.bundle
fetch-depth: 0 # For version numbering from git history
- name: Clean stale files
run: |
git clean -ffd
# Only clear Bee/ when C# files were added/deleted/renamed (structural
# changes that stale the DAG). Content-only modifications are handled by
# Bee's incremental compilation. See persist_library.sh for background.
BEE_DIR="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/Bee"
SHA_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha"
if [ -f "$SHA_FILE" ] && [ -d "$BEE_DIR" ]; then
LAST_SHA=$(cat "$SHA_FILE")
if git diff --diff-filter=ADR --name-only "$LAST_SHA" HEAD -- '*.cs' '*.csproj' '*.asmdef' 2>/dev/null | grep -q .; then
echo "C# files added/deleted/renamed since $LAST_SHA — clearing Bee/"
rm -rf "$BEE_DIR"
else
echo "No structural C# changes since $LAST_SHA — keeping Bee/"
fi
else
echo "No previous build SHA or no Bee/ — clearing Bee/ as safe default"
rm -rf "$BEE_DIR"
fi
- name: Fetch LFS files
run: |
git lfs install
@@ -115,12 +96,19 @@ jobs:
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh mac
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: mac
run: ./ci/github_actions/restore_library.sh
- name: Build Mac Unity
run: ./ci/github_actions/build_unity_mac.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC"
- name: Save build SHA for Bee/ cache invalidation
- name: Persist Library/
if: success()
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
env:
UNITY_CACHE_PLATFORM: mac
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
@@ -297,8 +285,6 @@ jobs:
needs: [build-and-sign, wait-notarization]
if: needs.build-and-sign.outputs.should_deploy == 'true'
runs-on: [self-hosted, macOS, unity-mac]
outputs:
deployed_version: ${{ steps.deploy-mac.outputs.deployed_version }}
steps:
- uses: actions/checkout@v4
@@ -321,7 +307,6 @@ jobs:
rm eagle0.app.zip
- name: Deploy Mac Build
id: deploy-mac
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
@@ -352,37 +337,44 @@ jobs:
rm "$SPARKLE_PRIVATE_KEY_PATH"
echo "deployed_version=$VERSION" >> $GITHUB_OUTPUT
# Export version for notify step
echo "DEPLOYED_VERSION=$VERSION" >> $GITHUB_ENV
# Artifact cleanup is handled by the cleanup job on ubuntu-latest
- name: Notify clients of update
if: success()
env:
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
run: |
# Wait for CDN cache to clear
sleep 60
# Notify via admin server (required=false for normal deploys)
curl -X POST "https://admin.eagle0.net/notify-update?platform=mac&version=$DEPLOYED_VERSION&required=false" \
-H "X-Notify-Secret: $NOTIFY_SECRET" \
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
- name: Delete this run's Mac app artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
# Delete this run's artifacts (names include run ID to avoid conflicts)
for artifact_name in signed-mac-app-${{ github.run_id }} notarized-mac-app-${{ github.run_id }}; do
echo "Deleting artifact: $artifact_name"
artifact_id=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \
-q ".artifacts[] | select(.name == \"$artifact_name\") | .id")
if [ -n "$artifact_id" ]; then
echo "Deleting artifact ID: $artifact_id"
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" || true
fi
done
echo "Cleanup complete"
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
notify-mac:
needs: deploy
if: needs.deploy.outputs.deployed_version != ''
runs-on: ubuntu-latest
steps:
- name: Purge CDN cache and notify clients
env:
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
DO_CDN_PAT: ${{ secrets.DO_CDN_PAT }}
run: |
# Purge CDN cache so clients get the latest files immediately
curl -s -X DELETE "https://api.digitalocean.com/v2/cdn/endpoints/8c98df29-6f0c-4704-8e82-ca40a2b81aa8/cache" \
-H "Authorization: Bearer $DO_CDN_PAT" \
-H "Content-Type: application/json" \
-d '{"files": ["mac/*", "addressables/StandaloneOSX/*"]}' \
--fail || echo "Warning: CDN purge failed (non-fatal)"
curl -X POST "https://admin.eagle0.net/notify-update?platform=mac&version=${{ needs.deploy.outputs.deployed_version }}&required=false" \
-H "X-Notify-Secret: $NOTIFY_SECRET" \
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
# Cleanup job runs regardless of success/failure to prevent artifact accumulation
cleanup:
needs: [build-and-sign, wait-notarization, deploy, notify-mac]
needs: [build-and-sign, wait-notarization, deploy]
if: always()
runs-on: ubuntu-latest
+17 -36
View File
@@ -20,10 +20,6 @@ on:
default: 'true'
type: boolean
concurrency:
group: shardok-arm64-deploy
cancel-in-progress: false
permissions:
contents: read
@@ -64,14 +60,12 @@ jobs:
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary is ELF format (Linux)"
# Check if it's ARM64 (e_machine = 0xB7 = 183 for aarch64)
# od -tx2 reads as a 16-bit value in host byte order (little-endian on macOS),
# so the little-endian ELF bytes b7 00 are displayed as 00b7.
E_MACHINE=$(od -An -j18 -N2 -tx2 "$LINUX_BIN" | tr -d ' ')
echo "ELF e_machine: $E_MACHINE"
if [ "$E_MACHINE" = "00b7" ]; then
if [ "$E_MACHINE" = "b700" ]; then
echo "SUCCESS: Binary is ARM64 (aarch64)"
else
echo "WARNING: Binary e_machine is $E_MACHINE (expected 00b7 for aarch64)"
echo "WARNING: Binary e_machine is $E_MACHINE (expected b700 for aarch64)"
fi
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
@@ -134,19 +128,20 @@ jobs:
exit 1
fi
# Get crane (cached across runs to avoid rebuilding bazel target,
# which would discard the ARM64 analysis cache due to platform change)
CRANE_VERSION="v0.20.2"
CRANE_DIR="${HOME}/.local/bin"
CRANE="${CRANE_DIR}/crane"
if [ ! -x "$CRANE" ] || ! "$CRANE" version 2>/dev/null | grep -q "0.20.2"; then
echo "Installing crane ${CRANE_VERSION}..."
mkdir -p "$CRANE_DIR"
curl -sL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Darwin_arm64.tar.gz" | tar xzf - -C "$CRANE_DIR" crane
chmod +x "$CRANE"
else
echo "Using cached crane at $CRANE"
# Build a push target to get crane in runfiles
bazel build //ci:eagle_server_push
# Find the Darwin crane binary
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
if [ -z "$CRANE" ]; then
CRANE=$(find "$RUNFILES" -name crane 2>/dev/null | head -1)
fi
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
echo "ERROR: crane not found"
exit 1
fi
echo "Using crane: $CRANE"
# Push with arm64-prefixed SHA tag (same repo as x86, different tag)
GIT_SHA=$(git rev-parse --short=8 HEAD)
@@ -191,22 +186,8 @@ jobs:
echo "Deploying Shardok ARM64: ${{ needs.build-shardok-arm64.outputs.image_tag }}"
# Pre-warm NAT64 path to DigitalOcean registry (IPv6-only Hetzner → IPv4 registry)
curl -sf --max-time 10 https://registry.digitalocean.com/v2/ > /dev/null 2>&1 || true
# Pull the new image (retry up to 3 times for NAT64 connectivity flakiness)
for attempt in 1 2 3; do
echo "Pull attempt $attempt..."
if docker pull "${{ needs.build-shardok-arm64.outputs.image_tag }}"; then
break
fi
if [ "$attempt" -eq 3 ]; then
echo "ERROR: docker pull failed after 3 attempts"
exit 1
fi
echo "Pull failed, retrying in 10s..."
sleep 10
done
# Pull the new image
docker pull "${{ needs.build-shardok-arm64.outputs.image_tag }}"
# Stop and remove any container using port 40042 or named shardok*
docker ps -q --filter "publish=40042" | xargs -r docker stop
+12 -5
View File
@@ -1,7 +1,18 @@
name: Shardok Build
on:
# Main pushes are covered by shardok_arm64_build.yml which builds the same target
push:
branches: [ "main" ]
paths:
- 'src/main/cpp/**'
- 'src/main/proto/net/eagle0/shardok/**'
- 'src/main/proto/net/eagle0/common/**'
- 'src/main/go/net/eagle0/build/**'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.bazelrc'
- '.github/workflows/shardok_build.yml'
pull_request:
paths:
- 'src/main/cpp/**'
@@ -14,10 +25,6 @@ on:
- '.bazelrc'
- '.github/workflows/shardok_build.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
permissions:
contents: read
+26 -57
View File
@@ -15,6 +15,8 @@ on:
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
- "ci/github_actions/build_unity.sh"
- "ci/github_actions/restore_library.sh"
- "ci/github_actions/persist_library.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
@@ -34,16 +36,14 @@ on:
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
- "ci/github_actions/build_unity.sh"
- "ci/github_actions/restore_library.sh"
- "ci/github_actions/persist_library.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "MODULE.bazel"
- "WORKSPACE"
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
@@ -54,8 +54,6 @@ env:
jobs:
windows-unity:
runs-on: [self-hosted, macOS, unity-windows]
outputs:
deployed_version: ${{ steps.get-version.outputs.deployed_version }}
steps:
- name: Prune stale PR refs
@@ -72,28 +70,7 @@ jobs:
- uses: actions/checkout@v4
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: false # Library/ persists between runs on self-hosted runners
- name: Clean stale files
run: |
git clean -ffd
# Only clear Bee/ when C# files were added/deleted/renamed (structural
# changes that stale the DAG). Content-only modifications are handled by
# Bee's incremental compilation. See persist_library.sh for background.
BEE_DIR="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/Bee"
SHA_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha"
if [ -f "$SHA_FILE" ] && [ -d "$BEE_DIR" ]; then
LAST_SHA=$(cat "$SHA_FILE")
if git diff --diff-filter=ADR --name-only "$LAST_SHA" HEAD -- '*.cs' '*.csproj' '*.asmdef' 2>/dev/null | grep -q .; then
echo "C# files added/deleted/renamed since $LAST_SHA — clearing Bee/"
rm -rf "$BEE_DIR"
else
echo "No structural C# changes since $LAST_SHA — keeping Bee/"
fi
else
echo "No previous build SHA or no Bee/ — clearing Bee/ as safe default"
rm -rf "$BEE_DIR"
fi
clean: true # Remove untracked files from previous builds
- name: Fetch LFS files
run: |
@@ -101,13 +78,17 @@ jobs:
git lfs pull
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh windows
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: windows
run: ./ci/github_actions/restore_library.sh
- name: Build Windows unity
run: ./ci/github_actions/build_unity.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN"
- name: Save build SHA for Bee/ cache invalidation
- name: Persist Library/
if: success()
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
env:
UNITY_CACHE_PLATFORM: windows
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
env:
@@ -145,12 +126,21 @@ jobs:
# Cleanup
rm -f /tmp/manifest_signing_key
- name: Export deployed version
id: get-version
- name: Notify clients of update
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
run: |
# Wait for CDN cache to clear
sleep 60
# Get version from manifest
VERSION=$(grep "^version=" /tmp/unity_manifest.txt | cut -d= -f2 || date +%Y.%m.%d)
echo "deployed_version=$VERSION" >> $GITHUB_OUTPUT
# Notify via admin server (required=false for normal deploys)
curl -X POST "https://admin.eagle0.net/notify-update?platform=windows&version=$VERSION&required=false" \
-H "X-Notify-Secret: $NOTIFY_SECRET" \
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
- name: Archive build log
if: success() || failure()
@@ -161,25 +151,4 @@ jobs:
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
notify-windows:
needs: windows-unity
if: needs.windows-unity.outputs.deployed_version != ''
runs-on: ubuntu-latest
steps:
- name: Purge CDN cache and notify clients
env:
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
DO_CDN_PAT: ${{ secrets.DO_CDN_PAT }}
run: |
# Purge CDN cache so clients get the latest files immediately
curl -s -X DELETE "https://api.digitalocean.com/v2/cdn/endpoints/8c98df29-6f0c-4704-8e82-ca40a2b81aa8/cache" \
-H "Authorization: Bearer $DO_CDN_PAT" \
-H "Content-Type: application/json" \
-d '{"files": ["windows/*", "addressables/StandaloneWindows64/*"]}' \
--fail || echo "Warning: CDN purge failed (non-fatal)"
curl -X POST "https://admin.eagle0.net/notify-update?platform=windows&version=${{ needs.windows-unity.outputs.deployed_version }}&required=false" \
-H "X-Notify-Secret: $NOTIFY_SECRET" \
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
-3
View File
@@ -37,8 +37,5 @@ scripts/refresh_name_layers/refresh_name_layers.zip
.metals
api_keys.txt
src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/GeneratedProtos/
src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/Eagle0Protos/
src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/Packages/com.unity.dedicated-server/
node_modules/
tools/map_generator/output/
-1
View File
@@ -5,7 +5,6 @@ repos:
rev: v4.3.0
hooks:
- id: check-added-large-files
- id: check-merge-conflict
- id: no-commit-to-branch
args: [--branch, main]
- repo: https://github.com/pocc/pre-commit-hooks
-5
View File
@@ -225,9 +225,6 @@ to be used for different players or game situations within the same server proce
- Real-time bidirectional streaming with server via `PersistentClientConnection.cs`
- Strategic map UI in `Assets/Eagle/`, tactical battle UI in `Assets/Shardok/`
- Seamless transition between strategic gameplay and hex-based tactical combat
- **NEVER add defensive null checks on Unity Inspector fields** - these hide configuration bugs. If a field isn't
linked in the editor, it should throw a NullReferenceException so the problem is immediately obvious. Silently
skipping code when a required field is null makes bugs harder to find.
**Go (Build Tools):**
@@ -336,8 +333,6 @@ dotty.tools.dotc.core.MissingType: Cannot resolve reference to type net.eagle0.e
**Configuration:** Game parameters in `/src/main/resources/net/eagle0/eagle/game_parameters.json`
**Data Files:** TSV format for battalions, heroes, and other game data
**NEVER modify hero names.** Do not change names in `heroes.tsv`, `generated_heroes`, or any other hero data files. The names are carefully chosen and are not to be altered.
## Deployment
- Bazel handles multi-language builds and dependencies
+2 -31
View File
@@ -126,26 +126,6 @@ use_repo(
"org_golang_x_sys",
)
#
# Language Support - Rust
#
bazel_dep(name = "rules_rust", version = "0.68.1")
rust = use_extension("@rules_rust//rust:extensions.bzl", "rust")
rust.toolchain(edition = "2021")
use_repo(rust, "rust_toolchains")
register_toolchains("@rust_toolchains//:all")
crate = use_extension("@rules_rust//crate_universe:extension.bzl", "crate")
crate.from_cargo(
name = "map_generator_crates",
cargo_lockfile = "//src/main/rust/net/eagle0/eagle/map_generator:Cargo.lock",
manifests = ["//src/main/rust/net/eagle0/eagle/map_generator:Cargo.toml"],
)
use_repo(crate, "map_generator_crates")
#
# Platform Support - Apple/iOS
#
@@ -154,7 +134,7 @@ use_repo(crate, "map_generator_crates")
# in a separate workspace at sparkle_workspace/ to isolate the conflict.
#
bazel_dep(name = "apple_support", version = "1.24.1", repo_name = "build_bazel_apple_support")
bazel_dep(name = "apple_support", version = "1.23.1", repo_name = "build_bazel_apple_support")
# rules_swift is a transitive dep of grpc and flatbuffers with conflicting versions:
# - grpc 1.76.0.bcr.1 requires rules_swift 3.x (compatibility level 3)
@@ -180,9 +160,8 @@ use_repo(apple_cc_configure, "local_config_apple_cc")
bazel_dep(name = "protobuf", version = "33.5", repo_name = "com_google_protobuf")
bazel_dep(name = "rules_proto", version = "7.1.0")
bazel_dep(name = "rules_cc", version = "0.2.14")
bazel_dep(name = "grpc", version = "1.74.1")
bazel_dep(name = "grpc", version = "1.74.0")
bazel_dep(name = "grpc-java", version = "1.78.0")
bazel_dep(name = "rules_proto_grpc_csharp", version = "5.8.0")
bazel_dep(name = "flatbuffers", version = "25.12.19")
#
@@ -201,12 +180,8 @@ bazel_dep(name = "aspect_bazel_lib", version = "2.22.5")
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
# Base image for Eagle (Java 25 JRE - smaller runtime, no dev tools needed)
# Digest pinned for Bazel repository cache hit; update with:
# TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:library/eclipse-temurin:pull" | python3 -c "import json,sys; print(json.load(sys.stdin)['token'])")
# curl -s -H "Authorization: Bearer $TOKEN" -H "Accept: application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json" "https://registry-1.docker.io/v2/library/eclipse-temurin/manifests/25-jre" | python3 -c "import json,sys; m=json.load(sys.stdin); [print(p['digest']) for p in m.get('manifests',[]) if p['platform']['architecture']=='amd64']"
oci.pull(
name = "eclipse_temurin_25_jre",
digest = "sha256:98236ffdfb61cff3fc4f8e40e5b80460a5a4c35a0f56a5b4182a926844a7db49",
image = "docker.io/library/eclipse-temurin",
platforms = ["linux/amd64"],
tag = "25-jre",
@@ -215,7 +190,6 @@ oci.pull(
# Base image for JFR sidecar (Java 25 JDK - includes jcmd for JFR dumps)
oci.pull(
name = "eclipse_temurin_25_jdk",
digest = "sha256:bc2d562355813350f47bc472b9721b0b84761866671ad95d9a9f1151fa69da6e",
image = "docker.io/library/eclipse-temurin",
platforms = ["linux/amd64"],
tag = "25-jdk",
@@ -309,9 +283,6 @@ maven.install(
# Error tracking
"io.sentry:sentry:8.31.0",
# SQLite for client text storage
"org.xerial:sqlite-jdbc:3.46.1.0",
],
duplicate_version_warning = "error",
fail_if_repin_required = True,
+10 -2083
View File
File diff suppressed because one or more lines are too long
-8
View File
@@ -243,13 +243,6 @@ pkg_tar(
package_dir = "/app",
)
# Package the .e0mj map files for the map editor
pkg_tar(
name = "admin_maps_layer",
srcs = ["//src/main/resources/net/eagle0/shardok/maps:maps_json"],
package_dir = "/app/maps",
)
oci_image(
name = "admin_server_image",
base = "@alpine_linux_linux_amd64",
@@ -258,7 +251,6 @@ oci_image(
tars = [
":busybox_layer",
":admin_binary_layer",
":admin_maps_layer",
],
workdir = "/app",
)
-11
View File
@@ -31,17 +31,6 @@ fi
echo "Found Xcode project: $XCODEPROJ"
# Ensure export compliance key is set in Info.plist
# This bypasses the "Missing Compliance" prompt in App Store Connect.
# Set here rather than relying solely on Unity's DeepLinkPostProcessor,
# which depends on UNITY_IOS being defined at script compilation time.
INFO_PLIST="$XCODE_PROJECT_PATH/Info.plist"
if [ -f "$INFO_PLIST" ]; then
/usr/libexec/PlistBuddy -c "Set :ITSAppUsesNonExemptEncryption false" "$INFO_PLIST" 2>/dev/null \
|| /usr/libexec/PlistBuddy -c "Add :ITSAppUsesNonExemptEncryption bool false" "$INFO_PLIST"
echo "Set ITSAppUsesNonExemptEncryption=false in Info.plist"
fi
# Unity always generates "Unity-iPhone" as the main app scheme
SCHEME="Unity-iPhone"
echo "Using scheme: $SCHEME"
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
#
# Persist Unity Library/ cache to persistent storage
#
# Environment variables:
# UNITY_CACHE_PLATFORM - Platform identifier (e.g., "mac", "windows")
# Defaults to "mac" if not set
#
# Note: Library/Bee/ is excluded because it contains DAG files with hardcoded
# file paths that become stale when project files change. This prevents
# "Data at the root level is invalid" XML errors from stale references.
set -uxo pipefail
PLATFORM="${UNITY_CACHE_PLATFORM:-mac}"
CACHE_DIR="/tmp/eagle0/Library-${PLATFORM}"
/bin/echo "persist Library/ to $CACHE_DIR (excluding Bee/)"
# rsync may exit with code 23 ("partial transfer due to error") if Unity's
# temporary files vanish during the copy. This is acceptable for a cache.
/usr/bin/rsync -rtlDvq --exclude='Bee/' src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ "$CACHE_DIR/"
rsync_exit=$?
if [ $rsync_exit -eq 0 ]; then
exit 0
elif [ $rsync_exit -eq 23 ]; then
echo "Warning: rsync exited with 23 (some files vanished during copy). This is expected for Unity temp files."
exit 0
else
echo "Error: rsync failed with exit code $rsync_exit"
exit $rsync_exit
fi
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
#
# Restore Unity Library/ cache from persistent storage
#
# Environment variables:
# UNITY_CACHE_PLATFORM - Platform identifier (e.g., "mac", "windows")
# Defaults to "mac" if not set
set -euxo pipefail
PLATFORM="${UNITY_CACHE_PLATFORM:-mac}"
CACHE_DIR="/tmp/eagle0/Library-${PLATFORM}"
/bin/echo "restore Library/ from $CACHE_DIR"
/bin/mkdir -p "$CACHE_DIR"
/usr/bin/rsync -rtlDvq "$CACHE_DIR/" src/main/csharp/net/eagle0/clients/unity/eagle0/Library/
-6
View File
@@ -208,8 +208,6 @@ services:
- "${JFR_SIDECAR_ADDR:-jfr-sidecar:8081}" # Can be switched for blue-green
- "--http-port"
- "8080"
- "--maps-dir"
- "/app/maps"
environment:
# Secret for CI to authenticate client update notifications
NOTIFY_SECRET: "${NOTIFY_SECRET:-}"
@@ -217,10 +215,6 @@ services:
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${ADMIN_S3_ACCESS_KEY:-}"
DO_SPACES_SECRET_KEY: "${ADMIN_S3_SECRET_KEY:-}"
# GitHub PAT for map editor PR creation (scoped to contents:write + pull_requests:write)
GITHUB_TOKEN: "${GITHUB_TOKEN:-}"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# No external port - accessed via nginx at admin.eagle0.net
depends_on:
- auth
-218
View File
@@ -1,218 +0,0 @@
# Programmatic Map Image Generation
## Overview
Create a tool to programmatically regenerate the Eagle strategic map images with more equalized province sizes while preserving neighbor relationships. Also implement dynamic text rendering for province names that scales with zoom.
## Current Map Assets
| File | Description | Dimensions |
|------|-------------|------------|
| `Assets/Eagle/rawGray.gz.bytes` | Province ID lookup map (gzip compressed) | 3786 x 1834 |
| `Assets/Eagle/map_bw_labels.png` | B&W map with baked-in province labels | 3786 x 1834, RGBA |
| `Assets/Eagle/Materials/1.png` - `43.png` | Individual province mask images | 3786 x 1834, grayscale |
## Province Data
- 43 provinces defined in `src/main/resources/net/eagle0/eagle/province_map.tsv`
- Each has: id, name, neighbors, neighborPositions, hexMapName
- Problem provinces (too small): **Shumal** (id=1), **Kojaria** (id=39), **Usvol** (id=33), **Tumala** (id=35), **Chia** (id=43)
## Goals
1. **Equalize province sizes** - Reduce disparity so small provinces are easier to click
2. **Preserve topology** - Maintain all neighbor relationships
3. **Preserve coastline** - Keep ocean/land boundaries
4. **Dynamic text rendering** - Province names scale with zoom level
---
## Part 1: Province Size Equalization Tool
### Algorithm: Constrained Voronoi Relaxation
1. **Extract current state from rawGray:**
- Calculate centroid of each province
- Calculate area (pixel count) of each province
- Identify ocean pixels (value 0 or 255)
2. **Iterative relaxation:**
```
For each iteration:
For each province:
Calculate target area (average of all provinces)
If province is too small:
Move centroid away from larger neighbors
If province is too large:
Move centroid toward smaller neighbors
Constraint: Don't break neighbor relationships
```
3. **Generate new boundaries:**
- Use weighted Voronoi diagram from relaxed centroids
- Weight by target area
- Mask with original coastline
4. **Output new images:**
- New rawGray (province ID map)
- New province masks (1.png - 43.png)
- New B&W base map (derived from boundaries)
### Implementation Options
**Option A: Python Script (Recommended)**
- Use numpy, scipy, PIL/Pillow
- Can run offline, generate assets once
- Easier to iterate and debug
- Output: New image files to replace existing
**Option B: Unity Editor Script**
- C# with Unity's Texture2D
- Integrated into build process
- Slower, harder to debug
### Tool Location
`tools/map_generator/` or `scripts/generate_map.py`
---
## Part 2: Dynamic Province Name Rendering
### Current State
- Province names are baked into `map_bw_labels.png`
- Not visible when zoomed out, too small when zoomed in
- `SetUpCenterText()` in MapController shows selected/hovered province name in a fixed UI panel
### New Approach
1. **Create province label GameObjects:**
- One TextMeshPro label per province
- Position at province centroid
- Child of map content (moves with zoom/pan)
2. **Dynamic scaling:**
- Scale inversely with zoom level
- At zoom 1.0: Show major provinces only (or none)
- At zoom 2.0+: Show all province names
- Font size adjusts to fit province area
3. **Implementation:**
```csharp
public class ProvinceLabelsController : MonoBehaviour {
public MapPinchZoomHandler zoomHandler;
public TextMeshProUGUI labelPrefab;
private Dictionary<int, TextMeshProUGUI> _labels;
void Update() {
float zoom = zoomHandler.CurrentZoom;
foreach (var label in _labels.Values) {
label.transform.localScale = Vector3.one / zoom;
label.gameObject.SetActive(zoom >= 1.5f);
}
}
}
```
4. **Province centroid data:**
- Add `centroid_x`, `centroid_y` columns to `province_map.tsv`
- Or calculate at runtime from rawGray
---
## Files to Create/Modify
### New Files
| File | Purpose |
|------|---------|
| `tools/map_generator/generate_map.py` | Python script to generate new map images |
| `tools/map_generator/requirements.txt` | Python dependencies |
| `Assets/Eagle/ProvinceLabelsController.cs` | Dynamic province name rendering |
### Modified Files
| File | Changes |
|------|---------|
| `Assets/Eagle/rawGray.gz.bytes` | Replaced with equalized version |
| `Assets/Eagle/Materials/*.png` | Replaced province masks |
| `Assets/Eagle/map_bw_labels.png` | Either: regenerated, or removed if using dynamic labels |
| `province_map.tsv` | Add centroid columns |
| `Assets/Gameplay.unity` | Add ProvinceLabelsController, label prefab instances |
---
## Verification
1. **Visual inspection:**
- Compare old vs new province sizes
- Verify small provinces (Shumal, Kojaria, Usvol) are larger
- Verify neighbor relationships preserved
2. **Functional testing:**
- Click on each province at various zoom levels
- Verify correct province is selected
- Verify province colors/weather still work
3. **Label testing:**
- Zoom in/out, verify labels scale appropriately
- Pan around, verify labels move with map
- Verify labels don't overlap excessively
---
## Design Decisions
1. **Equalization approach:** Reduce disparity only
- Make small provinces (Shumal, Kojaria, Usvol, etc.) 2-3x larger
- Minimal changes to large provinces
- Not targeting perfectly equal areas
2. **Coastline:** Allow simplification
- Coastline can be smoothed/simplified as part of generation
- Voronoi edges are acceptable
3. **Map style:** Solid fills with borders
- Clean, simple look
- Easier to generate programmatically
- Province boundaries rendered as dark lines
---
## Implementation Steps
### Step 1: Python Map Generator Tool
```
tools/map_generator/
├── generate_map.py # Main script
├── requirements.txt # numpy, scipy, Pillow
└── README.md # Usage instructions
```
**Algorithm:**
1. Load current rawGray.gz.bytes, decompress
2. Calculate each province's centroid and area
3. Identify land mask (non-ocean pixels)
4. Run constrained Voronoi relaxation:
- Small provinces push neighbors away
- Large provinces pull neighbors in
- Stop when min province is >= 50% of average
5. Generate weighted Voronoi from relaxed centroids
6. Output:
- `rawGray_new.bytes` (province ID map)
- `1.png` - `43.png` (province masks)
- `map_bw.png` (solid fill + borders)
- `centroids.json` (for label positioning)
### Step 2: Dynamic Province Labels (Unity)
1. Create `ProvinceLabelsController.cs`
2. Load centroids from JSON or calculate from rawGray
3. Spawn TextMeshPro labels at each centroid
4. Update label scale/visibility based on zoom
### Step 3: Integration
1. Replace asset files with generated versions
2. Update `MapController` to use new assets
3. Add `ProvinceLabelsController` to scene
4. Remove baked label image dependency
-79
View File
@@ -1,79 +0,0 @@
# Renaming a Province
This document explains the steps required to rename a province in Eagle0.
## Files to Modify
### 1. Province Map TSV (Server Source of Truth)
**File:** `src/main/resources/net/eagle0/eagle/province_map.tsv`
This is the primary source of province data. Each row contains:
- Province ID
- Province name
- Neighbor IDs
- Neighbor directions
- Province name (repeated)
- Neighbor names (dot-separated)
- Starting food
You need to:
1. Change the province name in its own row (columns 2 and 5)
2. Update the neighbor name references in all neighboring provinces (column 6)
Example: To rename "Fluria" to "NewName", you would need to update:
- Line 26: The Fluria row itself
- Lines 5, 10, 18, 24, 27, 38, 41: All provinces that list Fluria as a neighbor
### 2. LLM Map Description
**File:** `src/main/scala/net/eagle0/eagle/library/actions/llm_prompt_generators/MapDescription.scala`
This file contains a hardcoded geographic description of the map used in LLM prompts for generating narrative text. Update any references to the old province name.
### 3. Client Centroids JSON
**File:** `src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Eagle/centroids.json`
This JSON file contains province metadata used for map rendering (centroid positions, areas, orientations). Each entry has a `name` field that should be updated to match.
Note: The actual province name displayed to players comes from the server via the `ProvinceView` proto, not from this file. However, this file should be kept in sync for consistency and because it may be used for map label positioning.
### 4. Test Files
**File:** `src/test/scala/net/eagle0/eagle/library/util/validations/RuntimeValidatorTest.scala`
Update any test strings that reference the old province name.
## How Province Names Flow to the Client
Province names are sent from the Eagle server to the Unity client via the `ProvinceView` protobuf message:
```protobuf
message ProvinceView {
int32 id = 1;
string name = 6; // <-- Province name sent from server
...
}
```
The server reads province names from `province_map.tsv` at startup. The client receives names through the proto and displays them via `province.Name` in C# code. This means:
- Changing the TSV automatically updates what clients see
- No C# code changes are needed (the code uses `province.Name` generically)
- The `centroids.json` name field is for map rendering/positioning, not display
## Files That Do NOT Need Changes
- **Hero backstory TSV files** (`heroes.tsv`, `generated_heroes.tsv`) - These don't contain province name references
- **C# client code** - Uses `province.Name` from the server proto, not hardcoded names
- **Proto definitions** - No province names are hardcoded in protos
## Checklist
- [ ] Update `province_map.tsv` - province's own row
- [ ] Update `province_map.tsv` - all neighbor references
- [ ] Update `MapDescription.scala`
- [ ] Update `centroids.json`
- [ ] Update any test files with province name references
- [ ] Build and run tests: `bazel test //src/test/scala/...`
-1
View File
@@ -77,4 +77,3 @@ Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/1
## Nice to have
- [x] "What's new" changelog (fetch JSON, show entries since last launch)
- [ ] Windows code signing ([Azure Artifact Signing](https://azure.microsoft.com/en-us/products/artifact-signing/), ~$10/mo) to eliminate SmartScreen "unknown publisher" warning
+24 -61
View File
@@ -19,7 +19,7 @@ When a new player starts their first game in tutorial mode, they experience:
## Battle Configuration
### Defender (Player - John Ranil)
- **Heroes**: John Ranil + 2 random vassals (3 total)
- **Heroes**: John Ranil + 2 random sworn brothers (3 total)
- **Units**:
- 2x Light Infantry (300 troops each, 60 training/armament)
- 1x Longbowmen (200 troops, 60 training/armament)
@@ -29,7 +29,7 @@ When a new player starts their first game in tutorial mode, they experience:
### Attacker (Ikhaan Tarn)
- **Heroes**: Ikhaan Tarn + 2 sworn brothers (3 total)
- **Units**:
- 1x Heavy Cavalry (600 troops, 80 training/armament)
- 1x Heavy Cavalry (400 troops, 80 training/armament)
- 1x Heavy Infantry (500 troops, 80 training/armament)
- 1x Longbowmen (300 troops, 80 training/armament)
- **Origin Province**: 32
@@ -88,16 +88,19 @@ Screen 3: "Defend Your Home" - Call to action
**Key Methods:**
```cpp
// Check all events and execute triggered actions (called at end of each round)
std::vector<ActionResult> CheckAndExecuteEvents(
const GameStateW& state,
const SettingsGetter& settings,
const std::shared_ptr<RandomGenerator>& randomGenerator);
// Check if attacker should flee
bool ShouldTriggerScriptedFlee(const GameStateW& state);
// Check if player (defender) can flee
bool PlayerCanFlee(PlayerId playerId);
// Execute the scripted flee (100% success rate)
std::vector<ActionResult> ExecuteScriptedFlee(...);
```
**Integration:**
- `ShardokEngine.cpp`: Calls `CheckAndExecuteEvents()` at end of each round in `HandlePlayerTurnEnd()`
- Events fire in config order, each event fires at most once
**Integration Points (TODO):**
- `ShardokEngine.cpp`: Check `ShouldTriggerScriptedFlee()` during turn processing
- `FleeCommandFactory.cpp`: Disable flee commands for defender in tutorial mode
### Phase 4: Mid-Battle Reinforcements
@@ -129,33 +132,6 @@ std::vector<ActionResult> CheckAndExecuteEvents(
- Create `TutorialHeroJoined` action results adding reinforcement heroes to player faction
- Set appropriate loyalty, vigor, location for joined heroes
### Phase 7: Post-Battle Strategic Dialogue
After the battle ends and the player returns to the strategic map, narrative dialogues guide them through the next steps.
**Dialogue 1: Battle Aftermath** (trigger: `tutorial_battle_ended`)
- Old Marek reflects on the close call
- Tarn has vanished — rumors of sorcery or escape, no one knows
- Captured lieutenants: they followed orders, not to blame for Tarn's madness
- Player should try to recruit them or hold them
- No instruction text needed — Handle Captured Heroes UI is self-explanatory
**Dialogue 2: Rebuild Support** (trigger: `tutorial_rebuild_support`)
- Fires when available commands no longer include HandleCapturedHeroCommand
- Marek explains the province needs support rebuilt after the battle
- Engineers (John Ranil) can **Improve** the province — build infrastructure, develop economy
- Paladins (Elena Fyar) can **Give Alms** — distribute food to win hearts
- Instructions highlight **Improve** and **Give Alms** buttons
- Goal: get Support to **40** before January for tax revenue
**Implementation:**
- Dialogues defined in `tutorial_strategic.json`
- `tutorial_battle_ended`: fire when tutorial battle is removed from `RunningShardokGameModels`
- `tutorial_rebuild_support`: fire when available commands no longer include `HandleCapturedHeroCommand`
- Register `ImproveButton` and `AlmsButton` as highlight targets in `TutorialTargetRegistry`
See `TUTORIAL_CONTENT.md` for full dialogue text.
---
## File Summary
@@ -183,6 +159,12 @@ See `TUTORIAL_CONTENT.md` for full dialogue text.
## Remaining Implementation
### ShardokEngine Integration
Integrate `TutorialBattleController` into the Shardok battle loop:
1. Initialize controller from battle config when battle starts
2. Check `ShouldTriggerScriptedFlee()` at end of each round
3. If true, call `ExecuteScriptedFlee()` and add results to action queue
### Disable Player Flee
In `FleeCommandFactory.cpp`:
- Check if tutorial mode is enabled
@@ -239,29 +221,10 @@ In Eagle's battle resolution:
```protobuf
message TutorialBattleConfig {
bool enabled = 1;
repeated TutorialEvent events = 2;
repeated UnitInitialSize initial_sizes = 3; // For damage tracking
}
message TutorialEvent {
string event_id = 1;
TutorialTrigger trigger = 2;
TutorialAction action = 3;
}
message TutorialTrigger {
oneof trigger_type {
RoundTrigger after_round = 1;
UnitsLostTrigger units_lost = 2;
DamageTakenTrigger damage_taken = 3;
UnitKilledTrigger unit_killed = 4;
}
}
message TutorialAction {
oneof action_type {
FleeAction flee = 1;
ReinforcementsAction reinforcements = 2; // Uses CommonUnit
}
int32 flee_after_defender_units_lost = 2;
int32 flee_after_rounds = 3;
int32 attacker_player_id = 4;
bool defender_can_flee = 5;
repeated string reinforcement_hero_names = 6;
}
```
-46
View File
@@ -149,52 +149,6 @@ Triggered during battles when players encounter spells, terrain, or abilities.
---
## Post-Battle Dialogue (Narrative Dialogues)
These fire after the first tutorial battle ends and the player returns to the strategic map. They use the narrative dialogue system (`DialogueManager` / `tutorial_strategic.json`) rather than the modal tutorial system.
### Battle Aftermath
| Field | Value |
|-------|-------|
| ID | `post_battle_aftermath` |
| Trigger | `tutorial_battle_ended` (battle removed from RunningShardokGameModels) |
| Speaker | Old Marek the Learned |
| Panel Position | top |
**Dialogue (Marek):**
> That was closer than I'd like to admit. We held — barely. But Tarn... Tarn is gone. Not retreated — *gone*. The men say he vanished from the field. Some claim sorcery. Others say he slipped away in the chaos. Whatever the truth, no one can find him.
>
> But we captured some of his lieutenants. These are soldiers, Sadar — they followed orders, same as we once did. They're not to blame for Tarn's madness. See if you can bring them to our cause, or at least hold them until they come around.
**Instructions:** *(none — Handle Captured Heroes UI is self-explanatory)*
### Rebuild Support
| Field | Value |
|-------|-------|
| ID | `post_battle_rebuild` |
| Trigger | `tutorial_rebuild_support` (fires when available commands no longer include HandleCapturedHeroCommand) |
| Speaker | Old Marek the Learned |
| Panel Position | top |
**Dialogue (Marek):**
> Good. Now we need to rebuild our support here in Onmaa. The people are shaken — a battle on their doorstep will do that. We need them behind us if we're going to hold this province.
>
> Any of our heroes can develop the land or give alms to the people — nothing wins hearts faster than a full belly. Engineers like John Ranil are especially effective at improving a province, and paladins like Elena Fyar are the best at winning hearts. Either way, we need the people's support before the tax collectors come round in January.
**Instructions:** Use **Improve** and **Give Alms** to raise Support. You must have at least **40** support in the province by January in order to collect gold and food in taxes.
**Highlight targets:** `SupportField`
### Implementation Notes
- Both dialogues go in `tutorial_strategic.json`
- Need new trigger events: `tutorial_battle_ended` and `tutorial_rebuild_support`
- `tutorial_battle_ended` should fire when the tutorial battle is removed from `RunningShardokGameModels` in `EagleGameModel.ApplyGameStateViewDiff`
- `tutorial_rebuild_support` should fire when available commands no longer include `HandleCapturedHeroCommand` (i.e. the captured heroes phase is done and the player has regular commands available)
- The "rebuild" dialogue should highlight the Improve and Give Alms buttons in the command panel (requires registering them as highlight targets)
---
## Display Modes
| Mode | Description | Use For |
@@ -1,230 +0,0 @@
# Plan: Eliminate Battalion Backstory LLM Updates
## Goal
Dramatically reduce LLM request volume by eliminating battalion backstory updates. Battalions will retain:
- An **initial backstory** (generated once when created)
- A **history of events** (structured data, not LLM-generated prose)
The history will be fed into other LLM updates (hero backstories, chronicle) but will no longer be regenerated into prose for each battalion.
## Current System Overview
### Data Flow
```
Battalion Created → Initial Backstory LLM Request → Text stored
Battalion participates in events → Events accumulated → Update LLM Request → New text version
BattalionView.backstoryTextId → Client displays on hover
```
### Event Types (8 total)
Events are structured data already captured in `EventForBattalionBackstory`:
- OrganizedTroops, SuppressedBeasts, ApprehendedOutlaws, FoughtInBattle
- SuppressedRiot, Trained, Armed, SurvivedStarvation
Each event has date, province, hero, and event-specific details.
### Key Files
| Component | File |
|-----------|------|
| Unity Display | `Assets/Eagle/HeroesAndBattalionsPanelController.cs` |
| View Proto | `views/battalion_view.proto` |
| View Filter | `BattalionViewFilter.scala` |
| Update Action | `BattalionBackstoryUpdateAction.scala` |
| Update Generator | `BattalionBackstoryUpdateActionGenerator.scala` |
| Update Prompt | `BattalionBackstoryUpdatePromptGenerator.scala` |
| Event-to-Text | `BattalionBackstoryUpdatePromptGenerator.textForEvent()` |
| BattalionDescriptions | `BattalionDescriptions.scala` (used in other LLM prompts) |
---
## Stage 1: Stop Displaying Battalion Backstories in Client
**Goal:** Remove the UI that shows battalion backstory text on hover.
### Files to Modify
**`Assets/Eagle/HeroesAndBattalionsPanelController.cs`**
- In `BattalionLongHoverRowChanged()` (lines 143-169):
- Remove or comment out the backstory text population
- Keep the header/name display, remove `battalionPopupPanelBackstory.TextId = ...`
**Unity Scene/Prefab**
- Hide or remove the `battalionPopupPanelBackstory` UI element
### Verification
- [ ] Battalion hover popup no longer shows backstory text
- [ ] No errors when hovering over battalions
- [ ] Other battalion info (name, type, size) still displays
---
## Stage 2: Stop Populating Backstory in BattalionView
**Goal:** Stop sending backstory text ID to the client.
### Files to Modify
**`BattalionViewFilter.scala`**
- In `filteredBattalionViewBelongingToPlayer()`:
- Set `backstoryTextId = ""` instead of `battalion.backstoryTextId`
- In `limitedBattalionView()`:
- Same change
### Verification
- [ ] BattalionView messages have empty backstoryTextId
- [ ] Client handles empty backstoryTextId gracefully (Stage 1 should have removed the display)
---
## Stage 3: Remove backstory_text_id from BattalionView Proto
**Goal:** Clean up the proto schema.
### Files to Modify
**`views/battalion_view.proto`**
- Remove field: `string backstory_text_id = 7;`
- Mark field 7 as reserved to prevent reuse
**`Assets/Eagle/HeroesAndBattalionsPanelController.cs`**
- Remove any remaining references to `BackstoryTextId`
**`BattalionViewFilter.scala`**
- Remove `backstoryTextId` from view construction
### Verification
- [ ] Proto compiles
- [ ] C# client compiles without BackstoryTextId references
- [ ] Full test suite passes
---
## Stage 4: Replace Backstory Usage in BattalionDescriptions
**Goal:** When other LLM prompts need battalion context, use initial backstory + structured history instead of the latest LLM-generated prose.
### Current Usage in `BattalionDescriptions.scala`
```scala
def backstoryText(): TextGenerationResult =
battalion.backstoryVersions.lastOption
.map(v => clientTextStore.getText(v.textId))
.getOrElse(TextGenerationSuccess(""))
def fullDescription(): String =
s"${battalion.name} is $description. $backstoryText"
```
### New Approach
Create `historyDescription()` that:
1. Gets initial backstory (first version's text, if available)
2. Appends structured event history using the same `textForEvent()` logic from `BattalionBackstoryUpdatePromptGenerator`
```scala
def historyDescription(): String = {
val initialBackstory = battalion.backstoryVersions.headOption
.map(v => clientTextStore.getText(v.textId))
.collect { case TextGenerationSuccess(text) => text }
.getOrElse("")
val eventHistory = battalion.backstoryEvents
.map(textForEvent) // Reuse existing event-to-text conversion
.mkString(" ")
if eventHistory.isEmpty then initialBackstory
else s"$initialBackstory $eventHistory"
}
```
### Files to Modify
**`BattalionDescriptions.scala`**
- Add `historyDescription()` method
- Update `fullDescription()` to use `historyDescription()` instead of `backstoryText()`
- Keep `textForEvent()` logic (move from `BattalionBackstoryUpdatePromptGenerator` if needed)
**Move/refactor `textForEvent()`**
- Currently in `BattalionBackstoryUpdatePromptGenerator.scala` (lines 78-262)
- Move to `BattalionDescriptions.scala` or a shared utility
- This converts events to human-readable text without LLM
### Verification
- [ ] Other LLM prompts that use `BattalionDescriptions` still get meaningful context
- [ ] Chronicle updates include battalion history
- [ ] Hero backstory updates include relevant battalion history
---
## Stage 5: Stop Generating Battalion Backstory Updates
**Goal:** Remove the LLM request generation entirely.
### Files to Modify
**`BattalionBackstoryUpdateActionGenerator.scala`**
- Return empty Vector instead of generating actions
- Or delete the file entirely
**`BattalionBackstoryUpdateAction.scala`**
- Delete or mark as deprecated
**`BattalionBackstoryUpdatePromptGenerator.scala`**
- Delete or keep only `textForEvent()` if moved
**`LlmResolver.scala`**
- Remove case for `BattalionBackstoryUpdateRequest`
- Optionally throw error if somehow called
**`EndPlayerCommandsPhaseAction.scala`** (and other callers)
- Find where `BattalionBackstoryUpdateActionGenerator` is called
- Remove those calls
### Files to Search for Callers
```bash
grep -r "BattalionBackstoryUpdateAction" src/
```
### Verification
- [ ] No `BattalionBackstoryUpdateRequest` LLM requests generated
- [ ] Battalions still accumulate events (for history description)
- [ ] Full test suite passes
- [ ] Game runs without errors
---
## Future Considerations
### Keep or Remove Initial Backstory Generation?
The initial backstory (`BattalionInitialBackstoryRequest`) is generated once per battalion. Options:
1. **Keep it** - Low volume, provides flavor text for history
2. **Remove it** - Use a template-based approach instead
Recommendation: Keep for now, evaluate later based on volume.
### Event History Cleanup
Events currently accumulate forever on battalions. Consider:
- Capping event history length
- Summarizing old events
- Only keeping "significant" events
### Affected Tests
Search for tests that verify backstory update behavior:
```bash
bazel query 'tests(//src/test/scala/...) intersect rdeps(//src/test/scala/..., //src/main/scala/net/eagle0/eagle/library/actions/impl/action:battalion_backstory_update_action)'
```
---
## Summary of LLM Request Reduction
| Request Type | Current Volume | After Change |
|--------------|---------------|--------------|
| BattalionInitialBackstoryRequest | 1 per battalion | 1 per battalion (unchanged) |
| BattalionBackstoryUpdateRequest | ~N per battalion per game | **0** |
For a typical game with ~50 battalions and ~20 rounds, this could eliminate **hundreds** of LLM requests.
-93
View File
@@ -1,93 +0,0 @@
# Province Event 3D Effects
Ideas for replacing 2D shader-based province event effects with 3D particle systems.
## Current State
| Event | Effect Type | Notes |
|-------|-------------|-------|
| Festival | 3D particles | Complete |
| Epidemic | 3D particles | Complete (rising skulls + green haze) |
| Blizzard | 2D shader | Not visible on unowned (white) provinces |
| Drought | 2D shader | |
| Flood | 2D shader | |
| Beasts | None | Tricky - can be any animal or human |
## Proposed 3D Effects
### Blizzard
- Falling snowflakes with slight blue tint (ensures visibility on white backgrounds)
- Diagonal wind-blown particles to convey harsh weather
- Swirling vortex effect near ground level
- Frosty sparkle/glitter particles for magical winter feel
### Drought
- Rising dust/sand particles (tan/brown coloring)
- Small dust devils (spinning particle columns)
- Shimmering golden particles rising to suggest heat distortion
- Dried leaves/debris blowing across the province
### Flood
- Rain falling (blue-tinted streaks or droplets)
- Water splashes/ripples rising from ground
- Mist/spray particles hovering low
- Floating debris particles (leaves, sticks)
### Beasts
This is challenging since "beasts" can represent any dangerous creature. Universal approaches:
- **Circling vultures/crows overhead** - Predators attract scavengers; works for any beast type and is visually distinctive
- Dust clouds suggesting movement at province edges
- Glowing eyes appearing/disappearing in shadows
- Stylized claw/scratch mark effects
- Paw prints materializing on the ground
- Red "danger" aura pulsing from ground level
The vultures/crows approach is recommended as the most universal solution.
## Implementation Status
Controllers have been created for all 4 event types:
- `ProvinceBlizzardController.cs` - ready, needs prefab
- `ProvinceDroughtController.cs` - ready, needs prefab
- `ProvinceFloodController.cs` - ready, needs prefab
- `ProvinceBeastsController.cs` - ready, needs prefab
### Prefabs Needed
Create these prefabs in `Assets/Eagle/Effects/`:
1. **BlizzardEffect.prefab**
- Particle System with falling snowflakes
- Blue-tinted particles for visibility on white
- Diagonal velocity for wind effect
- RectTransform for UI positioning
2. **DroughtEffect.prefab**
- Particle System with rising dust particles
- Tan/brown coloring
- Upward velocity
- Optional spinning sub-emitters for dust devils
3. **FloodEffect.prefab**
- Particle System with falling rain streaks
- Blue tint
- Fast downward velocity
- Optional splash sub-emitter
4. **BeastsEffect.prefab**
- Particle System with bird silhouettes
- Circular orbit motion around center
- Black/dark particles
- Could use a crow/vulture sprite texture
### Scene Setup
Wire up in `Gameplay.unity`:
1. Add controller components to the Map GameObject
2. Assign `mapContainer`, `centroidsJson` (shared with other controllers)
3. Assign the effect prefab for each controller
-27
View File
@@ -60,7 +60,6 @@
"org.scalamock:scalamock_3": -610483078,
"org.slf4j:slf4j-api": 1609745898,
"org.slf4j:slf4j-simple": 1319506696,
"org.xerial:sqlite-jdbc": -1707012205,
"repositories": -1949687017,
"software.amazon.awssdk:aws-core": 1764800397,
"software.amazon.awssdk:http-client-spi": 1697477859,
@@ -174,7 +173,6 @@
"org.scalamock:scalamock_3": -1778657085,
"org.slf4j:slf4j-api": -771685699,
"org.slf4j:slf4j-simple": -776509812,
"org.xerial:sqlite-jdbc": 1311600081,
"software.amazon.awssdk:annotations": -324257201,
"software.amazon.awssdk:apache-client": 2066024214,
"software.amazon.awssdk:arns": -1240703529,
@@ -818,12 +816,6 @@
},
"version": "2.0.16"
},
"org.xerial:sqlite-jdbc": {
"shasums": {
"jar": "6dc7464e3803648d3ff18a7359bab6adf079fcd8495b18991f6f5edcb8ac6e3b"
},
"version": "3.46.1.0"
},
"software.amazon.awssdk:annotations": {
"shasums": {
"jar": "ed03bb4ff78900307dc96bacdec70ddf80c17df3eb6761219573025649f08ff3"
@@ -1360,9 +1352,6 @@
"org.slf4j:slf4j-simple": [
"org.slf4j:slf4j-api"
],
"org.xerial:sqlite-jdbc": [
"org.slf4j:slf4j-api"
],
"software.amazon.awssdk:apache-client": [
"commons-codec:commons-codec",
"org.apache.httpcomponents:httpclient",
@@ -2415,16 +2404,6 @@
"org.slf4j:slf4j-simple": [
"org.slf4j.simple"
],
"org.xerial:sqlite-jdbc": [
"org.sqlite",
"org.sqlite.core",
"org.sqlite.date",
"org.sqlite.javax",
"org.sqlite.jdbc3",
"org.sqlite.jdbc4",
"org.sqlite.nativeimage",
"org.sqlite.util"
],
"software.amazon.awssdk:annotations": [
"software.amazon.awssdk.annotations"
],
@@ -2835,7 +2814,6 @@
"org.scalamock:scalamock_3",
"org.slf4j:slf4j-api",
"org.slf4j:slf4j-simple",
"org.xerial:sqlite-jdbc",
"software.amazon.awssdk:annotations",
"software.amazon.awssdk:apache-client",
"software.amazon.awssdk:arns",
@@ -2912,11 +2890,6 @@
"org.slf4j.simple.SimpleServiceProvider"
]
},
"org.xerial:sqlite-jdbc": {
"java.sql.Driver": [
"org.sqlite.JDBC"
]
},
"software.amazon.awssdk:apache-client": {
"software.amazon.awssdk.http.SdkHttpService": [
"software.amazon.awssdk.http.apache.ApacheSdkHttpService"
+4 -64
View File
@@ -2,67 +2,7 @@
set -euxo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
UNITY_ROOT="$REPO_ROOT/src/main/csharp/net/eagle0/clients/unity/eagle0"
OUTPUT_DIR="$UNITY_ROOT/Assets/GeneratedProtos"
# All C# protobuf generation targets
TARGETS=(
"//src/main/protobuf/net/eagle0/common:common_csharp_proto_srcs"
"//src/main/protobuf/net/eagle0/shardok/storage:storage_csharp_proto_srcs"
"//src/main/protobuf/net/eagle0/shardok/common:common_csharp_proto_srcs"
"//src/main/protobuf/net/eagle0/shardok/api:api_csharp_proto_srcs"
"//src/main/protobuf/net/eagle0/eagle/common:common_csharp_proto_srcs"
"//src/main/protobuf/net/eagle0/eagle/views:views_csharp_proto_srcs"
"//src/main/protobuf/net/eagle0/eagle/api:api_csharp_proto_srcs"
"//src/main/protobuf/net/eagle0/eagle/api:api_csharp_grpc_srcs"
"//src/main/protobuf/net/eagle0/eagle/api/command/util:util_csharp_proto_srcs"
)
# Subdirectory for each target (avoids filename collisions across packages)
SUBDIRS=(
"net/eagle0/common"
"net/eagle0/shardok/storage"
"net/eagle0/shardok/common"
"net/eagle0/shardok/api"
"net/eagle0/eagle/common"
"net/eagle0/eagle/views"
"net/eagle0/eagle/api"
"net/eagle0/eagle/api"
"net/eagle0/eagle/api/command/util"
)
/bin/echo "Building C# protobuf sources..."
bazel build "${TARGETS[@]}"
/bin/echo "Syncing generated .cs files to $OUTPUT_DIR..."
# Stage new files in a temp directory, then rsync to preserve timestamps
# on unchanged files. This avoids triggering Unity reimport when protos
# haven't changed, saving ~7 minutes of script recompilation.
STAGING_DIR=$(mktemp -d)
trap "rm -rf '$STAGING_DIR'" EXIT
for i in "${!TARGETS[@]}"; do
target="${TARGETS[$i]}"
subdir="${SUBDIRS[$i]}"
# Convert target label to bazel-bin path
# //src/main/protobuf/net/eagle0/common:common_csharp_proto_srcs
# -> bazel-bin/src/main/protobuf/net/eagle0/common/common_csharp_proto_srcs
target_path="${target#//}"
pkg="${target_path%%:*}"
name="${target_path##*:}"
bin_dir="$REPO_ROOT/bazel-bin/$pkg/$name"
dest_dir="$STAGING_DIR/$subdir"
mkdir -p "$dest_dir"
find "$bin_dir" -name "*.cs" -exec cp {} "$dest_dir/" \;
done
# Sync only changed files and delete removed ones; --checksum compares
# content not timestamps so unchanged files keep their original mtime.
mkdir -p "$OUTPUT_DIR"
rsync -rc --delete "$STAGING_DIR/" "$OUTPUT_DIR/"
/bin/echo "Done. Generated C# proto sources in $OUTPUT_DIR"
/bin/echo "build protos"
mkdir -p src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/Eagle0Protos/
dotnet build src/main/csharp/net/eagle0/clients/unity/eagle0/protos/protos.csproj \
-o src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/Eagle0Protos/
+1 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
curl -L "https://docs.google.com/spreadsheets/d/1DHEsiv4cY4gE6AX3sVH82K__mpBD1aznIYCQwQxA_F0/export?gid=0&format=tsv" | tr -d '\r' > /tmp/names.tsv
bazel run //src/main/scala/net/eagle0/util:name_list_checker -- /tmp/names.tsv > /dev/null
bazel run //src/main/scala/net/eagle0/util:name_list_checker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.tsv
bazel run //src/main/scala/net/eagle0/util:name_list_json_maker -- /tmp/names.tsv > src/main/resources/net/eagle0/names.json
curl -L "https://docs.google.com/spreadsheets/d/1NhvG73HKyVE36yGpkV2oJiSIXoNqQOYTr5ArLnucYL0/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/shardok/battalionTypes.tsv
curl -L "https://docs.google.com/spreadsheets/d/1pNWiyxIks2wJ1v7jRLFD24zrKHG2AfhC-nkWmQKQGN4/export?gid=0&format=tsv" | tr -d '\r' > src/main/resources/net/eagle0/eagle/heroes.tsv
-73
View File
@@ -1,73 +0,0 @@
#!/bin/bash
#
# Restart the active Eagle instance.
# Reads the active instance from /opt/eagle0/.active-instance (set by deploy-blue-green.sh).
#
# This is useful when you need Eagle to reload game state from persistence
# (e.g., after a rewind) without doing a full blue-green deployment.
#
# Usage:
# eagle-restart # Restart active instance, wait for healthy
# eagle-restart --no-wait # Restart without waiting for health check
#
# To create an alias, add to ~/.bashrc:
# alias eagle-restart='/opt/eagle0/scripts/eagle-restart.sh'
#
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/eagle0}"
ACTIVE_FILE="${APP_DIR}/.active-instance"
HEALTH_TIMEOUT=120 # seconds
# Read active instance from file, with fallback
if [ -f "$ACTIVE_FILE" ]; then
ACTIVE=$(cat "$ACTIVE_FILE")
else
# Fallback: check which container is actually running
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
ACTIVE="eagle-blue"
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
ACTIVE="eagle-green"
else
ACTIVE=""
fi
fi
if [ -z "$ACTIVE" ]; then
echo "Error: No active Eagle instance found" >&2
exit 1
fi
NO_WAIT=false
if [ "${1:-}" = "--no-wait" ]; then
NO_WAIT=true
fi
echo "Restarting ${ACTIVE}..."
docker restart "$ACTIVE"
if [ "$NO_WAIT" = true ]; then
echo "Restart initiated. Not waiting for health check."
exit 0
fi
echo "Waiting for ${ACTIVE} to become healthy (timeout: ${HEALTH_TIMEOUT}s)..."
elapsed=0
while [ $elapsed -lt $HEALTH_TIMEOUT ]; do
health=$(docker inspect --format='{{.State.Health.Status}}' "$ACTIVE" 2>/dev/null || echo "unknown")
if [ "$health" = "healthy" ]; then
echo "${ACTIVE} is healthy after ${elapsed}s."
exit 0
fi
sleep 2
elapsed=$((elapsed + 2))
# Print progress every 10 seconds
if [ $((elapsed % 10)) -eq 0 ]; then
echo " ...${elapsed}s (status: ${health})"
fi
done
echo "Warning: ${ACTIVE} did not become healthy within ${HEALTH_TIMEOUT}s (status: ${health})" >&2
echo "Check logs with: eagle-logs" >&2
exit 1
@@ -95,16 +95,9 @@ auto ConvertUnit(
shardokUnit.mutate_stun_rounds_remaining(0);
for (const PlayerId pid : allPlayerIds) {
int8_t initialKnowledge = 0;
for (const auto visibleToPid : unit.visible_to_player_ids()) {
if (visibleToPid == pid) {
initialKnowledge = 100;
break;
}
}
shardokUnit.mutable_opponent_knowledge()->Mutate(
static_cast<flatbuffers::uoffset_t>(pid),
initialKnowledge);
0);
}
shardokUnit.mutate_has_moved_in_zoc(false);
@@ -20,9 +20,6 @@
#include <sstream>
#endif
#include <set>
#include <unordered_map>
#include "AIAttackerStrategySelector.hpp"
#include "AIConfig.hpp"
#include "AIDefenderStrategySelector.hpp"
@@ -174,117 +171,8 @@ auto ShardokAIClient::StandardChooseCommandIndex(
// }
// }
assert(commandCount == realAvailableCommands->size());
// Verify that the AI's guessed state produces the same available commands as reality
if (commandCount != realAvailableCommands->size()) {
fprintf(stderr,
"AI command count mismatch: guessed=%zu real=%zu player=%d round=%d\n",
commandCount,
realAvailableCommands->size(),
playerId,
guessedState->current_round());
const auto maxDump = std::max(commandCount, realAvailableCommands->size());
for (size_t i = 0; i < maxDump; i++) {
const auto *realName = i < realAvailableCommands->size()
? net::eagle0::shardok::common::CommandType_Name(
(*realAvailableCommands)[i]->GetCommandType())
.c_str()
: "(none)";
const auto *guessedName = i < commandCount
? net::eagle0::shardok::common::CommandType_Name(
(*guessedCommands)[i]->GetCommandType())
.c_str()
: "(none)";
const int realActor = i < realAvailableCommands->size()
? (*realAvailableCommands)[i]->GetActorUnitId()
: -1;
const int guessedActor =
i < commandCount ? (*guessedCommands)[i]->GetActorUnitId() : -1;
const int realRow = i < realAvailableCommands->size()
? (*realAvailableCommands)[i]->GetTargetRow()
: -1;
const int realCol = i < realAvailableCommands->size()
? (*realAvailableCommands)[i]->GetTargetColumn()
: -1;
const int guessedRow = i < commandCount ? (*guessedCommands)[i]->GetTargetRow() : -1;
const int guessedCol = i < commandCount ? (*guessedCommands)[i]->GetTargetColumn() : -1;
fprintf(stderr,
" [%zu] real: %s unit=%d (%d,%d) guessed: %s unit=%d (%d,%d)\n",
i,
realName,
realActor,
realRow,
realCol,
guessedName,
guessedActor,
guessedRow,
guessedCol);
}
// Find positions in real commands that are missing from guessed commands
// Group by unit to find the missing position per unit
std::unordered_map<int, std::set<std::pair<int, int>>> realPositions;
std::unordered_map<int, std::set<std::pair<int, int>>> guessedPositions;
for (size_t i = 0; i < realAvailableCommands->size(); i++) {
const auto &cmd = (*realAvailableCommands)[i];
realPositions[cmd->GetActorUnitId()].emplace(
cmd->GetTargetRow(),
cmd->GetTargetColumn());
}
for (size_t i = 0; i < commandCount; i++) {
const auto &cmd = (*guessedCommands)[i];
guessedPositions[cmd->GetActorUnitId()].emplace(
cmd->GetTargetRow(),
cmd->GetTargetColumn());
}
for (const auto &[unitId, positions] : realPositions) {
for (const auto &[row, col] : positions) {
if (!guessedPositions[unitId].contains({row, col})) {
fprintf(stderr,
" Missing from guessed: unit=%d pos=(%d,%d)",
unitId,
row,
col);
const Coords missingCoords(row, col);
const auto *occupant = guessedState.GetOccupant(missingCoords);
if (occupant) {
fprintf(stderr,
" -> guessed occupant: unit_id=%d player=%d status=%d "
"loc=(%d,%d) hidden=%d battalion_size=%d\n",
occupant->unit_id(),
occupant->player_id(),
static_cast<int>(occupant->status()),
occupant->location().row(),
occupant->location().column(),
occupant->hidden(),
occupant->battalion().size());
} else {
fprintf(stderr, " -> no occupant in guessed state\n");
}
}
}
}
// Dump all guessed state units for full picture
fprintf(stderr, " Guessed state units (%u total):\n", guessedState->units()->size());
for (size_t i = 0; i < guessedState->units()->size(); i++) {
const auto *u = guessedState->units()->Get(static_cast<unsigned int>(i));
fprintf(stderr,
" unit_id=%d player=%d status=%d loc=(%d,%d) hidden=%d "
"starting_pos_idx=%d\n",
u->unit_id(),
u->player_id(),
static_cast<int>(u->status()),
u->location().row(),
u->location().column(),
u->hidden(),
u->starting_position_index());
}
throw ShardokInternalErrorException(
"AI command count mismatch: guessed=" + std::to_string(commandCount) +
" real=" + std::to_string(realAvailableCommands->size()));
}
for (size_t i = 0; i < commandCount; i++) {
CheckCommand((*realAvailableCommands)[i], (*guessedCommands)[i]);
}
@@ -93,8 +93,7 @@ auto AbstractAIScoreCalculator::CalculateUnitsScoreComponents(
case net::eagle0::shardok::storage::fb::UnitStatus_OUTLAWED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RETREATED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT:
case net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT: break;
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT: break;
case net::eagle0::shardok::storage::fb::UnitStatus_UNKNOWN_UNIT:
throw ShardokInternalErrorException("Unknown unit status");
@@ -56,18 +56,13 @@ public:
commandFactories(CommandFactoriesList(settings).GetFactories()),
playerSetupCommandFactory(settings) {}
[[nodiscard]] auto GetPlayerSetupCommands(
const GameStateW &gameState,
PlayerId playerId,
const std::optional<std::vector<Coords>> &reinforcementPositions) const
[[nodiscard]] auto GetPlayerSetupCommands(const GameStateW &gameState, PlayerId playerId) const
-> CommandListSPtr override;
[[nodiscard]] auto GetAvailableCommands(
const GameStateW &gameState,
PlayerId player,
bool includeFollowUps,
const std::optional<std::vector<Coords>> &reinforcementPositions) const
-> CommandListSPtr override;
bool includeFollowUps) const -> CommandListSPtr override;
};
auto AvailableCommandsFactory::MakeAvailableCommandsFactory(const SettingsGetter &settings)
@@ -77,14 +72,9 @@ auto AvailableCommandsFactory::MakeAvailableCommandsFactory(const SettingsGetter
auto AvailableCommandsFactoryImpl::GetPlayerSetupCommands(
const GameStateW &gameState,
const PlayerId playerId,
const std::optional<std::vector<Coords>> &reinforcementPositions) const -> CommandListSPtr {
const PlayerId playerId) const -> CommandListSPtr {
CommandList previewCommands{};
playerSetupCommandFactory.AddAvailablePlayerSetupCommands(
previewCommands,
playerId,
gameState,
reinforcementPositions);
playerSetupCommandFactory.AddAvailablePlayerSetupCommands(previewCommands, playerId, gameState);
return make_shared<CommandList>(previewCommands);
}
@@ -178,14 +168,12 @@ void AvailableCommandsFactoryImpl::AddAvailableCommandsForOneUnit(
auto AvailableCommandsFactoryImpl::GetAvailableCommands(
const GameStateW &gameState,
PlayerId playerId,
bool includeFollowUps,
const std::optional<std::vector<Coords>> &reinforcementPositions) const -> CommandListSPtr {
bool includeFollowUps) const -> CommandListSPtr {
CommandList commands{};
vector<AdjacentTile> adjacentTiles;
playerSetupCommandFactory
.AddAvailablePlayerSetupCommands(commands, playerId, gameState, reinforcementPositions);
playerSetupCommandFactory.AddAvailablePlayerSetupCommands(commands, playerId, gameState);
if (!commands.empty()) return make_shared<CommandList>(commands);
for (const Unit *unit : *gameState->units()) {
@@ -9,12 +9,8 @@
#ifndef AvailableCommandsFactory_hpp
#define AvailableCommandsFactory_hpp
#include <optional>
#include <vector>
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/Coordinates.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/unit/Unit.hpp"
@@ -33,16 +29,12 @@ public:
[[nodiscard]] virtual auto GetPlayerSetupCommands(
const GameStateW &gameState,
PlayerId playerId,
const std::optional<std::vector<Coords>> &reinforcementPositions = std::nullopt) const
-> CommandListSPtr = 0;
PlayerId playerId) const -> CommandListSPtr = 0;
[[nodiscard]] virtual auto GetAvailableCommands(
const GameStateW &gameState,
PlayerId player,
bool includeFollowUps,
const std::optional<std::vector<Coords>> &reinforcementPositions = std::nullopt) const
-> CommandListSPtr = 0;
bool includeFollowUps) const -> CommandListSPtr = 0;
};
} // namespace shardok
@@ -27,7 +27,6 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library/actions:update_game_status_action",
"//src/main/cpp/net/eagle0/shardok/library/actions:update_opponent_knowledge_action",
"//src/main/cpp/net/eagle0/shardok/library/fb_helpers:game_state_helpers",
"//src/main/cpp/net/eagle0/shardok/library/tutorial:tutorial_battle_controller",
"//src/main/cpp/net/eagle0/shardok/library/util:game_state_validator",
"//src/main/cpp/net/eagle0/shardok/library/view_filters:action_result_filter",
"//src/main/cpp/net/eagle0/shardok/library/view_filters:game_state_filter",
@@ -58,7 +57,6 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library/actions:new_round_action",
"//src/main/cpp/net/eagle0/shardok/library/command_factories:command_factories_list",
"//src/main/cpp/net/eagle0/shardok/library/commands:end_turn_command",
"//src/main/cpp/net/eagle0/shardok/library/map:coordinates",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/cpp/net/eagle0/shardok/library/unit",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
@@ -286,49 +286,21 @@ void ShardokEngine::HandlePlayerTurnEnd(const std::shared_ptr<RandomGenerator> &
true /* atEndOfRound */)
.Execute(gameState, randomGenerator));
// Check for tutorial events at end of round
if (!GameIsOver() && tutorialController_.IsEnabled()) {
auto tutorialEventResults = tutorialController_.CheckAndExecuteEvents(
gameState,
settingsGetter,
randomGenerator);
ApplyAndAddActionResults(
UpdateOpponentKnowledgeAction(settingsGetter).Execute(gameState, randomGenerator));
if (!tutorialEventResults.actionResults.empty()) {
ApplyAndAddActionResults(tutorialEventResults.actionResults);
ApplyAndAddActionResults(
NewRoundAction(gameState, settingsGetter).Execute(gameState, randomGenerator));
// Update game status after tutorial events (e.g., attackers fled, defender wins)
ApplyAndAddActionResults(UpdateGameStatusAction(
GetCurrentGameState(),
criticalTileCoords,
settingsGetter,
true /* atEndOfRound */)
.Execute(gameState, randomGenerator));
ApplyAndAddActionResults(
StartPlayerTurnAction(gameState, UNCONTROLLED_PLAYER_ID, settingsGetter)
.Execute(gameState, randomGenerator));
// If tutorial events ended the game, don't start new round
if (GameIsOver()) { return; }
ApplyAndAddActionResults(PerformUndeadCommandsAction(gameState, settingsGetter)
.Execute(gameState, randomGenerator));
// If reinforcement placement is needed, pause the turn flow
if (tutorialEventResults.reinforcementPlacement.has_value()) {
pendingReinforcementPlacement_ = tutorialEventResults.reinforcementPlacement;
// Set state to REINFORCEMENT_PLACEMENT
ActionResultProto placementStateAction{};
placementStateAction.set_type(
net::eagle0::shardok::common::TUTORIAL_REINFORCEMENTS_ARRIVED);
placementStateAction.mutable_game_status()->set_state(
GameStatusProto::State::GameStatus_State_REINFORCEMENT_PLACEMENT);
*placementStateAction.mutable_game_status()->mutable_description() =
"Place your reinforcements!";
// Set current player to the reinforcement recipient
placementStateAction.mutable_next_player()->set_value(
pendingReinforcementPlacement_->playerId);
ApplyAndAddActionResult(placementStateAction);
return;
}
}
}
ResumeAfterReinforcementPlacement(randomGenerator);
ApplyAndAddActionResults(StartPlayerTurnAction(gameState, 0, settingsGetter)
.Execute(gameState, randomGenerator));
} else {
const auto updateAction = UpdateGameStatusAction(
@@ -344,27 +316,6 @@ void ShardokEngine::HandlePlayerTurnEnd(const std::shared_ptr<RandomGenerator> &
}
}
void ShardokEngine::ResumeAfterReinforcementPlacement(
const std::shared_ptr<RandomGenerator> &randomGenerator) {
pendingReinforcementPlacement_ = std::nullopt;
ApplyAndAddActionResults(
UpdateOpponentKnowledgeAction(settingsGetter).Execute(gameState, randomGenerator));
ApplyAndAddActionResults(
NewRoundAction(gameState, settingsGetter).Execute(gameState, randomGenerator));
ApplyAndAddActionResults(
StartPlayerTurnAction(gameState, UNCONTROLLED_PLAYER_ID, settingsGetter)
.Execute(gameState, randomGenerator));
ApplyAndAddActionResults(PerformUndeadCommandsAction(gameState, settingsGetter)
.Execute(gameState, randomGenerator));
ApplyAndAddActionResults(StartPlayerTurnAction(gameState, 0, settingsGetter)
.Execute(gameState, randomGenerator));
}
void ShardokEngine::PostPlacementCommands(
const PlayerId player,
const vector<UnitPlacementInfo> &placementInfos,
@@ -373,15 +324,8 @@ void ShardokEngine::PostPlacementCommands(
throw ShardokClientErrorException("Posting for a player who's not the current player ID!");
}
const auto reinforcementPositions =
pendingReinforcementPlacement_.has_value()
? std::optional<std::vector<Coords>>(
pendingReinforcementPlacement_->startingPositions)
: std::nullopt;
const auto placementCommands = availableCommandsFactory->GetPlayerSetupCommands(
gameState,
player,
reinforcementPositions);
const auto placementCommands =
availableCommandsFactory->GetPlayerSetupCommands(gameState, player);
// first make sure they're all valid and there are no duplicates
for (size_t i = 0; i < placementInfos.size(); i++) {
@@ -421,19 +365,6 @@ void ShardokEngine::PostPlacementCommands(
}
}
// If we were in reinforcement placement, check if all units are now placed
if (pendingReinforcementPlacement_.has_value()) {
const auto reserveUnits =
ReserveUnitsForPlayer(gameState->units(), pendingReinforcementPlacement_->playerId);
if (reserveUnits.empty()) {
// All reinforcements placed — resume the turn flow
ResumeAfterReinforcementPlacement(randomGenerator);
return;
}
// Still have units to place — stay in REINFORCEMENT_PLACEMENT state
return;
}
const auto updateAction = UpdateGameStatusAction(
GetCurrentGameState(),
criticalTileCoords,
@@ -632,16 +563,10 @@ auto ShardokEngine::GetAvailableCommandProtos(const PlayerId playerId, const boo
auto ShardokEngine::UncachedGetAvailableCommands(
const PlayerId playerId,
const bool includeFollowUps) const -> CommandListSPtr {
const auto reinforcementPositions =
pendingReinforcementPlacement_.has_value()
? std::optional<std::vector<Coords>>(
pendingReinforcementPlacement_->startingPositions)
: std::nullopt;
return availableCommandsFactory->GetAvailableCommands(
gameState,
playerId,
/* includeFollowUps=*/includeFollowUps,
reinforcementPositions);
/* includeFollowUps=*/includeFollowUps);
}
void AddUnits(vector<net::eagle0::shardok::storage::ResolvedUnit> &to, const Units &from) {
@@ -673,7 +598,6 @@ void AddUnits(vector<net::eagle0::shardok::storage::ResolvedUnit> &to, const Uni
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_NEVER_ENTERED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT:
case net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT:
ru.set_status(
net::eagle0::shardok::storage::ResolvedUnit_UnitStatus_NEVER_ENTERED_UNIT);
break;
@@ -20,7 +20,6 @@
#include "src/main/cpp/net/eagle0/shardok/library/fb_helpers/GameStateHelpers.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/CoordsSet.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/tutorial/TutorialBattleController.hpp"
#include "src/main/protobuf/net/eagle0/shardok/api/action_result_view.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/api/game_state_view.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/api/unit_view.pb.h"
@@ -37,7 +36,6 @@ using net::eagle0::shardok::api::UnitView;
using PlayerInfoProto = net::eagle0::shardok::common::PlayerInfo;
using net::eagle0::shardok::storage::ShardokActionWithResultingState;
using HexMapProto = net::eagle0::shardok::common::HexMap;
using TutorialBattleConfigProto = net::eagle0::common::TutorialBattleConfig;
class ShardokEngine {
private:
@@ -54,9 +52,6 @@ private:
const CoordsSet criticalTileCoords;
TutorialBattleController tutorialController_;
std::optional<ReinforcementPlacementInfo> pendingReinforcementPlacement_;
mutable CommandListSPtr cachedAvailableCommands{};
void ApplyAndAddActionResult(const ActionResult &result);
@@ -77,7 +72,6 @@ private:
}
void HandlePlayerTurnEnd(const std::shared_ptr<RandomGenerator> &randomGenerator);
void ResumeAfterReinforcementPlacement(const std::shared_ptr<RandomGenerator> &randomGenerator);
[[nodiscard]] auto UncachedGetAvailableCommands(PlayerId playerId, bool includeFollowUps) const
-> CommandListSPtr;
@@ -203,17 +197,6 @@ public:
[[nodiscard]] auto GetGameSettings() const -> GameSettingsSPtr { return gameSettings; }
/// Configure the tutorial battle controller (call after construction if needed)
void SetTutorialBattleConfig(const TutorialBattleConfigProto &config) {
tutorialController_ = TutorialBattleController(config);
}
/// Returns the pending reinforcement placement info, if any.
[[nodiscard]] auto GetPendingReinforcementPlacement() const
-> const std::optional<ReinforcementPlacementInfo> & {
return pendingReinforcementPlacement_;
}
static inline auto GameIsOver(const fb::GameStatus *status) -> bool {
return (status->state() == net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY);
}
@@ -90,7 +90,6 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library/actions:end_player_setup_command",
"//src/main/cpp/net/eagle0/shardok/library/actions:place_hidden_unit_command",
"//src/main/cpp/net/eagle0/shardok/library/actions:place_unit_command",
"//src/main/cpp/net/eagle0/shardok/library/map:coordinates",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/cpp/net/eagle0/shardok/library/util:hex_map_utils",
"//src/main/cpp/net/eagle0/shardok/library/util:player_utils",
@@ -66,30 +66,11 @@ auto PlayerSetupCommandFactory::AddAvailablePlaceAndHideUnitCommandsForOneUnit(
auto PlayerSetupCommandFactory::AddAvailablePlayerSetupCommands(
CommandList &existingCommands,
PlayerId playerId,
const GameStateW &gameState,
const std::optional<std::vector<Coords>> &reinforcementPositions) const -> void {
const GameStateW &gameState) const -> void {
if (playerId < 0) return;
const auto state = gameState->status()->state();
// Handle reinforcement placement phase
if (state == net::eagle0::shardok::storage::fb::GameStatus_::State_REINFORCEMENT_PLACEMENT) {
if (!reinforcementPositions.has_value()) return;
const auto unplacedUnits = ReserveUnitsForPlayer(gameState->units(), playerId);
if (unplacedUnits.empty()) return;
for (const auto &[unitId, unit] : unplacedUnits) {
for (const Coords &position : *reinforcementPositions) {
if (!gameState.GetOccupant(position)) {
existingCommands.push_back(std::make_shared<PlaceUnitCommand>(unit, position));
}
}
}
if (gameState->status()->state() !=
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP)
return;
}
if (state != net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) return;
const bool isDefender = PlayerIsDefender(gameState, playerId);
@@ -5,12 +5,8 @@
#ifndef EAGLE0_PLAYERSETUPCOMMANDFACTORY_HPP
#define EAGLE0_PLAYERSETUPCOMMANDFACTORY_HPP
#include <optional>
#include <vector>
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/ShardokCommand.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/Coordinates.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
namespace shardok {
@@ -33,9 +29,7 @@ public:
auto AddAvailablePlayerSetupCommands(
CommandList& existingCommands,
PlayerId playerId,
const GameStateW& gameState,
const std::optional<std::vector<Coords>>& reinforcementPositions = std::nullopt) const
-> void;
const GameStateW& gameState) const -> void;
};
} // namespace shardok
@@ -331,7 +331,7 @@ void MutatingApplyResult(
.size()
: changedUnit->battalion().size();
auto status = changedUnit->status();
auto status = net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT;
if (IsDestroyed(*changedUnit)) {
status =
changedUnit->has_attached_hero()
@@ -34,7 +34,6 @@ auto IsResolved(const net::eagle0::shardok::storage::fb::UnitStatus status) -> b
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_NEVER_ENTERED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT:
case net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT:
case net::eagle0::shardok::storage::fb::UnitStatus_UNKNOWN_UNIT: return false;
}
@@ -18,7 +18,6 @@ using net::eagle0::shardok::common::GameStatus;
auto actorAfter = *currentState->units()->Get(actorId);
actorAfter.mutable_location() = target;
actorAfter.mutate_hidden(true);
actorAfter.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT);
MutatingBumpAllKnowledgeToMinimum(&actorAfter, minimumKnowledge);
ActionResult placeResult{};
@@ -16,7 +16,6 @@ auto PlaceUnitCommand::InternalExecute(
const std::shared_ptr<RandomGenerator>& /*generator*/) const -> vector<ActionResult> {
auto actorAfter = *actor;
actorAfter.mutable_location() = target;
actorAfter.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT);
ActionResult placeResult{};
placeResult.set_type(ActionType::PLACE_UNIT);
@@ -89,9 +89,7 @@ auto UpdateGameStatusAction::InternalExecute(
// If the game has already ended, just return that state
if (GameIsOver(gameState->status())) { return results; }
if (gameState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP ||
gameState->status()->state() ==
net::eagle0::shardok::storage::fb::GameStatus_::State_REINFORCEMENT_PLACEMENT) {
net::eagle0::shardok::storage::fb::GameStatus_::State_SET_UP) {
return results;
}
@@ -25,7 +25,6 @@ auto ReinforceCommand::InternalExecute(
const Unit* joiningUnit = currentState->units()->Get(joiningUnitId);
auto joinerAfter = *joiningUnit;
joinerAfter.mutable_location() = location;
joinerAfter.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_NORMAL_UNIT);
ActionResult placeResult{};
placeResult.set_type(ActionType::REINFORCED);
@@ -176,10 +176,7 @@ auto SetupInitialGameState(
modifiedUnit.mutable_battalion().mutate_base_morale(baseMorale);
modifiedUnit.mutable_battalion().mutate_morale(baseMorale);
if (modifiedUnit.status() !=
net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT) {
modifiedUnit.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT);
}
modifiedUnit.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT);
unitsVec.push_back(modifiedUnit);
}
@@ -1,19 +0,0 @@
load("//tools:copts.bzl", "COPTS")
cc_library(
name = "tutorial_battle_controller",
srcs = ["TutorialBattleController.cpp"],
hdrs = ["TutorialBattleController.hpp"],
copts = COPTS,
visibility = ["//visibility:public"],
deps = [
"//src/main/cpp/net/eagle0/common:random_generator",
"//src/main/cpp/net/eagle0/shardok/library:game_state_w",
"//src/main/cpp/net/eagle0/shardok/library/map:coordinates",
"//src/main/cpp/net/eagle0/shardok/library/settings:game_settings",
"//src/main/cpp/net/eagle0/shardok/library/util:action_result_flatbuffer_helpers",
"//src/main/protobuf/net/eagle0/common:tutorial_battle_config_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/common:action_type_cc_proto",
"//src/main/protobuf/net/eagle0/shardok/storage:action_result_cc_proto",
],
)
@@ -1,283 +0,0 @@
//
// TutorialBattleController.cpp
// eagle0
//
// Created for tutorial battle system.
// Copyright 2025 none. All rights reserved.
//
#include "TutorialBattleController.hpp"
#include <ranges>
#include <vector>
#include "src/main/cpp/net/eagle0/shardok/library/util/ActionResultFlatbufferHelpers.hpp"
namespace shardok {
using UnitStatusFB = net::eagle0::shardok::storage::fb::UnitStatus;
using ActionType = net::eagle0::shardok::common::ActionType;
using FleeActionProto = net::eagle0::common::FleeAction;
using ReinforcementsActionProto = net::eagle0::common::ReinforcementsAction;
using UnitKilledTriggerProto = net::eagle0::common::UnitKilledTrigger;
TutorialBattleController::TutorialBattleController()
: enabled_(false),
config_(),
firedEventIds_() {}
TutorialBattleController::TutorialBattleController(const TutorialBattleConfigProto& protoConfig)
: enabled_(protoConfig.enabled()),
config_(protoConfig),
firedEventIds_() {}
auto TutorialBattleController::CheckAndExecuteEvents(
const GameStateW& state,
const SettingsGetter& settings,
const std::shared_ptr<RandomGenerator>& randomGenerator) -> TutorialEventResults {
if (!enabled_) { return {}; }
TutorialEventResults combined;
// Check events in config order
for (const auto& event : config_.events()) {
// Skip already-fired events
if (firedEventIds_.contains(event.event_id())) { continue; }
// Check if trigger condition is met
if (EvaluateTrigger(event.trigger(), state)) {
// Mark as fired before executing (to prevent re-triggering)
firedEventIds_.insert(event.event_id());
// Execute the action
auto eventResults = ExecuteAction(event.action(), state, settings, randomGenerator);
combined.actionResults.insert(
combined.actionResults.end(),
eventResults.actionResults.begin(),
eventResults.actionResults.end());
// Only one reinforcement placement can be pending at a time
if (eventResults.reinforcementPlacement.has_value()) {
combined.reinforcementPlacement = eventResults.reinforcementPlacement;
}
}
}
return combined;
}
auto TutorialBattleController::EvaluateTrigger(
const TutorialTriggerProto& trigger,
const GameStateW& state) const -> bool {
switch (trigger.trigger_type_case()) {
case TutorialTriggerProto::kAfterRound: {
const int currentRound = state->current_round();
return currentRound >= trigger.after_round().round_number();
}
case TutorialTriggerProto::kUnitsLost: {
const auto& unitsLost = trigger.units_lost();
const int lostCount = CountLostUnits(state, unitsLost.player_id());
return lostCount >= unitsLost.threshold();
}
case TutorialTriggerProto::kDamageTaken: {
const auto& damageTaken = trigger.damage_taken();
const int totalDamage = GetTotalDamageTaken(state, damageTaken.player_id());
return totalDamage >= damageTaken.threshold();
}
case TutorialTriggerProto::kUnitKilled: {
return IsUnitKilled(state, trigger.unit_killed());
}
case TutorialTriggerProto::TRIGGER_TYPE_NOT_SET:
default: return false;
}
}
auto TutorialBattleController::ExecuteAction(
const TutorialActionProto& action,
const GameStateW& state,
[[maybe_unused]] const SettingsGetter& settings,
[[maybe_unused]] const std::shared_ptr<RandomGenerator>& randomGenerator) const
-> TutorialEventResults {
switch (action.action_type_case()) {
case TutorialActionProto::kFlee:
return {.actionResults = ExecuteFleeAction(action.flee(), state),
.reinforcementPlacement = std::nullopt};
case TutorialActionProto::kReinforcements:
return ExecuteReinforcementsAction(action.reinforcements(), state);
case TutorialActionProto::ACTION_TYPE_NOT_SET:
default: return {};
}
}
auto TutorialBattleController::CountLostUnits(const GameStateW& state, const PlayerId playerId)
const -> int {
int count = 0;
for (const auto* unit : *state->units()) {
if (unit->player_id() != playerId) { continue; }
// Count units that are no longer in battle (destroyed, captured, fled, retreated)
const auto status = unit->status();
if (status != UnitStatusFB::UnitStatus_NORMAL_UNIT &&
status != UnitStatusFB::UnitStatus_RESERVE_UNIT &&
status != UnitStatusFB::UnitStatus_NEVER_ENTERED_UNIT &&
status != UnitStatusFB::UnitStatus_RESERVED_SLOT &&
status != UnitStatusFB::UnitStatus_PENDING_REINFORCEMENT) {
count++;
}
}
return count;
}
auto TutorialBattleController::GetTotalDamageTaken(const GameStateW& state, const PlayerId playerId)
const -> int {
int totalDamage = 0;
for (const auto* unit : *state->units()) {
if (unit->player_id() != playerId) { continue; }
// Calculate damage as difference between initial and current size
// Initial sizes are stored in config since flatbuffers don't track starting sizes
int initialSize = 0;
for (const auto& initial : config_.initial_sizes()) {
if (initial.unit_id() == unit->unit_id()) {
initialSize = initial.size();
break;
}
}
if (initialSize == 0) { continue; }
const int currentSize = unit->battalion().size();
if (initialSize > currentSize) { totalDamage += (initialSize - currentSize); }
}
return totalDamage;
}
auto TutorialBattleController::IsUnitKilled(
const GameStateW& state,
const UnitKilledTriggerProto& trigger) const -> bool {
for (const auto* unit : *state->units()) {
// Check if this unit matches the identifier
bool matches = false;
switch (trigger.unit_identifier_case()) {
case UnitKilledTriggerProto::kUnitId:
matches = (unit->unit_id() == trigger.unit_id());
break;
case UnitKilledTriggerProto::kEagleHeroId:
if (unit->has_attached_hero()) {
matches = (unit->attached_hero().eagle_hero_id() == trigger.eagle_hero_id());
}
break;
case UnitKilledTriggerProto::UNIT_IDENTIFIER_NOT_SET:
default: continue;
}
if (!matches) { continue; }
// Check if the unit is "killed" (destroyed/captured status)
const auto status = unit->status();
if (status == UnitStatusFB::UnitStatus_DESTROYED_SUMMONED_UNIT ||
status == UnitStatusFB::UnitStatus_CAPTURED_UNIT) {
return true;
}
}
return false;
}
auto TutorialBattleController::ExecuteFleeAction(
const FleeActionProto& fleeAction,
const GameStateW& state) const -> std::vector<ActionResult> {
std::vector<ActionResult> results;
const PlayerId fleeingPlayerId = fleeAction.player_id();
// First, create a "tutorial enemy fled" notification
ActionResult fleeNotification{};
fleeNotification.set_type(ActionType::TUTORIAL_ENEMY_FLED);
results.push_back(fleeNotification);
// Determine which units should flee
std::set<int32_t> specificUnitIds(fleeAction.unit_ids().begin(), fleeAction.unit_ids().end());
std::set<int32_t> specificHeroIds(
fleeAction.eagle_hero_ids().begin(),
fleeAction.eagle_hero_ids().end());
const bool fleeAllUnits = specificUnitIds.empty() && specificHeroIds.empty();
// Find all matching units that are still active and make them flee
for (const auto* unit : *state->units()) {
if (unit->player_id() != fleeingPlayerId) { continue; }
if (unit->status() != UnitStatusFB::UnitStatus_NORMAL_UNIT) { continue; }
// Check if this unit should flee
if (!fleeAllUnits) {
const bool matchesUnitId = specificUnitIds.contains(unit->unit_id());
const bool matchesHeroId =
unit->has_attached_hero() &&
specificHeroIds.contains(unit->attached_hero().eagle_hero_id());
if (!matchesUnitId && !matchesHeroId) { continue; }
}
// Create flee result for this unit (100% success - guaranteed flee)
ActionResult fleeResult{};
fleeResult.set_type(ActionType::FLED);
fleeResult.mutable_player()->set_value(fleeingPlayerId);
fleeResult.mutable_actor()->set_value(unit->unit_id());
auto* resolvedUnit = fleeResult.add_resolved_units();
AddUnitToResolved(*resolvedUnit, *unit);
resolvedUnit->set_status(net::eagle0::shardok::storage::ResolvedUnit_UnitStatus_FLED_UNIT);
results.push_back(fleeResult);
}
return results;
}
auto TutorialBattleController::ExecuteReinforcementsAction(
const ReinforcementsActionProto& reinforcementsAction,
const GameStateW& state) const -> TutorialEventResults {
TutorialEventResults eventResults;
if (reinforcementsAction.units().empty()) { return eventResults; }
const PlayerId playerId = reinforcementsAction.player_id();
// Look up starting positions from the hex map's attacker starting positions
using Coords = net::eagle0::shardok::storage::fb::Coords;
std::vector<Coords> availablePositions;
const int startingPosGroupIndex = reinforcementsAction.attacker_starting_position_index();
const auto* attackerPositions = state->hex_map()->attacker_starting_positions();
if (startingPosGroupIndex >= 0 &&
startingPosGroupIndex < static_cast<int>(attackerPositions->size())) {
for (const auto* pos : *attackerPositions->Get(startingPosGroupIndex)->positions()) {
if (!state.GetOccupant(*pos)) { availablePositions.push_back(*pos); }
}
}
// Place PENDING_REINFORCEMENT units directly at available positions as NORMAL_UNIT
ActionResult reinforcementsResult{};
reinforcementsResult.set_type(ActionType::TUTORIAL_REINFORCEMENTS_ARRIVED);
reinforcementsResult.mutable_player()->set_value(playerId);
size_t posIndex = 0;
for (const auto* unit : *state->units()) {
if (unit->status() != UnitStatusFB::UnitStatus_PENDING_REINFORCEMENT) { continue; }
if (posIndex >= availablePositions.size()) { break; }
net::eagle0::shardok::storage::fb::Unit changedUnit = *unit;
changedUnit.mutate_status(UnitStatusFB::UnitStatus_NORMAL_UNIT);
changedUnit.mutable_location() = availablePositions[posIndex++];
AddChangedUnit(reinforcementsResult, changedUnit);
}
eventResults.actionResults.push_back(reinforcementsResult);
return eventResults;
}
} // namespace shardok
@@ -1,116 +0,0 @@
//
// TutorialBattleController.hpp
// eagle0
//
// Created for tutorial battle system.
// Copyright 2025 none. All rights reserved.
//
#ifndef TutorialBattleController_hpp
#define TutorialBattleController_hpp
#include <optional>
#include <set>
#include <string>
#include <vector>
#include "src/main/cpp/net/eagle0/common/RandomGenerator.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/GameStateW.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/map/Coordinates.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/protobuf/net/eagle0/common/tutorial_battle_config.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/storage/action_result.pb.h"
namespace shardok {
using ActionResult = net::eagle0::shardok::storage::ActionResult;
using TutorialBattleConfigProto = net::eagle0::common::TutorialBattleConfig;
using TutorialEventProto = net::eagle0::common::TutorialEvent;
using TutorialTriggerProto = net::eagle0::common::TutorialTrigger;
using TutorialActionProto = net::eagle0::common::TutorialAction;
/// Info about a pending reinforcement placement phase.
struct ReinforcementPlacementInfo {
PlayerId playerId;
std::vector<Coords> startingPositions;
};
/// Results from tutorial event processing, including action results and any pending
/// reinforcement placement.
struct TutorialEventResults {
std::vector<ActionResult> actionResults;
std::optional<ReinforcementPlacementInfo> reinforcementPlacement;
};
/// Controller for scripted tutorial battles with event-driven triggers and actions.
/// Events are checked at the end of each round and fire in config order.
/// Each event fires at most once (tracked by event_id).
class TutorialBattleController {
public:
/// Creates a disabled controller.
TutorialBattleController();
/// Creates a controller from proto configuration.
explicit TutorialBattleController(const TutorialBattleConfigProto& protoConfig);
/// Returns true if the controller is enabled for this battle.
[[nodiscard]] auto IsEnabled() const -> bool { return enabled_; }
/// Check all events at end of round, return actions for triggered events.
/// Events fire in config order - first triggered event executes, then next.
/// Each event fires at most once.
[[nodiscard]] auto CheckAndExecuteEvents(
const GameStateW& state,
const SettingsGetter& settings,
const std::shared_ptr<RandomGenerator>& randomGenerator) -> TutorialEventResults;
/// Gets the set of fired event IDs (for testing/debugging).
[[nodiscard]] auto GetFiredEventIds() const -> const std::set<std::string>& {
return firedEventIds_;
}
private:
bool enabled_ = false;
TutorialBattleConfigProto config_;
std::set<std::string> firedEventIds_; // Track which events have fired
/// Evaluates whether a trigger condition is met.
[[nodiscard]] auto EvaluateTrigger(const TutorialTriggerProto& trigger, const GameStateW& state)
const -> bool;
/// Executes an action and returns the resulting action results.
[[nodiscard]] auto ExecuteAction(
const TutorialActionProto& action,
const GameStateW& state,
const SettingsGetter& settings,
const std::shared_ptr<RandomGenerator>& randomGenerator) const -> TutorialEventResults;
// Helper methods for trigger evaluation
/// Counts units lost (destroyed, captured, fled, retreated) for a player.
[[nodiscard]] auto CountLostUnits(const GameStateW& state, PlayerId playerId) const -> int;
/// Gets total damage taken (cumulative size reduction) for a player.
[[nodiscard]] auto GetTotalDamageTaken(const GameStateW& state, PlayerId playerId) const -> int;
/// Checks if a specific unit has been killed.
[[nodiscard]] auto IsUnitKilled(
const GameStateW& state,
const net::eagle0::common::UnitKilledTrigger& trigger) const -> bool;
// Helper methods for action execution
/// Executes a flee action for specified units.
[[nodiscard]] auto ExecuteFleeAction(
const net::eagle0::common::FleeAction& fleeAction,
const GameStateW& state) const -> std::vector<ActionResult>;
/// Executes a reinforcements action, placing units directly at available starting positions.
[[nodiscard]] auto ExecuteReinforcementsAction(
const net::eagle0::common::ReinforcementsAction& reinforcementsAction,
const GameStateW& state) const -> TutorialEventResults;
};
} // namespace shardok
#endif /* TutorialBattleController_hpp */
@@ -331,8 +331,7 @@ auto PlacedUnitsForPlayer(const Units *units, const PlayerId pid)
std::map<UnitId, const Unit *> placedUnits{};
for (const Unit *unit : *units) {
if (unit->player_id() == pid &&
unit->status() != net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT &&
unit->status() != net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT)
unit->status() != net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT)
placedUnits[unit->unit_id()] = unit;
}
@@ -73,9 +73,6 @@ void DumpGameStateToFile(const GameStateW& gameState, const std::string& gameId)
case net::eagle0::shardok::storage::fb::UnitStatus_RETREATED_UNIT:
out << "RETREATED_UNIT\n";
break;
case net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT:
out << "PENDING_REINFORCEMENT\n";
break;
default: out << "OTHER (" << (int)unit->status() << ")\n";
}
out << " Remaining Action Points: " << (int)unit->remaining_action_points() << "\n";
@@ -49,8 +49,7 @@ auto GameStateFilteredForPlayer(
case net::eagle0::shardok::storage::fb::UnitStatus_FLED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RETREATED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_OUTLAWED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT:
case net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT: break;
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT: break;
case net::eagle0::shardok::storage::fb::UnitStatus_UNKNOWN_UNIT:
throw ShardokInternalErrorException("Unknown unit status");
@@ -101,7 +100,6 @@ auto GameStateFilteredForPlayer(
case net::eagle0::shardok::storage::fb::UnitStatus_RETREATED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_OUTLAWED_UNIT:
case net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT:
case net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT:
case net::eagle0::shardok::storage::fb::UnitStatus_UNKNOWN_UNIT: break;
}
}
@@ -12,7 +12,6 @@ cc_library(
deps = [
":server_configuration",
"//src/main/cpp/net/eagle0/common:tsv_parser",
"//src/main/cpp/net/eagle0/common:unit_conversions",
"//src/main/cpp/net/eagle0/shardok/ai:shardok_ai_client",
"//src/main/cpp/net/eagle0/shardok/controller",
"//src/main/cpp/net/eagle0/shardok/library:engine",
@@ -20,7 +19,6 @@ cc_library(
"//src/main/cpp/net/eagle0/shardok/library/settings_loader",
"//src/main/cpp/net/eagle0/shardok/util:battalion_type_registrar",
"//src/main/cpp/net/eagle0/shardok/util:map_loader",
"//src/main/protobuf/net/eagle0/common:tutorial_battle_config_cc_proto",
"//src/main/protobuf/net/eagle0/common:victory_condition_cc_proto",
],
)
@@ -193,7 +193,6 @@ void EagleInterfaceImpl::StartGame(const NewGameRequest &request) {
mapName,
customMapBytes,
month,
request.tutorial_battle_config(),
request.SerializeAsString());
}
@@ -222,14 +221,20 @@ auto EagleInterfaceImpl::PostCommand(
request->game_setup_info().known_result_count(),
response);
} catch (ShardokClientErrorException &e) {
std::cerr << "Bad request from client: " << e.what() << std::endl;
return Status(StatusCode::INVALID_ARGUMENT, e.what());
std::cerr << "Bad request from client!" << std::endl;
std::cerr << "Exception: " << e.what();
throw e;
} catch (ShardokInternalErrorException &e) {
std::cerr << "Internal error: " << e.what() << std::endl;
return Status(StatusCode::INTERNAL, e.what());
std::cerr << "ShardokInternalErrorException thrown!" << std::endl;
std::cerr << "Exception: " << e.what();
throw e;
} catch (std::exception &e) {
std::cerr << "Unknown exception: " << e.what() << std::endl;
return Status(StatusCode::INTERNAL, e.what());
std::cerr << "Unknown exception in client->PostCommand" << std::endl;
std::cerr << "Exception: " << e.what();
throw e;
}
return Status::OK;
@@ -268,14 +273,20 @@ auto EagleInterfaceImpl::PostPlacementCommands(
request->game_setup_info().known_result_count(),
response);
} catch (ShardokClientErrorException &e) {
std::cerr << "Bad request from client: " << e.what() << std::endl;
return Status(StatusCode::INVALID_ARGUMENT, e.what());
std::cerr << "Bad request from client!" << std::endl;
std::cerr << "Exception: " << e.what();
throw e;
} catch (ShardokInternalErrorException &e) {
std::cerr << "Internal error: " << e.what() << std::endl;
return Status(StatusCode::INTERNAL, e.what());
std::cerr << "ShardokInternalErrorException thrown!" << std::endl;
std::cerr << "Exception: " << e.what();
throw e;
} catch (std::exception &e) {
std::cerr << "Unknown exception: " << e.what() << std::endl;
return Status(StatusCode::INTERNAL, e.what());
std::cerr << "Unknown exception in client->PostCommand" << std::endl;
std::cerr << "Exception: " << e.what();
throw e;
}
return Status::OK;
@@ -13,7 +13,6 @@
#include <utility>
#include "ServerConfiguration.hpp"
#include "src/main/cpp/net/eagle0/common/UnitConversions.hpp"
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
#include "src/main/cpp/net/eagle0/shardok/controller/ShardokGameController.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings_loader/SettingsLoader.hpp"
@@ -31,7 +30,6 @@ auto SetUpController(
int month,
const vector<Unit> &units,
const vector<PlayerInfoProto> &playerSetupInfos,
const TutorialBattleConfigProto &tutorialConfig,
const string &serializedRequest) -> shared_ptr<ShardokGameController>;
ShardokGamesManager::ShardokGamesManager(const std::vector<std::string> &extraSettings) {
@@ -79,7 +77,6 @@ auto ShardokGamesManager::UnlockedCreateSpecifiedGame(
const string &mapName,
const string &customMapBytes,
const int month,
const TutorialBattleConfigProto &tutorialConfig,
const string &serializedRequest) -> GameId {
// Check for existing game with this ID and early-exit
std::shared_lock<std::shared_mutex> lk(runningControllersLock);
@@ -114,7 +111,6 @@ auto ShardokGamesManager::UnlockedCreateSpecifiedGame(
month,
units,
playerInfos,
tutorialConfig,
serializedRequest);
return LockedCreateGame(controller, playerSetupInfos, serializedRequest);
@@ -141,7 +137,6 @@ auto SetUpController(
const int month,
const vector<Unit> &units,
const vector<PlayerInfoProto> &playerInfos,
const TutorialBattleConfigProto &tutorialConfig,
const string &serializedRequest) -> shared_ptr<ShardokGameController> {
auto unplacedUnits = std::vector<Unit>();
@@ -149,55 +144,9 @@ auto SetUpController(
for (auto up : units) {
up.mutate_unit_id(nextUnitId++);
up.mutate_status(net::eagle0::shardok::storage::fb::UnitStatus_RESERVE_UNIT);
// Apply initial visibility settings from tutorial config
if (tutorialConfig.enabled() && up.has_attached_hero()) {
const int heroId = up.attached_hero().eagle_hero_id();
for (const auto &visibility : tutorialConfig.initial_visibilities()) {
if (visibility.eagle_hero_id() == heroId) {
for (const int playerId : visibility.visible_to_player_ids()) {
if (playerId >= 0 && playerId < 10) {
up.mutable_opponent_knowledge()->Mutate(
static_cast<flatbuffers::uoffset_t>(playerId),
100);
}
}
break;
}
}
}
unplacedUnits.push_back(up);
}
// Add reinforcement units from tutorial config as PENDING_REINFORCEMENT.
// These units exist in the game state from the start but are invisible and inactive
// until the TutorialBattleController activates them at the triggered round.
if (tutorialConfig.enabled()) {
vector<PlayerId> allPlayerIds;
for (size_t i = 0; i < playerInfos.size(); i++) {
allPlayerIds.push_back(static_cast<PlayerId>(i));
}
for (const auto &event : tutorialConfig.events()) {
if (!event.has_action() ||
event.action().action_type_case() !=
net::eagle0::common::TutorialAction::kReinforcements) {
continue;
}
const auto &reinforcements = event.action().reinforcements();
const PlayerId playerId = reinforcements.player_id();
for (const auto &commonUnit : reinforcements.units()) {
Unit unit = ConvertUnit(commonUnit, playerId, allPlayerIds);
unit.mutate_unit_id(nextUnitId++);
unit.mutate_status(
net::eagle0::shardok::storage::fb::UnitStatus_PENDING_REINFORCEMENT);
unplacedUnits.push_back(unit);
}
}
}
unique_ptr<ShardokEngine> engine = std::make_unique<ShardokEngine>(
gameSettings,
std::move(unplacedUnits),
@@ -206,8 +155,6 @@ auto SetUpController(
gameId,
playerInfos);
engine->SetTutorialBattleConfig(tutorialConfig);
return std::make_shared<ShardokGameController>(std::move(engine), mapName, serializedRequest);
}
@@ -17,7 +17,6 @@
#include "src/main/cpp/net/eagle0/shardok/ai/ShardokAIClient.hpp"
#include "src/main/cpp/net/eagle0/shardok/library/settings/GameSettings.hpp"
#include "src/main/protobuf/net/eagle0/common/tutorial_battle_config.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/common/game_status.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/common/player_info.pb.h"
#include "src/main/protobuf/net/eagle0/shardok/common/victory_condition.pb.h"
@@ -26,7 +25,6 @@ class GameStatePersister;
using net::eagle0::shardok::common::GameStatus;
using PlayerInfoProto = net::eagle0::shardok::common::PlayerInfo;
using TutorialBattleConfigProto = net::eagle0::common::TutorialBattleConfig;
using std::string;
using std::vector;
using VictoryConditionProto = net::eagle0::shardok::common::VictoryCondition;
@@ -103,7 +101,6 @@ public:
const string &mapName,
const string &customMapBytes,
int month,
const TutorialBattleConfigProto &tutorialConfig,
const string &serializedRequest) -> GameId;
auto UnlockedCreateFromGameStateBytes(
@@ -29,7 +29,6 @@ ExportedObj/
# Unity3D generated meta files
*.pidb.meta
*.pdb.meta
Assets/GeneratedProtos.meta
# Unity3D Generated File On Crash Reports
sysinfo.txt
File diff suppressed because it is too large Load Diff
@@ -15,7 +15,7 @@ MonoBehaviour:
m_DefaultGroup: 4458439bbfafa478b905417eff3c5741
m_currentHash:
serializedVersion: 2
Hash: 0e8900f37a8b7b1c88049de57e371339
Hash: 00000000000000000000000000000000
m_OptimizeCatalogSize: 0
m_BuildRemoteCatalog: 1
m_CatalogRequestsTimeout: 0
@@ -272,8 +272,9 @@ namespace Auth {
/// <summary>
/// Start OAuth login with specified provider.
/// In editor: Opens browser and polls for completion.
/// In standalone: Opens browser, quits app, deep link relaunches with token.
/// Opens system browser for user consent, then quits the app.
/// The browser's "Return to Eagle" deep link will relaunch the app
/// with the auth session, which HandleSessionTransferAsync will process.
/// </summary>
public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
EnsureConfigured();
@@ -287,13 +288,6 @@ namespace Auth {
Debug.Log($"[OAuthManager] Opening browser for OAuth: {authUrl}");
Application.OpenURL(authUrl);
#if UNITY_EDITOR
// In editor: poll for OAuth completion since deep links don't work
Debug.Log("[OAuthManager] Editor mode - polling for OAuth completion");
var response = await _authClient.PollForOAuthCompletionAsync(state);
HandleOAuthResponse(response);
return response;
#else
// Quit the app - the browser's "Return to Eagle" deep link will
// relaunch a fresh instance with the auth session code.
// This avoids the dual-instance problem where both the original
@@ -305,7 +299,6 @@ namespace Auth {
// to satisfy the compiler. The new instance will handle auth
// via OnDeepLinkActivated -> HandleSessionTransferAsync.
return null;
#endif
} catch (Exception ex) {
Debug.LogWarning($"[OAuthManager] Login failed: {ex.Message}");
OnLoginFailed?.Invoke(ex.Message);
@@ -313,28 +306,6 @@ namespace Auth {
}
}
/// <summary>
/// Handle OAuth response from polling (editor mode).
/// </summary>
private void HandleOAuthResponse(CheckOAuthStatusResponse response) {
var providerName = response.User.Provider.ToString().ToLowerInvariant();
TokenStorage.StoreTokens(
response.AccessToken,
response.RefreshToken,
response.ExpiresAt,
response.User.UserId,
response.User.DisplayName,
providerName);
if (response.IsNewUser || string.IsNullOrEmpty(response.User.DisplayName)) {
Debug.Log("[OAuthManager] New user needs to set display name");
OnNewUserNeedsDisplayName?.Invoke(true);
} else {
Debug.Log($"[OAuthManager] OAuth successful for {response.User.DisplayName}");
OnLoginSuccess?.Invoke(response.User);
}
}
/// <summary>
/// Set display name for new user after OAuth.
/// </summary>
@@ -108,8 +108,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public TextMeshProUGUI connectionStatusText;
[Header("Connection Panel Environment")]
[Tooltip("Connect to QA button in connection panel (editor-only)")]
public Button connectToQAButton;
[Tooltip("Environment dropdown in connection panel (fallback if lobby unreachable)")]
public TMP_Dropdown connectionEnvironmentDropdown;
public GameObject connectionPanel;
public GameObject connectionBackgroundLayer;
@@ -121,7 +121,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public Button logoutButton;
public Button customBattleButton;
public Button cancelCustomBattleButton;
public Button lobbyConnectToQAButton;
public TMP_Dropdown lobbyEnvironmentDropdown;
public TextMeshProUGUI lobbyUserText;
public TextMeshProUGUI lobbyStatusText;
@@ -131,8 +131,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public GameObject availableGamesListArea;
public GameObject availableGamesListItemPrefab;
public GameObject waitingGamesListItemPrefab;
public CreateGameItem createGameItem;
public Button tutorialButton;
public GameObject createGameListItemPrefab;
public DropGameConfirmationPanel dropGameConfirmationPanel;
@@ -143,6 +142,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
List<GameInfo> fetchedRunningGames;
List<AvailableLeader> fetchedNewGameLeaders;
int fetchedMaxPlayerCount;
private EagleConnection eagleConnection;
private PersistentClientConnection _persistentClientConnection;
private HttpClient _httpClient;
@@ -150,7 +150,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
private CancellationTokenSource _cancellationTokenSource;
private readonly Object pendingReplyLock = new Object();
private int _connectedEnvironmentIndex = -1; // -1 means not connected
private bool _isLocalQAConnection = false;
private List<StoredAccountButton> _storedAccountButtons = new();
private bool _exitedFullscreenForNewUser = false; // Track if we exited fullscreen for login
private FullScreenMode _previousFullScreenMode; // Store mode to restore after login
@@ -169,8 +168,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
/// Returns the connected environment name ("prod." or "qa.") or null if not connected.
/// </summary>
public string ConnectedEnvironmentName =>
_isLocalQAConnection ? "QA (local)"
: _connectedEnvironmentIndex >= 0 &&
_connectedEnvironmentIndex >= 0 &&
_connectedEnvironmentIndex < EnvironmentDisplayNames.Count
? EnvironmentDisplayNames[_connectedEnvironmentIndex]
: null;
@@ -220,8 +218,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Initialize OAuth UI
SetupOAuthUI();
// Initialize connection panel QA button (editor-only)
SetupConnectToQAButton();
// Initialize connection panel environment dropdown
SetupConnectionEnvironmentDropdown();
// Initialize Lobby UI (logout button, etc.)
SetupLobbyUI();
@@ -237,17 +235,27 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
TryRestoreSession();
}
private void SetupConnectToQAButton() {
if (connectToQAButton == null) return;
private void SetupConnectionEnvironmentDropdown() {
if (connectionEnvironmentDropdown == null) return;
// Only show QA button in Unity Editor; always use prod in builds
// Only show environment dropdown in Unity Editor; always use prod in builds
if (!Application.isEditor) {
connectToQAButton.gameObject.SetActive(false);
connectionEnvironmentDropdown.gameObject.SetActive(false);
PlayerPrefs.SetInt(EnvironmentKey, 0); // Force prod
return;
}
connectToQAButton.onClick.AddListener(OnConnectToQAClicked);
connectionEnvironmentDropdown.ClearOptions();
connectionEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
connectionEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
connectionEnvironmentDropdown.onValueChanged.AddListener(OnConnectionEnvironmentChanged);
}
private void OnConnectionEnvironmentChanged(int newEnvironmentIndex) {
// Just save the preference - it will be used on next connection attempt
PlayerPrefs.SetInt(EnvironmentKey, newEnvironmentIndex);
Debug.Log(
$"[ConnectionHandler] Environment changed to {EnvironmentDisplayNames[newEnvironmentIndex]}");
}
private void SetupOAuthUI() {
@@ -287,6 +295,15 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Show OAuth panel
if (oauthPanel != null) oauthPanel.SetActive(true);
// Sync connection environment dropdown with saved preference
if (connectionEnvironmentDropdown != null) {
connectionEnvironmentDropdown.onValueChanged.RemoveListener(
OnConnectionEnvironmentChanged);
connectionEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
connectionEnvironmentDropdown.onValueChanged.AddListener(
OnConnectionEnvironmentChanged);
}
// Refresh stored account buttons
RefreshStoredAccountButtons();
@@ -394,52 +411,42 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
private void SetupLobbyUI() {
customBattleButton.onClick.AddListener(CustomBattle);
cancelCustomBattleButton.onClick.AddListener(CancelCustomBattle);
tutorialButton.gameObject.SetActive(
TokenStorage.DisplayName == "nolen" || TokenStorage.DisplayName == "tarn");
tutorialButton.onClick.AddListener(CreateTutorialGame);
if (customBattleButton != null) { customBattleButton.onClick.AddListener(CustomBattle); }
if (cancelCustomBattleButton != null) {
cancelCustomBattleButton.onClick.AddListener(CancelCustomBattle);
}
// Set up QA button (only in Unity Editor)
if (lobbyConnectToQAButton != null) {
// Set up environment dropdown (only in Unity Editor)
if (lobbyEnvironmentDropdown != null) {
if (!Application.isEditor) {
lobbyConnectToQAButton.gameObject.SetActive(false);
lobbyEnvironmentDropdown.gameObject.SetActive(false);
} else {
lobbyConnectToQAButton.onClick.AddListener(OnLobbyConnectToQAClicked);
lobbyEnvironmentDropdown.ClearOptions();
lobbyEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
lobbyEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
}
}
}
private void UpdateLobbyStatusDisplays() {
lobbyUserText.text = _isLocalQAConnection ? "editor" : TokenStorage.DisplayName ?? "";
}
private void OnConnectToQAClicked() {
Debug.Log("[ConnectionHandler] Connect to QA clicked");
_isLocalQAConnection = true;
SetLobbyActive(true);
connectionCanvas.enabled = true;
_createLocalQAConnection();
if (_persistentClientConnection == null) {
Debug.LogError("[ConnectionHandler] QA connection creation failed");
return;
// Update environment dropdown selection
if (lobbyEnvironmentDropdown != null && _connectedEnvironmentIndex >= 0) {
// Temporarily remove listener to avoid triggering reconnect
lobbyEnvironmentDropdown.onValueChanged.RemoveListener(OnLobbyEnvironmentChanged);
lobbyEnvironmentDropdown.value = _connectedEnvironmentIndex;
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
}
try {
RequestMaps();
StartListeningForLobbyUpdates();
} catch (Exception e) {
MainQueue.Q.Enqueue(() => { errorHandler.Add(e); });
}
// Show current user from OAuth
if (lobbyUserText != null) { lobbyUserText.text = TokenStorage.DisplayName ?? ""; }
}
private void OnLobbyConnectToQAClicked() {
if (_isLocalQAConnection) return;
private void OnLobbyEnvironmentChanged(int newEnvironmentIndex) {
if (newEnvironmentIndex == _connectedEnvironmentIndex) return;
Debug.Log("[ConnectionHandler] Lobby: switching to QA (local)");
Debug.Log(
$"[ConnectionHandler] Switching environment from {ConnectedEnvironmentName} to {EnvironmentDisplayNames[newEnvironmentIndex]}");
// Disconnect from current environment
_persistentClientConnection?.Dispose();
@@ -450,54 +457,23 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
eagleConnection = null;
_httpClient = null;
_isLocalQAConnection = true;
// Update environment index and reconnect
_connectedEnvironmentIndex = newEnvironmentIndex;
PlayerPrefs.SetInt(EnvironmentKey, newEnvironmentIndex);
// Clear current game lists while reconnecting
foreach (Transform row in runningGamesListArea.transform) { Destroy(row.gameObject); }
foreach (Transform row in availableGamesListArea.transform) { Destroy(row.gameObject); }
if (lobbyStatusText != null) { lobbyStatusText.text = "Connecting to QA..."; }
// Show loading indicator
if (lobbyStatusText != null) { lobbyStatusText.text = "Switching environment..."; }
_createLocalQAConnection();
// Reconnect to new environment
_createConnection();
RequestMaps();
StartListeningForLobbyUpdates();
}
private void _createLocalQAConnection() {
// Dispose existing connections before creating new ones
_httpClient?.Dispose();
_persistentClientConnection?.Dispose();
eagleConnection?.Dispose();
_cancellationTokenSource?.Dispose();
_cancellationTokenSource = new CancellationTokenSource();
eagleConnection = EagleConnection.CreateInsecure("http://localhost:40032");
// Plain HttpClient with no auth for local QA — ResourceFetcher still uses prod assets
_httpClient = new HttpClient();
_persistentClientConnection = new PersistentClientConnection(
eagleConnection.EagleGrpcClient,
eagleConnection.credentials,
EagleConnection.EagleCancellationToken);
_persistentClientConnection.OnChannelDead = () => {
MainQueue.Q.Enqueue(() => {
Debug.Log("[ConnectionHandler] QA channel dead, recreating connection");
_createLocalQAConnection();
});
};
_persistentClientConnection.OnCommandError = (errorMessage) => {
MainQueue.Q.Enqueue(() => {
Debug.LogError($"[ConnectionHandler] Command rejected by server: {errorMessage}");
});
};
_persistentClientConnection.Connect();
PersistentClientConnection.Current = _persistentClientConnection;
errorHandler.PersistentClientConnection = _persistentClientConnection;
}
public async void OnLogoutClicked() {
Debug.Log("[ConnectionHandler] Logout button clicked");
@@ -515,7 +491,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
_httpClient = null;
_cancellationTokenSource = null;
_connectedEnvironmentIndex = -1;
_isLocalQAConnection = false;
// Return to connection screen
gameSelectionPanel.SetActive(false);
@@ -694,7 +669,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
eagleConnection = null;
_connectedEnvironmentIndex = -1;
_isLocalQAConnection = false;
}
public void OnResolutionChanged(int dropdownIndex) {
@@ -713,14 +687,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
fetchedNewGameLeaders = new List<AvailableLeader>();
fetchedNewGameLeaders.AddRange(lobbyResponse.NewGameOptions.AvailableLeaders);
MainQueue.Q.Enqueue(() => {
// Skip lobby UI updates if we're already in an Eagle game.
// The lobby response may arrive after the game has started (e.g., when creating a new
// game), and the connection canvas is inactive, causing GetComponentInParent to fail.
// This check must be inside the queue callback since the state may change between
// when the response arrives and when this callback executes.
if (eagleCanvas.gameObject.activeSelf) return;
fetchedMaxPlayerCount = lobbyResponse.NewGameOptions.MaxSupportedPlayers;
MainQueue.Q.Enqueue(() => {
ResourceFetcher.headshotFetcher.Prefetch(
fetchedNewGameLeaders.Select(a => a.ImagePath));
@@ -733,13 +702,12 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Set up running games table
foreach (Transform row in runningGamesListArea.transform) { Destroy(row.gameObject); }
foreach (var runningGame in lobbyResponse.RunningGames
.OrderBy(g => g.CreatedTimestampMillis)
.ThenBy(g => g.GameId)) {
foreach (var runningGame in lobbyResponse.RunningGames) {
var listItem = Instantiate(
runningGamesListItemPrefab,
runningGamesListArea.transform,
false);
true);
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
var runningGameItem = listItem.GetComponent<RunningGameItem>();
runningGameItem.SetRunningGame(
runningGame.GameId,
@@ -756,7 +724,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
var listItem = Instantiate(
waitingGamesListItemPrefab,
availableGamesListArea.transform,
false);
true);
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
listItem.GetComponent<WaitingGameItem>().SetWaitingGame(
waitingGame.GameId,
string.Format(
@@ -766,14 +735,21 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
waitingGame.SelectedLeader);
}
createGameItem.SetCreationOptions(fetchedNewGameLeaders);
createGameItem.CreateCallback = this.CreateGame;
var createGameItem =
Instantiate(createGameListItemPrefab, availableGamesListArea.transform, true);
// var singlePlayerListItem = Instantiate(availableGamesListItemPrefab);
createGameItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
createGameItem.GetComponent<CreateGameItem>().SetCreationOptions(
fetchedNewGameLeaders,
fetchedMaxPlayerCount);
createGameItem.GetComponent<CreateGameItem>().CreateCallback = this.CreateGame;
foreach (var availableGame in lobbyResponse.AvailableGames) {
var listItem = Instantiate(
availableGamesListItemPrefab,
availableGamesListArea.transform,
false);
true);
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
var availableGameItem = listItem.GetComponent<AvailableGameItem>();
availableGameItem.SetAvailableNewGame(
availableGame.GameId,
@@ -830,7 +806,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
/// <summary>
/// Auto-create and launch a 7-faction game for first-time users.
/// Auto-create and launch a tutorial game for first-time users.
/// Tutorial mode: 3 factions (player, King, weak rival), player spawns at province 14 with
/// quest.
/// </summary>
private void AutoCreateFirstGame() {
if (fetchedNewGameLeaders == null || fetchedNewGameLeaders.Count == 0) {
@@ -842,9 +820,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
var randomIndex = new System.Random().Next(fetchedNewGameLeaders.Count);
var leaderTextId = fetchedNewGameLeaders[randomIndex].NameTextId;
// Create a 7-faction game (1 human + 6 AI) - same as lobby default
Debug.Log($"[ConnectionHandler] Auto-creating 7-faction game with leader {leaderTextId}");
CreateGame(leaderTextId, 1, 7);
// Create a tutorial game (1 human, 3 total factions - player, King, and weak rival)
Debug.Log($"[ConnectionHandler] Auto-creating tutorial game with leader {leaderTextId}");
CreateGame(leaderTextId, 1, 3, isTutorialMode: true);
}
private void StartListeningForLobbyUpdates() {
@@ -900,7 +878,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
};
_persistentClientConnection.Connect();
PersistentClientConnection.Current = _persistentClientConnection;
errorHandler.PersistentClientConnection = _persistentClientConnection;
ResourceFetcher.SetUpConnection(_httpClient);
}
@@ -971,23 +948,25 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
private void ConfirmDropGame(long gameId) { _internalDropGame(gameId); }
/// <summary>
/// 3-parameter overload for CreateCallback delegate compatibility.
/// </summary>
private void CreateGame(string leaderNameTextId, int humanPlayerCount, int totalPlayerCount) {
// Track multiplayer status for tutorial system
TutorialManager.IsMultiplayerGame = humanPlayerCount > 1;
TutorialManager.IsTutorialGame = false;
var game = _internalCreateGame(leaderNameTextId, humanPlayerCount, totalPlayerCount);
CreateGame(leaderNameTextId, humanPlayerCount, totalPlayerCount, isTutorialMode: false);
}
public void CreateTutorialGame() {
// Tutorial is always single-player
TutorialManager.IsMultiplayerGame = false;
TutorialManager.IsTutorialGame = true;
// Eagle will set up the hardcoded tutorial scenario when isTutorial is true
private void CreateGame(
string leaderNameTextId,
int humanPlayerCount,
int totalPlayerCount,
bool isTutorialMode) {
// Track multiplayer status for tutorial system
TutorialManager.IsMultiplayerGame = humanPlayerCount > 1;
var game = _internalCreateGame(
desiredLeaderTextId: "",
humanPlayerCount: 1,
totalPlayerCount: 2,
isTutorial: true);
leaderNameTextId,
humanPlayerCount,
totalPlayerCount,
isTutorialMode);
}
public void QuitButtonClicked() { Application.Quit(); }
@@ -1007,9 +986,14 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public void HandleCreateGameResponse(CreateGameResponse response) {
try {
switch (response.Result) {
case JoinGameResult.SuccessJoinGameResult: SelectEagleGame(response.GameId); break;
case JoinGameResult.SuccessJoinGameResult:
SelectEagleGame(response.GameId);
// Automatically start the game if it's one player?
break;
default: Debug.LogError($"Game creation failed: {response.Result}"); break;
default:
// FIXME: display something
break;
}
} catch (Exception e) { Console.WriteLine(e.ToString()); }
}
@@ -1025,7 +1009,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
string desiredLeaderTextId,
int humanPlayerCount,
int totalPlayerCount,
bool isTutorial = false) {
bool isTutorialMode = false) {
try {
return await _persistentClientConnection.SendUpdateStreamRequestAsync(
new UpdateStreamRequest {
@@ -1034,7 +1018,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
DesiredLeaderTextId = desiredLeaderTextId,
TotalPlayerCount = totalPlayerCount,
HumanPlayerCount = humanPlayerCount,
IsTutorial = isTutorial
IsTutorialMode = isTutorialMode
}
});
} catch (WebException e) {
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 5315bdd018d9e420dbbf55a0508a7f14
guid: 81b03eaddf4de4a4aa385617e4881511
PrefabImporter:
externalObjects: {}
userData:
@@ -11,10 +11,9 @@ using UnityEngine.UI;
using Random = System.Random;
public class CreateGameItem : MonoBehaviour {
private const int TotalPlayerCount = 7;
public HeroDropdownController heroDropdownController;
public TMP_Dropdown totalHumansDropdown;
public TMP_Dropdown totalPlayersDropdown;
public Button createButton;
public TextMeshProUGUI buttonText;
@@ -33,20 +32,30 @@ public class CreateGameItem : MonoBehaviour {
}
}
// Use this for initialization
void Start() {}
public void LeaderDropdownSelected(TMP_Dropdown dropdown) {}
public void TotalPlayersDropdownSelected(TMP_Dropdown dropdown) { SetHumanPlayersDropdown(); }
public void CreateClicked() {
CreateCallback(SelectedLeaderTextId, totalHumansDropdown.value + 1, TotalPlayerCount);
CreateCallback(
SelectedLeaderTextId,
totalHumansDropdown.value + 1,
totalPlayersDropdown.value + 1);
}
public void SetHumanPlayersDropdown() {
var currentValue = totalHumansDropdown.value;
totalHumansDropdown.ClearOptions();
totalHumansDropdown.AddOptions(Enumerable.Range(1, TotalPlayerCount)
.Select(i => i == 1 ? "1 player" : $"{i} players")
totalHumansDropdown.AddOptions(Enumerable.Range(1, totalPlayersDropdown.value + 1)
.Select(i => i.ToString())
.ToList());
totalHumansDropdown.value = Math.Min(TotalPlayerCount - 1, currentValue);
totalHumansDropdown.value = Math.Min(totalPlayersDropdown.value, currentValue);
}
public void SetCreationOptions(List<AvailableLeader> leaders) {
public void SetCreationOptions(List<AvailableLeader> leaders, int maxPlayerCount) {
this.availableLeaders = leaders;
buttonText.text = "CREATE";
@@ -68,10 +77,16 @@ public class CreateGameItem : MonoBehaviour {
heroDropdownController.PreResolvedNames =
leaders.Where(l => !string.IsNullOrEmpty(l.Name))
.ToDictionary(l => l.NameTextId, l => l.Name);
heroDropdownController.FallbackText = "Random Warlord";
heroDropdownController.FallbackText = "Random";
heroDropdownController.AvailableHeroes = heroOptions;
heroDropdownController.HeadshotImage.gameObject.SetActive(false);
totalPlayersDropdown.ClearOptions();
totalPlayersDropdown.AddOptions(
Enumerable.Range(1, maxPlayerCount).Select(i => i.ToString()).ToList());
// Default to 7 total players (index 6 = 7 players, so 1 human + 6 AI)
totalPlayersDropdown.value = Math.Min(6, maxPlayerCount - 1);
SetHumanPlayersDropdown();
// Default to 1 human (index 0)
totalHumansDropdown.value = 0;
@@ -631,99 +631,6 @@ MonoBehaviour:
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &5462510071559864326
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 7404235247014569559}
- component: {fileID: 7896497708396401296}
- component: {fileID: 6993077635068771622}
- component: {fileID: 1252979331277989500}
m_Layer: 5
m_Name: Profession Image
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &7404235247014569559
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5462510071559864326}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 909271087739711220}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &7896497708396401296
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5462510071559864326}
m_CullTransparentMesh: 1
--- !u!114 &6993077635068771622
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5462510071559864326}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 1344c3c82d62a2a41a3576d8abb8e3ea, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.RawImage
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_Texture: {fileID: 2800000, guid: d19d0fed4745dc14fa93e86993f9f2d6, type: 3}
m_UVRect:
serializedVersion: 2
x: 0
y: 0
width: 1
height: 1
--- !u!114 &1252979331277989500
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5462510071559864326}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.LayoutElement
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: 32
m_PreferredHeight: 32
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &5643565463360785033
GameObject:
m_ObjectHideFlags: 0
@@ -759,7 +666,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 2008335870574158510}
- {fileID: 7404235247014569559}
- {fileID: 6674238761151365000}
- {fileID: 5589171621630283209}
- {fileID: 1348356180262758599}
@@ -768,8 +674,8 @@ RectTransform:
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_AnchoredPosition: {x: 0, y: -600}
m_SizeDelta: {x: 0, y: -1080}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &1107237753715810996
CanvasRenderer:
@@ -827,7 +733,7 @@ MonoBehaviour:
m_Top: 0
m_Bottom: 0
m_ChildAlignment: 3
m_Spacing: 5
m_Spacing: 0
m_ChildForceExpandWidth: 0
m_ChildForceExpandHeight: 0
m_ChildControlWidth: 1
@@ -851,7 +757,7 @@ MonoBehaviour:
gameIdField: {fileID: 8058295588038032232}
leaderField: {fileID: 3311450659768657245}
lastPlayedField: {fileID: 5482253986454480250}
professionImage: {fileID: 6993077635068771622}
goButton: {fileID: 0}
dropButton: {fileID: 4081637582368108930}
--- !u!114 &2208043217657811503
MonoBehaviour:
@@ -10,7 +10,7 @@ public class RunningGameItem : MonoBehaviour {
public TextMeshProUGUI gameIdField;
public TextMeshProUGUI leaderField;
public TextMeshProUGUI lastPlayedField;
public RawImage professionImage;
public Button goButton;
public Button dropButton;
public Action<long> GoCallback;
@@ -43,11 +43,6 @@ public class RunningGameItem : MonoBehaviour {
leaderName,
DisplayNames.ProfessionNames[leader.Profession]);
// Set profession image
var textures = gameObject.GetComponentInParent<EagleCommonTextures>();
professionImage.texture = textures.Profession(leader.Profession);
professionImage.color = professionImage.texture != null ? Color.white : Color.clear;
// Display last played time in user's local timezone
if (lastPlayedField != null) {
if (lastPlayedTimestampMillis > 0) {
@@ -1,151 +0,0 @@
using UnityEngine;
namespace eagle {
/// <summary>
/// Creates a beasts/wildlife particle effect with circling birds (vultures/crows).
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class BeastsEffect : MonoBehaviour {
[Header("Bird Settings")]
public Texture2D birdTexture;
public Material birdMaterial; // Assign in prefab to ensure shader is included in build
public float birdCount = 5f;
public float birdLifetime = 8f;
public float birdSize = 12f;
public float circleRadius = 35f;
public float circleSpeed = 1f;
public Color birdColor = Color.white;
[Header("Flight Pattern")]
public float verticalOscillation = 10f;
public float radiusVariation = 10f;
private ParticleSystem _birdSystem;
private Material _materialInstance;
void Awake() { SetupParticleSystems(); }
private void SetupParticleSystems() {
var birdObj = new GameObject("BirdEffect");
birdObj.transform.SetParent(transform, false);
_birdSystem = birdObj.AddComponent<ParticleSystem>();
SetupBirdSystem();
}
private void SetupBirdSystem() {
var main = _birdSystem.main;
main.playOnAwake = true;
main.loop = true;
main.startLifetime = birdLifetime;
main.startSpeed = 0f;
main.startSize = birdSize;
main.startColor = birdColor;
main.startRotation3D = true;
main.simulationSpace = ParticleSystemSimulationSpace.Local;
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
main.maxParticles = 10;
var emission = _birdSystem.emission;
emission.enabled = true;
// Emit in bursts to maintain constant population
emission.rateOverTime = 0f;
emission.SetBursts(
new ParticleSystem.Burst[] { new ParticleSystem.Burst(0f, (short)birdCount) });
// Start on circle perimeter
var shape = _birdSystem.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Circle;
shape.radius = circleRadius;
shape.radiusThickness = 0f; // Only emit on edge
shape.rotation = new Vector3(-90f, 0f, 0f);
shape.arc = 360f;
shape.arcMode = ParticleSystemShapeMultiModeValue.Random;
// Orbital velocity for circling motion
var velocityOverLifetime = _birdSystem.velocityOverLifetime;
velocityOverLifetime.enabled = true;
velocityOverLifetime.space = ParticleSystemSimulationSpace.Local;
// All axes must use same curve mode (MinMaxCurve)
velocityOverLifetime.orbitalX = new ParticleSystem.MinMaxCurve(0f, 0f);
velocityOverLifetime.orbitalY =
new ParticleSystem.MinMaxCurve(circleSpeed, circleSpeed);
velocityOverLifetime.orbitalZ = new ParticleSystem.MinMaxCurve(0f, 0f);
velocityOverLifetime.radial = new ParticleSystem.MinMaxCurve(0f, 0f);
// Add vertical bobbing with noise
var noise = _birdSystem.noise;
noise.enabled = true;
noise.separateAxes = true;
noise.strengthX = radiusVariation;
noise.strengthY = verticalOscillation;
noise.strengthZ = 0f;
noise.frequency = 0.3f;
noise.scrollSpeed = 0.5f;
noise.damping = false;
noise.octaveCount = 1;
// Rotate to face direction of travel
var rotationOverLifetime = _birdSystem.rotationOverLifetime;
rotationOverLifetime.enabled = true;
rotationOverLifetime.separateAxes = true;
// Slight banking as they turn
rotationOverLifetime.z = new ParticleSystem.MinMaxCurve(-0.1f, 0.1f);
// No fade - birds stay visible while circling
var colorOverLifetime = _birdSystem.colorOverLifetime;
colorOverLifetime.enabled = true;
var gradient = new Gradient();
gradient.SetKeys(
new GradientColorKey[] {
new GradientColorKey(Color.white, 0f),
new GradientColorKey(Color.white, 1f)
},
new GradientAlphaKey[] {
new GradientAlphaKey(0f, 0f),
new GradientAlphaKey(1f, 0.05f),
new GradientAlphaKey(1f, 0.95f),
new GradientAlphaKey(0f, 1f)
});
colorOverLifetime.color = new ParticleSystem.MinMaxGradient(gradient);
var renderer = _birdSystem.GetComponent<ParticleSystemRenderer>();
if (renderer != null) {
// Use billboard facing camera
renderer.renderMode = ParticleSystemRenderMode.Billboard;
renderer.alignment = ParticleSystemRenderSpace.View;
// Use provided material if available (ensures shader is in build)
if (birdMaterial != null) {
_materialInstance = new Material(birdMaterial);
if (birdTexture != null) { _materialInstance.mainTexture = birdTexture; }
renderer.material = _materialInstance;
} else {
// Fallback for editor testing
var particleShader = Shader.Find("Particles/Standard Unlit");
if (particleShader != null) {
_materialInstance = new Material(particleShader);
_materialInstance.SetFloat("_ColorMode", 1);
_materialInstance.SetFloat("_Surface", 1);
_materialInstance.SetFloat("_Blend", 0);
_materialInstance.SetInt(
"_SrcBlend",
(int)UnityEngine.Rendering.BlendMode.SrcAlpha);
_materialInstance.SetInt(
"_DstBlend",
(int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
_materialInstance.EnableKeyword("_ALPHABLEND_ON");
_materialInstance.renderQueue = 3000;
if (birdTexture != null) { _materialInstance.mainTexture = birdTexture; }
renderer.material = _materialInstance;
}
}
renderer.sortingOrder = 102;
}
}
void OnDestroy() {
if (_materialInstance != null) { Destroy(_materialInstance); }
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: a5ee82ebc029341c4858ef5b7e7cf201
@@ -1,220 +0,0 @@
using UnityEngine;
namespace eagle {
/// <summary>
/// Creates a blizzard particle effect with falling snowflakes.
/// Uses province masking shader to clip particles to province boundaries.
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class BlizzardEffect : MonoBehaviour {
[Header("Snowflake Settings")]
public Texture2D snowflakeTexture;
public Material particleMaterial; // Assign in prefab to ensure shader is in build
public float snowEmitRate = 45f;
public float snowFallSpeed = 50f;
public float snowLifetime = 4f; // Longer lifetime
public float snowSize = 10f; // Slightly larger
public float snowDrift = 15f; // More drift
public Color snowColor = Color.white;
[Header("Wind Settings")]
public float windStrength = 5f;
public float windVariation = 3f;
[Header("Province Masking")]
public Texture2D provinceIdMap;
public int targetProvinceId;
private ParticleSystem _snowSystem;
private Material _material;
private RectTransform _mapContainer;
private Canvas _canvas;
private Vector3 _baseShapeScale;
private Vector3 _baseShapePosition;
private float _baseEmitRate;
private float _lastZoom = 1f;
void Awake() { SetupParticleSystems(); }
private void SetupParticleSystems() {
var snowObj = new GameObject("SnowEffect");
snowObj.transform.SetParent(transform, false);
_snowSystem = snowObj.AddComponent<ParticleSystem>();
SetupSnowSystem();
}
private void SetupSnowSystem() {
var main = _snowSystem.main;
main.playOnAwake = true;
main.loop = true;
main.startLifetime = snowLifetime;
main.startSpeed = 0f;
main.startSize = new ParticleSystem.MinMaxCurve(snowSize * 0.5f, snowSize);
main.startColor = snowColor;
main.startRotation = new ParticleSystem.MinMaxCurve(0f, Mathf.PI * 2f);
main.simulationSpace = ParticleSystemSimulationSpace.Local;
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
main.maxParticles = 900;
var emission = _snowSystem.emission;
emission.enabled = true;
emission.rateOverTime = snowEmitRate;
_baseEmitRate = snowEmitRate;
// Emit across full province height - particles spawn at varying Y positions
var shape = _snowSystem.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Box;
shape.scale = new Vector3(150f, 150f, 1f); // Wide and very tall emission area
shape.position = new Vector3(0f, 40f, 0f); // Centered above effect origin
// Store base values for zoom compensation
_baseShapeScale = shape.scale;
_baseShapePosition = shape.position;
// Fall downward with horizontal drift
var velocityOverLifetime = _snowSystem.velocityOverLifetime;
velocityOverLifetime.enabled = true;
velocityOverLifetime.space = ParticleSystemSimulationSpace.Local;
velocityOverLifetime.x = new ParticleSystem.MinMaxCurve(
-snowDrift - windStrength,
snowDrift + windStrength);
velocityOverLifetime.y = new ParticleSystem.MinMaxCurve(-snowFallSpeed, -snowFallSpeed);
velocityOverLifetime.z = new ParticleSystem.MinMaxCurve(0f, 0f);
// Gentle rotation as they fall
var rotationOverLifetime = _snowSystem.rotationOverLifetime;
rotationOverLifetime.enabled = true;
rotationOverLifetime.z = new ParticleSystem.MinMaxCurve(-0.5f, 0.5f);
// Use noise for fluttering motion
var noise = _snowSystem.noise;
noise.enabled = true;
noise.strength = windVariation;
noise.frequency = 0.5f;
noise.scrollSpeed = 0.2f;
noise.damping = false;
// Fade in and out
var colorOverLifetime = _snowSystem.colorOverLifetime;
colorOverLifetime.enabled = true;
var gradient = new Gradient();
gradient.SetKeys(
new GradientColorKey[] {
new GradientColorKey(Color.white, 0f),
new GradientColorKey(Color.white, 1f)
},
new GradientAlphaKey[] {
new GradientAlphaKey(0f, 0f),
new GradientAlphaKey(1f, 0.1f),
new GradientAlphaKey(1f, 0.8f),
new GradientAlphaKey(0f, 1f)
});
colorOverLifetime.color = new ParticleSystem.MinMaxGradient(gradient);
var renderer = _snowSystem.GetComponent<ParticleSystemRenderer>();
if (renderer != null) {
// Use provided material if available (ensures shader is in build)
if (particleMaterial != null) {
_material = new Material(particleMaterial);
_material.renderQueue = 3000;
if (snowflakeTexture != null) { _material.mainTexture = snowflakeTexture; }
renderer.material = _material;
} else {
// Fallback for editor testing
string[] shaderNames = {
"Eagle/ProvinceParticle",
"Universal Render Pipeline/Particles/Unlit",
"Particles/Standard Unlit",
"Legacy Shaders/Particles/Alpha Blended"
};
Shader shader = null;
foreach (var name in shaderNames) {
shader = Shader.Find(name);
if (shader != null) break;
}
if (shader != null) {
_material = new Material(shader);
_material.renderQueue = 3000;
if (snowflakeTexture != null) { _material.mainTexture = snowflakeTexture; }
renderer.material = _material;
} else {
Debug.LogWarning("BlizzardEffect: No particle shader found");
}
}
renderer.sortingOrder = 100;
}
}
public void SetProvinceId(int provinceId) {
targetProvinceId = provinceId;
if (_material != null) { _material.SetFloat("_TargetProvinceId", provinceId); }
}
public void SetProvinceIdMap(Texture2D idMap) {
provinceIdMap = idMap;
if (_material != null) { _material.SetTexture("_ProvinceIDMap", idMap); }
}
public void SetMapContainer(RectTransform container) {
_mapContainer = container;
_canvas = container?.GetComponentInParent<Canvas>();
}
void LateUpdate() {
UpdateMapBounds();
UpdateZoomCompensation();
}
private void UpdateMapBounds() {
if (_mapContainer == null || _material == null) { return; }
Vector3[] corners = new Vector3[4];
_mapContainer.GetWorldCorners(corners);
Camera cam = _canvas?.worldCamera ?? Camera.main;
Vector3 screenBL, screenTR;
if (_canvas != null && _canvas.renderMode == RenderMode.ScreenSpaceOverlay) {
screenBL = corners[0];
screenTR = corners[2];
} else {
screenBL = cam != null ? cam.WorldToScreenPoint(corners[0]) : corners[0];
screenTR = cam != null ? cam.WorldToScreenPoint(corners[2]) : corners[2];
}
var mapBounds = new Vector4(
screenBL.x,
screenBL.y,
screenTR.x - screenBL.x,
screenTR.y - screenBL.y);
_material.SetVector("_MapBounds", mapBounds);
}
private void UpdateZoomCompensation() {
if (_mapContainer == null || _snowSystem == null) { return; }
float zoom = _mapContainer.localScale.x;
if (Mathf.Approximately(zoom, _lastZoom)) { return; }
_lastZoom = zoom;
// Counter-scale particle system to maintain screen-space appearance
float inverseZoom = 1f / zoom;
_snowSystem.transform.localScale = Vector3.one * inverseZoom;
// Expand emission area to still cover the province
var shape = _snowSystem.shape;
shape.scale = _baseShapeScale * zoom;
shape.position = _baseShapePosition * zoom;
// Increase emission rate to maintain screen-space density (area scales as zoom²)
var emission = _snowSystem.emission;
emission.rateOverTime = _baseEmitRate * zoom * zoom;
}
void OnDestroy() {
if (_material != null) { Destroy(_material); }
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: c15980e88e57845ce920e43f29d35fcc
@@ -264,16 +264,15 @@ namespace eagle {
}
private int Cost(List<int> armaments) {
int totalCost = 0;
double totalCost = 0.0;
for (int i = 0; i < armaments.Count; i++) {
var battalion = _battalions[i];
var armamentDelta = Math.Max(armaments[i] - battalion.Armament.Value, 0.0);
// Ceiling per-battalion to match server calculation
totalCost += (int)Math.Ceiling(_costs[i] * armamentDelta);
totalCost += _costs[i] * Math.Max(armaments[i] - battalion.Armament.Value, 0.0);
}
return totalCost;
return (int)Math.Ceiling(Math.Max(totalCost, 0.0));
}
private int Cost() { return Cost(_newArmaments); }
@@ -15,6 +15,7 @@ namespace eagle {
public class ImproveCommandSelector : CommandSelector {
public HeroDropdownController heroDropdownController;
public Toggle lockToggle;
// Toggle buttons for each improvement type (assign to same ToggleGroup in Unity)
public Toggle economyToggle;
@@ -23,12 +24,6 @@ namespace eagle {
public Toggle devastationToggle;
public ToggleGroup improvementToggleGroup;
// Lock toggles - one per improvement type
public Toggle economyLockToggle;
public Toggle agricultureLockToggle;
public Toggle infrastructureLockToggle;
public Toggle devastationLockToggle;
// Labels showing type name and value
public TMP_Text economyLabel;
public TMP_Text agricultureLabel;
@@ -125,38 +120,6 @@ namespace eagle {
}
}
private Toggle GetLockToggleForType(ImprovementType type) {
switch (type) {
case ImprovementType.Economy: return economyLockToggle;
case ImprovementType.Agriculture: return agricultureLockToggle;
case ImprovementType.Infrastructure: return infrastructureLockToggle;
case ImprovementType.Devastation: return devastationLockToggle;
default: return null;
}
}
private void UpdateLockToggleVisibility() {
var selectedType = SelectedType;
var types = new[] {
ImprovementType.Economy,
ImprovementType.Agriculture,
ImprovementType.Infrastructure,
ImprovementType.Devastation
};
foreach (var type in types) {
var lockToggle = GetLockToggleForType(type);
if (lockToggle != null) {
// Only show lock for the selected type, hide all others
lockToggle.gameObject.SetActive(type == selectedType);
}
}
}
private void OnImprovementToggleChanged(bool isOn) {
// Only respond when a toggle is turned on (ToggleGroup handles turning off others)
if (isOn) { UpdateLockToggleVisibility(); }
}
private TMP_Text GetLabelForType(ImprovementType type) {
switch (type) {
case ImprovementType.Economy: return economyLabel;
@@ -242,43 +205,23 @@ namespace eagle {
ImprovementType.Devastation,
improveCommand.AvailableTypes.Contains(ImprovementType.Devastation));
// Wire up toggle change listeners for lock visibility updates
if (economyToggle != null) {
economyToggle.onValueChanged.AddListener(OnImprovementToggleChanged);
}
if (agricultureToggle != null) {
agricultureToggle.onValueChanged.AddListener(OnImprovementToggleChanged);
}
if (infrastructureToggle != null) {
infrastructureToggle.onValueChanged.AddListener(OnImprovementToggleChanged);
}
if (devastationToggle != null) {
devastationToggle.onValueChanged.AddListener(OnImprovementToggleChanged);
}
// Initialize all lock toggles to off
if (economyLockToggle != null) economyLockToggle.isOn = false;
if (agricultureLockToggle != null) agricultureLockToggle.isOn = false;
if (infrastructureLockToggle != null) infrastructureLockToggle.isOn = false;
if (devastationLockToggle != null) devastationLockToggle.isOn = false;
// Determine which type to select and whether to lock it
// Determine which type to select
ImprovementType selectedType;
if (improveCommand.LockedType != ImprovementType.None) {
var lockedType = improveCommand.LockedType;
if (improveCommand.AvailableTypes.Contains(lockedType)) {
selectedType = lockedType;
var lockToggle = GetLockToggleForType(lockedType);
if (lockToggle != null) lockToggle.isOn = true;
lockToggle.isOn = true;
} else {
selectedType = GetDefaultImprovementType();
lockToggle.isOn = false;
}
} else {
selectedType = GetDefaultImprovementType();
lockToggle.isOn = false;
}
SelectType(selectedType);
UpdateLockToggleVisibility();
}
public override AvailableCommand.SealedValueOneofCase CommandType =>
@@ -297,35 +240,13 @@ namespace eagle {
heroDropdownController.SelectedHeroId = heroId;
}
public override SelectedCommand Command {
get {
var selectedType = SelectedType;
var lockToggle = GetLockToggleForType(selectedType);
return new SelectedCommand {
ImproveCommand =
new ImproveSelectedCommand {
ImprovementType = selectedType,
ActingHeroId = heroDropdownController.SelectedHeroId,
LockType = lockToggle != null && lockToggle.isOn
}
};
}
}
void OnDestroy() {
// Clean up listeners
if (economyToggle != null) {
economyToggle.onValueChanged.RemoveListener(OnImprovementToggleChanged);
}
if (agricultureToggle != null) {
agricultureToggle.onValueChanged.RemoveListener(OnImprovementToggleChanged);
}
if (infrastructureToggle != null) {
infrastructureToggle.onValueChanged.RemoveListener(OnImprovementToggleChanged);
}
if (devastationToggle != null) {
devastationToggle.onValueChanged.RemoveListener(OnImprovementToggleChanged);
}
}
public override SelectedCommand Command => new SelectedCommand {
ImproveCommand =
new ImproveSelectedCommand {
ImprovementType = SelectedType,
ActingHeroId = heroDropdownController.SelectedHeroId,
LockType = lockToggle.isOn
}
};
}
}
@@ -219,7 +219,7 @@ namespace eagle {
if (!matches) continue;
var heroName = GetHeroDisplayName(uh.HeroId);
questMessages.Add($"{heroName} wants you to {actionVerb}.");
questMessages.Add($"{heroName} wants you to {actionVerb} this prisoner.");
}
}
@@ -231,24 +231,24 @@ namespace eagle {
switch (details.SealedValueCase) {
case QuestDetails.SealedValueOneofCase.ExecutePrisonerQuest:
if (details.ExecutePrisonerQuest.PrisonerHeroId == prisonerId) {
return (true, "execute this prisoner");
return (true, "execute");
}
break;
case QuestDetails.SealedValueOneofCase.ExilePrisonerQuest:
if (details.ExilePrisonerQuest.PrisonerHeroId == prisonerId) {
return (true, "exile this prisoner");
return (true, "exile");
}
break;
case QuestDetails.SealedValueOneofCase.FreePrisonerQuest:
if (details.FreePrisonerQuest.PrisonerHeroId == prisonerId) {
return (true, "release this prisoner");
return (true, "release");
}
break;
case QuestDetails.SealedValueOneofCase.ReturnPrisonerQuest:
if (details.ReturnPrisonerQuest.PrisonerHeroId == prisonerId) {
var factionName =
_model.FactionName(details.ReturnPrisonerQuest.ToFactionId);
return (true, $"return this prisoner to {factionName}");
return (true, $"return to {factionName}");
}
break;
}
@@ -40,10 +40,9 @@ namespace eagle {
public TMP_Dropdown fromDropdown;
public TMP_Dropdown toDropdown;
// Columns to show/hide based on layout mode
// Columns to hide in narrow layout mode (users select via side panels instead)
public GameObject heroesColumn;
public GameObject battalionsColumn;
public GameObject battalionsColumnNarrow; // Alternate battalions view for narrow layout
public Slider goldSlider;
public Slider foodSlider;
public TMP_Text goldTakenLabel;
@@ -303,11 +302,9 @@ namespace eagle {
}
public override void OnLayoutModeChanged() {
// Heroes always visible (user may march from different province than the viewed one)
if (heroesColumn != null) heroesColumn.SetActive(true);
// Toggle between wide and narrow battalion views
// Update hero/battalion column visibility when layout changes
if (heroesColumn != null) heroesColumn.SetActive(!IsNarrowLayout);
if (battalionsColumn != null) battalionsColumn.SetActive(!IsNarrowLayout);
if (battalionsColumnNarrow != null) battalionsColumnNarrow.SetActive(IsNarrowLayout);
}
protected override void SetUpUI() {
@@ -16,7 +16,6 @@ namespace eagle {
public class EventBasedUnitSelector : MonoBehaviour {
public GameObject availableHeroesContent;
public GameObject availableBattalionsContent;
public GameObject availableBattalionsContentNarrow; // Optional alternate for narrow layout
public GameObject selectedUnitsContent;
public GameObject availableHeroRow;
@@ -199,20 +198,10 @@ namespace eagle {
if (i % 2 == 1) { newRow.GetComponent<Image>().color = oddColor; }
}
PopulateBattalionsContent(availableBattalionsContent, oddColor);
if (availableBattalionsContentNarrow != null) {
PopulateBattalionsContent(availableBattalionsContentNarrow, oddColor);
foreach (Transform row in availableBattalionsContent.transform) {
Destroy(row.gameObject);
}
heroesAndBattalionsPanelController.UpdateTables();
selectionChanged.Invoke();
}
private void PopulateBattalionsContent(GameObject content, Color oddColor) {
foreach (Transform row in content.transform) { Destroy(row.gameObject); }
var parentTransform = content.transform;
parentTransform = availableBattalionsContent.transform;
for (int i = 0; i < AvailableBattalions.Count; i++) {
var battalion = AvailableBattalions[i];
@@ -228,6 +217,10 @@ namespace eagle {
if (i % 2 == 1) { newRow.GetComponent<Image>().color = oddColor; }
}
heroesAndBattalionsPanelController.UpdateTables();
selectionChanged.Invoke();
}
public void availableHeroButtonClicked(int index) {
@@ -23,9 +23,7 @@ namespace eagle {
/// Attach to a TextMeshProUGUI component to display status.
/// </summary>
public class ConnectionStatusUI : MonoBehaviour {
[Header("UI References")]
public TextMeshProUGUI statusText;
private TextMeshProUGUI _textComponent;
private PersistentClientConnection _connection;
private IGameStateProvider _gameStateProvider;
private string _environmentName;
@@ -41,8 +39,10 @@ namespace eagle {
private float _timeSinceLastUpdate = 0f;
void Start() {
if (statusText == null) {
Debug.LogError("ConnectionStatusUI: statusText reference not assigned");
_textComponent = GetComponent<TextMeshProUGUI>();
if (_textComponent == null) {
Debug.LogError(
"ConnectionStatusUI must be attached to a TextMeshProUGUI component");
enabled = false;
return;
}
@@ -78,7 +78,7 @@ namespace eagle {
}
void Update() {
if (_connection == null || statusText == null) { return; }
if (_connection == null || _textComponent == null) { return; }
_timeSinceLastUpdate += Time.deltaTime;
if (_timeSinceLastUpdate < UpdateInterval) { return; }
@@ -97,15 +97,16 @@ namespace eagle {
var timeUntilTest = nextTest.Value - DateTime.UtcNow;
if (timeUntilTest.TotalSeconds > 0) {
int seconds = Math.Min(10, (int)Math.Ceiling(timeUntilTest.TotalSeconds));
statusText.text = $"<color=red>●</color> Server down. Retry in {seconds}s";
_textComponent.text =
$"<color=red>●</color> Server down. Retry in {seconds}s";
return;
}
}
statusText.text = "<color=red>●</color> Server unavailable";
_textComponent.text = "<color=red>●</color> Server unavailable";
return;
} else if (circuitState == ConnectionCircuitBreaker.State.HalfOpen) {
SetRetryButtonVisible(false);
statusText.text = "<color=yellow>●</color> Testing connection...";
_textComponent.text = "<color=yellow>●</color> Testing connection...";
return;
}
@@ -118,7 +119,7 @@ namespace eagle {
(nextAttempt.Value - DateTime.UtcNow).TotalSeconds > 0;
SetRetryButtonVisible(isCountingDown);
string displayText = state switch {
string statusText = state switch {
ConnectionState.Connected => GetConnectedStatusText(),
ConnectionState.Connecting => "<color=yellow>●</color> Connecting...",
ConnectionState.Disconnected => "<color=red>●</color> Disconnected",
@@ -127,7 +128,7 @@ namespace eagle {
_ => "<color=gray>●</color> Unknown"
};
statusText.text = displayText;
_textComponent.text = statusText;
}
private string GetConnectedStatusText() {
@@ -1,121 +0,0 @@
using TMPro;
using UnityEngine;
namespace eagle {
/// <summary>
/// Curves TextMeshPro text along an arc.
/// Attach to a GameObject with TextMeshProUGUI component.
/// </summary>
[RequireComponent(typeof(TextMeshProUGUI))]
public class CurvedText : MonoBehaviour {
[Tooltip(
"Curvature amount. Positive curves upward (convex), negative curves downward (concave). 0 = no curve.")]
public float curvature = 0f;
private TextMeshProUGUI _textComponent;
private bool _needsUpdate = true;
void Awake() { _textComponent = GetComponent<TextMeshProUGUI>(); }
void OnEnable() {
TMPro_EventManager.TEXT_CHANGED_EVENT.Add(OnTextChanged);
_needsUpdate = true;
}
void OnDisable() { TMPro_EventManager.TEXT_CHANGED_EVENT.Remove(OnTextChanged); }
void OnTextChanged(Object obj) {
if (obj == _textComponent) { _needsUpdate = true; }
}
void LateUpdate() {
if (_needsUpdate && _textComponent != null) {
_needsUpdate = false;
ApplyCurve();
}
}
public void SetCurvature(float newCurvature) {
curvature = newCurvature;
_needsUpdate = true;
}
private void ApplyCurve() {
if (Mathf.Approximately(curvature, 0f)) { return; }
_textComponent.ForceMeshUpdate();
TMP_TextInfo textInfo = _textComponent.textInfo;
if (textInfo == null || textInfo.characterCount == 0) { return; }
// Find the bounds of all characters
float minX = float.MaxValue;
float maxX = float.MinValue;
for (int i = 0; i < textInfo.characterCount; i++) {
if (!textInfo.characterInfo[i].isVisible) { continue; }
var charInfo = textInfo.characterInfo[i];
minX = Mathf.Min(minX, charInfo.bottomLeft.x);
maxX = Mathf.Max(maxX, charInfo.bottomRight.x);
}
float textWidth = maxX - minX;
if (textWidth <= 0) { return; }
float centerX = (minX + maxX) / 2f;
// Apply curve to each character
for (int i = 0; i < textInfo.characterCount; i++) {
if (!textInfo.characterInfo[i].isVisible) { continue; }
var charInfo = textInfo.characterInfo[i];
int materialIndex = charInfo.materialReferenceIndex;
int vertexIndex = charInfo.vertexIndex;
Vector3[] vertices = textInfo.meshInfo[materialIndex].vertices;
// Get character center X position
float charCenterX = (vertices[vertexIndex].x + vertices[vertexIndex + 2].x) / 2f;
// Calculate normalized position (-1 to 1) from center
float normalizedX = (charCenterX - centerX) / (textWidth / 2f);
// Calculate Y offset using parabola: y = -curvature * (1 - x^2)
// Negated because Unity Y is up, but our curvature convention has positive = convex
// down. Scale by 0.25 to reduce intensity.
float scaledCurvature = curvature * 0.25f;
float yOffset = -scaledCurvature * (1f - normalizedX * normalizedX);
// Calculate rotation angle based on tangent of the curve
// Derivative of y = -c * (1 - x^2) is dy/dx = 2cx
float tangentSlope = 2f * scaledCurvature * normalizedX / (textWidth / 2f);
float angle = Mathf.Atan(tangentSlope) * Mathf.Rad2Deg;
// Apply transformation to all 4 vertices of the character
Vector3 charCenter = new Vector3(
charCenterX,
(vertices[vertexIndex].y + vertices[vertexIndex + 2].y) / 2f,
0);
for (int j = 0; j < 4; j++) {
Vector3 offset = vertices[vertexIndex + j] - charCenter;
// Rotate around character center
float rad = angle * Mathf.Deg2Rad;
float rotatedX = offset.x * Mathf.Cos(rad) - offset.y * Mathf.Sin(rad);
float rotatedY = offset.x * Mathf.Sin(rad) + offset.y * Mathf.Cos(rad);
vertices[vertexIndex + j] =
charCenter + new Vector3(rotatedX, rotatedY + yOffset, offset.z);
}
}
// Update the mesh
for (int i = 0; i < textInfo.meshInfo.Length; i++) {
textInfo.meshInfo[i].mesh.vertices = textInfo.meshInfo[i].vertices;
_textComponent.UpdateGeometry(textInfo.meshInfo[i].mesh, i);
}
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 299f66fc896a34cdeb95e92e927a6f10
@@ -15,7 +15,6 @@ namespace eagle {
}
public static readonly Dictionary<Profession, string> ProfessionNames = new() {
{ Profession.UnknownProfession, "Unknown" },
{ Profession.NoProfession, "None" },
{ Profession.Engineer, "Engineer" },
{ Profession.Mage, "Mage" },
@@ -26,7 +25,6 @@ namespace eagle {
};
public static readonly Dictionary<Profession, string> ShortProfessionNames = new() {
{ Profession.UnknownProfession, "?" },
{ Profession.NoProfession, "None" },
{ Profession.Engineer, "Engr" },
{ Profession.Mage, "Mage" },
@@ -106,9 +104,6 @@ namespace eagle {
return $"{name} is unlikely to stay here long enough to impress with a quest.";
case RecruitmentStatus.Sworn: return $"{name} is dedicated to another faction.";
case RecruitmentStatus.WouldNeverJoin:
return $"{name} would never join your faction.";
}
return $"Unknown!";
@@ -133,8 +128,6 @@ namespace eagle {
case RecruitmentStatus.Traveler: return "Traveler";
case RecruitmentStatus.Sworn: return "Dedicated to another faction";
case RecruitmentStatus.WouldNeverJoin: return "Would never join";
}
return $"Unknown!";
@@ -182,7 +175,6 @@ namespace eagle {
case SealedValueOneofCase.WinBattlesQuest: return "Win Battles";
case SealedValueOneofCase.ApprehendOutlawQuest: return "Apprehend Outlaw";
case SealedValueOneofCase.StartBlizzardQuest: return "Start Blizzard";
case SealedValueOneofCase.StartDroughtQuest: return "Start Drought";
case SealedValueOneofCase.StartEpidemicQuest: return "Start Epidemic";
case SealedValueOneofCase.SpendOnFeastsQuest: return "Host Feasts";
case SealedValueOneofCase.SendSuppliesQuest: return "Send Supplies";
@@ -1,152 +0,0 @@
using UnityEngine;
using UnityEngine.UI;
namespace eagle {
/// <summary>
/// Creates a drought effect with a hot sun and heat shimmer overlay.
/// The heat shimmer distorts the province using a shader that masks to province boundaries.
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class DroughtEffect : MonoBehaviour {
[Header("Sun Settings")]
public Sprite sunSprite;
public float sunSize = 40f;
public float sunVerticalOffset = 40f;
public Color sunColor = new Color(1f, 0.9f, 0.3f, 0.9f);
public float sunPulseSpeed = 1.5f;
public float sunPulseAmount = 0.15f;
public float sunWaveAmountX = 8f; // Horizontal wave distance
public float sunWaveAmountY = 5f; // Vertical wave distance
public float sunWaveSpeedX = 0.4f; // Horizontal wave speed
public float sunWaveSpeedY =
0.6f; // Vertical wave speed (slightly different for organic feel)
[Header("Heat Shimmer Settings")]
public Material shimmerMaterial;
public float shimmerSize = 300f; // Large enough to cover province
[Header("Province Masking")]
public Texture2D provinceIdMap;
public int targetProvinceId;
public Vector4 mapBounds; // x=minX, y=minY, z=width, w=height
private GameObject _sunObject;
private Image _sunImage;
private GameObject _shimmerObject;
private RawImage _shimmerImage; // RawImage renders without sprite
private Material _shimmerMatInstance;
private Texture2D _whiteTexture;
private float _baseSunScale;
private Vector2 _baseSunPosition;
void Start() { SetupEffect(); }
private void SetupEffect() {
SetupSun();
// Note: Shimmer disabled - GrabPass doesn't work well with UI Canvas
// TODO: Consider alternative shimmer approach (animated texture overlay)
// SetupShimmer();
}
private void SetupSun() {
_sunObject = new GameObject("Sun");
_sunObject.transform.SetParent(transform, false);
_sunImage = _sunObject.AddComponent<Image>();
if (sunSprite != null) { _sunImage.sprite = sunSprite; }
_sunImage.color = sunColor;
var rectTransform = _sunObject.GetComponent<RectTransform>();
rectTransform.sizeDelta = new Vector2(sunSize, sunSize);
_baseSunPosition = new Vector2(0f, sunVerticalOffset);
rectTransform.anchoredPosition = _baseSunPosition;
_baseSunScale = 1f;
}
private void SetupShimmer() {
// Try to find the shader and create material
var shader = Shader.Find("Eagle/HeatShimmer");
if (shader == null) {
Debug.LogWarning("DroughtEffect: Could not find Eagle/HeatShimmer shader");
return;
}
_shimmerObject = new GameObject("HeatShimmer");
_shimmerObject.transform.SetParent(transform, false);
// Put shimmer behind sun
_shimmerObject.transform.SetAsFirstSibling();
// Create a white texture for RawImage (required for rendering)
_whiteTexture = new Texture2D(1, 1);
_whiteTexture.SetPixel(0, 0, Color.white);
_whiteTexture.Apply();
// Use RawImage which renders without needing a sprite
_shimmerImage = _shimmerObject.AddComponent<RawImage>();
_shimmerImage.texture = _whiteTexture;
_shimmerMatInstance = new Material(shader);
_shimmerImage.material = _shimmerMatInstance;
_shimmerImage.color = Color.white;
var rectTransform = _shimmerObject.GetComponent<RectTransform>();
rectTransform.sizeDelta = new Vector2(shimmerSize, shimmerSize);
rectTransform.anchoredPosition = Vector2.zero;
// Set shader properties
if (provinceIdMap != null) {
_shimmerMatInstance.SetTexture("_ProvinceIDMap", provinceIdMap);
}
_shimmerMatInstance.SetFloat("_TargetProvinceId", targetProvinceId);
_shimmerMatInstance.SetVector("_MapBounds", mapBounds);
}
public void SetProvinceId(int provinceId) {
targetProvinceId = provinceId;
if (_shimmerMatInstance != null) {
_shimmerMatInstance.SetFloat("_TargetProvinceId", provinceId);
}
}
public void SetProvinceIdMap(Texture2D idMap) {
provinceIdMap = idMap;
if (_shimmerMatInstance != null) {
_shimmerMatInstance.SetTexture("_ProvinceIDMap", idMap);
}
}
public void SetMapBounds(Vector4 bounds) {
mapBounds = bounds;
if (_shimmerMatInstance != null) {
_shimmerMatInstance.SetVector("_MapBounds", bounds);
}
}
public void SetShimmerSize(float size) {
shimmerSize = size;
if (_shimmerObject != null) {
var rt = _shimmerObject.GetComponent<RectTransform>();
rt.sizeDelta = new Vector2(size, size);
}
}
void Update() {
if (_sunObject != null) {
// Pulse the sun size
float pulse = 1f + Mathf.Sin(Time.time * sunPulseSpeed) * sunPulseAmount;
_sunObject.transform.localScale = Vector3.one * (_baseSunScale * pulse);
// Wave the sun position slowly
var rectTransform = _sunObject.GetComponent<RectTransform>();
float waveX = Mathf.Sin(Time.time * sunWaveSpeedX) * sunWaveAmountX;
float waveY = Mathf.Sin(Time.time * sunWaveSpeedY) * sunWaveAmountY;
rectTransform.anchoredPosition = _baseSunPosition + new Vector2(waveX, waveY);
}
}
void OnDestroy() {
if (_shimmerMatInstance != null) { Destroy(_shimmerMatInstance); }
if (_whiteTexture != null) { Destroy(_whiteTexture); }
}
}
}
@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: f64938ef171de43dba1b0a865fc86f60
@@ -21,15 +21,12 @@ public class EagleCommonTextures : MonoBehaviour {
public Texture2D Profession(Profession profession) {
if (profession == Net.Eagle0.Eagle.Common.Profession.NoProfession)
return professionTextures.Last();
if (profession == Net.Eagle0.Eagle.Common.Profession.UnknownProfession) return null;
return professionTextures[(int)profession];
}
public Texture2D Profession(HeroView.Types.ProfessionSpecificInfo.Types.Profession profession) {
if (profession == HeroView.Types.ProfessionSpecificInfo.Types.Profession.NoProfession)
return professionTextures.Last();
if (profession == HeroView.Types.ProfessionSpecificInfo.Types.Profession.Unknown)
return null;
return professionTextures[(int)profession];
}
@@ -33,6 +33,8 @@ namespace eagle {
[Header("Status Labels")]
public TextMeshProUGUI roundStatusLabel;
public TextMeshProUGUI connectionStatusLabel;
private bool _connectionStatusUIInitialized = false;
[Header("Main Panels")]
public ProvinceInfoPanelController provinceInfoPanelController;
@@ -75,6 +77,10 @@ namespace eagle {
public Button gameIdButton;
public Button nextActiveProvinceButton;
[Header("Tutorial Narrative")]
public NarrativeScreenController narrativeScreenController;
private bool _narrativeScreensShown = false;
[Header("Commands")]
public MouseHandler mouseHandlerPrefab;
public GameObject commandPanel;
@@ -143,6 +149,16 @@ namespace eagle {
if (newWidth == _lastWidth && newHeight == _lastHeight) { return; }
// Initialize ConnectionStatusUI component once when connection is available
if (!_connectionStatusUIInitialized &&
errorHandler.PersistentClientConnection != null) {
var statusUI = connectionStatusLabel.GetComponent<ConnectionStatusUI>();
if (statusUI != null) {
statusUI.SetConnection(errorHandler.PersistentClientConnection);
_connectionStatusUIInitialized = true;
}
}
_lastWidth = newWidth;
_lastHeight = newHeight;
@@ -213,21 +229,12 @@ namespace eagle {
}
SwapModel();
Model = null;
// Clear stale province state from the previous game so it doesn't
// get used with the next game's hero/battalion maps.
provinceInfoPanelController.Province = null;
freeHeroesTableController.CurrentProvince = null;
movingArmiesTableControllerNarrow.CurrentProvince = null;
movingArmiesTableControllerWide.CurrentProvince = null;
factionsTableControllerNarrow.CurrentProvince = null;
factionsTableControllerWide.CurrentProvince = null;
heroesAndBattalionsPanelController.CurrentProvince = null;
chronicleCanvasController.Entries = new List<ChronicleEntry>();
SetMusic();
// Clear game state provider when leaving game
PersistentUIManager.Instance?.connectionStatusUI?.SetGameStateProvider(null);
var statusUI = connectionStatusLabel.GetComponent<ConnectionStatusUI>();
if (statusUI != null) { statusUI.SetGameStateProvider(null); }
}
void MapControllerChangedTarget(List<ProvinceId> newTarget) {
@@ -332,7 +339,7 @@ namespace eagle {
ModelUpdater.ErrorHandler = errorHandler;
// Set up game state provider and environment for connection status UI
var statusUI = PersistentUIManager.Instance?.connectionStatusUI;
var statusUI = connectionStatusLabel.GetComponent<ConnectionStatusUI>();
if (statusUI != null) {
statusUI.SetGameStateProvider(ModelUpdater);
statusUI.SetEnvironment(environmentName);
@@ -350,12 +357,6 @@ namespace eagle {
"CommandPanel",
commandPanel.GetComponent<RectTransform>());
}
// Register Go to Battle button for dialogue highlight
if (TutorialManager.Instance?.TargetRegistry != null && goToBattleButton != null) {
TutorialManager.Instance.TargetRegistry.RegisterTarget(
"GoToBattleButton",
goToBattleButton.GetComponent<RectTransform>());
}
#if UNITY_EDITOR
EditorApplication.playModeStateChanged += (state) => OnPlayModeStateChanged(state);
@@ -527,6 +528,29 @@ namespace eagle {
}
}
/// <summary>
/// Shows tutorial narrative screens before gameplay.
/// After all screens are dismissed, proceeds to battle if one is waiting.
/// </summary>
private void ShowNarrativeScreens(IList<TutorialNarrativeScreen> screens) {
if (narrativeScreenController == null) {
Debug.LogWarning("EagleGameController: NarrativeScreenController not assigned");
OnNarrativeComplete();
return;
}
narrativeScreenController.Show(screens, OnNarrativeComplete);
}
/// <summary>
/// Called when narrative screens are complete.
/// Proceeds to tutorial battle or normal game flow.
/// </summary>
private void OnNarrativeComplete() {
// If there's a battle waiting, go directly to it
if (Model != null && Model.RunningShardokGameModels.Count > 0) { GoToBattle(); }
}
void SetHeaderString() {
var date = Model.CurrentDate;
if (date != null) {
@@ -586,15 +610,18 @@ namespace eagle {
return;
}
// On first model, sync IsTutorialGame from the server-reported GameType.
// This covers joining an existing game (where ConnectionHandler doesn't know the type).
if (oldModel == null) {
TutorialManager.IsTutorialGame = Model.GameType == GameType.Tutorial;
// Check for tutorial narrative screens on first model load
if (oldModel == null && !_narrativeScreensShown &&
Model.PendingNarrativeScreens != null && Model.PendingNarrativeScreens.Count > 0) {
_narrativeScreensShown = true;
ShowNarrativeScreens(Model.PendingNarrativeScreens);
return; // Wait for narrative to complete before proceeding
}
// Start onboarding after first model update (UI panels are now populated)
// Skip onboarding if we had narrative screens (tutorial battle mode)
if (oldModel == null && TutorialManager.Instance != null &&
!TutorialManager.Instance.State.OnboardingCompleted) {
!TutorialManager.Instance.State.OnboardingCompleted && !_narrativeScreensShown) {
TutorialManager.Instance.StartOnboarding();
}
@@ -772,9 +799,6 @@ namespace eagle {
}
public void GoToBattle() {
// End any active dialogue before transitioning to battle
TutorialManager.Instance?.DialogueManager?.EndDialogue();
// Hide the button now, so if we get an exception below we can at least recover
goToBattleButton.gameObject.SetActive(false);
@@ -807,6 +831,7 @@ namespace eagle {
}
shardokController.SetUpGame(shardokModel);
shardokController.SetConnection(errorHandler.PersistentClientConnection);
gameObject.SetActive(false);
}
@@ -60,7 +60,10 @@ namespace eagle {
public List<ChronicleEntry> ChronicleEntries { get; }
GameType GameType { get; }
/// <summary>
/// Narrative screens to display before gameplay (tutorial intro).
/// </summary>
public IList<TutorialNarrativeScreen> PendingNarrativeScreens { get; }
}
public class GameModelUpdater : IClientConnectionSubscriber, IGameStateProvider {
@@ -203,7 +206,8 @@ namespace eagle {
public List<ChronicleEntry> ChronicleEntries { get; set; }
public GameType GameType => GsView.GameType;
public IList<TutorialNarrativeScreen> PendingNarrativeScreens =>
GsView.PendingNarrativeScreens;
public List<AvailableCommand> AvailableCommandsForProvince(ProvinceId pid) {
if (AvailableCommandsByProvince.TryGetValue(pid, out var cmds)) {
@@ -264,7 +268,7 @@ namespace eagle {
if (battleView == null) {
// Battle was removed (e.g., it ended) before we could create the model.
// This is expected when Eagle's RemovedBattleIds update arrives before a
// pending Shardok update - the UI already shows "Exit Battle" via
// pending Shardok update - the UI already shows "Back to Eagle" via
// MarkBattleEnded(), so we just skip this stale update.
return null;
}
@@ -302,13 +306,7 @@ namespace eagle {
kv => kv.Value.ImagePath),
battalionNames: _currentModel.BattalionNames.ToDictionary(
kv => kv.Key,
kv => kv.Value),
heroFallbackLookup: hid => {
if (_currentModel.Heroes.TryGetValue(hid, out var hero)) {
return (hero.NameTextId, hero.ImagePath);
}
return null;
});
kv => kv.Value));
return shardokModel;
}
@@ -942,10 +940,6 @@ namespace eagle {
}
foreach (var ce in entry.UpdatedChronicleEntries) { UpdateChronicleEntry(ce); }
if (entry.NewGameType != GameType.UnknownGameType) {
_currentModel.GsView.GameType = entry.NewGameType;
}
}
private void HandleNewHistoryEntry(ActionResultView arv) {
@@ -1,60 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &7893566172152383399
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 9219784709551883713}
- component: {fileID: 618063871244848148}
m_Layer: 0
m_Name: BeastsEffect
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &9219784709551883713
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
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: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &618063871244848148
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a5ee82ebc029341c4858ef5b7e7cf201, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::eagle.BeastsEffect
birdTexture: {fileID: 2800000, guid: 7b6ab1e235eea4e4e9319c88bb1cdc70, type: 3}
birdMaterial: {fileID: 2100000, guid: 67dcb765c9c324ff9a103624340bc9b5, type: 2}
birdCount: 5
birdLifetime: 8
birdSize: 12
circleRadius: 35
circleSpeed: 1
birdColor: {r: 1, g: 1, b: 1, a: 1}
verticalOscillation: 10
radiusVariation: 10
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 1c093deb39df94fbca5faa9f015a2331
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,63 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &7893566172152383399
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 9219784709551883713}
- component: {fileID: 3572684436619911535}
m_Layer: 0
m_Name: BlizzardEffect
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &9219784709551883713
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
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: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &3572684436619911535
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: c15980e88e57845ce920e43f29d35fcc, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::eagle.BlizzardEffect
snowflakeTexture: {fileID: 2800000, guid: 9a5b4e8c3d2f6b0a7c1e9d5f8a3b2c4d, type: 3}
particleMaterial: {fileID: 2100000, guid: b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6, type: 2}
snowEmitRate: 45
snowFallSpeed: 40
snowLifetime: 3
snowSize: 8
snowDrift: 10
snowColor: {r: 1, g: 1, b: 1, a: 1}
windStrength: 5
windVariation: 3
provinceIdMap: {fileID: 0}
targetProvinceId: 0
mapBounds: {x: 0, y: 0, z: 0, w: 0}
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 0cf4724a235af4fb8abab6456789936a
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,61 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &7893566172152383399
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 9219784709551883713}
- component: {fileID: -4077358403987153371}
m_Layer: 0
m_Name: DroughtEffect
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &9219784709551883713
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
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: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &-4077358403987153371
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f64938ef171de43dba1b0a865fc86f60, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::eagle.DroughtEffect
sunSprite: {fileID: 21300000, guid: ecdaab62f65f19745aee51dc3e5be2fc, type: 3}
sunSize: 20
sunVerticalOffset: 30
sunColor: {r: 1, g: 0.9019608, b: 0.29803923, a: 0.68235296}
sunPulseSpeed: 1.5
sunPulseAmount: 0.1
shimmerMaterial: {fileID: 10303, guid: 0000000000000000f000000000000000, type: 0}
shimmerSize: 15
provinceIdMap: {fileID: 0}
targetProvinceId: 0
mapBounds: {x: 0, y: 0, z: 0, w: 0}
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 1be06ed8f9be949478a81914e2479d4d
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,63 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &7893566172152383399
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 9219784709551883713}
- component: {fileID: 8607881802583290962}
m_Layer: 0
m_Name: EpidemicEffect
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &9219784709551883713
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
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: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &8607881802583290962
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 727093fab7ebc43dc94ee59675164706, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::eagle.EpidemicEffect
skullMaterial: {fileID: 2100000, guid: c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7, type: 2}
skullEmitRate: 1.5
skullRiseSpeed: 25
skullLifetime: 3
skullSize: 25
skullSway: 8
skullColor: {r: 1, g: 1, b: 1, a: 1}
hazeMaterial: {fileID: 2100000, guid: c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7, type: 2}
hazeEmitRate: 10
hazeSpeed: 8
hazeLifetime: 4
hazeSize: 10
hazeColor: {r: 0.4, g: 0.54901963, b: 0.34901962, a: 0.50980395}
@@ -1,61 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &529533522556784756
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 7895772391506986011}
- component: {fileID: 4807333260855875379}
m_Layer: 0
m_Name: Fireworks
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &7895772391506986011
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 529533522556784756}
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: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &4807333260855875379
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 529533522556784756}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 01a76cb7235d04623be14e4b01f18754, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::eagle.FireworksEffect
burstInterval: 1.5
particlesPerBurst: 30
particleLifetime: 1.5
startSpeed: 200
gravity: 100
fireworkColors:
- {r: 1, g: 0.3, b: 0.3, a: 1}
- {r: 1, g: 0.8, b: 0.2, a: 1}
- {r: 0.3, g: 0.8, b: 1, a: 1}
- {r: 0.3, g: 1, b: 0.3, a: 1}
- {r: 1, g: 0.5, b: 1, a: 1}
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 27861cc746ec947e88dac44d9a058e10
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,65 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &7893566172152383399
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 9219784709551883713}
- component: {fileID: 8954796869750569591}
m_Layer: 0
m_Name: FloodEffect
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &9219784709551883713
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
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: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &8954796869750569591
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 7893566172152383399}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 336c92d7cae77487fa6915c9839d38f6, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::eagle.FloodEffect
rainTexture: {fileID: 2800000, guid: 8f4a3e7b2c1d5a9e6b0f8c4d7a2e1b3c, type: 3}
particleMaterial: {fileID: 2100000, guid: b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6, type: 2}
rainEmitRate: 40
rainFallSpeed: 80
rainLifetime: 1.5
rainSize: 4
rainStretch: 3
rainColor: {r: 0.7, g: 0.8, b: 0.9, a: 0.6}
splashEmitRate: 10
splashLifetime: 0.5
splashSize: 8
splashColor: {r: 0.7, g: 0.85, b: 1, a: 0.4}
provinceIdMap: {fileID: 0}
targetProvinceId: 0
mapBounds: {x: 0, y: 0, z: 0, w: 0}

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