Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 9cf6e9cc3f Populate hero names in tutorial game ClientTextStore
The tutorial game creation was not calling withPopulatedPregeneratedTexts(),
causing hero names to show as "Hero" instead of their actual names because
the name text IDs couldn't be resolved.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-18 12:30:13 -08:00
adminandClaude Opus 4.5 aa62cbdae5 Remove default value from GameState.gameType parameter
Force callers to explicitly specify gameType when constructing GameState,
making the type requirement more explicit and preventing accidental defaults.

Updates all test files and InMemoryHistory to explicitly pass GameType.Normal.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-18 12:26:05 -08:00
adminandClaude Opus 4.5 b5ce58a448 Add newGameType field to GameStateViewDiff
Ensures game type changes are sent to clients via diffs, not just
the full GameStateView.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-18 09:22:27 -08:00
adminandClaude Opus 4.5 561babd7bc Add GameType enum to track tutorial vs normal games
- Add GameType enum (NORMAL, TUTORIAL) in common proto and Scala model
- Store gameType in GameState and pass to GameStateView for client
- Add newGameType field to ActionResult for setting game type on creation
- Mark tutorial games as TUTORIAL in TutorialGameCreation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-18 09:15:59 -08:00
4325385544 Adjust tutorial factions: Ikhaan Tarn joins King's Loyalists, add Vengeance (#6119)
- Move Ikhaan Tarn and province 31 into Bregos Fyar's King's Loyalists faction
- Add new Vengeance faction with Bridget and 300 light infantry in Kojaria (province 39)
- Fix startingTrusts to target player faction (was targeting self after reorganization)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-18 06:46:08 -08:00
adminandGitHub 88781d779b Revert "Add persistent Bazel server keepalive for CI runners (#6096)" (#6118)
This reverts commit cccbbb9d37.
2026-02-17 21:23:36 -08:00
b8e1e0d8b5 Add test for tutorial game creation (#6117)
- Add TutorialGameCreationTest to verify tutorial game can be created
  without exceptions
- Fix TutorialGameCreation to properly set provinceOrders from JSON
  config using ProvinceOrderTypeConverter
- Add test visibility for battalion_types resource

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 21:23:01 -08:00
023503e831 Bump rules_rust 0.60.0 → 0.68.1 and apple_support 1.23.1 → 1.24.1 (#6116)
Aligns pinned versions with what the dependency graph was already resolving,
eliminating the --check_direct_dependencies warnings.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 21:10:26 -08:00
3058538772 Add missing orders field to tutorial provinces (#6115)
Provinces 14 (player) and 31 (enemy) were missing the "orders" field,
causing a validation error: "Ruling player present, but province orders
not set".

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 21:04:18 -08:00
fe72e870a2 Add Tutorial button to lobby for launching tutorial games (#6044)
* Add Tutorial button to lobby for launching tutorial games

- Add tutorialButton field to ConnectionHandler for Unity to wire up
- Add CreateTutorialGame() method that creates a game with isTutorial=true
- Modify _internalCreateGame() to accept isTutorial parameter and include
  it in the CreateGameRequest proto message
- Wire up tutorial button click handler in SetupLobbyUI()

The Tutorial button will create a single-player game where Eagle sets up
the hardcoded tutorial scenario (defending Onmaa from Ikhaan Tarn's attack).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Hide tutorial button for non-nolen users

Temporary gate while tutorial is in development.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove defensive null checks on Inspector fields in lobby setup

If these fields aren't wired in the editor, we want a
NullReferenceException so the problem is immediately obvious.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 21:04:02 -08:00
69da124203 Use Vellus Kade in tutorial instead of missing Spartacus the Younger (#6113)
Updates tutorial_parameters.json to use Vellus Kade (who already exists
in heroes.tsv) as the ruler of province 40, instead of the non-existent
"Spartacus the Younger". Uses battalion names from game_parameters.json.

Also removes swornBrotherNames for Bregos Fyar in tutorial - Vellus Kade
is just a vassal.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 18:33:09 -08:00
67fad55d47 Skip Mac build on PRs unless Mac-specific files change (#6114)
The Windows build covers shared C#/proto compilation. Mac only needs
to build on PRs when Mac-specific config changes (workflow, build
scripts, Sparkle, code signing, etc.). Mac still builds on every
push to main.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:41:17 -08:00
a879243fd8 Remove unused persist_library.sh and restore_library.sh (#6112)
These scripts managed an rsync-based Library/ cache to /tmp/eagle0/ but
are never called from any workflow. Library/ caching now relies on
clean: false preserving the working directory between self-hosted runs.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:54:16 -08:00
a8ded5df45 Only clear Bee/ cache when C# files are added/deleted/renamed (#6111)
Bee's incremental compilation handles content-only .cs modifications
fine. The stale DAG errors that originally motivated unconditional Bee/
deletion are caused by structural changes (files moved/renamed/deleted),
not content edits. This saves significant rebuild time on asset-only
changes like FBX meta edits or texture updates.

The last-built commit SHA is saved after each successful build and
compared on the next run to detect structural C# changes.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:50:22 -08:00
9fbc409df9 Split bridge prefabs into permanent and constructed styles (#6110)
* Split bridge prefabs into permanent and constructed styles

Pre-existing bridges now use stone/wooden models while engineer-built
bridges use hastily-constructed models, selected at render time via
the existing _bridgeBuilderLocations tracking.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Scale up bridge models to better fill hex tiles

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 16:04:25 -08:00
ac260323c0 Fix HeroGenerator state threading to prevent duplicate heroes (#6078)
HeroGenerator.getHero() returns a new generator after consuming a hero
from the pool, but this updated generator was never stored back in
EngineImpl. This caused duplicate heroes to appear across rounds since
the same pool was being used repeatedly.

Changes:
- Add PhaseAdvancementResult to hold both results and optional updated generator
- Add resultsWithGenerator() to PerformUnaffiliatedHeroesAction
- Update RoundPhaseAdvancer to return PhaseAdvancementResult
- Add recursiveTransformWithGenerator() to EngineImpl to store updated generator

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 13:45:31 -08:00
3a897b6241 Add Windows code signing to small eagle TODO nice-to-haves (#6109)
Azure Artifact Signing at ~$10/mo eliminates the SmartScreen
"unknown publisher" warning on Windows builds.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:25:19 -08:00
161fa78b42 Add CDN cache purge to iOS workflows after addressables upload (#6108)
With the CDN TTL increased to 1 hour, iOS addressables updates need
active cache purging to be visible to clients promptly.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 12:40:45 -08:00
8dc9d12fdf Set ITSAppUsesNonExemptEncryption in archive script for reliable compliance bypass (#6107)
The Unity DeepLinkPostProcessor depends on UNITY_IOS being defined at
script compilation time, which can fail if the editor starts with a
non-iOS build target. Set the key via PlistBuddy in the archive script
as a reliable fallback that doesn't depend on Unity preprocessor state.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 12:28:42 -08:00
df105c9e31 Replace CDN sleep with cache purge for faster client notifications (#6106)
Instead of sleeping 60s for CDN cache to expire, actively purge the
DigitalOcean Spaces CDN cache before notifying clients of new builds.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 12:26:52 -08:00
d62120b833 Eliminate Xcode project artifact round-trip in iOS TestFlight workflow (#6105)
Both jobs now run on a dedicated [self-hosted, macOS, testflight]
runner (halfdan), so the Xcode project stays on disk between jobs.
This removes the tar/upload/download/extract cycle through GitHub's
artifact storage, saving ~5-13 minutes per TestFlight build.

Requires adding the 'testflight' label to the halfdan runner.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:34:29 -08:00
de1ad34005 Fix docker_build deploy sparse checkout for individual files (#6104)
Git sparse checkout cone mode only supports directories. Add
sparse-checkout-cone-mode: false to allow the docker-compose.prod.yml
file pattern.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:23:45 -08:00
5f366c04ed Remove duplicate artifact cleanup from mac_build deploy job (#6103)
The cleanup job on ubuntu-latest already deletes these artifacts with
if: always(). The deploy job's copy was redundant.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:20:46 -08:00
dfec602889 Misc CI optimizations: notify offload, skip redundant builds, fix LFS (#6102)
- Move sleep 60 + client notify from self-hosted runners to ubuntu-latest
  jobs in mac_build and unity_build, freeing expensive runners sooner
- Skip eagle_build and shardok_build on main pushes (redundant with
  docker_build and shardok_arm64_build respectively, kept for PRs)
- Add lfs: false to bazel_cache_cleanup (only runs bazel clean)
- Add sparse checkout + lfs: false to docker_build deploy job (only
  needs docker-compose.prod.yml, nginx/, and scripts/)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:10:11 -08:00
3f83cf2c45 Add concurrency groups to prevent redundant CI builds (#6101)
Build-only workflows (bazel_test, eagle_build, shardok_build) cancel
superseded runs for both PRs and main pushes.

Deploy workflows (mac_build, unity_build, ios_addressables,
shardok_arm64) cancel superseded PR builds but queue main pushes to
avoid interrupting in-progress deployments.

docker_build already had concurrency control.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:03:32 -08:00
6edc3fcb34 Stop round-tripping Unity Library/ through /tmp in iOS workflows (#6100)
Same change as #6099 for mac/windows: switch to `clean: false` with
manual `git clean -ffd` to preserve Library/ between runs on
self-hosted runners. Eliminates two rsync operations (~4GB each)
per build.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 11:03:09 -08:00
846e618fdd Stop round-tripping Unity Library/ through /tmp on self-hosted runners (#6099)
The previous approach used `clean: true` (which runs `git clean -ffdx`,
deleting gitignored files like Library/), then rsync'd ~4GB from /tmp
to restore it, then rsync'd it back after the build.

Since self-hosted runners have persistent working directories, Library/
naturally survives between runs. Switch to `clean: false` with a manual
`git clean -ffd` (without -x) to remove stale untracked files while
preserving gitignored Library/. Bee/ is still cleaned each build to
avoid stale DAG files.

The restore/persist scripts are kept for the iOS workflows.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:43:45 -08:00
759202b310 Fix deploy job artifact cleanup on self-hosted mac runner (#6098)
The deploy job runs on [self-hosted, macOS, unity-mac] which doesn't
have `gh` CLI installed. Replace `gh api` calls with `curl`/`python3`
which are universally available on macOS.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:30:33 -08:00
68e876bc93 Replace dotnet proto build with Bazel-hermetic C# proto generation (#6097)
* Replace dotnet proto build with Bazel-hermetic C# proto generation

Use rules_proto_grpc_csharp to generate .cs source files via Bazel
instead of requiring a locally-installed dotnet SDK to build protos.dll.
This eliminates the dotnet dependency for CI runners and makes the
proto build fully hermetic.

- Add bazel_dep for rules_proto_grpc_csharp 5.8.0
- Bump grpc 1.74.0 -> 1.74.1 (required by rules_proto_grpc_csharp)
- Add csharp_proto_compile/csharp_grpc_compile targets across 8 proto
  BUILD.bazel files (81 .cs files from 78 protos)
- Rewrite build_protos.sh to use bazel build + copy instead of dotnet
- Output goes to Assets/GeneratedProtos/ with package-based subdirs
  to avoid filename collisions (e.g. ActionResultView.cs in both
  shardok/api/ and eagle/views/)
- Add Assets/GeneratedProtos/ to .gitignore

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove stale Eagle0Protos DLL references

The old build_protos.sh generated protos.dll via dotnet; the new one
generates .cs source files instead. Remove the tracked .meta and
.deps.json files that reference the no-longer-generated DLL, and
gitignore the directory to prevent re-adding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 09:30:26 -08:00
cccbbb9d37 Add persistent Bazel server keepalive for CI runners (#6096)
GitHub Actions kills orphan processes after each job, which kills
the Bazel server and forces a cold reload of the skyframe graph on
every CI run (~15-20s for loading+analysis of 1080 packages / 78k
targets). With a warm server this drops to ~2-3s.

Changes:
- .bazelrc: Set startup --max_idle_secs=0 to prevent idle shutdown
- ci/runners/bazel-keepalive.sh: Pings the Bazel server to keep it alive
- ci/runners/install-bazel-keepalive.sh: One-time setup script that
  installs a macOS launchd agent to run the keepalive every 5 minutes

The launchd agent owns the Bazel server process, so GitHub Actions'
orphan cleanup won't kill it. To set up on a new runner:

  ./ci/runners/install-bazel-keepalive.sh /path/to/actions-runner/_work/eagle0/eagle0

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 07:19:46 -08:00
3e1f303da5 Use cached local crane instead of extracting from bazel runfiles (#6095)
Both docker_build and shardok_arm64_build workflows were running
'bazel build //ci:eagle_server_push' just to extract crane from
its runfiles. In the ARM64 workflow this was especially costly (~29s)
because it triggered 'discarding analysis cache' due to the platform
flag change from linux_arm64 back to the default.

Install crane directly to ~/.local/bin with a version check, cached
across runs. First run downloads it; subsequent runs reuse the cached
binary.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 07:08:22 -08:00
350edc3515 Fix ELF e_machine byte-order check for ARM64 binary (#6094)
od -tx2 reads the 2-byte e_machine field as a 16-bit value in host
byte order (little-endian on macOS ARM64), so the ELF bytes b7 00
are displayed as 00b7, not b700. The check was producing a false
WARNING on every build even though the binary is correct.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 07:07:42 -08:00
f1a87cb1b5 Cache crane binary on production droplet across deploys (#6093)
Instead of downloading crane fresh every deployment, check if the
expected version already exists and skip the download. Saves ~3-5s
per deploy.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 06:58:33 -08:00
8587003f1b Pin eclipse-temurin base image digests for Bazel cache (#6092)
Without an integrity hash, Bazel logs "Fetching without an integrity
hash, result will not be cached" and re-downloads the base image every
build. Pinning the sha256 digest lets Bazel cache the download across
runs, saving ~5-10s.

Includes a comment with the command to update the digest when the base
image needs to be bumped.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 06:51:00 -08:00
e9c63dfb42 Parallelize image pulls on production droplet during deploy (#6091)
Pull all five images (eagle, admin, jfr-sidecar, nginx, certbot)
concurrently, then load the OCI tarballs sequentially. The network
download is the bottleneck, so parallelizing pulls saves ~15-20s.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 06:47:21 -08:00
7ca4038696 Replace duplicate warmup build with artifact transfer (#6090)
The deploy job was rebuilding //ci:warmup_tar (~24s) even though
build-all already built it. Upload the warmup binary as a GitHub
Actions artifact and download it in the deploy job instead.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 06:46:29 -08:00
c7c8dccf3c Parallelize image pushes to container registry (#6089)
Push all three images (eagle, admin, jfr-sidecar) concurrently instead
of sequentially. Each push takes ~15-20s, so this should reduce the
push step from ~58s to ~20s.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 06:45:23 -08:00
63eae8b57d Upgrade Unity from 6000.3.6f1 to 6000.3.8f1 (#6088)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 21:33:39 -08:00
bb18b2f598 Add logging and backstory hint for rubber-band hero spawns (#6087)
* Add logging for rubber-band hero spawns

Logs when a hero is spawned via the rubber-banding mechanic, including:
- Hero ID
- Target province name
- Faction name
- Spawn chance percentage and roll

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add subtle backstory hint for rubber-band spawned heroes

Appends "seeking a worthy cause" to personalityWords for heroes spawned
via the rubber-banding mechanism, providing a subtle narrative hint
without an explicit notification.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-16 21:25:48 -08:00
2115c3969c Fix ITSAppUsesNonExemptEncryption not set when URL scheme already exists (#6086)
The post-processor returned early if the eagle0:// URL scheme was
already in the plist, skipping the ITSAppUsesNonExemptEncryption write.
Move the encryption key set before the URL scheme check so it's always
written.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 20:59:04 -08:00
03bc1d62ed Show error popup when game creation fails (#6085)
The client received the CreateGameResponse with a failure result but
silently ignored it. Now it calls Debug.LogError which triggers the
existing ErrorHandler popup.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 16:31:16 -08:00
adminandGitHub b76ec8a55d Fix hero ID collision when multiple factions spawn rubber band heroes (#6084) 2026-02-16 14:10:12 -08:00
adminandGitHub bf186ce59b Fix set faction heads getting wrong factionId during game creation (#6083) 2026-02-16 14:09:58 -08:00
87644c610a Reduce lock contention in game loading and stream updates (#6082)
Two changes to reduce time spent holding global locks:

1. GamesManager.ensureGameLoadedUnsafe: Use per-game locks (ConcurrentHashMap)
   so heavy I/O (disk reads, text initialization, hero name fetching) runs
   outside GamesManager.this.synchronized. The main lock is only held briefly
   to check/update gameControllerInfos.

2. EagleServiceImpl.streamOneUpdate: Move isEagleGame() call outside
   lockAndDoWithUserId. isEagleGame calls ensureGameLoaded which can do
   heavy disk I/O on cache miss — previously this blocked all other
   user operations while a game was being loaded.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 13:03:59 -08:00
64931a8aae Handle game creation Future failures instead of silently discarding them (#6081)
When createGame() threw an exception, the Future's failure was silently
discarded (`: Unit`), leaving the client waiting forever for a response.
Now the failure is caught with .recover, logged, reported to Sentry, and
an error response is sent back to the client.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:59:49 -08:00
45f1a70ef5 Batch SQLite writes in transactions to eliminate fsync overhead (#6080)
* Batch SQLite writes in transactions to eliminate per-statement fsync overhead

The accumulate fold in handleUnrequestedTexts was taking ~595ms because each
withMarkedRequested/withBypassed call triggered an individual autocommitted
SQLite write (~9ms fsync each × 65 operations). Similarly, the heroNameFetch
timing included 38 name writes at ~9ms each.

Adds beginTransaction/commitTransaction/rollbackTransaction to ClientTextStore
trait (no-op defaults for in-memory implementation) with SQLite overrides that
control autocommit. Wraps the four SQL-heavy folds in UnrequestedTextHandler
with transactions so all writes within each fold share a single fsync.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Suppress HeroNameCache log when serving 0 names

takeNames is called on every game tick for every game, and most calls
have no GeneratedHeroName requests. Only log when names were actually served.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Skip hero name and fixed name blocks when there are no requests

takeNames was being called on every game tick even with 0 names needed,
causing noisy logs. Now both the fixed names and hero names blocks short-
circuit when their respective request lists are empty.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:41:44 -08:00
05e7307e0e Add service-wide hero name cache with async replenishment (#6079)
* Add service-wide hero name cache with async replenishment

Pre-fetches hero names into a service-wide cache to eliminate ~612ms of
synchronous network latency from the game creation hot path. The cache
maintains three gender-specific pools using ConcurrentLinkedQueues,
replenished asynchronously via a single-thread executor. Falls back to
synchronous HeroNameFetcher.names() on cache miss.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Increase hero name cache pool size from 20 to 200

Negligible memory cost, reduces chance of cache misses during bursts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 01:23:42 -08:00
86861a2ddf Fix crash when no backstory version has resolved text in effectiveBackstory (#6075)
When all backstory versions for a hero are unresolved (e.g., bypassed LLM
requests removed from unrequestedTexts), effectiveBackstory's fallback
`getOrElse(versions.last)` picked a version guaranteed to be unresolved,
causing getText to return TextGenerationDependencyUnknown which crashed
sortedLlmRequestsWithPrompts.

Now handles the no-available-version case gracefully: logs a loud warning
with the hero's backstory version states for investigation, and returns
an empty string so dependent LLM requests can proceed with degraded
quality rather than crashing the command pipeline.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 23:54:52 -08:00
82de1a01d1 Add warning to never modify hero names (#6077)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 23:54:34 -08:00
1c90d3eb2d Add timing instrumentation to UnrequestedTextHandler (#6076)
Helps identify where time is spent in synchronizedHandlePostResults
during game creation. Logs breakdown when total exceeds 50ms.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 23:53:16 -08:00
83c2174a65 Fix prisoner quest message word order (#6073)
Change "wants you to return to X this prisoner" to
"wants you to return this prisoner to X"

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 23:26:32 -08:00
bd9554b21f Recruit panel layout adjustments (#6071)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 23:17:54 -08:00
21e41fc3ef Wrap createWithData batch inserts in transaction for performance (#6072)
SqliteClientTextStore.createWithData was doing batch inserts without
an explicit transaction, causing SQLite to auto-commit after each
batch operation. This led to ~3 seconds of disk syncs during game
creation.

Wrap all inserts in a single transaction like withAddedCompleteTexts
does, reducing disk syncs from O(n) to O(1).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 23:15:51 -08:00
f8575577db Fix lobby race condition check timing (#6070)
Move the eagleCanvas.activeSelf check inside the MainQueue callback.
The previous location checked at response arrival time, but the queued
work executes later - by which time the game canvas may have become
active, making the connection canvas inactive and causing
GetComponentInParent to fail.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 23:05:09 -08:00
bd4298d050 Use more distinguishable colors for battle observer mode (#6067)
Changed the dark player colors used when observing battles:
- Defender (idx 0): green instead of black
- Attacker (idx 1): red instead of dark green
- Remaining colors also brightened for better visibility

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 23:02:22 -08:00
846a9a3f65 Add timing logs for game creation to identify bottlenecks (#6069)
Logs added:
- [CREATE] Received CreateGameRequest from client
- [CREATE:gameId] Starting game creation
- [CREATE:gameId] gameCreation.createGame (with duration)
- [CREATE:gameId] SqliteClientTextStore.createWithData (with duration)
- [CREATE:gameId] withPopulatedPregeneratedTexts (with duration)
- [CREATE:gameId] performAiCommands (with duration)
- [CREATE:gameId] synchronizedHandlePostResults (with duration)
- [CREATE:gameId] Game creation complete
- [CREATE] Sending CreateGameResponse after Xms

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 23:02:04 -08:00
d6ecb9d44e Add bulk insert for pregenerated texts to reduce SQL operations (#6068)
withPopulatedPregeneratedTexts was calling withAddedCompleteText for each
hero name and backstory (~400 calls), with each call doing 3+ SQL operations.
This was a major bottleneck during game creation.

Added withAddedCompleteTexts() that:
- Collects all texts first
- Uses a single transaction
- Batch inserts texts and visibility entries

This reduces ~1200+ individual SQL operations to ~3 batch operations.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 22:52:07 -08:00
15f82c098c Lobby layout fixes (#6066)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 22:35:11 -08:00
f55b766beb Add efficient single-ID visibility lookup to avoid loading entire table (#6065)
accessibleTo() was loading the entire visibility table from SQLite on
every LLM streaming token, causing severe slowdown during game creation.

Added accessibleToForId(id) with a focused SQL query that only retrieves
visibility for a single text ID.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 22:30:24 -08:00
1ef4f05222 Skip lobby UI updates when already in Eagle game (#6064)
When creating a new game, a lobby update arrives after the game has
started. At that point, the connection canvas is inactive, causing
GetComponentInParent<EagleCommonTextures>() to fail since it doesn't
traverse inactive GameObjects.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 22:23:12 -08:00
dc46b07954 Force canvas layout update before building hex grid (#6063)
Prevents visible repositioning on first Shardok battle after launch.
The mapArea rect size wasn't settled when SetUp() was called, causing
the grid to rebuild on the next frame when Update() detected the size
change.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 22:13:59 -08:00
ebb6784e42 Populate pregenerated texts in new games more efficiently (#6062)
Instead of including ALL pregenerated texts (~10K) in getCompleteTextsAccessibleTo(),
which would send them all to every client, call withPopulatedPregeneratedTexts()
when creating new games. This copies only the relevant hero names and backstories
for heroes actually in the game into the SQLite database.

This is the same approach used for loaded games, ensuring consistency.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 22:01:07 -08:00
b7b0c85656 Fix missing pregenerated texts (hero names) for new games (#6061)
getCompleteTextsAccessibleTo() only queried the SQLite database, missing
pregenerated texts stored in PregeneratedClientTextStore. For existing
games this worked because withPopulatedPregeneratedTexts() copies them
to SQLite during load. But new games never had that copy step.

Now getCompleteTextsAccessibleTo() includes pregenerated texts, with
database texts taking precedence for any duplicates.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 21:54:20 -08:00
e850cb53fa Enable UpdateNotificationPanel parent so panel can be shown (#6060)
The optional update notification panel was not appearing when clicked
because its parent GameObject was inactive. SetActive(true) on a child
doesn't make it visible if the parent is inactive.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 21:38:18 -08:00
3928bab616 Restructure Sadar Rakon as potential warlord, add Vellus Kade (#6059)
- Sadar Rakon is no longer a sworn brother of the King; he is now a
  potential warlord (great person) with faction name "The Reclamation"
- Updated Sadar's backstory: rebelled against Tarn's tyranny before
  the Eagle arrived, now fights independently but open to reconciling
  with the King
- Added Vellus Kade as the King's new second-in-command (sworn brother)
- Vellus is a former mercenary captain who proved his loyalty to the Crown
- Vellus commands province 40 with battalions: Frontier Outriders,
  Kade's Company, Crown Vanguard, Ironside Regiment, Borderland Levy,
  Provincial Militia

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 21:20:16 -08:00
0313e0ce54 Add profession image to RunningGameItem (#6058)
- Add professionImage RawImage field
- Remove unused goButton field (whole row is now a button)
- Populate profession image using EagleCommonTextures
- Handle UnknownProfession by returning null (hides image)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 20:38:50 -08:00
32d6830a4b Add hero rubber-banding for struggling factions (#6056)
* Add hero rubber-banding for struggling factions

When a faction has few heroes relative to their territory, they now have
a chance (up to 25%) to spawn a low-power, easy-to-recruit hero each
round. This prevents players from getting stuck due to hero scarcity.

Key features:
- Spawn chance scales from 25% (struggling) to 0% (healthy)
- Spawns only in provinces containing faction leaders
- Low-power heroes have stats 1-50, ambition 1-30, no profession
- These heroes are much easier to recruit due to low power/ambition

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Address PR review comments

- Add FactionUtils.provincesWithLeaders() and use it in PerformUnaffiliatedHeroesAction
- Replace isInstanceOf with pattern matching in FactionStruggleUtils
- Keep constitution range at 60-100 (same as normal heroes) so they can march
- Heavily discount (0.25x) heroes in neighboring provinces since player may not
  know about them and needs to expand to access them
- Add tests for owned vs neighboring province weighting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 20:18:40 -08:00
ceb56060be Cache lobby leader metadata in games.e0es to avoid full game loads (#6057)
When populating the lobby, gamesFor() previously called ensureGameLoaded()
for every game just to extract leader name/profession/image. This loaded
full game history from disk, blocking the lobby response.

Now save() persists a factionLeaderCache in RunningGame, and gamesFor()
reads leader info directly from the cache. Falls back to full load for
old-format files or unresolved names.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 20:17:07 -08:00
877d330e24 Show completed provinces in ReconSpecificProvincesQuest description (#6055)
Display provinces that are already known (owned, allied, or reconned)
with strikethrough and grey color in the quest description. The counter
also reflects actual known province count.

Example: "Recon ~~Onmaa~~, Chapellia (1/2)"

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 19:52:16 -08:00
0050365e85 Exclude allied provinces from ReconSpecificProvincesQuest (#6054)
Allied provinces are already visible to the player, so it doesn't make
sense to ask them to recon those. This change:

1. Quest creation: filter out provinces owned by allied factions
2. Fulfillment: count allied provinces as already known

Adds FactionUtils.selfAndAlliedFactionIds for efficient lookup.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 19:42:52 -08:00
c36ff351bb Fix ReconSpecificProvincesQuest: check membership instead of counter (#6053)
The quest was broken because reconning the same target province multiple
times incorrectly incremented the completion counter. This fix changes
the approach:

1. Quest creation: filter out provinces already reconned or owned
2. Fulfillment: check if all targets are in reconnedProvinces or owned
3. Remove counter-increment from PerformReconResolutionAction

This ensures the quest can only be fulfilled by actually reconning each
unique target province (or owning it).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 19:27:17 -08:00
6b52f781a4 Fix recon quest: clarify it means scouting, require Ranger (#6052)
- Changed LLM prompts to use "scout" instead of "recon" to avoid
  confusion with "reconquest" - the quest is for reconnaissance/scouting
- ReconProvincesQuest and ReconSpecificProvincesQuest now require the
  divining faction to have at least one Ranger hero

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 18:34:37 -08:00
cce889d253 RestProvinceQuest targets current province only (#6051)
* Add tests for RestProvinceQuest increment behavior

Adds two test cases verifying that RestProvinceQuest progress increments
correctly when resting:
- When the free hero is in the same province being rested
- When the free hero is in a different province but has a quest targeting
  the resting province (cross-province scenario)

Both tests pass, confirming the existing logic is correct: the quest
increments based on the quest's targetProvinceId matching the province
where rest happens, regardless of which province the hero is currently in.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* RestProvinceQuest now targets current province only

Changed quest creation to only generate RestProvinceQuest targeting
the province where the free hero is located, rather than creating
quests for all faction provinces. This makes the quest more intuitive -
the hero wants the faction to rest in their province specifically.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 18:23:25 -08:00
7acb90b037 Use polling for OAuth in Unity editor (#6049)
In editor mode, deep links don't work since the editor isn't registered
for the eagle0:// URL scheme. Instead of quitting and waiting for a
deep link relaunch, poll for OAuth completion using the existing
PollForOAuthCompletionAsync infrastructure.

Standalone builds still use the quit-and-relaunch approach.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 17:11:58 -08:00
c71a73dc12 Remove global --stamp to fix remote cache sharing across CI workflows (#6050)
The global `common --stamp` in .bazelrc caused every action's cache key
to include workspace status data, preventing cache reuse between the
bazel_test, eagle_build, and shardok_build workflows. Only three Go
binary targets actually use stamped x_defs values (admin_server,
authservice, installer), so --stamp is now passed only in the CI
workflow commands that build those targets.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 17:11:32 -08:00
c6b51c4729 Set up tutorial battle to start immediately (#6048)
- Change starting phase from HostileArmySetup to BattleRequest
- Set up hostileArmies with status Attacking (attack decision pre-made)
- Set up defendingArmy (defense decision pre-made)
- User now sees battle immediately upon starting tutorial

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 15:50:40 -08:00
9da7805e16 Wire TutorialBattleConfig from gRPC request to ShardokEngine (#6047)
Thread tutorial_battle_config from NewGameRequest through
EagleInterfaceGrpcServer -> ShardokGamesManager -> SetUpController,
calling engine->SetTutorialBattleConfig() so the TutorialBattleController
is enabled and scripted tutorial events fire during battle.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 15:50:16 -08:00
c91bf118b1 Handle UnknownProfession in lobby display (#6046)
Add UnknownProfession to ProfessionNames and ShortProfessionNames
dictionaries to prevent KeyNotFoundException when displaying games
where leader info isn't fully available (e.g., tutorial games).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 15:40:24 -08:00
b9c88d28c4 Add King's faction to tutorial parameters (#6045)
Add Bregos Fyar faction with provinces 37 and 40 from game_parameters.json
to tutorial setup. Uses Spartacus the Younger as sworn brother ruling
province 40.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-15 15:37:59 -08:00
6d1253d6ff Add tutorial battle support to CustomBattleRequest (#6021)
* Add tutorial battle support to CustomBattleRequest

When is_tutorial_battle is set, Eagle constructs a hardcoded tutorial
battle (Sadar Rakon defending against Ikhaan Tarn) with scripted events
via TutorialBattleConfig, ignoring the rest of the request.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Move tutorial battle from CustomBattleRequest to CreateGameRequest

Tutorial battles are now triggered via CreateGameRequest.is_tutorial
instead of CustomBattleRequest.is_tutorial_battle, providing plumbing
for a full tutorial Eagle game. CreateGameResponse returns the
shardok_game_id for tutorial games.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Remove shardok_game_id from CreateGameResponse

The client discovers the battle through the normal Eagle game stream,
so CreateGameResponse doesn't need to return it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Route tutorial through real Eagle game instead of standalone CustomBattle

Instead of creating a standalone Shardok battle via CustomBattleManager,
the tutorial now creates a real Eagle game where the player holds Onmaa
(province 14) and Ikhaan Tarn's army attacks from Nikemi (province 31).
The battle flows through Eagle's normal phase system with
TutorialBattleConfig threaded through to Shardok.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Load tutorial heroes from data files instead of hardcoding

Replace inline hero stats, battalions, and faction config in
TutorialGameCreation with a data-driven approach: heroes loaded from
heroes.tsv and tutorial setup defined in a new tutorial_parameters.json
parsed via ScalaPB's JsonFormat. This follows the same pattern used by
regular game creation in NewGameCreation.

- Add Colin, Agamemnon, Tall Edgtheow, Waylaid Julius to heroes.tsv
- Create tutorial_parameters.json with faction/province/battalion config
- Rewrite TutorialGameCreation to use StartGameActionResultUtils helpers
- Expose tutorialGameParameters lazy val in GameParametersUtils
- Remove 3 duplicate heroes from generated_heroes.tsv (name collisions)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Add headshot entries for tutorial heroes

The fixed_headshots_present_test requires every hero in heroes.tsv to
have a corresponding entry in headshots.tsv. Add the 4 new tutorial
heroes (agamemnon, colin, tall_edgtheow, waylaid_julius) to the fixed
section.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 07:54:17 -08:00
5cb80ce185 Add tooltips to all province event icons (#6043)
Beasts already had a dynamic tooltip ("3 wolves"); now blizzard, flood,
drought, epidemic, and festival icons also show a brief label on
hover/long-press. Extracted a SetEventTooltip helper to reduce
duplication.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 07:08:08 -08:00
adminandGitHub c9c74f5e0d Randomize starting color index for festival fireworks (#6042) 2026-02-14 23:22:12 -08:00
b2ade6b348 Fix overlays not clearing when switching from board unit to reserve unit (#6041)
* Fix overlays not clearing when switching from board unit to reserve unit

When selecting a reserve unit while a board unit was already selected,
the old overlays showing valid moves for the board unit remained visible
alongside the new placement options.

SelectedReserveUnitChangedTo() now:
- Clears _selectedGridIndex (board unit selection)
- Clears overlays before drawing new placement options
- Also clears overlays when deselecting a reserve unit (unit == null)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add swap behavior when clicking reserve unit with board unit selected

In Shardok placement phase, clicking a reserve unit while a board unit is
selected now swaps them: the board unit moves to reserves and the reserve
unit is placed at the board position.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 23:15:23 -08:00
9bc51c918b Fix flashing borders not clearing after unit placement in Shardok (#6040)
PerformPlacementAction() was missing the overlay cleanup that
PerformAction() has. After placing or moving a unit during the
placement phase, the flashing borders showing available positions
would remain visible.

Added hexGrid.ClearOverlays() and HandleEnemyStartingPositionOverlays()
calls to match the cleanup pattern in PerformAction().

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 23:04:47 -08:00
3529d64c4a Add long-press support for touch input (iPad right-click equivalent) (#6039)
TableRowClickDetector:
- Add long-press detection (0.5s) for touch input
- Long-press triggers onRightClick event (same as right-click)
- Cancel if finger moves more than 10 pixels
- Prevents left-click from firing if long-press triggered
- Enables right-click actions on hero/battalion table rows on iPad

MouseHandler:
- Add long-press detection for tactical hex grid touch input
- Long-press triggers HandleSecondaryClick (same as right-click)
- Enables secondary click actions on tactical battle map on iPad

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 22:52:06 -08:00
87d1d2f1ed Increase weather effect visibility (#6038)
* Increase weather effect visibility

- Increase epidemic haze alpha from 0.25 to 0.51 for better visibility
- Increase drought texture scale from 2x to 20x
- Increase rain texture scale from 4x to 8x

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix epidemic skulls invisible on white provinces

The particle material was using Additive blending shader, which adds colors
to the background. On white backgrounds, adding any color results in white,
making particles invisible.

Changed shader from "Legacy Shaders/Particles/Additive" to
"Legacy Shaders/Particles/Alpha Blended" which properly replaces background
pixels based on alpha.

Also increased haze emit rate and size for better visibility.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 22:43:54 -08:00
ae613954ca Add per-improvement-type lock toggles (#6037)
* Add per-improvement-type lock toggles for ImproveCommandSelector

Replace single lock toggle with four separate lock toggles (one per improvement
type: economy, agriculture, infrastructure, devastation). Only the currently
selected improvement type's lock toggle is enabled; others are grayed out.
When posting a command, only the selected type's lock state is used.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Wire up lock toggles and hide non-selected locks

- Wire up economyLockToggle, agricultureLockToggle, infrastructureLockToggle,
  devastationLockToggle references in Unity
- Hide (SetActive false) lock toggles for non-selected improvement types
- Disable lock toggle if improvement type isn't available

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Simplify lock toggle visibility: only show for selected type

Lock toggle is shown only for the currently selected improvement type,
hidden for all others. Availability doesn't affect lock visibility.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix lock toggle wiring and visibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 22:28:38 -08:00
6ddf930e5c Fix epidemic effect visibility on white provinces (#6036)
Two fixes:
1. Update prefab hazeColor to use 25% alpha (was 8% due to serialized override)
2. Fix ParticleAlphaBlendMaterial to use proper alpha blending instead of
   additive - adds _ALPHABLEND_ON keyword and explicit blend modes
   (_SrcBlend: 5, _DstBlend: 10)

The additive blending was causing skulls to be invisible on white
backgrounds since adding to white can't make it any darker.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 22:04:18 -08:00
0a0777024e Fix weather effects not moving with map zoom/pan (#6035)
* Fix weather effects not moving with map zoom/pan

- Fix mapContainer references: all weather controllers now use the
  correct Content transform instead of Viewport
- BlizzardEffect now updates map bounds every frame in LateUpdate
  instead of using stale bounds from spawn time
- Removes one-time SetMapBounds in favor of SetMapContainer which
  allows continuous bound recalculation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add zoom compensation for blizzard effect

Maintain consistent screen-space appearance when zooming:
- Counter-scale particle system by 1/zoom for consistent snowflake size
- Expand emission area by zoom to still cover province
- Scale emission rate by zoom² to maintain particle density

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add zoom compensation for flood effect

Apply same fixes as blizzard:
- Update map bounds every frame for correct province masking
- Counter-scale particle systems to maintain screen-space size
- Scale emission rate by zoom² to maintain density

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 20:39:54 -08:00
9ea49e0ac3 Fix epidemic effect visibility on iOS (#6034)
- Bake skull texture directly into material (runtime texture assignment
  doesn't work on iOS Metal renderer)
- Remove unused skullTexture field and RefreshSkullTexture method
- Remove fallback shader code - fail loudly if materials aren't assigned
- Increase haze opacity from 8% to 25% for better visibility

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 19:58:25 -08:00
df8bc8ef36 Fix StartEpidemic quest fulfillment not triggering (#6032)
StartEpidemicCommand was a ProtolessSimpleAction that never checked for
quest fulfillment. Changed it to ProtolessSequentialResultsAction and
added quest matching logic (following the ControlWeatherCommand pattern)
so that StartEpidemicQuest is properly fulfilled when the epidemic
targets the matching province.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 19:48:16 -08:00
daa5f8c644 Send lobby response immediately, load games in background (#6033)
When a user enters the lobby, we were blocking until all their games
were loaded from disk. With the recent change to load more history
chunks, this could take several seconds for users with multiple games.

Fix: Send lobby response immediately with placeholder info for unloaded
games ("[Loading...]" for leader names), then load games in background.
When loading completes, send an updated lobby response with full info.

Changes:
- Add gamesForWithoutBlocking() that returns immediately with placeholder
  info for unloaded games
- Add loadGamesInBackground() that loads games async and calls callback
- Split lockedSendLobbyUpdate into internal method that takes game list
- On EnterLobbyRequest: send immediate response, start background load,
  send update when done (if user still in lobby)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 19:47:38 -08:00
c97c810de4 Load enough history chunks on game load to avoid disk reads during reconnection (#6031)
When loading a game from disk, we were only loading the last partial
game file (up to 25 results). But reconnection requires the last 100
results (maxInitialResults), causing stateAfter() to load from disk
during reconnection and adding ~450ms latency.

Fix: when loading a game, if recentHistory.length < 100, load additional
chunk files from disk to have enough for reconnection. This loads ~4
chunks instead of 1, adding ~150-300ms to game load but saving ~450ms
on each reconnection.

Also:
- Move minResultsToKeep constant to companion object
- Update log message to show how many chunks were loaded

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 19:35:06 -08:00
59d059656f Fix duplicate stateAfter disk load during game reconnection (#6030)
In streamUpdates() when reconnecting to a large game (>100 results),
stateAfter(history.count - maxInitialResults) was being called twice:
1. Inside filteredResultsFrom() for ActionResultFilter
2. Again for GameStateViewFilter to create the stateView

This caused the same partial game to be loaded from disk twice,
adding ~1 second to reconnection time.

Fix: compute starting state once and pass to both consumers via
new filteredResultsFromWithState() method.

Also fixes a minor issue where history.since() was called twice
in filteredResultsFrom - now called once and reused.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:37:05 -08:00
c4dce575a4 Implement platform-specific update behavior (#6029)
* Implement platform-specific update behavior

- Mac: Trigger Sparkle to check for updates immediately (shows native UI)
- Windows: Relaunch the application before quitting
- iOS: Open TestFlight URL to update
- Also added iOS platform detection for update notifications

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix TestFlight URL to correct app ID

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:29:04 -08:00
24b66b8872 Cache filtered game state views between consecutive action results (#6028)
When filtering action results for a player, we were calling
filteredGameState twice per result (before + after states). But the
"after" state of result N is the "before" state of result N+1.

Now we cache the filtered view and reuse it, cutting the number of
expensive filteredGameState calls from 2N to N+1.

For 100 results, this reduces filter time from ~1600ms to ~800ms.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:13:39 -08:00
22e6ff9b50 Add client update notification system (#6027)
* Add client update notification system

Notify connected clients when a new version is available after CI deploy.

Flow:
1. CI deploys new build and waits 60s for CDN cache
2. CI calls admin server HTTP endpoint with shared secret
3. Admin server calls Eagle via gRPC
4. Eagle broadcasts to all connected lobby users
5. Unity client shows notification with "Restart Now" button

Components:
- Proto: ClientUpdateAvailable message, NotifyClientUpdate RPC
- Eagle: notifyClientUpdate() broadcasts to lobby users
- Admin server: /notify-update HTTP endpoint with secret auth
- Unity: UpdateNotificationManager, Panel, and RequiredModal
- CI: Notify steps in mac_build.yml and unity_build.yml

Note: NOTIFY_SECRET env var must be set on admin server deployment.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add UpdateIndicator for top bar update notification

Instead of a popup panel for optional updates, show a pulsing indicator
in the top bar. Clicking it opens the details panel.

- UpdateIndicator: pulsing green circle button for top bar
- UpdateNotificationManager: stores pending update info, shows indicator
- Panel only opens when user clicks indicator

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Wire up update notification UI in Unity scene

- UpdateIndicator in top bar (pulsing green circle)
- UpdateNotificationPanel for optional update details
- UpdateRequiredModal for blocking required updates
- UpdateNotificationManager with all references connected

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix update notification panel showing and simplify fallbacks

- Remove fallback chain when panel/modal references are null
- Just warn and return instead of cascading to other UI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Broadcast update notifications to all connected users

Previously notifications only went to lobby users. Now they also go to
users who are in a game. This ensures players don't miss critical updates
while playing.

- Add broadcastToAllGameUsers() to GamesManager
- Update notifyClientUpdate() to send to both lobby and in-game users
- Log shows breakdown of lobby vs in-game notifications

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove Debug.Log messages from update notification UI

Keep only Debug.LogWarning for actual error conditions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 18:10:21 -08:00
8ef90d9542 Add cached Map for O(1) streaming text status lookups (#6026)
During game join, for each complete text being sent, we were doing
linear searches through streamingTextStatuses (O(n)). With thousands
of texts and statuses, this caused O(n²) behavior.

Added a lazy cached Map keyed by llmIdentifier for O(1) lookups.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 17:55:47 -08:00
08c8a52654 Optimize game join by using single SQL query for accessible texts (#6025)
When joining a game, the code was calling `accessibleTo` (which loads
the entire visibility table) twice per complete text. For games with
thousands of texts, this caused O(n²) database reads.

Added `getCompleteTextsAccessibleTo(factionId)` method that performs
a single SQL query with EXISTS subqueries to fetch only the texts
accessible to the joining player.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 17:25:05 -08:00
226a582838 Remove old text-based client text store migration code (#6024)
* Optimize text store accessors to avoid loading all entries

1. Replace completeTexts.get(id) with getText(id) for individual lookups
   - Avoids loading all complete texts into memory just to look up one

2. Add hasIncompleteTextsAccessibleTo(factionId) method
   - Default implementation uses existing maps (for ClientTextStoreImpl)
   - SQLite implementation uses optimized EXISTS query
   - Avoids loading all incomplete texts to check if any exist

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove old text-based client text store migration code

All existing games have been migrated to SQLite, so the migration
code from the old txt file format is no longer needed.

Removes ~136 lines of migration code and two unused dependencies.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 17:05:06 -08:00
e890cc8a54 Optimize text store accessors to avoid loading all entries (#6023)
1. Replace completeTexts.get(id) with getText(id) for individual lookups
   - Avoids loading all complete texts into memory just to look up one

2. Add hasIncompleteTextsAccessibleTo(factionId) method
   - Default implementation uses existing maps (for ClientTextStoreImpl)
   - SQLite implementation uses optimized EXISTS query
   - Avoids loading all incomplete texts to check if any exist

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 16:43:36 -08:00
16a4b8f87e Replace file-based ClientTextStore with SQLite implementation (#5553)
* Replace file-based ClientTextStore with SQLite implementation

SQLite provides indexed lookups instead of loading/parsing a 5MB+ text file.
This should significantly reduce the ~142ms spent on loadTextStore during
game loading.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add S3 sync for SQLite client text store

- Store dbPath and optional persister reference in SqliteClientTextStore
- Sync database file to cloud storage in saved() method
- Download from cloud storage on load if local file doesn't exist
- Pass persister to createWithData for new game creation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Sync SQLite to S3 immediately after migration

Prevents data loss if server restarts before saved() is called after
migrating from old text-based format to SQLite.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 16:01:52 -08:00
90ebd1a5be Add timing logs to gameController to diagnose remaining load time (#6022)
Breaking down the ~280ms gap between PersistedHistory and "Finished loading":
- loadTextStore: Loading ClientTextStore from disk (5MB+ completeText.txt)
- pregenAndRecovery: Populating pregenerated texts and recovering missing names
- buildController: Creating HeroGenerator, EngineImpl, and GameController

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 15:15:10 -08:00
7ee5b35696 Only load outstanding shardok battles on game load (#6020)
* Only load outstanding shardok battles on game load

Previously, we loaded ALL .e0s shardok battle files when loading a game,
even though most battles are completed. For a game with 71 battles, this
was loading ~70 unnecessary files.

Now we:
1. Load the e0a file first and reconstruct the GameState
2. Extract the outstanding battle IDs from outstandingBattles
3. Only load .e0s files for battles that are still in progress
4. Use parallel loading (via Futures) if > 4 files need loading

This should significantly reduce game load time for games with many
completed battles.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add timing for file listing and directory parsing

Logs the time to list all files in the game directory and parse the
directory index, to identify the remaining ~400ms gap in game loading.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add lazy loading for shardok battles not loaded on startup

When a client requests data for a shardok battle that wasn't loaded
during game initialization (because it wasn't in outstandingBattles),
we now try to load it from disk on-demand.

This handles the edge case where:
1. Client is watching a battle that's about to end
2. Battle ends but client hasn't received all updates
3. Server restarts and doesn't load the completed battle
4. Client requests the missing battle data

The lazy-loaded battles are cached (including negative results) to
avoid repeated disk access for the same battle ID.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 14:55:45 -08:00
444828221a Add timing logs for game file loading diagnostics (#6019)
Logs how many .e0a and .e0s files exist and how long each takes to load.
This helps diagnose why some games take 1-1.5s to load on first connection.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 12:59:51 -08:00
b4f87366e7 Reduce LLM log noise and fix usage tracking (#6018)
- Remove verbose per-request logs: "LLM request submitted", "[LLM-Caller]
  Request completed", "[LLM] Submitting", etc.
- Keep error logs prominent with [LLM ERROR] prefix to stderr
- Fix usage tracking bug: always record success when stream completes,
  using 0 for missing token usage (fixes success rate calculation)
- Enhance failure logs with request type, provider, and exception details

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 11:42:46 -08:00
8b729d61fb Persistent UI and Lobby UX improvements (#6017)
* Move connection status UI to persistent canvas

Centralize connection status management on a persistent canvas that
remains visible across Eagle and Shardok game modes.

Changes:
- Add PersistentUIManager singleton to wire persistent UI elements
- Add PersistentClientConnection.Current static accessor
- Move ConnectionStatusUI to persistent canvas with inspector reference
- Remove connection status wiring from EagleGameController and
  ShardokGameController
- ConnectionStatusUI.statusText is now an inspector field instead of
  using GetComponent

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* WIP: Lobby UX cleanup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix lobby prefab instantiation transforms

Change worldPositionStays from true to false when instantiating lobby
list items. This allows instantiated prefabs to adopt proper local
transforms relative to their parent, fixing rotation and Z-value issues.

Also removes unnecessary localScale resets since the layout system
handles positioning correctly with worldPositionStays=false.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Move Create Game to dedicated lobby section

Replace instantiated CreateGame prefab with a direct scene reference.
The Create Game UI now lives in its own section of the lobby panel.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Simplify Create Game UI and add dropdown zebra striping

- Hard-code total player count to 7, remove totalPlayersDropdown
- Change human player dropdown labels to "1 player", "2 players", etc.
- Change random warlord fallback text from "Random" to "Random Warlord"
- Add AlternatingRowColor component for dropdown zebra striping

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Gitignore map generator output folder

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update Assembly-CSharp.csproj with new script references

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 11:37:11 -08:00
2c4b5d22e7 Add success/failure rate tracking to LLM usage stats (#6016)
Tracks success and failure counts for LLM requests by provider and
request type. Failures are recorded when server errors or timeouts
occur in LlmResolver. The admin console now displays success count,
failure count, and success rate percentage in both the summary cards
and the detailed tables.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 11:00:51 -08:00
046a6aa3c9 Add LLM Stats page to admin console (#6015)
Add a new page at /llm-stats in the admin console that displays:
- Summary cards showing total calls and token usage
- Stats by provider (OpenAI, Claude, Gemini)
- Stats by request type with averages

The page calls the existing GetLlmUsageStats gRPC endpoint added in #6012.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-14 07:09:10 -08:00
c672ee5522 Add S3 persistence for LLM usage stats (#6014)
Persist LLM usage stats (by provider and request type) to DigitalOcean
Spaces on graceful shutdown and restore on startup. This follows PR #6012
which added the in-memory tracking.

Changes:
- Add JSON serialization to LlmUsageTracker with PersistedCounters and
  PersistedLlmUsageStats case classes
- Add saveToS3/loadFromS3 methods with callback injection to avoid
  circular dependencies between common and service packages
- Add registerShutdownHook to persist stats on graceful shutdown
- Create LlmUsageTrackerS3 to wire up S3 callbacks using existing S3Utils
- Initialize persistence in Main.scala after S3 warmup

Stats are stored at eagle/llm_usage_stats.json in Spaces. Stats will be
lost on crashes but preserved across graceful deployments.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-13 22:16:44 -08:00
c0d5ad539d Fix KeyNotFoundException crash when starting new game in same session (#6013)
StopAll() cleared the Model but not CurrentProvince on panel controllers.
When starting a new game, the stale province from the previous game still
held hero IDs that don't exist in the new game's Heroes map, causing a
KeyNotFoundException in SetUpHeroesTable().

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 21:58:04 -08:00
e1444d72e9 Add LLM usage tracking by provider and request type (#6012)
Track total calls and tokens (input/output) for each LLM provider
(Anthropic, OpenAI, Gemini) and request type. Usage data is extracted
from streaming responses and recorded in a thread-safe tracker.

Key changes:
- Add LlmUsageTracker with TokenUsage, LlmUsageRecord, LlmUsageStats
- Extract token usage from Claude, OpenAI, and Gemini streaming responses
- Add tokenUsage field to StreamingTextResults
- Record usage in LlmResolver when streams complete
- Add GetLlmUsageStats RPC to GameAdmin service
- Track stats both by provider and by request type for optimization analysis

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-13 21:56:52 -08:00
4603e449c0 Make previous backstory text a soft dependency in backstory updates (#6011)
When backstory updates are filtered out for AI-only factions, subsequent
backstory updates for that hero would fail because they had a hard
dependency on the previous backstory text ID.

Now the previous backstory text is optional:
- If available, use it for word count calculation
- If unavailable, estimate word count based on number of backstory
  versions (assuming each grew by the growth rate)
- Only include available previous backstories in the prompt
- Events are always included regardless of previous backstory availability

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-13 16:23:46 -08:00
6b022f9c9f Sync S3 saves to local storage at startup if local is empty (#6010)
On fresh deployments where the local save directory has no game files,
sync all saves from S3 during server startup. This avoids slow S3 loads
during the first user request.

The sync is skipped entirely when local game directories already exist
(the common case for normal deployments), so there's no overhead in
typical operation.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-13 16:05:18 -08:00
06aea9ebfd Optimize CompoundPersister.existingKeys to try local first (#6009)
Previously, existingKeys called ALL persisters and combined results,
which meant S3 ListObjectsV2 was called even when local files existed.
This caused slow first-connect latency after deployments.

Now existingKeys tries persisters in order and returns the first
non-empty result, matching the behavior of retrieveAsStream.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-13 13:42:11 -08:00
645a4fcb89 Use player-agnostic filtering for hero backstory LLM updates (#6008)
* Filter hero backstory LLM updates to human-visible heroes only

Only generate backstory updates for:
- Heroes in human player factions
- Heroes in factions allied with human players
- Unaffiliated heroes in provinces controlled by human factions

Key changes:
- Add humanFactionIds field to GameState (tracks which factions are human-controlled)
- Create HeroDescriptions object with historyDescription() for event-based backstory
- Filter heroes in HeroBackstoryUpdateActionGenerator based on human visibility
- Update HeroDescriptionGenerator.descriptionWithoutFaction to use history descriptions
- Simplify HeroBackstoryUpdatePromptGenerator to use shared HeroDescriptions.textForEvent

This significantly reduces LLM requests for games with AI-only factions while
maintaining full backstory generation for human-visible heroes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Use player-agnostic filtering for hero backstory LLM updates

Replace the previous humanFactionIds-based approach with a cleaner
player-agnostic design that leverages GameController's existing
recipientFactionIds filtering mechanism.

Key changes:
- Set alwaysGenerate=false on HeroBackstoryUpdateAction to enable filtering
- Remove humanFactionIds from GameState, Engine, and related converters
- Simplify HeroBackstoryUpdateActionGenerator to include all heroes
- Add sourceEvents field to BackstoryVersion to preserve events from
  bypassed LLM updates
- Add effectiveBackstory() to HeroDescriptions that returns the most
  recent available backstory plus collected events from bypassed versions
- Update ChronicleEventTextGenerator, HeroDescriptionGenerator, and
  ChronicleUpdatePromptGenerator to use effectiveBackstory()

This approach is cleaner because the engine remains player-agnostic -
it doesn't know which factions are human-controlled. Instead, it relies
on the existing GameController filtering that skips LLM requests where
all recipients are AI factions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-13 13:07:50 -08:00
ba6d405a04 Update ocean texture and remove unused weather textures (#6007)
* Update ocean texture with Stylize Water Texture asset

- Replace water_caustics with stylized water texture for ocean background
- Adjust ocean tint color for better ocean appearance
- Increase scroll speed for more visible water motion

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove unused SnowTex and EpidemicTex from weather shader

Blizzard and Epidemic effects now use 3D particle effects only, so the
2D overlay shader no longer needs snow or epidemic textures. Only Flood
and Drought use the 2D weather overlay.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-13 07:19:22 -08:00
ab9ce45544 Simplify battalion backstory to single initial text ID (#6006)
Since we no longer generate new backstory versions (only the initial
backstory plus structured event history), simplify the data model:

- Add `initial_backstory_text_id` field to battalion.proto (field 9)
- Deprecate the `backstory_versions` repeated field (field 7)
- Change Scala model from `backstoryVersions: Vector[BackstoryVersion]`
  to `initialBackstoryTextId: Option[ClientTextId]`
- Update BattalionConverter to:
  - Read from new field if non-empty, else fall back to last version
    from deprecated field (for migration)
  - Write only to new field
- Update BattalionDescriptions to use `initialBackstoryTextId` directly

This simplification reduces complexity since battalions now have exactly
one backstory text that never changes.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-13 07:18:53 -08:00
1522699745 Eliminate battalion backstory LLM updates (Stages 4-5) (#6005)
Stage 4: Replace backstory usage in BattalionDescriptions with initial
backstory + event history by adding historyDescription() method that
combines the first backstory version with structured event data.

Stage 5: Delete BattalionBackstoryUpdateActionGenerator entirely and
remove all calls to it from EndBattleAftermathPhaseAction,
EndVassalCommandsPhaseAction, EndPlayerCommandsPhaseAction, and
EngineImpl.

This eliminates a significant source of LLM requests since battalion
backstories were being regenerated after every battle and phase change.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-13 06:57:37 -08:00
063ec74193 Add ParticleStandardUnlitMaterial for BeastsEffect builds (#6004)
* Add ParticleStandardUnlitMaterial for BeastsEffect builds

Create material using Particles/Standard Unlit shader and assign to
BeastsEffect.birdMaterial. This ensures the shader is included in
standalone builds where Shader.Find() would otherwise fail.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Reduce EpidemicEffect green haze intensity

- Make haze color more desaturated (less pure green)
- Lower alpha from 0.12 to 0.08
- Reduce hazeEmitRate and hazeSize

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-13 06:32:12 -08:00
c7db77e241 Stage 3: Remove backstory_text_id from BattalionView proto (#6003)
Remove the backstory_text_id field from the BattalionView proto message
and corresponding Scala model. This is part of eliminating battalion
backstory updates to reduce LLM request volume.

Changes:
- Mark field 7 as reserved in battalion_view.proto
- Remove backstoryTextId from BattalionView case class
- Update BattalionViewFilter to not set the field
- Update BattalionViewConverter for proto/scala conversion
- Update related tests

See docs/eliminate-battalion-backstory-updates.md for the full plan.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-12 22:30:57 -08:00
46bc012a7b Remove 2D blizzard effect from ProvinceWeatherController (#5998)
Blizzard now uses only the 3D particle effect (ProvinceBlizzardController).
The 2D shader-based overlay is removed to avoid duplicate effects.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-12 22:08:26 -08:00
3ddf092f8e Stage 2: Stop populating backstory in BattalionView (#6002)
Set backstoryTextId to empty string instead of the actual value when
constructing BattalionView. This stops sending backstory text IDs to
the client.

Part of eliminating battalion backstory LLM updates.
See docs/eliminate-battalion-backstory-updates.md

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-12 21:59:02 -08:00
69433cc7f4 Fix BeastsEffect visibility and reduce EpidemicEffect green haze (#6000)
BeastsEffect:
- Change birdColor from dark brown to white so vulture texture renders
  at full brightness instead of being nearly invisible

EpidemicEffect:
- Reduce hazeColor saturation and alpha (0.3 -> 0.12) for subtler effect
- Reduce hazeEmitRate from 25 to 8 particles/sec

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-12 21:58:33 -08:00
94b5d95dd3 Stage 1: Stop displaying battalion backstories in client (#6001)
Disable the battalion backstory popup that appears on hover in the
Heroes & Battalions panel. This is the first stage of eliminating
battalion backstory LLM updates to reduce request volume.

The popup panel is now always hidden when hovering over battalions.
Hero backstories are unaffected.

See docs/eliminate-battalion-backstory-updates.md for the full plan.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-12 21:40:20 -08:00
7db53c56ac Fix 3D province effects in standalone builds (#5999)
* Add material fields to effect scripts for standalone build support

Shader.Find() fails in standalone builds because shaders aren't included
unless directly referenced. Add public Material fields to effect scripts
so materials can be assigned in prefabs, ensuring shaders are included.

Effects updated:
- BeastsEffect: birdMaterial
- EpidemicEffect: skullMaterial, hazeMaterial
- BlizzardEffect: particleMaterial
- FloodEffect: particleMaterial

Materials need to be created in Unity and assigned to prefabs:
1. Create material with "Particles/Standard Unlit" shader for Beasts/Epidemic
2. Create material with "Eagle/ProvinceParticle" shader for Blizzard/Flood

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add ProvinceParticleMaterial and include shader in build

- Create ProvinceParticleMaterial.mat referencing ProvinceParticleShader
- Add ProvinceParticleShader to GraphicsSettings always-included shaders
- Ensures shader is available in standalone Mac and iPad builds

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Wire up ProvinceParticleMaterial in effect prefabs

Assign the material reference in BlizzardEffect and FloodEffect prefabs
to ensure the ProvinceParticleShader is included in standalone builds.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add ParticleAlphaBlendMaterial for BeastsEffect and EpidemicEffect

- Create ParticleAlphaBlendMaterial using Legacy Particles/Alpha Blended shader
- Add shader (fileID 200) to GraphicsSettings always-included list
- This fixes vultures showing as black boxes and epidemic lacking transparency

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Wire up ParticleAlphaBlendMaterial in BeastsEffect and EpidemicEffect prefabs

- BeastsEffect: assign birdMaterial
- EpidemicEffect: assign skullMaterial and hazeMaterial

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-12 17:19:36 -08:00
adminandGitHub 98dc80a77e Add LLM provider failover on timeout errors (#5997) 2026-02-11 22:36:51 -08:00
521e87cb91 Add 3D particle effect controllers for province events (#5992)
* Add 3D particle effect controllers for province events

Add controllers for blizzard, drought, flood, and beasts events:
- ProvinceBlizzardController: falling snow particles
- ProvinceDroughtController: rising dust/heat particles
- ProvinceFloodController: rain/water splash particles
- ProvinceBeastsController: circling vultures/crows particles

These follow the same pattern as Festival and Epidemic controllers.
Prefabs need to be created in Unity and wired up in the scene.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update 3D effects doc with implementation status and prefab requirements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add particle effect components for province events

Add MonoBehaviour scripts that programmatically create particle systems
for each province event type:

- BlizzardEffect.cs: Falling snowflakes with wind drift
- DroughtEffect.cs: Rising dust and heat shimmer particles
- FloodEffect.cs: Falling rain streaks with ground splashes
- BeastsEffect.cs: Circling birds (vultures/crows) with orbital motion

Each effect is configurable via Inspector fields (emit rate, colors,
sizes, speeds, etc.) similar to the existing EpidemicEffect.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Wire up province event effect controllers in Unity scene

- Add effect components to prefabs (Blizzard, Drought, Flood, Beasts)
- Add controllers to Map GameObject in Gameplay.unity
- Wire up mapContainer, centroidsJson, and prefab references
- Wire controller references in MapController

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add test controller for province event effects

- Add ProvinceEffectTestController with test methods for each effect type
- Add TestSpawnEffect() and ClearAllEffects() methods to all controllers
- Supports testing effects without game model data

Usage: Add ProvinceEffectTestController to scene, set province IDs,
then use context menu "Test All Effects" or enable "Spawn On Start".

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Revert "Add test controller for province event effects"

This reverts commit d13ae05b5661c7b29adb424d8249cec39110efdc.

* Add vulture icon for beasts province effect

- Add griffon-vulture.png icon (Flaticon, requires attribution)
- Fix velocity curve mode error in BeastsEffect.cs
- Wire up vulture texture to BeastsEffect prefab

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Rework province event effects with sun/shimmer drought and masking infrastructure

- DroughtEffect: Replace particle-based effect with pulsing sun icon and
  heat shimmer overlay using custom GrabPass shader
- BlizzardEffect/FloodEffect: Add shader fallback chain for province masking
- Controllers: Add rawGray texture loading and province masking data passing
- Add HeatShimmerShader.shader for drought distortion effect
- Add ProvinceParticleShader.shader for province boundary clipping
- Scale particle emission areas based on province perpendicular_width

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix province masking with screen-space coordinate calculation

- Update shaders to use screen position instead of world position for
  accurate UV calculation in Canvas UI context
- Controllers now properly convert world corners to screen coordinates
- Handle both Screen Space Overlay and other canvas render modes
- Fix map bounds calculation for province ID texture sampling

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Enlarge weather effects to fill provinces with shader masking

- DroughtEffect: Fix shimmer by using RawImage with white texture,
  increase shimmer size to 2x province width for full coverage
- BlizzardEffect: Increase emission area, particle count (300),
  emit rate (40/s), and lifetime (4s) for dense snowfall
- FloodEffect: Increase rain emission area, particle count (400),
  emit rate (80/s), and splash coverage
- Controllers now use full province width for emission sizing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Double height of blizzard and rain emission areas

- Blizzard: Y scale 20→40, spawn height 60→120
- Rain: Y scale 20→40, spawn height 70→140

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix particle coverage and disable broken drought shimmer

- Blizzard/Flood: Change emission box to 150x150 with lower spawn point
  so particles spawn at varying heights across the province
- Drought: Disable shimmer effect - GrabPass doesn't work with UI Canvas
  (sun icon still shows)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add gentle waving animation to drought sun

- Sun now waves slowly in X and Y using different sine frequencies
  for an organic floating feel
- Reduce default sun size from 60 to 40
- Wave amounts: X=8, Y=5 at speeds 0.4 and 0.6

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove test effects from province event controllers

Remove hardcoded test province spawning and keep-alive logic from all
six province event controllers (blizzard, flood, drought, festival,
epidemic, beasts). Effects will now only appear for provinces that
actually have the corresponding events.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 22:23:27 -08:00
98fb1e773c Cache game state in LLM text requests to avoid repeated disk loads (#5996)
When LLM requests are stuck waiting on dependencies, they were repeatedly
loading game state from disk on each processing cycle. This change caches
the game state directly in the text request (UnrequestedClientText and
IncompleteClientText) so it's loaded once and reused until the request
completes or is removed.

The cache is automatically cleaned up when the request is completed, since
the cached state is attached to the request object itself rather than stored
in a separate map.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 22:18:12 -08:00
18581f003b Enable Medium managed code stripping for iOS builds (#5995)
Change iPhoneStrippingLevel from 0 (Disabled) to 2 and
managedStrippingLevel for iPhone from 1 (Minimal) to 3 (Medium).

This strips unused managed code from the IL2CPP build, potentially
reducing app size by 10-30MB. Medium stripping is safe for most code
but can break heavy reflection usage - protobuf serialization should
be fine.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 16:29:31 -08:00
9ab6aab8e4 Fix Arm Troops gold cost mismatch between client and server (#5994)
The server calculates cost per-battalion with ceiling applied individually,
then sums. The client was summing costs as floats then applying ceiling once.
This caused small discrepancies when multiple battalions had fractional costs.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 16:22:21 -08:00
c7ab71a928 Fix effect animator visibility and MeteorCast animation (#5993)
Two fixes for effect animations:

1. Z-position fix: Changed all effect animator z-positions from 3-6 to 0.
   Effects were rendering behind other elements after sorting layer changes.
   Affects 16 animators: Meteor, RaiseDead, Catapult, Control, Dismiss,
   Duel, Extinguish, Fear, Fire, Flee, Freeze, HolyWave, Lightning,
   Scout, Tool, and Water.

2. MeteorCast animation fix: Added IsServerTriggeredAction helper and a
   new branch in history handler for server-triggered animations
   (like MeteorCast) for the current player.

   This was broken by #5130 which split meteor phases. Before #5130,
   MeteorTargetCommand mapped to AnimationType.MeteorCast, so the
   animation played immediately when targeting. After #5130, it correctly
   mapped to MeteorTarget, but the MeteorCast result from the server
   fell through all history handler branches.

   The fix specifically handles server-triggered actions (MeteorCast,
   MeteorCancel) that happen automatically rather than from direct
   player commands. This avoids double-animating actions like Archery
   that are already animated in PerformAction.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 15:27:05 -08:00
9a6521b4d4 Add tooltip providers to province event icons and 3D effects doc (#5991)
- Add HoveringTooltipTextProvider to all 6 province event images
  (Festival, Blizzard, Epidemic, Flood, Drought, Beasts)
- Add docs/province-event-3d-effects.md with ideas for replacing
  2D shader effects with 3D particle systems

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 14:31:45 -08:00
fe4e390ab9 Fix double-decompress of rawGray province ID map (#5990)
ProvinceIDLoader now exposes the decompressed bytes via DecompressedBytes
property, and MapController uses this instead of decompressing separately.

- ProvinceIDLoader: store decompressed bytes, expose via property
- MapController: remove duplicate decompression, use provinceIDLoader reference
- Gameplay.unity: wire MapController.provinceIDLoader to ProvinceIDLoader component

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 14:06:46 -08:00
b91113039f Map adjustments: connect Chia island, thicken peninsula, fix Pozia (#5989)
- Chia: Connected the large island to the main peninsula via land bridge
- Chia: Thickened the peninsula by 15 pixels to better fit province label
- Pozia: Assigned 199 previously uncolored pixels in southeast region

- Regenerated map_borders.png (borders only, transparent elsewhere)
- Updated centroids.json with recalculated province centroids
- Added edit_provinces.py tool for province map editing

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 13:41:08 -08:00
b6af020fcc Add epidemic particle effect with rising skulls and green haze (#5988)
* Add epidemic particle effect with skulls and green haze

- ProvinceEpidemicController: Spawns effects at province centroids
  when EpidemicEvent is active (similar to ProvinceFestivalController)
- EpidemicEffect: Particle system with configurable skull texture
  reference and green haze/smoke rising from the ground
- Integrate with MapController to update effects when model changes

Skull texture needs to be assigned in Unity Inspector.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Wire up epidemic particle effect in Unity scene

- Add EpidemicEffect.prefab with configured particle systems
- Add ProvinceEpidemicController to Map GameObject
- Wire mapContainer, epidemicEffectPrefab, and centroidsJson references

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add test code to show epidemic effect over Musland

Temporary test code - spawns epidemic effect at province ID 5 (Musland)
on Start() for visual testing. Remove after testing.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Improve epidemic skull particle effect

- Fix transparency (proper alpha blend settings)
- Remove spinning, add slight random starting tilt
- Skulls grow as they rise (25% to 100%)
- More horizontal sway for drifting motion
- Use white color so texture renders as-is

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add rocking motion, spawn offset, and remove 2D epidemic effect

- Remove epidemic from ProvinceWeatherController (now handled by particles)
- Add vertical spawn offset so skulls rise through province center
- Add noise-based rotation for gentle rocking/swaying motion
- Skulls now sway back and forth ~11° as they rise

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove epidemic test code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 13:05:20 -08:00
adminandGitHub 80d3c341cf Implement neverJoinFactionIds logic for hero recruitment (#5987) 2026-02-11 12:37:43 -08:00
6998b66233 Add WouldNeverJoin recruitment status (#5986)
Add proto enum value and client support for a new recruitment status that
indicates a hero would never join a particular faction. This is plumbing
only - the logic to set this status will come in a follow-up PR.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 11:54:46 -08:00
367ca2d48d Replace hex background with text outlines and soft backings (#5983)
* Replace hex background with text outlines and image backing

Instead of drawing a white translucent background over the entire hex
when a unit is present:

- Text labels now use TMP outline + underlay for readability
- UnitType and Profession images have soft circular backing
- The hex-wide background color is no longer applied

This provides the same readability with less visual obstruction of the
terrain underneath.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix readability with soft circular backings for text and images

- Replace TMP underlay (box-shaped) with soft circular backing images
- Add backing images behind all text labels (upper, lower, overlay)
- Fix z-order bug where UnitTypeImage was moved behind its backing
- Add configurable labelBackingScale and imageBackingScale
- Show/hide backings dynamically based on content presence

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix backing z-order by moving all backings to front of sibling order

Backings from different cells were interleaving with foreground elements,
causing white blur to appear in front of text/images. Moving all backings
to the front of the sibling list ensures they render behind everything.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Use separate container for backings to fix z-order

Create a BackingsContainer as the first child of overlayContent.
All backings are parented to this container, ensuring they render
behind all foreground elements (images and labels) regardless of
cell creation order.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Make text backing elliptical to match text box proportions

Instead of a circle sized to the max dimension, the backing is now
an ellipse that matches the text box width/height ratio.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Configure HexGrid backing settings in scene

Set tuned values for label and image backings:
- Black outline (0.2 width) for text
- Cream-colored backings (1, 1, 0.74) for both labels and images
- Label backing scale 2.1, image backing scale 1.8

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 11:27:06 -08:00
005316aea7 Add neverJoinFactionIds data to heroes.tsv (#5984)
Add a new column `never_join_faction_ids` to heroes.tsv and wire it
through the hero loading pipeline:
- LoadedHero: add neverJoinFactionIds field
- FixedHeroes: parse the new column from TSV
- LoadedHeroConversion: pass the field to HeroC and Hero proto

Set initial values:
- Bregos Fyar: never joins faction 1 (The Fracture Covenant)
- Ikhaan Tarn: never joins faction 2 (The King's Loyalists)
- The Eagle: never joins faction 2 (The King's Loyalists)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 09:19:44 -08:00
8c0cacfc96 Add neverJoinFactionIds field to Hero model (#5982)
Add a new field to track faction IDs that a hero will never join under
any circumstances. This field is added to:
- hero.proto (repeated int32 never_join_faction_ids)
- HeroT trait (def neverJoinFactionIds: Set[FactionId])
- HeroC case class (neverJoinFactionIds: Set[FactionId] = Set.empty)
- HeroConverter (toProto and fromProto)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 08:47:53 -08:00
2f45b409b0 Fix Shardok overlay z-order and name HexGrid GameObjects with coordinates (#5981)
* Fix Shardok overlay rendering behind bridges

Set Overlay Container Pos Z to -100 to ensure overlay UI (hero names,
unit info, battalion icons) renders in front of 3D bridge meshes in
the ScreenSpaceCamera canvas.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Name dynamically created HexGrid GameObjects with coordinates

Adds coordinate suffixes (e.g., _3_5) to all dynamically created
GameObjects in HexGrid, making debugging easier. Objects are now
named like Terrain_0_0, UnitType_3_5, PrimaryLabel_2_4, etc.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 07:54:39 -08:00
a05a836b71 Fix rocket visibility by using explicit velocity with negative Z (#5980)
Rockets were invisible when their Z velocity was positive (moving away
from camera in ScreenSpaceCamera canvas). Fixed by:
- Moving rocket particle system to child GameObject (not on RectTransform)
- Disabling shape module and setting velocity explicitly in EmitParams
- Using negative Z velocity (-1) to keep particles in front of camera
- Adding X variation (±15) for natural angle spread
- Adding velocityOverLifetime Y deceleration (-20) for arcing path

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-11 07:12:07 -08:00
3b26fccc44 Refactor province event icons to horizontal layout (#5979)
Changes:
- Replace vertical GridLayoutGroup container with HorizontalLayoutGroup
- Remove provinceEventsContainer field and dynamic resizing code
- Event icons now shown/hidden individually without container wrapper
- Adjust icon sizes: min 12x12, preferred 24x24
- Add spacers for layout spacing
- Connect festivalController reference in MapController

This simplifies the layout code - no more dynamic cell size adjustments
based on wide/narrow screen mode.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 22:02:17 -08:00
adminandGitHub 6ba35fc7c6 Revert "Fix rocket visibility by creating particle system on child GameObject…" (#5978)
This reverts commit f33822104c.
2026-02-10 21:49:17 -08:00
f33822104c Fix rocket visibility by creating particle system on child GameObject (#5977)
Rockets were sometimes invisible because the particle system was
attached directly to a RectTransform (UI element). Particle systems
don't render reliably on UI GameObjects.

Fixed by creating the rocket particle system on a child GameObject,
matching how the burst system was already set up.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 21:48:41 -08:00
adminandGitHub 8ed6a21b6b Add epidemic overlay and festival fireworks effects (#5972) 2026-02-10 19:55:13 -08:00
db90fbf126 Fix low food warning incorrectly triggering at end of year (#5976)
The low food warning was incorrectly triggering in December even when
taxes would provide enough food in January. This was caused by an
off-by-one error in calculating consumptions until taxes arrive.

Taxes arrive at the START of January, before January's food consumption.
So in December there are 0 more consumption events before taxes, not 1.
The fix changes from `monthsUntilJanuary` (1 for December) to
`consumptionsUntilTaxes` which is `12 - month` (0 for December).

Added tests for:
- December with sufficient support: no warning (taxes will save)
- December with low support: warning (won't receive taxes)
- November with sufficient food/support: no warning (can survive until taxes)
- November without sufficient food: warning (will starve before taxes)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 19:39:37 -08:00
7980bf69a2 Support ScreenSpaceCamera canvas mode for UI interactions (#5975)
When the Canvas render mode is changed from ScreenSpaceOverlay to
ScreenSpaceCamera (needed for particle system rendering), world
coordinates must be converted to screen coordinates for hit testing.

- PanelPositions: Convert world corners to screen space using camera
- MapPinchZoomHandler: Use canvas camera for coordinate conversion

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 19:39:04 -08:00
6368a64ef7 Reduce debug log spam in console (#5974)
- Disable TutorialManager.DebugLogging in scene (was flooding console)
- Remove music loading debug logs from SoundManager

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 18:43:31 -08:00
09bc074198 Update Gameplay scene UI layout positions (#5973)
Adjust anchor positions and sizes for various UI elements.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 17:41:23 -08:00
d34215308a Initialize panel layout in Start() before first frame renders (#5971)
Call UpdateLayoutForAspectRatio() at end of Start() to set up the
correct layout before the scene becomes visible, avoiding a jarring
rearrangement on first battle load.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 17:40:59 -08:00
b1a8653189 Add ocean texture support to ProvinceMapShader (#5970)
* Add ocean texture support to ProvinceMapShader

Instead of returning transparent for ocean pixels (ID 0/255), sample
an ocean texture with a tint color. This allows MapBWImage to contain
only border lines, reducing its size.

Properties added:
- _OceanTex: tileable ocean texture (or use white for solid color)
- _OceanColor: tint color for the ocean

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add ocean animation (scroll + wave distortion)

New properties:
- _OceanScrollSpeed: slow drift direction (default: 0.02, 0.01)
- _OceanWaveStrength: ripple distortion amount (default: 0.01)
- _OceanWaveSpeed: ripple animation speed (default: 1.0)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add border-only map image generated from rawGray

map_borders.png contains only province border pixels (88 KB vs 6.6 MB
for map_bw.png which includes ocean fill). Generated by checking each
pixel's neighbors for province ID changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix Y-axis orientation in map_borders.png

Flip Y coordinate when reading rawGray to match PNG orientation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove old map_bw images (replaced by map_borders.png)

Delete map_bw.png (6.6 MB) and map_bw_labels.png - ocean is now
rendered by the shader, so only border lines are needed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Use hardcoded rawGray dimensions for hit testing

MapController now uses constant dimensions (3786x1834) instead of
reading from mapBWImage.texture, which fixes hit testing after
switching to map_borders.png.

Also includes scene/material updates for ocean texture configuration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix province ID 1 (Shumal) being treated as ocean

Province ID 1 = 1/255 = 0.00392, which was less than the 0.004
threshold. Lower threshold to 0.002 to only catch ID 0 (ocean).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove unused mapBWImage field from MapController

No longer needed since dimensions are hardcoded and ocean is
rendered by the shader.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 17:21:11 -08:00
e7244fa84f Replace 43 province RawImages with single-draw province rendering (#5969)
* Replace 43 province RawImages with single-draw province rendering

Use rawGray province ID map as source of truth for province boundaries,
eliminating 43+ draw calls and 43 mask textures.

New architecture:
- ProvinceMapShader samples rawGray for province ID, looks up color from
  256x1 texture
- ProvinceColorManager manages the color lookup texture
- ProvinceIDLoader loads rawGray.gz as GPU texture
- ProvinceWeatherMapShader uses same approach for weather overlays

Benefits:
- 1-2 draw calls instead of 43+
- Deleted 43 mask PNG files (~6MB)
- Deleted 43 per-province materials
- Single source of truth (rawGray)
- Simpler, more maintainable code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add _MainTex property to province shaders for Unity UI compatibility

Unity's RawImage component expects shaders to have a _MainTex property.
Add it as a hidden, unused property to satisfy this requirement.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Adjust layer ordering: move MapBWImage in front of province layers

Ensure province borders render on top of province colors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 16:44:17 -08:00
be28563d29 Province label improvements: area scaling, aspect ratio correction, font size slider (#5968)
* Scale province label font size based on area

Larger provinces now get proportionally larger text labels:
- Reduce baseFontSize from 11 to 7 for smaller overall labels
- Scale font using sqrt(area/avgArea) for gentle area-based sizing
- Clamp area scale to 0.6x-2.5x range
- Still cap font size to fit within province width

This makes large provinces like Yuetia and Berkorszag more readable
while keeping small provinces from having oversized labels.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Keep baseFontSize at 11 for area-scaled labels

The area-based scaling with baseFontSize=7 made labels too small.
Keeping baseFontSize=11 with the sqrt(area) scaling provides better
readability for both large and small provinces.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Adjust label rotation and curvature for map aspect ratio

When the map is displayed at a different aspect ratio than the source
(3786x1834), the label angles and curves now adjust dynamically:

- Store source orientation and curvature per label
- Detect aspect ratio changes in Update()
- Transform angles using atan2 to account for horizontal stretch
- Scale curvature inversely with stretch factor

This fixes labels appearing at wrong angles when the map is stretched
to fit different screen resolutions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix NaN rotation when map rect is zero during initialization

- Guard UpdateLabelAngles() against zero rect dimensions
- Apply initial rotation from source orientation during label creation
- Defer aspect ratio correction to first Update() when rect is valid

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add settings slider for province label font size

- Add provinceLabelFontSizeSlider to SettingsPanelController
- Store province data (area, length, name length) for dynamic recalculation
- Add CalculateFontSize() method to compute font size from current baseFontSize
- RefreshLabels() now recalculates all font sizes when base size changes
- Font size persisted to PlayerPrefs

To use: Wire up slider (range ~5-20) in Unity to OnProvinceLabelFontSizeSliderChange

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Wire up province label font size slider in settings panel

- Add slider UI to settings panel (range 0-24, default 11)
- Connect slider to SettingsPanelController.OnProvinceLabelFontSizeSliderChange
- Wire provinceLabelsController reference for live updates

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 16:09:10 -08:00
63d553c49f Scale province label font size based on area (#5967)
* Scale province label font size based on area

Larger provinces now get proportionally larger text labels:
- Reduce baseFontSize from 11 to 7 for smaller overall labels
- Scale font using sqrt(area/avgArea) for gentle area-based sizing
- Clamp area scale to 0.6x-2.5x range
- Still cap font size to fit within province width

This makes large provinces like Yuetia and Berkorszag more readable
while keeping small provinces from having oversized labels.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Keep baseFontSize at 11 for area-scaled labels

The area-based scaling with baseFontSize=7 made labels too small.
Keeping baseFontSize=11 with the sqrt(area) scaling provides better
readability for both large and small provinces.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 15:38:10 -08:00
827a0d50e6 Add curved province labels with partial zoom scaling (#5966)
* Add curved text support for province labels

- Create CurvedText component that warps TMP mesh vertices along an arc
- Calculate curvature for each province based on shape deviation from principal axis
- Update centroids.json with curvature values for all provinces
- Update Rust map generator to calculate and include curvature

Provinces with notable curvature:
- Berkorszag: -30 (curves downward)
- Yuetia: 30 (curves upward)
- Tegrot: -30 (curves downward)
- Fluria: 25.8 (curves upward)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix curved text direction (was inverted)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Reduce curve intensity by 75% (scale factor 0.25)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Make labels grow at half the zoom rate

Labels now scale with 1/sqrt(zoom) instead of 1/zoom, so they
grow slightly as you zoom in rather than staying constant size.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add distance-weighted centroid calculation for province labels

Implements BFS-based distance calculation from province boundaries to
weight interior pixels more heavily when computing centroids. This
produces label positions that are more visually centered within
irregular province shapes.

- Add calculate_weighted_centroid() to Rust map generator
- Update centroids.json with distance-weighted positions
- Key improvements: Yuetia centered (was at north coast), Berkorszag
  now properly within its bounds

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 15:19:07 -08:00
64c86ebd2b Add Rust map generator, equalized province maps, and dynamic province labels (#5957)
* Add design doc for programmatic map image generation

Documents the plan to:
- Create a Python tool for regenerating map images with more equalized province sizes
- Implement dynamic province name rendering that scales with zoom
- Preserve topology and neighbor relationships while enlarging small provinces

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add Python map generator tool for province size equalization

Implements Part 1 of the programmatic map generation plan:
- Python script using weighted dilation to grow small provinces
- Preserves jagged/natural edges using noise fields
- Allows small provinces to claim limited ocean pixels (20% max)
- Maintains neighbor relationships and realistic coastlines

Results:
- Province size ratio reduced from 110x to 22x
- Smallest province (Kojaria) grew 5x (3,262 → 16,590 pixels)
- All 43 province masks and centroids.json generated

Usage:
  cd tools/map_generator
  python -m venv venv && source venv/bin/activate
  pip install -r requirements.txt
  python generate_map.py --generate

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add ProvinceLabelsController for dynamic map labels

Part 2 of programmatic map generation - dynamic province name rendering:
- Creates TextMeshPro labels at province centroids
- Labels scale inversely with zoom level for readability
- Only visible when zoom >= 1.5x (configurable)
- Font size scales with province area for visual hierarchy
- Includes centroids.json with province positions

To use:
1. Add ProvinceLabelsController component to a GameObject in the scene
2. Assign zoomHandler, mapContent (RectTransform), and centroidsJson TextAsset
3. Optionally configure font, colors, and zoom threshold

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add topology documentation and tendril removal to map generator

- Document new province adjacencies caused by equalization
- Add priority_provinces parameter for boosting specific province growth
- Add remove_tendrils() post-processing to eliminate thin extensions
- Note: Will be restructured to use Bazel in next commit

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add Bazel Python support and parallelized map generator

- Add rules_python to MODULE.bazel with Python 3.13
- Move map generator to src/main/python/net/eagle0/eagle/
- Add parallel processing using ProcessPoolExecutor
- Provinces are now grown in parallel across multiple CPU cores

Usage:
  bazel run //src/main/python/net/eagle0/eagle:map_generator -- --generate
  bazel run //src/main/python/net/eagle0/eagle:map_generator -- --workers 8

The old tools/map_generator/ script remains for reference but the Bazel
version is now the primary implementation.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add Rust map generator and equalized province maps

- Add Rust map generator (src/main/rust/net/eagle0/eagle/map_generator/)
  - Uses parallel graph coloring for non-adjacent province processing
  - Generates equalized province sizes (smallest ~7x larger than before)
  - Outputs: rawGray.gz.bytes, map_bw.png, province masks, centroids.json
  - Run with: bazel run //src/main/rust/net/eagle0/eagle/map_generator -- --generate

- Update province_map.tsv with neighbor changes from equalization:
  - Usvol gains: Laufarvia, Hella, Grytrand
  - Chapellia loses Nikemi, gains Kojaria
  - Kojaria gains Motcia, Chapellia
  - Motcia gains Kojaria

- Update Unity assets with equalized map:
  - New rawGray.gz.bytes with equalized province boundaries
  - New map_bw.png with textured water, transparent land, dark borders
  - Updated province mask images (1-43.png)
  - Updated centroids.json with orientation data for label rotation

- Update ProvinceLabelsController.cs to support label rotation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix coordinate system for rawGray and province labels

- Fix save_raw_gray to not flip Y coordinates (was causing click detection
  to be inverted)
- Fix ProvinceLabelsController to not flip Y coordinates (was causing
  labels to appear in wrong positions)
- Update rawGray.gz.bytes with correct orientation
- Update Gameplay.unity with ProvinceLabelsController setup

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Improve province label rendering

- Reduce max font size scale from 2.0 to 1.3
- Disable word wrapping, allow text overflow
- Fix orientation sign (remove negation)
- Allow near-vertical labels by expanding angle range to [-90, 90]
- Recalculate province orientations with new range

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Scale province label font size based on province dimensions

- Add principal_length and perpendicular_width to centroids.json
- Update ProvinceLabelsController to scale font based on principal_length
- Update Rust map generator to calculate province dimensions via PCA
  eigenvalues (4 * sqrt(eigenvalue) for ±2 std dev coverage)
- Disable text wrapping on labels to prevent unwanted line breaks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Reduce province label base font size to 11

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update neighbor positions and hex map starting positions for new borders

TSV changes (neighborPositions for new neighbors):
- Laufarvia: Usvol attacks from dir 4 (E)
- Hella: Usvol attacks from dir 2 (NE)
- Chapellia: Kojaria attacks from dir 4 (E)
- Usvol: Laufarvia=3(W), Hella=5(SW), Grytrand=4(E)
- Kojaria: Chapellia=1(N), Motcia=3(W)
- Grytrand: Usvol attacks from dir 3 (W)

Hex map changes (new attackerStartingPositions):
- Motcia.e0mj: Added direction 0 (NW) for Kojaria
- Chapellia.e0mj: Added direction 4 (SE) for Kojaria
- Kojaria.e0mj: Added directions 1 (N) and 3 (E) for Chapellia/Motcia

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Revert outline width change (made text smaller)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add TMP underlay effect for better label visibility on dark backgrounds

The underlay expands outward (unlike outline which shrinks text inward),
providing a white glow around labels without reducing text size.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 14:30:58 -08:00
bfde365afd Fix Y coordinate orientation in rawGray output (#5965)
The save_raw_gray function was incorrectly flipping Y coordinates,
causing the output to be vertically inverted compared to the input.
This caused click detection in Unity to select the wrong provinces.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 12:48:29 -08:00
f7cc8cc2fc Replace Python map generator with Rust implementation (#5964)
* Replace Python map generator with Rust implementation

- Add Rust map generator at src/main/rust/net/eagle0/eagle/map_generator/
  - Parallel province equalization using graph coloring
  - Generates equalized province sizes with ~7x improvement
  - Run with: bazel run //src/main/rust/net/eagle0/eagle/map_generator -- --generate

- Remove Python map generator (src/main/python/)
  - Rust version is faster and more maintainable
  - Removes rules_python dependency from MODULE.bazel

- Add build test to verify Rust code compiles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Remove Python from CI test workflow

Python map generator was replaced with Rust in this branch.
Rust tests are already covered by //src/test/...

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 11:02:54 -08:00
47b92265db Rename battle end button from "Back to Eagle" to "Exit Battle" (#5963)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 10:59:32 -08:00
5a91a6c175 Implement contiguous growth algorithm for province equalization (#5962)
- Replace multi-step dilation with single-step contiguous growth
- Prioritize boundary pixels with more same-province neighbors
- Use np.argpartition for efficient top-k selection
- Add two-phase cleanup (3x3 + 5x5 neighborhoods)
- Add neighbor analysis and comparison with TSV
- Update province_map.tsv with new neighbor relationships:
  - Usvol now borders Laufarvia, Hella, Grytrand (was only Faluria)
  - Laufarvia/Faluria no longer neighbors (Usvol between them)
  - Al Raala now borders Tumala
  - Results: 12.9x size ratio (from 110x), 2,263 cleanup pixels

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-10 07:06:01 -08:00
3eac8bbc0f Fix Organize Troops panel layout (additional fixes) (#5961)
Additional layout improvements for the Organize Troops panel.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 21:56:01 -08:00
d041c68b99 Add tutorial battle system C++ implementation and docs (#5948)
* Add tutorial battle system C++ implementation and docs

Brings in the C++ tutorial battle controller and documentation from the
tutorial-scenario-system branch:
- TutorialBattleController for managing scripted battle scenarios
- Documentation describing the tutorial battle system design

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add proto definitions and ShardokEngine integration for tutorial battles

Proto changes:
- Add TUTORIAL_ENEMY_FLED and TUTORIAL_REINFORCEMENTS_ARRIVED action types
- Add TutorialBattleConfig message to player_info.proto
- Add tutorial_battle_config field to NewGameRequest

C++ changes:
- Remove unused defenderCanFlee field from TutorialBattleController
- Integrate TutorialBattleController into ShardokEngine
- Add SetTutorialBattleConfig method to configure tutorial mode
- Check for scripted flee at end of each round in HandlePlayerTurnEnd

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix visibility for player_info_proto

Allow game_setup_info_proto in //src/main/protobuf/net/eagle0/common
to depend on player_info_proto from shardok/common.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix fire_test by moving early return inside tutorial flee block

The if (GameIsOver()) { return; } check was placed before NewRoundAction,
which caused STRUCTURES_BURNED to be skipped even for normal games. The
early return should only happen when the tutorial flee actually triggers
and ends the game.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Revert "Fix fire_test by moving early return inside tutorial flee block"

This reverts commit b0b08020873cefeaf411c07d6223ade5baad3c4e.

* Reapply "Fix fire_test by moving early return inside tutorial flee block"

This reverts commit 8038d73b57b1cf4e29c4e79e91dcb9e11b49e44d.

* Redesign tutorial system to be event-driven with configurable triggers

Replace hardcoded flee triggers with a flexible event system that supports:
- Trigger types: RoundTrigger, UnitsLostTrigger, DamageTakenTrigger, UnitKilledTrigger
- Action types: FleeAction, ReinforcementsAction
- Events fire in config order and at most once (tracked by event_id)

Key changes:
- Proto: Replace TutorialBattleConfig fields with repeated TutorialEvent
- Controller: Replace ShouldTriggerScriptedFlee/ExecuteScriptedFlee with CheckAndExecuteEvents
- Engine: Update HandlePlayerTurnEnd to use new CheckAndExecuteEvents API

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update tutorial battle doc: vassals, 600 cavalry, new event API

- Change defender heroes from "sworn brothers" to "vassals"
- Update attacker heavy cavalry from 400 to 600 troops
- Update proto reference to show new event-driven TutorialBattleConfig
- Update C++ API docs to show CheckAndExecuteEvents method
- Remove completed ShardokEngine integration from "Remaining" section

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Move tutorial config to separate proto file

Extract TutorialBattleConfig and related messages from player_info.proto
into tutorial_battle_config.proto to avoid the code smell of having
game_setup_info.proto depend on two different player_info.proto files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Move tutorial_battle_config.proto to common/ package

Since the proto is used by both Eagle (game_setup_info.proto) and
Shardok (TutorialBattleController), it belongs in common/, not
shardok/common/.

Updated package from net.eagle0.shardok.common to net.eagle0.common
and updated all C++ references accordingly.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Change ReinforcementsAction to use CommonUnit instead of hero names

This allows the reinforcement units to be fully specified with all
their attributes (hero stats, battalion type/size, etc.) rather than
just referencing heroes by name.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add tutorial_battle_config.proto to Unity C# project

The new proto was missing from protos.csproj, causing Unity builds to fail.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add go_package option and include proto in Go build

- Add go_package option to tutorial_battle_config.proto for Go code gen
- Add tutorial_battle_config_proto to common_go_proto target

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 19:08:32 -08:00
60a147e31c Fix Organize Troops panel layout and add Unity Smart Merge config (#5960)
- Fix layout of Organize Troops panel
- Add Unity Smart Merge (.gitattributes) for better merge handling of
  Unity scene files (.unity, .prefab, .asset, etc.)

Note: To use Smart Merge, developers need to configure their ~/.gitconfig:
  [merge]
  tool = unityyamlmerge
  [mergetool "unityyamlmerge"]
  trustExitCode = false
  cmd = '/Applications/Unity/Hub/Editor/*/Unity.app/Contents/Tools/UnityYAMLMerge' merge -p "$BASE" "$REMOTE" "$LOCAL" "$MERGED"

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 19:08:02 -08:00
d8402694c2 Add Bazel Python support and parallelized map generator (#5958)
* Add Bazel Python support and parallelized map generator

- Add rules_python to MODULE.bazel with Python 3.13
- Create map generator at src/main/python/net/eagle0/eagle/
- Add parallel processing using ProcessPoolExecutor
- Provinces are grown in parallel across multiple CPU cores

Usage:
  bazel run //src/main/python/net/eagle0/eagle:map_generator -- --analyze
  bazel run //src/main/python/net/eagle0/eagle:map_generator -- --generate
  bazel run //src/main/python/net/eagle0/eagle:map_generator -- --workers 8

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add smoke test for map_generator

Basic unit tests that verify:
- Constants are correct
- Land mask generation works
- Border detection works
- Border pixel filling works

Run with: bazel test //src/main/python/net/eagle0/eagle:map_generator_test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Include Python tests in CI test job

Add //src/main/python/... to the bazel test command so Python tests
are run alongside other tests in CI.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-09 17:32:52 -08:00
e847f4ee80 Fix null references in Gameplay.unity (#5956)
Restore serialized references that had been nulled out.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 22:12:10 -08:00
77e8b1fbe3 Fix Screenshot button onClick in Bug Report panel (#5955)
* Fix Screenshot button onClick in Bug Report panel

The screenshot button's onClick handler was incorrectly pointing to
AttributionsController.Hide with a null target. Update to correctly
call BugReportPanelController.OnScreenshotButtonClicked.

Note: The screenshotButton reference in BugReportPanelController is
still null - this adds a scene-based onClick as a workaround. The
reference should be restored in Unity to fully fix the issue.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Restore BugReportPanelController screenshot references

Restore all the null references that were lost in a previous merge:
- screenshotButton
- screenshotStatusText
- screenshotPreviewContainer
- screenshotPreview
- settingsPanel

This allows the programmatic onClick listener in Awake() to work
correctly, and enables screenshot preview functionality.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix screenshotPreviewContainer reference

Point to "Screenshot Container" (the parent with LayoutElement) instead
of "Screenshot Preview" (the RawImage). This ensures the container is
properly hidden when no screenshot has been captured.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix Bug Report panel layout

Restore proper positions and sizes for Bug Report panel UI elements
that were incorrectly zeroed out. Panel is now hidden by default.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 21:28:32 -08:00
b2a0577121 Fix weather effects not clipping when map is zoomed/panned (#5954)
Add RectMask2D clipping support to ProvinceWeatherShader, matching
the fix applied to maskShader. Weather effects (drought, blizzard,
flood) now properly clip to the map bounds during zoom and pan.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 21:12:17 -08:00
b1601e19e0 Fix Report Bug button on exception popup (iPad) (#5953)
The reportBugButton and bugReportPanel references were null ({fileID: 0}),
causing the Report Bug button to not function on the exception popup.
Properly assign these references in the Unity scene.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 21:10:14 -08:00
f83ca0573a Compress province ID map with gzip (6.9MB → 75KB) (#5952)
The rawGray province lookup map compresses extremely well due to
repeated values. Decompress at load time using GZipStream.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 21:01:50 -08:00
2d4b5efac2 Add smooth interpolation to pinch-to-zoom on iPad (#5951)
Use Mathf.SmoothDamp to interpolate toward target zoom level instead
of applying zoom changes immediately. This eliminates jumpiness from
noisy touch input.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 18:25:50 -08:00
7a2f797e13 Update TSV data files from Google Sheets (#5950)
- beasts.tsv
- heroes.tsv
- names.json

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 18:23:19 -08:00
9934fb2ce9 Stop outputting names.tsv (only names.json is used) (#5949)
The names.tsv file was being generated but only names.json is actually
used by BattalionNameGenerator. The TSV was just an intermediate format.

- Update downloadTsvs.sh to send checker output to /dev/null
- Remove .gitignore entry for names.tsv

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 18:06:49 -08:00
8bd143ec67 Fix What's New "Add Entry" form not working (#5947)
The HTMX form was using hx-swap="outerHTML" which replaced the form element
with the response body. When the response body was empty (just HX-Redirect
header), the form would disappear before the redirect could trigger.

Fixed by:
1. Wrapping the form in a container div for proper targeting
2. Using hx-swap="innerHTML" to replace contents, not the container
3. Returning a success message body that HTMX can swap while processing
   the redirect

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 17:51:23 -08:00
adminandGitHub 839f75e697 Suppress low food warning when taxes will replenish supplies (#5946) 2026-02-08 17:36:39 -08:00
466ec331d2 Add pinch-to-zoom for Eagle strategic map (#5945)
* Add pinch-to-zoom support for Eagle map view on iPad

Implements pinch-to-zoom and pan gestures for the strategic map:
- New MapPinchZoomHandler component for touch and scroll input
- Updates MapController.ProvinceFromPoint() to handle zoom/pan offsets
- Supports two-finger pinch/pan on iOS, scroll wheel on desktop

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add pinch-to-zoom for Eagle map using ScrollRect

- Rewrite MapPinchZoomHandler to use ScrollRect for proper clipping/panning
- Update maskShader to support RectMask2D clipping while preserving colors
- Configure Gameplay.unity with Map Container (ScrollRect + RectMask2D)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix zoom to center on mouse pointer position

Calculate scroll position adjustment to keep the point under cursor
stationary when zooming in/out.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Revert ProvinceFromPoint to original working logic

The original PanelPositions.MapRect approach works correctly at zoom
level 1. Zoom adjustment still needed.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix province click detection when map is zoomed

Calculate MapRect size from world corners instead of local rect size,
so it correctly includes the zoom scale from ScrollRect content scaling.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Wire up zoomHandler reference in MapController

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 16:51:48 -08:00
ca73c6991b Add AI quest fulfillment for 7 more quest types (#5943)
New quest command choosers:
- SpendOnFeastsQuestCommandChooser: Selects FeastCommand when quest active
- RestProvinceQuestCommandChooser: Rests in target province
- SendSuppliesQuestCommandChooser: Sends food to target province
- ExecutePrisonerQuestCommandChooser: Executes specific prisoner
- ReturnPrisonerQuestCommandChooser: Returns prisoner to faction
- StartEpidemicQuestCommandChooser: Starts epidemic in target province
- SwearBrotherhoodQuestCommandChooser: Swears brotherhood with target hero

Also adds Execute and Return helper methods to ManagePrisonersCommandSelector.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 16:43:36 -08:00
a3174d9d5e Filter out warnings from error alert popups (#5944)
Unity warnings (like audio device changes) are informational and
shouldn't interrupt gameplay with alert popups. Only show alerts
for actual errors and exceptions.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 16:33:14 -08:00
be1f9cc67c Fix SendSuppliesQuest not incrementing when sending supplies (#5942)
SendSuppliesCommand was missing quest fulfillment logic. This adds
the incrementing of SendSuppliesQuest progress using the existing
QuestFulfillmentUtils pattern from RestCommand.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-08 06:59:59 -08:00
5e173d55ce Add narrow layout support for March command battalions (#5941)
* Add narrow layout support for March command battalions

- Add battalionsColumnNarrow view for narrow screen layouts
- Add availableBattalionsContentNarrow to EventBasedUnitSelector for dual population
- Add PopulateBattalionsContent() helper to populate both content areas
- Toggle between original and narrow battalion views based on aspect ratio
- Keep heroes column always visible (user may march from different province)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix selectionChanged event binding on March unit selector

Clear the erroneous binding to AddTargetedBattalion - the original
SelectionChanged method was removed in 2022 and the binding should
be empty.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 22:32:54 -08:00
0830ccf5d9 Consolidate Overlay Canvas into Shardok Canvas (#5940)
Remove the separate Overlay Canvas and move overlay content into the
main Shardok Canvas. This eliminates the need to duplicate view hierarchy
between the two canvases.

- Remove overlayCanvas reference from HexGrid
- Remove Start() that was setting overlayCanvas.overrideSorting
- Move overlay content to be a sibling after hex grid in Shardok Canvas

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 17:26:14 -08:00
776790e44d Add StartDroughtQuest fulfillment (PR3 of 3) (#5935)
Add quest fulfillment logic to ControlWeatherCommand:
- StartDroughtQuest is fulfilled when a Mage uses ControlWeather to start
  a drought in the target province
- StartBlizzardQuest is also now fulfilled via the same mechanism
- Both quest types were previously marked as "action quest - fulfilled in
  ControlWeatherCommand" but the implementation was missing

Changes:
- ControlWeatherCommand now extends ProtolessSequentialResultsAction instead
  of ProtolessSimpleAction to support returning quest fulfillment results
- Added QuestFulfillmentChecker and allProvinces parameters to ControlWeatherCommand
- Updated CommandFactory to pass new parameters
- Updated tests to use .results instead of .immediateExecute

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 17:16:58 -08:00
34ff1b9a1b Add StartDroughtQuest creation (PR2 of 3) (#5934)
Add quest creation logic for StartDroughtQuest, following the same pattern
as StartBlizzardQuest:
- Requires a Mage in the faction
- Targets provinces reachable by Control Weather (faction provinces + neighbors)
- Filters out provinces that already have a drought

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 17:16:15 -08:00
70079782ec Add AI quest completion for prisoner management quests (#5933)
Implement prisoner management quest choosers that enable the AI to
complete ReleasePrisonerQuest, ExilePrisonerQuest, and
ReleaseAllPrisonersQuest.

New components:
- ManagePrisonersCommandSelector: Reusable selector for prisoner
  management commands (release, exile, return)
- ReleasePrisonerQuestCommandChooser: Releases specific prisoner
- ExilePrisonerQuestCommandChooser: Exiles specific prisoner
- ReleaseAllPrisonersQuestCommandChooser: Releases any available
  prisoner to make progress toward quest completion

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 17:15:47 -08:00
d514606b00 Add StartDroughtQuest client display (PR2 of 3) (#5939)
Add client-side display code for StartDroughtQuest:
- DisplayNames.cs: Add "Start Drought" quest type string
- UnaffiliatedHeroRowController.cs: Add quest description "Start a drought in {provinceName}"

Following the 3-PR approach from docs/ADDING_NEW_QUESTS.md:
- PR1 #5924: Types (merged)
- PR2: Client handling (this PR)
- PR3 #5934/#5935: Quest generation and fulfillment

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 16:50:23 -08:00
61df596aa1 Fix hex grid centering to eliminate magic Y=32 offset (#5938)
HexMetrics: Properly center the grid vertically by setting center.y to
0.75*OuterRadius. Previously the grid center ended up at -0.75*OuterRadius
instead of 0, requiring a compensating Y=32 offset on GameObjects.

Added detailed comments explaining the centering math for future reference.

HexGrid: Remove the OuterRadius/2 vertical offsets from overlay elements
(text labels, unit type images, profession images, secondary images,
terrain modifier images) that were compensating for the old centering bug.

Unity: Set Hex Grid and Overlay Mesh Y positions to 0.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 16:47:44 -08:00
3e33c3c932 Shardok widescreen layout support (#5937)
* Update Shardok layout for widescreen support

Unity scene changes for Shardok layout variants.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Set Weather Canvas screen overlay alpha to 0 in editor

The overlay color is set programmatically at runtime, so setting it
transparent in the editor makes it easier to work with the scene.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update Shardok layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update Shardok layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add overlay container spacer references for widescreen layout

Add overlayContainerTopSpacer and overlayContainerLeftSpacer references:
- Top spacer active on normal/narrow screens (with top row)
- Left spacer active on widescreen (with left column)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add vertical offset to overlay elements for new layout

Move all overlay elements (labels, unit type images, profession images,
secondary images, terrain modifier images) up by metrics.OuterRadius/2
to align with hex cells in the new layout group structure.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Simplify Shardok mesh and hierarchy structure

- Remove -90 rotation requirement from HexMesh by swapping Y/Z coordinates
- HexMesh now uses Y for vertical position, Z for depth (was reversed)
- HexMetrics corners updated to match new coordinate system
- Replace gridCanvas with gridTransform reference in HexGrid
- Update all animators to work with simplified hierarchy

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Consolidate HexGrid onto HexMesh GameObject

- Merge HexGrid component onto the HexMesh GameObject (renamed to Hex Grid)
- Remove redundant separate Hex Grid GameObject
- Move Overlay Mesh under Overlay Content to match Hex Grid hierarchy
- Fix Overlay Mesh transform to match Hex Grid (Y=32, no rotation)
- Update component references

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 16:35:41 -08:00
88d3b37a4c Add AI quest completion for TotalDevelopmentQuest (#5932)
Implement TotalDevelopmentQuestCommandChooser that enables the AI to
complete TotalDevelopmentQuest by improving the province until the
total development reaches the quest target.

The chooser:
- Prioritizes repairing devastation if total devastation >= 4
- Otherwise improves the province's lowest development stat (economy,
  agriculture, or infrastructure)
- Uses existing ImproveCommandSelector for command generation

Also reorders quest choosers in FulfillQuestsCommandSelector:
- Move TotalDevelopment right after Improve (higher priority)
- Move Alliance to end of list (lower priority)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 09:48:58 -08:00
794a1fb503 Add automatic failover to backup LLM providers on 5xx errors (#5931)
When the primary LLM provider returns a 5xx error (like 503 Service
Unavailable), the system now automatically fails over to backup providers.

Key changes:
- Add ServerError case to ExternalTextGenerationError for 5xx detection
- OkHttpSseListener now creates appropriate error types based on HTTP status
- ApiKeys gains hasOpenAI/hasAnthropic/hasGemini and availableProviders methods
- LlmResolver tracks provider health with circuit breaker pattern:
  - 3 consecutive 5xx failures marks provider unhealthy for 5 minutes
  - Automatic failover tries providers in order: primary → openai → claude → gemini
  - All failover events are logged with [LLM] prefix

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 08:19:23 -08:00
97c243fa95 Add StartDroughtQuest types (PR1 of 3) (#5924)
Add StartDroughtQuest similar to StartBlizzardQuest and StartEpidemicQuest.
This is PR1 of the 3-PR pattern for new quests (types only).

Changes:
- Proto: Added StartDroughtQuest message and field to QuestDetails oneof
- Scala: Added StartDroughtQuest case class
- Converter: Added toProto/fromProto cases
- Fulfillment: Added case (returns false - action quest)
- Failure: Added case (returns false - never fails)
- LLM prompts: Added descriptions for DivineMessage and QuestEnded
- DivineCommand: Added province ID extraction for notifications

Note: Also added missing StartEpidemicQuest cases in DivineCommand.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 08:00:10 -08:00
e0e0f88358 Fix Shardok reconnect bug passing empty NewGameRequest (#5930)
On stream disconnect, scheduleReconnect was passing None for newGameRequest
instead of using the stored request from pendingBattles. This caused an
infinite error loop when Shardok lost the game state (e.g., after a pod
restart) because it couldn't recreate the game without the map path.

The fix retrieves the stored NewGameRequest and passes it on reconnect,
allowing Shardok to recreate the game from scratch if needed.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 07:40:07 -08:00
8e541e6d11 Restrict iOS app to iPad only (#5929)
The UI is too small on iPhone screens. Change targetDevice from 2
(Universal) to 1 (iPad only) so the app only appears in the App Store
for iPad users.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 22:58:53 -08:00
7afbde69f1 Fix TestFlight upload for Xcode 14+ (#5927)
Replace deprecated altool (removed in Xcode 14) with xcodebuild
-exportArchive using App Store Connect API authentication.

Changes:
- upload_testflight.sh: Use xcodebuild with destination=upload
  and API key authentication instead of altool
- ios_testflight.yml: Pass xcarchive path and use new API key secrets

Required new GitHub secrets:
- APP_STORE_CONNECT_API_KEY_ID
- APP_STORE_CONNECT_API_ISSUER_ID
- APP_STORE_CONNECT_API_KEY (contents of .p8 file)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 22:58:43 -08:00
e02afd112e Add documentation for AI quest completion behavior (#5928)
Documents which quests the AI proactively attempts to complete via
FulfillQuestsCommandSelector and which quests are not handled.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 22:49:23 -08:00
20b82af479 Allow Feast command when no heroes need vigor/loyalty boost (#5926)
Previously, Feast was only available when at least one hero had vigor < constitution
or loyalty < 100. This prevented players from completing SpendOnFeastsQuest when
all heroes were already at full stats.

Now Feast is available whenever the province has enough gold.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 22:37:01 -08:00
282b5fefa9 Add right-click province selection for Move in Manage Prisoners (#5923)
Override TargetedProvince to allow selecting the Move destination by
right-clicking a province on the map. When a valid move destination is
clicked, the Move toggle is enabled and the dropdown is set to that
province.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 21:47:26 -08:00
9549cf32e4 Fix RestProvinceQuest not incrementing when resting (#5925)
* Fix RestProvinceQuest not incrementing when resting

RestProvinceQuest is a ComponentQuest that should increment progress when the
player uses the Rest command in the target province. The increment logic was
missing.

Changes:
- RestCommand: Added quest increment logic to check for RestProvinceQuest
  where targetProvinceId matches the resting province
- CommandFactory: Pass factionProvinces to RestCommand.make
- BUILD.bazel: Added quest and unaffiliated_hero dependencies

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix RestCommandTest to pass factionProvinces parameter

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 21:40:22 -08:00
a1df9e87ee Allow ally-controlled provinces to count for BorderSecurityQuest (#5922)
The "secure borders" quest (BorderSecurityQuest) now counts provinces owned
by allies (actual alliances, not just truces) towards fulfillment. Previously,
only provinces directly controlled by the quest faction would count.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 20:56:42 -08:00
0bd6319c29 Fix ReconProvincesQuest and ReconSpecificProvincesQuest progress not incrementing (#5921)
When a recon succeeds, the quest counter wasn't being updated. Added logic to
PerformReconResolutionAction to increment quest progress using the existing
QuestFulfillmentUtils.withCountersIncremented pattern.

- ReconProvincesQuest: increments by 1 for any successful recon
- ReconSpecificProvincesQuest: increments by 1 if the reconned province is a target

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 20:41:33 -08:00
608a5fc0a5 Fix SendSuppliesQuest immediately failing (#5920)
The failure check had inverted logic - it was checking if the target
province is owned by the same faction (which is always true at creation)
instead of checking if we no longer own it. Now uses the same logic as
RestProvinceQuest.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 20:15:33 -08:00
201f637843 Fix alliance resolution command selector crash (#5918)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 19:49:11 -08:00
fa43fd6f48 Fix RepairDevastationQuest progress not incrementing (#5919)
ImproveCommand was not updating quest progress when repairing devastation.
Added quest progress tracking similar to how FeastCommand handles
SpendOnFeastsQuest - increments componentsFulfilled for any unaffiliated
hero with a RepairDevastationQuest across all faction provinces.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 19:48:35 -08:00
37f9acf847 Add hero backstory popup on hover for free heroes (#5917)
* Add hero backstory popup on hover for free heroes

Add LongHoverRowChanged handler to FreeHeroesTableController to show
the hero backstory popup when hovering over unaffiliated heroes, matching
the behavior of resident heroes in HeroesAndBattalionsPanelController.

Unity wiring needed:
- Wire popupPanel, popupPanelDetailsController, popupPanelBackstory refs
- Set LongHoverRowChangedHandler on unaffiliatedHeroesTable to call
  FreeHeroesTableController.LongHoverRowChanged

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Wire up free hero backstory popup in Unity scene

Connect popup panel references and LongHoverRowChangedHandler for
unaffiliated heroes table.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Add TableRowHoverDetector to UnaffiliatedHeroRow prefab

Required for hover detection to work on free hero rows.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 19:33:29 -08:00
2ce4c7f0fb Restrict blizzard and epidemic quests by hero profession (#5915)
Blizzard quests (StartBlizzardQuest) now require the faction to have a
Mage hero, and epidemic quests (StartEpidemicQuest) require a Necromancer.
This matches the profession requirements for the corresponding actions.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 19:14:03 -08:00
9cd57390e7 Handle missing user in streamUpdates gracefully (#5916)
Instead of throwing NoSuchElementException when a userId is not found
in userIdToFactionId (which can happen after server restart), send an
UNAUTHENTICATED error to the client so it knows to reconnect.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 19:13:15 -08:00
a1b26841dc Fix PhysX mesh collider error on empty hex mesh (#5914)
Defer mesh collider assignment to the next frame to avoid PhysX cooking
errors during initial Shardok setup. The error only occurs on the first
battle after launch, suggesting a timing issue with mesh initialization.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 18:47:16 -08:00
adminandGitHub 61a2d2d988 Shardok layout variants for widescreen support (#5913) 2026-02-06 17:15:03 -08:00
cd5aed4cde Minor layout and notification panel updates (#5912)
* Minor layout update

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update notification panel layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 16:35:11 -08:00
f1c825b37e Update diplomacy and quest panel layouts (#5911)
* Update Please Recruit Me quest UI layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update Ransom Offer Panel UI layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update Alliance, Break Alliance, and Truce offer panel layouts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 16:14:28 -08:00
6d68d5dc8b Update Recon, Start Epidemic, and Swear Brotherhood quest UI layouts (#5910)
* Update Recon quest UI layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update Start Epidemic quest UI layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update Swear Brotherhood quest UI layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 15:33:35 -08:00
748d5afee2 Show province count progress in Develop/Mobilize quest descriptions (#5909)
Quest descriptions now show both the current province count and months
completed, e.g., "Maintain 3 provinces with Develop orders for 4 months
(2/3 provinces, 1/4 months)"

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 15:19:46 -08:00
c1bbb11edc Convert Manage Prisoners to button-style toggles and update Ransom Panel layout (#5908)
* Convert Manage Prisoners to button-style toggles

- Add ToggleGroup and ConfigureToggle for consistent toggle styling
- Remove individual RawImage icon references
- Use CanvasGroup alpha for disabled state styling (35%)
- Default selection priority: Release > Move > Exile > Execute > Return
- Move dropdown only visible when Move option is selected

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Update Ransom Panel layout

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 15:08:47 -08:00
1e9953bc56 Add ReconSpecificProvincesQuest generation (#5901)
- Add reconSpecificProvincesQuests to QuestCreationUtils
- Creates quests with 2-4 specific province IDs to recon
- Targets provinces not controlled by the faction

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 13:37:52 -08:00
647ad25bb4 Add SpendOnFeastsQuest generation (#5893)
* Add SpendOnFeastsQuest generation

- Add spendOnFeastsQuests to QuestCreationUtils that creates quests
  requiring 2-4 feasts spending 200-500 gold total
- Modify FeastCommand to track quest progress when feasts are held
- Pass factionLeaderProvinces to FeastCommand from CommandFactory
- Add quest_fulfillment_utils, quest, and unaffiliated_hero deps

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix FeastCommandTest to pass factionLeaderProvinces parameter

The FeastCommand.make method was updated to require a factionLeaderProvinces
parameter for quest progress tracking, but the test file wasn't updated.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Simplify SpendOnFeastsQuest to track total gold spent

- Remove factionLeaderProvinces parameter from FeastCommand
- Track gold spent (componentCount = totalGold, increment by goldCost per feast)
- Only check current province for quest progress
- Simplify quest creation (single random for totalGold target)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Change SpendOnFeastsQuest target gold range to 500-1000

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 13:26:46 -08:00
7221ee48d6 Improve DivineCommandTest stability for notification assertions (#5907)
Check each notification's field associations individually while ignoring
affectedProvinceIds, which varies based on random quest assignment. This
prevents the test from failing every time a new quest type is added.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 13:15:01 -08:00
6bd0934144 Add RepairDevastationQuest generation (#5897)
- Add repairDevastationQuests to QuestCreationUtils
- Creates quests requiring repair of 30-100 devastation points
- Update test expectations for changed random sequence

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 12:52:23 -08:00
b656ba4518 Add ReconProvincesQuest generation (#5896)
* Add ReconProvincesQuest generation

- Add reconProvincesQuests to QuestCreationUtils
- Creates quests requiring reconnaissance of 3-6 provinces
- Update test expectations for changed random sequence

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix DivineCommandTest expected affectedProvinceIds

The random sequence changed with the addition of reconProvincesQuests,
causing the quest generation to produce different quests.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 11:25:56 -08:00
f4902727a5 Optimize BUILD.bazel dependency check and run lint in parallel (#5906)
1. Speed up check_build_deps.sh:
   - Cache expensive `bazel query deps(...)` results
   - Reuse cached deps for all three checks instead of running
     separate bazel query commands
   - Use grep filtering on cached results instead of bazel intersect

2. Run lint job in parallel with test job:
   - Split bazel_test.yml into separate lint and test jobs
   - Jobs run concurrently, reducing total CI time

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 11:24:09 -08:00
773 changed files with 61256 additions and 34573 deletions
+3 -1
View File
@@ -42,5 +42,7 @@ 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,3 +11,19 @@ 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
+2 -1
View File
@@ -41,7 +41,8 @@ jobs:
set -ex
# Build auth server image (Go binary has explicit goos/goarch in BUILD.bazel)
bazel build //ci:auth_server_image
# --stamp is needed for authservice x_defs (git commit, build time)
bazel build --stamp //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,6 +13,8 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
lfs: false
- name: Show disk usage before cleanup
run: |
+13 -2
View File
@@ -23,13 +23,16 @@ on:
- '!src/main/csharp/**'
- '!src/test/csharp/**'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
test:
lint:
runs-on: [self-hosted, bazel]
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -37,6 +40,14 @@ jobs:
lfs: false
- name: Check BUILD.bazel dependencies
run: ./scripts/check_build_deps.sh --strict
test:
runs-on: [self-hosted, bazel]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Run tests
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
- name: Collect failed test logs
+88 -48
View File
@@ -62,8 +62,10 @@ jobs:
# 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 \
@@ -89,6 +91,13 @@ jobs:
echo "Admin: $ADMIN_PATH"
echo "JFR Sidecar: $JFR_PATH"
- name: Upload warmup binary
uses: actions/upload-artifact@v4
with:
name: warmup-binary
path: scripts/bin/warmup
retention-days: 1
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
@@ -110,40 +119,59 @@ jobs:
GIT_SHA=$(git rev-parse --short=8 HEAD)
# 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)
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
# 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
echo "Using crane: $CRANE"
# Push Eagle image
# Set up image paths and tags
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}"
# 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"
$CRANE copy "$JFR_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
$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"
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 "eagle_image_tag=$EAGLE_TAG" >> $GITHUB_OUTPUT
echo "admin_image_tag=$ADMIN_TAG" >> $GITHUB_OUTPUT
echo "jfr_sidecar_image_tag=$JFR_TAG" >> $GITHUB_OUTPUT
echo "=== All images pushed successfully ==="
@@ -192,6 +220,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
sparse-checkout: |
docker-compose.prod.yml
nginx
scripts
sparse-checkout-cone-mode: false
- name: Setup SSH key
run: |
@@ -200,11 +235,11 @@ jobs:
chmod 600 ~/.ssh/deploy_key
ssh-keyscan -H "$DO_DROPLET_IP" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Build warmup tool
run: |
bazel build //ci:warmup_tar
mkdir -p scripts/bin
tar -xf bazel-bin/ci/warmup_tar.tar -C scripts/bin --strip-components=1
- name: Download warmup binary
uses: actions/download-artifact@v4
with:
name: warmup-binary
path: scripts/bin/
- name: Copy config files to droplet
run: |
@@ -322,29 +357,34 @@ jobs:
echo "Using images: \$EAGLE_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
# 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
# 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
# Pull and load all images
echo "Pulling Eagle image..."
./crane pull "\${EAGLE_IMAGE}" eagle.tar && docker load -i eagle.tar && rm eagle.tar
# 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
echo "Pulling Admin image..."
./crane pull "\${ADMIN_IMAGE}" admin.tar && docker load -i admin.tar && rm admin.tar
echo "Loading images..."
docker load -i eagle.tar && rm eagle.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 "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"
echo "All images pulled and loaded successfully"
# =================================================================
# Verify Shardok connectivity before proceeding with deployment
+5 -11
View File
@@ -1,17 +1,7 @@
name: Eagle Build
on:
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'
# Main pushes are covered by docker_build.yml which builds the same target
pull_request:
paths:
- 'src/main/scala/**'
@@ -23,6 +13,10 @@ on:
- '.bazelrc'
- '.github/workflows/eagle_build.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
permissions:
contents: read
+21 -12
View File
@@ -20,6 +20,10 @@ 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
@@ -46,7 +50,12 @@ jobs:
- uses: actions/checkout@v4
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: true
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/
- name: Fetch LFS files
run: |
@@ -56,20 +65,9 @@ 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:
@@ -77,6 +75,17 @@ 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
+32 -75
View File
@@ -11,7 +11,6 @@ on:
permissions:
contents: read
actions: write
env:
# Runner-specific build directory to allow parallel builds on multiple runners
@@ -21,9 +20,7 @@ env:
jobs:
build-unity:
runs-on: [self-hosted, macOS, unity-mac]
outputs:
xcode_project_path: ${{ steps.build.outputs.xcode_project_path }}
runs-on: [self-hosted, macOS, testflight]
steps:
- name: Prune stale PR refs
@@ -40,9 +37,14 @@ jobs:
- uses: actions/checkout@v4
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: true
clean: false # Library/ persists between runs on self-hosted runners
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
@@ -51,22 +53,10 @@ 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()
@@ -75,18 +65,16 @@ jobs:
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: ./ci/github_actions/upload_addressables.sh iOS
- name: Zip Xcode project for artifact
- name: Purge CDN cache for iOS addressables
if: success()
env:
DO_CDN_PAT: ${{ secrets.DO_CDN_PAT }}
run: |
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
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()
@@ -95,37 +83,12 @@ 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, unity-mac]
runs-on: [self-hosted, macOS, testflight]
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 }}
@@ -184,11 +147,23 @@ jobs:
- name: Upload to TestFlight
if: ${{ github.event.inputs.skip_upload != 'true' }}
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
# App Store Connect API Key (replaces deprecated altool with Apple ID)
# Create at: https://appstoreconnect.apple.com/access/api
APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
APP_STORE_CONNECT_API_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
# Write API key to file (xcodebuild needs a file path)
API_KEY_PATH=$(mktemp)
echo "$APP_STORE_CONNECT_API_KEY" > "$API_KEY_PATH"
export APP_STORE_CONNECT_API_KEY_PATH="$API_KEY_PATH"
chmod +x ./ci/github_actions/upload_testflight.sh
./ci/github_actions/upload_testflight.sh "$EAGLE0_BUILD_DIR/archive/eagle0.ipa"
./ci/github_actions/upload_testflight.sh "$EAGLE0_BUILD_DIR/archive/eagle0.xcarchive" "$APPLE_TEAM_ID" "$PROFILE_UUID"
# Cleanup
rm -f "$API_KEY_PATH"
- name: Upload IPA artifact
# Only keep artifact if we skipped TestFlight upload (for debugging)
@@ -206,21 +181,3 @@ 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
+58 -50
View File
@@ -25,25 +25,19 @@ 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:
@@ -53,6 +47,10 @@ 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
@@ -85,9 +83,30 @@ jobs:
- uses: actions/checkout@v4
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: true # Remove untracked files like old SparklePlugin.bundle
clean: false # Library/ persists between runs on self-hosted runners
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
@@ -96,19 +115,12 @@ 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: Persist Library/
- name: Save build SHA for Bee/ cache invalidation
if: success()
env:
UNITY_CACHE_PLATFORM: mac
run: ./ci/github_actions/persist_library.sh
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
@@ -285,6 +297,8 @@ 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
@@ -307,6 +321,7 @@ 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 }}
@@ -337,44 +352,37 @@ jobs:
rm "$SPARKLE_PRIVATE_KEY_PATH"
# Export version for notify step
echo "DEPLOYED_VERSION=$VERSION" >> $GITHUB_ENV
echo "deployed_version=$VERSION" >> $GITHUB_OUTPUT
- 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"
# Artifact cleanup is handled by the cleanup job on ubuntu-latest
- 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]
needs: [build-and-sign, wait-notarization, deploy, notify-mac]
if: always()
runs-on: ubuntu-latest
+20 -15
View File
@@ -20,6 +20,10 @@ on:
default: 'true'
type: boolean
concurrency:
group: shardok-arm64-deploy
cancel-in-progress: false
permissions:
contents: read
@@ -60,12 +64,14 @@ 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" = "b700" ]; then
if [ "$E_MACHINE" = "00b7" ]; then
echo "SUCCESS: Binary is ARM64 (aarch64)"
else
echo "WARNING: Binary e_machine is $E_MACHINE (expected b700 for aarch64)"
echo "WARNING: Binary e_machine is $E_MACHINE (expected 00b7 for aarch64)"
fi
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
@@ -128,20 +134,19 @@ jobs:
exit 1
fi
# 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)
# 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"
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)
+5 -12
View File
@@ -1,18 +1,7 @@
name: Shardok Build
on:
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'
# Main pushes are covered by shardok_arm64_build.yml which builds the same target
pull_request:
paths:
- 'src/main/cpp/**'
@@ -25,6 +14,10 @@ on:
- '.bazelrc'
- '.github/workflows/shardok_build.yml'
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
permissions:
contents: read
+57 -26
View File
@@ -15,8 +15,6 @@ 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"
@@ -36,14 +34,16 @@ 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,6 +54,8 @@ 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
@@ -70,7 +72,28 @@ jobs:
- uses: actions/checkout@v4
with:
lfs: false # Fetch LFS after checkout to avoid stale ref issues
clean: true # Remove untracked files from previous builds
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
- name: Fetch LFS files
run: |
@@ -78,17 +101,13 @@ 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: Persist Library/
- name: Save build SHA for Bee/ cache invalidation
if: success()
env:
UNITY_CACHE_PLATFORM: windows
run: ./ci/github_actions/persist_library.sh
run: git rev-parse HEAD > src/main/csharp/net/eagle0/clients/unity/eagle0/Library/.last_built_sha
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
env:
@@ -126,21 +145,12 @@ jobs:
# Cleanup
rm -f /tmp/manifest_signing_key
- name: Notify clients of update
- name: Export deployed version
id: get-version
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)
# 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)"
echo "deployed_version=$VERSION" >> $GITHUB_OUTPUT
- name: Archive build log
if: success() || failure()
@@ -151,4 +161,25 @@ jobs:
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
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)"
+3
View File
@@ -37,5 +37,8 @@ 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/
+5
View File
@@ -225,6 +225,9 @@ 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):**
@@ -333,6 +336,8 @@ 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
+31 -2
View File
@@ -126,6 +126,26 @@ 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
#
@@ -134,7 +154,7 @@ use_repo(
# in a separate workspace at sparkle_workspace/ to isolate the conflict.
#
bazel_dep(name = "apple_support", version = "1.23.1", repo_name = "build_bazel_apple_support")
bazel_dep(name = "apple_support", version = "1.24.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)
@@ -160,8 +180,9 @@ 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.0")
bazel_dep(name = "grpc", version = "1.74.1")
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")
#
@@ -180,8 +201,12 @@ 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",
@@ -190,6 +215,7 @@ 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",
@@ -283,6 +309,9 @@ 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,
+2083 -10
View File
File diff suppressed because one or more lines are too long
+11
View File
@@ -31,6 +31,17 @@ 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
@@ -1,33 +0,0 @@
#!/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
@@ -1,16 +0,0 @@
#!/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/
+80 -26
View File
@@ -1,48 +1,102 @@
#!/usr/bin/env bash
# Upload IPA to TestFlight using Apple ID credentials (same as Mac notarization)
# Upload xcarchive to TestFlight using App Store Connect API Key
# This replaces the deprecated altool which was removed in Xcode 14+
#
# Uses xcodebuild -exportArchive with destination=upload, which is Apple's
# recommended approach for CI/CD pipelines.
set -euxo pipefail
IPA_PATH=${1:?Usage: upload_testflight.sh <ipa_path>}
# Ensure xcodebuild uses Xcode.app, not Command Line Tools
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
if [ ! -f "$IPA_PATH" ]; then
echo "Error: IPA file not found: $IPA_PATH"
ARCHIVE_PATH=${1:?Usage: upload_testflight.sh <xcarchive_path> <team_id> <profile_uuid>}
TEAM_ID=${2:?Missing team ID}
PROFILE_UUID=${3:?Missing provisioning profile UUID}
if [ ! -d "$ARCHIVE_PATH" ]; then
echo "Error: xcarchive not found: $ARCHIVE_PATH"
exit 1
fi
echo "Uploading to TestFlight: $IPA_PATH"
echo "Uploading to TestFlight: $ARCHIVE_PATH"
# Uses same credentials as Mac notarization:
# - APPLE_ID: Your Apple ID email
# - APP_SPECIFIC_PASSWORD: App-specific password from appleid.apple.com
# Requires App Store Connect API Key:
# - APP_STORE_CONNECT_API_KEY_ID: Key ID from App Store Connect
# - APP_STORE_CONNECT_API_ISSUER_ID: Issuer ID from App Store Connect
# - APP_STORE_CONNECT_API_KEY_PATH: Path to .p8 private key file
#
# To create an API key:
# 1. Go to https://appstoreconnect.apple.com/access/api
# 2. Click the + button to create a new key
# 3. Give it a name and Admin or App Manager access
# 4. Download the .p8 file (you can only download it once!)
# 5. Note the Key ID and Issuer ID shown on the page
if [ -z "${APPLE_ID:-}" ]; then
echo "Error: APPLE_ID environment variable not set"
if [ -z "${APP_STORE_CONNECT_API_KEY_ID:-}" ]; then
echo "Error: APP_STORE_CONNECT_API_KEY_ID environment variable not set"
echo "Create an API key at https://appstoreconnect.apple.com/access/api"
exit 1
fi
if [ -z "${APP_SPECIFIC_PASSWORD:-}" ]; then
echo "Error: APP_SPECIFIC_PASSWORD environment variable not set"
if [ -z "${APP_STORE_CONNECT_API_ISSUER_ID:-}" ]; then
echo "Error: APP_STORE_CONNECT_API_ISSUER_ID environment variable not set"
exit 1
fi
echo "Uploading with xcrun altool..."
# Capture output to check for errors (altool may return 0 even on failure)
OUTPUT=$(xcrun altool --upload-app \
--type ios \
--file "$IPA_PATH" \
--username "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" 2>&1) || true
echo "$OUTPUT"
# Check for error indicators in output
if echo "$OUTPUT" | grep -q "ERROR:"; then
echo "ERROR: Upload failed. See error messages above."
if [ -z "${APP_STORE_CONNECT_API_KEY_PATH:-}" ]; then
echo "Error: APP_STORE_CONNECT_API_KEY_PATH environment variable not set"
exit 1
fi
if [ ! -f "$APP_STORE_CONNECT_API_KEY_PATH" ]; then
echo "Error: API key file not found: $APP_STORE_CONNECT_API_KEY_PATH"
exit 1
fi
# Create export options plist with upload destination
EXPORT_OPTIONS_PLIST=$(mktemp)
trap "rm -f $EXPORT_OPTIONS_PLIST" EXIT
cat > "$EXPORT_OPTIONS_PLIST" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>destination</key>
<string>upload</string>
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>$TEAM_ID</string>
<key>uploadSymbols</key>
<true/>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Apple Distribution: Daniel Crosby (UWJ88DX8WQ)</string>
<key>provisioningProfiles</key>
<dict>
<key>net.eagle0.eagle</key>
<string>$PROFILE_UUID</string>
</dict>
</dict>
</plist>
EOF
echo "Export options:"
cat "$EXPORT_OPTIONS_PLIST"
echo "Uploading to App Store Connect..."
# Use xcodebuild to upload with App Store Connect API authentication
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST" \
-authenticationKeyPath "$APP_STORE_CONNECT_API_KEY_PATH" \
-authenticationKeyID "$APP_STORE_CONNECT_API_KEY_ID" \
-authenticationKeyIssuerID "$APP_STORE_CONNECT_API_ISSUER_ID"
echo "Upload complete! Check App Store Connect for processing status."
echo "The build should appear in TestFlight within 15-30 minutes after processing."
+218
View File
@@ -0,0 +1,218 @@
# 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
+1
View File
@@ -77,3 +77,4 @@ 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
+240
View File
@@ -0,0 +1,240 @@
# Tutorial Battle System
This document describes the tutorial battle system for first-time players. The system provides a scripted introductory battle that teaches combat basics while telling a story.
---
## Overview
When a new player starts their first game in tutorial mode, they experience:
1. **Narrative Intro** - Story screens introducing the scenario
2. **Immediate Battle** - Skip strategic map, start directly in combat
3. **Scripted Flee** - Enemy flees when player is "on the ropes"
4. **Reinforcements** - Allied heroes arrive mid-battle
5. **Heroes Join** - Reinforcement heroes join player's faction after battle
---
## Battle Configuration
### Defender (Player - John Ranil)
- **Heroes**: John Ranil + 2 random vassals (3 total)
- **Units**:
- 2x Light Infantry (300 troops each, 60 training/armament)
- 1x Longbowmen (200 troops, 60 training/armament)
- **Province**: 14 (Onmaa) with 40 support, 500 gold, 3000 food
- **Restriction**: Cannot flee (must defend)
### Attacker (Ikhaan Tarn)
- **Heroes**: Ikhaan Tarn + 2 sworn brothers (3 total)
- **Units**:
- 1x Heavy Cavalry (600 troops, 80 training/armament)
- 1x Heavy Infantry (500 troops, 80 training/armament)
- 1x Longbowmen (300 troops, 80 training/armament)
- **Origin Province**: 32
### Flee Trigger
Attacker flees when ANY of these conditions are met:
- Player loses 1 unit
- 5 rounds have passed
### Reinforcements
When Tarn flees, these heroes arrive as player reinforcements:
- Elena Fyar
- Hedrick
- The Boulder
---
## Implementation Architecture
### Phase 1: Narrative Intro System
**Proto Changes:**
- `game_state_view.proto`: Added `TutorialNarrativeScreen` message and `pending_narrative_screens` field
- `game_parameters.proto`: Added `TutorialNarrativeScreen` and `tutorial_narrative_screens` field
**Unity Client:**
- `NarrativeScreenController.cs`: Modal screen with title, body text, optional image
- `EagleGameController.cs`: Checks for pending narrative screens on game start
**Narrative Content** (defined in `tutorial_game_parameters.json`):
```
Screen 1: "The Engineer of Onmaa" - Player backstory
Screen 2: "The Traitor Arrives" - Tarn's attack
Screen 3: "Defend Your Home" - Call to action
```
### Phase 2: Auto-Start Battle
**Configuration:**
- `tutorial_game_parameters.json`: Contains `tutorialBattle` config with attacker, target, flee conditions
- `game_parameters.proto`: `TutorialBattleConfig` message
**Game Creation:**
- `NewGameCreation.scala`: `setupTutorialBattle()` method creates:
- `HostileArmyGroup` with `Attacking` status (bypasses decision phase)
- `Army` for defender
- Both added to target province
**Battle Creation:**
- When round advances to `BattleRequest` phase, `RequestBattlesAction` automatically creates the `ShardokBattle`
### Phase 3: Scripted Flee Mechanism
**C++ Component:**
- `TutorialBattleController.hpp/cpp`: Controller for scripted battle logic
**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);
```
**Integration:**
- `ShardokEngine.cpp`: Calls `CheckAndExecuteEvents()` at end of each round in `HandlePlayerTurnEnd()`
- Events fire in config order, each event fires at most once
### Phase 4: Mid-Battle Reinforcements
**Action Types** (in `action_type.proto`):
- `TUTORIAL_ENEMY_FLED = 78`
- `TUTORIAL_REINFORCEMENTS_ARRIVED = 79`
**Implementation (TODO):**
- When scripted flee triggers, create reinforcement units in defender's reserves
- Units have `UnitStatus_RESERVE_UNIT` status
- Reinforcements enter battle on subsequent turns
### Phase 5: Tutorial Popups
**Content Definitions** (in `TutorialContentDefinitions.cs`):
- `tutorial_battle_started`: "Defend your province!"
- `tutorial_enemy_fled`: "The enemy retreats!"
- `tutorial_reinforcements_arrived`: "Allies have arrived!"
- `tutorial_battle_victory`: Victory celebration
**Trigger Integration:**
- `TutorialTriggerRegistry.cs`: Handles tutorial action types from `ActionResultView`
- `ShardokGameController.cs`: Detects tutorial events and triggers popups
### Phase 6: Post-Battle Hero Joining
**Implementation (TODO):**
- `ResolveBattleAction.scala`: Check if battle was tutorial and defender won
- Create `TutorialHeroJoined` action results adding reinforcement heroes to player faction
- Set appropriate loyalty, vigor, location for joined heroes
---
## File Summary
### New Files
| File | Purpose |
|------|---------|
| `TutorialBattleController.hpp/cpp` | C++ scripted flee and reinforcement logic |
| `NarrativeScreenController.cs` | Unity narrative screen display |
### Modified Files
| File | Changes |
|------|---------|
| `tutorial_game_parameters.json` | Battle configuration and narrative content |
| `game_state_view.proto` | Narrative screens field |
| `game_parameters.proto` | Tutorial battle config |
| `player_info.proto` | Tutorial battle flags for Shardok |
| `action_type.proto` | Tutorial action types |
| `NewGameCreation.scala` | Auto-start battle setup |
| `EagleGameController.cs` | Narrative display integration |
| `TutorialContentDefinitions.cs` | Battle tutorial content |
| `TutorialTriggerRegistry.cs` | Tutorial event triggers |
---
## Remaining Implementation
### Disable Player Flee
In `FleeCommandFactory.cpp`:
- Check if tutorial mode is enabled
- If defender, don't offer flee commands
### Reinforcement Units
In `TutorialBattleController::ExecuteScriptedFlee()`:
- Create `Unit` objects for Elena Fyar, Hedrick, The Boulder
- Add to defender's reserve units
- Generate `TUTORIAL_REINFORCEMENTS_ARRIVED` action
### Post-Battle Hero Joining
In Eagle's battle resolution:
- Detect tutorial battle completion
- Create heroes in player's faction with proper stats
- Generate notification action results
---
## Testing Checklist
- [ ] New user sees narrative screens before battle
- [ ] Battle starts immediately after narratives (no strategic map)
- [ ] Player has correct units (3 heroes, 3 battalions)
- [ ] Attacker has correct units (3 heroes, 3 battalions)
- [ ] Player cannot flee
- [ ] Tarn flees after player loses 1 unit
- [ ] Tarn flees after 5 rounds (if no units lost)
- [ ] Reinforcements appear when Tarn flees
- [ ] Tutorial popups appear at correct moments
- [ ] Reinforcement heroes join faction after battle victory
---
## Configuration Reference
### tutorial_game_parameters.json
```json
{
"tutorialBattle": {
"attackerFactionHead": "Ikhaan Tarn",
"targetProvinceId": 14,
"fleeAfterDefenderUnitsLost": 1,
"fleeAfterRounds": 5,
"reinforcements": ["Elena Fyar", "Hedrick", "The Boulder"]
},
"tutorialNarrativeScreens": [
{ "title": "...", "bodyText": "...", "imagePath": "" }
]
}
```
### TutorialBattleConfig Proto (Shardok)
```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
}
}
```
+113
View File
@@ -0,0 +1,113 @@
# AI Quest Completion Behavior
This document describes which quests the AI attempts to complete proactively to recruit unaffiliated heroes.
## Overview
The AI attempts to complete quests via `FulfillQuestsCommandSelector`, which is invoked by `MidGameAIClient.chosenFulfillEasyQuestsCommand`. The AI only considers quests from unaffiliated heroes in provinces ruled by a faction leader.
## Quests the AI Actively Completes
### Diplomacy Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `AllianceQuest` | `AllianceQuestCommandChooser` | Target faction must meet trust conditions for alliance and not already be in an alliance |
| `TruceWithFactionQuest` | `TruceWithFactionQuestCommandChooser` | Target faction must meet trust conditions for truce |
| `TruceCountQuest` | `TruceCountQuestCommandChooser` | Picks a random faction that meets trust conditions and isn't already in a truce/alliance |
### Resource Giving Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `AlmsToProvinceQuest` | `AlmsToProvinceQuestCommandChooser` | Gives food to the specified province |
| `AlmsAcrossRealmQuest` | `AlmsAcrossRealmQuestCommandChooser` | Gives food from the province with the largest surplus |
| `GiveToHeroesInProvinceQuest` | `GiveToHeroesInProvinceQuestCommandChooser` | Gives gold to the hero with the lowest loyalty in the specified province |
| `GiveToHeroesAcrossRealmQuest` | `GiveToHeroesAcrossRealmQuestCommandChooser` | Gives gold from the province with the most gold available |
### Province Development Quests
| Quest | Handler | Notes |
|-------|---------|-------|
| `ImproveAgricultureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Agriculture type |
| `ImproveEconomyQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Economy type |
| `ImproveInfrastructureQuest` | `ImproveQuestCommandChooser` | Issues an Improve command with Infrastructure type |
### Other Quests
| Quest | Handler | Conditions |
|-------|---------|------------|
| `DismissSpecificVassalQuest` | `DismissSpecificVassalCommandChooser` | Only if province has more than 2 heroes AND the unaffiliated hero's power >= target hero's power * `RequiredPowerMultiplierForDismiss` |
## Quests the AI Does Not Attempt to Complete
The following quests have no handler in `FulfillQuestsCommandSelector` and must be completed naturally through gameplay:
### Combat/Military Quests
- `DefeatFactionQuest` - Defeat a specific faction
- `GrandArmyQuest` - Accumulate a large number of troops
- `UpgradeBattalionQuest` - Upgrade a battalion to minimum armament/training
- `WinBattleOutnumberedQuest` - Win a battle while outnumbered
- `WinBattlesQuest` - Win a number of battles
- `RescueImprisonedLeaderQuest` - Rescue an imprisoned leader from another faction
### Expansion Quests
- `ExpandToProvincesQuest` - Expand to control a certain number of provinces
- `SpecificExpansionQuest` - Conquer a specific province
- `BorderSecurityQuest` - Have troops in a border province
### Prisoner Quests
- `ExecutePrisonerQuest` - Execute a specific prisoner
- `ExilePrisonerQuest` - Exile a specific prisoner
- `ReleasePrisonerQuest` - Release a specific prisoner
- `ReturnPrisonerQuest` - Return a prisoner to their faction
- `ReleaseAllPrisonersQuest` - Release all prisoners
### Province Order Quests
- `DevelopProvincesQuest` - Maintain provinces in Develop order for months
- `MobilizeProvincesQuest` - Maintain provinces in Mobilize order for months
- `RestProvinceQuest` - Use the Rest command in a specific province
### Reconnaissance Quests
- `ReconProvincesQuest` - Reconnoiter a number of provinces
- `ReconSpecificProvincesQuest` - Reconnoiter specific provinces
### Economic Quests
- `TotalDevelopmentQuest` - Achieve total development level in a province
- `WealthQuest` - Accumulate gold and food
- `SpendOnFeastsQuest` - Spend gold on feasts
- `SendSuppliesQuest` - Send food to a specific province
- `RepairDevastationQuest` - Repair devastation
### Special Event Quests
- `SuppressRiotByForceQuest` - Suppress a riot by force
- `FightBeastsAloneQuest` - Fight beasts alone
- `StartBlizzardQuest` - Start a blizzard in a province
- `StartEpidemicQuest` - Start an epidemic in a province
- `ApprehendOutlawQuest` - Apprehend an outlaw hero
### Miscellaneous
- `BattalionDiversityQuest` - Have diverse battalion types
- `SwearBrotherhoodWithHeroQuest` - Swear brotherhood with a specific hero
- `BetrayAllyQuest` - Betray an allied faction
## Implementation Details
The quest completion logic is located in:
- `FulfillQuestsCommandSelector.scala` - Main entry point, iterates through choosers
- `quest_command_selectors/` - Individual quest handlers
Each chooser extends either:
- `QuestCommandChooser` - For quests requiring randomness
- `DeterministicQuestCommandChooser` - For quests with deterministic command selection
The AI prioritizes quests in the order they appear in `FulfillQuestsCommandSelector.choosers`:
1. Alliance
2. TruceWithFaction
3. Improve (Agriculture/Economy/Infrastructure)
4. AlmsToProvince
5. GiveToHeroesInProvince
6. AlmsAcrossRealm
7. GiveToHeroesAcrossRealm
8. TruceCount
9. DismissSpecificVassal
@@ -0,0 +1,230 @@
# 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
@@ -0,0 +1,93 @@
# 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,6 +60,7 @@
"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,
@@ -173,6 +174,7 @@
"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,
@@ -816,6 +818,12 @@
},
"version": "2.0.16"
},
"org.xerial:sqlite-jdbc": {
"shasums": {
"jar": "6dc7464e3803648d3ff18a7359bab6adf079fcd8495b18991f6f5edcb8ac6e3b"
},
"version": "3.46.1.0"
},
"software.amazon.awssdk:annotations": {
"shasums": {
"jar": "ed03bb4ff78900307dc96bacdec70ddf80c17df3eb6761219573025649f08ff3"
@@ -1352,6 +1360,9 @@
"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",
@@ -2404,6 +2415,16 @@
"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"
],
@@ -2814,6 +2835,7 @@
"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",
@@ -2890,6 +2912,11 @@
"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"
+54 -4
View File
@@ -2,7 +2,57 @@
set -euxo pipefail
/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/
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 "Copying generated .cs files to $OUTPUT_DIR..."
rm -rf "$OUTPUT_DIR"
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="$OUTPUT_DIR/$subdir"
mkdir -p "$dest_dir"
find "$bin_dir" -name "*.cs" -exec cp {} "$dest_dir/" \;
done
/bin/echo "Done. Generated C# proto sources in $OUTPUT_DIR"
+26 -5
View File
@@ -22,11 +22,31 @@ NC='\033[0m' # No Color
MODE="${1:-check}"
EXIT_CODE=0
# Cache for expensive bazel query results
LIBRARY_DEPS_CACHE=""
MAIN_DEPS_CACHE=""
# Get deps of library/ (cached)
get_library_deps() {
if [ -z "$LIBRARY_DEPS_CACHE" ]; then
LIBRARY_DEPS_CACHE=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...)' 2>/dev/null || true)
fi
echo "$LIBRARY_DEPS_CACHE"
}
# Get deps of src/main (cached)
get_main_deps() {
if [ -z "$MAIN_DEPS_CACHE" ]; then
MAIN_DEPS_CACHE=$(bazel query 'deps(//src/main/...)' 2>/dev/null || true)
fi
echo "$MAIN_DEPS_CACHE"
}
# Rule 1: src/main should not depend on src/test
check_main_depends_on_test() {
echo -e "${YELLOW}Checking: src/main should not depend on src/test...${NC}"
violations=$(bazel query 'deps(//src/main/...) intersect //src/test/...' 2>/dev/null || true)
violations=$(get_main_deps | grep "^//src/test/" || true)
if [ -n "$violations" ]; then
echo -e "${RED}VIOLATION: src/main depends on src/test:${NC}"
@@ -43,7 +63,7 @@ check_main_depends_on_test() {
check_library_depends_on_scala_proto() {
echo -e "${YELLOW}Checking: library/ should not depend on Scala proto types...${NC}"
violations=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep "_scala_proto" || true)
violations=$(get_library_deps | grep "^//src/main/protobuf/.*_scala_proto$" || true)
if [ -z "$violations" ]; then
count=0
else
@@ -65,7 +85,7 @@ check_library_depends_on_scala_proto() {
check_library_depends_on_proto_converters() {
echo -e "${YELLOW}Checking: library/ should not depend on proto_converters...${NC}"
violations=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/scala/net/eagle0/eagle/model/proto_converters/...' 2>/dev/null | grep "^//" || true)
violations=$(get_library_deps | grep "^//src/main/scala/net/eagle0/eagle/model/proto_converters/" || true)
if [ -n "$violations" ]; then
count=$(echo "$violations" | wc -l | tr -d ' ')
@@ -85,7 +105,8 @@ check_library_depends_on_proto_converters() {
count_proto_deps() {
echo -e "${YELLOW}=== Proto dependency counts ===${NC}"
scala_proto_results=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep "_scala_proto" || true)
library_deps=$(get_library_deps)
scala_proto_results=$(echo "$library_deps" | grep "^//src/main/protobuf/.*_scala_proto$" || true)
if [ -z "$scala_proto_results" ]; then
scala_proto_count=0
else
@@ -94,7 +115,7 @@ count_proto_deps() {
echo "library/ Scala proto deps: $scala_proto_count"
# C++/Go proto deps are expected (map generation tools)
all_proto_count=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | wc -l | tr -d ' ')
all_proto_count=$(echo "$library_deps" | grep -c "^//src/main/protobuf/" || echo "0")
echo "library/ all proto deps (includes C++/Go build tools): $all_proto_count"
}
+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 > src/main/resources/net/eagle0/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_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
@@ -27,6 +27,7 @@ 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",
@@ -286,6 +286,29 @@ 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 tutorialResults = tutorialController_.CheckAndExecuteEvents(
gameState,
settingsGetter,
randomGenerator);
if (!tutorialResults.empty()) {
ApplyAndAddActionResults(tutorialResults);
// Update game status after tutorial events (e.g., attackers fled, defender wins)
ApplyAndAddActionResults(UpdateGameStatusAction(
GetCurrentGameState(),
criticalTileCoords,
settingsGetter,
true /* atEndOfRound */)
.Execute(gameState, randomGenerator));
// If tutorial events ended the game, don't start new round
if (GameIsOver()) { return; }
}
}
ApplyAndAddActionResults(
UpdateOpponentKnowledgeAction(settingsGetter).Execute(gameState, randomGenerator));
@@ -20,6 +20,7 @@
#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"
@@ -36,6 +37,7 @@ 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:
@@ -52,6 +54,8 @@ private:
const CoordsSet criticalTileCoords;
TutorialBattleController tutorialController_;
mutable CommandListSPtr cachedAvailableCommands{};
void ApplyAndAddActionResult(const ActionResult &result);
@@ -197,6 +201,11 @@ 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);
}
static inline auto GameIsOver(const fb::GameStatus *status) -> bool {
return (status->state() == net::eagle0::shardok::storage::fb::GameStatus_::State_VICTORY);
}
@@ -0,0 +1,18 @@
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/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",
],
)
@@ -0,0 +1,236 @@
//
// 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) -> std::vector<ActionResult> {
if (!enabled_) { return {}; }
std::vector<ActionResult> allResults;
// 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 results = ExecuteAction(event.action(), state, settings, randomGenerator);
allResults.insert(allResults.end(), results.begin(), results.end());
}
}
return allResults;
}
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,
[[maybe_unused]] const GameStateW& state,
[[maybe_unused]] const SettingsGetter& settings,
[[maybe_unused]] const std::shared_ptr<RandomGenerator>& randomGenerator) const
-> std::vector<ActionResult> {
switch (action.action_type_case()) {
case TutorialActionProto::kFlee: return ExecuteFleeAction(action.flee(), state);
case TutorialActionProto::kReinforcements:
return ExecuteReinforcementsAction(action.reinforcements());
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) {
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> specificUnits(fleeAction.unit_ids().begin(), fleeAction.unit_ids().end());
const bool fleeAllUnits = specificUnits.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 is a specific unit to flee, or if we're fleeing all
if (!fleeAllUnits && !specificUnits.contains(unit->unit_id())) { 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 -> std::vector<ActionResult> {
std::vector<ActionResult> results;
// Add reinforcements arrival notification if there are units
if (!reinforcementsAction.units().empty()) {
ActionResult reinforcementsResult{};
reinforcementsResult.set_type(ActionType::TUTORIAL_REINFORCEMENTS_ARRIVED);
results.push_back(reinforcementsResult);
}
return results;
}
} // namespace shardok
@@ -0,0 +1,102 @@
//
// TutorialBattleController.hpp
// eagle0
//
// Created for tutorial battle system.
// Copyright 2025 none. All rights reserved.
//
#ifndef TutorialBattleController_hpp
#define TutorialBattleController_hpp
#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/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;
/// 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) -> std::vector<ActionResult>;
/// 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
-> std::vector<ActionResult>;
// 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.
[[nodiscard]] auto ExecuteReinforcementsAction(
const net::eagle0::common::ReinforcementsAction& reinforcementsAction) const
-> std::vector<ActionResult>;
};
} // namespace shardok
#endif /* TutorialBattleController_hpp */
@@ -19,6 +19,7 @@ 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,6 +193,7 @@ void EagleInterfaceImpl::StartGame(const NewGameRequest &request) {
mapName,
customMapBytes,
month,
request.tutorial_battle_config(),
request.SerializeAsString());
}
@@ -30,6 +30,7 @@ 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) {
@@ -77,6 +78,7 @@ 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);
@@ -111,6 +113,7 @@ auto ShardokGamesManager::UnlockedCreateSpecifiedGame(
month,
units,
playerInfos,
tutorialConfig,
serializedRequest);
return LockedCreateGame(controller, playerSetupInfos, serializedRequest);
@@ -137,6 +140,7 @@ 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>();
@@ -155,6 +159,8 @@ auto SetUpController(
gameId,
playerInfos);
engine->SetTutorialBattleConfig(tutorialConfig);
return std::make_shared<ShardokGameController>(std::move(engine), mapName, serializedRequest);
}
@@ -17,6 +17,7 @@
#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"
@@ -25,6 +26,7 @@ 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;
@@ -101,6 +103,7 @@ public:
const string &mapName,
const string &customMapBytes,
int month,
const TutorialBattleConfigProto &tutorialConfig,
const string &serializedRequest) -> GameId;
auto UnlockedCreateFromGameStateBytes(
File diff suppressed because it is too large Load Diff
@@ -272,9 +272,8 @@ namespace Auth {
/// <summary>
/// Start OAuth login with specified provider.
/// 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.
/// In editor: Opens browser and polls for completion.
/// In standalone: Opens browser, quits app, deep link relaunches with token.
/// </summary>
public async Task<CheckOAuthStatusResponse> LoginAsync(OAuthProvider provider) {
EnsureConfigured();
@@ -288,6 +287,13 @@ 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
@@ -299,6 +305,7 @@ 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);
@@ -306,6 +313,28 @@ 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>
@@ -131,7 +131,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public GameObject availableGamesListArea;
public GameObject availableGamesListItemPrefab;
public GameObject waitingGamesListItemPrefab;
public GameObject createGameListItemPrefab;
public CreateGameItem createGameItem;
public Button tutorialButton;
public DropGameConfirmationPanel dropGameConfirmationPanel;
@@ -142,7 +143,6 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
List<GameInfo> fetchedRunningGames;
List<AvailableLeader> fetchedNewGameLeaders;
int fetchedMaxPlayerCount;
private EagleConnection eagleConnection;
private PersistentClientConnection _persistentClientConnection;
private HttpClient _httpClient;
@@ -411,27 +411,25 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
private void SetupLobbyUI() {
if (customBattleButton != null) { customBattleButton.onClick.AddListener(CustomBattle); }
if (cancelCustomBattleButton != null) {
cancelCustomBattleButton.onClick.AddListener(CancelCustomBattle);
}
customBattleButton.onClick.AddListener(CustomBattle);
cancelCustomBattleButton.onClick.AddListener(CancelCustomBattle);
tutorialButton.gameObject.SetActive(TokenStorage.DisplayName == "nolen");
tutorialButton.onClick.AddListener(CreateTutorialGame);
// Set up environment dropdown (only in Unity Editor)
if (lobbyEnvironmentDropdown != null) {
if (!Application.isEditor) {
lobbyEnvironmentDropdown.gameObject.SetActive(false);
} else {
lobbyEnvironmentDropdown.ClearOptions();
lobbyEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
lobbyEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
}
if (!Application.isEditor) {
lobbyEnvironmentDropdown.gameObject.SetActive(false);
} else {
lobbyEnvironmentDropdown.ClearOptions();
lobbyEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
lobbyEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
lobbyEnvironmentDropdown.onValueChanged.AddListener(OnLobbyEnvironmentChanged);
}
}
private void UpdateLobbyStatusDisplays() {
// Update environment dropdown selection
if (lobbyEnvironmentDropdown != null && _connectedEnvironmentIndex >= 0) {
if (_connectedEnvironmentIndex >= 0) {
// Temporarily remove listener to avoid triggering reconnect
lobbyEnvironmentDropdown.onValueChanged.RemoveListener(OnLobbyEnvironmentChanged);
lobbyEnvironmentDropdown.value = _connectedEnvironmentIndex;
@@ -439,7 +437,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
}
// Show current user from OAuth
if (lobbyUserText != null) { lobbyUserText.text = TokenStorage.DisplayName ?? ""; }
lobbyUserText.text = TokenStorage.DisplayName ?? "";
}
private void OnLobbyEnvironmentChanged(int newEnvironmentIndex) {
@@ -687,9 +685,14 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
fetchedNewGameLeaders = new List<AvailableLeader>();
fetchedNewGameLeaders.AddRange(lobbyResponse.NewGameOptions.AvailableLeaders);
fetchedMaxPlayerCount = lobbyResponse.NewGameOptions.MaxSupportedPlayers;
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;
ResourceFetcher.headshotFetcher.Prefetch(
fetchedNewGameLeaders.Select(a => a.ImagePath));
@@ -706,8 +709,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
var listItem = Instantiate(
runningGamesListItemPrefab,
runningGamesListArea.transform,
true);
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
false);
var runningGameItem = listItem.GetComponent<RunningGameItem>();
runningGameItem.SetRunningGame(
runningGame.GameId,
@@ -724,8 +726,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
var listItem = Instantiate(
waitingGamesListItemPrefab,
availableGamesListArea.transform,
true);
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
false);
listItem.GetComponent<WaitingGameItem>().SetWaitingGame(
waitingGame.GameId,
string.Format(
@@ -735,14 +736,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
waitingGame.SelectedLeader);
}
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;
createGameItem.SetCreationOptions(fetchedNewGameLeaders);
createGameItem.CreateCallback = this.CreateGame;
foreach (var availableGame in lobbyResponse.AvailableGames) {
var listItem = Instantiate(
@@ -876,6 +871,7 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
};
_persistentClientConnection.Connect();
PersistentClientConnection.Current = _persistentClientConnection;
errorHandler.PersistentClientConnection = _persistentClientConnection;
ResourceFetcher.SetUpConnection(_httpClient);
}
@@ -952,6 +948,17 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
var game = _internalCreateGame(leaderNameTextId, humanPlayerCount, totalPlayerCount);
}
public void CreateTutorialGame() {
// Tutorial is always single-player
TutorialManager.IsMultiplayerGame = false;
// Eagle will set up the hardcoded tutorial scenario when isTutorial is true
var game = _internalCreateGame(
desiredLeaderTextId: "",
humanPlayerCount: 1,
totalPlayerCount: 2,
isTutorial: true);
}
public void QuitButtonClicked() { Application.Quit(); }
public void HandleJoinGameResponse(JoinGameResponse response) {
@@ -969,14 +976,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
public void HandleCreateGameResponse(CreateGameResponse response) {
try {
switch (response.Result) {
case JoinGameResult.SuccessJoinGameResult:
SelectEagleGame(response.GameId);
// Automatically start the game if it's one player?
break;
case JoinGameResult.SuccessJoinGameResult: SelectEagleGame(response.GameId); break;
default:
// FIXME: display something
break;
default: Debug.LogError($"Game creation failed: {response.Result}"); break;
}
} catch (Exception e) { Console.WriteLine(e.ToString()); }
}
@@ -988,8 +990,11 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
} catch (Exception e) { Console.WriteLine(e.ToString()); }
}
private async Task<bool>
_internalCreateGame(string desiredLeaderTextId, int humanPlayerCount, int totalPlayerCount) {
private async Task<bool> _internalCreateGame(
string desiredLeaderTextId,
int humanPlayerCount,
int totalPlayerCount,
bool isTutorial = false) {
try {
return await _persistentClientConnection.SendUpdateStreamRequestAsync(
new UpdateStreamRequest {
@@ -997,7 +1002,8 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
new CreateGameRequest {
DesiredLeaderTextId = desiredLeaderTextId,
TotalPlayerCount = totalPlayerCount,
HumanPlayerCount = humanPlayerCount
HumanPlayerCount = humanPlayerCount,
IsTutorial = isTutorial
}
});
} catch (WebException e) {
@@ -11,9 +11,10 @@ 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;
@@ -32,30 +33,20 @@ 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,
totalPlayersDropdown.value + 1);
CreateCallback(SelectedLeaderTextId, totalHumansDropdown.value + 1, TotalPlayerCount);
}
public void SetHumanPlayersDropdown() {
var currentValue = totalHumansDropdown.value;
totalHumansDropdown.ClearOptions();
totalHumansDropdown.AddOptions(Enumerable.Range(1, totalPlayersDropdown.value + 1)
.Select(i => i.ToString())
totalHumansDropdown.AddOptions(Enumerable.Range(1, TotalPlayerCount)
.Select(i => i == 1 ? "1 player" : $"{i} players")
.ToList());
totalHumansDropdown.value = Math.Min(totalPlayersDropdown.value, currentValue);
totalHumansDropdown.value = Math.Min(TotalPlayerCount - 1, currentValue);
}
public void SetCreationOptions(List<AvailableLeader> leaders, int maxPlayerCount) {
public void SetCreationOptions(List<AvailableLeader> leaders) {
this.availableLeaders = leaders;
buttonText.text = "CREATE";
@@ -77,16 +68,10 @@ public class CreateGameItem : MonoBehaviour {
heroDropdownController.PreResolvedNames =
leaders.Where(l => !string.IsNullOrEmpty(l.Name))
.ToDictionary(l => l.NameTextId, l => l.Name);
heroDropdownController.FallbackText = "Random";
heroDropdownController.FallbackText = "Random Warlord";
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,6 +631,99 @@ 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
@@ -666,6 +759,7 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 2008335870574158510}
- {fileID: 7404235247014569559}
- {fileID: 6674238761151365000}
- {fileID: 5589171621630283209}
- {fileID: 1348356180262758599}
@@ -674,8 +768,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: -600}
m_SizeDelta: {x: 0, y: -1080}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &1107237753715810996
CanvasRenderer:
@@ -733,7 +827,7 @@ MonoBehaviour:
m_Top: 0
m_Bottom: 0
m_ChildAlignment: 3
m_Spacing: 0
m_Spacing: 5
m_ChildForceExpandWidth: 0
m_ChildForceExpandHeight: 0
m_ChildControlWidth: 1
@@ -757,7 +851,7 @@ MonoBehaviour:
gameIdField: {fileID: 8058295588038032232}
leaderField: {fileID: 3311450659768657245}
lastPlayedField: {fileID: 5482253986454480250}
goButton: {fileID: 0}
professionImage: {fileID: 6993077635068771622}
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 Button goButton;
public RawImage professionImage;
public Button dropButton;
public Action<long> GoCallback;
@@ -43,6 +43,11 @@ 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) {
@@ -0,0 +1,151 @@
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); }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a5ee82ebc029341c4858ef5b7e7cf201
@@ -0,0 +1,220 @@
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 = 40f; // More particles
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 = 300; // More particles for full coverage
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); }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c15980e88e57845ce920e43f29d35fcc
@@ -264,15 +264,16 @@ namespace eagle {
}
private int Cost(List<int> armaments) {
double totalCost = 0.0;
int totalCost = 0;
for (int i = 0; i < armaments.Count; i++) {
var battalion = _battalions[i];
totalCost += _costs[i] * Math.Max(armaments[i] - battalion.Armament.Value, 0.0);
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);
}
return (int)Math.Ceiling(Math.Max(totalCost, 0.0));
return totalCost;
}
private int Cost() { return Cost(_newArmaments); }
@@ -15,7 +15,6 @@ 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;
@@ -24,6 +23,12 @@ 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;
@@ -120,6 +125,38 @@ 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;
@@ -205,23 +242,43 @@ namespace eagle {
ImprovementType.Devastation,
improveCommand.AvailableTypes.Contains(ImprovementType.Devastation));
// Determine which type to select
// 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
ImprovementType selectedType;
if (improveCommand.LockedType != ImprovementType.None) {
var lockedType = improveCommand.LockedType;
if (improveCommand.AvailableTypes.Contains(lockedType)) {
selectedType = lockedType;
lockToggle.isOn = true;
var lockToggle = GetLockToggleForType(lockedType);
if (lockToggle != null) lockToggle.isOn = true;
} else {
selectedType = GetDefaultImprovementType();
lockToggle.isOn = false;
}
} else {
selectedType = GetDefaultImprovementType();
lockToggle.isOn = false;
}
SelectType(selectedType);
UpdateLockToggleVisibility();
}
public override AvailableCommand.SealedValueOneofCase CommandType =>
@@ -240,13 +297,35 @@ namespace eagle {
heroDropdownController.SelectedHeroId = heroId;
}
public override SelectedCommand Command => new SelectedCommand {
ImproveCommand =
new ImproveSelectedCommand {
ImprovementType = SelectedType,
ActingHeroId = heroDropdownController.SelectedHeroId,
LockType = lockToggle.isOn
}
};
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);
}
}
}
}
@@ -21,12 +21,7 @@ namespace eagle {
public Toggle ExileToggle;
public Toggle ExecuteToggle;
public Toggle ReturnToggle;
public RawImage ReleaseIcon;
public RawImage MoveIcon;
public RawImage ExileIcon;
public RawImage ExecuteIcon;
public RawImage ReturnIcon;
public ToggleGroup decisionToggleGroup;
public TMP_Dropdown moveToDropdown;
@@ -76,6 +71,33 @@ namespace eagle {
public override string CommitWarningText =>
$"This will create a treaty with {_model.FactionName(SelectedHero.FactionId.Value)}";
private List<PrisonerManagementOption> MoveOptions =>
SelectedPrisonerToManage.AvailableOptions
.Where(opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Move)
.ToList();
protected override ProvinceId? TargetedProvince {
get {
if (MoveToggle.isOn && moveToDropdown.value >= 0 &&
moveToDropdown.value < MoveOptions.Count) {
return MoveOptions[moveToDropdown.value].Move.ToProvinceId;
}
return null;
}
set {
if (!value.HasValue) return;
var moveOptions = MoveOptions;
int index = moveOptions.FindIndex(opt => opt.Move.ToProvinceId == value.Value);
if (index != -1) {
MoveToggle.isOn = true;
moveToDropdown.value = index;
moveToDropdown.gameObject.SetActive(true);
}
}
}
private PrisonerManagementOption SelectedOption {
get {
if (ExecuteToggle.isOn)
@@ -143,28 +165,43 @@ namespace eagle {
MessageText.text = GetQuestMessagesForPrisoner(SelectedHero.Id);
// The order here is important: the last available toggle in this list
// will be on by default
ConditionallyEnable(
ExecuteToggle,
ExecuteIcon,
PrisonerManagementOption.SealedValueOneofCase.Execute);
ConditionallyEnable(
ExileToggle,
ExileIcon,
PrisonerManagementOption.SealedValueOneofCase.Exile);
ConditionallyEnable(
ReturnToggle,
ReturnIcon,
PrisonerManagementOption.SealedValueOneofCase.Return);
ConditionallyEnable(
MoveToggle,
MoveIcon,
PrisonerManagementOption.SealedValueOneofCase.Move);
ConditionallyEnable(
ReleaseToggle,
ReleaseIcon,
PrisonerManagementOption.SealedValueOneofCase.Release);
var options = SelectedPrisonerToManage.AvailableOptions;
bool hasRelease = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Release);
bool hasMove = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Move);
bool hasExile = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Exile);
bool hasExecute = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Execute);
bool hasReturn = options.Any(
opt => opt.SealedValueCase ==
PrisonerManagementOption.SealedValueOneofCase.Return);
ConfigureToggle(ReleaseToggle, hasRelease);
ConfigureToggle(MoveToggle, hasMove);
ConfigureToggle(ExileToggle, hasExile);
ConfigureToggle(ExecuteToggle, hasExecute);
ConfigureToggle(ReturnToggle, hasReturn);
// Select first available option (priority order: Release > Move > Exile > Execute >
// Return)
if (hasRelease) ReleaseToggle.isOn = true;
else if (hasMove)
MoveToggle.isOn = true;
else if (hasExile)
ExileToggle.isOn = true;
else if (hasExecute)
ExecuteToggle.isOn = true;
else if (hasReturn)
ReturnToggle.isOn = true;
// Update dropdown visibility based on selected toggle
moveToDropdown.gameObject.SetActive(MoveToggle.isOn);
}
private string GetQuestMessagesForPrisoner(HeroId prisonerId) {
@@ -182,7 +219,7 @@ namespace eagle {
if (!matches) continue;
var heroName = GetHeroDisplayName(uh.HeroId);
questMessages.Add($"{heroName} wants you to {actionVerb} this prisoner.");
questMessages.Add($"{heroName} wants you to {actionVerb}.");
}
}
@@ -194,24 +231,24 @@ namespace eagle {
switch (details.SealedValueCase) {
case QuestDetails.SealedValueOneofCase.ExecutePrisonerQuest:
if (details.ExecutePrisonerQuest.PrisonerHeroId == prisonerId) {
return (true, "execute");
return (true, "execute this prisoner");
}
break;
case QuestDetails.SealedValueOneofCase.ExilePrisonerQuest:
if (details.ExilePrisonerQuest.PrisonerHeroId == prisonerId) {
return (true, "exile");
return (true, "exile this prisoner");
}
break;
case QuestDetails.SealedValueOneofCase.FreePrisonerQuest:
if (details.FreePrisonerQuest.PrisonerHeroId == prisonerId) {
return (true, "release");
return (true, "release this prisoner");
}
break;
case QuestDetails.SealedValueOneofCase.ReturnPrisonerQuest:
if (details.ReturnPrisonerQuest.PrisonerHeroId == prisonerId) {
var factionName =
_model.FactionName(details.ReturnPrisonerQuest.ToFactionId);
return (true, $"return to {factionName}");
return (true, $"return this prisoner to {factionName}");
}
break;
}
@@ -225,36 +262,23 @@ namespace eagle {
: "A free hero";
}
private bool ConditionallyEnable(
Toggle toggle,
PrisonerManagementOption.SealedValueOneofCase option) {
bool enable = SelectedPrisonerToManage.AvailableOptions.Any(
opt => opt.SealedValueCase == option);
toggle.gameObject.SetActive(enable);
private const float DisabledAlpha = 0.35f;
if (enable) {
ExecuteToggle.isOn = false;
ExileToggle.isOn = false;
MoveToggle.isOn = false;
ReleaseToggle.isOn = false;
ReturnToggle.isOn = false;
private void ConfigureToggle(Toggle toggle, bool available) {
if (toggle == null) return;
toggle.isOn = true;
} else
toggle.isOn = false;
toggle.gameObject.SetActive(true);
toggle.interactable = available;
return enable;
}
if (!available) { toggle.isOn = false; }
private void ConditionallyEnable(
Toggle toggle,
RawImage img,
PrisonerManagementOption.SealedValueOneofCase option) {
bool enable = ConditionallyEnable(toggle, option);
toggle.group = available ? decisionToggleGroup : null;
Color color = img.color;
color.a = enable ? 1.0f : 0.25f;
img.color = color;
var canvasGroup = toggle.GetComponent<CanvasGroup>();
if (canvasGroup == null) {
canvasGroup = toggle.gameObject.AddComponent<CanvasGroup>();
}
canvasGroup.alpha = available ? 1f : DisabledAlpha;
}
}
}
@@ -40,9 +40,10 @@ namespace eagle {
public TMP_Dropdown fromDropdown;
public TMP_Dropdown toDropdown;
// Columns to hide in narrow layout mode (users select via side panels instead)
// Columns to show/hide based on layout mode
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;
@@ -302,9 +303,11 @@ namespace eagle {
}
public override void OnLayoutModeChanged() {
// Update hero/battalion column visibility when layout changes
if (heroesColumn != null) heroesColumn.SetActive(!IsNarrowLayout);
// 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
if (battalionsColumn != null) battalionsColumn.SetActive(!IsNarrowLayout);
if (battalionsColumnNarrow != null) battalionsColumnNarrow.SetActive(IsNarrowLayout);
}
protected override void SetUpUI() {
@@ -16,6 +16,7 @@ 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;
@@ -198,10 +199,20 @@ namespace eagle {
if (i % 2 == 1) { newRow.GetComponent<Image>().color = oddColor; }
}
foreach (Transform row in availableBattalionsContent.transform) {
Destroy(row.gameObject);
PopulateBattalionsContent(availableBattalionsContent, oddColor);
if (availableBattalionsContentNarrow != null) {
PopulateBattalionsContent(availableBattalionsContentNarrow, oddColor);
}
parentTransform = availableBattalionsContent.transform;
heroesAndBattalionsPanelController.UpdateTables();
selectionChanged.Invoke();
}
private void PopulateBattalionsContent(GameObject content, Color oddColor) {
foreach (Transform row in content.transform) { Destroy(row.gameObject); }
var parentTransform = content.transform;
for (int i = 0; i < AvailableBattalions.Count; i++) {
var battalion = AvailableBattalions[i];
@@ -217,10 +228,6 @@ namespace eagle {
if (i % 2 == 1) { newRow.GetComponent<Image>().color = oddColor; }
}
heroesAndBattalionsPanelController.UpdateTables();
selectionChanged.Invoke();
}
public void availableHeroButtonClicked(int index) {
@@ -23,7 +23,9 @@ namespace eagle {
/// Attach to a TextMeshProUGUI component to display status.
/// </summary>
public class ConnectionStatusUI : MonoBehaviour {
private TextMeshProUGUI _textComponent;
[Header("UI References")]
public TextMeshProUGUI statusText;
private PersistentClientConnection _connection;
private IGameStateProvider _gameStateProvider;
private string _environmentName;
@@ -39,10 +41,8 @@ namespace eagle {
private float _timeSinceLastUpdate = 0f;
void Start() {
_textComponent = GetComponent<TextMeshProUGUI>();
if (_textComponent == null) {
Debug.LogError(
"ConnectionStatusUI must be attached to a TextMeshProUGUI component");
if (statusText == null) {
Debug.LogError("ConnectionStatusUI: statusText reference not assigned");
enabled = false;
return;
}
@@ -78,7 +78,7 @@ namespace eagle {
}
void Update() {
if (_connection == null || _textComponent == null) { return; }
if (_connection == null || statusText == null) { return; }
_timeSinceLastUpdate += Time.deltaTime;
if (_timeSinceLastUpdate < UpdateInterval) { return; }
@@ -97,16 +97,15 @@ namespace eagle {
var timeUntilTest = nextTest.Value - DateTime.UtcNow;
if (timeUntilTest.TotalSeconds > 0) {
int seconds = Math.Min(10, (int)Math.Ceiling(timeUntilTest.TotalSeconds));
_textComponent.text =
$"<color=red>●</color> Server down. Retry in {seconds}s";
statusText.text = $"<color=red>●</color> Server down. Retry in {seconds}s";
return;
}
}
_textComponent.text = "<color=red>●</color> Server unavailable";
statusText.text = "<color=red>●</color> Server unavailable";
return;
} else if (circuitState == ConnectionCircuitBreaker.State.HalfOpen) {
SetRetryButtonVisible(false);
_textComponent.text = "<color=yellow>●</color> Testing connection...";
statusText.text = "<color=yellow>●</color> Testing connection...";
return;
}
@@ -119,7 +118,7 @@ namespace eagle {
(nextAttempt.Value - DateTime.UtcNow).TotalSeconds > 0;
SetRetryButtonVisible(isCountingDown);
string statusText = state switch {
string displayText = state switch {
ConnectionState.Connected => GetConnectedStatusText(),
ConnectionState.Connecting => "<color=yellow>●</color> Connecting...",
ConnectionState.Disconnected => "<color=red>●</color> Disconnected",
@@ -128,7 +127,7 @@ namespace eagle {
_ => "<color=gray>●</color> Unknown"
};
_textComponent.text = statusText;
statusText.text = displayText;
}
private string GetConnectedStatusText() {
@@ -0,0 +1,121 @@
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);
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 299f66fc896a34cdeb95e92e927a6f10
@@ -15,6 +15,7 @@ namespace eagle {
}
public static readonly Dictionary<Profession, string> ProfessionNames = new() {
{ Profession.UnknownProfession, "Unknown" },
{ Profession.NoProfession, "None" },
{ Profession.Engineer, "Engineer" },
{ Profession.Mage, "Mage" },
@@ -25,6 +26,7 @@ namespace eagle {
};
public static readonly Dictionary<Profession, string> ShortProfessionNames = new() {
{ Profession.UnknownProfession, "?" },
{ Profession.NoProfession, "None" },
{ Profession.Engineer, "Engr" },
{ Profession.Mage, "Mage" },
@@ -104,6 +106,9 @@ 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!";
@@ -128,6 +133,8 @@ namespace eagle {
case RecruitmentStatus.Traveler: return "Traveler";
case RecruitmentStatus.Sworn: return "Dedicated to another faction";
case RecruitmentStatus.WouldNeverJoin: return "Would never join";
}
return $"Unknown!";
@@ -175,6 +182,7 @@ 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";
@@ -0,0 +1,152 @@
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); }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f64938ef171de43dba1b0a865fc86f60
@@ -21,12 +21,15 @@ 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,8 +33,6 @@ namespace eagle {
[Header("Status Labels")]
public TextMeshProUGUI roundStatusLabel;
public TextMeshProUGUI connectionStatusLabel;
private bool _connectionStatusUIInitialized = false;
[Header("Main Panels")]
public ProvinceInfoPanelController provinceInfoPanelController;
@@ -145,16 +143,6 @@ 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;
@@ -225,12 +213,21 @@ 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
var statusUI = connectionStatusLabel.GetComponent<ConnectionStatusUI>();
if (statusUI != null) { statusUI.SetGameStateProvider(null); }
PersistentUIManager.Instance?.connectionStatusUI?.SetGameStateProvider(null);
}
void MapControllerChangedTarget(List<ProvinceId> newTarget) {
@@ -335,7 +332,7 @@ namespace eagle {
ModelUpdater.ErrorHandler = errorHandler;
// Set up game state provider and environment for connection status UI
var statusUI = connectionStatusLabel.GetComponent<ConnectionStatusUI>();
var statusUI = PersistentUIManager.Instance?.connectionStatusUI;
if (statusUI != null) {
statusUI.SetGameStateProvider(ModelUpdater);
statusUI.SetEnvironment(environmentName);
@@ -795,7 +792,6 @@ namespace eagle {
}
shardokController.SetUpGame(shardokModel);
shardokController.SetConnection(errorHandler.PersistentClientConnection);
gameObject.SetActive(false);
}
@@ -260,7 +260,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 "Back to Eagle" via
// pending Shardok update - the UI already shows "Exit Battle" via
// MarkBattleEnded(), so we just skip this stale update.
return null;
}
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 33c3d3ae0724f43b6990a941f06026cd
guid: 0a32317c8e94148f1b87a7f66c498077
folderAsset: yes
DefaultImporter:
externalObjects: {}
@@ -0,0 +1,60 @@
%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,5 +1,5 @@
fileFormatVersion: 2
guid: 81b03eaddf4de4a4aa385617e4881511
guid: 1c093deb39df94fbca5faa9f015a2331
PrefabImporter:
externalObjects: {}
userData:
@@ -0,0 +1,63 @@
%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: 15
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}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 0cf4724a235af4fb8abab6456789936a
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,61 @@
%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}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 1be06ed8f9be949478a81914e2479d4d
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,63 @@
%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}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 5315bdd018d9e420dbbf55a0508a7f14
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,61 @@
%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}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 27861cc746ec947e88dac44d9a058e10
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,65 @@
%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}
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 384a5a10723d4475e8dba09c8ef66eca
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,45 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: ParticleAlphaBlendMaterial
m_Shader: {fileID: 4800000, guid: a60360570f7264533a1146b26d028b00, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords:
- _ALPHABLEND_ON
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _MainTex:
m_Texture: {fileID: 2800000, guid: 5fb52be5b250f5f42ace466a01c19bed, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ProvinceIDMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _DstBlend: 10
- _InvFade: 1
- _SrcBlend: 5
- _TargetProvinceId: 0
m_Colors:
- _Color: {r: 1, g: 1, b: 1, a: 1}
- _TintColor: {r: 1, g: 1, b: 1, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6f7
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
@@ -2,20 +2,27 @@
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 6
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: province11_material
m_Shader: {fileID: 4800000, guid: a5be49c245cce4a4e96cfbb74dbec1b6, type: 3}
m_ShaderKeywords:
m_LightmapFlags: 4
m_Name: ParticleStandardUnlitMaterial
m_Shader: {fileID: 211, guid: 0000000000000000f000000000000000, type: 0}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords:
- _ALPHABLEND_ON
m_InvalidKeywords: []
m_LightmapFlags: 0
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap: {}
disabledShaderPasses: []
m_CustomRenderQueue: 3000
stringTagMap:
RenderType: Transparent
disabledShaderPasses:
- GRABPASS
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
@@ -43,10 +50,6 @@ Material:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _Mask:
m_Texture: {fileID: 2800000, guid: 25ef829372cfa42c3aa9f659bda36aaf, type: 3}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MetallicGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
@@ -59,23 +62,45 @@ Material:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _BlendOp: 0
- _BumpScale: 1
- _Cutoff: 0.52
- _CameraFadingEnabled: 0
- _CameraFarFadeDistance: 2
- _CameraNearFadeDistance: 1
- _ColorMode: 0
- _Cull: 2
- _Cutoff: 0.5
- _DetailNormalMapScale: 1
- _DstBlend: 0
- _DistortionBlend: 0.5
- _DistortionEnabled: 0
- _DistortionStrength: 1
- _DistortionStrengthScaled: 0
- _DstBlend: 10
- _EmissionEnabled: 0
- _FlipbookMode: 0
- _GlossMapScale: 1
- _Glossiness: 0.5
- _GlossyReflections: 1
- _LightingEnabled: 0
- _Metallic: 0
- _Mode: 0
- _Mode: 2
- _OcclusionStrength: 1
- _Parallax: 0.02
- _SmoothnessTextureChannel: 0
- _SoftParticlesEnabled: 0
- _SoftParticlesFarFadeDistance: 1
- _SoftParticlesNearFadeDistance: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _SrcBlend: 5
- _UVSec: 0
- _ZWrite: 1
- _ZWrite: 0
m_Colors:
- _CameraFadeParams: {r: 0, g: Infinity, b: 0, a: 0}
- _Color: {r: 1, g: 1, b: 1, a: 1}
- _ColorAddSubDiff: {r: 0, g: 0, b: 0, a: 0}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
- _SoftParticleFadeParams: {r: 0, g: 0, b: 0, a: 0}
m_BuildTextureStacks: []
m_AllowLocking: 1
@@ -1,8 +1,8 @@
fileFormatVersion: 2
guid: 5523b3ce166c34ef98836d6891a63c0f
guid: 67dcb765c9c324ff9a103624340bc9b5
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 0
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,40 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: ProvinceParticleMaterial
m_Shader: {fileID: 4800000, guid: a60360570f7264533a1146b26d028b00, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: 3000
stringTagMap: {}
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _MainTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ProvinceIDMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _TargetProvinceId: 0
m_Colors:
- _Color: {r: 1, g: 1, b: 1, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b1c2d3e4f5a6b7c8d9e0f1a2b3c4d5e6
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,206 @@
using UnityEngine;
namespace eagle {
/// <summary>
/// Creates an epidemic particle effect with skulls rising and green haze.
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class EpidemicEffect : MonoBehaviour {
[Header("Skull Settings")]
public Material skullMaterial;
public float skullEmitRate = 1.5f;
public float skullRiseSpeed = 25f;
public float skullLifetime = 3f;
public float skullSize = 12f;
public float skullSway = 8f;
public Color skullColor = Color.white;
[Header("Haze Settings")]
public Material hazeMaterial;
public float hazeEmitRate = 5f;
public float hazeSpeed = 8f;
public float hazeLifetime = 4f;
public float hazeSize = 40f;
public Color hazeColor = new Color(0.4f, 0.55f, 0.35f, 0.25f);
private ParticleSystem _skullSystem;
private ParticleSystem _hazeSystem;
private Material _skullMatInstance;
private Material _hazeMatInstance;
void Awake() { SetupParticleSystems(); }
private void SetupParticleSystems() {
// Create haze first (renders behind skulls)
var hazeObj = new GameObject("HazeEffect");
hazeObj.transform.SetParent(transform, false);
_hazeSystem = hazeObj.AddComponent<ParticleSystem>();
SetupHazeSystem();
// Create skull particle system
var skullObj = new GameObject("SkullEffect");
skullObj.transform.SetParent(transform, false);
_skullSystem = skullObj.AddComponent<ParticleSystem>();
SetupSkullSystem();
}
private void SetupSkullSystem() {
var main = _skullSystem.main;
main.playOnAwake = true;
main.loop = true;
main.startLifetime = skullLifetime;
main.startSpeed = 0f;
main.startSize = skullSize;
main.startColor = skullColor;
main.startRotation = new ParticleSystem.MinMaxCurve(-0.2f, 0.2f); // Slight random tilt
main.simulationSpace = ParticleSystemSimulationSpace.Local;
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
main.maxParticles = 20;
var emission = _skullSystem.emission;
emission.enabled = true;
emission.rateOverTime = skullEmitRate;
// Emit from a small area at the base with random direction for sway
var shape = _skullSystem.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Circle;
shape.radius = 15f;
shape.rotation = new Vector3(-90f, 0f, 0f);
shape.randomDirectionAmount = 0.3f; // Add horizontal sway variation
// Rise upward with horizontal drift for swaying motion
var velocityOverLifetime = _skullSystem.velocityOverLifetime;
velocityOverLifetime.enabled = true;
velocityOverLifetime.space = ParticleSystemSimulationSpace.Local;
// Use constant mode for all axes to avoid mode mismatch
velocityOverLifetime.x = skullSway; // More horizontal drift
velocityOverLifetime.y = skullRiseSpeed;
velocityOverLifetime.z = 0f;
// Grow as they rise (start at 1/4 size, end at full size)
var sizeOverLifetime = _skullSystem.sizeOverLifetime;
sizeOverLifetime.enabled = true;
sizeOverLifetime.size = new ParticleSystem.MinMaxCurve(
1f,
new AnimationCurve(new Keyframe(0f, 0.25f), new Keyframe(1f, 1f)));
// Fade out as they rise
var colorOverLifetime = _skullSystem.colorOverLifetime;
colorOverLifetime.enabled = true;
var gradient = new Gradient();
gradient.SetKeys(
new GradientColorKey[] {
new GradientColorKey(Color.white, 0f),
new GradientColorKey(Color.white, 0.7f),
new GradientColorKey(Color.white, 1f)
},
new GradientAlphaKey[] {
new GradientAlphaKey(0f, 0f),
new GradientAlphaKey(1f, 0.1f),
new GradientAlphaKey(1f, 0.6f),
new GradientAlphaKey(0f, 1f)
});
colorOverLifetime.color = new ParticleSystem.MinMaxGradient(gradient);
// Disable rotation over lifetime (causes spinning)
var rotationOverLifetime = _skullSystem.rotationOverLifetime;
rotationOverLifetime.enabled = false;
// Use noise module for gentle rocking/swaying rotation
var noise = _skullSystem.noise;
noise.enabled = true;
noise.separateAxes = true;
noise.strengthX = 0f; // No position noise
noise.strengthY = 0f;
noise.strengthZ = 0f;
noise.rotationAmount = 0.2f; // ~11 degrees of rotation sway
noise.frequency = 0.5f; // Slow oscillation
noise.scrollSpeed = 0f;
noise.damping = false;
noise.octaveCount = 1;
var renderer = _skullSystem.GetComponent<ParticleSystemRenderer>();
if (renderer != null) {
if (skullMaterial == null) {
Debug.LogError("[EpidemicEffect] skullMaterial is not assigned!");
return;
}
_skullMatInstance = new Material(skullMaterial);
renderer.material = _skullMatInstance;
renderer.sortingOrder = 101;
}
}
private void SetupHazeSystem() {
var main = _hazeSystem.main;
main.playOnAwake = true;
main.loop = true;
main.startLifetime = hazeLifetime;
main.startSpeed = hazeSpeed;
main.startSize = new ParticleSystem.MinMaxCurve(hazeSize * 0.5f, hazeSize);
main.startColor = hazeColor;
main.simulationSpace = ParticleSystemSimulationSpace.Local;
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
main.maxParticles = 30;
var emission = _hazeSystem.emission;
emission.enabled = true;
emission.rateOverTime = hazeEmitRate;
// Emit from ground area, spread outward and up
var shape = _hazeSystem.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Cone;
shape.angle = 40f;
shape.radius = 10f;
shape.rotation = new Vector3(-90f, 0f, 0f);
// Slow down over time
var velocityOverLifetime = _hazeSystem.velocityOverLifetime;
velocityOverLifetime.enabled = true;
velocityOverLifetime.speedModifier = new ParticleSystem.MinMaxCurve(
1f,
new AnimationCurve(new Keyframe(0f, 1f), new Keyframe(1f, 0.2f)));
// Grow and fade
var sizeOverLifetime = _hazeSystem.sizeOverLifetime;
sizeOverLifetime.enabled = true;
sizeOverLifetime.size = new ParticleSystem.MinMaxCurve(
1f,
new AnimationCurve(new Keyframe(0f, 0.5f), new Keyframe(1f, 2f)));
var colorOverLifetime = _hazeSystem.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(0.6f, 0.2f),
new GradientAlphaKey(0.6f, 0.5f),
new GradientAlphaKey(0f, 1f)
});
colorOverLifetime.color = new ParticleSystem.MinMaxGradient(gradient);
var renderer = _hazeSystem.GetComponent<ParticleSystemRenderer>();
if (renderer != null) {
if (hazeMaterial == null) {
Debug.LogError("[EpidemicEffect] hazeMaterial is not assigned!");
return;
}
_hazeMatInstance = new Material(hazeMaterial);
renderer.material = _hazeMatInstance;
renderer.sortingOrder = 100;
}
}
void OnDestroy() {
if (_skullMatInstance != null) { Destroy(_skullMatInstance); }
if (_hazeMatInstance != null) { Destroy(_hazeMatInstance); }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 727093fab7ebc43dc94ee59675164706
@@ -0,0 +1,217 @@
using System.Collections;
using UnityEngine;
namespace eagle {
/// <summary>
/// Creates a fireworks particle effect for festival events.
/// Rockets fly upward and burst into colorful explosions.
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class FireworksEffect : MonoBehaviour {
[Header("Rocket Settings")]
public float launchInterval = 2f;
public float rocketSpeed = 80f;
public float rocketLifetime = 0.8f;
public float rocketSize = 6f;
[Header("Burst Settings")]
public int burstParticles = 40;
public float burstSpeed = 50f;
public float burstLifetime = 1.2f;
public float burstGravity = 30f;
[Header("Colors")]
public Color[] fireworkColors = {
new Color(1f, 0.3f, 0.3f), // Red
new Color(1f, 0.8f, 0.2f), // Gold
new Color(0.3f, 0.8f, 1f), // Blue
new Color(0.3f, 1f, 0.3f), // Green
new Color(1f, 0.5f, 1f), // Pink
new Color(1f, 1f, 0.4f) // Yellow
};
private ParticleSystem _rocketSystem;
private ParticleSystem _burstSystem;
private float _timer;
private int _colorIndex;
void Awake() {
_colorIndex = Random.Range(0, fireworkColors.Length);
SetupParticleSystems();
}
private void SetupParticleSystems() {
// Create rocket particle system on child GameObject (not on RectTransform)
var rocketObj = new GameObject("RocketEffect");
rocketObj.transform.SetParent(transform, false);
_rocketSystem = rocketObj.AddComponent<ParticleSystem>();
SetupRocketSystem();
// Create burst particle system (separate, not sub-emitter)
var burstObj = new GameObject("BurstEffect");
burstObj.transform.SetParent(transform, false);
_burstSystem = burstObj.AddComponent<ParticleSystem>();
SetupBurstSystem();
StartCoroutine(LaunchSequence());
}
private void SetupRocketSystem() {
var main = _rocketSystem.main;
main.playOnAwake = false;
main.loop = false;
main.startLifetime = rocketLifetime;
main.startSpeed = 0f; // We set velocity explicitly in EmitParams
main.startSize = rocketSize;
main.simulationSpace = ParticleSystemSimulationSpace.Local;
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
main.maxParticles = 10;
main.startColor = new Color(1f, 0.9f, 0.6f); // Bright orange/yellow
var emission = _rocketSystem.emission;
emission.enabled = false;
// Disable shape - we set velocity explicitly in EmitParams to avoid Z movement
var shape = _rocketSystem.shape;
shape.enabled = false;
// Add slight arc to rocket path (gravity slowing upward movement)
var velocityOverLifetime = _rocketSystem.velocityOverLifetime;
velocityOverLifetime.enabled = true;
velocityOverLifetime.space = ParticleSystemSimulationSpace.Local;
velocityOverLifetime.y = new ParticleSystem.MinMaxCurve(-20f); // Slight downward pull
// Rocket trail
var trails = _rocketSystem.trails;
trails.enabled = true;
trails.ratio = 1f;
trails.lifetime = new ParticleSystem.MinMaxCurve(0.4f);
trails.minVertexDistance = 2f;
trails.worldSpace = false;
trails.dieWithParticles = true;
trails.widthOverTrail = new ParticleSystem.MinMaxCurve(
1f,
new AnimationCurve(new Keyframe(0f, 1f), new Keyframe(1f, 0.3f)));
trails.colorOverLifetime = new ParticleSystem.MinMaxGradient(
new Color(1f, 0.8f, 0.3f),
new Color(1f, 0.5f, 0.1f));
var renderer = _rocketSystem.GetComponent<ParticleSystemRenderer>();
if (renderer != null) {
// Use a shader that respects vertex colors
var particleShader = Shader.Find("Particles/Standard Unlit");
if (particleShader != null) {
var mat = new Material(particleShader);
mat.SetFloat("_ColorMode", 1); // Multiply mode
renderer.material = mat;
renderer.trailMaterial = mat;
}
renderer.sortingOrder = 100;
}
}
private void SetupBurstSystem() {
var main = _burstSystem.main;
main.playOnAwake = false;
main.loop = false;
main.startLifetime = burstLifetime;
main.startSpeed = burstSpeed;
main.startSize = new ParticleSystem.MinMaxCurve(4f, 7f);
main.simulationSpace = ParticleSystemSimulationSpace.Local;
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
main.maxParticles = 200;
var emission = _burstSystem.emission;
emission.enabled = false;
var shape = _burstSystem.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Sphere;
shape.radius = 1f;
// Gravity pulls burst particles down
var velocityOverLifetime = _burstSystem.velocityOverLifetime;
velocityOverLifetime.enabled = true;
velocityOverLifetime.y = new ParticleSystem.MinMaxCurve(-burstGravity);
// Disable colorOverLifetime to preserve startColor from EmitParams
var colorOverLifetime = _burstSystem.colorOverLifetime;
colorOverLifetime.enabled = false;
// Shrink over lifetime
var sizeOverLifetime = _burstSystem.sizeOverLifetime;
sizeOverLifetime.enabled = true;
sizeOverLifetime.size = new ParticleSystem.MinMaxCurve(
1f,
new AnimationCurve(new Keyframe(0f, 1f), new Keyframe(1f, 0.2f)));
// Sparkle trails on burst particles
var trails = _burstSystem.trails;
trails.enabled = true;
trails.ratio = 0.4f;
trails.lifetime = new ParticleSystem.MinMaxCurve(0.2f);
trails.minVertexDistance = 1f;
trails.worldSpace = false;
trails.dieWithParticles = true;
trails.widthOverTrail = new ParticleSystem.MinMaxCurve(
1f,
new AnimationCurve(new Keyframe(0f, 0.6f), new Keyframe(1f, 0f)));
trails.inheritParticleColor = true;
var burstRenderer = _burstSystem.GetComponent<ParticleSystemRenderer>();
if (burstRenderer != null) {
// Use a shader that respects vertex colors
var particleShader = Shader.Find("Particles/Standard Unlit");
if (particleShader != null) {
var mat = new Material(particleShader);
mat.SetFloat("_ColorMode", 1); // Multiply mode
burstRenderer.material = mat;
burstRenderer.trailMaterial = mat;
}
burstRenderer.sortingOrder = 101;
}
}
private IEnumerator LaunchSequence() {
while (true) {
LaunchRocket();
yield return new WaitForSeconds(launchInterval);
}
}
private void LaunchRocket() {
if (_rocketSystem == null || _burstSystem == null) { return; }
Color burstColor = fireworkColors[_colorIndex % fireworkColors.Length];
_colorIndex++;
// Launch rocket with explicit velocity and color
// X: random variation for slight angle, Y: upward, Z: must be negative to render
float xVariation = Random.Range(-15f, 15f);
var rocketParams = new ParticleSystem.EmitParams();
rocketParams.position = Vector3.zero;
rocketParams.velocity = new Vector3(xVariation, rocketSpeed, -1f);
rocketParams.startColor = new Color(1f, 0.9f, 0.5f); // Bright yellow-orange
_rocketSystem.Emit(rocketParams, 1);
// Schedule burst at rocket death position
StartCoroutine(EmitBurstAfterDelay(burstColor));
}
private IEnumerator EmitBurstAfterDelay(Color color) {
// Wait for rocket to reach peak
yield return new WaitForSeconds(rocketLifetime);
// Calculate burst position (where rocket ended)
Vector3 burstPosition = new Vector3(0f, rocketSpeed * rocketLifetime, 0f);
// Move burst system to rocket's end position
_burstSystem.transform.localPosition = burstPosition;
// Emit particles with explicit color using EmitParams
var emitParams = new ParticleSystem.EmitParams();
emitParams.startColor = color;
for (int i = 0; i < burstParticles; i++) { _burstSystem.Emit(emitParams, 1); }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 01a76cb7235d04623be14e4b01f18754
@@ -0,0 +1,316 @@
using UnityEngine;
namespace eagle {
/// <summary>
/// Creates a flood/rain particle effect with falling raindrops and splashes.
/// Uses province masking shader to clip particles to province boundaries.
/// </summary>
[RequireComponent(typeof(RectTransform))]
public class FloodEffect : MonoBehaviour {
[Header("Rain Settings")]
public Texture2D rainTexture;
public Material particleMaterial; // Assign in prefab to ensure shader is in build
public float rainEmitRate = 80f; // More rain
public float rainFallSpeed = 100f;
public float rainLifetime = 2f; // Longer lifetime
public float rainSize = 5f;
public float rainStretch = 4f;
public Color rainColor = new Color(0.7f, 0.8f, 0.9f, 0.6f);
[Header("Splash Settings")]
public float splashEmitRate = 25f; // More splashes
public float splashLifetime = 0.6f;
public float splashSize = 10f;
public Color splashColor = new Color(0.7f, 0.85f, 1f, 0.4f);
[Header("Province Masking")]
public Texture2D provinceIdMap;
public int targetProvinceId;
private ParticleSystem _rainSystem;
private ParticleSystem _splashSystem;
private Material _rainMaterial;
private Material _splashMaterial;
private RectTransform _mapContainer;
private Canvas _canvas;
private Vector3 _baseRainShapeScale;
private Vector3 _baseRainShapePosition;
private float _baseSplashRadius;
private float _baseRainEmitRate;
private float _baseSplashEmitRate;
private float _lastZoom = 1f;
void Awake() { SetupParticleSystems(); }
private void SetupParticleSystems() {
// Create splash first (renders behind rain)
var splashObj = new GameObject("SplashEffect");
splashObj.transform.SetParent(transform, false);
_splashSystem = splashObj.AddComponent<ParticleSystem>();
SetupSplashSystem();
// Create rain particle system
var rainObj = new GameObject("RainEffect");
rainObj.transform.SetParent(transform, false);
_rainSystem = rainObj.AddComponent<ParticleSystem>();
SetupRainSystem();
}
private void SetupRainSystem() {
var main = _rainSystem.main;
main.playOnAwake = true;
main.loop = true;
main.startLifetime = rainLifetime;
main.startSpeed = 0f;
main.startSize = rainSize;
main.startColor = rainColor;
main.startRotation = 0f;
main.simulationSpace = ParticleSystemSimulationSpace.Local;
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
main.maxParticles = 400; // More particles for coverage
var emission = _rainSystem.emission;
emission.enabled = true;
emission.rateOverTime = rainEmitRate;
_baseRainEmitRate = rainEmitRate;
// Emit across full province height - particles spawn at varying Y positions
var shape = _rainSystem.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
_baseRainShapeScale = shape.scale;
_baseRainShapePosition = shape.position;
// Fall downward with slight angle
var velocityOverLifetime = _rainSystem.velocityOverLifetime;
velocityOverLifetime.enabled = true;
velocityOverLifetime.space = ParticleSystemSimulationSpace.Local;
velocityOverLifetime.x = new ParticleSystem.MinMaxCurve(-5f, -5f);
velocityOverLifetime.y = new ParticleSystem.MinMaxCurve(-rainFallSpeed, -rainFallSpeed);
velocityOverLifetime.z = new ParticleSystem.MinMaxCurve(0f, 0f);
// Fade in at top, stay visible
var colorOverLifetime = _rainSystem.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.9f),
new GradientAlphaKey(0.5f, 1f)
});
colorOverLifetime.color = new ParticleSystem.MinMaxGradient(gradient);
var renderer = _rainSystem.GetComponent<ParticleSystemRenderer>();
if (renderer != null) {
renderer.renderMode = ParticleSystemRenderMode.Stretch;
renderer.lengthScale = rainStretch;
renderer.velocityScale = 0f;
// Use provided material if available (ensures shader is in build)
if (particleMaterial != null) {
_rainMaterial = new Material(particleMaterial);
_rainMaterial.renderQueue = 3000;
if (rainTexture != null) { _rainMaterial.mainTexture = rainTexture; }
renderer.material = _rainMaterial;
} 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) {
_rainMaterial = new Material(shader);
_rainMaterial.renderQueue = 3000;
if (rainTexture != null) { _rainMaterial.mainTexture = rainTexture; }
renderer.material = _rainMaterial;
} else {
Debug.LogWarning("FloodEffect: No particle shader found");
}
}
renderer.sortingOrder = 101;
}
}
private void SetupSplashSystem() {
var main = _splashSystem.main;
main.playOnAwake = true;
main.loop = true;
main.startLifetime = splashLifetime;
main.startSpeed = new ParticleSystem.MinMaxCurve(5f, 15f);
main.startSize = new ParticleSystem.MinMaxCurve(splashSize * 0.5f, splashSize);
main.startColor = splashColor;
main.simulationSpace = ParticleSystemSimulationSpace.Local;
main.scalingMode = ParticleSystemScalingMode.Hierarchy;
main.maxParticles = 150; // More splashes
main.gravityModifier = 2f;
var emission = _splashSystem.emission;
emission.enabled = true;
emission.rateOverTime = splashEmitRate;
_baseSplashEmitRate = splashEmitRate;
// Emit from ground area - large radius since shader masks
var shape = _splashSystem.shape;
shape.enabled = true;
shape.shapeType = ParticleSystemShapeType.Circle;
shape.radius = 60f; // Larger splash area
_baseSplashRadius = shape.radius;
shape.rotation = new Vector3(-90f, 0f, 0f);
shape.arc = 180f;
shape.arcMode = ParticleSystemShapeMultiModeValue.Random;
// Quick fade out
var colorOverLifetime = _splashSystem.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(0.8f, 0f),
new GradientAlphaKey(0f, 1f)
});
colorOverLifetime.color = new ParticleSystem.MinMaxGradient(gradient);
// Shrink as they fade
var sizeOverLifetime = _splashSystem.sizeOverLifetime;
sizeOverLifetime.enabled = true;
sizeOverLifetime.size = new ParticleSystem.MinMaxCurve(
1f,
new AnimationCurve(new Keyframe(0f, 1f), new Keyframe(1f, 0.2f)));
var renderer = _splashSystem.GetComponent<ParticleSystemRenderer>();
if (renderer != null) {
// Use provided material if available (ensures shader is in build)
if (particleMaterial != null) {
_splashMaterial = new Material(particleMaterial);
_splashMaterial.renderQueue = 3000;
renderer.material = _splashMaterial;
} 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) {
_splashMaterial = new Material(shader);
_splashMaterial.renderQueue = 3000;
renderer.material = _splashMaterial;
}
}
renderer.sortingOrder = 100;
}
}
public void SetProvinceId(int provinceId) {
targetProvinceId = provinceId;
if (_rainMaterial != null) { _rainMaterial.SetFloat("_TargetProvinceId", provinceId); }
if (_splashMaterial != null) {
_splashMaterial.SetFloat("_TargetProvinceId", provinceId);
}
}
public void SetProvinceIdMap(Texture2D idMap) {
provinceIdMap = idMap;
if (_rainMaterial != null) { _rainMaterial.SetTexture("_ProvinceIDMap", idMap); }
if (_splashMaterial != null) { _splashMaterial.SetTexture("_ProvinceIDMap", idMap); }
}
public void SetMapContainer(RectTransform container) {
_mapContainer = container;
_canvas = container?.GetComponentInParent<Canvas>();
}
void LateUpdate() {
UpdateMapBounds();
UpdateZoomCompensation();
}
private void UpdateMapBounds() {
if (_mapContainer == 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);
if (_rainMaterial != null) { _rainMaterial.SetVector("_MapBounds", mapBounds); }
if (_splashMaterial != null) { _splashMaterial.SetVector("_MapBounds", mapBounds); }
}
private void UpdateZoomCompensation() {
if (_mapContainer == null) { return; }
float zoom = _mapContainer.localScale.x;
if (Mathf.Approximately(zoom, _lastZoom)) { return; }
_lastZoom = zoom;
float inverseZoom = 1f / zoom;
// Counter-scale rain system
if (_rainSystem != null) {
_rainSystem.transform.localScale = Vector3.one * inverseZoom;
var shape = _rainSystem.shape;
shape.scale = _baseRainShapeScale * zoom;
shape.position = _baseRainShapePosition * zoom;
var emission = _rainSystem.emission;
emission.rateOverTime = _baseRainEmitRate * zoom * zoom;
}
// Counter-scale splash system
if (_splashSystem != null) {
_splashSystem.transform.localScale = Vector3.one * inverseZoom;
var shape = _splashSystem.shape;
shape.radius = _baseSplashRadius * zoom;
var emission = _splashSystem.emission;
emission.rateOverTime = _baseSplashEmitRate * zoom * zoom;
}
}
void OnDestroy() {
if (_rainMaterial != null) { Destroy(_rainMaterial); }
if (_splashMaterial != null) { Destroy(_splashMaterial); }
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 336c92d7cae77487fa6915c9839d38f6
@@ -9,6 +9,11 @@ namespace eagle {
public class FreeHeroesTableController : MonoBehaviour {
public GameObject panel;
[Header("Hero Backstory Popup")]
public GameObject popupPanel;
public HeroDetailsController popupPanelDetailsController;
public GeneratedTextUpdater popupPanelBackstory;
private ProvinceView _currentProvince;
public ProvinceView CurrentProvince {
get => _currentProvince;
@@ -73,6 +78,21 @@ namespace eagle {
UpdateUnaffiliatedHeroSelections();
}
public void LongHoverRowChanged(int? newIndex) {
if (popupPanel == null) return;
if (newIndex is {} index) {
var heroId = UnaffiliatedHeroes.ElementAt(index).HeroId;
if (Model.Heroes.TryGetValue(heroId, out var hero)) {
popupPanelDetailsController.SetHero(hero, Model);
popupPanelBackstory.TextId = hero.BackstoryTextId;
popupPanel.SetActive(true);
}
} else {
popupPanel.SetActive(false);
}
}
private void setUpUnaffiliatedHeroesTable() {
unaffiliatedHeroesTable.RowCount = UnaffiliatedHeroCount;
@@ -88,7 +108,10 @@ namespace eagle {
});
}
void Start() { panel.SetActive(Model != null); }
void Start() {
panel.SetActive(Model != null);
if (popupPanel != null) { popupPanel.SetActive(false); }
}
private bool HasProvinceInfo =>
(CurrentProvince != null && CurrentProvince.FullInfo != null);
@@ -141,31 +141,9 @@ namespace eagle {
}
public void BattalionLongHoverRowChanged(int? newIndex) {
if (battalionPopupPanel == null) return;
if (newIndex is {} index) {
var battalion = Battalions.ElementAt(index);
if (!string.IsNullOrEmpty(battalion.BackstoryTextId)) {
// Populate header
if (battalionPopupPanelName != null) {
// Use PrefixedText for plain name; ready for NameTextId when available
battalionPopupPanelName.TextId = "";
battalionPopupPanelName.PrefixedText = battalion.Name;
}
if (battalionPopupPanelTypeIcon != null) {
var textures = GetComponentInParent<EagleCommonTextures>();
battalionPopupPanelTypeIcon.texture =
textures?.BattalionType(battalion.Type);
}
battalionPopupPanelBackstory.TextId = battalion.BackstoryTextId;
battalionPopupPanel.SetActive(true);
} else {
battalionPopupPanel.SetActive(false);
}
} else {
battalionPopupPanel.SetActive(false);
}
// Battalion backstory popups disabled - backstory updates are being eliminated
// to reduce LLM request volume. See docs/eliminate-battalion-backstory-updates.md
if (battalionPopupPanel != null) { battalionPopupPanel.SetActive(false); }
}
private void SetUpHeroesTable() {
@@ -4,7 +4,6 @@ using System.Linq;
using Net.Eagle0.Eagle.Views;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using ProvinceId = System.Int32;
using FactionId = System.Int32;
@@ -12,9 +11,15 @@ namespace eagle {
public class MapController : MonoBehaviour {
public EagleGameController eagleGameController;
public RawImage mapBWImage;
public List<RawImage> provinceImagesList;
public ProvinceColorManager provinceColorManager;
public ProvinceWeatherController weatherController;
public ProvinceFestivalController festivalController;
public ProvinceEpidemicController epidemicController;
public ProvinceBlizzardController blizzardController;
public ProvinceDroughtController droughtController;
public ProvinceFloodController floodController;
public ProvinceBeastsController beastsController;
public MapPinchZoomHandler zoomHandler;
public TMP_Text provinceNameText;
public TMP_Text provinceOwnerText;
@@ -34,16 +39,9 @@ namespace eagle {
}
private void SetProvinceColor(ProvinceId provinceId, Color color) {
var image = ImageForProvince(provinceId);
if (!image.texture) {
Texture2D texture = new Texture2D(1, 1);
texture.wrapMode = TextureWrapMode.Repeat;
image.texture = texture;
if (provinceColorManager != null) {
provinceColorManager.SetProvinceColor(provinceId, color);
}
Texture2D t2d = (Texture2D)image.texture;
t2d.SetPixel(0, 0, color);
t2d.Apply();
}
private ProvinceId? _selectedProvinceId;
@@ -115,16 +113,18 @@ namespace eagle {
}
if (weatherController != null) { weatherController.UpdateWeatherEffects(value); }
if (festivalController != null) { festivalController.UpdateFestivalEffects(value); }
if (epidemicController != null) { epidemicController.UpdateEpidemicEffects(value); }
if (blizzardController != null) { blizzardController.UpdateBlizzardEffects(value); }
if (droughtController != null) { droughtController.UpdateDroughtEffects(value); }
if (floodController != null) { floodController.UpdateFloodEffects(value); }
if (beastsController != null) { beastsController.UpdateBeastsEffects(value); }
eagleGameController.ProvinceWasSelected(_selectedProvinceId);
}
}
private RawImage ImageForProvince(ProvinceId id) => provinceImagesList[id - 1];
public TextAsset rawGray;
private int _imageWidth;
private int _imageHeight;
public ProvinceIDLoader provinceIDLoader;
private readonly Color _selectionColor = Color.grey;
private readonly Color _targetColor = Color.red;
@@ -147,9 +147,7 @@ namespace eagle {
}
void Start() {
_mapIdBytes = rawGray.bytes;
_imageWidth = mapBWImage.texture.width;
_imageHeight = mapBWImage.texture.height;
_mapIdBytes = provinceIDLoader.DecompressedBytes;
Enabled = true;
_previouslyHoveredProvinceId = null;
}
@@ -158,17 +156,18 @@ namespace eagle {
var mousePosition = new Vector2(point.x, Screen.height - point.y);
var mapRect = gameObject.GetComponentInParent<PanelPositions>().MapRect;
var relativeX = (int)((mousePosition.x - mapRect.x) * _imageWidth / mapRect.width);
var relativeY =
(int)(_imageHeight -
(mousePosition.y - mapRect.y) * _imageHeight / mapRect.height);
int mapWidth = provinceIDLoader.mapWidth;
int mapHeight = provinceIDLoader.mapHeight;
if (relativeX < 0 || relativeX >= _imageWidth || relativeY < 0 ||
relativeY >= _imageHeight) {
var relativeX = (int)((mousePosition.x - mapRect.x) * mapWidth / mapRect.width);
var relativeY =
(int)(mapHeight - (mousePosition.y - mapRect.y) * mapHeight / mapRect.height);
if (relativeX < 0 || relativeX >= mapWidth || relativeY < 0 || relativeY >= mapHeight) {
return null;
}
var provinceId = _mapIdBytes[relativeY * _imageWidth + relativeX];
var provinceId = _mapIdBytes[relativeY * mapWidth + relativeX];
if (provinceId == 0 || provinceId == 0xFF) { return null; }
return provinceId;
}
@@ -0,0 +1,187 @@
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace eagle {
/// <summary>
/// Handles pinch-to-zoom and scroll-wheel zoom for a map displayed in a ScrollRect.
/// Attach this to the same GameObject as the ScrollRect.
/// </summary>
[RequireComponent(typeof(ScrollRect))]
public class MapPinchZoomHandler : MonoBehaviour, IScrollHandler {
public float minZoom = 1.0f;
public float maxZoom = 3.0f;
public float scrollZoomSpeed = 0.1f;
public float pinchZoomSpeed = 0.01f;
public float zoomSmoothTime = 0.1f; // Time to smooth zoom changes
private ScrollRect _scrollRect;
private RectTransform _content;
private RectTransform _viewport;
private Canvas _canvas;
private float _currentZoom = 1.0f;
private float _targetZoom = 1.0f;
private float _zoomVelocity = 0f;
private Vector2 _lastZoomCenter;
// For coordinate conversion (used by MapController)
public float CurrentZoom => _currentZoom;
public Vector2 PanOffset {
get {
if (_content == null) return Vector2.zero;
return _content.anchoredPosition;
}
}
// Pinch tracking
private bool _isPinching;
private float _previousPinchDistance;
private Vector2 _previousPinchCenter;
void Awake() {
_scrollRect = GetComponent<ScrollRect>();
_content = _scrollRect.content;
_viewport = _scrollRect.viewport != null ? _scrollRect.viewport
: GetComponent<RectTransform>();
_canvas = GetComponentInParent<Canvas>();
// Disable scrolling at zoom level 1 (no panning needed)
UpdateScrollability();
}
void Update() {
// Handle touch input on iOS
if (Application.platform == RuntimePlatform.IPhonePlayer) { HandleTouchInput(); }
// Smoothly interpolate toward target zoom
if (!Mathf.Approximately(_currentZoom, _targetZoom)) {
float previousZoom = _currentZoom;
_currentZoom = Mathf.SmoothDamp(
_currentZoom,
_targetZoom,
ref _zoomVelocity,
zoomSmoothTime);
// Apply the smoothed zoom
ApplyCurrentZoom(_lastZoomCenter, previousZoom);
}
}
private void HandleTouchInput() {
if (Input.touchCount == 2) {
Touch touch0 = Input.GetTouch(0);
Touch touch1 = Input.GetTouch(1);
float currentDistance = Vector2.Distance(touch0.position, touch1.position);
Vector2 currentCenter = (touch0.position + touch1.position) / 2f;
if (!_isPinching) {
_isPinching = true;
_previousPinchDistance = currentDistance;
_previousPinchCenter = currentCenter;
// Disable scroll rect during pinch to prevent fighting
_scrollRect.enabled = false;
return;
}
// Calculate zoom delta from pinch
float distanceDelta = currentDistance - _previousPinchDistance;
float zoomDelta = distanceDelta * pinchZoomSpeed;
ApplyZoom(zoomDelta, currentCenter);
_previousPinchDistance = currentDistance;
_previousPinchCenter = currentCenter;
} else {
if (_isPinching) {
_isPinching = false;
UpdateScrollability();
}
}
}
// IScrollHandler - captures scroll wheel events
public void OnScroll(PointerEventData eventData) {
float scrollDelta = eventData.scrollDelta.y;
if (scrollDelta != 0) { ApplyZoom(scrollDelta * scrollZoomSpeed, eventData.position); }
}
private void ApplyZoom(float delta, Vector2 screenCenter) {
// Set target zoom - actual zoom is applied smoothly in Update()
_targetZoom = Mathf.Clamp(_targetZoom + delta, minZoom, maxZoom);
_lastZoomCenter = screenCenter;
}
private void ApplyCurrentZoom(Vector2 screenCenter, float previousZoom) {
// Get the cursor position relative to viewport (0,0 = bottom-left, 1,1 = top-right)
// Use canvas camera for ScreenSpaceCamera mode, null for ScreenSpaceOverlay
Camera cam = _canvas != null ? _canvas.worldCamera : null;
RectTransformUtility.ScreenPointToLocalPointInRectangle(
_viewport,
screenCenter,
cam,
out Vector2 viewportLocalPoint);
Vector2 viewportSize = _viewport.rect.size;
Vector2 cursorViewportNorm = new Vector2(
(viewportLocalPoint.x + viewportSize.x * _viewport.pivot.x) / viewportSize.x,
(viewportLocalPoint.y + viewportSize.y * _viewport.pivot.y) / viewportSize.y);
// Calculate content size before and after zoom
Vector2 contentSizeBefore = _content.rect.size * previousZoom;
Vector2 contentSizeAfter = _content.rect.size * _currentZoom;
// Calculate the scrollable range (content size - viewport size)
Vector2 scrollRangeBefore = contentSizeBefore - viewportSize;
Vector2 scrollRangeAfter = contentSizeAfter - viewportSize;
// Get current scroll offset in pixels
Vector2 scrollOffsetBefore = new Vector2(
scrollRangeBefore.x > 0 ? _scrollRect.normalizedPosition.x * scrollRangeBefore.x
: 0,
scrollRangeBefore.y > 0 ? _scrollRect.normalizedPosition.y * scrollRangeBefore.y
: 0);
// The point under cursor in content space (from bottom-left of content)
Vector2 cursorContentPoint = scrollOffsetBefore + cursorViewportNorm * viewportSize;
// After zoom, where should scroll offset be to keep same content point under cursor?
float zoomRatio = _currentZoom / previousZoom;
Vector2 cursorContentPointAfter = cursorContentPoint * zoomRatio;
Vector2 scrollOffsetAfter = cursorContentPointAfter - cursorViewportNorm * viewportSize;
// Apply the zoom scale
_content.localScale = Vector3.one * _currentZoom;
// Update scrollability based on new zoom level
UpdateScrollability();
// Convert back to normalized position
if (_currentZoom > 1.0f && scrollRangeAfter.x > 0 && scrollRangeAfter.y > 0) {
Vector2 newNormalizedPos = new Vector2(
Mathf.Clamp01(scrollOffsetAfter.x / scrollRangeAfter.x),
Mathf.Clamp01(scrollOffsetAfter.y / scrollRangeAfter.y));
_scrollRect.normalizedPosition = newNormalizedPos;
} else if (_currentZoom <= 1.0f) {
// At zoom 1, center the content
_scrollRect.normalizedPosition = new Vector2(0.5f, 0.5f);
}
}
private void UpdateScrollability() {
// Only allow scrolling when zoomed in
bool canScroll = _currentZoom > 1.0f;
_scrollRect.horizontal = canScroll;
_scrollRect.vertical = canScroll;
_scrollRect.enabled = canScroll || !_isPinching;
}
public void ResetZoom() {
_currentZoom = 1.0f;
_targetZoom = 1.0f;
_zoomVelocity = 0f;
_content.localScale = Vector3.one;
_scrollRect.normalizedPosition = new Vector2(0.5f, 0.5f);
UpdateScrollability();
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cc258a4d89a7a41278242c1405ccebe5

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