Compare commits

..
Author SHA1 Message Date
adminandClaude Opus 4.5 99e331b40b Fix symbol exports for SparklePlugin native library
The C functions need to be exported with visibility("default") and
explicit linker flags for Unity P/Invoke to find them. Without this,
the bundle binary had no exported symbols.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:04:51 -08:00
adminandClaude Opus 4.5 0d832ea8f2 Convert Sparkle plugin build from clang to Bazel
- Add Sparkle framework as http_archive dependency in MODULE.bazel
- Add BUILD.sparkle to import the framework
- Add BUILD.bazel for SparklePlugin using macos_bundle rule
- Update build_sparkle_plugin.sh to use Bazel instead of direct clang
- Register Apple CC toolchain extension

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 17:10:10 -08:00
adminandClaude Opus 4.5 e296a31033 Add native Sparkle plugin to enable Mac auto-updates
The Sparkle framework was being injected into the app bundle, but
nothing was initializing it. This adds:

- Native Objective-C plugin (SparklePlugin.m) that initializes
  SPUStandardUpdaterController at runtime
- C# wrapper (SparkleUpdater.cs) for Unity to call the native plugin
- SparkleInitializer.cs uses RuntimeInitializeOnLoadMethod to
  automatically initialize Sparkle at app startup
- Build script to compile the plugin as a universal binary

The plugin is weak-linked against Sparkle.framework, which is injected
separately by inject_sparkle.sh during the build process.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 17:00:34 -08:00
239d626ac1 Fix Mac build artifact handling to preserve .app bundle structure (#5305)
The issue was that upload-artifact uploads directory *contents*, not
the directory itself. So uploading eagle0.app resulted in an artifact
containing Contents/... without the eagle0.app wrapper. When downloaded,
this corrupted the .app bundle structure.

Fix by:
- Zip the .app bundle with ditto before uploading (preserves structure
  and macOS extended attributes)
- Unzip after downloading to restore the proper .app bundle
- Clean download directories before extracting to avoid stale state

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:53:18 -08:00
bb1f3929c3 Fix proto workflow triggers and remove redundant workflow (#5303)
- Fix unity_build.yml and mac_build.yml to trigger on actual client
  proto directories instead of non-existent src/main/proto/** path
- Trigger on: common/**, shardok/**, eagle/api/**, eagle/common/**,
  eagle/views/** (excludes eagle/internal/** which is server-only)
- Remove build_protos_test.yml as redundant (unity/mac builds run
  build_protos.sh)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:47:46 -08:00
8fb218d8ee Remove proto overloads from diplomacy resolution factories (#5302)
Delete proto GameState overloads from AvailableResolve*CommandFactory
classes since they now use only Scala types. Move package.scala with
proto helper functions to test directory. Convert break alliance test
to use Scala types.

Reduces proto imports in library/ from 192 to 168.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:43:09 -08:00
c85973edda Remove unused client presigner service (#5301)
The client presigner was intended for generating presigned S3 URLs,
but assets.eagle0.net now points directly to the DigitalOcean CDN
(eagle0-windows bucket is public). The presigner was never deployed.

Removed:
- .github/workflows/client_presigner.yml
- src/main/go/net/eagle0/client_download/
- Presigning code from util/aws/s3.go (GetPresignedURL, NewPresigner, Presigner)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:42:38 -08:00
ae970a6693 Remove unused internal proto from client build (#5304)
The internal/unaffiliated_hero.proto was mistakenly included in the
client proto build. Internal protos should only be used server-side.
No C# code references types from Net.Eagle0.Eagle.Internal namespace.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:42:09 -08:00
6df165cc5a Tutorial bug fixes: prevent multiple popups and remove placeholder paths (#5300)
* Prevent multiple tutorials from showing simultaneously

- Don't trigger contextual tutorials while another is already active
- Fix HideOverlay to work even when parent container is inactive
  (was silently returning without hiding, causing UI pile-up)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Remove placeholder TargetGameObjectPath values from tutorials

The placeholder values like "ProvinceUI", "BattleButton", "EndTurnButton"
don't match actual GameObjects in the scene, causing warnings.

Overlays now show centered without targets. TODO comments mark where
to add proper targeting once the actual UI hierarchy is known.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Add tutorial content documentation for editing

Creates docs/TUTORIAL_CONTENT.md with:
- Full onboarding sequence (13 steps) with titles, descriptions, triggers
- Strategic contextual tutorials (diplomacy, heroes, weather, prisoners)
- Tactical contextual tutorials (spells, terrain, abilities)
- Display mode reference
- Content guidelines

Edit this doc to refine content, then update TutorialContentDefinitions.cs.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:19:43 -08:00
c26b531792 Fix last played time not persisting across server restarts (#5299)
The lastPlayedByUser timestamp was updated in postCommand and
postShardokCommand but save() was not called, so the data stayed
in memory until something else triggered a save.

Add save() calls after updating lastPlayedByUser to ensure the
timestamp is persisted to disk immediately.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:14:46 -08:00
4682784737 Consolidate validators by removing proto versions (#5298)
Remove redundant proto-based Validator and RuntimeValidator that used
proto types. The Scala versions (ScalaValidator/ScalaRuntimeValidator)
have identical validation logic and are already used by ActionResultApplierImpl.

- Delete proto Validator.scala and RuntimeValidator.scala
- Rename ScalaValidator -> Validator
- Rename ScalaRuntimeValidator -> RuntimeValidator
- Update all imports across library/ and test/ code
- Delete unused TestingNoopValidator

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:07:29 -08:00
139eb89edb Fix command loss during blue-green deployment reconnect (#5297)
Bug: When a deploy happened while a user had a command in-flight,
the command could be lost without the user knowing:

1. User posts command with token T
2. PostRequest adds to _pendingCommands, WriteAsync completes locally
3. PostRequest removes from _pendingCommands (TOO EARLY!)
4. Connection dies before server receives command
5. Reconnect - _pendingCommands is empty, command never retried
6. User sees "Processing..." forever

Root cause: WriteAsync completing only means data was written to local
TCP buffers, not that the server received and processed it. The command
was removed from _pendingCommands prematurely.

Fix:
- Don't remove from _pendingCommands after WriteAsync success
- Remove only when server confirms: PostCommandResponse SUCCESS or BAD_TOKEN
- TryPendingCommands already handles stale commands (token advanced)

This ensures commands are retried on reconnect if they weren't confirmed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 15:51:01 -08:00
c6c31cd28e Remove redundant S3 backup from deploy script (#5296)
Eagle server already persists to S3 on every game save via CompoundPersister
when S3Credentials.isEnabled. The deploy script's s3cmd backup was redundant
and caused warnings when s3cmd wasn't installed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 15:36:47 -08:00
d5665c9570 Add bazel label to workflows for consistent Bazel cache (#5295)
Docker builds were failing because jobs could run on any self-hosted
runner, but each runner has a different Bazel output base. When a job
ran on a different runner than previous builds, Bazel's remote cache
reported "cached" but local output files didn't exist.

Fix: Add `bazel` label requirement to all generic Bazel-based workflows.
The specialized Unity/notarization runners don't have this label, so
they won't pick up these jobs.

Workflows updated:
- auth_build.yml
- bazel_test.yml
- build_protos_test.yml
- client_presigner.yml
- docker_build.yml
- installer_build.yml
- shardok_arm64_build.yml
- shardok_build.yml

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 14:59:10 -08:00
0719d01819 Tutorial Phase 4: Content definitions (#5289)
* Add tutorial content definitions (Phase 4)

Create TutorialContentDefinitions.cs with all tutorial content:

Onboarding sequence (13 steps):
- Welcome, map overview, province selection
- Command panel, march command, turn cycle
- Battle intro, enter battle, tactical overview
- Move units, attack enemies, end turn, completion

Strategic contextual tutorials:
- Diplomacy introduction
- Hero recruitment
- Weather control
- Prisoner management

Tactical contextual tutorials:
- Spells: Lightning, Meteor, Holy Wave, Raise Dead
- Terrain: Fire hazards, water crossing
- Abilities: Cavalry charge

Content is defined in code for easy version control and review.
TutorialManager now auto-registers all content on initialization.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix tutorial UI not showing when parent container inactive

Add ActivateParents() to TutorialOverlayController and TutorialModalPanel
to ensure all parent GameObjects are active before showing. This fixes
the error "Coroutine couldn't be started because the game object is inactive".

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 14:53:11 -08:00
a4d7ac4283 Delete unused appliedResults and rename appliedResultsScala (#5294)
The proto-based appliedResults method was never called - all code paths
use the Scala-based appliedResultsScala. This removes the dead code and
renames appliedResultsScala to appliedResults for clarity.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 14:47:59 -08:00
cf16c594a2 Delete unused ActionResultTApplier and ActionResultTApplierImpl (#5293)
These files wrapped proto→Scala→proto conversions but were never used
anywhere in the codebase. Removing them as dead code cleanup.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 14:35:10 -08:00
1073a208de Make AvailableCommandConverter use Scala GameState directly (#5292)
Previously, AvailableCommandConverter.toProto() took a proto GameState and
internally called GameStateConverter.fromProto() to get the Scala GameState
needed for lookups. This caused unnecessary Scala→Proto→Scala round-trips.

This change:
- Updates AvailableCommandConverter.toProto() to take Scala GameState directly
- Updates OneProvinceAvailableCommandsConverter.toProto() similarly
- Updates all callers (GameController, AIClient, action files) to pass
  Scala GameState directly instead of converting to proto first
- Updates tests to use Scala GameState

This eliminates proto conversion overhead in the command availability path.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 13:57:57 -08:00
e96c0704ed Split Unity builds across dedicated runners with async notarization (#5291)
Add dedicated self-hosted runners for parallel Unity builds:
- unity-mac: Mac Unity builds and deployment
- unity-windows: Windows Unity builds (cross-compiled on Mac)
- notarize: Notarization waiting (lightweight, doesn't block builds)

Split mac_build.yml into 3 jobs:
1. build-and-sign (unity-mac): Build, sign, submit to Apple
2. wait-notarization (notarize): Wait for Apple, staple ticket
3. deploy (unity-mac): Deploy notarized app

This allows:
- Mac and Windows Unity builds to run in parallel
- Notarization waiting doesn't block other builds
- All runners share the same Mac Mini hardware

New scripts:
- notarize_submit.sh: Submit without waiting, output submission ID
- notarize_wait.sh: Wait for submission ID, staple ticket

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 13:29:27 -08:00
ef1a447431 Make ActionResultFilter use Scala GameState directly (#5290)
Eliminates unnecessary Scala→Proto→Scala round-trip conversions in the
action result filtering path:

- ActionResultFilter now takes Scala GameState instead of proto
- Uses Scala RoundPhase enum instead of proto RoundPhase
- Removed GameStateConverter.fromProto calls in filteredGameStateDiff
- Updated callers (EngineImpl, HumanPlayerClientConnectionState) to pass
  Scala state directly instead of converting to proto first

JFR profiling showed proto conversion taking ~16% of eagle0 time. This
change reduces that overhead in the filtering path by eliminating:
- 1x Scala→Proto conversion per filter call in callers
- 2x Proto→Scala conversions per action result (before/after states)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 12:58:33 -08:00
f874231e04 Fix notarize script cleanup to be idempotent (#5288)
Use rm -f instead of rm when cleaning up the zip file after notarization.
The zip may already be deleted if a previous step failed and was retried,
causing the script to fail even when notarization actually succeeded.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 11:46:18 -08:00
5acb4c4f14 Add download confirmation dialog when stopping JFR (#5287)
When clicking "Stop" on JFR recording, a dialog now appears with options:
- Download & Stop: Downloads the recording then stops
- Stop Only: Stops without downloading
- Cancel: Keeps recording

This prevents accidentally losing recordings by clicking Stop without
remembering to download first.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 11:38:45 -08:00
2dc6f4d0bb Make Engine.getAvailablePlayerCommands return Scala types only (#5283)
* Add getScalaAvailablePlayerCommands to Engine interface

Expose a method that returns available commands using Scala types directly,
avoiding proto conversion overhead. This enables AI clients and other internal
callers to work with native Scala types without round-tripping through proto.

The existing getAvailablePlayerCommands method continues to return proto types
for gRPC client compatibility.

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

* Make Engine.getAvailablePlayerCommands return Scala types only

Callers that need proto types for gRPC (like GameController) now
convert using OneProvinceAvailableCommandsConverter. AIClient
converts to proto temporarily until command choosers are migrated.

Changes:
- Engine.getAvailablePlayerCommands returns SortedMap[ProvinceId, ScalaOneProvinceAvailableCommands]
- Removed separate getScalaAvailablePlayerCommands method
- Added toProtoAvailableCommands helper to GameController for gRPC conversion
- Updated AIClient to convert Scala to proto for command choosers
- Updated tests to use SortedMap.empty for mocked commands
- Removed unused proto deps from EngineImpl

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

* Remove unnecessary Scala prefix from OneProvinceAvailableCommands imports

No longer need to disambiguate since proto types are only used where needed.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 11:25:00 -08:00
bbef0c1430 Display last played time in lobby game list (client-side) (#5285)
* Display last played time in lobby game list (client-side)

- Add lastPlayedField to RunningGameItem for displaying time
- Format time as relative (e.g., "Just now", "5m ago", "2h ago", "3d ago")
- Fall back to date format ("Jan 5") for older times
- Display in user's local timezone

Requires server-side changes from PR #5281 (now merged).

Note: The lastPlayedField TextMeshProUGUI reference needs to be added
to the RunningGameItem prefab in Unity.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Wire up lastPlayedField in RunningGameItem prefab

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 11:20:40 -08:00
f929672f83 Refresh game state when pending command is dropped as stale (#5284)
When a pending command is dropped because the server's token has
advanced (indicating the command was already processed), the client
now triggers a re-subscription to ensure it has the current game state.

This fixes a race condition during deployment reconnects where:
1. User posts command, UI clears available commands
2. Connection drops during deployment
3. Server processes command, token advances
4. Client reconnects, pending command dropped as "stale"
5. UI was stuck with no commands visible

Changes:
- Add game_id to PostCommandResponse proto for targeted refresh
- Server echoes game_id in SUCCESS and ERROR responses
- Client refreshes specific game subscription when:
  - Pending command dropped as stale (token mismatch)
  - Server returns BAD_TOKEN response

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 11:04:21 -08:00
7eb362a09b Add timing logs for first-connect performance profiling (#5279)
Adds [TIMING] logs to identify slow operations during client subscription:

- GamesManager.streamUpdates: logs ensureGameLoaded and filtering time
- HumanPlayerClientConnectionState.streamUpdates: logs shardok filtering,
  action result filtering, and game state view filtering
- filteredResultsFrom: logs GameStateConverter.toProto and
  ActionResultFilter.filterForOptionalPlayer separately

Logs only appear when operations exceed 10-50ms thresholds to avoid
noise during normal operation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 11:03:55 -08:00
e4b7d8e8a8 Fix: Remove shardok from eagle-green depends_on (#5286)
Missed this in #5282 - eagle-green still had depends_on: shardok
which caused the deployment to fail.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 11:02:06 -08:00
a56bea82f1 Track last played time for games (server-side) (#5281)
- Add last_played_by_user map to RunningGame proto for persistence
- Track last played time in ControllerInfo when processing commands
- Update postCommand and postShardokCommand to record timestamps
- Persist and load last played times across server restarts
- Include lastPlayedTimestampMillis in GameInfo lobby response

The client-side display will be added in a follow-up PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 10:55:59 -08:00
8bc22baa7c Remove DigitalOcean Shardok deployment (use Hetzner only) (#5282)
Shardok now runs exclusively on the Hetzner ARM64 server, deployed via
the shardok_arm64_build.yml workflow. This removes:

- shardok service from docker-compose.prod.yml
- Shardok x86 build/push from docker_build.yml
- SHARDOK_IMAGE from env.template and deploy script
- C++ path trigger from docker_build.yml (handled by ARM64 workflow)

The next deployment will stop and remove any existing shardok-server
container on the DigitalOcean droplet.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 10:54:07 -08:00
2f31d94313 Increase warmup timeout from 90s to 180s for cold JVM (#5280)
CreateGame on a cold JVM took >90s in production, causing warmup to
fail and abort the deployment. Increase per-operation timeout to 180s.

The overall warmup timeout (--timeout flag) is already 300s, but the
internal per-operation timeout was only 90s.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 09:53:57 -08:00
7b46295e17 Optimize PersistedHistory to reduce serialization overhead (#5278)
Change incremental saves from O(n²) to O(n) by saving individual ActionResults
to separate .e0r files immediately, then consolidating into chunks and deleting
the individual files. This eliminates redundant re-serialization of the same
results when building up a chunk incrementally.

Also adds crash recovery support: orphaned .e0r files are loaded and merged
with chunk data on startup, preserving any results written before a crash.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 09:45:06 -08:00
6c154e827c FilterContext optimization for filteredGameState (#5277)
* Add FilterContext optimization for filteredGameState

Introduces a FilterContext class that pre-computes expensive data once
at the start of filteredGameState, avoiding repeated O(n) and O(n*m)
lookups when filtering game state for player views.

Key optimizations:
- Pre-compute ally pairs as Set for O(1) alliance checks (was O(n))
- Pre-compute prisoner hero IDs as Set for O(1) lookups (was O(heroes*provinces))
- Pre-compute hero-to-province mapping for O(1) lookups (was O(provinces))
- Cache factionLeaderIds to avoid repeated flatMap allocations

Complexity improvements:
- HeroViewFilter: O(heroes * provinces) -> O(heroes + provinces)
- BattalionNameFilter: O(factions²) -> O(factions)
- Overall filteredGameState: eliminates ~148+ repeated Vector allocations

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

* Remove unused filter overloads and update tests

- Remove old FactionViewFilter.filteredFactionView 3-param overload
- Remove old ProvinceViewFilter.filteredProvinceView 3-param overload
- Remove old BattalionNameFilter.filteredBattalionNames 2-param overload
- Remove old helper methods no longer needed
- Update FactionViewFilterTest to use FilterContext
- Update ProvinceViewFilterTest to use FilterContext

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 08:23:10 -08:00
040b0cf580 Fix user wait window metric to measure actual user impact (#5275)
* Auto-invalidate game cache when flush marker is updated

During warmup, the staging server may cache stale game data that was
loaded before the active server flushed. Instead of exposing an RPC
for cache invalidation (which leaks internal state), the server now
automatically detects when the flush marker is updated and invalidates
any cached games.

Changes:
- GamesManager: Added `invalidateCacheIfFlushMarkerUpdated()` that
  checks the flush marker's modification time and clears the cache
  if it's been updated since the last check
- Called before checking if a game is in cache, so stale data is
  cleared before any attempt to use it
- Removed the InvalidateGameCache RPC (no longer needed)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix user wait window metric to measure actual user impact

The max user wait window was measuring from when nginx switching
started (before recreation) instead of when it completed. This
inflated the metric by ~12s because nginx recreation time was included.

Users can only experience a wait AFTER nginx starts routing to the
staging server, so we now measure from nginx_switch_end (after
recreation completes) to flush_end.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 08:17:24 -08:00
3ae3f6ba16 Auto-invalidate game cache when flush marker is updated (#5273)
During warmup, the staging server may cache stale game data that was
loaded before the active server flushed. Instead of exposing an RPC
for cache invalidation (which leaks internal state), the server now
automatically detects when the flush marker is updated and invalidates
any cached games.

Changes:
- GamesManager: Added `invalidateCacheIfFlushMarkerUpdated()` that
  checks the flush marker's modification time and clears the cache
  if it's been updated since the last check
- Called before checking if a game is in cache, so stale data is
  cleared before any attempt to use it
- Removed the InvalidateGameCache RPC (no longer needed)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 08:04:56 -08:00
6da0636731 Handle PostCommandResponse ERROR status from server (#5274)
When the server returns a PostCommandResponse with ERROR status,
disconnect and reconnect using the normal flow. This ensures the
client recovers gracefully from server-side errors during command
processing.

Also logs BAD_TOKEN responses for debugging purposes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 08:04:29 -08:00
53ab903568 Make view_filters completely protoless (#5271)
* Make view_filters completely protoless

Remove all proto type overloads from the view_filters package, forcing
callers to use Scala types exclusively. This is part of the ongoing
effort to reduce proto dependencies in the codebase.

Changes:
- Visibility: Remove proto GameState overloads
- BattalionNameFilter: Remove proto overload (~60 lines)
- HeroViewFilter: Remove proto overloads, use Scala RoundPhase
- ProvinceViewFilter: Remove proto overloads (~280 lines)
- FactionViewFilter: Remove proto overload
- BattleFilter: Remove proto overloads
- ArmyFilter: Remove proto overloads

Also:
- Delete unused ExpandedCombatUnitUtils.scala
- Update AvailableCommandConverter to convert types before calling filters
- Remove outdated view_filter tests that used proto types

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

* Add protoless tests for view_filters

Restore FactionViewFilterTest, HeroViewFilterTest, and ProvinceViewFilterTest
using Scala types (FactionC, ProvinceC, HeroC, GameState) instead of protos.

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

* Add comprehensive protoless ProvinceViewFilterTest

Rewrites ProvinceViewFilterTest with full coverage of all original test
cases, using Scala types instead of proto types. Tests cover:
- Devastation and economy values
- Ruler traveling status
- Incoming armies (own/hostile/neutral provinces)
- Unaffiliated heroes
- Incoming supplies visibility
- Reconned views from self/allies
- Most recent reconned view selection
- Killed heroes filtering from reconned views

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 07:26:52 -08:00
e7745cc9ec Fix deployment script permission denied on marker files (#5272)
The saves directory is owned by root (created by Docker) but the deploy
script runs as the deploy user. Use docker exec to create marker files
from inside a running container that has the saves directory mounted.

- create_deployment_marker: uses active container (running before staging starts)
- create_flush_marker: uses staging container (running after active stops)
- cleanup_markers_on_failure: uses active container (still running on failure)
- remove_stale_deployment_marker: finds any running eagle container

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 07:02:05 -08:00
a3f185b6f6 Enhance tutorial trigger detection for strategic and tactical events (#5269)
* Enhance tutorial trigger detection for strategic and tactical events

Expands TutorialTriggerRegistry with specific condition detection:

Strategic triggers:
- Diplomacy command availability
- Weather control availability
- Province riots (new)
- Hero recruitment opportunities

Tactical triggers:
- Spell cast detection (lightning, meteor, holy wave, raise dead)
- Ability usage (charge, flanking)
- Terrain encounters (fire, water)
- Spell/ability availability when commands update

Also adds OnTacticalCommandsAvailable hook to ShardokGameController
to detect when special abilities become available.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix tutorial trigger compilation errors

- Use correct field name ControlWeatherSelectedCommand (not ControlWeatherCommand)
- Remove CheckBattleMapFeatures - HexMap doesn't have FireCoords/WaterCoords

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix remaining compilation errors in TutorialTriggerRegistry

- Use ControlWeatherAvailableCommand (not ControlWeatherCommand) for AvailableCommand
- Use FactionId (not Faction) for HeroView

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix all proto field name mismatches in TutorialTriggerRegistry

- Use opac.Commands (not AvailableCommands) for OneProvinceAvailableCommands
- Remove province riot detection (riot status not exposed in ProvinceView)
- Remove FlankAttack (doesn't exist in ActionType)
- Use CrossedWater (not CrossWater) for ActionType
- Use LightningBoltCommand (not LightningCommand) for CommandType

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 07:00:58 -08:00
8f3bee853f Add flush marker coordination for zero-downtime blue-green deploys (#5270)
* Fix nginx not picking up config changes during blue-green deploy

Root cause: `docker compose restart nginx` doesn't refresh bind-mounted
volume files. The nginx container keeps using its cached copy of
nginx.conf even after we update the host file with sed.

This caused nginx to keep trying to connect to the old (now deleted)
eagle instance, resulting in 502 errors after deployment.

Fix:
- Use `docker compose up -d --force-recreate nginx` instead of `restart`
  This recreates the container, forcing it to read the updated config
- Add verification that nginx picked up the correct backend
- Remove the useless pre-validation (it validated old config in old container)

The progression of failed fixes:
1. `nginx -s reload` - doesn't re-resolve Docker DNS
2. `docker compose restart` - doesn't refresh bind-mounted files
3. `docker compose up -d --force-recreate` - THIS WORKS

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Reorder deploy: switch nginx BEFORE stopping blue to avoid 502s

Previous order (caused 502 errors):
1. Stop blue → nginx still points to blue → 502!
2. Update nginx config
3. Recreate nginx → traffic finally works

New order (eliminates 502 window):
1. Update nginx config
2. Recreate nginx → traffic goes to green (blue still running)
3. Stop blue → flushes state to disk
4. 3-second pause for flush to complete

The stale data race condition is minimized by stopping blue immediately
after the nginx switch. Users reconnecting to green will lazy-load
fresh game data from disk (after blue has flushed).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Add flush marker coordination for zero-downtime blue-green deploys

This ensures green never serves stale game data during deployments:

1. Deploy script creates .deployment_in_progress marker at start
2. Green's lazy-load waits for flush marker if deployment in progress
3. nginx switches to green BEFORE stopping blue (zero 502 downtime)
4. Blue stops, flushes state to disk
5. Deploy script creates .flush_complete marker
6. Green's waiting lazy-loads proceed with fresh disk data

Key changes:
- GamesManager.scala: Add waitForFlushMarker() that blocks lazy-load
  during deployment until flush marker appears (30s timeout)
- GamesManager.scala: Auto-clean stale markers >5 minutes old
- GamesManager.scala: Add deployment ID correlation in logs [DEPLOY:xxx]
- GamesManager.scala: Report flush marker timeouts to Sentry
- deploy-blue-green.sh: Reorder to switch nginx BEFORE stopping blue
- deploy-blue-green.sh: Add marker file coordination with deployment ID
- deploy-blue-green.sh: Add timing metrics (flush duration, user wait window)
- nginx.conf: Keep variable-based routing (Docker DNS only resolves
  running containers, so upstream+backup doesn't work)

Monitoring and observability:
- All deployment-related logs tagged with [DEPLOY:timestamp] for correlation
- Wait duration logged for each lazy-load during deployment
- Flush marker timeouts reported to Sentry for alerting
- Deploy script logs total duration, flush duration, max user wait window

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 06:42:59 -08:00
b4234debce Fix O(N^2) performance issue in HeroViewFilter proto path (#5268)
Convert GameState once upfront in GameStateViewFilter proto overload,
then use the already-converted Scala heroes directly instead of
converting each hero individually.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 05:36:41 -08:00
7f562de2a8 Handle missing recruitmentInfo in UnaffiliatedHeroConverter (#5267)
Use fold with RecruitmentInfo.Unknown as fallback instead of .get
to handle proto UnaffiliatedHero objects where recruitmentInfo
is None.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:31:52 -08:00
bc5d86fb1e Consolidate Visibility proto overloads to delegate to Scala (#5266)
The proto overloads now convert factions via FactionConverter
and delegate to the Scala implementations, eliminating duplicate
logic.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:17:31 -08:00
10a747114a Consolidate FactionViewFilter proto overload to delegate to Scala (#5265)
Changes:
- Proto filteredFactionView now converts types and delegates to Scala version
- Removed ~45 lines of duplicate private methods (filteredRelationshipLevel,
  filteredFactionRelationshipView)

This continues the deproto work of consolidating proto and Scala code paths.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:12:46 -08:00
a94a48a4bf Add batch delete for games in admin console (#5264)
Add checkboxes next to each game in the admin console Games list,
allowing multiple games to be selected and deleted at once. When
games are selected, a batch actions bar appears with "Delete Selected"
and "Clear" buttons.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:07:21 -08:00
945511153a Consolidate HeroViewFilter proto overload to delegate to Scala (#5262)
Changes:
- Proto filteredHeroView now converts types and delegates to Scala version
- Removed duplicate private methods (heroIsPrisoner, heroUnaffiliatedInProvince,
  heroIsOfferedInRansom, includeFullHeroInfo)
- Added Gender.Unknown to Scala enum to preserve GENDER_UNKNOWN on round-trip
- Updated GenderConverter to handle Unknown case
- Fixed test data in AvailableCommandConverterTest to include valid currentPhase

This reduces code duplication while maintaining backward compatibility.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 21:05:00 -08:00
d131aa50f0 Use move command path for animation instead of straight line (#5261)
When clicking to move a unit, the animation now follows the actual
path of hexes from the CommandDescriptor's path field instead of
drawing a straight line from origin to destination. This prevents
the animation from showing units moving "over water" when the
actual path goes around it.

Changes:
- MoveAnimator: Add path-based animation method that draws prints
  along each segment of the path
- ShardokGameModel: Return full CommandDescriptor from
  PerformTargetedCommand instead of just CommandType
- ShardokGameController: Extract path from executed command and
  pass to animation system

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 20:58:03 -08:00
d0d84e98e0 Use nginx restart instead of reload for blue-green switch (#5260)
nginx reload doesn't always force DNS re-resolution in Docker. During
blue-green deployment, after updating nginx.conf to point to the new
instance (e.g., eagle-green:40032), nginx -s reload would sometimes
keep trying to connect to the old (now removed) container, causing
502 errors.

A full restart ensures nginx picks up the new upstream correctly.
The ~1-2 second restart time is acceptable for deployment reliability.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:52:56 -08:00
0e3f1bcb87 Increase warmup timeouts for cold JVM startup (#5259)
The warmup tool was timing out during CreateGame because the per-step
timeout was only 30 seconds. On a cold JVM (freshly started Eagle
instance during blue-green deployment), CreateGame can take longer
than 30 seconds due to:
- JIT compilation not yet warmed up
- First-time class loading
- Game initialization including Shardok communication

Changes:
- Increase per-operation timeout from 30s to 90s
- Increase overall warmup timeout from 60s to 300s (5 minutes)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:51:03 -08:00
37fe57be71 Add path field to CommandDescriptor for move animations (#5258)
CommandDescriptor now includes a repeated Coords path field that
contains all hexes traversed during a move command. This allows
clients to animate the actual path taken rather than a straight
line from origin to destination (which sometimes showed units
moving over water).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:50:29 -08:00
1a147b0a19 Delete LegacyProvinceUtils - complete deproto of province utilities (#5257)
This removes the last of the Legacy*Utils wrapper classes, completing the
deproto migration. ProvinceViewFilter now uses ProvinceUtils directly,
with a new resourceCap helper method to calculate gold/food caps from
pre-computed effective development values.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:47:20 -08:00
37a9a29eaf Add delete button to games list in admin console (#5256)
- Add delete button next to each game in the main games list
- Include confirmation modal with option to delete save files
- Reuses existing /games/{id}/delete endpoint

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:42:41 -08:00
0ff7d51f2c Fix deployment issues: missing volumes, redundant operations, validation (#5254)
1. Add missing jfr and jvm-tmp volumes to eagle-green
   - Without these, JFR sidecar can't attach to the JVM when green is active
   - JFR recordings wouldn't work either

2. Remove redundant sync_config_files from deploy script
   - CI already copies config files via scp before running deploy
   - Fetching from GitHub main could cause version mismatches
   - Eliminates unnecessary network calls during deployment

3. Skip image pull if already present locally
   - CI already pulls images before running deploy script
   - Saves time during CI deployments
   - Manual deployments still pull if needed

4. Add nginx config validation before reload
   - Run nginx -t before nginx -s reload
   - Rollback to backup config if validation fails
   - Prevents broken config from taking down nginx

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:42:02 -08:00
0354fefdca Add Unity meta file for TutorialOverlayBuilder.cs (#5252)
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:34:41 -08:00
6fb14ef9af Fix deployment downtime and config sync issues (#5253)
Problems fixed:
1. CI was recreating admin BEFORE blue-green, causing stale .env
2. CI was restarting nginx AFTER blue-green (double restart)
3. Deploy script used slow nginx restart instead of reload
4. Cleanup was blocking the critical path

Changes:
- CI: Only restart shardok before blue-green, let script handle rest
- CI: Remove duplicate nginx restart and fallback path
- Deploy: Use nginx reload (faster) with restart fallback
- Deploy: Update .env before restarting services
- Deploy: Run container cleanup in background

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:33:53 -08:00
eaf00de74d Delete LegacyFactionUtils - complete deproto of faction utilities (#5251)
* Begin deleting LegacyFactionUtils - convert first batch of callers

Converts the following callers to use FactionUtils with FactionConverter:
- ShardokInterfaceGrpcClient: isFactionLeader
- BattalionNameFilter: provinces (inlined as filter)
- IncomingArmyUtils: factionsAreMutuallyAllied, factionsAreHostile, hostilityStatus

Remaining files to convert:
- ProvinceViewFilter (hasAlliance, isFactionLeader)
- FactionViewFilter (prestige)
- BattleFilter (hostilityStatus)
- Visibility (hasAlliance)
- ActionResultFilter (hasAlliance)
- EligibleDiplomacyStatuses (hasProvinces)
- AvailableResolveInvitationCommandFactory (provinceCount)

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

* Delete LegacyFactionUtils - complete deproto of faction utilities

Convert all callers of LegacyFactionUtils to use FactionUtils + FactionConverter:
- BattleFilter: Use FactionConverter to get factions vector for hostilityStatus
- FactionViewFilter: Convert faction and provinces for prestige calculation
- ProvinceViewFilter: Convert factions for hasAlliance and isFactionLeader
- Visibility: Convert factions for hasAlliance
- ActionResultFilter: Convert factions for hasAlliance check
- EligibleDiplomacyStatuses: Convert provinces for hasProvinces check
- AvailableResolveInvitationCommandFactory: Convert provinces for provinceCount

Delete LegacyFactionUtils.scala and LegacyFactionUtilsTest.scala.
Update BUILD.bazel files to remove legacy_faction_utils dependencies.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:26:31 -08:00
f2743fe9cc Increase nginx client_max_body_size for game uploads (#5250)
Default is 1MB which is too small for game save uploads.
Set to 50MB to allow large game files.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:05:37 -08:00
0bdc4d46ce Add environment dropdown to connection panel (#5248)
* Add environment dropdown to connection panel

Allows switching environments before connecting, useful when selected
environment (e.g. QA) is down and user can't reach the lobby to switch.

- Add connectionEnvironmentDropdown field
- SetupConnectionEnvironmentDropdown() initializes on Start
- OnConnectionEnvironmentChanged() saves preference for next connection
- ShowAuthPanel() syncs dropdown when returning to connection screen

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Wire up connection panel environment dropdown in Unity

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 19:05:08 -08:00
0e9dc4121f Log and return errors for failed command Futures (#5249)
Previously, when command processing threw an exception (e.g., invalid
diplomacy resolution status), the Future would fail silently - no error
was logged, sent to Sentry, or returned to the client. The client would
just wait forever for a response that never came.

Changes:
- Add ERROR status and error_message field to PostCommandResponse proto
- Add .recover handler to postCommand Future in streaming handler
- Log errors to console with SimpleTimedLogger
- Print stack trace for debugging
- Report errors to Sentry for monitoring
- Return PostCommandResponse with ERROR status to client

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 18:14:48 -08:00
e9281f66fe Add concurrency control to prevent parallel deployments (#5247)
Multiple deployments were running simultaneously, causing:
- Container name conflicts ("shardok-server is already in use")
- Corrupted image downloads (short read errors)
- Race conditions with docker compose

This adds a concurrency group so deployments run one at a time.
New deployments queue (cancel-in-progress: false) rather than
canceling running ones to avoid leaving production in a bad state.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 18:07:09 -08:00
336d008f26 Fix Imprison status not being handled in SelectedCommandConverter (#5246)
The statusFromProto function was throwing IllegalArgumentException for
DIPLOMACY_OFFER_STATUS_IMPRISONED instead of returning the Imprisoned
status. This caused the game to fail when players selected the Imprison
option for diplomacy offers.

The bug was introduced in #5106 when SelectedCommandConverter was added.
The function was only designed to handle Accept/Reject but the Imprison
option was later added as an eligible status.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 18:06:38 -08:00
5a56f71c4c Require invitation codes for new user registration (#5244)
Add REQUIRE_INVITATION_CODE=true to auth service in production.
Without this, anyone could create an account without an invitation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:45:45 -08:00
6e66b88929 Delete LegacyHeroUtils and use HeroUtils with converters (#5245)
Updates callers to use HeroConverter + HeroUtils instead of LegacyHeroUtils:
- HeroViewFilter: Updated proto overload to convert heroes and use HeroUtils
- ProvinceViewFilter: Added heroIdSortOrderer helper using HeroConverter
- ShardokInterfaceGrpcClient: Updated archeryCapable/startFireCapable calls
- GameStateViewFilterTest: Updated test to use HeroConverter + HeroUtils

Also added new overload for HeroUtils.loyaltyAsStatWithCondition that takes
factionLeaderIds directly for cases where proto code has leader IDs but not
full faction objects.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:45:13 -08:00
eee10dc209 Fix CI jfr-sidecar startup when eagle-green is active (#5241)
The CI workflow was unconditionally starting jfr-sidecar (which shares
PID namespace with eagle-blue). When eagle-green is active after a
blue-green deployment, eagle-blue doesn't exist and jfr-sidecar fails.

Fix: Remove the redundant jfr-sidecar startup from CI. The
deploy-blue-green.sh script already handles starting the appropriate
jfr-sidecar (either jfr-sidecar or jfr-sidecar-green) based on which
eagle instance is active.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:44:35 -08:00
caa843f763 Fix archived/deleted games reappearing in games.e0es (#5243)
With lazy loading, save() merges in-memory games with unloaded games
from storage. When a game was archived or deleted:
1. It was removed from gameControllerInfos
2. save() read games.e0es and found the game
3. Since it wasn't in loadedGameIds, it was treated as "unloaded"
4. The game was written back to games.e0es

This caused FileNotFoundException spam in Sentry when the server tried
to load these archived games (their files no longer exist).

Fix: Track explicitly removed games in removedGameIds and exclude them
from the merge in save().

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:42:04 -08:00
bb6de75ad0 Add tutorial overlay system with runtime UI construction (#5214)
* Add tutorial overlay system with runtime UI construction

Implements TutorialOverlayBuilder to construct overlay UI at runtime,
similar to TutorialCanvasBuilder for modals.

Features:
- TutorialOverlayBuilder creates complete overlay UI hierarchy:
  - Background dimmer (semi-transparent)
  - Highlight frame with gold border and corner decorations
  - Tooltip container with title, description, continue button
  - Arrow pointer for visual connection
- TutorialUIManager auto-builds overlay if not assigned
- TutorialOverlayController.OnContinueClicked made public for button wiring
- Test tutorial now includes overlay step for End Turn button
- Updated TUTORIAL_PLAN.md with overlay system completion

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix HideOverlay coroutine error on inactive GameObject

Check if gameObject is active before starting FadeOut coroutine.
HideAll() may be called when overlay is already hidden.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:32:51 -08:00
ad418c8848 Make Mac installer double-clickable (#5242)
Change Mac installer from .sh to .command extension:
- .command files open Terminal and execute when double-clicked on macOS
- No more asking users to open Terminal and run bash commands
- Updated instructions to reflect simpler flow
- Added "Press Enter to exit" so users can see completion message

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:32:30 -08:00
093316b1c6 Delete LegacyBattalionUtils and use BattalionUtils with converters (#5240)
Updated LegacyProvinceUtils.monthlyFoodConsumption to use
BattalionUtils with BattalionConverter and BattalionTypeConverter
to convert proto types to Scala types.

Also added export for model/state/battalion from battalion_converter.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:16:54 -08:00
55ad8a8278 Admin console shows all games, auto-install s3cmd, restart nginx properly (#5238)
* Fix: Move S3 backup to BEFORE stopping old server

Critical fix: The old server may wipe games.e0es on shutdown if it
doesn't have the save() merge fix. The backup must happen BEFORE
stopping to capture valid data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Admin console shows all games, auto-install s3cmd, restart nginx properly

1. Admin console now shows all games from games.e0es, not just loaded ones:
   - Added getAllRunningGamesSummary() to GamesManager
   - Updated getRunningGames() to include unloaded games with "[Not loaded]" status
   - Clicking into a game triggers lazy loading via getGameHistory()

2. Deploy script improvements:
   - Auto-install s3cmd if not present (via pip or apt)
   - Auto-configure s3cmd for DigitalOcean Spaces from .env
   - Use nginx restart instead of reload to ensure all workers pick up new config

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:04:33 -08:00
0361464db1 Delete LegacyBattalionViewFilter and update callers to use Scala types (#5239)
Updated callers to use BattalionConverter + BattalionViewFilter instead:
- AvailableCommandConverter: proto Battalion → Scala → BattalionView → proto
- ExpandedCombatUnitUtils: proto Battalion → Scala → BattalionView
- ProvinceViewFilter: Scala BattalionT → BattalionView (direct)

Also added required exports and visibility for battalion_view_filter.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 17:02:02 -08:00
31649c73a7 Make manifest signature verification blocking (#5235)
* Make manifest signature verification blocking

When a public key is configured, the installer now rejects:
- Unsigned manifests (signature required)
- Manifests with invalid signatures

This closes the security gap where a compromised manifest could
point to a malicious installer. The SHA check on the installer
was already blocking, but an attacker could modify the manifest
to include the SHA of their malicious installer.

Behavior:
- No public key configured: allows any manifest (backwards compatible)
- Public key configured + valid signature: proceeds
- Public key configured + missing/invalid signature: BLOCKS update

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Always require valid manifest signature

Remove backwards-compatibility fallback - any installer with this
code will have been built with the public key injected by CI.

Now requires:
- Public key must be configured (fails if missing)
- Manifest must be signed (fails if unsigned)
- Signature must be valid (fails if invalid)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:41:10 -08:00
c24509e0b2 Fix: Move S3 backup to BEFORE stopping old server (#5237)
* Fix critical bug: save() now merges with existing games.e0es

CRITICAL BUG FIX: With lazy loading, gameControllerInfos only contains
games that have been loaded into memory. The old save() would overwrite
games.e0es with only the loaded games, losing all unloaded games.

This caused complete data loss of user-to-game mappings when:
1. New server started (empty gameControllerInfos)
2. Any operation triggered save()
3. games.e0es was overwritten with empty data

Fix: save() now reads existing games.e0es first and merges:
- Unloaded games are preserved from disk
- Loaded games use fresh in-memory state

Also adds logging to diagnose games.e0es read failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Add S3 backup of games.e0es during blue-green deployment

After stopping the old container (which flushes state to storage),
create a timestamped backup in S3 before switching nginx traffic.
This provides a recovery point if something goes wrong during deployment.

Backups are stored at: s3://eagle0/eagle/save/backups/games.e0es.<timestamp>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix blue-green deployment dependencies and auth routing

1. docker-compose.prod.yml:
   - Remove eagle-blue from nginx depends_on (use EAGLE_ADDR variable instead)
   - Remove eagle-blue from admin depends_on (use EAGLE_ADDR variable instead)
   - Add TODO note about jfr-sidecar limitation during green deployments

2. nginx/nginx.conf:
   - Fix auth.Auth location on port 443 to route to auth:40033 instead of
     eagle_backend. This was causing auth failures when clients connected
     via the main HTTPS port.

These changes allow blue-green deployments to work without hard
dependencies that cause docker-compose to recreate stopped containers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Add jfr-sidecar-green for blue-green JFR profiling support

- Add jfr-sidecar-green service that shares PID namespace with eagle-green
- Make admin's jfr-sidecar address configurable via JFR_SIDECAR_ADDR env var
- Update deploy script to:
  - Start appropriate jfr-sidecar with each eagle instance
  - Update .env with EAGLE_ADDR and JFR_SIDECAR_ADDR after switching
  - Restart admin service to pick up new addresses
  - Clean up old jfr-sidecar when removing old eagle instance

This ensures the JFR button in admin console works regardless of
whether blue or green is the active instance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Sync config files from GitHub at start of deployment

Downloads docker-compose.prod.yml and nginx.conf from the main branch
before starting deployment. This ensures new services (like jfr-sidecar-green)
are available when the deploy script runs.

- Preserves the current active instance in nginx.conf
- Creates .bak backups before overwriting
- Continues with existing files if GitHub fetch fails

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix: Move S3 backup to BEFORE stopping old server

Critical fix: The old server may wipe games.e0es on shutdown if it
doesn't have the save() merge fix. The backup must happen BEFORE
stopping to capture valid data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:40:31 -08:00
ca2a13e17b Delete LegacyRansomValidity and convert test to Scala types (#5236)
Remove the proto wrapper LegacyRansomValidity and its only caller (the
proto overload of AvailableResolveRansomOfferCommandFactory). Convert
the test from proto types to Scala types, replacing ScalaPB's .update()
lens syntax with helper functions.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:35:21 -08:00
65a9cf1f97 CRITICAL: Fix save() to merge with existing games.e0es (#5233)
* Fix critical bug: save() now merges with existing games.e0es

CRITICAL BUG FIX: With lazy loading, gameControllerInfos only contains
games that have been loaded into memory. The old save() would overwrite
games.e0es with only the loaded games, losing all unloaded games.

This caused complete data loss of user-to-game mappings when:
1. New server started (empty gameControllerInfos)
2. Any operation triggered save()
3. games.e0es was overwritten with empty data

Fix: save() now reads existing games.e0es first and merges:
- Unloaded games are preserved from disk
- Loaded games use fresh in-memory state

Also adds logging to diagnose games.e0es read failures.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Add S3 backup of games.e0es during blue-green deployment

After stopping the old container (which flushes state to storage),
create a timestamped backup in S3 before switching nginx traffic.
This provides a recovery point if something goes wrong during deployment.

Backups are stored at: s3://eagle0/eagle/save/backups/games.e0es.<timestamp>

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix blue-green deployment dependencies and auth routing

1. docker-compose.prod.yml:
   - Remove eagle-blue from nginx depends_on (use EAGLE_ADDR variable instead)
   - Remove eagle-blue from admin depends_on (use EAGLE_ADDR variable instead)
   - Add TODO note about jfr-sidecar limitation during green deployments

2. nginx/nginx.conf:
   - Fix auth.Auth location on port 443 to route to auth:40033 instead of
     eagle_backend. This was causing auth failures when clients connected
     via the main HTTPS port.

These changes allow blue-green deployments to work without hard
dependencies that cause docker-compose to recreate stopped containers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Add jfr-sidecar-green for blue-green JFR profiling support

- Add jfr-sidecar-green service that shares PID namespace with eagle-green
- Make admin's jfr-sidecar address configurable via JFR_SIDECAR_ADDR env var
- Update deploy script to:
  - Start appropriate jfr-sidecar with each eagle instance
  - Update .env with EAGLE_ADDR and JFR_SIDECAR_ADDR after switching
  - Restart admin service to pick up new addresses
  - Clean up old jfr-sidecar when removing old eagle instance

This ensures the JFR button in admin console works regardless of
whether blue or green is the active instance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Sync config files from GitHub at start of deployment

Downloads docker-compose.prod.yml and nginx.conf from the main branch
before starting deployment. This ensures new services (like jfr-sidecar-green)
are available when the deploy script runs.

- Preserves the current active instance in nginx.conf
- Creates .bak backups before overwriting
- Continues with existing files if GitHub fetch fails

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:28:56 -08:00
dd9d6d046d Add Ed25519 signature verification for manifest (#5227)
* Add Ed25519 signature verification for manifest

When a manifest has a signature line (# signature=...), the installer
now verifies it using the embedded public key. If verification fails,
a warning is logged but the update proceeds to allow graceful degradation.

Changes:
- Add NSec.Cryptography NuGet package for Ed25519
- Add VerifyManifestSignature() to parse and verify signature
- Update ReadConfiguration() to support comments in config file
- Call verification when fetching remote manifest

Behavior:
- If no signature: proceeds normally (backwards compatible)
- If no public key configured: logs info, proceeds
- If signature valid: logs success, proceeds
- If signature invalid: logs WARNING, proceeds (graceful degradation)

To enable verification:
1. Generate key pair: go run scripts/generate_manifest_keys.go
2. Add public key to configuration.txt as manifest_public_key
3. Add private key as GitHub secret MANIFEST_SIGNING_KEY (from PR 3)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix: NSec PublicKey is not IDisposable

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:22:30 -08:00
738dd12c7c Delete unused Legacy utility classes (#5234)
- Remove LegacyRecruitmentOdds (callers already use Scala RecruitmentOdds)
- Remove LegacyBattalionTypeFinder (inline simple lookup in RuntimeValidator)

Part of ongoing deproto effort to remove proto wrapper classes.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 16:06:46 -08:00
1d98df6cbc Remove obsolete ReloadGames RPC call from deploy script (#5232)
With lazy game loading (merged in #5223), games are loaded on-demand
when users reconnect after nginx switches traffic. No explicit reload
call is needed - the new server reads fresh state from storage.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 13:58:22 -08:00
f74f61c18e Implement lazy game loading for zero-downtime blue-green deployments (#5223)
Games are now loaded on-demand when a user subscribes or lists their games,
rather than all at startup. This enables true zero-downtime deployments:

1. New server starts with no games loaded (fast startup)
2. Old server stops, flushes all game state to disk
3. nginx switches traffic to new server
4. Users reconnect, triggering fresh game loads from disk

Key changes:
- GamesManager.apply() no longer loads games at startup
- New ensureGameLoaded() method loads a single game from disk on demand
- readRunningGamesFromDisk() reads games.e0es fresh each time to handle
  race conditions (e.g., game created just before deployment)
- streamUpdates() calls ensureGameLoaded() before accessing game
- gamesFor() reads games.e0es to find user's games, then loads them
  (handles "lost game ID after disconnect" scenario)
- dropGame() tries lazy loading before returning "not found"
- begin() simplified to just connect to Shardok
- Removed ReloadGames RPC (no longer needed with lazy loading)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:57:40 -08:00
88bd7ade50 Add workflow_dispatch trigger to Unity Build (#5231)
Enables manual triggering of the Unity build workflow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:51:41 -08:00
99721d963a Upgrade FlatBuffers to version 25.9.23 (#5230)
FlatBuffers 25.9.23 changed GetMutableObject() on vectors of structs
to return const T* instead of T*. This is a const-correctness
improvement in the library.

Updated all C++ code to handle this change:
- Added const_cast<T*>() wrappers where mutation is needed on owned buffers
- Added helper function GetMutableTerrain() in HexMapUtils for terrain access
- All const_casts are safe because the code owns the underlying mutable buffers

All 112 C++ tests and 209 Scala tests pass.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:44:23 -08:00
adminandGitHub 4296d5046a Add workflow_dispatch and inject MANIFEST_PUBLIC_KEY in installer build (#5229) 2026-01-12 12:41:03 -08:00
353eb3907c Sign manifest with Ed25519 at build time (#5226)
Add optional Ed25519 signing to the manifest_manager. When a signing key
is provided, the manifest is signed and the signature is prepended as
a header comment that clients can verify.

Changes:
- manifest_manager: Accept optional private key file as 3rd argument
- manifest_manager: Sign manifest content and prepend signature line
- installer_build.yml: Pass MANIFEST_SIGNING_KEY to manifest_manager
- unity_build.yml: Pass MANIFEST_SIGNING_KEY to manifest_manager
- Add generate_manifest_keys.go script to create key pairs

The signature line format is: # signature=<base64-encoded-ed25519-signature>

To enable signing:
1. Run: go run scripts/generate_manifest_keys.go
2. Add the private key as GitHub secret MANIFEST_SIGNING_KEY
3. Embed the public key in the installer for verification (PR 4)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:08:54 -08:00
ded06ba815 Verify installer SHA256 after download (#5224)
* Verify installer SHA256 after download

The Windows installer was downloading and launching new installer updates
without verifying the SHA256 hash, which could allow a corrupted or
tampered installer to run. This adds SHA256 verification after download
and before launching the new installer.

- Add expectedSha parameter to DownloadAndLaunchNewInstaller
- Compute SHA256 of downloaded file and compare to manifest value
- Delete the file and fail if SHA doesn't match
- Log verification success on match

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Remove accidentally committed node_modules cache files

* Add node_modules to .gitignore

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:58:03 -08:00
70392b109a Stream downloads to disk with incremental SHA256 hashing (#5225)
Previously, FetchAndWriteOne() would download entire files into memory,
then compute the SHA256, then write to disk. This doubled memory usage
for each concurrent download.

Now the function streams directly to a temp file while computing SHA256
incrementally using TransformBlock. The temp file is renamed to the
final location only after SHA verification passes.

Benefits:
- Eliminates memory buffering of entire files
- Safer atomic writes using temp file + rename pattern
- Temp files cleaned up on failure

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 11:57:01 -08:00
821e3f1a4a Add retry loop for stapler after notarization (#5222)
Apple's CloudKit can have a brief delay after notarization completes
before the ticket is available for stapling. This adds a retry loop
with 10-second delays, up to 5 attempts.

Error was:
  CloudKit query for eagle0.app failed due to "Record not found".
  The staple and validate action failed! Error 65.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 10:13:43 -08:00
ebe819a5a1 Update Bazel dependencies to latest compatible versions (#5218)
Updates the following dependencies:
- bazel_skylib: 1.8.1 → 1.9.0
- rules_pkg: 1.1.0 → 1.2.0
- rules_go: 0.56.1 → 0.59.0
- gazelle: 0.45.0 → 0.47.0
- rules_oci: 2.2.6 → 2.2.7
- aspect_bazel_lib: 2.16.0 → 2.22.4
- rules_jvm_external: 6.3 → 6.9

Not updated (compatibility issues):
- googletest 1.17.0.bcr.2: pulls abseil-cpp incompatible with protobuf 29.2
- rules_scala 7.1.6: protobuf gencode/runtime version mismatch
- flatbuffers 25.9.23: C++ API changes break existing code
- grpc/grpc-java: requires rules_swift 3.x which conflicts with rules_apple

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:54:35 -08:00
3e868d0305 Fix nginx failing to reload during blue-green deployment (#5219)
The previous configuration used an upstream block with a static hostname:
  upstream eagle_grpc { server eagle-blue:40032; }

This caused nginx -s reload to fail when eagle-blue was stopped because
nginx tries to resolve all upstream hostnames at config load time.

Changed to use a map directive with a variable:
  map $host $eagle_backend { default "eagle-blue:40032"; }
  grpc_pass grpc://$eagle_backend;

This pattern (already used for auth backend) resolves the hostname at
request time, allowing nginx to reload even when the backend is down.
Requests to a stopped backend will get 502 errors instead of failing
to reload nginx entirely.

Tradeoff: Loses keepalive 100; setting, but deployment reliability
is more important than connection pooling optimization.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:53:38 -08:00
e46867333c Add mac_build_handler to Mac Build workflow triggers (#5220)
Changes to the Go upload tool should trigger the Mac build workflow.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:51:54 -08:00
2d59d4ff51 Fix rsync exit code 23 in persist_library.sh (#5221)
Unity's temporary .traceevents files can vanish during rsync, causing
exit code 23 ("partial transfer due to error"). This is acceptable for
the Library cache, so treat exit code 23 as success.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:51:19 -08:00
d493182184 Fix Sparkle sign_update flag: use -f for file path (#5217)
The -s flag expects the private key as a string argument, not a file
path. Changed to -f which correctly reads the key from a file.

Error was: "Failed to decode base64 encoded key data from: /tmp/sparkle_private_key"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 09:05:36 -08:00
8ce4e4c4a9 Add rule: Claude must never merge PRs (#5216)
User explicitly stated: "never ever ever ever merge a PR for me.
You create PRs. I merge them."

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:41:55 -08:00
9729110ea8 Consolidate Docker Build into single job for better runner utilization (#5215)
Previously, docker_build.yml had 4 separate jobs (build-eagle, build-shardok,
build-admin, build-jfr-sidecar) that competed for runner slots. With 3 runners
and 6+ workflows triggering on main push, these jobs serialized rather than
running in parallel.

Now consolidated into a single `build-all` job that:
- Builds all 4 images with one `bazel build` command (Bazel parallelizes internally)
- Uses 1 runner slot instead of 4, freeing runners for other workflows
- Shares Bazel cache warming across all builds
- Pushes all images sequentially (fast, network-bound)

Expected improvement: Docker Build workflow goes from ~8.5m (4 serialized jobs)
to ~3-4m (1 consolidated job with internal parallelism).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:37:07 -08:00
adminandGitHub 2224e78a36 Improve Sparkle sign_update error reporting (#5209)
Improves Sparkle sign_update error reporting by capturing stderr, and allows full signing/notarization/deploy pipeline on feature branches via workflow_dispatch.
2026-01-12 08:36:41 -08:00
007a57eea2 Convert CommandSelection and AI clients to use Scala types (#5160)
Add toScala() method to CommandSelection that converts proto-based
command selection to ScalaCommandSelection. This simplifies the action
files that were previously doing manual conversion from proto to Scala
types.

Updated files:
- CommandSelection.scala: Added toScala() method
- EndHandleRiotsPhaseAction.scala: Use toScala() instead of manual conversion
- PerformVassalCommandsPhaseAction.scala: Use toScala() instead of manual conversion
- PerformVassalDefenseDecisionsAction.scala: Use toScala() instead of manual conversion
- Removed unused AvailableCommandTypeMap and SelectedCommandConverter imports

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 08:31:35 -08:00
3febf2a6cd Use DigitalOcean Spaces for busybox binary (private repo fix) (#5213)
GitHub release URLs don't work for private repos without authentication.
Bazel's http_file can't use GitHub auth, so we need a public URL.

Uploaded the busybox binary to DigitalOcean Spaces alongside the sysroots.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 07:43:30 -08:00
26204cc879 Add runtime Canvas UI builder for tutorial modal (#5208)
* Add runtime Canvas UI builder for tutorial modal

Implements TutorialCanvasBuilder to construct Canvas-based tutorial modal
UI at runtime, replacing the IMGUI fallback when no prefab is assigned.

- TutorialCanvasBuilder creates complete Canvas UI hierarchy:
  - Modal blocker (dark overlay)
  - Panel with title, description, icon, progress bar
  - Continue, Skip, and Skip All buttons
  - Fantasy RPG color scheme matching game style
- TutorialUIManager auto-builds Canvas UI if ModalPanel not assigned
- TutorialModalPanel click handlers made public for external setup
- Updated TUTORIAL_PLAN.md to reflect Canvas UI completion

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix tutorial Canvas UI issues

- Auto-load Stoke font if not assigned (searches Resources and loaded assets)
- Increase panel height (550px) and use flexible spacer for layout
- Fix text truncation by using Overflow mode instead of Ellipsis
- Replace "Province Selected" tutorial with proper welcome intro
- Intro tutorial triggers immediately on game start
- Skip buttons hidden when AllowSkip=false (intro is non-skippable)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Remove font search logic - use CanvasFont field instead

Font should be assigned in TutorialUIManager inspector (CanvasFont field).
Removed unnecessary auto-search logic from TutorialCanvasBuilder.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Fix TutorialTestSetup compile errors

- Use HasCompletedTutorial instead of HasSeenTutorial
- Use OnGameEvent instead of TriggerEvent

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Trigger intro tutorial when entering game, not lobby

- Remove immediate trigger from TutorialTestSetup.Start()
- Trigger "game_started" event from TutorialManager.Initialize()
  when EagleGameController is passed (actual game entry)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Update TUTORIAL_PLAN.md with current status and future work

- Document completed phases (foundation, Canvas UI, triggers, test setup)
- Add Unity setup instructions (font assignments)
- Add future work: lobby tutorial helper, overlay system, hints
- Add lobby tutorial section to planned contextual tutorials
- Update testing instructions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Assign Stoke-Regular-SDF font to TutorialUIManager

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* Mark font assignment complete in TUTORIAL_PLAN.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 07:36:29 -08:00
7032260a31 Make Engine.postCommand use Scala SelectedCommand (#5211)
Updates Engine.postCommand to accept Scala SelectedCommand instead of
the proto version. The conversion from proto to Scala now happens at
the GameController layer, keeping the Engine interface proto-free.

Changes:
- Engine.scala: Import Scala SelectedCommand instead of proto
- EngineImpl.scala: Remove proto import and converter, use Scala directly
- GameController.scala: Convert proto→Scala before calling engine.postCommand
- Update test files to use Scala SelectedCommand for mock expectations

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 07:26:51 -08:00
277 changed files with 8809 additions and 7896 deletions
+1 -1
View File
@@ -24,7 +24,7 @@ permissions:
jobs:
build-auth:
runs-on: self-hosted
runs-on: [self-hosted, bazel]
outputs:
image_tag: ${{ steps.push-auth.outputs.image_tag }}
steps:
+1 -1
View File
@@ -26,7 +26,7 @@ permissions:
jobs:
test:
runs-on: self-hosted
runs-on: [self-hosted, bazel]
steps:
- name: Checkout repository
-21
View File
@@ -1,21 +0,0 @@
name: Build Protos
on:
pull_request:
paths:
- "src/main/protobuf/**"
permissions:
contents: read
jobs:
build:
runs-on: self-hosted
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Run tests
run: ./scripts/build_protos.sh
-35
View File
@@ -1,35 +0,0 @@
name: Client Presigner
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/client_presigner.yml"
- "src/main/go/net/eagle0/client_download/**"
- "src/main/go/net/eagle0/util/**"
pull_request:
paths:
- ".github/workflows/client_presigner.yml"
- "src/main/go/net/eagle0/client_download/**"
- "src/main/go/net/eagle0/util/**"
permissions:
contents: read
jobs:
client-presigner:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
with:
lfs: false
clean: false
- name: Build Client Presigner
run: bazel build --platforms=@io_bazel_rules_go//go/toolchain:linux_amd64 //src/main/go/net/eagle0/client_download
- name: Archive presigner binary
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: client_download
path: bazel-bin/src/main/go/net/eagle0/client_download/client_download_/client_download
+104 -444
View File
@@ -4,7 +4,7 @@ on:
push:
branches: [ "main" ]
paths:
- 'src/main/cpp/**'
# Note: C++ changes trigger shardok_arm64_build.yml instead
- 'src/main/go/**'
- 'src/main/scala/**'
- 'src/main/protobuf/**'
@@ -22,37 +22,63 @@ on:
default: 'false'
type: boolean
# Only allow one deployment at a time to prevent race conditions
concurrency:
group: docker-build-deploy
cancel-in-progress: false # Don't cancel running deployments, queue new ones
permissions:
contents: read
jobs:
build-eagle:
runs-on: self-hosted
# Single consolidated build job - builds all images with one bazel invocation
# This uses 1 runner slot instead of 4, and Bazel parallelizes internally
# NOTE: Must run on a runner with the bazel label to ensure consistent Bazel cache
build-all:
runs-on: [self-hosted, bazel]
outputs:
image_tag: ${{ steps.push-eagle.outputs.image_tag }}
eagle_image_tag: ${{ steps.push-images.outputs.eagle_image_tag }}
admin_image_tag: ${{ steps.push-images.outputs.admin_image_tag }}
jfr_sidecar_image_tag: ${{ steps.push-images.outputs.jfr_sidecar_image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Eagle Docker image
id: build-eagle
- name: Build all Docker images
id: build-all
run: |
set -ex
bazel build --platforms=//:linux_x86_64 //ci:eagle_server_image
# Also build the warmup tool for Linux
bazel build //src/main/go/net/eagle0/warmup:warmup_linux_amd64
# Build ALL images in a single bazel command - Bazel parallelizes internally
# Note: Shardok is built separately for ARM64 and deployed to Hetzner
echo "=== Building Docker images ==="
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//ci:eagle_server_image \
//ci:admin_server_image \
//ci:jfr_sidecar_image \
//src/main/go/net/eagle0/warmup:warmup_linux_amd64
# Copy warmup binary to scripts/ so it gets deployed with other scripts
# Copy warmup binary to scripts/ for deployment
mkdir -p scripts/bin
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
# Save all image paths before any other bazel command changes bazel-bin symlink
EAGLE_PATH=$(readlink -f bazel-bin/ci/eagle_server_image)
ADMIN_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
JFR_PATH=$(readlink -f bazel-bin/ci/jfr_sidecar_image)
echo "eagle_path=$EAGLE_PATH" >> $GITHUB_OUTPUT
echo "admin_path=$ADMIN_PATH" >> $GITHUB_OUTPUT
echo "jfr_path=$JFR_PATH" >> $GITHUB_OUTPUT
echo "=== Image paths ==="
echo "Eagle: $EAGLE_PATH"
echo "Admin: $ADMIN_PATH"
echo "JFR Sidecar: $JFR_PATH"
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
@@ -62,381 +88,70 @@ jobs:
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
# Also set for current directory in case Bazel uses different home
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Eagle image to DO registry
id: push-eagle
- name: Push all images to DO registry
id: push-images
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
# Use cross-compiled image path from build step
EAGLE_IMAGE="${{ steps.build-eagle.outputs.image_path }}"
echo "Using Eagle image: $EAGLE_IMAGE"
GIT_SHA=$(git rev-parse --short=8 HEAD)
if [ -z "$EAGLE_IMAGE" ] || [ ! -d "$EAGLE_IMAGE" ]; then
echo "ERROR: Eagle image not found at: $EAGLE_IMAGE"
exit 1
fi
# Debug: show OCI layout contents
echo "=== OCI Layout Contents ==="
cat "$EAGLE_IMAGE/index.json"
echo ""
echo "=== Blobs ==="
ls -la "$EAGLE_IMAGE/blobs/sha256/" | head -20
# Verify OCI layout consistency before pushing
echo "=== Verifying OCI layout consistency ==="
for digest in $(cat "$EAGLE_IMAGE/index.json" | grep -o '"sha256:[^"]*"' | tr -d '"'); do
blob_path="$EAGLE_IMAGE/blobs/${digest/://}"
if [ ! -f "$blob_path" ]; then
echo "ERROR: Blob not found: $blob_path"
exit 1
fi
actual_digest="sha256:$(shasum -a 256 "$blob_path" | cut -d' ' -f1)"
if [ "$digest" != "$actual_digest" ]; then
echo "ERROR: Digest mismatch for $blob_path"
echo " Index says: $digest"
echo " Actual: $actual_digest"
exit 1
fi
echo "✓ Verified: $digest"
done
# Build the push target to get crane in runfiles
# Get crane from push target runfiles
bazel build //ci:eagle_server_push
# Use crane directly for push (avoids OCI->Docker digest mismatch)
CRANE="bazel-bin/ci/push_eagle_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
echo "Pushing eagle image: $IMAGE_TAG"
$CRANE push "$EAGLE_IMAGE" "$IMAGE_TAG"
# Verify push by checking what's in the registry
echo "=== Verifying push ==="
$CRANE manifest "$IMAGE_TAG" | head -50
PUSHED_DIGEST=$($CRANE digest "$IMAGE_TAG")
echo "Registry reports digest: $PUSHED_DIGEST"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience (but deploy won't use it)
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
build-shardok:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Shardok binary (cross-compile for Linux)
run: |
set -ex
# Step 1: Build JUST the binary with cross-compilation
# We need --extra_toolchains to force the Linux toolchain to be used
# because toolchains_llvm registers with dev_dependency=True
echo "=== Building shardok-server binary for linux-x86_64 ==="
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//src/main/cpp/net/eagle0/shardok:shardok-server
# Step 2: Check the binary directly from bazel-bin
# bazel-bin is a symlink that points to the correct output directory
LINUX_BIN="bazel-bin/src/main/cpp/net/eagle0/shardok/shardok-server"
echo "=== Checking binary at: $LINUX_BIN ==="
if [ ! -f "$LINUX_BIN" ]; then
echo "ERROR: Binary not found at $LINUX_BIN"
exit 1
if [ ! -e "$CRANE" ]; then
# Fallback: find any Darwin crane
RUNFILES="bazel-bin/ci/push_eagle_server_push.sh.runfiles"
CRANE=$(find "$RUNFILES" -path "*darwin*" -name crane 2>/dev/null | head -1)
fi
# Debug: show what bazel-bin points to
echo "bazel-bin symlink target: $(readlink bazel-bin || echo 'not a symlink')"
# Step 3: Verify it's ELF (Linux) not Mach-O (macOS)
echo "=== Verifying binary format ==="
MAGIC=$(head -c 4 "$LINUX_BIN" | xxd -p)
echo "Binary magic bytes: $MAGIC"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary is ELF format (Linux)"
elif [ "$MAGIC" = "cfaeedfe" ] || [ "$MAGIC" = "cffaedfe" ]; then
echo "ERROR: Binary is Mach-O format (macOS) - cross-compilation failed!"
echo ""
echo "Debug info:"
echo "- bazel-bin points to: $(readlink bazel-bin)"
file "$LINUX_BIN" || true
exit 1
else
echo "WARNING: Unknown binary format: $MAGIC"
file "$LINUX_BIN" || true
fi
- name: Build Shardok Docker image
id: build-shardok
run: |
set -ex
# Build the OCI image with cross-compilation flags
bazel build \
--platforms=//:linux_x86_64 \
--extra_toolchains=@llvm_toolchain_linux//:all \
//ci:shardok_server_image
# The image is output to bazel-bin which is a symlink.
# Resolve it now before any other bazel commands change where it points.
IMAGE_PATH=$(readlink -f bazel-bin/ci/shardok_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
# Verify the binary inside the tar layer is ELF
echo "=== Verifying binary in image tar ==="
BINARY_TAR="bazel-bin/ci/shardok_binary_layer.tar"
if [ -f "$BINARY_TAR" ]; then
echo "Checking binary in $BINARY_TAR"
# Extract just the first 4 bytes of the binary from the tar
MAGIC=$(tar -xOf "$BINARY_TAR" app/shardok-server 2>/dev/null | head -c 4 | xxd -p)
echo "Binary magic in tar: $MAGIC"
if [ "$MAGIC" = "7f454c46" ]; then
echo "SUCCESS: Binary in tar is ELF format (Linux)"
else
echo "ERROR: Binary in tar is NOT ELF format!"
echo "This means pkg_tar is packaging the wrong binary."
exit 1
fi
else
echo "WARNING: Could not find $BINARY_TAR"
fi
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
# Also set for current directory in case Bazel uses different home
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Shardok image to DO registry
id: push-shardok
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
# Use cross-compiled image path from build step
CROSS_IMAGE="${{ steps.build-shardok.outputs.image_path }}"
echo "Using cross-compiled image: $CROSS_IMAGE"
if [ -z "$CROSS_IMAGE" ] || [ ! -d "$CROSS_IMAGE" ]; then
echo "ERROR: Cross-compiled image not found at: $CROSS_IMAGE"
exit 1
fi
# Get crane from Eagle push target (which doesn't need cross-compilation)
# This gives us a macOS crane binary we can actually run.
# We can't build shardok_server_push with platform flags because it would
# download a Linux crane that can't run on macOS.
bazel build //ci:eagle_server_push
# Find the Darwin crane binary (may be a symlink)
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
# Fallback to any crane
CRANE=$(find "$RUNFILES" -name crane 2>/dev/null | head -1)
fi
if [ -z "$CRANE" ] || [ ! -e "$CRANE" ]; then
echo "ERROR: crane not found. Listing runfiles:"
find "$RUNFILES" -name crane 2>/dev/null || true
echo "ERROR: crane not found"
exit 1
fi
echo "Using crane: $CRANE"
# Push the cross-compiled image with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/shardok-server:${GIT_SHA}"
echo "Pushing shardok image: $IMAGE_TAG"
$CRANE push "$CROSS_IMAGE" "$IMAGE_TAG"
# Push Eagle image
EAGLE_IMAGE="${{ steps.build-all.outputs.eagle_path }}"
EAGLE_TAG="registry.digitalocean.com/eagle0/eagle-server:${GIT_SHA}"
echo "Pushing Eagle: $EAGLE_TAG"
$CRANE push "$EAGLE_IMAGE" "$EAGLE_TAG"
$CRANE copy "$EAGLE_TAG" "registry.digitalocean.com/eagle0/eagle-server:latest"
echo "eagle_image_tag=$EAGLE_TAG" >> $GITHUB_OUTPUT
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_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
# Also update :latest for convenience (but deploy won't use it)
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/shardok-server:latest"
# Push JFR Sidecar image
JFR_IMAGE="${{ steps.build-all.outputs.jfr_path }}"
JFR_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
echo "Pushing JFR Sidecar: $JFR_TAG"
$CRANE push "$JFR_IMAGE" "$JFR_TAG"
$CRANE copy "$JFR_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
echo "jfr_sidecar_image_tag=$JFR_TAG" >> $GITHUB_OUTPUT
build-admin:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-admin.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Admin Server Docker image
id: build-admin
run: |
set -ex
# Build admin server image (Go binary has explicit goos/goarch in BUILD.bazel)
bazel build //ci:admin_server_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/admin_server_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push Admin image to DO registry
id: push-admin
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
ADMIN_IMAGE="${{ steps.build-admin.outputs.image_path }}"
echo "Using Admin image: $ADMIN_IMAGE"
if [ -z "$ADMIN_IMAGE" ] || [ ! -d "$ADMIN_IMAGE" ]; then
echo "ERROR: Admin image not found at: $ADMIN_IMAGE"
exit 1
fi
# Build the push target to get crane in runfiles
bazel build //ci:admin_server_push
# Use crane directly for push
CRANE="bazel-bin/ci/push_admin_server_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/admin-server:${GIT_SHA}"
echo "Pushing admin image: $IMAGE_TAG"
$CRANE push "$ADMIN_IMAGE" "$IMAGE_TAG"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/admin-server:latest"
build-jfr-sidecar:
runs-on: self-hosted
outputs:
image_tag: ${{ steps.push-jfr-sidecar.outputs.image_tag }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build JFR Sidecar Docker image
id: build-jfr-sidecar
run: |
set -ex
# Build JFR sidecar image (Go binary has explicit goos/goarch in BUILD.bazel)
bazel build //ci:jfr_sidecar_image
# Save the resolved path before any other bazel command changes bazel-bin symlink
IMAGE_PATH=$(readlink -f bazel-bin/ci/jfr_sidecar_image)
echo "Image path: $IMAGE_PATH"
echo "image_path=$IMAGE_PATH" >> $GITHUB_OUTPUT
- name: Login to DigitalOcean Container Registry
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DO_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
run: |
mkdir -p ~/.docker
AUTH=$(echo -n "${DO_TOKEN}:${DO_TOKEN}" | base64)
echo "{\"auths\":{\"registry.digitalocean.com\":{\"auth\":\"${AUTH}\"}}}" > ~/.docker/config.json
mkdir -p .docker
cp ~/.docker/config.json .docker/
- name: Push JFR Sidecar image to DO registry
id: push-jfr-sidecar
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
env:
DOCKER_CONFIG: ${{ github.workspace }}/.docker
run: |
set -ex
JFR_IMAGE="${{ steps.build-jfr-sidecar.outputs.image_path }}"
echo "Using JFR Sidecar image: $JFR_IMAGE"
if [ -z "$JFR_IMAGE" ] || [ ! -d "$JFR_IMAGE" ]; then
echo "ERROR: JFR Sidecar image not found at: $JFR_IMAGE"
exit 1
fi
# Build the push target to get crane in runfiles
bazel build //ci:jfr_sidecar_push
# Use crane directly for push
CRANE="bazel-bin/ci/push_jfr_sidecar_push.sh.runfiles/rules_oci~~oci~oci_crane_darwin_arm64/crane"
echo "Using crane: $CRANE"
# Push with SHA tag
GIT_SHA=$(git rev-parse --short=8 HEAD)
IMAGE_TAG="registry.digitalocean.com/eagle0/jfr-sidecar:${GIT_SHA}"
echo "Pushing JFR sidecar image: $IMAGE_TAG"
$CRANE push "$JFR_IMAGE" "$IMAGE_TAG"
# Output the full image tag for deploy step
echo "image_tag=$IMAGE_TAG" >> $GITHUB_OUTPUT
# Also update :latest for convenience
echo "Copying to :latest tag"
$CRANE copy "$IMAGE_TAG" "registry.digitalocean.com/eagle0/jfr-sidecar:latest"
echo "=== All images pushed successfully ==="
deploy:
runs-on: self-hosted
needs: [build-eagle, build-shardok, build-admin, build-jfr-sidecar]
runs-on: [self-hosted, bazel]
needs: [build-all]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
env:
EAGLE_IMAGE: ${{ needs.build-eagle.outputs.image_tag }}
SHARDOK_IMAGE: ${{ needs.build-shardok.outputs.image_tag }}
ADMIN_IMAGE: ${{ needs.build-admin.outputs.image_tag }}
JFR_SIDECAR_IMAGE: ${{ needs.build-jfr-sidecar.outputs.image_tag }}
EAGLE_IMAGE: ${{ needs.build-all.outputs.eagle_image_tag }}
ADMIN_IMAGE: ${{ needs.build-all.outputs.admin_image_tag }}
JFR_SIDECAR_IMAGE: ${{ needs.build-all.outputs.jfr_sidecar_image_tag }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
@@ -475,16 +190,13 @@ jobs:
- name: Copy config files to droplet
run: |
# Create directory structure on remote
# Use -p to create parents, and test write access before copying
ssh -i ~/.ssh/deploy_key deploy@"$DO_DROPLET_IP" bash -s << 'SETUP_DIRS'
set -e
mkdir -p /opt/eagle0/scripts/bin /opt/eagle0/nginx /opt/eagle0/deploy
# Remove existing warmup binary (it may have read-only permissions from bazel)
rm -f /opt/eagle0/scripts/bin/warmup
SETUP_DIRS
# Copy files preserving structure
# Copy files
scp -i ~/.ssh/deploy_key docker-compose.prod.yml deploy@"$DO_DROPLET_IP":/opt/eagle0/
scp -i ~/.ssh/deploy_key nginx/nginx.conf deploy@"$DO_DROPLET_IP":/opt/eagle0/nginx/
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
@@ -499,7 +211,6 @@ jobs:
# Environment variables passed via heredoc
EAGLE_IMAGE="${EAGLE_IMAGE}"
SHARDOK_IMAGE="${SHARDOK_IMAGE}"
ADMIN_IMAGE="${ADMIN_IMAGE}"
JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
OPENAI_API_KEY="${OPENAI_API_KEY}"
@@ -520,18 +231,15 @@ jobs:
FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
# Check Docker has IPv6 support for connecting to Hetzner Shardok
# (One-time setup: sudo tee /etc/docker/daemon.json <<< '{"ipv6": true, "ip6tables": true, "experimental": true, "fixed-cidr-v6": "fd00::/80"}' && sudo systemctl restart docker)
# Check Docker has IPv6 support
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
echo "Run: sudo tee /etc/docker/daemon.json <<< '{\"ipv6\": true, \"ip6tables\": true, \"experimental\": true, \"fixed-cidr-v6\": \"fd00::/80\"}' && sudo systemctl restart docker"
fi
# Update env vars using shared script (preserves vars set by other workflows)
chmod +x update-env.sh
./update-env.sh \
# Update env vars
chmod +x deploy/update-env.sh
cd deploy && ./update-env.sh \
"EAGLE_IMAGE=\${EAGLE_IMAGE}" \
"SHARDOK_IMAGE=\${SHARDOK_IMAGE}" \
"ADMIN_IMAGE=\${ADMIN_IMAGE}" \
"JFR_SIDECAR_IMAGE=\${JFR_SIDECAR_IMAGE}" \
"OPENAI_API_KEY=\${OPENAI_API_KEY}" \
@@ -550,103 +258,55 @@ jobs:
"FASTMAIL_API_TOKEN=\${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=\${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=\${FASTMAIL_FROM_NAME}"
cd ..
# Login to registry
echo "\$DO_REGISTRY_TOKEN" | docker login registry.digitalocean.com -u "\$DO_REGISTRY_TOKEN" --password-stdin
# Use exact image tags passed from build jobs (no :latest fallback)
# Note: AUTH_IMAGE is managed separately by auth_build.yml
echo "Using images: \$EAGLE_IMAGE, \$SHARDOK_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
echo "Using images: \$EAGLE_IMAGE, \$ADMIN_IMAGE, \$JFR_SIDECAR_IMAGE"
# Use crane to pull images (handles OCI format correctly) then load into Docker
# This avoids digest mismatch from DO registry's OCI->Docker format conversion
# 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
if [ ! -x crane ]; then
echo "ERROR: Failed to install crane"
ls -la crane || true
exit 1
fi
echo "Crane installed"
ls -la crane
# crane uses Docker config for auth
echo "Pulling Eagle image with crane..."
./crane pull "\${EAGLE_IMAGE}" eagle.tar || { echo "ERROR: Failed to pull eagle image"; exit 1; }
echo "Loading Eagle image into Docker..."
docker load -i eagle.tar
rm eagle.tar
# Pull and load all images
echo "Pulling Eagle image..."
./crane pull "\${EAGLE_IMAGE}" eagle.tar && docker load -i eagle.tar && rm eagle.tar
echo "Pulling Shardok image with crane..."
./crane pull "\${SHARDOK_IMAGE}" shardok.tar || { echo "ERROR: Failed to pull shardok image"; exit 1; }
echo "Loading Shardok image into Docker..."
docker load -i shardok.tar
rm shardok.tar
echo "Pulling Admin image..."
./crane pull "\${ADMIN_IMAGE}" admin.tar && docker load -i admin.tar && rm admin.tar
echo "Pulling Admin image with crane..."
./crane pull "\${ADMIN_IMAGE}" admin.tar || { echo "ERROR: Failed to pull admin image"; exit 1; }
echo "Loading Admin image into Docker..."
docker load -i admin.tar
rm admin.tar
echo "Pulling JFR Sidecar image..."
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar && docker load -i jfr-sidecar.tar && rm jfr-sidecar.tar
echo "Pulling JFR Sidecar image with crane..."
./crane pull "\${JFR_SIDECAR_IMAGE}" jfr-sidecar.tar || { echo "ERROR: Failed to pull jfr-sidecar image"; exit 1; }
echo "Loading JFR Sidecar image into Docker..."
docker load -i jfr-sidecar.tar
rm jfr-sidecar.tar
# Keep crane for blue-green deploys (don't delete it)
echo "Crane kept at ./crane for future blue-green deploys"
# Also pull other compose images
# Pull other compose images
docker pull nginx:alpine || true
docker pull certbot/certbot || true
echo "All images pulled successfully"
# Recreate non-Eagle services (not auth - managed by auth_build.yml)
# Note: jfr-sidecar must start after eagle-blue due to PID namespace sharing
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate shardok admin
# Stop local shardok container if running (now runs on Hetzner)
docker stop shardok-server 2>/dev/null || true
docker rm shardok-server 2>/dev/null || true
# Deploy Eagle with blue-green (zero-downtime) if scripts are available
if [ -x "/opt/eagle0/scripts/deploy-blue-green.sh" ]; then
echo "Using blue-green deployment for Eagle..."
chmod +x /opt/eagle0/scripts/*.sh
# Make warmup binary executable if present
if [ -f "/opt/eagle0/scripts/bin/warmup" ]; then
chmod +x /opt/eagle0/scripts/bin/warmup
fi
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}" || {
echo "Blue-green deployment failed, falling back to direct restart"
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle-blue
}
else
echo "Blue-green scripts not found, using direct restart..."
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate eagle-blue
fi
# Deploy Eagle with blue-green (handles eagle, nginx, admin, jfr-sidecar)
# Note: Shardok runs on Hetzner, deployed separately via shardok_arm64_build.yml
chmod +x /opt/eagle0/scripts/*.sh
[ -f "/opt/eagle0/scripts/bin/warmup" ] && chmod +x /opt/eagle0/scripts/bin/warmup
GIT_SHA=\$(echo "\${EAGLE_IMAGE}" | sed 's/.*://')
/opt/eagle0/scripts/deploy-blue-green.sh "\${GIT_SHA}"
# Start jfr-sidecar after eagle-blue (shares PID namespace with eagle-blue)
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate jfr-sidecar
# Ensure auth is running (but don't force-recreate it)
# Ensure auth is running
docker compose -f docker-compose.prod.yml up -d auth
# Restart nginx to pick up new container IPs
# (nginx caches DNS at startup, so it needs restart after eagle/shardok)
docker compose -f docker-compose.prod.yml up -d --force-recreate nginx
# Wait for health checks
# Verify
sleep 10
docker compose -f docker-compose.prod.yml ps
# Verify containers are using correct images
echo "=== Verifying container image tags ==="
docker compose -f docker-compose.prod.yml images
# Cleanup stopped containers and old images
# Cleanup
docker container prune -f
docker image prune -f
DEPLOY_SCRIPT
+35 -6
View File
@@ -10,13 +10,14 @@ on:
paths:
- ".github/workflows/installer_build.yml"
- "src/main/csharp/net/eagle0/clients/win/installer/**"
workflow_dispatch:
permissions:
contents: read
jobs:
build-installer:
runs-on: self-hosted
runs-on: [self-hosted, bazel]
steps:
- uses: actions/checkout@v4
@@ -29,6 +30,19 @@ jobs:
with:
dotnet-version: '8.0.x'
- name: Inject manifest public key
env:
MANIFEST_PUBLIC_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }}
run: |
if [ -n "$MANIFEST_PUBLIC_KEY" ]; then
CONFIG_FILE="src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/configuration.txt"
echo "manifest_public_key = $MANIFEST_PUBLIC_KEY" >> "$CONFIG_FILE"
echo "Injected manifest public key into configuration.txt"
cat "$CONFIG_FILE"
else
echo "MANIFEST_PUBLIC_KEY not set, skipping injection"
fi
- name: Restore dependencies
run: dotnet restore src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj
@@ -68,15 +82,30 @@ jobs:
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
run: |
# Create installer manifest content
# Create installer manifest content
INSTALLER_SHA=$(sha256sum ./installer-output/EagleInstaller.exe | cut -d' ' -f1)
echo "installer_version=$INSTALLER_SHA" > /tmp/installer_manifest.txt
echo "installer_url=installer/EagleInstaller.exe" >> /tmp/installer_manifest.txt
echo "=== Installer manifest content ==="
cat /tmp/installer_manifest.txt
echo "=================================="
# Update the unified manifest
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /tmp/installer_manifest.txt
# Write signing key to temp file (if available)
SIGNING_ARGS=""
if [ -n "$MANIFEST_SIGNING_KEY" ]; then
echo "$MANIFEST_SIGNING_KEY" > /tmp/manifest_signing_key
chmod 600 /tmp/manifest_signing_key
SIGNING_ARGS="/tmp/manifest_signing_key"
echo "Manifest signing key available"
else
echo "Warning: MANIFEST_SIGNING_KEY not set, manifest will be unsigned"
fi
# Update the unified manifest (with optional signing)
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer /tmp/installer_manifest.txt $SIGNING_ARGS
# Cleanup
rm -f /tmp/manifest_signing_key
+146 -27
View File
@@ -6,12 +6,18 @@ on:
paths:
- ".github/workflows/mac_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/proto/**"
- "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_protos.sh"
- "scripts/build_mac_plugin.sh"
- "scripts/inject_sparkle.sh"
- "scripts/codesign_mac_app.sh"
- "scripts/notarize_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/mac/**"
@@ -19,17 +25,24 @@ on:
paths:
- ".github/workflows/mac_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/protobuf/net/eagle0/common/**"
- "src/main/protobuf/net/eagle0/shardok/**"
- "src/main/protobuf/net/eagle0/eagle/api/**"
- "src/main/protobuf/net/eagle0/eagle/common/**"
- "src/main/protobuf/net/eagle0/eagle/views/**"
- "src/main/go/net/eagle0/build/mac_build_handler/**"
- "scripts/build_mac_plugin.sh"
- "scripts/inject_sparkle.sh"
- "scripts/codesign_mac_app.sh"
- "scripts/notarize_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/mac/**"
workflow_dispatch:
inputs:
skip_notarization:
description: 'Skip notarization (for testing)'
skip_signing:
description: 'Skip code signing, notarization, and deploy (build only)'
required: false
default: 'false'
type: boolean
@@ -38,8 +51,11 @@ permissions:
contents: read
jobs:
mac-unity:
runs-on: self-hosted
build-and-sign:
runs-on: [self-hosted, macOS, unity-mac]
outputs:
submission_id: ${{ steps.notarize-submit.outputs.submission_id }}
should_deploy: ${{ steps.check-deploy.outputs.should_deploy }}
steps:
- uses: actions/checkout@v4
@@ -68,8 +84,21 @@ jobs:
chmod +x ./scripts/inject_sparkle.sh
./scripts/inject_sparkle.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
- name: Check if should deploy
id: check-deploy
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ github.event.inputs.skip_signing }}" == "true" ]]; then
echo "should_deploy=false" >> $GITHUB_OUTPUT
elif [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "should_deploy=true" >> $GITHUB_OUTPUT
elif [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "should_deploy=true" >> $GITHUB_OUTPUT
else
echo "should_deploy=false" >> $GITHUB_OUTPUT
fi
- name: Import Code Signing Certificate
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
env:
MACOS_CERTIFICATE: ${{ secrets.MACOS_CERTIFICATE }}
MACOS_CERTIFICATE_PWD: ${{ secrets.MACOS_CERTIFICATE_PWD }}
@@ -81,8 +110,11 @@ jobs:
# Decode certificate
echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12
# Delete any existing keychain from previous runs
security delete-keychain build.keychain 2>/dev/null || true
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain || true
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
@@ -96,25 +128,124 @@ jobs:
rm certificate.p12
- name: Code Sign App
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
env:
SIGNING_IDENTITY: ${{ secrets.SIGNING_IDENTITY }}
run: |
chmod +x ./scripts/codesign_mac_app.sh
./scripts/codesign_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app" "ci/mac/eagle0.entitlements"
- name: Notarize App
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.event.inputs.skip_notarization != 'true'
- name: Submit for Notarization
id: notarize-submit
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_mac_app.sh
./scripts/notarize_mac_app.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
chmod +x ./scripts/notarize_submit.sh
./scripts/notarize_submit.sh "/tmp/eagle0/eagle0MAC/eagle0.app" >> $GITHUB_OUTPUT
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain build.keychain 2>/dev/null || true
- name: Zip signed app for artifact
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
run: |
cd /tmp/eagle0/eagle0MAC
ditto -c -k --keepParent eagle0.app eagle0.app.zip
- name: Upload signed app
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
uses: actions/upload-artifact@v4
with:
name: signed-mac-app
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
retention-days: 1
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_mac.log
path: /tmp/eagle0/editor_mac.log
wait-notarization:
needs: build-and-sign
if: needs.build-and-sign.outputs.should_deploy == 'true'
runs-on: [self-hosted, macOS, notarize]
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts
- name: Clean download directory
run: rm -rf /tmp/eagle0/eagle0MAC
- name: Download signed app
uses: actions/download-artifact@v4
with:
name: signed-mac-app
path: /tmp/eagle0/eagle0MAC
- name: Unzip signed app
run: |
cd /tmp/eagle0/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
ls -la /tmp/eagle0/eagle0MAC/eagle0.app/
- name: Wait for Notarization and Staple
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./scripts/notarize_wait.sh
./scripts/notarize_wait.sh "${{ needs.build-and-sign.outputs.submission_id }}" "/tmp/eagle0/eagle0MAC/eagle0.app"
- name: Zip notarized app for artifact
run: |
cd /tmp/eagle0/eagle0MAC
rm -f eagle0.app.zip
ditto -c -k --keepParent eagle0.app eagle0.app.zip
- name: Upload notarized app
uses: actions/upload-artifact@v4
with:
name: notarized-mac-app
path: /tmp/eagle0/eagle0MAC/eagle0.app.zip
retention-days: 1
deploy:
needs: [build-and-sign, wait-notarization]
if: needs.build-and-sign.outputs.should_deploy == 'true'
runs-on: [self-hosted, macOS, unity-mac]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # For version numbering
- name: Clean download directory
run: rm -rf /tmp/eagle0/eagle0MAC
- name: Download notarized app
uses: actions/download-artifact@v4
with:
name: notarized-mac-app
path: /tmp/eagle0/eagle0MAC
- name: Unzip notarized app
run: |
cd /tmp/eagle0/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
- name: Deploy Mac Build
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
@@ -135,15 +266,3 @@ jobs:
"$SPARKLE_PRIVATE_KEY_PATH"
rm "$SPARKLE_PRIVATE_KEY_PATH"
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain build.keychain 2>/dev/null || true
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_mac.log
path: /tmp/eagle0/editor_mac.log
+2 -2
View File
@@ -24,7 +24,7 @@ permissions:
jobs:
build-shardok-arm64:
runs-on: self-hosted
runs-on: [self-hosted, bazel]
outputs:
image_tag: ${{ steps.push-shardok.outputs.image_tag }}
steps:
@@ -159,7 +159,7 @@ jobs:
echo "Also tagged as: registry.digitalocean.com/eagle0/shardok-server:arm64-latest"
deploy-hetzner:
runs-on: self-hosted
runs-on: [self-hosted, bazel]
needs: [build-shardok-arm64]
if: github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && github.event.inputs.push_images == 'true')
environment: production
+1 -1
View File
@@ -28,7 +28,7 @@ permissions:
jobs:
build:
runs-on: self-hosted
runs-on: [self-hosted, bazel]
steps:
- name: Checkout repository
+30 -6
View File
@@ -6,7 +6,11 @@ on:
paths:
- ".github/workflows/unity_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/proto/**"
- "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/**"
- "scripts/build_protos.sh"
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
@@ -15,12 +19,16 @@ on:
- "ci/github_actions/persist_library.sh"
- "MODULE.bazel"
- "WORKSPACE"
- "src/main/proto/net/eagle0/eagle/**/BUILD.bazel"
workflow_dispatch:
pull_request:
paths:
- ".github/workflows/unity_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/**"
- "src/main/proto/**"
- "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/**"
- "scripts/build_protos.sh"
- "scripts/build_plugins.sh"
- "scripts/build_windows_plugin.sh"
@@ -29,14 +37,13 @@ on:
- "ci/github_actions/persist_library.sh"
- "MODULE.bazel"
- "WORKSPACE"
- "src/main/proto/**/BUILD.bazel"
permissions:
contents: read
jobs:
windows-unity:
runs-on: self-hosted
runs-on: [self-hosted, macOS, unity-windows]
steps:
- uses: actions/checkout@v4
@@ -63,7 +70,24 @@ jobs:
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /tmp/unity_manifest.txt
MANIFEST_SIGNING_KEY: ${{ secrets.MANIFEST_SIGNING_KEY }}
run: |
# Write signing key to temp file (if available)
SIGNING_ARGS=""
if [ -n "$MANIFEST_SIGNING_KEY" ]; then
echo "$MANIFEST_SIGNING_KEY" > /tmp/manifest_signing_key
chmod 600 /tmp/manifest_signing_key
SIGNING_ARGS="/tmp/manifest_signing_key"
echo "Manifest signing key available"
else
echo "Warning: MANIFEST_SIGNING_KEY not set, manifest will be unsigned"
fi
# Update the unified manifest (with optional signing)
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d /tmp/unity_manifest.txt $SIGNING_ARGS
# Cleanup
rm -f /tmp/manifest_signing_key
- name: Archive build log
if: success() || failure()
uses: actions/upload-artifact@v4
+1
View File
@@ -38,3 +38,4 @@ scripts/refresh_name_layers/refresh_name_layers.zip
api_keys.txt
src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/Packages/com.unity.dedicated-server/
node_modules/
+5 -1
View File
@@ -4,14 +4,18 @@
**NEVER push directly to main/master.** No exceptions. Not for "small changes." Not for docs. Not ever.
**NEVER merge PRs.** You create PRs. The user merges them. No exceptions.
**ALWAYS use this workflow:**
1. Create a feature branch from origin/main
2. Commit to that branch
3. Create a PR with `gh pr create`
4. Wait for user to merge
4. Wait for user to merge (DO NOT run `gh pr merge`)
If you catch yourself about to run `git push origin main` or `git push origin <branch>:main`, STOP. You are about to violate a critical rule. Create a PR instead.
If you catch yourself about to run `gh pr merge`, STOP. Only the user merges PRs.
---
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+28 -10
View File
@@ -13,8 +13,8 @@ AWS_SDK_VERSION = "2.28.1"
# Core Build Tools
#
bazel_dep(name = "bazel_skylib", version = "1.8.1")
bazel_dep(name = "rules_pkg", version = "1.1.0")
bazel_dep(name = "bazel_skylib", version = "1.9.0")
bazel_dep(name = "rules_pkg", version = "1.2.0")
#
# Language Support - Scala
@@ -102,8 +102,8 @@ sysroot(
# Language Support - Go
#
bazel_dep(name = "rules_go", version = "0.56.1", repo_name = "io_bazel_rules_go")
bazel_dep(name = "gazelle", version = "0.45.0", repo_name = "bazel_gazelle")
bazel_dep(name = "rules_go", version = "0.59.0", repo_name = "io_bazel_rules_go")
bazel_dep(name = "gazelle", version = "0.47.0", repo_name = "bazel_gazelle")
go_sdk = use_extension("@io_bazel_rules_go//go:extensions.bzl", "go_sdk")
go_sdk.download(version = "1.23.3")
@@ -130,6 +130,13 @@ bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_a
bazel_dep(name = "rules_apple", version = "4.3.3", repo_name = "build_bazel_rules_apple")
bazel_dep(name = "rules_swift", version = "2.4.0", repo_name = "build_bazel_rules_swift")
# Register Apple CC toolchain for Objective-C compilation
apple_cc_configure = use_extension(
"@build_bazel_apple_support//crosstool:setup.bzl",
"apple_cc_configure_extension",
)
use_repo(apple_cc_configure, "local_config_apple_cc")
#
# Protocol Buffers & RPC
#
@@ -137,7 +144,7 @@ bazel_dep(name = "rules_swift", version = "2.4.0", repo_name = "build_bazel_rule
bazel_dep(name = "protobuf", version = "29.2", repo_name = "com_google_protobuf")
bazel_dep(name = "grpc", version = "1.71.0")
bazel_dep(name = "grpc-java", version = "1.71.0")
bazel_dep(name = "flatbuffers", version = "25.2.10")
bazel_dep(name = "flatbuffers", version = "25.9.23")
#
# Testing
@@ -149,8 +156,8 @@ bazel_dep(name = "googletest", version = "1.17.0")
# Container Images (OCI)
#
bazel_dep(name = "rules_oci", version = "2.2.6")
bazel_dep(name = "aspect_bazel_lib", version = "2.16.0")
bazel_dep(name = "rules_oci", version = "2.2.7")
bazel_dep(name = "aspect_bazel_lib", version = "2.22.4")
oci = use_extension("@rules_oci//oci:extensions.bzl", "oci")
@@ -186,7 +193,7 @@ use_repo(oci, "alpine_linux", "alpine_linux_linux_amd64", "eclipse_temurin_17",
# Java/Scala Dependencies
#
bazel_dep(name = "rules_jvm_external", version = "6.3")
bazel_dep(name = "rules_jvm_external", version = "6.9")
maven = use_extension("@rules_jvm_external//:extensions.bzl", "maven")
maven.install(
@@ -293,14 +300,25 @@ http_archive(
],
)
# Sparkle framework for macOS auto-updates
SPARKLE_VERSION = "2.6.4"
http_archive(
name = "sparkle",
build_file = "@//external:BUILD.sparkle",
sha256 = "50612a06038abc931f16011d7903b8326a362c1074dabccb718404ce8e585f0b",
strip_prefix = "",
url = "https://github.com/sparkle-project/Sparkle/releases/download/%s/Sparkle-%s.tar.xz" % (SPARKLE_VERSION, SPARKLE_VERSION),
)
# Busybox static binary for Docker health checks (provides nc, wget, etc.)
# Primary: GitHub release mirror (reliable)
# Primary: DigitalOcean Spaces (public, reliable)
# Fallback: busybox.net (can be unreliable/slow)
http_file(
name = "busybox_x86_64",
sha256 = "6e123e7f3202a8c1e9b1f94d8941580a25135382b99e8d3e34fb858bba311348",
urls = [
"https://github.com/nolen777/eagle0/releases/download/busybox-1.35.0/busybox-1.35.0-x86_64-linux-musl",
"https://eagle0-sysroot.sfo3.digitaloceanspaces.com/busybox/busybox-1.35.0-x86_64-linux-musl",
"https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox",
],
downloaded_file_path = "busybox",
+119 -30
View File
@@ -27,9 +27,9 @@
"https://bcr.bazel.build/modules/aspect_bazel_lib/1.40.3/MODULE.bazel": "668e6bcb4d957fc0e284316dba546b705c8d43c857f87119619ee83c4555b859",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.11.0/MODULE.bazel": "cb1ba9f9999ed0bc08600c221f532c1ddd8d217686b32ba7d45b0713b5131452",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.14.0/MODULE.bazel": "2b31ffcc9bdc8295b2167e07a757dbbc9ac8906e7028e5170a3708cecaac119f",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.16.0/MODULE.bazel": "852f9ebbda017572a7c113a2434592dd3b2f55cd9a0faea3d4be5a09a59e4900",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/MODULE.bazel": "253d739ba126f62a5767d832765b12b59e9f8d2bc88cc1572f4a73e46eb298ca",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.19.3/source.json": "ffab9254c65ba945f8369297ad97ca0dec213d3adc6e07877e23a48624a8b456",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.4/MODULE.bazel": "a05cbd9bc16712a58dc27ffe0dceaefd0da59a9bd87a227379b2a934b26a39ab",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.22.4/source.json": "9780bc57f521968ee82b7c3e85b7d0c71518fb7ce83ed7a9e5077ce20923207b",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.2/MODULE.bazel": "780d1a6522b28f5edb7ea09630748720721dfe27690d65a2d33aa7509de77e07",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.7.7/MODULE.bazel": "491f8681205e31bb57892d67442ce448cda4f472a8e6b3dc062865e29a64f89c",
"https://bcr.bazel.build/modules/aspect_bazel_lib/2.8.1/MODULE.bazel": "812d2dd42f65dca362152101fbec418029cc8fd34cbad1a2fde905383d705838",
@@ -39,11 +39,11 @@
"https://bcr.bazel.build/modules/aspect_rules_js/1.33.1/MODULE.bazel": "db3e7f16e471cf6827059d03af7c21859e7a0d2bc65429a3a11f005d46fc501b",
"https://bcr.bazel.build/modules/aspect_rules_js/1.39.0/MODULE.bazel": "aece421d479e3c31dc3e5f6d49a12acc2700457c03c556650ec7a0ff23fc0d95",
"https://bcr.bazel.build/modules/aspect_rules_js/2.0.0/MODULE.bazel": "b45b507574aa60a92796e3e13c195cd5744b3b8aff516a9c0cb5ae6a048161c5",
"https://bcr.bazel.build/modules/aspect_rules_js/2.1.3/MODULE.bazel": "47cc48eec374d69dced3cf9b9e5926beac2f927441acfb1a3568bbb709b25666",
"https://bcr.bazel.build/modules/aspect_rules_js/2.1.3/source.json": "6b0fe67780c101430be087381b7a79d75eeebe1a1eae6a2cee937713603634ac",
"https://bcr.bazel.build/modules/aspect_rules_js/2.3.8/MODULE.bazel": "74bf20a7a6bd5f2be09607fdb4196cfd6f203422ea271752ec2b1afe95426101",
"https://bcr.bazel.build/modules/aspect_rules_js/2.3.8/source.json": "411ec9d79d6f5fe8a083359588c21d01a5b48d88a2cbd334a4c90365015b7836",
"https://bcr.bazel.build/modules/aspect_rules_lint/0.12.0/MODULE.bazel": "e767c5dbfeb254ec03275a7701b5cfde2c4d2873676804bc7cb27ddff3728fed",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.4.0/MODULE.bazel": "5b554d5de90d96ee14117527c0519037713dd33884f3212eae391beccb2e94ff",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.4.0/source.json": "9ada3722b716853b6dccdb7b650d8e776a23bc8a190de0c59bd15f21afea6f8a",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.6.0/MODULE.bazel": "d0045b5eabb012be550a609589b3e5e47eba682344b19cfd9365d4d896ed07df",
"https://bcr.bazel.build/modules/aspect_rules_ts/3.6.0/source.json": "5593e3f1cd0dd5147f7748e163307fd5c2e1077913d6945b58739ad8d770a290",
"https://bcr.bazel.build/modules/bazel_features/0.1.0/MODULE.bazel": "47011d645b0f949f42ee67f2e8775188a9cf4a0a1528aa2fa4952f2fd00906fd",
"https://bcr.bazel.build/modules/bazel_features/1.1.0/MODULE.bazel": "cfd42ff3b815a5f39554d97182657f8c4b9719568eb7fded2b9135f084bf760b",
"https://bcr.bazel.build/modules/bazel_features/1.1.1/MODULE.bazel": "27b8c79ef57efe08efccbd9dd6ef70d61b4798320b8d3c134fd571f78963dbcd",
@@ -57,12 +57,15 @@
"https://bcr.bazel.build/modules/bazel_features/1.27.0/MODULE.bazel": "621eeee06c4458a9121d1f104efb80f39d34deff4984e778359c60eaf1a8cb65",
"https://bcr.bazel.build/modules/bazel_features/1.28.0/MODULE.bazel": "4b4200e6cbf8fa335b2c3f43e1d6ef3e240319c33d43d60cc0fbd4b87ece299d",
"https://bcr.bazel.build/modules/bazel_features/1.3.0/MODULE.bazel": "cdcafe83ec318cda34e02948e81d790aab8df7a929cec6f6969f13a489ccecd9",
"https://bcr.bazel.build/modules/bazel_features/1.30.0/MODULE.bazel": "a14b62d05969a293b80257e72e597c2da7f717e1e69fa8b339703ed6731bec87",
"https://bcr.bazel.build/modules/bazel_features/1.34.0/MODULE.bazel": "e8475ad7c8965542e0c7aac8af68eb48c4af904be3d614b6aa6274c092c2ea1e",
"https://bcr.bazel.build/modules/bazel_features/1.38.0/MODULE.bazel": "f9b8a9c890ebd216b4049fd12a31d3c2602e3403c7af636b04fbbd7453edc9c9",
"https://bcr.bazel.build/modules/bazel_features/1.38.0/source.json": "31ba776c122b54a2885e23651642e32f087a87bf025465f8040751894b571277",
"https://bcr.bazel.build/modules/bazel_features/1.4.1/MODULE.bazel": "e45b6bb2350aff3e442ae1111c555e27eac1d915e77775f6fdc4b351b758b5d7",
"https://bcr.bazel.build/modules/bazel_features/1.9.0/MODULE.bazel": "885151d58d90d8d9c811eb75e3288c11f850e1d6b481a8c9f766adee4712358b",
"https://bcr.bazel.build/modules/bazel_features/1.9.1/MODULE.bazel": "8f679097876a9b609ad1f60249c49d68bfab783dd9be012faf9d82547b14815a",
"https://bcr.bazel.build/modules/bazel_lib/3.0.0/MODULE.bazel": "22b70b80ac89ad3f3772526cd9feee2fa412c2b01933fea7ed13238a448d370d",
"https://bcr.bazel.build/modules/bazel_lib/3.0.0/source.json": "895f21909c6fba01d7c17914bb6c8e135982275a1b18cdaa4e62272217ef1751",
"https://bcr.bazel.build/modules/bazel_skylib/1.0.3/MODULE.bazel": "bcb0fd896384802d1ad283b4e4eb4d718eebd8cb820b0a2c3a347fb971afd9d8",
"https://bcr.bazel.build/modules/bazel_skylib/1.1.1/MODULE.bazel": "1add3e7d93ff2e6998f9e118022c84d163917d912f5afafb3058e3d2f1545b5e",
"https://bcr.bazel.build/modules/bazel_skylib/1.2.0/MODULE.bazel": "44fe84260e454ed94ad326352a698422dbe372b21a1ac9f3eab76eb531223686",
@@ -77,7 +80,8 @@
"https://bcr.bazel.build/modules/bazel_skylib/1.7.1/MODULE.bazel": "3120d80c5861aa616222ec015332e5f8d3171e062e3e804a2a0253e1be26e59b",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.1/MODULE.bazel": "88ade7293becda963e0e3ea33e7d54d3425127e0a326e0d17da085a5f1f03ff6",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/MODULE.bazel": "69ad6927098316848b34a9142bcc975e018ba27f08c4ff403f50c1b6e646ca67",
"https://bcr.bazel.build/modules/bazel_skylib/1.8.2/source.json": "34a3c8bcf233b835eb74be9d628899bb32999d3e0eadef1947a0a562a2b16ffb",
"https://bcr.bazel.build/modules/bazel_skylib/1.9.0/MODULE.bazel": "72997b29dfd95c3fa0d0c48322d05590418edef451f8db8db5509c57875fb4b7",
"https://bcr.bazel.build/modules/bazel_skylib/1.9.0/source.json": "7ad77c1e8c1b84222d9b3f3cae016a76639435744c19330b0b37c0a3c9da7dc0",
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/MODULE.bazel": "fd1f9432ca04c947e91b500df69ce7c5b6dbfe1bc45ab1820338205dae3383a6",
"https://bcr.bazel.build/modules/bazel_worker_api/0.0.6/source.json": "5d68545f224904745a3cabd35aea6bc2b6cc5a78b7f49f3f69660eab2eeeb273",
"https://bcr.bazel.build/modules/boringssl/0.0.0-20211025-d4f1ab9/MODULE.bazel": "6ee6353f8b1a701fe2178e1d925034294971350b6d3ac37e67e5a7d463267834",
@@ -104,8 +108,8 @@
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20241214-918efc9/MODULE.bazel": "24e05f6f52f37be63a795192848555a2c8c855e7814dbc1ed419fb04a7005464",
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20250128-4de3c74/MODULE.bazel": "1fe72489212c530086e3ffb0e018b2bfef4663200ca03571570f9f006bef1d75",
"https://bcr.bazel.build/modules/envoy_api/0.0.0-20250128-4de3c74/source.json": "028519164a2e24563f4b43d810fdedc702daed90e71e7042d45ba82ad807b46f",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/MODULE.bazel": "dab15cafe8512d2c4a8daa44c2d7968c5c79f01e220d40076cdc260bf58605e2",
"https://bcr.bazel.build/modules/flatbuffers/25.2.10/source.json": "7eae7ea3eb913b9802426e4d5df11d6c6072a3573a548f8cabf1e965f5cca4d0",
"https://bcr.bazel.build/modules/flatbuffers/25.9.23/MODULE.bazel": "32753ba60bf3bacfe7737c0f3e8e3e55624b19af5d398c485580d57492d145d8",
"https://bcr.bazel.build/modules/flatbuffers/25.9.23/source.json": "a2116f0017f6896353fd3abf65ef2b89b0a257e8a87f395c5000f53934829f31",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/MODULE.bazel": "cdf8cbe5ee750db04b78878c9633cc76e80dcf4416cbe982ac3a9222f80713c8",
"https://bcr.bazel.build/modules/gawk/5.3.2.bcr.1/source.json": "fa7b512dfcb5eafd90ce3959cf42a2a6fe96144ebbb4b3b3928054895f2afac2",
"https://bcr.bazel.build/modules/gazelle/0.27.0/MODULE.bazel": "3446abd608295de6d90b4a8a118ed64a9ce11dcb3dda2dc3290a22056bd20996",
@@ -115,8 +119,8 @@
"https://bcr.bazel.build/modules/gazelle/0.34.0/MODULE.bazel": "abdd8ce4d70978933209db92e436deb3a8b737859e9354fb5fd11fb5c2004c8a",
"https://bcr.bazel.build/modules/gazelle/0.36.0/MODULE.bazel": "e375d5d6e9a6ca59b0cb38b0540bc9a05b6aa926d322f2de268ad267a2ee74c0",
"https://bcr.bazel.build/modules/gazelle/0.37.0/MODULE.bazel": "d1327ba0907d0275ed5103bfbbb13518f6c04955b402213319d0d6c0ce9839d4",
"https://bcr.bazel.build/modules/gazelle/0.45.0/MODULE.bazel": "ecd19ebe9f8e024e1ccffb6d997cc893a974bcc581f1ae08f386bdd448b10687",
"https://bcr.bazel.build/modules/gazelle/0.45.0/source.json": "111d182facc5f5e80f0b823d5f077b74128f40c3fd2eccc89a06f34191bd3392",
"https://bcr.bazel.build/modules/gazelle/0.47.0/MODULE.bazel": "b61bb007c4efad134aa30ee7f4a8e2a39b22aa5685f005edaa022fbd1de43ebc",
"https://bcr.bazel.build/modules/gazelle/0.47.0/source.json": "aeb2e5df14b7fb298625d75d08b9c65bdb0b56014c5eb89da9e5dd0572280ae6",
"https://bcr.bazel.build/modules/google_benchmark/1.8.2/MODULE.bazel": "a70cf1bba851000ba93b58ae2f6d76490a9feb74192e57ab8e8ff13c34ec50cb",
"https://bcr.bazel.build/modules/google_benchmark/1.8.4/MODULE.bazel": "c6d54a11dcf64ee63545f42561eda3fd94c1b5f5ebe1357011de63ae33739d5e",
"https://bcr.bazel.build/modules/google_benchmark/1.8.5/MODULE.bazel": "9ba9b31b984022828a950e3300410977eda2e35df35584c6b0b2d0c2e52766b7",
@@ -176,6 +180,7 @@
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/MODULE.bazel": "b3925269f63561b8b880ae7cf62ccf81f6ece55b62cd791eda9925147ae116ec",
"https://bcr.bazel.build/modules/opentracing-cpp/1.6.0/source.json": "da1cb1add160f5e5074b7272e9db6fd8f1b3336c15032cd0a653af9d2f484aed",
"https://bcr.bazel.build/modules/package_metadata/0.0.2/MODULE.bazel": "fb8d25550742674d63d7b250063d4580ca530499f045d70748b1b142081ebb92",
"https://bcr.bazel.build/modules/package_metadata/0.0.3/MODULE.bazel": "77890552ecea9e284b5424c9de827a58099348763a4359e975c359a83d4faa83",
"https://bcr.bazel.build/modules/package_metadata/0.0.5/MODULE.bazel": "ef4f9439e3270fdd6b9fd4dbc3d2f29d13888e44c529a1b243f7a31dfbc2e8e4",
"https://bcr.bazel.build/modules/package_metadata/0.0.5/source.json": "2326db2f6592578177751c3e1f74786b79382cd6008834c9d01ec865b9126a85",
"https://bcr.bazel.build/modules/platforms/0.0.10/MODULE.bazel": "8cb8efaf200bdeb2150d93e162c40f388529a25852b332cec879373771e48ed5",
@@ -229,9 +234,9 @@
"https://bcr.bazel.build/modules/rules_android/0.1.1/MODULE.bazel": "48809ab0091b07ad0182defb787c4c5328bd3a278938415c00a7b69b50c4d3a8",
"https://bcr.bazel.build/modules/rules_android/0.1.1/source.json": "e6986b41626ee10bdc864937ffb6d6bf275bb5b9c65120e6137d56e6331f089e",
"https://bcr.bazel.build/modules/rules_apple/3.16.0/MODULE.bazel": "0d1caf0b8375942ce98ea944be754a18874041e4e0459401d925577624d3a54a",
"https://bcr.bazel.build/modules/rules_apple/3.16.1/MODULE.bazel": "8294474defa70af2534a558ab905c083d69203344145e6f7d544d5098611ec7d",
"https://bcr.bazel.build/modules/rules_apple/3.16.1/source.json": "9190fd9d34a5d048bfbba8a530a57f2c2bf3f61e5634a9ab0b6ab005458857f9",
"https://bcr.bazel.build/modules/rules_apple/3.5.1/MODULE.bazel": "3d1bbf65ad3692003d36d8a29eff54d4e5c1c5f4bfb60f79e28646a924d9101c",
"https://bcr.bazel.build/modules/rules_apple/4.3.3/MODULE.bazel": "c5c2c4adeeac5f3f2f9b7f16abfa8be7ffefa596171d0d92bed4cae9ade0a498",
"https://bcr.bazel.build/modules/rules_apple/4.3.3/source.json": "3cb1d69c8243ffcc42ecbf84ae8b9cccd7b1e2f091b0aee5a3e9c9a45267f312",
"https://bcr.bazel.build/modules/rules_buf/0.1.1/MODULE.bazel": "6189aec18a4f7caff599ad41b851ab7645d4f1e114aa6431acf9b0666eb92162",
"https://bcr.bazel.build/modules/rules_cc/0.0.1/MODULE.bazel": "cb2aa0747f84c6c3a78dad4e2049c154f08ab9d166b1273835a8174940365647",
"https://bcr.bazel.build/modules/rules_cc/0.0.10/MODULE.bazel": "ec1705118f7eaedd6e118508d3d26deba2a4e76476ada7e0e3965211be012002",
@@ -246,6 +251,8 @@
"https://bcr.bazel.build/modules/rules_cc/0.0.8/MODULE.bazel": "964c85c82cfeb6f3855e6a07054fdb159aced38e99a5eecf7bce9d53990afa3e",
"https://bcr.bazel.build/modules/rules_cc/0.0.9/MODULE.bazel": "836e76439f354b89afe6a911a7adf59a6b2518fafb174483ad78a2a2fde7b1c5",
"https://bcr.bazel.build/modules/rules_cc/0.1.1/MODULE.bazel": "2f0222a6f229f0bf44cd711dc13c858dad98c62d52bd51d8fc3a764a83125513",
"https://bcr.bazel.build/modules/rules_cc/0.1.2/MODULE.bazel": "557ddc3a96858ec0d465a87c0a931054d7dcfd6583af2c7ed3baf494407fd8d0",
"https://bcr.bazel.build/modules/rules_cc/0.1.5/MODULE.bazel": "88dfc9361e8b5ae1008ac38f7cdfd45ad738e4fa676a3ad67d19204f045a1fd8",
"https://bcr.bazel.build/modules/rules_cc/0.2.14/MODULE.bazel": "353c99ed148887ee89c54a17d4100ae7e7e436593d104b668476019023b58df8",
"https://bcr.bazel.build/modules/rules_cc/0.2.14/source.json": "55d0a4587c5592fad350f6e698530f4faf0e7dd15e69d43f8d87e220c78bea54",
"https://bcr.bazel.build/modules/rules_foreign_cc/0.10.1/MODULE.bazel": "b9527010e5fef060af92b6724edb3691970a5b1f76f74b21d39f7d433641be60",
@@ -263,8 +270,8 @@
"https://bcr.bazel.build/modules/rules_go/0.48.0/MODULE.bazel": "d00ebcae0908ee3f5e6d53f68677a303d6d59a77beef879598700049c3980a03",
"https://bcr.bazel.build/modules/rules_go/0.50.1/MODULE.bazel": "b91a308dc5782bb0a8021ad4330c81fea5bda77f96b9e4c117b9b9c8f6665ee0",
"https://bcr.bazel.build/modules/rules_go/0.53.0/MODULE.bazel": "a4ed760d3ac0dbc0d7b967631a9a3fd9100d28f7d9fcf214b4df87d4bfff5f9a",
"https://bcr.bazel.build/modules/rules_go/0.56.1/MODULE.bazel": "d5b835c548ac917345f1780cd2da52edc1130a908fe091c92096895303ae78a0",
"https://bcr.bazel.build/modules/rules_go/0.56.1/source.json": "0c902f7272e8d4e47e459af97be472bc19dadbbe6023a0719d1adce8483ac75a",
"https://bcr.bazel.build/modules/rules_go/0.59.0/MODULE.bazel": "b7e43e7414a3139a7547d1b4909b29085fbe5182b6c58cbe1ed4c6272815aeae",
"https://bcr.bazel.build/modules/rules_go/0.59.0/source.json": "1df17bb7865cfc029492c30163cee891d0dd8658ea0d5bfdf252c4b6db5c1ef6",
"https://bcr.bazel.build/modules/rules_java/4.0.0/MODULE.bazel": "5a78a7ae82cd1a33cef56dc578c7d2a46ed0dca12643ee45edbb8417899e6f74",
"https://bcr.bazel.build/modules/rules_java/5.1.0/MODULE.bazel": "324b6478b0343a3ce7a9add8586ad75d24076d6d43d2f622990b9c1cfd8a1b15",
"https://bcr.bazel.build/modules/rules_java/5.3.5/MODULE.bazel": "a4ec4f2db570171e3e5eb753276ee4b389bae16b96207e9d3230895c99644b86",
@@ -292,7 +299,8 @@
"https://bcr.bazel.build/modules/rules_jvm_external/6.0/MODULE.bazel": "37c93a5a78d32e895d52f86a8d0416176e915daabd029ccb5594db422e87c495",
"https://bcr.bazel.build/modules/rules_jvm_external/6.1/MODULE.bazel": "75b5fec090dbd46cf9b7d8ea08cf84a0472d92ba3585b476f44c326eda8059c4",
"https://bcr.bazel.build/modules/rules_jvm_external/6.3/MODULE.bazel": "c998e060b85f71e00de5ec552019347c8bca255062c990ac02d051bb80a38df0",
"https://bcr.bazel.build/modules/rules_jvm_external/6.3/source.json": "6f5f5a5a4419ae4e37c35a5bb0a6ae657ed40b7abc5a5189111b47fcebe43197",
"https://bcr.bazel.build/modules/rules_jvm_external/6.9/MODULE.bazel": "07c5db05527db7744a54fcffd653e1550d40e0540207a7f7e6d0a4de5bef8274",
"https://bcr.bazel.build/modules/rules_jvm_external/6.9/source.json": "b12970214f3cc144b26610caeb101fa622d910f1ab3d98f0bae1058edbd00bd4",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.0/MODULE.bazel": "ef85697305025e5a61f395d4eaede272a5393cee479ace6686dba707de804d59",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.6/MODULE.bazel": "d269a01a18ee74d0335450b10f62c9ed81f2321d7958a2934e44272fe82dcef3",
"https://bcr.bazel.build/modules/rules_kotlin/1.9.6/source.json": "2faa4794364282db7c06600b7e5e34867a564ae91bda7cae7c29c64e9466b7d5",
@@ -306,12 +314,12 @@
"https://bcr.bazel.build/modules/rules_nodejs/6.3.0/MODULE.bazel": "45345e4aba35dd6e4701c1eebf5a4e67af4ed708def9ebcdc6027585b34ee52d",
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/MODULE.bazel": "b66eadebd10f1f1b25f52f95ab5213a57e82c37c3f656fcd9a57ad04d2264ce7",
"https://bcr.bazel.build/modules/rules_nodejs/6.3.3/source.json": "45bd343155bdfed2543f0e39b80ff3f6840efc31975da4b5795797f4c94147ad",
"https://bcr.bazel.build/modules/rules_oci/2.2.6/MODULE.bazel": "2ba6ddd679269e00aeffe9ca04faa2d0ca4129650982c9246d0d459fe2da47d9",
"https://bcr.bazel.build/modules/rules_oci/2.2.6/source.json": "94e7decb8f95d9465b0bbea71c65064cd16083be1350c7468f131818641dc4a5",
"https://bcr.bazel.build/modules/rules_oci/2.2.7/MODULE.bazel": "f6150e4b224d459f7f6523ef65967464ca4efdd266c7fbf2f5a2a51011957e0c",
"https://bcr.bazel.build/modules/rules_oci/2.2.7/source.json": "b099f02af330f47f19dc67fc9300ef6e1937a8c86882690db0e7a2fcea8c7f6b",
"https://bcr.bazel.build/modules/rules_pkg/0.7.0/MODULE.bazel": "df99f03fc7934a4737122518bb87e667e62d780b610910f0447665a7e2be62dc",
"https://bcr.bazel.build/modules/rules_pkg/1.0.1/MODULE.bazel": "5b1df97dbc29623bccdf2b0dcd0f5cb08e2f2c9050aab1092fd39a41e82686ff",
"https://bcr.bazel.build/modules/rules_pkg/1.1.0/MODULE.bazel": "9db8031e71b6ef32d1846106e10dd0ee2deac042bd9a2de22b4761b0c3036453",
"https://bcr.bazel.build/modules/rules_pkg/1.1.0/source.json": "fef768df13a92ce6067e1cd0cdc47560dace01354f1d921cfb1d632511f7d608",
"https://bcr.bazel.build/modules/rules_pkg/1.2.0/MODULE.bazel": "c7db3c2b407e673c7a39e3625dc05dc9f12d6682cbd82a3a5924a13b491eda7e",
"https://bcr.bazel.build/modules/rules_pkg/1.2.0/source.json": "9062e00845bf91a4247465d371baa837adf9b6ff44c542f73ba084f07667e1dc",
"https://bcr.bazel.build/modules/rules_proto/4.0.0/MODULE.bazel": "a7a7b6ce9bee418c1a760b3d84f83a299ad6952f9903c67f19e4edd964894e06",
"https://bcr.bazel.build/modules/rules_proto/5.3.0-21.7/MODULE.bazel": "e8dff86b0971688790ae75528fe1813f71809b5afd57facb44dad9e8eca631b7",
"https://bcr.bazel.build/modules/rules_proto/6.0.0-rc1/MODULE.bazel": "1e5b502e2e1a9e825eef74476a5a1ee524a92297085015a052510b09a1a09483",
@@ -334,7 +342,8 @@
"https://bcr.bazel.build/modules/rules_python/0.4.0/MODULE.bazel": "9208ee05fd48bf09ac60ed269791cf17fb343db56c8226a720fbb1cdf467166c",
"https://bcr.bazel.build/modules/rules_python/0.40.0/MODULE.bazel": "9d1a3cd88ed7d8e39583d9ffe56ae8a244f67783ae89b60caafc9f5cf318ada7",
"https://bcr.bazel.build/modules/rules_python/1.0.0/MODULE.bazel": "898a3d999c22caa585eb062b600f88654bf92efb204fa346fb55f6f8edffca43",
"https://bcr.bazel.build/modules/rules_python/1.0.0/source.json": "b0162a65c6312e45e7912e39abd1a7f8856c2c7e41ecc9b6dc688a6f6400a917",
"https://bcr.bazel.build/modules/rules_python/1.3.0/MODULE.bazel": "8361d57eafb67c09b75bf4bbe6be360e1b8f4f18118ab48037f2bd50aa2ccb13",
"https://bcr.bazel.build/modules/rules_python/1.3.0/source.json": "25932f917cd279c7baefa6cb1d3fa8750a7a29de522024449b19af6eab51f4a0",
"https://bcr.bazel.build/modules/rules_rust/0.45.1/MODULE.bazel": "a69d0db3a958fab2c6520961e1b2287afcc8b36690fd31bbc4f6f7391397150d",
"https://bcr.bazel.build/modules/rules_scala/7.1.1/MODULE.bazel": "b1f80c52ae49b27d41b9291d8b328b69247de2b7596d35d09afe6147b82cf562",
"https://bcr.bazel.build/modules/rules_scala/7.1.1/source.json": "5038cb231d4020c5965c920681cf961a7bf137b40315025e40f3a7b6a0ac1f0f",
@@ -345,8 +354,8 @@
"https://bcr.bazel.build/modules/rules_swift/1.16.0/MODULE.bazel": "4a09f199545a60d09895e8281362b1ff3bb08bbde69c6fc87aff5b92fcc916ca",
"https://bcr.bazel.build/modules/rules_swift/1.18.0/MODULE.bazel": "a6aba73625d0dc64c7b4a1e831549b6e375fbddb9d2dde9d80c9de6ec45b24c9",
"https://bcr.bazel.build/modules/rules_swift/2.1.1/MODULE.bazel": "494900a80f944fc7aa61500c2073d9729dff0b764f0e89b824eb746959bc1046",
"https://bcr.bazel.build/modules/rules_swift/2.3.1/MODULE.bazel": "0b42093600d9226bcbdb31fb86d25d4204293d716fdbb2e50a1852547032a660",
"https://bcr.bazel.build/modules/rules_swift/2.3.1/source.json": "87d28609c37d2061db2f6fc3aae8ab7fbda9adf556cd88fbd0c7d520b8d81391",
"https://bcr.bazel.build/modules/rules_swift/2.4.0/MODULE.bazel": "1639617eb1ede28d774d967a738b4a68b0accb40650beadb57c21846beab5efd",
"https://bcr.bazel.build/modules/rules_swift/2.4.0/source.json": "a6577f57f9febbdc015a01f2a8f3487422032f134d6c61d18ed8e8ca3b9acc7c",
"https://bcr.bazel.build/modules/stardoc/0.5.0/MODULE.bazel": "f9f1f46ba8d9c3362648eea571c6f9100680efc44913618811b58cc9c02cd678",
"https://bcr.bazel.build/modules/stardoc/0.5.1/MODULE.bazel": "1a05d92974d0c122f5ccf09291442580317cdd859f07a8655f1db9a60374f9f8",
"https://bcr.bazel.build/modules/stardoc/0.5.3/MODULE.bazel": "c7f6948dae6999bf0db32c1858ae345f112cacf98f174c7a8bb707e41b974f1c",
@@ -360,6 +369,7 @@
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/MODULE.bazel": "5e463fbfba7b1701d957555ed45097d7f984211330106ccd1352c6e0af0dcf91",
"https://bcr.bazel.build/modules/swift_argument_parser/1.3.1.1/source.json": "32bd87e5f4d7acc57c5b2ff7c325ae3061d5e242c0c4c214ae87e0f1c13e54cb",
"https://bcr.bazel.build/modules/tar.bzl/0.2.1/MODULE.bazel": "52d1c00a80a8cc67acbd01649e83d8dd6a9dc426a6c0b754a04fe8c219c76468",
"https://bcr.bazel.build/modules/tar.bzl/0.5.1/MODULE.bazel": "7c2eb3dcfc53b0f3d6f9acdfd911ca803eaf92aadf54f8ca6e4c1f3aee288351",
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/MODULE.bazel": "a3584b4edcfafcabd9b0ef9819808f05b372957bbdff41601429d5fd0aac2e7c",
"https://bcr.bazel.build/modules/tar.bzl/0.6.0/source.json": "4a620381df075a16cb3a7ed57bd1d05f7480222394c64a20fa51bdb636fda658",
"https://bcr.bazel.build/modules/toolchains_llvm/1.6.0/MODULE.bazel": "39603859cafb1c6830160fcd6370552e836790e6abb2bfb8d13bff53c0c10a64",
@@ -386,7 +396,7 @@
"@@apple_support~//crosstool:setup.bzl%apple_cc_configure_extension": {
"general": {
"bzlTransitiveDigest": "Z3yAd66IJL0GAZUTSeMOjoHiE1SZPPwiIs/XQui5BvE=",
"usagesDigest": "TOb4CUri5UsTKxgIDTNzR0ddIc21eYLCRIm+jqQmjlg=",
"usagesDigest": "DqQsfZN5lA8z+nLEEY+EpKGzQ8M73mDm/A8lofDSyus=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -413,7 +423,7 @@
},
"@@aspect_rules_esbuild~//esbuild:extensions.bzl%esbuild": {
"general": {
"bzlTransitiveDigest": "8jv3p0xDR/oitFeH8y0+Y5xlyrUbfsTRlc9TSwYkwl8=",
"bzlTransitiveDigest": "8L5Llfl6uxIWXd5GR+Qmmm04/jxp6TuJH5LFhIZIUCA=",
"usagesDigest": "iDVoyPxUeADmfK8ssoyG3Ehq1bj6p7A43LpEiE266os=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
@@ -492,6 +502,7 @@
"extra_build_content": "",
"generate_bzl_library_targets": false,
"extract_full_archive": false,
"exclude_package_contents": [],
"system_tar": "auto"
}
},
@@ -516,11 +527,17 @@
"package_visibility": [
"//visibility:public"
],
"replace_package": ""
"replace_package": "",
"exclude_package_contents": []
}
}
},
"recordedRepoMappingEntries": [
[
"aspect_bazel_lib~",
"bazel_lib",
"bazel_lib~"
],
[
"aspect_bazel_lib~",
"bazel_skylib",
@@ -531,6 +548,11 @@
"bazel_tools",
"bazel_tools"
],
[
"aspect_bazel_lib~",
"tar.bzl",
"tar.bzl~"
],
[
"aspect_rules_esbuild~",
"aspect_rules_js",
@@ -546,6 +568,11 @@
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"aspect_rules_js~",
"aspect_rules_js",
"aspect_rules_js~"
],
[
"aspect_rules_js~",
"bazel_skylib",
@@ -555,6 +582,31 @@
"aspect_rules_js~",
"bazel_tools",
"bazel_tools"
],
[
"bazel_lib~",
"bazel_skylib",
"bazel_skylib~"
],
[
"bazel_lib~",
"bazel_tools",
"bazel_tools"
],
[
"tar.bzl~",
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"tar.bzl~",
"bazel_skylib",
"bazel_skylib~"
],
[
"tar.bzl~",
"tar.bzl",
"tar.bzl~"
]
]
}
@@ -1162,7 +1214,7 @@
"@@rules_nodejs~//nodejs:extensions.bzl%node": {
"general": {
"bzlTransitiveDigest": "q44Ox2Nwogn6OsO0Xw5lhjkd/xmxkvvpwVOn5P4pmHQ=",
"usagesDigest": "WQpLKLujnBfrx9sMWCJgyaK9P04binseT6CGBy3vP4E=",
"usagesDigest": "Py5Wgc5kr5fTMe1FKrlFK276B6SodesXp6nw2Fq5XA8=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -1292,8 +1344,8 @@
},
"@@rules_oci~//oci:extensions.bzl%oci": {
"general": {
"bzlTransitiveDigest": "FaY+7xb13bB3hmxqwAWaGp3Tf3Q4Nfdlr+F38CP5mcg=",
"usagesDigest": "39yHQmifPoGf+JSYpnQSnJGugxbrFVge/+ENmUiqZ6M=",
"bzlTransitiveDigest": "AOLP47LtVHSKSDiukosQymx543OwcgeoQP666wwuj3o=",
"usagesDigest": "3Xsv1/UEV8MOARW4BZScPw3Gxtx19OQ5EXOqcL1p9bI=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -1580,6 +1632,43 @@
]
}
},
"@@rules_python~//python/uv:uv.bzl%uv": {
"general": {
"bzlTransitiveDigest": "Xpqjnjzy6zZ90Es9Wa888ZLHhn7IsNGbph/e6qoxzw8=",
"usagesDigest": "qI5PVlIum/YAnGJg5oXGHzDkMFWt2aNSUZY4G8PBbic=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"uv": {
"bzlFile": "@@rules_python~//python/uv/private:uv_toolchains_repo.bzl",
"ruleClassName": "uv_toolchains_repo",
"attributes": {
"toolchain_type": "'@@rules_python~//python/uv:uv_toolchain_type'",
"toolchain_names": [
"none"
],
"toolchain_implementations": {
"none": "'@@rules_python~//python:none'"
},
"toolchain_compatible_with": {
"none": [
"@platforms//:incompatible"
]
},
"toolchain_target_settings": {}
}
}
},
"recordedRepoMappingEntries": [
[
"rules_python~",
"platforms",
"platforms"
]
]
}
},
"@@rules_scala~//scala/extensions:config.bzl%scala_config": {
"general": {
"bzlTransitiveDigest": "TdBxhkZTM7VU6teIFS+KoonKU7wmb5BL7leCWWx7yX8=",
@@ -5056,7 +5145,7 @@
"@@rules_swift~//swift:extensions.bzl%non_module_deps": {
"general": {
"bzlTransitiveDigest": "PAIMhc1bVKfcyoHeg0xO8LMS9KN5yzbsMGwa5O2ifJM=",
"usagesDigest": "A3fzk5iHsrLdI3PokT1bHIdeJ2j9tc09H3/3Old6IfU=",
"usagesDigest": "l2vIL7SL4tJqHIVLWd78Y/ym+r0II64lmvoX8o+0Bb0=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
+16 -2
View File
@@ -1,6 +1,20 @@
#!/bin/bash
set -euxo pipefail
set -uxo pipefail
/bin/echo "persist Library/"
/usr/bin/rsync -rtlDvq src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ /tmp/eagle0/Library/
# rsync may exit with code 23 ("partial transfer due to error") if Unity's
# temporary files vanish during the copy. This is acceptable for a cache.
/usr/bin/rsync -rtlDvq src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ /tmp/eagle0/Library/
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
+3 -3
View File
@@ -3,8 +3,8 @@
# Workflows should update their specific vars without overwriting others
# Container images (managed by respective build workflows)
# Note: Shardok runs on Hetzner, deployed via shardok_arm64_build.yml
EAGLE_IMAGE=registry.digitalocean.com/eagle0/eagle-server:latest
SHARDOK_IMAGE=registry.digitalocean.com/eagle0/shardok-server:latest
ADMIN_IMAGE=registry.digitalocean.com/eagle0/admin-server:latest
JFR_SIDECAR_IMAGE=registry.digitalocean.com/eagle0/jfr-sidecar:latest
AUTH_IMAGE=registry.digitalocean.com/eagle0/auth-server:latest
@@ -27,8 +27,8 @@ DISCORD_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Shardok connection
SHARDOK_ADDRESS=shardok:40042
# Shardok connection (Hetzner ARM64 server)
SHARDOK_ADDRESS=
SHARDOK_AUTH_TOKEN=
# Monitoring
+39 -32
View File
@@ -1,11 +1,13 @@
# Docker Compose for production deployment
#
# Local testing:
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:shardok_server_load && bazel run //ci:auth_server_load
# Build images: bazel run //ci:eagle_server_load && bazel run //ci:auth_server_load
# Run: docker compose -f docker-compose.prod.yml up
#
# Production deployment:
# Run: docker compose -f docker-compose.prod.yml up -d
#
# Note: Shardok runs on Hetzner ARM64 server, deployed via shardok_arm64_build.yml workflow.
services:
# Blue-green deployment: eagle-blue is the primary (production) instance
@@ -33,7 +35,7 @@ services:
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
# Auth token for remote Shardok on Hetzner (only used when shardok address contains .eagle0.net)
# Auth token for Shardok on Hetzner (required)
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
# Use persistent volume for save data (users, games, etc.)
EAGLE_SAVE_DIR: "/app/saves"
@@ -47,7 +49,6 @@ services:
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
depends_on:
- shardok
- auth
restart: unless-stopped
logging:
@@ -92,9 +93,10 @@ services:
volumes:
- ./saves:/app/saves # Same save directory as blue
- ./archived:/app/archived # Same archive directory as blue
- ./jfr:/app/jfr # JFR recordings (same as blue)
- jvm-tmp:/tmp # Shared with jfr-sidecar-green for JVM attach socket files
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
depends_on:
- shardok
- auth
restart: "no" # Don't auto-restart during deployment
logging:
@@ -140,6 +142,8 @@ services:
FASTMAIL_API_TOKEN: "${FASTMAIL_API_TOKEN:-}"
FASTMAIL_FROM_EMAIL: "${FASTMAIL_FROM_EMAIL:-}"
FASTMAIL_FROM_NAME: "${FASTMAIL_FROM_NAME:-}"
# Require invitation codes for new user registration
REQUIRE_INVITATION_CODE: "true"
# Note: port 40033 is exposed via nginx, not directly
volumes:
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with Eagle
@@ -158,30 +162,8 @@ services:
retries: 3
start_period: 10s
shardok:
image: ${SHARDOK_IMAGE:-registry.digitalocean.com/eagle0/shardok-server:latest}
container_name: shardok-server
mem_limit: 1g
memswap_limit: 1g # Prevent swap, OOM-kill cleanly instead
ports:
- "40042:40042"
- "40052:40052"
environment:
SHARDOK_RESOURCES_PATH: "/app/resources"
SHARDOK_MAPS_PATH: "/app/resources/maps"
SHARDOK_EAGLE_INTERFACE_ADDRESS: "0.0.0.0:40042"
restart: unless-stopped
logging:
driver: "json-file"
options:
max-size: "100m"
max-file: "5"
healthcheck:
test: ["CMD-SHELL", "nc -z localhost 40042 || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
# Note: Shardok runs on Hetzner ARM64 server, not in this docker-compose.
# Configure SHARDOK_ADDRESS to point to the Hetzner instance.
nginx:
image: nginx:alpine
@@ -196,8 +178,9 @@ services:
- ./certbot/www:/var/www/certbot:ro
- ./auth:/etc/nginx/auth:ro
depends_on:
- eagle-blue
- admin
# Note: nginx connects to eagle via EAGLE_ADDR (default: eagle-blue:40032)
# For blue-green deployments, update EAGLE_ADDR in .env before switching
restart: unless-stopped
logging:
driver: "json-file"
@@ -214,14 +197,14 @@ services:
- "--auth-addr"
- "auth:40033"
- "--jfr-sidecar-addr"
- "jfr-sidecar:8081"
- "${JFR_SIDECAR_ADDR:-jfr-sidecar:8081}" # Can be switched for blue-green
- "--http-port"
- "8080"
# No external port - accessed via nginx at admin.eagle0.net
depends_on:
- eagle-blue
- auth
- jfr-sidecar
# Note: admin connects to eagle via EAGLE_ADDR and jfr-sidecar via JFR_SIDECAR_ADDR
# For blue-green deployments, set both in .env before switching
restart: unless-stopped
logging:
driver: "json-file"
@@ -239,6 +222,7 @@ services:
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
container_name: jfr-sidecar
# Share PID namespace with Eagle to access its JVM via jcmd
# For blue-green: use JFR_SIDECAR_ADDR=jfr-sidecar-green:8081 when green is active
pid: "service:eagle-blue"
volumes:
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
@@ -257,6 +241,29 @@ services:
retries: 3
start_period: 10s
jfr-sidecar-green:
image: ${JFR_SIDECAR_IMAGE:-registry.digitalocean.com/eagle0/jfr-sidecar:latest}
container_name: jfr-sidecar-green
profiles: ["blue-green"] # Only started during blue-green deployment
# Share PID namespace with Eagle green instance
pid: "service:eagle-green"
volumes:
- jvm-tmp:/tmp # Shared with Eagle for JVM attach socket files
depends_on:
- eagle-green
restart: "no" # Don't auto-restart during deployment
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "2"
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:8081/health || exit 1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
certbot:
image: certbot/certbot
container_name: certbot
+181
View File
@@ -0,0 +1,181 @@
# Tutorial Content Guide
This document defines all tutorial content. Edit this to refine the text, then update `TutorialContentDefinitions.cs` to match.
---
## Onboarding Sequence
Shown to first-time players. Guides them through the basics of strategic and tactical gameplay.
| Step | ID | Display | Trigger | Title | Description |
|------|-----|---------|---------|-------|-------------|
| 1 | `welcome` | Modal | Auto (game start) | Welcome to Eagle0 | Command your armies, recruit heroes, and conquer provinces in this turn-based strategy game.<br><br>Let's walk through the basics! |
| 2 | `select_province` | Overlay | Completes on: `province_selected` | The Strategic Map | This is your kingdom. Each colored region is a province.<br><br>Tap a province you control (shown in your color) to see what you can do there. |
| 3 | `province_panel` | Modal | Button click | Province Information | This panel shows province details: its name, terrain, any armies present, and the commands available to you.<br><br>Commands let you move troops, recruit heroes, and more. |
| 4 | `try_march` | Overlay | Completes on: `command_issued` | Issue a Command | Try issuing a March command to move your army to an adjacent province.<br><br>Select a destination and confirm the order. |
| 5 | `turn_cycle` | Modal | Button click | The Turn Cycle | Eagle0 uses simultaneous turns. All players give orders at the same time, then turns resolve together.<br><br>When all players are ready, the server processes everyone's commands and shows the results. |
| 6 | `wait_for_battle` | Hidden | Completes on: `first_battle_available` | *(none)* | *(Invisible step - waits for a battle to become available)* |
| 7 | `battle_intro` | Modal | Button click | Battle Time! | When armies collide, you'll fight tactical battles on a hex grid.<br><br>You command individual units - infantry, cavalry, archers, and heroes with special abilities. |
| 8 | `enter_battle` | Overlay | Completes on: `battle_entered` | Enter the Battle | Tap the Battle button to enter tactical combat. |
| 9 | `tactical_overview` | Modal | Button click | Tactical Combat | Each unit has movement points and attack power. Position your troops wisely!<br><br>Units attack adjacent enemies. Flanking (attacking from multiple sides) deals bonus damage. |
| 10 | `move_unit` | Overlay | Completes on: `battle_action` | Move Your Units | Tap one of your units to select it, then tap a highlighted hex to move there.<br><br>Blue hexes show where you can move. |
| 11 | `attack_enemy` | Overlay | Completes on: `battle_action` | Attack! | Move next to an enemy unit, then tap the enemy to attack.<br><br>Red highlights show valid attack targets. |
| 12 | `end_turn` | Overlay | Completes on: `turn_ended` | End Your Turn | When you've moved all units or want to pass, tap End Turn.<br><br>The enemy will then take their turn. |
| 13 | `complete` | Modal | Button click (no skip) | You're Ready! | You now know the basics of Eagle0!<br><br>Explore diplomacy, recruit powerful heroes, and conquer the realm. Good luck, commander! |
### Notes on Onboarding Flow
- Steps 1-5 cover strategic gameplay
- Step 6 is invisible - just waits for a battle
- Steps 7-12 cover tactical combat
- Step 13 celebrates completion
**Questions to consider:**
- Should we skip tactical tutorial if player skips to first battle themselves?
- Should there be a "skip all" option visible from step 1?
- Is the step order correct for typical first-game flow?
---
## Strategic Contextual Tutorials
Triggered when players encounter features for the first time.
### Diplomacy Introduction
| Field | Value |
|-------|-------|
| ID | `diplomacy_intro` |
| Trigger | `diplomacy_available` (diplomacy commands appear) |
| Display | Modal |
| Title | Diplomacy |
| Description | You can negotiate with other factions!<br><br>Offer alliances, declare war, or propose tribute. Your diplomatic choices shape the realm. |
### Hero Recruitment
| Field | Value |
|-------|-------|
| ID | `hero_recruitment` |
| Trigger | `hero_recruitment_available` (free heroes detected) |
| Display | Modal |
| Title | Heroes Available |
| Description | Free heroes wander the realm seeking a lord to serve.<br><br>Recruit them to lead your armies! Heroes have unique abilities and grow stronger with experience. |
### Weather Control
| Field | Value |
|-------|-------|
| ID | `weather_control` |
| Trigger | `weather_control_available` (weather command appears) |
| Display | Overlay |
| Title | Weather Magic |
| Description | Your mages can influence the weather!<br><br>Rain slows movement, storms disrupt enemies, and clear skies speed your march. |
### Prisoner Management
| Field | Value |
|-------|-------|
| ID | `prisoner_management` |
| Trigger | `prisoner_command_issued` (player uses prisoner command) |
| Display | Modal |
| Title | Prisoners Captured |
| Description | You've captured enemy soldiers!<br><br>You can ransom them for gold, recruit them into your army, or execute them as a warning. |
---
## Tactical Contextual Tutorials
Triggered during battles when players encounter spells, terrain, or abilities.
### Lightning Bolt Spell
| Field | Value |
|-------|-------|
| ID | `spell_lightning` |
| Trigger | `spell_lightning_available` |
| Display | Tooltip |
| Title | Lightning Bolt |
| Description | Your mage can cast Lightning Bolt!<br><br>This spell strikes a single target for heavy damage. Great for eliminating key enemy units. |
### Meteor Strike Spell
| Field | Value |
|-------|-------|
| ID | `spell_meteor` |
| Trigger | `spell_meteor_available` |
| Display | Modal |
| Title | Meteor Strike |
| Description | Meteor is a devastating area spell!<br><br>It takes a turn to cast: first select target, then it lands next turn. Plan ahead! |
### Holy Wave Spell
| Field | Value |
|-------|-------|
| ID | `spell_holywave` |
| Trigger | `spell_holywave_available` |
| Display | Tooltip |
| Title | Holy Wave |
| Description | Holy Wave heals your units and damages undead!<br><br>Position your troops carefully to maximize its effect. |
### Raise Dead Spell
| Field | Value |
|-------|-------|
| ID | `spell_raisedead` |
| Trigger | `spell_raisedead_available` |
| Display | Modal |
| Title | Raise Dead |
| Description | Dark magic can raise fallen soldiers as undead!<br><br>They fight for you, but beware - they may crumble if your necromancer falls. |
### Fire Terrain
| Field | Value |
|-------|-------|
| ID | `terrain_fire` |
| Trigger | `terrain_fire_encountered` (fire damage occurs) |
| Display | Tooltip |
| Title | Fire Hazard |
| Description | Fire spreads across the battlefield!<br><br>Units in burning hexes take damage. Use fire to block enemy routes or avoid it yourself. |
### Water Crossing
| Field | Value |
|-------|-------|
| ID | `terrain_water` |
| Trigger | `terrain_water_encountered` (water crossing attempted) |
| Display | Tooltip |
| Title | Water Crossing |
| Description | Units can cross shallow water, but it's risky.<br><br>Crossing takes extra movement and may fail. Some units swim better than others. |
### Cavalry Charge
| Field | Value |
|-------|-------|
| ID | `ability_charge` |
| Trigger | `ability_charge_available` |
| Display | Overlay |
| Title | Cavalry Charge |
| Description | Your cavalry can Charge!<br><br>Charging deals bonus damage based on distance traveled. Use open terrain for maximum impact. |
---
## Display Modes
| Mode | Description | Use For |
|------|-------------|---------|
| **Modal** | Full popup with dimmed background, blocks interaction | Important concepts, multi-paragraph explanations |
| **Overlay** | Semi-transparent overlay, can highlight UI elements | Guiding player to interact with specific UI |
| **Tooltip** | Small popup near target element | Quick tips, less important info |
| **Hint** | Pulsing dot indicator only | Subtle suggestions |
| **None** | Invisible, just waits for event | Transition steps |
---
## Adding New Tutorials
1. Add entry to this document
2. Update `TutorialContentDefinitions.cs`:
- For onboarding: add to `CreateOnboardingSequence()`
- For contextual: add to `RegisterStrategicTutorials()` or `RegisterTacticalTutorials()`
3. Ensure trigger event exists in `TutorialTriggerRegistry.cs`
4. Test the flow
---
## Content Guidelines
- Keep descriptions to 2-3 short paragraphs max
- Use `<br><br>` for paragraph breaks (renders as newlines in Unity)
- Avoid jargon - explain game terms when first introduced
- Be encouraging, not condescending
- Focus on "what to do" not exhaustive "how it works"
+8
View File
@@ -0,0 +1,8 @@
load("@build_bazel_rules_apple//apple:apple.bzl", "apple_dynamic_framework_import")
# Import pre-built Sparkle framework
apple_dynamic_framework_import(
name = "Sparkle",
framework_imports = glob(["Sparkle.framework/**"]),
visibility = ["//visibility:public"],
)
+15 -11
View File
@@ -3,6 +3,9 @@ events {
}
http {
# Allow large request bodies for game uploads (default is 1MB)
client_max_body_size 50M;
# Logging
log_format grpc_json escape=json '{'
'"time":"$time_iso8601",'
@@ -24,12 +27,12 @@ http {
# This prevents stale IP caching when containers restart
resolver 127.0.0.11 valid=10s ipv6=off;
# Upstream for Eagle gRPC server
# For blue-green deployments, this is switched between eagle-blue:40032 and eagle-green:40032
# by scripts/deploy-blue-green.sh, then nginx is reloaded with 'nginx -s reload'
upstream eagle_grpc {
server eagle-blue:40032;
keepalive 100;
# Eagle backend - blue-green deployment with variable-based routing
# Uses a variable so nginx only resolves the configured backend (not all backends).
# This allows nginx to start/reload even when the inactive backend is stopped.
# The deploy script updates this map, then recreates nginx.
map $host $eagle_backend {
default "eagle-blue:40032";
}
# HTTP server for Let's Encrypt challenge and redirect
@@ -73,8 +76,8 @@ http {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# gRPC proxy
grpc_pass grpc://eagle_grpc;
# gRPC proxy - uses variable for blue-green deployment
grpc_pass grpc://$eagle_backend;
# Timeouts for long-running streams
grpc_read_timeout 1200s;
@@ -85,13 +88,14 @@ http {
error_page 502 = /error502grpc;
}
# gRPC proxy for Auth service
# gRPC proxy for Auth service (routes to Go auth service, not Eagle)
location /net.eagle0.eagle.api.auth.Auth {
# Rate limiting
limit_req zone=grpc_limit burst=50 nodelay;
# gRPC proxy
grpc_pass grpc://eagle_grpc;
# Route to auth service directly (not through Eagle)
set $auth_backend "auth:40033";
grpc_pass grpc://$auth_backend;
# Timeouts
grpc_read_timeout 30s;
+3
View File
@@ -8,3 +8,6 @@ ZIP_LOCATION=$(bazel cquery --config=mactools --output=files @net_eagle0_unity_g
/usr/bin/unzip -o $ZIP_LOCATION -d src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/
/usr/bin/plutil -convert xml1 src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/DarwinGodiceBundle.bundle/Contents/Info.plist
/bin/echo "building sparkle plugin"
./scripts/build_sparkle_plugin.sh
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
#
# Build the SparklePlugin native library for Unity using Bazel
#
# Usage: build_sparkle_plugin.sh [output_dir]
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
OUTPUT_DIR="${1:-$PROJECT_ROOT/src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Plugins/macOS}"
echo "=== Building SparklePlugin with Bazel ==="
bazel build --config=mactools //src/main/objc/net/eagle0/sparkle:SparklePlugin
# Get the zip path from bazel
ZIP_PATH=$(bazel cquery --config=mactools --output=files //src/main/objc/net/eagle0/sparkle:SparklePlugin 2>/dev/null)
echo "=== Extracting SparklePlugin.bundle ==="
mkdir -p "$OUTPUT_DIR"
rm -rf "$OUTPUT_DIR/SparklePlugin.bundle"
unzip -o "$ZIP_PATH" -d "$OUTPUT_DIR/"
echo "=== SparklePlugin built successfully ==="
ls -la "$OUTPUT_DIR/SparklePlugin.bundle/"
+194 -58
View File
@@ -2,14 +2,21 @@
#
# Blue-Green Deployment Script for Eagle Server
#
# This script performs a zero-downtime deployment by:
# 1. Starting the new version on a staging port (green)
# 2. Waiting for it to become healthy
# 3. Running warmup traffic to pre-heat the JIT
# 4. Stopping the old version (blue) - which flushes state to disk
# 5. Telling green to reload games from disk
# 6. Switching nginx to route traffic to green
# 7. Cleaning up
# This script performs a zero-downtime deployment with state consistency:
# 1. Create .deployment_in_progress marker (signals deployment started)
# 2. Start the staging instance (green) with new image
# 3. Run warmup/smoke tests against staging (warms JIT)
# 4. Switch nginx to staging (zero downtime - users immediately route to staging)
# 5. Stop the active instance (blue) - blocks until flush completes
# 6. Create .flush_complete marker (signals disk state is fresh)
#
# The flush marker coordination ensures green never serves stale game data:
# - When users reconnect to green and trigger lazy-load, the code checks for markers
# - If .deployment_in_progress exists, lazy-load WAITS for .flush_complete
# - Once blue's flush completes and marker is created, lazy-load proceeds with fresh data
#
# Key insight: nginx switches to green BEFORE blue stops, achieving zero downtime.
# Users who trigger lazy-load during blue's shutdown will wait for the flush marker.
#
# Usage: ./deploy-blue-green.sh [NEW_IMAGE_TAG]
#
@@ -25,6 +32,9 @@ APP_DIR="${APP_DIR:-/opt/eagle0}"
NGINX_CONF="${APP_DIR}/nginx/nginx.conf"
COMPOSE_FILE="${APP_DIR}/docker-compose.prod.yml"
WARMUP_SCRIPT="${SCRIPT_DIR}/warmup-eagle.sh"
SAVES_DIR="${APP_DIR}/saves"
FLUSH_MARKER="${SAVES_DIR}/.flush_complete"
DEPLOYMENT_IN_PROGRESS="${SAVES_DIR}/.deployment_in_progress"
# Colors for output
RED='\033[0;31m'
@@ -36,15 +46,57 @@ log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
# Determine which instance is currently active
get_active_instance() {
if grep -q "eagle-blue:40032" "${NGINX_CONF}"; then
# Marker file operations use docker exec because saves directory is owned by root (Docker).
# We run commands inside a container that has the saves directory mounted.
create_deployment_marker() {
local deploy_id=$1
local container=$2 # Container to use for file operations
docker exec "${container}" rm -f /app/saves/.flush_complete
docker exec "${container}" sh -c "echo '${deploy_id}' > /app/saves/.deployment_in_progress"
}
create_flush_marker() {
local deploy_id=$1
local container=$2 # Container to use for file operations
docker exec "${container}" sh -c "echo '${deploy_id}' > /app/saves/.flush_complete"
docker exec "${container}" rm -f /app/saves/.deployment_in_progress
}
cleanup_markers_on_failure() {
local container=$1 # Container to use for file operations
docker exec "${container}" rm -f /app/saves/.deployment_in_progress 2>/dev/null || true
docker exec "${container}" touch /app/saves/.flush_complete 2>/dev/null || true
}
remove_stale_deployment_marker() {
# Try any running eagle container
local container
container=$(docker ps --filter "name=eagle-" --format "{{.Names}}" | head -1)
if [ -n "${container}" ]; then
docker exec "${container}" rm -f /app/saves/.deployment_in_progress 2>/dev/null || true
fi
}
# Determine which instance is currently running (not from nginx config)
get_running_instance() {
local blue_running green_running
blue_running=$(docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null || echo "false")
green_running=$(docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null || echo "false")
if [ "$blue_running" = "true" ] && [ "$green_running" = "true" ]; then
# Both running - use nginx config to determine primary
if grep -q "server eagle-blue:40032;" "${NGINX_CONF}" | head -1 | grep -qv backup; then
echo "blue"
else
echo "green"
fi
elif [ "$blue_running" = "true" ]; then
echo "blue"
elif grep -q "eagle-green:40032" "${NGINX_CONF}"; then
elif [ "$green_running" = "true" ]; then
echo "green"
else
log_error "Cannot determine active instance from nginx config"
exit 1
# Neither running - default to blue (first deploy or recovery)
echo "none"
fi
}
@@ -54,6 +106,12 @@ pull_with_retry() {
local max_attempts=${2:-3}
local attempt=1
# Skip pull if image already exists locally (e.g., CI already pulled it)
if docker image inspect "${image}" &>/dev/null; then
log_info "Image ${image} already exists locally, skipping pull"
return 0
fi
# Use crane if available (handles OCI format correctly)
if [ -x "${APP_DIR}/crane" ]; then
while [ $attempt -le $max_attempts ]; do
@@ -114,46 +172,63 @@ main() {
local registry="registry.digitalocean.com/eagle0/eagle-server"
local new_image="${registry}:${new_tag}"
# Generate deployment ID for log correlation with server logs
local deploy_id
deploy_id=$(date +%s)
local deploy_start_time=$deploy_id
log_info "========================================="
log_info "Starting blue-green deployment"
log_info "Deployment ID: ${deploy_id}"
log_info "New image: ${new_image}"
log_info "========================================="
cd "${APP_DIR}"
# Determine current active instance
local active=$(get_active_instance)
# Determine current active instance (need this before creating marker)
local active=$(get_running_instance)
local staging
if [ "$active" = "blue" ]; then
if [ "$active" = "blue" ] || [ "$active" = "none" ]; then
staging="green"
active="blue" # Normalize "none" to "blue" for first deploy
else
staging="blue"
fi
log_info "Active instance: eagle-${active}"
log_info "Staging instance: eagle-${staging}"
# Step 1: Signal deployment in progress
log_info "[DEPLOY:${deploy_id}] Step 1: Signaling deployment in progress..."
# Use active container for marker operations (it's the one currently running)
if [ "$active" != "none" ] && docker ps --filter "name=eagle-${active}" --format "{{.Names}}" | grep -q .; then
create_deployment_marker "${deploy_id}" "eagle-${active}"
log_info "[DEPLOY:${deploy_id}] Deployment marker created via eagle-${active}"
else
log_warn "[DEPLOY:${deploy_id}] No running container to create marker (first deploy?)"
fi
# Pull the new image (with retry for intermittent registry issues)
if ! pull_with_retry "${new_image}" 3; then
log_error "Failed to pull new image, aborting deployment"
cleanup_markers_on_failure "eagle-${active}"
exit 1
fi
# Start staging instance with new image
log_info "Starting eagle-${staging} with new image..."
# Step 2: Start staging instance with new image
log_info "Step 2: Starting eagle-${staging} with new image..."
if [ "$staging" = "green" ]; then
EAGLE_IMAGE_NEW="${new_image}" docker compose -f "${COMPOSE_FILE}" --profile blue-green up -d eagle-green
EAGLE_IMAGE_NEW="${new_image}" docker compose -f "${COMPOSE_FILE}" --profile blue-green up -d eagle-green jfr-sidecar-green
else
EAGLE_IMAGE="${new_image}" docker compose -f "${COMPOSE_FILE}" up -d eagle-blue
EAGLE_IMAGE="${new_image}" docker compose -f "${COMPOSE_FILE}" up -d eagle-blue jfr-sidecar
fi
# Wait for staging to be healthy
if ! wait_for_healthy "eagle-${staging}" 90; then
log_error "Staging instance failed health check, aborting deployment"
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
cleanup_markers_on_failure "eagle-${active}"
exit 1
fi
# Run warmup/smoke test
# Step 3: Run warmup/smoke test
local staging_port
if [ "$staging" = "green" ]; then
staging_port=40034
@@ -161,12 +236,12 @@ main() {
staging_port=40032
fi
log_info "Running warmup against eagle-${staging}..."
log_info "Step 3: Running warmup against eagle-${staging}..."
if [ -x "${WARMUP_SCRIPT}" ]; then
if ! "${WARMUP_SCRIPT}" "localhost:${staging_port}"; then
log_error "Warmup/smoke test failed, aborting deployment"
docker compose -f "${COMPOSE_FILE}" stop "eagle-${staging}"
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${staging}"
cleanup_markers_on_failure "eagle-${active}"
exit 1
fi
else
@@ -174,49 +249,98 @@ main() {
log_warn "JIT will be cold on first requests"
fi
# Stop the active instance (this flushes state to disk)
log_info "Stopping eagle-${active} (flushing state to disk)..."
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
# Step 4: Switch nginx to staging BEFORE stopping active
# This achieves zero downtime - users immediately route to staging.
# Any lazy-loads will wait for the flush marker (created in step 6).
local nginx_switch_start
nginx_switch_start=$(date +%s)
log_info "[DEPLOY:${deploy_id}] Step 4: Switching nginx to eagle-${staging}..."
# Tell staging to reload games from disk
log_info "Telling eagle-${staging} to reload games from disk..."
if command -v grpcurl &> /dev/null; then
grpcurl -plaintext -d '{}' "localhost:${staging_port}" net.eagle0.eagle.api.Eagle/ReloadGames || true
else
log_warn "grpcurl not installed, skipping game reload"
log_warn "New instance will use games loaded at startup"
fi
# Switch nginx upstream
log_info "Switching nginx upstream to eagle-${staging}..."
# Update nginx config (variable-based routing)
if [ "$staging" = "green" ]; then
sed -i.bak 's/eagle-blue:40032/eagle-green:40032/g' "${NGINX_CONF}"
else
sed -i.bak 's/eagle-green:40032/eagle-blue:40032/g' "${NGINX_CONF}"
fi
# Reload nginx
log_info "Reloading nginx..."
docker compose -f "${COMPOSE_FILE}" exec nginx nginx -s reload
# Recreate nginx to pick up new config
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate nginx
# Clean up old instance
log_info "Removing old eagle-${active} container..."
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}"
# Update the staging instance's restart policy and image var
# For blue, we need to update EAGLE_IMAGE; for green, update EAGLE_IMAGE_NEW
if [ "$staging" = "blue" ]; then
log_info "Updating EAGLE_IMAGE to ${new_image} for future restarts"
# User should update their .env file
# Verify nginx picked up the correct config
local nginx_backend
nginx_backend=$(docker exec nginx grep -o 'eagle-[a-z]*:40032' /etc/nginx/nginx.conf | head -1 || echo "unknown")
if [ "$nginx_backend" = "eagle-${staging}:40032" ]; then
log_info "[DEPLOY:${deploy_id}] Verified: nginx routing to eagle-${staging}"
else
log_info "Green is now active. Consider switching to blue on next deployment."
log_error "[DEPLOY:${deploy_id}] nginx config mismatch! Expected eagle-${staging}:40032, got ${nginx_backend}"
exit 1
fi
log_info "[DEPLOY:${deploy_id}] Traffic switched to eagle-${staging} (lazy-loads will wait for flush marker)"
local nginx_switch_end
nginx_switch_end=$(date +%s)
# Step 5: Stop active instance (blocks until exit, ensuring flush completes)
# Users may be lazy-loading on staging during this time - they'll wait for the marker.
local flush_start
flush_start=$(date +%s)
log_info "[DEPLOY:${deploy_id}] Step 5: Stopping eagle-${active} (waiting for flush)..."
docker compose -f "${COMPOSE_FILE}" stop "eagle-${active}"
local flush_end
flush_end=$(date +%s)
local flush_duration=$((flush_end - flush_start))
log_info "[DEPLOY:${deploy_id}] eagle-${active} stopped, flush completed in ${flush_duration}s"
# Step 6: Create flush marker - signals that disk state is fresh
# Any waiting lazy-loads on staging will now proceed with fresh data.
# The Eagle server automatically detects the flush marker update and invalidates any stale cached games.
log_info "[DEPLOY:${deploy_id}] Step 6: Creating flush marker..."
create_flush_marker "${deploy_id}" "eagle-${staging}"
log_info "[DEPLOY:${deploy_id}] Flush marker created - server will auto-invalidate stale cache"
# Update .env for admin service
local env_file="${APP_DIR}/.env"
if [ "$staging" = "green" ]; then
log_info "Updating .env for green instance..."
sed -i.bak '/^EAGLE_ADDR=/d; /^JFR_SIDECAR_ADDR=/d' "${env_file}" 2>/dev/null || true
echo "EAGLE_ADDR=eagle-green:40032" >> "${env_file}"
echo "JFR_SIDECAR_ADDR=jfr-sidecar-green:8081" >> "${env_file}"
else
log_info "Updating .env for blue instance..."
sed -i.bak '/^EAGLE_ADDR=/d; /^JFR_SIDECAR_ADDR=/d' "${env_file}" 2>/dev/null || true
echo "EAGLE_ADDR=eagle-blue:40032" >> "${env_file}"
echo "JFR_SIDECAR_ADDR=jfr-sidecar:8081" >> "${env_file}"
fi
log_info "Deployment complete!"
log_info "Active instance: eagle-${staging}"
# Restart admin to pick up new .env
log_info "Restarting admin service..."
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate admin
# Clean up old instance
log_info "Cleaning up old eagle-${active}..."
docker compose -f "${COMPOSE_FILE}" rm -f "eagle-${active}" 2>/dev/null || true
# Stop the old jfr-sidecar (it can't attach to removed container anyway)
if [ "$active" = "green" ]; then
docker compose -f "${COMPOSE_FILE}" stop "jfr-sidecar-green" 2>/dev/null || true
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar-green" 2>/dev/null || true
else
docker compose -f "${COMPOSE_FILE}" stop "jfr-sidecar" 2>/dev/null || true
docker compose -f "${COMPOSE_FILE}" rm -f "jfr-sidecar" 2>/dev/null || true
fi
local deploy_end_time
deploy_end_time=$(date +%s)
local total_duration=$((deploy_end_time - deploy_start_time))
local user_wait_window=$((flush_end - nginx_switch_end))
log_info ""
log_info "Note: Update your .env file with EAGLE_IMAGE=${new_image}"
log_info " if you want future 'docker compose up' to use this version."
log_info "========================================="
log_info "[DEPLOY:${deploy_id}] Deployment complete!"
log_info " Active instance: eagle-${staging}"
log_info " Total duration: ${total_duration}s"
log_info " Flush duration: ${flush_duration}s"
log_info " Max user wait window: ${user_wait_window}s"
log_info "========================================="
}
# Check for required tools
@@ -240,6 +364,18 @@ check_requirements() {
log_error "docker-compose file not found at ${COMPOSE_FILE}"
exit 1
fi
# Ensure saves directory exists
if [ ! -d "${SAVES_DIR}" ]; then
log_info "Creating saves directory at ${SAVES_DIR}"
mkdir -p "${SAVES_DIR}"
fi
# Clean up any stale deployment-in-progress marker from a previous failed deploy
if [ -f "${DEPLOYMENT_IN_PROGRESS}" ]; then
log_warn "Found stale deployment-in-progress marker, removing it"
remove_stale_deployment_marker
fi
}
# Run
+41
View File
@@ -0,0 +1,41 @@
// +build ignore
// Script to generate Ed25519 key pair for manifest signing.
// Run with: go run scripts/generate_manifest_keys.go
//
// This will output:
// - Private key (base64): Store as MANIFEST_SIGNING_KEY GitHub secret
// - Public key (base64): Embed in EagleInstaller for verification
package main
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"fmt"
"log"
)
func main() {
// Generate Ed25519 key pair
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
log.Fatalf("Failed to generate key pair: %v", err)
}
// Encode keys as base64
privateKeyB64 := base64.StdEncoding.EncodeToString(privateKey)
publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey)
fmt.Println("=== Ed25519 Key Pair for Manifest Signing ===")
fmt.Println()
fmt.Println("PRIVATE KEY (store as GitHub secret MANIFEST_SIGNING_KEY):")
fmt.Println(privateKeyB64)
fmt.Println()
fmt.Println("PUBLIC KEY (embed in EagleInstaller.cs for verification):")
fmt.Println(publicKeyB64)
fmt.Println()
fmt.Printf("Private key size: %d bytes\n", len(privateKey))
fmt.Printf("Public key size: %d bytes\n", len(publicKey))
}
+39 -5
View File
@@ -31,17 +31,51 @@ echo "=== Creating ZIP for notarization: $ZIP_PATH ==="
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
echo "=== Submitting to Apple for notarization ==="
xcrun notarytool submit "$ZIP_PATH" \
SUBMIT_OUTPUT=$(xcrun notarytool submit "$ZIP_PATH" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID" \
--wait
--wait 2>&1) || true
# Clean up the zip
rm "$ZIP_PATH"
echo "$SUBMIT_OUTPUT"
# Extract submission ID and status (look for " status:" to avoid matching "Current status:")
SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | grep "id:" | head -1 | awk '{print $2}')
STATUS=$(echo "$SUBMIT_OUTPUT" | grep "^ status:" | awk '{print $2}')
echo "Submission ID: $SUBMISSION_ID"
echo "Status: $STATUS"
# Clean up the zip (use -f to avoid failure if already deleted)
rm -f "$ZIP_PATH"
if [ "$STATUS" != "Accepted" ]; then
echo "=== Notarization failed! Fetching log for details ==="
xcrun notarytool log "$SUBMISSION_ID" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID"
exit 1
fi
echo "=== Stapling notarization ticket to app ==="
xcrun stapler staple "$APP_PATH"
# Retry stapling - Apple's CloudKit can have a brief delay after notarization completes
MAX_STAPLE_ATTEMPTS=5
STAPLE_ATTEMPT=1
while [ $STAPLE_ATTEMPT -le $MAX_STAPLE_ATTEMPTS ]; do
echo "Stapling attempt $STAPLE_ATTEMPT/$MAX_STAPLE_ATTEMPTS..."
if xcrun stapler staple "$APP_PATH"; then
echo "Stapling successful"
break
fi
if [ $STAPLE_ATTEMPT -eq $MAX_STAPLE_ATTEMPTS ]; then
echo "ERROR: Stapling failed after $MAX_STAPLE_ATTEMPTS attempts"
exit 1
fi
echo "Stapling failed, waiting 10 seconds before retry..."
sleep 10
STAPLE_ATTEMPT=$((STAPLE_ATTEMPT + 1))
done
echo "=== Verifying notarization ==="
xcrun stapler validate "$APP_PATH"
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
#
# Submit a macOS .app bundle to Apple for notarization (no waiting)
# Usage: notarize_submit.sh <app_path>
# Outputs: submission_id=<id> to stdout (for GitHub Actions)
#
# Environment variables (required):
# APPLE_ID - Apple Developer account email
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
# TEAM_ID - Apple Developer Team ID
set -euo pipefail
APP_PATH="$1"
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH" >&2
exit 1
fi
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
echo "ERROR: Required environment variables not set" >&2
echo " APPLE_ID: ${APPLE_ID:-<not set>}" >&2
echo " APP_SPECIFIC_PASSWORD: ${APP_SPECIFIC_PASSWORD:+<set>}" >&2
echo " TEAM_ID: ${TEAM_ID:-<not set>}" >&2
exit 1
fi
# Create ZIP for notarization submission
ZIP_PATH="${APP_PATH%.app}.zip"
echo "=== Creating ZIP for notarization: $ZIP_PATH ===" >&2
ditto -c -k --keepParent "$APP_PATH" "$ZIP_PATH"
echo "=== Submitting to Apple for notarization ===" >&2
SUBMIT_OUTPUT=$(xcrun notarytool submit "$ZIP_PATH" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID" 2>&1)
echo "$SUBMIT_OUTPUT" >&2
# Extract submission ID
SUBMISSION_ID=$(echo "$SUBMIT_OUTPUT" | grep "id:" | head -1 | awk '{print $2}')
if [ -z "$SUBMISSION_ID" ]; then
echo "ERROR: Failed to get submission ID" >&2
exit 1
fi
# Clean up the zip
rm "$ZIP_PATH"
echo "Submission ID: $SUBMISSION_ID" >&2
# Output for GitHub Actions
echo "submission_id=$SUBMISSION_ID"
+76
View File
@@ -0,0 +1,76 @@
#!/usr/bin/env bash
#
# Wait for Apple notarization to complete and staple the ticket
# Usage: notarize_wait.sh <submission_id> <app_path>
#
# Environment variables (required):
# APPLE_ID - Apple Developer account email
# APP_SPECIFIC_PASSWORD - App-specific password for notarytool
# TEAM_ID - Apple Developer Team ID
set -euo pipefail
SUBMISSION_ID="$1"
APP_PATH="$2"
if [ -z "$SUBMISSION_ID" ]; then
echo "ERROR: submission_id is required" >&2
exit 1
fi
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH" >&2
exit 1
fi
if [ -z "${APPLE_ID:-}" ] || [ -z "${APP_SPECIFIC_PASSWORD:-}" ] || [ -z "${TEAM_ID:-}" ]; then
echo "ERROR: Required environment variables not set" >&2
exit 1
fi
echo "=== Waiting for notarization of submission $SUBMISSION_ID ==="
WAIT_OUTPUT=$(xcrun notarytool wait "$SUBMISSION_ID" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID" 2>&1) || true
echo "$WAIT_OUTPUT"
# Extract status (look for " status:" to avoid matching "Current status:")
STATUS=$(echo "$WAIT_OUTPUT" | grep "^ status:" | awk '{print $2}')
echo "Status: $STATUS"
if [ "$STATUS" != "Accepted" ]; then
echo "=== Notarization failed! Fetching log for details ==="
xcrun notarytool log "$SUBMISSION_ID" \
--apple-id "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" \
--team-id "$TEAM_ID"
exit 1
fi
echo "=== Stapling notarization ticket to app ==="
# Retry stapling - Apple's CloudKit can have a brief delay after notarization completes
MAX_STAPLE_ATTEMPTS=5
STAPLE_ATTEMPT=1
while [ $STAPLE_ATTEMPT -le $MAX_STAPLE_ATTEMPTS ]; do
echo "Stapling attempt $STAPLE_ATTEMPT/$MAX_STAPLE_ATTEMPTS..."
if xcrun stapler staple "$APP_PATH"; then
echo "Stapling successful"
break
fi
if [ $STAPLE_ATTEMPT -eq $MAX_STAPLE_ATTEMPTS ]; then
echo "ERROR: Stapling failed after $MAX_STAPLE_ATTEMPTS attempts"
exit 1
fi
echo "Stapling failed, waiting 10 seconds before retry..."
sleep 10
STAPLE_ATTEMPT=$((STAPLE_ATTEMPT + 1))
done
echo "=== Verifying notarization ==="
xcrun stapler validate "$APP_PATH"
spctl --assess --type exec -v "$APP_PATH"
echo "Notarization complete: $APP_PATH"
+2 -1
View File
@@ -65,7 +65,8 @@ fi
# If we found the Go tool, use it
if [ -n "${WARMUP_TOOL}" ]; then
log_info "Using Go warmup tool: ${WARMUP_TOOL}"
if "${WARMUP_TOOL}" --address="${HOST}" --timeout=60s; then
# Use 5 minute timeout to allow for slow operations on cold JVM
if "${WARMUP_TOOL}" --address="${HOST}" --timeout=300s; then
log_info "Warmup complete!"
exit 0
else
@@ -20,8 +20,9 @@ auto UnitIdsRequiringWaterCrossing(
fb::HexMapW mapCopy = fb::CopyHexMap(gameState->hex_map());
for (uint32_t index = 0; index < mapCopy->terrain()->size(); index++) {
if (IsWater(mapCopy->terrain()->Get(index)->type())) continue;
mapCopy->mutable_terrain()
->GetMutableObject(index)
// const_cast is safe because we own the mutable buffer (mapCopy)
const_cast<net::eagle0::shardok::storage::fb::Terrain *>(
mapCopy->mutable_terrain()->GetMutableObject(index))
->mutable_modifier()
.mutable_fire()
.mutate_present(false);
@@ -164,16 +165,11 @@ auto WaterCrossingTiles(
if (modifier.ice().present() && !modifier.fire().present()) continue;
// Now try adding a bridge to the tile to see if it helps
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_bridge()
.mutate_present(true);
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_fire()
.mutate_present(false);
// const_cast is safe because we own the mutable buffer (mapCopy)
auto *terr = const_cast<net::eagle0::shardok::storage::fb::Terrain *>(
mapCopy->mutable_terrain()->GetMutableObject(index));
terr->mutable_modifier().mutable_bridge().mutate_present(true);
terr->mutable_modifier().mutable_fire().mutate_present(false);
auto hash = ActionPointDistancesCache::GetMapId(mapCopy);
@@ -183,11 +179,7 @@ auto WaterCrossingTiles(
}
// Undo the new bridge for the next iteration of the loop
mapCopy->mutable_terrain()
->GetMutableObject(index)
->mutable_modifier()
.mutable_bridge()
.mutate_present(false);
terr->mutable_modifier().mutable_bridge().mutate_present(false);
}
return returnCoords;
@@ -76,11 +76,13 @@ static auto CreateIceClearedMap(const HexMap* map) -> fb::HexMapW {
// Now modify the ice on the mutable copy
auto* mutableMap = mapCopy.Get();
const auto* terrainVec = mutableMap->mutable_terrain();
auto* terrainVec = mutableMap->mutable_terrain();
for (size_t i = 0; i < terrainVec->size(); i++) {
// Only process tiles with ice
if (auto* terrain = terrainVec->GetMutableObject(i); terrain->modifier().ice().present()) {
// const_cast is safe here because we own the mutable buffer (mapCopy)
if (auto* terrain = const_cast<Terrain*>(terrainVec->GetMutableObject(i));
terrain->modifier().ice().present()) {
terrain->mutable_modifier().mutable_ice().mutate_present(false);
terrain->mutable_modifier().mutable_ice().mutate_integrity(0.0f);
}
@@ -22,6 +22,13 @@ using std::unique_ptr;
using ResolvedUnitProto = net::eagle0::shardok::storage::ResolvedUnit;
using GameState = net::eagle0::shardok::storage::fb::GameState;
using GameStateT = net::eagle0::shardok::storage::fb::GameStateT;
using Unit = net::eagle0::shardok::storage::fb::Unit;
// Helper to get a mutable unit from the units vector.
// const_cast is safe because we're accessing through a mutable GameState pointer.
inline auto GetMutableUnit(GameState *state, UnitId unitId) -> Unit * {
return const_cast<Unit *>(state->mutable_units()->GetMutableObject(unitId));
}
using net::eagle0::shardok::storage::fb::DrawType;
using net::eagle0::shardok::storage::fb::VictoryCondition;
using net::eagle0::shardok::storage::fb::VictoryType;
@@ -37,7 +44,7 @@ void ApplyResolvedUnit(
if (unit.has_attached_hero() &&
unit.attached_hero().control_info().controlled_unit_id() != -1) {
const UnitId controlledUnitId = unit.attached_hero().control_info().controlled_unit_id();
auto *controlledUnit = inoutState->mutable_units()->GetMutableObject(controlledUnitId);
auto *controlledUnit = GetMutableUnit(inoutState, controlledUnitId);
internalAssert(controlledUnit->unit_id() == controlledUnitId);
internalAssert(controlledUnit->commanding_unit_id() == unitId);
controlledUnit->mutate_commanding_unit_id(-1);
@@ -47,7 +54,7 @@ void ApplyResolvedUnit(
if (unit.commanding_unit_id() != -1) {
const UnitId commandingUnitId = unit.commanding_unit_id();
auto *commandingUnit = inoutState->mutable_units()->GetMutableObject(commandingUnitId);
auto *commandingUnit = GetMutableUnit(inoutState, commandingUnitId);
internalAssert(commandingUnit->unit_id() == commandingUnitId);
internalAssert(
commandingUnit->attached_hero().control_info().controlled_unit_id() == unitId);
@@ -58,7 +65,7 @@ void ApplyResolvedUnit(
if (status == net::eagle0::shardok::storage::fb::UnitStatus_CAPTURED_UNIT &&
unit.has_attached_hero() && unit.attached_hero().is_vip()) {
for (uint32_t i = 0; i < inoutState->units()->size(); i++) {
auto *playerUnit = inoutState->mutable_units()->GetMutableObject(i);
auto *playerUnit = GetMutableUnit(inoutState, i);
if (playerUnit->player_id() != unit.player_id()) continue;
if (playerUnit->unit_id() == unit.unit_id()) continue;
@@ -70,7 +77,7 @@ void ApplyResolvedUnit(
}
}
inoutState->mutable_units()->GetMutableObject(unitId)->mutate_status(status);
GetMutableUnit(inoutState, unitId)->mutate_status(status);
}
void ApplyResolvedUnit(
@@ -189,7 +196,7 @@ void MutatingAddUnits(GameStateW &mutatingState, const ActionResultProto &result
// We only need to process the units that are being changed
for (const auto &unitBytes : result.changed_units_fb()) {
const auto *unit = (Unit *)unitBytes.data();
auto *mutableUnit = mutatingState->mutable_units()->GetMutableObject(unit->unit_id());
auto *mutableUnit = GetMutableUnit(mutatingState.Get(), unit->unit_id());
if (mutableUnit->status() ==
net::eagle0::shardok::storage::fb::UnitStatus_RESERVED_SLOT) {
// Convert this reserved slot to a real unit
@@ -340,7 +347,7 @@ void MutatingApplyResult(
}
// Capture old position before applying changes
auto *mutableUnit = mutatingGameState->units()->GetMutableObject(changedUnit->unit_id());
auto *mutableUnit = GetMutableUnit(mutatingGameState.Get(), changedUnit->unit_id());
const auto oldLocation = mutableUnit->location();
fb::ApplyUnit(mutableUnit, changedUnit, status);
@@ -191,6 +191,7 @@ auto MoveCommand::GetCommandProto() const -> CommandProto {
proto.mutable_action_points()->set_value(pointCost);
proto.mutable_actor()->set_value(moverId);
*proto.mutable_target() = ToCoordsProto(interimTargets.back());
for (const auto& coords : interimTargets) { *proto.add_path() = ToCoordsProto(coords); }
for (const auto& fup : followUpCommandTypes) { proto.add_follow_up_command_types(fup); }
proto.set_will_unhide(willUnhide);
@@ -64,8 +64,16 @@ auto GetTerrain(const HexMap *map, const Coords &coords) -> const Terrain * {
return map->terrain()->Get(coords.row() * map->column_count() + coords.column());
}
auto GetMutableTerrain(HexMap *map, const Coords &coords) -> Terrain * {
// const_cast is safe because we're accessing through a mutable HexMap pointer
return const_cast<Terrain *>(map->mutable_terrain()->GetMutableObject(
coords.row() * map->column_count() + coords.column()));
}
auto GetMutableTerrain(const HexMap *map, const Coords &coords) -> Terrain * {
return map->terrain()->GetMutableObject(coords.row() * map->column_count() + coords.column());
// const_cast is safe when the underlying buffer is known to be mutable
return const_cast<Terrain *>(
map->terrain()->Get(coords.row() * map->column_count() + coords.column()));
}
auto HasForestAccess(
@@ -597,7 +605,9 @@ void MutatingSetTileModifier(
const int row,
const int column,
const TileModifierProto &TileModifierProto) {
auto *terr = hexMap->mutable_terrain()->GetMutableObject(row * hexMap->column_count() + column);
// const_cast is safe because we're accessing through a mutable HexMap pointer
auto *terr = const_cast<Terrain *>(
hexMap->mutable_terrain()->GetMutableObject(row * hexMap->column_count() + column));
if (TileModifierProto.has_bridge()) {
terr->mutable_modifier().mutable_bridge().mutate_present(true);
@@ -82,6 +82,9 @@ auto HasForestAccess(
PlayerId player) -> bool;
auto GetTerrain(const HexMap *map, const Coords &coords) -> const Terrain *;
auto GetMutableTerrain(HexMap *map, const Coords &coords) -> Terrain *;
// Overload for const HexMap - uses const_cast internally. Safe when the underlying buffer is
// mutable.
auto GetMutableTerrain(const HexMap *map, const Coords &coords) -> Terrain *;
auto CoordsAreValid(const HexMap *map, const Coords &coords) -> bool;
@@ -29,6 +29,13 @@ using UnitViewProto = net::eagle0::shardok::api::UnitView;
using Coords = net::eagle0::shardok::storage::fb::Coords;
using Unit = net::eagle0::shardok::storage::fb::Unit;
using GameState = net::eagle0::shardok::storage::fb::GameState;
// Helper to get a mutable unit from the units vector.
// const_cast is safe because we're accessing through a mutable GameState pointer.
inline auto GetMutableUnit(GameState *state, UnitId unitId) -> Unit * {
return const_cast<Unit *>(state->mutable_units()->GetMutableObject(unitId));
}
constexpr int8_t kGuessedHeroStat = 75;
constexpr int8_t kGuessedBattalionStat = 0;
@@ -412,16 +419,13 @@ auto GameStateGuesser::GuessedState(
if (unit->has_attached_hero()) {
UnitId controlledUnitId = unit->attached_hero().control_info().controlled_unit_id();
if (controlledUnitId != -1) {
gsw->mutable_units()
->GetMutableObject(controlledUnitId)
->mutate_commanding_unit_id(unitId);
GetMutableUnit(gsw.Get(), controlledUnitId)->mutate_commanding_unit_id(unitId);
}
}
UnitId commandingUnitId = unit->commanding_unit_id();
if (unit->commanding_unit_id() != -1) {
gsw->mutable_units()
->GetMutableObject(commandingUnitId)
GetMutableUnit(gsw.Get(), commandingUnitId)
->mutable_attached_hero()
.mutable_control_info()
.mutate_controlled_unit_id(unitId);
@@ -126,6 +126,7 @@
<Compile Include="Assets/Modern UI Pack/Scripts/Notification/NotificationManagerEditor.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicEditor.cs" />
<Compile Include="Assets/ConnectionHandler/RunningGameItem.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialCanvasBuilder.cs" />
<Compile Include="Assets/Shardok/Table Rows/ArmyRowController.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/PrisonerExchangeDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/Notifications/Notification.cs" />
@@ -166,6 +167,7 @@
<Compile Include="Assets/Eagle/Table Rows/ExtraTroopsRowController.cs" />
<Compile Include="Assets/Auth/JwtAuthInterceptor.cs" />
<Compile Include="Assets/Eagle/CommandPanelController.cs" />
<Compile Include="Assets/Tutorial/Content/TutorialContentDefinitions.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/ShatteredArmyDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/TurnHistoryPanelController.cs" />
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/HeroDepartureDetailsNotificationGenerator.cs" />
@@ -206,6 +208,7 @@
<Compile Include="Assets/Eagle/Notifications/ARNNotifications/RansomRejectedDetailsNotificationGenerator.cs" />
<Compile Include="Assets/Eagle/FactionsTableController.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Slider/RangeSlider.cs" />
<Compile Include="Assets/Tutorial/UI/TutorialOverlayBuilder.cs" />
<Compile Include="Assets/Eagle/CommandSelectors/ExileVassalCommandSelector.cs" />
<Compile Include="Assets/Tutorial/TutorialState.cs" />
<Compile Include="Assets/Modern UI Pack/Scripts/Button/ButtonManagerBasicWithIconEditor.cs" />
@@ -385,6 +388,7 @@
<Compile Include="Assets/Eagle/Table Rows/IncomingArmyTableRow.cs" />
<Compile Include="Assets/ConnectionHandler/CreateGameItem.cs" />
<Compile Include="Assets/Eagle/EagleGameController.cs" />
<Compile Include="Assets/Tutorial/TutorialTestSetup.cs" />
<Compile Include="Assets/Eagle/Notifications/FailedSwearBrotherhoodNotificationGenerator.cs" />
<Compile Include="Assets/Shardok/FireEffectAnimator.cs" />
<Compile Include="Assets/common/GUIUtils/EventBasedTable.cs" />
@@ -109,6 +109,10 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
[Header("Status Display")]
public TextMeshProUGUI connectionStatusText;
[Header("Connection Panel Environment")]
[Tooltip("Environment dropdown in connection panel (fallback if lobby unreachable)")]
public TMP_Dropdown connectionEnvironmentDropdown;
public GameObject connectionPanel;
public GameObject gameSelectionPanel;
public GameObject customBattlePanel;
@@ -217,6 +221,9 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Initialize OAuth UI
SetupOAuthUI();
// Initialize connection panel environment dropdown
SetupConnectionEnvironmentDropdown();
// Initialize Lobby UI (logout button, etc.)
SetupLobbyUI();
@@ -227,6 +234,22 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
TryRestoreSession();
}
private void SetupConnectionEnvironmentDropdown() {
if (connectionEnvironmentDropdown == null) return;
connectionEnvironmentDropdown.ClearOptions();
connectionEnvironmentDropdown.AddOptions(EnvironmentDisplayNames);
connectionEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
connectionEnvironmentDropdown.onValueChanged.AddListener(OnConnectionEnvironmentChanged);
}
private void OnConnectionEnvironmentChanged(int newEnvironmentIndex) {
// Just save the preference - it will be used on next connection attempt
PlayerPrefs.SetInt(EnvironmentKey, newEnvironmentIndex);
Debug.Log(
$"[ConnectionHandler] Environment changed to {EnvironmentDisplayNames[newEnvironmentIndex]}");
}
private void SetupOAuthUI() {
// Set up OAuth button click handlers for new sign-ins
if (discordLoginButton != null) {
@@ -264,6 +287,15 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
// Show OAuth panel
if (oauthPanel != null) oauthPanel.SetActive(true);
// Sync connection environment dropdown with saved preference
if (connectionEnvironmentDropdown != null) {
connectionEnvironmentDropdown.onValueChanged.RemoveListener(
OnConnectionEnvironmentChanged);
connectionEnvironmentDropdown.value = PlayerPrefs.GetInt(EnvironmentKey, 0);
connectionEnvironmentDropdown.onValueChanged.AddListener(
OnConnectionEnvironmentChanged);
}
// Refresh stored account buttons
RefreshStoredAccountButtons();
}
@@ -682,7 +714,10 @@ public class ConnectionHandler : MonoBehaviour, ILobbySubscriber, IDisposable {
true);
listItem.transform.localScale = new Vector3 { x = 1.0f, y = 1.0f, z = 1.0f };
var runningGameItem = listItem.GetComponent<RunningGameItem>();
runningGameItem.SetRunningGame(runningGame.GameId, runningGame.Leader);
runningGameItem.SetRunningGame(
runningGame.GameId,
runningGame.Leader,
runningGame.LastPlayedTimestampMillis);
runningGameItem.GoCallback = this.SelectEagleGame;
runningGameItem.DropCallback = this.DropGame;
}
@@ -1,5 +1,164 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &246340277068347999
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 5589171621630283209}
- component: {fileID: 9020655488278447847}
- component: {fileID: 5482253986454480250}
- component: {fileID: 6109349435209553009}
m_Layer: 5
m_Name: Last Played
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &5589171621630283209
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 246340277068347999}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 909271087739711220}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &9020655488278447847
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 246340277068347999}
m_CullTransparentMesh: 0
--- !u!114 &5482253986454480250
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 246340277068347999}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: Tars Tarkas
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4294967295
m_fontColor: {r: 1, g: 1, b: 1, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 24
m_fontSizeBase: 24
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 1
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 1
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!114 &6109349435209553009
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 246340277068347999}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 100
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &649403511955802327
GameObject:
m_ObjectHideFlags: 0
@@ -94,8 +253,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 30
m_fontSizeBase: 30
m_fontSize: 24
m_fontSizeBase: 24
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -153,9 +312,9 @@ MonoBehaviour:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 450
m_MinHeight: 60
m_MinHeight: -1
m_PreferredWidth: 450
m_PreferredHeight: 60
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -253,8 +412,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 36
m_fontSizeBase: 36
m_fontSize: 24
m_fontSizeBase: 24
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -314,7 +473,7 @@ MonoBehaviour:
m_MinWidth: 200
m_MinHeight: 35
m_PreferredWidth: -1
m_PreferredHeight: 35
m_PreferredHeight: -1
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -521,10 +680,10 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 0
m_MinHeight: -1
m_PreferredWidth: 50
m_PreferredHeight: -1
m_MinWidth: 40
m_MinHeight: 40
m_PreferredWidth: 40
m_PreferredHeight: 40
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -621,8 +780,8 @@ MonoBehaviour:
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 36
m_fontSizeBase: 36
m_fontSize: 24
m_fontSizeBase: 24
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
@@ -885,9 +1044,9 @@ MonoBehaviour:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 200
m_MinHeight: -1
m_MinHeight: 40
m_PreferredWidth: 200
m_PreferredHeight: -1
m_PreferredHeight: 40
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
@@ -926,6 +1085,7 @@ RectTransform:
m_Children:
- {fileID: 2008335870574158510}
- {fileID: 6674238761151365000}
- {fileID: 5589171621630283209}
- {fileID: 1348356180262758599}
- {fileID: 7336928442537586311}
- {fileID: 7341333336313446738}
@@ -994,7 +1154,7 @@ MonoBehaviour:
m_ChildAlignment: 0
m_Spacing: 0
m_ChildForceExpandWidth: 0
m_ChildForceExpandHeight: 1
m_ChildForceExpandHeight: 0
m_ChildControlWidth: 1
m_ChildControlHeight: 1
m_ChildScaleWidth: 0
@@ -1015,6 +1175,7 @@ MonoBehaviour:
item: {fileID: 5643565463360785033}
gameIdField: {fileID: 8058295588038032232}
leaderField: {fileID: 3311450659768657245}
lastPlayedField: {fileID: 5482253986454480250}
goButton: {fileID: 145214844678457394}
dropButton: {fileID: 4081637582368108930}
--- !u!114 &2208043217657811503
@@ -1033,9 +1194,9 @@ MonoBehaviour:
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_PreferredHeight: 45
m_FlexibleWidth: 1
m_FlexibleHeight: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &7814378479006347708
GameObject:
@@ -9,6 +9,7 @@ public class RunningGameItem : MonoBehaviour {
public GameObject item;
public TextMeshProUGUI gameIdField;
public TextMeshProUGUI leaderField;
public TextMeshProUGUI lastPlayedField;
public Button goButton;
public Button dropButton;
@@ -24,7 +25,8 @@ public class RunningGameItem : MonoBehaviour {
public void DropClicked() { DropCallback?.Invoke(gameId); }
public void SetRunningGame(long gameId, AvailableLeader leader) {
public void
SetRunningGame(long gameId, AvailableLeader leader, long lastPlayedTimestampMillis) {
this.gameId = gameId;
gameIdField.text = string.Format("{0:X}", gameId);
@@ -34,5 +36,31 @@ public class RunningGameItem : MonoBehaviour {
"{0} ({1})",
leaderName,
DisplayNames.ProfessionNames[leader.Profession]);
// Display last played time in user's local timezone
if (lastPlayedField != null) {
if (lastPlayedTimestampMillis > 0) {
var lastPlayed = DateTimeOffset.FromUnixTimeMilliseconds(lastPlayedTimestampMillis)
.LocalDateTime;
var now = DateTime.Now;
var diff = now - lastPlayed;
string timeText;
if (diff.TotalMinutes < 1) {
timeText = "Just now";
} else if (diff.TotalHours < 1) {
timeText = $"{(int)diff.TotalMinutes}m ago";
} else if (diff.TotalDays < 1) {
timeText = $"{(int)diff.TotalHours}h ago";
} else if (diff.TotalDays < 7) {
timeText = $"{(int)diff.TotalDays}d ago";
} else {
timeText = lastPlayed.ToString("MMM d");
}
lastPlayedField.text = timeText;
} else {
lastPlayedField.text = "";
}
}
}
}
@@ -299,6 +299,36 @@ namespace eagle {
}
}
/// <summary>
/// Remove all pending commands for a specific game.
/// Called when the server confirms a command was processed (SUCCESS or BAD_TOKEN).
/// </summary>
private void RemovePendingCommandsForGame(long gameId) {
lock (this) {
var toRemove = _pendingCommands.Where(cmd => cmd.GameId == gameId).ToList();
foreach (var cmd in toRemove) { _pendingCommands.Remove(cmd); }
if (toRemove.Count > 0) {
_remoteEagleClientLogger.LogLine(
$"[POST] Removed {toRemove.Count} pending command(s) for game {gameId}");
}
}
}
/// <summary>
/// Refresh the subscription for a specific game to get fresh state from the server.
/// Used when a command is rejected (e.g., BAD_TOKEN) and we need current state.
/// </summary>
private void RefreshGameSubscription(long gameId) {
IClientConnectionSubscriber subscriber;
lock (this) { _subscribers.TryGetValue(gameId, out subscriber); }
if (subscriber != null) {
_ = StreamOneGameAsync(subscriber);
} else {
_remoteEagleClientLogger.LogLine(
$"[REFRESH] No subscriber found for game {gameId}");
}
}
public async Task Connect() {
// Prevent concurrent connection attempts
if (_isConnecting) {
@@ -468,7 +498,13 @@ namespace eagle {
await PostRequest(nextCommand);
} else if (eagleToken > providedToken) {
_remoteEagleClientLogger.LogLine(
$"{providedToken} seems to be stale, dropping");
$"{providedToken} seems to be stale (current token " +
$"{eagleToken}), dropping and refreshing state");
// Server already processed this command and advanced
// the token. Re-subscribe to ensure we have current
// state, especially if turn passed and we need new
// commands.
_ = StreamOneGameAsync(subscriber);
} else {
_remoteEagleClientLogger.LogLine(
$"{providedToken} seems to be from the future, adding back to the queue");
@@ -503,7 +539,10 @@ namespace eagle {
await PostRequest(nextCommand);
} else {
_remoteEagleClientLogger.LogLine(
$"Shardok token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
$"Shardok token mismatch: pending={providedShardokToken} " +
$"current={currentShardokToken}, dropping and refreshing state");
// Server processed command, token advanced. Refresh state.
_ = StreamOneGameAsync(subscriber);
}
break;
@@ -528,7 +567,10 @@ namespace eagle {
await PostRequest(nextCommand);
} else {
_remoteEagleClientLogger.LogLine(
$"Shardok placement token mismatch: pending={providedShardokToken} current={currentShardokToken}, dropping");
$"Shardok placement token mismatch: pending={providedShardokToken} " +
$"current={currentShardokToken}, dropping and refreshing state");
// Server processed command, token advanced. Refresh state.
_ = StreamOneGameAsync(subscriber);
}
break;
@@ -658,11 +700,12 @@ namespace eagle {
return true;
});
// Only remove from pending if successfully sent.
// If connection was dead, leave in queue for retry after reconnect.
if (success) {
lock (this) { _pendingCommands.Remove(request); }
} else {
// IMPORTANT: Do NOT remove from pending here even if write succeeded.
// WriteAsync completing only means data was written to local buffers,
// not that the server received and processed it. The command stays in
// _pendingCommands until we receive PostCommandResponse SUCCESS or
// TryPendingCommands sees the token has advanced (command was processed).
if (!success) {
_remoteEagleClientLogger.LogLine(
$"[POST] Command not sent (connection dead), keeping in pending queue for retry");
}
@@ -1073,6 +1116,41 @@ namespace eagle {
}
ackTcs?.TrySetResult(ack);
break;
case UpdateStreamResponse.ResponseDetailsOneofCase.PostCommandResponse:
var postResponse = current.PostCommandResponse;
if (postResponse.Status == PostCommandResponse.Types.Status.Error) {
_remoteEagleClientLogger.LogLine(
$"[POST] Server returned ERROR: {postResponse.ErrorMessage}");
LogConnectionEvent(
"post_command_error",
postResponse.ErrorMessage);
// Disconnect and let normal reconnect flow handle recovery
_streamingCall?.Dispose();
_streamingCall = null;
} else if (
postResponse.Status ==
PostCommandResponse.Types.Status.BadToken) {
_remoteEagleClientLogger.LogLine(
$"[POST] Server returned BAD_TOKEN for game {postResponse.GameId} " +
"- command rejected, refreshing state");
// Server rejected command due to stale token. Remove from
// pending (already processed) and re-subscribe to ensure we
// have current state.
RemovePendingCommandsForGame(postResponse.GameId);
RefreshGameSubscription(postResponse.GameId);
} else if (
postResponse.Status ==
PostCommandResponse.Types.Status.Success) {
_remoteEagleClientLogger.LogLine(
$"[POST] Server confirmed command for game {postResponse.GameId}");
// Command was successfully processed. Remove from pending.
RemovePendingCommandsForGame(postResponse.GameId);
}
// UNKNOWN is benign (old servers that don't set status)
break;
}
@@ -0,0 +1,13 @@
using UnityEngine;
/// <summary>
/// Initializes Sparkle auto-updater at app startup.
/// Uses RuntimeInitializeOnLoadMethod to ensure early initialization.
/// </summary>
public static class SparkleInitializer {
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void Initialize() {
Debug.Log("SparkleInitializer: Initializing Sparkle at startup");
SparkleUpdater.Initialize();
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2b4c6d8e0f1a3b5c7d9e1f3a5b7c9d1e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: -100
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,120 @@
using System.Runtime.InteropServices;
using UnityEngine;
/// <summary>
/// C# wrapper for the native SparklePlugin on macOS.
/// Provides auto-update functionality via the Sparkle framework.
/// On non-macOS platforms, all methods are no-ops.
/// </summary>
public static class SparkleUpdater {
#if UNITY_STANDALONE_OSX && !UNITY_EDITOR
private const string PluginName = "SparklePlugin";
[DllImport(PluginName)]
private static extern void SparklePlugin_Initialize();
[DllImport(PluginName)]
private static extern void SparklePlugin_CheckForUpdates();
[DllImport(PluginName)]
private static extern void SparklePlugin_CheckForUpdatesInBackground();
[DllImport(PluginName)]
private static extern int SparklePlugin_IsCheckingForUpdates();
[DllImport(PluginName)]
private static extern int SparklePlugin_GetAutomaticallyChecksForUpdates();
[DllImport(PluginName)]
private static extern void SparklePlugin_SetAutomaticallyChecksForUpdates(int enabled);
private static bool _initialized = false;
/// <summary>
/// Initialize the Sparkle updater. Should be called once at app startup.
/// This starts automatic background update checks based on Info.plist settings.
/// </summary>
public static void Initialize() {
if (_initialized) {
Debug.Log("SparkleUpdater: Already initialized");
return;
}
Debug.Log("SparkleUpdater: Initializing Sparkle");
try {
SparklePlugin_Initialize();
_initialized = true;
Debug.Log("SparkleUpdater: Initialization complete");
} catch (System.Exception e) {
Debug.LogError($"SparkleUpdater: Failed to initialize: {e.Message}");
}
}
/// <summary>
/// Manually check for updates. Shows the update UI to the user.
/// </summary>
public static void CheckForUpdates() {
if (!_initialized) {
Debug.LogWarning("SparkleUpdater: Not initialized");
return;
}
SparklePlugin_CheckForUpdates();
}
/// <summary>
/// Check for updates silently in the background.
/// Only shows UI if an update is found.
/// </summary>
public static void CheckForUpdatesInBackground() {
if (!_initialized) {
Debug.LogWarning("SparkleUpdater: Not initialized");
return;
}
SparklePlugin_CheckForUpdatesInBackground();
}
/// <summary>
/// Returns true if an update check is currently in progress.
/// </summary>
public static bool IsCheckingForUpdates {
get {
if (!_initialized) return false;
return SparklePlugin_IsCheckingForUpdates() != 0;
}
}
/// <summary>
/// Gets or sets whether automatic update checks are enabled.
/// </summary>
public static bool AutomaticallyChecksForUpdates {
get {
if (!_initialized) return false;
return SparklePlugin_GetAutomaticallyChecksForUpdates() != 0;
}
set {
if (!_initialized) return;
SparklePlugin_SetAutomaticallyChecksForUpdates(value? 1: 0);
}
}
#else
// Stub implementations for non-macOS platforms and Editor
public static void Initialize() { Debug.Log("SparkleUpdater: Not available on this platform"); }
public static void CheckForUpdates() {
Debug.Log("SparkleUpdater: Not available on this platform");
}
public static void CheckForUpdatesInBackground() {
Debug.Log("SparkleUpdater: Not available on this platform");
}
public static bool IsCheckingForUpdates => false;
public static bool AutomaticallyChecksForUpdates {
get => false;
set {}
}
#endif
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 8a3b5c7d9e1f2a3b4c5d6e7f8a9b0c1d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,6 @@
System.Runtime.CompilerServices.Unsafe/**
!**/*.psd
**/*.dll
# Native plugins built at CI time
DarwinGodiceBundle.bundle/
macOS/SparklePlugin.bundle/
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3c5d7e9f1a2b4c6d8e0f2a4b6c8d0e2f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,45 @@
fileFormatVersion: 2
guid: 4d6e8f0a2b3c5d7e9f1a3b5c7d9e1f3a
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 0
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
: Any
second:
enabled: 0
settings:
Exclude Editor: 1
Exclude Linux64: 1
Exclude OSXUniversal: 0
Exclude Win: 1
Exclude Win64: 1
- first:
Any:
second:
enabled: 0
settings: {}
- first:
Editor: Editor
second:
enabled: 0
settings:
CPU: AnyCPU
DefaultValueInitialized: true
OS: AnyOS
- first:
Standalone: OSXUniversal
second:
enabled: 1
settings:
CPU: AnyCPU
userData:
assetBundleName:
assetBundleVariant:
@@ -55,6 +55,21 @@ namespace Shardok {
/// <param name="unitType">Type of battalion (determines boot vs hoof prints)</param>
public void
AnimateMove(int sourceCellIndex, int targetCellIndex, BattalionTypeId unitType) {
AnimateMove(new List<int> { sourceCellIndex, targetCellIndex }, unitType);
}
/// <summary>
/// Animate movement along a path of hex cells.
/// </summary>
/// <param name="pathCellIndices">List of cell indices forming the path (including
/// start)</param> <param name="unitType">Type of battalion (determines boot vs hoof
/// prints)</param>
public void AnimateMove(List<int> pathCellIndices, BattalionTypeId unitType) {
if (pathCellIndices == null || pathCellIndices.Count < 2) {
Debug.LogWarning("MoveAnimator: Path must have at least 2 points");
return;
}
if (bootPrintSprite == null && hoofPrintSprite == null) {
Debug.LogWarning("MoveAnimator: No print sprites assigned");
return;
@@ -65,19 +80,21 @@ namespace Shardok {
return;
}
Vector2? sourcePos = _hexGrid.GetCellCenterPosition(sourceCellIndex);
Vector2? targetPos = _hexGrid.GetCellCenterPosition(targetCellIndex);
if (sourcePos == null || targetPos == null) {
Debug.LogWarning(
$"MoveAnimator: Invalid cell indices {sourceCellIndex} or {targetCellIndex}");
return;
// Convert cell indices to positions
var pathPositions = new List<Vector2>();
foreach (int cellIndex in pathCellIndices) {
Vector2? pos = _hexGrid.GetCellCenterPosition(cellIndex);
if (pos == null) {
Debug.LogWarning($"MoveAnimator: Invalid cell index {cellIndex}");
return;
}
pathPositions.Add(pos.Value);
}
bool isMounted = unitType == BattalionTypeId.LightCavalry ||
unitType == BattalionTypeId.HeavyCavalry;
StartCoroutine(SpawnPrintTrail(sourcePos.Value, targetPos.Value, isMounted));
StartCoroutine(SpawnPrintTrailAlongPath(pathPositions, isMounted));
}
/// <summary>
@@ -88,39 +105,52 @@ namespace Shardok {
}
private IEnumerator SpawnPrintTrail(Vector2 source, Vector2 target, bool isMounted) {
var prints = new List<GameObject>();
Vector2 direction = (target - source).normalized;
float totalDistance = Vector2.Distance(source, target);
yield return SpawnPrintTrailAlongPath(new List<Vector2> { source, target }, isMounted);
}
// Calculate rotation angle for prints to face direction of travel
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg - 90f;
private IEnumerator SpawnPrintTrailAlongPath(List<Vector2> pathPositions, bool isMounted) {
var prints = new List<GameObject>();
Sprite printSprite = isMounted ? (hoofPrintSprite ?? bootPrintSprite)
: (bootPrintSprite ?? hoofPrintSprite);
Color printColor = isMounted ? hoofPrintColor : bootPrintColor;
// Spawn prints along the path
for (int i = 0; i < printsPerHex; i++) {
float t = (i + 1f) / (printsPerHex + 1f);
Vector2 position = Vector2.Lerp(source, target, t);
int totalPrintIndex = 0;
// Alternate left/right for boot prints (flip horizontally)
bool flipX = !isMounted && (i % 2 == 1);
// Spawn prints along each segment of the path
for (int segmentIdx = 0; segmentIdx < pathPositions.Count - 1; segmentIdx++) {
Vector2 source = pathPositions[segmentIdx];
Vector2 target = pathPositions[segmentIdx + 1];
Vector2 direction = (target - source).normalized;
// Lateral offset for alternating prints (feet or front/rear hooves)
Vector2 perpendicular = new Vector2(-direction.y, direction.x);
float offset = (i % 2 == 0) ? -lateralOffset : lateralOffset;
position += perpendicular * offset;
// Calculate rotation angle for prints to face direction of travel
float angle = Mathf.Atan2(direction.y, direction.x) * Mathf.Rad2Deg - 90f;
GameObject print = CreatePrint(position, angle, printSprite, printColor, flipX);
prints.Add(print);
// Spawn prints along this segment
for (int i = 0; i < printsPerHex; i++) {
float t = (i + 1f) / (printsPerHex + 1f);
Vector2 position = Vector2.Lerp(source, target, t);
// Start FIFO fade coroutine for this print
var fadeCoroutine =
StartCoroutine(FadePrintFIFO(print, printLingerTime + i * printInterval));
_activeCoroutines.Add(fadeCoroutine);
// Alternate left/right for boot prints (flip horizontally)
bool flipX = !isMounted && (totalPrintIndex % 2 == 1);
yield return new WaitForSeconds(printInterval);
// Lateral offset for alternating prints (feet or front/rear hooves)
Vector2 perpendicular = new Vector2(-direction.y, direction.x);
float offset = (totalPrintIndex % 2 == 0) ? -lateralOffset : lateralOffset;
position += perpendicular * offset;
GameObject print = CreatePrint(position, angle, printSprite, printColor, flipX);
prints.Add(print);
// Start FIFO fade coroutine for this print
var fadeCoroutine = StartCoroutine(FadePrintFIFO(
print,
printLingerTime + totalPrintIndex * printInterval));
_activeCoroutines.Add(fadeCoroutine);
totalPrintIndex++;
yield return new WaitForSeconds(printInterval);
}
}
// Wait for all fades to complete
@@ -480,6 +480,9 @@ namespace Shardok {
SetModifiers();
UpdateReserves();
// Notify tutorial system of available commands (for spell/ability tutorials)
TutorialManager.Instance?.TriggerRegistry?.OnTacticalCommandsAvailable(Model);
HandleEnemyStartingPositionOverlays();
endTurnButton.interactable = false;
@@ -1531,13 +1534,15 @@ namespace Shardok {
/// For two-stage sounds (when isOwnCommand=true), plays attempt sound and tracks pending.
/// For single-stage or other player actions, plays full sound.
/// </summary>
/// <param name="movePath">Optional path for move animations (list of grid indices)</param>
void PlayAnimationAndSound(
AnimationType animationType,
int sourceGridIndex,
int targetGridIndex,
UnitViewWithName attackerUnit,
UnitViewWithName defenderUnit,
bool isOwnCommand = false) {
bool isOwnCommand = false,
List<int> movePath = null) {
if (animationType == AnimationType.None) return;
// Handle sound based on whether this is own command with two-stage sounds
@@ -1576,10 +1581,14 @@ namespace Shardok {
break;
case AnimationType.Move:
if (moveAnimator != null && attackerUnit != null) {
moveAnimator.AnimateMove(
sourceGridIndex,
targetGridIndex,
attackerUnit.Battalion.Type);
if (movePath != null && movePath.Count >= 2) {
moveAnimator.AnimateMove(movePath, attackerUnit.Battalion.Type);
} else {
moveAnimator.AnimateMove(
sourceGridIndex,
targetGridIndex,
attackerUnit.Battalion.Type);
}
}
break;
case AnimationType.LightningBolt:
@@ -1897,13 +1906,22 @@ namespace Shardok {
Coords finishCoords = GridIndexToMapCoords(finishGridIndex);
var commandTypes = commandTypeUIManager.CommandTypesForGroup(_displayedCommandGroup);
CommandType? executedCommand =
var executedCommand =
Model.PerformTargetedCommand(startCoords, finishCoords, commandTypes);
if (executedCommand != null) {
var attackerUnit = Model.UnitAtCoords(startCoords);
var defenderUnit = Model.UnitAtCoords(finishCoords);
var animationType = AnimationTypeForCommand(executedCommand.Value);
var animationType = AnimationTypeForCommand(executedCommand.Type);
// Extract path for move animations (prepend start position)
List<int> movePath = null;
if (executedCommand.Path.Count > 0) {
movePath = new List<int> { startGridIndex };
foreach (var coords in executedCommand.Path) {
movePath.Add(MapCoordsToGridIndex(coords));
}
}
if (animationType != AnimationType.None) {
PlayAnimationAndSound(
@@ -1912,7 +1930,8 @@ namespace Shardok {
finishGridIndex,
attackerUnit,
defenderUnit,
isOwnCommand: true);
isOwnCommand: true,
movePath: movePath);
} else if (Model.InSetUp) {
// For commands without specific animations, play generic click in setup
audioClipSource.PlayOneShot(soundManager.GenericClickSound());
@@ -274,16 +274,14 @@ public class ShardokGameModel {
return true;
}
public CommandType? PerformTargetedCommand(
Coords start,
Coords target,
List<CommandType> possibleTypes) {
public CommandDescriptor
PerformTargetedCommand(Coords start, Coords target, List<CommandType> possibleTypes) {
List<CommandDescriptor> heroActions = GetCommandsForCoords(start);
foreach (CommandDescriptor action in heroActions) {
if (target.Equals(action.Target) && possibleTypes.Contains(action.Type)) {
PostAction(action);
return action.Type;
return action;
}
}
return null;
@@ -0,0 +1,310 @@
using System.Collections.Generic;
using UnityEngine;
namespace Eagle0.Tutorial {
/// <summary>
/// Defines all tutorial content programmatically.
/// This keeps content in code for easy version control and review.
/// Call RegisterAll() during TutorialManager initialization.
/// </summary>
public static class TutorialContentDefinitions {
/// <summary>
/// Creates and registers all tutorial sequences with the registry.
/// </summary>
public static void RegisterAll(TutorialManager manager, TutorialTriggerRegistry registry) {
// Create and assign onboarding sequence
var onboarding = CreateOnboardingSequence();
manager.OnboardingSequence = onboarding;
// Register strategic contextual tutorials
RegisterStrategicTutorials(registry);
// Register tactical contextual tutorials
RegisterTacticalTutorials(registry);
}
// ========== ONBOARDING SEQUENCE (13 steps) ==========
private static TutorialSequence CreateOnboardingSequence() {
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
sequence.SequenceId = "onboarding";
sequence.DisplayName = "Welcome to Eagle0";
sequence.IsOnboarding = true;
sequence.Steps = new List<TutorialStep> {
// Step 1: Welcome
new TutorialStep {
StepId = "welcome",
Title = "Welcome to Eagle0",
Description =
"Command your armies, recruit heroes, and conquer provinces in this turn-based strategy game.\n\nLet's walk through the basics!",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 2: Map overview - select a province
new TutorialStep {
StepId = "select_province",
Title = "The Strategic Map",
Description =
"This is your kingdom. Each colored region is a province.\n\nTap a province you control (shown in your color) to see what you can do there.",
DisplayMode = TutorialDisplayMode.Overlay,
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "province_selected",
AllowSkip = true
},
// Step 3: Province info panel
new TutorialStep {
StepId = "province_panel",
Title = "Province Information",
Description =
"This panel shows province details: its name, terrain, any armies present, and the commands available to you.\n\nCommands let you move troops, recruit heroes, and more.",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 4: Try March command
new TutorialStep {
StepId = "try_march",
Title = "Issue a Command",
Description =
"Try issuing a March command to move your army to an adjacent province.\n\nSelect a destination and confirm the order.",
DisplayMode = TutorialDisplayMode.Overlay,
// TODO: Add TargetGameObjectPath once we know the actual UI hierarchy
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "command_issued",
AllowSkip = true
},
// Step 5: Turn cycle explanation
new TutorialStep {
StepId = "turn_cycle",
Title = "The Turn Cycle",
Description =
"Eagle0 uses simultaneous turns. All players give orders at the same time, then turns resolve together.\n\nWhen all players are ready, the server processes everyone's commands and shows the results.",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 6: Hidden wait for battle
new TutorialStep {
StepId = "wait_for_battle",
Title = "",
Description = "",
DisplayMode = TutorialDisplayMode.None,
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "first_battle_available",
AllowSkip = true
},
// Step 7: Battle introduction
new TutorialStep {
StepId = "battle_intro",
Title = "Battle Time!",
Description =
"When armies collide, you'll fight tactical battles on a hex grid.\n\nYou command individual units - infantry, cavalry, archers, and heroes with special abilities.",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 8: Battle button highlight
new TutorialStep {
StepId = "enter_battle",
Title = "Enter the Battle",
Description = "Tap the Battle button to enter tactical combat.",
DisplayMode = TutorialDisplayMode.Overlay,
// TODO: Add TargetGameObjectPath once we know the actual UI hierarchy
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "battle_entered",
AllowSkip = true
},
// Step 9: Tactical overview
new TutorialStep {
StepId = "tactical_overview",
Title = "Tactical Combat",
Description =
"Each unit has movement points and attack power. Position your troops wisely!\n\nUnits attack adjacent enemies. Flanking (attacking from multiple sides) deals bonus damage.",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 10: Move a unit
new TutorialStep {
StepId = "move_unit",
Title = "Move Your Units",
Description =
"Tap one of your units to select it, then tap a highlighted hex to move there.\n\nBlue hexes show where you can move.",
DisplayMode = TutorialDisplayMode.Overlay,
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "battle_action",
AllowSkip = true
},
// Step 11: Attack an enemy
new TutorialStep {
StepId = "attack_enemy",
Title = "Attack!",
Description =
"Move next to an enemy unit, then tap the enemy to attack.\n\nRed highlights show valid attack targets.",
DisplayMode = TutorialDisplayMode.Overlay,
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "battle_action",
AllowSkip = true
},
// Step 12: End turn
new TutorialStep {
StepId = "end_turn",
Title = "End Your Turn",
Description =
"When you've moved all units or want to pass, tap End Turn.\n\nThe enemy will then take their turn.",
DisplayMode = TutorialDisplayMode.Overlay,
// TODO: Add TargetGameObjectPath once we know the actual UI hierarchy
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "turn_ended",
AllowSkip = true
},
// Step 13: Completion
new TutorialStep {
StepId = "complete",
Title = "You're Ready!",
Description =
"You now know the basics of Eagle0!\n\nExplore diplomacy, recruit powerful heroes, and conquer the realm. Good luck, commander!",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = false // Don't allow skipping the final step
}
};
return sequence;
}
// ========== STRATEGIC CONTEXTUAL TUTORIALS ==========
private static void RegisterStrategicTutorials(TutorialTriggerRegistry registry) {
// Diplomacy introduction
var diplomacy = CreateSingleStepTutorial(
"diplomacy_intro",
"Diplomacy",
"You can negotiate with other factions!\n\nOffer alliances, declare war, or propose tribute. Your diplomatic choices shape the realm.",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(diplomacy, "diplomacy_available");
// Hero recruitment
var heroRecruitment = CreateSingleStepTutorial(
"hero_recruitment",
"Heroes Available",
"Free heroes wander the realm seeking a lord to serve.\n\nRecruit them to lead your armies! Heroes have unique abilities and grow stronger with experience.",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(heroRecruitment, "hero_recruitment_available");
// Weather control
var weatherControl = CreateSingleStepTutorial(
"weather_control",
"Weather Magic",
"Your mages can influence the weather!\n\nRain slows movement, storms disrupt enemies, and clear skies speed your march.",
TutorialDisplayMode.Overlay);
registry.RegisterTutorial(weatherControl, "weather_control_available");
// Prisoner management
var prisoners = CreateSingleStepTutorial(
"prisoner_management",
"Prisoners Captured",
"You've captured enemy soldiers!\n\nYou can ransom them for gold, recruit them into your army, or execute them as a warning.",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(prisoners, "prisoner_command_issued");
}
// ========== TACTICAL CONTEXTUAL TUTORIALS ==========
private static void RegisterTacticalTutorials(TutorialTriggerRegistry registry) {
// Lightning spell
var lightning = CreateSingleStepTutorial(
"spell_lightning",
"Lightning Bolt",
"Your mage can cast Lightning Bolt!\n\nThis spell strikes a single target for heavy damage. Great for eliminating key enemy units.",
TutorialDisplayMode.Tooltip);
registry.RegisterTutorial(lightning, "spell_lightning_available");
// Meteor spell
var meteor = CreateSingleStepTutorial(
"spell_meteor",
"Meteor Strike",
"Meteor is a devastating area spell!\n\nIt takes a turn to cast: first select target, then it lands next turn. Plan ahead!",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(meteor, "spell_meteor_available");
// Holy Wave spell
var holyWave = CreateSingleStepTutorial(
"spell_holywave",
"Holy Wave",
"Holy Wave heals your units and damages undead!\n\nPosition your troops carefully to maximize its effect.",
TutorialDisplayMode.Tooltip);
registry.RegisterTutorial(holyWave, "spell_holywave_available");
// Raise Dead spell
var raiseDead = CreateSingleStepTutorial(
"spell_raisedead",
"Raise Dead",
"Dark magic can raise fallen soldiers as undead!\n\nThey fight for you, but beware - they may crumble if your necromancer falls.",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(raiseDead, "spell_raisedead_available");
// Fire terrain
var fireTerrain = CreateSingleStepTutorial(
"terrain_fire",
"Fire Hazard",
"Fire spreads across the battlefield!\n\nUnits in burning hexes take damage. Use fire to block enemy routes or avoid it yourself.",
TutorialDisplayMode.Tooltip);
registry.RegisterTutorial(fireTerrain, "terrain_fire_encountered");
// Water terrain
var waterTerrain = CreateSingleStepTutorial(
"terrain_water",
"Water Crossing",
"Units can cross shallow water, but it's risky.\n\nCrossing takes extra movement and may fail. Some units swim better than others.",
TutorialDisplayMode.Tooltip);
registry.RegisterTutorial(waterTerrain, "terrain_water_encountered");
// Charge ability
var charge = CreateSingleStepTutorial(
"ability_charge",
"Cavalry Charge",
"Your cavalry can Charge!\n\nCharging deals bonus damage based on distance traveled. Use open terrain for maximum impact.",
TutorialDisplayMode.Overlay);
registry.RegisterTutorial(charge, "ability_charge_available");
}
// ========== HELPER METHODS ==========
private static TutorialSequence CreateSingleStepTutorial(
string id,
string title,
string description,
TutorialDisplayMode displayMode) {
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
sequence.SequenceId = id;
sequence.DisplayName = title;
sequence.IsOnboarding = false;
sequence.Steps = new List<TutorialStep> { new TutorialStep {
StepId = id + "_step",
Title = title,
Description = description,
DisplayMode = displayMode,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
} };
return sequence;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3a768fdf067694657a5d24b935064f88
@@ -14,14 +14,25 @@ A modular tutorial system for the Eagle0 Unity client supporting:
### Completed
- [x] **Phase 1: Foundation** - TutorialState, TutorialManager, TutorialStep/Sequence
- [x] **Phase 2: UI (partial)** - IMGUI fallback modal with Stoke font
- [x] **Phase 3: Triggers (partial)** - Basic trigger system with hooks in game controllers
- [x] **Test setup** - TutorialTestSetup for province selection, battle entry, command triggers
- [x] **Phase 2: UI** - Canvas-based modal with runtime construction
- TutorialCanvasBuilder creates full Canvas UI at runtime (no prefab needed)
- Fantasy RPG color scheme (dark purple panel, gold accents)
- Title, description, icon, progress bar, buttons
- IMGUI fallback still available if needed
- [x] **Phase 3: Triggers** - Event-based trigger system with game controller hooks
- [x] **Test setup** - TutorialTestSetup with welcome intro, battle, command tutorials
- [x] **Settings integration** - Reset Tutorials button in Settings panel
- [x] **Game entry trigger** - Welcome tutorial shows when entering a game (not lobby)
- [x] **Font assignment** - Stoke-Regular-SDF assigned to TutorialUIManager.CanvasFont
- [x] **Overlay system** - TutorialOverlayController with runtime UI construction
- TutorialOverlayBuilder creates overlay UI at runtime (no prefab needed)
- Background dimmer, highlight frame with gold border
- Tooltip with title, description, continue button
- Pulsing animation on highlight frame
- Test tutorial includes overlay step for End Turn button
### Remaining
- [ ] **Canvas UI prefabs** - Replace IMGUI fallback with styled Canvas-based modal
- [ ] **Overlay system** - TutorialOverlayController for highlighting UI elements
### Future Work
- [ ] **Lobby tutorial helper** - Separate tutorial for lobby/game selection
- [ ] **Hint indicators** - TutorialHintIndicator for subtle pulsing dots
- [ ] **Real tutorial content** - Replace test tutorials with actual onboarding sequence
- [ ] **More contextual triggers** - Diplomacy, hero recruitment, spells, terrain, etc.
@@ -37,45 +48,65 @@ TutorialManager (Singleton, DontDestroyOnLoad)
│ ├── OnboardingTriggers
│ └── ContextualTriggers
└── TutorialUIManager
├── TutorialModalPanel (TODO)
├── TutorialOverlayController (TODO)
├── TutorialHintIndicator (TODO)
├── TutorialCanvasBuilder (runtime Canvas construction)
├── TutorialModalPanel (wired up by builder)
├── TutorialOverlayBuilder (runtime overlay construction)
├── TutorialOverlayController (wired up by builder)
├── TutorialHintIndicator (stub)
└── IMGUI Fallback (working)
```
---
## File Structure (Current)
## File Structure
```
Assets/Tutorial/
├── TutorialManager.cs ✓ Singleton, coordinates everything
├── TutorialState.cs ✓ PlayerPrefs persistence
├── TutorialTestSetup.cs ✓ Test component for validation
├── TutorialTestSetup.cs ✓ Test tutorials (welcome, battle, command)
├── TUTORIAL_PLAN.md ✓ This document
├── Content/
│ ├── TutorialStep.cs ✓ Step data structure
│ └── TutorialSequence.cs ✓ Sequence ScriptableObject
├── Triggers/
│ └── TutorialTriggerRegistry.cs ✓ Event routing
└── UI/
├── TutorialUIManager.cs ✓ UI coordination + IMGUI fallback
├── TutorialModalPanel.cs Stub (needs Canvas implementation)
├── TutorialOverlayController.cs ✓ Stub
── TutorialHintIndicator.cs ✓ Stub
├── TutorialUIManager.cs ✓ UI coordination + auto-build Canvas/Overlay
├── TutorialCanvasBuilder.cs ✓ Runtime Canvas UI construction
├── TutorialModalPanel.cs ✓ Canvas-based modal panel controller
── TutorialOverlayBuilder.cs ✓ Runtime overlay UI construction
├── TutorialOverlayController.cs ✓ Overlay with highlight + tooltip
└── TutorialHintIndicator.cs Stub (future)
```
---
## Integration Points (Implemented)
## Unity Setup
### Required Inspector Assignments
1. **TutorialUIManager.CanvasFont**`Stoke-Regular-SDF` (TextMeshPro font)
2. **TutorialUIManager.FallbackFont**`Stoke-Regular` (for IMGUI backup)
### No Prefab Required
The `TutorialCanvasBuilder` creates the entire Canvas UI hierarchy at runtime:
- Canvas with ScreenSpaceOverlay, sorting order 1000
- Modal blocker (dark semi-transparent background)
- Panel with vertical layout (title, description, progress, buttons)
- All buttons wired to TutorialModalPanel handlers
---
## Integration Points
### EagleGameController.cs
- `SetUpGame()`: Initializes TutorialManager, starts onboarding
- `SetUpGame()`: Calls `TutorialManager.Instance.Initialize(this, null)` → triggers "game_started" event
- `SwapModel()`: Calls `OnModelUpdated()`
- `ProvinceWasSelected()`: Calls `OnProvinceSelected()`
- `PostCommittedCommand()`: Calls `OnCommandIssued()`
### ShardokGameController.cs
- `SetUpGame()`: Initializes TutorialManager for battle
- `SetUpGame()`: Calls `TutorialManager.Instance.Initialize(null, this)`
- `ModelUpdated()`: Calls `OnBattleAction()`
- `OnTurnEnded()`: Calls `OnTurnEnded()`
- Unit selection: Calls `OnUnitSelected()`
@@ -85,6 +116,17 @@ Assets/Tutorial/
---
## Test Tutorials (Current)
| ID | Trigger Event | Title | Type | When |
|----|---------------|-------|------|------|
| `test_intro` | `game_started` | Welcome to Eagle0! | Modal | Entering a game |
| `test_intro` | (step 2) | End Turn | Overlay | After welcome modal |
| `test_first_battle` | `battle_entered` | Battle Begins! | Modal | First tactical battle |
| `test_first_command` | `command_issued` | Command Issued! | Modal | First strategic command |
---
## Onboarding Flow (Planned)
| Step | Type | Content | Completion |
@@ -128,12 +170,23 @@ Assets/Tutorial/
| `ability_charge` | Charge command available | Overlay |
| `flanking` | Flanking opportunity | Hint |
### Lobby (Future)
| ID | Trigger | Display |
|----|---------|---------|
| `lobby_welcome` | First time in lobby | Modal |
| `lobby_join_game` | Viewing game list | Overlay |
| `lobby_create_game` | Create game button | Tooltip |
---
## Testing
1. Add `TutorialTestSetup` component to TutorialManager GameObject
2. Assign `Stoke-Regular.ttf` to `FallbackFont` on TutorialUIManager
3. Enable `Debug Logging` on TutorialManager for console output
4. Play game → select province → see test tutorial modal
5. Use Settings → Reset Tutorials to test again
1. Ensure TutorialManager GameObject exists with:
- TutorialManager component
- TutorialUIManager component (assign fonts!)
- TutorialTestSetup component
2. Enable `Debug Logging` on TutorialManager for console output
3. Play game → enter a game → see "Welcome to Eagle0!" modal
4. Click "Continue" → see overlay highlighting End Turn button
5. Click "Got it" → overlay closes
6. Use Settings → Reset Tutorials to test again
@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 69183de61f7d440a29b4a4270395a180
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,5 +1,9 @@
using System.Collections.Generic;
using System.Linq;
using eagle;
using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Shardok.Api;
using Net.Eagle0.Shardok.Common;
using Shardok;
using UnityEngine;
@@ -92,10 +96,51 @@ namespace Eagle0.Tutorial {
}
}
// Check for diplomacy offers
// Check for riots
// Check for hero recruitment opportunities
// etc. - These would check model state changes
// Check available commands for strategic tutorials
CheckStrategicCommandsAvailable(model, previousModel);
// Check for province state changes
CheckProvinceStateChanges(model, previousModel);
// Check for free heroes (recruitment opportunity)
CheckHeroRecruitmentAvailable(model, previousModel);
}
private void CheckStrategicCommandsAvailable(IGameModel model, IGameModel previousModel) {
if (model.AvailableCommandsByProvince == null) return;
var allCommands =
model.AvailableCommandsByProvince.Values.SelectMany(opac => opac.Commands)
.ToList();
// Check for diplomacy commands
bool hasDiplomacy = allCommands.Any(cmd => cmd.DiplomacyCommand != null);
if (hasDiplomacy && !_manager.State.HasCompletedTutorial("diplomacy_intro")) {
OnGameEvent("diplomacy_available");
}
// Check for weather control
bool hasWeatherControl =
allCommands.Any(cmd => cmd.ControlWeatherAvailableCommand != null);
if (hasWeatherControl && !_manager.State.HasCompletedTutorial("weather_control")) {
OnGameEvent("weather_control_available");
}
}
private void CheckProvinceStateChanges(IGameModel model, IGameModel previousModel) {
// Note: Riot status is not exposed in ProvinceView, so we can't detect riots here.
// Province state change detection can be added when the view exposes relevant fields.
}
private void CheckHeroRecruitmentAvailable(IGameModel model, IGameModel previousModel) {
if (model.Heroes == null) return;
// Check for free heroes (heroes with no faction)
var freeHeroes =
model.Heroes.Values.Where(h => h.FactionId == null || h.FactionId == 0);
if (freeHeroes.Any() && !_manager.State.HasCompletedTutorial("hero_recruitment")) {
OnGameEvent("hero_recruitment_available");
}
}
/// <summary>
@@ -113,8 +158,18 @@ namespace Eagle0.Tutorial {
// Track first command for onboarding
OnGameEvent("command_issued", command);
// Could also check command type for specific tutorials
// e.g., first diplomacy command, first weather control, etc.
// Check specific command types
if (command is SelectedCommand selectedCommand) {
if (selectedCommand.DiplomacyCommand != null) {
OnGameEvent("diplomacy_command_issued", selectedCommand);
}
if (selectedCommand.ControlWeatherSelectedCommand != null) {
OnGameEvent("weather_command_issued", selectedCommand);
}
if (selectedCommand.ManagePrisonersCommand != null) {
OnGameEvent("prisoner_command_issued", selectedCommand);
}
}
}
// ========== Tactical Layer Events ==========
@@ -132,8 +187,62 @@ namespace Eagle0.Tutorial {
public void OnBattleAction(object actionResult) {
OnGameEvent("battle_action", actionResult);
// Check for specific action types that need tutorials
// e.g., first spell cast, first charge, etc.
// Check for specific action types
if (actionResult is ActionResultView result) { CheckTacticalActionType(result); }
}
private void CheckTacticalActionType(ActionResultView result) {
switch (result.Type) {
// Spell tutorials
case ActionType.LightningBolt: OnGameEvent("spell_lightning_cast", result); break;
case ActionType.MeteorStart:
case ActionType.MeteorTarget:
case ActionType.MeteorCast: OnGameEvent("spell_meteor_cast", result); break;
case ActionType.HolyWave:
case ActionType.HolyWaveDamage: OnGameEvent("spell_holywave_cast", result); break;
case ActionType.RaisedUndead:
case ActionType.FailedRaiseUndead:
OnGameEvent("spell_raisedead_cast", result);
break;
// Combat tutorials
case ActionType.ChargeAttack: OnGameEvent("ability_charge_used", result); break;
// Terrain tutorials
case ActionType.FireDamage:
case ActionType.FireSpread: OnGameEvent("terrain_fire_encountered", result); break;
case ActionType.CrossedWater:
case ActionType.CrossWaterFailed:
OnGameEvent("terrain_water_encountered", result);
break;
}
}
/// <summary>
/// Called when available commands change in tactical view.
/// Used to detect when special abilities become available.
/// </summary>
public void OnTacticalCommandsAvailable(ShardokGameModel model) {
if (model?.AvailableCommands == null) return;
// Check for spell availability
foreach (var cmd in model.AvailableCommands) {
switch (cmd.Type) {
case CommandType.LightningBoltCommand:
OnGameEvent("spell_lightning_available");
break;
case CommandType.MeteorStartCommand:
OnGameEvent("spell_meteor_available");
break;
case CommandType.HolyWaveCommand:
OnGameEvent("spell_holywave_available");
break;
case CommandType.RaiseDeadCommand:
OnGameEvent("spell_raisedead_available");
break;
case CommandType.ChargeCommand: OnGameEvent("ability_charge_available"); break;
}
}
}
/// <summary>
@@ -69,6 +69,9 @@ namespace Eagle0.Tutorial {
_state = TutorialState.Load();
_triggerRegistry = new TutorialTriggerRegistry(this);
// Register all tutorial content
TutorialContentDefinitions.RegisterAll(this, _triggerRegistry);
Log("TutorialManager initialized");
}
@@ -83,6 +86,9 @@ namespace Eagle0.Tutorial {
public void Initialize(EagleGameController eagle, ShardokGameController shardok) {
_triggerRegistry?.Initialize(eagle, shardok);
Log($"TutorialManager initialized with controllers - Eagle: {eagle != null}, Shardok: {shardok != null}");
// Trigger game_started event when entering a game (not lobby)
if (eagle != null) { _triggerRegistry?.OnGameEvent("game_started"); }
}
/// <summary>
@@ -143,6 +149,13 @@ namespace Eagle0.Tutorial {
if (!TutorialsEnabled || _state.AllTutorialsDisabled) return;
if (_state.HasCompletedTutorial(tutorialId)) return;
// Don't interrupt an active tutorial with a contextual one
// (onboarding can be interrupted by explicit StartSequence calls)
if (_activeSequence != null) {
Log($"TriggerContextualTutorial: Skipping '{tutorialId}' - another tutorial is active");
return;
}
// Look up the tutorial sequence by ID
var sequence = _triggerRegistry?.GetSequenceById(tutorialId);
if (sequence != null) { StartSequence(sequence); }
@@ -30,9 +30,9 @@ namespace Eagle0.Tutorial {
}
private void RegisterTestTutorials(TutorialManager manager) {
// Create a simple "first province selected" tutorial
var provinceSelectedTutorial = CreateProvinceSelectedTutorial();
manager.TriggerRegistry.RegisterTutorial(provinceSelectedTutorial, "province_selected");
// Create welcome/intro tutorial that shows on game start
var introTutorial = CreateIntroTutorial();
manager.TriggerRegistry.RegisterTutorial(introTutorial, "game_started");
// Create a simple "first battle" tutorial
var firstBattleTutorial = CreateFirstBattleTutorial();
@@ -41,20 +41,38 @@ namespace Eagle0.Tutorial {
// Create a simple "first command" tutorial
var firstCommandTutorial = CreateFirstCommandTutorial();
manager.TriggerRegistry.RegisterTutorial(firstCommandTutorial, "command_issued");
// Note: "game_started" event is triggered by EagleGameController.SetUpGame()
// via TutorialManager.Initialize(), not here
}
private TutorialSequence CreateProvinceSelectedTutorial() {
private TutorialSequence CreateIntroTutorial() {
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
sequence.SequenceId = "test_province_selected";
sequence.DisplayName = "Province Selection";
sequence.SequenceId = "test_intro";
sequence.DisplayName = "Welcome";
// Step 1: Welcome modal
sequence.Steps.Add(new TutorialStep {
StepId = "province_selected_intro",
Title = "Province Selected!",
StepId = "welcome",
Title = "Welcome to Eagle0!",
Description =
"You've selected a province. Here you can see information about the province and issue commands to your forces.\n\n(This is a test tutorial to verify the tutorial system is working.)",
"Eagle0 is a strategy game where you command armies, manage provinces, and engage in tactical combat.\n\nClick on provinces to view their details and issue commands. When battles occur, you'll fight them out on a hex-based tactical map.\n\nGood luck, Commander!",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = false
});
// Step 2: Overlay test - highlight the End Turn button
sequence.Steps.Add(new TutorialStep {
StepId = "end_turn_highlight",
Title = "End Turn",
Description =
"When you're ready to advance to the next turn, click the End Turn button.\n\n(This is a test of the overlay tutorial system.)",
DisplayMode = TutorialDisplayMode.Overlay,
TargetGameObjectPath = "EagleCanvas/EndTurnButton",
HighlightOffset = new Vector2(0, 150),
HighlightPulsing = true,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
});
@@ -0,0 +1,475 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Eagle0.Tutorial {
/// <summary>
/// Builds the tutorial Canvas UI programmatically at runtime.
/// Creates a complete TutorialModalPanel with all required UI elements.
/// </summary>
public static class TutorialCanvasBuilder {
// Colors matching the game's fantasy RPG style
private static readonly Color PanelBackgroundColor = new Color(0.15f, 0.12f, 0.2f, 0.95f);
private static readonly Color BorderColor = new Color(0.6f, 0.5f, 0.3f, 1f);
private static readonly Color TitleColor = new Color(1f, 0.9f, 0.7f, 1f);
private static readonly Color TextColor = new Color(0.9f, 0.85f, 0.8f, 1f);
private static readonly Color ButtonColor = new Color(0.4f, 0.3f, 0.2f, 1f);
private static readonly Color ButtonHoverColor = new Color(0.5f, 0.4f, 0.25f, 1f);
private static readonly Color ButtonTextColor = new Color(1f, 0.95f, 0.85f, 1f);
private static readonly Color ModalBlockerColor = new Color(0f, 0f, 0f, 0.75f);
private static readonly Color ProgressBarColor = new Color(0.6f, 0.5f, 0.3f, 1f);
private static readonly Color ProgressBackgroundColor = new Color(0.2f, 0.15f, 0.1f, 1f);
/// <summary>
/// Creates a complete tutorial Canvas with modal panel.
/// Returns the Canvas GameObject and sets up the TutorialModalPanel component.
/// </summary>
/// <param name="font">TextMeshPro font to use (assign Stoke-Regular-SDF in
/// inspector)</param>
public static Canvas BuildTutorialCanvas(TMP_FontAsset font) {
// Create Canvas
GameObject canvasObj = new GameObject("TutorialCanvas");
Canvas canvas = canvasObj.AddComponent<Canvas>();
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
canvas.sortingOrder = 1000;
CanvasScaler scaler = canvasObj.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920, 1080);
scaler.matchWidthOrHeight = 0.5f;
canvasObj.AddComponent<GraphicRaycaster>();
// Create Modal Blocker
GameObject blockerObj = CreateModalBlocker(canvasObj.transform);
// Create Panel Container
GameObject panelContainer = CreatePanelContainer(canvasObj.transform, font);
// Add TutorialModalPanel component and wire it up
TutorialModalPanel modalPanel = canvasObj.AddComponent<TutorialModalPanel>();
WireUpModalPanel(modalPanel, blockerObj, panelContainer, font);
// Set up button listeners (Awake runs before fields are wired, so do it here)
SetupButtonListeners(modalPanel);
// Start with everything hidden
blockerObj.SetActive(false);
panelContainer.SetActive(false);
return canvas;
}
private static GameObject CreateModalBlocker(Transform parent) {
GameObject blocker = new GameObject("ModalBlocker");
blocker.transform.SetParent(parent, false);
RectTransform rect = blocker.AddComponent<RectTransform>();
rect.anchorMin = Vector2.zero;
rect.anchorMax = Vector2.one;
rect.offsetMin = Vector2.zero;
rect.offsetMax = Vector2.zero;
Image image = blocker.AddComponent<Image>();
image.color = ModalBlockerColor;
image.raycastTarget = true;
return blocker;
}
private static GameObject CreatePanelContainer(Transform parent, TMP_FontAsset font) {
// Panel Container (centered)
GameObject panel = new GameObject("PanelContainer");
panel.transform.SetParent(parent, false);
RectTransform panelRect = panel.AddComponent<RectTransform>();
panelRect.anchorMin = new Vector2(0.5f, 0.5f);
panelRect.anchorMax = new Vector2(0.5f, 0.5f);
panelRect.sizeDelta = new Vector2(800, 550);
panelRect.anchoredPosition = Vector2.zero;
// Panel Background
Image panelBg = panel.AddComponent<Image>();
panelBg.color = PanelBackgroundColor;
panelBg.raycastTarget = true;
// Add outline/border effect
Outline outline = panel.AddComponent<Outline>();
outline.effectColor = BorderColor;
outline.effectDistance = new Vector2(3, 3);
// Add vertical layout group
VerticalLayoutGroup layout = panel.AddComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(40, 40, 30, 30);
layout.spacing = 20;
layout.childAlignment = TextAnchor.UpperCenter;
layout.childControlHeight = false;
layout.childControlWidth = true;
layout.childForceExpandHeight = false;
layout.childForceExpandWidth = true;
// Title
CreateTitle(panel.transform, font);
// Icon (placeholder - starts hidden)
CreateIcon(panel.transform);
// Description
CreateDescription(panel.transform, font);
// Flexible spacer to push buttons to bottom
CreateFlexibleSpacer(panel.transform);
// Progress section
CreateProgressSection(panel.transform, font);
// Button row
CreateButtonRow(panel.transform, font);
return panel;
}
private static void CreateTitle(Transform parent, TMP_FontAsset font) {
GameObject titleObj = new GameObject("TitleText");
titleObj.transform.SetParent(parent, false);
RectTransform rect = titleObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(720, 60);
TextMeshProUGUI text = titleObj.AddComponent<TextMeshProUGUI>();
text.text = "Tutorial";
text.fontSize = 42;
text.fontStyle = FontStyles.Bold;
text.color = TitleColor;
text.alignment = TextAlignmentOptions.Center;
if (font != null) text.font = font;
LayoutElement layoutElement = titleObj.AddComponent<LayoutElement>();
layoutElement.preferredHeight = 60;
}
private static void CreateIcon(Transform parent) {
GameObject iconObj = new GameObject("IconImage");
iconObj.transform.SetParent(parent, false);
RectTransform rect = iconObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(100, 100);
Image image = iconObj.AddComponent<Image>();
image.color = Color.white;
image.preserveAspect = true;
LayoutElement layoutElement = iconObj.AddComponent<LayoutElement>();
layoutElement.preferredHeight = 100;
layoutElement.preferredWidth = 100;
// Start hidden
iconObj.SetActive(false);
}
private static void CreateDescription(Transform parent, TMP_FontAsset font) {
GameObject descObj = new GameObject("DescriptionText");
descObj.transform.SetParent(parent, false);
RectTransform rect = descObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(720, 200);
TextMeshProUGUI text = descObj.AddComponent<TextMeshProUGUI>();
text.text = "";
text.fontSize = 26;
text.color = TextColor;
text.alignment = TextAlignmentOptions.TopLeft;
text.enableWordWrapping = true;
text.overflowMode = TextOverflowModes.Overflow; // Don't truncate
if (font != null) text.font = font;
LayoutElement layoutElement = descObj.AddComponent<LayoutElement>();
layoutElement.preferredHeight = 200;
layoutElement.minHeight = 100;
}
private static void CreateSpacer(Transform parent, float height) {
GameObject spacer = new GameObject("Spacer");
spacer.transform.SetParent(parent, false);
spacer.AddComponent<RectTransform>();
LayoutElement layoutElement = spacer.AddComponent<LayoutElement>();
layoutElement.preferredHeight = height;
}
private static void CreateFlexibleSpacer(Transform parent) {
GameObject spacer = new GameObject("FlexibleSpacer");
spacer.transform.SetParent(parent, false);
spacer.AddComponent<RectTransform>();
LayoutElement layoutElement = spacer.AddComponent<LayoutElement>();
layoutElement.flexibleHeight = 1;
}
private static void CreateProgressSection(Transform parent, TMP_FontAsset font) {
GameObject progressSection = new GameObject("ProgressSection");
progressSection.transform.SetParent(parent, false);
RectTransform rect = progressSection.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(720, 50);
VerticalLayoutGroup layout = progressSection.AddComponent<VerticalLayoutGroup>();
layout.spacing = 8;
layout.childAlignment = TextAnchor.MiddleCenter;
layout.childControlHeight = false;
layout.childControlWidth = true;
LayoutElement layoutElement = progressSection.AddComponent<LayoutElement>();
layoutElement.preferredHeight = 50;
// Progress Text
GameObject progressTextObj = new GameObject("ProgressText");
progressTextObj.transform.SetParent(progressSection.transform, false);
RectTransform textRect = progressTextObj.AddComponent<RectTransform>();
textRect.sizeDelta = new Vector2(720, 24);
TextMeshProUGUI progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
progressText.text = "Step 1 of 1";
progressText.fontSize = 20;
progressText.color = TextColor;
progressText.alignment = TextAlignmentOptions.Center;
if (font != null) progressText.font = font;
LayoutElement textLayout = progressTextObj.AddComponent<LayoutElement>();
textLayout.preferredHeight = 24;
// Progress Slider
CreateProgressSlider(progressSection.transform);
}
private static void CreateProgressSlider(Transform parent) {
GameObject sliderObj = new GameObject("ProgressSlider");
sliderObj.transform.SetParent(parent, false);
RectTransform sliderRect = sliderObj.AddComponent<RectTransform>();
sliderRect.sizeDelta = new Vector2(400, 12);
Slider slider = sliderObj.AddComponent<Slider>();
slider.minValue = 0;
slider.maxValue = 1;
slider.value = 0;
slider.interactable = false;
LayoutElement sliderLayout = sliderObj.AddComponent<LayoutElement>();
sliderLayout.preferredHeight = 12;
sliderLayout.preferredWidth = 400;
// Background
GameObject bgObj = new GameObject("Background");
bgObj.transform.SetParent(sliderObj.transform, false);
RectTransform bgRect = bgObj.AddComponent<RectTransform>();
bgRect.anchorMin = Vector2.zero;
bgRect.anchorMax = Vector2.one;
bgRect.offsetMin = Vector2.zero;
bgRect.offsetMax = Vector2.zero;
Image bgImage = bgObj.AddComponent<Image>();
bgImage.color = ProgressBackgroundColor;
// Fill Area
GameObject fillArea = new GameObject("Fill Area");
fillArea.transform.SetParent(sliderObj.transform, false);
RectTransform fillAreaRect = fillArea.AddComponent<RectTransform>();
fillAreaRect.anchorMin = Vector2.zero;
fillAreaRect.anchorMax = Vector2.one;
fillAreaRect.offsetMin = Vector2.zero;
fillAreaRect.offsetMax = Vector2.zero;
// Fill
GameObject fill = new GameObject("Fill");
fill.transform.SetParent(fillArea.transform, false);
RectTransform fillRect = fill.AddComponent<RectTransform>();
fillRect.anchorMin = Vector2.zero;
fillRect.anchorMax = new Vector2(0, 1);
fillRect.offsetMin = Vector2.zero;
fillRect.offsetMax = Vector2.zero;
Image fillImage = fill.AddComponent<Image>();
fillImage.color = ProgressBarColor;
// Wire up slider
slider.fillRect = fillRect;
}
private static void SetupButtonListeners(TutorialModalPanel panel) {
// Button listeners need to be set up after wiring since Awake runs before fields exist
if (panel.ContinueButton != null) {
panel.ContinueButton.onClick.RemoveAllListeners();
panel.ContinueButton.onClick.AddListener(panel.OnContinueClicked);
}
if (panel.SkipButton != null) {
panel.SkipButton.onClick.RemoveAllListeners();
panel.SkipButton.onClick.AddListener(panel.OnSkipClicked);
}
if (panel.SkipAllButton != null) {
panel.SkipAllButton.onClick.RemoveAllListeners();
panel.SkipAllButton.onClick.AddListener(panel.OnSkipAllClicked);
}
}
private static void CreateButtonRow(Transform parent, TMP_FontAsset font) {
GameObject buttonRow = new GameObject("ButtonRow");
buttonRow.transform.SetParent(parent, false);
RectTransform rect = buttonRow.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(720, 60);
HorizontalLayoutGroup layout = buttonRow.AddComponent<HorizontalLayoutGroup>();
layout.spacing = 20;
layout.childAlignment = TextAnchor.MiddleCenter;
layout.childControlHeight = false;
layout.childControlWidth = false;
layout.childForceExpandHeight = false;
layout.childForceExpandWidth = false;
LayoutElement layoutElement = buttonRow.AddComponent<LayoutElement>();
layoutElement.preferredHeight = 60;
// Skip Button (left)
CreateButton(buttonRow.transform, "SkipButton", "Skip", 150, 50, font);
// Skip All Button
CreateButton(buttonRow.transform, "SkipAllButton", "Skip All", 150, 50, font);
// Spacer
GameObject spacer = new GameObject("ButtonSpacer");
spacer.transform.SetParent(buttonRow.transform, false);
spacer.AddComponent<RectTransform>();
LayoutElement spacerLayout = spacer.AddComponent<LayoutElement>();
spacerLayout.flexibleWidth = 1;
// Continue Button (right)
CreateButton(buttonRow.transform, "ContinueButton", "Continue", 180, 50, font);
}
private static GameObject CreateButton(
Transform parent,
string name,
string text,
float width,
float height,
TMP_FontAsset font) {
GameObject buttonObj = new GameObject(name);
buttonObj.transform.SetParent(parent, false);
RectTransform rect = buttonObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(width, height);
Image image = buttonObj.AddComponent<Image>();
image.color = ButtonColor;
Button button = buttonObj.AddComponent<Button>();
ColorBlock colors = button.colors;
colors.normalColor = ButtonColor;
colors.highlightedColor = ButtonHoverColor;
colors.pressedColor = ButtonHoverColor;
colors.selectedColor = ButtonHoverColor;
button.colors = colors;
// Add outline
Outline outline = buttonObj.AddComponent<Outline>();
outline.effectColor = BorderColor;
outline.effectDistance = new Vector2(2, 2);
LayoutElement layoutElement = buttonObj.AddComponent<LayoutElement>();
layoutElement.preferredWidth = width;
layoutElement.preferredHeight = height;
// Button text
GameObject textObj = new GameObject("Text");
textObj.transform.SetParent(buttonObj.transform, false);
RectTransform textRect = textObj.AddComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = Vector2.zero;
textRect.offsetMax = Vector2.zero;
TextMeshProUGUI buttonText = textObj.AddComponent<TextMeshProUGUI>();
buttonText.text = text;
buttonText.fontSize = 24;
buttonText.color = ButtonTextColor;
buttonText.alignment = TextAlignmentOptions.Center;
if (font != null) buttonText.font = font;
return buttonObj;
}
private static void WireUpModalPanel(
TutorialModalPanel panel,
GameObject blocker,
GameObject panelContainer,
TMP_FontAsset font) {
panel.ModalBlocker = blocker;
panel.PanelContainer = panelContainer;
// Find and wire up components
Transform containerTransform = panelContainer.transform;
// Title
Transform titleTransform = containerTransform.Find("TitleText");
if (titleTransform != null) {
panel.TitleText = titleTransform.GetComponent<TextMeshProUGUI>();
}
// Icon
Transform iconTransform = containerTransform.Find("IconImage");
if (iconTransform != null) { panel.IconImage = iconTransform.GetComponent<Image>(); }
// Description
Transform descTransform = containerTransform.Find("DescriptionText");
if (descTransform != null) {
panel.DescriptionText = descTransform.GetComponent<TextMeshProUGUI>();
}
// Progress Section
Transform progressSection = containerTransform.Find("ProgressSection");
if (progressSection != null) {
Transform progressTextTransform = progressSection.Find("ProgressText");
if (progressTextTransform != null) {
panel.ProgressText = progressTextTransform.GetComponent<TextMeshProUGUI>();
}
Transform sliderTransform = progressSection.Find("ProgressSlider");
if (sliderTransform != null) {
panel.ProgressSlider = sliderTransform.GetComponent<Slider>();
}
}
// Button Row
Transform buttonRow = containerTransform.Find("ButtonRow");
if (buttonRow != null) {
Transform skipTransform = buttonRow.Find("SkipButton");
if (skipTransform != null) {
panel.SkipButton = skipTransform.GetComponent<Button>();
}
Transform skipAllTransform = buttonRow.Find("SkipAllButton");
if (skipAllTransform != null) {
panel.SkipAllButton = skipAllTransform.GetComponent<Button>();
}
Transform continueTransform = buttonRow.Find("ContinueButton");
if (continueTransform != null) {
panel.ContinueButton = continueTransform.GetComponent<Button>();
Transform continueTextTransform = continueTransform.Find("Text");
if (continueTextTransform != null) {
panel.ContinueButtonText =
continueTextTransform.GetComponent<TextMeshProUGUI>();
}
}
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 98467fdbaf584fbb928a373340b542d7
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -112,7 +112,8 @@ namespace Eagle0.Tutorial {
ProgressSlider.gameObject.SetActive(totalSteps > 1);
}
// Show panel
// Show panel - ensure all parent containers are active first
ActivateParents();
if (ModalBlocker != null) { ModalBlocker.SetActive(true); }
if (PanelContainer != null) { PanelContainer.SetActive(true); }
gameObject.SetActive(true);
@@ -121,6 +122,17 @@ namespace Eagle0.Tutorial {
if (PanelAnimator != null) { PanelAnimator.SetTrigger("Show"); }
}
/// <summary>
/// Activates all parent GameObjects to ensure this object can be active in hierarchy.
/// </summary>
private void ActivateParents() {
Transform parent = transform.parent;
while (parent != null) {
if (!parent.gameObject.activeSelf) { parent.gameObject.SetActive(true); }
parent = parent.parent;
}
}
/// <summary>
/// Hides the modal panel.
/// </summary>
@@ -135,19 +147,31 @@ namespace Eagle0.Tutorial {
_onSkip = null;
}
private void OnContinueClicked() {
/// <summary>
/// Called when Continue button is clicked.
/// Public to allow external setup of button listeners.
/// </summary>
public void OnContinueClicked() {
var callback = _onContinue;
Hide();
callback?.Invoke();
}
private void OnSkipClicked() {
/// <summary>
/// Called when Skip button is clicked.
/// Public to allow external setup of button listeners.
/// </summary>
public void OnSkipClicked() {
var callback = _onSkip;
Hide();
callback?.Invoke();
}
private void OnSkipAllClicked() {
/// <summary>
/// Called when Skip All button is clicked.
/// Public to allow external setup of button listeners.
/// </summary>
public void OnSkipAllClicked() {
Hide();
TutorialManager.Instance?.SkipAllOnboarding();
}
@@ -0,0 +1,391 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Eagle0.Tutorial {
/// <summary>
/// Builds the tutorial overlay UI programmatically at runtime.
/// Creates highlight frame, tooltip, and dimmer components for TutorialOverlayController.
/// </summary>
public static class TutorialOverlayBuilder {
// Colors matching the game's fantasy RPG style (same as TutorialCanvasBuilder)
private static readonly Color DimmerColor = new Color(0f, 0f, 0f, 0.7f);
private static readonly Color HighlightBorderColor =
new Color(1f, 0.85f, 0.4f, 1f); // Gold
private static readonly Color TooltipBackgroundColor = new Color(0.15f, 0.12f, 0.2f, 0.95f);
private static readonly Color TooltipBorderColor = new Color(0.6f, 0.5f, 0.3f, 1f);
private static readonly Color TitleColor = new Color(1f, 0.9f, 0.7f, 1f);
private static readonly Color TextColor = new Color(0.9f, 0.85f, 0.8f, 1f);
private static readonly Color ButtonColor = new Color(0.4f, 0.3f, 0.2f, 1f);
private static readonly Color ButtonHoverColor = new Color(0.5f, 0.4f, 0.25f, 1f);
private static readonly Color ButtonTextColor = new Color(1f, 0.95f, 0.85f, 1f);
private static readonly Color ArrowColor = new Color(1f, 0.85f, 0.4f, 1f); // Gold arrow
/// <summary>
/// Builds the overlay UI components and returns the configured controller.
/// </summary>
/// <param name="parent">Parent transform (typically the TutorialCanvas)</param>
/// <param name="font">TextMeshPro font to use</param>
/// <returns>The configured TutorialOverlayController</returns>
public static TutorialOverlayController BuildOverlayUI(
Transform parent,
TMP_FontAsset font) {
// Create overlay container - this will be the controller's gameObject
// so show/hide works via gameObject.SetActive()
GameObject overlayContainer = new GameObject("OverlayContainer");
overlayContainer.transform.SetParent(parent, false);
RectTransform containerRect = overlayContainer.AddComponent<RectTransform>();
containerRect.anchorMin = Vector2.zero;
containerRect.anchorMax = Vector2.one;
containerRect.offsetMin = Vector2.zero;
containerRect.offsetMax = Vector2.zero;
// Add the controller component to this container
TutorialOverlayController controller =
overlayContainer.AddComponent<TutorialOverlayController>();
// Create background dimmer
GameObject dimmer = CreateBackgroundDimmer(overlayContainer.transform);
controller.BackgroundDimmer = dimmer.GetComponent<Image>();
// Create highlight frame
GameObject highlightFrame = CreateHighlightFrame(overlayContainer.transform);
controller.HighlightFrame = highlightFrame.GetComponent<RectTransform>();
// Create tooltip container
GameObject tooltip = CreateTooltipContainer(overlayContainer.transform, font);
controller.TooltipContainer = tooltip.GetComponent<RectTransform>();
// Wire up tooltip components
WireUpTooltipComponents(controller, tooltip, font);
// Start hidden
overlayContainer.SetActive(false);
Debug.Log("TutorialOverlayBuilder: Built overlay UI at runtime");
return controller;
}
private static GameObject CreateBackgroundDimmer(Transform parent) {
GameObject dimmer = new GameObject("BackgroundDimmer");
dimmer.transform.SetParent(parent, false);
RectTransform rect = dimmer.AddComponent<RectTransform>();
rect.anchorMin = Vector2.zero;
rect.anchorMax = Vector2.one;
rect.offsetMin = Vector2.zero;
rect.offsetMax = Vector2.zero;
Image image = dimmer.AddComponent<Image>();
image.color = DimmerColor;
image.raycastTarget = true;
return dimmer;
}
private static GameObject CreateHighlightFrame(Transform parent) {
// Create highlight frame with border effect
GameObject frame = new GameObject("HighlightFrame");
frame.transform.SetParent(parent, false);
RectTransform rect = frame.AddComponent<RectTransform>();
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.sizeDelta = new Vector2(200, 100); // Default size, will be adjusted
// Create border using 4 edge images
CreateFrameBorder(
frame.transform,
"TopBorder",
new Vector2(0, 0.5f),
new Vector2(1, 0.5f),
new Vector2(0, -2),
new Vector2(0, 2),
true);
CreateFrameBorder(
frame.transform,
"BottomBorder",
new Vector2(0, -0.5f),
new Vector2(1, -0.5f),
new Vector2(0, -2),
new Vector2(0, 2),
true);
CreateFrameBorder(
frame.transform,
"LeftBorder",
new Vector2(-0.5f, 0),
new Vector2(-0.5f, 1),
new Vector2(-2, 0),
new Vector2(2, 0),
false);
CreateFrameBorder(
frame.transform,
"RightBorder",
new Vector2(0.5f, 0),
new Vector2(0.5f, 1),
new Vector2(-2, 0),
new Vector2(2, 0),
false);
// Add corner decorations
CreateCornerDecoration(
frame.transform,
"TopLeftCorner",
new Vector2(0, 1),
new Vector2(-8, 8));
CreateCornerDecoration(
frame.transform,
"TopRightCorner",
new Vector2(1, 1),
new Vector2(8, 8));
CreateCornerDecoration(
frame.transform,
"BottomLeftCorner",
new Vector2(0, 0),
new Vector2(-8, -8));
CreateCornerDecoration(
frame.transform,
"BottomRightCorner",
new Vector2(1, 0),
new Vector2(8, -8));
return frame;
}
private static void CreateFrameBorder(
Transform parent,
string name,
Vector2 anchorMin,
Vector2 anchorMax,
Vector2 offsetMin,
Vector2 offsetMax,
bool horizontal) {
GameObject border = new GameObject(name);
border.transform.SetParent(parent, false);
RectTransform rect = border.AddComponent<RectTransform>();
if (horizontal) {
rect.anchorMin = new Vector2(0, anchorMin.y + 0.5f);
rect.anchorMax = new Vector2(1, anchorMax.y + 0.5f);
rect.offsetMin = new Vector2(0, offsetMin.y);
rect.offsetMax = new Vector2(0, offsetMax.y);
} else {
rect.anchorMin = new Vector2(anchorMin.x + 0.5f, 0);
rect.anchorMax = new Vector2(anchorMax.x + 0.5f, 1);
rect.offsetMin = new Vector2(offsetMin.x, 0);
rect.offsetMax = new Vector2(offsetMax.x, 0);
}
Image image = border.AddComponent<Image>();
image.color = HighlightBorderColor;
image.raycastTarget = false;
}
private static void
CreateCornerDecoration(Transform parent, string name, Vector2 anchor, Vector2 offset) {
GameObject corner = new GameObject(name);
corner.transform.SetParent(parent, false);
RectTransform rect = corner.AddComponent<RectTransform>();
rect.anchorMin = anchor;
rect.anchorMax = anchor;
rect.sizeDelta = new Vector2(16, 16);
rect.anchoredPosition = offset;
Image image = corner.AddComponent<Image>();
image.color = HighlightBorderColor;
image.raycastTarget = false;
}
private static GameObject CreateTooltipContainer(Transform parent, TMP_FontAsset font) {
GameObject tooltip = new GameObject("TooltipContainer");
tooltip.transform.SetParent(parent, false);
RectTransform rect = tooltip.AddComponent<RectTransform>();
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.sizeDelta = new Vector2(400, 200);
rect.anchoredPosition = new Vector2(0, -150); // Below center by default
// Background
Image bg = tooltip.AddComponent<Image>();
bg.color = TooltipBackgroundColor;
bg.raycastTarget = true;
// Border
Outline outline = tooltip.AddComponent<Outline>();
outline.effectColor = TooltipBorderColor;
outline.effectDistance = new Vector2(2, 2);
// Layout
VerticalLayoutGroup layout = tooltip.AddComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(20, 20, 15, 15);
layout.spacing = 10;
layout.childAlignment = TextAnchor.UpperCenter;
layout.childControlHeight = false;
layout.childControlWidth = true;
layout.childForceExpandHeight = false;
layout.childForceExpandWidth = true;
// Content size fitter to auto-size based on content
ContentSizeFitter fitter = tooltip.AddComponent<ContentSizeFitter>();
fitter.horizontalFit = ContentSizeFitter.FitMode.PreferredSize;
fitter.verticalFit = ContentSizeFitter.FitMode.PreferredSize;
// Title
CreateTooltipTitle(tooltip.transform, font);
// Description
CreateTooltipDescription(tooltip.transform, font);
// Continue button
CreateTooltipButton(tooltip.transform, font);
// Arrow pointer (separate from tooltip, positioned independently)
CreateArrowPointer(parent);
return tooltip;
}
private static void CreateTooltipTitle(Transform parent, TMP_FontAsset font) {
GameObject titleObj = new GameObject("TooltipTitle");
titleObj.transform.SetParent(parent, false);
RectTransform rect = titleObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(360, 35);
TextMeshProUGUI text = titleObj.AddComponent<TextMeshProUGUI>();
text.text = "";
text.fontSize = 28;
text.fontStyle = FontStyles.Bold;
text.color = TitleColor;
text.alignment = TextAlignmentOptions.Center;
text.enableWordWrapping = true;
if (font != null) text.font = font;
LayoutElement layout = titleObj.AddComponent<LayoutElement>();
layout.preferredHeight = 35;
layout.minWidth = 200;
}
private static void CreateTooltipDescription(Transform parent, TMP_FontAsset font) {
GameObject descObj = new GameObject("TooltipDescription");
descObj.transform.SetParent(parent, false);
RectTransform rect = descObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(360, 80);
TextMeshProUGUI text = descObj.AddComponent<TextMeshProUGUI>();
text.text = "";
text.fontSize = 20;
text.color = TextColor;
text.alignment = TextAlignmentOptions.TopLeft;
text.enableWordWrapping = true;
text.overflowMode = TextOverflowModes.Overflow;
if (font != null) text.font = font;
LayoutElement layout = descObj.AddComponent<LayoutElement>();
layout.preferredHeight = 80;
layout.minWidth = 200;
layout.flexibleHeight = 1;
}
private static void CreateTooltipButton(Transform parent, TMP_FontAsset font) {
GameObject buttonObj = new GameObject("ContinueButton");
buttonObj.transform.SetParent(parent, false);
RectTransform rect = buttonObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(140, 40);
Image image = buttonObj.AddComponent<Image>();
image.color = ButtonColor;
Button button = buttonObj.AddComponent<Button>();
ColorBlock colors = button.colors;
colors.normalColor = ButtonColor;
colors.highlightedColor = ButtonHoverColor;
colors.pressedColor = ButtonHoverColor;
colors.selectedColor = ButtonHoverColor;
button.colors = colors;
Outline outline = buttonObj.AddComponent<Outline>();
outline.effectColor = TooltipBorderColor;
outline.effectDistance = new Vector2(1, 1);
LayoutElement layout = buttonObj.AddComponent<LayoutElement>();
layout.preferredWidth = 140;
layout.preferredHeight = 40;
// Button text
GameObject textObj = new GameObject("Text");
textObj.transform.SetParent(buttonObj.transform, false);
RectTransform textRect = textObj.AddComponent<RectTransform>();
textRect.anchorMin = Vector2.zero;
textRect.anchorMax = Vector2.one;
textRect.offsetMin = Vector2.zero;
textRect.offsetMax = Vector2.zero;
TextMeshProUGUI buttonText = textObj.AddComponent<TextMeshProUGUI>();
buttonText.text = "Got it";
buttonText.fontSize = 20;
buttonText.color = ButtonTextColor;
buttonText.alignment = TextAlignmentOptions.Center;
if (font != null) buttonText.font = font;
}
private static void CreateArrowPointer(Transform parent) {
// Create arrow pointing from tooltip to target
GameObject arrow = new GameObject("ArrowPointer");
arrow.transform.SetParent(parent, false);
RectTransform rect = arrow.AddComponent<RectTransform>();
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.sizeDelta = new Vector2(30, 30);
Image image = arrow.AddComponent<Image>();
image.color = ArrowColor;
image.raycastTarget = false;
// Note: In a real implementation, you'd use a triangle sprite
// For now, we'll use a rotated square as a simple arrow indicator
}
private static void WireUpTooltipComponents(
TutorialOverlayController controller,
GameObject tooltip,
TMP_FontAsset font) {
Transform tooltipTransform = tooltip.transform;
// Title
Transform titleTransform = tooltipTransform.Find("TooltipTitle");
if (titleTransform != null) {
controller.TooltipTitle = titleTransform.GetComponent<TextMeshProUGUI>();
}
// Description
Transform descTransform = tooltipTransform.Find("TooltipDescription");
if (descTransform != null) {
controller.TooltipDescription = descTransform.GetComponent<TextMeshProUGUI>();
}
// Continue button
Transform buttonTransform = tooltipTransform.Find("ContinueButton");
if (buttonTransform != null) {
controller.ContinueButton = buttonTransform.GetComponent<Button>();
// Wire up click listener
controller.ContinueButton.onClick.RemoveAllListeners();
controller.ContinueButton.onClick.AddListener(controller.OnContinueClicked);
}
// Arrow pointer (sibling of tooltip)
Transform arrowTransform = tooltip.transform.parent.Find("ArrowPointer");
if (arrowTransform != null) {
controller.ArrowPointer = arrowTransform.GetComponent<Image>();
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d58277ecfc2d84d30a7ca71b8ccb994e
@@ -65,8 +65,8 @@ namespace Eagle0.Tutorial {
if (ContinueButton != null) { ContinueButton.onClick.AddListener(OnContinueClicked); }
// Start hidden
gameObject.SetActive(false);
// Note: Don't hide here - let the builder or manual setup handle initial visibility.
// When built at runtime, TutorialOverlayBuilder sets inactive after wiring up.
}
/// <summary>
@@ -100,7 +100,8 @@ namespace Eagle0.Tutorial {
CenterTooltip();
}
// Show
// Show - ensure all parent containers are active first
ActivateParents();
gameObject.SetActive(true);
StartCoroutine(FadeIn());
@@ -123,6 +124,8 @@ namespace Eagle0.Tutorial {
if (TooltipContainer != null) { TooltipContainer.gameObject.SetActive(false); }
if (BackgroundDimmer != null) { BackgroundDimmer.gameObject.SetActive(false); }
// Ensure all parent containers are active first
ActivateParents();
gameObject.SetActive(true);
_pulseCoroutine = StartCoroutine(PulseHighlight());
}
@@ -150,7 +153,15 @@ namespace Eagle0.Tutorial {
_pulseCoroutine = null;
}
StartCoroutine(FadeOut());
// If we can start coroutines, fade out nicely
if (gameObject.activeInHierarchy) {
StartCoroutine(FadeOut());
} else {
// Otherwise just deactivate immediately
gameObject.SetActive(false);
_currentTarget = null;
_onComplete = null;
}
}
private void PositionHighlight(Transform target) {
@@ -261,7 +272,11 @@ namespace Eagle0.Tutorial {
}
}
private void OnContinueClicked() {
/// <summary>
/// Called when the continue button is clicked.
/// Made public for external button wiring.
/// </summary>
public void OnContinueClicked() {
var callback = _onComplete;
HideOverlay();
callback?.Invoke();
@@ -274,5 +289,16 @@ namespace Eagle0.Tutorial {
PositionHighlight(_currentTarget);
}
}
/// <summary>
/// Activates all parent GameObjects to ensure this object can be active in hierarchy.
/// </summary>
private void ActivateParents() {
Transform parent = transform.parent;
while (parent != null) {
if (!parent.gameObject.activeSelf) { parent.gameObject.SetActive(true); }
parent = parent.parent;
}
}
}
}
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
namespace Eagle0.Tutorial {
@@ -22,13 +23,21 @@ namespace Eagle0.Tutorial {
[Tooltip("Canvas for tutorial UI (should be above game UI)")]
public Canvas TutorialCanvas;
[Header("Fonts")]
[Tooltip("TextMeshPro font for Canvas UI (assign Stoke-Regular-SDF)")]
public TMP_FontAsset CanvasFont;
[Header("Fallback UI")]
[Tooltip("Use IMGUI fallback when no ModalPanel is assigned")]
[Tooltip("Use IMGUI fallback when Canvas UI cannot be built")]
public bool UseFallbackUI = true;
[Tooltip("Font for IMGUI fallback (assign Stoke-Regular)")]
public Font FallbackFont;
[Header("Runtime Options")]
[Tooltip("Auto-build Canvas UI at runtime if ModalPanel is not assigned")]
public bool AutoBuildCanvasUI = true;
// Active hints
private Dictionary<string, TutorialHintIndicator> _activeHints =
new Dictionary<string, TutorialHintIndicator>();
@@ -43,12 +52,57 @@ namespace Eagle0.Tutorial {
private bool _fallbackIsOnboarding;
private void Awake() {
// Auto-build Canvas UI if needed
if (ModalPanel == null && AutoBuildCanvasUI) { BuildCanvasUI(); }
// Auto-build overlay UI if needed
if (OverlayController == null && AutoBuildCanvasUI && TutorialCanvas != null) {
BuildOverlayUI();
}
// Ensure canvas is set up correctly
if (TutorialCanvas != null) {
TutorialCanvas.sortingOrder = 1000; // Above most game UI
}
}
/// <summary>
/// Builds the Canvas-based tutorial UI at runtime.
/// Called automatically if AutoBuildCanvasUI is true and ModalPanel is not assigned.
/// </summary>
private void BuildCanvasUI() {
TutorialCanvas = TutorialCanvasBuilder.BuildTutorialCanvas(CanvasFont);
if (TutorialCanvas != null) {
// Make canvas a child of this object so it persists with TutorialManager
TutorialCanvas.transform.SetParent(transform, false);
// Get the modal panel that was created
ModalPanel = TutorialCanvas.GetComponent<TutorialModalPanel>();
Debug.Log("TutorialUIManager: Built Canvas UI at runtime");
} else {
Debug.LogWarning("TutorialUIManager: Failed to build Canvas UI");
}
}
/// <summary>
/// Builds the overlay UI components at runtime.
/// Called automatically if AutoBuildCanvasUI is true and OverlayController is not assigned.
/// </summary>
private void BuildOverlayUI() {
if (TutorialCanvas == null) {
Debug.LogWarning(
"TutorialUIManager: Cannot build overlay UI without TutorialCanvas");
return;
}
// Build the UI elements and get the controller
OverlayController =
TutorialOverlayBuilder.BuildOverlayUI(TutorialCanvas.transform, CanvasFont);
Debug.Log("TutorialUIManager: Built overlay UI at runtime");
}
private void OnGUI() {
if (!_fallbackModalVisible || _fallbackStep == null) return;
@@ -186,6 +240,10 @@ namespace Eagle0.Tutorial {
Action onSkip,
bool isOnboarding) {
if (ModalPanel != null) {
// Ensure canvas is active
if (TutorialCanvas != null && !TutorialCanvas.gameObject.activeSelf) {
TutorialCanvas.gameObject.SetActive(true);
}
ModalPanel.Show(step, currentIndex, totalSteps, onComplete, onSkip, isOnboarding);
} else if (UseFallbackUI) {
ShowFallbackModal(step, currentIndex, totalSteps, onComplete, onSkip, isOnboarding);
@@ -136,9 +136,6 @@
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\common\unaffiliated_hero_type.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\">
<Link>src\main\protobuf\net\eagle0\eagle\common\unaffiliated_hero_type.proto</Link>
</Protobuf>
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\internal\unaffiliated_hero.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\">
<Link>src\main\protobuf\net\eagle0\eagle\internal\unaffiliated_hero.proto</Link>
</Protobuf>
<Protobuf Include="..\..\..\..\..\..\..\..\..\src\main\protobuf\net\eagle0\eagle\common\chronicle_entry.proto" ProtoRoot="..\..\..\..\..\..\..\..\..\">
<Link>src\main\protobuf\net\eagle0\eagle\common\chronicle_entry.proto</Link>
</Protobuf>
@@ -14,6 +14,11 @@
<EnableWindowsTargeting>true</EnableWindowsTargeting>
</PropertyGroup>
<ItemGroup>
<!-- Ed25519 signature verification for manifest -->
<PackageReference Include="NSec.Cryptography" Version="24.4.0" />
</ItemGroup>
<ItemGroup>
<None Remove="AWSSDK.S3" />
</ItemGroup>
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Security.Cryptography;
using System.Threading.Tasks;
using System.Threading;
@@ -10,6 +11,9 @@ namespace EagleInstaller {
internal class UpdaterFailureException
(string message) : Exception(message);
internal class ManifestSecurityException
(string message) : Exception(message);
public class InstallerUpdateInfo {
public bool InstallerNeedsUpdate { get; set; }
public string NewInstallerVersion { get; set; }
@@ -66,14 +70,105 @@ namespace EagleInstaller {
using (StringReader sr = new StringReader(configText)) {
string line;
while ((line = sr.ReadLine()) != null) {
var components = line.Split(" = ");
dict.Add(components[0], components[1]);
// Skip empty lines and comments
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith("#")) {
continue;
}
var idx = line.IndexOf(" = ");
if (idx > 0) {
var key = line.Substring(0, idx);
var value = line.Substring(idx + 3);
dict[key] = value;
}
}
}
return dict;
}
// Ed25519 public key for manifest signature verification (base64 encoded)
// Generated with: go run scripts/generate_manifest_keys.go
// This key should be updated when a new key pair is generated
private static readonly string ManifestPublicKeyB64 =
ConfigFileEntries.TryGetValue("manifest_public_key", out var key) ? key : null;
/// <summary>
/// Verifies the Ed25519 signature on a manifest.
/// Returns the manifest content (without signature line) if valid.
/// Throws ManifestSecurityException if signature is missing, invalid, or cannot be
/// verified.
/// </summary>
private static string VerifyManifestSignature(string manifestText) {
if (string.IsNullOrEmpty(manifestText)) {
throw new ManifestSecurityException("Manifest is empty");
}
// Require public key to be configured
if (string.IsNullOrEmpty(ManifestPublicKeyB64)) {
Logger.WriteError(
"SECURITY ERROR: No public key configured for manifest verification");
throw new ManifestSecurityException(
"No public key configured for manifest verification");
}
// Check for signature line at start: # signature=<base64>
var lines = manifestText.Split('\n');
if (lines.Length == 0 || !lines[0].TrimStart().StartsWith("# signature=")) {
Logger.WriteError("SECURITY ERROR: Manifest is not signed");
throw new ManifestSecurityException("Manifest is not signed");
}
// Extract signature
string signatureLine = lines[0];
string signatureB64 = signatureLine.Substring(signatureLine.IndexOf('=') + 1).Trim();
// Content is everything after the signature line
string content = string.Join('\n', lines.Skip(1));
try {
byte[] signatureBytes = Convert.FromBase64String(signatureB64);
byte[] publicKeyBytes = Convert.FromBase64String(ManifestPublicKeyB64);
byte[] contentBytes = System.Text.Encoding.UTF8.GetBytes(content);
// Verify using Ed25519
bool valid = VerifyEd25519Signature(publicKeyBytes, contentBytes, signatureBytes);
if (valid) {
Logger.WriteLine("Manifest signature verified successfully");
return content;
} else {
Logger.WriteError("SECURITY ERROR: Manifest signature is INVALID!");
Logger.WriteError("This could indicate a compromised or tampered manifest.");
throw new ManifestSecurityException("Manifest signature verification failed");
}
} catch (ManifestSecurityException) {
throw; // Re-throw security exceptions
} catch (Exception e) {
Logger.WriteError(
$"SECURITY ERROR: Failed to verify manifest signature: {e.Message}");
throw new ManifestSecurityException(
$"Manifest signature verification error: {e.Message}");
}
}
/// <summary>
/// Verifies an Ed25519 signature using the NSec library.
/// </summary>
private static bool
VerifyEd25519Signature(byte[] publicKeyBytes, byte[] data, byte[] signature) {
try {
var algorithm = NSec.Cryptography.SignatureAlgorithm.Ed25519;
var publicKey = NSec.Cryptography.PublicKey.Import(
algorithm,
publicKeyBytes,
NSec.Cryptography.KeyBlobFormat.RawPublicKey);
return algorithm.Verify(publicKey, data, signature);
} catch (Exception e) {
Logger.WriteError($"Ed25519 verification error: {e.Message}");
return false;
}
}
public EagleUpdater(string serverUrl) {
if (string.IsNullOrEmpty(serverUrl)) {
throw new ArgumentNullException(nameof(serverUrl));
@@ -246,7 +341,11 @@ namespace EagleInstaller {
response.EnsureSuccessStatusCode();
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
using StreamReader reader = new StreamReader(responseStream);
return await reader.ReadToEndAsync();
string rawManifest = await reader.ReadToEndAsync();
// Verify signature and extract content (strips signature line if present)
// Throws ManifestSecurityException if public key is configured and verification fails
return VerifyManifestSignature(rawManifest);
}
static Dictionary<String, String> ShasFromText(string text) {
@@ -277,6 +376,7 @@ namespace EagleInstaller {
async Task<FetchAttempt>
FetchAndWriteOne(string remotePath, string localDir, string expectedSha) {
string writePath = WritePathFromRemotePath(remotePath, localDir);
string tempPath = writePath + ".downloading";
Directory.CreateDirectory(Path.GetDirectoryName(writePath));
@@ -288,41 +388,67 @@ namespace EagleInstaller {
response.EnsureSuccessStatusCode();
await using Stream responseStream = await response.Content.ReadAsStreamAsync();
await using FileStream fileStream = File.Create(writePath);
using MemoryStream memStream = new MemoryStream();
await responseStream.CopyToAsync(memStream);
memStream.Position = 0;
var sha = System.Security.Cryptography.SHA256.Create();
string computedSha = BitConverter.ToString(sha.ComputeHash(memStream))
.Replace("-", string.Empty)
.ToLower();
// Stream directly to disk while computing SHA256 incrementally
using var sha = System.Security.Cryptography.SHA256.Create();
await using (FileStream fileStream = File.Create(tempPath)) {
var buffer = new byte[81920]; // 80KB buffer
int bytesRead;
while ((bytesRead = await responseStream.ReadAsync(buffer, 0, buffer.Length)) >
0) {
// Write to disk
await fileStream.WriteAsync(buffer, 0, bytesRead);
// Update hash incrementally
sha.TransformBlock(buffer, 0, bytesRead, null, 0);
}
}
// Finalize hash computation
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
string computedSha =
BitConverter.ToString(sha.Hash).Replace("-", string.Empty).ToLower();
if (computedSha.Equals(expectedSha)) {
// SHA matches - rename temp file to final location
if (File.Exists(writePath)) { File.Delete(writePath); }
File.Move(tempPath, writePath);
Logger.WriteLine($"✓ Verified and saved: {remotePath}");
memStream.Position = 0;
await memStream.CopyToAsync(fileStream);
return new FetchAttempt {
Success = true,
Kvp = new KeyValuePair<String, String>(remotePath, computedSha)
};
} else {
// SHA mismatch - delete temp file
try {
File.Delete(tempPath);
} catch {}
Logger.WriteError($"✗ SHA mismatch for {remotePath}");
return new FetchAttempt() { Success = false };
}
} catch (TaskCanceledException e) {
// Clean up temp file on failure
try {
File.Delete(tempPath);
} catch {}
Logger.WriteError($"Download cancelled: {e.Message}");
return new FetchAttempt() { Success = false };
} catch (HttpRequestException e) {
try {
File.Delete(tempPath);
} catch {}
Logger.WriteError($"Network error: {e.Message}");
return new FetchAttempt() { Success = false };
} catch (Exception e) {
try {
File.Delete(tempPath);
} catch {}
Logger.WriteError($"Download failed: {e.Message}");
return new FetchAttempt() { Success = false };
} finally { Pool.Release(); }
}
public async Task<bool> DownloadAndLaunchNewInstaller(string installerUrl) {
public async Task<bool> DownloadAndLaunchNewInstaller(
string installerUrl,
string expectedSha) {
try {
Logger.UpdateStatus("Downloading new installer...");
@@ -372,6 +498,28 @@ namespace EagleInstaller {
}
}
// Verify SHA256 before launching
Logger.UpdateStatus("Verifying installer integrity...");
string computedSha;
using (var sha256 = System.Security.Cryptography.SHA256.Create()) using (
var stream = File.OpenRead(newInstallerPath)) {
var hashBytes = sha256.ComputeHash(stream);
computedSha =
BitConverter.ToString(hashBytes).Replace("-", string.Empty).ToLower();
}
if (!computedSha.Equals(expectedSha, StringComparison.OrdinalIgnoreCase)) {
Logger.WriteError($"SECURITY ERROR: Installer SHA mismatch!");
Logger.WriteError($" Expected: {expectedSha}");
Logger.WriteError($" Got: {computedSha}");
// Delete the corrupted/tampered file
try {
File.Delete(newInstallerPath);
} catch {}
return false;
}
Logger.WriteLine($"✓ Installer verified: SHA256 matches");
// Wait a moment to ensure file handles are released
await Task.Delay(500);
@@ -110,7 +110,8 @@ namespace EagleInstaller {
if (installerUpdateInfo.InstallerNeedsUpdate) {
Logger.UpdateStatus("Installer update required...");
bool downloadSuccess = await updater.DownloadAndLaunchNewInstaller(
installerUpdateInfo.NewInstallerUrl);
installerUpdateInfo.NewInstallerUrl,
installerUpdateInfo.NewInstallerVersion);
if (downloadSuccess) {
Logger.UpdateStatus("New installer launched. Exiting current version.");
@@ -1,3 +1,8 @@
manifest_name = eagle0_manifest.txt
remote_manifest_prefix = installer/
remote_asset_prefix = unity3d/win/
# Ed25519 public key for manifest signature verification (base64)
# Generate key pair with: go run scripts/generate_manifest_keys.go
# Copy the public key here after generating
# manifest_public_key = YOUR_PUBLIC_KEY_HERE
@@ -109,29 +109,192 @@ function cancelUpload() {
</script>
{{if .Games}}
<div id="batch-actions" class="batch-actions" style="display: none; margin-bottom: 1rem; padding: 0.75rem; background: #f8f9fa; border-radius: 4px;">
<span id="selected-count">0</span> game(s) selected
<button class="btn-small btn-danger" onclick="showBatchDeleteModal()" style="margin-left: 1rem;">Delete Selected</button>
<button class="btn-small btn-secondary" onclick="clearSelection()" style="margin-left: 0.5rem;">Clear</button>
</div>
{{range .Games}}
<article class="game-card">
<h3>
<a href="/games/{{.GameID}}">Game {{.GameID}}</a>
<span class="status-badge {{if eq .RunStatus "Running"}}running{{else}}finished{{end}}">
{{.RunStatus}}
</span>
</h3>
<div class="meta">
Round {{.CurrentRound}} &bull; {{.ActionCount}} actions
</div>
<div class="players">
{{range .Players}}
<span class="player-badge {{if .IsHuman}}human{{else}}ai{{end}}">
{{.FactionName}}{{if .IsHuman}} ({{.UserName}}){{end}}
</span>
{{end}}
</div>
<div class="actions">
<a href="/games/{{.GameID}}" role="button" class="outline">View History</a>
<div style="display: flex; align-items: flex-start; gap: 0.75rem;">
<input type="checkbox" class="game-checkbox" data-game-id="{{.GameID}}" onchange="updateSelection()" style="margin-top: 0.3rem; width: 18px; height: 18px;">
<div style="flex: 1;">
<h3>
<a href="/games/{{.GameID}}">Game {{.GameID}}</a>
<span class="status-badge {{if eq .RunStatus "Running"}}running{{else}}finished{{end}}">
{{.RunStatus}}
</span>
</h3>
<div class="meta">
Round {{.CurrentRound}} &bull; {{.ActionCount}} actions
</div>
<div class="players">
{{range .Players}}
<span class="player-badge {{if .IsHuman}}human{{else}}ai{{end}}">
{{.FactionName}}{{if .IsHuman}} ({{.UserName}}){{end}}
</span>
{{end}}
</div>
<div class="actions">
<a href="/games/{{.GameID}}" role="button" class="outline">View History</a>
<button class="btn-small btn-danger" onclick="showDeleteModal('{{.GameID}}')">Delete</button>
</div>
</div>
</div>
</article>
{{end}}
<!-- Delete Game Modal -->
<dialog id="delete-game-modal">
<form method="dialog">
<h3>Delete Game</h3>
<p>Are you sure you want to delete game <strong id="delete-game-id-display"></strong>?</p>
<div class="form-group">
<label>
<input type="checkbox" id="delete-save-files" name="delete_save_files">
Also delete save files from disk
</label>
</div>
<p class="warning" style="color: #e74c3c; font-size: 0.9em;">
<strong>Warning:</strong> This action cannot be undone.
</p>
<input type="hidden" id="delete-game-id" name="game_id">
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="document.getElementById('delete-game-modal').close()">Cancel</button>
<button type="button" class="btn-danger" onclick="submitDeleteGame()">Delete</button>
</div>
</form>
</dialog>
<div id="delete-result"></div>
<!-- Batch Delete Modal -->
<dialog id="batch-delete-modal">
<form method="dialog">
<h3>Delete Multiple Games</h3>
<p>Are you sure you want to delete <strong id="batch-delete-count"></strong> game(s)?</p>
<div class="form-group">
<label>
<input type="checkbox" id="batch-delete-save-files" name="delete_save_files">
Also delete save files from disk
</label>
</div>
<p class="warning" style="color: #e74c3c; font-size: 0.9em;">
<strong>Warning:</strong> This action cannot be undone.
</p>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick="document.getElementById('batch-delete-modal').close()">Cancel</button>
<button type="button" class="btn-danger" onclick="submitBatchDelete()">Delete All</button>
</div>
</form>
</dialog>
<script>
function getSelectedGameIds() {
var checkboxes = document.querySelectorAll('.game-checkbox:checked');
var ids = [];
checkboxes.forEach(function(cb) {
ids.push(cb.getAttribute('data-game-id'));
});
return ids;
}
function updateSelection() {
var selectedIds = getSelectedGameIds();
var batchActions = document.getElementById('batch-actions');
var selectedCount = document.getElementById('selected-count');
if (selectedIds.length > 0) {
batchActions.style.display = 'block';
selectedCount.textContent = selectedIds.length;
} else {
batchActions.style.display = 'none';
}
}
function clearSelection() {
var checkboxes = document.querySelectorAll('.game-checkbox');
checkboxes.forEach(function(cb) {
cb.checked = false;
});
updateSelection();
}
function showBatchDeleteModal() {
var selectedIds = getSelectedGameIds();
if (selectedIds.length === 0) {
alert('No games selected');
return;
}
document.getElementById('batch-delete-count').textContent = selectedIds.length;
document.getElementById('batch-delete-save-files').checked = false;
document.getElementById('batch-delete-modal').showModal();
}
function submitBatchDelete() {
var selectedIds = getSelectedGameIds();
var deleteSaveFiles = document.getElementById('batch-delete-save-files').checked;
document.getElementById('batch-delete-modal').close();
document.getElementById('delete-result').innerHTML = '<div class="alert">Deleting ' + selectedIds.length + ' game(s)...</div>';
var promises = selectedIds.map(function(gameId) {
return fetch('/games/' + gameId + '/delete', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'delete_save_files=' + (deleteSaveFiles ? 'true' : 'false')
});
});
Promise.all(promises).then(function(responses) {
var failed = responses.filter(function(r) { return !r.ok; });
if (failed.length === 0) {
window.location.reload();
} else {
document.getElementById('delete-result').innerHTML =
'<div class="alert alert-error">' + failed.length + ' of ' + selectedIds.length + ' deletions failed. Refresh to see current state.</div>';
}
}).catch(function(error) {
document.getElementById('delete-result').innerHTML =
'<div class="alert alert-error">Batch delete failed: ' + error + '</div>';
});
}
function showDeleteModal(gameId) {
document.getElementById('delete-game-id').value = gameId;
document.getElementById('delete-game-id-display').textContent = gameId;
document.getElementById('delete-save-files').checked = false;
document.getElementById('delete-game-modal').showModal();
}
function submitDeleteGame() {
const gameId = document.getElementById('delete-game-id').value;
const deleteSaveFiles = document.getElementById('delete-save-files').checked;
fetch('/games/' + gameId + '/delete', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'delete_save_files=' + (deleteSaveFiles ? 'true' : 'false')
}).then(function(response) {
if (response.ok) {
// Reload page to show updated list
window.location.reload();
} else {
return response.text().then(function(text) {
document.getElementById('delete-result').innerHTML =
'<div class="alert alert-error">' + text + '</div>';
});
}
}).catch(function(error) {
document.getElementById('delete-result').innerHTML =
'<div class="alert alert-error">Delete failed: ' + error + '</div>';
});
document.getElementById('delete-game-modal').close();
}
</script>
{{else}}
<div class="empty-state">
<p>No games currently running.</p>
@@ -26,6 +26,20 @@
<main class="container">
{{template "content" .}}
</main>
<!-- JFR Stop Dialog -->
<dialog id="jfr-stop-dialog">
<article style="max-width: 400px;">
<h3>Stop JFR Recording</h3>
<p>Download the recording before stopping?</p>
<footer style="display: flex; gap: 0.5rem; justify-content: flex-end;">
<button class="secondary outline" onclick="jfrStopCancel()">Cancel</button>
<button class="secondary" onclick="jfrStopOnly()">Stop Only</button>
<button onclick="jfrStopWithDownload()">Download & Stop</button>
</footer>
</article>
</dialog>
<script>
// Handle JFR status response and update controls
document.body.addEventListener('htmx:afterRequest', function(evt) {
@@ -54,7 +68,7 @@
if (isRecording) {
container.innerHTML = `
<span class="jfr-status jfr-on">JFR: ON</span>
<button hx-post="/jfr/stop" hx-swap="none" class="btn-small secondary">Stop</button>
<button onclick="stopJfr()" class="btn-small secondary">Stop</button>
<a href="/jfr/download" class="btn-small">Download</a>
`;
} else {
@@ -65,6 +79,31 @@
}
htmx.process(container);
}
function stopJfr() {
// Show dialog to offer download before stopping
const dialog = document.getElementById('jfr-stop-dialog');
dialog.showModal();
}
function jfrStopWithDownload() {
document.getElementById('jfr-stop-dialog').close();
// Trigger download in new tab, then stop
window.open('/jfr/download', '_blank');
// Small delay to ensure download starts before stopping
setTimeout(function() {
htmx.ajax('POST', '/jfr/stop', {target: '#jfr-controls', swap: 'none'});
}, 500);
}
function jfrStopOnly() {
document.getElementById('jfr-stop-dialog').close();
htmx.ajax('POST', '/jfr/stop', {target: '#jfr-controls', swap: 'none'});
}
function jfrStopCancel() {
document.getElementById('jfr-stop-dialog').close();
}
</script>
</body>
</html>
@@ -57,8 +57,8 @@ func (h *InvitationHTTPHandler) handleInvite(w http.ResponseWriter, r *http.Requ
if len(parts) == 2 && parts[1] == "install.bat" {
h.handleInstallBat(w, r, code)
} else if len(parts) == 2 && parts[1] == "install.sh" {
h.handleInstallSh(w, r, code)
} else if len(parts) == 2 && parts[1] == "install.command" {
h.handleInstallCommand(w, r, code)
} else if len(parts) == 1 {
h.handleLandingPage(w, r, code)
} else {
@@ -92,7 +92,7 @@ func (h *InvitationHTTPHandler) handleLandingPage(w http.ResponseWriter, r *http
InstallBatURL: fmt.Sprintf("/invite/%s/install.bat", code),
InstallerURL: h.installerURL,
IsMac: isMac,
InstallShURL: fmt.Sprintf("/invite/%s/install.sh", code),
InstallShURL: fmt.Sprintf("/invite/%s/install.command", code),
MacInstallerURL: h.macInstallerURL,
}
@@ -181,19 +181,19 @@ timeout /t 5
log.Printf("[InviteHTTP] Served install.bat for code %s...", code[:8])
}
// handleInstallSh serves the Mac shell installer script
func (h *InvitationHTTPHandler) handleInstallSh(w http.ResponseWriter, r *http.Request, code string) {
// handleInstallCommand serves the Mac installer as a double-clickable .command file
func (h *InvitationHTTPHandler) handleInstallCommand(w http.ResponseWriter, r *http.Request, code string) {
// Validate the code exists and is valid
if !h.invitationService.IsValidCode(code) {
http.Error(w, "Invalid or expired invitation code", http.StatusNotFound)
return
}
// Generate shell script
// Generate shell script (.command files are double-clickable on macOS)
script := fmt.Sprintf(`#!/bin/bash
#
# Eagle0 Mac Installer
# This script downloads Eagle0 and sets up your invitation code.
# Double-click this file to install Eagle0.
#
set -e
@@ -254,13 +254,17 @@ echo ""
echo "Starting Eagle0..."
open "$APP_PATH"
echo ""
echo "You can close this window."
read -p "Press Enter to exit..."
`, code, h.macInstallerURL)
w.Header().Set("Content-Type", "application/x-sh")
w.Header().Set("Content-Disposition", "attachment; filename=eagle0-install.sh")
w.Header().Set("Content-Disposition", "attachment; filename=eagle0-install.command")
w.Write([]byte(script))
log.Printf("[InviteHTTP] Served install.sh for code %s...", code[:8])
log.Printf("[InviteHTTP] Served install.command for code %s...", code[:8])
}
var landingPageTemplate = template.Must(template.New("landing").Parse(`<!DOCTYPE html>
@@ -419,10 +423,8 @@ var landingPageTemplate = template.Must(template.New("landing").Parse(`<!DOCTYPE
<h3>How to install on Mac:</h3>
<ol>
<li>Click the "Download for Mac" button above</li>
<li>Open Terminal and navigate to your Downloads folder:<br>
<code>cd ~/Downloads</code></li>
<li>Run the installer script:<br>
<code>bash eagle0-install.sh</code></li>
<li>Open your Downloads folder and double-click <strong>eagle0-install.command</strong></li>
<li>If macOS asks for permission, click "Open"</li>
<li>Eagle0 will be installed to /Applications and launched automatically</li>
</ol>
</div>
@@ -167,11 +167,12 @@ func signWithSparkle(filePath string, privateKeyPath string) (string, error) {
}
}
// Sign the file
cmd := exec.Command(sparkleSignTool, filePath, "-s", privateKeyPath)
output, err := cmd.Output()
// Sign the file using -f to specify private key file path
// Note: -s is deprecated and expects key as string argument, not file path
cmd := exec.Command(sparkleSignTool, filePath, "-f", privateKeyPath)
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("failed to sign file: %w", err)
return "", fmt.Errorf("failed to sign file: %s: %w", string(output), err)
}
// sign_update outputs: sparkle:edSignature="<signature>" length="<length>"
@@ -1,12 +1,15 @@
package main
import (
"crypto/ed25519"
"encoding/base64"
"fmt"
"github.com/nolen777/eagle0/src/main/go/net/eagle0/util/aws"
"log"
"os"
"strings"
"time"
"github.com/nolen777/eagle0/src/main/go/net/eagle0/util/aws"
)
const bucketName = "eagle0-windows"
@@ -64,7 +67,7 @@ func (mm *ManifestManager) parseManifest(content string) map[string]string {
return sections
}
func (mm *ManifestManager) buildManifest(sections map[string]string) string {
func (mm *ManifestManager) buildManifest(sections map[string]string, privateKey ed25519.PrivateKey) string {
var manifest strings.Builder
manifest.WriteString(fmt.Sprintf("# Generated: %s\n", time.Now().Format(time.RFC3339)))
@@ -90,10 +93,21 @@ func (mm *ManifestManager) buildManifest(sections map[string]string) string {
}
}
return manifest.String()
// The content to sign is everything we've built so far
contentToSign := manifest.String()
// If we have a private key, sign the manifest
if privateKey != nil {
signature := ed25519.Sign(privateKey, []byte(contentToSign))
signatureB64 := base64.StdEncoding.EncodeToString(signature)
// Prepend signature line at the very beginning
return fmt.Sprintf("# signature=%s\n%s", signatureB64, contentToSign)
}
return contentToSign
}
func (mm *ManifestManager) UpdateSection(sectionName, sectionContent string) error {
func (mm *ManifestManager) UpdateSection(sectionName, sectionContent string, privateKey ed25519.PrivateKey) error {
log.Printf("Updating manifest section: %s", sectionName)
// Fetch existing manifest
@@ -103,14 +117,14 @@ func (mm *ManifestManager) UpdateSection(sectionName, sectionContent string) err
existing = ""
}
// Parse sections
// Parse sections (strips out old signature line if present)
sections := mm.parseManifest(existing)
// Update the specified section
sections[sectionName] = sectionContent
// Build new manifest
newManifest := mm.buildManifest(sections)
// Build new manifest (with signature if key provided)
newManifest := mm.buildManifest(sections, privateKey)
// Upload updated manifest with public ACL
err = mm.bucketBasics.UploadBytesPublic(bucketName, manifestPath, []byte(newManifest))
@@ -118,13 +132,36 @@ func (mm *ManifestManager) UpdateSection(sectionName, sectionContent string) err
return fmt.Errorf("failed to upload manifest: %v", err)
}
log.Printf("Successfully updated manifest section: %s", sectionName)
if privateKey != nil {
log.Printf("Successfully updated and signed manifest section: %s", sectionName)
} else {
log.Printf("Successfully updated manifest section: %s (unsigned)", sectionName)
}
return nil
}
func loadPrivateKey(keyPath string) (ed25519.PrivateKey, error) {
keyData, err := os.ReadFile(keyPath)
if err != nil {
return nil, fmt.Errorf("failed to read private key file: %v", err)
}
// Key should be base64-encoded 64-byte Ed25519 private key
keyBytes, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(keyData)))
if err != nil {
return nil, fmt.Errorf("failed to decode private key: %v", err)
}
if len(keyBytes) != ed25519.PrivateKeySize {
return nil, fmt.Errorf("invalid private key size: expected %d bytes, got %d", ed25519.PrivateKeySize, len(keyBytes))
}
return ed25519.PrivateKey(keyBytes), nil
}
func main() {
if len(os.Args) != 3 {
log.Fatal("Usage: manifest_manager <section_name> <section_content_file>")
if len(os.Args) < 3 || len(os.Args) > 4 {
log.Fatal("Usage: manifest_manager <section_name> <section_content_file> [private_key_file]")
}
sectionName := os.Args[1]
@@ -136,6 +173,16 @@ func main() {
log.Fatalf("Failed to read content file: %v", err)
}
// Load private key if provided
var privateKey ed25519.PrivateKey
if len(os.Args) == 4 {
privateKey, err = loadPrivateKey(os.Args[3])
if err != nil {
log.Fatalf("Failed to load private key: %v", err)
}
log.Println("Ed25519 signing key loaded")
}
// Create manifest manager
mm, err := NewManifestManager()
if err != nil {
@@ -143,7 +190,7 @@ func main() {
}
// Update the section
err = mm.UpdateSection(sectionName, string(content))
err = mm.UpdateSection(sectionName, string(content), privateKey)
if err != nil {
log.Fatalf("Failed to update manifest: %v", err)
}
@@ -1,15 +0,0 @@
load("@io_bazel_rules_go//go:def.bzl", "go_binary", "go_library")
go_library(
name = "client_download_lib",
srcs = ["client_download.go"],
importpath = "github.com/nolen777/eagle0/src/main/go/net/eagle0/client_download",
visibility = ["//visibility:private"],
deps = ["//src/main/go/net/eagle0/util/aws"],
)
go_binary(
name = "client_download",
embed = [":client_download_lib"],
visibility = ["//visibility:public"],
)
@@ -1,58 +0,0 @@
package main
import (
"github.com/nolen777/eagle0/src/main/go/net/eagle0/util/aws"
"net/http"
"strings"
"time"
)
func getAsset(w http.ResponseWriter, r *http.Request) {
urlPath := r.URL.Path
var bucketName string
var destinationRoot string
var fileName string
components := strings.SplitN(urlPath, "/", 3)
if len(components) < 3 {
http.Error(w, "Invalid URL path", http.StatusBadRequest)
return
}
baseComponent := components[1] // Get the first component of the path
fileName = components[2] // Get the path after the first "/"
// Check if the URL path starts with "/unity3d/"
switch baseComponent {
case "unity3d":
bucketName = "eagle0-windows"
destinationRoot = "unity3d/"
case "installer":
bucketName = "eagle0-windows"
destinationRoot = "installer/"
case "headshots":
bucketName = "eagle0-headshots"
destinationRoot = ""
default:
// If the URL path does not start with either, return an error
http.Error(w, "Invalid URL path", http.StatusBadRequest)
}
// Create a presigned URL for the file
url, err := aws.GetPresignedURL(bucketName, destinationRoot+fileName, 10*time.Minute)
if err != nil {
http.Error(w, "Error generating presigned URL", http.StatusInternalServerError)
return
}
// Redirect the client to the presigned URL
http.Redirect(w, r, url, http.StatusTemporaryRedirect)
}
func main() {
http.HandleFunc("GET /", getAsset)
err := http.ListenAndServe(":3333", nil)
if err != nil {
panic(err)
}
}
+1 -32
View File
@@ -21,10 +21,6 @@ type BucketBasics struct {
S3Client *s3.Client
}
type Presigner struct {
PresignClient *s3.PresignClient
}
func readConfig() (map[string]string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
@@ -90,16 +86,7 @@ func NewBucketBasics() (BucketBasics, error) {
}, nil
}
func NewPresigner() (Presigner, error) {
bucketBasics, err := NewBucketBasics()
if err != nil {
return Presigner{}, err
}
presignClient := s3.NewPresignClient(bucketBasics.S3Client)
return Presigner{PresignClient: presignClient}, nil
}
// DownloadFile gets an object from a bucket and stores it in a local file.
// FetchBytes gets an object from a bucket and returns it as bytes.
func (basics BucketBasics) FetchBytes(bucketName string, objectKey string) ([]byte, error) {
result, err := basics.S3Client.GetObject(basics.ctx, &s3.GetObjectInput{
Bucket: aws.String(bucketName),
@@ -196,21 +183,3 @@ func (basics BucketBasics) UploadBytesWithACL(bucketName string, objectKey strin
return nil
}
func GetPresignedURL(bucketName string, objectKey string, duration time.Duration) (string, error) {
basics, err := NewBucketBasics()
if err != nil {
return "", err
}
presigner, err := NewPresigner()
if err != nil {
return "", err
}
req, _ := presigner.PresignClient.PresignGetObject(basics.ctx, &s3.GetObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
}, s3.WithPresignExpires(duration))
return req.URL, nil
}
+3 -1
View File
@@ -396,8 +396,10 @@ done:
}
// waitForResponse waits for a response matching the predicate
// Use 180s timeout because CreateGame can be very slow on cold JVM (JIT not warmed up)
// In production we've seen CreateGame take >90s on first boot
func waitForResponse(stream eagle.Eagle_StreamUpdatesClient, matches func(*eagle.UpdateStreamResponse) bool) (*eagle.UpdateStreamResponse, error) {
return waitForResponseWithTimeout(stream, matches, 30*time.Second)
return waitForResponseWithTimeout(stream, matches, 180*time.Second)
}
func waitForResponseWithTimeout(stream eagle.Eagle_StreamUpdatesClient, matches func(*eagle.UpdateStreamResponse) bool, timeout time.Duration) (*eagle.UpdateStreamResponse, error) {
@@ -0,0 +1,40 @@
load("@build_bazel_rules_apple//apple:macos.bzl", "macos_bundle")
# Native Sparkle plugin for Unity
# This plugin initializes Sparkle auto-updater at runtime
objc_library(
name = "sparkle_plugin_lib",
srcs = ["SparklePlugin.m"],
copts = [
"-fmodules",
"-fobjc-arc",
"-fvisibility=default", # Export symbols for Unity P/Invoke
],
sdk_frameworks = ["Foundation"],
deps = ["@sparkle//:Sparkle"],
)
macos_bundle(
name = "SparklePlugin",
bundle_id = "net.eagle0.SparklePlugin",
bundle_name = "SparklePlugin",
infoplists = ["Info.plist"],
linkopts = [
# Export symbols for Unity P/Invoke
"-exported_symbol",
"_SparklePlugin_Initialize",
"-exported_symbol",
"_SparklePlugin_CheckForUpdates",
"-exported_symbol",
"_SparklePlugin_CheckForUpdatesInBackground",
"-exported_symbol",
"_SparklePlugin_IsCheckingForUpdates",
"-exported_symbol",
"_SparklePlugin_GetAutomaticallyChecksForUpdates",
"-exported_symbol",
"_SparklePlugin_SetAutomaticallyChecksForUpdates",
],
minimum_os_version = "10.13",
visibility = ["//visibility:public"],
deps = [":sparkle_plugin_lib"],
)
@@ -0,0 +1,22 @@
<?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>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>SparklePlugin</string>
<key>CFBundleIdentifier</key>
<string>net.eagle0.SparklePlugin</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>SparklePlugin</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>
@@ -0,0 +1,86 @@
//
// SparklePlugin.m
// Native macOS plugin for Unity to initialize Sparkle auto-updates
//
// Copyright 2026 Dan Crosby
//
#import <Foundation/Foundation.h>
#import <Sparkle/Sparkle.h>
// The updater controller - retained for the lifetime of the app
static SPUStandardUpdaterController *updaterController = nil;
// Export macro for Unity P/Invoke
#define EXPORT __attribute__((visibility("default")))
// Called from Unity to initialize Sparkle
// This should be called once at app startup
EXPORT void SparklePlugin_Initialize(void) {
if (updaterController != nil) {
NSLog(@"SparklePlugin: Already initialized");
return;
}
NSLog(@"SparklePlugin: Initializing Sparkle auto-updater");
// Create the updater controller with automatic update checking enabled
// The feed URL and public key are read from Info.plist (SUFeedURL, SUPublicEDKey)
updaterController = [[SPUStandardUpdaterController alloc]
initWithStartingUpdater:YES
updaterDelegate:nil
userDriverDelegate:nil];
if (updaterController != nil) {
NSLog(@"SparklePlugin: Sparkle initialized successfully");
} else {
NSLog(@"SparklePlugin: Failed to initialize Sparkle");
}
}
// Manually trigger an update check (shows UI)
EXPORT void SparklePlugin_CheckForUpdates(void) {
if (updaterController == nil) {
NSLog(@"SparklePlugin: Not initialized, call Initialize first");
return;
}
NSLog(@"SparklePlugin: Checking for updates");
[updaterController checkForUpdates:nil];
}
// Check for updates silently in background (no UI unless update found)
EXPORT void SparklePlugin_CheckForUpdatesInBackground(void) {
if (updaterController == nil) {
NSLog(@"SparklePlugin: Not initialized, call Initialize first");
return;
}
NSLog(@"SparklePlugin: Checking for updates in background");
[[updaterController updater] checkForUpdatesInBackground];
}
// Returns 1 if an update check is in progress, 0 otherwise
EXPORT int SparklePlugin_IsCheckingForUpdates(void) {
if (updaterController == nil) {
return 0;
}
return [[updaterController updater] sessionInProgress] ? 1 : 0;
}
// Returns 1 if automatic update checks are enabled, 0 otherwise
EXPORT int SparklePlugin_GetAutomaticallyChecksForUpdates(void) {
if (updaterController == nil) {
return 0;
}
return [[updaterController updater] automaticallyChecksForUpdates] ? 1 : 0;
}
// Enable or disable automatic update checks
EXPORT void SparklePlugin_SetAutomaticallyChecksForUpdates(int enabled) {
if (updaterController == nil) {
NSLog(@"SparklePlugin: Not initialized");
return;
}
[[updaterController updater] setAutomaticallyChecksForUpdates:(enabled != 0)];
}
@@ -45,9 +45,6 @@ service Eagle {
rpc DeleteGame(DeleteGameRequest) returns (DeleteGameResponse) {}
rpc CheckGameExists(CheckGameExistsRequest) returns (CheckGameExistsResponse) {}
rpc DownloadGameSave(DownloadGameSaveRequest) returns (stream DownloadGameSaveResponse) {}
// Server lifecycle management (for blue-green deployments)
rpc ReloadGames(ReloadGamesRequest) returns (ReloadGamesResponse) {}
}
message PostCommandRequest {
@@ -61,8 +58,11 @@ message PostCommandResponse {
UNKNOWN = 0;
SUCCESS = 1;
BAD_TOKEN = 2;
ERROR = 3;
}
Status status = 1;
string error_message = 2; // Only set if status is ERROR
int64 game_id = 3; // Echo back game_id from request for targeted refresh
}
message EnterLobbyRequest {
@@ -266,6 +266,8 @@ message GameInfo {
int64 game_id = 1;
.google.protobuf.Int32Value faction_id = 2;
AvailableLeader leader = 3;
// Timestamp (millis since epoch) of when this user last took a turn
int64 last_played_timestamp_millis = 4;
}
message EagleCommand {
@@ -608,12 +610,3 @@ message DownloadGameSaveRequest {
message DownloadGameSaveResponse {
bytes chunk = 1; // Chunk of zip file data
}
// Server lifecycle management (for blue-green deployments)
message ReloadGamesRequest {}
message ReloadGamesResponse {
bool success = 1;
string error_message = 2;
int32 games_reloaded = 3; // Number of games that were reloaded from disk
}
@@ -15,6 +15,8 @@ message RunningGame {
int64 game_id = 1;
map<string, int32> user_to_pid = 2;
bool ongoing = 3;
// Timestamp (millis since epoch) of when each user last took a turn
map<string, int64> last_played_by_user = 4;
}
message RunningGames {
@@ -26,6 +26,9 @@ message CommandDescriptor {
.net.eagle0.shardok.common.Coords target = 5;
repeated .net.eagle0.shardok.common.CommandType follow_up_command_types = 6;
// Full path for multi-step commands like Move (all hexes traversed, in order)
repeated .net.eagle0.shardok.common.Coords path = 12;
bool will_unhide = 7;
.net.eagle0.shardok.api.OddsView odds = 8;
@@ -4,8 +4,8 @@ import scala.collection.Map
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
import net.eagle0.eagle.api.available_command.AvailableCommand as ProtoAvailableCommand
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands as ProtoOneProvinceAvailableCommands
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.library.util.*
import net.eagle0.eagle.library.util.command_choice_helpers.{
@@ -14,6 +14,7 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
CommandChooser
}
import net.eagle0.eagle.library.Engine
import net.eagle0.eagle.model.proto_converters.command.available.OneProvinceAvailableCommandsConverter
import net.eagle0.eagle.model.state.game_state.GameState
case class AIClientWithSelectedCommand(
@@ -45,22 +46,26 @@ case class AIClient(
engine: Engine,
functionalRandom: FunctionalRandom
): RandomState[AIClientWithSelectedCommand] = {
val maybeCommandsMap = engine
.getAvailablePlayerCommands(factionId)
val scalaCommandsMap = engine.getAvailablePlayerCommands(factionId)
if maybeCommandsMap.isEmpty || maybeCommandsMap.get.commandsByProvince.isEmpty
then RandomState(AIClientWithSelectedCommand(this, None), functionalRandom)
else
if scalaCommandsMap.isEmpty then RandomState(AIClientWithSelectedCommand(this, None), functionalRandom)
else {
// Convert to proto for command choosers (temporary until command choosers are migrated to Scala types)
val protoCommandsMap = scalaCommandsMap.map {
case (pid, scalaCmds) =>
pid -> OneProvinceAvailableCommandsConverter.toProto(scalaCmds, engine.currentState, factionId)
}
chooseFrom(
maybeCommandsMap.get.commandsByProvince,
protoCommandsMap,
engine.currentState,
functionalRandom
)
}
}
private def chooseMidGameCommandFrom(
gameState: GameState,
oneProvinceAvailableCommands: OneProvinceAvailableCommands,
oneProvinceAvailableCommands: ProtoOneProvinceAvailableCommands,
functionalRandom: FunctionalRandom
): RandomState[AIClientWithSelectedCommand] =
MidGameAIClient.chosenMidGameCommand(
@@ -113,7 +118,7 @@ case class AIClient(
}
private def chooseFrom(
acs: Map[ProvinceId, OneProvinceAvailableCommands],
acs: Map[ProvinceId, ProtoOneProvinceAvailableCommands],
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[AIClientWithSelectedCommand] = {
@@ -186,7 +191,7 @@ case class AIClient(
(
fid: FactionId,
gameState: GameState,
acs: Vector[AvailableCommand],
acs: Vector[ProtoAvailableCommand],
functionalRandom
) =>
RandomState(
@@ -24,6 +24,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:alms_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:attack_decision_command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/command/available:one_province_available_commands_converter",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
],
)
@@ -3,12 +3,12 @@ package net.eagle0.eagle.library
import net.eagle0.eagle.common.action_result_notification_details.Notification
import net.eagle0.eagle.common.action_result_type.ActionResultType
import net.eagle0.eagle.common.action_result_type.ActionResultType.*
import net.eagle0.eagle.common.round_phase.RoundPhase
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.library.util.faction_utils.LegacyFactionUtils
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.view_filters.GameStateViewFilter
import net.eagle0.eagle.library.util.GameStateViewDiffer
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.RoundPhase
import net.eagle0.eagle.views.action_result_view.ActionResultView
import net.eagle0.eagle.views.game_state_view.GameStateViewDiff
import net.eagle0.eagle.FactionId
@@ -49,7 +49,7 @@ object ActionResultFilter {
result.actionResult.affectedPlayers.contains(fid) ||
UNIVERSALLY_VISIBLE_TYPES.contains(result.actionResult.`type`) ||
result.actionResult.province.exists(pid =>
result.gameState
result.scalaGameState
.provinces(pid)
.rulingFactionId
.contains(fid)
@@ -81,12 +81,12 @@ object ActionResultFilter {
)
private val allowOpponentActionPhases: Vector[RoundPhase] = Vector(
RoundPhase.HERO_DEPARTURES,
RoundPhase.UNCONTESTED_CONQUEST,
RoundPhase.BATTLE_RESOLUTION,
RoundPhase.BATTLE_AFTERMATH, // MIGHT BE WRONG
RoundPhase.ATTACK_DECISION, // MIGHT BE WRONG
RoundPhase.DIPLOMACY_RESOLUTION // IS WRONG
RoundPhase.HeroDepartures,
RoundPhase.UncontestedConquest,
RoundPhase.BattleResolution,
RoundPhase.BattleAftermath, // MIGHT BE WRONG
RoundPhase.AttackDecision, // MIGHT BE WRONG
RoundPhase.DiplomacyResolution // IS WRONG
)
private def filteredGameStateDiff(
@@ -95,8 +95,7 @@ object ActionResultFilter {
factionId: Option[FactionId]
): Option[GameStateViewDiff] =
GameStateViewDiffer.diff(
before = GameStateViewFilter
.filteredGameState(gs = before, factionId = factionId),
before = GameStateViewFilter.filteredGameState(gs = before, factionId = factionId),
after = GameStateViewFilter.filteredGameState(gs = after, factionId = factionId)
)
@@ -106,10 +105,11 @@ object ActionResultFilter {
factionId: Option[FactionId]
): Option[ActionResultView] = {
val actionResult = result.actionResult
val factions = startingState.factions.values.toVector
val gsDiff = filteredGameStateDiff(
before = startingState,
after = result.gameState,
after = result.scalaGameState,
factionId = factionId
)
@@ -121,10 +121,10 @@ object ActionResultFilter {
factionId.exists { askingFid =>
!result.actionResult.player.exists(targetFid =>
targetFid == askingFid ||
LegacyFactionUtils.hasAlliance(
FactionUtils.hasAlliance(
askingFid,
targetFid,
startingState
factions
)
)
} &&
@@ -169,7 +169,7 @@ object ActionResultFilter {
factionId = factionId
)
(res.gameState, acc ++ maybeNewRes)
(res.scalaGameState, acc ++ maybeNewRes)
}
._2
@@ -11,17 +11,19 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/service/controller:__pkg__",
],
exports = [
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update",
],
deps = [
":game_history",
"//src/main/protobuf/net/eagle0/eagle/api:command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:action_result_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_update",
@@ -43,16 +45,12 @@ scala_library(
":engine",
":game_history",
":round_phase_advancer",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:action_result_view_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier_impl",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier_impl",
"//src/main/scala/net/eagle0/eagle/library/actions/availability",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action:check_for_fulfilled_quests_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action:hero_backstory_update_action_generator",
@@ -63,7 +61,7 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/actions/random_state_sequencer",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util/hero_generator",
"//src/main/scala/net/eagle0/eagle/library/util/validations:scala_runtime_validator",
"//src/main/scala/net/eagle0/eagle/library/util/validations:runtime_validator",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
@@ -74,8 +72,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/proto_converters:action_result_proto_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:battalion_type_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/command/available:one_province_available_commands_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/command/selected:selected_command_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/battalion",
"//src/main/scala/net/eagle0/eagle/model/state/command:command_type",
@@ -99,14 +95,15 @@ scala_library(
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:action_result_view_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/views:game_state_view_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/util:game_state_view_differ",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils:legacy_faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/view_filters:game_state_view_filter",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -181,7 +178,6 @@ scala_library(
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:game_history",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_trait_applier_impl",
"//src/main/scala/net/eagle0/eagle/library/actions/availability",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action:end_attack_decision_phase_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/action:end_battle_aftermath_phase_action",
@@ -1,9 +1,11 @@
package net.eagle0.eagle.library
import scala.collection.immutable.SortedMap
import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
import net.eagle0.eagle.api.command.AvailableCommands
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.model.state.command.available.OneProvinceAvailableCommands
import net.eagle0.eagle.model.state.command.selected.SelectedCommand
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.shardok_interface.{BattleResolution, BattleUpdate}
import net.eagle0.eagle.views.action_result_view.ActionResultView
@@ -37,9 +39,14 @@ trait Engine {
def updated: EngineAndResults
/**
* Returns available commands using native Scala types. Callers that need proto types for gRPC should convert using
* OneProvinceAvailableCommandsConverter.
*/
def getAvailablePlayerCommands(
fid: FactionId
): Option[AvailableCommands]
): SortedMap[ProvinceId, OneProvinceAvailableCommands]
def tokenForFaction(fid: FactionId): Long
def resolveBattle(battleResolution: BattleResolution): EngineAndResults
@@ -1,11 +1,10 @@
package net.eagle0.eagle.library
import scala.annotation.tailrec
import scala.collection.immutable.SortedMap
import net.eagle0.common.SeededRandom
import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
import net.eagle0.eagle.api.command.AvailableCommands
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.internal.action_result.ActionResult
import net.eagle0.eagle.library.actions.applier.{ActionResultApplierImpl, ActionResultWithResultingState}
import net.eagle0.eagle.library.actions.availability.AvailableCommandsFactory
@@ -15,18 +14,15 @@ import net.eagle0.eagle.library.actions.impl.action.{
ResolveBattleAction
}
import net.eagle0.eagle.library.actions.impl.command.{AvailableCommandTypeMap, CommandFactory}
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.library.actions.random_state_sequencer.RandomStateSequencer
import net.eagle0.eagle.library.util.hero_generator.HeroGenerator
import net.eagle0.eagle.library.util.validations.ScalaRuntimeValidator
import net.eagle0.eagle.library.util.validations.RuntimeValidator
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.EngineImpl.{appliedResults, appliedResultsScala, withUpdateChecks}
import net.eagle0.eagle.library.EngineImpl.{appliedResults, withUpdateChecks}
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.command.available.OneProvinceAvailableCommandsConverter
import net.eagle0.eagle.model.proto_converters.command.selected.SelectedCommandConverter
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
import net.eagle0.eagle.model.state.command.available.AvailableCommand as ScalaAvailableCommand
import net.eagle0.eagle.model.state.command.CommandType
import net.eagle0.eagle.model.state.command.available.OneProvinceAvailableCommands
import net.eagle0.eagle.model.state.command.selected.SelectedCommand
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.shardok_interface.{BattleResolution, BattleUpdate}
import net.eagle0.eagle.views.action_result_view.ActionResultView
@@ -50,7 +46,7 @@ object EngineImpl {
engineAndResultsImpl.recursiveTransformScala(eng =>
RoundPhaseAdvancer.checkForPhaseAdvancement(
currentState = eng.currentState,
actionResultApplier = ActionResultApplierImpl(Some(ScalaRuntimeValidator)),
actionResultApplier = ActionResultApplierImpl(Some(RuntimeValidator)),
history = eng.history,
availableCommandsFactory = eng.availableCommandsFactory,
heroGenerator = eng.heroGenerator,
@@ -88,21 +84,6 @@ object EngineImpl {
}
def appliedResults(
engine: EngineImpl,
results: Vector[ActionWithResultingState]
): EngineAndResults = withUpdateChecks(
EngineAndResultsImpl(
engine = engine.copy(
currentState = results.lastOption
.map(awrs => GameStateConverter.fromProto(awrs.gameState))
.getOrElse(engine.currentState),
history = engine.history.withNewResults(results)
),
results = results.map(_.actionResult)
)
)
def appliedResultsScala(
engine: EngineImpl,
results: Vector[ActionResultWithResultingState]
): EngineAndResults = withUpdateChecks(
@@ -136,7 +117,7 @@ final case class EngineAndResultsImpl(
if goResults.isEmpty then EngineAndResultsImpl(eng, acc)
else
appliedResultsScala(eng, goResults) match {
appliedResults(eng, goResults) match {
case EngineAndResultsImpl(eng2, res) =>
go(eng2, acc ++ res)
}
@@ -148,7 +129,7 @@ final case class EngineAndResultsImpl(
def recursiveTransformT(
f: EngineImpl => Vector[ActionResultT]
): EngineAndResultsImpl = recursiveTransformScala { eng =>
val actionResultApplier = ActionResultApplierImpl(Some(ScalaRuntimeValidator))
val actionResultApplier = ActionResultApplierImpl(Some(RuntimeValidator))
RandomStateSequencer(
initialState = eng.currentState,
actionResultApplier = actionResultApplier,
@@ -196,7 +177,7 @@ case class EngineImpl(
.filterForPlayer(
results = history.since(knownUnfilteredCount),
factionId = factionId,
startingState = GameStateConverter.toProto(history.stateAfter(knownUnfilteredCount))
startingState = history.stateAfter(knownUnfilteredCount)
),
unfilteredCountAfter = history.count
)
@@ -207,31 +188,15 @@ case class EngineImpl(
def getAvailablePlayerCommands(
fid: FactionId
): Option[AvailableCommands] = {
val scalaCommandsByProvince =
availableCommandsFactory.availablePlayerCommands(currentState, fid)
Option.when(scalaCommandsByProvince.nonEmpty) {
val currentStateProto = GameStateConverter.toProto(currentState)
val protoCommandsByProvince = scalaCommandsByProvince.map {
case (pid, scalaCmds) =>
pid -> OneProvinceAvailableCommandsConverter.toProto(scalaCmds, currentStateProto, fid)
}
AvailableCommands(
token = tokenForFaction(fid),
commandsByProvince = protoCommandsByProvince,
suggestedProvinceId = AvailableCommandsFactory
.suggestedProvinceId(fid, scalaCommandsByProvince, currentState)
)
}
}
): SortedMap[ProvinceId, OneProvinceAvailableCommands] =
availableCommandsFactory.availablePlayerCommands(currentState, fid)
def tokenForFaction(fid: FactionId): Long =
currentState.factionCommandCounts.getOrElse(fid, 0).toLong
def resolveBattle(battleResolution: BattleResolution): EngineAndResults = {
val actionResultApplier = ActionResultApplierImpl(Some(ScalaRuntimeValidator))
appliedResultsScala(
val actionResultApplier = ActionResultApplierImpl(Some(RuntimeValidator))
appliedResults(
engine = this,
results = ResolveBattleAction(
startingGameState = currentState,
@@ -305,13 +270,10 @@ case class EngineImpl(
val scalaCommandsForProvince = scalaCommandsByProvince(selectedProvinceId)
// Convert proto SelectedCommand to Scala
val scalaSelectedCommand = SelectedCommandConverter.fromProto(selectedCommand)
// Match using CommandType
val scalaAvailableCommandOpt = AvailableCommandTypeMap.matchingAvailableCommand(
scalaCommandsForProvince.commands,
scalaSelectedCommand
selectedCommand
)
commandRequire(
scalaAvailableCommandOpt.isDefined,
@@ -319,10 +281,10 @@ case class EngineImpl(
)
val scalaAvailableCommand = scalaAvailableCommandOpt.get
val commandType = scalaSelectedCommand.commandType
val commandType = selectedCommand.commandType
val sequencer = RandomStateSequencer(
initialState = this.currentState,
actionResultApplier = ActionResultApplierImpl(Some(ScalaRuntimeValidator)),
actionResultApplier = ActionResultApplierImpl(Some(RuntimeValidator)),
functionalRandom = SeededRandom(this.currentState.randomSeed)
).withTCommandAndLastCommand(
commandGen = gs =>
@@ -330,7 +292,7 @@ case class EngineImpl(
actingFactionId = factionId,
gameState = gs,
availableCommand = scalaAvailableCommand,
selectedCommand = scalaSelectedCommand
selectedCommand = selectedCommand
),
commandType = commandType
).withProtolessSequentialResultsAction(gs => HeroBackstoryUpdateActionGenerator(gs))
@@ -347,7 +309,7 @@ case class EngineImpl(
s"Result with type ${firstResult.map(_.actionResultType).getOrElse("unknown")} did not have a player set"
)
appliedResultsScala(
appliedResults(
engine = this,
results = sequencer.resultsWithStates.newValue
)
@@ -1,6 +1,6 @@
package net.eagle0.eagle.library.actions.applier
import net.eagle0.eagle.library.util.validations.ScalaValidator
import net.eagle0.eagle.library.util.validations.Validator
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.state.battalion.BattalionT
import net.eagle0.eagle.model.state.date.Date
@@ -18,30 +18,30 @@ import GameStateExtensions.*
* This uses extension methods on GameState to apply each type of change.
*/
object ActionResultApplierImpl {
def apply(validator: Option[ScalaValidator]): ActionResultApplierImpl = new ActionResultApplierImpl(validator)
def apply(validator: Option[Validator]): ActionResultApplierImpl = new ActionResultApplierImpl(validator)
// Type class for single-argument validation
trait Validatable[T]:
def validate(v: ScalaValidator, value: T): T
def validate(v: Validator, value: T): T
given Validatable[HeroT] with
def validate(v: ScalaValidator, value: HeroT): HeroT = v.validate(value)
def validate(v: Validator, value: HeroT): HeroT = v.validate(value)
given Validatable[GameState] with
def validate(v: ScalaValidator, value: GameState): GameState = v.validate(value)
def validate(v: Validator, value: GameState): GameState = v.validate(value)
given Validatable[ActionResultT] with
def validate(v: ScalaValidator, value: ActionResultT): ActionResultT = v.validate(value)
def validate(v: Validator, value: ActionResultT): ActionResultT = v.validate(value)
// Type class for validation with GameState context
trait ValidatableWithGameState[T]:
def validate(v: ScalaValidator, value: T, gs: GameState): T
def validate(v: Validator, value: T, gs: GameState): T
given ValidatableWithGameState[BattalionT] with
def validate(v: ScalaValidator, value: BattalionT, gs: GameState): BattalionT = v.validate(value, gs)
def validate(v: Validator, value: BattalionT, gs: GameState): BattalionT = v.validate(value, gs)
}
class ActionResultApplierImpl(validator: Option[ScalaValidator]) extends ActionResultApplier {
class ActionResultApplierImpl(validator: Option[Validator]) extends ActionResultApplier {
import ActionResultApplierImpl.{Validatable, ValidatableWithGameState, given}
// Generic single-argument validate
@@ -1,21 +0,0 @@
package net.eagle0.eagle.library.actions.applier
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.model.action_result.ActionResultT
case class ActionResultTWithResultingState(
actionResult: ActionResultT,
resultingState: GameState
)
trait ActionResultTApplier {
def applyActionResults(
startingState: GameState,
results: Iterable[ActionResultT]
): Vector[ActionResultTWithResultingState]
def applyActionResult(
startingState: GameState,
result: ActionResultT
): ActionResultTWithResultingState
}
@@ -1,44 +0,0 @@
package net.eagle0.eagle.library.actions.applier
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.validations.ScalaValidator
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.game_state.GameStateConverter
object ActionResultTApplierImpl {
/** Creates an ActionResultTApplierImpl with no validation (for tests) */
def apply(): ActionResultTApplierImpl =
new ActionResultTApplierImpl(new ActionResultApplierImpl(None))
def apply(validator: ScalaValidator): ActionResultTApplierImpl =
new ActionResultTApplierImpl(new ActionResultApplierImpl(Some(validator)))
def apply(baseApplier: ActionResultApplier): ActionResultTApplierImpl =
new ActionResultTApplierImpl(baseApplier)
}
class ActionResultTApplierImpl(baseApplier: ActionResultApplier) extends ActionResultTApplier {
override def applyActionResults(
startingState: GameState,
results: Iterable[ActionResultT]
): Vector[ActionResultTWithResultingState] = baseApplier
.applyActionResults(
GameStateConverter.fromProto(startingState),
results
)
.zip(results)
.map {
case (actionWithResultingState, actionResult) =>
ActionResultTWithResultingState(
actionResult = actionResult,
resultingState = GameStateConverter.toProto(actionWithResultingState.resultingState)
)
}
override def applyActionResult(
startingState: GameState,
result: ActionResultT
): ActionResultTWithResultingState =
applyActionResults(startingState, Vector(result)).head
}
@@ -198,66 +198,10 @@ scala_library(
deps = [
":action_result_applier",
":game_state_extensions",
"//src/main/scala/net/eagle0/eagle/library/util/validations:scala_validator",
"//src/main/scala/net/eagle0/eagle/library/util/validations:validator",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/run_status",
],
)
scala_library(
name = "action_result_trait_applier",
srcs = ["ActionResultTApplier.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_hero_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province/concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types",
"//src/main/scala/net/eagle0/eagle/model/state/hero:gender",
],
)
scala_library(
name = "action_result_trait_applier_impl",
srcs = ["ActionResultTApplierImpl.scala"],
visibility = [
"//src/main/scala/net/eagle0/eagle:__subpackages__",
"//src/test/scala/net/eagle0/eagle/library/actions:__subpackages__",
],
exports = [
":action_result_applier",
":action_result_trait_applier",
"//src/main/scala/net/eagle0/eagle/library/util/validations:runtime_validator",
"//src/main/scala/net/eagle0/eagle/library/util/validations:scala_validator",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
":action_result_applier",
":action_result_applier_impl",
":action_result_trait_applier",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/library/util/validations:scala_validator",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_battalion_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_faction_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:changed_hero_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request",
"//src/main/scala/net/eagle0/eagle/model/action_result/types",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -1,25 +1,16 @@
package net.eagle0.eagle.library.actions.availability
import net.eagle0.eagle.api.available_command.ResolveAllianceOfferAvailableCommand
import net.eagle0.eagle.common.diplomacy_offer.{AllianceOfferDetails, DiplomacyOffer as DiplomacyOfferProto}
import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.DIPLOMACY_OFFER_STATUS_UNRESOLVED
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.model.proto_converters.diplomacy_offer.status.StatusConverter
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.DiplomacyOfferInfo
import net.eagle0.eagle.model.state.diplomacy_offer.status.Unresolved
import net.eagle0.eagle.model.state.diplomacy_offer.AllianceOffer
import net.eagle0.eagle.model.state.game_state.GameState as ScalaGameState
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object AvailableResolveAllianceOfferCommandFactory {
// ==================== Scala overload ====================
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.DiplomacyOfferInfo
/** Scala overload - returns pure Scala type */
def availableCommand(
gameState: ScalaGameState,
gameState: GameState,
factionId: FactionId
): Option[AvailableCommand] = {
val unresolvedOffers = gameState
@@ -48,61 +39,4 @@ object AvailableResolveAllianceOfferCommandFactory {
)
end if
}
// ==================== Proto overload ====================
private def unresolvedAllianceOffers(
gameState: GameState,
factionId: FactionId
): Vector[DiplomacyOfferProto] =
gameState
.factions(factionId)
.incomingDiplomacyOffers
.filter(_.status == DIPLOMACY_OFFER_STATUS_UNRESOLVED)
.collect { diploOffer =>
diploOffer.offerDetails match {
case _: AllianceOfferDetails => diploOffer
}
}
.toVector
def availableCommand(
gameState: GameState,
factionId: FactionId
): Option[ResolveAllianceOfferAvailableCommand] =
unresolvedAllianceOffers(gameState, factionId) match {
case items if items.isEmpty => None
case offers =>
Some(
ResolveAllianceOfferAvailableCommand(
offers = offers.collect {
case DiplomacyOfferProto(
originatingFactionId,
_ /* targetFactionId */,
messengerHeroId,
messengerOriginProvinceId,
status,
_ /* eligibleStatuses */,
offerTextId: String,
AllianceOfferDetails(_ /* unknownFieldSet */ ),
_ /* unknownFieldSet */
) =>
DiplomacyOfferProto(
originatingFactionId = originatingFactionId,
targetFactionId = factionId,
offerDetails = AllianceOfferDetails(),
messengerHeroId = messengerHeroId,
messengerOriginProvinceId = messengerOriginProvinceId,
status = status,
eligibleStatuses = (EligibleDiplomacyStatuses.acceptRejectStatuses ++ EligibleDiplomacyStatuses
.maybeImprisonStatus(
actingFactionId = factionId,
gameState = gameState
)).map(StatusConverter.toProto),
offerTextId = offerTextId
)
}
)
)
}
}
@@ -1,29 +1,16 @@
package net.eagle0.eagle.library.actions.availability
import net.eagle0.eagle.api.available_command.ResolveBreakAllianceAvailableCommand
import net.eagle0.eagle.common.diplomacy_offer.{BreakAllianceDetails, DiplomacyOffer as DiplomacyOfferProto}
import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus
import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.{
DIPLOMACY_OFFER_STATUS_ACCEPTED,
DIPLOMACY_OFFER_STATUS_UNRESOLVED
}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.model.proto_converters.diplomacy_offer.status.StatusConverter
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.DiplomacyOfferInfo
import net.eagle0.eagle.model.state.diplomacy_offer.status.{Accepted, Unresolved}
import net.eagle0.eagle.model.state.diplomacy_offer.BreakAlliance
import net.eagle0.eagle.model.state.game_state.GameState as ScalaGameState
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object AvailableResolveBreakAllianceCommandFactory {
// ==================== Scala overload ====================
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.DiplomacyOfferInfo
/** Scala overload - returns pure Scala type */
def availableCommand(
gameState: ScalaGameState,
gameState: GameState,
factionId: FactionId
): Option[AvailableCommand] = {
val unresolvedOffers = gameState
@@ -52,64 +39,4 @@ object AvailableResolveBreakAllianceCommandFactory {
)
end if
}
// ==================== Proto overload ====================
private def unresolvedBreakAllianceOffers(
gameState: GameState,
factionId: FactionId
): Vector[DiplomacyOfferProto] =
gameState
.factions(factionId)
.incomingDiplomacyOffers
.filter(_.status == DIPLOMACY_OFFER_STATUS_UNRESOLVED)
.collect { diploOffer =>
diploOffer.offerDetails match {
case _: BreakAllianceDetails => diploOffer
}
}
.toVector
def availableCommand(
gameState: GameState,
factionId: FactionId
): Option[ResolveBreakAllianceAvailableCommand] =
unresolvedBreakAllianceOffers(gameState, factionId) match {
case items if items.isEmpty => None
case offers =>
Some(
ResolveBreakAllianceAvailableCommand(
offers = offers.collect {
case DiplomacyOfferProto(
originatingFactionId: FactionId,
_ /* targetFactionId */,
messengerHeroId: FactionId,
messengerOriginProvinceId: FactionId,
status: DiplomacyOfferStatus,
_ /* eligibleStatuses */,
offerTextId: String,
details: BreakAllianceDetails,
_ /* unknownFieldSet */
) =>
DiplomacyOfferProto(
originatingFactionId = originatingFactionId,
targetFactionId = factionId,
offerDetails = details,
messengerHeroId = messengerHeroId,
messengerOriginProvinceId = messengerOriginProvinceId,
status = status,
eligibleStatuses = Vector(
DIPLOMACY_OFFER_STATUS_ACCEPTED
) ++ EligibleDiplomacyStatuses
.maybeImprisonStatus(
actingFactionId = factionId,
gameState = gameState
)
.map(StatusConverter.toProto),
offerTextId = offerTextId
)
}
)
)
}
}
@@ -1,27 +1,17 @@
package net.eagle0.eagle.library.actions.availability
import net.eagle0.eagle.api.available_command.ResolveInvitationAvailableCommand
import net.eagle0.eagle.common.diplomacy_offer.{DiplomacyOffer as DiplomacyOfferProto, InvitationDetails}
import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.{
DIPLOMACY_OFFER_STATUS_ACCEPTED,
DIPLOMACY_OFFER_STATUS_UNRESOLVED
}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.faction_utils.{FactionUtils, LegacyFactionUtils}
import net.eagle0.eagle.model.proto_converters.diplomacy_offer.status.StatusConverter
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.DiplomacyOfferInfo
import net.eagle0.eagle.model.state.diplomacy_offer.{DiplomacyOffer, Invitation}
import net.eagle0.eagle.model.state.diplomacy_offer.status.{Accepted, Unresolved}
import net.eagle0.eagle.model.state.game_state.GameState as ScalaGameState
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.FactionId
object AvailableResolveInvitationCommandFactory {
// ==================== Scala overload ====================
private def alreadyAcceptedInvitationScala(
private def alreadyAcceptedInvitation(
incomingDiplomacyOffers: Iterable[DiplomacyOffer]
): Boolean =
incomingDiplomacyOffers.exists {
@@ -29,16 +19,15 @@ object AvailableResolveInvitationCommandFactory {
case _ => false
}
private def invitationIsActionableScala(
private def invitationIsActionable(
invitation: Invitation,
provinces: Iterable[ProvinceT]
): Boolean =
invitation.status == Unresolved &&
FactionUtils.provinceCount(invitation.originatingFactionId, provinces) > 0
/** Scala overload - returns pure Scala type */
def availableCommand(
gameState: ScalaGameState,
gameState: GameState,
factionId: FactionId
): Option[AvailableCommand] = {
val allInvitations = gameState
@@ -47,10 +36,10 @@ object AvailableResolveInvitationCommandFactory {
.collect { case inv: Invitation => inv }
val actionableInvitations =
allInvitations.filter(inv => invitationIsActionableScala(inv, gameState.provinces.values))
allInvitations.filter(inv => invitationIsActionable(inv, gameState.provinces.values))
Option.when(
actionableInvitations.nonEmpty && !alreadyAcceptedInvitationScala(allInvitations)
actionableInvitations.nonEmpty && !alreadyAcceptedInvitation(allInvitations)
) {
AvailableCommand.ResolveInvitationAvailable(
invitations = actionableInvitations.map { invitation =>
@@ -68,81 +57,4 @@ object AvailableResolveInvitationCommandFactory {
)
}
}
// ==================== Proto overload ====================
private def alreadyAcceptedInvitation(
incomingDiplomacyOffers: Seq[DiplomacyOfferProto]
): Boolean =
incomingDiplomacyOffers.exists {
case DiplomacyOfferProto(
_ /* originatingFactionId */,
_ /* targetFactionId */,
_ /* messengerHeroId */,
_ /* messengerOriginProvinceId */,
DIPLOMACY_OFFER_STATUS_ACCEPTED,
_ /* eligibleStatuses */,
_ /* offerMessage */,
InvitationDetails(_ /* unknownFieldSet */ ),
_ /* unknownFieldSet */
) =>
true
case _ => false
}
private def invitationIsActionable(
diplomacyOffer: DiplomacyOfferProto,
gameState: GameState
): Boolean = diplomacyOffer match {
case DiplomacyOfferProto(
originatingFactionId,
_ /* targetFactionId */,
_ /* messengerHeroId */,
_ /* messengerOriginProvinceId */,
DIPLOMACY_OFFER_STATUS_UNRESOLVED,
_ /* eligibleStatuses */,
_ /* offerMessage */,
InvitationDetails(_ /* unknownFieldSet */ ),
_ /* unknownFieldSet */
) =>
LegacyFactionUtils
.provinceCount(originatingFactionId, gameState) > 0
case _ => false
}
def availableCommand(
gameState: GameState,
factionId: FactionId
): Option[ResolveInvitationAvailableCommand] = {
val allInvitations = gameState
.factions(factionId)
.incomingDiplomacyOffers
.filter { diploOffer =>
diploOffer.offerDetails match {
case InvitationDetails(_ /* unknownFieldSet */ ) => true
case _ => false
}
}
val actionableInvitations =
allInvitations.filter(inv => invitationIsActionable(inv, gameState))
Option.when(
actionableInvitations.nonEmpty && !alreadyAcceptedInvitation(
allInvitations
)
) {
ResolveInvitationAvailableCommand(
invitations = actionableInvitations.map {
_.withEligibleStatuses(
(EligibleDiplomacyStatuses.acceptRejectStatuses ++ EligibleDiplomacyStatuses
.maybeImprisonStatus(
actingFactionId = factionId,
gameState = gameState
)).map(StatusConverter.toProto)
)
}
)
}
}
}
@@ -1,37 +1,18 @@
package net.eagle0.eagle.library.actions.availability
import net.eagle0.eagle.api.available_command.ResolveRansomOfferAvailableCommand
import net.eagle0.eagle.common.diplomacy_offer.{
DiplomacyOffer as DiplomacyOfferProto,
HostageOfferedInExchange as HostageOfferedInExchangeProto,
InvitationDetails,
PrisonerOfferedInExchange as PrisonerOfferedInExchangeProto,
PrisonerToBeRansomed as PrisonerToBeRansomedProto,
RansomOfferDetails as RansomOfferDetailsProto
}
import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.{
DIPLOMACY_OFFER_STATUS_REJECTED,
DIPLOMACY_OFFER_STATUS_UNRESOLVED
}
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.util.ransom_validity.RansomValidity
import net.eagle0.eagle.library.util.LegacyRansomValidity
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.proto_converters.diplomacy_offer.status.StatusConverter
import net.eagle0.eagle.model.state.diplomacy_offer.{DiplomacyOffer, Invitation, RansomOffer}
import net.eagle0.eagle.model.state.diplomacy_offer.{Invitation, RansomOffer}
import net.eagle0.eagle.model.state.diplomacy_offer.status.{Rejected, Unresolved}
import net.eagle0.eagle.model.state.game_state.GameState as ScalaGameState
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object AvailableResolveRansomOfferCommandFactory {
// ==================== Scala overload ====================
// Don't show an offer if the same faction has an outstanding offer that trades away the prisoner to be ransomed,
// or either the offering or targeted faction has an incoming invitation that must be resolved first
private def hasOutstandingConflictScala(
private def hasOutstandingConflict(
offer: RansomOffer,
gameState: ScalaGameState
gameState: GameState
): Boolean = {
val prisonerHeroId = offer.prisonerToBeRansomed.prisonerHeroId
@@ -69,9 +50,8 @@ object AvailableResolveRansomOfferCommandFactory {
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.DiplomacyOfferInfo
/** Scala overload - returns pure Scala type */
def availableCommand(
gameState: ScalaGameState,
gameState: GameState,
factionId: FactionId
): Option[AvailableCommand] = {
val provinces = gameState.provinces.values.toVector
@@ -80,7 +60,7 @@ object AvailableResolveRansomOfferCommandFactory {
.incomingDiplomacyOffers
.collect { case ro: RansomOffer => ro }
.filter(offer => RansomValidity.isStillValid(offer, provinces))
.filter(offer => !hasOutstandingConflictScala(offer, gameState))
.filter(offer => !hasOutstandingConflict(offer, gameState))
.filter(_.status == Unresolved) match {
case items if items.isEmpty => None
case offers =>
@@ -98,86 +78,4 @@ object AvailableResolveRansomOfferCommandFactory {
)
}
}
// ==================== Proto overload ====================
// Don't show an offer if the same faction has an outstanding offer that trades away the prisoner to be ransomed,
// or either the offering or targeted faction has an incoming invitation that must be resolved first
private def hasOutstandingConflict(
offer: DiplomacyOfferProto,
gs: GameState
): Boolean = offer.offerDetails match {
case RansomOfferDetailsProto(
Some(prisonerToBeRansomed),
_,
_,
_,
_ /* unknownFieldSet */
) =>
gs.factions.values
.flatMap(_.incomingDiplomacyOffers)
.exists { diploOffer =>
diploOffer.offerDetails match {
case RansomOfferDetailsProto(
_,
prisonersOffered,
_,
_,
_ /* unknownFieldSet */
) =>
prisonersOffered
.exists(
_.heroId == prisonerToBeRansomed.prisonerHeroId
) && diploOffer.status != DIPLOMACY_OFFER_STATUS_REJECTED
case _ => false
}
} || gs
.factions(offer.originatingFactionId)
.incomingDiplomacyOffers
.filter(_.status != DIPLOMACY_OFFER_STATUS_REJECTED)
.exists { invitation =>
invitation.offerDetails match {
case InvitationDetails(_ /* unknownFieldSet */ ) => true
case _ => false
}
} || gs
.factions(offer.targetFactionId)
.incomingDiplomacyOffers
.filter(_.status != DIPLOMACY_OFFER_STATUS_REJECTED)
.exists {
_.offerDetails match {
case InvitationDetails(_ /* unknownFieldSet */ ) => true
case _ => false
}
}
case _ => throw new EagleInternalException("Not a ransom offer")
}
def availableCommand(
gameState: GameState,
factionId: FactionId
): Option[ResolveRansomOfferAvailableCommand] =
gameState
.factions(factionId)
.incomingDiplomacyOffers
.collect { diplomacyOffer =>
diplomacyOffer.offerDetails match {
case _: RansomOfferDetailsProto => diplomacyOffer
}
}
.filter(offer => LegacyRansomValidity.isStillValid(offer, gameState))
.filter(offer => !hasOutstandingConflict(offer, gameState))
.filter(_.status == DIPLOMACY_OFFER_STATUS_UNRESOLVED) match {
case items if items.isEmpty => None
case offers: Seq[DiplomacyOffer] =>
Some(
ResolveRansomOfferAvailableCommand(
offers = offers.map {
_.withEligibleStatuses(
EligibleDiplomacyStatuses.acceptRejectStatuses.map(StatusConverter.toProto)
)
}
)
)
}
}
@@ -1,25 +1,16 @@
package net.eagle0.eagle.library.actions.availability
import net.eagle0.eagle.api.available_command.ResolveTruceOfferAvailableCommand
import net.eagle0.eagle.common.diplomacy_offer.{DiplomacyOffer as DiplomacyOfferProto, TruceOfferDetails}
import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.DIPLOMACY_OFFER_STATUS_UNRESOLVED
import net.eagle0.eagle.internal.game_state.GameState
import net.eagle0.eagle.library.settings.TruceMonths
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.proto_converters.diplomacy_offer.status.StatusConverter
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.DiplomacyOfferInfo
import net.eagle0.eagle.model.state.diplomacy_offer.status.Unresolved
import net.eagle0.eagle.model.state.diplomacy_offer.TruceOffer
import net.eagle0.eagle.model.state.game_state.GameState as ScalaGameState
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object AvailableResolveTruceOfferCommandFactory {
import net.eagle0.eagle.library.util.DateProtoUtils.*
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.DiplomacyOfferInfo
/** Scala overload - returns pure Scala type */
def availableCommand(
gameState: ScalaGameState,
gameState: GameState,
factionId: FactionId
): Option[AvailableCommand] = {
val unresolvedOffers = gameState
@@ -48,66 +39,4 @@ object AvailableResolveTruceOfferCommandFactory {
)
end if
}
// ==================== Proto overload ====================
private def unresolvedTruceOffers(
gameState: GameState,
factionId: FactionId
): Vector[DiplomacyOfferProto] =
gameState
.factions(factionId)
.incomingDiplomacyOffers
.filter(_.status == DIPLOMACY_OFFER_STATUS_UNRESOLVED)
.collect { diploOffer =>
diploOffer.offerDetails match {
case _: TruceOfferDetails => diploOffer
}
}
.toVector
def availableCommand(
gameState: GameState,
factionId: FactionId
): Option[ResolveTruceOfferAvailableCommand] =
unresolvedTruceOffers(gameState, factionId) match {
case items if items.isEmpty => None
case offers =>
Some(
ResolveTruceOfferAvailableCommand(
offers = offers.collect {
case DiplomacyOfferProto(
originatingFactionId,
_ /* targetFactionId */,
messengerHeroId,
messengerOriginProvinceId,
status,
eligibleStatuses,
offerTextId,
TruceOfferDetails(_, _),
_ /* unknownFieldSet */
) =>
DiplomacyOfferProto(
originatingFactionId = originatingFactionId,
targetFactionId = factionId,
offerDetails = TruceOfferDetails(
endDate = Some(
gameState.currentDate.get.addMonths(TruceMonths.intValue)
)
),
messengerHeroId = messengerHeroId,
messengerOriginProvinceId = messengerOriginProvinceId,
status = status,
eligibleStatuses = (EligibleDiplomacyStatuses.acceptRejectStatuses ++ EligibleDiplomacyStatuses
.maybeImprisonStatus(
actingFactionId = factionId,
gameState = gameState
)
.toVector).map(StatusConverter.toProto),
offerTextId = offerTextId
)
}.toVector
)
)
}
}

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