Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 3f81b34f21 Add stale PR refs cleanup to iOS addressables build
Same fix as mac_build - prune stale PR refs before checkout to prevent
"Could not scan for Git LFS files" errors on self-hosted runners.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 06:59:34 -08:00
adminandClaude Opus 4.5 35ce2f7931 Give each modal panel its own dedicated modal blocker
Previously Settings, Bug Report, and What's New panels shared a single
modal blocker, which could cause state conflicts if one panel's blocker
state affected another.

Now each panel has its own dedicated blocker:
- Settings Panel Modal Blocker
- Bug Report Modal Blocker
- What's New Modal Blocker

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 06:45:39 -08:00
adminandClaude Opus 4.5 515be0b759 Add click-to-dismiss on modal blockers as safety net
If a modal panel somehow becomes invisible while its blocker remains
active, users can now tap the dimmed area to dismiss it. This prevents
getting stuck with an unresponsive UI.

Applied to all three controllers that share the modal blocker:
- WhatsNewPanelController
- BugReportPanelController
- SettingsPanelController

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 06:40:19 -08:00
893bc3a009 Split Organize Troops cost display into separate value and warning labels (#5824)
- Change costLabel to only show the numeric cost value
- Add separate costWarningLabel for "(only X available)" warning
- Warning label only shown when insufficient gold available

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:20:59 -08:00
191dd0c3fd Request narrative prose output for backstory updates (#5823)
Update hero and battalion backstory prompt generators to explicitly
request flowing narrative prose rather than dated lists. The LLM
should weave events into a cohesive story that reads as a biography
or unit history, not a timeline.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:12:23 -08:00
faf53e642f Add clicked province panel support for narrow screens (#5822)
- Add underProvinceNameText/underProvinceOwnerText to MapController
- Update SetUpCenterText to populate both regular and under text fields
- Add clickedProvincePanel/underClickedProvincePanel refs to EagleGameController
- Switch panel visibility based on screen width in ArrangeLayout
- Organize EagleGameController Inspector fields with Header attributes

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:00:54 -08:00
45c6b22ef8 Add client update notification system (server-side) (#5820)
Notify connected clients when a new version is available after CI deploy.

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

Components:
- Proto: ClientUpdateAvailable message, NotifyClientUpdate RPC
- Eagle: notifyClientUpdate() broadcasts to lobby users
- Admin server: /notify-update HTTP endpoint with secret auth
- CI: Notify steps in mac_build.yml and unity_build.yml
- Docker: NOTIFY_SECRET env var passed to admin container

Client-side handling will be added in a follow-up PR.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 20:57:37 -08:00
7460b32098 Add battalion backstory to LLM prompt generators (#5813)
* Add battalion backstory to LLM prompt generators

Add descriptionWithBackstory methods to BattalionDescriptions that include
the battalion's backstory text alongside the basic description (name, type,
size). This gives LLM prompts richer context about the units involved.

Updated prompt generators:
- SuppressBeastsPromptGenerator (Failed and Succeeded): Include battalion
  backstory when describing beast hunting attempts
- HeroBackstoryUpdatePromptGenerator: Include battalion backstory in
  FoughtInBattle, SuppressedBeasts, and SuppressedRiot events

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

* Fix battalion backstory usage in prompts

Use basic description inline in sentences and include backstory as
separate context block, matching how hero backstories are handled.

- In SuppressBeastsPromptGenerator: use basic description in
  "$heroName took [battalion]" and add fullDescription as context
- In HeroBackstoryUpdatePromptGenerator: use basic description only
  in event text (hero's story doesn't need battalion backstory detail)
- Renamed methods in BattalionDescriptions for clarity:
  - backstoryText: just the backstory
  - fullDescription: "Name is a battalion... $backstory"
  - optionalFullDescription: for optional battalions

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

* Fix duplicate code blocks from rebase

The rebase introduced duplicate code blocks in FoughtInBattle,
SuppressedBeasts, and SuppressedRiot cases that caused syntax errors.

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

* Remove unused BattalionView methods from BattalionDescriptions

Since EventForHeroBackstory now uses BattalionId instead of BattalionView,
we no longer need the BattalionView overloads in BattalionDescriptions.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 20:56:53 -08:00
d4c6f3f83c Change EventForHeroBackstory to use BattalionId instead of BattalionView (#5818)
The FoughtInBattle, SuppressedBeasts, and SuppressedRiot events now store
Option[BattalionId] instead of Option[BattalionView]. This allows looking
up the battalion from GameState at prompt generation time, providing access
to the battalion's current backstory text rather than a snapshot.

Changes:
- Proto: Changed battalion field from BattalionView to optional int32
- EventForHeroBackstoryT.scala: Changed battalion type to Option[BattalionId]
- EventForHeroBackstoryConverter: Updated toProto/fromProto conversion
- ResolveBattleAction, SuppressBeastsCommand, HandleRiotCrackDownCommand:
  Now pass battalion.map(_.id) when creating events
- HeroBackstoryUpdatePromptGenerator: Looks up battalion from GameState
  using gameState.battalions.getOrElse(id, gameState.destroyedBattalions(id))
- Updated tests and removed unused BattalionViewFilter deps

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 20:27:17 -08:00
d1c30315c4 Update Notification Panel layout (#5817)
* Update Notification Panel layout

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

* Additional Notification Panel layout tweaks

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

* Update button text styling

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 20:09:13 -08:00
b54a64becb Update Exile Vassal and Hero Gift panel layouts (#5816)
* Update Exile Vassal panel layout

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

* Update Hero Gift panel layout

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 19:31:21 -08:00
47e9af8808 Fix battalion backstory crash and generate initial backstories for raised battalions (#5814)
* Fix empty.tail crash in BattalionBackstoryUpdatePromptGenerator

When a battalion had no previous backstory versions, calling .last on
an empty vector caused a NoSuchElementException. Use lastOption with
a match to safely handle the empty case by returning an empty string.

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

* Generate initial backstory for newly raised battalions

When a player raises a new battalion via OrganizeTroopsCommand, now
generates a BattalionInitialBackstoryRequest so the battalion gets
its first backstory. Previously only battalions created at game start
(in NewGameCreation) received initial backstories.

This ensures all battalions have backstories from their creation,
complementing the empty.tail fix which handles the transitional case.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 19:14:56 -08:00
627dab71c6 Improve Suppress Beasts layout and cost display formatting (#5815)
- Update Suppress Beasts command panel layout in Unity
- Only show "(only X available)" when insufficient gold, with newline

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 19:07:17 -08:00
465b1dbab0 Add battalion popup header with name and type icon (#5811)
Add references for battalion name (GeneratedTextUpdater) and type icon
(RawImage) to the battalion popup panel header. Populate them when
hovering over a battalion row.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 18:55:18 -08:00
242df8d760 Fix None.get crash in OrganizeTroopsCommand backstory event creation (#5812)
When creating backstory events for changed battalions, the code assumed
every battalion in changedBatt would also be in changedBattalions. This
is not true when a battalion is modified indirectly - for example, when
troops are transferred OUT of it to another battalion.

The fix uses find() and handles the None case with default values (0 for
hired, dismissed, and transferred in counts).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 18:47:03 -08:00
eb52ea5aaf Fix CI: detect and recover from git corruption (#5809)
* Add git corruption detection to Unity CI workflows

Self-hosted runners can accumulate corrupted git state over time.
Add a pre-checkout step that runs git fsck and nukes the repo if
corruption is detected, allowing a fresh clone.

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

* Fix CI git corruption: check target ref objects instead of full fsck

Self-hosted runners can have stale refs (e.g., refs/remotes/pull/*/merge)
pointing to commits whose objects weren't fetched when the PR was updated.
Running git fsck is too broad and slow. Instead, check if the specific
target ref's objects are complete before checkout.

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

* Fix CI: prune stale PR refs instead of checking all objects

Self-hosted runners persist .git between runs. When a PR is updated,
actions/checkout doesn't prune old local refs like refs/remotes/pull/*/merge.
These stale refs may point to commits whose objects were never fetched,
causing "missing object" errors during checkout.

Fix by explicitly removing PR refs before checkout. This is faster and
more targeted than validating object completeness.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 18:32:10 -08:00
1bcac6ee27 Add battalion backstory events for riot, training, arming, and starvation (#5810)
Add four new battalion backstory event types:
- SuppressedRiot: Records when a battalion helps suppress a riot, including
  the riot size, casualties, and whether the suppression succeeded
- Trained: Records training sessions by a hero, with training improvement
- Armed: Records equipment upgrades with armament improvement and cost
- SurvivedStarvation: Records when battalions suffer casualties due to
  food shortage, with loss proportion and whether on march or in garrison

The LLM prompt generator includes guidance to not over-emphasize routine
training and arming events in favor of more dramatic events like battles
and riots.

Also includes per-battalion cost tracking in ArmTroopsCommand to properly
attribute gold spending for each battalion's backstory event.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 18:31:12 -08:00
2562e99e3c Update TODO: mark shardok cancellation and what's new as done (#5807)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 18:11:23 -08:00
01bb6c47ab Add What's New feature to Unity client (#5805)
* Add What's New feature to Unity client

- Add WhatsNewManager singleton for fetching and tracking changelog
- Add WhatsNewPanelController for modal display with category styling
- Integrate CheckAndShow() call in ConnectionHandler after OAuth login

The manager fetches entries from assets.eagle0.net/whats-new.json,
tracks last seen date in PlayerPrefs, and shows new entries on login.
UI prefab setup required in Unity Editor.

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

* Wire up What's New UI in Unity

- Move WhatsNewEntryUI to separate file for Unity component discovery
- Add What's New Entry prefab
- Wire up WhatsNewManager and WhatsNewPanelController in Gameplay scene

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:38:43 -08:00
85255d15d5 Style What's New summary as a button (#5808)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 17:24:34 -08:00
371d964922 Add battalion backstory events and LLM-driven updates (#5796)
* Add battalion backstory infrastructure

Adds support for battalions to have backstory events similar to heroes:

- New proto message EventForBattalionBackstory with event types:
  - OrganizedTroops: tracks size changes, hiring, dismissals, transfers
  - SuppressedBeasts: records beast suppression operations
  - ApprehendedOutlaws: records outlaw apprehension (for future use)
  - FoughtInBattle: records battle participation and casualties

- New Scala enum EventForBattalionBackstoryT matching the proto

- Updated BattalionT trait and BattalionC with:
  - backstoryVersions: Vector[BackstoryVersion] for LLM-generated text
  - backstoryEvents: Vector[EventForBattalionBackstoryT] for events
  - Helper methods: withBackstoryVersions, withBackstoryEvents, addBackstoryEvent

- New converters:
  - BackstoryVersionConverter (extracted for reuse)
  - EventForBattalionBackstoryConverter
  - Updated BattalionConverter to handle backstory fields

- Wired up backstory events in:
  - SuppressBeastsCommand: adds SuppressedBeasts event to battalion
  - ResolveBattleAction: adds FoughtInBattle event to battalions

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

* Add LLM-driven battalion backstory updates

This commit adds the system to actually use battalion backstory events
to trigger LLM-generated backstory updates, similar to the hero backstory
update system.

New components:
- BattalionBackstoryUpdateAction: finds battalions with events, generates
  LLM requests, adds new backstory versions, and clears events
- BattalionBackstoryUpdateActionGenerator: creates action from game state
- BattalionBackstoryUpdatePromptGenerator: generates prompts describing
  events for LLM to update backstory
- BattalionBackstoryUpdateRequest proto message and converter

The system is wired into:
- EndPlayerCommandsPhaseAction
- EndVassalCommandsPhaseAction
- EndBattleAftermathPhaseAction
- EngineImpl (command execution)

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

* Track previousSize/newSize for battalion events, add heroId to OrganizedTroops

Changes:
- SuppressedBeasts event: replaced 'casualties' with 'previousSize' and 'newSize'
- ApprehendedOutlaws event: added 'previousSize' and 'newSize' fields
- OrganizedTroops event: added 'heroId' for the province ruler who ordered the reorganization
- OrganizeTroopsCommand now creates battalion backstory events when troops are organized
- Updated prompt generator to include hero names and size information

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 16:55:46 -08:00
20f4ed5bc5 Cancel outstanding Shardok battles when game is deleted (#5806)
When a game is deleted (via admin console or last player dropping),
cancel any pending Shardok battles to:
- Stop processing battles for non-existent games
- Prevent reconnection attempts for deleted games
- Clean up resources

The implementation removes matching entries from pendingBattles map,
which prevents scheduleReconnect from re-establishing streams and
handleStreamingResponse from processing further updates.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 16:04:44 -08:00
7d69596176 Add What's New admin console and changelog script integration (#5804)
- Add CRUD handlers for managing what's-new entries stored in S3
- Add whats_new.html template with add/edit/delete forms
- Add navigation link to layout.html
- Add player-friendly summary generation to generate_changelog.sh
- Add seed JSON file for initial data

The admin console at /whats-new allows managing changelog entries
that will be displayed to players in the Unity client. The script
integration generates summaries and opens the admin console with
pre-populated content after sending weekly changelogs.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 15:51:53 -08:00
689dc5e531 Add battalion backstory popup on hover (#5803)
* Add battalion backstory popup on hover

Add support for displaying battalion backstories when hovering over
battalions in the Battalions panel, similar to hero backstories:

- Added battalionPopupPanel and battalionPopupPanelBackstory fields
- Added BattalionLongHoverRowChanged method to handle hover events
- Uses GeneratedTextUpdater for auto-scrolling text display

To wire up in Unity:
1. Create a popup panel similar to hero popup
2. Add GeneratedTextUpdater component for backstory text
3. Assign references in HeroesAndBattalionsPanelController
4. Wire battalionsTable.LongHoverRowChangedHandler to BattalionLongHoverRowChanged

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

* Add Eagle0 > Build Protos menu item for editor proto rebuilds

Provides a convenient way to rebuild protocol buffer files from within
Unity Editor (Cmd+Shift+P on Mac). This helps when proto files change
and the generated C# code needs to be updated.

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

* Wire up battalion backstory popup panel in Unity

- Add TableRowHoverDetector to Battalion Row Prefab for hover events
- Configure battalion popup panel in Gameplay scene
- Wire LongHoverRowChangedHandler to BattalionLongHoverRowChanged

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

* Position popup column spacer via EagleGameController layout system

Set the popup column spacer width in ArrangeLayout() alongside the
heroes and battalions panel width, ensuring they stay synchronized
when resolution changes.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 15:03:36 -08:00
3a8f8a4aff Add backstory_text_id to BattalionView (#5802)
Propagate battalion backstories to clients by adding backstory_text_id
field to BattalionView proto and Scala view classes.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 11:24:50 -08:00
b957cdeed7 Add command interaction guidance and hero stat gain tutorials (#5800)
1. Command Interaction Guidance (onboarding step 7):
   - Explains clicking provinces for acting vs right-click for target
   - Describes using left side panels for heroes/battalions selection
   - Reminds that Commit finalizes the command

2. Hero Stat Gain Tutorial:
   - Triggered first time a player's hero gains a stat point
   - Uses HeroStatGained action result type for reliable detection
   - Explains experience, stat growth, and professions

Also updated info button tutorial to mention the '?' keyboard shortcut.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 10:57:49 -08:00
207f5e07ca Update alpha TODO: mark completed items, add end game ideas (#5801)
Completed:
- Bug report form in Unity client
- Feedback channel (Discord server)
- Support plan (Discord + bug reporting + email)
- Art/music licenses and open source disclosures (attributions panel)

Added end game ideas:
- Win condition: all other factions defeated
- Mid-game progression: King recognition events

Moved "What's new" changelog to nice-to-have.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 10:01:29 -08:00
fff739e144 Add battalion initial backstory generation (#5798)
* Add battalion initial backstory generation

Adds LLM-generated backstories for battalions created at game start.

Changes:
- Add backstoryVersions field to Battalion proto and BattalionT/BattalionC
- Add BattalionInitialBackstoryRequest to GeneratedTextRequestT
- Create BattalionInitialBackstoryPromptGenerator for LLM prompts
- Wire up in LlmResolver and NewGameCreation
- Update BattalionConverter to handle backstory versions

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

* Rename hero_backstory_version to backstory_version

Since BackstoryVersion is used for both heroes and battalions, rename:
- hero_backstory_version.proto -> backstory_version.proto
- hero_backstory_version_proto -> backstory_version_proto
- hero_backstory_version_scala_proto -> backstory_version_scala_proto

Also rename leading_hero_id to province_ruler_hero_id in
BattalionInitialBackstoryRequest to clarify this is the hero who
rules the province, not a battalion commander.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 09:44:48 -08:00
14fcfa0f03 Fix bug report panel not showing on first click (#5799)
* Fix bug report panel not showing on first click

Change Start() to Awake() so initialization happens at load time
rather than when the GameObject first becomes active. This prevents
Start() from disabling the panel after Show() has enabled it.

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

* Enable BugReportPanelController GameObject at scene start

The controller needs to be active so Awake() runs at load time,
allowing Show() to work on first click.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 09:39:06 -08:00
a098bf2f56 Add bug report form to Unity client (#5784)
* Add bug report form to Unity client

Adds a Report Bug button to the Settings panel that opens a bug report
form. Reports include:
- User description
- Game state (game ID, faction, date, active battles)
- System info (OS, CPU, GPU, resolution)
- Recent connection logs

Reports are sent via webhook (supports Discord and Slack).
Configure webhook URL with BugReportPanelController.SetWebhookUrl().

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

* Configure Discord webhook URL for bug reports

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

* Add escape key handling for bug report and attributions panels

- Bug report panel closes on Escape
- Attributions panel closes on Escape
- Settings panel only toggles if other panels aren't open
- Wire up bug report panel UI in Gameplay scene

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 09:10:44 -08:00
46b16ecbb7 Fix sporadic warmup binary build failure in CI (#5797)
The warmup binary was being copied from a hardcoded bazel-bin path that
doesn't work reliably with cross-compilation and remote caching. When the
binary was cached but not materialized locally, the cp command would fail.

Fix by:
- Create a warmup_tar pkg_tar target that packages the warmup binary
- Use tar extraction instead of cp, which forces Bazel to materialize
  the output file before the command runs
- Rename the binary from warmup_linux_amd64 to warmup in the tar

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 07:06:25 -08:00
1112739c04 Unity layout updates: Recruit panel and Diplomacy button-style toggles (#5795)
* Update Recruit command panel layout

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

* Update Diplomacy toggles to button-style like Improve

Change radio-style toggles to highlighted button toggles using
ToggleGroup and CanvasGroup alpha for disabled state.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 07:05:48 -08:00
0e24333aaf Refactor HeroGenerator and BattalionNameGenerator for functional purity (#5791)
- Make HeroGenerator immutable with private constructor
  - Change `var remainingPregeneratedHeroes` to `val`
  - Return tuple `(HeroGenerationResponse, HeroGenerator)` from getHero()
  - Add `forTesting()` factory method for test usage
  - Filter excluded names during construction instead of lazily

- Make BattalionNameGenerator immutable
  - Change `var excludingNames` to `val` (was never mutated anyway)

- Update callers to properly thread the generator through recursion/fold
  - PerformUnaffiliatedHeroesAction: thread generator through `go` recursion
  - NewGameCreation: thread generator through fold, extract at end
  - EngineImplTest: use forTesting() instead of mock

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 06:55:13 -08:00
4eb43010fd Fix casualtyCount to only reflect battalion losses (#5794)
casualtyCount was incorrectly set to total damage (including hero vigor
loss) instead of just battalion troop losses. Now:
- No battalion: 0 casualties
- Battalion survived: actual troop losses
- Battalion destroyed: entire battalion size

This ensures casualtyCount semantically represents battalion casualties,
not hero damage which is tracked separately.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 06:31:02 -08:00
c9ca34e5e8 Fix None.get crash in SuppressBeastsSucceededPromptGenerator (#5793)
When a hero suppresses beasts alone (no battalion), casualtyCount can
still be > 0 because casualties are applied to hero vigor. The code
incorrectly tried to access the battalion name for casualty text even
when no battalion existed.

Now only show battalion casualty text when a battalion actually exists.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 05:37:18 -08:00
5ffa08b0e5 Update Unity layout for command panel (#5792)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:53:42 -08:00
12b473ac61 Add detailed help content for command tutorials (#5790)
* Add detailed help content for command tutorials

Update help text for many commands with more useful information:
- Rest: Vigor mechanics and Constitution bonus
- Train: Strength/Charisma stats, Champion bonus
- Hero Gift: Loyalty thresholds and January check
- Feast: Benefits and cost scaling
- Trade: Exchange rates and Economy bonus
- Arm Troops: Infrastructure requirements
- Organize Troops: Battalion types and requirements
- Recruit Heroes: Ready to Join mechanics
- Travel: List of available town activities
- Send Supplies: Destination selection tips
- Divine: What it reveals and cost considerations
- Recon: Intelligence gathered and risks
- Swear Kinship: Requirements and benefits

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

* Auto-switch help panel when selecting a different command

If the help panel is open and the user selects a different command,
the help panel now automatically switches to show help for the new
command instead of staying on the old one.

Also fix Rest tutorial: Constitution is max Vigor, not recovery rate.

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

* Add combat warning to March help

Mention that marching may trigger a battle if enemy occupies the
destination or is also marching there.

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

* Fix Feast help: only costs gold, not food

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

* Fix Recon help: scouts can't be captured

Remove incorrect warning about capture risk.

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

* Fix Recon help: remove incorrect agility info

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:41:18 -08:00
af5fb35731 Remove non-local returns and -Wconf suppression (#5789)
* Remove non-local returns and -Wconf suppression

Refactor code to avoid non-local returns (using `return` inside closures)
which are deprecated in Scala 3. Changes:

- OAuthService.scala: Replace early returns with if/else patterns in
  exchangeCodeForToken, fetchUserInfo, generateAppleClientSecret,
  exchangeAppleCode, and parseAppleIdToken methods
- PerformProvinceEventsAction.scala: Replace for-loop with early return
  with foldLeft pattern in beastType method

Remove the `msg=Non local returns:silent` suppression from the toolchain.

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

* Fix non-local returns in MapValidationTest

Refactor checkMonthlyWeather to use exists() instead of foreach with
early returns. Also fixes typo in error message (was "> 0" should be "> 100").

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:32:54 -08:00
6f25ba93cf Add minimum width (720px) to tutorial panel (#5788)
* Add minimum width (480px) to tutorial panel

Prevents help text from being too tall and narrow.

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

* Change tutorial panel minimum width to 720px

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:12:20 -08:00
660ee12afc Remove unnecessary -Wconf suppression for 'this' qualifier warnings (#5787)
This suppression was not needed - there are no warnings of this type
in the codebase.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 21:11:45 -08:00
c00db45794 Replace automatic command tutorials with info button system (#5785)
* Replace automatic command tutorials with info button system

Instead of showing one-time tutorial popups when command panels open,
users can now click an info button in the command panel header to view
help anytime. This makes help more accessible and less intrusive.

Changes:
- Remove automatic tutorial trigger from CommandSelector
- Add info button to CommandPanelController (created programmatically)
- Add ShowCommandHelp() to TutorialManager for repeatable help display
- Add info button intro step to onboarding sequence
- Update March, Defend, Improve help with detailed instructions
- Remove onboarding prerequisite from command tutorials

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

* Remove programmatic info button creation

Keep just the public field reference so it can be set up in Unity Editor.

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

* Fix command tutorial ID lookup

Convert CommandType enum (e.g., "ImproveCommand") to tutorial ID format
(e.g., "command_improve") by removing "Command" suffix and lowercasing.

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

* Add toggle behavior to info button and improve help content

- Clicking info button while help is showing now closes it
- Add ActiveSequenceId property to TutorialManager
- Update Improve help to mention agility/strength bonuses and engineer bonus

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

* Fix tutorial panel positioning to stay above command panel

- Simplify positioning logic for Top anchor to reliably place panel just above target
- Make panel at least 600px wide (or target width) for better readability
- Remove complex size-dependent clamping that caused inconsistent positioning
- Use simpler pivot-based positioning that doesn't depend on panel size calculations

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

* Fix command tutorial ID conversion for multi-word commands

Convert PascalCase command names to snake_case to match tutorial IDs:
- "OrganizeTroopsCommand" -> "command_organize_troops"
- "ArmTroopsCommand" -> "command_arm_troops"
- etc.

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

* Reduce tutorial panel minimum width to 540px

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

* Add Paladin bonus tip to Give Alms help

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

* Add imprisonment warning to Diplomacy help

Warn that untrusted factions may imprison your ambassador.

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

* Fix manage_prisoner tutorial ID to match command type

Command is ManagePrisonerCommand (singular), not ManagePrisonersCommand.

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

* Add '?' keyboard shortcut to toggle command help

Press Shift+/ (?) to open/close help for the current command.

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

* Remove forced width matching for tutorial panel

Let the panel use its natural ContentSizeFitter width instead of
forcing it to match the command panel width.

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

* Add info button to command panel in Unity scene

Wire up info button UI in Gameplay.unity to show command help.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 17:09:57 -08:00
3af3c29630 Replace deprecated _ wildcard syntax with ? in type arguments (#5786)
Scala 3 deprecates `_` for wildcard type arguments in favor of `?`.
This fixes the one occurrence in CommandChoiceHelpers.scala and removes
the -Wconf suppression for this warning.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:54:25 -08:00
09280e2e70 Enable -Werror and fix all Scala compiler warnings (#5783)
* Enable -Werror and fix compiler warnings (partial)

This commit enables -Werror in the Scala toolchain and fixes warnings
in the main server code and several test files. Main changes:

Server code fixes:
- Remove unused imports (HeroId, ActionResult, unused, Try)
- Remove unused parameters (playerCount cascade in NewGameCreation,
  persister in synchronizedCreateGame, userServiceForGames in buildServer)
- Fix discarded value warnings with explicit : Unit type ascriptions
- Fix pattern match warning by removing unnecessary type annotation

Test code fixes:
- Remove unused imports across multiple test files
- Remove unused default parameter values that are always explicitly passed
- Fix ScalaMock expectation setup warnings with : Unit
- Fix MapValidationTest to properly combine boolean checks
- Fix loneElement usage to use proper assertions

The -Werror flag is now enabled along with -Wconf suppressions for:
- Initialization warnings (safe patterns Scala 3 warns about)
- ScalaTest assertion return values
- External/generated sources

Many test files still have warnings that need to be fixed in follow-up
commits before all 315 tests will build with -Werror.

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

* Remove unused heroes variable in ReturningHeroesTest

Fix CI failure caused by unused private member warning.

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

* Fix Scala compiler warnings in test files for -Werror compatibility

- Remove unused imports across multiple test files
- Remove unused private members and default parameters
- Add `: Unit` type ascription to ScalaMock expectations to silence discarded value warnings
- Fix deprecated `<function> _` syntax by removing trailing ` _`
- Replace deprecated `= _` with `scala.compiletime.uninitialized`
- Add `val _ =` to explicitly discard unused return values

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

* Fix remaining Scala compiler warnings in main sources for -Werror

- Remove unused imports: JString in OpenAIResponsesServiceImpl, FactionId in
  DiplomacyOptionConverter and RansomOfferDetailsConverter,
  OpenAIChatCompletionsServiceImpl in HeroLibraryGenerator
- Replace deprecated `= _` with `= uninitialized` in SseSubscriber and
  ShardokInstanceManager
- Remove unused private member MessageType in SseSubscriber
- Remove unused private member openAICaller in HeroLibraryGenerator
- Add missing pattern match cases: UNKNOWN_UNIT in UnitStatusConverter,
  Gender.Unknown in HeroLibraryGenerator
- Fix discarded value warnings with `val _ =` in ShardokInstanceManager
- Fix unused pattern variables in NameListChecker

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 16:28:25 -08:00
adminandGitHub 44e90aaed2 Fix compiler warnings across codebase for -Werror preparation (#5782) 2026-02-02 08:46:54 -08:00
73f6d1fc33 Fix tutorial auto-created game to use 7 factions (#5781)
Previously the tutorial created a 1-faction game (solo with no AI).
Now it creates a 7-faction game (1 human + 6 AI), matching the
default when creating a game from the lobby.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 21:21:38 -08:00
5e7d833738 Add alpha test notice to invitation landing page and email (#5780)
For the closed alpha, we're using a simple notice instead of a full
privacy policy. The notice explains:
- This is a private alpha test
- We collect OAuth email and gameplay data
- Users can delete their account via accounts.eagle0.net
- Contact email for questions

Added to:
- Invitation landing page (/invite/{code})
- Invitation email (HTML and plaintext)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 20:47:53 -08:00
b40be57329 Clean up faction names by removing unnecessary "The" prefix (#5779)
Remove "The" from faction names where it sounds better without:
- Jade Caravan
- Maniacal Monks
- Bulwark Brotherhood
- Builders of a Better World
- Iron Fist Confederacy
- Mad Doombringers
- Shadow Army
- Verdant Fellowship

Keep "The" where it adds gravitas or feels essential:
- The Ashen Circle
- The Fracture Covenant
- The Hollow Throne
- The King's Loyalists
- The Syncopated Sanctum

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 19:10:42 -08:00
3123d597e6 Add iOS export compliance flag to avoid manual App Store Connect step (#5778)
Set ITSAppUsesNonExemptEncryption=false in Info.plist during iOS build.
This indicates the app only uses standard OS-provided encryption (HTTPS)
and avoids the manual compliance questionnaire in App Store Connect.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 19:05:49 -08:00
85226178ec Fix additional compiler warnings for -Werror preparation (#5777)
- Remove unused imports (FactionT, HostileArmyGroupProto, GameId)
- Remove unused parameters (jwtService in AuthServiceImpl, gameId in MarchCommand, provinces in ProvinceDistances)
- Add exhaustive match handling for ImprovementTypeProto UNKNOWN case

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 18:03:00 -08:00
3ca987f3cd Unity layout updates (#5776)
* Layout updates

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

* Additional layout updates

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 15:53:14 -08:00
85fd136cf9 March/Defend command selector layout improvements (#5775)
* Wire up heroesColumn and battalionsColumn references in March Command Selector

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

* Update command selector layout when aspect ratio changes

Previously, hero/battalion column visibility was only set when a command
selector was first opened. Now layout changes are propagated to the
active selector via OnLayoutModeChanged(), so columns hide/show
dynamically when the window is resized.

- Add OnLayoutModeChanged() virtual method to CommandSelector
- Override in MarchCommandSelector and DefendCommandSelector
- Add UpdateSelectorLayoutMode() to CommandPanelController
- Call from EagleGameController when layout mode changes

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

* Increase narrow screen threshold from 1.35 to 1.45

The previous threshold was too low to catch some narrow aspect ratios
that should trigger narrow layout mode.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 15:13:25 -08:00
813a1a05c3 Enable -Wall and fix compiler warnings for Scala 3 (#5767)
This is a stepping stone toward enabling -Werror for exhaustiveness checking
on sealed traits. Changes include:

- Enable -Wall in Scala toolchain configuration
- Add -Wconf suppressions for generated code and external dependencies
- Fix discarded value warnings by explicitly discarding with `val _ =`
- Fix non-local return warnings in JwtService using nested if/else
- Fix deprecated `= _` syntax using `= uninitialized` in OAuthHttpHandler
- Fix implicit parameter syntax: `(ec)` -> `(using ec)` in GrpcRetrier
- Remove unused imports across multiple files
- Add @unused annotation for intentionally unused parameters
- Fix wrong imports in SettingUpdater (was using wrong proto package)
- Fix BUILD.bazel exports to make GrpcRetrier available transitively
- BUG FIX: StringConstructionParser was silently ignoring parse errors
  when closing character wasn't found (added missing return)

Note: -Werror is not yet enabled (TODO in tools/BUILD.bazel) because
there are still unused import warnings in some files. Once those are
fixed, -Werror can be uncommented to get compile-time exhaustiveness
checking.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 15:08:00 -08:00
2726c477a1 Move displayName -> userId resolution from Eagle to admin server (#5774)
Eagle's GameAdminServiceImpl now expects userId directly in requests,
removing its dependency on UserService for displayName lookups.

Changes:
- Rename new_username -> new_user_id in game_admin.proto
- Rename previous_username -> previous_user_id in ReassignFactionResponse
- Rename Result enum values: INVALID_USERNAME -> INVALID_USER_ID,
  USERNAME_ALREADY_IN_USE -> USER_ALREADY_IN_USE
- Add resolveToUserId() to admin_server.go that accepts either userId
  or displayName and resolves to userId using the Admin API
- Remove resolveToUserId and UserService dependency from GameAdminServiceImpl

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 15:02:01 -08:00
641fe066fb Remove user_name field from Shardok PlayerSetupInfo (#5773)
Shardok never used the user_name field - the C++ code completely ignores
it and the Unity client creates its own player names ("You" and "AI").

Changes:
- Mark user_name as reserved in player_info.proto
- Change ShardokInterfaceProxy to use humanFactions: Set[FactionId]
  instead of playerToUserMap: Map[FactionId, String]
- Simplify GamesManager.resolveBattle to pass just the set of human factions
- Remove UserName assignments from CustomBattleHandler.cs

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 15:00:19 -08:00
df0379d36c Fix admin console display name lookup by passing admin auth (#5772)
The resolveDisplayName function calls adminClient.GetUser, which
requires admin authentication. Previously, fetchGames was passing
a plain context without the admin JWT token, causing GetUser to fail
and fall back to showing the UUID instead of the display name.

This fix changes fetchGames to accept the HTTP request and use
createAdminContext(r) which extracts the admin JWT token from the
request and adds it to the gRPC metadata.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 14:18:29 -08:00
1243330527 Move intro text below Development Team on credits page (#5771)
The "Eagle0 is built with..." paragraph now appears after the
Development Team section, giving proper prominence to the team.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 13:46:24 -08:00
ec995cf94b Move displayName lookup from Eagle to admin server (#5770)
Eagle now sends userId in the userName field for running games.
The admin server looks up the displayName via GetUser and falls
back to showing the userId if displayName is not set.

This is cleaner separation of concerns - Eagle handles game logic,
admin server handles user display.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 12:11:53 -08:00
a0cb963e18 Add widescreen/narrow layout support for Attack Decision panel (#5766)
* Add widescreen/narrow layout support for Attack Decision panel

- Add separate control containers for widescreen and narrow layouts
- Add duplicate references for toggles, images, sliders, and labels
- Use Active* properties to return the appropriate controls based on layout
- Enable/disable layout containers in SetUpUI based on IsNarrowLayout

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

* Wire up Attack Decision panel layout references

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

* Revert AttackDecisionCommandSelector changes, keep layout updates

Reverted the widescreen/narrow code changes - simpler approach works.
Keep Unity layout improvements.

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

* Additional layout updates

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 12:10:39 -08:00
431e589109 Remove migration code - all games now use UUID from the start (#5769)
Since we're starting fresh with no legacy displayName-based games,
remove the migration code that ran at startup. All new games will
use userId (UUID) from the start.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:34:43 -08:00
f1391c1f1a Use UUID for user-to-game mapping instead of displayName (#5765)
* Use UUID for user-to-game mapping instead of displayName

Previously, Eagle mapped users to games/factions using displayName. This
meant users who changed their display name would lose connection to their
games.

This change migrates to using userId (UUID) for the mapping:
- Add new proto fields user_id_to_pid and last_played_by_user_id
- Update GameController to use userIdToFactionId
- Add migration logic in GamesManager to convert legacy games on load
- Update EagleServiceImpl to use AuthorizationUtils.userId
- Update GameAdminServiceImpl to resolve display names from userIds
- Write to both old and new fields during transition period

Existing games are automatically migrated when first loaded after deploy.

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

* Add UserId opaque type, startup migration, and backup logic

Major changes:
- Create UserId opaque type for compile-time safety between userId and displayName
- Add explicit startup migration in GamesManager.begin() that converts all games
  from displayName-based mapping to userId-based mapping
- Create backup of games.e0es before migration (games.e0es.backup.<timestamp>)
- Remove mixed-state handling logic ("if it looks like UUID") - all games now
  use userId format after migration
- Update GameController, EagleServiceImpl, GameAdminServiceImpl, AuthorizationUtils
  to use UserId type throughout
- Add comprehensive migration tests covering:
  - Normal migration of displayName to userId
  - Skipping already-migrated games
  - Handling users not found (faction becomes AI)
  - Backup file creation verification

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

* Use UserId type in UserService for full type safety

- Updated UserService.findByUserId to take UserId instead of String
- Updated UserService.setDisplayName to take UserId instead of String
- Updated UserServiceImpl and NoOpUserService implementations
- Updated all call sites to pass UserId directly instead of using .value
- This completes the type safety for userId throughout the codebase,
  preventing accidental mixing of userId and displayName strings

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:03:24 -08:00
adminandGitHub 0b56573567 Trigger auth build on attributions.json changes (#5764) 2026-01-31 15:58:28 -08:00
81c8ac1bd1 Style credits panel with colors and better layout (#5763)
* Style credits panel with colors and better layout

- Gold (#FFD700) section headers
- White names/titles, gray license/source details
- Centered and larger team section at top
- Better spacing between sections
- Helper method for consistent section headers

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

* Update credits panel background color

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 15:47:11 -08:00
26c27d176b Add Eagle0 team credits to attributions (#5762)
- Game Design: Dan Crosby & Dan Kiracofe
- Programming: Dan Crosby
- Map Design: Dan Kiracofe

Updated both client and web attributions JSON files.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 15:28:33 -08:00
536badf7cc Add attributions/credits display (#5761)
- Add AttributionsController to load and format attributions from JSON
- Add scrollable attributions panel to Settings UI
- Display credits for music, sound effects, icons, fonts, and software

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 15:21:07 -08:00
ecf7f78a06 Add accounts.eagle0.net user self-service portal (#5760)
Implements a user-facing account management portal at accounts.eagle0.net
that allows non-admin users to view their account information and delete
their account.

The same admin server backend handles both admin.eagle0.net (admin-only)
and accounts.eagle0.net (user self-service), distinguished by Host header.

Changes:
- nginx: Add server blocks for accounts.eagle0.net
- admin_server: Add host detection (isAccountsHost/isAdminHost)
- admin_server: Add requireUser auth wrapper (auth without admin check)
- admin_server: Add /my-account, /my-account/delete, /goodbye routes
- admin_server: Modify login flow to not require admin for accounts portal
- templates: Add my_account_layout.html, my_account.html, goodbye.html
- templates: Update login.html to show different text per portal

Post-deploy manual steps required:
1. DNS: Add A record for accounts.eagle0.net -> droplet IP
2. SSL: Run certbot on droplet for accounts.eagle0.net certificate

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 14:46:42 -08:00
60ef23e4e0 Improve Heroes/Battalions panel and Trade command selector layout (#5759)
* Layout improvements

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

* Hero row and gameplay layout updates

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

* Additional Trade command selector layout

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 13:43:38 -08:00
c453b74140 Update LLVM from 20.1.2 to 20.1.4 (#5758)
Incremental update to get bug fixes and improvements in the LLVM/Clang
toolchain.

Note: LLVM 21.x has libc++ ABI incompatibilities that cause linker errors,
so staying on 20.x for now.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 12:26:21 -08:00
b0a2b0948a Unity layout changes (#5757)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 10:15:05 -08:00
46c8c8a44a Fix iOS build by checking for iOS module before skipping install (#5753)
* Fix iOS build by checking for iOS module before skipping install

The script was checking if Unity was installed but not whether the iOS
module was present. This caused iOS builds to fail when Unity was
installed without iOS support.

Now checks for platform-specific module directories before declaring
Unity ready.

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

* Use install-modules when editor exists but modules missing

Unity Hub CLI has separate commands:
- install: installs editor with modules (fails if editor exists)
- install-modules: adds modules to existing installation

Now detects which command to use based on whether the editor
directory exists.

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

* Output Unity log on build failure for easier debugging

When Unity builds fail, the actual error is hidden in a log file that
requires downloading an artifact. Now all Unity build scripts output
the last 200 lines of the log directly to the console on failure.

Updated scripts:
- build_unity_ios.sh
- build_windows.sh
- build_mac.sh
- build_ios_addressables.sh

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

* Treat 'module already installed' as success

Unity Hub CLI returns non-zero exit code when all requested modules
are already installed, with message "No modules found to install".
Now check output for "already installed" messages and treat as success.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 08:31:08 -08:00
6a9ce30635 Hide QA/prod environment selector in builds (#5756)
Environment selection UI (dropdowns and indicator) should only be
visible when running in the Unity Editor. In builds, always use prod.

Changes:
- Connection panel environment dropdown: hidden in builds
- Lobby environment dropdown: hidden in builds
- Environment indicator text (top left of Eagle gameplay): hidden in builds
- Force environment to prod (index 0) when not in editor

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 08:10:32 -08:00
e23a2ddd1b Update Sentry from 7.19.0 to 8.31.0 (#5755)
Major version upgrade with new features including:
- Feature flag evaluations on scope(s)
- MDC properties attached to structured logs as attributes
- Various bug fixes and improvements

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 08:06:01 -08:00
75c3d4cd9a Update AWS SDK from 2.28.1 to 2.41.18 (#5754)
Upgrade to the latest AWS SDK for Java v2, which includes many bug fixes,
performance improvements, and new features.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 07:48:41 -08:00
049f639322 Add admin container image verification in deploy workflow (#5752)
* Add eagle0.net as server name alias

Allows accessing the site via eagle0.net in addition to prod.eagle0.net.

Note: After deploying, run certbot to add eagle0.net to the SSL certificate:
  certbot --nginx -d prod.eagle0.net -d eagle0.net

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

* Add admin container image verification in deploy workflow

The admin container was running a stale image after the entrypoint was
changed from /app/admin_server to /app/admin_server_linux_amd64. This
happened because:

1. crane pull + docker load doesn't update the :latest tag locally
2. docker-compose fallback to :latest used the old cached image
3. --force-recreate recreated the container but with the old image

Fixes:
- Tag pulled admin image as :latest after docker load (ensures fallback
  uses correct image)
- Add image digest verification after deploy (fails early if wrong
  image is running)
- Add admin startup verification in deploy-blue-green.sh (catches
  container crashes with helpful logs)

This follows the same pattern used in auth_build.yml for auth service
verification.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 06:48:53 -08:00
af2efce7d2 Fix Trade starting position indicator to only move horizontally (#5750)
Preserve the vertical anchor values from the prefab instead of
overwriting them to span 0-1, which was causing vertical stretching.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:26:38 -08:00
3361dbf99e Add eagle0.net as server name alias (#5751)
Allows accessing the site via eagle0.net in addition to prod.eagle0.net.

Note: After deploying, run certbot to add eagle0.net to the SSL certificate:
  certbot --nginx -d prod.eagle0.net -d eagle0.net

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:23:01 -08:00
107d230d35 Add /credits route to nginx config (#5734)
Proxy the credits/attributions page from the auth service
so it's accessible at prod.eagle0.net/credits.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:20:17 -08:00
8a3353018e Show "Waiting for server..." during connection warmup (#5749)
When connected but no server status has been received yet (e.g., during
server warmup after deployment), show "Waiting for server..." with a
yellow indicator instead of just "Connected" with green. This gives
users better feedback that something is still happening.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 22:16:45 -08:00
024bcea7e6 Fix ransom offer lookup using wrong hero ID (#5748)
The DiplomacyOfferInfo was storing messengerHeroId but the lookup in
ResolveDiplomacyCommandSelector was matching against prisonerHeroId,
causing a "RansomOffer not found" exception.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 21:44:08 -08:00
73b9df5794 Sort alms command heroes by lowest fatigue instead of highest vigor (#5747)
Paladins are still prioritized first. Within profession groups,
heroes are now sorted by fatigue (constitution - vigor) ascending,
so less tired heroes are recommended first.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 21:34:01 -08:00
04eeeaeea5 Tune weather effect materials for better visual quality (#5746)
DroughtEffect:
- Increase texture tiling from 1x1 to 10x10 to reduce pixelation

FloodEffect:
- Increase texture tiling from 10x10 to 40x40 for finer detail
- Increase distortion strength from 0.02 to 0.1 for more visible waves
- Increase distortion speed from 2 to 5 for livelier animation

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 21:11:58 -08:00
d3ecaaf908 Remove overly aggressive animation cancellation from ModelUpdated (#5745)
The CancelAllAnimations() call in ModelUpdated() was interrupting normal
animations (like archery) because ModelUpdated() is called frequently
during gameplay. The ChargeAnimator fix alone (tracking _weapon in a
class field) is sufficient to prevent floating weapons.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 20:40:11 -08:00
60ee36017c Account for devastation in TotalDevelopmentQuest current value (#5744)
Use effective development values (base - devastation) when showing
current total development, matching how the quest completion is
actually evaluated.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 20:39:49 -08:00
7e7821ab55 Fix quest fulfillment to only credit acting faction, not allies (#5742)
SuppressRiotByForceQuest and FightBeastsAloneQuest were incorrectly
checking all provinces for quest fulfillment. This meant if an ally
performed the action, unaffiliated heroes in your provinces would
have their quests fulfilled.

Now filters to only check provinces ruled by the acting faction.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 20:29:17 -08:00
6304d5e3f9 Fix floating weapons by properly cleaning up combat animations (#5741)
- Add _weapon field to ChargeAnimator to track created weapon GameObjects
- Add CleanupWeapon() method to ChargeAnimator for proper cleanup
- Update CancelAnimation() in ChargeAnimator to destroy lingering weapons
- Add CancelAllAnimations() method to ShardokGameController
- Call CancelAllAnimations() at start of ModelUpdated() to clean up any
  lingering animation effects when the model state changes

Previously, if a charge animation was interrupted (e.g., by rapid clicking
or state changes), the weapon GameObject would remain floating on the map
because it was stored in a local variable rather than a class field.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 20:21:03 -08:00
15f32ed0e2 Fix weather shader texture property name for Unity UI compatibility (#5740)
Rename _EffectTex to _MainTex in the ProvinceWeather shader and update
all weather effect materials to use the new property name. Unity's
RawImage component expects _MainTex as the default texture property.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:56:49 -08:00
37d07b2bfa Add all missing quest type cases in pastTenseQuestDescription (#5739)
Adds the following missing cases:
- RescueImprisonedLeaderQuest
- ExpandToProvincesQuest
- SuppressRiotByForceQuest
- FightBeastsAloneQuest

This ensures all Quest subtypes are handled in pastTenseQuestDescription.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:52:28 -08:00
eead7bddfd Add TotalDevelopmentQuest case in pastTenseQuestDescription (#5738)
Fixes crash when generating hero backstory update for a completed
TotalDevelopmentQuest.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:37:09 -08:00
18a0c573aa Wire up province weather effect overlays in scene (#5737)
Hook up ProvinceWeatherController references in the Gameplay scene to
enable animated weather overlays (blizzard, flood, drought) on the
strategic map.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:36:40 -08:00
f10fd322e5 Show current development in TotalDevelopmentQuest description (#5736)
Display the current total development value alongside the target in the
quest description, making it easier for players to track their progress.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:33:48 -08:00
a60e64f1bf Add missing RandomSequential case in PerformVassalCommandsPhaseAction (#5735)
The TCommand.RandomSequential case was missing from the match expression,
causing a MatchError when SuppressBeastsCommand (which returns RandomSequential)
was executed during vassal commands phase.

Also made randomResults public in ProtolessRandomSequentialResultsAction
so it can be called from the match.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:14:00 -08:00
f069475f99 Add public credits/attributions web page (#5733)
Adds /credits endpoint to the auth service that displays all attribution
information from attributions.json in a readable HTML format.

The page includes:
- Creative Commons music (31 tracks with artist and license)
- Sound effects from Freesound (with source links)
- Fonts (with license info)
- Open source software dependencies (Java, Go, C++, C#)

The attributions.json file is now included in the auth server Docker
image so it can be served at runtime.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:46:54 -08:00
f40a860c93 Add attributions.json for license disclosures (#5732)
Structured JSON file containing attribution data for:
- Creative Commons music (31 tracks)
- CC sound effects from Freesound (7 files)
- Fonts (Open Sans, Alata, Josefin Sans, etc.)
- Open source software dependencies (Java, Go, C++, C#)
- License reference definitions

File is placed in both server resources and Unity Resources
folder for use in web and in-app attribution displays.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:40:24 -08:00
a3d64a17a2 Update SMALL_EAGLE_TODO with completed items (#5731)
* Update SMALL_EAGLE_TODO with completed items

Mark as done:
- Fix the Mac installer
- Generatedtext healing
- Audit assets for anything we don't have rights to and replace it
- Replace heroes that are based on real 20th or 21st century people or IP
- Running low on food tutorial

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

* Mark warlord profession tutorial as complete

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:18:26 -08:00
49ac693501 Remove obsolete documentation (#5730)
Delete outdated docs for completed work:
- OAuth implementation plans (OAuth is implemented)
- Scala 3 migration docs (migration is complete)
- Admin server enhancements (implemented)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:15:02 -08:00
be4519b51e Add starting position indicator to Trade slider (#5729)
Shows an arrow indicating the original food/gold balance, making it
easy to return the slider to its starting position (no change).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 16:10:50 -08:00
1eaa70fa47 Layout improvements (#5728)
* HeroesAndBattalions layout improvements

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

* Additional layout improvements

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:43:07 -08:00
3f685b05f5 Restore ransom offer scoring for AI acceptance and offering (#5726)
Restores the ransom offer scoring logic that was removed during deproto work.

**Acceptance side (ResolveDiplomacyCommandSelector):**
- AI now scores ransom offers using: prisoners + hostages + gold - base threshold
- Offers with positive score are accepted, others rejected

**Offering side (CommandChoiceHelpers.maybeRansomLeaderCommand):**
- Uses RansomOfferHelpers.chosenOffer to create minimal acceptable offers
- Checks trust threshold between factions before offering
- Enforces minimum time between repeat offers to same faction
- Filters out faction leaders from hostage offers
- Calculates minimal offer (prisoners + gold first, adds hostages only if needed)

**New file: RansomOfferHelpers.scala**
- ransomOfferScore(): Calculate offer value using settings
- chosenResolution(): Accept/reject based on score
- chosenOffer(): Create a reasonable offer from available options

Settings used:
- RansomOfferScorePerPrisoner, RansomOfferScorePerHostage, RansomOfferScorePerGold
- RansomOfferBaseNegative (threshold)
- MinimumTrustToOfferRansom, MinimumMonthsBeforeRepeatRansomOffer

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:37:23 -08:00
85a7b2669f Add widescreen-aware HeroesAndBattalions width and layout improvements (#5727)
* Add client support for SuppressRiotByForceQuest and FightBeastsAloneQuest

- DisplayNames.cs: Add display names for new quest types
- UnaffiliatedHeroRowController.cs: Add quest text formatting

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

* Clarify FightBeastsAloneQuest text

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

* Add widescreen-aware HeroesAndBattalions width and layout improvements

- Add popupCanvasLeftSpacer and popupCanvasRightSpacer LayoutElement refs
- In widescreen: HeroesAndBattalions width = left + right spacer
- In non-widescreen: HeroesAndBattalions width = left spacer only
- PleaseRecruitMe layout improvements

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 15:16:00 -08:00
228b63e2e0 Add client support for SuppressRiotByForceQuest and FightBeastsAloneQuest (#5725)
* Add client support for SuppressRiotByForceQuest and FightBeastsAloneQuest

- DisplayNames.cs: Add display names for new quest types
- UnaffiliatedHeroRowController.cs: Add quest text formatting

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

* Clarify FightBeastsAloneQuest text

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 14:59:16 -08:00
8fab725ac8 Add FightBeastsAloneQuest (#5720)
* Add FightBeastsAloneQuest - fulfilled when hero fights beasts without battalion

- Add proto message FightBeastsAloneQuest (field 27)
- Add Quest case class and converter
- Add soothsayer text for divine message prompt
- Add to CheckForFulfilledQuestsAction (returns false - action-based quest)
- Add to QuestCreationUtils for provinces with BeastsEvent
- Modify SuppressBeastsCommand to check quest fulfillment when optionalBattalion.isEmpty
- Add TCommand.RandomSequential variant for commands with both random rolls and multiple results
- Update RandomStateSequencer to handle RandomSequential

The quest is fulfilled simply by making the attempt to fight beasts alone,
regardless of whether the hero survives or not.

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

* Make FightBeastsAloneQuest province-agnostic

Fighting beasts alone in any province now fulfills the quest, rather than
requiring a specific province. Also replace isInstanceOf with pattern matching.

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

* Add missing override modifier to withProtolessRandomSequentialResultsAction

The method in RandomStateSequencerImpl overrides the trait definition
but was missing the override keyword, causing a compilation error.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 14:05:43 -08:00
71298e2cce Make SuppressRiotByForceQuest always available (#5724)
The quest should be offered regardless of whether a riot is currently
imminent - it's a general "when riots happen, crack down on them" quest
requirement that applies to future riots.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:39:41 -08:00
1c36b75201 Filter faction leaders from AI ransom offers (#5723)
When the AI makes ransom offers, it was including faction leaders as
potential hostages. This change:

1. Filters out any hostages that are leaders of the offering faction
2. Skips making the offer entirely if after filtering there's nothing
   left to offer (no prisoners, no non-leader hostages, no gold)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:33:56 -08:00
3b393ea84e Always enforce single instance - never allow second copy to run (#5721)
Previously, when a second instance was launched without a deep link,
it would allow itself to run as a duplicate. This caused issues in
the invitation flow where the user could end up with two copies.

Now the second instance always exits, regardless of whether it has
a deep link. If you want to run a new instance, close the existing one.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:18:41 -08:00
e4bcbc2083 Add widescreen spacer and layout improvements (#5722)
- Add wideSpacer GameObject reference to EagleGameController
- Enable spacer only when aspect ratio > 2.0 (widescreen)
- Additional layout adjustments in Gameplay.unity

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:05:59 -08:00
70c4c0025e Add SuppressRiotByForceQuest (#5719)
* Add SuppressRiotByForceQuest

Adds a new quest type that is fulfilled when a player successfully
suppresses a riot using the crack down command (hero survives).

Changes:
- Add SuppressRiotByForceQuest to proto and Quest.scala
- Add TCommand.RandomSequential variant for commands that return
  multiple results with random rolls
- Refactor HandleRiotCrackDownCommand to use the new variant and
  check for quest fulfillment on success
- Add quest creation for provinces with ImminentRiotEvent
- Update tests for new command signature

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

* Make SuppressRiotByForceQuest province-agnostic

Suppressing a riot in any province now fulfills the quest, rather than
requiring a specific province.

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

* Replace isInstanceOf with pattern matching in QuestCreationUtils

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 13:02:21 -08:00
75e32393b3 Improve OAuth landing pages for different user scenarios (#5718)
- New user without invitation (when required): Show clear error page
  explaining that an invitation is needed, no deep link
- Existing user: Auto-redirect to app via JavaScript (no button click)
- New user with invitation: Auto-redirect to app for registration flow

The invitation flow (separate handler) keeps the button with deep link
as users may need to download the app first.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 12:29:00 -08:00
79fa0bbee0 Upgrade Unity from 6000.3.0f1 to 6000.3.6f1 (#5717)
* Upgrade Unity from 6000.3.0f1 to 6000.3.6f1

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

* Add missing .meta files for notification generators

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 11:41:27 -08:00
9433577fc3 Fix OAuth flow for new users - don't create user prematurely (#5716)
* Fix OAuth flow for new users - don't create user prematurely

The generateDeepLinkURL function was calling FindOrCreateUser immediately
in the OAuth callback, which:
1. Created users before invitation code validation
2. Created users before display name was set

This caused new users without invitations to get through the OAuth flow
successfully but then fail to log in because they had no display name.

Fix:
- Server: Check if user exists first. For existing users, generate
  session transfer code. For new users, return state parameter so
  client can poll CheckOAuthStatus which properly handles invitation
  validation and new user registration.
- Client: Add handling for eagle0://auth/result/{state} deep link
  that polls CheckOAuthStatus for new user registration flow.

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

* Add CheckOAuthStatusAsync method to AuthClient

The HandleOAuthResultAsync method was calling a non-existent method.
Add a public single-check method that wraps the gRPC call.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 11:36:27 -08:00
c7b26828d6 Automate Unity version sync and CI installation (#5714)
* Automate Unity version sync and CI installation

1. UnityVersionSync.cs: Editor script that automatically updates
   ci/unity_version.sh when the project is opened in a newer Unity version.
   This runs on project load via [InitializeOnLoad].

2. ensure_unity_installed.sh: CI script that checks if the required Unity
   version is installed and attempts to install it via Unity Hub CLI if not.
   Added to all Unity build workflows.

3. Updated workflow paths to trigger on ci/unity_version.sh changes.

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

* Add platform-specific Unity modules and concurrent install safety

- Pass platform argument (mac/windows/ios) to ensure_unity_installed.sh
- Add lock file mechanism to prevent concurrent Unity installations
- Install only needed modules per platform (mac-il2cpp, windows-mono, ios)

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

* Read Unity version from ProjectVersion.txt instead of manual file

- Remove ci/unity_version.sh (manually maintained)
- Remove Assets/Editor/UnityVersionSync.cs (auto-sync script)
- Update ensure_unity_installed.sh to parse ProjectVersion.txt directly
- Unity automatically maintains ProjectVersion.txt, no manual step needed

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

* Update all build scripts to read Unity version from ProjectVersion.txt

- build_windows.sh
- build_unity_ios.sh
- build_ios_addressables.sh
- build_mac.sh
- ensure_unity_installed.sh (fix comment)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 11:25:23 -08:00
672adc2554 Add GaveToAvertRiot backstory event for riot prevention attempts (#5715)
When a hero gives food or gold to avert a potential riot, a backstory
event is now recorded with whether the attempt succeeded or failed.
This complements the existing SuppressedRiot event for crackdowns.

- Add GaveToAvertRiotBackstoryEvent to proto with province_id, food_given,
  gold_given, and succeeded fields
- Add GaveToAvertRiot case to EventForHeroBackstory enum
- Update HandleRiotGiveCommand to accept currentDate and create the
  backstory event for the ruling hero
- Add handler in HeroBackstoryUpdatePromptGenerator for LLM text generation
- Add tests verifying backstory events are created for both success and
  failure cases

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 11:19:30 -08:00
4fb50142ff Quit app when opening browser for OAuth (#5713)
* Quit app when opening browser for OAuth

Instead of keeping the app running and polling for OAuth completion,
quit the app after opening the browser. The server's "Return to Eagle"
deep link will relaunch a fresh instance with the auth session code,
which HandleSessionTransferAsync processes.

This eliminates the dual-instance problem where both the original app
(polling/waiting) and the deep-linked instance were running, leaving
an orphaned app window.

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

* Generate session transfer code in OAuth callbacks

When a desktop client completes OAuth, the callback now:
1. Creates/finds the user immediately (instead of during polling)
2. Generates a session transfer code
3. Returns deep link with the code: eagle0://auth/session/{code}

This allows the Unity app to quit during OAuth and relaunch cleanly
when the user clicks "Return to Eagle" - the new instance exchanges
the session transfer code for tokens via HandleSessionTransferAsync.

Updated all three OAuth callbacks (generic, Apple, Steam) to use
the new generateDeepLinkURL helper.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 11:06:51 -08:00
1129d81435 Add weekly Bazel cache cleanup workflow (#5710)
* Add weekly Bazel cache cleanup workflow

Removes Bazel output bases not accessed in 7 days to prevent disk
space accumulation on self-hosted runners. Keeps 'cache' and 'install'
directories. Remote cache means minimal perf impact from clearing
local output bases.

Runs weekly on Sunday at 00:00 UTC, can also be triggered manually.

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

* Use bazel clean instead of find-based cleanup

The previous approach looked for output bases not accessed in 7 days,
but since the runner is used regularly, those directories always have
recent access times and would never be cleaned up.

Using `bazel clean` weekly clears the local output base, freeing disk
space. The next build will be slightly slower but will pull from remote
cache.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:49:36 -08:00
ff9c455e81 Use AllowSetForegroundWindow to bring game to foreground (#5712)
* Use AllowSetForegroundWindow to let game take foreground

Instead of complex window closing timing, use the Windows API
AllowSetForegroundWindow() to grant the game process permission
to bring itself to foreground. This is the proper way to allow
a child process to take focus.

Changes:
- Add foreground_windows.go with AllowSetForegroundWindow wrapper
- Installer calls AllowSetForegroundWindow(pid) after launching game
- Pass --foreground flag to game
- Re-add --foreground handling in SingleInstanceEnforcer
- Game calls WindowFocusManager.BringToForeground() after 500ms delay

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

* Add foreground_stub.go for non-Windows builds

The AllowSetForegroundWindow function is Windows-only but the
installer needs to compile on all platforms for testing.

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

* Remove unused unsafe import

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

* Add foreground_windows.go to WebView genrule srcs

The genrule for the WebView build manually lists source files
and was missing the new foreground_windows.go file.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:45:04 -08:00
a6b0c78d48 Add UX support for ExpandToProvincesQuest and TotalDevelopmentQuest (#5711)
* Add UX support for ExpandToProvincesQuest and TotalDevelopmentQuest

- ExpandToProvincesQuest: Shows "Control X provinces (currently Y)"
- TotalDevelopmentQuest: Shows "Raise total development to X in {province}"

All quest types are now handled in the client.

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

* Fix ExpandToProvincesQuest to count developed provinces only

A province is considered "developed" when support >= 40 (MinSupportForTaxes).
Changed text from "Control X provinces" to "Have X developed provinces".

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:29:23 -08:00
5f44a79b18 Add TotalDevelopmentQuest for comprehensive province improvement (#5702)
New quest type that challenges players to raise the total development
(effective agriculture + economy + infrastructure) of the quest province
significantly higher than its current level. Uses 2x the standard
improvement quest range.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:16:17 -08:00
62a1aaff07 Fix installer launch order for proper foreground behavior (#5709)
The installer must close its window before launching the game so the
game can naturally become the foreground window. But ui.Close()
terminates the event loop, causing main() to exit.

Solution: Use a channel to make main() wait for the game to launch
after the window closes:
1. Goroutine: ui.Close() - closes window
2. Main thread: ui.Run() returns, waits on gameLaunched channel
3. Goroutine: sleeps 200ms (window now fully closed)
4. Goroutine: cmd.Start() - launches game (no foreground competition)
5. Goroutine: close(gameLaunched)
6. Main thread: receives signal, exits

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:10:22 -08:00
52498bff60 Add ExpandToProvincesQuest for faction expansion goals (#5701)
* Add ExpandToProvincesQuest for faction expansion goals

New quest type that challenges factions to expand to X provinces (where X is
current count + 2 to 5). Success requires having X provinces with support at
or above the tax minimum threshold. No failure condition.

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

* Cap ExpandToProvincesQuest target to total map size

Ensures the quest never requires more provinces than exist on the map.
If the faction already controls enough provinces that the minimum
expansion (2 provinces) would exceed the map size, don't offer the quest.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:04:56 -08:00
760435183b Add cleanup for /tmp/eagle0-* build directories in workflows (#5708)
All four workflows that use EAGLE0_BUILD_DIR were creating build
directories at /tmp/eagle0-{run_id} but never cleaning them up,
causing disk space accumulation on self-hosted runners.

Added `rm -rf "$EAGLE0_BUILD_DIR"` cleanup step with `if: always()`
to ensure cleanup runs even if the build fails.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 10:02:10 -08:00
599ae2db7f Rename QuestC to Quest and flatten directory structure (#5707)
* move out of concrete/

* Rename QuestC to Quest

The "C" suffix was for "Concrete" to distinguish from the old QuestT trait.
Now that QuestT is removed, simplify to just Quest.

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

* Remove redundant explicit imports

The wildcard import already covers all quest types.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:27:48 -08:00
1fccff00ff Close installer window before launching game for natural foreground (#5706)
Instead of trying to force the game to foreground with SetForegroundWindow
(which doesn't work reliably from a background process), close the installer
window before launching the game. This allows the game to naturally become
the foreground window since there's no competing window.

Also removes the --foreground flag handling that was added but didn't work.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 09:23:28 -08:00
2f8468af4d Simplify questDescription in DivineMessagePromptGenerator (#5705)
Refactor nested flatMap/match to use idiomatic Option chaining with
.flatMap(_.quest).map(...).getOrElse(...) pattern.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 08:52:04 -08:00
d9aa51d368 Bring game window to foreground after install (#5703)
The installer now passes --foreground flag when launching the game.
SingleInstanceEnforcer detects this flag and calls WindowFocusManager.BringToForeground()
after a 500ms delay to ensure the window is fully created.

This fixes the issue where the game window would appear behind the installer
after a fresh install or update.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 08:35:57 -08:00
bb3ed2bd17 Remove QuestT trait, use QuestC directly for exhaustive matching (#5704)
Replace the non-sealed QuestT trait with the sealed QuestC trait throughout
the codebase. This enables compile-time exhaustiveness checking on quest
type pattern matches, ensuring all quest types are handled.

Changes:
- Delete QuestT.scala and its BUILD target
- Update all imports from quest.QuestT to quest.concrete.QuestC
- Update method signatures and type annotations
- Add quest/concrete to BUILD exports for transitive visibility

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 08:29:48 -08:00
1ee0b3283b Add UX support for RescueImprisonedLeaderQuest (#5700)
* Add UX support for RescueImprisonedLeaderQuest

Display the new quest type in the free heroes list with the imprisoned
hero's name and the imprisoning faction. Uses dynamic hero name loading
like other hero-specific quests.

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

* Use hero name instead of 'imprisoned leader' in quest text

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

* Use actual hero name in rescue quest fallback text

Look up the imprisoned hero's name from the model instead of showing
generic "a hero" text.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:20:36 -08:00
c240778819 Fix RescueImprisonedLeaderQuest fulfillment check (#5699)
The quest should only be fulfilled when the hero is back as a faction
leader, not just when they're no longer imprisoned. Previously, the
quest would be incorrectly marked as fulfilled if the hero was executed.

- Fulfillment: Hero must be back in the faction's leaderIds
- Failure: Hero is no longer imprisoned but also not back as a leader
  (e.g., executed or released as traveler)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 22:06:22 -08:00
08072b2a3b Display quests concerning prisoners in Manage Prisoners UI (#5696)
When viewing a prisoner in the Manage Prisoners command selector, the UI
now shows any known quests from free heroes that relate to this prisoner.
For example, if a free hero has a quest to execute or release a specific
prisoner, that information is now displayed in the message text area.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 21:58:45 -08:00
04b6f21cd3 Add RescueImprisonedLeaderQuest for free heroes (#5698)
A new quest type that free heroes may offer when the divining faction
has at least one faction leader imprisoned by another faction.

The quest is to rescue that leader, either by ransom or by force
(conquering the province where they are held).

- Quest is fulfilled when the leader is no longer imprisoned by the
  specified faction
- Quest fails if the imprisoning faction is eliminated

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 21:55:17 -08:00
5c8b324dc8 Fix Mac codesigning to use correct keychain in CI (#5697)
* Remove --keychain flag from codesign commands

The --keychain flag was causing codesign to look for the private key in
the build keychain, but the matching cert+key is in login.keychain.
Since we now use the unambiguous SHA-1 hash, codesign will find the
correct certificate and key pair in whichever keychain contains them.

This fixes the intermittent errSecInternalComponent failures.

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

* Use build keychain specifically for CI codesigning

In CI, the workflow imports the signing certificate into a temporary
build keychain. Previously, the script searched ALL keychains and
picked the first certificate found, which could be an old certificate
from login.keychain instead of the freshly imported one.

Now when KEYCHAIN_NAME is set (CI environment), the script looks for
certificates only in that specific keychain. For local dev (no
KEYCHAIN_NAME), it still searches all keychains.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 21:51:15 -08:00
f8e8ca4fab Warn when sending faction leader on Suppress Beasts command (#5695)
* Warn when sending faction leader on Suppress Beasts command

Add warning when the player is about to send their faction leader on a
beast suppression mission. Losing the faction leader ends the game.

Three warning cases:
- No battalion: "Your hero may be killed"
- Faction leader with battalion: "You're risking your faction leader!"
- Faction leader without battalion: Combined warning for both risks

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

* Fix: Use TryGetValue instead of GetValueOrDefault

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

* Update warning text to use sworn brother/sister title

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:35:11 -08:00
adminandGitHub 3bf44093a5 Add LLM-generated text to outlaw apprehended notification (#5691) 2026-01-29 17:35:54 -08:00
9a7dd953f9 Add starvation notification visible to all factions (#5694)
* Add starvation notification visible to all factions

When a province runs out of food and battalions suffer casualties, a notification
is now generated with a darkly humorous LLM message from the province's ruling
hero. The notification is visible to all factions (targetFactionIds = empty).

Changes:
- Add StarvationDetails proto message for notification details
- Add StarvationNotificationMessage proto for LLM text generation
- Add Scala case classes for both notification and LLM request types
- Create StarvationNotificationPromptGenerator with gallows humor prompt
- Wire up in LlmResolver and PerformFoodConsumptionPhaseAction
- Add C# notification generator for Unity client display
- Update proto converters for serialization/deserialization

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

* Add LLM-generated text to outlaw apprehended notification

The captured outlaw now delivers a reflection on their situation.
Also adds apprehendingHeroId to the notification details.

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

* Revert "Add LLM-generated text to outlaw apprehended notification"

This reverts commit 60295e09ba74fd0b755bf4808353be05fa8554f5.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 17:16:39 -08:00
9171222a45 Fix iOS artifact storage and broken pipe error (#5693)
1. Don't upload IPA artifact after TestFlight upload - it's redundant
   and takes 150MB per build. Only keep artifact when skipping upload
   (for debugging), with 1-day retention.

2. Fix "broken pipe" error in artifact storage check by writing to temp
   file instead of piping through sort | head (which causes SIGPIPE).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 16:44:28 -08:00
adminandGitHub 921b11c075 Fix iOS TestFlight upload and add auto-incrementing build numbers (#5692) 2026-01-29 12:53:29 -08:00
53446667f9 Add hero backstory events for epidemic, weather, and starvation (#5689)
* Add hero backstory events for epidemic, weather, and starvation

- Add StartedEpidemic backstory event when a hero starts an epidemic
- Add ChangedWeather backstory event when a hero changes weather
- Add SurvivedStarvation backstory event for heroes in starving provinces

Changes:
- EventForHeroBackstoryT: Add 3 new enum cases
- Proto: Add corresponding proto messages
- EventForHeroBackstoryConverter: Add toProto/fromProto conversions
- HeroBackstoryUpdatePromptGenerator: Add LLM prompt text for events
- StartEpidemicCommand: Generate backstory event when executed
- ControlWeatherCommand: Generate backstory event when executed
- PerformFoodConsumptionPhaseAction: Generate backstory events for heroes
  in provinces experiencing starvation
- CommandFactory: Pass required date/faction params to commands
- Tests: Update to include new command parameters

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

* Improve backstory events: add acting province, enum weather type

- Add actingProvinceId to StartedEpidemic and ChangedWeather events
  to record the province the hero acted from (not just the target)
- Change weatherChangeType from String to WeatherChangeType enum
  for type safety in both Scala and proto
- Remove percentage from starvation prompt text (keep severity only)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 06:36:33 -08:00
c3ad03c270 Fix Mac codesign by using certificate hash (#5690)
* Fix Mac codesign by isolating build keychain during signing

The --keychain flag alone doesn't prevent codesign from finding matching
identities in other keychains on the search list. Fix by temporarily
setting ONLY the build keychain as the search list during signing, then
restoring the original list on exit.

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

* Include System.keychain for Apple root certificates

The previous fix broke certificate chain verification because we removed
the login keychain but also lost access to Apple's root certificates.
Include System.keychain (which has Apple roots) but not login.keychain
(which has the duplicate signing identity).

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

* Use certificate SHA-1 hash instead of name to avoid ambiguity

Instead of manipulating the keychain search list (which breaks certificate
chain verification), extract the SHA-1 hash of the specific certificate
from the build keychain and use that as the signing identity. SHA-1 hashes
are unambiguous and codesign will use the exact certificate specified.

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

* Fix certificate hash extraction - get first valid identity

The previous grep for SIGNING_IDENTITY failed because GitHub Actions
masks the value. Instead, get the first valid codesigning identity from
the keychain (there should only be one since we just imported it).

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

* Use full keychain path for find-identity

security find-identity requires the full path to the keychain file,
not just the keychain name. Resolve the full path from list-keychains
output before querying for identities.

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

* Add debug output to certificate import and remove set-keychain-settings

Add debug output to see what identities are available after import.
Also remove the set-keychain-settings line which may be causing issues
(the -u flag locks keychain on sleep).

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

* Search all keychains for identity hash

The build keychain import isn't creating a recognizable codesigning
identity, but the certificate exists in login.keychain. Search all
keychains and use the hash of the first valid identity - hashes are
unique and unambiguous regardless of which keychain contains them.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 06:34:58 -08:00
adminandGitHub 5389c2ca19 Refactor EventForHeroBackstoryT from sealed trait to Scala 3 enum (#5687) 2026-01-28 23:14:42 -08:00
8336db7b29 Fix Mac codesign ambiguous identity error (#5688)
Add --keychain flag to all codesign commands to explicitly specify which
keychain to use. This fixes the "ambiguous" error when the same signing
identity exists in multiple keychains (build keychain and login keychain).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 23:08:32 -08:00
77791da7c7 Fix Mac build keychain access (errSecInternalComponent) (#5686)
The Mac build was failing with errSecInternalComponent because the newly
created keychain wasn't added to the search list. Without this step,
codesign cannot find the certificate.

Changes:
- Add keychain to search list with security list-keychains
- Add -T /usr/bin/security to import command for completeness
- Set 1-hour keychain timeout for signing large app bundles

This matches the keychain setup used in the iOS TestFlight workflow.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 22:49:22 -08:00
adminandGitHub 7bef82a419 Add new profession proposals document (#4900) 2026-01-28 17:19:05 -08:00
558b2e54d6 Fix fullscreen toggle not working on Windows (#5685)
* Fix fullscreen toggle not working on Windows

Screen.fullScreen alone doesn't reliably toggle fullscreen on Windows.
Use Screen.SetResolution with explicit FullScreenMode instead.

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

* Fix fullscreen toggle control wiring in Gameplay scene

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 17:07:03 -08:00
9e464eac2c Add SuppressedRiotBackstoryEvent for crack down command (#5684)
Heroes who suppress riots via the crack down command now get a backstory
event recording the encounter. The event captures the province, riot size,
casualties, battalion used (if any), and whether suppression succeeded.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 16:53:04 -08:00
8aebf6bbc8 Add province weather effect overlays for strategic map (#5661)
* Add province weather effect overlays for strategic map

Implements animated weather effect overlays (blizzard, flood, drought) on
the Eagle strategic map. Effects are province-specific, dynamically created
as child overlays when weather events are active.

- Add ProvinceWeatherShader with scrolling animation and distortion support
- Add ProvinceWeatherController that dynamically creates overlays per province
- Add placeholder textures for snow, water caustics, and cracked earth
- Add effect materials with configured animation parameters
- Integrate weather updates into MapController's model setter

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

* Update weather effect materials with proper settings

- BlizzardEffect: Set scroll direction to move snow downward
- Assign texture tiling for appropriate particle size
- Wire up weatherController reference in Gameplay scene

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 16:50:17 -08:00
fbbee0e9ee Skip loading stale commands when pending command matches token (#5682)
When HandleAvailableCommands receives commands with a token that matches
a pending command, skip loading the commands entirely. The token is still
set (so TryPendingCommands can match and post the pending command), but
the commands are not loaded since they're stale - we already acted on them.

This is simpler than the flag-based approach:
- No SuppressNextUIUpdate flag
- Direct check in HandleAvailableCommands via callback
- If pending command fails, error handling refreshes state

Changes:
- Add HasPendingCommandWithToken callback to IClientConnectionSubscriber
- Add HasPendingEagleCommandWithToken helper to PersistentClientConnection
- Wire up callback in Subscribe method
- In HandleAvailableCommands, skip loading commands if token matches pending
- Preserve LastPostedToken across reconnects (don't clear in HandleStartingState)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 15:41:38 -08:00
ac0cc60fc2 Add low food warning notification with LLM-backed messaging (#5683)
When a province has less than 3 months of food remaining after
consumption, generate a notification where the ruling hero warns
about the food shortage in their personality/voice.

- Add LowFoodWarningDetails proto message for notification details
- Add LowFoodWarningMessage proto message for LLM request
- Create LowFoodWarningPromptGenerator for hero-voiced warnings
- Wire up notification generation in PerformFoodConsumptionPhaseAction
- Add C# notification generator for Unity client display

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 15:29:54 -08:00
75fed990ca Fix infinite reconnect loop when server rejects command with ERROR (#5681)
* Fix infinite reconnect loop when server rejects command with ERROR

When the server returned a command ERROR (e.g., invalid province selection),
the client would dispose the connection but NOT clear the pending command.
On reconnect, it would retry the same invalid command, get ERROR again,
and loop forever with increasing backoff.

The fix: handle ERROR the same way as BAD_TOKEN - remove the pending command
(it was processed/rejected, don't retry) and refresh the game subscription
to get current valid state. Don't disconnect since the connection is fine.

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

* Display command error to user via ErrorHandler

When server rejects a command with ERROR, invoke OnCommandError callback
which displays the error message via Debug.LogError (caught by ErrorHandler).

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 14:42:08 -08:00
38a07b6373 Reduce default lightning flash frequency by half (#5680)
Change default lightning interval from (1, 4) to (2, 8) seconds,
making flashes occur approximately half as often.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 14:41:44 -08:00
49c458bc5d Add debugging to iOS export step to diagnose certificate issue (#5679)
- List available keychains
- List available signing identities
- Show export options plist
- Remove -allowProvisioningUpdates (not needed for manual signing)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 13:27:39 -08:00
2e4d458759 Fix GameEnded notification crash when no victor (#5678)
The notification generator was accessing victor.Value unconditionally,
causing a NullReferenceException when the game ended without a victor.
This prevented the "Game Over" notification from being displayed.

Now safely handles the no-victor case and uses TryGetValue for the hero
lookup.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 13:25:24 -08:00
569194a68d Fix allHumansDefeated to check for imprisoned leaders (#5677)
The previous logic only checked if a faction leader was ruling a
province. If all leaders were traveling in armies (or in any other
non-ruling state), the game would incorrectly end.

Simpler fix: a faction can play if ANY leader is NOT imprisoned.
This covers all valid states (ruling, traveling, in province, etc.)
without enumerating them.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:54:41 -08:00
d39aa53a76 Reconnect immediately for expected stream timeouts (#5676)
For DeadlineExceeded (5 min gRPC timeout) and StreamEndedNormally,
reconnect immediately without backoff delay. These are expected events,
not failures - there's no reason to wait 2+ seconds before reconnecting.

This eliminates the "Retrying in..." status for normal stream refreshes.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:52:32 -08:00
43d56ee5a4 Use full signing certificate name for iOS export (#5675)
Try using full certificate name "Apple Distribution: Daniel Crosby (UWJ88DX8WQ)"
instead of just "Apple Distribution" to see if that resolves the export signing issue.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:52:13 -08:00
17441c4cdb Add design doc for notification diff batching optimization (#5674)
Documents analysis of why client notification generators currently
require per-action-result game state diffs, and proposes a solution
to enable diff batching by moving display data to server-side.

Deferred for now as current performance is acceptable.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:20:47 -08:00
6bdb6e4aa9 Fix iOS signing: archive without signing, sign during export (#5673)
- Archive step now skips signing (CODE_SIGN_IDENTITY="-")
- This avoids "UnityFramework does not support provisioning profiles" error
- Export step handles all signing with proper certificate and profile
- Added signingCertificate to export options

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 12:09:47 -08:00
3b379e55bc Optimize ActionResultFilter with Set and ListBuffer (#5672)
- Change UNIVERSALLY_VISIBLE_TYPES from Vector to Set for O(1) contains
- Replace O(n²) Vector concatenation with O(n) ListBuffer in filterForPlayer

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:19:37 -08:00
e99df3b900 Set DEVELOPER_DIR to use Xcode.app for iOS archive (#5671)
Fixes "xcode-select: error: tool 'xcodebuild' requires Xcode" when
the build machine's active developer directory is set to Command
Line Tools instead of Xcode.app.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 11:00:30 -08:00
335500a145 Fix isGameValid returning false during game loading (#5670)
The unrequestedTextHandler was being called BEFORE the game was added to
gameControllerInfos. This caused isGameValid(eagleGameId) to return false
for the game being loaded, causing all its LLM requests to be skipped
as "game deleted".

The fix adds the game to gameControllerInfos BEFORE calling the text
handlers, then updates it again after text handling completes.

Also add eagleGameId (in decimal and hex) to LLM request logging.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 10:38:26 -08:00
f84bf524f1 Add logging to diagnose stuck LLM requests (#5669)
Logs each unrequested text being processed with its type, ID, and
requestedAfterHistoryCount. Also logs the result of each request
(submitted, bypassed, deferred, or waiting on dependency).

This helps diagnose why certain LLM requests are repeatedly triggering
disk loads for old game states.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 10:02:09 -08:00
e7a97990d6 Fix duplicate connection on session restore (#5668)
TryRestoreSessionAsync() fires OnLoginSuccess on success, which triggers
ConnectWithOAuth() via the OnOAuthLoginSuccess handler. The calling code
was also calling ConnectWithOAuth() directly when restore returned true,
causing _createConnection() to be called twice in quick succession.

This caused the first connection to be disposed while still connecting,
leading to ObjectDisposedException and slow reconnection recovery.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 09:52:10 -08:00
3907f59c60 Skip validation when replaying persisted action results (#5667)
* Skip validation when replaying persisted action results

Create separate appliers:
- validatingApplier: for new results (e.g., new game creation)
- replayApplier: for replaying persisted results from storage

Persisted results were already validated when first applied, so
re-validating them during replay is unnecessary overhead.

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

* Add logging to track when stateAfter loads from disk

Logs the requested count, persisted count, and how far back in history
the request is going. This helps diagnose performance issues with LLM
prompt generation that requires loading historical game states.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 09:49:09 -08:00
e72e79c96c Fix O(n²) performance in formAwrs by using ListBuffer (#5666)
The previous implementation used Vector with `:+` append in a foldLeft,
which is O(n) per append, giving O(n²) total time for n results.

Changed both formAwrs and formAwrsFromScala to use:
- ListBuffer for O(1) amortized append
- foldLeft with tuple to track (buffer, currentState)

This should significantly improve performance when replaying persisted
game history.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 09:31:21 -08:00
173dfbafd9 Use run-specific keychain names to fix parallel signing conflicts (#5665)
When multiple runners on the same machine try to sign simultaneously,
they were conflicting on the shared keychain names (build.keychain,
ios-build.keychain). This caused errSecInternalComponent errors.

Changes:
- mac_build.yml: Use build-${run_id}.keychain
- ios_testflight.yml: Use ios-build-${run_id}.keychain
- codesign_mac_app.sh: Support KEYCHAIN_NAME env var with fallback

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 08:25:09 -08:00
18dd663b4e Fix reconnect bug where client gets stuck showing YourTurn with no commands (#5663)
When a command POST fails due to disconnect, the client would get stuck
after reconnecting:

1. PostCommand() sets LastPostedToken before POST succeeds
2. POST fails due to DeadlineExceeded, command kept in pending queue
3. After reconnect, server sends update with same token
4. HandleAvailableCommands() skips update because token == LastPostedToken
5. Client stuck with no commands despite server saying YourTurn

Fix: Instead of skipping when token matches LastPostedToken and we have
no commands, accept the commands. The server will return BAD_TOKEN if
we try to re-post a successfully processed command. But if our POST
failed, we need these commands to proceed.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 08:14:03 -08:00
171d4fd333 Add Map Editor scene assets and missing meta file (#5662)
- Add Map Editor lighting data and reflection probe assets
- Add missing VassalRisesDetailsNotificationGenerator.cs.meta

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 08:12:27 -08:00
a9b8e27e2a Use runner-specific build directories for parallel CI builds (#5664)
Add EAGLE0_BUILD_DIR environment variable to workflows, defaulting to
/tmp/eagle0-${github.run_id}. This allows multiple runners with the
unity-mac label to run builds in parallel without path conflicts.

Changes:
- Workflows set EAGLE0_BUILD_DIR based on run ID
- Build scripts use EAGLE0_BUILD_DIR with fallback to /tmp/eagle0
- Library cache remains shared (beneficial for build speed)

To add a second parallel runner:
1. Set up a new runner with the same 'unity-mac' label
2. GitHub Actions will automatically distribute jobs to available runners

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 07:35:43 -08:00
3e00e01c1f Add actions:write permission to iOS TestFlight workflow (#5660)
The cleanup job needs this permission to delete intermediate
xcode-project artifacts. Without it, the gh api DELETE call
fails with HTTP 403.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 07:02:25 -08:00
412207c4d4 Fix iOS bundle ID and signing for TestFlight (#5659)
- Update bundle identifier to net.eagle0.eagle (matches provisioning profile)
- Set appleDeveloperTeamID in Unity project settings
- Add CODE_SIGN_IDENTITY="Apple Distribution" to use distribution cert

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 06:47:22 -08:00
f128f7ea3c Enable PleaseRecruitMe from prisoners (#5658)
* Enable PleaseRecruitMe from prisoners

Prisoners can now trigger PleaseRecruitMe, allowing them to request
to join the faction that holds them. The LLM prompt includes context
about their prisoner status, which faction captured them, and when
they were captured.

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

* Fix BUILD deps and use correct API for prisoner prompt

- Add missing BUILD dependencies for event_for_hero_backstory_trait and
  unaffiliated_hero
- Use TextGenerationSuccess instead of non-existent TextGenerationResult.pure
- Use GeneratorUtilities.dateString for date formatting

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 06:41:46 -08:00
06b5a15f0c Fix iOS archive script: use Unity-iPhone scheme, remove xcpretty (#5657)
- Always use "Unity-iPhone" scheme (Unity's default app scheme)
- Remove xcpretty dependency (not installed on build machine)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 22:11:57 -08:00
25c85e4524 Use existing APPLE_ID credentials for TestFlight upload (#5656)
Reuse the same credentials already used for Mac notarization instead
of requiring a separate App Store Connect API key.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:56:41 -08:00
e24dace474 Add iOS TestFlight workflow for manual builds (#5655)
- New workflow_dispatch-only workflow for iOS TestFlight builds
- build_unity_ios.sh: Builds Unity iOS player (generates Xcode project)
- archive_ios.sh: Archives and exports IPA using xcodebuild
- upload_testflight.sh: Uploads IPA to TestFlight via App Store Connect API

Required secrets:
- IOS_CERTIFICATE: Apple Distribution certificate (.p12, base64)
- IOS_CERTIFICATE_PWD: Certificate password
- IOS_PROVISIONING_PROFILE: App Store provisioning profile (base64)
- APP_STORE_CONNECT_API_KEY: JSON with key_id, issuer_id, and key

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:34:39 -08:00
eb558ee8b8 Improve rain particle randomization and wind scaling (#5654)
* Additional layout improvements

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

* Wire up ambient volume slider

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

* Improve rain particle randomization and wind scaling

- Add per-particle speed variation (0.7x to 1.3x) to break up "sheet" effect
- Scale wind offset proportionally to rain speed so angle stays consistent
- Add rainWindMultiplier setting to Inspector for easy tuning

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 21:32:49 -08:00
9616f31cfc Show health status inline in admin console nav bar (#5653)
Replace the Health link that navigated to a separate page with an
inline status indicator that auto-refreshes every 10 seconds using
htmx. This provides at-a-glance health visibility without requiring
navigation.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:52:24 -08:00
cf18884cc7 Additional layout improvements (#5652)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:51:23 -08:00
ce3ed26cc2 Consider alliances when vassals decide to mobilize (#5650)
* Consider alliances when vassals decide to mobilize

Change chosenMobilizeIfAdjacentEnemyCommand to use FactionUtils.hostileNeighbors
instead of ProvinceUtils.adjacentHostiles. The latter treats any province owned
by a different faction as hostile, causing vassals to mobilize (and withhold
supplies) even when bordered only by allied provinces.

Now vassals will correctly send supplies to their faction head when their only
neighbors are allies or have truces, rather than unnecessarily mobilizing.

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

* Delete misleading ProvinceUtils.adjacentHostiles method

This method was named "adjacentHostiles" but actually returned provinces
owned by any different faction, ignoring alliance/truce relationships.
It had only one production usage which was just fixed to use the correct
FactionUtils.hostileNeighbors method instead.

Deleting to avoid future confusion.

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

* Add tests for FactionUtils.hostileNeighbors

Test coverage for:
- Returns hostile (no relationship) neighbors
- Excludes allied provinces
- Excludes truce provinces
- Excludes unruled provinces
- Excludes own faction's provinces
- Returns empty for province with no neighbors
- Correctly filters mixed neighbor types

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:47:25 -08:00
b928c3779e Improve HandleCapturedHero layout (#5651)
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:35:48 -08:00
6b90112568 Replace failure_horn.mp3 with licensed Negative Effect 04.wav (#5649)
Replace the last unlicensed sound effect with Negative Effect 04.wav
from the purchased Magic Spells Sound Effects LITE pack.

This completes the asset audit - all required items are now resolved.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 20:09:11 -08:00
d2cb5328cc Handle game loading failures gracefully (#5648)
Wrap ensureGameLoaded in try-catch to prevent one corrupted/broken game
from blocking all game loading for a user. On failure:
- Log loudly to stderr with CRITICAL prefix and stack trace
- Send exception to Sentry for monitoring
- Return false so other games can continue loading

This ensures players can still access their other games even if one
game's save data is corrupted or fails to deserialize.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:52:56 -08:00
f090efe04a Add fullscreen settings and F11 shortcut (#5647)
* Add fullscreen toggle, resolution dropdown, and F11 shortcut

- Add fullscreen toggle to settings panel
- Add resolution dropdown (same as connection canvas)
- F11 key toggles fullscreen from anywhere
- Resolution change preserves current fullscreen/windowed mode

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

* Wire up fullscreen toggle and resolution dropdown in Unity

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:52:24 -08:00
291026e044 Add ambient volume setting and improved thunder sounds (#5646)
* Add ambient volume setting for weather sounds

- Add ambientSlider to SettingsPanelController with static AmbientVolume property
- Weather effects (rain, thunderstorm, blizzard) respect ambient volume
- Volume updates in real-time when slider is adjusted during playback

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

* Add distant thunder sound effect

Source: Freesound #351526 by LittleRainySeasons (CC BY 4.0)

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

* Add more thunder sound effects

- thunder_loud.mp3 - mokasza (CC BY 4.0, Freesound #810746)
- thunder_crack.wav - OneSoundToRuleThemAll (CC BY 4.0, Freesound #238796)
- thunder_clap.wav - FreqMan (CC BY 4.0, Freesound #32544)

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

* Wire up thunder sounds and ambient volume slider in Unity

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:34:45 -08:00
173569f82a End game when all human players are defeated (#5645)
When all human factions are eliminated or have no provinces ruled by
faction leaders, the game now ends automatically instead of continuing
to run AI commands indefinitely.

Changes:
- Add endGame() method to Engine trait and EngineImpl to create a
  GameEnded action result
- Add allHumansDefeated() check in GameController.performAiCommands
- Check both before the AI loop (handles game load case) and after
  each batch of AI commands (handles AI destroying human factions)
- Handle edge case where game state has no factions yet (initialization)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:32:08 -08:00
a37d540411 Add resolution change detection to hex grid (#5644)
* Add resolution change detection to hex grid

Detects when the mapArea size changes (e.g., resolution change) and
rebuilds the grid with new metrics. Preserves terrain textures while
recreating all UI elements with correct sizes for the new resolution.

- Track mapArea size and detect changes in Update()
- RebuildGrid() destroys and recreates cells with new HexMetrics
- OnGridRebuilt event notifies ShardokGameController to refresh labels

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

* Update Unity scene and remove unused turnHistoryButtonText

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 19:16:42 -08:00
14a2eab47d Add Unity client support for VassalRises notification (#5643)
Adds notification generator and dispatcher registration for the VassalRises
notification added in #5642. Displays appropriate messages when a vassal
rises to lead a faction after all leaders are killed or imprisoned.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 18:19:00 -08:00
481f42d01c Add sound effect hooks to weather effects (#5641)
* Add sound effect hooks to WeatherEffectAnimator

Audio clips for weather sounds (assign in Inspector):
- rainLoopSound: gentle pitter-patter for normal rain
- thunderstormLoopSound: more intense rain for thunderstorm
- thunderSound: one-shot thunder clap (plays on lightning flash)
- blizzardLoopSound: intense wind sound

Volume controls:
- loopVolume: volume for looping sounds (default 0.5)
- thunderVolume: volume for thunder (default 0.8)

Sound behavior:
- Rain: plays rainLoopSound continuously
- Thunderstorm: plays thunderstormLoopSound + thunderSound on each flash
- Snow: no sound
- Blizzard: plays blizzardLoopSound continuously
- Sun/Clouds: no sound

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

* Support multiple thunder sounds with random selection

- Change thunderSound field to thunderSounds array
- Each lightning flash picks a random sound from the array
- Allows assigning Lightning Spelll 01-08 for variety

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

* Add rain loop sound effect from Freesound

- rain_loop.ogg by aesqe (CC BY 4.0)
- Source: https://freesound.org/people/aesqe/sounds/37618/
- Rain falling on clay roof tiles, loopable
- Added attribution to docs/ASSET_AUDIT.md

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

* Document CC BY 4.0 sound effects for weather and runaway

Added to ASSET_AUDIT.md:
- rain_loop.ogg by aesqe (Freesound #37618) - for rain effect
- blizzard_wind_loop.wav by nsstudios (Freesound #651540) - for blizzard
- runaway.mp3 by Yap_Audio_Production (Freesound #218997) - replaces unlicensed file

Note: Files must be downloaded manually from Freesound (requires login).
Only 1 unlicensed sound effect remaining: failure_horn.mp3

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

* Remove stray download file

* Add rain and blizzard wind sound effects

- rain_loop.ogg by aesqe (CC BY 4.0, Freesound #37618)
- blizzard_wind_loop.wav by nsstudios (CC BY 4.0, Freesound #651540)

Location: Assets/Shardok/Sounds/

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

* Replace runaway.mp3 with licensed version

MedievalArmyRunningLoop by Yap_Audio_Production (CC BY 4.0)
Source: https://freesound.org/people/Yap_Audio_Production/sounds/218997/

This resolves the licensing issue for runaway.mp3.

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

* Wire up weather sound effects in scene

- Assign rain_loop.ogg to rainLoopSound
- Assign blizzard_wind_loop.wav to blizzardLoopSound
- Configure thunder sounds array

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 17:31:22 -08:00
49af7c7524 Add vassal rises to leader when faction has no available leaders (#5642)
* Add vassal rises to leader when faction has no available leaders

When all faction leaders are killed or imprisoned, the strongest vassal
(by HeroUtils.power) is now promoted to faction leader instead of
destroying the faction. This prevents "zombie factions" that have
provinces and armies but can't act due to imprisoned leaders.

Changes:
- Add VassalRises notification and VassalRisesMessage LLM request types
- Add proto definitions for new types in notification and LLM protos
- Add proto converters for VassalRises types
- Create VassalRisesPromptGenerator for LLM narrative generation
- Modify CheckForFactionChangesAction to detect imprisoned leaders and
  promote vassals when no available leaders remain
- Generate appropriate notifications and LLM messages for vassal rises

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

* Enhance VassalRises prompt with previous leader details

Add previousLeaderIds to VassalRisesMessage to track fallen/imprisoned
leaders. The prompt generator now includes detailed descriptions for
each previous leader including:
- Their name and full backstory via HeroDescriptionGenerator
- Status: KILLED IN ACTION or IMPRISONED in [province] by [faction]

This gives the LLM more context to generate a message that properly
acknowledges and references the fallen/captured leaders.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 17:21:16 -08:00
b6dcc48bdd Add weather visual effects to Shardok battles (#5640)
* Add weather visual effects to Shardok battles

Adds full-screen weather effects based on the current weather conditions:
- SUN: Subtle brightness overlay
- CLOUDS: Slight dimming overlay
- RAIN: Falling rain particles with wind influence
- THUNDERSTORM: Heavy rain + periodic lightning flashes
- SNOW: Falling snowflakes with drift animation
- BLIZZARD: Heavy snow with strong horizontal wind

The WeatherEffectAnimator creates particles on a dedicated canvas
overlay and updates them continuously. Wind speed from the weather
data influences particle movement.

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

* Add procedural sprite generation and pre-made sprite assets

- Add PNG sprites for rain drops and snowflakes in Assets/Shardok/Sprites/
- Add procedural sprite generation as fallback if sprites not assigned
- Update tooltip to indicate sprites are optional
- Clean up generated textures in OnDestroy to prevent memory leaks

The component now works out of the box without requiring any assets
to be manually assigned. Sprites will be generated procedurally if
the inspector fields are left empty.

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

* Remove procedural sprite generation, fix canvas render mode

- Remove procedural sprite generation code (use pre-made PNGs instead)
- Force canvas to Screen Space - Overlay mode so particles fall
  correctly relative to the screen instead of the 3D world

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

* Add Weather Canvas and wire up WeatherEffectAnimator in scene

- Add Weather Canvas with Screen Overlay for weather effects
- Wire up rainDropSprite and snowFlakeSprite references
- Connect weatherEffectAnimator to ShardokGameController

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

* Fix particle positioning to use screen coordinates

- Anchor particles to bottom-left (0,0) so anchoredPosition is in
  screen pixel coordinates
- Use Screen.width/height directly instead of canvas rect size
- Remove unused _canvasRect field

This ensures particles appear correctly on screen regardless of
canvas RectTransform configuration.

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

* Fix particle positioning using canvas rect size

- Use canvas RectTransform rect.size instead of Screen dimensions
- This properly accounts for CanvasScaler settings
- Anchor particles to center (0.5, 0.5) and position relative to center
- Wrap-around logic uses half-width/height from center

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

* Double particle count and size for all weather effects

- Rain: 100→200 particles, 2x12→4x24 size
- Thunderstorm: 200→400 particles
- Snow: 80→160 particles, 3-8→6-16 size
- Blizzard: 250→500 particles

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 14:49:35 -08:00
7d5320220c Remove PregeneratedText proto and unused service handling (#5637)
* Remove PregeneratedText proto and unused service handling

Now that clients no longer request pregenerated texts (they're sent via
normal ClientTextStore streaming), remove the unused proto messages:
- PregeneratedTextRequest/Response from eagle.proto
- pregenerated_text.proto file entirely
- Empty case handler in EagleServiceImpl.scala

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

* Remove pregenerated_text.proto reference from protos.csproj

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:52:12 -08:00
3eda670759 Replace free_icons with licensed Asset Store equivalents (#5639)
Replace all icons in Assets/free_icons/ (unknown license) with
equivalents from purchased Asset Store packs and delete the folder:

- blizzard.png → 16_blizzard_nobg.png (4000_Fantasy_Icons)
- rain.png → 12_Magic_rain_nobg.png (4000_Fantasy_Icons)
- thunderstorm.png → 27_Storm_nobg.png (4000_Fantasy_Icons)
- wind.png → 23_Light_blow_nobg.png (4000_Fantasy_Icons)
- thermometer.png → startFire.png (existing licensed asset)

These icons were only used in Map Editor (dev tool).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:50:02 -08:00
ea79b80d7b Replace snow.png with 16_blizzard_nobg.png from Asset Store (#5636)
Replace the free_icons/snow.png (unknown license) with
16_blizzard_nobg.png from 4000_Fantasy_Icons (purchased Asset Store
pack) in all locations:
- Gameplay.unity (Province Info blizzard indicator)
- Dominion Wide.prefab
- Dominion Two Rows.prefab
- Map Editor.unity

Delete the now-unused snow.png file.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:22:31 -08:00
2da74769b9 Remove PregeneratedTextRequest handling (#5633)
Pregenerated texts are now populated into each game's ClientTextStore
and sent via normal text streaming (see #5630). This removes the
separate PregeneratedTextRequest/Response mechanism.

- Remove PregeneratedText import from EagleServiceImpl
- Replace PregeneratedTextRequest handler with no-op for backward compatibility
- Remove getPregeneratedClientText method from GamesManager
- Remove unused pregenerated_text_scala_proto dependency from BUILD

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 12:21:16 -08:00
bb52212f9f Update asset audit to reflect current state (#5634)
* Update asset audit to reflect current state

- 2 sound effects remaining to replace (failure_horn.mp3, runaway.mp3)
- List the 8 weather icons in free_icons folder
- Reorganize action items into Must Replace / Low Priority / Resolved
- Update recommendation section

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

* Delete unused weather icons (cloud.png, sun.png)

These icons were not referenced anywhere in the codebase.
Update asset audit to reflect 6 remaining weather icons.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:34:04 -08:00
81e8aad93b Remove client-side pregenerated text mechanism (#5635)
* Remove client-side pregenerated text mechanism

Pregenerated texts (hero names, backstories) are now sent to clients
via normal ClientTextStore streaming (thanks to #5630), so the separate
pregenerated text request/response mechanism is no longer needed.

- Remove ClientPregeneratedText.cs class and its Unity component
- Remove PregeneratedTextRequest sending from ConnectionHandler
- Remove PregeneratedTextResponse handling from PersistentClientConnection
- Remove ILobbySubscriber.ReceivePregeneratedTextUpdate method
- Update ClientTextProvider.GetTextEntry to not check pregenerated cache

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

* Add resolved hero names to AvailableLeader for lobby display

The previous commit removed client-side pregenerated text storage, but
lobby UI (hero dropdowns for Create/Join game) still needed hero names.
This fix has the server resolve names and send them in AvailableLeader.name,
so the client can display them immediately without text lookups.

- Add name field to AvailableLeader proto message
- Server resolves names from ClientTextStore (running games) or
  PregeneratedClientTextStore (new game options)
- HeroDropdownController uses PreResolvedNames when available
- RunningGameItem/WaitingGameItem use leader.Name with fallback

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:02:49 -08:00
f0bb59b5e5 Don't warn about river crossing when marching to own province (#5632)
The river crossing warning is only relevant when assaulting enemy
territory. When marching to a province owned by the same faction,
there's no assault so the river crossing isn't a concern.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 09:01:40 -08:00
ff09bda820 Populate pregenerated texts into game-specific ClientTextStore (#5630)
When creating or loading a game, copy the relevant pregenerated texts
(hero names, backstories, initial chronicle) from the global store into
the game's ClientTextStore as complete texts.

This ensures:
- Games are self-contained and immune to TSV file changes
- Texts are saved with the game and sent to clients normally
- Old games get the pregenerated texts they need on load

This is step 1 of removing the separate pregenerated text mechanism.
The global store is still used as a lookup source, but texts are now
stored in the regular completeTexts map for each game.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 08:20:59 -08:00
09919e63eb Layout tweaks and fix provider icon/invalid account bugs (#5631)
* Layout tweaks to game list prefabs and Gameplay scene

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

* Fix provider icon bug and handle invalid stored accounts

Bug fixes:
1. Provider icon bug: Session transfer was storing "session_transfer" as
   the provider instead of using the actual provider (discord, twitch,
   etc.) from the response. Now uses response.User.Provider.

2. User not found: When stored account validation fails with "user not
   found" (NotFound gRPC status), the invalid account is now removed from
   storage instead of just clearing the current selection.

3. NullReferenceException: When _createConnection() fails (no valid token),
   _internalConnectEagle() was continuing to use _persistentClientConnection
   which was null. Now returns early if connection creation failed.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 08:19:40 -08:00
65d5e6867e Sanitize OAuth username for display name suggestion (#5629)
When suggesting a display name from the OAuth provider's username,
sanitize it to meet our validation requirements:
- Replace spaces with underscores
- Remove invalid characters (only allow alphanumeric + underscore)
- Collapse multiple underscores into one
- Trim leading/trailing underscores
- Truncate to 20 characters max
- Return empty if result is less than 3 characters

This prevents users from seeing a pre-filled invalid name that they
would need to manually fix.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 07:39:03 -08:00
f6c52ad5ab Use download buttons for both Windows and Mac in welcome email (#5628)
Change the Mac download from a text link to a button to match
the Windows download button.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 07:34:55 -08:00
3f1f9ebf07 Send welcome email after invitation flow completion (#5627)
After a user completes the invitation flow (OAuth sign-in, display name
selection), send them a welcome email with:
- Download instructions for Windows and Mac
- Download links for both platforms
- Deep link button to complete sign-in automatically
- Info about which provider/account they used

Also extends session transfer code expiration from 5 minutes to 1 hour
to give users time to install the game before the deep link expires.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 07:11:46 -08:00
9c279d85bd Fix resolution not being restored after fullscreen exit (#5626)
When minimizing the game for OAuth browser flow, we were storing
fullscreen mode but not resolution. When restoring, we only set
Screen.fullScreen=true, which uses whatever windowed resolution
Unity had stored (often 1280x720).

Now we:
- Store both fullscreen mode AND resolution (width/height) before exiting
- Use Screen.SetResolution() to restore both together

This affects both WindowFocusManager (OAuth flow) and ConnectionHandler
(new user sign-in flow).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 07:00:31 -08:00
e91e349d76 Fix accounts page delete behavior (#5625)
- Remove confirmation dialog for deleting non-pending invitations (already
  redeemed/expired/revoked, no need to confirm)
- Reload page after delete actions instead of htmx partial updates (the
  search endpoint returns rows with different columns than the accounts page)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 06:55:19 -08:00
358086154d Improve login tutorial for new users from invitation flow (#5624)
Update the sign-in tutorial text to:
- Mention the "Complete Sign-In with Eagle0" button on the download page
- Clarify they should use the same account they created

Also exit fullscreen when showing the auth panel to new users (no stored
accounts) so they can see the browser with the sign-in link, then restore
fullscreen after successful login.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 06:51:54 -08:00
7a6e871914 Fix chronicle dismiss crash from stale StartingState (#5623)
Root cause: HandleStartingState unconditionally overwrites ChronicleEntries.
During startup, multiple StartingState messages can arrive - a stale one
(e.g., from stateAfter(1) early in the game before chronicles existed)
arriving after a current state would wipe out existing entries.

Fix:
1. Don't overwrite existing chronicle entries with an empty list, since
   chronicles are historical records that accumulate (never disappear)
2. Close the chronicle canvas if entries are cleared while visible
   (defense-in-depth + correct UX behavior)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 06:43:57 -08:00
287b31f4c2 Use named pipes for Windows deep link IPC (#5622)
On Windows, when a second instance is launched via deep link, the URL
needs to be passed to the existing instance. Previously we just
activated the window and exited, losing the URL.

Now uses named pipes (the standard .NET IPC mechanism):
- First instance creates a NamedPipeServerStream and listens
- Second instance connects, sends the URL, then exits
- First instance receives the URL and processes it via OnDeepLinkReceived

This is the same pattern used by Electron's requestSingleInstanceLock().

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 21:38:47 -08:00
18c64713bc Fix Accounts page filter conflicts and event handling (#5621)
- Add unique IDs to filter inputs to prevent hx-include conflicts
- Fix invitation actions to only refresh invitations table, not users
- Use ID-based selectors instead of name-based for HTMX includes

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 21:29:21 -08:00
cb19483f34 Fix session transfer deep link arriving before auth client ready (#5620)
When the app is launched via a deep link (e.g., from the invitation
download page), the deep link can arrive in Awake() before
ConnectionHandler calls SetServerUrls(). This caused the session
transfer to silently fail because _authClient was null.

Fix by queuing the session transfer code and processing it once
SetServerUrls() is called and the auth client is available.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 21:21:14 -08:00
19e2f22b57 Combine Users and Invitations into single Accounts tab (#5619)
Merges the separate Users and Invitations pages into a unified
two-column layout under a single "Accounts" navigation tab.

- New /accounts route with side-by-side Users and Invitations panels
- Each panel has independent search/filter functionality
- Responsive layout stacks columns on smaller screens
- Keeps existing /users and /invitations routes for HTMX partial updates

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 21:17:59 -08:00
d8d3cb1317 Use gRPC for session transfer instead of HTTP (#5618)
* Use gRPC for session transfer instead of HTTP

The session transfer was using an HTTP endpoint on port 8080 which isn't
exposed to clients. Convert to use gRPC on port 40033 like all other auth
methods.

- Add ExchangeSessionTransferCode RPC to auth.proto
- Update AuthClient.cs to call gRPC instead of HTTP
- Add gRPC handler in Go auth service
- Remove HttpClient and SessionTransferResponse class from C#

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

* Add exchangeSessionTransferCode to Scala auth service

The Scala Eagle server also implements the Auth gRPC service and needs
the new method. Add a stub that proxies to the external Go auth service
when available, or returns UNIMPLEMENTED when not configured.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 19:51:17 -08:00
a999f8b936 Add single-instance enforcement for Windows deep links (#5617)
* Add single-instance enforcement for Windows deep links

On Windows, URL schemes just launch the executable - they don't redirect
to an existing instance like macOS does. This causes deep links to open
a new game window instead of activating the existing one.

Added SingleInstanceEnforcer which:
- Uses a named mutex to detect if another instance is running
- If launched via deep link (eagle0://) and another instance exists:
  - Finds the existing window via FindWindow
  - Brings it to foreground via SetForegroundWindow
  - Exits the duplicate instance
- Normal launches (without deep link) still allow multiple instances

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

* Fix single-instance detection: use process name instead of window title

- Use Process.GetProcessesByName instead of FindWindow - more reliable
- Use Environment.Exit(0) instead of Application.Quit() - works early in lifecycle
- Increase sleep to 500ms to ensure window activation completes

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 19:40:24 -08:00
5035fe1a70 Add session transfer for seamless sign-in after invitation (#5615)
After completing OAuth on the web invitation flow, users can now click a
"Complete Sign-In in Eagle0" button to automatically sign in to the game
without going through OAuth again.

Implementation:
- New SessionTransferService generates one-time codes after OAuth
- Codes are 256-bit random, expire in 5 minutes, single-use
- Download page shows deep link button: eagle0://auth/session/{code}
- Unity client handles deep link, exchanges code for tokens via HTTP
- Login screen shows hint for users coming from invitations

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 19:20:12 -08:00
1c1066c1d8 Fix installer self-update not completing (#5616)
Two issues fixed:
1. WebView Close() was calling Destroy() directly from a goroutine,
   but this didn't properly terminate the event loop on the main thread.
   Now uses Dispatch(Terminate()) to properly exit the event loop.

2. handleInstallerReplacement was using fmt.Println instead of the UI,
   so the WebView showed "Initializing..." while the actual logic was
   invisible. Now uses ui.UpdateStatus/ShowError/etc for visibility.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 19:13:04 -08:00
d3d72ff8dc Add LoadIntoImage method to ResourceFetcher (#5613)
Adds LoadIntoImage alongside LoadIntoRawImage for loading runtime
textures into Unity Image components (which support Preserve Aspect
in layout groups). Factored out common functionality into helpers:
- CreateTexture: creates Texture2D from bytes or placeholder
- CreateSprite: creates Sprite from Texture2D
- CleanupRawImageTexture/CleanupImageSprite: cleanup helpers

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 19:00:53 -08:00
149872bea7 Fix OAuth timeout showing exception popup (#5612)
Changed Debug.LogError to Debug.LogWarning for handled OAuth errors.
The SimpleErrorHandler subscribes to Unity log messages and shows
popups for errors, but OAuth timeouts are expected conditions that
are already displayed in the status text.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 18:56:22 -08:00
0216ceb26b Fix OAuth deep link redirect closing before permission dialog (#5611)
The auto-redirect with window.close() was closing the page before
users could accept the browser's permission dialog to open the app.

Changed to a button-based approach where the user clicks to return
to the app, giving them control over when the deep link triggers.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 18:41:30 -08:00
5b840a4523 Fix Overlay Mesh transform for Shardok map (#5607)
* Fix Overlay Mesh transform for Shardok map

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

* Fix Province Info Panel being disabled on dominion toggle

Changed DominionViewToggleClicked to hide only the panel content
instead of disabling the entire controller GameObject.

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

* Add iOS Addressables content state and image meta file

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

* Update Unity-generated files (csproj, fonts, addressables settings)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 18:33:38 -08:00
a51cad0ca0 Raise timing log thresholds from 10ms/50ms to 500ms (#5610)
Only log timing warnings when operations take more than 500ms,
reducing noise from normal operation while still catching slow
operations worth investigating.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 18:32:58 -08:00
04928f0e2c Register eagle0:// URL scheme on Windows for OAuth deep links (#5609)
* Register eagle0:// URL scheme on Windows for OAuth deep links

The Windows installer now registers the eagle0:// URL scheme in the
registry (HKEY_CURRENT_USER\Software\Classes\eagle0) pointing to the
game executable. This allows OAuth callbacks to redirect back to the
app automatically.

Also updated OAuthManager to check command line arguments for deep
links since Windows passes URL scheme launches as command line args.

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

* Add golang.org/x/sys dependency for Windows registry access

The Windows installer uses golang.org/x/sys/windows/registry to register
the eagle0:// URL scheme. This adds the required dependency to go.mod,
go.sum, and MODULE.bazel.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 18:32:39 -08:00
132abfa7a4 Add deep link redirect for OAuth callbacks (#5608)
After OAuth authentication completes, the browser now redirects to
eagle0://auth/complete to bring the app back to foreground. This
provides a better UX than asking users to manually close the browser.

- Unity client registers for deep link events and focuses window
- DeepLinkPostProcessor adds URL scheme to Info.plist on macOS/iOS
- All OAuth handlers (Google/Discord, Apple, Steam) redirect via deep link

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 18:10:58 -08:00
bbe301bc04 Unity layout adjustments (#5606)
* Unity layout adjustments

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

* Additional Unity layout adjustments

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 22:12:51 -08:00
138e7d89f3 Refactor Shardok map to use RectTransform for sizing (#5605)
- Change HexMetrics to accept RectTransform instead of Canvas
- Rename mapCanvas to mapArea in HexGrid (RectTransform type)
- Constrain map area to exclude sidebar for proper sizing at
  different aspect ratios

This allows the map to be properly sized based on the available
space, accounting for the sidebar at various screen resolutions.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 21:53:36 -08:00
d858f8243d Add three-tier layout mode (Wide/Medium/Narrow) for command panels (#5603)
* Add three-tier layout mode (Wide/Medium/Narrow) for command panels

- Add LayoutMode enum: Wide (>2.0), Medium (>=1.35), Narrow (<1.35)
- EagleGameController exposes CurrentLayoutMode property
- CommandSelector base class receives layout mode from CommandPanelController
- Adds IsNarrowLayout helper for selectors to check narrow mode

In narrow mode (iPad-like 4:3 aspect ratios), selectors can hide
hero/battalion pickers since users can select via the Resident
Heroes and Battalions panels instead.

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

* Add heroesColumn and battalionsColumn fields to March/Defend selectors

These GameObject fields allow hiding the hero and battalion selection
columns in narrow layout mode. Wire them up in Unity editor to the
appropriate column GameObjects.

Also fixes DefendCommandSelector import: UnityEditor -> UnityEngine

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

* Hide hero/battalion columns in narrow layout mode

In SetUpUI(), hide heroesColumn and battalionsColumn when IsNarrowLayout
is true. Users can still select heroes and battalions via the Resident
Heroes and Battalions side panels.

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

* Wire up heroesColumn and battalionsColumn in Unity

Connect the column GameObjects to MarchCommandSelector and
DefendCommandSelector so they can be hidden in narrow layout mode.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:35:59 -08:00
5572a806af Update splash screen and app icon (#5604)
- Add new icon.png for app icon
- Remove old unused image asset
- Update ProjectSettings for splash screen

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:27:54 -08:00
1b153fbad6 Change default LLM provider to Gemini (#5602)
Gemini offers better cost/performance for standard LLM requests.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 14:04:21 -08:00
e53b899d7c Add turn mismatch detection with auto-reconnection (#5600)
* Add turn mismatch detection with auto-reconnection

Detects when server reports YourTurn but client has no available
commands. After a 3-second grace period (to avoid false positives
during normal update processing), logs an error via ErrorHandler
and triggers ForceReconnect() to recover state.

This addresses intermittent bugs where exceptions or token mismatches
leave the connection status showing "Your Turn" but the UI doesn't
display any turn actions.

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

* Read ServerGameStatus from GameUpdate level, add BattleInProgress display

- Move ServerGameStatus reading from ActionResultResponse to GameUpdate
  level so status updates work for all update types including Shardok
- Add "Battle in progress" display for the new BattleInProgress status

This fixes the turn mismatch false positives during Shardok battles -
the server now sends BattleInProgress instead of YourTurn during battles.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:57:49 -08:00
89c8fea438 Add BATTLE_IN_PROGRESS status and report during Shardok battles (#5601)
- Add new BATTLE_IN_PROGRESS status to ServerGameStatus enum
- Add server_game_status field to GameUpdate so any update type can
  include status (not just ActionResultResponse)
- Report BATTLE_IN_PROGRESS when there are outstanding Shardok battles
  instead of YOUR_TURN
- Set serverGameStatus on both GameUpdate and ActionResultResponse
  for backwards compatibility

This fixes the issue where "Your Turn" was shown during battles,
including observed battles where the player isn't participating.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 13:42:26 -08:00
7e19476167 Fix March command panel layout (#5599)
Adjust layout to prevent overflow on narrow aspect ratios.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 10:28:47 -08:00
54cd089106 Fix command panel layout for narrow aspect ratios (#5598)
Adjust layout to prevent overflow on iPad (4:3) and similar resolutions.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 07:47:39 -08:00
a6a0d58d20 Refactor widescreen layout to use enable/disable instead of reparenting (#5597)
* Refactor widescreen layout to use enable/disable instead of reparenting

Instead of moving UI panels between containers at runtime, use duplicate
panels positioned in Unity and enable/disable the appropriate version
based on aspect ratio.

Changes:
- Add separate narrow/wide versions of FactionsTable, MovingArmiesTable,
  and DominionPanel
- ArrangeLayout() now just enables/disables panels instead of reparenting
- Remove unused container GameObjects (factionsAndMovingArmiesRow, etc.)
- Both panel copies are kept in sync with model data

This approach is simpler to maintain and allows layouts to be fully
configured in the Unity editor.

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

* Update Unity scene with layout adjustments

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 20:31:41 -08:00
c2f1064742 Trim map textures to remove excess water border (#5596)
* Trim map textures to remove excess water border

Crop all map textures (map_bw_labels.png and province textures 1-43.png)
from 4096x2064 to 3786x1834, removing:
- Left: 200px
- Top: 130px
- Right: 110px
- Bottom: 100px

This leaves a small water margin around the landmass edges.

Also update newMapPrefab.prefab to set AnchoredPosition and SizeDelta to 0
instead of previous offset values.

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

* Trim rawGray.bytes to match trimmed map textures

Apply same crop (left 200, top 130, right 110, bottom 100) to the
province hit-test data so pixel lookups remain aligned with the
trimmed map images.

Dimensions: 4096x2064 → 3786x1834

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

* Update texture import settings and RectTransforms for trimmed map

- Set Non-Power of 2 to "None" for map texture to preserve actual
  dimensions (3786x1834) instead of scaling to 4096x2048
- Update RectTransforms to remove offsets now that images are trimmed

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 14:01:09 -08:00
7173cf8faa Re-wire Shardok buttons after ShardokGameController move (#5595)
After moving ShardokGameController from ShardokCanvas to ShardokContainer,
several button onClick events lost their target references. Re-wire:
- EndTurn button (Back to Eagle / End Turn)
- DisplayActionsButtonClicked (9 action buttons)
- WarningPanelCommitClicked
- WarningPanelCancelClicked

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 06:50:22 -08:00
44dde46e39 Add missing Unity .meta files and update .gitignore (#5594)
Add .meta files for:
- BuildInfo.cs (build info feature)
- Editor/ folder and its scripts
- TouchAwareTooltip.cs

Also add ServerData/ to .gitignore to prevent local data from being tracked.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 22:09:20 -08:00
4c74265447 Move ShardokGameController from ShardokCanvas to ShardokContainer (#5593)
* Move ShardokGameController from ShardokCanvas to ShardokContainer

When exiting a battle, ShardokGameController calls this.gameObject.SetActive(false).
Previously this deactivated ShardokCanvas, leaving it inactive for the next battle.
Since ShardokCanvas is now inside ShardokContainer, GetComponentInChildren couldn't
find the controller because the canvas was inactive.

Fix: Move ShardokGameController to ShardokContainer so SetActive(false) deactivates
the whole container. Also update code to use GetComponent instead of GetComponentInChildren.

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

* Use serialized eagleCommonTextures field instead of GetComponent

After moving ShardokGameController to ShardokContainer, EagleCommonTextures
was also moved. Replace runtime GetComponent calls with a serialized field
reference for better performance and clearer dependencies.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 22:09:07 -08:00
30fd79f4ff Add null checks with specific messages to GoToBattle (#5592)
Adds explicit null checks to identify exactly which reference is null:
- Model
- shardokContainer
- shardokController (from GetComponentInChildren)

This replaces generic NullReferenceException with specific error messages.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:31:04 -08:00
53e1dd623b Add git commit hash to exception logs (#5591)
- Add BuildInfo.cs with commit hash, short hash, and build timestamp
- Add BuildInfoGenerator.cs to auto-generate BuildInfo before builds
- Update ErrorHandler to include commit hash in error messages

This helps identify which exact build a production error came from.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 21:04:00 -08:00
1604343975 Link ShardokContainer in SettingsPanelController (#5590)
The ShardokContainer reference was missing (fileID: 0), causing a
NullReferenceException when clicking "Return to Lobby" in settings.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:33:05 -08:00
aa7d07d7c6 Fix GoToBattle button not hidden when Model becomes null (#5589)
Root cause: SwapModel() returns early when Model is null (line 504),
but goToBattleButton visibility is updated after that early return
(line 626). When StopAll() sets Model to null via SwapModel(), the
button was never hidden.

Fix: Hide the goToBattleButton in the early return path when Model
becomes null.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 20:27:14 -08:00
0042d56386 Recreate connection when gRPC channel is disposed (#5588)
When the underlying GrpcChannel is disposed (e.g., during environment
switch), the PersistentClientConnection now invokes an OnChannelDead
callback instead of futilely attempting to reconnect on the dead channel.

ConnectionHandler sets this callback to recreate the EagleConnection
and PersistentClientConnection, properly recovering from channel disposal.

Fixes ObjectDisposedException spam when channel dies.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:49:40 -08:00
0bb61da38c Fix overlay mesh position offset (#5586)
Adjust the overlay mesh local position to align correctly with the
hex grid on widescreen displays.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:29:24 -08:00
5dcf3b9c17 Fix Eagle UI layout on widescreen monitors (#5585)
Add HorizontalLayoutGroup to Improve Panel to prevent overlapping
elements on ultrawide displays (e.g., 3440x1440). The improvement
type buttons were covering the lock toggle and hero dropdown when
the screen was wider than the reference resolution.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 19:03:08 -08:00
8814f2a955 Render overlay text above 3D bridges (#5584)
Create separate Overlay Canvas with smaller plane distance so text
labels render in front of 3D bridge objects.

Changes:
- Add ShardokContainer parent to hold both canvases
- Rename shardokCanvas references to shardokContainer
- Add overlayContent RectTransform field to HexGrid for label parenting
- Update all scripts to use shardokContainer for activation

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 18:26:54 -08:00
c0b2e41212 Narrow iOS addressables workflow trigger paths (#5583)
Only trigger on changes to:
- AddressableAssetsData config
- Music assets (the addressable content)
- BuildScript.cs
- Build scripts

Previously triggered on any Unity file change, which was too broad.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:54:10 -08:00
902a65be08 Prefer bridge orientations that connect two land tiles (#5582)
When placing default bridges, check for opposite pairs of land tiles
and prefer orientations where both endpoints touch land, rather than
just orienting toward the first land tile found.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:48:10 -08:00
ac66d372f8 Fix iOS addressables building for wrong platform (#5581)
The -buildTarget iOS flag doesn't reliably switch the active build
target before editor scripts run. The addressables were being built
for StandaloneOSX instead of iOS.

Added BuildiOSAddressables() method that explicitly calls
EditorUserBuildSettings.SwitchActiveBuildTarget() before building.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 16:38:45 -08:00
14308026ea Update ASSET_AUDIT.md: mark Terrain Hexes and Discord logo as verified (#5580)
* Update ASSET_AUDIT.md: mark Terrain Hexes and Discord logo as verified

- Terrain Hexes: confirmed Unity Asset Store purchase
- Discord logo: complies with Discord brand guidelines
- Updated action items and recommendations to reflect remaining work

Remaining items:
- 6 music tracks (1 unknown source, 5 Audius non-CC license)
- 56 Shardok sound effects (unknown origin)
- 138 StrategyGameIcons (unknown source)

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

* Update ASSET_AUDIT.md: Medieval Victory Theme is CC0 Public Domain

Found source: RandomMind on Chosic, CC0 Public Domain license.
No attribution required, free for commercial use.

Remaining items:
- 5 Dima Koltsov/Audius tracks (non-CC license)
- 56 Shardok sound effects (unknown origin)
- 138 StrategyGameIcons (unknown source)

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

* Update ASSET_AUDIT.md: Dima Koltsov tracks mostly verified as CC BY 4.0

- No Time for Greatness: CC BY 4.0 (YouTube)
- Warriors of Demacia: CC BY 4.0 (YouTube)
- Valor: CC BY 4.0 (YouTube)
- Forest Queen Tale: Presumed CC BY 4.0 (not found on YouTube)
- Clouds: Presumed CC BY 4.0 (not found on YouTube)

Remaining items:
- 56 Shardok sound effects (unknown origin)
- 138 StrategyGameIcons (unknown source)

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

* Update ASSET_AUDIT.md: StrategyGameIcons verified as Asset Store purchase

Confirmed as "Strategy Game Icons" by REXARD from Unity Asset Store.

Remaining items:
- 56 Shardok sound effects (unknown origin)

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

* Update ASSET_AUDIT.md: identify 3 sounds from Zombie Monster Undead Collection

Verified from Unity Asset Store purchase:
- raise_undead.mp3
- undead_break_control.mp3
- undead_grew.wav

Also corrected count: 37 audio files (not 56 - was counting .meta files)

34 sound effects still unverified.

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

* Update ASSET_AUDIT.md: flag anybody.mp3 and runaway.mp3 for replacement

These two sound effects have licensing issues and must be replaced.

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

* Update ASSET_AUDIT.md: also flag burnination.mp3 for replacement

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

* Update ASSET_AUDIT.md: mark remaining sound effects as presumed Asset Store

Owner believes 31 remaining sounds are from purchased Asset Store packs:
- Fantasy Interface Sounds
- Medieval Combat Sounds
- Magic Spells Sound Effects LITE
- Medieval Battle Sound Pack

Only 3 files need replacement: anybody.mp3, burnination.mp3, runaway.mp3

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

* Replace anybody.mp3 and burnination.mp3 with licensed alternatives

- anybody.mp3: replaced with Positive Effect 6.wav (Magic Spells Sound Effects LITE)
- burnination.mp3: replaced with Magic Element Fire 04.wav (Medieval Combat Sounds)

Still need replacements for:
- failure_horn.mp3
- runaway.mp3

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:44:53 -08:00
4b7a1791e0 Add bridge rotation based on builder position or adjacent land (#5577)
* Add bridge rotation based on builder position or adjacent land

Bridges now rotate to face meaningful directions:
- For bridges built by engineers: one end faces the builder's location
- For bridges present at battle start: one end faces adjacent land

Implementation:
- Add rotationDegrees parameter to HexGrid.SetCellModifier3D()
- Track bridge builder locations in _bridgeBuilderLocations dictionary
- Add GetHexNeighbors() to find adjacent hex cells
- Add CalculateBridgeRotation() to compute angle between cells
- Add CalculateBridgeRotationFromLand() to find land for initial bridges

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

* Simplify bridge rotation to 3-orientation lookup table

Replace complex trigonometry with simple lookup based on hex grid
direction. Only 3 possible orientations exist:
- Horizontal (0°): left ↔ right
- Diagonal up (124°): down-left ↔ up-right
- Diagonal down (56°): up-left ↔ down-right

Add BridgeRotationCalculator with unit tests for all 6 hex directions
from both even and odd rows.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 15:34:36 -08:00
a6a3e26853 Add iOS addressables build workflow (#5579)
* Add iOS addressables build workflow

Creates a separate workflow to build and upload iOS addressables to CDN.
This is needed for iOS clients to load remote assets.

- Adds build_ios_addressables.sh script
- Adds ios_addressables_build.yml workflow
- Uploads to https://assets.eagle0.net/addressables/iOS/

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

* Fix iOS addressables build: add proto build step

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 13:18:07 -08:00
dc1c9e8284 Update ASSET_AUDIT.md with new AI-generated bridge icon (#5578)
Document that bridge.png was replaced with AI-generated wooden rope
bridge icon created with ChatGPT/DALL-E 3.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 13:17:23 -08:00
21eb5ff79a Replace bridge icon with AI-generated wooden rope bridge (#5576)
New 512x512 PNG icon featuring an isometric wooden rope bridge,
better suited for the fantasy game aesthetic. Generated with
ChatGPT/DALL-E, has transparent background.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:32:34 -08:00
2cd0583bb0 Add 3D bridge prefab support for hex map terrain modifiers (#5575)
Replace 2D bridge texture overlay with 3D bridge prefabs for more
visually appealing bridges on water tiles. The system now supports
an array of bridge prefabs for variety, selected deterministically
based on cell index.

- Add TerrainModifier3D field to HexGrid Cell struct
- Add SetCellModifier3D() and ClearCellModifier3Ds() methods
- Change ShardokGameController.bridgeImage to bridgePrefabs array
- Bridge positioned at true cell center using Geometry.AnchoredPosition
- Assign 6 bridge prefabs from TileableBridgePack in Gameplay scene

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 12:09:33 -08:00
e45ad8fc14 Silence log messages for LLM responses to deleted warmup games (#5573)
During blue-green deployments, warmup games may be invalidated while
LLM requests are in flight. The responses are correctly ignored, but
the log messages were noisy. Remove them since this is expected behavior.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:23:00 -08:00
d41535ffc0 Add missing TMP shader include files to Shaders directory (#5572)
The mobile shaders in TextMesh Pro/Shaders/ reference these include
files with relative paths but they only exist in Resources/Shaders/.
Copy them here to fix shader compilation errors.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:17:06 -08:00
c0eff139bf Add eagle-logs helper for tailing active instance logs (#5571)
Usage:
  eagle-logs           # Tail logs (follow mode)
  eagle-logs -n 100    # Show last 100 lines and follow
  eagle-logs --no-follow -n 50   # Last 50 lines without following

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 11:00:16 -08:00
4338600896 Replace fire icon with UXWing flame and consolidate duplicates (#5570)
* Replace fire icon with UXWing flame and consolidate duplicates

Replaced the startFire.png icon with a clean flat-style flame from UXWing
(free for commercial use, no attribution required). Consolidated from two
duplicate copies to a single canonical copy in Assets/Images/, updating all
Unity scene references.

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

* Update ASSET_AUDIT.md with UXWing flame icon source

Document that startFire.png was replaced with the flame icon from UXWing
(https://uxwing.com/flame-icon/) which is free for commercial use with no
attribution required. Also note the consolidation from two duplicate copies
to one canonical copy.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:59:51 -08:00
1ac7477597 Fix eagle-exec deployment and simplify implementation (#5569)
- Remove sudo symlink creation (deploy user lacks passwordless sudo)
- Simplify eagle-exec to read from /opt/eagle0/.active-instance file
- Update deploy-blue-green.sh to write active instance to file
- Keep fallback to checking running containers if file doesn't exist

Users can add their own alias:
  alias eagle-exec='/opt/eagle0/scripts/eagle-exec.sh'

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:47:16 -08:00
23cad2f383 Add iOS/mobile support with touch-aware tooltips (#5550)
* Add iOS/mobile support with touch-aware tooltips

- Add melee button to Shardok action bar (button 4 with [SHFT] label)
  - Changed MeleeAttackGroup from -1 to 4 to show melee commands as button
  - Updated Gameplay.unity to assign melee/charge commands to group 4
- Add touch-aware tooltip system using long-press pattern
  - Updated HoveringTooltipTextProvider to detect touch vs mouse input
  - On desktop: tooltips appear immediately on hover
  - On mobile: tooltips appear after 0.4s long-press
  - Created TouchAwareTooltip.cs as reusable component
- Add iOS build method to BuildScript.cs
  - Generates Xcode project for building/signing

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

* Add dedicated melee button (button9) for mobile/tablet support

- Add button9 and buttonText9 fields to ShardokGameController
- Change MeleeAttackGroup from 4 to 9 for dedicated button
- Change EndTurnCommandGroup from 9 to 12 (handled separately)
- Update melee/charge commands in Gameplay.unity to group 9
- Update End Turn command to group 12
- Add null checks for unassigned buttons in UpdateActionButtons

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

* Fix melee/charge command groups in Gameplay.unity

Set commandGroup to 9 for melee and charge commands so they
appear on the dedicated melee button and indicators work correctly.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:45:29 -08:00
84c1bf61b0 Add eagle-exec helper for blue-green deployments (#5567)
Adds a helper script that automatically runs docker exec against the
active Eagle instance (blue or green), determined by checking nginx
config.

Usage:
  eagle-exec printenv GEMINI_API_KEY
  eagle-exec jcmd 1 VM.flags
  eagle-exec sh

The script is deployed to /opt/eagle0/scripts/ and symlinked to
/usr/local/bin/eagle-exec for easy access.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:31:53 -08:00
21dfa12197 Fix Gemini 404 errors by adding missing model name case (#5566)
When LlmProvider was set to "gemini", the validation code was missing a
case for extracting the GeminiModelName, causing it to default to an
empty string. This resulted in malformed API URLs like:
https://generativelanguage.googleapis.com/v1beta/models/:streamGenerateContent

which returned 404 errors.

Added the "gemini" case to properly extract GeminiModelName from
settings.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 10:12:33 -08:00
de4c568aa0 Handle LLM validation errors gracefully (#5565)
When switching LLM providers in the admin console, if the API key for
the target provider isn't configured, the validation would crash with
ExceptionInInitializerError. This fix:

- Wraps service creation in Try to catch initialization errors
- Logs the error with context (likely missing API key)
- Reports to Sentry for monitoring
- Returns a user-friendly error message
- Falls back to keeping the old setting unchanged

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:53:05 -08:00
edccde9a90 Fix Gemini provider validation in admin settings (#5564)
The LLM settings validation in GameAdminServiceImpl was missing the
"gemini" provider case, causing "Unknown provider: gemini" errors when
trying to switch to Gemini from the admin console.

Changes:
- Add GeminiServiceImpl import
- Add "gemini" case to validateLlmSettings()
- Add "GeminiModelName" to llmSettingKeys set
- Add gemini_service_impl dep to BUILD.bazel

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 09:09:50 -08:00
0f7f019f14 Replace characters that could cause IP/publicity issues (#5563)
Replace 13 heroes that referenced real people or copyrighted characters
with original alternatives that preserve gameplay stats and general archetypes:

Real people replaced:
- Jesse "The Body" Ventura → Magnus "The Boulder" Varnok
- Jeb! → Aldric the Overlooked
- Ringo → Tomlin the Tuneful
- Flea → Zander the Restless

Copyrighted characters replaced:
- Darth Plagueis → Voros the Undying
- Deckard Cain → Old Marek the Learned
- Skeletor → Karvax the Fleshless
- Trap Jaw → Ironmaw
- Shu Lien → Mei Shen
- Roger the Shrubber → Hedrick the Hedge-Merchant
- Kriss Kross → The Tumbler
- Daddy Mac → Silvio the Smooth
- Ul (removed Warcraft "Far Seer" reference)

Image files in eagle0-headshots bucket have been renamed to match.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 08:45:11 -08:00
adcafe3224 Refactor LLM update interface to use Scala domain types instead of protos (#5562)
* Refactor LLM update interface to use Scala domain types instead of protos

Replace proto types (GeneratedTextRequest, LLMResponse) with Scala domain
type (GeneratedTextRequestT) throughout the LLM streaming interface. This
eliminates unnecessary proto conversions and simplifies the code.

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

* Remove unused llm_response_scala_proto dependency from llm_resolver

The LLMResponse proto is no longer used since we now pass Scala domain
types through the LLM update interface.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 08:38:34 -08:00
89ad8371b5 Add Gemini LLM support with model comparison documentation (#5561)
This adds Google Gemini as a third LLM provider option alongside OpenAI
and Anthropic (Claude). Based on streaming latency tests, Gemini 2.5
Flash-Lite shows the fastest time-to-first-token (~0.6s) at the lowest
cost ($0.10/$0.40 per 1M tokens).

Changes:
- Add GeminiServiceImpl.scala following OpenAI/Claude pattern
- Add GeminiModelName setting (default: gemini-2.5-flash-lite)
- Update LlmResolver to support "gemini" provider
- Update admin console dropdowns with Gemini options
- Reorder dropdown options to show recommended models first:
  - OpenAI: gpt-4.1-mini (was gpt-5-mini)
  - Claude: claude-3-5-haiku (already first)
  - Gemini: gemini-2.5-flash-lite
- Add GEMINI_API_KEY to docker-compose and deployment workflow
- Add docs/LLM_MODEL_COMPARISON.md with latency/pricing comparison

Streaming TTFT results from testing:
- Gemini 2.5 Flash-Lite: ~0.6s (fastest, cheapest)
- gpt-4.1-mini: ~1.7s
- claude-3-5-haiku: ~1.9s
- Gemini 2.5 Flash: ~6.8s (surprisingly slow)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 08:23:38 -08:00
b8c584572d Filter LLM requests for deleted games before sending (#5560)
* Filter LLM requests for deleted games before sending

Add a game validity check to LlmResolver to prevent sending LLM requests
for games that have been deleted (e.g., warmup games during blue-green
deployments). This avoids wasted API calls and eliminates the "Ignoring
LLM response for deleted game" log messages at startup.

Changes:
- Add LlmResolverGameDeleted result type to LlmResolver
- Add isGameValid callback parameter to LlmResolver constructor
- Check game validity before sending each request
- Handle LlmResolverGameDeleted in UnrequestedTextHandler
- Pass gameControllerInfos.contains check from GamesManager

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

* Move proto conversion inside TextGenerationSuccess case

The proto conversion is only needed for the success case where we
actually send the request. Moving it avoids unnecessary conversion
for dependency-blocked requests.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 08:10:11 -08:00
acb411c4d2 Reorder Claude models to put haiku first (#5559)
Move claude-3-5-haiku to the top of the dropdown since it's the
fastest model for streaming responses (~1.9s TTFT vs ~5.0s for sonnet).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 08:00:31 -08:00
25f92c7832 Add ANTHROPIC_API_KEY to deployment pipeline (#5558)
* Add ANTHROPIC_API_KEY to docker-compose environment

Required for Claude LLM provider to work in production.

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

* Add ANTHROPIC_API_KEY to deployment workflow

Pass the secret to the deploy job and export it for docker-compose.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-23 07:25:04 -08:00
0f0d2433bb Fix reasoning_effort for non-reasoning models, add gpt-5.1 option (#5557)
* Only send reasoning_effort for reasoning models (o1, o3)

The reasoning_effort parameter is only valid for OpenAI reasoning models.
Sending it to non-reasoning models like gpt-5-mini causes a 400 Bad Request.

Now only includes reasoning_effort when the model name starts with "o1" or "o3".

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

* Treat models without -mini/-nano as reasoning models, add gpt-5.1 option

- Change reasoning model detection: models with -mini or -nano suffix are
  non-reasoning; all others (gpt-5.1, gpt-5.2, o1, o3, etc.) get reasoning_effort
- Add gpt-5.1 to admin console dropdown options

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 22:00:53 -08:00
eea61f1c6a Fix create game defaults: 7 total players, 1 human (#5556)
The previous change incorrectly set both total players and human players
to 1. This restores the intended behavior: 7 total players (1 human + 6 AI)
with 1 human player selected by default.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:59:29 -08:00
559f051efa Fix NPE when OkHttp SSE onFailure receives null Throwable (#5555)
OkHttp can call onFailure with a null Throwable when there's an HTTP
error response but no actual exception. Handle this case by:
- Null-checking before calling getMessage()
- Creating a synthetic RuntimeException when t is null

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:49:25 -08:00
a2e136af3d Eagerly initialize S3 client at startup to reduce first-connection latency (#5552)
JFR profiling revealed that the first client connection after deploy was
experiencing 5+ second delays due to lazy initialization of the AWS S3 SDK
(profile loading, TLS handshake to DigitalOcean Spaces, etc.).

This change adds a warmup() method to S3Utils that:
- Forces initialization of the lazy transferManager
- Makes a lightweight headBucket call to complete TLS handshake

The warmup is called early in Main.scala before the server starts accepting
connections, moving the initialization cost to startup time rather than
first-request time.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:43:03 -08:00
c4f887255b Remove --gpt-model-name from docker-compose (#5554)
The --gpt-model-name CLI argument was removed in the LLM model switching
PR (97b313f6), but docker-compose.prod.yml still passed it, causing the
server to fail with "Unknown option --gpt-model-name".

LLM provider and model are now configured via settings (admin console)
instead of CLI arguments.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:41:44 -08:00
97b313f6d5 Add LLM model switching from admin console (#5551)
* Add LLM model switching from admin console

Implement the ability to switch LLM provider and model dynamically from the
admin console with validation before applying changes.

Changes:
- Add String setting type support (StringSetting.scala, generator updates)
- Add LlmProvider, OpenAiModelName, ClaudeModelName settings
- Modify LlmResolver to read provider/model from settings dynamically
- Add recreateLlmCallers() to allow hot-swapping LLM configuration
- Add validation in GameAdminServiceImpl that tests API before applying
- Remove gptModelName command line flag (now configured via settings)
- Update AddSettingsResponse proto with success/errorMessage fields

The admin console now shows these settings and validates them by making
a test API call before applying. If validation fails, the error is returned
and settings remain unchanged.

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

* Add dropdown UI for LLM settings in admin console

Instead of showing LLM provider and model names as text inputs in the
general settings list, display them as dropdowns with common options:
- LlmProvider: openai, claude
- OpenAiModelName: gpt-5-mini, gpt-5.2, gpt-4.1, gpt-4.1-mini, etc.
- ClaudeModelName: claude-sonnet-4-20250514, claude-opus-4-20250514, etc.

This provides better UX by making it easy to switch between known models
without having to remember exact model name strings.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 21:31:04 -08:00
13e9fb5dad Add self-healing for missing hero name text during game load (#5547)
When loading a game, if a hero's name text (hn_X format) is missing from
the text store, the server would throw an exception requiring the game
to be deleted. This occasionally happened when a hero was created but
the name request was somehow lost before persistence.

Changes:
- New MissingHeroNameRecovery utility: Provides a reusable method to
  check heroes for missing name texts and recreate GeneratedHeroName
  requests. Can be called from game load or other contexts as needed.
- GamesManager: Uses the new utility during game loading to recover
  missing hero names instead of throwing an exception.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:25:46 -08:00
6936c36fc1 Limit chronicle length to ~1.5 pages (#5549)
- Add explicit length instructions to prompt (IMPORTANT prefix)
- Add reminder at end of prompt to be selective
- Increase default word count from 200 to 400 (appropriate for 1.5 pages)
- Instruct LLM to focus on 2-3 most significant events

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:20:38 -08:00
2db4db0ba3 Update March tutorial to mention right-click on map (#5548)
Clarifies that users can right-click a province on the map or use the
dropdown menu to select a destination.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 22:10:58 -08:00
5abfdc1f09 Improve first-run experience for new users (#5546)
- Default to 1-player game in create game dropdown
- Auto-create 1-player game on first launch (skip lobby)
- Add sign-in tutorial for users without stored accounts
- Add lobby tutorial for users who reach the lobby

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:38:41 -08:00
5c5cf2ce43 Exclude Windows installer from Docker build trigger (#5545)
The installer is built by installer_build.yml, not the Docker workflow.
Adding exclusion prevents unnecessary Docker builds when only installer
files change.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:34:20 -08:00
a31aa0a46d Add icon to WebView installer (#5544)
Generate 64-bit .syso resource file for the WebView installer build.
The previous resource file was 32-bit and caused link errors with the
64-bit CGO cross-compilation.

Changes:
- Add resource_windows_amd64.syso (64-bit COFF object with icon/version)
- Update genrule to copy the .syso file during build

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:28:38 -08:00
63f0119a02 Fix genrule variable syntax for manifest public key (#5542)
* Fix genrule variable syntax for manifest public key

Genrules use Make variable syntax $(VAR), not curly brace syntax {VAR}.
The curly brace syntax only works in x_defs for go_binary rules.

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

* Use --action_env to pass MANIFEST_PUBLIC_KEY to genrule

Bazel genrules don't support workspace status variable substitution
in cmd strings. Instead, use --action_env to pass the environment
variable into the sandbox, where it can be referenced as a regular
shell variable ($$MANIFEST_PUBLIC_KEY in the genrule cmd).

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

* Handle missing MANIFEST_PUBLIC_KEY env var in genrule

When building via 'bazel test //src/main/go/...', the genrule runs
without --action_env=MANIFEST_PUBLIC_KEY set. Use bash default value
syntax ${VAR:-} to default to empty string when the env var isn't set,
allowing the build to succeed with an empty public key.

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

* Simplify public key handling - let empty var expand naturally

Bazel doesn't support ${VAR:-} syntax in genrule cmd. Instead, just
use $MANIFEST_PUBLIC_KEY directly - if not set, it expands to empty
string which the installer handles gracefully.

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

* Add manual tag to exclude webview installer from wildcard builds

The webview installer genrule requires --action_env=MANIFEST_PUBLIC_KEY
to be set. Adding tags = ["manual"] excludes it from wildcard patterns
like "bazel test //src/main/go/..." so it won't fail in the test workflow.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:12:47 -08:00
8436106c7b Fix server not pushing Shardok updates to connected clients (#5543)
PR #5459 accidentally broke Shardok update streaming when refactoring
postBattleUpdate to make Engine protoless. The original code called
withHandledEngineAndResults() which invoked humanClientsAfterPostingResults
to push updates to connected clients. The refactored code updated the
history but skipped client notification.

This caused clients to only receive Shardok updates on initial subscription
or reconnect, not during ongoing gameplay. The heartbeat would detect sync
mismatches (client=X server=Y) but updates weren't pushed.

Fix: Call humanClientsAfterPostingResults in postBattleUpdate to push
Shardok results to connected human clients, restoring the behavior that
was lost during the refactor.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 21:08:30 -08:00
62f4d40ef3 Enable stamp substitution for WebView installer genrule (#5541)
Add stamp = 1 to the genrule so that {STABLE_MANIFEST_PUBLIC_KEY}
gets substituted with the actual value from workspace status.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:49:15 -08:00
5a650cb9b7 Fix Shardok updates not received after initial subscription (#5540)
When the client subscribes with ShardokViewStatuses empty (because
ShardokGameModels hasn't been populated yet), the server sends an
initial ShardokActionResultResponse as catch-up but doesn't know to
keep sending updates for that game.

The model is created when processing ShardokActionResultResponse, but
by then the subscription was already sent without it. The server
won't send further updates, causing sync mismatches detected by
heartbeat until the client reconnects.

Fix: When creating a new ShardokGameModel from ShardokActionResultResponse,
re-subscribe to tell the server to include this game in future updates.

This issue was exposed by commit fb2b0beb85 which added
ShardokGameModels.Clear() in HandleStartingState, ensuring models are
always empty when subscription is initially sent after reconnect.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:49:03 -08:00
643765a2ba Fix unit placement tutorial text to reflect default placement (#5539)
Units are auto-placed in starting positions by default, so the
tutorial should explain how to move them rather than implying
you need to place them from the Unplaced Units panel first.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:39:26 -08:00
91553a0eec Fix installer manifest public key injection (#5537)
The manifest public key wasn't being injected properly because:
1. Workspace status variables need STABLE_ prefix for x_defs stamping
2. BUILD.bazel was using {MANIFEST_PUBLIC_KEY} but the variable
   wasn't being output with the STABLE_ prefix

This caused the installer to try to base64 decode the literal string
"{MANIFEST_PUBLIC_KEY}" instead of the actual public key value.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:29:25 -08:00
e1384fffd1 Fix Observe Battle button not appearing due to null GameStatus (#5538)
When a ShardokGameModel is first created via MakeGameModel, its
GameStatus property is null. It's only set later when history
entries are processed with a non-null gsvDiff.GameStatus.

The ShardokGameModelIsRunning check accessed sgm.GameStatus.State
without a null check, causing a NullReferenceException when
filtering ShardokGameModels. This caused RunningShardokGameModels
to fail and return empty, making the Observe Battle button not appear.

Fix: Add null check before accessing GameStatus.State, matching the
pattern already used in ShardokGameModel.InSetUp property.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:28:16 -08:00
1f3c4bfef0 Fix pending command retry loop when token is null (#5536)
When CurrentEagleToken or CurrentShardokToken is null (e.g., during
battles), TryPendingCommands was calling PostRequest which:
1. Adds the command back to _pendingCommands
2. Sends it to the server
3. Server returns BAD_TOKEN
4. Next game update triggers TryPendingCommands again
5. Command is still pending with null token → retry loop

This caused the same command to be posted multiple times, flooding the
server with BAD_TOKEN errors and potentially causing reconnect storms.

Fix: When token is null, add the command back to _pendingCommands
directly without calling PostRequest. The command stays pending until
we have a valid token to compare against.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:25:13 -08:00
bfd48bd628 Rename installer to Eagle0.exe and use WebView GUI (#5535)
- Rename WebView installer output from EagleInstallerWebView.exe to Eagle0.exe
- Update CI workflow to build and deploy Eagle0.exe
- Update all references in authservice (download URLs, batch scripts, HTML)
- Update installer_build_handler default key and version content
- Update help text and versioninfo.json metadata

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 20:08:56 -08:00
12bd6a78b1 Add WebView GUI to Windows installer (#5532)
* Add WebView GUI to Windows installer

Adds a native GUI window to the Windows installer using the webview
library which embeds Microsoft Edge WebView2. The installer now shows
a modern HTML/CSS/JS interface with progress bar and status updates.

Key changes:
- Add llvm-mingw toolchain for macOS → Windows CGO cross-compilation
- Add webview_go dependency for WebView2 integration
- Create UIInterface abstraction with ConsoleUI and WebViewUI impls
- Build tags select stub (non-Windows/non-CGO) vs real WebView impl
- Genrule handles CGO cross-compilation within Bazel sandbox

Build targets:
- eagle_installer_windows_amd64: Pure Go console version (5.8 MB)
- eagle_installer_windows_amd64_webview: CGO WebView GUI (13.7 MB)

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

* Fix WebView genrule to use Bazel Go SDK

The CI doesn't have Go in PATH, so the genrule needs to use Bazel's
Go SDK explicitly. This change:

- Exposes @go_default_sdk via use_repo in MODULE.bazel
- Adds @go_default_sdk//:files as a srcs dependency to the genrule
- Uses absolute paths for GO and GOROOT so they work after cd
- Filters out Go SDK files when copying source files
- Updates go.mod version to 1.23 to match the Bazel SDK

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:36:21 -08:00
420edb15ae Fix Bazel test workflow to show failure on correct step (#5533)
Remove continue-on-error from "Run tests" step so GitHub UI expands
the actual failing step instead of "Fail if tests failed".

The log collection and artifact upload steps use `if: always()` so
they still run after test failures.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:22:05 -08:00
8c0ac6e0fa Use fatigue instead of absolute vigor for hero selection (#5534)
Change hero selection in several commands to use HeroUtils.fatigue
(constitution - vigor) instead of absolute vigor. This is more
consistent with how other commands select heroes and properly accounts
for heroes having different max vigor (constitution).

Commands updated:
- HandleRiotCrackDown in CommandChoiceHelpers
- TruceOfferCommandSelector
- AllianceOfferCommandSelector
- Recon in MidGameAIClient

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 19:18:44 -08:00
81ce184e17 Improve captured hero plea prompt variety (#5531)
Add relationship context and personality guidance to prevent repetitive
"If you mean to kill me, do it swiftly" patterns:

- Detect if hero was previously in captor's faction (defection context)
- Note previous captures by the same faction
- Reference prior battles between the parties
- Use hero's personality words to guide tone
- Explicitly suggest varied emotional approaches and discourage clichés

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 17:00:16 -08:00
9a4324e302 Change OAuth buttons to two-column layout (#5530)
Match Unity client layout: left column (Discord, GitHub, Steam),
right column (Google, Apple, Twitch). Includes responsive fallback
to single column on narrow screens.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:49:58 -08:00
473f83e5f6 Complete deproto migration: delete plan and update linter (#5529)
The library/ code is now fully protoless (zero Scala proto dependencies).
Transitive deps through C++/Go build tools (map generation) are expected.

Changes:
- Delete docs/DEPROTO_PLAN.md - migration complete
- Delete scripts/build_deps_baseline.txt - no longer needed
- Update check_build_deps.sh to enforce zero Scala proto deps in library/
  (C++/Go proto deps are allowed as they're build-time tools)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 16:26:57 -08:00
7d7fa53f46 Remove deprecated .NET installer code (#5528)
The Windows installer has been rewritten in Go. Remove the old C#/.NET
installer code and the associated CI build script.

Deleted:
- src/main/csharp/net/eagle0/clients/win/ - .NET installer source
- ci/build_eagle_installer_win.sh - old manual build script

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:57:53 -08:00
56df35a3b7 Clean up backup installer after successful self-update (#5527)
After the installer updates itself, the old version is left behind as
EagleInstaller.exe.backup. Now on startup, the installer checks for and
removes this backup file.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:55:47 -08:00
b93a2ad1ce Remove stale proto_matchers dependencies from library tests (#5526)
The perform_province_events_action_test and perform_vassal_commands_phase_action_test
had BUILD.bazel dependencies on proto_matchers but the Scala files don't actually
use ProtoMatchers or equalProto. Remove these dead dependencies.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:52:45 -08:00
8213c733a5 Simplify installer: Go only, v2 manifest only (#5525)
Remove .NET installer build and old manifest support. The Go installer is
now the only version built and deployed. Both the installer and Unity
builds now update only the v2 manifest at installer/v2/eagle0_manifest.txt.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:50:32 -08:00
f2bc2f995e Fix v2 manifest missing Unity files and add game existence check (#5523)
Two issues fixed:

1. Unity build workflow now updates BOTH manifests (old and v2)
   - The v2 manifest was missing all game file hashes because only the
     installer workflow was updating it
   - Now unity_build.yml calls manifest_manager with both "unity3d" and
     "unity3d-v2" to update both manifests

2. Go installer now verifies game exists before claiming "up to date"
   - Previously, if manifests matched but files didn't exist, it would
     claim success and try to launch a non-existent game
   - Now checks if eagle0.exe exists; if not, forces redownload

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:40:32 -08:00
c2f00244b0 Migrate ResolveBattleActionTest to Scala model types (#5524)
Remove all proto imports and proto_converters from ResolveBattleActionTest.
Use native Scala model types (ProvinceC, HeroC, FactionC, BattalionC,
GameState, etc.) and standard ScalaTest matchers instead of ProtoMatchers.

Key changes:
- Replace proto imports with Scala model imports
- Use inside() pattern for type-safe assertions
- Add resultsOfExecute() extension method using ActionResultApplierImpl
- Update BUILD.bazel to remove proto dependencies
- Add test visibility for battalion_view package

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:26:48 -08:00
b1e70c443c Delete dead BattalionSuitabilityTest.scala file (#5521)
This file was a proto-using duplicate of the test in battalion_suitability/
but was never built (no BUILD target referenced it).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:08:51 -08:00
c29485cb89 Two-stage manifest for clean installer upgrade path (#5522)
* Add console UI with progress bar to Go installer

Adds a polished console-based UI to the Go installer with:
- Box-drawn banner header
- Status messages with ► prefix
- Success messages with ✓ prefix
- Error messages with ✗ prefix
- Warning messages with ⚠ prefix
- Progress bar with █ (filled) and ░ (empty) characters
- File count display during downloads
- Elapsed time on completion

The UI uses pure Go (no external dependencies) so the binary
size remains at 5.4MB.

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

* Add application icon to Go installer

- Copy eagle0.ico from .NET installer
- Add versioninfo.json for goversioninfo configuration
- Generate resource_windows.syso with embedded icon and version info
- Update BUILD.bazel to include the .syso resource in Windows build

Binary size increased from 5.4MB to 5.5MB.

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

* Fix Zone.Identifier removal in .NET installer

File.Exists() doesn't work with Windows Alternate Data Streams (ADS) -
it always returns false. Changed to unconditionally attempt deletion
and ignore errors if the ADS doesn't exist.

This fixes the self-update mechanism where SmartScreen was blocking
the new installer because Zone.Identifier was never actually removed.

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

* Two-stage manifest for clean installer upgrade path

Old .NET installers have a bug where Zone.Identifier removal fails,
blocking SmartScreen from allowing the Go installer to run. This change
creates a two-stage upgrade path:

1. OLD manifest (installer/eagle0_manifest.txt) -> points to .NET installer
   - Old .NET installers download new .NET installer (same format, works)

2. NEW manifest (installer/v2/eagle0_manifest.txt) -> points to Go installer
   - New .NET installer checks v2 manifest, downloads Go installer
   - Go installer also checks v2 manifest for future updates

Changes:
- .NET installer now checks installer/v2/ manifest path
- Go installer now checks installer/v2/ manifest path
- manifest_manager supports "installer-v2" section for v2 manifest
- CI workflow updates both manifests appropriately

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:07:47 -08:00
32631879a0 Delete unused AvailableCommandMatcher and SelectedCommandMatcher (#5520)
* Delete unused AvailableCommandMatcher and SelectedCommandMatcher

These proto-based test matchers were defined but never used by any test.
Removing them eliminates direct proto imports from library test code.

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

* Remove stale selected_command_matcher dependency

perform_vassal_commands_phase_action_test had a dep on selected_command_matcher
but didn't actually use it.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 15:02:40 -08:00
a818577a59 Add console UI with progress bar to Go installer (#5518)
* Add console UI with progress bar to Go installer

Adds a polished console-based UI to the Go installer with:
- Box-drawn banner header
- Status messages with ► prefix
- Success messages with ✓ prefix
- Error messages with ✗ prefix
- Warning messages with ⚠ prefix
- Progress bar with █ (filled) and ░ (empty) characters
- File count display during downloads
- Elapsed time on completion

The UI uses pure Go (no external dependencies) so the binary
size remains at 5.4MB.

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

* Add application icon to Go installer

- Copy eagle0.ico from .NET installer
- Add versioninfo.json for goversioninfo configuration
- Generate resource_windows.syso with embedded icon and version info
- Update BUILD.bazel to include the .syso resource in Windows build

Binary size increased from 5.4MB to 5.5MB.

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

* Fix Zone.Identifier removal in .NET installer

File.Exists() doesn't work with Windows Alternate Data Streams (ADS) -
it always returns false. Changed to unconditionally attempt deletion
and ignore errors if the ADS doesn't exist.

This fixes the self-update mechanism where SmartScreen was blocking
the new installer because Zone.Identifier was never actually removed.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:57:46 -08:00
d116ad0105 Fix installer CI: use installer_build_handler instead of s3cmd (#5519)
The CI runner doesn't have s3cmd installed. Modified installer_build_handler
to accept an optional second argument for the S3 key, allowing it to upload
the Go installer to installer/EagleInstallerMini.exe.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:41:46 -08:00
41732c8f98 Migrate deferred test files to Scala types, delete unused utilities (#5517)
* Migrate deferred test files to Scala types, delete unused utilities

- Migrate BattalionTypesTestData.scala to use Scala BattalionType
- Migrate NewRoundActionTest.scala to Scala types (FactionRelationship, MovingArmy, etc.)
- Migrate EndBattleAftermathPhaseActionTest.scala with complete rewrite
- Migrate ResolveBattleActionTest.scala (remove IDable dependency)
- Delete unused impl/package.scala and availability/package.scala
- Remove 48 dangling action_impl_pkg references from BUILD files
- Update DEPROTO_PLAN.md (24 migrated, 1 deferred, 3 deleted)

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

* Delete unused IDable.scala and remove all :idable BUILD references

IDable.scala was listed as a BUILD dependency in 22 targets, but no code
actually imported or used it. The BUILD dependencies were stale.

This completes the library test deproto migration with zero deferred files.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 14:40:29 -08:00
f096cdead1 Remove Zone.Identifier to fix Windows SmartScreen blocking (#5516)
* Remove Zone.Identifier to fix Windows SmartScreen blocking

When the installer downloads a new version and launches it programmatically,
Windows SmartScreen blocks the execution because the file has a "Mark of the
Web" (Zone.Identifier alternate data stream) indicating it was downloaded
from the internet.

Remove the Zone.Identifier after download to allow the new installer to
launch without being blocked.

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

* Add Zone.Identifier fix to .NET installer, deploy both versions

- Add Zone.Identifier removal to .NET installer's download logic
- Update CI to build both .NET and Go installers
- Deploy .NET installer to installer/EagleInstaller.exe
- Deploy Go installer to installer/EagleInstallerMini.exe
- Manifest points to EagleInstallerMini.exe (Go version)

Flow for existing users:
1. Old .NET installer → auto-updates to fixed .NET installer
2. Fixed .NET installer → downloads EagleInstallerMini.exe, strips Zone.Identifier, updates to Go

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 13:54:37 -08:00
1f2bc115d0 Rewrite Windows installer in Go (73MB -> 5.4MB) (#5515)
* Rewrite Windows installer in Go (73MB -> 5.4MB)

Rewrites the Eagle0 Windows installer from C#/.NET to Go, reducing the
binary size from 73MB to 5.4MB (93% reduction).

The Go implementation provides the same functionality:
- Downloads and verifies game files via manifest
- Ed25519 signature verification for manifest
- SHA256 verification for all downloaded files
- Parallel downloads (8 concurrent slots)
- Self-update mechanism for installer updates
- Console-based progress output
- Invitation code support

Changes:
- Add Go installer source code (main.go, updater.go, config.go)
- Add Bazel BUILD file with Windows cross-compilation target
- Update CI workflow to build Go binary instead of .NET
- Add MANIFEST_PUBLIC_KEY to workspace status for build-time injection

The installer is cross-compiled for Windows from Mac using Bazel.

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

* Fix CI permission error when copying installer output

Remove any existing installer-output directory before copying
to avoid permission denied errors from previous runs.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 13:41:32 -08:00
de727b1810 Migrate EngineImplTest to Scala types (#5488)
- Replace proto ActionResult with Scala ActionResultC
- Replace proto Date with Scala Date
- Replace proto RoundPhase with Scala RoundPhase
- Add InMemoryHistory.fromScalaResults() method for building history
  from Scala ActionResultT objects without proto conversion
- Update visibility for action_result_concrete to allow library tests

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 13:15:30 -08:00
c83ea25658 Fix DEPROTO_PLAN.md: Production library code IS protoless (#5513)
Corrects error from #5512 - the LLM prompt generators are actually
fully protoless. My grep was matching Scala utility imports like
`net.eagle0.common.JsonUtils`, not proto imports.

Verified with: grep -r "import net.eagle0.eagle.(internal|common|api|views)."
which returns zero matches in src/main/scala/net/eagle0/eagle/library/

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:20:16 -08:00
4d4056324c Configure Addressables for remote loading (#5508)
- Enable remote catalog building in AddressableAssetSettings
- Switch Default Local Group to use remote build/load paths
- Set CDN URL to https://assets.eagle0.net/addressables/[BuildTarget]
- Update BuildScript to log bundle output location for upload
- Add graceful error handling in SoundManager when music fails to load
  (e.g., offline and not cached) - game continues without music

After building, bundles in ServerData/[BuildTarget] are uploaded to the
CDN by CI (see PR #5509).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:18:43 -08:00
4d2e91a54d Update DEPROTO_PLAN.md: Hostility complete, LLM generators remain (#5512)
- Mark Hostility proto migration as complete (all 4 files now use Scala enum)
- Correct status: LLM prompt generators still have 4 files with proto imports
- List specific LLM files and their proto dependencies
- Move LLM generators from "Fully Protoless" to "Remaining Proto Usage"

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 12:09:51 -08:00
a242237534 Migrate all CI uploads to eagle0-assets bucket (#5509)
- Add upload_addressables.sh script for Addressables bundles
- Add upload step to Mac and Windows build workflows
- Update mac_build_handler to use eagle0-assets bucket
- Update unity3d_windows_build_handler to use eagle0-assets bucket
- Update installer_build_handler to use eagle0-assets bucket
- Update manifest_manager to use eagle0-assets bucket

All uploads now go to eagle0-assets bucket, accessible via assets.eagle0.net.
Old eagle0-windows bucket can be deleted after DNS TTL expires (~1 week).

Addressables uploaded to:
- https://assets.eagle0.net/addressables/StandaloneOSX/
- https://assets.eagle0.net/addressables/StandaloneWindows64/

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 11:25:03 -08:00
916c01978b Update DEPROTO_PLAN.md to reflect additional test migrations (#5511)
- EngineImplTest.scala migrated (#5488)
- GameStateViewFilterTest.scala migrated (#5510)
- Updated counts: 20 migrated, 6 deferred (was 18/8)
- Removed "API boundary tests" category - these test Scala APIs, not proto

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 11:00:46 -08:00
1fc25fc93a Migrate GameStateViewFilterTest from proto to Scala model types (#5510)
This commit migrates the GameStateViewFilterTest to use native Scala
model types instead of protobuf types:

- Replace proto GameState with Scala GameState
- Use Scala concrete types: FactionC, HeroC, ProvinceC, UnaffiliatedHeroC
- Use Scala enums: Profession.NoProfession, RoundPhase, etc.
- Update ShardokBattle and ShardokPlayer with all required fields
- Replace proto assertions with field-specific assertions on view types
- Add visibility for shardok_battle to test package
- Add required view dependencies to BUILD.bazel

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 10:31:47 -08:00
9da6ddc899 Delete orphaned RansomCommandTest.scala (#5492)
This test file was left behind when RansomCommand was refactored into
the DiplomacyCommand system. The file:
- Had no BUILD target
- Used types that no longer exist (RansomAvailableCommand, RansomSelectedCommand)
- Called APIs that no longer exist (RansomCommand.make)

The ransom functionality is now tested in DiplomacyCommandTest.scala
using the current API.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 09:35:01 -08:00
d6f6bcb002 Fix Addressables music loading for CI builds (#5503)
* Reapply "Convert SoundManager music loading to Addressables (#5475)" (#5494)

This reverts commit bda787c07e.

* Add BuildScript to build Addressables before player build

The Addressables change (PR #5475) was reverted because the Mac build
failed. The issue was that Addressables content must be built before the
player build, which the CI scripts weren't doing.

This commit:
- Reapplies the Addressables changes (revert of the revert)
- Adds Editor/BuildScript.cs with methods to build Addressables content
  before building the player
- Updates CI build scripts to use -executeMethod BuildScript.Build*Player
  instead of -buildWindows64Player/-buildOSXUniversalPlayer

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 08:53:47 -08:00
0f4d2ce2aa Update DEPROTO_PLAN.md with completed library test migration status (#5507)
Documents the completion of Phase 10 library test migration:
- 18 test files successfully migrated to Scala types
- 8 files deferred with documented rationale (shared utilities, API
  boundary tests, complex proto integration)
- 1 orphaned test file deleted
- Added migration patterns and key conversions reference

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 08:53:37 -08:00
d0e3d8eeb6 Migrate NewRoundActionTest from proto to Scala model types (#5506)
Convert test assertions from using proto ActionResult types to native
Scala types (ActionResultT, ChangedProvinceC, ChangedHeroC, ChangedFactionC,
UnaffiliatedHeroC, etc.). Keep proto types for input data construction
with GameStateConverter.fromProto() conversion.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 08:51:54 -08:00
1b38b81cf3 Migrate ProvinceConqueredActionTest.scala from proto to Scala types (#5505)
Convert test to use native Scala model types instead of ScalaPB protos:
- Replace proto GameState, Hero, Province, Faction, Battalion with Scala types
- Use Scala Gender, Profession, UnaffiliatedHeroType, RecruitmentInfo enums
- Use FactionRelationship.RelationshipLevel instead of proto enum
- Use native Scala quest types (WealthQuest, TruceCountQuest)
- Use BackstoryVersion from Scala model
- Remove all proto converters (DateConverter, FactionConverter, etc.)
- Remove 16 proto dependencies from BUILD.bazel

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 08:51:26 -08:00
4455c3fb6d Migrate EndPlayerCommandsPhaseActionTest.scala from proto to Scala types (#5504)
Convert test to use native Scala model types instead of ScalaPB protos:
- Replace proto Date, Gender, Profession with Scala equivalents
- Use DeferredChange.* for BlizzardStarted, DroughtStarted, etc.
- Use ProvinceOrderType Scala enum
- Use RecruitmentInfo and UnaffiliatedHeroType enums
- Use GameState, ProvinceC, HeroC, FactionC, UnaffiliatedHeroC directly
- Remove 15 proto dependencies from BUILD.bazel

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 08:50:38 -08:00
c04cb72326 Migrate AvailableDiplomacyCommandsFactoryTest to Scala models (#5502)
Convert from proto types with ScalaPB lens syntax to native Scala
models with .copy():
- Replace proto GameState/Province/Faction/Hero with Scala types
- Replace proto FactionRelationship/PrestigeModifier with Scala types
- Replace proto DiplomacyOffer with Scala TruceOffer
- Replace proto UnaffiliatedHero with UnaffiliatedHeroC
- Replace proto RecruitmentInfo/Status with Scala RecruitmentInfo
- Remove GameStateConverter.fromProto() calls
- Remove IDable.mapifyFactions/mapifyHeroes/mapifyProvinces helpers
- Remove 13 proto dependencies from BUILD.bazel

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 07:36:51 -08:00
1981f537ee Make entire Running Game row clickable instead of just Go button (#5501)
Simplifies the UI by making the whole row a button, improving click targets.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 07:29:31 -08:00
c0d4ebe425 Tutorial improvements: Skip Turn Cycle for AI, fix profession timing (#5483)
* Tutorial improvements: queue-based system, skip Turn Cycle for AI

Refactored tutorial system to use a queue-based approach:
- Tutorials that can't be shown immediately are queued
- Queue is processed when current tutorial completes or is skipped
- Removes complex interruption logic and pending profession tracking
- Prerequisites checked when dequeuing (may have changed since enqueue)

Turn Cycle tutorial only shows for multiplayer games (>1 human player):
- Added TutorialManager.IsMultiplayerGame static property
- Set from ConnectionHandler when creating/joining games

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

* Fix profession tutorial timing after onboarding

- Remove ArePrerequisitesMet check from OnGameEvent so tutorials
  can be queued during onboarding even when prerequisites aren't
  met yet. ProcessQueue checks prerequisites when showing them.
- Process the tutorial queue when entering a hidden step, allowing
  queued tutorials to show while onboarding waits for async events.
- Add re-entrancy guard to ProcessQueue to prevent nested calls
  from StartSequence -> StopCurrentSequence -> ProcessQueue causing
  tutorials to be started and immediately overwritten.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 07:21:20 -08:00
ad43a1a247 Migrate PerformFoodConsumptionPhaseActionTest to Scala models (#5500)
Replace proto imports with native Scala types:
- Use Date, ProvinceC, HeroC, BattalionC
- Use Army, MovingArmy, CombatUnit, Supplies
- Use BattalionType, BattalionTypeId
- Use GameState directly instead of via converter

Remove 8 proto dependencies from BUILD.bazel.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 07:19:35 -08:00
87d8650883 Migrate PerformProvinceEventsActionTest to Scala models (#5499)
Replace proto imports with native Scala types:
- Use Date, ProvinceC, FactionC, HeroC, BattalionC
- Use Army, MovingArmy, Supplies for army types
- Use ProvinceEvent subtypes for events
- Use ProvinceEventRolls from action package
- Use GameState directly instead of via converter

Remove 9 proto dependencies from BUILD.bazel.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 07:14:27 -08:00
c615cd8ae0 Migrate AvailableCommandsFactoryTest.scala from proto to Scala types (#5498)
- Replace proto imports with native Scala model types
- Use GameState, ProvinceC, FactionC, Army, MovingArmy, HostileArmyGroup
- Update FactionRelationship and RelationshipLevel imports
- Remove proto dependencies from BUILD.bazel

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 07:07:24 -08:00
bbb520f140 Migrate AvailablePleaseRecruitMeCommandFactoryTest to Scala models (#5497)
Replace proto types with native Scala equivalents:
- GameState, Province, Hero, Faction, Date
- UnaffiliatedHero, RecruitmentInfo, UnaffiliatedHeroType
- Remove IDable helpers, GameStateConverter.fromProto
- Remove 6 proto dependencies from BUILD.bazel

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 07:06:53 -08:00
50af7d64dc Migrate PerformReconResolutionActionTest to Scala models (#5496)
Replace proto types with native Scala equivalents:
- GameState, Province, Hero, Faction, Date, BackstoryVersion
- IncomingEndTurnAction, IncomingRecon, ProvinceOrderType
- Remove 10 proto dependencies from BUILD.bazel
- Remove GameStateConverter.fromProto() calls

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 06:46:15 -08:00
d329fafc80 Migrate AttackDecisionCommandChooserTest to Scala models (#5495)
Replace proto types with native Scala equivalents:
- GameState, Province, Hero, Faction, Battalion
- Army, MovingArmy, HostileArmyGroup, CombatUnit
- Remove 10 proto dependencies from BUILD.bazel

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 06:44:11 -08:00
9f4c02b82c Migrate PerformHostileArmySetupActionTest to pure Scala types (#5489)
* Migrate PerformHostileArmySetupActionTest to pure Scala types

Replace proto types (GameState, Province, Army, MovingArmy, RoundPhase)
with their Scala equivalents. Construct GameState directly instead of
using GameStateConverter.fromProto. Remove dependency on IDable.

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

* Refactor PerformHostileArmySetupActionTest for clarity

* Update PerformHostileArmySetupActionTest.scala

* Add Inside matcher import for tests

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 06:41:47 -08:00
84ad215491 Migrate FeastCommandTest from proto to Scala ActionResultType (#5484)
* Migrate FeastCommandTest from proto to Scala ActionResultType

Replace proto ActionResultType.FEAST import with Scala ActionResultType.Feast
and remove proto dependency from BUILD.bazel.

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

* Fix assertion for actionResultType in FeastCommandTest

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 06:38:10 -08:00
54ba129d2b Migrate AvailableMarchCommandFactoryTest to Scala types (#5493)
- Replace proto Hero, Battalion, Province, GameState with HeroC, BattalionC, ProvinceC, GameState
- Replace proto BattalionType, BattalionTypeId with Scala equivalents
- Replace proto Date, BlizzardEvent with Scala Date, ProvinceEvent.BlizzardEvent
- Replace proto Neighbor with Scala Neighbor
- Remove GameStateConverter.fromProto usage - construct GameState directly
- Remove availability/package.scala dependency (HeroMap/BattalionMap inlined)
- Remove 7 proto deps, 1 converter dep
- Add Scala model deps

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 06:33:12 -08:00
85fe20171d Migrate ProvinceHeldActionTest.scala from proto to Scala types (#5491)
Full migration of test data from proto types to native Scala model types.

Key changes:
- Replace proto Army, CombatUnit, Battalion with Scala equivalents
- Remove ProvinceConverter.fromProto - construct ProvinceC directly
- Remove IDable.mapifyFactions usage
- Remove 10 proto deps, 3 converter deps, and idable from BUILD.bazel
- Add Scala state type dependencies

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 06:31:31 -08:00
adminandGitHub bda787c07e Revert "Convert SoundManager music loading to Addressables (#5475)" (#5494)
This reverts commit 6757f23c5d.
2026-01-21 06:29:49 -08:00
a5bb630e65 Migrate NewYearActionTest.scala from proto to Scala types (#5490)
Full migration of test data from proto types (Province, Hero, Faction,
Battalion, GameState) to native Scala model types (ProvinceC, HeroC,
FactionC, BattalionC, GameState).

Key changes:
- Replace ScalaPB .update() lens syntax with Scala .copy() methods
- Remove GameStateConverter.fromProto usage
- Remove 6 proto deps from BUILD.bazel
- Add equivalent Scala type dependencies

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 06:29:42 -08:00
6daa44cb13 Document test utilities with cascading proto dependencies (#5487)
BattalionTypesTestData, impl/package.scala, and IDable.scala are shared
test utilities that use proto types and have cascading dependencies.
These need to be migrated together with their dependent tests.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 06:25:17 -08:00
0051b08c55 Remove unused proto imports from DeclineQuestCommandTest (#5486)
The questDetailsProto, questProto, and euh variables using proto types
were defined but never used in any test. Removed the dead code and
proto dependencies.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 06:24:21 -08:00
27e55a1fc5 Remove unused proto Hero import from FriendlyMoveActionTest (#5485)
The residentHeroes variable using proto Hero was dead code (created but
never used, alongside commented-out test code). Removed the variable,
the commented code, and the proto dependency.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 05:31:32 -08:00
062a09bd20 Remove proto imports from QuestFulfillmentUtilsTest (#5482)
Remove unused proto imports and dead code (questDetailsProto, questProto).
The test already uses Scala model types; these proto variables were never used.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:28:14 -08:00
ad4b9e88f6 Update asset audit: clean up deleted items, document music licenses (#5481)
- Remove deleted stock images section (already removed files)
- Document Market Day (RandomMind) and Shopping List (Komiku) as free-to-use
- Flag Medieval: Victory Theme as needing verification
- Flag Dima Koltsov Audius tracks for eventual replacement (Open Music License, not CC)
- Update action items and recommendations

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:27:52 -08:00
bf7f736a4a Update DEPROTO_PLAN.md for library test migration (#5480)
- Mark production library/ code as complete
- Remove detailed history of completed phases
- Add Phase 10: Library Test Migration with 27 files to migrate
- Categorize files by proto import count (light/medium/heavy)
- Include key type conversions and recommended migration order

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:12:33 -08:00
6757f23c5d Convert SoundManager music loading to Addressables (#5475)
* Convert SoundManager music loading to Addressables

Replace synchronous Resources.LoadAll calls with async Addressables loading.
This enables music files to be updated independently via Addressables catalog,
reducing download sizes for game updates.

- Add LoadMusicAsync coroutine using Addressables.LoadAssetsAsync
- Load music by label (music-spring, music-summer, etc.) instead of path
- Add MusicLoaded property to check when music is ready
- Guard PlayMusic() against empty clips during async loading

Note: Requires Unity Editor setup to mark music files as Addressable assets
and assign the corresponding labels.

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

* Auto-play music after async loading completes

If a music type was requested before loading finished, start playing
once the music is ready. This ensures music plays even if
CurrentMusicType was set during the async load window.

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

* Prioritize Summer/Autumn music loading, parallelize rest

- Load Summer first (most likely starting season)
- Load Autumn second (next season after Summer)
- Load remaining music (Spring, Winter, Travel, Battle, Victory) in parallel
- Try to start playing after each priority load completes

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

* Fix: Don't interrupt playing music after async load

TryStartMusicIfNotPlaying() only starts music if:
- Music type was requested (not MusicNone)
- Music isn't already playing
- The requested type's clips are loaded

This prevents interrupting Summer playback when Autumn finishes loading.

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

* Fix missing Clipboard Button texture in Chronicle canvas

Replace broken Unity built-in sprite reference with btn_icon_copy.png
from GUI Pro Kit. Also change Image Type to Simple with Preserve Aspect
for proper icon rendering.

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

* Revert "Fix missing Clipboard Button texture in Chronicle canvas"

This reverts commit c747e78ea2dbe03ed5215566c6f1a6f2ac8dbdb2.

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:09:31 -08:00
c41306912d Improve Mac notarization stapling reliability (#5479)
- Verify code signature before attempting to staple (catches transfer corruption)
- Increase retry attempts from 5 to 10
- Increase wait between retries from 10s to 30s (total wait up to 5 minutes)
- Capture and display stapler error output for better debugging
- Show signature details if verification fails

This addresses intermittent stapling failures due to Apple CloudKit
propagation delays after notarization completes.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:06:03 -08:00
c6e1fe10ea Remove stale proto exports from province:province (#5478)
* Remove stale proto exports from province:province

The province target was exporting province_event_scala_proto,
army_scala_proto, and unaffiliated_hero_scala_proto but no downstream
targets actually needed these through that path. Removing these stale
exports reduces library/ proto deps from 41 to 26.

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

* Fix unused import and stale test dependency

Remove unused LlmRequestT import and stale unaffiliated_hero_scala_proto
dependency that were exposed by removing the province proto exports.

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

* Fix test build failures from stale proto dependencies

- Remove unused ProtoMatchers from tests that don't use it
- Add missing unaffiliated_hero_quest_scala_proto dep to quest_fulfillment_utils_test
- Remove stale unaffiliated_hero_quest_scala_proto dep from check_for_fulfilled_quests_action_test
- Keep ProtoMatchers in ResolveBattleActionTest (uses equalProto matcher)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 22:05:24 -08:00
6b92d14d1d Remove stale eagle_unit dependencies from library/ (#5477)
Remove unused eagle_unit deps from resolved_eagle_unit and
province_conquered_action. These files use ResolvedEagleUnit (which
uses Scala types), not EagleUnit (which uses proto types).

This breaks the transitive proto dependency chain, reducing library/
proto deps from 74 to 41.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:25:18 -08:00
5705fb9403 Add Shardok battle tutorials (#5468)
* Add Shardok battle tutorials

Adds three introductory tutorials for tactical combat:
1. Victory conditions - explains attacker/defender win conditions
2. Unit placement - how to position units before battle
3. Battle controls - click to select, click to move/attack, ctrl+click for specials

Tutorials chain with prerequisites and trigger at appropriate game states.

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

* Split victory conditions into multi-step tutorial

Expands the victory conditions tutorial into three steps:
1. "Battle!" - introduces hex-grid combat, shows if player is attacker/defender
2. "Attacker Victory" - capture all castles + hold, or eliminate defenders, by Day 31
3. "Defender Victory" - eliminate attackers, or hold out past Day 31 (scattering works)

Adds IsDefender property to ShardokGameModel for role detection.

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

* Fix profession tutorial bugs

- Fix duplicate hero name appearing multiple times by storing original
  descriptions in a dictionary
- Fix Champion description: Duel, not Fear

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 21:22:40 -08:00
658bdac97f Add alpha tester support, legal, and onboarding items to TODO (#5476)
- Alpha Tester Support: feedback channel, crash reporting, known issues
- Legal: privacy policy, ToS, data deletion
- Onboarding: narrative hook, first-session goals, early victories

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 17:08:51 -08:00
5599d07247 Replace EndGameCondition proto with Scala sealed trait (#5473)
Migrate ResolvedShardokPlayer to use native Scala EndGameCondition
instead of the proto type. This removes 1 transitive proto dependency
from library/ (75 → 74).

Changes:
- Add Scala EndGameCondition sealed trait with Victory, AllyVictory,
  Draw, and Loss cases
- Add DrawType enum for draw conditions
- Add EndGameConditionConverter in ShardokBattleConverter
- Update ResolvedShardokPlayer to use Scala EndGameCondition
- Move proto conversion to ShardokInterfaceGrpcClient boundary
- Update ResolveBattleAction to use Scala EndGameCondition API
- Update ResolveBattleActionTest to construct Scala EndGameCondition

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 17:06:12 -08:00
b7afd6d6ad Add Small Eagle TODO checklist for closed alpha (#5474)
Tracks remaining work for 10-50 user private alpha release:
- Gameplay productionization (Mac installer, client updates, etc.)
- User management (link accounts, MOTD)
- IP/Legal (licenses, asset audit)
- Basic gameplay (tutorials, goals/ending)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 17:02:12 -08:00
6e7611d547 Move ShardokBattle boundary conversion to ShardokInterfaceGrpcClient (#5471)
* Remove stale proto_converter deps from library/ BUILD files

Library code should not depend on proto_converters - those belong at
the service layer boundary. Removed 9 stale proto_converter deps that
were no longer used by any Scala code.

The only remaining proto_converter dep is shardok_battle_converter,
which is actually used by ResolveBattleAction.scala (to be fixed in
a follow-up PR).

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

* Move ShardokBattle boundary conversion to ShardokInterfaceGrpcClient

Previously, BattleResolution contained a proto ShardokBattle, requiring
ResolveBattleAction in library/ to depend on ShardokBattleConverter.
This violated the boundary principle where proto conversions should
happen at the service layer, not in library code.

This change:
- Updates BattleResolution.battle to use Scala ShardokBattle
- Moves the proto-to-Scala conversion into ShardokInterfaceGrpcClient
- Removes ShardokBattleConverter dependency from library/ code
- Updates tests to use the scalaBattle() helper for conversion

This completely removes proto_converters dependencies from library/
(the last one was shardok_battle_converter).

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

* Add linter check for library/ proto_converters boundary

Adds Rule 3 to check_build_deps.sh that verifies library/ code does not
depend on proto_converters. Proto conversions should happen at service
boundaries (ShardokInterfaceGrpcClient, EagleServiceImpl, etc.), not in
library code.

Also updates baseline from 167 to 75 proto deps reflecting recent cleanup.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-20 06:27:57 -08:00
8975514e7c Add Sparkle delta updates implementation plan (#5469)
Documents the design for implementing delta patches in the Mac auto-update
system to reduce download sizes from ~200MB to ~10-30MB per update.

Covers:
- Appcast XML structure with sparkle:deltas
- S3 storage layout for app bundles and deltas
- BinaryDelta tool usage
- Migration strategy and error handling
- Storage impact analysis (~3.25GB total for 85% bandwidth savings)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 22:35:53 -08:00
75f08812db Fix Mac build artifact race condition with run-specific names (#5470)
Use run-specific artifact names (signed-mac-app-{run_id}, notarized-mac-app-{run_id})
to prevent race conditions where a previous run's cleanup job might delete
artifacts from a concurrent run.

Root cause: When PR #5465 merged, the previous run (which used the old workflow
that deleted ALL artifacts by name) ran its cleanup job between when the new
run uploaded its artifact (06:13:20) and when it tried to download it (06:18:27).

Fix: Include the GitHub run ID in artifact names so each run's artifacts are
uniquely named and cleanup jobs can only delete their own artifacts.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 22:35:29 -08:00
6639ef62fc Remove stale proto_converter deps from library/ BUILD files (#5467)
Library code should not depend on proto_converters - those belong at
the service layer boundary. Removed 9 stale proto_converter deps that
were no longer used by any Scala code.

The only remaining proto_converter dep is shardok_battle_converter,
which is actually used by ResolveBattleAction.scala (to be fixed in
a follow-up PR).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 22:07:56 -08:00
c8c7c1da42 Add Eagle server build to CI (#5466)
* Add Eagle server build to CI

Add eagle_build.yml workflow that builds the Eagle server when Scala
or Eagle proto files change. This ensures build failures in the Eagle
server are caught during PR review, similar to how shardok_build.yml
works for the C++ Shardok server.

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

* Fix unused dependencies and simplify GameAdminServiceImpl

- Remove unused json4s and settings_loader deps from :service target
- Simplify GameAdminServiceImpl since GamesManager now returns
  game_admin proto types directly (no conversion needed)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 22:04:42 -08:00
e03fc90787 Fix appcast version mismatch with actual app bundle (#5465)
Read version from the built app's Info.plist instead of calculating
from git commit count at deploy time. This ensures the appcast version
always matches what's actually in the app bundle.

Previously, the deploy step used `git rev-list --count HEAD` which could
differ from the version baked into the Unity build if commits were added
between the build and deploy steps.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 22:04:05 -08:00
b7034918a2 Add first-encounter profession tutorials (#5464)
* Add first-encounter profession tutorials

Shows tutorial popup explaining each profession's abilities when player
first encounters a hero with that profession. Covers both strategic
(Eagle) and tactical (Shardok) abilities in natural language.

Triggers when viewing provinces with employed or free heroes.

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

* Update mage and paladin profession tutorial text

- Mage: Can create *or end* blizzards and droughts
- Paladin: Giving alms provides much larger support boost

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 22:02:32 -08:00
5201e4c737 Remove admin endpoints from Eagle service (#5463)
Now that the admin console uses the GameAdmin service (PR #5460),
remove the duplicate admin endpoints from eagle.proto and their
implementations from EagleServiceImpl.

Changes:
- Remove 14 admin RPC endpoints from Eagle service in eagle.proto
- Remove corresponding message types (AddSettings*, Get*, Convert*,
  Reassign*, Rewind*, Import*, Delete*, CheckGame*, DownloadGame*)
- Remove admin method implementations from EagleServiceImpl.scala
- Update GamesManager to import admin response types from game_admin
  proto instead of eagle proto

This completes the admin service separation:
- PR #5458: Create GameAdmin service
- PR #5460: Migrate admin console to GameAdmin
- This PR: Remove admin endpoints from Eagle

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 21:12:04 -08:00
f395bde15c Fix FullGameHistory access in EagleServiceImpl (#5462)
After the GameHistory split, EagleServiceImpl needs to access
shardokPlayerCount from controller.fullHistory instead of
controller.engine.history. Also export full_game_history from
game_controller so downstream targets can see the FullGameHistory type.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 16:38:30 -08:00
af854ea2cc Remove stale proto deps from library/ BUILD.bazel files (#5461)
With all proto imports removed from library/ Scala code, these proto
dependencies in BUILD.bazel files are no longer needed. This completes
the deproto milestone for the library/ layer.

- Update DEPROTO_PLAN.md to reflect 0 proto files in library/
- Remove unused proto deps from library/BUILD.bazel, util/, actions/impl/

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 16:30:37 -08:00
113b6eee27 Migrate admin console to use GameAdmin service (#5460)
Update admin_server.go to call the new GameAdmin gRPC service instead
of Eagle for all game management operations. This is step 2 of the
admin service separation:

- Replace grpcClient (EagleClient) with gameAdminClient (GameAdminClient)
- Update all game admin RPC calls to use gameadminpb types
- Add game_admin_go_proto dependency to BUILD.bazel
- Fix game_admin BUILD.bazel to use go_grpc instead of go_grpc_v2
  (v2 only generates gRPC code, not message types)

Auth operations still use authpb (via Eagle's Auth service).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 16:29:34 -08:00
3d89fcd9de Add GameAdmin gRPC service for admin game management (#5458)
Create a separate GameAdmin service to handle admin-only game management
operations (RewindGame, DeleteGame, GetRunningGames, etc.) that were
previously exposed through the Eagle service alongside client endpoints.

This is the first step in separating admin and client APIs:
- Creates game_admin.proto with all 13 admin game endpoints
- Implements GameAdminServiceImpl calling the existing GamesManager
- Registers the new service in Main.scala on the same port as Eagle

The admin console will be migrated to use this service in a follow-up PR,
after which the admin endpoints can be removed from eagle.proto.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 16:04:24 -08:00
621e2fc128 Fix Sparkle appcast namespace after XML unmarshal (#5456)
Go's XML unmarshaler doesn't preserve xmlns attributes correctly.
When the appcast was fetched and re-marshaled, the Sparkle namespace
was being set to empty string, which prevented Sparkle from parsing
version numbers and detecting updates.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 16:02:41 -08:00
fb5ca109b7 Update bundle identifier to net.eagle0.eagle0 (#5455)
Change from com.Shardok-Games.eagle0 to net.eagle0.eagle0 to match
the project's domain (net.eagle0).

Updated in:
- Unity ProjectSettings (applicationIdentifier for Standalone)
- inject_sparkle.sh (CFBundleURLName for URL scheme)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 16:00:31 -08:00
a56c4feaf2 Check for Sparkle updates on every app startup (#5457)
Sparkle's default check interval is 24 hours. If a user launches the app
shortly after a new version is deployed but before their scheduled check,
they won't see the update prompt.

This adds an explicit background check on every startup to ensure users
get updates promptly when a new version is available.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 15:56:36 -08:00
d10fda1b41 Split GameHistory into protoless GameHistory and FullGameHistory (#5459)
* Make Engine API protoless by introducing Scala ActionResultView

Created Scala ActionResultView type to replace proto ActionResultView
in the Engine's public API. Proto conversion now happens at the gRPC
boundary in HumanPlayerClientConnectionState.

Changes:
- Created model/view/action_result/ActionResultView.scala
- Created proto_converters/view/action_result/ActionResultViewConverter.scala
- Updated ActionResultFilter to return Scala ActionResultView
- Updated Engine and EngineImpl to use Scala type
- Updated HumanPlayerClientConnectionState to convert to proto at gRPC boundary
- Removed proto dependencies from library/

This follows the architecture principle that the Engine should vend a
pure Scala model API, with proto conversions happening at the outer
boundary layer (GameController/GamesManager).

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

* Split GameHistory into protoless GameHistory and FullGameHistory

- Create FullGameHistory trait in service/ that extends GameHistory
  and adds Shardok-specific methods (shardokPlayerResultsSince,
  shardokPlayerAvailableCommands)
- Remove receiveBattleUpdate from Engine trait (moved to GamesManager)
- Update GameController to hold fullHistory: FullGameHistory field
- Update PersistedHistory and InMemoryHistory to extend FullGameHistory
- Add extractFullHistory helper methods for safe type extraction
- Update tests to use FullGameHistory mocks

This continues the deproto effort by isolating proto-dependent
Shardok methods from the library layer's GameHistory trait.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 15:55:31 -08:00
c6d958dd9e Fix installer icon format for proper Windows display (#5454)
Regenerate ICO file with proper format:
- Use PNG compression for 256x256 size (Windows Vista+ requirement)
- Use BMP format for smaller sizes (16, 24, 32, 48)
- Proper BITMAPINFOHEADER structure for BMP entries

The previous ICO may not have displayed correctly due to format issues.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 15:40:58 -08:00
ce521e534b Move actually-used icons from Unused to InUse folder (#5452)
* Reduce build size: optimize Twitch button, remove unused profession icons

1. glitch_flat_purple.png (Twitch OAuth button):
   - Reduce maxTextureSize from 2048 to 512
   - Enable crunched compression
   - Expected savings: ~8 MB

2. Delete Resources/Professions folder:
   - These icons were unused (profession textures are assigned via
     EagleCommonTextures component, not loaded from Resources)
   - Expected savings: ~7 MB (5.3 MB paladin + others)

Total expected savings: ~15 MB

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

* Move actually-used icons from Unused to InUse folder

22 icons from 4000_Fantasy_Icons/Unused were actually referenced by
Gameplay.unity (for flame effects, UI elements, etc). Moving them
to an InUse folder makes the naming accurate.

Unity tracks assets by GUID, so moving files with their .meta files
preserves all scene/prefab references.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 15:38:42 -08:00
945d5f05b0 Fix installer self-update URL construction (#5453)
The installer download URL was incorrectly prepending "assets/" to the
installer path, resulting in a 403 error when trying to download the
new installer from:
  https://assets.eagle0.net/assets/installer/EagleInstaller.exe

The correct URL is:
  https://assets.eagle0.net/installer/EagleInstaller.exe

This bug prevented installed copies from auto-updating to newer versions.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 15:38:01 -08:00
d5f8b08018 Add application icon to Windows installer (#5451)
Uses the same eagle head icon that was added to the Unity/macOS app.
Converted from PNG to ICO format with multiple sizes (16-256px).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 15:14:19 -08:00
c80761a3f9 Reduce build size: optimize Twitch button, remove unused profession icons (#5450)
1. glitch_flat_purple.png (Twitch OAuth button):
   - Reduce maxTextureSize from 2048 to 512
   - Enable crunched compression
   - Expected savings: ~8 MB

2. Delete Resources/Professions folder:
   - These icons were unused (profession textures are assigned via
     EagleCommonTextures component, not loaded from Resources)
   - Expected savings: ~7 MB (5.3 MB paladin + others)

Total expected savings: ~15 MB

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 14:52:50 -08:00
d22f360d1f Ignore native plugin .meta files in gitignore (#5449)
Unity creates .meta files for the native plugins which should also
be ignored since the plugins are built at CI time.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 14:20:27 -08:00
9d07984b2e Make Engine API protoless by introducing Scala ActionResultView (#5447)
Created Scala ActionResultView type to replace proto ActionResultView
in the Engine's public API. Proto conversion now happens at the gRPC
boundary in HumanPlayerClientConnectionState.

Changes:
- Created model/view/action_result/ActionResultView.scala
- Created proto_converters/view/action_result/ActionResultViewConverter.scala
- Updated ActionResultFilter to return Scala ActionResultView
- Updated Engine and EngineImpl to use Scala type
- Updated HumanPlayerClientConnectionState to convert to proto at gRPC boundary
- Removed proto dependencies from library/

This follows the architecture principle that the Engine should vend a
pure Scala model API, with proto conversions happening at the outer
boundary layer (GameController/GamesManager).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 13:48:13 -08:00
bc763d0590 Add app icon for macOS build (#5448)
Eagle head facing crowned king - represents the strategic conflict
theme of the game.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 13:46:55 -08:00
10933d3c8b Add styled DMG with arrow pointing to Applications (#5445)
* Add styled DMG with arrow pointing to Applications

- Add create-dmg as a bazel dependency for creating styled DMG installers
- Add background image with arrow pointing from app to Applications
- Update mac_build_handler to use create-dmg for styled DMG creation
- Update CI workflow to use the new DMG creation process

The DMG now shows a visual arrow guiding users to drag the app
to the Applications folder.

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

* Add --skip-jenkins flag for headless CI environment

The AppleScript that positions icons times out in CI environments
without a GUI session. Using --skip-jenkins skips the Finder styling
while still including the background image and Applications link.

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

* Switch from create-dmg to dmgbuild for CI compatibility

dmgbuild generates .DS_Store files programmatically without needing
AppleScript or Finder access, making it work in headless CI environments.

- Remove create-dmg bazel dependency (relied on AppleScript)
- Use dmgbuild Python tool instead (pip install dmgbuild)
- Generates proper icon positions and background without GUI

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

* Fix DMG styling: lighter background, adjust icon positions

- Use light gray background so file/folder names are readable
- Move arrow up to align with icon centers
- Move Applications link further right (500 -> 520) to clear the arrow

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

* Fix arrow: move left, connect head to shaft properly

- Arrow now starts at x=200 (closer to app icon)
- Arrow ends at x=460 (further from Applications at x=520)
- Head and shaft now overlap for proper connection

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 13:06:37 -08:00
56312414ec Make Engine API protoless by introducing Scala ActionResultView (#5446)
Created Scala ActionResultView type to replace proto ActionResultView
in the Engine's public API. Proto conversion now happens at the gRPC
boundary in HumanPlayerClientConnectionState.

Changes:
- Created model/view/action_result/ActionResultView.scala
- Created proto_converters/view/action_result/ActionResultViewConverter.scala
- Updated ActionResultFilter to return Scala ActionResultView
- Updated Engine and EngineImpl to use Scala type
- Updated HumanPlayerClientConnectionState to convert to proto at gRPC boundary
- Removed proto dependencies from library/

This follows the architecture principle that the Engine should vend a
pure Scala model API, with proto conversions happening at the outer
boundary layer (GameController/GamesManager).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 13:04:40 -08:00
9b2315aaff Remove proto types from LLM prompt generators (#5444)
* Remove proto Date from GeneratorUtilities

All callers use Scala Date, so the proto Date overload is unused.
- Remove import of net.eagle0.eagle.common.date.Date
- Remove the proto Date overload of dateString()
- Keep only the Scala Date version

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

* Remove proto types from LLM prompt generators

Migrated 8 files to use Scala types instead of proto:
- BattalionDescriptions: uses BattalionTypeId, BattalionT, BattalionView
- ChronicleEventTextGenerator: uses ChronicleEvent sealed trait
- ChronicleUpdatePromptGenerator: removed ChronicleEventConverter
- HeroBackstoryUpdatePromptGenerator: removed BattalionViewConverter, QuestConverter
- QuestEndedGeneratorUtilities: uses QuestT/QuestC types
- QuestFailedPromptGenerator: removed QuestConverter
- QuestFulfilledPromptGenerator: removed QuestConverter

Updated DEPROTO_PLAN.md to accurately reflect completion of Phase 8
(LLM Prompt Generators) and Phase 8b (Hostility Migration).

All non-boundary library code is now protoless.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 10:29:25 -08:00
d7f1f27ea7 Export DMG warning symbols from SparklePlugin (#5443)
The IsRunningFromReadOnlyVolume and ShowDMGWarning functions were
not being exported from the native plugin, causing the DMG launch
check to fail silently.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 10:15:43 -08:00
08d9e04428 Add periodic artifact storage monitoring workflow (#5442)
Runs every 6 hours to check total artifact storage. Fails if storage
exceeds 500 MB and lists the largest artifacts for debugging.

Steady-state should be ~50 MB (logs and test results). This acts as
a safety net in case cleanup jobs fail to run properly.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 07:56:23 -08:00
62c7c45ef1 Fix Mac build failures from concurrent artifact deletion (#5441)
The cleanup step was deleting ALL signed-mac-app artifacts across the
entire repo, not just the current run's. When two main builds ran
concurrently, one build's cleanup would delete the other's artifacts.

Fix: Change cleanup to only delete the current run's artifacts using
the runs/$run_id/artifacts API endpoint instead of deleting all
artifacts matching the name.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 07:55:21 -08:00
4da6675b91 Add BUILD.bazel dependency linting to CI (#5439)
Adds a step to the bazel_test workflow that runs check_build_deps.sh
in strict mode. This enforces architectural boundaries:
- src/main should not depend on src/test
- library/ proto dependencies should not exceed baseline

Runs before tests so failures are caught early.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 07:44:18 -08:00
57ffabdb67 Disable test tutorial popups (#5440)
The test tutorials ("You've issued your first command!") were
accidentally left enabled in the Gameplay scene.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 07:43:44 -08:00
916501cbf8 Replace Hostility proto with Scala Hostility enum (#5438)
* Replace Hostility proto with Scala Hostility enum

This removes direct proto imports from library/ for the Hostility type:

- FactionUtils.hostilityStatus now returns Scala Hostility
- ArmyStats.hostility now uses Scala Hostility type
- AvailableAttackDecisionCommandFactory no longer imports proto
- AvailableFreeForAllDecisionCommandFactory no longer imports proto
- AttackDecisionCommandChooser uses Hostility.Enemy instead of proto
- BattleFilter no longer needs protoToScalaHostility converter
- AvailableCommandConverter uses HostilityConverter.toProto

This completes Phase 8b of the deproto migration, reducing direct
proto imports in library/ from 8 files to 4 (LLM prompt generators).

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

* Fix AvailableCommandConverterTest to use Scala Hostility enum

Update test to use net.eagle0.eagle.model.state.Hostility.Self
instead of proto net.eagle0.common.hostility.Hostility.SELF_HOSTILITY.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 07:35:38 -08:00
2d58f0231c Add retention-days to all GitHub Actions artifacts (#5436)
* Add retention-days to all GitHub Actions artifacts

- Use retention-days: 1 for artifacts deployed to external storage
  (sysroot, installer, mac app builds)
- Use retention-days: 3 for debug logs and test results

Prevents artifact storage quota from being exceeded.

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

* Delete Mac build artifacts after successful deploy

Automatically deletes signed-mac-app and notarized-mac-app artifacts
after deployment completes. These ~250MB artifacts are only needed to
pass the app between workflow jobs; once deployed, they're redundant.

This prevents artifact storage from accumulating even if retention-days
doesn't expire them quickly enough.

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

* Delete ALL old build artifacts after deploy (not just current run)

Both Mac and Windows installer workflows now delete ALL artifacts
with their respective names after successful deployment:
- Mac: signed-mac-app, notarized-mac-app
- Windows: eagle-installer

This ensures no artifact buildup even from failed/stale runs.

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

* Increase retention for test/build logs to 7 days

These are small files useful for debugging, so keep them longer:
- test.json (~100KB each)
- editor_win.log / editor_mac.log (~60KB each)
- failed-test-logs

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

* Use 5-day retention to match repository maximum

The repository has a 5-day maximum retention policy configured.

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

* Delete old Mac builds from DigitalOcean when pruning appcast

The appcast keeps the last 10 versions, but the old DMG files were never
deleted from S3. Now when items are removed from the appcast, the
corresponding DMG files are also deleted from DigitalOcean Spaces.

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

* Add cleanup job that runs even when build fails

Previously, artifact cleanup only happened in the deploy job, which
doesn't run if earlier jobs fail. This left behind large artifacts
from failed builds, eventually hitting the storage quota.

The new cleanup job runs on ubuntu-latest with `if: always()` so it
executes regardless of whether build-and-sign, wait-notarization,
or deploy succeeded or failed.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 07:30:58 -08:00
7ab8fc796b Add BUILD.bazel dependency linting (#5437)
* Add BUILD.bazel dependency linting

Adds tooling to enforce architectural boundaries in the codebase:

1. Shell script `scripts/check_build_deps.sh` with multiple modes:
   - Default: runs all checks and shows dependency counts
   - --ci: fails only on hard violations (src/main depends on src/test)
   - --strict: also fails if proto deps in library/ increase from baseline
   - --count: just show current dependency counts
   - --update-baseline: update the baseline file for tracking

2. Bazel test `//:build_deps_test` for integration with test suite

3. Baseline file `scripts/build_deps_baseline.txt` tracking proto dep count

Current enforced rules:
- src/main must not depend on src/test (enforced now)
- library/ proto dependencies tracked (167 currently, deproto in progress)

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

* Update DEPROTO_PLAN.md with accurate proto import inventory

The previous status was inaccurate. This update:

- Corrects summary table: Availability, Command Choice Helpers,
  and other utilities still have Hostility proto imports
- Adds Phase 8b documenting Hostility proto migration (4 files)
- Updates detailed inventory showing actual 13 files with direct
  proto imports (vs 167 transitive deps from bazel query)
- Corrects success criteria checkboxes to reflect actual status
- Documents the grep command used to verify imports

The 167 proto deps from bazel query are transitive dependencies.
Only 13 files have direct proto imports:
- 5 boundary files (expected to keep proto)
- 4 files using Hostility proto (Phase 8b)
- 4 LLM prompt generator files (Phase 8)

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 07:28:58 -08:00
adminandGitHub a0bf6050b4 Reduce Unity build size (~84MB savings) (#5433) 2026-01-19 07:16:25 -08:00
adminandGitHub 8fd95fc596 Auto-size tutorial panel to fit content (#5430) 2026-01-19 07:16:11 -08:00
adminandGitHub e91da453a1 Remove ActionWithResultingState, create PersistedActionResult (#5434) 2026-01-19 06:54:39 -08:00
8981298394 Update DEPROTO_PLAN.md - all business logic now protoless (#5432)
Mark Phase 8 (LLM Prompt Generators) as complete following PR #5429.
All 34 prompt generator files and all request generators now use Scala
LlmRequestT types instead of proto types.

Summary:
- Total proto imports: ~7 (down from 149)
- All remaining proto usage is at system boundaries (gRPC, persistence)
- All success criteria met
- All open questions resolved

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 21:45:01 -08:00
0836636fda Update Unity audio compression settings and font asset (#5435)
Unity auto-updated audio import settings:
- Serialized version 6 → 8
- Quality setting adjusted
- Preload audio data setting moved

Font SDF asset regenerated.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 21:42:42 -08:00
f0bfcb005a Convert all remaining LLM prompt generators to Scala types (#5429)
* Convert all remaining LLM prompt generators to Scala types

This converts all remaining prompt generators from proto types to Scala
types (LlmRequestT). Updates include:
- All prompt generators now use LlmRequestT.* instead of proto types
- LlmResolver uses Scala type matches for all generators
- Proto fallback cases removed (now throw exceptions for non-LLM types)
- Updated BUILD files with correct dependencies
- Fixed test file to use Scala types

Note: ChronicleUpdatePromptGeneratorTest still needs to be updated to use
Scala types in a follow-up PR.

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

* Fix ChronicleUpdatePromptGeneratorTest to use Scala types

Update the test to use LlmRequestT.ChronicleUpdateMessage and related
Scala types instead of proto types.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 07:47:33 -08:00
c3b882f178 Fix notification filtering to send immediate notifications to clients (#5431)
The deferred flag logic was inverted - we were collecting notifications
where deferred=true (hold for later) instead of deferred=false (send now).

This bug was introduced in commit 8d3bc914ed when migrating from proto
types to Scala types. The original proto-based code used notificationsToDeliver
which already handled the deferred distinction. The new Scala code used
NotificationConverter.toProto() which returns (proto, deferred) tuple,
but the .collect pattern incorrectly filtered for deferred=true.

Affected features:
- Divine command notifications (learning about quests)
- Quest completion/failure notifications
- Diplomacy event notifications (alliance, truce, etc.)
- All other immediate notifications

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 07:47:13 -08:00
8c322eb812 Switch Mac distribution from ZIP to DMG (#5427)
* Add in-app warning when running from Downloads folder

Sparkle auto-update framework cannot update apps running from the
Downloads folder or other transient locations. This adds a native
macOS alert that warns users when they launch the app from an
invalid location and instructs them to move it to Applications.

The check detects:
- /Downloads/ - where macOS downloads files
- /tmp/ or /private/tmp/ - temporary directories
- /.Trash/ - deleted files

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

* Fix build: import AppKit for NSAlert

NSAlert is part of AppKit, not Foundation. Changed import to AppKit
which also includes Foundation.

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

* Offer to move app to /Applications and relaunch

Instead of just showing a warning, now offers to:
1. Copy the app to /Applications (removing existing if present)
2. Launch the new copy
3. Terminate the current instance

This provides a seamless experience - user just clicks "Move to
Applications" and the app handles everything automatically.

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

* Switch Mac distribution from ZIP to DMG

Changes the Mac app distribution from a ZIP file to a DMG disk image.
This provides a better installation experience and enables enforcing
proper installation:

1. **DMG with Applications alias**: Users see the app and an
   Applications folder alias, making drag-to-install intuitive.

2. **Read-only enforcement**: DMGs are mounted read-only, so if
   users try to run the app directly from the DMG, we detect it
   and show a blocking dialog telling them to install first.

3. **Sparkle works from any writable location**: Unlike the previous
   Downloads-folder check, this approach allows the app to run from
   anywhere writable (~/Desktop, ~/Games, /Applications, etc.).

Changes:
- SparklePlugin.m: Replace location warning with read-only volume check
- SparkleUpdater.cs: Update P/Invoke bindings for new functions
- SparkleInitializer.cs: Check for read-only volume instead of path
- mac_build_handler.go: Create DMG instead of ZIP using hdiutil
- invitation_handlers.go: Update download URL and instructions for DMG

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 07:27:00 -08:00
485da704b4 Convert BreakAllianceResolutionMessagePromptGenerator to Scala types (#5425)
Update BreakAllianceResolutionMessagePromptGenerator to accept
LlmRequestT.BreakAllianceResolutionMessage instead of the proto type.
Add Scala type matching in LlmResolver and update BUILD dependency
from proto to Scala types.

Also updates enum handling from proto DiplomacyOfferStatus to Scala Status.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-18 06:16:40 -08:00
ecda346773 Convert BreakAllianceMessagePromptGenerator to Scala types (#5424)
Update BreakAllianceMessagePromptGenerator to accept LlmRequestT.BreakAllianceMessage
instead of the proto type. Add Scala type matching in LlmResolver and update BUILD
dependency from proto to Scala types.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 22:48:48 -08:00
97bb3789b3 Convert AllianceResolutionMessagePromptGenerator to Scala types (#5423)
Update AllianceResolutionMessagePromptGenerator to accept
LlmRequestT.AllianceOfferResolutionMessage instead of the proto type.
Add Scala type matching in LlmResolver and update BUILD dependency
from proto to Scala types.

Also updates enum handling from proto DiplomacyOfferStatus to Scala Status.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 22:45:03 -08:00
fdf42c812f Add Twitch OAuth secrets to docker_build.yml (#5428)
docker_build.yml was missing TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET
in its env block and export statements. All other OAuth providers
(Discord, Google, GitHub, Apple) were present, but Twitch was omitted
when the provider was added.

This caused Twitch to not appear on the invitation landing page after
Eagle deployments, even though auth_build.yml had the secrets.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 22:34:19 -08:00
1b3ccc9e5d Convert AllianceOfferMessagePromptGenerator to Scala types (#5422)
Update AllianceOfferMessagePromptGenerator to accept LlmRequestT.AllianceOfferMessage
instead of the proto AllianceOfferMessage type. Add Scala type matching in LlmResolver
and update BUILD dependency from proto to Scala types.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 22:32:54 -08:00
54cf54ad83 Convert TruceResolutionMessagePromptGenerator to Scala types (#5421)
Update TruceResolutionMessagePromptGenerator to accept
LlmRequestT.TruceResolutionMessage and use the Scala Status enum
instead of proto DiplomacyOfferStatus. Add Scala type matching in
LlmResolver.promptGenerator before falling back to proto matching.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 22:28:45 -08:00
81d1eaa875 Convert TruceOfferMessagePromptGenerator to Scala types (#5420)
Update TruceOfferMessagePromptGenerator to accept
LlmRequestT.TruceOfferMessage instead of the proto TruceOfferMessage
type. Add Scala type matching in LlmResolver.promptGenerator before
falling back to proto matching.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 21:52:00 -08:00
0e9a06cc88 Fix deploy script to not cascade to auth container (#5426)
Add --no-deps to nginx and admin docker compose commands to prevent
them from cascading to auth. The auth container has secrets (like
TWITCH_CLIENT_ID) that are only available in auth_build.yml, not in
docker_build.yml. Without --no-deps, docker compose would recreate
auth with blank env vars when it detected config changes.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 21:44:38 -08:00
3fffdfa506 Convert DivineMessagePromptGenerator to Scala types (#5419)
Update DivineMessagePromptGenerator to accept LlmRequestT.DivineMessage
instead of the proto DivineMessage type. Add Scala type matching in
LlmResolver.promptGenerator before falling back to proto matching.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 21:42:32 -08:00
6fa324f5ef Add sworn kinship guidance tutorial (#5411)
* Add sworn brotherhood guidance tutorial

- Add guidance_sworn_brotherhood tutorial that triggers when:
  - Player has 4+ provinces, OR
  - SwearBrotherhood command is available with good candidates (server-determined)
- Explains that sworn siblings become faction leaders for direct province control
- Mentions succession benefit if Warlord falls

Also update guidance_expand to note that vassals manage provinces without
a Warlord or sworn sibling present.

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

* Improve sworn brotherhood tutorial with candidate detection

- Use SwornBrotherChooser logic to identify good candidates:
  charisma >= 65, constitution >= 65, loyalty >= 95, ranked by power
- Dynamically update tutorial text to name the specific candidate
- Add 95 loyalty requirement and permanence info to description
- Keep 4+ provinces as alternative trigger condition

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

* Fix hero stat access - use int directly, not StatView

Charisma, Constitution, Strength, Agility, Wisdom are int properties,
only Loyalty uses StatView?.Stat pattern.

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

* Fix TextEntry access - use .Text property for string value

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

* Use gender-neutral language: Brotherhood → Kinship

- Command header: "Swear Kinship with Hero"
- Tutorial text uses "sworn siblings" and "swear kinship"
- Internal IDs updated: guidance_sworn_brotherhood → guidance_sworn_kinship

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 14:24:42 -08:00
455e8c0367 Add detailed Sparkle logging for debugging (#5418)
Native plugin (SparklePlugin.m):
- Log app version and build number from Info.plist
- Log feed URL configuration
- Log whether public key is set
- Log auto-check plist setting
- Log current auto-check state after initialization
- Log last update check date

Unity initializer (SparkleInitializer.cs):
- Log Application.version and Unity version
- Log platform and product name
- Log AutomaticallyChecksForUpdates setting

This helps debug why Sparkle updates may not be working.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 14:18:45 -08:00
db7f19d7ae Update LlmResolver to use Scala types instead of proto (#5417)
This is the next step in the incremental migration from proto to Scala types
in the LLM processing pipeline.

Changes:
- LlmRequestWithGameState now takes GeneratedTextRequestT instead of
  GeneratedTextRequest (proto)
- LlmResolver.resolveLlmRequests returns Scala types in the result
- Proto conversion happens at boundaries:
  - Converting to proto for LLMResponse when streaming
  - Converting to proto for receiveStreamingLlmFailure
  - Converting to proto in promptGenerator for individual prompt generators
- UnrequestedTextHandler no longer needs to convert to proto when calling
  LlmResolver

The prompt generators still expect proto types - they will be updated
incrementally in follow-up changes.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 14:17:47 -08:00
f5d89e67e4 Revert nginx deployment from auth workflow (#5416)
The nginx config is already deployed by docker_build.yml (which has
nginx/** in its trigger paths). Adding it to auth_build.yml was
redundant and broken (the production server isn't a git repo).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 14:11:26 -08:00
ae2bf05e7d Handle InvitationRequired OAuth status gracefully in Unity client (#5414)
When a user tries to sign in with an OAuth provider but doesn't have an
existing account, the auth service returns InvitationRequired status.
Previously this fell through to the default case and threw a generic
"Unknown OAuth status" exception, providing a poor user experience.

Now displays a friendly message explaining that an invitation is required
to create a new account, and suggests using an invitation link or signing
in with the original provider if they already have an account.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 14:02:19 -08:00
3e1919a715 Update auth workflow to deploy nginx config changes (#5412)
- Add nginx/nginx.conf and docker-compose.prod.yml to trigger paths
- Pull latest nginx config and restart nginx during deployment

This ensures nginx config changes (like OAuth callback routes) are
automatically deployed when merged to main.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:56:25 -08:00
699bcc684a Store Scala types in ClientTextStore instead of proto (#5407)
* Store Scala types in ClientTextStore instead of proto

This change updates the ClientTextStore layer to store the native Scala
type `GeneratedTextRequestT` in memory instead of the proto type
`GeneratedTextRequest`. Proto conversion now only happens at persistence
boundaries (when saving to/loading from disk).

Key changes:
- ClientText.scala: Changed llmRequest field to use GeneratedTextRequestT
- ClientTextStore.scala: Updated interface to accept Scala types
- ClientTextStoreImpl.scala: Added toProto/fromProto conversion at persistence
- GameController.scala: Removed proto conversion in allNewLlmRequests
- UnrequestedTextHandler.scala: Uses Scala types internally, converts to proto
  only when passing to LlmResolver
- GamesManager.scala: Uses Scala types directly when creating UnrequestedClientText
- Updated BUILD files for new dependencies and visibility

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

* Fix tests for ClientTextStore Scala types migration

- Update UnrequestedTextHandlerTest to use Scala types instead of proto
- Add GeneratedTextRequestConverter import for proto conversions in test mocks
- Change test requests from FixedHeroName to LlmRequestT.DivineMessage to properly test LLM resolution path
- Add visibility for test packages in BUILD files
- Remove unused proto dependency from generator_utilities_test

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:54:50 -08:00
42fd9c5853 Fix flush marker wait: skip for old instance being replaced (#5413)
During blue-green deployment, the old (active) instance should not wait
for the flush marker - it would be waiting for itself to stop, which
causes a 30s timeout warning.

Now compares deployment timestamp to instance start time:
- If deployment started AFTER instance: skip wait (we're the old instance)
- If deployment started BEFORE instance: wait (we're the new instance)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:53:59 -08:00
01aa3dbace Add /oauth/steam/callback to nginx config (#5410)
Steam OpenID 2.0 redirects back to /oauth/steam/callback after
authentication, but nginx was not configured to proxy this path to the
auth service, resulting in 404 errors.

Added the location block alongside the existing /oauth/callback and
/oauth/apple/callback blocks.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:49:34 -08:00
a7595e81a2 Add all OAuth providers to admin server login page (#5409)
- Add GitHub, Apple, Steam, and Twitch to handleLoginStart switch
- Add sign-in buttons with icons for all providers to login.html
- Add CSS styles for new provider buttons

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:40:33 -08:00
6a61634f37 Add Steam and Twitch sign-in buttons to Unity client (#5404)
* Add Steam and Twitch sign-in buttons to Unity client

- Add steamLoginButton and twitchLoginButton fields
- Add steamProviderIcon and twitchProviderIcon for stored accounts
- Wire up button click handlers in SetupOAuthUI()
- Add icon mapping for stored account display

Note: Unity scene needs Steam/Twitch button GameObjects and icons wired up.

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

* Add Steam and Twitch OAuth button assets

- Add Steam and Twitch (glitch) logo icons for OAuth buttons
- Consolidate Discord icon to OAuth Buttons folder
- Remove unused Google OAuth button variants
- Update Gameplay.unity with new button references

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:34:11 -08:00
cfa552270a Add Twitch secrets to auth service deployment workflow (#5408)
The TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET were added to
docker-compose.prod.yml but not to the GitHub Actions workflow
that deploys the auth service.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:32:03 -08:00
09d560208e Add game state guidance tutorials (#5405)
* Add game state guidance tutorials

Add four contextual tutorials that trigger based on game state analysis:

1. guidance_loyalty_danger - November warning when heroes have low loyalty
   Suggests using Feast or Give Gift to boost loyalty before year end

2. guidance_neighbor_danger - Alert when province borders hostile faction
   Suggests Organizing Troops or seeking Diplomacy

3. guidance_recruit_heroes - When stable province has recruitable heroes
   Suggests using Travel, Divine, and Recruit commands

4. guidance_expand - When player has stable province but only one territory
   Suggests using March to claim new provinces

These provide strategic guidance for new players based on their game state.

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

* Fix compilation errors in guidance trigger checks

- MarchAvailableCommand.OneProvinceCommands to access destination provinces
- FactionRelationshipView.TargetFactionId instead of FactionId
- province.FullInfo.Gold instead of model.CurrentGold
- Net.Eagle0.Eagle.Common.RecruitmentStatus instead of Views namespace

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

* Use Any() instead of ToList().Count for efficiency

Any() short-circuits on first match rather than materializing entire list.

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

* Simplify recruit heroes gold check

Just check for 100 gold - January was when this would naturally be true,
not an alternative condition.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 13:18:17 -08:00
2188834ddf Fix auth deploy: remove SHARDOK_ADDRESS validation from docker-compose (#5406)
docker-compose validates the entire file even when deploying just auth.
Remove the :? validation since docker_build.yml already validates
SHARDOK_ADDRESS explicitly before deploying Eagle.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 12:45:18 -08:00
eafe5d73ee Add comment about dynamically registered hero row targets (#5402)
Document that WarlordRow, VassalRow1, VassalRow2 are registered dynamically
by HeroesAndBattalionsPanelController when hero rows are created from prefabs.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 12:27:54 -08:00
f16257591b Add Steam and Twitch OAuth support (#5403)
- Steam: OpenID 2.0 authentication (no API key required)
- Twitch: Standard OAuth 2.0 with user:read:email scope
- Add TWITCH_CLIENT_ID and TWITCH_CLIENT_SECRET to deployment config
- Add Steam/Twitch buttons to invitation landing page

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 12:26:53 -08:00
d259282213 Add fromProto converters for GeneratedTextRequest types (#5401)
Add fromProto methods to LlmRequestConverter and GeneratedTextRequestConverter
to enable converting proto types back to Scala types. This is preparation for
storing Scala types in-memory in ClientTextStore, with proto conversion only
happening at persistence boundaries.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 11:22:27 -08:00
de5e6cc65e Fix duplicate stored account buttons and add delete button (#5400)
- Clear all children of storedAccountsContainer and disable them
  immediately before destroying (fixes visual duplication from
  deferred Destroy)
- Add delete button to StoredAccountButton to remove saved accounts
- Wire up delete button to OAuthManager.RemoveStoredAccount

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 11:21:30 -08:00
7da2fb7913 Fix Divine tutorial text to describe actual functionality (#5399)
Divine reveals what you must do to impress a hero and recruit them,
not their stats/abilities.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 10:00:53 -08:00
a485772e66 Consolidate secrets handling: Eagle uses public key only for JWT validation (#5398)
Security improvement: Eagle no longer has access to JWT private keys.

- JwtService.scala: Simplified to validation-only (removed signing methods)
- Loads public key from /etc/eagle0/keys/public.pem (shared volume from auth)
- Falls back to extracting public key from JWT_PRIVATE_KEY for backward compat
- AuthServiceImpl.scala: Local fallbacks throw UNIMPLEMENTED (signing in Go auth)
- docker-compose.prod.yml: Removed JWT_PRIVATE_KEY from Eagle, volume now read-only

Auth service unchanged - still bootstraps PEM files from JWT_PRIVATE_KEY env var.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:44:03 -08:00
e0212fa7a2 Fix unit placement deselection during setup phase (#5397)
Clicking a selected unit during setup would trigger a placement action
instead of deselecting the unit, because HasPlacementAction returned
true for the unit's own tile. Now the placement check excludes the
currently selected tile, allowing the deselection logic to run.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:32:23 -08:00
6e76ff0fbc Use commit-count based versioning for Mac builds (#5392)
Change version scheme from git describe (which picked up unrelated tags
like busybox-1.35.0) to commit-count based versions like 1.0.9548.

This gives automatic, always-incrementing version numbers that Sparkle
can properly compare for updates.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:17:59 -08:00
a37e0af028 Make DiplomacyResolutionLlmRequestGenerator protoless (#5396) (#5396)
* Update DEPROTO_PLAN.md with recent completions

- Mark Phase 9 (Utilities) as complete - MapGenerator now protoless
- Mark Phase 10 (History APIs) as complete - PersistedHistory accepts Scala types
- Add PRs #5373, #5378, #5381, #5390 to recent completions
- Update proto import inventory with current counts
- Update success criteria checklist

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

* Make DiplomacyResolutionLlmRequestGenerator protoless

Replace proto types with Scala equivalents:
- DiplomacyOfferStatus -> Status
- GeneratedTextRequest -> LlmRequestT
- Proto message types -> LlmRequestT enum cases

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:05:37 -08:00
d6704413d3 Fix Sparkle codesigning for XPC services (#5393)
- Use ditto instead of cp -R to copy Sparkle.framework (preserves bundle structure)
- Skip individual signing of Sparkle's internal XPC services, apps, and executables
- Use --deep flag when signing Sparkle.framework to handle its internal components
- Verify cached Sparkle.framework has proper symlink structure, re-download if corrupted

Fixes "bundle format unrecognized" error during codesigning.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:04:39 -08:00
be59f9775f Add deployment validation for env vars and Shardok connectivity (#5394)
After investigating a bad deployment where Eagle connected to "shardok"
instead of the actual Hetzner address, found the root cause: GitHub
Actions secrets can occasionally fail to load, causing the heredoc to
expand with default values instead of the actual secrets.

Changes:
- Add validation at deploy start to check critical env vars are present
- Add network connectivity check to Shardok before blue-green deployment
  (blocks deployment if Shardok is unreachable)
- Remove the misleading "shardok:40042" default - now SHARDOK_ADDRESS
  must be explicitly set, making configuration errors immediately obvious
- docker-compose.prod.yml now uses :? syntax to require SHARDOK_ADDRESS

The env var validation will abort deployment early with a clear error
message if secrets failed to load, rather than deploying with broken
config.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 09:00:13 -08:00
bebe050352 Add Scala ActionResultT overload to PersistedHistory.apply (#5390)
Add a new apply overload that takes Vector[ActionResultT] directly,
avoiding proto conversion at the call site. The conversion to proto
for storage still happens internally, but callers like NewGameCreation
can now pass Scala types directly.

Removed the unused proto-based apply overload since all callers that
need proto types use the case class constructor directly.

This makes NewGameCreation fully protoless.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 08:36:52 -08:00
77d6fc571b Fix logout button to return to connection panel instead of quitting (#5391)
The logout button was wired to QuitButtonClicked in the Unity scene,
causing the app to exit instead of returning to the connection panel.

- Change scene to wire logout button to OnLogoutClicked
- Make OnLogoutClicked public so it's visible in Unity inspector
- Remove redundant programmatic listener setup

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 08:32:46 -08:00
adminandGitHub 65e0ad93cc center the text in the rows in the lobby (#5389) 2026-01-16 17:05:35 -08:00
adminandGitHub 275cb0c563 Enable the Sign In With Apple button and fix the button layouts (#5388)
* add sign in with apple button (disabled)

* and the github icon

* add the apple button

* better stored account button support
2026-01-16 15:44:17 -08:00
d64f35b18a Add loading indicator when switching environments in lobby (#5365)
* Add loading indicator when switching environments in lobby

Shows "Switching environment..." text while waiting for the server
to respond after changing the environment dropdown. Text is cleared
when the lobby data arrives and populates the game lists.

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

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

* add lobby connection status and fix 16:10 aspect ratio

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 15:04:07 -08:00
adminandGitHub ac0c03af9b bad cast (#5387) 2026-01-16 08:40:28 -08:00
adminandGitHub 6b0d58a9e7 map and continue (#5386) 2026-01-16 08:25:01 -08:00
adminandGitHub fb56cf0029 wrong match (#5385) 2026-01-16 07:49:07 -08:00
adminandGitHub 70a5a517af more debugging (#5384) 2026-01-16 07:38:06 -08:00
adminandGitHub 6de07a70dc full stack trace in lockedSendLobbyUpdate (#5383) 2026-01-16 07:30:02 -08:00
8e64af7233 Make NewGameCreation protoless (#5381)
* Make NewGameCreation protoless

Convert NewGameCreation to build Scala ActionResultC internally instead of
protobuf ActionResult. Proto conversion now only happens at the boundary
when storing to PersistedHistory.

Changes:
- NewGameCreation.scala: Use ActionResultC, Scala RoundPhase, ChronicleEntry,
  FactionC, FactionRelationship instead of proto equivalents
- StartGameActionResultUtils.scala: Change from proto ActionResult to
  ActionResultC throughout
- GameParametersUtils.scala: Add provinceWithOverrideScala method
- Add withId methods to HeroT/HeroC and BattalionT/BattalionC traits
- Update BUILD.bazel files with visibility for new_game_creation package
- Update test to use Scala types

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

* empty check

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 07:20:41 -08:00
ca91d3193e Fix missing newProvinces in ActionResultProtoConverter.toProto (#5382)
The toProto method was not serializing newProvinces, causing provinces
to be lost when persisting game state. This led to stack overflow during
game creation as the phase advancer would loop infinitely with no provinces.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 07:18:52 -08:00
1de12597d7 Export proto types from converters for caller visibility (#5380)
* Export proto types from converters for caller visibility

ActionResultProtoConverter and GameStateConverter return proto types,
so those proto dependencies need to be exported for callers to use
the return values.

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

* Fix missing proto exports and ServerSetupHelpers warnings

- Export shardok_battle_scala_proto from ShardokBattleConverter
- Export game_state_scala_proto and shardok_battle_scala_proto from GamesManager
- Remove unused ManagedChannelBuilder import from ServerSetupHelpers
- Remove unused default parameter from private newShardokInterface method

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 14:17:08 -08:00
36c09cae77 Fix unused parameter warnings in CustomBattleManager (#5379)
- Add @unused annotation to parameters that are intentionally unused
  (userName and name parameters that need future verification)
- Add missing game_state_scala_proto dependency to BUILD.bazel
- Add CustomBattleManagerTest with tests for CustomBattleGameController

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 13:54:53 -08:00
83d159823a Remove proto ActionResult from Engine layer (#5378)
- Change EngineAndResults.results to return Vector[ActionResultT] (Scala) instead of Vector[ActionResult] (proto)
- Update PostResults to use Scala ActionResultT and ShardokBattle types
- Update GameController methods to use Scala types internally
- Add GeneratedTextRequestConverter for Scala-to-proto conversion when needed for LLM requests
- Add recipientFactionIds field to ClientTextVisibilityExtensionT trait

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 11:32:51 -08:00
5ce88f04d2 Remove Apple Sign-In debug logging (#5377)
Remove verbose debug logs that were added during Apple Sign-In debugging:
- Token exchange client_id/redirect_uri
- id_token length
- Parsed user info (id/email)

Error logging is retained for troubleshooting.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 11:15:52 -08:00
1bfcb343d9 Fix Apple id_token claims to handle both string and bool types (#5376)
Apple inconsistently returns email_verified and is_private_email as
either boolean (true/false) or string ("true"/"false"). Added custom
AppleBool type that unmarshals both formats.

Also added missing claims from Apple's documentation:
- nonce, nonce_supported, c_hash, auth_time

See: https://developer.apple.com/forums/thread/121411

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:36:46 -08:00
88b628b73f Add logging for Apple id_token parsing (#5375)
Log the id_token length and any parse errors to help debug
"Failed to parse user info" errors.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:29:51 -08:00
9004e6887a Fix Apple private key parsing for base64-encoded PEM (#5374)
The key is stored as base64-encoded PEM. The previous code base64
decoded it but then tried to use the result directly as key bytes.
The fix: after base64 decoding, PEM decode to extract the actual
key bytes.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:23:39 -08:00
c62b3d3166 Make MapGenerator return Scala types (#5373)
MapGenerator now returns Scala ProvinceT instead of proto Province.
GameParametersUtils works with Scala types internally, converting to
proto only in NewGameCreation when needed for ActionResult.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:08:35 -08:00
6d54312aec Fix auth deployment using wrong image tag (#5372)
docker-compose ignores AUTH_IMAGE env var for unknown reasons.
Fix with multiple approaches:

1. Tag pulled image as :latest locally (belt)
2. Pass AUTH_IMAGE explicitly on command line (suspenders)
3. Add debug output to diagnose .env file issues
4. Verify by comparing image digests, not tags

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:00:54 -08:00
c886ddc769 Make BattalionTypeLoader return Scala types (#5371)
BattalionTypeLoader now returns Scala BattalionType instead of proto.
GamesManager and NewGameCreation updated to use Scala types internally,
with conversion to proto only when needed for GameState updates.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 09:57:28 -08:00
f6a7545533 Fully separate auth and eagle deployments (#5370)
- Exclude auth paths from docker_build.yml triggers so eagle builds
  don't trigger when only auth code changes
- Remove `docker compose up -d auth` from docker_build.yml - auth is
  deployed exclusively by auth_build.yml now
- Delete unused deploy/update-env.sh and deploy/env.template

This prevents docker_build.yml from reverting auth to stale images
when it runs after auth_build.yml.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 09:28:46 -08:00
8d3bc914ed Make GameHistory APIs return Scala types (#5367)
* Make GameHistory APIs return Scala types

The GameHistory trait now returns ActionResultWithResultingState (pure
Scala) instead of ActionWithResultingState (proto-based). Internal
storage in InMemoryHistory and PersistedHistory still uses proto types
for file persistence, but the public API is now protoless.

Changes:
- GameHistory.all, since, sinceDate, last, recentResultsForRound now
  return Vector[ActionResultWithResultingState]
- withNewResults accepts Scala types and converts to proto for storage
- Added toScala() helper in history implementations
- Updated callers: EngineImpl, EagleServiceImpl, ChronicleEventGenerator,
  ActionResultFilter, GamesManager
- SavedGameUtils uses internal ActionWithProtoState for proto reflection
- Fixed Option[Date] comparisons with .forall() pattern

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

* Delete unused SavedGameUtils

This file was a standalone debugging utility that was never integrated
into the build (BUILD target was commented out). Removing it as cleanup.

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 08:45:09 -08:00
844d7d8e50 Fix auth service deployment not updating container (#5368)
The crane+docker-load approach was causing image tagging issues where the
loaded image didn't have the expected registry tag, causing docker-compose
to not find the correct image.

Changes:
- Use simple `docker pull` instead of crane pull + docker load (since
  deploy server is already logged into the registry)
- Add verification that the running container is using the expected image
- Fail the workflow if container is running the wrong image

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 08:28:47 -08:00
cae2d47af0 Add Apple OAuth debugging and GitHub email fetch (#5364)
- Add comprehensive logging to Apple callback handler
- Fetch GitHub email from /user/emails when not in main response

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 07:34:46 -08:00
cc5c6422bd Improve OAuth flow by minimizing game before opening browser (#5363)
When user clicks an OAuth login button:
- Game window minimizes (Windows) or exits fullscreen (macOS/Linux)
- Browser opens and is immediately visible to user
- After auth completes (success or failure), game returns to foreground
- Fullscreen mode is restored if it was enabled

Adds WindowFocusManager utility with platform-specific native calls:
- Windows: P/Invoke to user32.dll (ShowWindow, SetForegroundWindow)
- macOS: Placeholder for native plugin, falls back to fullscreen toggle

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 07:22:23 -08:00
c71bf10c0e Add scalaActionResult to ActionWithResultingState and use Scala types in ActionResultFilter (#5362)
- Add precomputedScalaActionResult parameter and lazy scalaActionResult property to
  ActionWithResultingState, following the same pattern as scalaGameState
- Update GameHistory.withNewResultsScala to preserve Scala ActionResultT to avoid
  re-conversion
- Convert ActionResultFilter.includeForPlayer to use Scala types (ActionResultT,
  NotificationT, ActionResultType) instead of proto types
- Add UNIVERSALLY_VISIBLE_TYPES_SCALA constant with Scala ActionResultType values

This continues Phase 10 of the deproto migration, moving internal logic to use
Scala types while keeping proto for boundaries.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 07:18:52 -08:00
fd94d2ac8b Fetch GitHub email from /user/emails endpoint (#5358)
* Use single quotes in .env to handle JSON and special chars

Double quotes don't work when values contain embedded quotes
(like JWT_PRIVATE_KEY JSON). Single quotes treat content literally.

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

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

* Use exported env vars instead of .env file for deployment

The .env file approach was fragile for complex values like JSON
(JWT_PRIVATE_KEY) and base64 (APPLE_SIGNIN_PRIVATE_KEY).

Export environment variables directly in the deploy script so
docker compose reads them from the shell environment.

Also adds Apple Sign-In credentials to auth_build.yml workflow.

Note: Delete /opt/eagle0/.env on the server before deploying.

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

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

* Fetch GitHub email from /user/emails endpoint

GitHub only returns email in the /user endpoint if the user has made
their email public. For private emails, we need to call /user/emails.

This ensures we get the user's primary verified email even when they
have their email set to private on GitHub.

🤖 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-14 21:40:10 -08:00
46f3c52174 Make GameStateViewFilter and GameStateViewDiffer protoless (#5360)
This PR completes Phase 7 of the deproto migration by making the view
filter and differ components work with Scala types internally, converting
to proto only at boundaries.

## New Scala Types

- `GameStateView` - full game state view
- `GameStateViewDiff` - diff between two game state views
- `ProvinceViewDiff` - diff for province views
- `HeroViewDiff` - diff for hero views
- `FactionViewDiff` - diff for faction views
- `FullProvinceInfoDiff` - diff for full province info
- `ShardokBattleView` - battle view type
- `Hostility` - enum for hostility levels

## New Converters

- `GameStateViewConverter` - converts Scala GameStateView to proto
- `GameStateViewDiffConverter` - converts Scala GameStateViewDiff to proto
- `ProvinceViewDiffConverter` - converts Scala ProvinceViewDiff to proto
- `HeroViewDiffConverter` - converts Scala HeroViewDiff to proto
- `FactionViewDiffConverter` - converts Scala FactionViewDiff to proto
- `ShardokBattleViewConverter` - converts Scala ShardokBattleView to proto
- `HostilityConverter` - converts Scala Hostility to proto

## Updated Components

- `GameStateViewFilter` - now returns Scala `GameStateView`
- `GameStateViewDiffer` - now uses Scala diff types internally
- `ActionResultFilter` - converts Scala diff to proto at boundary
- `HumanPlayerClientConnectionState` - converts Scala GameStateView to proto

## Test Updates

- Updated tests to use Scala `ProvinceOrderType` instead of proto

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:39:37 -08:00
8e952c3611 Add validation for empty hexMapName when creating battles (#5359)
Fail fast with a clear error message if a province has an empty
hexMapName when a battle is being created. Previously this would
fail downstream in Shardok with a generic "Must include map path"
error, making it harder to diagnose.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 21:23:12 -08:00
a61be38ea6 Use exported env vars instead of .env file for deployment (#5357)
* Use single quotes in .env to handle JSON and special chars

Double quotes don't work when values contain embedded quotes
(like JWT_PRIVATE_KEY JSON). Single quotes treat content literally.

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

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

* Use exported env vars instead of .env file for deployment

The .env file approach was fragile for complex values like JSON
(JWT_PRIVATE_KEY) and base64 (APPLE_SIGNIN_PRIVATE_KEY).

Export environment variables directly in the deploy script so
docker compose reads them from the shell environment.

Also adds Apple Sign-In credentials to auth_build.yml workflow.

Note: Delete /opt/eagle0/.env on the server before deploying.

🤖 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-14 20:49:35 -08:00
4120196f11 Include state parameter in OAuth callback redirect (#5356)
* Include state parameter in OAuth callback redirect

When redirecting from /oauth/callback to /invite/{code}/callback,
include the state parameter so the invitation handler can look up
the OAuth result.

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

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

* Quote values in .env file to handle special characters

Base64-encoded values contain / characters that break unquoted
.env parsing. Wrap all values in double quotes.

🤖 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-14 20:28:48 -08:00
191ee501f4 Add Apple Sign-In secrets to deploy workflow (#5355)
Pass APPLE_SIGNIN_CLIENT_ID, APPLE_TEAM_ID, APPLE_SIGNIN_KEY_ID,
and APPLE_SIGNIN_PRIVATE_KEY to the auth container during deployment.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:19:27 -08:00
9a26173ceb Fix connection background layer bleeding into lobby screen (#5354)
Add connectionBackgroundLayer field to ConnectionHandler that can be
linked in the Unity Editor. The layer is hidden when entering the lobby
and shown when returning to the connection screen.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 20:11:49 -08:00
7cf2072dad Simplify client invitation flow and fix OAuth issues (#5353)
* Remove invitation code handling from Unity client

Account creation now happens on the web landing page, so the client
no longer needs to handle invitation codes.

Removed:
- InvitationCodeManager.cs (entire file)
- Invitation code parameter from AuthClient.GetOAuthUrlAsync()
- OAuthStatus.InvitationRequired handling in AuthClient
- OnInvitationRequired event and handlers in OAuthManager
- Invitation code panel UI fields in ConnectionHandler
- OnInvitationRequired and OnSubmitInvitationCodeClicked methods

The display name panel is retained for edge cases where a user
somehow doesn't have a display name set.

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

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

* Fix OAuth issues on landing page

- Fix Apple OAuth redirect_uri mismatch in token exchange
  The token exchange was using /oauth/callback but the auth request
  uses /oauth/apple/callback, causing redirect_uri mismatch error

- Add nginx route for /oauth/apple/callback
  Apple OAuth uses form_post response mode which posts to a separate
  callback path that wasn't proxied through nginx

- Add credential validation in GetAuthURL
  Only show OAuth buttons if provider credentials are configured,
  preventing broken auth URLs when client ID/secret are missing

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

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

* Pass GitHub and Apple OAuth credentials to auth container

The GH_OAUTH_CLIENT_ID/SECRET and Apple Sign-In credentials were
set in GitHub secrets but not passed to the auth service container
in docker-compose.prod.yml, causing the OAuth providers to appear
unconfigured.

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

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

* Update OAuth tests to expect error on empty credentials

The credential validation now returns an error for empty client ID
or client secret, so update the test to expect this behavior.

🤖 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-14 20:10:22 -08:00
c6c7430dce Delete unused RansomOfferHelpers and its tests (#5351)
RansomOfferHelpers had no production callers - it was only tested.
The ransom offer logic in CommandChoiceHelpers already uses
Scala DiplomacyOptionType.Ransom and Scala RansomOfferDetails.

This removes the last 3 proto imports from command_choice_helpers/.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:46:20 -08:00
c4fc30d0bd Fix registry cleanup script to use JSON output for reliable parsing (#5352)
The doctl --format output was being incorrectly parsed, causing the script
to read size values (18.67 MB) as dates. This resulted in all images being
deleted, including those with the 'latest' tag.

Switch to --output json with jq parsing for reliable field extraction.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:45:33 -08:00
b6dd273c12 Add GitHub OAuth button support in Unity client (#5343)
* Add GitHub OAuth button support in Unity client

- Add githubLoginButton and githubProviderIcon properties
- Add click handler for GitHub OAuth login
- Update stored account icon logic to show GitHub icon

Unity editor changes (button assignment, icon sprite) to follow.

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

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

* Add GitHub OAuth button and update login UI layout

- Add GitHub login button with github-mark.png icon
- Add additional Google button image variants
- Update Gameplay.unity scene with OAuth button layout
- Remove orphaned SparklePlugin.bundle.meta

🤖 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-14 19:35:53 -08:00
56df06d21b Web-based invitation flow with OAuth on landing page (#5349)
Move account creation from Unity client to the web landing page.
Users now complete OAuth sign-in and display name selection in the
browser before downloading the client.

New flow:
1. User visits /invite/{code} → sees OAuth buttons
2. User clicks provider → OAuth flow
3. Existing user: redirect to download page (code not consumed)
4. New user: show display name form → create account → redeem code
5. Download page with platform-specific installer links

Changes:
- Landing page shows OAuth buttons instead of download buttons
- New routes: /invite/{code}/auth/{provider}, /invite/{code}/callback,
  /invite/{code}/set-name, /invite/{code}/download
- HMAC-signed cookies for session management between OAuth and form
- 4 new HTML templates: landing, display name form, download, error
- Legacy .bat/.command handlers retained for backwards compatibility

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:34:45 -08:00
a7a64fea11 Add GH_OAUTH secrets to deployment workflows (#5350)
Pass GH_OAUTH_CLIENT_ID and GH_OAUTH_CLIENT_SECRET through both
auth_build.yml and docker_build.yml to enable GitHub OAuth login.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:32:08 -08:00
b48a44d910 Delete unused stats() method from IncomingArmyUtils (#5348)
The stats() method that returns proto ArmyStats has no callers.
Removing it eliminates both proto imports from IncomingArmyUtils.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:20:25 -08:00
4bec466f54 Add GitHub and Apple to OAuth provider mapping (#5346)
The providerToString and stringToProvider functions were missing
cases for GITHUB and APPLE providers, causing them to return
"unknown" which resulted in "unsupported provider" errors.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:14:34 -08:00
84be65cfc3 Delete unused proto overloads from StatWithConditionUtils and ProvinceEventUtils (#5345)
- StatWithConditionUtils: Remove supportSc() and scWithRanges() proto versions
  (only Scala versions supportScala() and scWithRangesScala() were being used)
- ProvinceEventUtils: Remove all proto overloads (only Scala overloads used)
- Remove proto dependencies from BUILD.bazel files

Both files are now completely protoless.
Other Utilities: 13 → 9 proto imports (5 → 3 files)
Total: ~84 → ~80 proto imports remaining

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:09:09 -08:00
3834471840 Make ProvinceView use Scala ProvinceEvent instead of proto (#5341)
- Update ProvinceView.knownEvents from proto ProvinceEvent to Scala type
- Update ProvinceViewConverter to convert events with ProvinceEventConverter
- Update ProvinceViewFilter to work with Scala events internally:
  - Remove proto import
  - Use Scala overloads of ProvinceEventUtils checkers
  - Simplify event filtering logic
- Add visibility for event target to proto_converters/view and model/view

ProvinceViewFilter is now completely protoless - zero proto imports.
View filters directory reduced from 4 proto imports to 3 (in 2 files).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:39:52 -08:00
fdeb2ccf20 Add isEagleGame() for routing decisions, fix remaining lazy-load bugs (#5344)
- Add GamesManager.isEagleGame(gameId) wrapper for routing decisions
  with clear documentation warning against using gameControllerInfos directly
- Fix postCommand routing for ShardokCommand and PlacementCommands
  to use isEagleGame() instead of checking empty gameControllerInfos map
- Update streamOneUpdate to use isEagleGame() for consistency

These are the remaining places that had the same bug pattern as the
streamOneUpdate fix (PR #5342): after deployment the map is empty,
causing Eagle games to be incorrectly routed to customBattleManager.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:34:01 -08:00
f10a749ffe Fix StreamGameRequest to lazy-load games before routing (#5342)
The streamOneUpdate function was checking if the game was already in
gameControllerInfos to decide between gamesManager and customBattleManager.
After deployment (empty map), Eagle games would incorrectly route to
customBattleManager, which doesn't load the game. Then subsequent commands
would fail with "key not found".

Fix: Call ensureGameLoaded first to try loading the game from storage.
If it loads successfully, use gamesManager. If not (game doesn't exist),
fall back to customBattleManager.

Also made ensureGameLoaded public so EagleServiceImpl can call it.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:22:52 -08:00
f636b2b0df Server: ensure game is loaded before processing commands (#5339)
Add ensureGameLoaded() calls to:
- postCommand, postShardokCommand, postPlacementCommands
- joinGame case None (for started games)

This handles the case where a command arrives before the game subscription
has loaded the game into memory. The check is cheap (map contains) if the
game is already loaded.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:07:12 -08:00
cb0111a00f Make HeroViewFilter return Scala types instead of proto (#5336)
* Make HeroViewFilter return Scala types instead of proto

- Create HeroView.scala - Scala case class for the hero view type
- Create HeroViewConverter - converts Scala HeroView to proto
- Update HeroViewFilter to return Scala HeroView
- Update GameStateViewFilter to convert to proto at the edge
- Update AvailableCommandConverter to convert HeroView at the edge
- Update tests to use Scala types

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

* Consolidate DEPROTO_PLAN.md files and update with current stats

- Move detailed proto import inventory from root DEPROTO_PLAN.md to docs/
- Delete root level DEPROTO_PLAN.md (duplicate)
- Update all proto import counts based on current codebase state:
  - Total: ~85 imports remaining (down from 149)
  - Command choice helpers: 3 imports in 1 file (was 57 in 17)
  - View filters: 4 imports in 3 files
  - LLM generators: 56 imports in 34 files
- Add HeroViewFilter to recent completions (PR #5336)
- Update Phase 7 table with current view filter statuses

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 16:10:54 -08:00
508bfabece Add tutorials for all command panels (#5335)
* Add tutorials for all remaining command panels

Add 21 new command tutorials:
- Travel, Return, Diplomacy, Send Supplies, Recon
- Divine, Issue Orders, Control Weather, Swear Brotherhood
- Apprehend Outlaw, Suppress Beasts, Exile Vassal
- Handle Captured Hero, Manage Prisoners, Decline Quest
- Start Epidemic (plague), Handle Riot (3 variants)
- Attack Decision, Free-For-All Decision, Resolve Tribute

All command panels now have tutorials that appear after onboarding.

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

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

* Fix tutorial text accuracy and remove duplicates

- Rest: Only restores hero vigor, not troops
- Feast: Adds vigor alongside loyalty, cost based on hero count
- Travel: Goes to town within province, enables various activities
- Organize Troops: Emphasize hiring battalions, mention requirements
- Remove End Turn step (no End Turn button in Eagle)
- Remove duplicate Diplomacy tutorial (command panel version remains)

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

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

* Fix Trade, Travel, and Return tutorial descriptions

- Trade: Exchange food/gold within province, market takes cut
- Travel: Mention multiple actions per turn, reference Return
- Return: Opposite of Travel, returns to camp and ends turn

🤖 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-14 15:44:44 -08:00
de532a3448 Make FactionViewFilter return Scala types instead of proto (#5334)
* Make FactionViewFilter return Scala types instead of proto

- Create FactionView.scala - Scala case class for the view type
- Create FactionViewConverter - converts Scala FactionView to proto
- Create FactionRelationshipViewConverter - converts FactionRelationship to proto view
- Update FactionViewFilter to return Scala FactionView
- Update GameStateViewFilter to convert to proto at the edge
- Update tests to use Scala types

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

* Update DEPROTO_PLAN.md with recent progress

- Actions layer now at 0 proto imports (PR #5332)
- ProvinceUtils converted to Scala BattalionType (PR #5333)
- FactionViewFilter returns Scala types (PR #5334)
- Updated summary table and success criteria
- Reorganized remaining work into clear phases

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:25:16 -08:00
29c3d5f2c1 Add contextual tutorials for command panels (#5329)
* Add contextual tutorials for command panels

When a command panel is shown for the first time (after onboarding
completes), display a tutorial explaining what the command does
and its options.

- Hook CommandSelector.Show() to trigger tutorial events
- Add OnCommandPanelShown() to TutorialTriggerRegistry
- Create tutorials for: Improve, Alms, March, Defend, Rest, Trade,
  Feast, Hero Gift, Train, Arm Troops, Organize Troops, Recruit Heroes
- Improve tutorial includes tip about selecting heroes via dropdown
  or by clicking in the Resident Heroes panel

All command tutorials require onboarding completion before showing.

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

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

* Fix command panel tutorial trigger timing and step count display

Two fixes:

1. Move tutorial trigger from Show() to UpdateAvailableCommand()
   - Show() only fires when selector changes, not when same command is re-selected
   - During onboarding, users explore commands but prerequisites aren't met yet
   - After onboarding, re-selecting same command wouldn't trigger Show()
   - UpdateAvailableCommand() fires every time a command is selected

2. Use visible step count instead of total step count
   - Onboarding has 16 total steps but includes hidden wait steps and tactical steps
   - Add VisibleStepCount and GetVisibleStepIndex to TutorialSequence
   - Now shows "Step 3 of 15" (visible) instead of "Step 3 of 16" (total)

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

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

* Mark onboarding complete after strategic portion, fix highlight

Two fixes:

1. Add MarksOnboardingComplete flag to TutorialStep
   - When set, marks onboarding complete when that step finishes
   - Set on "strategic_complete" step so command tutorials can appear
   - Tactical portion continues when battle becomes available

2. Remove highlight from "Province Commands" step
   - Was causing yellow box to appear over modal text
   - The buttons are explained in the text, no highlight needed

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

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

* Fix command panel tutorials: step count, trigger timing, and highlight

- VisibleStepCount now stops at MarksOnboardingComplete step (8 vs 15)
- Allow contextual tutorials to interrupt hidden DisplayMode.None steps
- Restore Province Commands highlighting with HighlightBoundsFromChildren
- Add defensive highlight clearing before showing new modals

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

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

* Fix command tutorial positioning, initial trigger, and switching

- Position command tutorials above CommandPanel using AdjacentTargetPath
- Track last command shown and re-trigger after onboarding completes
- Allow command tutorials to interrupt each other when switching commands
- Same command tutorial won't restart if already showing

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

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

* Register CommandPanel target and add debug logging

- Add CommandPanel to TutorialTargetRegistry static targets
- Register commandPanel from EagleGameController on tutorial init
- Add debug logging to RetriggerLastCommandTutorial for diagnosis

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

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

* Fix command tutorial timing and switching behavior

- Defer RetriggerLastCommandTutorial until after advancing to hidden step
  (was firing while onboarding modal was still active)
- When switching to a command whose tutorial is already completed,
  hide the current command tutorial without marking it complete

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

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

* Delay onboarding start until first model update

Move StartOnboarding() from SetUpGame() to SwapModel() so it runs after
the first game model is received and UI panels are populated. This
prevents the tutorial from appearing before the province info panel
is visible/positioned.

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

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

* Link CommandPanel in TutorialTargetRegistry and enable debug logging

- Add CommandPanel reference for tutorial positioning
- Enable tutorial debug logging for testing

🤖 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-14 14:13:32 -08:00
7b118f24b6 Convert ProvinceUtils to use Scala BattalionType (#5333)
Replace proto BattalionType import with Scala version in
ProvinceUtils.availableBattalionTypeIds method. Update test
to use Scala BattalionType with helper function for test data.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:52:21 -08:00
2ddb627c58 Remove proto enum import from Actions layer (#5332)
Use Scala ActionResultType enum instead of proto enum in
ChronicleEventGenerator. This completes the deproto migration
for the Actions layer (0 proto imports remaining).

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:43:29 -08:00
154fac85a5 Remove legacy proto CommandSelection and rename ScalaCommandSelection (#5330)
- Delete old proto-based CommandSelection class
- Rename ScalaCommandSelection to CommandSelection
- Delete ProtoCommandChooser and ProtoCommandChooserImplicits
- Delete ProtoAvailableCommandSelector
- Update all imports (47 files) to use CommandSelection
- Remove proto dependencies from BUILD.bazel files
- Delete AvailableCommandSelectorTest (tested deleted proto functionality)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 13:13:27 -08:00
5e61f43183 Make CommandChoiceHelpers and AI clients protoless (#5326)
* Make CommandChoiceHelpers protoless

Convert all command choice helper files to use Scala types instead of
proto types. This eliminates proto dependencies from the command
selection logic in the library.

Key changes:
- Update AvailableCommandSelector, CommandChoiceHelpers, and all
  command selector files to use Scala AvailableCommand/SelectedCommand
- Convert CommandSelection to ScalaCommandSelection throughout
- Update action files (EndHandleRiotsPhaseAction,
  PerformVassalCommandsPhaseAction, PerformVassalDefenseDecisionsAction)
  to work with Scala commands directly
- Update all quest command choosers to use Scala types
- Fix ArmedBattalion Scala definition (battalionTypeId -> newArmament)
- Add exports to combat_unit_selector BUILD.bazel

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

* Make AI clients protoless

Convert AI client source and test files to use Scala AvailableCommand
and ScalaCommandSelection types instead of proto types.

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

* Update CommandChoiceHelpers tests to use Scala types

Convert test files to use Scala AvailableCommand and ScalaCommandSelection
types instead of proto types. Also includes additional source updates for
AvailableCommandSelector and CommandChooser.

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

* Fix remaining test conversions to Scala types

- Fix CommandChoiceHelpersTest to use DiplomacyAvailable Scala type
- Convert ExpandCommandSelectorTest fixtures from proto to Scala types
- Remove tests that relied on ScalaPB .update() lens syntax (marked with TODO)

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

* Fix AIClient and GameController to use Scala types consistently

- AIClient now works entirely with Scala command types
- GameController.withPostedCommand accepts Scala SelectedCommand
- postHumanCommand converts proto to Scala at the API boundary
- All 202 tests pass

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

* Restore 5 missing tests in ExpandCommandSelectorTest

Tests were removed during protoless migration because they used ScalaPB
.update() lens syntax. Rewrote them using .copy() syntax:
- "return nothing if not enough heroes can move to keep balance"
- "move some heroes to a friendly province if there's an imbalance"
- "return a march command with one hero if that's close enough"
- "return a march command with hero count rounding up if possible"
- "keep hero count balanced even if lots are available"

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 12:58:47 -08:00
a79209891d Tutorial row highlighting for province info, warlord, and vassals (#5328)
* Add tutorial row highlighting for province info, warlord, and vassals

- Highlight entire Province Info panel for province_stats step
- Register hero table rows dynamically for tutorial targeting
- Add WarlordRow target highlighting for heroes_warlord step
- Add VassalRow1+VassalRow2 combined highlight for heroes_vassals step
- Add AdditionalHighlightTargets field for multi-element highlights
- Add HighlightMultiple method to compute combined bounding boxes
- Add GetRowRectTransform helper to EventBasedTable

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

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

* Clamp tutorial highlights to stay within screen bounds

Adds screen bounds clamping to both PositionHighlight and
PositionHighlightMultiple methods to prevent highlights from
going off-screen. Uses a 5px margin from canvas edges.

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

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

* Add highlighting for Command Buttons tutorial step

The command_buttons step had AdjacentTargetPath for panel positioning
but was missing TargetGameObjectPath for highlighting. Added both
TargetGameObjectPath and HighlightPulsing to show the highlight.

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

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

* Fix highlight bounds to stay within screen

Two fixes for tutorial highlighting:

1. Account for HighlightPadding in screen bounds clamping - the padding
   was added to size AFTER clamping, pushing edges off-screen again.
   Now the margin includes HighlightPadding so final bounds stay on screen.

2. Add HighlightBoundsFromChildren option for containers where the
   RectTransform is larger than visible content. When enabled, highlight
   bounds are computed from active child elements instead of the target's
   own RectTransform. Used for CommandButtonsPanel where buttons are
   85x85 with 5px spacing.

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

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

* Point CommandButtonsPanel to actual button container

Changed the reference to target the button container directly
rather than the larger parent panel.

🤖 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-14 12:25:08 -08:00
cbcad13d23 Tutorial: non-blocking positioned panels with updated content (#5322)
* Tutorial: non-blocking panels with positioning and updated content

- Add TutorialPanelAnchor enum (Center, Left, Right, Top, Bottom)
- Add BlocksInteraction property to TutorialStep for non-modal tutorials
- Update TutorialModalPanel with PositionPanel() method for anchored placement
- Update TutorialUIManager fallback UI to support positioning and non-blocking
- Revise tax/Support content: explain taxes provide both Gold AND Food
- Add vassals tutorial step with loyalty mechanics warning
- Set province-related tutorial steps to non-blocking and right-anchored

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

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

* Tutorial: smaller panel, adjacent positioning, Support highlight

- Reduce panel size from 800x550 to 500x400
- Reduce font sizes (title 28, desc 18, progress 14, buttons 16)
- Add AdjacentTargetPath property to position panel next to UI elements
- Add PositionAdjacentTo() method for target-relative positioning
- Highlight Support field during Support tutorial step
- Fix "Give Alms" to say "costs Food" not "costs Gold"
- Fix turn cycle text (no End Turn button - turns end automatically)
- Fix vassals text: "feasts" instead of "victories"
- Emphasize that commands are safe to explore until Commit

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

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

* Tutorial: add TutorialTargetRegistry for Unity-linked targets

- Create TutorialTargetRegistry component with serialized fields for UI targets
- Add TargetRegistry reference to TutorialManager
- Update TutorialUIManager to use registry instead of GameObject.Find
- Update TutorialModalPanel to use registry for adjacent positioning
- Registry provides drag-and-drop configuration in Inspector
- Falls back to GameObject.Find for unregistered targets

Supported targets:
- ProvinceInfoPanel, SupportField, AgricultureField, EconomyField,
  InfrastructureField, HeroesPanel, CommandButtonsPanel, ImproveButton,
  AlmsButton, MarchButton, CommitButton, BattleButton

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

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

* Tutorial: support dynamic target registration for prefab buttons

- Remove individual command button fields (ImproveButton, AlmsButton, etc.)
- Remove BattleButton (not needed yet)
- Add RegisterTarget/UnregisterTarget methods for runtime registration
- Command buttons can be registered when instantiated from prefabs

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

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

* Add TutorialTargetRegistry.cs.meta

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

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

* Tutorial: fix Infrastructure description

- Infrastructure improves troop armament and disaster resilience
- Note that all three stats increase storage capacity

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

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

* Tutorial: increase panel size to 540x500

Panel was too small, causing title to clip at top and buttons below bottom.

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

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

* Tutorial: soften Warlord warning text

Changed from "game over" to "protect them" - the full mechanic
is more nuanced and doesn't need to be explained in onboarding.

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

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

* Tutorial: add debug logging for highlight targeting

Helps diagnose why Support field highlight may not be appearing.

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

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

* Add diagnostic logging to debug registry lookup and panel positioning

- Log TutorialManager.Instance and TargetRegistry state
- Log which target is found (static vs dynamic vs not found)
- Log panel positioning anchor and resulting position
- Log step details when Show() is called

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

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

* Wire TutorialTargetRegistry to TutorialManager in scene

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

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

* Fix tutorial panel positioning and highlight sizing

- Add screen bounds clamping to prevent panels from going off-screen
- Add Top/Bottom positioning support for adjacent panel placement
- Fix highlight frame using canvas-local coordinates instead of screen coords
- Position Warlord/Vassals panels adjacent to HeroesPanel
- Position Command panel above CommandButtonsPanel with highlight

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

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

* Clean up debug logging and remove command button highlight

- Remove diagnostic debug logs from TutorialModalPanel and TutorialTargetRegistry
- Remove command buttons highlight (panel keeps oversized element bounds)
- Keep panel positioning adjacent to CommandButtonsPanel

🤖 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-14 11:31:25 -08:00
9e7eaa6367 Fix diplomacy commands not deducting gold costs (#5327)
The diplomacyOptionTypeToOption method was incorrectly setting goldCost=0
for all diplomacy types (Alliance, Truce, Invitation, BreakAlliance).
This meant gold was never deducted when these commands were executed.

Fix uses the proper settings values for each diplomacy type.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:37:41 -08:00
3fc8589847 Add GitHub and Apple OAuth provider support (#5323)
Server-side implementation for GitHub and Apple Sign-In OAuth:

- Add OAUTH_PROVIDER_GITHUB and OAUTH_PROVIDER_APPLE to auth.proto enum
- Add GitHub OAuth config (standard OAuth 2.0 flow)
- Add Apple Sign-In config with JWT client_secret generation
- Handle Apple's POST callback and id_token parsing for user info
- Support per-provider callback URLs (Apple requires /oauth/apple/callback)

Environment variables required:
- GitHub: GH_OAUTH_CLIENT_ID, GH_OAUTH_CLIENT_SECRET
- Apple: APPLE_SIGNIN_CLIENT_ID, APPLE_SIGNIN_KEY_ID, APPLE_SIGNIN_PRIVATE_KEY
  (APPLE_TEAM_ID already exists for notarization)

Client UI changes will follow in a separate PR.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 08:16:54 -08:00
abec33585d Improve onboarding tutorial flow and simplify UI buttons (#5321)
* Improve onboarding tutorial flow and simplify UI buttons

- Revise onboarding to focus on the single starting province:
  - Province stats (Agriculture/Economy/Infrastructure)
  - Support importance (40 by January for taxes)
  - Faction Head and hero panel
  - Command buttons (Improve and Give Alms)
  - Turn cycle explanation

- Simplify tutorial modal buttons to just two options:
  - "Continue" - advance to next step
  - "Skip Tutorial" - skip all remaining steps (onboarding only)
  - Remove redundant "Skip" button (was identical to Continue)

- Add note in welcome step about restarting from Settings

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

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

* Use "Warlord" instead of "Faction Head" in tutorial

🤖 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-14 07:20:36 -08:00
e82c174814 Add diagnostic logging for stalled LLM text generation (#4711)
Add lastUpdateAtMillis field to IncompleteClientText to track when data
was last received from the LLM stream. This enables better diagnosis of
stalled incomplete texts by distinguishing between:
- Streams that stalled immediately (small partialLen, secsSinceLastUpdate ≈ secsSinceRequest)
- Streams that received data then went silent (larger partialLen, secsSinceLastUpdate << secsSinceRequest)

This helps verify the hypothesis that HTTP/2 streams can go into a zombie
state where no data, error, or completion is received.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 07:02:38 -08:00
38732c9255 Improve Unity Library/ cache resilience (#5320)
1. Separate cache paths per platform (/tmp/eagle0/Library-mac vs Library-windows)
   - Prevents cross-platform contamination if runners share /tmp

2. Only persist cache on successful builds
   - Prevents failed builds from poisoning the cache

3. Exclude Library/Bee/ from cache
   - Bee contains DAG files with hardcoded paths that become stale
   - Prevents "Data at the root level is invalid" XML errors
   - ScriptAssemblies and ShaderCache are still cached for speed

Also adds clean: true to Windows Unity workflow.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 06:53:47 -08:00
7cd56e5980 Fix token mismatch causing connection retry loop after deploy (#5308)
Two related fixes for handling stale game state after blue-green deploy:

Server (EagleServiceImpl.scala):
- Catch "Token mismatch" exceptions in postCommand and return BAD_TOKEN
  status instead of throwing an exception
- Previously the exception caused an RPC error, bypassing the client's
  BAD_TOKEN handling which refreshes game state

Client (PersistentClientConnection.cs):
- When WriteAsync times out or fails, dispose the dead connection and
  schedule reconnect
- Previously the connection was left in a zombie state (appeared alive
  but couldn't communicate)
- Add better logging for write errors

Root cause: After a deploy, the client reconnects but may have stale
game state. When posting a command with an old token, the server threw
an exception instead of returning BAD_TOKEN. The client didn't know to
refresh its state, and the stale command stayed in the retry queue.

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 06:42:11 -08:00
454d4e2fc7 Remove GoDice Bluetooth dice integration (#5312)
* Remove GoDice Bluetooth dice integration

The GoDice integration for physical Bluetooth dice was incomplete and
causing Mac build failures due to orphaned .meta files for plugin
binaries that weren't tracked in git.

This commit removes all GoDice-related code:
- Deleted Assets/Bluetooth folder with all dice interface code
- Removed DarwinGodiceBundle.bundle.meta and GoDiceDll.dll.meta
- Removed RollFetcher interface and references from game models
- Removed GoDice settings from SettingsPanelController
- Updated ShardokGameModel to always pass null for rolls (server
  generates random rolls when no physical roll is provided)

The feature can be re-added later when there's time to properly
implement and test GoDice integration.

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

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

* Remove GoDice Canvas with orphaned script references

Removed the GoDice Canvas GameObject from the scene which contained
components referencing the deleted Bluetooth/RollPanelController scripts.

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

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

* Remove GoDice plugin build steps from CI

Since GoDice integration is removed, no need to build the
DarwinGodiceBundle or GoDiceDll plugins in CI.

🤖 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-14 06:30:26 -08:00
7ad5ebdb56 Re-enable Sparkle auto-update integration for Mac builds (#5318)
* 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>

* 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>

* 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>

* Re-enable Sparkle auto-update integration for Mac builds

Restores Sparkle integration that was temporarily removed in #5317:
- Restore inject_sparkle.sh script
- Add Sparkle injection step to mac_build.yml
- Re-enable Sparkle signing and appcast updates in deploy step

Combined with native SparklePlugin that initializes Sparkle at runtime.

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

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

* Build SparklePlugin.bundle before Unity build

The SparklePlugin.bundle.meta file tells Unity to include the plugin,
but the actual bundle needs to be built by Bazel first.

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

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

* Convert SparklePlugin Info.plist to XML format

Unity's build system requires Info.plist files in XML format,
but Bazel outputs them in binary plist format.

🤖 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-14 05:28:58 -08:00
1e3ae0d82c Fix ArmedBattalion field name: battalionTypeId -> newArmament (#5319)
The ArmedBattalion.battalionTypeId field was misnamed - it represents
the armament level to raise troops to, not a battalion type ID.
Renamed to newArmament to match the proto field name and the
semantic meaning.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:46:17 -08:00
92efdbae8b Remove Sparkle auto-update integration from Mac builds (#5317)
* Remove Sparkle auto-update integration from Mac builds

Temporarily removing Sparkle integration to get Mac builds working:
- Remove inject_sparkle.sh script and workflow step
- Make mac_build_handler's Sparkle private key optional
- Skip Sparkle signing and appcast updates when no key provided

This allows Mac builds to complete without Sparkle. Auto-updates can
be re-enabled later once the basic build pipeline is stable.

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

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

* Use clean checkout to remove stale SparklePlugin.bundle

The runner had a leftover SparklePlugin.bundle from previous builds
which was causing Unity to fail when trying to process it.

🤖 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 22:28:17 -08:00
378d3f6828 Fix crash when LLM responses arrive for deleted games (#5315)
When warmup games (or any games) are deleted while LLM requests are
in flight, the async responses would crash with "key not found" because
GamesManager used direct Map access that throws NoSuchElementException.

This caused two problems:
1. The exception disrupted LLM response processing
2. Other games' text generation could get blocked as a result

Changed three methods to use safe .get() access and gracefully ignore
responses for deleted games:
- receiveStreamingLlmResponses: logs and returns early
- receiveStreamingLlmFailure: logs and returns early
- aiPlayers: returns empty vector

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

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:02:51 -08:00
ca0b3930dc Delete unused DateProtoUtils (#5314)
DateProtoUtils has no production callers - it's dead code that only
has a test file. The Scala Date type at model/state/date/ is the
preferred way to work with dates in the codebase.

Proto imports in library/ after this change: 143

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 18:26:09 -08:00
99fef8312b Move IDable to test code (#5313)
IDable is a test-only utility that provides mapify* helper methods
for converting proto collections to Maps. This moves it from the
main library/ directory to test code.

Changes:
- Inline IDable methods in StartGameActionResultUtils (the only main
  code user)
- Move IDable.scala to src/test/scala/net/eagle0/eagle/library/util/
- Update all test BUILD.bazel files to use the test version

Proto imports in library/ after this change: 145

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 18:25:16 -08:00
cdb8631e91 Make IncomingArmyUtils protoless (#5310)
Delete all proto overloads from IncomingArmyUtils since all callers use
Scala types. This required adding an export to ProvinceOrderTypeConverter
to properly expose the proto type to callers.

Proto imports in library/: 149 → 146

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 17:35:37 -08:00
7c0bbd8b9a Delete unused ArmyUtils (#5311)
ArmyUtils.heroCount and ArmyUtils.troopCount methods are never called.
Delete the file entirely.

Proto imports in library/: 149 → 147

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 17:35:10 -08:00
7c9e434430 Add comprehensive proto import inventory to DEPROTO_PLAN (#5306)
Document all 149 proto imports in library/ with:
- Proto deps by BUILD.bazel file (for build-level tracking)
- Detailed imports by file/line (for code-level tracking)
- Cleanup priority candidates

This inventory makes it easier to track deproto progress.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 16:57:00 -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
4067 changed files with 90771 additions and 47744 deletions
+1
View File
@@ -10,3 +10,4 @@
src/main/csharp/**/GUI[[:space:]]Pro[[:space:]]Kit*/**/*.ttf !filter !diff !merge
src/main/csharp/**/Modern[[:space:]]UI[[:space:]]Pack/**/*.ttf !filter !diff !merge
*.herodata filter=lfs diff=lfs merge=lfs -text
*.ogg filter=lfs diff=lfs merge=lfs -text
@@ -0,0 +1,39 @@
name: Artifact Storage Check
on:
schedule:
# Run every 6 hours
- cron: '0 */6 * * *'
workflow_dispatch:
jobs:
check-storage:
runs-on: ubuntu-latest
steps:
- name: Check artifact storage size
env:
GH_TOKEN: ${{ github.token }}
run: |
# Calculate total artifact storage
total_bytes=$(gh api "repos/${{ github.repository }}/actions/artifacts" \
--paginate -q '.artifacts[].size_in_bytes' | awk '{sum+=$1} END {print sum}')
total_mb=$((total_bytes / 1024 / 1024))
echo "Total artifact storage: ${total_mb} MB"
# Fail if over 500MB
if [ "$total_mb" -gt 500 ]; then
echo "::error::Artifact storage is ${total_mb} MB, which exceeds the 500 MB threshold!"
echo ""
echo "Largest artifacts:"
# Save to temp file to avoid SIGPIPE/broken pipe errors with head
gh api "repos/${{ github.repository }}/actions/artifacts" \
--paginate -q '.artifacts[] | "\(.size_in_bytes)\t\(.name)\t\(.created_at)"' > /tmp/artifacts.txt
sort -rn /tmp/artifacts.txt | head -20 | \
awk -F'\t' '{printf "%d MB\t%s\t%s\n", $1/1024/1024, $2, $3}'
rm -f /tmp/artifacts.txt
exit 1
fi
echo "Storage is within acceptable limits."
+62 -39
View File
@@ -9,6 +9,7 @@ on:
- 'src/main/protobuf/net/eagle0/eagle/api/auth.proto'
- 'src/main/protobuf/net/eagle0/eagle/api/admin/**'
- 'src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
- 'src/main/resources/net/eagle0/attributions.json'
- 'ci/BUILD.bazel'
- '.github/workflows/auth_build.yml'
workflow_dispatch:
@@ -24,7 +25,7 @@ permissions:
jobs:
build-auth:
runs-on: self-hosted
runs-on: [self-hosted, bazel]
outputs:
image_tag: ${{ steps.push-auth.outputs.image_tag }}
steps:
@@ -104,6 +105,14 @@ jobs:
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GH_OAUTH_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }}
GH_OAUTH_CLIENT_SECRET: ${{ secrets.GH_OAUTH_CLIENT_SECRET }}
APPLE_SIGNIN_CLIENT_ID: ${{ secrets.APPLE_SIGNIN_CLIENT_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_SIGNIN_KEY_ID: ${{ secrets.APPLE_SIGNIN_KEY_ID }}
APPLE_SIGNIN_PRIVATE_KEY: ${{ secrets.APPLE_SIGNIN_PRIVATE_KEY }}
TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }}
TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }}
JWT_PRIVATE_KEY: ${{ secrets.JWT_PRIVATE_KEY }}
FASTMAIL_API_TOKEN: ${{ secrets.FASTMAIL_API_TOKEN }}
FASTMAIL_FROM_EMAIL: ${{ secrets.FASTMAIL_FROM_EMAIL }}
@@ -112,16 +121,6 @@ jobs:
- name: Checkout repository
uses: actions/checkout@v4
- name: Copy update-env script to server
uses: appleboy/scp-action@v0.1.7
with:
host: ${{ secrets.DO_DROPLET_IP }}
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
source: "deploy/update-env.sh,deploy/env.template"
target: /opt/eagle0/
strip_components: 1
- name: Deploy auth service to production
uses: appleboy/ssh-action@v1.0.3
with:
@@ -129,51 +128,75 @@ jobs:
username: deploy
key: ${{ secrets.DO_SSH_KEY }}
script_stop: true
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
envs: AUTH_IMAGE,DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRET,GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET,GH_OAUTH_CLIENT_ID,GH_OAUTH_CLIENT_SECRET,APPLE_SIGNIN_CLIENT_ID,APPLE_TEAM_ID,APPLE_SIGNIN_KEY_ID,APPLE_SIGNIN_PRIVATE_KEY,TWITCH_CLIENT_ID,TWITCH_CLIENT_SECRET,JWT_PRIVATE_KEY,FASTMAIL_API_TOKEN,FASTMAIL_FROM_EMAIL,FASTMAIL_FROM_NAME
script: |
set -x
cd /opt/eagle0
# Update env vars using shared script (preserves vars set by other workflows)
chmod +x update-env.sh
./update-env.sh \
"AUTH_IMAGE=${AUTH_IMAGE}" \
"DISCORD_CLIENT_ID=${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET}" \
"JWT_PRIVATE_KEY=${JWT_PRIVATE_KEY}" \
"FASTMAIL_API_TOKEN=${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=${FASTMAIL_FROM_NAME}"
# Export env vars for docker compose (appleboy/ssh-action sets them but doesn't export)
export AUTH_IMAGE="${AUTH_IMAGE}"
export DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
export DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
export GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
export GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
export GH_OAUTH_CLIENT_ID="${GH_OAUTH_CLIENT_ID}"
export GH_OAUTH_CLIENT_SECRET="${GH_OAUTH_CLIENT_SECRET}"
export APPLE_SIGNIN_CLIENT_ID="${APPLE_SIGNIN_CLIENT_ID}"
export APPLE_TEAM_ID="${APPLE_TEAM_ID}"
export APPLE_SIGNIN_KEY_ID="${APPLE_SIGNIN_KEY_ID}"
export APPLE_SIGNIN_PRIVATE_KEY="${APPLE_SIGNIN_PRIVATE_KEY}"
export TWITCH_CLIENT_ID="${TWITCH_CLIENT_ID}"
export TWITCH_CLIENT_SECRET="${TWITCH_CLIENT_SECRET}"
export JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
export FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
export FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
export FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
# Login to registry
echo "${{ secrets.DO_REGISTRY_TOKEN }}" | docker login registry.digitalocean.com -u "${{ secrets.DO_REGISTRY_TOKEN }}" --password-stdin
echo "Deploying auth service: $AUTH_IMAGE"
# Use crane to pull image
echo "Installing crane..."
curl -sL https://github.com/google/go-containerregistry/releases/download/v0.20.2/go-containerregistry_Linux_x86_64.tar.gz | tar xzf - crane
chmod +x crane
# Pull the image directly (docker is already logged in)
echo "Pulling Auth image..."
docker pull "${AUTH_IMAGE}" || { echo "ERROR: Failed to pull auth image"; exit 1; }
echo "Pulling Auth image with crane..."
./crane pull "${AUTH_IMAGE}" auth.tar || { echo "ERROR: Failed to pull auth image"; exit 1; }
echo "Loading Auth image into Docker..."
docker load -i auth.tar
rm auth.tar
rm ./crane
# Tag as :latest locally so any fallback uses correct image
docker tag "${AUTH_IMAGE}" registry.digitalocean.com/eagle0/auth-server:latest
# Only recreate the auth container (not eagle, shardok, etc.)
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate auth
# Debug: check environment and .env file
echo "DEBUG: AUTH_IMAGE=$AUTH_IMAGE"
env | grep AUTH || echo "AUTH_IMAGE not in env output"
if [ -f .env ]; then
echo "DEBUG: .env file contents related to AUTH:"
grep AUTH .env || echo "No AUTH in .env"
fi
# Recreate auth container - pass AUTH_IMAGE explicitly on command line
AUTH_IMAGE="${AUTH_IMAGE}" docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate auth
# Wait for health check
sleep 5
docker compose -f docker-compose.prod.yml ps auth
# Verify container is using correct image
# Verify container is using the correct image
# Note: docker-compose may use :latest tag (which we tagged to the correct image)
echo "=== Verifying auth container image ==="
docker compose -f docker-compose.prod.yml images auth
RUNNING_IMAGE=$(docker inspect auth-server --format '{{.Config.Image}}')
RUNNING_DIGEST=$(docker inspect auth-server --format '{{.Image}}')
EXPECTED_DIGEST=$(docker inspect "${AUTH_IMAGE}" --format '{{.Id}}')
echo "Expected image: ${AUTH_IMAGE}"
echo "Running image: ${RUNNING_IMAGE}"
echo "Expected digest: ${EXPECTED_DIGEST}"
echo "Running digest: ${RUNNING_DIGEST}"
if [ "$RUNNING_DIGEST" != "$EXPECTED_DIGEST" ]; then
echo "ERROR: Container is running wrong image!"
exit 1
fi
echo "Image digests match - correct image is running"
# Show container status
docker compose -f docker-compose.prod.yml ps auth
# Cleanup old images
docker image prune -f
+36
View File
@@ -0,0 +1,36 @@
name: Bazel Cache Cleanup
on:
schedule:
# Run weekly on Sunday at 00:00 UTC
- cron: '0 0 * * 0'
workflow_dispatch: # Allow manual trigger
jobs:
cleanup:
runs-on: [self-hosted, bazel]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Show disk usage before cleanup
run: |
echo "=== Disk usage before cleanup ==="
BAZEL_USER_ROOT="/private/var/tmp/_bazel_$(whoami)"
echo "Bazel user root: $BAZEL_USER_ROOT"
du -sh "$BAZEL_USER_ROOT" 2>/dev/null || echo "Not found"
df -h .
- name: Run bazel clean
run: |
echo "=== Running bazel clean ==="
bazel clean
echo "Clean complete"
- name: Show disk usage after cleanup
run: |
echo "=== Disk usage after cleanup ==="
BAZEL_USER_ROOT="/private/var/tmp/_bazel_$(whoami)"
du -sh "$BAZEL_USER_ROOT" 2>/dev/null || echo "Not found"
df -h .
+5 -6
View File
@@ -26,16 +26,16 @@ permissions:
jobs:
test:
runs-on: self-hosted
runs-on: [self-hosted, bazel]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Check BUILD.bazel dependencies
run: ./scripts/check_build_deps.sh --strict
- name: Run tests
id: test
continue-on-error: true
run: bazel test --build_event_json_file=test.json //src/test/... //src/main/go/...
- name: Collect failed test logs
if: always()
@@ -75,6 +75,7 @@ jobs:
with:
name: test.json
path: test.json
retention-days: 5
- name: Archive failed test logs
if: always()
uses: actions/upload-artifact@v4
@@ -82,6 +83,4 @@ jobs:
name: failed-test-logs
path: failed_test_logs/
if-no-files-found: ignore
- name: Fail if tests failed
if: steps.test.outcome == 'failure'
run: exit 1
retention-days: 5
-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
+2
View File
@@ -37,6 +37,7 @@ jobs:
with:
name: ubuntu-noble-sysroot-amd64
path: tools/sysroot/output/
retention-days: 1
- name: Install AWS CLI
run: |
@@ -98,6 +99,7 @@ jobs:
with:
name: ubuntu-noble-sysroot-arm64
path: tools/sysroot/output/
retention-days: 1
- name: Install AWS CLI
run: |
-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
+245 -490
View File
@@ -4,10 +4,18 @@ on:
push:
branches: [ "main" ]
paths:
- 'src/main/cpp/**'
# Note: C++ changes trigger shardok_arm64_build.yml instead
# Note: Auth changes trigger auth_build.yml instead
# Note: Windows installer changes trigger installer_build.yml instead
- 'src/main/go/**'
- '!src/main/go/net/eagle0/authservice/**'
- '!src/main/go/net/eagle0/authcli/**'
- '!src/main/go/net/eagle0/clients/**'
- 'src/main/scala/**'
- 'src/main/protobuf/**'
- '!src/main/protobuf/net/eagle0/eagle/api/auth.proto'
- '!src/main/protobuf/net/eagle0/eagle/api/admin/**'
- '!src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto'
- 'src/main/resources/**'
- 'ci/BUILD.bazel'
- 'MODULE.bazel'
@@ -22,37 +30,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 \
//ci:warmup_tar
# Copy warmup binary to scripts/ so it gets deployed with other scripts
# Extract warmup binary from tar for deployment
mkdir -p scripts/bin
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
tar -xf bazel-bin/ci/warmup_tar.tar -C scripts/bin --strip-components=1
# 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,382 +96,73 @@ 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 }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GPT_MODEL_NAME: ${{ secrets.GPT_MODEL_NAME }}
EAGLE_ENABLE_S3: ${{ secrets.EAGLE_ENABLE_S3 }}
DO_SPACES_ACCESS_KEY: ${{ secrets.DO_SPACES_ACCESS_KEY }}
@@ -447,6 +172,14 @@ jobs:
DISCORD_CLIENT_SECRET: ${{ secrets.DISCORD_CLIENT_SECRET }}
GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }}
GH_OAUTH_CLIENT_ID: ${{ secrets.GH_OAUTH_CLIENT_ID }}
GH_OAUTH_CLIENT_SECRET: ${{ secrets.GH_OAUTH_CLIENT_SECRET }}
APPLE_SIGNIN_CLIENT_ID: ${{ secrets.APPLE_SIGNIN_CLIENT_ID }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
APPLE_SIGNIN_KEY_ID: ${{ secrets.APPLE_SIGNIN_KEY_ID }}
APPLE_SIGNIN_PRIVATE_KEY: ${{ secrets.APPLE_SIGNIN_PRIVATE_KEY }}
TWITCH_CLIENT_ID: ${{ secrets.TWITCH_CLIENT_ID }}
TWITCH_CLIENT_SECRET: ${{ secrets.TWITCH_CLIENT_SECRET }}
SHARDOK_ADDRESS: ${{ secrets.SHARDOK_ADDRESS }}
SHARDOK_AUTH_TOKEN: ${{ secrets.SHARDOK_AUTH_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
@@ -455,6 +188,7 @@ jobs:
FASTMAIL_FROM_NAME: ${{ secrets.FASTMAIL_FROM_NAME }}
DO_DROPLET_IP: ${{ secrets.DO_DROPLET_IP }}
DO_REGISTRY_TOKEN: ${{ secrets.DO_REGISTRY_TOKEN }}
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -468,28 +202,24 @@ jobs:
- name: Build warmup tool
run: |
bazel build //src/main/go/net/eagle0/warmup:warmup_linux_amd64
bazel build //ci:warmup_tar
mkdir -p scripts/bin
cp bazel-bin/src/main/go/net/eagle0/warmup/warmup_linux_amd64_/warmup_linux_amd64 scripts/bin/warmup
tar -xf bazel-bin/ci/warmup_tar.tar -C scripts/bin --strip-components=1
- 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)
mkdir -p /opt/eagle0/scripts/bin /opt/eagle0/nginx
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/
scp -i ~/.ssh/deploy_key scripts/deploy-blue-green.sh scripts/warmup-eagle.sh scripts/eagle-exec.sh scripts/eagle-logs.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/
scp -i ~/.ssh/deploy_key scripts/bin/warmup deploy@"$DO_DROPLET_IP":/opt/eagle0/scripts/bin/
scp -i ~/.ssh/deploy_key deploy/env.template deploy/update-env.sh deploy@"$DO_DROPLET_IP":/opt/eagle0/deploy/
- name: Deploy to production droplet
run: |
@@ -497,156 +227,181 @@ jobs:
set -ex
cd /opt/eagle0
# Environment variables passed via heredoc
EAGLE_IMAGE="${EAGLE_IMAGE}"
SHARDOK_IMAGE="${SHARDOK_IMAGE}"
ADMIN_IMAGE="${ADMIN_IMAGE}"
JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
OPENAI_API_KEY="${OPENAI_API_KEY}"
GPT_MODEL_NAME="${GPT_MODEL_NAME}"
EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3}"
DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
SHARDOK_ADDRESS="${SHARDOK_ADDRESS}"
SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
SENTRY_DSN="${SENTRY_DSN}"
FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
# =================================================================
# CRITICAL: Validate environment variables before proceeding
# This catches GitHub Actions secret store hiccups early
# =================================================================
validate_env() {
local var_name="\$1"
local var_value="\$2"
local default_value="\${3:-}"
# Check Docker has IPv6 support for connecting to Hetzner Shardok
# (One-time setup: sudo tee /etc/docker/daemon.json <<< '{"ipv6": true, "ip6tables": true, "experimental": true, "fixed-cidr-v6": "fd00::/80"}' && sudo systemctl restart docker)
if ! cat /etc/docker/daemon.json 2>/dev/null | grep -q '"ip6tables"'; then
echo "WARNING: Docker IPv6 not configured. Eagle may not reach Hetzner Shardok."
echo "Run: sudo tee /etc/docker/daemon.json <<< '{\"ipv6\": true, \"ip6tables\": true, \"experimental\": true, \"fixed-cidr-v6\": \"fd00::/80\"}' && sudo systemctl restart docker"
if [ -z "\${var_value}" ]; then
echo "ERROR: \${var_name} is empty. GitHub Actions secrets may have failed to load."
echo "Please retry the workflow."
return 1
fi
# Check if we got a default value instead of the real secret
if [ -n "\${default_value}" ] && [ "\${var_value}" = "\${default_value}" ]; then
echo "ERROR: \${var_name} has default value '\${default_value}' instead of the actual secret."
echo "This indicates GitHub Actions secrets failed to load. Please retry the workflow."
return 1
fi
return 0
}
echo "Validating critical environment variables..."
# These are the raw values from GitHub Actions (before export)
# We check them before exporting to catch issues early
VALIDATION_FAILED=0
validate_env "SHARDOK_ADDRESS" "${SHARDOK_ADDRESS}" "" || VALIDATION_FAILED=1
validate_env "EAGLE_IMAGE" "${EAGLE_IMAGE}" "" || VALIDATION_FAILED=1
validate_env "JWT_PRIVATE_KEY" "${JWT_PRIVATE_KEY}" "" || VALIDATION_FAILED=1
validate_env "DO_REGISTRY_TOKEN" "${DO_REGISTRY_TOKEN}" "" || VALIDATION_FAILED=1
if [ "\${VALIDATION_FAILED}" -eq 1 ]; then
echo ""
echo "========================================="
echo "DEPLOYMENT ABORTED: Missing critical secrets"
echo "This is likely a transient GitHub Actions issue."
echo "Please retry the workflow."
echo "========================================="
exit 1
fi
# Update env vars using shared script (preserves vars set by other workflows)
chmod +x update-env.sh
./update-env.sh \
"EAGLE_IMAGE=\${EAGLE_IMAGE}" \
"SHARDOK_IMAGE=\${SHARDOK_IMAGE}" \
"ADMIN_IMAGE=\${ADMIN_IMAGE}" \
"JFR_SIDECAR_IMAGE=\${JFR_SIDECAR_IMAGE}" \
"OPENAI_API_KEY=\${OPENAI_API_KEY}" \
"GPT_MODEL_NAME=\${GPT_MODEL_NAME:-gpt-4o}" \
"EAGLE_ENABLE_S3=\${EAGLE_ENABLE_S3:-false}" \
"DO_SPACES_ACCESS_KEY=\${DO_SPACES_ACCESS_KEY}" \
"DO_SPACES_SECRET_KEY=\${DO_SPACES_SECRET_KEY}" \
"JWT_PRIVATE_KEY=\${JWT_PRIVATE_KEY}" \
"DISCORD_CLIENT_ID=\${DISCORD_CLIENT_ID}" \
"DISCORD_CLIENT_SECRET=\${DISCORD_CLIENT_SECRET}" \
"GOOGLE_CLIENT_ID=\${GOOGLE_CLIENT_ID}" \
"GOOGLE_CLIENT_SECRET=\${GOOGLE_CLIENT_SECRET}" \
"SHARDOK_ADDRESS=\${SHARDOK_ADDRESS:-shardok:40042}" \
"SHARDOK_AUTH_TOKEN=\${SHARDOK_AUTH_TOKEN}" \
"SENTRY_DSN=\${SENTRY_DSN}" \
"FASTMAIL_API_TOKEN=\${FASTMAIL_API_TOKEN}" \
"FASTMAIL_FROM_EMAIL=\${FASTMAIL_FROM_EMAIL}" \
"FASTMAIL_FROM_NAME=\${FASTMAIL_FROM_NAME}"
echo "All critical environment variables validated successfully."
# =================================================================
# Export environment variables for docker compose
# These are passed via heredoc and exported so child processes (docker compose) can access them
export EAGLE_IMAGE="${EAGLE_IMAGE}"
export ADMIN_IMAGE="${ADMIN_IMAGE}"
export JFR_SIDECAR_IMAGE="${JFR_SIDECAR_IMAGE}"
export OPENAI_API_KEY="${OPENAI_API_KEY}"
export ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY}"
export GEMINI_API_KEY="${GEMINI_API_KEY}"
export GPT_MODEL_NAME="${GPT_MODEL_NAME:-gpt-4o}"
export EAGLE_ENABLE_S3="${EAGLE_ENABLE_S3:-false}"
export DO_SPACES_ACCESS_KEY="${DO_SPACES_ACCESS_KEY}"
export DO_SPACES_SECRET_KEY="${DO_SPACES_SECRET_KEY}"
export JWT_PRIVATE_KEY="${JWT_PRIVATE_KEY}"
export DISCORD_CLIENT_ID="${DISCORD_CLIENT_ID}"
export DISCORD_CLIENT_SECRET="${DISCORD_CLIENT_SECRET}"
export GOOGLE_CLIENT_ID="${GOOGLE_CLIENT_ID}"
export GOOGLE_CLIENT_SECRET="${GOOGLE_CLIENT_SECRET}"
export GH_OAUTH_CLIENT_ID="${GH_OAUTH_CLIENT_ID}"
export GH_OAUTH_CLIENT_SECRET="${GH_OAUTH_CLIENT_SECRET}"
export APPLE_SIGNIN_CLIENT_ID="${APPLE_SIGNIN_CLIENT_ID}"
export APPLE_TEAM_ID="${APPLE_TEAM_ID}"
export APPLE_SIGNIN_KEY_ID="${APPLE_SIGNIN_KEY_ID}"
export APPLE_SIGNIN_PRIVATE_KEY="${APPLE_SIGNIN_PRIVATE_KEY}"
export TWITCH_CLIENT_ID="${TWITCH_CLIENT_ID}"
export TWITCH_CLIENT_SECRET="${TWITCH_CLIENT_SECRET}"
export SHARDOK_ADDRESS="${SHARDOK_ADDRESS}"
export SHARDOK_AUTH_TOKEN="${SHARDOK_AUTH_TOKEN}"
export SENTRY_DSN="${SENTRY_DSN}"
export FASTMAIL_API_TOKEN="${FASTMAIL_API_TOKEN}"
export FASTMAIL_FROM_EMAIL="${FASTMAIL_FROM_EMAIL}"
export FASTMAIL_FROM_NAME="${FASTMAIL_FROM_NAME}"
export DO_REGISTRY_TOKEN="${DO_REGISTRY_TOKEN}"
export NOTIFY_SECRET="${NOTIFY_SECRET}"
# 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."
fi
# 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
# Tag as :latest locally so docker-compose fallback uses correct image
docker tag "\${ADMIN_IMAGE}" registry.digitalocean.com/eagle0/admin-server:latest
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
# =================================================================
# Verify Shardok connectivity before proceeding with deployment
# This catches network/firewall issues early
# =================================================================
echo "Verifying Shardok connectivity..."
SHARDOK_HOST=\$(echo "\${SHARDOK_ADDRESS}" | cut -d: -f1)
SHARDOK_PORT=\$(echo "\${SHARDOK_ADDRESS}" | cut -d: -f2)
# 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
}
# Try to connect to Shardok (timeout after 10 seconds)
if nc -z -w 10 "\${SHARDOK_HOST}" "\${SHARDOK_PORT}" 2>/dev/null; then
echo "Shardok connectivity verified: \${SHARDOK_ADDRESS} is reachable"
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
echo ""
echo "========================================="
echo "ERROR: Cannot reach Shardok at \${SHARDOK_ADDRESS}"
echo "This may indicate:"
echo " - Shardok server is not running on Hetzner"
echo " - Network/firewall issues between DigitalOcean and Hetzner"
echo " - Incorrect SHARDOK_ADDRESS configuration"
echo ""
echo "DEPLOYMENT ABORTED: Shardok must be reachable for battles to work."
echo "========================================="
exit 1
fi
# Start jfr-sidecar after eagle-blue (shares PID namespace with eagle-blue)
docker compose -f docker-compose.prod.yml up -d --no-deps --force-recreate jfr-sidecar
# 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
# Ensure auth is running (but don't force-recreate it)
docker compose -f docker-compose.prod.yml up -d auth
# Deploy Eagle with blue-green (handles eagle, nginx, admin, jfr-sidecar)
# Note: Shardok runs on Hetzner, deployed separately via shardok_arm64_build.yml
# Note: Auth is deployed separately via auth_build.yml - do NOT touch auth here
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}"
# 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
# Verify admin container is running the correct image
echo "=== Verifying admin container image ==="
ADMIN_RUNNING_DIGEST=\$(docker inspect admin-server --format '{{.Image}}')
ADMIN_EXPECTED_DIGEST=\$(docker inspect "\${ADMIN_IMAGE}" --format '{{.Id}}')
echo "Expected image: \${ADMIN_IMAGE}"
echo "Expected digest: \${ADMIN_EXPECTED_DIGEST}"
echo "Running digest: \${ADMIN_RUNNING_DIGEST}"
if [ "\${ADMIN_RUNNING_DIGEST}" != "\${ADMIN_EXPECTED_DIGEST}" ]; then
echo "ERROR: Admin container is running wrong image!"
echo "Container entrypoint:"
docker inspect admin-server --format '{{.Config.Entrypoint}}'
exit 1
fi
echo "Admin image verification passed"
# Cleanup
docker container prune -f
docker image prune -f
DEPLOY_SCRIPT
+37
View File
@@ -0,0 +1,37 @@
name: Eagle Build
on:
push:
branches: [ "main" ]
paths:
- 'src/main/scala/**'
- 'src/main/protobuf/net/eagle0/eagle/**'
- 'src/main/protobuf/net/eagle0/common/**'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.github/workflows/eagle_build.yml'
pull_request:
paths:
- 'src/main/scala/**'
- 'src/main/protobuf/net/eagle0/eagle/**'
- 'src/main/protobuf/net/eagle0/common/**'
- 'WORKSPACE'
- 'MODULE.bazel'
- 'BUILD.bazel'
- '.github/workflows/eagle_build.yml'
permissions:
contents: read
jobs:
build:
runs-on: [self-hosted, bazel]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
lfs: false
- name: Build Eagle server
run: bazel build //src/main/scala/net/eagle0/eagle:eagle_server
+73 -30
View File
@@ -5,18 +5,20 @@ on:
branches: [ "main" ]
paths:
- ".github/workflows/installer_build.yml"
- "src/main/csharp/net/eagle0/clients/win/installer/**"
- "src/main/go/net/eagle0/clients/win/installer/**"
pull_request:
paths:
- ".github/workflows/installer_build.yml"
- "src/main/csharp/net/eagle0/clients/win/installer/**"
- "src/main/go/net/eagle0/clients/win/installer/**"
workflow_dispatch:
permissions:
contents: read
actions: write # Required to delete artifacts after deploy
jobs:
build-installer:
runs-on: self-hosted
runs-on: [self-hosted, bazel]
steps:
- uses: actions/checkout@v4
@@ -24,34 +26,47 @@ jobs:
lfs: false
clean: false
- name: Setup .NET 8
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Build Go installer for Windows
env:
MANIFEST_PUBLIC_KEY: ${{ secrets.MANIFEST_PUBLIC_KEY }}
run: |
# Require manifest public key for production builds
if [ -z "$MANIFEST_PUBLIC_KEY" ]; then
echo "ERROR: MANIFEST_PUBLIC_KEY secret is not set"
echo "The installer requires a public key for manifest signature verification"
exit 1
fi
- name: Restore dependencies
run: dotnet restore src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj
# Build Windows installer with WebView GUI (uses CGO cross-compilation)
# Use --action_env to pass the signing key into the genrule sandbox
bazel build //src/main/go/net/eagle0/clients/win/installer:eagle_installer_windows_amd64_webview --stamp --action_env=MANIFEST_PUBLIC_KEY
- name: Build installer
run: dotnet publish src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller/EagleInstaller.csproj -c Release -r win-x64 --self-contained true --output ./installer-output
# Copy to output directory
rm -rf ./installer-output
mkdir -p ./installer-output
cp bazel-bin/src/main/go/net/eagle0/clients/win/installer/Eagle0.exe ./installer-output/Eagle0.exe
echo "Go installer size: $(ls -lh ./installer-output/Eagle0.exe | awk '{print $5}')"
- name: Archive installer binary
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: eagle-installer
path: ./installer-output/EagleInstaller.exe
path: ./installer-output/
retention-days: 1
- name: Verify installer exists
if: success()
run: |
if [ ! -f "./installer-output/EagleInstaller.exe" ]; then
echo "ERROR: EagleInstaller.exe not found at expected location"
echo "Directory contents:"
ls -la ./installer-output/
echo "=== Installer output directory ==="
ls -lh ./installer-output/
if [ ! -f "./installer-output/Eagle0.exe" ]; then
echo "ERROR: Eagle0.exe not found"
exit 1
fi
echo "Installer found at correct location"
echo "Installer found"
- name: Deploy installer
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
@@ -59,24 +74,52 @@ jobs:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: |
INSTALLER_PATH="$(pwd)/installer-output/EagleInstaller.exe"
echo "Using absolute path: $INSTALLER_PATH"
bazel run //src/main/go/net/eagle0/build/installer_build_handler:installer_build_handler -- "$INSTALLER_PATH"
INSTALLER_PATH="$(pwd)/installer-output/Eagle0.exe"
echo "Deploying Go installer to installer/Eagle0.exe"
bazel run //src/main/go/net/eagle0/build/installer_build_handler:installer_build_handler -- "$INSTALLER_PATH" "installer/Eagle0.exe"
- name: Update unified manifest
- name: Update manifest
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
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
INSTALLER_SHA=$(sha256sum ./installer-output/EagleInstaller.exe | cut -d' ' -f1)
INSTALLER_SHA=$(sha256sum ./installer-output/Eagle0.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 ==="
echo "installer_url=installer/Eagle0.exe" >> /tmp/installer_manifest.txt
echo "=== 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
echo "========================"
# 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 v2 manifest at installer/v2/eagle0_manifest.txt
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- installer-v2 /tmp/installer_manifest.txt $SIGNING_ARGS
rm -f /tmp/manifest_signing_key
- name: Delete all installer artifacts
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
GH_TOKEN: ${{ github.token }}
run: |
# Delete ALL eagle-installer artifacts to free up storage
echo "Fetching all eagle-installer artifacts..."
artifact_ids=$(gh api "repos/${{ github.repository }}/actions/artifacts" \
--paginate -q '.artifacts[] | select(.name == "eagle-installer") | .id')
for id in $artifact_ids; do
echo "Deleting artifact ID: $id"
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$id" || true
done
echo "Cleanup complete"
@@ -0,0 +1,86 @@
name: iOS Addressables Build
on:
push:
branches: [ "main" ]
paths:
- ".github/workflows/ios_addressables_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/AddressableAssetsData/**"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Music/**"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Editor/BuildScript.cs"
- "ci/github_actions/build_ios_addressables.sh"
- "ci/github_actions/upload_addressables.sh"
pull_request:
paths:
- ".github/workflows/ios_addressables_build.yml"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/AddressableAssetsData/**"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Music/**"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/Assets/Editor/BuildScript.cs"
- "ci/github_actions/build_ios_addressables.sh"
- "ci/github_actions/upload_addressables.sh"
workflow_dispatch:
permissions:
contents: read
env:
# Runner-specific build directory to allow parallel builds on multiple runners
EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}
jobs:
build-ios-addressables:
runs-on: [self-hosted, macOS, unity-mac]
steps:
- name: Cleanup stale PR refs
run: |
# Prune stale PR refs from previous runs. These can accumulate on self-hosted runners
# and cause "reference broken" errors during checkout. Must be done
# before checkout to prevent "missing object" errors.
if [ -d ".git" ]; then
echo "Pruning stale PR refs..."
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
xargs -r git update-ref -d 2>/dev/null || true
fi
- uses: actions/checkout@v4
with:
lfs: true
clean: true
- name: Pull LFS files
run: git lfs pull
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh ios
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/restore_library.sh
- name: Build iOS Addressables
run: ./ci/github_actions/build_ios_addressables.sh "$EAGLE0_BUILD_DIR/editor_ios_addressables.log"
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: ./ci/github_actions/upload_addressables.sh iOS
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_ios_addressables.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_ios_addressables.log
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
+213
View File
@@ -0,0 +1,213 @@
name: iOS TestFlight
on:
workflow_dispatch:
inputs:
skip_upload:
description: 'Skip TestFlight upload (build and archive only)'
required: false
default: 'false'
type: boolean
permissions:
contents: read
actions: write
env:
# Runner-specific build directory to allow parallel builds on multiple runners
EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}
# Runner-specific keychain to avoid conflicts when multiple runners sign simultaneously
KEYCHAIN_NAME: ios-build-${{ github.run_id }}.keychain
jobs:
build-unity:
runs-on: [self-hosted, macOS, unity-mac]
outputs:
xcode_project_path: ${{ steps.build.outputs.xcode_project_path }}
steps:
- uses: actions/checkout@v4
with:
lfs: true
clean: true
fetch-depth: 0
- name: Pull LFS files
run: git lfs pull
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh ios
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/restore_library.sh
- name: Build iOS Unity Project
id: build
run: |
./ci/github_actions/build_unity_ios.sh "$EAGLE0_BUILD_DIR/eagle0iOS"
echo "xcode_project_path=$EAGLE0_BUILD_DIR/eagle0iOS" >> $GITHUB_OUTPUT
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: ios
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success()
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: ./ci/github_actions/upload_addressables.sh iOS
- name: Zip Xcode project for artifact
run: |
cd $EAGLE0_BUILD_DIR
# Use tar for speed - Xcode projects have many small files
tar -czf eagle0iOS.tar.gz eagle0iOS
- name: Upload Xcode project
uses: actions/upload-artifact@v4
with:
name: xcode-project-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0iOS.tar.gz
retention-days: 1
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_ios.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_ios.log
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
archive-and-upload:
needs: build-unity
runs-on: [self-hosted, macOS, unity-mac]
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: |
ci
scripts
- name: Clean download directory
run: rm -rf $EAGLE0_BUILD_DIR/eagle0iOS
- name: Download Xcode project
uses: actions/download-artifact@v4
with:
name: xcode-project-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}
- name: Extract Xcode project
run: |
cd $EAGLE0_BUILD_DIR
tar -xzf eagle0iOS.tar.gz
rm eagle0iOS.tar.gz
ls -la eagle0iOS/
- name: Install Signing Certificate
env:
IOS_CERTIFICATE: ${{ secrets.IOS_CERTIFICATE }}
IOS_CERTIFICATE_PWD: ${{ secrets.IOS_CERTIFICATE_PWD }}
run: |
# Generate random keychain password
KEYCHAIN_PASSWORD=$(openssl rand -base64 32)
echo "KEYCHAIN_PASSWORD=$KEYCHAIN_PASSWORD" >> $GITHUB_ENV
# Decode certificate
echo "$IOS_CERTIFICATE" | base64 --decode > certificate.p12
# Delete any existing keychain from previous runs
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security default-keychain -s "$KEYCHAIN_NAME"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security set-keychain-settings -t 3600 -u "$KEYCHAIN_NAME"
# Import certificate
security import certificate.p12 -k "$KEYCHAIN_NAME" -P "$IOS_CERTIFICATE_PWD" -T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
# Add keychain to search list
security list-keychains -d user -s "$KEYCHAIN_NAME" login.keychain
rm certificate.p12
- name: Install Provisioning Profile
env:
IOS_PROVISIONING_PROFILE: ${{ secrets.IOS_PROVISIONING_PROFILE }}
run: |
# Decode provisioning profile
echo "$IOS_PROVISIONING_PROFILE" | base64 --decode > profile.mobileprovision
# Extract UUID from provisioning profile
PROFILE_UUID=$(/usr/libexec/PlistBuddy -c "Print :UUID" /dev/stdin <<< $(security cms -D -i profile.mobileprovision))
echo "PROFILE_UUID=$PROFILE_UUID" >> $GITHUB_ENV
# Install to standard location
mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/$PROFILE_UUID.mobileprovision
rm profile.mobileprovision
echo "Installed provisioning profile: $PROFILE_UUID"
- name: Archive and Export IPA
env:
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
chmod +x ./ci/github_actions/archive_ios.sh
./ci/github_actions/archive_ios.sh "$EAGLE0_BUILD_DIR/eagle0iOS" "$EAGLE0_BUILD_DIR/archive" "$APPLE_TEAM_ID" "$PROFILE_UUID"
- name: Upload to TestFlight
if: ${{ github.event.inputs.skip_upload != 'true' }}
env:
APPLE_ID: ${{ secrets.APPLE_ID }}
APP_SPECIFIC_PASSWORD: ${{ secrets.APP_SPECIFIC_PASSWORD }}
run: |
chmod +x ./ci/github_actions/upload_testflight.sh
./ci/github_actions/upload_testflight.sh "$EAGLE0_BUILD_DIR/archive/eagle0.ipa"
- name: Upload IPA artifact
# Only keep artifact if we skipped TestFlight upload (for debugging)
if: success() && github.event.inputs.skip_upload == 'true'
uses: actions/upload-artifact@v4
with:
name: eagle0-ios-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/archive/eagle0.ipa
retention-days: 1
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
cleanup:
needs: [build-unity, archive-and-upload]
if: always()
runs-on: ubuntu-latest
steps:
- name: Delete intermediate artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
artifact_name="xcode-project-${{ github.run_id }}"
echo "Deleting artifact: $artifact_name"
artifact_id=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \
-q ".artifacts[] | select(.name == \"$artifact_name\") | .id")
if [ -n "$artifact_id" ]; then
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" || true
fi
+281 -36
View File
@@ -6,70 +6,138 @@ 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/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "ci/mac/**"
pull_request:
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/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "ci/mac/**"
workflow_dispatch:
inputs:
skip_notarization:
description: 'Skip notarization (for testing)'
skip_signing:
description: 'Skip code signing, notarization, and deploy (build only)'
required: false
default: 'false'
type: boolean
permissions:
contents: read
actions: write # Required to delete artifacts after deploy
env:
# Runner-specific build directory to allow parallel builds on multiple runners
EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}
# Runner-specific keychain to avoid conflicts when multiple runners sign simultaneously
KEYCHAIN_NAME: build-${{ github.run_id }}.keychain
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:
- name: Prune stale PR refs
run: |
# Self-hosted runners persist .git between runs. When a PR is updated,
# old local refs (refs/remotes/pull/*/merge) may point to commits whose
# objects were never fetched or have been pruned. Remove these stale refs
# before checkout to prevent "missing object" errors.
if [ -d ".git" ]; then
echo "Pruning stale PR refs..."
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
xargs -r git update-ref -d 2>/dev/null || true
fi
- uses: actions/checkout@v4
with:
lfs: true
clean: false
clean: true # Remove untracked files like old SparklePlugin.bundle
fetch-depth: 0 # For version numbering from git history
- name: Pull LFS files
run: git lfs pull
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh mac
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: mac
run: ./ci/github_actions/restore_library.sh
- name: Build Mac Unity
run: ./ci/github_actions/build_unity_mac.sh "/tmp/eagle0/eagle0MAC"
run: ./ci/github_actions/build_unity_mac.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC"
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: mac
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: ./ci/github_actions/upload_addressables.sh StandaloneOSX
- name: Inject Sparkle Framework
if: success()
env:
SPARKLE_EDDSA_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }}
run: |
chmod +x ./scripts/inject_sparkle.sh
./scripts/inject_sparkle.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
./scripts/inject_sparkle.sh "${{ env.EAGLE0_BUILD_DIR }}/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,40 +149,162 @@ jobs:
# Decode certificate
echo "$MACOS_CERTIFICATE" | base64 --decode > certificate.p12
# Delete any existing keychain from previous runs
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
# Create temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain || true
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
security default-keychain -s "$KEYCHAIN_NAME"
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
# Import certificate
security import certificate.p12 -k build.keychain -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign
echo "=== Importing certificate ==="
security import certificate.p12 -k "$KEYCHAIN_NAME" -P "$MACOS_CERTIFICATE_PWD" -T /usr/bin/codesign -T /usr/bin/security
# Allow codesign to access keychain
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" build.keychain
echo "=== Setting key partition list ==="
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME"
# Add keychain to search list (required for codesign to find certificates)
echo "=== Adding keychain to search list ==="
security list-keychains -d user -s "$KEYCHAIN_NAME" login.keychain
# Debug: Check what's in the keychain after import
echo "=== Debug: Identities in build keychain ==="
KEYCHAIN_PATH="$HOME/Library/Keychains/$KEYCHAIN_NAME-db"
security find-identity -v -p codesigning "$KEYCHAIN_PATH" || true
echo "=== Debug: All available identities ==="
security find-identity -v -p codesigning || true
# Clean up
rm certificate.p12
- name: Code Sign App
if: success() && github.ref == 'refs/heads/main' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch')
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"
./scripts/codesign_mac_app.sh "${{ env.EAGLE0_BUILD_DIR }}/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 "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" >> $GITHUB_OUTPUT
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain "$KEYCHAIN_NAME" 2>/dev/null || true
- name: Zip signed app for artifact
if: success() && steps.check-deploy.outputs.should_deploy == 'true'
run: |
cd ${{ env.EAGLE0_BUILD_DIR }}/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-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/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: ${{ env.EAGLE0_BUILD_DIR }}/editor_mac.log
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
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 ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Download signed app
uses: actions/download-artifact@v4
with:
name: signed-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Unzip signed app
run: |
cd ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
ditto -x -k eagle0.app.zip .
rm eagle0.app.zip
ls -la ${{ env.EAGLE0_BUILD_DIR }}/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 }}" "${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
- name: Zip notarized app for artifact
run: |
cd ${{ env.EAGLE0_BUILD_DIR }}/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-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app.zip
retention-days: 1
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
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 ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Download notarized app
uses: actions/download-artifact@v4
with:
name: notarized-mac-app-${{ github.run_id }}
path: ${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC
- name: Unzip notarized app
run: |
cd ${{ env.EAGLE0_BUILD_DIR }}/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 }}
@@ -125,25 +315,80 @@ jobs:
echo "$SPARKLE_EDDSA_PRIVATE_KEY" > "$SPARKLE_PRIVATE_KEY_PATH"
chmod 600 "$SPARKLE_PRIVATE_KEY_PATH"
VERSION=$(git describe --tags --always)
BUILD_NUMBER=$(git rev-list --count HEAD)
# Install dmgbuild (creates .DS_Store programmatically, no AppleScript needed)
pip3 install dmgbuild
# Background image for styled DMG
BACKGROUND_PATH="$(pwd)/ci/mac/dmg/background.png"
# Read version from the built app's Info.plist to ensure appcast matches the actual app
APP_PATH="${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app"
BUILD_NUMBER=$(/usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$APP_PATH/Contents/Info.plist")
VERSION=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$APP_PATH/Contents/Info.plist")
bazel run //src/main/go/net/eagle0/build/mac_build_handler:mac_build_handler -- \
"/tmp/eagle0/eagle0MAC/eagle0.app" \
"${{ env.EAGLE0_BUILD_DIR }}/eagle0MAC/eagle0.app" \
"$VERSION" \
"$BUILD_NUMBER" \
"$BACKGROUND_PATH" \
"$SPARKLE_PRIVATE_KEY_PATH"
rm "$SPARKLE_PRIVATE_KEY_PATH"
- name: Cleanup Keychain
if: always()
run: |
security delete-keychain build.keychain 2>/dev/null || true
# Export version for notify step
echo "DEPLOYED_VERSION=$VERSION" >> $GITHUB_ENV
- name: Archive Build Log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_mac.log
path: /tmp/eagle0/editor_mac.log
- name: Notify clients of update
if: success()
env:
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
run: |
# Wait for CDN cache to clear
sleep 60
# Notify via admin server (required=false for normal deploys)
curl -X POST "https://admin.eagle0.net/notify-update?platform=mac&version=$DEPLOYED_VERSION&required=false" \
-H "X-Notify-Secret: $NOTIFY_SECRET" \
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
- name: Delete this run's Mac app artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
# Delete this run's artifacts (names include run ID to avoid conflicts)
for artifact_name in signed-mac-app-${{ github.run_id }} notarized-mac-app-${{ github.run_id }}; do
echo "Deleting artifact: $artifact_name"
artifact_id=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \
-q ".artifacts[] | select(.name == \"$artifact_name\") | .id")
if [ -n "$artifact_id" ]; then
echo "Deleting artifact ID: $artifact_id"
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" || true
fi
done
echo "Cleanup complete"
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
# Cleanup job runs regardless of success/failure to prevent artifact accumulation
cleanup:
needs: [build-and-sign, wait-notarization, deploy]
if: always()
runs-on: ubuntu-latest
steps:
- name: Delete this run's Mac app artifacts
env:
GH_TOKEN: ${{ github.token }}
run: |
# Delete this run's artifacts (names include run ID to avoid conflicts)
for artifact_name in signed-mac-app-${{ github.run_id }} notarized-mac-app-${{ github.run_id }}; do
echo "Deleting artifact: $artifact_name"
artifact_id=$(gh api "repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/artifacts" \
-q ".artifacts[] | select(.name == \"$artifact_name\") | .id")
if [ -n "$artifact_id" ]; then
echo "Deleting artifact ID: $artifact_id"
gh api -X DELETE "repos/${{ github.repository }}/actions/artifacts/$artifact_id" || true
fi
done
echo "Cleanup complete"
+7 -7
View File
@@ -46,26 +46,26 @@ jobs:
for REPO in $REPOS; do
echo "=== Processing repository: ${REPO} ==="
# Get all manifests with their tags and dates
# Filter out header row and empty lines
MANIFESTS=$(doctl registry repository list-manifests "${REPO}" --format Digest,UpdatedAt,Tags --no-header 2>/dev/null | grep -v '^Digest' | grep -v '^$' || echo "")
# Get all manifests with their tags and dates using JSON output for reliable parsing
MANIFESTS_JSON=$(doctl registry repository list-manifests "${REPO}" --output json 2>/dev/null || echo "[]")
if [ -z "$MANIFESTS" ]; then
if [ "$MANIFESTS_JSON" = "[]" ] || [ -z "$MANIFESTS_JSON" ]; then
echo " No manifests found"
continue
fi
echo "$MANIFESTS" | while read -r DIGEST UPDATED_AT TAGS; do
# Parse JSON and process each manifest
echo "$MANIFESTS_JSON" | jq -r '.[] | "\(.digest) \(.updated_at) \(.tags // [] | join(","))"' | while read -r DIGEST UPDATED_AT TAGS; do
# Skip if no digest or if it doesn't look like a valid digest (sha256:...)
if [ -z "$DIGEST" ] || ! echo "$DIGEST" | grep -q '^sha256:'; then
continue
fi
# Parse the date
# Parse the date (ISO 8601 format from JSON)
MANIFEST_DATE=$(date -d "$UPDATED_AT" +%s 2>/dev/null || echo "0")
# Skip protected tags (latest, arm64-latest)
if echo "$TAGS" | grep -qE '(^|,)(latest|arm64-latest)(,|$)'; then
if echo ",$TAGS," | grep -qE ',(latest|arm64-latest),'; then
echo " KEEP: ${DIGEST:0:20}... (protected tag: $TAGS)"
continue
fi
+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
+89 -10
View File
@@ -6,67 +6,146 @@ 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"
- "ci/github_actions/build_unity.sh"
- "ci/github_actions/restore_library.sh"
- "ci/github_actions/persist_library.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "MODULE.bazel"
- "WORKSPACE"
- "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"
- "ci/github_actions/build_unity.sh"
- "ci/github_actions/restore_library.sh"
- "ci/github_actions/persist_library.sh"
- "ci/github_actions/upload_addressables.sh"
- "ci/github_actions/ensure_unity_installed.sh"
- "src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
- "MODULE.bazel"
- "WORKSPACE"
- "src/main/proto/**/BUILD.bazel"
permissions:
contents: read
env:
# Runner-specific build directory to allow parallel builds on multiple runners
EAGLE0_BUILD_DIR: /tmp/eagle0-${{ github.run_id }}
jobs:
windows-unity:
runs-on: self-hosted
runs-on: [self-hosted, macOS, unity-windows]
steps:
- name: Prune stale PR refs
run: |
# Self-hosted runners persist .git between runs. When a PR is updated,
# old local refs (refs/remotes/pull/*/merge) may point to commits whose
# objects were never fetched or have been pruned. Remove these stale refs
# before checkout to prevent "missing object" errors.
if [ -d ".git" ]; then
echo "Pruning stale PR refs..."
git for-each-ref --format='%(refname)' refs/remotes/pull/ 2>/dev/null | \
xargs -r git update-ref -d 2>/dev/null || true
fi
- uses: actions/checkout@v4
with:
lfs: true
clean: false
clean: true # Remove untracked files from previous builds
- name: Pull lfs files
run: git lfs pull
- name: Ensure Unity version installed
run: ./ci/github_actions/ensure_unity_installed.sh windows
- name: Restore Library/
env:
UNITY_CACHE_PLATFORM: windows
run: ./ci/github_actions/restore_library.sh
- name: Build Windows unity
run: ./ci/github_actions/build_unity.sh "/tmp/eagle0/eagle0WIN"
run: ./ci/github_actions/build_unity.sh "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN"
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: windows
run: ./ci/github_actions/persist_library.sh
- name: Upload Addressables to CDN
if: success() && github.ref == 'refs/heads/main' && github.event_name != 'pull_request'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: ./ci/github_actions/upload_addressables.sh StandaloneWindows64
- name: Deploy Windows unity
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "/tmp/eagle0/eagle0WIN" "/tmp/unity_manifest.txt"
run: bazel run //src/main/go/net/eagle0/build/unity3d_windows_build_handler:unity3d_windows_build_handler -- "${{ env.EAGLE0_BUILD_DIR }}/eagle0WIN" "/tmp/unity_manifest.txt"
- name: Update unified manifest
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
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 v2 manifest at installer/v2/eagle0_manifest.txt
bazel run //src/main/go/net/eagle0/build/manifest_manager:manifest_manager -- unity3d-v2 /tmp/unity_manifest.txt $SIGNING_ARGS
# Cleanup
rm -f /tmp/manifest_signing_key
- name: Notify clients of update
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
env:
NOTIFY_SECRET: ${{ secrets.EAGLE_NOTIFY_SECRET }}
run: |
# Wait for CDN cache to clear
sleep 60
# Get version from manifest
VERSION=$(grep "^version=" /tmp/unity_manifest.txt | cut -d= -f2 || date +%Y.%m.%d)
# Notify via admin server (required=false for normal deploys)
curl -X POST "https://admin.eagle0.net/notify-update?platform=windows&version=$VERSION&required=false" \
-H "X-Notify-Secret: $NOTIFY_SECRET" \
--fail --silent --show-error || echo "Warning: Failed to notify clients (non-fatal)"
- name: Archive build log
if: success() || failure()
uses: actions/upload-artifact@v4
with:
name: editor_win.log
path: /tmp/eagle0/editor_win.log
path: ${{ env.EAGLE0_BUILD_DIR }}/editor_win.log
retention-days: 5
- name: Cleanup build directory
if: always()
run: rm -rf "${{ env.EAGLE0_BUILD_DIR }}"
+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/
+12
View File
@@ -32,3 +32,15 @@ nogo(
vet = True,
visibility = ["//visibility:public"],
)
# Dependency constraint tests
# These verify architectural boundaries are maintained
sh_test(
name = "build_deps_test",
srcs = ["scripts/check_build_deps.sh"],
args = ["--ci"],
tags = [
"local", # Needs bazel query access
"no-sandbox",
],
)
+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.
-213
View File
@@ -1,213 +0,0 @@
# Deproto Migration Plan
This document tracks the migration from protobuf types to native Scala models inside the Eagle game engine.
## Architectural Decisions
1. **Keep proto for persistence**: Yes - protobuf is used for persisting game state
2. **Keep proto for Shardok communication**: Yes - protobuf is used for Eagle-Shardok gRPC communication
3. **Use Scala views inside the library**: Yes - use native Scala types like `ProvinceView`, `FactionView`, `HeroView`, etc. within the library code
## Recent Completed Work
### GameState Round-Trip Elimination (PRs #4913, #4914, #4915)
Eliminated wasteful Scala→proto→Scala conversions in the hot path:
1. **LLM Pipeline** (#4913): `LlmRequestWithGameState` now uses Scala `GameState` instead of proto. All ~38 prompt generators updated to use Scala model types (`FactionT`, `HeroT`, `ProvinceT`).
2. **ActionWithResultingState Caching** (#4914): Added `precomputedScalaState: Option[GameState]` to cache Scala state when available, avoiding `fromProto()` conversion in `stateAfter()`.
3. **PostResults Simplification** (#4915): Changed `PostResults.gameState` from proto to `Option[GameState]` (Scala), eliminating `toProto()` calls when creating PostResults.
## Migration Pattern
The codebase follows a **Legacy* pattern** for separating proto-dependent and protoless code:
- **Protoless utilities**: `FactionUtils`, `HeroUtils`, `ProvinceUtils`, `ProvinceDistances`, etc.
- **Proto-dependent utilities**: `LegacyFactionUtils`, `LegacyHeroUtils`, `LegacyProvinceUtils`, `LegacyProvinceDistances`, etc.
When migrating a file:
1. Create a `Legacy*` version containing the proto-dependent methods
2. Keep the original file name for protoless methods
3. Update callers to use the appropriate version based on their context
## Migration Status
### Fully Protoless (no proto imports)
**Utilities:**
- [x] `FactionUtils` - has protoless `ownedNeighbors` method
- [x] `ProvinceDistances` - split into protoless + `LegacyProvinceDistances`
- [x] `SwornBrotherChooser` - fully protoless (removed `bestChoiceProto`)
**Command Selectors (all use native GameState):**
- [x] `AllianceOfferCommandSelector`
- [x] `AlmsCommandSelector`
- [x] `AttackCommandChooser`
- [x] `ExpandCommandSelector`
- [x] `HeroGiftCommandSelector`
- [x] `ImproveCommandSelector`
- [x] `MarchTowardProvinceCommandChooser` - in AI folder, uses native GameState (callers convert)
- [x] `OrganizeCommandSelector`
- [x] `RansomOfferHelpers`
- [x] `SeekMoreLeadersCommandChooser` - in AI folder, uses native GameState
- [x] `TruceOfferCommandSelector`
- [x] `TrustForDiplomacy`
**Quest Command Selectors (all protoless):**
- [x] `AllianceQuestCommandChooser`
- [x] `AlmsAcrossRealmQuestCommandChooser`
- [x] `AlmsToProvinceQuestCommandChooser`
- [x] `DismissSpecificVassalCommandChooser`
- [x] `GiveToHeroesAcrossRealmQuestCommandChooser`
- [x] `GiveToHeroesInProvinceQuestCommandChooser`
- [x] `ImproveQuestCommandChooser`
- [x] `QuestCommandChooser`
- [x] `TruceCountQuestCommandChooser`
- [x] `TruceWithFactionQuestCommandChooser`
### Fully Protoless
- [x] `AIClientUtils` - has protoless overloads (`takenHeroIdsForMarchTowardFocus`, `mostPowerfulHeroes`)
- [x] `AttackCommandChooser` - uses Scala `GameState` and `BattalionViewC` (Scala view type)
- [x] `BattalionPower` - has `estimatedPower(BattalionViewC)` for recon data with optional stats
- [x] `CommandChoiceHelpers` - fully protoless, uses Scala `GameState` throughout
- [x] `ProvinceGoldSurplusCalculator` - fully protoless (callers use converters)
- [x] `HeroSelector` - fully protoless (removed dead `minimallyFatiguedHeroesProto`)
### AI Layer ✅ COMPLETE
All AI and command chooser code is now fully protoless:
- [x] `AttackDecisionCommandChooser` - uses Scala GameState
- [x] `CommandChooser` - trait uses Scala GameState
- [x] `FulfillQuestsCommandSelector` - uses Scala GameState
- [x] `MidGameAIClient` - uses Scala GameState internally
### Still Using Proto GameState (Boundary Code)
These files use proto GameState because they're at system boundaries:
**View Filters (client projection):**
- `view_filters/GameStateViewFilter` - has Scala overload, uses Scala sub-filters
- `view_filters/ProvinceViewFilter` - has Scala overloads for some methods
- `view_filters/FactionViewFilter` - has Scala overload
- `view_filters/HeroViewFilter` - has Scala overload
- `view_filters/BattalionNameFilter` - has Scala overload
- `view_filters/BattleFilter` - has Scala overload
**Legacy Utilities (to be deprecated):**
- `LegacyProvinceDistances`, `LegacyFactionUtils`, `LegacyHeroUtils`, etc.
- Used by code that still needs proto GameState
**Persistence/Action System:**
- `ActionResultTApplier`, `ActionResultProtoApplier` - apply results to proto state
- `ActionWithResultingState` - caches both proto and Scala state
**Shardok Interface (gRPC boundary):**
- `ShardokInterfaceGrpcClient`, `ShardokInterfaceProxy` - must use proto for C++ communication
## Next Steps
### Phase 1-3: AI Layer ✅ COMPLETE
The entire AI decision-making layer is now protoless.
### Phase 4: View Filters ✅ COMPLETE
The view_filters package migration is complete:
**Completed:**
- [x] `GameStateViewFilter` - added Scala GameState overload (uses Scala sub-filters)
- [x] `ProvinceViewFilter` - already has Scala overloads for server-side views
- [x] `HumanPlayerClientConnectionState` - updated to pass Scala GameState directly
- [x] `HeroViewFilter` - added Scala overload
- [x] `FactionViewFilter` - added Scala overload
- [x] `Visibility` - added Scala overloads
**Still Using Proto:**
- [x] `BattalionNameFilter` - has Scala overload
- [x] `BattleFilter` - has Scala overload
- [ ] `ActionResultFilter` - uses proto internally (boundary code)
**Strategy:**
1. Add Scala GameState overloads to view filter methods
2. Update callers to pass Scala GameState where available
3. Eventually deprecate proto versions
### Phase 5: Legacy Utility Cleanup (IN PROGRESS)
Remove Legacy* utilities by migrating remaining callers:
1. Identify callers of each Legacy* util
2. Update callers to use protoless versions
3. Delete Legacy* files when no longer needed
**Deleted (no production callers):**
- [x] `LegacyProvinceDistances` - deleted (no callers)
- [x] `LegacyBattalionSuitability` - deleted (no callers)
- [x] `LegacyFoodConsumptionUtils` - deleted (no callers)
- [x] `LegacyHandleRiotUtils` - deleted (no callers)
**Refactored to Thin Wrappers (delegating to protoless versions):**
- [x] `LegacyRansomValidity` - already a thin wrapper delegating to `RansomValidity`
- [x] `LegacyRecruitmentOdds` - refactored to delegate to `RecruitmentOdds`
**Parallel Implementations (proto mirrors protoless):**
- [x] `FactionUtils` / `LegacyFactionUtils` - both have matching APIs; LegacyFactionUtils used by boundary code (24+ callers)
- [x] `HeroUtils` / `LegacyHeroUtils` - both have matching APIs; LegacyHeroUtils used by boundary code (10 callers)
- [x] `ProvinceUtils` / `LegacyProvinceUtils` - both have matching APIs; LegacyProvinceUtils used by boundary code (20 callers: availability factories, view filters)
**Parallel Implementations (awaiting migration of callers):**
- [x] `BattalionUtils` / `LegacyBattalionUtils` - both have matching core methods; LegacyBattalionUtils used by boundary code (4 callers)
- [x] `BattalionViewFilter` / `LegacyBattalionViewFilter` - protoless version exists; Legacy used by view filters, action appliers (3 callers)
- [x] `BattalionTypeFinder` / `LegacyBattalionTypeFinder` - protoless version exists; Legacy used by validators (1 caller: RuntimeValidator)
### Recent Caller Migration
**CheckForFulfilledQuestsAction** - migrated to use protoless `BattalionTypeFinder`:
- Changed `battalionTypes` parameter from proto `Vector[BattalionType]` to Scala `Vector[BattalionType]`
- Updated callers (EngineImpl, EndVassalCommandsPhaseAction) to pass Scala types directly
- Eliminated wasteful `BattalionTypeConverter.toProto()` conversions
**ExpandedUnaffiliatedHeroUtils** - added Scala overload:
- New overload takes Scala `GameState` and `UnaffiliatedHeroT` directly
- Added `UnaffiliatedHeroConverter.unaffiliatedHeroTypeToProto()` helper for efficient enum conversion
- Proto overload retained for backward compatibility
**AvailablePleaseRecruitMeCommandFactory** - eliminated wasteful proto conversions:
- Now uses `ExpandedUnaffiliatedHeroUtils.expandedUnaffiliatedHero(ScalaGameState, UnaffiliatedHeroT)` directly
- Removed `GameStateConverter.toProto()` and `UnaffiliatedHeroConverter.toProto()` calls
- Factory is now fully protoless internally (still returns proto types for API boundary)
**ProvinceViewFilter** - added Scala overload with faction filtering:
- New `filteredProvinceView(province: ProvinceT, gs: ScalaGameState, factionId: FactionId)` overload
- Uses protoless `FactionUtils.hasAlliance`, `Visibility.hasFullVisibility`, and `ProvinceUtils.incomingOthers`
- Handles reconned provinces directly from Scala `FactionT.reconnedProvinces` (already Scala type)
- Added helper methods: `fullProvinceInfoScala`, `maybeIncomingAttackersScala`, `unaffiliatedHeroInfoScala`
- Events still converted to proto at the end (ProvinceView.knownEvents uses proto events)
**GameStateViewFilter** - eliminated GameStateConverter.toProto() call:
- Scala overload now fully protoless internally
- Uses the new ProvinceViewFilter Scala overload with faction filtering
- Converts `battalionTypes` and `chronicleEntries` to proto only at output boundary
## Key Files
### Protoless Model Types
- `src/main/scala/net/eagle0/eagle/model/state/game_state/GameState.scala` - native Scala GameState
- `src/main/scala/net/eagle0/eagle/model/state/province/ProvinceView.scala` - province view type
- `src/main/scala/net/eagle0/eagle/model/state/faction/FactionView.scala` - faction view type
- `src/main/scala/net/eagle0/eagle/model/state/hero/HeroView.scala` - hero view type
### Proto Converters
- `src/main/scala/net/eagle0/eagle/model/proto_converters/game_state/` - converts between proto and Scala types
## Notes
- The AI client code (`src/main/scala/net/eagle0/eagle/ai/`) is now fully protoless
- Proto GameState is still needed at boundaries: persistence, gRPC to Shardok
- `PerformUnaffiliatedHeroesAction` and the LLM pipeline use protoless `GameState`
- `GameStateViewFilter` Scala overload is now fully protoless internally (converts to proto only at output)
- `ProvinceViewFilter` has Scala overloads for all three modes: no filtering, faction filtering, and withdrawn-from view
+49 -15
View File
@@ -7,14 +7,14 @@ NETTY_VERSION = "4.1.110.Final"
SCALAPB_VERSION = "1.0.0-alpha.1"
AWS_SDK_VERSION = "2.28.1"
AWS_SDK_VERSION = "2.41.18"
#
# 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
@@ -47,14 +47,14 @@ llvm = use_extension("@toolchains_llvm//toolchain/extensions:llvm.bzl", "llvm")
# Native toolchain (macOS -> macOS, Linux -> Linux)
llvm.toolchain(
name = "llvm_toolchain",
llvm_version = "20.1.2",
llvm_version = "20.1.4",
)
# Cross-compilation toolchain (macOS -> Linux x86_64)
# Uses the same LLVM distribution but with a Linux sysroot
llvm.toolchain(
name = "llvm_toolchain_linux",
llvm_version = "20.1.2",
llvm_version = "20.1.4",
)
# Linux x86_64 sysroot for cross-compilation
@@ -67,7 +67,7 @@ llvm.sysroot(
# Cross-compilation toolchain (macOS -> Linux ARM64)
llvm.toolchain(
name = "llvm_toolchain_linux_arm64",
llvm_version = "20.1.2",
llvm_version = "20.1.4",
)
# Linux ARM64 sysroot for cross-compilation
@@ -102,11 +102,12 @@ 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")
use_repo(go_sdk, "go_default_sdk")
go_deps = use_extension("@bazel_gazelle//:extensions.bzl", "go_deps")
go_deps.from_file(go_mod = "//:go.mod")
@@ -118,8 +119,10 @@ use_repo(
"com_github_aws_aws_sdk_go_v2_service_s3",
"com_github_golang_jwt_jwt_v5",
"com_github_google_uuid",
"com_github_webview_webview_go",
"org_golang_google_grpc",
"org_golang_google_protobuf",
"org_golang_x_sys",
)
#
@@ -130,6 +133,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 +147,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 +159,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 +196,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(
@@ -248,7 +258,7 @@ maven.install(
"com.nimbusds:nimbus-jose-jwt:9.37.3",
# Error tracking
"io.sentry:sentry:7.19.0",
"io.sentry:sentry:8.31.0",
],
duplicate_version_warning = "error",
fail_if_repin_required = True,
@@ -293,14 +303,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",
@@ -318,6 +339,19 @@ http_file(
executable = True,
)
# LLVM MinGW toolchain for Windows cross-compilation from macOS
# This provides a complete toolchain for building Windows executables including
# the MinGW-w64 libraries needed for CGO cross-compilation
LLVM_MINGW_VERSION = "20250305"
http_archive(
name = "llvm_mingw",
build_file = "@//external:BUILD.llvm_mingw",
sha256 = "32c24fc62fc8b9f8a900bf2c730b78b36767688f816f9d21e97a168289ff44e0",
strip_prefix = "llvm-mingw-%s-ucrt-macos-14.4.1-universal" % LLVM_MINGW_VERSION,
urls = ["https://github.com/fathonix/llvm-mingw-arm64ec-macos/releases/download/%s/llvm-mingw-%s-ucrt-macos-14.4.1-universal.tar.xz" % (LLVM_MINGW_VERSION, LLVM_MINGW_VERSION)],
)
#
# Toolchain Registration
#
+589 -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,470 @@
"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~"
]
]
}
},
"@@aspect_rules_js~//npm:extensions.bzl%pnpm": {
"general": {
"bzlTransitiveDigest": "T22SdPzhLxF1CM+j9RD/Rq03yJ0NDfH2eE6hAbUcOII=",
"usagesDigest": "6rWte4KDbiluq1s7w98bc4+2NjA8w67DKHDj4+DNw/Y=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"pnpm": {
"bzlFile": "@@aspect_rules_js~//npm/private:npm_import.bzl",
"ruleClassName": "npm_import_rule",
"attributes": {
"package": "pnpm",
"version": "8.6.7",
"root_package": "",
"link_workspace": "",
"link_packages": {},
"integrity": "sha512-vRIWpD/L4phf9Bk2o/O2TDR8fFoJnpYrp2TKqTIZF/qZ2/rgL3qKXzHofHgbXsinwMoSEigz28sqk3pQ+yMEQQ==",
"url": "",
"commit": "",
"patch_args": [
"-p0"
],
"patches": [],
"custom_postinstall": "",
"npm_auth": "",
"npm_auth_basic": "",
"npm_auth_username": "",
"npm_auth_password": "",
"lifecycle_hooks": [],
"extra_build_content": "load(\"@aspect_rules_js//js:defs.bzl\", \"js_binary\")\njs_binary(name = \"pnpm\", data = glob([\"package/**\"]), entry_point = \"package/dist/pnpm.cjs\", visibility = [\"//visibility:public\"])",
"generate_bzl_library_targets": false,
"extract_full_archive": true,
"exclude_package_contents": [],
"system_tar": "auto"
}
},
"pnpm__links": {
"bzlFile": "@@aspect_rules_js~//npm/private:npm_import.bzl",
"ruleClassName": "npm_import_links",
"attributes": {
"package": "pnpm",
"version": "8.6.7",
"dev": false,
"root_package": "",
"link_packages": {},
"deps": {},
"transitive_closure": {},
"lifecycle_build_target": false,
"lifecycle_hooks_env": [],
"lifecycle_hooks_execution_requirements": [
"no-sandbox"
],
"lifecycle_hooks_use_default_shell_env": false,
"bins": {},
"package_visibility": [
"//visibility:public"
],
"replace_package": "",
"exclude_package_contents": []
}
}
},
"recordedRepoMappingEntries": [
[
"aspect_bazel_lib~",
"bazel_lib",
"bazel_lib~"
],
[
"aspect_bazel_lib~",
"bazel_skylib",
"bazel_skylib~"
],
[
"aspect_bazel_lib~",
"bazel_tools",
"bazel_tools"
],
[
"aspect_bazel_lib~",
"tar.bzl",
"tar.bzl~"
],
[
"aspect_rules_js~",
"aspect_bazel_lib",
"aspect_bazel_lib~"
],
[
"aspect_rules_js~",
"aspect_rules_js",
"aspect_rules_js~"
],
[
"aspect_rules_js~",
"bazel_features",
"bazel_features~"
],
[
"aspect_rules_js~",
"bazel_skylib",
"bazel_skylib~"
],
[
"aspect_rules_js~",
"bazel_tools",
"bazel_tools"
],
[
"bazel_features~",
"bazel_features_globals",
"bazel_features~~version_extension~bazel_features_globals"
],
[
"bazel_features~",
"bazel_features_version",
"bazel_features~~version_extension~bazel_features_version"
],
[
"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~"
]
]
}
},
"@@aspect_rules_ts~//ts:extensions.bzl%ext": {
"general": {
"bzlTransitiveDigest": "h1hftyFCdJgiHD9blfFcMAiEk5ltEoUdNkuHPIkg/hM=",
"usagesDigest": "v0aTa4/gasWF2bvssXYr1bqcYWc3kjV48hcj0z2QVT0=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"npm_typescript": {
"bzlFile": "@@aspect_rules_ts~//ts/private:npm_repositories.bzl",
"ruleClassName": "http_archive_version",
"attributes": {
"bzlmod": true,
"version": "5.8.3",
"integrity": "",
"build_file": "@@aspect_rules_ts~//ts:BUILD.typescript",
"build_file_substitutions": {
"bazel_worker_version": "5.4.2",
"google_protobuf_version": "3.20.1"
},
"urls": [
"https://registry.npmjs.org/typescript/-/typescript-{}.tgz"
]
}
}
},
"recordedRepoMappingEntries": [
[
"aspect_rules_ts~",
"bazel_tools",
"bazel_tools"
]
]
}
},
"@@cel-spec~//:extensions.bzl%non_module_dependencies": {
"general": {
"bzlTransitiveDigest": "/2uyuQa5purSharolRXYOGYMGSGjDeByo6JfQDWsraA=",
"usagesDigest": "2f6juplOpWu+UdD1kVgi773xavnFQ+OcH0PRuQduDxY=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"com_google_googleapis": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"sha256": "bd8e735d881fb829751ecb1a77038dda4a8d274c45490cb9fcf004583ee10571",
"strip_prefix": "googleapis-07c27163ac591955d736f3057b1619ece66f5b99",
"urls": [
"https://github.com/googleapis/googleapis/archive/07c27163ac591955d736f3057b1619ece66f5b99.tar.gz"
]
}
}
},
"recordedRepoMappingEntries": [
[
"cel-spec~",
"bazel_tools",
"bazel_tools"
]
]
}
},
"@@cel-spec~//:googleapis_ext.bzl%googleapis_ext": {
"general": {
"bzlTransitiveDigest": "yun2jmsomFi3bs5bjQWXApBzqQf66zBJ39JEBYigzdc=",
"usagesDigest": "OK8FsLndSl2AbwGM1Npe5NdHR1kDAebcw7Ee+KkekE0=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"com_google_googleapis_imports": {
"bzlFile": "@@cel-spec~~non_module_dependencies~com_google_googleapis//:repository_rules.bzl",
"ruleClassName": "switched_rules",
"attributes": {
"rules": {
"proto_library_with_info": [
"",
""
],
"moved_proto_library": [
"",
""
],
"java_proto_library": [
"",
""
],
"java_grpc_library": [
"",
""
],
"java_gapic_library": [
"",
""
],
"java_gapic_test": [
"",
""
],
"java_gapic_assembly_gradle_pkg": [
"",
""
],
"py_proto_library": [
"",
""
],
"py_grpc_library": [
"",
""
],
"py_gapic_library": [
"",
""
],
"py_test": [
"",
""
],
"py_gapic_assembly_pkg": [
"",
""
],
"py_import": [
"",
""
],
"go_proto_library": [
"",
""
],
"go_library": [
"",
""
],
"go_test": [
"",
""
],
"go_gapic_library": [
"",
""
],
"go_gapic_assembly_pkg": [
"",
""
],
"cc_proto_library": [
"native.cc_proto_library",
""
],
"cc_grpc_library": [
"",
""
],
"cc_gapic_library": [
"",
""
],
"php_proto_library": [
"",
"php_proto_library"
],
"php_grpc_library": [
"",
"php_grpc_library"
],
"php_gapic_library": [
"",
"php_gapic_library"
],
"php_gapic_assembly_pkg": [
"",
"php_gapic_assembly_pkg"
],
"nodejs_gapic_library": [
"",
"typescript_gapic_library"
],
"nodejs_gapic_assembly_pkg": [
"",
"typescript_gapic_assembly_pkg"
],
"ruby_proto_library": [
"",
""
],
"ruby_grpc_library": [
"",
""
],
"ruby_ads_gapic_library": [
"",
""
],
"ruby_cloud_gapic_library": [
"",
""
],
"ruby_gapic_assembly_pkg": [
"",
""
],
"csharp_proto_library": [
"",
""
],
"csharp_grpc_library": [
"",
""
],
"csharp_gapic_library": [
"",
""
],
"csharp_gapic_assembly_pkg": [
"",
""
]
}
}
}
},
"recordedRepoMappingEntries": [
[
"cel-spec~",
"com_google_googleapis",
"cel-spec~~non_module_dependencies~com_google_googleapis"
]
]
}
},
"@@envoy_api~//bazel:repositories.bzl%non_module_deps": {
"general": {
"bzlTransitiveDigest": "6TqmRfVELxZJRPQYuJpC4JBX4QvdrHqTDOUJGOGODSo=",
"usagesDigest": "IivjlawPvqhHPUJ3c6dLiPsH22mn/g/dJtlmv3zdimM=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"prometheus_metrics_model": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/prometheus/client_model/archive/v0.6.1.tar.gz"
],
"sha256": "b9b690bc35d80061f255faa7df7621eae39fe157179ccd78ff6409c3b004f05e",
"strip_prefix": "client_model-0.6.1",
"build_file_content": "\nload(\"@envoy_api//bazel:api_build_system.bzl\", \"api_cc_py_proto_library\")\nload(\"@io_bazel_rules_go//proto:def.bzl\", \"go_proto_library\")\n\napi_cc_py_proto_library(\n name = \"client_model\",\n srcs = [\n \"io/prometheus/client/metrics.proto\",\n ],\n visibility = [\"//visibility:public\"],\n)\n\ngo_proto_library(\n name = \"client_model_go_proto\",\n importpath = \"github.com/prometheus/client_model/go\",\n proto = \":client_model\",\n visibility = [\"//visibility:public\"],\n)\n"
}
},
"com_github_bufbuild_buf": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/bufbuild/buf/releases/download/v1.49.0/buf-Linux-x86_64.tar.gz"
],
"sha256": "ee8da9748249f7946d79191e36469ce7bc3b8ba80019bff1fa4289a44cbc23bf",
"strip_prefix": "buf",
"build_file_content": "\npackage(\n default_visibility = [\"//visibility:public\"],\n)\n\nfilegroup(\n name = \"buf\",\n srcs = [\n \"@com_github_bufbuild_buf//:bin/buf\",\n ],\n tags = [\"manual\"], # buf is downloaded as a linux binary; tagged manual to prevent build for non-linux users\n)\n"
}
},
"envoy_toolshed": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"urls": [
"https://github.com/envoyproxy/toolshed/archive/bazel-v0.2.2.tar.gz"
],
"sha256": "443fe177aba0cef8c17b7a48905c925c67b09005b10dd70ff12cd9f729a72d51",
"strip_prefix": "toolshed-bazel-v0.2.2/bazel"
}
}
},
"recordedRepoMappingEntries": [
[
"envoy_api~",
"bazel_tools",
"bazel_tools"
],
[
"envoy_api~",
"envoy_api",
"envoy_api~"
]
]
}
@@ -727,6 +1218,37 @@
"recordedRepoMappingEntries": []
}
},
"@@pybind11_bazel~//:internal_configure.bzl%internal_configure_extension": {
"general": {
"bzlTransitiveDigest": "CyAKLVVonohnkTSqg9II/HA7M49sOlnMkgMHL3CmDuc=",
"usagesDigest": "mFrTHX5eCiNU/OIIGVHH3cOILY9Zmjqk8RQYv8o6Thk=",
"recordedFileInputs": {
"@@pybind11_bazel~//MODULE.bazel": "e6f4c20442eaa7c90d7190d8dc539d0ab422f95c65a57cc59562170c58ae3d34"
},
"recordedDirentsInputs": {},
"envVariables": {},
"generatedRepoSpecs": {
"pybind11": {
"bzlFile": "@@bazel_tools//tools/build_defs/repo:http.bzl",
"ruleClassName": "http_archive",
"attributes": {
"build_file": "@@pybind11_bazel~//:pybind11-BUILD.bazel",
"strip_prefix": "pybind11-2.12.0",
"urls": [
"https://github.com/pybind/pybind11/archive/v2.12.0.zip"
]
}
}
},
"recordedRepoMappingEntries": [
[
"pybind11_bazel~",
"bazel_tools",
"bazel_tools"
]
]
}
},
"@@rules_foreign_cc~//foreign_cc:extensions.bzl%tools": {
"general": {
"bzlTransitiveDigest": "a7qnESofmIRYId6wwGNPJ9kvExU80KrkxL281P3+lBE=",
@@ -1162,7 +1684,7 @@
"@@rules_nodejs~//nodejs:extensions.bzl%node": {
"general": {
"bzlTransitiveDigest": "q44Ox2Nwogn6OsO0Xw5lhjkd/xmxkvvpwVOn5P4pmHQ=",
"usagesDigest": "WQpLKLujnBfrx9sMWCJgyaK9P04binseT6CGBy3vP4E=",
"usagesDigest": "Py5Wgc5kr5fTMe1FKrlFK276B6SodesXp6nw2Fq5XA8=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
@@ -1292,8 +1814,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 +2102,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 +5615,7 @@
"@@rules_swift~//swift:extensions.bzl%non_module_deps": {
"general": {
"bzlTransitiveDigest": "PAIMhc1bVKfcyoHeg0xO8LMS9KN5yzbsMGwa5O2ifJM=",
"usagesDigest": "A3fzk5iHsrLdI3PokT1bHIdeJ2j9tc09H3/3Old6IfU=",
"usagesDigest": "l2vIL7SL4tJqHIVLWd78Y/ym+r0II64lmvoX8o+0Bb0=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
+21
View File
@@ -1,6 +1,19 @@
load("@rules_oci//oci:defs.bzl", "oci_image", "oci_load", "oci_push")
load("@rules_pkg//pkg:tar.bzl", "pkg_tar")
#
# Deployment artifacts (tools needed on the host, not in containers)
#
pkg_tar(
name = "warmup_tar",
srcs = ["//src/main/go/net/eagle0/warmup:warmup_linux_amd64"],
package_dir = "bin",
remap_paths = {
"/warmup_linux_amd64": "/warmup",
},
)
#
# Shared utilities layer (busybox for nc, wget, etc.)
#
@@ -317,6 +330,13 @@ pkg_tar(
package_dir = "/app",
)
# Package the attributions.json for the credits page
pkg_tar(
name = "auth_attributions_layer",
srcs = ["//src/main/resources/net/eagle0:attributions"],
package_dir = "/app",
)
oci_image(
name = "auth_server_image",
base = "@alpine_linux_linux_amd64",
@@ -328,6 +348,7 @@ oci_image(
tars = [
":busybox_layer",
":auth_binary_layer",
":auth_attributions_layer",
],
workdir = "/app",
)
-28
View File
@@ -1,28 +0,0 @@
#!/usr/bin/env bash
/bin/mkdir -p win_output
/usr/bin/dotnet publish src/main/csharp/net/eagle0/clients/win/installer/EagleInstaller.sln -o win_output
SHA=`sha256sum /tmp/EagleInstaller.exe | awk '{print $1 }'`
ZIP_FILE="updater__$SHA.zip"
/usr/bin/zip win_output/$ZIP_FILE win_output/EagleInstaller.exe
rm win_output/EagleInstaller.exe
rm win_output/EagleInstaller.pdb
DATE=`date +"%Y-%m-%d %T"`
cat > win_output/updater.html <<-EOF
<html>
<head>
<title>Download Eagle Updater</title>
</head>
<body>
<a href="http://eagle0.net/assets/$ZIP_FILE">$ZIP_FILE</a> (updated $DATE)
</body>
</html>
EOF
SSH_KEY_FILE=$1
SSH_USER_NAME=$2
/usr/bin/rsync -r --copy-links -e "/usr/bin/ssh -i $SSH_KEY_FILE -p 9022" win_output/ $SSH_USER_NAME@eagle0.net:/www/assets/
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env bash
# Archive and export iOS app for App Store / TestFlight
set -euxo pipefail
# Ensure xcodebuild uses Xcode.app, not Command Line Tools
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
XCODE_PROJECT_PATH=${1:?Usage: archive_ios.sh <xcode_project_path> <output_path> <team_id> <profile_uuid>}
OUTPUT_PATH=${2:?Missing output path}
TEAM_ID=${3:?Missing team ID}
PROFILE_UUID=${4:?Missing provisioning profile UUID}
ARCHIVE_PATH="$OUTPUT_PATH/eagle0.xcarchive"
EXPORT_PATH="$OUTPUT_PATH"
echo "Archiving iOS app..."
echo " Xcode project: $XCODE_PROJECT_PATH"
echo " Archive path: $ARCHIVE_PATH"
echo " Team ID: $TEAM_ID"
mkdir -p "$OUTPUT_PATH"
# Find the .xcodeproj file
XCODEPROJ=$(find "$XCODE_PROJECT_PATH" -name "*.xcodeproj" -maxdepth 1 | head -1)
if [ -z "$XCODEPROJ" ]; then
echo "Error: No .xcodeproj found in $XCODE_PROJECT_PATH"
exit 1
fi
echo "Found Xcode project: $XCODEPROJ"
# Unity always generates "Unity-iPhone" as the main app scheme
SCHEME="Unity-iPhone"
echo "Using scheme: $SCHEME"
# Archive without signing - we'll sign during export
# This avoids issues with provisioning profiles on framework targets
xcodebuild archive \
-project "$XCODEPROJ" \
-scheme "$SCHEME" \
-archivePath "$ARCHIVE_PATH" \
-destination "generic/platform=iOS" \
CODE_SIGN_IDENTITY="-" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO
echo "Archive complete: $ARCHIVE_PATH"
# Create export options plist
# Signing happens here, not during archive
EXPORT_OPTIONS_PLIST="$OUTPUT_PATH/ExportOptions.plist"
cat > "$EXPORT_OPTIONS_PLIST" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>method</key>
<string>app-store-connect</string>
<key>teamID</key>
<string>$TEAM_ID</string>
<key>uploadSymbols</key>
<true/>
<key>signingStyle</key>
<string>manual</string>
<key>signingCertificate</key>
<string>Apple Distribution: Daniel Crosby (UWJ88DX8WQ)</string>
<key>provisioningProfiles</key>
<dict>
<key>net.eagle0.eagle</key>
<string>$PROFILE_UUID</string>
</dict>
</dict>
</plist>
EOF
echo "Exporting IPA..."
# Debug: List available keychains and signing identities
echo "=== Debug: Available keychains ==="
security list-keychains -d user
echo "=== Debug: Available signing identities ==="
security find-identity -v -p codesigning
echo "=== Debug: Export options plist ==="
cat "$EXPORT_OPTIONS_PLIST"
echo "=== End debug ==="
# Export IPA (removed -allowProvisioningUpdates as we're using manual signing)
xcodebuild -exportArchive \
-archivePath "$ARCHIVE_PATH" \
-exportPath "$EXPORT_PATH" \
-exportOptionsPlist "$EXPORT_OPTIONS_PLIST"
# Find and rename the IPA to a consistent name
IPA_FILE=$(find "$EXPORT_PATH" -name "*.ipa" | head -1)
if [ -n "$IPA_FILE" ] && [ "$IPA_FILE" != "$EXPORT_PATH/eagle0.ipa" ]; then
mv "$IPA_FILE" "$EXPORT_PATH/eagle0.ipa"
fi
echo "Export complete: $EXPORT_PATH/eagle0.ipa"
ls -la "$EXPORT_PATH"
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Build iOS Addressables only (no player build)
# This switches Unity to iOS target and builds addressables for CDN upload
set -euxo pipefail
# Read Unity version from project file
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
WORKSPACE=$(pwd)
echo "Building protos"
./scripts/build_protos.sh
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
LOG_PATH=${1:-"${BUILD_BASE}/editor_ios_addressables.log"}
echo "Building iOS Addressables"
mkdir -p "$(dirname "$LOG_PATH")"
# Build Addressables for iOS target
# Uses BuildiOSAddressables which explicitly switches build target
# Capture exit code to show log on failure
set +e
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-nographics \
-batchmode \
-quit \
-executeMethod BuildScript.BuildiOSAddressables \
-logFile "$LOG_PATH" \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
UNITY_EXIT_CODE=$?
set -e
if [ $UNITY_EXIT_CODE -ne 0 ]; then
echo ""
echo "Unity build failed with exit code $UNITY_EXIT_CODE"
echo "=== Unity Editor Log (last 200 lines) ==="
tail -200 "$LOG_PATH" || echo "Could not read log file at $LOG_PATH"
echo "=== End of Unity Editor Log ==="
exit $UNITY_EXIT_CODE
fi
echo "iOS Addressables build complete"
echo "Bundles should be in: $WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0/ServerData/iOS/"
+18 -2
View File
@@ -2,7 +2,8 @@
set -euxo pipefail
. ./ci/unity_version.sh
# Read Unity version from project file
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
WORKSPACE=$(pwd)
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
@@ -15,10 +16,25 @@ echo "Cleaning up $BUILD_DIR"
/bin/rm -rf "$BUILD_DIR"
/bin/mkdir -p "$BUILD_DIR"
# Use custom build script that builds Addressables before the player
# Capture exit code to show log on failure
set +e
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-nographics \
-batchmode \
-quit \
-buildOSXUniversalPlayer "$BUILD_DIR/eagle0.app" \
-executeMethod BuildScript.BuildMacPlayer \
-buildPath "$BUILD_DIR/eagle0.app" \
-logFile "$LOG_PATH" \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
UNITY_EXIT_CODE=$?
set -e
if [ $UNITY_EXIT_CODE -ne 0 ]; then
echo ""
echo "Unity build failed with exit code $UNITY_EXIT_CODE"
echo "=== Unity Editor Log (last 200 lines) ==="
tail -200 "$LOG_PATH" || echo "Could not read log file at $LOG_PATH"
echo "=== End of Unity Editor Log ==="
exit $UNITY_EXIT_CODE
fi
+3 -5
View File
@@ -2,20 +2,18 @@
set -euxo pipefail
LOG_PATH=""
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
COMMIT=$(/usr/bin/git rev-parse --short HEAD)
/bin/echo "build protos"
./scripts/build_protos.sh
/bin/echo "build plugins"
./scripts/build_windows_plugin.sh
git log -3
/bin/echo "build Windows"
LOG_PATH="/tmp/eagle0/editor_win.log"
LOG_PATH="${BUILD_BASE}/editor_win.log"
BUILD_DIR=$1
./ci/github_actions/build_windows.sh $BUILD_DIR $LOG_PATH
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Build iOS Unity player (generates Xcode project)
set -euxo pipefail
# Read Unity version from project file
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
WORKSPACE=$(pwd)
BUILD_PATH=${1:-"${BUILD_BASE}/eagle0iOS"}
LOG_PATH="${BUILD_BASE}/editor_ios.log"
# Generate unique build number from git commit count
# This ensures each build has a unique number for TestFlight
BUILD_NUMBER=$(git rev-list --count HEAD)
echo "Build number (git commit count): $BUILD_NUMBER"
echo "Building protos"
./scripts/build_protos.sh
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
echo "Building iOS Unity player to: $BUILD_PATH"
mkdir -p "$(dirname "$LOG_PATH")"
mkdir -p "$BUILD_PATH"
# Build iOS player - this generates an Xcode project
# Capture exit code to show log on failure
set +e
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-nographics \
-batchmode \
-quit \
-executeMethod BuildScript.BuildiOSPlayer \
-buildPath "$BUILD_PATH" \
-buildNumber "$BUILD_NUMBER" \
-logFile "$LOG_PATH" \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
UNITY_EXIT_CODE=$?
set -e
if [ $UNITY_EXIT_CODE -ne 0 ]; then
echo ""
echo "Unity build failed with exit code $UNITY_EXIT_CODE"
echo "=== Unity Editor Log (last 200 lines) ==="
tail -200 "$LOG_PATH" || echo "Could not read log file at $LOG_PATH"
echo "=== End of Unity Editor Log ==="
exit $UNITY_EXIT_CODE
fi
echo "iOS Unity build complete"
echo "Xcode project generated at: $BUILD_PATH"
ls -la "$BUILD_PATH"
+6 -3
View File
@@ -2,18 +2,21 @@
set -euxo pipefail
# Use runner-specific build directory if EAGLE0_BUILD_DIR is set, otherwise default
BUILD_BASE="${EAGLE0_BUILD_DIR:-/tmp/eagle0}"
COMMIT=$(/usr/bin/git rev-parse --short HEAD)
/bin/echo "build protos"
./scripts/build_protos.sh
/bin/echo "build Mac plugin"
./scripts/build_mac_plugin.sh
/bin/echo "build Sparkle plugin"
./scripts/build_sparkle_plugin.sh
git log -3
/bin/echo "build Mac"
LOG_PATH="/tmp/eagle0/editor_mac.log"
LOG_PATH="${BUILD_BASE}/editor_mac.log"
BUILD_DIR=$1
./ci/github_actions/build_mac.sh "$BUILD_DIR" "$LOG_PATH"
+18 -2
View File
@@ -2,7 +2,8 @@
set -euxo pipefail
. ./ci/unity_version.sh
# Read Unity version from project file
UNITY_VERSION=$(grep "m_EditorVersion:" src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt | head -1 | sed 's/m_EditorVersion: //')
WORKSPACE=`pwd`
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
@@ -15,10 +16,25 @@ echo "Cleaning up $1"
/bin/rm -rf $1
/bin/mkdir -p $1
# Use custom build script that builds Addressables before the player
# Capture exit code to show log on failure
set +e
${UNITY_INSTALL_PATH}/${UNITY_VERSION}/Unity.app/Contents/MacOS/Unity \
-nographics \
-batchmode \
-quit \
-buildWindows64Player $BUILD_DIR/eagle0.exe \
-executeMethod BuildScript.BuildWindowsPlayer \
-buildPath "$BUILD_DIR/eagle0.exe" \
-logFile $LOG_PATH \
-projectPath "$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
UNITY_EXIT_CODE=$?
set -e
if [ $UNITY_EXIT_CODE -ne 0 ]; then
echo ""
echo "Unity build failed with exit code $UNITY_EXIT_CODE"
echo "=== Unity Editor Log (last 200 lines) ==="
tail -200 "$LOG_PATH" || echo "Could not read log file at $LOG_PATH"
echo "=== End of Unity Editor Log ==="
exit $UNITY_EXIT_CODE
fi
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env bash
# Ensures the required Unity version is installed via Unity Hub.
# Usage: ./ensure_unity_installed.sh [PLATFORM]
#
# PLATFORM can be: mac, windows, ios, or all (default: all)
#
# This script:
# 1. Reads the required version from ProjectSettings/ProjectVersion.txt
# 2. Checks if it's already installed
# 3. If not, attempts to install it via Unity Hub CLI with appropriate modules
#
# Note: Unity Hub CLI installation may require:
# - Unity Hub to be installed
# - User to be logged in to Unity Hub (for some versions)
# - Appropriate licenses
set -euo pipefail
# Read Unity version directly from the project file (maintained by Unity itself)
PROJECT_VERSION_FILE="src/main/csharp/net/eagle0/clients/unity/eagle0/ProjectSettings/ProjectVersion.txt"
UNITY_VERSION=$(grep "m_EditorVersion:" "$PROJECT_VERSION_FILE" | head -1 | sed 's/m_EditorVersion: //')
UNITY_INSTALL_PATH="/Applications/Unity/Hub/Editor"
UNITY_HUB_CLI="/Applications/Unity Hub.app/Contents/MacOS/Unity Hub"
LOCK_FILE="/tmp/unity_install.lock"
LOCK_TIMEOUT=1800 # 30 minutes max wait for another installation
PLATFORM="${1:-all}"
echo "Required Unity version: ${UNITY_VERSION}"
echo "Platform: ${PLATFORM}"
# Check if Unity is installed and has required modules
check_modules_installed() {
local unity_path="${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
if [ ! -d "$unity_path" ]; then
return 1
fi
case "${PLATFORM}" in
ios)
# iOS module installs to PlaybackEngines/iOSSupport
if [ ! -d "${unity_path}/PlaybackEngines/iOSSupport" ]; then
echo "✗ iOS module not installed for Unity ${UNITY_VERSION}"
return 1
fi
;;
mac)
# Mac IL2CPP module
if [ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport/Variations/macos_development_il2cpp" ] && \
[ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport" ]; then
echo "✗ Mac IL2CPP module not installed for Unity ${UNITY_VERSION}"
return 1
fi
;;
windows)
# Windows mono module
if [ ! -d "${unity_path}/PlaybackEngines/WindowsStandaloneSupport" ]; then
echo "✗ Windows module not installed for Unity ${UNITY_VERSION}"
return 1
fi
;;
all)
# Check all required modules
local missing=0
if [ ! -d "${unity_path}/PlaybackEngines/iOSSupport" ]; then
echo "✗ iOS module missing"
missing=1
fi
if [ ! -d "${unity_path}/PlaybackEngines/MacStandaloneSupport" ]; then
echo "✗ Mac module missing"
missing=1
fi
if [ ! -d "${unity_path}/PlaybackEngines/WindowsStandaloneSupport" ]; then
echo "✗ Windows module missing"
missing=1
fi
if [ $missing -eq 1 ]; then
return 1
fi
;;
esac
return 0
}
# Check if already installed with required modules
if check_modules_installed; then
echo "✓ Unity ${UNITY_VERSION} is already installed with ${PLATFORM} support"
exit 0
fi
if [ ! -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
echo "✗ Unity ${UNITY_VERSION} not found at ${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
fi
# Check if Unity Hub CLI is available
if [ ! -f "${UNITY_HUB_CLI}" ]; then
echo ""
echo "Unity Hub CLI not found at ${UNITY_HUB_CLI}"
echo ""
echo "To install Unity ${UNITY_VERSION} manually:"
echo " 1. Open Unity Hub"
echo " 2. Go to Installs -> Install Editor"
echo " 3. Select version ${UNITY_VERSION}"
echo " 4. Add modules based on platform: mac-il2cpp, windows-mono, ios"
exit 1
fi
# Acquire lock to prevent concurrent installations
acquire_lock() {
local waited=0
while ! mkdir "${LOCK_FILE}" 2>/dev/null; do
if [ $waited -ge $LOCK_TIMEOUT ]; then
echo "ERROR: Timed out waiting for Unity installation lock after ${LOCK_TIMEOUT}s"
echo "Another installation may be stuck. Remove ${LOCK_FILE} manually if needed."
exit 1
fi
echo "Another Unity installation is in progress, waiting... (${waited}s)"
sleep 10
waited=$((waited + 10))
done
# Store PID for debugging
echo $$ > "${LOCK_FILE}/pid"
trap release_lock EXIT
}
release_lock() {
rm -rf "${LOCK_FILE}" 2>/dev/null || true
}
# Re-check after acquiring lock (another process may have installed it)
acquire_lock
if check_modules_installed; then
echo "✓ Unity ${UNITY_VERSION} with ${PLATFORM} support was installed while waiting for lock"
exit 0
fi
echo ""
# Determine modules needed for this platform
get_modules_for_platform() {
case "${PLATFORM}" in
mac)
echo "mac-il2cpp"
;;
windows)
echo "windows-mono"
;;
ios)
echo "ios"
;;
all)
echo "mac-il2cpp windows-mono ios"
;;
*)
echo "Unknown platform: ${PLATFORM}" >&2
echo "Valid platforms: mac, windows, ios, all" >&2
exit 1
;;
esac
}
MODULES=$(get_modules_for_platform)
# Check if editor is already installed (just missing modules)
if [ -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
echo "Unity ${UNITY_VERSION} is installed but missing modules. Adding modules..."
echo ""
# Use install-modules to add modules to existing installation
INSTALL_CMD=("${UNITY_HUB_CLI}" -- --headless install-modules --version "${UNITY_VERSION}")
for mod in $MODULES; do
INSTALL_CMD+=(--module "$mod")
done
else
echo "Attempting to install Unity ${UNITY_VERSION} via Unity Hub CLI..."
echo ""
# Use install to install editor with modules
INSTALL_CMD=("${UNITY_HUB_CLI}" -- --headless install --version "${UNITY_VERSION}")
for mod in $MODULES; do
INSTALL_CMD+=(--module "$mod")
done
fi
echo "Running: ${INSTALL_CMD[*]}"
echo ""
# Capture output to check for "already installed" messages
OUTPUT=$("${INSTALL_CMD[@]}" 2>&1) || true
EXIT_CODE=$?
echo "$OUTPUT"
# Check if modules are already installed (Unity Hub returns error but modules are present)
if echo "$OUTPUT" | grep -q "already installed\|No modules found to install"; then
echo ""
echo "✓ Modules already installed"
elif [ $EXIT_CODE -eq 0 ]; then
echo ""
echo "Unity Hub CLI command completed"
else
echo ""
echo "Unity Hub CLI command failed (exit code: ${EXIT_CODE})"
echo ""
echo "This may happen if:"
echo " - The Unity version is not available for download"
echo " - You need to log in to Unity Hub first"
echo " - Unity Hub requires a GUI interaction"
echo ""
echo "To install manually:"
echo " 1. Open Unity Hub"
echo " 2. Go to Installs -> Install Editor"
echo " 3. Select version ${UNITY_VERSION}"
echo " 4. Add modules: ${PLATFORM}"
exit 1
fi
# Verify installation
echo ""
if [ -d "${UNITY_INSTALL_PATH}/${UNITY_VERSION}" ]; then
echo "✓ Verified: Unity ${UNITY_VERSION} is now installed"
exit 0
else
echo "✗ Unity ${UNITY_VERSION} installation could not be verified"
echo " Expected path: ${UNITY_INSTALL_PATH}/${UNITY_VERSION}"
echo ""
echo "The installation may still be in progress, or may require manual intervention."
exit 1
fi
+30 -3
View File
@@ -1,6 +1,33 @@
#!/bin/bash
#
# Persist Unity Library/ cache to persistent storage
#
# Environment variables:
# UNITY_CACHE_PLATFORM - Platform identifier (e.g., "mac", "windows")
# Defaults to "mac" if not set
#
# Note: Library/Bee/ is excluded because it contains DAG files with hardcoded
# file paths that become stale when project files change. This prevents
# "Data at the root level is invalid" XML errors from stale references.
set -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/
PLATFORM="${UNITY_CACHE_PLATFORM:-mac}"
CACHE_DIR="/tmp/eagle0/Library-${PLATFORM}"
/bin/echo "persist Library/ to $CACHE_DIR (excluding Bee/)"
# rsync may exit with code 23 ("partial transfer due to error") if Unity's
# temporary files vanish during the copy. This is acceptable for a cache.
/usr/bin/rsync -rtlDvq --exclude='Bee/' src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ "$CACHE_DIR/"
rsync_exit=$?
if [ $rsync_exit -eq 0 ]; then
exit 0
elif [ $rsync_exit -eq 23 ]; then
echo "Warning: rsync exited with 23 (some files vanished during copy). This is expected for Unity temp files."
exit 0
else
echo "Error: rsync failed with exit code $rsync_exit"
exit $rsync_exit
fi
+12 -3
View File
@@ -1,7 +1,16 @@
#!/bin/bash
#
# Restore Unity Library/ cache from persistent storage
#
# Environment variables:
# UNITY_CACHE_PLATFORM - Platform identifier (e.g., "mac", "windows")
# Defaults to "mac" if not set
set -euxo pipefail
/bin/echo "restore Library/"
/bin/mkdir -p /tmp/eagle0/Library
/usr/bin/rsync -rtlDvq /tmp/eagle0/Library/ src/main/csharp/net/eagle0/clients/unity/eagle0/Library/
PLATFORM="${UNITY_CACHE_PLATFORM:-mac}"
CACHE_DIR="/tmp/eagle0/Library-${PLATFORM}"
/bin/echo "restore Library/ from $CACHE_DIR"
/bin/mkdir -p "$CACHE_DIR"
/usr/bin/rsync -rtlDvq "$CACHE_DIR/" src/main/csharp/net/eagle0/clients/unity/eagle0/Library/
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Upload Addressables bundles to DigitalOcean Spaces
# Usage: ./upload_addressables.sh <build_target>
# Example: ./upload_addressables.sh StandaloneOSX
#
# Required environment variables:
# ACCESS_KEY_ID - DigitalOcean Spaces access key (same as other deploys)
# SECRET_KEY - DigitalOcean Spaces secret key (same as other deploys)
set -euxo pipefail
BUILD_TARGET=$1
WORKSPACE=$(pwd)
UNITY_PROJECT="$WORKSPACE/src/main/csharp/net/eagle0/clients/unity/eagle0"
SERVER_DATA="$UNITY_PROJECT/ServerData/$BUILD_TARGET"
# DigitalOcean Spaces configuration (same region as other eagle0 buckets)
DO_ENDPOINT="https://sfo3.digitaloceanspaces.com"
DO_BUCKET="eagle0-assets"
if [ ! -d "$SERVER_DATA" ]; then
echo "No Addressables bundles found at $SERVER_DATA"
echo "Skipping upload (this is expected if Addressables are bundled locally)"
exit 0
fi
echo "Uploading Addressables bundles from $SERVER_DATA"
echo "Target: s3://$DO_BUCKET/addressables/$BUILD_TARGET/"
# Configure AWS CLI for DigitalOcean Spaces
export AWS_ACCESS_KEY_ID="$ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$SECRET_KEY"
# Sync bundles to Spaces
# --delete removes files in destination that don't exist in source
# --acl public-read makes files publicly accessible
aws s3 sync "$SERVER_DATA" "s3://$DO_BUCKET/addressables/$BUILD_TARGET/" \
--endpoint-url "$DO_ENDPOINT" \
--acl public-read \
--delete
echo "Addressables upload complete"
echo "Files available at: https://assets.eagle0.net/addressables/$BUILD_TARGET/"
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
# Upload IPA to TestFlight using Apple ID credentials (same as Mac notarization)
set -euxo pipefail
IPA_PATH=${1:?Usage: upload_testflight.sh <ipa_path>}
if [ ! -f "$IPA_PATH" ]; then
echo "Error: IPA file not found: $IPA_PATH"
exit 1
fi
echo "Uploading to TestFlight: $IPA_PATH"
# Uses same credentials as Mac notarization:
# - APPLE_ID: Your Apple ID email
# - APP_SPECIFIC_PASSWORD: App-specific password from appleid.apple.com
if [ -z "${APPLE_ID:-}" ]; then
echo "Error: APPLE_ID environment variable not set"
exit 1
fi
if [ -z "${APP_SPECIFIC_PASSWORD:-}" ]; then
echo "Error: APP_SPECIFIC_PASSWORD environment variable not set"
exit 1
fi
echo "Uploading with xcrun altool..."
# Capture output to check for errors (altool may return 0 even on failure)
OUTPUT=$(xcrun altool --upload-app \
--type ios \
--file "$IPA_PATH" \
--username "$APPLE_ID" \
--password "$APP_SPECIFIC_PASSWORD" 2>&1) || true
echo "$OUTPUT"
# Check for error indicators in output
if echo "$OUTPUT" | grep -q "ERROR:"; then
echo "ERROR: Upload failed. See error messages above."
exit 1
fi
echo "Upload complete! Check App Store Connect for processing status."
echo "The build should appear in TestFlight within 15-30 minutes after processing."
Binary file not shown.
-1
View File
@@ -1 +0,0 @@
UNITY_VERSION='6000.3.0f1'
-40
View File
@@ -1,40 +0,0 @@
# Environment template for production deployment
# This file defines all env vars used by docker-compose.prod.yml
# Workflows should update their specific vars without overwriting others
# Container images (managed by respective build workflows)
EAGLE_IMAGE=registry.digitalocean.com/eagle0/eagle-server:latest
SHARDOK_IMAGE=registry.digitalocean.com/eagle0/shardok-server:latest
ADMIN_IMAGE=registry.digitalocean.com/eagle0/admin-server:latest
JFR_SIDECAR_IMAGE=registry.digitalocean.com/eagle0/jfr-sidecar:latest
AUTH_IMAGE=registry.digitalocean.com/eagle0/auth-server:latest
# OpenAI / LLM
OPENAI_API_KEY=
GPT_MODEL_NAME=gpt-4o
# DigitalOcean Spaces (S3-compatible storage)
EAGLE_ENABLE_S3=false
DO_SPACES_ACCESS_KEY=
DO_SPACES_SECRET_KEY=
# JWT authentication
JWT_PRIVATE_KEY=
# OAuth providers
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Shardok connection
SHARDOK_ADDRESS=shardok:40042
SHARDOK_AUTH_TOKEN=
# Monitoring
SENTRY_DSN=
# Email (Fastmail JMAP)
FASTMAIL_API_TOKEN=
FASTMAIL_FROM_EMAIL=
FASTMAIL_FROM_NAME=
-59
View File
@@ -1,59 +0,0 @@
#!/bin/bash
# Update .env file without losing other variables
# Usage: ./update-env.sh KEY1=value1 KEY2=value2 ...
#
# This script:
# 1. Creates .env from template if it doesn't exist
# 2. Updates only the specified KEY=value pairs
# 3. Preserves all other existing values
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
ENV_FILE="${ENV_FILE:-/opt/eagle0/.env}"
TEMPLATE_FILE="${TEMPLATE_FILE:-$SCRIPT_DIR/env.template}"
# Create .env from template if it doesn't exist
if [ ! -f "$ENV_FILE" ]; then
if [ -f "$TEMPLATE_FILE" ]; then
echo "Creating .env from template..."
grep -v '^#' "$TEMPLATE_FILE" | grep -v '^$' > "$ENV_FILE"
else
echo "Creating empty .env..."
touch "$ENV_FILE"
fi
chmod 600 "$ENV_FILE"
fi
# Process each KEY=VALUE argument
for arg in "$@"; do
# Skip empty args
[ -z "$arg" ] && continue
# Parse KEY=VALUE
KEY="${arg%%=*}"
VALUE="${arg#*=}"
# Skip if no key
[ -z "$KEY" ] && continue
# Skip setting empty values (keeps existing value)
if [ -z "$VALUE" ]; then
echo "Skipping $KEY (empty value)"
continue
fi
# Remove existing line for this key and add new one
if grep -q "^${KEY}=" "$ENV_FILE" 2>/dev/null; then
# Key exists, update it
sed -i "s|^${KEY}=.*|${KEY}=${VALUE}|" "$ENV_FILE"
echo "Updated $KEY"
else
# Key doesn't exist, add it
echo "${KEY}=${VALUE}" >> "$ENV_FILE"
echo "Added $KEY"
fi
done
chmod 600 "$ENV_FILE"
echo "Done updating $ENV_FILE"
+64 -46
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
@@ -16,24 +18,23 @@ services:
image: ${EAGLE_IMAGE:-registry.digitalocean.com/eagle0/eagle-server:latest}
container_name: eagle-blue
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "${SHARDOK_ADDRESS:-shardok:40042}"
- "${SHARDOK_ADDRESS}"
- "--auth-service-url"
- "auth:40033"
ports:
- "40032:40032"
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
GEMINI_API_KEY: "${GEMINI_API_KEY:-}"
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
# Auth token for remote Shardok on Hetzner (only used when shardok address contains .eagle0.net)
# JWT public key for token validation (auth service handles signing)
# Reads from /etc/eagle0/keys/public.pem via shared volume
# 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"
@@ -45,9 +46,8 @@ services:
- ./archived:/app/archived # Archived completed games
- ./jfr:/app/jfr # JFR recordings - dump with: docker exec eagle-blue jcmd 1 JFR.dump filename=/app/jfr/profile.jfr
- jvm-tmp:/tmp # Shared with jfr-sidecar for JVM attach socket files
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
- jwt-keys:/etc/eagle0/keys:ro # JWT public key from auth service (read-only)
depends_on:
- shardok
- auth
restart: unless-stopped
logging:
@@ -67,23 +67,22 @@ services:
container_name: eagle-green
profiles: ["blue-green"] # Only started during blue-green deployment
command:
- "--gpt-model-name"
- "${GPT_MODEL_NAME:-gpt-5.1}"
- "--shardok-interface-remote-address"
- "${SHARDOK_ADDRESS:-shardok:40042}"
- "${SHARDOK_ADDRESS}"
- "--auth-service-url"
- "auth:40033"
ports:
- "40034:40032" # Different host port for staging
environment:
OPENAI_API_KEY: "${OPENAI_API_KEY:-}"
ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}"
GEMINI_API_KEY: "${GEMINI_API_KEY:-}"
EAGLE_ENABLE_S3: "${EAGLE_ENABLE_S3:-false}"
DO_SPACES_ENDPOINT: "${DO_SPACES_ENDPOINT:-https://sfo3.digitaloceanspaces.com}"
DO_SPACES_ACCESS_KEY: "${DO_SPACES_ACCESS_KEY:-}"
DO_SPACES_SECRET_KEY: "${DO_SPACES_SECRET_KEY:-}"
JWT_PRIVATE_KEY: "${JWT_PRIVATE_KEY:-}"
DISCORD_CLIENT_ID: "${DISCORD_CLIENT_ID:-}"
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
# JWT public key for token validation (auth service handles signing)
# Reads from /etc/eagle0/keys/public.pem via shared volume
SHARDOK_AUTH_TOKEN: "${SHARDOK_AUTH_TOKEN:-}"
EAGLE_SAVE_DIR: "/app/saves"
EAGLE_ARCHIVE_DIR: "/app/archived"
@@ -92,9 +91,10 @@ services:
volumes:
- ./saves:/app/saves # Same save directory as blue
- ./archived:/app/archived # Same archive directory as blue
- jwt-keys:/etc/eagle0/keys # Shared JWT keys with auth service
- ./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:ro # JWT public key from auth service (read-only)
depends_on:
- shardok
- auth
restart: "no" # Don't auto-restart during deployment
logging:
@@ -131,6 +131,16 @@ services:
DISCORD_CLIENT_SECRET: "${DISCORD_CLIENT_SECRET:-}"
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID:-}"
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET:-}"
GH_OAUTH_CLIENT_ID: "${GH_OAUTH_CLIENT_ID:-}"
GH_OAUTH_CLIENT_SECRET: "${GH_OAUTH_CLIENT_SECRET:-}"
# Apple Sign-In credentials
APPLE_SIGNIN_CLIENT_ID: "${APPLE_SIGNIN_CLIENT_ID:-}"
APPLE_TEAM_ID: "${APPLE_TEAM_ID:-}"
APPLE_SIGNIN_KEY_ID: "${APPLE_SIGNIN_KEY_ID:-}"
APPLE_SIGNIN_PRIVATE_KEY: "${APPLE_SIGNIN_PRIVATE_KEY:-}"
# Twitch OAuth credentials
TWITCH_CLIENT_ID: "${TWITCH_CLIENT_ID:-}"
TWITCH_CLIENT_SECRET: "${TWITCH_CLIENT_SECRET:-}"
# Server base URL for OAuth callbacks
SERVER_BASE_URL: "${SERVER_BASE_URL:-https://prod.eagle0.net}"
# JWT keys - PEM files in volume, bootstrapped from JWK on first run
@@ -140,6 +150,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 +170,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 +186,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 +205,17 @@ 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"
environment:
# Secret for CI to authenticate client update notifications
NOTIFY_SECRET: "${NOTIFY_SECRET:-}"
# 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 +233,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 +252,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
-471
View File
@@ -1,471 +0,0 @@
# Admin Server Enhancement Plan
## Overview
This document outlines enhancements to the Go admin server (`src/main/go/net/eagle0/admin_server/`) to provide a proper web UI for game administration.
### Current State
The admin server provides a full web UI with htmx interactivity:
- `GET /` - Redirect to games list
- `GET /games` - Game list page (HTML)
- `GET /games/{id}` - Game detail with action history
- `GET /games/{id}/history` - History rows (htmx partial, infinite scroll)
- `GET /games/{id}/action/{index}` - Action detail (htmx partial)
- `POST /games/{id}/rewind` - Rewind game to target action
- `GET /settings` - Settings list with live search
- `POST /settings/update` - Update setting value
- `GET /health` - Health check (JSON)
- `GET /api/games` - JSON API for programmatic access
- `GET /api/games/{id}/history` - JSON API for history
### Goals
1. **Web UI**: Replace raw JSON with an interactive HTML interface
2. **Settings Management**: View and modify the 275+ game settings at runtime
3. **Game Rewind**: Restore a game to a previous action count
---
## Architecture
### Technology Choice: Go Templates + htmx
**Rationale:**
- Single binary deployment (no separate frontend build)
- htmx provides interactivity without JavaScript framework complexity
- Familiar HTML/CSS, minimal learning curve
- Excellent for admin tools where SEO and bundle size don't matter
**Alternatives Considered:**
- React/Vue SPA: Adds build complexity, separate deployment artifact
- Server-side only: Less interactive, full page reloads
### Directory Structure
```
src/main/go/net/eagle0/admin_server/
├── admin_server.go # Main entry point, HTTP routes
├── handlers/
│ ├── games.go # Game list and detail handlers
│ ├── settings.go # Settings list and update handlers
│ └── rewind.go # Game rewind handlers
├── templates/
│ ├── layout.html # Base layout with nav, htmx includes
│ ├── games/
│ │ ├── list.html # Game list page
│ │ ├── detail.html # Single game view with history
│ │ └── history.html # Partial for history table (htmx)
│ ├── settings/
│ │ ├── list.html # Settings list with search/filter
│ │ └── edit.html # Inline edit partial (htmx)
│ └── rewind/
│ └── confirm.html # Rewind confirmation modal
├── static/
│ ├── style.css # Minimal CSS (Pico CSS or similar)
│ └── htmx.min.js # htmx library
└── BUILD.bazel
```
---
## Feature 1: Web UI
### Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/` | GET | Redirect to `/games` |
| `/games` | GET | Game list page (HTML) |
| `/games/{id}` | GET | Game detail page with history |
| `/games/{id}/history` | GET | History partial (htmx, for infinite scroll) |
| `/api/games` | GET | JSON API (existing, keep for programmatic access) |
| `/api/games/{id}/history` | GET | JSON API (existing) |
### Game List Page
```
┌─────────────────────────────────────────────────────────────┐
│ Eagle Admin [Settings] [Health] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Running Games (3) │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Game abc123f Round 45 │ │
│ │ Players: Liu Bei (Human), Cao Cao (AI), Sun Quan │ │
│ │ Actions: 1,234 [View] [Rewind]│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Game def456a Round 12 │ │
│ │ Players: Test Player (Human) │ │
│ │ Actions: 456 [View] [Rewind]│ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Game Detail Page
Shows game info and scrollable action history:
- **Reverse chronological order**: Most recent actions displayed first
- Each action shows: index, type, round ID
- **Clickable actions**: Clicking an action row expands to show JSON representation of the full action data
- "Rewind to here" button on each action row
- Infinite scroll loads more history via htmx (loading older actions as user scrolls down)
### Implementation Notes
1. **Embed static files**: Use `//go:embed` to bundle templates and static files
2. **Template functions**: Add helpers for formatting (hex IDs, timestamps, action summaries)
3. **CSS framework**: Use Pico CSS (~10KB) for clean defaults without classes
---
## Feature 2: Settings Management
### New gRPC Endpoints (Eagle Server)
Add to `eagle.proto`:
```protobuf
message Setting {
string name = 1;
string type = 2; // "Int" or "Double"
string value = 3; // Current value as string
string default_value = 4; // Default from BUILD.bazel
string description = 5; // Optional, for UI hints
}
message GetSettingsRequest {
string filter = 1; // Optional name filter (substring match)
}
message GetSettingsResponse {
repeated Setting settings = 1;
}
message UpdateSettingRequest {
string name = 1;
string value = 2;
}
message UpdateSettingResponse {
Setting setting = 1; // Updated setting
string error = 2; // Empty on success
}
service Eagle {
// ... existing methods ...
rpc GetSettings(GetSettingsRequest) returns (GetSettingsResponse);
rpc UpdateSetting(UpdateSettingRequest) returns (UpdateSettingResponse);
}
```
### Eagle Server Implementation
Create a settings registry that:
1. Discovers all `IntSetting` and `DoubleSetting` instances via reflection or explicit registration
2. Provides get/set by name
3. Validates types on update
```scala
// src/main/scala/net/eagle0/eagle/library/settings/SettingsRegistry.scala
object SettingsRegistry {
private val settings: Map[String, Either[IntSetting, DoubleSetting]] = Map(
"ActionVigorCost" -> Left(ActionVigorCost),
"BaseFoodBuyPrice" -> Right(BaseFoodBuyPrice),
// ... register all 275 settings
)
def getAll(filter: Option[String]): Seq[Setting] = ...
def get(name: String): Option[Setting] = ...
def update(name: String, value: String): Either[String, Setting] = ...
}
```
**Alternative: Code generation**
Rather than manually registering 275 settings, modify `setting_rule.bzl` to generate a registry file during build.
### Admin Server Routes
| Route | Method | Description |
|-------|--------|-------------|
| `/settings` | GET | Settings list page with search |
| `/settings/{name}` | GET | Single setting detail (htmx partial) |
| `/settings/{name}` | PUT | Update setting value |
| `/api/settings` | GET | JSON API |
| `/api/settings/{name}` | PUT | JSON API |
### Settings UI
```
┌─────────────────────────────────────────────────────────────┐
│ Eagle Admin [Games] [Health] │
├─────────────────────────────────────────────────────────────┤
│ │
│ Settings [Search: __________ ] │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ ActionVigorCost (Int) │ │
│ │ Current: [15 ] Default: 15 [Save] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ BaseFoodBuyPrice (Double) │ │
│ │ Current: [0.5 ] Default: 0.5 [Save] │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ... (275 settings, virtualized/paginated) ... │
│ │
└─────────────────────────────────────────────────────────────┘
```
### Considerations
1. **Persistence**: Settings changes are in-memory only. Document that restarts reset to defaults.
2. **Validation**: Validate numeric ranges where applicable (e.g., percentages 0-100)
3. **Categories**: Consider grouping settings by prefix (AI*, Combat*, Economy*, etc.)
4. **Audit log**: Log setting changes with timestamp for debugging
---
## Feature 3: Game Rewind
### Concept
Restore a game to a previous point in its action history. This is useful for:
- Debugging issues that occurred at a specific point
- Testing "what if" scenarios
- Recovering from bugs that corrupted state
### New gRPC Endpoint
Add to `eagle.proto`:
```protobuf
message RewindGameRequest {
int64 game_id = 1;
int32 target_action_count = 2; // Rewind to state after this many actions
}
message RewindGameResponse {
bool success = 1;
string error = 2;
int32 new_action_count = 3;
int32 disconnected_clients = 4; // Number of clients that were disconnected
}
service Eagle {
// ... existing methods ...
rpc RewindGame(RewindGameRequest) returns (RewindGameResponse);
}
```
### Eagle Server Implementation
The `GameHistory` already stores `ActionWithResultingState` for each action, which includes the `GameState` after that action. Rewinding means:
1. **Validate**: Check that `target_action_count` is within valid range (0 to current count)
2. **Get target state**: Retrieve `GameState` at target action count from history
3. **Disconnect clients**: Close all human player connections (they'll need to reconnect)
4. **Replace engine**: Create new `EngineImpl` with target state and truncated history
5. **Reset AI state**: Clear any cached AI state that depends on current game state
```scala
// GameController.scala (pseudocode)
def rewindTo(targetActionCount: Int): Either[String, RewindResult] = {
if (targetActionCount < 0 || targetActionCount > engine.history.count)
return Left(s"Invalid action count: $targetActionCount")
// Get state at target point
val targetState = engine.history.stateAt(targetActionCount)
val truncatedHistory = engine.history.truncateTo(targetActionCount)
// Disconnect all human clients
val disconnectedCount = humanClients.length
humanClients.foreach(_.disconnect("Game rewound by admin"))
// Create new engine at target state
val newEngine = EngineImpl(
gameId = engine.gameId,
currentState = targetState,
history = truncatedHistory,
// ... other fields
)
// Replace controller's engine
this.engine = newEngine
Right(RewindResult(targetActionCount, disconnectedCount))
}
```
### GameHistory Enhancement
Add method to get state at a specific action count:
```scala
trait GameHistory {
// ... existing methods ...
def stateAt(actionCount: Int): GameState = {
if (actionCount == 0) initialState
else all(actionCount - 1).resultingState
}
def truncateTo(actionCount: Int): GameHistory = {
GameHistoryImpl(
initialState = initialState,
actions = all.take(actionCount)
)
}
}
```
### Admin Server Route
| Route | Method | Description |
|-------|--------|-------------|
| `/games/{id}/rewind` | POST | Rewind game (form: `target_action_count`) |
| `/games/{id}/rewind/confirm` | GET | Confirmation modal (htmx partial) |
### Rewind UI Flow
1. User views game history
2. User clicks "Rewind to here" on an action row
3. Confirmation modal appears via htmx:
```
┌─────────────────────────────────────────┐
│ Rewind Game abc123f? │
│ │
│ This will: │
│ • Restore to action 456 (Round 23) │
│ • Discard 778 subsequent actions │
│ • Disconnect 2 connected players │
│ │
│ This cannot be undone. │
│ │
│ [Cancel] [Rewind] │
└─────────────────────────────────────────┘
```
4. On confirm, POST to `/games/{id}/rewind`
5. Success: redirect to game detail showing new state
6. Error: show error message
### Safety Considerations
1. **No undo**: Rewinding discards history. Consider optional backup before rewind.
2. **Client disconnect**: All connected clients are forcibly disconnected.
3. **AI state**: Ensure AI clients restart cleanly after rewind.
4. **Concurrent access**: Lock game during rewind to prevent race conditions.
5. **Authorization**: In production, require admin authentication.
---
## Implementation Phases
### Phase 1: Web UI Foundation
**Status: Complete**
1. ✅ Set up Go templates with `embed`
2. ✅ Add Pico CSS and htmx
3. ✅ Create base layout with navigation
4. ✅ Convert `/games` to HTML with styling
5. ✅ Add game detail page with history table
6. ✅ Implement htmx infinite scroll for history
7. ✅ Reverse history order (most recent first)
8. ✅ Clickable action rows that expand to show JSON representation
9. ✅ Add `/games/{id}/action/{index}` endpoint for fetching action details
**Deliverable**: Browsable game list and history in HTML with clickable action details
### Phase 2: Settings Management
**Status: Complete**
1. ✅ Add `GetSettings` to `eagle.proto` (uses existing `AddSettings` for updates)
2. ✅ Add `getAllSettings` method to auto-generated `SettingsLoader`
3. ✅ Implement `getSettings` in `EagleServiceImpl`
4. ✅ Create settings list page with live search
5. ✅ Add inline editing with htmx
6. ✅ Modified settings are highlighted
**Deliverable**: View and edit settings via admin UI
### Phase 3: Game Rewind
**Status: Complete**
1. ✅ Add `RewindGame` to `eagle.proto`
2. ✅ Implement `stateAt` and `truncateTo` in `GameHistory`
3. ✅ Implement rewind logic in `Engine` and `GameController`
4. ✅ Add rewind confirmation (htmx `hx-confirm` dialog)
5. ✅ Handle client disconnection gracefully
6. ✅ Add rewind button to history rows
7. ✅ Implement `rewindGame` in `GamesManager` and `EagleServiceImpl`
8. ✅ Add admin server `/games/{id}/rewind` POST handler
9. ✅ Add success/error feedback UI
**Deliverable**: Rewind games to any previous action
### Phase 4: Polish
**Status: Not Started**
#### High Priority
1. **Add tests for rewind functionality**
- `PersistedHistory.truncateTo` (handles complex persisted vs recent logic)
- `InMemoryHistory.truncateTo`
- `EngineImpl.rewindTo`
- `GameController.rewindTo`
- `GamesManager.rewindGame`
2. **Improve action history display**
- Human-readable action type names (e.g., "New Round" instead of "NewRoundAction")
- Show acting faction/province when available
- Action summaries from the `summary` field in `GameHistoryEntry`
#### Medium Priority
3. **Settings improvements**
- Group settings by category prefix (AI*, Combat*, Economy*, etc.)
- Show setting descriptions where available
- Pagination for large settings lists
4. **Error handling improvements**
- Better error messages on failed operations
- Retry logic for transient gRPC failures
#### Low Priority (Nice to Have)
5. **Basic auth** - HTTP Basic Auth or OAuth for production use
6. **Audit logging** - Log admin actions with timestamps
7. **Documentation** - Usage guide, deployment notes
#### Future Considerations
- Game creation from admin UI
- Player management (view connected players, force disconnect)
- Export game history to file
- Metrics/stats dashboard
---
## Security Notes
The admin server is intended for local/trusted network use only. For production:
1. **Do not expose to public internet** without authentication
2. Consider adding HTTP Basic Auth or OAuth
3. Run on internal network or behind VPN
4. Log all admin actions for audit trail
---
## Open Questions
1. **Settings persistence**: Should we add optional persistence to disk/database?
2. **Game snapshots**: Should rewind create a backup first?
3. **Multi-admin**: Need locking if multiple admins access simultaneously?
4. **Shardok settings**: Are there Shardok (C++) settings to expose too?
+114 -51
View File
@@ -109,11 +109,41 @@ All 26 tracks have CC licenses with proper attribution:
| Dragon Castle | Makai Symphony | CC BY-SA 3.0 |
| Durandal | Makai Symphony | CC BY-SA 3.0 |
**Tracks without specific license (verify):**
- Market Day
- Shopping List
- Medieval: Victory Theme
- Tracks by Dima Koltsov (AUDIUS): No Time for Greatness, Warriors of Demacia, Forest Queen Tale, Valor, Clouds
**Tracks with non-CC licenses:**
| Track | Artist | License | Source |
|-------|--------|---------|--------|
| Market Day | RandomMind | Free without attribution | [Chosic](https://www.chosic.com/download-audio/27016/) |
| Shopping List | Komiku | Free without attribution | [Chosic](https://www.chosic.com/download-audio/24714/) |
| Medieval: Victory Theme | RandomMind | CC0 Public Domain | [Chosic](https://www.chosic.com/download-audio/28492/) |
| No Time for Greatness | Dima Koltsov | CC BY 4.0 | [YouTube](https://www.youtube.com/watch?v=cQh0OWIFdgM) |
| Warriors of Demacia | Dima Koltsov | CC BY 4.0 | [YouTube](https://www.youtube.com/watch?v=yktSUMJn9ao) |
| Forest Queen Tale | Dima Koltsov | Presumed CC BY 4.0 | Not found on YouTube; other Dima Koltsov tracks are CC BY 4.0 |
| Valor | Dima Koltsov | CC BY 4.0 | [YouTube](https://www.youtube.com/watch?v=uoHYJRPcS2Y) |
| Clouds | Dima Koltsov | Presumed CC BY 4.0 | Not found on YouTube; other Dima Koltsov tracks are CC BY 4.0 |
**Note on Dima Koltsov tracks:** 3 of 5 tracks confirmed CC BY 4.0 via YouTube. 2 remaining tracks (Forest Queen Tale, Clouds) presumed same license but not verified.
---
## 2b. Creative Commons Sound Effects
**Location:** `Assets/Shardok/Sounds/`
| File | Description | Artist | License | Source |
|------|-------------|--------|---------|--------|
| `rain_loop.ogg` | Rain falling on clay roof tiles (loopable) | aesqe | CC BY 4.0 | [Freesound #37618](https://freesound.org/people/aesqe/sounds/37618/) |
| `blizzard_wind_loop.wav` | Wind draft loop (indoor recording, loops seamlessly) | nsstudios | CC BY 4.0 | [Freesound #651540](https://freesound.org/people/nsstudios/sounds/651540/) |
| `thunder_distant.mp3` | Distant thunder rumble | LittleRainySeasons | CC BY 4.0 | [Freesound #351526](https://freesound.org/people/LittleRainySeasons/sounds/351526/) |
| `thunder_loud.mp3` | Loud thunder clap | mokasza | CC BY 4.0 | [Freesound #810746](https://freesound.org/people/mokasza/sounds/810746/) |
| `thunder_crack.wav` | Thunder crack | OneSoundToRuleThemAll | CC BY 4.0 | [Freesound #238796](https://freesound.org/people/OneSoundToRuleThemAll/sounds/238796/) |
| `thunder_clap.wav` | Thunder clap | FreqMan | CC BY 4.0 | [Freesound #32544](https://freesound.org/people/FreqMan/sounds/32544/) |
**Location:** `Assets/Shardok/soundEffects/`
| File | Description | Artist | License | Source |
|------|-------------|--------|---------|--------|
| `runaway.mp3` | Medieval army running loop (gravel + metal/chain) | Yap_Audio_Production | CC BY 4.0 | [Freesound #218997](https://freesound.org/people/Yap_Audio_Production/sounds/218997/) |
---
@@ -129,42 +159,61 @@ All 26 tracks have CC licenses with proper attribution:
## 4. Potentially Problematic Assets (Review Needed)
### Stock Images (Possible License Issues)
These appear to be stock images that may have been used as placeholders:
| File | Concern |
|------|---------|
| ~~`Assets/Eagle/79066358-stock-illustration-raster-illustration-medieval-purse-bag...jpg`~~ | **DELETED** (2025-01-04) |
| ~~`Assets/Images/kisspng-hammer-hand-saws-tool-clip-art...jpg`~~ | **DELETED** (2025-01-04) |
| ~~`Assets/Images/lee-ermy-cropped.jpg`~~ | **DELETED** (2025-01-04) |
| ~~`Assets/Eagle/images.jpeg`~~ | **DELETED** (2025-01-04) |
### Clip Art (Unknown License)
| File | Concern |
|------|---------|
| `Assets/Shardok/commandImages/bridge.png` | Clip art style wooden bridge, unknown source - **needs replacement** |
| `Assets/Images/startFire.png` | Icon, unknown source - **needs verification or replacement** |
### ~~Clip Art (Unknown License)~~ RESOLVED
| File | Status |
|------|--------|
| ~~`Assets/Shardok/commandImages/bridge.png`~~ | **REPLACED** (2026-01-23) with AI-generated wooden rope bridge icon (ChatGPT/DALL-E 3, 512x512 PNG). No licensing restrictions - AI-generated for this project. |
| ~~`Assets/Images/startFire.png`~~ | **REPLACED** (2026-01-23) with "Flame Icon" from [UXWing](https://uxwing.com/flame-icon/) (free for commercial use, no attribution required). Consolidated duplicate removed. |
| ~~`Assets/Shardok/commandImages/startFire.png`~~ | **DELETED** (2026-01-23) - duplicate removed, all references updated to use `Assets/Images/startFire.png` |
### Shardok Sound Effects
- **Location:** `Assets/Shardok/soundEffects/`
- **Count:** 56 MP3 files
- **Count:** 37 audio files (was incorrectly counted as 56 including .meta files)
- **Contents:** Spell effects, movement, combat sounds
- **Status:** Unknown origin - may be custom or need verification
### Free Icons
- **Location:** `Assets/free_icons/`
- **Count:** 8 PNG weather icons
- **Status:** Verify "free" means commercially usable
**Verified from [Zombie Monster - Undead Collection](https://assetstore.unity.com/packages/audio/sound-fx/creatures/zombie-monster-undead-collection-70662) (Unity Asset Store):**
- `raise_undead.mp3`
- `undead_break_control.mp3`
- `undead_grew.wav`
### Terrain Hexes
**⚠️ MUST REPLACE (1 remaining):**
- ~~`anybody.mp3`~~ - **REPLACED** (2026-01-23) with `Positive Effect 6.wav` from Magic Spells Sound Effects LITE
- ~~`burnination.mp3`~~ - **REPLACED** (2026-01-23) with `Magic Element Fire 04.wav` from Medieval Combat Sounds
- `failure_horn.mp3` - licensing issue, no replacement found in purchased assets
- ~~`runaway.mp3`~~ - **REPLACED** (2026-01-27) with `MedievalArmyRunningLoop.mp3` from Freesound (CC BY 4.0)
**Presumed from Unity Asset Store purchases (31 files):**
Owner believes these are from: Fantasy Interface Sounds, Medieval Combat Sounds, Magic Spells Sound Effects LITE, and/or Medieval Battle Sound Pack.
- `archery.mp3`, `battle_shout.mp3`, `boo.mp3`, `braved_water.mp3`
- `build_bridge.mp3`, `build_bridge_failure.mp3`, `charge.mp3`
- `dismiss_unit.mp3`, `duel_challenged.mp3`, `failure_horn.mp3`, `fear.mp3`, `fear_failed.mp3`
- `fire_extinguish.mp3`, `fire_spread.mp3`, `fire_start.mp3`, `fire_start_failure.mp3`
- `freeze.mp3`, `holy_wave.mp3`, `holy_wave_damage.mp3`, `jail_door.mp3`, `lightning.mp3`
- `melee.mp3`, `meteor.mp3`, `mind_control.mp3`, `move.mp3`, `move 1.mp3`
- `raging_fire.mp3`, `reduce.mp3`, `repair.mp3`, `repair_failed.mp3`, `splash.mp3`
### ~~Free Icons~~ RESOLVED
- **Location:** `Assets/free_icons/` - **DELETED** (2026-01-27)
- **Resolution:** All icons replaced with equivalents from purchased Asset Store packs:
- `blizzard.png``16_blizzard_nobg.png` (4000_Fantasy_Icons)
- `rain.png``12_Magic_rain_nobg.png` (4000_Fantasy_Icons)
- `thunderstorm.png``27_Storm_nobg.png` (4000_Fantasy_Icons)
- `wind.png``23_Light_blow_nobg.png` (4000_Fantasy_Icons)
- `thermometer.png``startFire.png` (existing licensed asset)
- `snow.png``16_blizzard_nobg.png` (4000_Fantasy_Icons)
- `cloud.png`, `sun.png` → deleted (unused)
### ~~Terrain Hexes~~ VERIFIED
- **Location:** `Assets/Terrain Hexes/`
- **Count:** 85 PNG files
- **Status:** Unknown source - verify licensing
- **Status:** ✓ Confirmed Unity Asset Store purchase (2026-01-23)
### StrategyGameIcons
### ~~StrategyGameIcons~~ VERIFIED
- **Location:** `Assets/StrategyGameIcons/`
- **Count:** 138 PNG files
- **Status:** Unknown source - verify licensing
- **Publisher:** REXARD
- **Asset Store Link:** https://assetstore.unity.com/packages/2d/gui/icons/strategy-game-icons-64816
- **Status:** ✓ Confirmed Unity Asset Store purchase (2026-01-23)
---
@@ -194,28 +243,31 @@ NuGet packages in `Assets/Packages/` all include LICENSE.TXT files:
## Action Items
### Must Verify Before Opening Public Access:
### Must Replace Before Opening Public Access:
1. ~~**Stock images** - The JPG files with stock image filenames need review.~~ **DONE** - Deleted lee-ermy, kisspng, stock-illustration, Yosemite Sam, and images.jpeg (2025-01-04)
1. ~~**Clip art images**~~ - **RESOLVED** (2026-01-23): Replaced with properly licensed alternatives
2. **Clip art images** - Unknown license, need replacement with properly licensed alternatives:
- `Assets/Shardok/commandImages/bridge.png` - wooden bridge icon
- `Assets/Images/startFire.png` - fire icon
2. ~~**Shardok sound effects**~~ - **ALL RESOLVED**:
- ~~`anybody.mp3`~~ - **REPLACED** (2026-01-23)
- ~~`burnination.mp3`~~ - **REPLACED** (2026-01-23)
- ~~`failure_horn.mp3`~~ - **REPLACED** (2026-01-27) with `Negative Effect 04.wav` from Magic Spells Sound Effects LITE
- ~~`runaway.mp3`~~ - **REPLACED** (2026-01-27) with Freesound CC BY 4.0
3. **Shardok sound effects** - 56 MP3 files of unknown origin. Either:
- Document their source
- Replace with known-licensed alternatives
- Confirm they were custom-created
### Low Priority (Verify):
4. **Terrain Hexes** - 85 hex tiles of unknown source
- **TODO:** Confirm if this is a Unity Asset Store purchase (owner believes it is)
3. **Dima Koltsov tracks** - 2 of 5 not verified: `Forest Queen Tale`, `Clouds` (presumed CC BY 4.0 like his other tracks)
5. **StrategyGameIcons** - 138 icons of unknown source
- **TODO:** Investigate origin - check Unity Asset Store purchase history
### Already Resolved:
6. **AUDIUS music tracks** - Verify Dima Koltsov tracks are licensed for commercial use
4. ~~**Free Icons**~~ - **RESOLVED** (2026-01-27): All replaced with Asset Store equivalents, folder deleted
7. **Discord logo** (`Eagle/Discord-Logo-Blurple.png`) - Likely fine for "Login with Discord" button per Discord brand guidelines, but verify usage complies with their terms
5. ~~**Terrain Hexes**~~ - **VERIFIED** (2026-01-23): Confirmed Unity Asset Store purchase
6. ~~**StrategyGameIcons**~~ - **VERIFIED** (2026-01-23): Unity Asset Store purchase (REXARD)
7. ~~**Medieval: Victory Theme**~~ - **VERIFIED** (2026-01-23): CC0 Public Domain by RandomMind ([Chosic](https://www.chosic.com/download-audio/28492/))
8. ~~**Discord logo**~~ - **OK** (2026-01-23): Usage complies with Discord brand guidelines for "Login with Discord" button
### Already Safe:
@@ -229,12 +281,23 @@ NuGet packages in `Assets/Packages/` all include LICENSE.TXT files:
## Recommendation
Before removing HTTP basic auth:
**Remaining before public release:**
1. ~~Delete or replace the 4 suspicious JPG/JPEG files in `Assets/Eagle/` and `Assets/Images/`~~ **DONE** (2025-01-04)
2. Replace clip art images (`bridge.png`, `startFire.png`) with properly licensed alternatives (e.g., from [game-icons.net](https://game-icons.net) CC BY 3.0)
3. Verify source of `Assets/Shardok/soundEffects/` MP3s
4. Verify source of `Assets/Terrain Hexes/` and `Assets/StrategyGameIcons/`
5. If any are from early development with unclear licensing, replace them
None! All required items resolved.
**Low priority:**
2. Verify 2 Dima Koltsov tracks (`Forest Queen Tale`, `Clouds`) - presumed CC BY 4.0
**Already resolved:**
- ~~Clip art images~~ **DONE** - replaced with properly licensed alternatives
- ~~Terrain Hexes~~ **DONE** - confirmed Asset Store purchase
- ~~StrategyGameIcons~~ **DONE** - Unity Asset Store (REXARD)
- ~~Medieval: Victory Theme~~ **DONE** - CC0 Public Domain
- ~~3 other Dima Koltsov tracks~~ **DONE** - confirmed CC BY 4.0
- ~~anybody.mp3, burnination.mp3~~ **DONE** - replaced
- ~~free_icons~~ **DONE** - replaced with Asset Store equivalents
- ~~failure_horn.mp3~~ **DONE** - replaced with Negative Effect 04.wav
The bulk of your assets (95%+) are properly licensed Asset Store purchases or CC content.
-388
View File
@@ -1,388 +0,0 @@
# Deproto Migration Plan
## Vision
**Protocol buffers should only be used at the edges** — for network serialization (gRPC) and disk persistence. Inside the Eagle game engine, all logic should operate on native Scala models.
```
┌─────────────────────────────────────────────────────────────────────┐
│ GRPC BOUNDARY │
│ EagleServiceImpl.scala ←→ Proto Messages ←→ Unity Client │
└─────────────────────────────────────────────────────────────────────┘
GameStateConverter
┌─────────────────────────────────────────────────────────────────────┐
│ SCALA ENGINE │
│ │
│ GameStateC ───→ Actions ───→ ActionResultT ───→ New GameStateC │
│ ↑ │ │
│ │ (Pure Scala models) │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ HeroC, FactionC, ProvinceC, BattalionC, ArmyC, etc. │
└─────────────────────────────────────────────────────────────────────┘
GameStateConverter
┌─────────────────────────────────────────────────────────────────────┐
│ PERSISTENCE BOUNDARY │
│ GameHistory.scala ←→ Proto Messages ←→ File/Database │
└─────────────────────────────────────────────────────────────────────┘
```
---
## Current State
### Completed Phases
| Phase | Status | Summary |
|-------|--------|---------|
| Phase 1: GameStateC | **Complete** | Scala `GameState` model with 22 fields |
| Phase 2: EngineImpl | **Complete** | Holds Scala `GameState` internally |
| Phase 3: GameHistory | **Complete** | `stateAfter` returns Scala GameState |
| Phase 4: ActionResultT | **Complete** | All 59 actions return `ActionResultT` |
| Phase 5: Action Base Classes | **Complete** | All `RandomSequentialResultsAction` and `DeterministicSingleResultAction` converted to T-type base classes |
| Phase 5b: Base Class Cleanup | **Complete** | `RandomSequentialResultsAction` and `DeterministicSingleResultAction` deleted |
| Phase 5c: RoundPhaseAdvancer Actions | **Complete** | All actions called by RoundPhaseAdvancer accept Scala GameState |
| Phase 5d: RoundPhaseAdvancer Itself | **Complete** | RoundPhaseAdvancer.checkForPhaseAdvancement takes Scala GameState |
### Phase 5c/5d Progress (Complete)
`RoundPhaseAdvancer.checkForPhaseAdvancement` now accepts Scala `GameState` and `ActionResultApplier` directly (PR #4677).
| Action | PR | Status |
|--------|-----|--------|
| `PrisonerExchangeAction` | #4670 | ✅ Merged |
| `PerformForcedTurnBackAction` | #4671 | ✅ Merged |
| `PerformHeroDeparturesAction` | #4672 | ✅ Merged |
| `RequestFreeForAllBattlesAction` | #4673 | ✅ Merged |
| `EndPlayerCommandsPhaseAction` | #4674 | ✅ Merged |
| `EndDiplomacyResolutionPhaseAction` | #4675 | ✅ Merged |
| `RoundPhaseAdvancer` itself | #4677 | ✅ Merged |
### EngineImpl Progress
| Change | PR | Status |
|--------|-----|--------|
| `recursiveTransform` deleted | #4677 | ✅ Merged |
| `recursiveTransformT` uses `RandomStateTSequencer` | #4677 | ✅ Merged |
### Current Architecture
**ActionResultT Production (100% Complete):**
- All actions produce `ActionResultT`
- Conversion to `ActionResultProto` happens via `ActionResultProtoConverter.toProto()`
- No direct `ActionResultProto` construction outside the converter
**ActionResultProto Consumption (Next Target):**
- `ActionResultProtoApplierImpl` - applies proto results to proto GameState
- `RoundPhaseAdvancer` - calls converter, passes protos to applier
- `InMemoryHistory` / `PersistedHistory` - stores proto results
- Service layer (`GameController`, `GamesManager`, etc.) - uses proto for client communication
---
## Phase 6: Migrate to ActionResultT Consumers
### Objective
Eliminate internal consumption of `ActionResultProto`. Everything inside the engine should work with `ActionResultT`.
### Current Flow (Proto-Heavy)
```
Action.execute()
→ ActionResultT
→ ActionResultProtoConverter.toProto()
→ ActionResultProto
→ ActionResultProtoApplierImpl.applyActionResults()
→ GameStateProto
→ GameStateConverter.fromProto()
→ GameStateC
```
### Target Flow (T-Types Throughout)
```
Action.execute()
→ ActionResultT
→ ActionResultApplier.applyActionResults()
→ GameStateC
(Proto conversion only at boundaries)
```
### Key Files to Convert
**Tier 1 - Core Applier:****Complete**
```
src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultApplierImpl.scala
```
`ActionResultApplier` applies `ActionResultT` directly to Scala `GameState`. The legacy `ActionResultTApplierImpl` wraps it and converts to/from proto for callers that still need proto types.
**Tier 2 - RoundPhaseAdvancer:****Complete**
```
src/main/scala/net/eagle0/eagle/library/RoundPhaseAdvancer.scala
```
Now accepts Scala `GameState` and `ActionResultApplier`. Only converts to proto lazily for `AvailableCommandsFactory` calls.
**Tier 3 - Sequencers:**
```
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateTSequencer.scala
src/main/scala/net/eagle0/eagle/library/actions/impl/common/RandomStateProtoSequencer.scala
```
Modify `RandomStateTSequencer` to thread Scala `GameState` throughout (currently converts to proto internally). Then evaluate whether `RandomStateProtoSequencer` is still needed at all.
**Current State**: `RandomStateTSequencer` accepts Scala `GameState` via its `apply()` method but internally converts to proto. All callback methods (`withRandomActionResult`, `withActionResults`, etc.) pass `GameStateProto` to callers, forcing actions that use the sequencer to work with proto types internally.
**Target State**: Create a fully protoless sequencer where:
1. `lastState` returns Scala `GameState` (not `lastStateProto`)
2. All callback methods pass Scala `GameState` to callers
3. Actions using the sequencer can be fully protoless
**Migration Path**:
1. Add `lastState: GameState` method alongside `lastStateProto` (non-breaking)
2. Add parallel callback methods that pass Scala GameState (e.g., `withScalaActionResult`)
3. Migrate actions one by one to use the new Scala-based callbacks
4. Once all actions migrated, deprecate/remove proto-based callbacks
5. Remove `lastStateProto` once no longer used
**RandomStateSequencer Migration Progress** (PR #4679 introduced protoless `RandomStateSequencer`):
| Action | Status |
|--------|--------|
| `TruceTurnBackPhaseAction` | ✅ Migrated (PR #4680) |
| `EndHandleRiotsPhaseAction` | ✅ Migrated (PR #4684) |
| `PerformVassalCommandsPhaseAction` | ✅ Migrated |
| `PerformVassalDefenseDecisionsAction` | ✅ Migrated |
| `EndVassalCommandsPhaseAction` | ✅ Migrated |
| `PerformReconResolutionAction` | ✅ Migrated |
| `NewRoundAction` | ✅ Migrated (PR #4698) |
| `EndBattleAftermathPhaseAction` | ✅ Migrated (PR #4699) |
| `EndDiplomacyResolutionPhaseAction` | ✅ Migrated |
| `PerformUnaffiliatedHeroesAction` | ✅ Migrated |
| `EngineImpl.recursiveTransformT` | ✅ Migrated (PR #4704) |
| `ProtolessSequentialResultsActionWrapper` | ✅ Migrated (PR #4705) |
| `LegacyRandomStateTSequencer` | ✅ **Deleted** (PR #4705) |
**TCommandFactory Extraction** (PR #4684):
To enable lightweight mocking of command creation in tests, `TCommandFactory` trait was extracted from `CommandFactory`. This allows tests to mock just the `makeTCommand` method without pulling in all 40+ command dependencies that `CommandFactory` requires.
- `TCommandFactory` - lightweight trait with just `makeTCommand`
- `CommandFactory extends TCommandFactory` - maintains backward compatibility
- Actions accepting command factories now use `TCommandFactory` type for better testability
**Tier 4 - History APIs:**
```
src/main/scala/net/eagle0/eagle/service/InMemoryHistory.scala
src/main/scala/net/eagle0/eagle/service/PersistedHistory.scala
```
Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versions. `PersistedHistory` converts to proto internally for disk persistence; `InMemoryHistory` doesn't need proto at all.
### ActionResultProto Consumer Inventory
| File | Usage | Status |
|------|-------|--------|
| `ActionResultApplierImpl.scala` | Applies ActionResultT to Scala GameState | ✅ **Complete** |
| `ActionResultTApplierImpl.scala` | Legacy wrapper - converts to/from proto | Keep until all callers migrated |
| `RoundPhaseAdvancer.scala` | Uses Scala GameState | ✅ **Complete** |
| `RandomStateSequencer.scala` | Threads Scala GameState | ✅ **Complete** |
| `VigorXPApplier.scala` | Has both proto and Scala methods | Scala method exists, delete proto method when unused |
| `PerformForcedTurnBackAction.scala` | Fully protoless | ✅ **Complete** |
| `ResolveBattleAction.scala` | ✅ Fully protoless | Complete (PR #5048) |
| `InMemoryHistory.scala` | Stores proto results | Pending - vend Scala types |
| `PersistedHistory.scala` | Stores proto results | Pending - vend Scala types, convert for disk |
| `GameController.scala` | Uses proto for client communication | Keep proto (gRPC boundary) |
### Remaining Proto Usage in Actions
**Progress: 52 of 52 action files (100%) are fully protoless.**
All action files have been migrated to use Scala types:
| Action | Status | Notes |
|--------|--------|-------|
| `ResolveBattleAction` | ✅ Complete | PR #5048 - uses Scala GameState and ActionResultApplier |
| `PerformVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `EndHandleRiotsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `PerformVassalDefenseDecisionsAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
| `EndVassalCommandsPhaseAction` | ✅ Complete | Uses Scala types via CommandChoiceHelpers |
**Note:** `NewRoundAction` is now fully protoless after converting `ChronicleEventGenerator` to return Scala `ChronicleEvent` types directly.
**Deleted Dead Code:**
- `UnaffiliatedHeroMovedAction` - Was never called; `PerformUnaffiliatedHeroesAction.heroMovedResult` constructs `ActionResultC` directly
- `HeroBackstoryUpdateActionGenerator.fromGameState` - Dead method that converted proto to Scala; only `apply(GameState)` is used
**Note**: `PerformReconResolutionAction` and `EndBattleAftermathPhaseAction` are now fully protoless after:
1. Migrating `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` to use Scala `ProvinceView`
2. Adding Scala overload of `ProvinceViewFilter.withdrawnFromProvinceView`
### Estimated Effort (Remaining)
| Component | Lines | Complexity | Blocks |
|-----------|-------|------------|--------|
| History API updates | ~100 | Low | - |
| **Total Remaining** | **~100** | | |
**Completed:**
-`ChronicleEventGenerator` converted to return Scala `ChronicleEvent` types directly
-`CommandChoiceHelpers` migrated to Scala types
-`ResolveBattleAction` refactored to use Scala GameState and ActionResultApplier (PR #5048)
### Enum Type Migrations
Proto enums are being converted to Scala sealed traits with converters at boundaries:
| Enum | Scala Type | Status | Notes |
|------|------------|--------|-------|
| `DiplomacyOfferStatus` | `Status` sealed trait | ✅ **Complete** | PR #5093 - `EligibleDiplomacyStatuses` uses Scala types internally |
| `RoundPhase` | `RoundPhase` sealed trait | Partial | Some usages converted |
| `BattalionType` | `BattalionType` sealed trait | Partial | Some usages converted |
**DiplomacyOfferStatus Migration (PR #5093):**
- `EligibleDiplomacyStatuses.scala` now returns `Vector[Status]` instead of `Vector[DiplomacyOfferStatus]`
- Call sites in `AvailableResolve*CommandFactory` files convert to proto via `StatusConverter.toProto` at the boundary
- This pattern should be applied to other proto enums
### CommandChoiceHelpers Migration Status
Several command selectors have already been converted to use Scala types:
| File | Status | Notes |
|------|--------|-------|
| `AttackCommandChooser.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `AlmsCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `HeroT`, `ProvinceT` |
| `FoodConsumptionUtils.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `RoundPhase` |
| `MarchSuppliesHelpers.scala` | ✅ **Protoless** | Uses `BattalionT` |
| `CombatUnitSelector.scala` | ✅ **Protoless** | Uses `HeroT`, `BattalionT`, `BattalionType` |
| `ExpandCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `FactionT` |
| `ImproveCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `ProvinceT`, `HeroT` |
| `OrganizeCommandSelector.scala` | ✅ **Protoless** | Uses Scala `GameState`, `BattalionT`, `BattalionType` |
| `RansomOfferHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState`, `FactionT` |
| `CommandChoiceHelpers.scala` | ✅ **Protoless** | Uses Scala `GameState` throughout |
| `ProvinceGoldSurplusCalculator.scala` | ✅ **Protoless** | Uses Scala types |
**All CommandChoiceHelpers selectors have been migrated to Scala types.**
### Progress Summary
| Metric | Value |
|--------|-------|
| Action files fully protoless | 52 / 52 (100%) ✅ |
| Proto usages in remaining actions | 0 |
| Next target | See "Next Candidates" section below |
### Next Candidates
Priority candidates for further deproto work:
1. **More Enum Migrations** - Apply the `DiplomacyOfferStatus` pattern to other proto enums:
- Files importing `net.eagle0.eagle.common.round_phase.RoundPhase` (proto) could use Scala `RoundPhase`
- Files importing `net.eagle0.eagle.common.battalion_type.BattalionType` (proto) could use Scala `BattalionType`
2. **AvailableCommandsFactory Files** - Many still use proto `GameState` internally:
- These files build proto `AvailableCommand` messages but could use Scala types for internal logic
- Convert to accept Scala `GameState`, only convert fields to proto when building the response
3. **History APIs** - `InMemoryHistory` and `PersistedHistory`:
- Change to vend Scala `GameState` and `ActionResultT` instead of proto versions
- `PersistedHistory` converts to proto internally for disk persistence
### Validation
- [x] `ActionResultApplier` created and tested
- [x] `RandomStateSequencer` threads Scala GameState throughout
- [x] `RoundPhaseAdvancer` uses T-types internally
- [x] `ProvinceViewFilter` has Scala overload for server-side use (PR #4752)
- [x] `FactionT.reconnedProvinces` and `ChangedFactionC.updatedReconnedProvinces` use Scala `ProvinceView`
- [x] `ProvinceViewFilter.withdrawnFromProvinceView` has Scala overload
- [x] `CommandChoiceHelpers` uses Scala types ✅
- [x] All action files (52/52) are fully protoless ✅
- [ ] `ProvinceViewFilter` faction-filtered views use Scala types
- [ ] History APIs vend Scala types
- [ ] No `ActionResultProtoConverter.toProto()` calls except at persistence/gRPC boundaries
- [ ] All tests pass
---
## Phase 7: Clean Up Legacy Utilities
### Objective
Remove remaining direct proto imports from utility classes.
### Files to Modify
| File | Status |
|------|--------|
| `CommandChoiceHelpers.scala` | Accepts proto `GameState`; blocks full deproto of `PerformVassalCommandsPhaseAction` and `PerformVassalDefenseDecisionsAction` |
| `LegacyProvinceUtils.scala` | Replace with `ProvinceUtils.scala` - `hasImminentRiot` added (PR #4683) |
| `LegacyFactionUtils.scala` | Replace proto imports with `FactionT` |
| `LegacyUnaffiliatedHeroUtils.scala` | Replace proto imports with Scala models |
| `BattalionTypeLoader.scala` | Keep proto for file loading, convert immediately after |
| `BeastUtils.scala` | **Complete** - now uses Scala `BeastInfo` only |
### View Filters (Partially Complete)
The view filter utilities now have Scala overloads for server-side use:
| File | Status | Notes |
|------|--------|-------|
| `ProvinceViewFilter.scala` | **Partial** | `filteredProvinceView(ProvinceT, ScalaGameState)` added (PR #4752) |
| `ArmyFilter.scala` | **Partial** | `filterArmy(ScalaArmy, Map[BattalionId, BattalionT], Option[FactionId])` added |
| `BattalionViewFilter.scala` | **Complete** | Uses Scala `BattalionT` throughout |
| `GameStateViewFilter.scala` | Pending | Uses proto types throughout |
| `GameStateViewDiffer.scala` | Pending | Works with view protos |
**Unblocked Actions** (PR #4752):
- `EndBattleAftermathPhaseAction` - can now use `filteredProvinceView(province, scalaGameState)`
- `PerformReconResolutionAction` - can now use Scala overload
- `GameStateFactionExtensions` - can now use `updatedReconnedProvinces` with Scala types
**Remaining Work**:
- Faction-filtered `filteredProvinceView(Province, GameState, FactionId)` still uses proto types
- `withdrawnFromProvinceView` still uses proto types
- These are needed for client-facing views with visibility restrictions
---
## Phase 8: Verify Boundaries
### Objective
Confirm protos are used correctly at boundaries — and ONLY there.
### Expected Proto Usage (Keep)
- `EagleServiceImpl.scala` - gRPC boundary
- `InMemoryHistory.scala` / `PersistedHistory.scala` - Persistence boundary
- `*Converter.scala` - Explicit conversion utilities
- `*Loader.scala` - File loading utilities
### Expected No Proto Usage (Verify)
- `/library/actions/impl/` - Pure Scala models
- `/library/util/` - Pure Scala models (except loaders)
- `/model/state/` - Pure Scala models
---
## Open Questions
1. **Persistence Format**: Currently game state is persisted as proto. Should we keep proto for persistence (good for schema evolution) or switch to a different format?
2. **Shardok Integration**: `ResolveBattleAction` communicates with Shardok. Should the Shardok interface use protos (external service) or Scala models?
3. **View Generation**: `GameStateViewDiffer` works with view protos for client updates. Views need Scala models (`ProvinceViewT`, etc.) to allow actions like `EndBattleAftermathPhaseAction` to be fully protoless. The Scala views would be converted to proto only at the gRPC boundary when sending updates to clients.
---
## Success Criteria
### Code Quality
- [ ] Zero proto imports in `/library/actions/` (except boundaries)
- [ ] Zero proto imports in `/library/` utilities (except loaders)
- [ ] `GameStateT` used throughout engine internals
- [ ] Proto usage limited to: `EagleServiceImpl`, loaders, converters, persistence
### Architecture
- [ ] Clear separation: Scala models (internal) vs Proto (boundaries)
- [ ] Converters as the only bridge between domains
- [ ] No "proto creep" into business logic
+94
View File
@@ -0,0 +1,94 @@
# LLM Model Comparison
This document compares streaming latency (time-to-first-token) and pricing across OpenAI, Anthropic (Claude), and Google (Gemini) models for use in Eagle's narrative text generation.
## Test Methodology
All tests were performed locally using curl with streaming enabled. Each model was tested 3 times with the same prompt:
> "Write a short paragraph about a brave knight who discovers a hidden cave. Make it vivid and descriptive."
Time-to-first-token (TTFT) was measured from request initiation to the first text content appearing in the stream.
## Streaming Latency Results (January 2026)
| Model | Run 1 | Run 2 | Run 3 | Average TTFT |
|-------|-------|-------|-------|--------------|
| **Gemini 2.5 Flash-Lite** | 0.76s | 0.54s | 0.53s | **~0.6s** |
| **gpt-4.1-mini** | 1.65s | 1.72s | 1.68s | **~1.7s** |
| **claude-3-5-haiku** | 1.85s | 1.92s | 1.88s | **~1.9s** |
| gpt-5.2 | 3.25s | 3.38s | 3.32s | **~3.3s** |
| gpt-5-mini | 2.52s | 5.82s | 3.12s | **~3.8s** (high variance) |
| Gemini 3 Flash Preview | 4.11s | 4.77s | 4.28s | **~4.4s** |
| claude-sonnet-4 | 4.89s | 5.12s | 4.98s | **~5.0s** |
| Gemini 2.5 Flash | 5.60s | 7.00s | 7.79s | **~6.8s** |
## Pricing Comparison (per 1M tokens)
| Model | Input Price | Output Price | Notes |
|-------|-------------|--------------|-------|
| **Gemini 2.5 Flash-Lite** | $0.10 | $0.40 | Cheapest and fastest |
| Gemini 2.5 Flash | $0.15 | $0.60 | |
| gpt-5-mini | $0.25 | $2.00 | |
| **gpt-4.1-mini** | $0.40 | $1.60 | Best OpenAI value |
| Gemini 3 Flash Preview | $0.50 | $3.00 | Includes thinking tokens |
| **claude-3-5-haiku** | $0.80 | $4.00 | Best Anthropic value |
| gpt-5.2 | ~$1.00 | ~$10.00 | Full reasoning model |
| Gemini 2.5 Pro | $1.25 | $10.00 | |
| Gemini 3 Pro Preview | $2.00 | $12.00 | ≤200K context |
| claude-sonnet-4 | $3.00 | $15.00 | |
## Recommendations
### For Narrative Text Generation (Default)
**Gemini 2.5 Flash-Lite** is recommended as the default:
- Fastest TTFT (~0.6s) - nearly 3x faster than alternatives
- Cheapest pricing ($0.10/$0.40 per 1M tokens)
- Quality is acceptable for short narrative snippets
### Alternative Options
| Priority | Model | When to Use |
|----------|-------|-------------|
| Speed + Cost | Gemini 2.5 Flash-Lite | Default for most use cases |
| Speed + Quality | gpt-4.1-mini | When you need OpenAI quality with good speed |
| Instruction Following | claude-3-5-haiku | Complex multi-step prompts, consistent tone |
| Maximum Quality | claude-sonnet-4 or gpt-5.2 | When output quality is paramount |
### Quality Trade-offs
For short narrative snippets (1-3 paragraphs):
- **Flash-Lite vs Haiku/4.1-mini**: Minor quality difference, significant speed gain
- **Haiku vs Sonnet**: Noticeable quality difference in creative writing variety
- **gpt-4.1-mini vs gpt-5.2**: Moderate quality difference, significant cost savings
## Configuration
LLM settings can be changed at runtime via the admin console:
1. Navigate to Admin Console → Settings
2. Change `LlmProvider` to select vendor (gemini, openai, claude)
3. Change the corresponding model name setting:
- `GeminiModelName` (default: gemini-2.5-flash-lite)
- `OpenAiModelName` (default: gpt-4.1-mini)
- `ClaudeModelName` (default: claude-3-5-haiku-20241022)
Changes take effect on the next LLM request.
## Environment Variables
For production deployment, ensure API keys are set:
```bash
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
GEMINI_API_KEY=AIza...
```
## Notes
- **gpt-5-mini** showed high latency variance (2.5s - 5.8s) in testing
- **Gemini 2.5 Flash** was surprisingly slower than Flash-Lite, possibly due to internal reasoning overhead
- **Gemini 3 Flash** is a frontier model with better quality but higher latency than 2.5 Flash-Lite
- All Gemini models have a generous free tier (up to 1,000 daily requests)
+103
View File
@@ -0,0 +1,103 @@
# New Profession Proposals
This document proposes 5 new hero professions for Eagle0. Each profession has abilities for both the Eagle (strategic) and Shardok (tactical) game layers.
## Current Professions Reference
| Profession | Eagle Ability | Shardok Abilities |
|------------|---------------|-------------------|
| **Mage** | Control Weather | Lightning Bolt, Meteor, Freeze Water, Start Fire (enhanced) |
| **Necromancer** | Start Epidemic | Raise Dead, Fear |
| **Engineer** | (general) | Repair, Fortify, Build Bridge, Reduce (siege) |
| **Paladin** | Alms (prioritized) | Holy Wave |
| **Ranger** | Recon | Scout, Hide (enhanced), Brave Water (enhanced) |
| **Champion** | (general) | Challenge Duel |
---
## Proposed New Professions
### 1. HERALD (Morale & Communication Specialist)
**Fantasy:** The inspiring leader who rallies troops and carries messages across the battlefield.
**Eagle Ability: "Rally Province"**
- Spend vigor to boost recruitment in a province for one turn, or reduce unrest
- Synergy: Pairs well with provinces in turmoil or after losses
**Shardok Ability: "Inspire"**
- Target friendly unit within 3 hexes gains +1 action point this turn
- Creates interesting tactical choices about when to act vs. when to buff allies
- Cannot target self (prevents simple optimization)
---
### 2. ALCHEMIST (Fire & Transformation Specialist)
**Fantasy:** The mad scientist who manipulates the elements through science, not magic.
**Eagle Ability: "Transmute"**
- Convert one resource type to another in a province (gold to food or vice versa, at a loss)
- Provides economic flexibility during shortages
**Shardok Ability: "Wildfire"**
- Start a fire that spreads to 2 additional adjacent hexes immediately (not just at end of round)
- More aggressive than Mage's Start Fire but less controlled
- Cannot freeze water (that's magic, not science)
---
### 3. WARDEN (Defensive Specialist)
**Fantasy:** The stalwart defender who holds the line and protects allies.
**Eagle Ability: "Garrison"**
- A province with a Warden-led unit gets +1 to defense when attacked
- Encourages strategic placement of defensive heroes
**Shardok Ability: "Intercept"**
- Once per turn, when an adjacent friendly unit is attacked, the Warden's unit can take the hit instead
- Creates a "bodyguard" mechanic that protects valuable units
- Costs action points to maintain readiness
---
### 4. INQUISITOR (Anti-Magic & Intelligence)
**Fantasy:** The witch-hunter who counters supernatural threats and uncovers secrets.
**Eagle Ability: "Expose"**
- Reveal hidden information about enemy heroes in a province (stats, profession, vigor)
- Counter to Ranger's stealth/recon advantages
**Shardok Ability: "Dispel"**
- Cancel an active magical effect: stop a meteor cast, remove Fear from friendly unit, or reveal a hidden unit
- Direct counter to Mage and Necromancer abilities
- Creates meaningful profession rock-paper-scissors
---
### 5. BEASTMASTER (Animal Control)
**Fantasy:** The wild one who commands beasts and understands nature's fury.
**Eagle Ability: "Suppress Beasts" (Enhanced)**
- Already exists in game, but Beastmaster does it at reduced vigor cost
- Additionally: Can redirect beast attacks to enemy provinces instead of just suppressing
**Shardok Ability: "Beast Call"**
- Summon a wolf pack (weak undead-style unit) that attacks the nearest enemy
- Wolves act immediately but disappear at end of round
- Provides disposable units for screening or harassing archers
---
## Design Philosophy
These professions were designed to:
1. **Fill mechanical gaps**: Warden provides defensive depth, Inquisitor counters magic-heavy strategies
2. **Create counterplay**: Inquisitor vs Mage/Necromancer, Beastmaster vs Rangers (nature vs nature)
3. **Avoid overlap**: Each has a unique niche not covered by existing professions
4. **Support both layers**: Each ability is meaningful in its respective game mode
5. **Enable interesting decisions**: Intercept creates bodyguard tactics, Inspire creates action economy choices
+150
View File
@@ -0,0 +1,150 @@
# Notification Diff Batching Optimization
## Problem
`ActionResultFilter.filterForPlayer` generates per-action-result game state diffs, which is expensive. Profiling shows significant time spent in `filteredGameStateDiff``GameStateViewFilter.filteredGameState` (called twice per result) → `GameStateViewDiffer.diff`.
A potential optimization is to batch diffs: instead of computing N diffs for N action results, compute one combined diff representing the final state change. However, this is blocked by how client notification generators work.
## Current Architecture
### Server Side
1. `ActionResultFilter.filterForPlayer` processes each action result
2. For each result, computes `filteredGameStateDiff(before, after, factionId)`
3. Returns `Vector[ActionResultView]` where each view has its own `gameStateDiff`
### Client Side
1. `EagleGameModel.HandleNewHistoryEntry` processes each `ActionResultView`:
```csharp
private void HandleNewHistoryEntry(ActionResultView arv) {
_currentModel.HistoryCount++;
MaybeSendNotification(arv); // Uses currentModel state
ApplyGameStateViewDiff(arv.GameStateDiff); // Updates currentModel
}
```
2. Notification generators receive `(ActionResultView, IGameModel)` and look up display data from the model:
```csharp
var province = currentModel.Provinces[details.ProvinceId];
var factionName = currentModel.FactionName(details.FactionId);
var hero = currentModel.Heroes[heroId];
var affectedProvinces = currentModel.ProvincesForFaction(factionId);
```
### The Problem
Notification for action N sees model state after actions 1..N-1 have been applied. If we batch diffs, notification N would see model state before ANY actions, potentially showing stale data.
Example:
1. Action 1: Province X conquered by Faction B (was Faction A)
2. Action 2: Notification needs to show Province X's current ruler
With batching, action 2's notification would incorrectly show Faction A.
## Audit of Client Notification Generators
~50 generators access `currentModel`. Key lookup patterns:
| Lookup | Count | Mutable? | Risk |
|--------|-------|----------|------|
| `currentModel.PlayerId` | 22 | No | Safe |
| `currentModel.Provinces[id]` | 17 | Yes | `RulingFactionId` changes on conquest |
| `currentModel.Heroes[id]` | ~20 | Mostly safe | Hero data stable, used for display |
| `currentModel.FactionName(id)` | ~10 | No | Names don't change |
| `currentModel.MaybeDestroyedFaction(id)` | ~15 | Yes | Faction may be destroyed |
| `currentModel.ProvincesForFaction(id)` | ~15 | Yes | Changes on conquest |
### State-Changing Action Types
- `ProvinceConquered` - changes province ownership
- `FactionDestroyed` - removes faction
- `FactionLeaderRemoved` - changes faction head
## Proposed Solution: Server-Side Display Data
Eliminate client model lookups by including all display data in server-generated notifications.
### Current Notification Structure
```scala
case class NotificationC(
details: NotificationDetails,
targetFactionIds: Vector[FactionId],
affectedProvinceIds: Vector[ProvinceId], // Already exists, underused
affectedHeroIds: Vector[HeroId], // Already exists, underused
llm: NotificationT.Llm,
deferred: Boolean
)
```
### Proposed Changes
**Option A: Add fields to NotificationC**
```scala
case class NotificationC(
details: NotificationDetails,
targetFactionIds: Vector[FactionId],
affectedProvinceIds: Vector[ProvinceId],
affectedHeroIds: Vector[HeroId],
// New fields:
factionNames: Map[FactionId, String],
displayedHeroViews: Vector[HeroView],
provinceNames: Map[ProvinceId, String],
llm: NotificationT.Llm,
deferred: Boolean
)
```
**Option B: Enrich each NotificationDetails type**
```scala
case class TruceAccepted(
offeringFactionId: FactionId,
offeringFactionName: String, // New
targetFactionId: FactionId,
targetFactionName: String, // New
ambassadorHeroId: HeroId,
ambassadorHeroView: HeroView // New
)
```
### Implementation Steps
1. **Proto changes**: Add new fields to `Notification` message
2. **Server**: Populate display fields when creating notifications
3. **Client**: Update ~50 generators to use notification fields
4. **Test**: Add test that greps for `currentModel.` access in generators (excluding `PlayerId`)
5. **Server optimization**: With client decoupled from model state, batch diffs in `filterForPlayer`
### Trade-offs
**Pros:**
- Clean separation: server provides all display data
- Enables diff batching optimization
- Easier to reason about notification correctness
- Test can enforce the invariant
**Cons:**
- Larger notification messages (includes names, hero views)
- Proto changes required
- ~50 generators need updating (mechanical but tedious)
- Server must know what display data each notification type needs
## Alternative Approaches Considered
### A: Selective Per-Action Diffs
Only generate individual diffs for action results with notifications that need mutable model state. Requires tracking which notification types need which state.
**Rejected because:** Fragile; easy to add a new generator that breaks the invariant.
### B: State-Change-Triggered Diffs
If batch contains state-changing action types (ProvinceConquered, etc.), generate individual diffs from that point. Otherwise batch.
**Rejected because:** Still conservative; many batches would fall back to individual diffs.
## Status
**Deferred** - Current performance is acceptable. This doc captures the analysis for future reference if optimization becomes necessary.
## References
- `ActionResultFilter.scala` - Server-side filtering
- `EagleGameModel.cs` - Client-side model updates
- `Assets/Eagle/Notifications/` - All notification generators
- `NotificationT.scala` - Server notification types
-383
View File
@@ -1,383 +0,0 @@
# Plan: Extract OAuth to Go Service
## Goal
Move OAuth authentication handling from the Eagle Scala server into a separate Go service. This simplifies Eagle (gRPC-only, no HTTP), reduces complexity, and sets up for potentially moving JWT validation outside Eagle too.
## Architecture Decision: Sidecar Service (Not DO Functions)
**Recommendation: Go sidecar service on the same droplet, in a separate container**
**Why not DO Functions:**
- OAuth requires **stateful sessions** (pendingOAuth/completedOAuth maps with 10-min TTL)
- Client polling pattern (every 2 seconds) would incur high function invocation costs
- Cold start latency problematic for auth flows
- State would require external store (Redis), adding complexity
**Why sidecar (separate container):**
- Simple process on same droplet, minimal network latency
- In-memory state management (like current Scala impl)
- Easy to monitor/debug alongside Eagle
- Can share filesystem for key files (RSA keys) via volume mounts
- **Independent deployment**: Deploying Eagle doesn't restart auth service (and vice versa)
- **Independent scaling**: Could move to separate droplet later if needed
## Current Architecture (What Exists)
```
Unity Client
├── GetOAuthUrl RPC → Eagle AuthServiceImpl → OAuthService.getAuthUrl()
├── [User browser auth] → HTTP callback → OAuthHttpHandler → OAuthService.handleCallback()
├── CheckOAuthStatus RPC (polling) → AuthServiceImpl → OAuthService.checkStatus()
└── All other RPCs include JWT → AuthorizationInterceptor validates
```
**Key files:**
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow, state management
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD (persisted)
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
- `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala` - HTTP callback handler
## Target Architecture (Phase 1)
```
Unity Client
├── GetOAuthUrl RPC ──────────────┐
├── CheckOAuthStatus RPC (polling)├──→ Eagle (port 40032) ──proxy──→ Go Auth Container (port 40033)
├── RefreshToken RPC ─────────────┘ │
├── [User browser] → HTTP callback ────────────────────────────────────────┤
│ ↓
│ (Internal gRPC: GetOrCreateUser, GetUser)
│ ↓
└── Game RPCs with JWT ─────────────────────→ Eagle (port 40032) ← JWT validation stays here
[Same Droplet]
┌─────────────────────────────────────────────────────────────────────────────┐
│ │
│ ┌──────────────────────┐ ┌────────────────────────────────────┐ │
│ │ Go Auth Container │◄────────►│ Eagle Container │ │
│ │ (eagle0-auth) │ internal │ (eagle0-server) │ │
│ │ │ gRPC │ │ │
│ │ - OAuth flow │ │ - JWT validation │ │
│ │ - JWT creation │ │ - UserService (persistence) │ │
│ │ - HTTP callback │ │ - Game logic │ │
│ └──────────────────────┘ └────────────────────────────────────┘ │
│ │ │ │
│ └────────────────┬───────────────────────┘ │
│ ▼ │
│ /etc/eagle0/keys/ (shared volume) │
│ - private.pem │
│ - public.pem │
└─────────────────────────────────────────────────────────────────────────────┘
```
## Component Responsibilities
### Go Auth Service (NEW - separate container)
- **OAuth flow**: getAuthUrl, handleCallback (HTTP), checkStatus
- **State management**: pendingOAuth, completedOAuth maps with TTL
- **JWT creation**: Issue access/refresh tokens (shares RSA private key with Eagle)
- **Token refresh**: Validate refresh token, issue new access token
- Calls Eagle's internal UserService gRPC to find/create users
### Eagle Server (SIMPLIFIED)
- **JWT validation**: AuthorizationInterceptor stays (validates tokens on game RPCs)
- **UserService**: Stays in Eagle (user persistence, display name logic)
- **New internal gRPC**: Expose GetOrCreateUser, GetUser for Go service to call
- **Proxy (Phase 1)**: Forward OAuth RPCs to Go service
- **Remove (Phase 2)**: OAuthService, OAuthHttpHandler, HTTP server setup
### Unity Client (NO CHANGES in Phase 1)
- Eagle proxies Auth RPCs to Go service
- Client still connects to Eagle on port 40032
## Implementation Phases
### Phase 1: Go Auth Service with Eagle Proxy (Zero Client Changes)
1. **Create Go service structure**
```
src/main/go/net/eagle0/authservice/
├── main.go # Entry point, starts gRPC + HTTP servers
├── oauth.go # OAuth state management, provider configs
├── jwt.go # JWT creation (copy logic from Scala)
├── handlers.go # gRPC handlers for Auth service
├── http_callback.go # HTTP handler for OAuth callback
└── BUILD.bazel
```
2. **Internal gRPC proto for Eagle UserService**
```protobuf
// src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto
service InternalUserService {
rpc GetOrCreateUser(GetOrCreateUserRequest) returns (GetOrCreateUserResponse);
rpc GetUser(GetUserRequest) returns (GetUserResponse);
}
message GetOrCreateUserRequest {
string provider = 1; // "discord" or "google"
string provider_user_id = 2;
string email = 3;
string avatar_url = 4;
}
message GetOrCreateUserResponse {
string user_id = 1;
string display_name = 2;
string avatar_url = 3;
bool is_admin = 4;
bool is_new_user = 5;
}
```
3. **Eagle: Expose InternalUserService**
- New `InternalUserServiceImpl.scala` wrapping UserService
- Bind to same port, different service name (internal only)
4. **Eagle: Proxy Auth RPCs to Go**
- AuthServiceImpl delegates GetOAuthUrl, CheckOAuthStatus, RefreshToken to Go service
- SetDisplayName, GetCurrentUser, Logout stay in Eagle
5. **Share RSA keys via volume mount**
- Go service reads same key files as Eagle
- Both can create valid JWTs
- Eagle continues to validate JWTs
6. **Docker/Container setup**
- New Dockerfile for Go auth service
- docker-compose or Kubernetes config for both containers
- Shared volume for /etc/eagle0/keys/
- Internal network for container-to-container gRPC
### Phase 2: Client Direct to Go Service (Future)
1. **Update Unity client**
- Connect to Go Auth service directly for OAuth RPCs
- Keep connecting to Eagle for game RPCs
2. **Remove Eagle proxy code**
- Delete AuthServiceImpl OAuth delegation
- AuthServiceImpl only handles SetDisplayName, GetCurrentUser, Logout
### Phase 3: Move JWT Validation to Go (Optional Future)
1. **Go service validates JWTs**
- Add ValidateToken RPC or use shared middleware pattern
2. **Eagle calls Go for validation**
- AuthorizationInterceptor calls Go to validate tokens
- OR: Use stateless validation (both share public key)
## Files to Create
### Go Service
- `src/main/go/net/eagle0/authservice/main.go`
- `src/main/go/net/eagle0/authservice/oauth.go`
- `src/main/go/net/eagle0/authservice/jwt.go`
- `src/main/go/net/eagle0/authservice/handlers.go`
- `src/main/go/net/eagle0/authservice/http_callback.go`
- `src/main/go/net/eagle0/authservice/BUILD.bazel`
### Protos
- `src/main/protobuf/net/eagle0/eagle/internal/auth_internal.proto`
### Scala
- `src/main/scala/net/eagle0/eagle/service/InternalUserServiceImpl.scala`
### Docker/Deployment
- `ci/auth_service.Dockerfile`
- Update `docker-compose.yml` (or equivalent)
## Files to Modify
### Scala (Phase 1)
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - Proxy OAuth RPCs to Go
- `src/main/scala/net/eagle0/eagle/Main.scala` - Start internal user service, add auth-service-url flag
### Scala (Phase 2 - Removal)
- Delete `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala`
- Delete `src/main/scala/net/eagle0/eagle/service/OAuthHttpHandler.scala`
- Simplify `src/main/scala/net/eagle0/eagle/Main.scala` - Remove HTTP server
### Unity (Phase 2)
- `Assets/Auth/OAuthManager.cs` - Point OAuth RPCs to Go service port
- `Assets/EagleConnection.cs` - Add second channel for auth service
## Key Implementation Details
### State Management in Go
```go
type OAuthState struct {
Provider string
CreatedAt time.Time
}
type OAuthResult struct {
Success bool
UserInfo *ProviderUserInfo
Provider string
Error string
}
var pendingOAuth = sync.Map{} // state -> OAuthState
var completedOAuth = sync.Map{} // state -> OAuthResult
const stateExpiration = 10 * time.Minute
// Background goroutine cleans expired states every minute
func cleanupExpiredStates() {
ticker := time.NewTicker(1 * time.Minute)
for range ticker.C {
cutoff := time.Now().Add(-stateExpiration)
pendingOAuth.Range(func(key, value any) bool {
if value.(OAuthState).CreatedAt.Before(cutoff) {
pendingOAuth.Delete(key)
}
return true
})
// Similar for completedOAuth
}
}
```
### JWT Creation in Go
```go
import "github.com/golang-jwt/jwt/v5"
type EagleClaims struct {
jwt.RegisteredClaims
UserId string `json:"userId"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
}
func CreateAccessToken(userId, displayName string, isAdmin bool) (string, error) {
claims := EagleClaims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(7 * 24 * time.Hour)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
UserId: userId,
DisplayName: displayName,
IsAdmin: isAdmin,
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
return token.SignedString(privateKey)
}
```
### OAuth Provider Configs
- Read from environment variables (same as current OAuthConfig.scala)
- DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET
- GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
- OAUTH_CALLBACK_URL (e.g., https://eagle0.shardok.games/oauth/callback)
### Container Networking
```yaml
# docker-compose.yml example
services:
eagle0-auth:
build:
context: .
dockerfile: ci/auth_service.Dockerfile
ports:
- "40033:40033" # gRPC
- "8080:8080" # HTTP callback
volumes:
- ./keys:/etc/eagle0/keys:ro
environment:
- DISCORD_CLIENT_ID
- DISCORD_CLIENT_SECRET
- GOOGLE_CLIENT_ID
- GOOGLE_CLIENT_SECRET
- EAGLE_INTERNAL_URL=eagle0-server:40034
eagle0-server:
build:
context: .
dockerfile: ci/eagle_run.Dockerfile
ports:
- "40032:40032" # Public gRPC
expose:
- "40034" # Internal gRPC (container-to-container only)
volumes:
- ./keys:/etc/eagle0/keys:ro
- ./data:/var/lib/eagle0
environment:
- AUTH_SERVICE_URL=eagle0-auth:40033
```
## Deployment
### Development
```bash
# Terminal 1: Go Auth Service
bazel run //src/main/go/net/eagle0/authservice:authservice -- \
--grpc-port=40033 \
--http-port=8080 \
--eagle-internal-url=localhost:40034
# Terminal 2: Eagle Server
bazel run //src/main/scala/net/eagle0/eagle:eagle_server -- \
--eagle-grpc-port=40032 \
--internal-grpc-port=40034 \
--auth-service-url=localhost:40033
```
### Production
- Both containers on same droplet via docker-compose
- Shared volume for RSA keys at /etc/eagle0/keys/
- Internal Docker network for container-to-container communication
- External access: 40032 (Eagle gRPC), 8080 (OAuth HTTP callback)
## Testing Strategy
1. **Unit tests for Go service**
- OAuth state management (expiration, cleanup)
- JWT creation matches Scala output (test with same keys)
- HTTP callback parsing
2. **Integration tests**
- Go service ↔ Eagle internal gRPC
- Full OAuth flow with mock provider
3. **Existing tests continue to pass**
- All Scala tests (JWT validation, user service)
4. **End-to-end test**
- Spin up both containers
- Run OAuth flow through proxy
## Risks and Mitigations
| Risk | Mitigation |
|------|------------|
| Key file permissions | Shared volume with read-only mount |
| State loss on Go restart | Document this (same as current Scala behavior); consider Redis later |
| Clock skew affecting JWT | Both on same machine |
| OAuth callback race | HTTP callback completes before gRPC poll |
| Container networking | Use docker-compose for reliable internal DNS |
| Proxy adds latency | Minimal (same machine), remove in Phase 2 |
## Estimated Scope
- **Phase 1**: ~500-700 lines Go, ~100 lines Scala changes, ~50 lines Docker config
- **Phase 2**: ~50 lines Unity, deletion of ~300 lines Scala
- **Phase 3**: Optional, separate decision
## Alternative Considered: Move Everything to Go
Could move UserService to Go as well, but:
- UserService is tightly integrated with game persistence
- Would require duplicating persistence layer
- Not worth the complexity for now
Keep UserService in Eagle, expose via internal gRPC.
## Open Questions
1. **HTTP callback routing**: Does the OAuth callback URL need to change, or can we route traffic from the existing URL to the new Go service?
2. **Health checks**: Should we add health check endpoints for container orchestration?
3. **Logging**: Should Go service log to same format/destination as Eagle?
-189
View File
@@ -1,189 +0,0 @@
# Discord + Google OAuth Implementation Plan
## Overview
Replace HTTP Basic Auth with OAuth 2.0 (Discord + Google) for Eagle0. Users authenticate via system browser, receive JWT tokens, and choose their own display names.
## Architecture
```
Unity Client Eagle Server
| |
| 1. Click "Login with Discord/Google" |
| -------------------------------------------------> |
| GetOAuthUrl(provider) -> auth_url + state |
| |
| 2. Open system browser -> OAuth consent |
| 3. User authenticates with provider |
| 4. Redirect to eagle0://auth/callback?code=xxx |
| |
| 5. ExchangeCode(code, state) |
| -------------------------------------------------> |
| Exchange code with provider |
| Fetch user info (id, email, avatar) |
| Create/update user record |
| Issue JWT + refresh token |
| <------------------------------------------------- |
| (jwt, refresh_token, user_info, is_new_user) |
| |
| 6. [If new user] SetDisplayName(name) |
| -------------------------------------------------> |
| |
| 7. Subsequent gRPC calls |
| Authorization: Bearer <jwt> |
| -------------------------------------------------> |
```
## Key Design Decisions
| Decision | Choice | Rationale |
|----------|--------|-----------|
| OAuth flow | System browser + deep link | Secure, supports password managers |
| Code exchange | Eagle server directly | No separate auth service needed |
| JWT signing | RS256 (asymmetric) | Future flexibility for token verification |
| User storage | Protobuf file via Persister | Consistent with existing patterns |
| Token expiry | 7-day access, 30-day refresh | Balance security and gaming UX |
## Implementation Phases
### Phase 1: Proto Definitions & Infrastructure
**New files:**
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth API messages
- `src/main/protobuf/net/eagle0/eagle/internal/user.proto` - User storage schema
**Key proto messages:**
```protobuf
// API
GetOAuthUrlRequest/Response // Get OAuth URL to open in browser
ExchangeCodeRequest/Response // Exchange auth code for JWT
SetDisplayNameRequest/Response // Set user's display name
RefreshTokenRequest/Response // Refresh expired access token
// Internal storage
User // user_id, display_name, oauth_identities
UserDatabase // All users + indexes for lookup
```
### Phase 2: Eagle Server Auth Services
**New Scala files:**
- `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` - Discord/Google config from env vars
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - JWT creation/validation (RS256)
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD, display name validation
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth code exchange
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC service implementation
**Modify:**
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala`
- Replace Basic Auth parsing with JWT validation
- Skip auth for public endpoints (GetOAuthUrl, ExchangeCode, RefreshToken)
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala`
- Change context keys from `userName` to `userId` + `displayName`
- `src/main/scala/net/eagle0/eagle/service/Main.scala`
- Wire up new auth services and JWT key loading
### Phase 3: Unity Client OAuth Flow
**New C# files:**
- `Assets/Auth/OAuthManager.cs` - OAuth flow + deep link handling
- `Assets/Auth/TokenStorage.cs` - Secure token persistence
- `Assets/Auth/AuthClient.cs` - gRPC client for auth service
**Modify:**
- `Assets/EagleConnection.cs`
- Replace `AuthInterceptor` (Basic Auth) with `JwtAuthInterceptor` (Bearer token)
- `Assets/ConnectionHandler/ConnectionHandler.cs`
- Replace username/password UI with Discord/Google login buttons
- Add display name setup flow for new users
### Phase 4: Platform Configuration
**Deep link registration:**
- iOS: Add `eagle0://` to CFBundleURLSchemes in Info.plist
- Android: Add intent-filter for `eagle0://auth` in AndroidManifest.xml
- Desktop: Register URL scheme (Windows registry / macOS plist)
**OAuth provider setup:**
1. Discord Developer Portal: Create app, add redirect URI `eagle0://auth/callback`
2. Google Cloud Console: Create OAuth client, add redirect URI
**Environment variables (server):**
```
DISCORD_CLIENT_ID
DISCORD_CLIENT_SECRET
GOOGLE_CLIENT_ID
GOOGLE_CLIENT_SECRET
JWT_PRIVATE_KEY_PATH
JWT_PUBLIC_KEY_PATH
```
### Phase 5: Testing
**Unit tests:**
- `JwtServiceSpec.scala` - Token creation/validation
- `UserServiceSpec.scala` - Display name validation, uniqueness
- `OAuthServiceSpec.scala` - OAuth flow with mocked providers
**Integration tests:**
- Full OAuth flow with mock provider
- JWT validation in AuthorizationInterceptor
- gRPC calls with valid/invalid tokens
**Manual testing:**
- [ ] Discord login (Windows, macOS)
- [ ] Google login (Windows, macOS)
- [ ] Deep link callback works
- [ ] Display name validation
- [ ] Session persistence across restarts
- [ ] Token refresh
## Files Summary
### Create
| File | Purpose |
|------|---------|
| `src/main/protobuf/net/eagle0/eagle/api/auth.proto` | Auth API definitions |
| `src/main/protobuf/net/eagle0/eagle/internal/user.proto` | User storage schema |
| `src/main/scala/net/eagle0/eagle/auth/OAuthConfig.scala` | Provider config |
| `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` | JWT handling |
| `src/main/scala/net/eagle0/eagle/auth/UserService.scala` | User management |
| `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` | OAuth flow |
| `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` | gRPC service |
| `Assets/Auth/OAuthManager.cs` | Unity OAuth manager |
| `Assets/Auth/TokenStorage.cs` | Token storage |
| `Assets/Auth/AuthClient.cs` | Auth gRPC client |
### Modify
| File | Changes |
|------|---------|
| `AuthorizationInterceptor.scala` | Basic Auth -> JWT validation |
| `AuthorizationUtils.scala` | userName -> userId + displayName |
| `Main.scala` | Wire auth services |
| `EagleConnection.cs` | AuthInterceptor -> JwtAuthInterceptor |
| `ConnectionHandler.cs` | Login UI -> OAuth buttons + display name |
### Delete
- nginx htpasswd configuration (no longer needed)
## Security Considerations
1. **State parameter** - CSRF protection in OAuth flow
2. **PKCE** - Consider adding for mobile (enhancement)
3. **Secure storage** - Use Keychain (iOS) / Keystore (Android) for tokens
4. **Token refresh** - 7-day access tokens with 30-day refresh
5. **Rate limiting** - Limit login attempts per IP
## Dependencies to Add
**Scala (MODULE.bazel):**
- JWT library (e.g., `jwt-scala` or `nimbus-jose-jwt`)
- HTTP client (e.g., `sttp` for OAuth requests)
**Unity:**
- Deep linking is built-in (Unity 2021+)
- No additional packages required
## Rollback Plan
Keep Basic Auth code in a feature branch. Both auth methods can coexist during transition via feature flag if needed.
-350
View File
@@ -1,350 +0,0 @@
# OAuth Implementation: Next Steps and Design
## Executive Summary
The OAuth implementation is functional but has several gaps that need addressing before it's production-ready. This document outlines the known issues, proposes a comprehensive user identity model, and provides a prioritized implementation plan.
## Current State (Updated January 2026)
### What Works ✅
- Discord OAuth flow (server-mediated polling)
- Google OAuth flow
- JWT token generation and validation
- User creation and display name setting
- Auto-login with stored tokens
- Basic game creation and play with OAuth users
- Headshot fetching via public CDN (no auth required)
- Logout button in lobby (preserves tokens for quick reconnect)
- Environment (prod/qa) and user display in lobby
- Game identity with userName = displayName (PR #4964 merged)
### Known Issues
#### 1. Game Identity Model Fragility (Deferred)
**Status**: Accepted for now. PR #4964 merged with `userName = displayName`.
**Current behavior**:
- Games store `userNameToFactionId: Map[String, Int]`
- For JWT users, this maps displayName → factionId
- displayName is technically mutable (users could change it)
- No migration path when displayName changes
**Why this is acceptable**:
1. We don't currently have a "change display name" feature
2. The alternative (using userId) requires more extensive changes
3. Can migrate to userId-based identity later if needed
#### 2. In-Game Headshot Fetching ✅ FIXED
**Solution**: Made the `eagle0-headshots` S3 bucket public and enabled CDN.
- Client now fetches directly from `https://eagle0-headshots.sfo3.cdn.digitaloceanspaces.com/`
- No authentication required
- Works for both OAuth and Basic Auth users
- Simpler architecture, no dependency on home Mac server
#### 3. Logout from Lobby ✅ FIXED
**Solution**: Added logout button to lobby UI (PR #4967).
- Button disconnects from server and returns to connection screen
- Intentionally does NOT clear OAuth tokens
- Allows quick reconnect with same account without full OAuth flow
#### 4. Display Name Uniqueness Not Enforced (Medium) - OPEN
**Problem**: User was able to set displayName "nolen" when that name was already taken.
**Root cause**: Unknown - needs investigation. Either:
- The uniqueness check is buggy
- The displayNameIndex wasn't populated correctly during user creation
- Race condition during concurrent registrations
#### 5. Admin Server Crashes ✅ FIXED
**Solution**: PR #4964 sets `userName = displayName` for JWT users.
#### 6. Intermittent "Expired" Errors During Login (Medium) - INVESTIGATING
**Problem**: Users occasionally get "OAuth session expired" errors even when server logs show the callback succeeded.
**Status**: Added diagnostic logging in PR #4974 to trace:
- State creation in `getAuthUrl`
- State lookup in `handleCallback`
- Result lookup in `checkStatus`
**Possible causes**:
- State mismatch between client and server
- Race condition in polling
- Cleanup running at wrong time
#### 7. Token Expiry Field Bug ✅ FIXED
**Problem**: `CheckOAuthStatusResponse.expiresAt` was returning refresh token expiry (30 days) instead of access token expiry (7 days).
**Solution**: Fixed in PR #4974 to calculate correct access token expiry.
---
## Proposed User Identity Model
### Design Principles
1. **Stable Internal Identity**: `userId` (UUID) is the only key used for persistent associations
2. **Display Name is Cosmetic**: Can change without breaking game associations
3. **Backwards Compatibility**: Basic Auth continues to work for local development
4. **Multi-Provider Support**: Users can link Discord, Google, and future providers
5. **Avatar Flexibility**: Use OAuth avatar by default, support custom uploads later
### Data Model
```
User {
userId: String (UUID) // Primary key, immutable, used for all internal references
displayName: String // Unique, user-visible, mutable with migration
displayNameLower: String // Case-insensitive uniqueness
email: String // Primary email for account recovery/linking
avatarUrl: String // Current avatar URL
avatarData: bytes // Cached avatar for offline/fast access (future)
oauthIdentities: [OAuthIdentity]
createdAt: Timestamp
lastLoginAt: Timestamp
isAdmin: Boolean
}
OAuthIdentity {
provider: String // "discord", "google", etc.
providerUserId: String // Provider's user ID
providerEmail: String // Email from this provider
avatarUrl: String // Avatar from this provider
linkedAt: Timestamp
}
```
### Identity Resolution Strategy
The key question: **What should `AuthorizationUtils.userName` return?**
#### Option A: userName = displayName (Current PR #4964)
- **Pro**: Human-readable in logs, game saves, debugging
- **Con**: Breaks if displayName changes
- **Migration**: None needed now, complex later
#### Option B: userName = userId (Recommended)
- **Pro**: Stable identity, displayName changes are safe
- **Con**: UUIDs in logs are ugly, need display name lookup for UI
- **Migration**: Cleaner long-term, but breaking change for any existing OAuth games
#### Option C: Hybrid with Migration Support
- **userName** = userId for new games
- **Legacy lookup** for old games by displayName
- **Display layer** resolves userId → displayName for UI
**Recommendation**: Option B with a display name resolution layer. The ugliness in logs is acceptable for the stability it provides. Implement a `UserService.resolveDisplayName(identifier: String): String` that returns displayName for UUIDs or the identifier itself for legacy usernames.
### Account Linking Strategy
#### Automatic Linking (Future)
When a user logs in with a new OAuth provider:
1. Check if the provider email matches an existing user's email
2. If match found, prompt: "An account exists with this email. Link accounts?"
3. If confirmed, add new OAuthIdentity to existing user
4. If declined, create separate account (different email required)
#### Manual Linking (MVP)
1. User logs in with primary account
2. User goes to Settings → Linked Accounts
3. User clicks "Link Discord" or "Link Google"
4. OAuth flow adds new identity to current user
### Avatar/Headshot Strategy
#### Phase 1: OAuth Avatars (MVP)
- Store `avatarUrl` from OAuth provider during login
- Server proxies avatar requests to avoid CORS issues
- Cache avatars locally with TTL
#### Phase 2: Avatar Caching
- Download avatar to local storage on login
- Serve from local storage for reliability
- Refresh periodically or on login
#### Phase 3: Custom Avatars (Future)
- Allow users to upload custom avatar
- Store in S3/DO Spaces
- Custom avatar overrides OAuth avatar
---
## Implementation Plan
### Phase 1: Stabilization ✅ COMPLETE
#### 1.1 Fix Display Name Uniqueness Bug - OPEN
- [ ] Investigate why "nolen" was allowed when it existed
- [ ] Add logging to `setDisplayName` to trace the issue
- [ ] Ensure `displayNameIndex` is correctly maintained
- [ ] Add unit tests for uniqueness enforcement
#### 1.2 Add Logout Button to Lobby ✅ DONE
- [x] Add "Logout" button to lobby UI
- [x] Disconnect from server
- [x] Navigate to connection screen
- [x] Preserve OAuth tokens for quick reconnect (intentional change from original plan)
#### 1.3 Merge PR #4964 (userName = displayName) ✅ DONE
- [x] Merged - games work with OAuth users
- [x] Documented limitation (games break if displayName changes)
#### 1.4 Fix Headshot Fetching ✅ DONE
- [x] Made eagle0-headshots bucket public
- [x] Client fetches directly from CDN
- [x] No authentication required
#### 1.5 Add Lobby Status Display ✅ DONE
- [x] Show environment (prod/qa) in lobby
- [x] Show current user in lobby (OAuth displayName or classic username)
### Phase 2: Remaining Work (Priority Order)
#### 2.1 Diagnose Intermittent "Expired" Errors - IN PROGRESS
- [x] Add diagnostic logging (PR #4974)
- [ ] Deploy and reproduce the issue
- [ ] Analyze logs to identify root cause
- [ ] Implement fix based on findings
#### 2.2 Fix Display Name Uniqueness
- [ ] Investigate UserService.setDisplayName logic
- [ ] Check displayNameIndex population
- [ ] Add logging to trace the issue
- [ ] Fix the bug and add tests
#### 2.3 Wire Up Lobby UI in Unity
- [ ] Connect `lobbyEnvironmentText` to TextMeshProUGUI in scene
- [ ] Connect `lobbyUserText` to TextMeshProUGUI in scene
#### 2.4 Implement Token Refresh During Gameplay
- [ ] Implement `RefreshToken` RPC on server (currently throws UNIMPLEMENTED)
- [ ] Store refresh tokens server-side for validation
- [ ] Add proactive refresh in client before token expires
- [ ] Handle refresh during reconnection attempts
### Phase 3: Nice-to-Haves (Future)
#### 3.1 Proactive Token Refresh
- [ ] Monitor token expiry in client
- [ ] Refresh automatically when < 5 minutes remaining
- [ ] Update TokenStorage with new access token
#### 3.2 Better Error Messages
- [ ] Distinguish between network errors and auth errors
- [ ] Show user-friendly messages for OAuth failures
- [ ] Add retry suggestions
#### 3.3 Session Persistence Across Server Restarts
- [ ] Move pendingOAuth from in-memory TrieMap to Redis/database
- [ ] Move completedOAuth to Redis with TTL
- [ ] Server can restart without breaking in-flight OAuth flows
#### 3.4 Migrate to userId-based Game Identity (Deferred)
- [ ] Change `AuthorizationUtils.userName` to return `userId` for JWT users
- [ ] Add `UserService.resolveDisplayName(id: String): String` for UI display
- [ ] Update game UI to resolve userIds to displayNames
- [ ] Existing Basic Auth games continue to work (userName is literal)
#### 3.5 Display Name Change Support (Requires 3.4)
- [ ] Add `ChangeDisplayName` RPC
- [ ] Validate new name is unique
- [ ] Update user record
- [ ] No game migration needed (games use userId)
### Phase 3: Multi-Provider Support (Future)
#### 3.1 Account Linking UI
- [ ] Add Settings page with "Linked Accounts" section
- [ ] Show currently linked providers
- [ ] "Link Another Account" button triggers OAuth flow
- [ ] `LinkOAuthProvider` RPC adds identity to current user
#### 3.2 Login Provider Selection
- [ ] If user has multiple providers, any can be used to login
- [ ] All resolve to same userId
- [ ] Session shows which provider was used
#### 3.3 Account Merging (Complex)
- [ ] Handle case where user created separate accounts
- [ ] Merge game history, stats, etc.
- [ ] Delete duplicate user record
- [ ] This is complex - may defer or not implement
### Phase 4: Enhanced Avatars (Future)
#### 4.1 Avatar Caching
- [ ] Download avatars to S3/DO Spaces on login
- [ ] Serve from our CDN
- [ ] Refresh on login if changed
#### 4.2 Custom Avatar Upload
- [ ] Upload endpoint with size/format validation
- [ ] Store in S3/DO Spaces
- [ ] Custom avatar overrides OAuth avatar
---
## Technical Debt to Address
1. **Context Propagation in Futures**: PR #4960 fixed `setDisplayName` and `getCurrentUser`, but audit all `Future` blocks that access `AuthorizationUtils`
2. **Dual Auth Support**: The system supports both Basic Auth and JWT. Consider:
- Should Basic Auth be deprecated for production?
- Should it remain for local development only?
- How do Basic Auth users interact with OAuth users in the same game?
3. **Token Refresh**: `RefreshToken` RPC throws UNIMPLEMENTED. Need to:
- Implement refresh token storage and validation
- Handle token refresh in client
- Consider refresh token rotation for security
4. **Session Management**: No server-side session tracking. Consider:
- Track active sessions per user
- Allow "logout all devices"
- Detect concurrent logins
---
## Open Questions
1. **What happens when a Basic Auth user and OAuth user have the same name?**
- Currently possible - Basic Auth doesn't check UserService
- Could cause confusion in games
- Solution: Require OAuth for multiplayer? Or namespace Basic Auth names?
2. **Should displayName changes be allowed?**
- With userId-based identity, it's safe
- But could cause confusion ("who is this new player?")
- Consider: rate limit changes, show "formerly known as" temporarily
3. **How to handle OAuth provider account deletion?**
- User deletes their Discord account
- Their Eagle0 account still exists
- They can't login unless they linked another provider
- Solution: Encourage linking multiple providers, or add email/password fallback
4. **Admin impersonation with OAuth**
- Currently works via X-Impersonate-User header
- Should this use userId or displayName?
- Probably userId for stability
---
## Appendix: File Locations
### Server (Scala)
- `src/main/scala/net/eagle0/eagle/auth/UserService.scala` - User CRUD
- `src/main/scala/net/eagle0/eagle/auth/JwtService.scala` - Token generation/validation
- `src/main/scala/net/eagle0/eagle/auth/OAuthService.scala` - OAuth flow
- `src/main/scala/net/eagle0/eagle/service/AuthServiceImpl.scala` - gRPC Auth service
- `src/main/scala/net/eagle0/eagle/service/AuthorizationInterceptor.scala` - Auth middleware
- `src/main/scala/net/eagle0/eagle/service/AuthorizationUtils.scala` - Context accessors
### Client (C#)
- `Assets/Auth/AuthClient.cs` - gRPC client for Auth service
- `Assets/Auth/OAuthManager.cs` - OAuth flow orchestration
- `Assets/Auth/TokenStorage.cs` - Persistent token storage
- `Assets/Auth/JwtAuthInterceptor.cs` - Attaches JWT to requests
### Protos
- `src/main/protobuf/net/eagle0/eagle/api/auth.proto` - Auth service definition
- `src/main/protobuf/net/eagle0/eagle/internal/user/user.proto` - User data model
-205
View File
@@ -1,205 +0,0 @@
# Scala 3 Modernization Guide
## Overview
This document outlines opportunities to modernize the Eagle0 codebase to use Scala 3 best practices and features. The migration to Scala 3 is complete, but the code still uses many Scala 2 patterns that can be improved.
## Modernization Opportunities
### 1. **Convert Sealed Traits to Enums** 🎯 HIGH IMPACT
**Benefits**: Better performance, more concise syntax, improved exhaustiveness checking
**Current pattern** (`ExternalTextGenerationCaller.scala:23-31`):
```scala
sealed trait ExternalTextGenerationError extends Error {
def message: String
}
case class ExternalTextGenerationRateLimitError(code: Int, message: String)
extends ExternalTextGenerationError
case class ExternalTextGenerationHttpError(code: Int, message: String)
extends ExternalTextGenerationError
case class ExternalTextGenerationTimeoutError(message: String)
extends ExternalTextGenerationError
```
**Scala 3 improvement**:
```scala
enum ExternalTextGenerationError extends Error:
case RateLimit(code: Int, message: String)
case Http(code: Int, message: String)
case Timeout(message: String)
def message: String = this match
case RateLimit(_, msg) => msg
case Http(_, msg) => msg
case Timeout(msg) => msg
```
**Files to check**:
- `/src/main/scala/net/eagle0/common/llm_integration/ExternalTextGenerationCaller.scala`
- `/src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request/GeneratedTextRequestT.scala`
- `/src/main/scala/net/eagle0/eagle/model/state/quest/concrete/QuestC.scala`
### 2. **Convert Implicit Classes to Extension Methods** 🎯 HIGH IMPACT
**Benefits**: Modern syntax, better IDE support, cleaner imports
**Current pattern** (`MoreSeq.scala:23-26`):
```scala
implicit def SeqCollect[A, Repr[_]](coll: Repr[A])(implicit
itr: IsIterable[Repr[A]]
): SeqCollect[A, Repr, itr.type] =
new SeqCollect[A, Repr, itr.type](coll, itr)
```
**Scala 3 improvement**:
```scala
extension [A, Repr[_]](coll: Repr[A])(using itr: IsIterable[Repr[A]])
def flatCollect[B](pf: PartialFunction[itr.A, Option[B]])(using Factory[B, Repr[B]]): Repr[B] =
Factory[B, Repr[B]].fromSpecific(itr(coll).collect(pf).flatten)
def flatCollectFirst[B](pf: PartialFunction[itr.A, Option[B]]): Option[B] =
itr(coll).collect(pf).flatten.headOption
```
**Files to check**:
- `/src/main/scala/net/eagle0/common/MoreSeq.scala`
- `/src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers/CommandChooser.scala`
- `/src/main/scala/net/eagle0/eagle/service/new_game_creation/NewGameCreation.scala`
- `/src/main/scala/net/eagle0/eagle/library/actions/applier/ActionResultProtoApplierImpl.scala`
- `/src/main/scala/net/eagle0/eagle/service/new_game_creation/StartGameActionResultUtils.scala`
- `/src/main/scala/net/eagle0/eagle/model/state/date/Date.scala`
### 3. **Convert Implicit Parameters to Using Clauses** 🎯 MEDIUM IMPACT
**Benefits**: Cleaner syntax, better tooling support, clearer intent
**Current pattern**:
```scala
def method[T](value: T)(implicit ec: ExecutionContext): Future[T]
def process[A](items: Seq[A])(implicit ord: Ordering[A]): Seq[A]
```
**Scala 3 improvement**:
```scala
def method[T](value: T)(using ExecutionContext): Future[T]
def process[A](items: Seq[A])(using Ordering[A]): Seq[A]
```
**Files to check**:
- `/src/main/scala/net/eagle0/common/MoreSeq.scala`
- `/src/main/scala/net/eagle0/eagle/library/util/hero_name_fetcher/HeroNameFetcher.scala`
- `/src/main/scala/net/eagle0/eagle/library/util/ShardokMapInfo.scala`
- `/src/main/scala/net/eagle0/common/llm_integration/OpenAIChatCompletionsServiceImpl.scala`
- `/src/main/scala/net/eagle0/common/llm_integration/ClaudeServiceImpl.scala`
### 4. **Opaque Types for Type Safety** 🎯 MEDIUM IMPACT
**Benefits**: Zero runtime cost, compile-time type safety, prevents mixing up similar types
**Pattern to look for**: Type aliases that represent distinct concepts
```scala
// Instead of: type UserId = String, type GameId = String
opaque type UserId = String
object UserId:
def apply(s: String): UserId = s
extension (id: UserId)
def value: String = id
def isValid: Boolean = id.nonEmpty && id.length > 3
opaque type GameId = Long
object GameId:
def apply(l: Long): GameId = l
extension (id: GameId) def value: Long = id
```
**Candidates**: Look for simple type aliases and ID types throughout the codebase.
### 5. **Inline Methods for Performance** 🎯 LOW IMPACT
**Benefits**: Compile-time optimization, better performance for hot paths
**Pattern**: Mark small, frequently-called methods as `inline`
```scala
inline def isValidId(id: String): Boolean =
id.nonEmpty && id.length > 3
inline def calculateScore(base: Int, multiplier: Double): Double =
base * multiplier
```
**Candidates**: Small utility methods in performance-critical paths (AI calculations, game state updates).
### 6. **Union Types Instead of Complex Hierarchies** 🎯 LOW IMPACT
**Benefits**: Simpler type definitions for either/or scenarios
**Pattern**: Simple sealed traits with only case classes
```scala
// Instead of:
sealed trait Result
case class Success(value: String) extends Result
case class Error(message: String) extends Result
// Consider:
type Result = Success | Error
case class Success(value: String)
case class Error(message: String)
```
### 7. **Context Functions for Cleaner APIs** 🎯 LOW IMPACT
**Benefits**: Cleaner API design, implicit context passing
**Pattern**: Replace implicit function parameters
```scala
// Old
type Handler = GameState => Unit
def withGameState(gs: GameState)(handler: Handler): Unit = handler(gs)
// New
type Handler = GameState ?=> Unit
def withGameState(gs: GameState)(handler: Handler): Unit =
given GameState = gs
handler
```
## Implementation Priority
### Phase 1: Quick Wins (High Impact, Low Risk)
1. **Convert Extension Methods** in `MoreSeq.scala` - immediate readability improvement
2. **Update Using Clauses** - simple find/replace operation
3. **Convert Simple Sealed Traits to Enums** - start with error types
### Phase 2: Type Safety Improvements
4. **Add Opaque Types** for IDs and measurements - improves type safety
5. **Inline Performance-Critical Methods** - measure before/after impact
### Phase 3: Advanced Features (Lower Priority)
6. **Union Types** where appropriate - only for simple either/or cases
7. **Context Functions** for complex API improvements
## Implementation Guidelines
### Style Consistency
- **Keep curly braces**: Continue using Scala 2 style `{}` instead of indentation-based syntax
- **Gradual adoption**: Modernize files as they're touched for other reasons
- **Test thoroughly**: Each modernization should include verification that behavior is unchanged
### Performance Considerations
- **Measure enum performance**: Verify that enum conversion actually improves performance in hot paths
- **Benchmark inline methods**: Use profiling to confirm performance gains
- **Consider compilation time**: Some features may increase compile time
### Migration Strategy
- **File-by-file approach**: Complete modernization of one file at a time
- **Separate PRs**: Each modernization type should be its own PR for easier review
- **Documentation**: Update this document as patterns are modernized
## Success Criteria
- [ ] All extension methods converted from implicit classes
- [ ] All implicit parameters converted to using clauses
- [ ] Key sealed traits converted to enums where appropriate
- [ ] Opaque types introduced for important ID types
- [ ] Performance-critical methods marked as inline (with benchmarks)
- [ ] No regression in functionality or performance
- [ ] Code remains readable and maintainable
## Notes
- Focus on high-impact, low-risk improvements first
- Each change should be driven by clear benefits (performance, readability, type safety)
- Maintain backward compatibility where possible
- Document any breaking changes clearly
+79
View File
@@ -0,0 +1,79 @@
# The Small Eagle TODO
## Goals
Be able to support a small (10-50 user) private alpha, including with strangers.
Larger set of goals in [The Big Eagle TODO](https://docs.google.com/document/d/17RTt3-4Wl2AAVMRLodaC3a84E4de6xuQTWBPvCRM484/edit?pli=1&tab=t.0), but most of that is not necessary for MVP.
## Required
### Gameplay Productionization
- [x] ~~All functionality works on production eagle / shardok servers~~
- [x] ~~Acceptable latency in all regions~~
- [x] ~~Shardok performance similar to QA~~
- [x] ~~Error logging & alerting~~
- [x] ~~Fix long disconnects on deployments~~
- [x] Fix the Mac installer
- [x] ~~Still not reconnecting after deployments~~
- [ ] Notify about client updates, button to come directly back
- [x] Generatedtext healing
- [x] Kill outstanding shardok requests when game is deleted
### Other Productionization
- [x] ~~Oauth sign-in~~
- [x] ~~Add Google, others?~~
- [ ] User management
- [x] ~~Invite codes~~
- [ ] Link accounts
- [x] ~~Choose display name~~
- [x] ~~Just do account setup from the landing page?~~
- [x] ~~Download client directly from DO, avoid basic auth/my home network~~
- [x] Support plan (Discord + in-client bug reporting + email contact)
### Alpha Tester Support
- [x] Feedback channel (Discord server)
- [x] Bug report form in Unity client (Settings > Report Bug, sends to Discord webhook)
- [ ] Known issues doc (so testers don't report the same things)
### IP / Legal
- [x] Document and make available licenses for art & music (attributions panel)
- [x] Required open source disclosures (attributions panel)
- [x] Audit assets for anything we don't have rights to and replace it
- [x] Replace heroes that are based on real 20th or 21st century people or IP
- [x] ~~Privacy policy (collecting accounts, OAuth data, gameplay data)~~ (alpha notice on invite page/email)
- [ ] Terms of service (basic liability protection) - defer until public release
- [x] ~~Data deletion capability (user requests account removal)~~ (accounts.eagle0.net)
### Basic Gameplay
- [ ] Tutorial
- [x] In the Your Warlord panel, say what the profession is
- [x] ~~And separate panels for each profession when you encounter one~~
- [x] ~~Command tutorial for each command the first time it's clicked~~
- [x] ~~Time to recruit / expand~~
- [x] ~~And how expansion works~~
- [ ] Province events
- [x] Running low on food
- [x] ~~Time to swear brotherhood~~
- [x] ~~When you get large, or~~
- [x] ~~When you get a good candidate~~
- [ ] Shardok tutorial!
- [ ] Basic Shardok AI stuff fixed
- [ ] Lobby fixes
- [ ] Have goals / ending
- [ ] Win condition: all other factions defeated
- [ ] Mid-game progression: King recognizes you as you gain power (generated events)
- [ ] First-session onboarding (beyond mechanics tutorial)
- [ ] Narrative hook in first few minutes - why should I care about my warlord?
- [ ] Clear first-session goal ("try to capture your first province" or similar)
- [ ] Early small victory to build momentum
- [ ] Guided first scenario vs. overwhelming sandbox?
## Nice to have
- [x] "What's new" changelog (fetch JSON, show entries since last launch)
+568
View File
@@ -0,0 +1,568 @@
# Sparkle Delta Updates Implementation Plan
## Overview
This document outlines the implementation plan for adding delta update support to the Eagle0 macOS auto-update system using Sparkle's BinaryDelta feature.
### Current State
- Full DMG downloads (~200MB) for every update
- `mac_build_handler.go` creates DMG, signs it, uploads to S3, updates appcast.xml
- Keeps last 10 versions in appcast, deletes older DMGs
- Users must download full app even for small changes
### Goals
- Reduce update download size from ~200MB to ~10-30MB (85% reduction)
- Maintain backward compatibility with full DMG downloads
- Automatic fallback for users who are many versions behind
## Sparkle Delta Update Architecture
Sparkle supports binary delta updates through the `<sparkle:deltas>` element in the appcast. When a user updates, Sparkle:
1. Checks if a delta patch exists from their current version to the new version
2. If found, downloads the smaller delta patch instead of the full DMG
3. Applies the patch locally to create the new app version
4. Falls back to full DMG if no matching delta exists
### Appcast XML Structure with Deltas
```xml
<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle">
<channel>
<title>Eagle0</title>
<link>https://assets.eagle0.net/mac/appcast.xml</link>
<description>Eagle0 game updates</description>
<language>en</language>
<item>
<title>Version 1.0.9615</title>
<pubDate>Sun, 19 Jan 2026 12:00:00 -0800</pubDate>
<sparkle:version>9615</sparkle:version>
<sparkle:shortVersionString>1.0.9615</sparkle:shortVersionString>
<enclosure
url="https://assets.eagle0.net/mac/builds/eagle0-1.0.9615.dmg"
length="200000000"
type="application/octet-stream"
sparkle:edSignature="..." />
<sparkle:deltas>
<enclosure
url="https://assets.eagle0.net/mac/deltas/9614-9615.delta"
sparkle:deltaFrom="9614"
length="15000000"
type="application/octet-stream"
sparkle:edSignature="..." />
<enclosure
url="https://assets.eagle0.net/mac/deltas/9613-9615.delta"
sparkle:deltaFrom="9613"
length="18000000"
type="application/octet-stream"
sparkle:edSignature="..." />
<enclosure
url="https://assets.eagle0.net/mac/deltas/9612-9615.delta"
sparkle:deltaFrom="9612"
length="22000000"
type="application/octet-stream"
sparkle:edSignature="..." />
</sparkle:deltas>
</item>
<!-- older versions... -->
</channel>
</rss>
```
## Implementation Plan
### Phase 1: Add S3 Utility Functions
**File:** `src/main/go/net/eagle0/util/aws/bucket_basics.go`
Add two new functions to support delta generation:
```go
// ListObjectsWithPrefix returns all object keys matching the given prefix
func (bb BucketBasics) ListObjectsWithPrefix(bucket, prefix string) ([]string, error) {
var keys []string
paginator := s3.NewListObjectsV2Paginator(bb.S3Client, &s3.ListObjectsV2Input{
Bucket: aws.String(bucket),
Prefix: aws.String(prefix),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(context.TODO())
if err != nil {
return nil, err
}
for _, obj := range page.Contents {
keys = append(keys, *obj.Key)
}
}
return keys, nil
}
// DownloadFile downloads an object to a local file path
func (bb BucketBasics) DownloadFile(bucket, key, localPath string) error {
result, err := bb.S3Client.GetObject(context.TODO(), &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
})
if err != nil {
return err
}
defer result.Body.Close()
file, err := os.Create(localPath)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(file, result.Body)
return err
}
```
### Phase 2: Store App Bundles for Delta Generation
**File:** `src/main/go/net/eagle0/build/mac_build_handler/mac_build_handler.go`
Add storage paths:
```go
var appsRoot = "mac/apps/" // Zipped app bundles for delta generation
var deltasRoot = "mac/deltas/" // Delta patches
```
After DMG creation, upload the zipped app bundle:
```go
func uploadAppBundle(bb aws.BucketBasics, appPath string, buildNumber string) error {
appZipPath := filepath.Join("/tmp", fmt.Sprintf("eagle0-%s.app.zip", buildNumber))
// Create zip of app bundle using ditto (preserves metadata)
cmd := exec.Command("ditto", "-c", "-k", "--keepParent", appPath, appZipPath)
if output, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("failed to zip app: %s: %w", string(output), err)
}
defer os.Remove(appZipPath)
// Upload to S3
remotePath := appsRoot + fmt.Sprintf("eagle0-%s.app.zip", buildNumber)
log.Printf("Uploading app bundle to S3: %s", remotePath)
return bb.UploadFilePublic(bucketName, remotePath, appZipPath)
}
```
### Phase 3: Add Delta XML Structures
**File:** `src/main/go/net/eagle0/build/mac_build_handler/mac_build_handler.go`
Add new structs for delta representation:
```go
// Delta represents a delta patch from a previous version
type Delta struct {
XMLName xml.Name `xml:"enclosure"`
URL string `xml:"url,attr"`
DeltaFrom string `xml:"sparkle:deltaFrom,attr"`
Length int64 `xml:"length,attr"`
Type string `xml:"type,attr"`
EdSig string `xml:"sparkle:edSignature,attr"`
}
// Deltas wraps the sparkle:deltas element
type Deltas struct {
XMLName xml.Name `xml:"sparkle:deltas"`
Items []Delta `xml:"enclosure"`
}
// Update Item struct to include Deltas
type Item struct {
Title string `xml:"title"`
PubDate string `xml:"pubDate"`
SparkleVersion string `xml:"sparkle:version"`
SparkleShortVersion string `xml:"sparkle:shortVersionString"`
Description string `xml:"description,omitempty"`
Enclosure Enclosure `xml:"enclosure"`
Deltas *Deltas `xml:"sparkle:deltas,omitempty"`
}
```
### Phase 4: Generate Delta Patches
**File:** `src/main/go/net/eagle0/build/mac_build_handler/mac_build_handler.go`
```go
// Maximum number of versions to generate deltas from
const maxDeltaVersions = 5
// generateDeltas creates delta patches from previous versions to the new version
func generateDeltas(bb aws.BucketBasics, newBuildNumber string, newAppPath string, privateKeyPath string) ([]Delta, error) {
var deltas []Delta
// Ensure BinaryDelta tool is available
binaryDeltaPath, err := ensureBinaryDelta()
if err != nil {
return nil, fmt.Errorf("failed to get BinaryDelta: %w", err)
}
// List available app bundles
appKeys, err := bb.ListObjectsWithPrefix(bucketName, appsRoot+"eagle0-")
if err != nil {
log.Printf("Warning: failed to list app bundles: %v", err)
return deltas, nil // Continue without deltas
}
// Parse build numbers from keys and sort descending
var buildNumbers []string
for _, key := range appKeys {
// Extract build number from "mac/apps/eagle0-9614.app.zip"
base := filepath.Base(key)
if strings.HasPrefix(base, "eagle0-") && strings.HasSuffix(base, ".app.zip") {
bn := strings.TrimSuffix(strings.TrimPrefix(base, "eagle0-"), ".app.zip")
if bn != newBuildNumber {
buildNumbers = append(buildNumbers, bn)
}
}
}
// Sort descending (most recent first) and limit to maxDeltaVersions
sort.Sort(sort.Reverse(sort.StringSlice(buildNumbers)))
if len(buildNumbers) > maxDeltaVersions {
buildNumbers = buildNumbers[:maxDeltaVersions]
}
// Generate delta from each previous version
for _, oldBuild := range buildNumbers {
delta, err := generateSingleDelta(bb, binaryDeltaPath, oldBuild, newBuildNumber, newAppPath, privateKeyPath)
if err != nil {
log.Printf("Warning: failed to generate delta from %s: %v", oldBuild, err)
continue // Skip this delta but continue with others
}
deltas = append(deltas, delta)
}
return deltas, nil
}
func generateSingleDelta(bb aws.BucketBasics, binaryDeltaPath, oldBuild, newBuild, newAppPath, privateKeyPath string) (Delta, error) {
// Download old app bundle
oldAppZipKey := appsRoot + fmt.Sprintf("eagle0-%s.app.zip", oldBuild)
oldAppZipLocal := filepath.Join("/tmp", fmt.Sprintf("eagle0-%s.app.zip", oldBuild))
defer os.Remove(oldAppZipLocal)
if err := bb.DownloadFile(bucketName, oldAppZipKey, oldAppZipLocal); err != nil {
return Delta{}, fmt.Errorf("failed to download old app: %w", err)
}
// Unzip old app
oldAppDir := filepath.Join("/tmp", fmt.Sprintf("old-app-%s", oldBuild))
defer os.RemoveAll(oldAppDir)
cmd := exec.Command("ditto", "-x", "-k", oldAppZipLocal, oldAppDir)
if output, err := cmd.CombinedOutput(); err != nil {
return Delta{}, fmt.Errorf("failed to unzip old app: %s: %w", string(output), err)
}
oldAppPath := filepath.Join(oldAppDir, "eagle0.app")
// Generate delta
deltaPath := filepath.Join("/tmp", fmt.Sprintf("%s-%s.delta", oldBuild, newBuild))
defer os.Remove(deltaPath)
cmd = exec.Command(binaryDeltaPath, "create", oldAppPath, newAppPath, deltaPath)
if output, err := cmd.CombinedOutput(); err != nil {
return Delta{}, fmt.Errorf("failed to create delta: %s: %w", string(output), err)
}
// Get delta size
deltaSize, err := getFileSize(deltaPath)
if err != nil {
return Delta{}, fmt.Errorf("failed to get delta size: %w", err)
}
log.Printf("Delta %s->%s size: %d bytes (%.1f MB)", oldBuild, newBuild, deltaSize, float64(deltaSize)/1024/1024)
// Sign delta
signature, err := signWithSparkle(deltaPath, privateKeyPath)
if err != nil {
return Delta{}, fmt.Errorf("failed to sign delta: %w", err)
}
// Upload delta
deltaKey := deltasRoot + fmt.Sprintf("%s-%s.delta", oldBuild, newBuild)
if err := bb.UploadFilePublic(bucketName, deltaKey, deltaPath); err != nil {
return Delta{}, fmt.Errorf("failed to upload delta: %w", err)
}
deltaURL := fmt.Sprintf("https://assets.eagle0.net/%s", deltaKey)
return Delta{
URL: deltaURL,
DeltaFrom: oldBuild,
Length: deltaSize,
Type: "application/octet-stream",
EdSig: signature,
}, nil
}
func ensureBinaryDelta() (string, error) {
binaryDeltaPath := "/tmp/sparkle-cache/Sparkle-2.6.4/bin/BinaryDelta"
if _, err := os.Stat(binaryDeltaPath); os.IsNotExist(err) {
log.Println("Sparkle BinaryDelta not found, downloading...")
cmd := exec.Command("bash", "-c", `
mkdir -p /tmp/sparkle-cache
curl -sL https://github.com/sparkle-project/Sparkle/releases/download/2.6.4/Sparkle-2.6.4.tar.xz | tar -xJ -C /tmp/sparkle-cache
`)
if output, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("failed to download Sparkle: %s: %w", string(output), err)
}
}
return binaryDeltaPath, nil
}
```
### Phase 5: Update Main Deploy Flow
**File:** `src/main/go/net/eagle0/build/mac_build_handler/mac_build_handler.go`
Modify `main()` to integrate delta generation:
```go
func main() {
// ... existing argument parsing ...
// Create DMG (existing)
if err := createDMG(appPath, dmgPath, "Eagle0"); err != nil {
log.Fatalf("Failed to create DMG: %v", err)
}
// ... existing DMG upload ...
if privateKeyPath != "" {
// Upload app bundle for future delta generation (NEW)
log.Println("Uploading app bundle for delta generation...")
if err := uploadAppBundle(bb, appPath, buildNumber); err != nil {
log.Printf("Warning: failed to upload app bundle: %v", err)
// Continue - delta generation is optional
}
// Generate deltas from previous versions (NEW)
log.Println("Generating delta patches...")
deltas, err := generateDeltas(bb, buildNumber, appPath, privateKeyPath)
if err != nil {
log.Printf("Warning: failed to generate deltas: %v", err)
} else {
log.Printf("Generated %d delta patches", len(deltas))
}
// Update appcast with deltas
log.Println("Updating appcast.xml...")
appcast, err := fetchAppcast(bb)
if err != nil {
log.Fatalf("Failed to fetch appcast: %v", err)
}
// Create new item with deltas
newItem := Item{
Title: fmt.Sprintf("Version %s", version),
PubDate: time.Now().Format(time.RFC1123Z),
SparkleVersion: buildNumber,
SparkleShortVersion: version,
Description: "",
Enclosure: Enclosure{
URL: downloadURL,
Length: fileSize,
Type: "application/octet-stream",
EdSig: signature,
},
}
// Add deltas if any were generated
if len(deltas) > 0 {
newItem.Deltas = &Deltas{Items: deltas}
}
// ... rest of appcast handling ...
}
}
```
### Phase 6: Cleanup Old Artifacts
**File:** `src/main/go/net/eagle0/build/mac_build_handler/mac_build_handler.go`
When pruning old versions from appcast, also delete associated artifacts:
```go
// In the appcast pruning section, after removing old items:
if len(appcast.Channel.Items) > 10 {
oldItems := appcast.Channel.Items[10:]
for _, item := range oldItems {
oldBuild := item.SparkleVersion
// Delete old DMG (existing)
dmgKey := strings.TrimPrefix(item.Enclosure.URL, "https://assets.eagle0.net/")
log.Printf("Deleting old build: %s", dmgKey)
bb.DeleteObject(bucketName, dmgKey)
// Delete old app bundle (NEW)
appKey := appsRoot + fmt.Sprintf("eagle0-%s.app.zip", oldBuild)
log.Printf("Deleting old app bundle: %s", appKey)
bb.DeleteObject(bucketName, appKey)
// Delete deltas TO this version (NEW)
deltaKeys, _ := bb.ListObjectsWithPrefix(bucketName, deltasRoot)
for _, key := range deltaKeys {
if strings.HasSuffix(key, fmt.Sprintf("-%s.delta", oldBuild)) {
log.Printf("Deleting old delta: %s", key)
bb.DeleteObject(bucketName, key)
}
}
}
appcast.Channel.Items = appcast.Channel.Items[:10]
}
```
## S3 Storage Structure
After implementation, the S3 bucket will have this structure:
```
eagle0-windows/
├── mac/
│ ├── appcast.xml # Update feed with delta info
│ ├── builds/ # Full DMG downloads
│ │ ├── eagle0-1.0.9620.dmg
│ │ ├── eagle0-1.0.9619.dmg
│ │ ├── ...
│ │ └── eagle0-latest.dmg # Symlink to latest
│ ├── apps/ # Zipped app bundles (NEW)
│ │ ├── eagle0-9620.app.zip
│ │ ├── eagle0-9619.app.zip
│ │ ├── eagle0-9618.app.zip
│ │ ├── eagle0-9617.app.zip
│ │ └── eagle0-9616.app.zip # Keep last 5 for delta gen
│ └── deltas/ # Delta patches (NEW)
│ ├── 9619-9620.delta
│ ├── 9618-9620.delta
│ ├── 9617-9620.delta
│ ├── 9616-9620.delta
│ ├── 9615-9620.delta
│ ├── 9618-9619.delta
│ ├── 9617-9619.delta
│ └── ...
```
## Storage Impact Analysis
### Current Storage (without deltas)
- 10 DMGs × 200MB = **~2GB**
### Estimated Storage (with deltas)
- 10 DMGs × 200MB = 2GB
- 5 app bundles × 150MB = 0.75GB (zip compression)
- ~25 delta files × 20MB avg = 0.5GB
- **Total: ~3.25GB**
### Trade-offs
- **+1.25GB storage** (~60% increase)
- **-170MB per user update** (~85% bandwidth savings)
- Break-even: ~8 user updates to recoup storage cost
## Bandwidth Savings
| Scenario | Without Deltas | With Deltas | Savings |
|----------|---------------|-------------|---------|
| 1 version behind | 200MB | ~15MB | 92% |
| 2 versions behind | 200MB | ~20MB | 90% |
| 3 versions behind | 200MB | ~25MB | 87% |
| 5 versions behind | 200MB | ~35MB | 82% |
| 6+ versions behind | 200MB | 200MB (full) | 0% |
## Migration Strategy
The implementation is backward-compatible and requires no changes to existing clients:
1. **First deploy after implementation:**
- Stores app bundle for the first time
- No deltas generated (no previous app bundles exist)
- Appcast has no `<sparkle:deltas>` element
2. **Second deploy:**
- Generates delta from previous version
- Appcast now has `<sparkle:deltas>` with one entry
- Users on previous version get delta update
3. **Subsequent deploys:**
- Generate deltas from last 5 versions
- Users within 5 versions get delta updates
- Users more than 5 versions behind get full DMG
4. **Client behavior:**
- Sparkle automatically checks for matching delta
- Falls back to full DMG if no delta matches
- No client code changes required
## Error Handling
The implementation handles failures gracefully:
1. **S3 list/download fails:** Skip delta generation, use full DMG
2. **BinaryDelta fails for one version:** Log warning, continue with other versions
3. **Signing fails:** Skip that delta, continue with others
4. **Upload fails:** Skip that delta, continue with others
The deploy never fails due to delta issues - deltas are optional enhancements.
## Verification Plan
### Manual Testing
1. **Deploy version N:**
- Verify app bundle uploaded to `mac/apps/eagle0-N.app.zip`
- Verify appcast has no deltas (first deploy)
2. **Deploy version N+1:**
- Verify delta generated at `mac/deltas/N-(N+1).delta`
- Verify appcast contains `<sparkle:deltas>` element
- Verify delta signature is valid
3. **Test update from N to N+1:**
- Install version N manually
- Check for updates
- Monitor download size in Player.log (should be ~15-30MB, not 200MB)
- Verify app updated successfully
4. **Test fresh install:**
- Download latest DMG directly
- Verify installation works normally
5. **Test fallback scenario:**
- Install a version more than 5 versions behind
- Update should download full DMG
### Automated Verification
Add to CI workflow (optional):
```yaml
- name: Verify delta generation
run: |
# Check app bundle exists
aws s3 ls s3://eagle0-windows/mac/apps/ | grep eagle0-${BUILD_NUMBER}.app.zip
# Check deltas exist (after second deploy)
aws s3 ls s3://eagle0-windows/mac/deltas/ | head -5
# Verify appcast has deltas
curl -s https://assets.eagle0.net/mac/appcast.xml | grep "sparkle:deltas"
```
## Security Considerations
1. **All deltas are EdDSA signed:** Same signature verification as full DMG
2. **BinaryDelta is Sparkle's official tool:** Well-audited, production-ready
3. **App bundles in S3 are public:** Same as DMGs, no additional exposure
4. **Cleanup removes old artifacts:** No indefinite storage of old versions
## Future Enhancements
1. **Parallel delta generation:** Generate multiple deltas concurrently
2. **Delta size threshold:** Skip uploading deltas larger than X% of full DMG
3. **Delta metrics:** Track delta download rates vs full DMG
4. **Configurable delta count:** Allow adjusting how many versions to keep
+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"
-310
View File
@@ -1,310 +0,0 @@
# Scala 3 Migration: Reflection Issues Found
This document catalogs all reflection-related problems discovered during the Scala 2.13.16 → Scala 3.7.2 migration of the Eagle0 codebase.
## Summary
The migration revealed several categories of reflection issues that needed to be addressed for Scala 3 compatibility:
1. **Scala 2 Runtime Reflection API** - No longer available in Scala 3
2. **Settings System Reflection** - Custom reflection for loading settings singletons
3. **json4s Automatic Case Class Extraction** - Uses reflection that fails with Scala 3 metaprogramming classes
4. **ScalaTest Exception Handling** - Syntax changes affecting exception variable binding
## 1. Scala 2 Runtime Reflection (FIXED)
### Issue
Tests using `scala.reflect.runtime.universe` fail because this reflection API doesn't exist in Scala 3.
### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/actions/types/ActionResultTypesTest.scala`
### Error
```scala
import scala.reflect.runtime.universe // Not available in Scala 3
```
### Solution Applied
**Deleted the test entirely** as it was redundant. The test was verifying that auto-generated Scala objects (created by Bazel from proto enum values) matched their source proto values - something already guaranteed by the build system. Since the objects are generated directly from the proto definitions, this test provided no value.
**Files deleted:**
- `src/test/scala/net/eagle0/eagle/library/actions/types/ActionResultTypesTest.scala`
## 2. Settings System Reflection (FIXED)
### Issue
Custom `SettingsLoader` class used reflection to access Scala object singletons, but the reflection pattern changed between Scala 2 and Scala 3.
### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/settings/loaders/SettingsLoader.scala`
### Error
```
java.lang.NoSuchMethodException: net.eagle0.eagle.library.settings.ApprehendOutlawVigorCost$.MODULE$
```
### Root Cause
In Scala 2, singleton objects are accessed via `ClassName$.MODULE$()`, but in Scala 3, they're accessed directly via `ClassName$` field. Additionally, `scala.reflect.runtime.universe` is not available in Scala 3.
### Solution Applied
**Completely eliminated reflection** by auto-generating the entire `SettingsLoader.scala` file from BUILD.bazel definitions:
1. **Created generator**: `src/main/go/net/eagle0/build/settings_loader_generator/settings_loader_generator.go` - parses BUILD.bazel and generates complete SettingsLoader.scala with pattern matching for all 272 settings
2. **Added genrule**: In `src/main/scala/net/eagle0/eagle/library/settings/loaders/BUILD.bazel`:
```python
genrule(
name = "settings_loader_src",
srcs = ["//src/main/scala/net/eagle0/eagle/library/settings:BUILD.bazel"],
outs = ["SettingsLoader.scala"],
cmd = "$(location //src/main/go/net/eagle0/build/settings_loader_generator) $(location //src/main/scala/net/eagle0/eagle/library/settings:BUILD.bazel) > $@",
tools = ["//src/main/go/net/eagle0/build/settings_loader_generator"],
)
```
3. **Result**: SettingsLoader now uses compile-time pattern matching instead of reflection:
```scala
private def settingObjectForKey(key: String): Any = key match {
case "ActionVigorCost" => ActionVigorCost
case "BaseFoodBuyPrice" => BaseFoodBuyPrice
// ... all 272 settings auto-generated
case _ => throw NoSuchSettingException(key)
}
```
### Benefits
- **No reflection** - Completely Scala 3 compatible
- **Maintainable** - New settings automatically included when added to BUILD.bazel
- **Performance** - Pattern matching is faster than reflection
- **Type-safe** - Compile-time checking of all settings
## 3. json4s Reflection Issues (MULTIPLE LOCATIONS)
### 3.1 EagleServiceImpl JSON Serialization (FIXED)
#### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/service/EagleServiceImpl.scala`
#### Error
```
java.lang.NoClassDefFoundError: scala/quoted/staging/package$
```
#### Root Cause
json4s automatic case class serialization uses reflection that tries to access Scala 3 metaprogramming classes (`scala.quoted.staging.package$`) which aren't available at runtime.
#### Solution Applied
Replaced automatic json4s serialization with ScalaPB's built-in JSON support:
```scala
// Old (reflection-based):
// implicit val formats: DefaultFormats.type = DefaultFormats
// write(actionResultView)
// New (ScalaPB JSON support):
import scalapb.json4s.JsonFormat
JsonFormat.toJsonString(actionResultView.toProto)
```
### 3.2 ShardokMapInfo JSON Parsing (FIXED)
#### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/util/ShardokMapInfo.scala` (Line 44)
#### Error
```
java.lang.NoClassDefFoundError: scala/quoted/staging/package$
at org.json4s.reflect.ScalaSigReader$.readConstructor(ScalaSigReader.scala:42)
```
#### Root Cause
The line `val extracted = parsedJson.extract[List[ShardokMapInfo]]` uses json4s automatic case class extraction which relies on reflection.
#### Solution Applied
Replaced automatic extraction with manual JSON parsing:
```scala
// OLD (reflection-based):
val extracted = parsedJson.extract[List[ShardokMapInfo]]
// NEW (manual parsing, no reflection):
val extracted = parsedJson match {
case JArray(items) => items.map { item =>
val name = (item \ "name").extract[String]
val castleCount = (item \ "castleCount").extract[Int]
val positions = (item \ "positions").extract[Map[Int, Int]]
ShardokMapInfo(name, castleCount, positions)
}
case _ => throw new Exception("Expected JSON array for map info")
}
```
#### Testing
The fix was verified - `attack_command_chooser_test` now passes successfully.
### 3.3 HeroNameFetcher JSON Parsing (FIXED)
#### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/main/scala/net/eagle0/eagle/library/util/hero_name_fetcher/HeroNameFetcher.scala`
#### Issue
Case class extraction `parsedJson.extract[ResponseBody]` uses reflection that may fail in Scala 3.
#### Solution Applied
Replaced automatic case class extraction with manual JSON parsing:
```scala
// OLD (reflection-based):
val parsedJson = json.parse(src.getLines().mkString)
parsedJson.extract[ResponseBody]
// NEW (manual parsing, no reflection):
parsedJson \ "names" match {
case JArray(nameArray) =>
nameArray.map { nameObj =>
val id = (nameObj \ "id").extract[String]
val name = (nameObj \ "name").extract[String]
NameResponse(id, name)
}.toVector
case _ => throw new Exception("Expected 'names' array in response")
}
```
#### Testing
The fix was verified - HeroNameFetcher now builds successfully without reflection.
### 3.4 Other json4s Usage Analysis
#### Files with json4s extraction:
- **✅ SAFE**: OpenAI/Claude Services - Only extract simple types (`String`, `Int`) - no reflection
- **✅ FIXED**: `HeroNameFetcher.scala` - Replaced `extract[ResponseBody]` with manual parsing (no reflection)
- **⚠️ POTENTIAL ISSUES** (not currently causing failures but should be monitored):
- `JsonUtils.scala`: `extract[Map[String, Vector[String]]]` - complex type extraction
- `HexMapJsonUtils.scala`: `extract[List[JObject]]` - may be problematic
#### Recommendation
Apply the same manual parsing pattern to remaining case class extractions if they cause runtime failures during Scala 3 migration.
## 4. ScalaTest Exception Handling Syntax (FIXED)
### Issue
Scala 3 changed how exception variables are bound in ScalaTest's `the[Exception] thrownBy {...}` construct.
### Files Affected
**70+ test files** across the codebase using exception testing patterns.
### Error Pattern
```
Not found: ex
```
### Root Cause
In Scala 2: `the[Exception] thrownBy { ... }` automatically creates an `ex` variable.
In Scala 3: The exception variable must be explicitly bound.
### Solution Applied
Added explicit variable binding across all affected test files:
```scala
// Old Scala 2 syntax:
the[EagleCommandException] thrownBy {
// test code
}
ex.getMessage shouldBe "expected message"
// New Scala 3 syntax:
val ex = the[EagleCommandException] thrownBy {
// test code
}
ex.getMessage shouldBe "expected message"
```
### Script Used
Created and ran a systematic fix script that processed 70+ files:
```bash
# Pattern to find and fix exception handling
find . -name "*.scala" -exec sed -i '' 's/the\[\([^]]*\)\] thrownBy {/val ex = the[\1] thrownBy {/g' {} \;
```
## 5. ScalaTest Import Changes (FIXED)
### Issue
Scala 3 requires different imports for ScalaTest matchers.
### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/actions/impl/command/DeclineQuestCommandTest.scala`
### Error
```
value convertToAnyShouldWrapper is not a member of object org.scalatest.matchers.should.Matchers
```
### Solution Applied
Changed from specific imports to wildcard import:
```scala
// Old:
import org.scalatest.matchers.should.Matchers.{convertToAnyShouldWrapper, the}
// New:
import org.scalatest.matchers.should.Matchers.*
```
## 6. Mock Framework Issues (FIXED)
### Issue
ScalaMock had type inference issues with Scala 3 for classes with constructor parameters.
### Files Affected
- `/Users/dancrosby/CodingProjects/github/eagle0/src/test/scala/net/eagle0/eagle/library/EngineImplTest.scala`
### Error
```
Found: Vector
Required: Vector[net.eagle0.eagle.library.util.hero_generator.hero_with_name.HeroWithName]
```
### Root Cause
Mock framework couldn't properly infer types for `mock[HeroGenerator]` where `HeroGenerator` has constructor parameters.
### Solution Applied
The user updated to a newer ScalaMock version that fixed this issue, plus added some missing Bazel dependencies:
```scala
// Also needed to add missing dependency:
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution"
```
## Migration Status
### ✅ COMPLETED
- [x] Scala 2 runtime reflection removal
- [x] Settings system reflection compatibility
- [x] EagleServiceImpl json4s → ScalaPB JSON
- [x] ScalaTest exception handling syntax (70+ files)
- [x] ScalaTest import changes
- [x] Mock framework issues (via ScalaMock update)
- [x] All test compilation issues resolved
### ⚠️ REMAINING
- [ ] **Potential json4s case class extractions** - May cause runtime failures (JsonUtils, HexMapJsonUtils) - currently no test failures reported
### 📊 PROGRESS
- **Tests passing**: All identified runtime failures resolved
- **Build failures**: 0 (all tests now compile)
- **Runtime failures**: 0 (critical ShardokMapInfo issue resolved)
## Recommendations
1. **✅ COMPLETED**: ShardokMapInfo json4s reflection issue resolved with manual parsing
2. **Monitor remaining json4s usage**: Watch for runtime failures in HeroNameFetcher, JsonUtils, and HexMapJsonUtils during full Scala 3 migration
3. **Consider ScalaPB for new JSON needs**: For new functionality, prefer ScalaPB's JSON support to avoid reflection entirely
4. **Apply manual parsing pattern**: If other json4s case class extractions cause runtime failures, use the same manual parsing approach demonstrated in ShardokMapInfo
## Key Learnings
- **Scala 3 reflection changes**: Major differences in singleton object access patterns
- **json4s compatibility**: Automatic case class extraction doesn't work well with Scala 3 metaprogramming
- **ScalaPB advantage**: Using ScalaPB's JSON support avoids reflection issues entirely
- **Systematic approach**: Many issues followed patterns that could be fixed with scripts across multiple files
+48
View File
@@ -0,0 +1,48 @@
# LLVM MinGW toolchain for Windows cross-compilation
# Provides x86_64-w64-mingw32 target compiler and libraries
package(default_visibility = ["//visibility:public"])
filegroup(
name = "all_files",
srcs = glob(["**/*"]),
)
# Compiler binaries
filegroup(
name = "compiler_files",
srcs = glob([
"bin/x86_64-w64-mingw32-*",
"bin/clang*",
"bin/llvm-*",
"bin/lld*",
]),
)
# Windows x86_64 sysroot (headers and libraries)
filegroup(
name = "windows_x86_64_sysroot",
srcs = glob([
"x86_64-w64-mingw32/**/*",
"generic-w64-mingw32/include/**/*",
]),
)
# All library files needed for linking
filegroup(
name = "linker_files",
srcs = glob([
"bin/x86_64-w64-mingw32-*",
"bin/lld*",
"bin/ld.lld*",
"lib/**/*",
"x86_64-w64-mingw32/lib/**/*",
]),
)
# The main C compiler wrapper script path for CGO
# CGO needs CC to point to the cross-compiler
exports_files([
"bin/x86_64-w64-mingw32-clang",
"bin/x86_64-w64-mingw32-clang++",
])
+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"],
)
+2
View File
@@ -11,6 +11,8 @@ require (
github.com/aws/aws-sdk-go-v2/service/s3 v1.72.2
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6
golang.org/x/sys v0.28.0
google.golang.org/grpc v1.68.0
google.golang.org/protobuf v1.36.3
)
+4
View File
@@ -41,6 +41,10 @@ github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6 h1:VQpB2SpK88C6B5lPHTuSZKb2Qee1QWwiFlC5CKY4AW0=
github.com/webview/webview_go v0.0.0-20240831120633-6173450d4dd6/go.mod h1:yE65LFCeWf4kyWD5re+h4XNvOHJEXOCOuJZ4v8l5sgk=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+134 -98
View File
@@ -1,19 +1,19 @@
{
"__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL",
"__INPUT_ARTIFACTS_HASH": -2049857450,
"__RESOLVED_ARTIFACTS_HASH": -1728186926,
"__INPUT_ARTIFACTS_HASH": 434250008,
"__RESOLVED_ARTIFACTS_HASH": -824975294,
"conflict_resolution": {
"com.google.guava:failureaccess:1.0.1": "com.google.guava:failureaccess:1.0.2",
"com.squareup.okio:okio:2.10.0": "com.squareup.okio:okio:3.6.0",
"io.netty:netty-buffer:4.1.110.Final": "io.netty:netty-buffer:4.1.112.Final",
"io.netty:netty-codec-http2:4.1.110.Final": "io.netty:netty-codec-http2:4.1.112.Final",
"io.netty:netty-codec-http:4.1.110.Final": "io.netty:netty-codec-http:4.1.112.Final",
"io.netty:netty-codec:4.1.110.Final": "io.netty:netty-codec:4.1.112.Final",
"io.netty:netty-common:4.1.110.Final": "io.netty:netty-common:4.1.112.Final",
"io.netty:netty-handler:4.1.110.Final": "io.netty:netty-handler:4.1.112.Final",
"io.netty:netty-resolver:4.1.110.Final": "io.netty:netty-resolver:4.1.112.Final",
"io.netty:netty-transport-native-unix-common:4.1.110.Final": "io.netty:netty-transport-native-unix-common:4.1.112.Final",
"io.netty:netty-transport:4.1.110.Final": "io.netty:netty-transport:4.1.112.Final",
"io.netty:netty-buffer:4.1.110.Final": "io.netty:netty-buffer:4.1.130.Final",
"io.netty:netty-codec-http2:4.1.110.Final": "io.netty:netty-codec-http2:4.1.130.Final",
"io.netty:netty-codec-http:4.1.110.Final": "io.netty:netty-codec-http:4.1.130.Final",
"io.netty:netty-codec:4.1.110.Final": "io.netty:netty-codec:4.1.130.Final",
"io.netty:netty-common:4.1.110.Final": "io.netty:netty-common:4.1.130.Final",
"io.netty:netty-handler:4.1.110.Final": "io.netty:netty-handler:4.1.130.Final",
"io.netty:netty-resolver:4.1.110.Final": "io.netty:netty-resolver:4.1.130.Final",
"io.netty:netty-transport-native-unix-common:4.1.110.Final": "io.netty:netty-transport-native-unix-common:4.1.130.Final",
"io.netty:netty-transport:4.1.110.Final": "io.netty:netty-transport:4.1.130.Final",
"io.opencensus:opencensus-api:0.31.0": "io.opencensus:opencensus-api:0.31.1",
"org.checkerframework:checker-qual:3.12.0": "org.checkerframework:checker-qual:3.43.0"
},
@@ -296,27 +296,27 @@
},
"io.netty:netty-buffer": {
"shasums": {
"jar": "bc182c48f5369d48cd8370d2ab0c5b8d99dd8ffa4a0f8ac701652d57bd380eff"
"jar": "00a522b67ea35cb7b4dd9cf27f85c6c58f5e306785aa045302e5f6b2d4944a87"
},
"version": "4.1.112.Final"
"version": "4.1.130.Final"
},
"io.netty:netty-codec": {
"shasums": {
"jar": "72db4f93629f7ea520d2998c08e2b1d69f9c6a4792b53da5e9a001d24c78b151"
"jar": "52636bc29bd62120b97bbe5d1d21eab9b1cb2bef8efbb54d2221c5f3fa08d8cd"
},
"version": "4.1.112.Final"
"version": "4.1.130.Final"
},
"io.netty:netty-codec-http": {
"shasums": {
"jar": "21b502d1374d6992728d004e0c1c95544d46d971f55ea78dcb854ce1ac0c83bc"
"jar": "5b6addc1df7b3397a193bd6544a8bfdb18ecac99fd13bee4ec75b1781a664e5e"
},
"version": "4.1.112.Final"
"version": "4.1.130.Final"
},
"io.netty:netty-codec-http2": {
"shasums": {
"jar": "7f73efc845e8818d71da23b21dc65d69132dd0e12ed0e80cc937bd79ab7d5749"
"jar": "f8ffdb550368fd5dee7c7f1393fa49552522f280ed8de96aebf4269cab0dc8f3"
},
"version": "4.1.112.Final"
"version": "4.1.130.Final"
},
"io.netty:netty-codec-socks": {
"shasums": {
@@ -326,15 +326,15 @@
},
"io.netty:netty-common": {
"shasums": {
"jar": "b03967f32c65de5ed339b97729170e0289b22ffa5729e7f45f68bf6b431fb567"
"jar": "53921f28dd5a352b1bed0e1cbcc54d013dc60ffebeae9b2b1e53eabef317e581"
},
"version": "4.1.112.Final"
"version": "4.1.130.Final"
},
"io.netty:netty-handler": {
"shasums": {
"jar": "ea4d6062a5fb10a6e2364d8bbdebc1cfa814f1fc9f910ef57e5caf02fb15c588"
"jar": "98c78ec187ca30a4b9775bf6f632f5c9929db6bf06a60e6971f945813880ca0f"
},
"version": "4.1.112.Final"
"version": "4.1.130.Final"
},
"io.netty:netty-handler-proxy": {
"shasums": {
@@ -344,9 +344,9 @@
},
"io.netty:netty-resolver": {
"shasums": {
"jar": "6b4ac9f3b67f562f0770d57c389279ff9c708eb401e1a3635f52297f0f897edc"
"jar": "48c5b218a89d184e1b601d46433957f515fcefdb4464182b1348bce4f5a18f35"
},
"version": "4.1.112.Final"
"version": "4.1.130.Final"
},
"io.netty:netty-tcnative-boringssl-static": {
"shasums": {
@@ -367,15 +367,15 @@
},
"io.netty:netty-transport": {
"shasums": {
"jar": "d38e31624d25ca790ee413d529c152170217ebedbcdcf61164fa6291f3a56c92"
"jar": "1bf573266d271f856705a9984d25449c56a1d73c02a16af12033ceccfe555dbb"
},
"version": "4.1.112.Final"
"version": "4.1.130.Final"
},
"io.netty:netty-transport-classes-epoll": {
"shasums": {
"jar": "96cf2e6622ea70e2bc67aed373607df32863f863d0d7b8c9c94d468efd1d0638"
"jar": "1c352508d70aca1ccc3068daac907ad1e9d8dc68b07148d57426730bf0d0c8b6"
},
"version": "4.1.112.Final"
"version": "4.1.130.Final"
},
"io.netty:netty-transport-native-epoll": {
"shasums": {
@@ -385,9 +385,9 @@
},
"io.netty:netty-transport-native-unix-common": {
"shasums": {
"jar": "e79ccea1b87a6348d4ebd3dfb37a2cccd9b7cb65c3375f6ccdac086c7b5ce487"
"jar": "cf5efc4168597d7cd14695b469418cac2a1134533f9a0c82ef0538d796fd39e1"
},
"version": "4.1.112.Final"
"version": "4.1.130.Final"
},
"io.opencensus:opencensus-api": {
"shasums": {
@@ -415,9 +415,9 @@
},
"io.sentry:sentry": {
"shasums": {
"jar": "740a118182fc089d307830f4e508372e01ad94639b00b4e1b1d83762298a5f35"
"jar": "bf4feb94944d03cc12e6d64ac8827d2328e3992744a3ce836f3f561e2c896209"
},
"version": "7.19.0"
"version": "8.31.0"
},
"javax.activation:javax.activation-api": {
"shasums": {
@@ -589,183 +589,189 @@
},
"software.amazon.awssdk:annotations": {
"shasums": {
"jar": "a8faf8a259a3044758c3b6d2c53e33e7ec511bce9ef9b43a2a28af51483e868f"
"jar": "ed03bb4ff78900307dc96bacdec70ddf80c17df3eb6761219573025649f08ff3"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:apache-client": {
"shasums": {
"jar": "a63a91614d68fd7a72bd6e2f8e3c3a14dfca5b9768af30bc9b612c5592dd811e"
"jar": "37c1331e8e5e8bb8357b459258f4df7d367a7c0bb5ca36d640f688551484e98a"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:arns": {
"shasums": {
"jar": "71a9f48c063ca66c2b72172254ade7a4207bb055290c668f42e49c744b11df69"
"jar": "79074ed723d0bbc0eeb06ed8b30a1a5c1c109587706e284f6c02cb6e03c1732e"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:auth": {
"shasums": {
"jar": "91416554cbc4cb293847e9f79019e6eb292f73b2221b91d5c2f9704709030f72"
"jar": "6ead038931e9348fc54f83f57b3788e7bbf2427d530e979206342c6bcd1c419d"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:aws-core": {
"shasums": {
"jar": "a7a36217cb47ab0025fad65cec0f4911ef7fa65ab3bcb21fefcfb41aa145021e"
"jar": "962c6a0037f054fafc38535c2ace8e2645a079ab7020068bd8c874f3112d8fe5"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:aws-query-protocol": {
"shasums": {
"jar": "34a9492bed2015c82587521e9761a4a19e4976a8e20956ace88f1dd2083b7714"
"jar": "46ef904b4f6ed9966a8f02255e2089b2b87d2028430dfca3e14b111bb4ef745b"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:aws-xml-protocol": {
"shasums": {
"jar": "027b5935a88bc8cbfee038c15344d209bc08d542d190f8598b28995e33474297"
"jar": "199af876a21a7768b1690582c28792a83f7da0c398dc02c8e66f60fb57805622"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:checksums": {
"shasums": {
"jar": "19a304fbee84b6a0de316ddc71ae109167f8c6c9e64af9a5f968bc04860507dd"
"jar": "63685bbafb8c88c3cf184d83abf568b9bcbecd590494e471262d827ebf4500e8"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:checksums-spi": {
"shasums": {
"jar": "b17c77ca39e9b0e263fc9101cadfc8c0f80154b830b99d5941ff43133307215b"
"jar": "b3dfcbb293b1ec7d2c7004c66b4dfeb1dc45b564e0974e1e7ecfb422cc558b8f"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:crt-core": {
"shasums": {
"jar": "9c9c11c2c81fe6bd2e1b2a0be998de2c4558e399e6eb4f2d8f0f5e2370522499"
"jar": "a376745be7ff748996a3c11ae3ee5d702eac07197d1c4690c58d67dd313e6be6"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:endpoints-spi": {
"shasums": {
"jar": "327346ed4ac231de6c0b941b82a103e111c3398d6465680fe5325595ab8a72d1"
"jar": "2176cf79255a9f7b5b3ab687b5b24a94faa0f626cd4368307db59d1a4f438d7e"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:http-auth": {
"shasums": {
"jar": "1f3a01603ee338261326739b2305c75f06945fba095824b25d9618a831096663"
"jar": "c1332af673bebee0485e0b92c6fdc9f2022339c0b34a39c56458f1b293dbfe96"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:http-auth-aws": {
"shasums": {
"jar": "cc12522d777a90ec8b24210889fc5a3304eb8b44c84a1663a1777add1697a571"
"jar": "01904850fba39ef2658931116ddb6ad00e3491e65ee09d4763ede7ce65b95574"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:http-auth-aws-eventstream": {
"shasums": {
"jar": "6c42fa55bc91bd9ffcfdce1ad27c40f53053ac331afa40f597eda9f55afafd58"
"jar": "7294716ba8fd2f77ab917ca39ba0fad9e30c5d65329a98c6fde37bdd47aa0550"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:http-auth-spi": {
"shasums": {
"jar": "363542d7632ec1d0b4950a0458a0ab67b0dfa9297c536ea518f2359c13e9fa66"
"jar": "1bec31a85ba3261e6e3ffde62f1a45834df1accf45262c42d0fd354c299752ba"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:http-client-spi": {
"shasums": {
"jar": "da646c041506463aab868c6fd44148e743b177abc7e59c31c0a4d72cda6800de"
"jar": "590ad7e05bdf79b4a873f47767a8cef8b906023f7cfbced58050c85af28bfd0b"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:identity-spi": {
"shasums": {
"jar": "41712b35f2194e0551512762de18c5be5588d3ef0968f160bd1f9d18710fbb55"
"jar": "8d8857f2f1243ccb6bb9c13beaa9e1ccce5087fe0a2599f4a801eafe2f8c0783"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:json-utils": {
"shasums": {
"jar": "3315c8ad3f1c71938d462e10435ecd0391f44441dcfe31e8a9d344fb7e43721f"
"jar": "7b8209883466083d3cd405679f1a75298e56926dc2e19640c377b3c8e2592969"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:metrics-spi": {
"shasums": {
"jar": "871fb80426158d296e3c7b695944aa2dcc4535f49a97d4a1616a7cdb21d66994"
"jar": "1f58e5147bcbb57f24ca6ab3534ca62fa7b35cc119eb0739cfb2a561a3c89c29"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:netty-nio-client": {
"shasums": {
"jar": "ce410946eb8f20421293ee9f6931e76d80a0c89d6ea31bdbf223ba07f316f2e3"
"jar": "798550b0b59307a3d00801cc1dced02cde77c69268ff25455bb0aeeddecbe242"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:profiles": {
"shasums": {
"jar": "5789b27935bf6137884e35c86b3f3a246081209ecc511716b3d058e0e41c0719"
"jar": "b11c9b445f9bd16853ccf7130ae659ad76f2b749254c9920745c2a04230195b7"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:protocol-core": {
"shasums": {
"jar": "8e5bb55d4fe57951cbe6e06b66823f2bf5b5f93e9332ae6f2fc06599e0436544"
"jar": "8b0e21264c340b49e677b98c1303bd8916140e33cdad96bba8c8e831b7cbdd81"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:regions": {
"shasums": {
"jar": "fb8020d6c948366384e1f669542170779764b5efdc127fa4b2b01d38e92c592a"
"jar": "3d493a84884636d1fd6d8ef008e40d88639c9f8e38832d0cd99dc3ba3332c258"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:retries": {
"shasums": {
"jar": "4a175be77663ddd5657d6919bf681fef71bf5782b5c00b683c73f32b639c6e3f"
"jar": "dbc0a5d16cc37cf325d60313e47ed41e9a683d46168920e72e13faa000bc7bcd"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:retries-spi": {
"shasums": {
"jar": "57013438cf22c138a420c3c621a68202e0a7ee5f7a65de7681d9e2b308a01671"
"jar": "65aa3abb7bf5bac9ffac6ec20b5a70e7d4cb077f57edd3e4a0347180c75f82e5"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:s3": {
"shasums": {
"jar": "2fa7b521592c0df9b9d5a8c6619132b84c9154fe2964ec32b408d386ad640169"
"jar": "06ca194e7bc633013a50ee8da497f83c2b275d2f41673ae63d0da2f61a352e90"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:s3-transfer-manager": {
"shasums": {
"jar": "c05103cedc46ca416c4f5556561a113133d21deafe3c54682afb054ff0388647"
"jar": "c56152a84c1c291414f3e33523887f0a2cf551613b8a8eb4661fd9b66478b385"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:sdk-core": {
"shasums": {
"jar": "5b7d8a82c65af05f1ab789d3ca29aaf9a0fdc8656053e9114609feb49399a5bf"
"jar": "f09e0cce359200dec42d5f8d59e1c7e4159dfa6d27c38acff176deccda348fa7"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:third-party-jackson-core": {
"shasums": {
"jar": "84e86f09375ebc378b390e968ad0c945af670241149e814e5fbe40ac87e0ae52"
"jar": "23e0c862fd5573c952bbf7fb35e7632a4328d37a188591e1e40f3c15d409690b"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:utils": {
"shasums": {
"jar": "38d869eeb74e9fc16ae1d707b919cbf71186f00920b81fe7a68f93a4db9b7d84"
"jar": "3d4bec1ab45fe5316337e285eb5f6729122388af538807927624c7f5cb5d4a91"
},
"version": "2.28.1"
"version": "2.41.18"
},
"software.amazon.awssdk:utils-lite": {
"shasums": {
"jar": "b42350e316ced8ef805f1443dc1716e6854865ae882729b2d21e8ce6416fc8fd"
},
"version": "2.41.18"
},
"software.amazon.eventstream:eventstream": {
"shasums": {
@@ -1110,6 +1116,7 @@
],
"software.amazon.awssdk:auth": [
"software.amazon.awssdk:annotations",
"software.amazon.awssdk:checksums-spi",
"software.amazon.awssdk:http-auth",
"software.amazon.awssdk:http-auth-aws",
"software.amazon.awssdk:http-auth-aws-eventstream",
@@ -1138,6 +1145,7 @@
"software.amazon.awssdk:retries-spi",
"software.amazon.awssdk:sdk-core",
"software.amazon.awssdk:utils",
"software.amazon.awssdk:utils-lite",
"software.amazon.eventstream:eventstream"
],
"software.amazon.awssdk:aws-query-protocol": [
@@ -1159,7 +1167,8 @@
],
"software.amazon.awssdk:checksums": [
"software.amazon.awssdk:annotations",
"software.amazon.awssdk:checksums-spi"
"software.amazon.awssdk:checksums-spi",
"software.amazon.awssdk:utils"
],
"software.amazon.awssdk:checksums-spi": [
"software.amazon.awssdk:annotations"
@@ -1194,6 +1203,7 @@
"software.amazon.awssdk:http-auth-spi": [
"org.reactivestreams:reactive-streams",
"software.amazon.awssdk:annotations",
"software.amazon.awssdk:checksums-spi",
"software.amazon.awssdk:http-client-spi",
"software.amazon.awssdk:identity-spi",
"software.amazon.awssdk:utils"
@@ -1321,6 +1331,9 @@
"org.reactivestreams:reactive-streams",
"org.slf4j:slf4j-api",
"software.amazon.awssdk:annotations"
],
"software.amazon.awssdk:utils-lite": [
"software.amazon.awssdk:annotations"
]
},
"packages": {
@@ -1795,21 +1808,31 @@
"io.sentry",
"io.sentry.backpressure",
"io.sentry.cache",
"io.sentry.cache.tape",
"io.sentry.clientreport",
"io.sentry.config",
"io.sentry.exception",
"io.sentry.featureflags",
"io.sentry.hints",
"io.sentry.instrumentation.file",
"io.sentry.internal",
"io.sentry.internal.debugmeta",
"io.sentry.internal.eventprocessor",
"io.sentry.internal.gestures",
"io.sentry.internal.modules",
"io.sentry.internal.viewhierarchy",
"io.sentry.logger",
"io.sentry.metrics",
"io.sentry.opentelemetry",
"io.sentry.profilemeasurements",
"io.sentry.profiling",
"io.sentry.protocol",
"io.sentry.protocol.profiling",
"io.sentry.rrweb",
"io.sentry.transport",
"io.sentry.util",
"io.sentry.util.network",
"io.sentry.util.runtime",
"io.sentry.util.thread",
"io.sentry.vendor",
"io.sentry.vendor.gson.internal.bind.util",
@@ -2147,6 +2170,7 @@
],
"software.amazon.awssdk:aws-core": [
"software.amazon.awssdk.awscore",
"software.amazon.awssdk.awscore.auth",
"software.amazon.awssdk.awscore.client.builder",
"software.amazon.awssdk.awscore.client.config",
"software.amazon.awssdk.awscore.client.handler",
@@ -2158,11 +2182,13 @@
"software.amazon.awssdk.awscore.exception",
"software.amazon.awssdk.awscore.interceptor",
"software.amazon.awssdk.awscore.internal",
"software.amazon.awssdk.awscore.internal.auth",
"software.amazon.awssdk.awscore.internal.authcontext",
"software.amazon.awssdk.awscore.internal.client.config",
"software.amazon.awssdk.awscore.internal.defaultsmode",
"software.amazon.awssdk.awscore.internal.interceptor",
"software.amazon.awssdk.awscore.internal.token",
"software.amazon.awssdk.awscore.internal.useragent",
"software.amazon.awssdk.awscore.presigner",
"software.amazon.awssdk.awscore.retry",
"software.amazon.awssdk.awscore.retry.conditions",
@@ -2180,7 +2206,8 @@
"software.amazon.awssdk.protocols.xml.internal.unmarshall"
],
"software.amazon.awssdk:checksums": [
"software.amazon.awssdk.checksums"
"software.amazon.awssdk.checksums",
"software.amazon.awssdk.checksums.internal"
],
"software.amazon.awssdk:checksums-spi": [
"software.amazon.awssdk.checksums.spi"
@@ -2340,7 +2367,7 @@
"software.amazon.awssdk.core.internal",
"software.amazon.awssdk.core.internal.async",
"software.amazon.awssdk.core.internal.capacity",
"software.amazon.awssdk.core.internal.checksums.factory",
"software.amazon.awssdk.core.internal.checksums",
"software.amazon.awssdk.core.internal.chunked",
"software.amazon.awssdk.core.internal.compression",
"software.amazon.awssdk.core.internal.handler",
@@ -2376,6 +2403,7 @@
"software.amazon.awssdk.core.signer",
"software.amazon.awssdk.core.sync",
"software.amazon.awssdk.core.traits",
"software.amazon.awssdk.core.useragent",
"software.amazon.awssdk.core.util",
"software.amazon.awssdk.core.waiters"
],
@@ -2386,8 +2414,8 @@
"software.amazon.awssdk.thirdparty.jackson.core.exc",
"software.amazon.awssdk.thirdparty.jackson.core.filter",
"software.amazon.awssdk.thirdparty.jackson.core.format",
"software.amazon.awssdk.thirdparty.jackson.core.internal.shaded.fdp.v2_18_5",
"software.amazon.awssdk.thirdparty.jackson.core.io",
"software.amazon.awssdk.thirdparty.jackson.core.io.doubleparser",
"software.amazon.awssdk.thirdparty.jackson.core.io.schubfach",
"software.amazon.awssdk.thirdparty.jackson.core.json",
"software.amazon.awssdk.thirdparty.jackson.core.json.async",
@@ -2400,11 +2428,18 @@
"software.amazon.awssdk.utils.async",
"software.amazon.awssdk.utils.builder",
"software.amazon.awssdk.utils.cache",
"software.amazon.awssdk.utils.cache.bounded",
"software.amazon.awssdk.utils.cache.lru",
"software.amazon.awssdk.utils.http",
"software.amazon.awssdk.utils.internal",
"software.amazon.awssdk.utils.internal.async",
"software.amazon.awssdk.utils.internal.proxy"
"software.amazon.awssdk.utils.internal.proxy",
"software.amazon.awssdk.utils.io",
"software.amazon.awssdk.utils.uri",
"software.amazon.awssdk.utils.uri.internal"
],
"software.amazon.awssdk:utils-lite": [
"software.amazon.awssdk.utilslite"
],
"software.amazon.eventstream:eventstream": [
"software.amazon.eventstream"
@@ -2541,6 +2576,7 @@
"software.amazon.awssdk:sdk-core",
"software.amazon.awssdk:third-party-jackson-core",
"software.amazon.awssdk:utils",
"software.amazon.awssdk:utils-lite",
"software.amazon.eventstream:eventstream"
]
},
+78 -13
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,19 +27,19 @@ 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
server {
listen 80;
listen [::]:80;
server_name prod.eagle0.net;
server_name prod.eagle0.net eagle0.net;
# Let's Encrypt challenge
location /.well-known/acme-challenge/ {
@@ -54,7 +57,7 @@ http {
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
server_name prod.eagle0.net;
server_name prod.eagle0.net eagle0.net;
# SSL certificates (managed by certbot)
ssl_certificate /etc/letsencrypt/live/prod.eagle0.net/fullchain.pem;
@@ -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;
@@ -108,6 +112,20 @@ http {
proxy_set_header X-Real-IP $remote_addr;
}
# Apple OAuth callback (Apple uses POST with form_post response mode)
location /oauth/apple/callback {
proxy_pass http://auth:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Steam OAuth callback (Steam uses OpenID 2.0)
location /oauth/steam/callback {
proxy_pass http://auth:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Invitation landing page (proxied to Go auth service)
location /invite/ {
proxy_pass http://auth:8080;
@@ -115,6 +133,13 @@ http {
proxy_set_header X-Real-IP $remote_addr;
}
# Credits/attributions page (proxied to Go auth service)
location /credits {
proxy_pass http://auth:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Health check endpoint
location /health {
access_log off;
@@ -244,4 +269,44 @@ http {
proxy_set_header X-Forwarded-Proto $scheme;
}
}
# HTTP server for Accounts Console (Let's Encrypt + redirect)
server {
listen 80;
listen [::]:80;
server_name accounts.prod.eagle0.net accounts.eagle0.net;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS server for Accounts Console (user self-service portal)
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name accounts.prod.eagle0.net accounts.eagle0.net;
ssl_certificate /etc/letsencrypt/live/accounts.eagle0.net/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/accounts.eagle0.net/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers off;
ssl_session_timeout 1d;
ssl_session_cache shared:SSL:10m;
ssl_session_tickets off;
location / {
proxy_pass http://admin:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}
+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
+30
View File
@@ -0,0 +1,30 @@
#!/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/"
# Convert Info.plist from binary to XML format (Unity requires XML)
/usr/bin/plutil -convert xml1 "$OUTPUT_DIR/SparklePlugin.bundle/Contents/Info.plist"
echo "=== SparklePlugin built successfully ==="
ls -la "$OUTPUT_DIR/SparklePlugin.bundle/"
+129
View File
@@ -0,0 +1,129 @@
#!/bin/bash
# Check BUILD.bazel dependency constraints
# This script enforces architectural boundaries in the codebase.
#
# Usage:
# ./scripts/check_build_deps.sh # Check all rules
# ./scripts/check_build_deps.sh --ci # CI mode (fail on any violation)
# ./scripts/check_build_deps.sh --count # Just count current violations (for tracking progress)
# ./scripts/check_build_deps.sh --strict # Same as --ci (strict enforcement)
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$REPO_ROOT"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
MODE="${1:-check}"
EXIT_CODE=0
# Rule 1: src/main should not depend on src/test
check_main_depends_on_test() {
echo -e "${YELLOW}Checking: src/main should not depend on src/test...${NC}"
violations=$(bazel query 'deps(//src/main/...) intersect //src/test/...' 2>/dev/null || true)
if [ -n "$violations" ]; then
echo -e "${RED}VIOLATION: src/main depends on src/test:${NC}"
echo "$violations"
return 1
else
echo -e "${GREEN}✓ No violations${NC}"
return 0
fi
}
# Rule 2: library/ should not depend on Scala proto types
# C++/Go proto deps are allowed (they're build-time deps for map generation tools)
check_library_depends_on_scala_proto() {
echo -e "${YELLOW}Checking: library/ should not depend on Scala proto types...${NC}"
violations=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep "_scala_proto" || true)
if [ -z "$violations" ]; then
count=0
else
count=$(echo "$violations" | grep -c "^//" || true)
fi
if [ "$count" -gt 0 ]; then
echo -e "${RED}VIOLATION: Found $count Scala proto dependencies in library/:${NC}"
echo "$violations"
return 1
else
echo -e "${GREEN}✓ No Scala proto dependencies in library/${NC}"
return 0
fi
}
# Rule 3: library/ should not depend on proto_converters
# Proto conversions should happen at service boundaries, not in library code
check_library_depends_on_proto_converters() {
echo -e "${YELLOW}Checking: library/ should not depend on proto_converters...${NC}"
violations=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/scala/net/eagle0/eagle/model/proto_converters/...' 2>/dev/null | grep "^//" || true)
if [ -n "$violations" ]; then
count=$(echo "$violations" | wc -l | tr -d ' ')
echo -e "${RED}VIOLATION: library/ depends on $count proto_converters targets:${NC}"
echo "$violations"
echo ""
echo "Proto conversions should happen at service boundaries (ShardokInterfaceGrpcClient,"
echo "EagleServiceImpl, etc.), not in library code."
return 1
else
echo -e "${GREEN}✓ No proto_converters dependencies in library/${NC}"
return 0
fi
}
# Count proto deps for informational purposes
count_proto_deps() {
echo -e "${YELLOW}=== Proto dependency counts ===${NC}"
scala_proto_results=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | grep "_scala_proto" || true)
if [ -z "$scala_proto_results" ]; then
scala_proto_count=0
else
scala_proto_count=$(echo "$scala_proto_results" | wc -l | tr -d ' ')
fi
echo "library/ Scala proto deps: $scala_proto_count"
# C++/Go proto deps are expected (map generation tools)
all_proto_count=$(bazel query 'deps(//src/main/scala/net/eagle0/eagle/library/...) intersect //src/main/protobuf/...' 2>/dev/null | wc -l | tr -d ' ')
echo "library/ all proto deps (includes C++/Go build tools): $all_proto_count"
}
echo "=== BUILD.bazel Dependency Check ==="
echo ""
case "$MODE" in
--count)
count_proto_deps
;;
--ci|--strict)
check_main_depends_on_test || EXIT_CODE=1
check_library_depends_on_scala_proto || EXIT_CODE=1
check_library_depends_on_proto_converters || EXIT_CODE=1
;;
*)
check_main_depends_on_test || EXIT_CODE=1
check_library_depends_on_scala_proto || EXIT_CODE=1
check_library_depends_on_proto_converters || EXIT_CODE=1
echo ""
count_proto_deps
;;
esac
echo ""
if [ $EXIT_CODE -eq 0 ]; then
echo -e "${GREEN}=== All checks passed ===${NC}"
else
echo -e "${RED}=== Some checks failed ===${NC}"
fi
exit $EXIT_CODE
+63 -10
View File
@@ -5,24 +5,58 @@
#
# Environment variables:
# SIGNING_IDENTITY - The signing identity (default: "Developer ID Application")
# KEYCHAIN_PASSWORD - Password to unlock the build keychain (optional)
# KEYCHAIN_PASSWORD - Password to unlock the keychain (optional, CI only)
# KEYCHAIN_NAME - Name of the keychain containing the signing certificate (optional, CI only)
# When set, looks for certificate in this specific keychain.
# When not set, searches all keychains (local dev mode).
set -euxo pipefail
APP_PATH="$1"
ENTITLEMENTS_PATH="${2:-}"
SIGNING_IDENTITY="${SIGNING_IDENTITY:-Developer ID Application}"
# KEYCHAIN_NAME is set by CI workflow - don't set a default here so we can detect if we're in CI
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH"
exit 1
fi
# Unlock keychain if password provided
if [ -n "${KEYCHAIN_PASSWORD:-}" ]; then
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain || true
# Unlock keychain if password and keychain name are provided (CI environment)
if [ -n "${KEYCHAIN_PASSWORD:-}" ] && [ -n "${KEYCHAIN_NAME:-}" ]; then
security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_NAME" || true
fi
# Get the SHA-1 hash of the signing certificate
# Using the hash avoids "ambiguous" errors when the same identity exists in multiple keychains
# We look in the build keychain specifically if KEYCHAIN_NAME is set (CI environment)
# Otherwise fall back to searching all keychains (local dev)
if [ -n "${KEYCHAIN_NAME:-}" ]; then
# CI environment: look for certificate in the build keychain specifically
KEYCHAIN_PATH="$HOME/Library/Keychains/${KEYCHAIN_NAME}-db"
echo "Looking for signing identity in build keychain: $KEYCHAIN_PATH"
echo "Available identities in build keychain:"
security find-identity -v -p codesigning "$KEYCHAIN_PATH"
CERT_HASH=$(security find-identity -v -p codesigning "$KEYCHAIN_PATH" | grep -E '^\s+[0-9]+\)' | head -1 | awk '{print $2}')
else
# Local dev: search all keychains
echo "Available codesigning identities:"
security find-identity -v -p codesigning
CERT_HASH=$(security find-identity -v -p codesigning | grep -E '^\s+[0-9]+\)' | head -1 | awk '{print $2}')
fi
if [ -z "$CERT_HASH" ]; then
echo "ERROR: No valid signing identity found"
exit 1
fi
echo "Using certificate hash: $CERT_HASH"
# Use the hash as the signing identity to avoid ambiguity
SIGNING_IDENTITY="$CERT_HASH"
echo "=== Signing nested components first ==="
# Sign all dylibs
@@ -39,32 +73,51 @@ find "$APP_PATH" -name "*.bundle" -print0 | while IFS= read -r -d '' item; do
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign XPC services (inside Sparkle framework)
# Sign XPC services (but skip ones inside Sparkle.framework - they're already signed)
find "$APP_PATH" -name "*.xpc" -print0 | while IFS= read -r -d '' item; do
if [[ "$item" == *"Sparkle.framework"* ]]; then
echo "Skipping Sparkle XPC service (pre-signed): $item"
continue
fi
echo "Signing XPC service: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign nested apps (like Sparkle's Updater.app)
# Sign nested apps (but skip ones inside Sparkle.framework - they're already signed)
find "$APP_PATH" -path "*/Frameworks/*.app" -print0 | while IFS= read -r -d '' item; do
if [[ "$item" == *"Sparkle.framework"* ]]; then
echo "Skipping Sparkle nested app (pre-signed): $item"
continue
fi
echo "Signing nested app: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign standalone executables inside frameworks (like Autoupdate)
# Sign standalone executables inside frameworks (but skip Sparkle.framework internals)
find "$APP_PATH" -path "*/Frameworks/*/Versions/*/Autoupdate" -type f -print0 | while IFS= read -r -d '' item; do
if [[ "$item" == *"Sparkle.framework"* ]]; then
echo "Skipping Sparkle executable (pre-signed): $item"
continue
fi
echo "Signing executable: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
done
# Sign all frameworks (after their contents are signed)
# Use --deep for Sparkle.framework to handle its XPC services
find "$APP_PATH" -name "*.framework" -print0 | while IFS= read -r -d '' item; do
echo "Signing framework: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
if [[ "$item" == *"Sparkle.framework" ]]; then
echo "Signing Sparkle framework with --deep: $item"
codesign --deep --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
else
echo "Signing framework: $item"
codesign --force --verify --verbose --timestamp --options runtime \
--sign "$SIGNING_IDENTITY" "$item"
fi
done
echo "=== Signing main app bundle ==="
+213 -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,10 @@ 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"
ACTIVE_INSTANCE_FILE="${APP_DIR}/.active-instance"
# Colors for output
RED='\033[0;31m'
@@ -36,15 +47,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 +107,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 +173,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 +237,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 +250,116 @@ 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
# Use --no-deps to prevent cascading to auth (which has secrets not available here)
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate --no-deps 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"
# Write active instance file for eagle-exec helper
echo "eagle-${staging}" > "${ACTIVE_INSTANCE_FILE}"
log_info "[DEPLOY:${deploy_id}] Active instance file updated: eagle-${staging}"
# 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 and ensure latest image
# Use --no-deps to prevent cascading to auth (which has secrets not available here)
log_info "Restarting admin service..."
docker compose -f "${COMPOSE_FILE}" up -d --force-recreate --no-deps admin
# Verify admin container is running
sleep 3
if ! docker inspect admin-server --format '{{.State.Running}}' 2>/dev/null | grep -q "true"; then
log_error "Admin container failed to start!"
log_error "Container logs:"
docker logs admin-server --tail 20 2>&1 || true
log_error "Container inspect:"
docker inspect admin-server 2>&1 | head -50 || true
exit 1
fi
log_info "Admin service restarted successfully"
# 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 +383,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
+46
View File
@@ -0,0 +1,46 @@
#!/bin/bash
#
# Helper to run docker exec against the active Eagle instance.
# Reads the active instance from /opt/eagle0/.active-instance (set by deploy-blue-green.sh).
#
# Usage:
# eagle-exec printenv GEMINI_API_KEY
# eagle-exec jcmd 1 VM.flags
# eagle-exec sh # Get a shell
#
# To create an alias, add to ~/.bashrc:
# alias eagle-exec='/opt/eagle0/scripts/eagle-exec.sh'
#
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/eagle0}"
ACTIVE_FILE="${APP_DIR}/.active-instance"
# Read active instance from file, with fallback
if [ -f "$ACTIVE_FILE" ]; then
ACTIVE=$(cat "$ACTIVE_FILE")
else
# Fallback: check which container is actually running
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
ACTIVE="eagle-blue"
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
ACTIVE="eagle-green"
else
ACTIVE=""
fi
fi
if [ -z "$ACTIVE" ]; then
echo "Error: No active Eagle instance found" >&2
exit 1
fi
if [ $# -eq 0 ]; then
echo "Active instance: $ACTIVE"
echo "Usage: $0 <command> [args...]"
echo "Example: $0 printenv GEMINI_API_KEY"
exit 0
fi
exec docker exec "$ACTIVE" "$@"
+44
View File
@@ -0,0 +1,44 @@
#!/bin/bash
#
# Helper to tail logs from the active Eagle instance.
# Reads the active instance from /opt/eagle0/.active-instance (set by deploy-blue-green.sh).
#
# Usage:
# eagle-logs # Tail logs (follow mode)
# eagle-logs -n 100 # Show last 100 lines and follow
# eagle-logs --no-follow -n 50 # Show last 50 lines without following
#
# To create an alias, add to ~/.bashrc:
# alias eagle-logs='/opt/eagle0/scripts/eagle-logs.sh'
#
set -euo pipefail
APP_DIR="${APP_DIR:-/opt/eagle0}"
ACTIVE_FILE="${APP_DIR}/.active-instance"
# Read active instance from file, with fallback
if [ -f "$ACTIVE_FILE" ]; then
ACTIVE=$(cat "$ACTIVE_FILE")
else
# Fallback: check which container is actually running
if docker inspect --format='{{.State.Running}}' eagle-blue 2>/dev/null | grep -q true; then
ACTIVE="eagle-blue"
elif docker inspect --format='{{.State.Running}}' eagle-green 2>/dev/null | grep -q true; then
ACTIVE="eagle-green"
else
ACTIVE=""
fi
fi
if [ -z "$ACTIVE" ]; then
echo "Error: No active Eagle instance found" >&2
exit 1
fi
# Default to follow mode if no args provided
if [ $# -eq 0 ]; then
exec docker logs -f "$ACTIVE"
else
exec docker logs "$@" "$ACTIVE"
fi
+129
View File
@@ -373,6 +373,115 @@ send_email_fastmail() {
echo "Email sent successfully"
}
# Generate player-friendly "What's New" summary
# This creates a short summary suitable for in-game display
generate_whats_new_summary() {
local pr_file="$1"
local output_file="/tmp/eagle0_whats_new_$$.txt"
echo "Generating player-friendly What's New summary..." >&2
# Create a prompt for player-facing summary
local prompt_file="/tmp/eagle0_whats_new_prompt_$$.txt"
cat > "$prompt_file" <<'WHATS_NEW_PROMPT'
Based on these merged PRs, write a SHORT player-friendly summary.
Focus only on changes players will notice. Ignore internal/technical changes.
Output format (exactly this format, no markdown, no extra text):
TITLE: (5-10 words describing the main change)
DESCRIPTION: (1-2 sentences, what players can now do differently)
CATEGORY: (one of: feature, improvement, fix, content)
If there are multiple notable player-visible changes, pick the single most important one.
If there are no player-visible changes at all, output exactly: NONE
Examples of good output:
TITLE: Bug Reporting
DESCRIPTION: You can now report bugs directly from the Settings menu.
CATEGORY: feature
TITLE: Faster Reconnection
DESCRIPTION: The game now reconnects more smoothly after network interruptions.
CATEGORY: improvement
Here are the merged PRs:
WHATS_NEW_PROMPT
cat "$pr_file" >> "$prompt_file"
# Use Claude CLI to generate the summary
if ! command -v claude &> /dev/null; then
echo "NONE"
rm -f "$prompt_file"
return
fi
cat "$prompt_file" | claude --print > "$output_file" 2>/dev/null
rm -f "$prompt_file"
# Check if the output is NONE
if grep -q "^NONE$" "$output_file"; then
echo "NONE"
rm -f "$output_file"
return
fi
# Return the output
cat "$output_file"
rm -f "$output_file"
}
# URL encode a string for use in query parameters
urlencode() {
local string="$1"
local strlen=${#string}
local encoded=""
local pos c o
for (( pos=0 ; pos<strlen ; pos++ )); do
c=${string:$pos:1}
case "$c" in
[-_.~a-zA-Z0-9] ) o="${c}" ;;
* ) printf -v o '%%%02x' "'$c"
esac
encoded+="${o}"
done
echo "${encoded}"
}
# Open the admin console with pre-populated what's new content
open_whats_new_preview() {
local title="$1"
local description="$2"
local category="$3"
local encoded_title=$(urlencode "$title")
local encoded_desc=$(urlencode "$description")
local encoded_cat=$(urlencode "$category")
local preview_url="https://admin.eagle0.net/whats-new?preview=true&title=${encoded_title}&description=${encoded_desc}&category=${encoded_cat}"
echo ""
echo "=== What's New Preview ==="
echo "Title: $title"
echo "Description: $description"
echo "Category: $category"
echo ""
echo "Opening admin console preview..."
# Open the URL in the default browser
if [[ "$(uname)" == "Darwin" ]]; then
open "$preview_url"
elif command -v xdg-open &> /dev/null; then
xdg-open "$preview_url"
else
echo "Preview URL: $preview_url"
fi
}
# Update the tag to mark this run
update_tag() {
echo "Updating $TAG_NAME tag..."
@@ -462,6 +571,26 @@ main() {
# Update tag for next run
update_tag
# Generate player-friendly What's New summary and open admin preview
echo ""
echo "Generating What's New summary for in-game display..."
whats_new_output=$(generate_whats_new_summary "$pr_file")
if [[ "$whats_new_output" != "NONE" ]]; then
# Parse the output
title=$(echo "$whats_new_output" | grep "^TITLE:" | sed 's/^TITLE:[[:space:]]*//')
description=$(echo "$whats_new_output" | grep "^DESCRIPTION:" | sed 's/^DESCRIPTION:[[:space:]]*//')
category=$(echo "$whats_new_output" | grep "^CATEGORY:" | sed 's/^CATEGORY:[[:space:]]*//')
if [[ -n "$title" && -n "$description" ]]; then
open_whats_new_preview "$title" "$description" "$category"
else
echo "Could not parse What's New output, skipping preview"
fi
else
echo "No player-visible changes detected, skipping What's New preview"
fi
fi
echo ""
+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))
}
+47 -20
View File
@@ -27,33 +27,59 @@ if [ -z "${SPARKLE_EDDSA_PUBLIC_KEY:-}" ]; then
exit 1
fi
# Download Sparkle if not cached
# Always use a fresh Sparkle download to avoid cache corruption issues
SPARKLE_DIR="$SPARKLE_CACHE_DIR/Sparkle-$SPARKLE_VERSION"
if [ ! -d "$SPARKLE_DIR/Sparkle.framework" ]; then
echo "=== Downloading Sparkle $SPARKLE_VERSION ==="
mkdir -p "$SPARKLE_CACHE_DIR"
SPARKLE_URL="https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz"
curl -L "$SPARKLE_URL" | tar -xJ -C "$SPARKLE_CACHE_DIR"
mv "$SPARKLE_CACHE_DIR/Sparkle-$SPARKLE_VERSION" "$SPARKLE_DIR" 2>/dev/null || true
# If the extracted directory doesn't match version pattern, it may just be "Sparkle"
if [ ! -d "$SPARKLE_DIR" ]; then
mkdir -p "$SPARKLE_DIR"
mv "$SPARKLE_CACHE_DIR/Sparkle.framework" "$SPARKLE_DIR/" 2>/dev/null || true
mv "$SPARKLE_CACHE_DIR/bin" "$SPARKLE_DIR/" 2>/dev/null || true
fi
echo "=== Clearing Sparkle cache and downloading fresh copy ==="
rm -rf "$SPARKLE_DIR"
mkdir -p "$SPARKLE_DIR"
SPARKLE_URL="https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz"
echo "Downloading from: $SPARKLE_URL"
curl -L "$SPARKLE_URL" -o /tmp/sparkle.tar.xz
tar -xJf /tmp/sparkle.tar.xz -C "$SPARKLE_DIR"
rm /tmp/sparkle.tar.xz
# Show what was extracted
echo "=== Extracted contents ==="
ls -la "$SPARKLE_DIR/"
# The tarball extracts files directly, not into a subdirectory
# Verify the framework has proper symlink structure
echo "=== Verifying Sparkle.framework structure ==="
ls -la "$SPARKLE_DIR/Sparkle.framework/"
if [ ! -L "$SPARKLE_DIR/Sparkle.framework/Sparkle" ]; then
echo "ERROR: Sparkle.framework/Sparkle is not a symlink"
file "$SPARKLE_DIR/Sparkle.framework/Sparkle"
exit 1
fi
if [ ! -L "$SPARKLE_DIR/Sparkle.framework/Versions/Current" ]; then
echo "ERROR: Sparkle.framework/Versions/Current is not a symlink"
ls -la "$SPARKLE_DIR/Sparkle.framework/Versions/"
exit 1
fi
echo "Sparkle framework structure verified OK"
echo "=== Injecting Sparkle framework ==="
FRAMEWORKS_DIR="$APP_PATH/Contents/Frameworks"
mkdir -p "$FRAMEWORKS_DIR"
# Copy Sparkle framework
cp -R "$SPARKLE_DIR/Sparkle.framework" "$FRAMEWORKS_DIR/"
# Remove any existing Sparkle.framework in the app
rm -rf "$FRAMEWORKS_DIR/Sparkle.framework"
# Also copy the XPC services if present
if [ -d "$SPARKLE_DIR/Sparkle.framework/Versions/B/XPCServices" ]; then
echo "Sparkle XPC services present"
# Copy Sparkle framework (use ditto to preserve symlinks and bundle structure)
ditto "$SPARKLE_DIR/Sparkle.framework" "$FRAMEWORKS_DIR/Sparkle.framework"
# Verify the copied framework still has proper structure
echo "=== Verifying copied Sparkle.framework structure ==="
ls -la "$FRAMEWORKS_DIR/Sparkle.framework/"
if [ ! -L "$FRAMEWORKS_DIR/Sparkle.framework/Sparkle" ]; then
echo "ERROR: Copied framework lost symlink structure"
exit 1
fi
echo "Copied framework structure OK"
echo "=== Updating Info.plist ==="
PLIST_PATH="$APP_PATH/Contents/Info.plist"
@@ -69,8 +95,9 @@ PLIST_PATH="$APP_PATH/Contents/Info.plist"
/usr/libexec/PlistBuddy -c "Add :SUEnableAutomaticChecks bool true" "$PLIST_PATH"
# Set bundle version from git for Sparkle version comparison
VERSION=$(git describe --tags --always 2>/dev/null || echo "1.0.0")
# Use commit count for automatic incrementing versions (e.g., 1.0.9548)
BUILD_NUMBER=$(git rev-list --count HEAD 2>/dev/null || echo "1")
VERSION="1.0.${BUILD_NUMBER}"
echo "Setting version: $VERSION (build $BUILD_NUMBER)"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST_PATH" 2>/dev/null || \
@@ -83,7 +110,7 @@ echo "=== Adding URL scheme for invitation codes ==="
/usr/libexec/PlistBuddy -c "Delete :CFBundleURLTypes" "$PLIST_PATH" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes array" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0 dict" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLName string 'com.Shardok-Games.eagle0'" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLName string 'net.eagle0.eagle0'" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes array" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string 'eagle0'" "$PLIST_PATH"
+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"
+88
View File
@@ -0,0 +1,88 @@
#!/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 "=== Verifying code signature before stapling ==="
if ! codesign --verify --deep --strict "$APP_PATH" 2>&1; then
echo "ERROR: Code signature verification failed - app may have been damaged during transfer"
echo "Attempting to show signature details:"
codesign -dvvv "$APP_PATH" 2>&1 || true
exit 1
fi
echo "Code signature verified successfully"
echo "=== Stapling notarization ticket to app ==="
# Retry stapling - Apple's CloudKit can take several minutes to propagate the ticket
MAX_STAPLE_ATTEMPTS=10
STAPLE_WAIT_SECONDS=30
STAPLE_ATTEMPT=1
while [ $STAPLE_ATTEMPT -le $MAX_STAPLE_ATTEMPTS ]; do
echo "Stapling attempt $STAPLE_ATTEMPT/$MAX_STAPLE_ATTEMPTS..."
if STAPLE_OUTPUT=$(xcrun stapler staple "$APP_PATH" 2>&1); then
echo "$STAPLE_OUTPUT"
echo "Stapling successful"
break
fi
echo "Stapler output: $STAPLE_OUTPUT"
if [ $STAPLE_ATTEMPT -eq $MAX_STAPLE_ATTEMPTS ]; then
echo "ERROR: Stapling failed after $MAX_STAPLE_ATTEMPTS attempts (total wait: $((MAX_STAPLE_ATTEMPTS * STAPLE_WAIT_SECONDS)) seconds)"
exit 1
fi
echo "Stapling failed, waiting $STAPLE_WAIT_SECONDS seconds before retry..."
sleep $STAPLE_WAIT_SECONDS
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);
@@ -37,4 +37,7 @@ sysinfo.txt
*.apk
*.unitypackage
mono_crash.*
mono_crash.*
# Local server data
ServerData/
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 6ddb90d3140384d50a98a19e72539b62
guid: fb38e3be95e9247c5a451d60236def15
folderAsset: yes
DefaultImporter:
externalObjects: {}

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