Compare commits

...
Author SHA1 Message Date
adminandClaude Opus 4.5 3ed9982822 Add JoinGameRequest on reconnect to ensure game is loaded
Server:
- Add ensureGameLoaded() to joinGame's case None branch
- This triggers game loading even for already-started games

Client:
- Send JoinGameRequest before StreamGameRequest in StreamOneGameAsync
- This ensures the game is loaded on the server before subscribing
- JoinGameRequest returns GAME_FULL for started games but still loads them

Together with the command handler fix, this provides belt-and-suspenders
protection against "key not found" errors after reconnect.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 17:24:47 -08:00
adminandClaude Opus 4.5 dc351a48b8 Server: ensure game is loaded before processing commands
Add ensureGameLoaded() calls to postCommand, postShardokCommand, and
postPlacementCommands. This handles the case where a command arrives
before the game subscription has loaded the game into memory.

This is cheap (just a map contains check) if the game is already loaded,
but prevents "key not found" errors when commands race with subscriptions.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 17:23:39 -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
190 changed files with 7073 additions and 11674 deletions
+25 -1
View File
@@ -14,6 +14,7 @@ on:
- "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_submit.sh"
- "scripts/notarize_wait.sh"
@@ -31,6 +32,7 @@ on:
- "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_submit.sh"
- "scripts/notarize_wait.sh"
@@ -66,14 +68,27 @@ jobs:
run: git lfs pull
- 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"
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: mac
run: ./ci/github_actions/persist_library.sh
- name: Inject Sparkle Framework
if: success()
env:
SPARKLE_EDDSA_PUBLIC_KEY: ${{ secrets.SPARKLE_EDDSA_PUBLIC_KEY }}
run: |
chmod +x ./scripts/inject_sparkle.sh
./scripts/inject_sparkle.sh "/tmp/eagle0/eagle0MAC/eagle0.app"
- name: Check if should deploy
id: check-deploy
run: |
@@ -239,11 +254,20 @@ jobs:
env:
ACCESS_KEY_ID: ${{ secrets.ACCESS_KEY_ID }}
SECRET_KEY: ${{ secrets.SECRET_KEY }}
SPARKLE_EDDSA_PRIVATE_KEY: ${{ secrets.SPARKLE_EDDSA_PRIVATE_KEY }}
run: |
# Write private key to temp file for signing
SPARKLE_PRIVATE_KEY_PATH="/tmp/sparkle_private_key"
echo "$SPARKLE_EDDSA_PRIVATE_KEY" > "$SPARKLE_PRIVATE_KEY_PATH"
chmod 600 "$SPARKLE_PRIVATE_KEY_PATH"
VERSION=$(git describe --tags --always)
BUILD_NUMBER=$(git rev-list --count HEAD)
bazel run //src/main/go/net/eagle0/build/mac_build_handler:mac_build_handler -- \
"/tmp/eagle0/eagle0MAC/eagle0.app" \
"$VERSION" \
"$BUILD_NUMBER"
"$BUILD_NUMBER" \
"$SPARKLE_PRIVATE_KEY_PATH"
rm "$SPARKLE_PRIVATE_KEY_PATH"
+6 -1
View File
@@ -49,14 +49,19 @@ jobs:
- 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: 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"
- name: Persist Library/
if: success()
env:
UNITY_CACHE_PLATFORM: windows
run: ./ci/github_actions/persist_library.sh
- name: Deploy Windows unity
if: success() && github.ref == 'refs/heads/main' && github.event_name == 'push'
-566
View File
@@ -1,566 +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
---
## Proto Import Inventory (library/)
**Total: 149 proto imports across 51 files** (as of 2026-01-13)
### Proto Dependencies by BUILD.bazel (unique deps)
#### library/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:action_result_type_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:round_phase_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:action_result_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:game_state_view_scala_proto`
- `//src/main/protobuf/net/eagle0/shardok/api:action_result_view_scala_proto`
- `//src/main/protobuf/net/eagle0/shardok/api:command_descriptor_scala_proto`
- `//src/main/protobuf/net/eagle0/shardok/storage:action_result_scala_proto`
#### library/actions/impl/action/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:action_result_type_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:province_order_type_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:unaffiliated_hero_quest_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
#### library/actions/impl/common/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
#### library/actions/llm_prompt_generators/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:event_for_chronicle_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto`
#### library/actions/llm_request_generators/diplomacy_llm_helpers/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:llm_request_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:province_scala_proto`
#### library/settings/loaders/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto`
#### library/util/BUILD.bazel
- `//src/main/protobuf/net/eagle0/common:hostility_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api/command/util:army_stats_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:date_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:province_event_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:round_phase_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:army_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:faction_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:hero_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:province_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:battalion_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:faction_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:game_state_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:hero_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:stat_with_condition_scala_proto`
#### library/util/command_choice_helpers/BUILD.bazel
- `//src/main/protobuf/net/eagle0/common:hostility_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api/command/util:armed_battalion_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api/command/util:battalion_with_food_cost_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:battalion_type_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:combat_unit_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:diplomacy_offer_scala_proto`
#### library/util/command_choice_helpers/quest_command_selectors/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:improvement_type_scala_proto`
#### library/util/faction_utils/BUILD.bazel
- `//src/main/protobuf/net/eagle0/common:hostility_scala_proto`
#### library/util/quest_creation/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:action_result_scala_proto`
#### library/util/quest_fulfillment/BUILD.bazel
- `//src/main/protobuf/net/eagle0/eagle/internal:unaffiliated_hero_scala_proto`
#### library/util/view_filters/BUILD.bazel
- `//src/main/protobuf/net/eagle0/common:hostility_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/common:province_event_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:army_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:faction_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:hero_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:province_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/internal:shardok_battle_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:army_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:battalion_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:faction_relationship_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:faction_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:game_state_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:hero_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:incoming_army_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:province_view_scala_proto`
- `//src/main/protobuf/net/eagle0/eagle/views:shardok_battle_view_scala_proto`
---
### Summary by Directory
| Directory | Files | Imports | Status |
|-----------|-------|---------|--------|
| `actions/availability/` | 0 | 0 | ✅ Clean |
| `actions/impl/action/` | 2 | 3 | Boundary code |
| `actions/impl/common/` | 1 | 2 | Boundary code |
| `actions/llm_prompt_generators/` | 24 | 46 | LLM request types |
| `actions/llm_request_generators/` | 1 | 2 | LLM request types |
| `settings/loaders/` | 1 | 1 | Loader (expected) |
| `util/` | 9 | 20 | Mixed - cleanup candidates |
| `util/command_choice_helpers/` | 17 | 57 | API types |
| `util/view_filters/` | 1 | 1 | View boundary |
| Root (`library/`) | 3 | 5 | Boundary code |
---
### Detailed Inventory
#### Root library/ (5 imports, 3 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ActionResultFilter.scala` | 3 | `common.action_result_notification_details.Notification` | Action result filtering |
| `ActionResultFilter.scala` | 4 | `common.action_result_type.ActionResultType` | |
| `ActionResultFilter.scala` | 5 | `common.action_result_type.ActionResultType.*` | |
| `Engine.scala` | 6 | `internal.action_result.ActionResult` | Trait interface |
| `EngineImpl.scala` | 8 | `internal.action_result.ActionResult` | Returns proto for persistence |
#### actions/impl/action/ (3 imports, 2 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ChronicleEventGenerator.scala` | 3 | `common.action_result_type.ActionResultType.FACTION_DESTROYED` | Single enum value |
| `PerformVassalCommandsPhaseAction.scala` | 5 | `api.available_command.{AvailableCommand, RestAvailableCommand}` | Commands from factory |
| `PerformVassalDefenseDecisionsAction.scala` | 4 | `api.available_command.AvailableCommand` | Commands from factory |
#### actions/impl/common/ (2 imports, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ActionWithResultingState.scala` | 3 | `internal.action_result.ActionResult` | Caches both proto and Scala |
| `ActionWithResultingState.scala` | 4 | `internal.game_state.GameState` | |
#### actions/llm_prompt_generators/ (46 imports, 24 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `AllianceOfferMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.AllianceOfferMessage` | LLM request type |
| `AllianceResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `AllianceResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `AllianceResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.AllianceOfferResolutionMessage` | |
| `BattalionDescriptions.scala` | 3 | `common.battalion_type.BattalionTypeId` | |
| `BattalionDescriptions.scala` | 4 | `internal.battalion.Battalion` | |
| `BreakAllianceMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.BreakAllianceMessage` | |
| `BreakAllianceResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `BreakAllianceResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `BreakAllianceResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.BreakAllianceResolutionMessage` | |
| `CapturedHeroMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.{...}` | |
| `ChronicleEventTextGenerator.scala` | 4 | `internal.event_for_chronicle.{...}` | |
| `ChronicleEventTextGenerator.scala` | 31 | `internal.event_for_chronicle.ShatteredArmyEvent.Reason.{...}` | |
| `ChronicleUpdatePromptGenerator.scala` | 4 | `common.date.Date` | |
| `ChronicleUpdatePromptGenerator.scala` | 5 | `internal.event_for_chronicle.EventForChronicle` | |
| `ChronicleUpdatePromptGenerator.scala` | 6 | `internal.generated_text_request.ChronicleUpdateMessage` | |
| `DivineMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.DivineMessage` | |
| `ExileVassalPromptGenerator.scala` | 4 | `internal.generated_text_request.ExileVassalMessage` | |
| `GeneratorUtilities.scala` | 13 | `common.date.Date` | |
| `HandleCapturedHeroPleaPromptGenerator.scala` | 4 | `internal.generated_text_request.HandleCapturedHeroPlea` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 9 | `internal.event_for_hero_backstory.{...}` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 37 | `internal.event_for_hero_backstory.FoughtInBattleBackstoryEvent.UnitStatus` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 38 | `internal.event_for_hero_backstory.FoughtInBattleBackstoryEvent.UnitStatus.{...}` | |
| `HeroBackstoryUpdatePromptGenerator.scala` | 47 | `internal.generated_text_request.HeroBackstoryUpdateRequest` | |
| `HeroDeparturePromptGenerator.scala` | 4 | `internal.generated_text_request.HeroDepartureMessage` | |
| `HeroInitialBackstoryPromptGenerator.scala` | 4 | `internal.generated_text_request.HeroInitialBackstoryRequest` | |
| `InvitationMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.InvitationMessage` | |
| `InvitationResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `InvitationResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `InvitationResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.InvitationResolutionMessage` | |
| `NewFactionHeadPromptGenerator.scala` | 4 | `internal.generated_text_request.NewFactionHeadMessage` | |
| `PleaseRecruitMePromptGenerator.scala` | 4 | `internal.generated_text_request.PleaseRecruitMeMessage` | |
| `PrisonerExecutedPromptGenerator.scala` | 4 | `internal.generated_text_request.PrisonerExecutedMessage` | |
| `PrisonerExiledPromptGenerator.scala` | 4 | `internal.generated_text_request.PrisonerExiledMessage` | |
| `PrisonerReleasedPromptGenerator.scala` | 4 | `internal.generated_text_request.PrisonerReleasedMessage` | |
| `PrisonerReturnedPromptGenerator.scala` | 4 | `internal.generated_text_request.PrisonerReturnedMessage` | |
| `ProfessionGainedPromptGenerator.scala` | 4 | `internal.generated_text_request.ProfessionGainedMessage` | |
| `QuestEndedGeneratorUtilities.scala` | 5 | `common.battalion_type.BattalionTypeId` | |
| `QuestEndedGeneratorUtilities.scala` | 6 | `common.unaffiliated_hero_quest.{...}` | |
| `QuestEndedGeneratorUtilities.scala` | 29 | `common.unaffiliated_hero_quest.QuestDetails.Empty` | |
| `QuestFailedPromptGenerator.scala` | 4 | `internal.generated_text_request.QuestFailedMessage` | |
| `QuestFulfilledPromptGenerator.scala` | 4 | `internal.generated_text_request.QuestFulfilledMessage` | |
| `RansomOfferMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.RansomOfferMessage` | |
| `RansomResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `RansomResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `RansomResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.RansomResolutionMessage` | |
| `RecruitmentRefusedPromptGenerator.scala` | 4 | `internal.generated_text_request.RecruitmentRefusedMessage` | |
| `SuppressBeastsPromptGenerator.scala` | 4 | `internal.generated_text_request.{...}` | |
| `SwearBrotherhoodPromptGenerator.scala` | 3 | `common.gender.Gender.{...}` | |
| `SwearBrotherhoodPromptGenerator.scala` | 4 | `internal.generated_text_request.SwearBrotherhoodMessage` | |
| `TruceOfferMessagePromptGenerator.scala` | 4 | `internal.generated_text_request.TruceOfferMessage` | |
| `TruceResolutionMessagePromptGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `TruceResolutionMessagePromptGenerator.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `TruceResolutionMessagePromptGenerator.scala` | 13 | `internal.generated_text_request.TruceResolutionMessage` | |
#### actions/llm_request_generators/ (2 imports, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `DiplomacyResolutionLlmRequestGenerator.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `DiplomacyResolutionLlmRequestGenerator.scala` | 5 | `internal.generated_text_request.{...}` | |
#### settings/loaders/ (1 import, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `BattalionTypeLoader.scala` | 8 | `common.battalion_type.{BattalionType, BattalionTypeId}` | Loads from file, converts immediately |
#### util/ (20 imports, 9 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ArmyUtils.scala` | 3 | `internal.army.{Army, HostileArmyGroup, MovingArmy}` | Has Scala overloads |
| `ArmyUtils.scala` | 4 | `internal.game_state.GameState` | |
| `CommandSelection.scala` | 4 | `api.available_command.AvailableCommand` | Wraps proto command |
| `CommandSelection.scala` | 5 | `api.selected_command.SelectedCommand` | |
| `DateProtoUtils.scala` | 5 | `common.date.Date` | Conversion utility |
| `GameStateViewDiffer.scala` | 3 | `common.round_phase.NewRoundPhase` | View diff for client |
| `IDable.scala` | 4 | `internal.battalion.Battalion` | Test helper only |
| `IDable.scala` | 5 | `internal.faction.Faction` | |
| `IDable.scala` | 6 | `internal.hero.Hero` | |
| `IDable.scala` | 7 | `internal.province.Province` | |
| `IncomingArmyUtils.scala` | 4 | `api.command.util.army_stats.ArmyStats` | Returns proto type |
| `IncomingArmyUtils.scala` | 5 | `internal.army.{Army, MovingArmy}` | Has Scala overloads |
| `IncomingArmyUtils.scala` | 6 | `internal.game_state.GameState` | |
| `IncomingArmyUtils.scala` | 7 | `internal.province.Province` | |
| `MapGenerator.scala` | 6 | `internal.province.{Neighbor, Province}` | Map generation |
| `ProvinceEventUtils.scala` | 3 | `common.beast_info.BeastInfo` | |
| `ProvinceEventUtils.scala` | 4 | `common.province_event.*` | |
#### util/command_choice_helpers/ (57 imports, 17 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `AllianceOfferCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, DiplomacyAvailableCommand}` | |
| `AllianceOfferCommandSelector.scala` | 5 | `api.command.util.diplomacy_option.{AllianceOption, DiplomacyOption}` | |
| `AllianceOfferCommandSelector.scala` | 6 | `api.selected_command.DiplomacySelectedCommand` | |
| `AlmsCommandSelector.scala` | 4 | `api.available_command.{AlmsAvailableCommand, AvailableCommand}` | |
| `AlmsCommandSelector.scala` | 5 | `api.selected_command.AlmsSelectedCommand` | |
| `AttackCommandChooser.scala` | 4 | `api.available_command.*` | |
| `AttackCommandChooser.scala` | 5 | `api.selected_command.*` | |
| `AttackCommandChooser.scala` | 6 | `common.combat_unit.CombatUnit` | |
| `AttackDecisionCommandChooser.scala` | 5 | `api.available_command.{AttackDecisionAvailableCommand, AvailableCommand}` | |
| `AttackDecisionCommandChooser.scala` | 6 | `api.command.util.attack_decision_type.{...}` | |
| `AttackDecisionCommandChooser.scala` | 12 | `api.selected_command.AttackDecisionSelectedCommand` | |
| `AttackDecisionCommandChooser.scala` | 13 | `common.tribute_amount.TributeAmount` | |
| `AvailableCommandSelector.scala` | 6 | `api.available_command.AvailableCommand` | |
| `CombatUnitSelector.scala` | 5 | `common.combat_unit.CombatUnit` | |
| `CommandChoiceHelpers.scala` | 5 | `api.available_command.*` | |
| `CommandChoiceHelpers.scala` | 6 | `api.command.util.armed_battalion.ArmedBattalion` | |
| `CommandChoiceHelpers.scala` | 7 | `api.command.util.attack_decision_type.{AdvanceDecision, WithdrawDecision}` | |
| `CommandChoiceHelpers.scala` | 8 | `api.command.util.captured_hero_option.CapturedHeroOption.{...}` | |
| `CommandChoiceHelpers.scala` | 13 | `api.command.util.diplomacy_option.RansomOfferOption` | |
| `CommandChoiceHelpers.scala` | 14 | `api.selected_command.*` | |
| `CommandChoiceHelpers.scala` | 15 | `api.selected_command.OrganizeTroopsSelectedCommand.{ChangedBattalion, NewBattalion}` | |
| `CommandChoiceHelpers.scala` | 16 | `common.battalion_type.BattalionTypeId` | |
| `CommandChoiceHelpers.scala` | 17 | `common.combat_unit.CombatUnit` | |
| `CommandChoiceHelpers.scala` | 18 | `common.improvement_type.ImprovementType.INFRASTRUCTURE` | |
| `CommandChoiceHelpers.scala` | 19 | `common.tribute_amount.TributeAmount` | |
| `CommandChooser.scala` | 6 | `api.available_command.AvailableCommand` | |
| `ExileVassalCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, ExileVassalAvailableCommand}` | |
| `ExileVassalCommandSelector.scala` | 5 | `api.selected_command.ExileVassalSelectedCommand` | |
| `ExpandCommandSelector.scala` | 7 | `api.available_command.{AvailableCommand, MarchAvailableCommand, MarchCommandFromOneProvince}` | |
| `ExpandCommandSelector.scala` | 8 | `api.selected_command.MarchSelectedCommand` | |
| `ExpandCommandSelector.scala` | 9 | `common.combat_unit.CombatUnit` | |
| `FulfillQuestsCommandSelector.scala` | 4 | `api.available_command.AvailableCommand` | |
| `HeroGiftCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, HeroGiftAvailableCommand}` | |
| `HeroGiftCommandSelector.scala` | 5 | `api.selected_command.HeroGiftSelectedCommand` | |
| `ImproveCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, ImproveAvailableCommand}` | |
| `ImproveCommandSelector.scala` | 5 | `api.selected_command.ImproveSelectedCommand` | |
| `ImproveCommandSelector.scala` | 6 | `common.improvement_type.ImprovementType` | |
| `ImproveCommandSelector.scala` | 7 | `common.improvement_type.ImprovementType.DEVASTATION` | |
| `OrganizeCommandSelector.scala` | 5 | `api.available_command.{AvailableCommand, OrganizeTroopsAvailableCommand}` | |
| `OrganizeCommandSelector.scala` | 6 | `api.selected_command.OrganizeTroopsSelectedCommand` | |
| `OrganizeCommandSelector.scala` | 7 | `api.selected_command.OrganizeTroopsSelectedCommand.{ChangedBattalion, NewBattalion}` | |
| `OrganizeCommandSelector.scala` | 8 | `common.battalion_type.BattalionTypeId` | |
| `RansomOfferHelpers.scala` | 3 | `common.diplomacy_offer.RansomOfferDetails` | |
| `RansomOfferHelpers.scala` | 4 | `common.diplomacy_offer_status.DiplomacyOfferStatus` | |
| `RansomOfferHelpers.scala` | 5 | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | |
| `SwearBrotherhoodCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, SwearBrotherhoodAvailableCommand}` | |
| `SwearBrotherhoodCommandSelector.scala` | 5 | `api.selected_command.SwearBrotherhoodSelectedCommand` | |
| `TruceOfferCommandSelector.scala` | 4 | `api.available_command.{AvailableCommand, DiplomacyAvailableCommand}` | |
| `TruceOfferCommandSelector.scala` | 5 | `api.command.util.diplomacy_option.{DiplomacyOption, TruceOption}` | |
| `TruceOfferCommandSelector.scala` | 6 | `api.selected_command.DiplomacySelectedCommand` | |
#### util/command_choice_helpers/quest_command_selectors/ (12 imports, 10 files)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `AllianceQuestCommandChooser.scala` | 4 | `api.available_command.{AvailableCommand, DiplomacyAvailableCommand}` | |
| `AllianceQuestCommandChooser.scala` | 5 | `api.command.util.diplomacy_option.AllianceOption` | |
| `AlmsAcrossRealmQuestCommandChooser.scala` | 2 | `api.available_command.AvailableCommand` | |
| `AlmsToProvinceQuestCommandChooser.scala` | 3 | `api.available_command.AvailableCommand` | |
| `DismissSpecificVassalCommandChooser.scala` | 5 | `api.available_command.AvailableCommand` | |
| `GiveToHeroesAcrossRealmQuestCommandChooser.scala` | 2 | `api.available_command.AvailableCommand` | |
| `GiveToHeroesInProvinceQuestCommandChooser.scala` | 4 | `api.available_command.AvailableCommand` | |
| `ImproveQuestCommandChooser.scala` | 3 | `api.available_command.AvailableCommand` | |
| `ImproveQuestCommandChooser.scala` | 4 | `common.improvement_type.ImprovementType.{...}` | |
| `QuestCommandChooser.scala` | 4 | `api.available_command.AvailableCommand` | |
| `TruceCountQuestCommandChooser.scala` | 4 | `api.available_command.AvailableCommand` | |
| `TruceWithFactionQuestCommandChooser.scala` | 3 | `api.available_command.AvailableCommand` | |
#### util/view_filters/ (1 import, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ProvinceViewFilter.scala` | 3 | `common.province_event.ProvinceEvent` | For knownEvents field |
#### util/province/ (1 import, 1 file)
| File | Line | Import | Notes |
|------|------|--------|-------|
| `ProvinceUtils.scala` | 4 | `common.battalion_type.BattalionType` | Single proto reference |
---
### Cleanup Candidates (Priority Order)
1. **Delete proto overloads where Scala overloads exist and proto unused**
- `IncomingArmyUtils.scala` - verify all main callers use Scala overloads
- `ArmyUtils.scala` - verify all main callers use Scala overloads
2. **Move test-only utilities to test code**
- `IDable.scala` - only used by tests (except StartGameActionResultUtils)
3. **Create Scala versions of common enums**
- `DiplomacyOfferStatus` → already done in availability package
- `ImprovementType` → used by ImproveCommandSelector, CommandChoiceHelpers
- `BattalionTypeId` → used by multiple command selectors
4. **Large refactoring (requires Scala AvailableCommand/SelectedCommand)**
- `command_choice_helpers/` package (57 imports) - uses proto API command types
- Would need Scala versions of AvailableCommand and SelectedCommand hierarchies
+18
View File
@@ -130,6 +130,13 @@ bazel_dep(name = "apple_support", version = "1.21.1", repo_name = "build_bazel_a
bazel_dep(name = "rules_apple", version = "4.3.3", repo_name = "build_bazel_rules_apple")
bazel_dep(name = "rules_swift", version = "2.4.0", repo_name = "build_bazel_rules_swift")
# Register Apple CC toolchain for Objective-C compilation
apple_cc_configure = use_extension(
"@build_bazel_apple_support//crosstool:setup.bzl",
"apple_cc_configure_extension",
)
use_repo(apple_cc_configure, "local_config_apple_cc")
#
# Protocol Buffers & RPC
#
@@ -293,6 +300,17 @@ 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: DigitalOcean Spaces (public, reliable)
# Fallback: busybox.net (can be unreliable/slow)
+1 -1
View File
@@ -396,7 +396,7 @@
"@@apple_support~//crosstool:setup.bzl%apple_cc_configure_extension": {
"general": {
"bzlTransitiveDigest": "Z3yAd66IJL0GAZUTSeMOjoHiE1SZPPwiIs/XQui5BvE=",
"usagesDigest": "tl3VVeQX3Hzh7FhM2gjnkCwEJpRMlY5S6a850WY/xvc=",
"usagesDigest": "DqQsfZN5lA8z+nLEEY+EpKGzQ8M73mDm/A8lofDSyus=",
"recordedFileInputs": {},
"recordedDirentsInputs": {},
"envVariables": {},
-3
View File
@@ -9,9 +9,6 @@ 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"
+2 -2
View File
@@ -7,8 +7,8 @@ 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
+15 -2
View File
@@ -1,12 +1,25 @@
#!/bin/bash
#
# Persist Unity Library/ cache to persistent storage
#
# Environment variables:
# UNITY_CACHE_PLATFORM - Platform identifier (e.g., "mac", "windows")
# Defaults to "mac" if not set
#
# Note: Library/Bee/ is excluded because it contains DAG files with hardcoded
# file paths that become stale when project files change. This prevents
# "Data at the root level is invalid" XML errors from stale references.
set -uxo pipefail
/bin/echo "persist 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 src/main/csharp/net/eagle0/clients/unity/eagle0/Library/ /tmp/eagle0/Library/
/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
+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/
+198 -300
View File
@@ -33,7 +33,23 @@
---
## Current State
## Current State (January 2026)
### Summary
| Area | Proto Imports | Status |
|------|---------------|--------|
| AI (`/ai/`) | **0** | ✅ **Complete** |
| Actions (`/library/actions/impl/action/`) | **0** | ✅ **Complete** |
| Commands (`/library/actions/impl/command/`) | **0** | ✅ **Complete** |
| Availability (`/library/actions/availability/`) | **0** | ✅ **Complete** |
| Command Choice Helpers (`/library/util/command_choice_helpers/`) | **3** | ✅ Near Complete (1 file) |
| View Filters (`/library/util/view_filters/`) | **4** | ⏳ In progress (3 files) |
| LLM Prompt Generators (`/library/actions/llm_prompt_generators/`) | **56** | ⏳ Remaining work (34 files) |
| Other Utilities (`/library/util/`) | **13** | ⏳ Remaining work (5 files) |
| Root Library (`/library/`) | **9** | Boundary code (3 files) |
**Total: ~85 proto imports remaining** (down from 149)
### Completed Phases
@@ -43,346 +59,228 @@
| 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 5: Action Base Classes | **Complete** | All actions converted to T-type base classes |
| Phase 5b-d: RoundPhaseAdvancer | **Complete** | Uses Scala GameState throughout |
| Phase 6: Core Engine | **Complete** | ActionResultApplier, RandomStateSequencer protoless |
| Phase 6b: CommandFactory | **Complete** | Uses Scala SelectedCommand and AvailableCommand |
| Phase 6c: AI Clients | **Complete** | AIClient, command choosers all protoless |
| Phase 6d: CommandSelection | **Complete** | Renamed ScalaCommandSelection → CommandSelection |
### Phase 5c/5d Progress (Complete)
### Recent Completions (PRs #5326, #5330, #5332, #5333, #5334, #5336)
`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
1. **CommandFactory protoless** - Now accepts/returns Scala `SelectedCommand` types
2. **AI clients protoless** - `AIClient`, `MidGameAIClient`, all command choosers use Scala types
3. **CommandSelection cleanup** - Deleted legacy proto-based `CommandSelection`, renamed `ScalaCommandSelection``CommandSelection`
4. **AvailableCommandsFactory protoless** - Returns Scala `OneProvinceAvailableCommands`
5. **All CommandChoiceHelpers protoless** - ~25 selector/chooser files converted (only RansomOfferHelpers remains)
6. **ChronicleEventGenerator protoless** (PR #5332) - Removed last proto enum import from Actions layer
7. **ProvinceUtils protoless** (PR #5333) - Converted to use Scala `BattalionType`
8. **FactionViewFilter protoless** (PR #5334) - Returns Scala `FactionView`, converts to proto at edge
9. **HeroViewFilter protoless** (PR #5336) - Returns Scala `HeroView`, converts to proto at edge
---
## Phase 6: Migrate to ActionResultT Consumers
## Remaining Work
### Objective
### Phase 7: View Filters (~4 proto imports remaining in 3 files)
Eliminate internal consumption of `ActionResultProto`. Everything inside the engine should work with `ActionResultT`.
| File | Status | Migration Path |
|------|--------|----------------|
| `FactionViewFilter.scala` | ✅ **Complete** | Returns Scala `FactionView` (PR #5334) |
| `HeroViewFilter.scala` | ✅ **Complete** | Returns Scala `HeroView` (PR #5336) |
| `GameStateViewFilter.scala` | Partial | Converts views at edge; uses `FactionViewProto`, `GameStateView` |
| `ProvinceViewFilter.scala` | Partial | Uses `ProvinceEvent` proto for `knownEvents` field |
| `BattleFilter.scala` | Keep | Uses `ShardokBattleView` (boundary code for battles) |
### Current Flow (Proto-Heavy)
```
Action.execute()
→ ActionResultT
→ ActionResultProtoConverter.toProto()
→ ActionResultProto
→ ActionResultProtoApplierImpl.applyActionResults()
→ GameStateProto
→ GameStateConverter.fromProto()
→ GameStateC
```
**Pattern established**: Create Scala view type → Create converter → Update filter to return Scala type → Convert at edge in `GameStateViewFilter`.
### Target Flow (T-Types Throughout)
```
Action.execute()
→ ActionResultT
→ ActionResultApplier.applyActionResults()
→ GameStateC
### Phase 8: LLM Prompt Generators (~56 proto imports in 34 files)
(Proto conversion only at boundaries)
```
The LLM prompt generators still use proto types for hero/faction/province data:
### Key Files to Convert
| File Category | Files | Proto Usage |
|---------------|-------|-------------|
| Diplomacy prompts | 12 | `Hero`, `Faction`, `Province` protos |
| Quest prompts | 4 | `Hero`, `Faction` protos |
| Chronicle prompts | 3 | `Hero`, `Faction`, `Province` protos |
| Other prompts | 15 | Various proto types |
**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.
**Migration strategy**: These files generate text for LLM prompts. They can be migrated to accept Scala types (`HeroT`, `FactionT`, `ProvinceT`) with converters at the call sites if needed.
**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.
**Priority**: Medium - These don't block other migrations and are isolated.
**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.
### Phase 9: Remaining Utilities (~13 proto imports in 5 files)
**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.
| File | Proto Usage | Migration Path |
|------|-------------|----------------|
| `ProvinceEventUtils.scala` | 2 imports | Convert to Scala types |
| `MapGenerator.scala` | 1 import | Convert to Scala types |
| `IncomingArmyUtils.scala` | 1 import | Convert to Scala types |
| `GameStateViewDiffer.scala` | 7 imports | Keep - works with client view protos (boundary) |
| `StatWithConditionUtils.scala` | 2 imports | Convert to Scala types |
**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
### Phase 10: History APIs
**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
Change APIs to vend Scala `GameState` and `ActionResultT` instead of proto versions.
---
## Phase 7: Clean Up Legacy Utilities
## Architecture Summary
### Objective
Remove remaining direct proto imports from utility classes.
### Fully Protoless Areas ✅
### Files to Modify
- **AI layer** (`/ai/`) - All AI clients and command choosers
- **Actions** (`/library/actions/impl/action/`) - All 52 action files
- **Commands** (`/library/actions/impl/command/`) - CommandFactory and all commands
- **Availability** (`/library/actions/availability/`) - AvailableCommandsFactory and all factories
- **Command helpers** (`/library/util/command_choice_helpers/`) - All selectors and choosers (except RansomOfferHelpers)
- **Core types** - `CommandSelection`, `SelectedCommand`, `AvailableCommand`, `OneProvinceAvailableCommands`
- **View types** - `FactionView`, `HeroView` (return Scala, convert at edge)
| 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 |
### Proto at Boundaries (Expected) ✅
### 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
- `GameController.scala` - Client communication
- `GameStateViewFilter.scala` - Converts Scala views to proto for client
- `*Converter.scala` - Explicit conversion utilities
- `*Loader.scala` - File loading utilities
- `PersistedHistory.scala` - Disk persistence
### Expected No Proto Usage (Verify)
- `/library/actions/impl/` - Pure Scala models
- `/library/util/` - Pure Scala models (except loaders)
- `/model/state/` - Pure Scala models
### Remaining Proto Usage
- View filters (4 imports in 3 files) - In progress
- LLM prompt generators (56 imports in 34 files) - Medium priority
- Some utility files (13 imports in 5 files) - Low priority
- History API internals - Low priority
---
## Open Questions
## Estimated Remaining Effort
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.
| Component | Files | Imports | Priority |
|-----------|-------|---------|----------|
| View Filters | 3 | 4 | High |
| LLM Prompt Generators | 34 | 56 | Medium |
| Utility files | 5 | 13 | Low |
| Root Library (boundary) | 3 | 9 | Keep |
| **Total** | **45** | **~82** | |
---
## 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
- [x] Zero proto imports in `/ai/`
- [x] Zero proto imports in `/library/actions/impl/command/`
- [x] Zero proto imports in `/library/actions/availability/`
- [x] Zero proto imports in `/library/util/command_choice_helpers/` (except RansomOfferHelpers)
- [x] Zero proto imports in `/library/actions/impl/action/`
- [ ] Zero proto imports in `/library/util/view_filters/` (except BattleFilter, GameStateViewDiffer)
- [ ] Zero proto imports in `/library/actions/llm_prompt_generators/`
- [ ] Zero proto imports in `/library/util/` (except view filters)
### Architecture
- [ ] Clear separation: Scala models (internal) vs Proto (boundaries)
- [ ] Converters as the only bridge between domains
- [x] Clear separation: Scala models (internal) vs Proto (boundaries)
- [x] Converters as the only bridge between domains
- [x] CommandFactory accepts/returns Scala types
- [x] AI layer fully protoless
- [x] FactionViewFilter returns Scala types
- [x] HeroViewFilter returns Scala types
- [ ] All view filters return Scala types
- [ ] LLM layer uses Scala types
- [ ] No "proto creep" into business logic
---
## Open Questions
1. **LLM Prompt Generators**: Should these accept Scala types directly, or is it acceptable to have proto usage here since they're generating text (not core game logic)?
2. **GameStateViewDiffer**: This works with view protos for client updates. Should it remain proto-based since it's generating client-facing data?
3. **History Serialization**: Keep proto for persistence (good for schema evolution) or consider alternatives?
---
## Proto Import Inventory (Detailed)
**Current: ~85 proto imports across 45 files** (as of 2026-01-14)
### Summary by Directory
| Directory | Files | Imports | Status |
|-----------|-------|---------|--------|
| `ai/` | 0 | 0 | ✅ Clean |
| `actions/availability/` | 0 | 0 | ✅ Clean |
| `actions/impl/action/` | 0 | 0 | ✅ Clean |
| `actions/impl/command/` | 0 | 0 | ✅ Clean |
| `actions/llm_prompt_generators/` | 34 | 56 | LLM request types |
| `util/command_choice_helpers/` | 1 | 3 | Near complete |
| `util/view_filters/` | 3 | 4 | View boundary |
| `util/` (other) | 5 | 13 | Mixed |
| Root (`library/`) | 3 | 9 | Boundary code |
### util/command_choice_helpers/ (3 imports, 1 file)
| File | Import | Notes |
|------|--------|-------|
| `RansomOfferHelpers.scala` | `common.diplomacy_offer.RansomOfferDetails` | Diplomacy details |
| `RansomOfferHelpers.scala` | `common.diplomacy_offer_status.DiplomacyOfferStatus` | Status enum |
| `RansomOfferHelpers.scala` | `common.diplomacy_offer_status.DiplomacyOfferStatus.{...}` | Status variants |
### util/view_filters/ (4 imports, 3 files)
| File | Import | Notes |
|------|--------|-------|
| `ProvinceViewFilter.scala` | `common.province_event.ProvinceEvent` | For knownEvents field |
| `GameStateViewFilter.scala` | `views.faction_view.FactionView` | Output type |
| `GameStateViewFilter.scala` | `views.game_state_view.GameStateView` | Output type |
| `BattleFilter.scala` | `views.shardok_battle_view.{...}` | Battle view boundary |
### util/ other files (13 imports, 5 files)
| File | Imports | Notes |
|------|---------|-------|
| `GameStateViewDiffer.scala` | 7 | View diff for client (boundary code) |
| `ProvinceEventUtils.scala` | 2 | Province event handling |
| `StatWithConditionUtils.scala` | 2 | Stat condition handling |
| `MapGenerator.scala` | 1 | Map generation |
| `IncomingArmyUtils.scala` | 1 | Army utilities |
### Root library/ (9 imports, 3 files)
| File | Imports | Notes |
|------|---------|-------|
| `ActionResultFilter.scala` | 3 | Action result filtering (boundary) |
| `Engine.scala` | 1 | Trait interface |
| `EngineImpl.scala` | 5 | Returns proto for persistence (boundary) |
### actions/llm_prompt_generators/ (56 imports, 34 files)
These files generate LLM prompts and primarily use `internal.generated_text_request.*` types plus some common enums like `DiplomacyOfferStatus` and `BattalionTypeId`.
**Migration strategy**: Can be migrated to Scala types when convenient, but low priority as they're isolated from core game logic.
---
## 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`, 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
4. Delete `Legacy*` files when no longer needed
### Deleted Legacy Files (no production callers)
- `LegacyProvinceDistances`
- `LegacyBattalionSuitability`
- `LegacyFoodConsumptionUtils`
- `LegacyHandleRiotUtils`
+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"],
)
+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/"
+93
View File
@@ -0,0 +1,93 @@
#!/usr/bin/env bash
#
# Inject Sparkle framework into a macOS .app bundle for auto-updates
# Usage: inject_sparkle.sh <app_path>
#
# Environment variables (required):
# SPARKLE_EDDSA_PUBLIC_KEY - EdDSA public key for verifying updates
#
# Optional environment variables:
# SPARKLE_FEED_URL - Appcast URL (default: https://assets.eagle0.net/mac/appcast.xml)
# SPARKLE_VERSION - Sparkle version to use (default: 2.6.4)
set -euxo pipefail
APP_PATH="$1"
SPARKLE_VERSION="${SPARKLE_VERSION:-2.6.4}"
SPARKLE_FEED_URL="${SPARKLE_FEED_URL:-https://assets.eagle0.net/mac/appcast.xml}"
SPARKLE_CACHE_DIR="/tmp/sparkle-cache"
if [ ! -d "$APP_PATH" ]; then
echo "ERROR: App not found at $APP_PATH"
exit 1
fi
if [ -z "${SPARKLE_EDDSA_PUBLIC_KEY:-}" ]; then
echo "ERROR: SPARKLE_EDDSA_PUBLIC_KEY environment variable not set"
exit 1
fi
# Download Sparkle if not cached
SPARKLE_DIR="$SPARKLE_CACHE_DIR/Sparkle-$SPARKLE_VERSION"
if [ ! -d "$SPARKLE_DIR/Sparkle.framework" ]; then
echo "=== Downloading Sparkle $SPARKLE_VERSION ==="
mkdir -p "$SPARKLE_CACHE_DIR"
SPARKLE_URL="https://github.com/sparkle-project/Sparkle/releases/download/${SPARKLE_VERSION}/Sparkle-${SPARKLE_VERSION}.tar.xz"
curl -L "$SPARKLE_URL" | tar -xJ -C "$SPARKLE_CACHE_DIR"
mv "$SPARKLE_CACHE_DIR/Sparkle-$SPARKLE_VERSION" "$SPARKLE_DIR" 2>/dev/null || true
# If the extracted directory doesn't match version pattern, it may just be "Sparkle"
if [ ! -d "$SPARKLE_DIR" ]; then
mkdir -p "$SPARKLE_DIR"
mv "$SPARKLE_CACHE_DIR/Sparkle.framework" "$SPARKLE_DIR/" 2>/dev/null || true
mv "$SPARKLE_CACHE_DIR/bin" "$SPARKLE_DIR/" 2>/dev/null || true
fi
fi
echo "=== Injecting Sparkle framework ==="
FRAMEWORKS_DIR="$APP_PATH/Contents/Frameworks"
mkdir -p "$FRAMEWORKS_DIR"
# Copy Sparkle framework
cp -R "$SPARKLE_DIR/Sparkle.framework" "$FRAMEWORKS_DIR/"
# Also copy the XPC services if present
if [ -d "$SPARKLE_DIR/Sparkle.framework/Versions/B/XPCServices" ]; then
echo "Sparkle XPC services present"
fi
echo "=== Updating Info.plist ==="
PLIST_PATH="$APP_PATH/Contents/Info.plist"
# Add Sparkle configuration to Info.plist
/usr/libexec/PlistBuddy -c "Delete :SUFeedURL" "$PLIST_PATH" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :SUFeedURL string '$SPARKLE_FEED_URL'" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Delete :SUPublicEDKey" "$PLIST_PATH" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string '$SPARKLE_EDDSA_PUBLIC_KEY'" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Delete :SUEnableAutomaticChecks" "$PLIST_PATH" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :SUEnableAutomaticChecks bool true" "$PLIST_PATH"
# Set bundle version from git for Sparkle version comparison
VERSION=$(git describe --tags --always 2>/dev/null || echo "1.0.0")
BUILD_NUMBER=$(git rev-list --count HEAD 2>/dev/null || echo "1")
echo "Setting version: $VERSION (build $BUILD_NUMBER)"
/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString $VERSION" "$PLIST_PATH" 2>/dev/null || \
/usr/libexec/PlistBuddy -c "Add :CFBundleShortVersionString string '$VERSION'" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Set :CFBundleVersion $BUILD_NUMBER" "$PLIST_PATH" 2>/dev/null || \
/usr/libexec/PlistBuddy -c "Add :CFBundleVersion string '$BUILD_NUMBER'" "$PLIST_PATH"
# Add URL scheme for invitation codes (eagle0://invite?code=XXXX)
echo "=== Adding URL scheme for invitation codes ==="
/usr/libexec/PlistBuddy -c "Delete :CFBundleURLTypes" "$PLIST_PATH" 2>/dev/null || true
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes array" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0 dict" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLName string 'com.Shardok-Games.eagle0'" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes array" "$PLIST_PATH"
/usr/libexec/PlistBuddy -c "Add :CFBundleURLTypes:0:CFBundleURLSchemes:0 string 'eagle0'" "$PLIST_PATH"
echo "=== Sparkle injection complete ==="
echo "App: $APP_PATH"
echo "Feed URL: $SPARKLE_FEED_URL"
echo "Version: $VERSION (build $BUILD_NUMBER)"
@@ -1,8 +0,0 @@
fileFormatVersion: 2
guid: 6ddb90d3140384d50a98a19e72539b62
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,221 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using common;
using TMPro;
using UnityEngine;
using UnityGoDiceInterface;
public class DiceConfigurationPanelController : MonoBehaviour {
private DiceInterface _diceInterface;
public EventBasedTable diceRollGroup;
public PickerRowController pickerRowPrefab;
public TMP_Text instructionsLabel;
public delegate void ConfigurationCallback(DieInfo? tensDieInfo, DieInfo? onesDieInfo);
private ConfigurationCallback _configurationCallback;
private DieInfo? _tensDieInfo = null;
private DieInfo? _onesDieInfo = null;
private List<DieInfo> _knownDice = new List<DieInfo>();
private HashSet<string> _connectingIdentifiers = new HashSet<string>();
void Awake() {}
public void GetConfiguration(ConfigurationCallback cb) {
if (_diceInterface == null) { _diceInterface = GetComponentInParent<DiceInterface>(); }
_diceInterface.deviceFoundCallback = DeviceFoundCallback;
_diceInterface.connectionCallback = ConnectionCallback;
_diceInterface.connectionFailedCallback = ConnectionFailedCallback;
_diceInterface.disconnectionCallback = DisconnectionCallback;
_diceInterface.listenerStoppedCallback = ListenerStoppedCallback;
_diceInterface.colorCallback = ReceiveColorCallback;
string currentPath = NewLogLocation();
Directory.CreateDirectory(Path.GetDirectoryName(currentPath));
_diceInterface.logger = log => {
MainQueue.Q.Enqueue(() => {
Debug.Log(log);
File.AppendAllText(currentPath, log);
});
};
_diceInterface.StartListening();
_configurationCallback = cb;
_tensDieInfo = null;
_onesDieInfo = null;
instructionsLabel.text = "Looking for dice...";
gameObject.SetActive(true);
}
string NewLogLocation() {
return Path.Combine(
Application.persistentDataPath,
"eagle0",
"Resources",
"DiceConfigurationLogs",
DateTime.Now.ToString("yyyy-MM-dd-HH-mm-ss") + ".log");
}
public void RowClicked(int rowIndex) {
if (_tensDieInfo == null) {
_tensDieInfo = _knownDice[rowIndex];
_knownDice.RemoveAt(rowIndex);
SetUpTable();
} else {
_diceInterface.StopListening();
_onesDieInfo = _knownDice[rowIndex];
_knownDice.RemoveAt(rowIndex);
_knownDice.ForEach(dieInfo => _diceInterface.Disconnect(dieInfo.identifier));
// _diceInterface.deviceFoundCallback = null;
// _diceInterface.connectionCallback = null;
// _diceInterface.disconnectionCallback = null;
// _diceInterface.listenerStoppedCallback = null;
// _diceInterface.rollCallback = null;
// _diceInterface.colorCallback = null;
_configurationCallback(_tensDieInfo, _onesDieInfo);
gameObject.SetActive(false);
}
}
private void OnEnable() {}
private void OnDisable() { diceRollGroup.RowCount = 0; }
void ListenerStoppedCallback() { Debug.Log("Listener stopped!"); }
void SetUpTable() {
diceRollGroup.RowCount = _knownDice.Count;
if (_knownDice.Count == 0) {
instructionsLabel.text = "Looking for dice...";
} else if (_tensDieInfo == null) {
instructionsLabel.text = "Select a TENS die:";
} else {
instructionsLabel.text = "Select an ONES die:";
}
for (int i = 0; i < _knownDice.Count; i++) {
var row = diceRollGroup.ComponentAt<PickerRowController>(i);
row.Text = _knownDice[i].deviceName;
row.TextColor = UnityDieColors.ColorFromDieColor(_knownDice[i].color);
}
}
void ReceiveColorCallback(string identifier, DieColor dieColor) {
MainQueue.Q.Enqueue(() => {
var existingIndex =
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
if (existingIndex >= 0) {
var colorName = dieColor.ToString();
_knownDice[existingIndex] = new DieInfo(
identifier: _knownDice[existingIndex].identifier,
deviceName: colorName,
color: dieColor,
connected: true);
} else {
Debug.Log("This shouldn't happen");
}
SetUpTable();
});
}
void DeviceFoundCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
if (_connectingIdentifiers.Contains(identifier)) { return; }
_connectingIdentifiers.Add(identifier);
var existingIndex =
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
if (existingIndex >= 0) {
_knownDice[existingIndex] =
new DieInfo(identifier, "Unknown", DieColor.DieColorBlack);
} else {
_knownDice.Add(new DieInfo(identifier, "Unknown", DieColor.DieColorBlack));
}
SetUpTable();
_diceInterface.Connect(identifier);
});
}
void ConnectionCallback(string identifier, string deviceName) {
MainQueue.Q.Enqueue(() => {
_connectingIdentifiers.Remove(identifier);
if (_tensDieInfo != null && _tensDieInfo.Value.identifier.Equals(identifier)) {
return;
}
if (_onesDieInfo != null && _onesDieInfo.Value.identifier.Equals(identifier)) {
return;
}
var shortenedName = deviceName.Split('_')[1];
var existingIndex =
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
if (existingIndex >= 0) {
_knownDice[existingIndex] =
new DieInfo(identifier, shortenedName, DieColor.DieColorBlack);
} else {
_knownDice.Add(new DieInfo(identifier, shortenedName, DieColor.DieColorBlack));
}
SetUpTable();
_diceInterface.RequestColor(identifier);
});
}
void ConnectionFailedCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
_connectingIdentifiers.Remove(identifier);
var existingIndex =
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
if (existingIndex >= 0) {
_knownDice.RemoveAt(existingIndex);
SetUpTable();
}
});
}
void DisconnectionCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
_connectingIdentifiers.Remove(identifier);
if (_tensDieInfo is {} tensInfo && tensInfo.identifier.Equals(identifier)) {
_tensDieInfo = null;
_onesDieInfo = null;
} else if (_onesDieInfo is {} onesInfo && onesInfo.identifier.Equals(identifier)) {
_onesDieInfo = null;
}
var existingIndex =
_knownDice.FindIndex(dieInfo => dieInfo.identifier.Equals(identifier));
if (existingIndex >= 0) { _knownDice.RemoveAt(existingIndex); }
SetUpTable();
_diceInterface.Connect(identifier);
});
}
public void DismissButtonClicked() {
gameObject.SetActive(false);
_configurationCallback(null, null);
}
// Update is called once per frame
void Update() {}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: e694fb18ea1df4034b392352bf3aa04a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,213 +0,0 @@
#if UNITY_IOS || UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
#define USE_SWIFT_INTERFACE
#elif UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN || ENABLE_WINMD_SUPPORT
#define USE_WINDOWS_INTERFACE
#else
#endif
using System.Collections.Generic;
using UnityEngine;
using UnityGoDiceInterface;
public class DiceInterface : MonoBehaviour {
public struct Commands {
public static readonly byte PulseLed = 16;
public static readonly byte GetColor = 23;
}
readonly NativeDiceInterfaceImports _diceInterfaceImports = new();
private static DiceInterface _singleton = null;
private static Dictionary<string, string> _deviceNames = new Dictionary<string, string>();
public delegate void DeviceFoundCallback(string identifier);
public delegate void ConnectionCallback(string identifier, string deviceName);
public delegate void ConnectionFailedCallback(string identifier);
public delegate void DisconnectionCallback(string identifier);
public delegate void ListenerStoppedCallback();
public delegate void RollCallback(string identifier, sbyte x, sbyte y, sbyte z);
public delegate void ColorCallback(string identifier, DieColor color);
public delegate void LoggerCallback(string log);
public DeviceFoundCallback deviceFoundCallback = null;
public ConnectionCallback connectionCallback = null;
public ConnectionFailedCallback connectionFailedCallback = null;
public DisconnectionCallback disconnectionCallback = null;
public ListenerStoppedCallback listenerStoppedCallback = null;
public RollCallback rollCallback = null;
public ColorCallback colorCallback = null;
public LoggerCallback logger = null;
public void StartListening() { _diceInterfaceImports.StartListening(); }
public void StopListening() { _diceInterfaceImports.StopListening(); }
public void Connect(string identifier) { _diceInterfaceImports.Connect(identifier); }
public void Disconnect(string identifier) { _diceInterfaceImports.Disconnect(identifier); }
public void Reset() {
_diceInterfaceImports.Reset();
_deviceNames.Clear();
}
public void RequestColor(string identifier) {
_diceInterfaceImports.Send(identifier, new List<byte> { Commands.GetColor });
}
public void FlashColor(
string identifier,
byte pulseCount,
byte onTime10ms,
byte offTime10ms,
byte red,
byte green,
byte blue) {
_diceInterfaceImports.Send(
identifier,
new List<byte> {
Commands.PulseLed,
pulseCount,
onTime10ms,
offTime10ms,
red,
green,
blue,
0x01,
0x00
});
}
// Start is called before the first frame update
void Start() {
_singleton = this;
_diceInterfaceImports.SetDeviceConnectedCallback(DeviceConnectedDelegateMessageReceived);
_diceInterfaceImports.SetDeviceConnectionFailedCallback(
DeviceConnectionFailedDelegateMessageReceived);
_diceInterfaceImports.SetDeviceDisconnectedCallback(
DeviceDisconnectedDelegateMessageReceived);
_diceInterfaceImports.SetDeviceFoundCallback(DeviceFoundDelegateMessageReceived);
_diceInterfaceImports.SetDataCallback(DataDelegateMessageReceived);
_diceInterfaceImports.SetListenerStoppedCallback(ListenerStoppedDelegateMessageReceived);
_diceInterfaceImports.SetLoggerCallback(LoggerDelegateMessageReceived);
}
// Update is called once per frame
void Update() {}
private static sbyte[] GetRollVector(List<byte> rawData) {
if (rawData.Count < 1) {
Debug.Log("rollVector: no data");
return null;
}
byte firstByte = rawData[0];
if (firstByte != 83) {
Debug.Log("rollVector: first byte is not 83");
return null;
}
if (rawData.Count != 4) {
Debug.Log("rollVector: data length is not 4");
return null;
}
return new[] { (sbyte)rawData[1], (sbyte)rawData[2], (sbyte)rawData[3] };
}
private static void DeviceFoundDelegateMessageReceived(string identifier, string deviceName) {
if (_singleton != null) {
_deviceNames[identifier] = deviceName;
if (_singleton.deviceFoundCallback != null) {
_singleton.deviceFoundCallback(identifier);
}
}
}
private static void DeviceConnectedDelegateMessageReceived(string identifier) {
if (_singleton != null && _singleton.connectionCallback != null) {
_singleton.connectionCallback(identifier, _deviceNames[identifier]);
}
}
private static void DeviceConnectionFailedDelegateMessageReceived(string identifier) {
if (_singleton != null && _singleton.connectionFailedCallback != null) {
_singleton.connectionFailedCallback(identifier);
}
}
private static void DeviceDisconnectedDelegateMessageReceived(string identifier) {
if (_singleton != null && _singleton.disconnectionCallback != null) {
_singleton.disconnectionCallback(identifier);
}
}
private static void DataDelegateMessageReceived(string identifier, List<byte> byteList) {
if (_singleton != null) {
if (byteList.Count == 0) {
if (_singleton.connectionCallback != null) {
// I don't think this one should still happen
return;
}
} else {
byte firstByte = byteList[0];
switch (firstByte) {
case 82:
// Roll started
break;
case 66:
// Battery level
break;
case (byte)'C':
if (byteList[1] == (byte)'o' && byteList[2] == (byte)'l') {
byte colorRawValue = byteList[3];
_singleton.colorCallback(identifier, (DieColor)colorRawValue);
}
// Color (fetched)
break;
case 83: {
var roll = GetRollVector(byteList);
if (roll != null && _singleton.rollCallback != null) {
_singleton.rollCallback(identifier, roll[0], roll[1], roll[2]);
}
break;
}
case 70:
case 84:
case 77: {
byteList.RemoveAt(0);
var roll = GetRollVector(byteList);
if (roll != null && _singleton.rollCallback != null) {
_singleton.rollCallback(identifier, roll[0], roll[1], roll[2]);
}
break;
}
default: Debug.Log("Not yet handled"); break;
}
}
}
}
private static void ListenerStoppedDelegateMessageReceived() {
if (_singleton != null && _singleton.listenerStoppedCallback != null) {
_singleton.listenerStoppedCallback();
}
}
private static void LoggerDelegateMessageReceived(string log) {
if (_singleton != null && _singleton.logger != null) { _singleton.logger(log); }
}
public void OnDestroy() {
_diceInterfaceImports.SetDeviceConnectedCallback(null);
_diceInterfaceImports.SetDeviceConnectionFailedCallback(null);
_diceInterfaceImports.SetDeviceDisconnectedCallback(null);
_diceInterfaceImports.SetDeviceFoundCallback(null);
_diceInterfaceImports.SetDataCallback(null);
_diceInterfaceImports.SetListenerStoppedCallback(null);
_diceInterfaceImports.SetLoggerCallback(null);
#if UNITY_EDITOR
StopListening();
Reset();
#endif
}
}
@@ -1,204 +0,0 @@
using System.Collections.Generic;
namespace UnityGoDiceInterface {
public class DiceVectors {
public struct Vector3 {
public sbyte x;
public sbyte y;
public sbyte z;
public Vector3(sbyte x, sbyte y, sbyte z) {
this.x = x;
this.y = y;
this.z = z;
}
}
public static int D6Value(sbyte x, sbyte y, sbyte z) {
return DieValueForVector(d6, new Vector3(x, y, z));
}
public static int D10Value(sbyte x, sbyte y, sbyte z) {
return d10Transform[DieValueForVector(d20, new Vector3(x, y, z))];
}
private static int sq(int x) { return x * x; }
private static int DieValueForVector(List<Vector3> table, Vector3 vector) {
var closestIndex = -1;
var closestDistance = int.MaxValue;
for (int i = 0; i < table.Count; i++) {
var entry = table[i];
var distance =
sq(entry.x - vector.x) + sq(entry.y - vector.y) + sq(entry.z - vector.z);
if (distance < closestDistance) {
closestDistance = distance;
closestIndex = i;
}
}
return closestIndex + 1;
}
// The private vectors are 0-based, so we need to add 1 to the index
private static List<Vector3> d6 = new List<Vector3> {
new Vector3(-64, 0, 0),
new Vector3(-0, 0, 64),
new Vector3(0, 64, 0),
new Vector3(0, -64, 0),
new Vector3(0, 0, -64),
new Vector3(64, 0, 0)
};
private static List<Vector3> d20 = new List<Vector3> {
new Vector3(-64, 0, -22), new Vector3(42, -42, 40), new Vector3(0, 22, -64),
new Vector3(0, 22, 64), new Vector3(-42, -42, 42), new Vector3(22, 64, 0),
new Vector3(-42, -42, -42), new Vector3(64, 0, -22), new Vector3(-22, 64, 0),
new Vector3(42, -42, -42), new Vector3(-42, 42, 42), new Vector3(22, -64, 0),
new Vector3(-64, 0, 22), new Vector3(42, 42, 42), new Vector3(-22, -64, 0),
new Vector3(42, 42, -42), new Vector3(0, -22, -64), new Vector3(0, -22, 64),
new Vector3(-42, 42, -42), new Vector3(64, 0, 22),
};
// static d24Vectors = {
// new Vector3(20, -60, -20),
// new Vector3(20, 0, 60),
// new Vector3(-40, -40, 40),
// new Vector3(-60, 0, 20),
// new Vector3(40, 20, 40),
// new Vector3(-20, -60, -20),
// new Vector3(20, 60, 20),
// new Vector3(-40, 20, -40),
// new Vector3(-40, 40, 40),
// new Vector3(-20, 0, 60),
// new Vector3(-20, -60, 20),
// new Vector3(60, 0, 20),
// new Vector3(-60, 0, -20),
// new Vector3(20, 60, -20),
// new Vector3(20, 0, -60),
// new Vector3(40, -20, -40),
// new Vector3(-20, 60, -20),
// new Vector3(-40, -40, -40),
// new Vector3(40, -20, 40),
// new Vector3(20, -60, 20),
// new Vector3(60, 0, -20),
// new Vector3(40, 20, -40),
// new Vector3(-20, 0, -60),
// new Vector3(-20, 60, 20),
// }
//
// Transforms from each shell type to according number on shell
// D20 Transforms
private static Dictionary<int, int> d10Transform = new Dictionary<int, int> {
{ 1, 8 }, { 2, 2 }, { 3, 6 }, { 4, 1 }, { 5, 4 }, { 6, 3 }, { 7, 9 },
{ 8, 0 }, { 9, 7 }, { 10, 5 }, { 11, 5 }, { 12, 7 }, { 13, 0 }, { 14, 9 },
{ 15, 3 }, { 16, 4 }, { 17, 1 }, { 18, 6 }, { 19, 2 }, { 20, 8 },
};
//
// static d10XTransform = {
// 1: 80,
// 2: 20,
// 3: 60,
// 4: 10,
// 5: 40,
// 6: 30,
// 7: 90,
// 8: 0,
// 9: 70,
// 10: 50,
// 11: 50,
// 12: 70,
// 13: 0,
// 14: 90,
// 15: 30,
// 16: 40,
// 17: 10,
// 18: 60,
// 19: 20,
// 20: 80,
// }
//
// // D24 Transforms
// static d4Transform = {
// 1: 3,
// 2: 1,
// 3: 4,
// 4: 1,
// 5: 4,
// 6: 4,
// 7: 1,
// 8: 4,
// 9: 2,
// 10: 3,
// 11: 1,
// 12: 1,
// 13: 1,
// 14: 4,
// 15: 2,
// 16: 3,
// 17: 3,
// 18: 2,
// 19: 2,
// 20: 2,
// 21: 4,
// 22: 1,
// 23: 3,
// 24: 2,
// }
//
// static d8Transform = {
// 1: 3,
// 2: 3,
// 3: 6,
// 4: 1,
// 5: 2,
// 6: 8,
// 7: 1,
// 8: 1,
// 9: 4,
// 10: 7,
// 11: 5,
// 12: 5,
// 13: 4,
// 14: 4,
// 15: 2,
// 16: 5,
// 17: 7,
// 18: 7,
// 19: 8,
// 20: 2,
// 21: 8,
// 22: 3,
// 23: 6,
// 24: 6,
// }
//
// static d12Transform = {
// 1: 1,
// 2: 2,
// 3: 3,
// 4: 4,
// 5: 5,
// 6: 6,
// 7: 7,
// 8: 8,
// 9: 9,
// 10: 10,
// 11: 11,
// 12: 12,
// 13: 1,
// 14: 2,
// 15: 3,
// 16: 4,
// 17: 5,
// 18: 6,
// 19: 7,
// 20: 8,
// 21: 9,
// 22: 10,
// 23: 11,
// 24: 12,
// }
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: e9d67d9a2f8145a999d04de91c27073d
timeCreated: 1702612480
@@ -1,28 +0,0 @@
namespace UnityGoDiceInterface {
public enum DieColor : byte {
DieColorBlack = 0,
DieColorRed = 1,
DieColorGreen = 2,
DieColorBlue = 3,
DieColorYellow = 4,
DieColorOrange = 5,
}
public struct DieInfo {
public string identifier;
public string deviceName;
public DieColor color;
public bool connected;
public DieInfo(
string identifier,
string deviceName,
DieColor color,
bool connected = false) {
this.identifier = identifier;
this.deviceName = deviceName;
this.color = color;
this.connected = connected;
}
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: 2e1000782bb845399869ca7910eebcd6
timeCreated: 1703172326
@@ -1,177 +0,0 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using AOT;
namespace UnityGoDiceInterface {
public class NativeDiceInterfaceImports {
public delegate void DeviceFoundDelegateMessage(string identifier, string name);
public delegate void DataDelegateMessage(string identifier, List<byte> bytes);
public delegate void DeviceConnectedDelegateMessage(string identifier);
public delegate void DeviceConnectionFailedDelegateMessage(string identifier);
public delegate void DeviceDisconnectedDelegateMessage(string identifier);
public delegate void ListenerStoppedDelegateMessage();
public delegate void LoggerDelegateMessage(string log);
private static DeviceFoundDelegateMessage deviceFoundDelegate;
private static DataDelegateMessage dataDelegate;
private static DeviceConnectedDelegateMessage deviceConnectedDelegate;
private static DeviceConnectionFailedDelegateMessage deviceConnectionFailedDelegate;
private static DeviceDisconnectedDelegateMessage deviceDisconnectedDelegate;
private static ListenerStoppedDelegateMessage listenerStoppedDelegate;
private static LoggerDelegateMessage loggerDelegateMessage;
#if UNITY_IOS
private const string BundleName = "__Internal";
#elif UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
private const string BundleName = "DarwinGodiceBundle";
#elif UNITY_STANDALONE_WIN || UNITY_EDITOR_WIN
private const string BundleName = "GoDiceDll.dll";
#endif
private delegate void MonoDeviceFoundDelegateMessage(string identifier, string name);
private delegate void
MonoDataDelegateMessage(string identifier, UInt32 byteCount, IntPtr bytePtr);
private delegate void MonoDeviceConnectedDelegateMessage(string identifier);
private delegate void MonoDeviceConnectionFailedDelegateMessage(string identifier);
private delegate void MonoDeviceDisconnectedDelegateMessage(string identifier);
private delegate void MonoListenerStoppedDelegateMessage();
private delegate void MonoLoggerDelegateMessage(string log);
[DllImport(dllName: BundleName, EntryPoint = "godice_start_listening")]
private static extern void _NativeBridgeStartListening();
[DllImport(dllName: BundleName, EntryPoint = "godice_stop_listening")]
private static extern void _NativeBridgeStopListening();
[DllImport(dllName: BundleName, EntryPoint = "godice_set_callbacks")]
private static extern void _NativeBridgeSetCallbacks(
MonoDeviceFoundDelegateMessage deviceFoundDelegate,
MonoDataDelegateMessage dataDelegate,
MonoDeviceConnectedDelegateMessage deviceConnectedDelegate,
MonoDeviceConnectionFailedDelegateMessage deviceConnectionFailedDelegateMessage,
MonoDeviceDisconnectedDelegateMessage deviceDisconnectedDelegate,
MonoListenerStoppedDelegateMessage listenerStoppedDelegate);
[DllImport(dllName: BundleName, EntryPoint = "godice_connect")]
private static extern void _NativeBridgeConnect(string identifier);
[DllImport(dllName: BundleName, EntryPoint = "godice_disconnect")]
private static extern void _NativeBridgeDisconnect(string identifier);
[DllImport(dllName: BundleName, EntryPoint = "godice_send")]
private static extern void
_NativeBridgeSend(string identifier, UInt32 byteCount, IntPtr bytePtr);
[DllImport(dllName: BundleName, EntryPoint = "godice_set_logger")]
private static extern void _NativeBridgeSetLogger(MonoLoggerDelegateMessage loggerDelegate);
[DllImport(dllName: BundleName, EntryPoint = "godice_reset")]
private static extern void _NativeBridgeReset();
private static List<byte> BytesFromRawPointer(UInt32 byteCount, IntPtr bytes) {
byte[] array = new byte[byteCount];
if (byteCount > 0) { Marshal.Copy(bytes, array, 0, (int)byteCount); }
return new List<byte>(array);
}
/*
* Callback methods
*/
[MonoPInvokeCallback(typeof(MonoDeviceFoundDelegateMessage))]
private static void MonoDeviceFoundMessageReceived(string identifier, string name) {
if (deviceFoundDelegate != null) { deviceFoundDelegate(identifier, name); }
}
[MonoPInvokeCallback(typeof(MonoDataDelegateMessage))]
private static void
MonoDataMessageReceived(string identifier, UInt32 byteCount, IntPtr bytePtr) {
if (dataDelegate != null) {
dataDelegate(identifier, BytesFromRawPointer(byteCount, bytePtr));
}
}
[MonoPInvokeCallback(typeof(MonoDeviceConnectedDelegateMessage))]
private static void MonoDeviceConnected(string identifier) {
if (deviceConnectedDelegate != null) { deviceConnectedDelegate(identifier); }
}
[MonoPInvokeCallback(typeof(MonoDeviceConnectionFailedDelegateMessage))]
private static void MonoDeviceConnectionFailed(string identifier) {
if (deviceConnectionFailedDelegate != null) {
deviceConnectionFailedDelegate(identifier);
}
}
[MonoPInvokeCallback(typeof(MonoDeviceDisconnectedDelegateMessage))]
private static void MonoDeviceDisconnected(string identifier) {
if (deviceDisconnectedDelegate != null) { deviceDisconnectedDelegate(identifier); }
}
[MonoPInvokeCallback(typeof(MonoListenerStoppedDelegateMessage))]
private static void MonoListenerStopped() {
if (listenerStoppedDelegate != null) { listenerStoppedDelegate(); }
}
[MonoPInvokeCallback(typeof(MonoLoggerDelegateMessage))]
private static void MonoLogger(string log) {
if (loggerDelegateMessage != null) { loggerDelegateMessage(log); }
}
public void StartListening() {
_NativeBridgeSetCallbacks(
MonoDeviceFoundMessageReceived,
MonoDataMessageReceived,
MonoDeviceConnected,
MonoDeviceConnectionFailed,
MonoDeviceDisconnected,
MonoListenerStopped);
_NativeBridgeSetLogger(MonoLogger);
_NativeBridgeStartListening();
}
public void StopListening() { _NativeBridgeStopListening(); }
public void Connect(string identifier) { _NativeBridgeConnect(identifier); }
public void Disconnect(string identifier) { _NativeBridgeDisconnect(identifier); }
public void Send(string identifier, List<byte> data) {
byte[] byteArray = data.ToArray();
GCHandle pinnedArray = GCHandle.Alloc(byteArray, GCHandleType.Pinned);
IntPtr pointer = pinnedArray.AddrOfPinnedObject();
_NativeBridgeSend(identifier, (uint)data.Count, pointer);
pinnedArray.Free();
}
public void SetDeviceFoundCallback(DeviceFoundDelegateMessage deviceFound) {
deviceFoundDelegate = deviceFound;
}
public void SetDataCallback(DataDelegateMessage data) { dataDelegate = data; }
public void SetDeviceConnectedCallback(DeviceConnectedDelegateMessage deviceConnected) {
deviceConnectedDelegate = deviceConnected;
}
public void SetDeviceConnectionFailedCallback(
DeviceConnectionFailedDelegateMessage deviceConnectionFailed) {
deviceConnectionFailedDelegate = deviceConnectionFailed;
}
public void SetDeviceDisconnectedCallback(
DeviceDisconnectedDelegateMessage deviceDisconnected) {
deviceDisconnectedDelegate = deviceDisconnected;
}
public void SetListenerStoppedCallback(ListenerStoppedDelegateMessage listenerStopped) {
listenerStoppedDelegate = listenerStopped;
}
public void SetLoggerCallback(LoggerDelegateMessage logger) {
loggerDelegateMessage = logger;
}
public void Reset() { _NativeBridgeReset(); }
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: 424476bb5b18c47039d649c28a58a8fa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,447 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &2154262805260441194
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 7181142147825592926}
- component: {fileID: 4917217417751722011}
- component: {fileID: 8876282893591316834}
- component: {fileID: 1837103631940551462}
- component: {fileID: 7123394496182663843}
m_Layer: 0
m_Name: OneDiceResult
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &7181142147825592926
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2154262805260441194}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 145111913694346175}
- {fileID: 6185631331142294830}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &4917217417751722011
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2154262805260441194}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 59f8146938fff824cb5fd77236b75775, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Padding:
m_Left: 0
m_Right: 0
m_Top: 0
m_Bottom: 0
m_ChildAlignment: 4
m_Spacing: 0
m_ChildForceExpandWidth: 0
m_ChildForceExpandHeight: 0
m_ChildControlWidth: 1
m_ChildControlHeight: 1
m_ChildScaleWidth: 0
m_ChildScaleHeight: 0
m_ReverseArrangement: 0
--- !u!114 &8876282893591316834
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2154262805260441194}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!114 &1837103631940551462
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2154262805260441194}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 680b7d1179eb34a0fa338286236db2e9, type: 3}
m_Name:
m_EditorClassIdentifier:
nameLabel: {fileID: 9015301821484574050}
d6Label: {fileID: 0}
d10Label: {fileID: 4483020407950697354}
--- !u!222 &7123394496182663843
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2154262805260441194}
m_CullTransparentMesh: 1
--- !u!1 &6943825177879701675
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 6185631331142294830}
- component: {fileID: 4032026062387785650}
- component: {fileID: 4483020407950697354}
- component: {fileID: 742703408719749652}
- component: {fileID: 4292989854150447437}
m_Layer: 0
m_Name: result
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &6185631331142294830
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6943825177879701675}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 7181142147825592926}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &4032026062387785650
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6943825177879701675}
m_CullTransparentMesh: 1
--- !u!114 &4483020407950697354
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6943825177879701675}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: 9
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4278190080
m_fontColor: {r: 0, g: 0, b: 0, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 48
m_fontSizeBase: 48
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!114 &742703408719749652
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6943825177879701675}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!114 &4292989854150447437
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6943825177879701675}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &8198927819804470251
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 145111913694346175}
- component: {fileID: 8510898822381381753}
- component: {fileID: 9015301821484574050}
- component: {fileID: 4918688670369599320}
m_Layer: 0
m_Name: Name
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &145111913694346175
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8198927819804470251}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 7181142147825592926}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &8510898822381381753
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8198927819804470251}
m_CullTransparentMesh: 1
--- !u!114 &9015301821484574050
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8198927819804470251}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: 'Tens
'
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4278190080
m_fontColor: {r: 0, g: 0, b: 0, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 16
m_fontSizeBase: 16
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!114 &4918688670369599320
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8198927819804470251}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: 200
m_MinHeight: -1
m_PreferredWidth: -1
m_PreferredHeight: -1
m_FlexibleWidth: 1
m_FlexibleHeight: 1
m_LayoutPriority: 1
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 9acf3ba64a8c04e6083aae699248b0c4
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,32 +0,0 @@
using TMPro;
public class OneDiceRollController : TableRowController {
private string _identifier;
public TMP_Text nameLabel;
public TMP_Text d6Label;
public TMP_Text d10Label;
// Start is called before the first frame update
void Start() {}
// Update is called once per frame
void Update() {}
public void SetUp(string identifier, string deviceName) {
_identifier = identifier;
nameLabel.text = deviceName;
d6Label.text = "?";
d10Label.text = "?";
}
public void SetRoll(string identifier, string d6, string d10) {
_identifier = identifier;
d6Label.text = d6;
d10Label.text = d10;
}
public string GetIdentifier() { return _identifier; }
}
@@ -1,281 +0,0 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &3756143511112798868
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 5260824885446700081}
- component: {fileID: 757087991837136050}
- component: {fileID: 8474069203316727232}
m_Layer: 0
m_Name: Text (TMP)
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &5260824885446700081
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3756143511112798868}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 3793282440965013679}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 200, y: 50}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &757087991837136050
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3756143511112798868}
m_CullTransparentMesh: 1
--- !u!114 &8474069203316727232
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 3756143511112798868}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: Orange
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: ec2736df0379a45bfa7349b652fd07d6, type: 2}
m_sharedMaterial: {fileID: 1026247389042360723, guid: ec2736df0379a45bfa7349b652fd07d6,
type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4278229493
m_fontColor: {r: 0.9607843, g: 0.6, b: 0, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 18
m_fontSizeBase: 18
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_enableWordWrapping: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 1
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!1 &5800426311821892848
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 3793282440965013679}
- component: {fileID: 6926687573176944590}
- component: {fileID: -2050598564750628712}
- component: {fileID: 2781819584472077043}
- component: {fileID: -2772541110306127347}
- component: {fileID: 5171470299130298381}
m_Layer: 0
m_Name: PickerRow
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &3793282440965013679
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 5260824885446700081}
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 0, y: 0}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 200, y: 50}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!222 &6926687573176944590
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
m_CullTransparentMesh: 1
--- !u!114 &-2050598564750628712
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 306cc8c2b49d7114eaa3623786fc2126, type: 3}
m_Name:
m_EditorClassIdentifier:
m_IgnoreLayout: 0
m_MinWidth: -1
m_MinHeight: 30
m_PreferredWidth: -1
m_PreferredHeight: 30
m_FlexibleWidth: 1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!114 &2781819584472077043
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier:
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 0}
m_Type: 0
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!114 &-2772541110306127347
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d7b0b988957134dbc9c5e270bd5e9e70, type: 3}
m_Name:
m_EditorClassIdentifier:
nameLabel: {fileID: 8474069203316727232}
--- !u!114 &5171470299130298381
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5800426311821892848}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 876f18cbcbaea4cba9510a977f343c42, type: 3}
m_Name:
m_EditorClassIdentifier:
onClick:
m_PersistentCalls:
m_Calls: []
onRightClick:
m_PersistentCalls:
m_Calls: []
onLeftDown:
m_PersistentCalls:
m_Calls: []
onLeftUp:
m_PersistentCalls:
m_Calls: []
onRightDown:
m_PersistentCalls:
m_Calls: []
onRightUp:
m_PersistentCalls:
m_Calls: []
selectable: 1
rightSelectable: 0
@@ -1,7 +0,0 @@
fileFormatVersion: 2
guid: 9a5c83874e75141728c54f347958d4ae
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,19 +0,0 @@
using TMPro;
using UnityEngine.UI;
public class PickerRowController : TableRowController {
public TMP_Text nameLabel;
public string Text {
get => nameLabel.text;
set => nameLabel.text = value;
}
override public void ColorRow() { gameObject.GetComponent<Image>().color = BackgroundColor; }
// Start is called before the first frame update
void Start() {}
// Update is called once per frame
void Update() {}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: d7b0b988957134dbc9c5e270bd5e9e70
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,9 +0,0 @@
using Net.Eagle0.Shardok.Api;
namespace UnityGoDiceInterface {
public interface RollFetcher {
public delegate void RollResultCallback(int? result);
public void GetRoll(RollRequest rollType, RollResultCallback cb);
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: aade83941cf241b398049ff3fe0475cb
timeCreated: 1703812608
@@ -1,375 +0,0 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using Net.Eagle0.Shardok.Api;
using Net.Eagle0.Shardok.Common;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using UnityGoDiceInterface;
public class RollPanelController : MonoBehaviour, RollFetcher {
public DiceConfigurationPanelController diceConfigurationPanelController;
public Button skipContinueButton;
public TMP_Text skipContinueButtonText;
private DiceInterface _diceInterface;
public TMP_Text tensResultLabel;
public TMP_Text onesResultLabel;
public TMP_Text header;
public TMP_Text reconnectionLabel;
public TMP_Text runningTotalLabel;
private RollFetcher.RollResultCallback _rollResultCallback;
private RollRequest _rollRequest;
private string logLocation;
enum OpenEndedState { None, High, Low, RollComplete }
private OpenEndedState _openEndedState = OpenEndedState.None;
private readonly int _d100OpenEndedHighThreshold = 96;
private readonly int _d100OpenEndedLowThreshold = 5;
private DateTime _lastRollTime = DateTime.MinValue;
private readonly TimeSpan _disconnectionDelay = TimeSpan.FromMinutes(5);
private readonly TimeSpan _displayTime = TimeSpan.FromSeconds(3);
private readonly TimeSpan _openEndedResetTime = TimeSpan.FromSeconds(2);
public void GetRoll(RollRequest rollRequest, RollFetcher.RollResultCallback cb) {
// Check for dice enabled
if (PlayerPrefs.GetInt(SettingsPanelController.UseGoDiceKey, 0) == 0) {
cb(null);
return;
}
skipContinueButton.gameObject.SetActive(true);
if (_diceInterface == null) { _diceInterface = GetComponentInParent<DiceInterface>(); }
_onesResult = null;
_tensResult = null;
_runningTotal = 0;
_openEndedState = OpenEndedState.None;
skipContinueButtonText.text = "Skip";
_rollResultCallback = cb;
_rollRequest = rollRequest;
SetResultLabels();
logLocation = NewLogLocation();
Directory.CreateDirectory(Path.GetDirectoryName(logLocation));
if (string.IsNullOrEmpty(_tensDieInfo.identifier) ||
string.IsNullOrEmpty(_onesDieInfo.identifier)) {
GetConfiguration();
} else {
_diceInterface.deviceFoundCallback = DeviceFoundCallback;
_diceInterface.connectionCallback = ConnectionCallback;
_diceInterface.rollCallback = RollCallback;
_diceInterface.logger = LogFromNative;
// _diceInterface.StartListening();
SetAlphas();
if (!_tensDieInfo.connected) { _diceInterface.Connect(_tensDieInfo.identifier); }
if (!_onesDieInfo.connected) { _diceInterface.Connect(_onesDieInfo.identifier); }
}
gameObject.SetActive(true);
}
private DieInfo _tensDieInfo;
private DieInfo _onesDieInfo;
private int? _tensResult = null;
private int? _onesResult = null;
private int _runningTotal = 0;
private void SetAlphas() {
tensResultLabel.alpha = _tensDieInfo.connected ? 1.0f : 0.25f;
onesResultLabel.alpha = _onesDieInfo.connected ? 1.0f : 0.25f;
}
void OnEnable() {}
private void GetConfiguration() {
diceConfigurationPanelController.GetConfiguration(ConfigurationCallback);
}
public void ConfigureClicked() { GetConfiguration(); }
public void SkipClicked() {
gameObject.SetActive(false);
_rollResultCallback(_runningTotal);
_diceInterface.StopListening();
}
void ConfigurationCallback(DieInfo? tensOpt, DieInfo? onesOpt) {
MainQueue.Q.Enqueue(() => {
if (tensOpt is DieInfo tens && onesOpt is DieInfo ones) {
gameObject.SetActive(true);
_diceInterface.connectionCallback = ConnectionCallback;
_diceInterface.connectionFailedCallback = ConnectionFailedCallback;
_diceInterface.disconnectionCallback = DisconnectionCallback;
_diceInterface.listenerStoppedCallback = ListenerStoppedCallback;
_diceInterface.rollCallback = RollCallback;
_tensDieInfo = tens;
_onesDieInfo = ones;
reconnectionLabel.gameObject.SetActive(false);
if (!_tensDieInfo.connected) _diceInterface.Connect(_tensDieInfo.identifier);
if (!_onesDieInfo.connected) _diceInterface.Connect(_onesDieInfo.identifier);
SetAlphas();
SetResultLabels();
tensResultLabel.color = UnityDieColors.ColorFromDieColor(_tensDieInfo.color);
onesResultLabel.color = UnityDieColors.ColorFromDieColor(_onesDieInfo.color);
} else {
gameObject.SetActive(false);
_rollResultCallback(null);
}
});
}
void DeviceFoundCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
if ((_tensDieInfo.identifier == identifier && !_tensDieInfo.connected) ||
(_onesDieInfo.identifier == identifier && !_onesDieInfo.connected)) {
_diceInterface.Connect(identifier);
}
});
}
void ConnectionCallback(string identifier, string deviceName) {
MainQueue.Q.Enqueue(() => {
if (identifier == _tensDieInfo.identifier) {
_tensDieInfo.connected = true;
} else if (identifier == _onesDieInfo.identifier) {
_onesDieInfo.connected = true;
}
SetAlphas();
if (_tensDieInfo.connected && _onesDieInfo.connected) {
reconnectionLabel.gameObject.SetActive(false);
_diceInterface.StopListening();
}
});
}
void ConnectionFailedCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
if (identifier == _onesDieInfo.identifier || identifier == _tensDieInfo.identifier) {
reconnectionLabel.gameObject.SetActive(true);
_diceInterface.StartListening();
} else {
Debug.Log("Got connection failed for unknown identifier");
}
});
}
void DisconnectionCallback(string identifier) {
MainQueue.Q.Enqueue(() => {
if (identifier == _tensDieInfo.identifier) {
_tensDieInfo.connected = false;
SetAlphas();
_diceInterface.Connect(identifier);
} else if (identifier == _onesDieInfo.identifier) {
_onesDieInfo.connected = false;
SetAlphas();
_diceInterface.Connect(identifier);
} else {
Debug.Log("Got disconnection callback for unknown identifier");
}
});
}
void ListenerStoppedCallback() {
MainQueue.Q.Enqueue(() => { reconnectionLabel.text = "Unable to connect."; });
}
private readonly Dictionary<CommandType, string> _baseHeaderText = new() {
{ CommandType.ArcheryCommand, "Archery Attack" },
{ CommandType.BuildBridgeCommand, "Build Bridge" },
{ CommandType.ChargeCommand, "Charge" },
{ CommandType.BraveWaterCommand, "Brave Water" },
{ CommandType.MeleeCommand, "Melee Attack" }
};
private string BaseHeaderText(CommandType commandType) {
if (_baseHeaderText.ContainsKey(commandType)) { return _baseHeaderText[commandType]; }
return "Action";
}
void SetResultLabels() {
var baseText = BaseHeaderText(_rollRequest.CommandType);
string fullText = "";
if (_openEndedState == OpenEndedState.High) {
fullText = $"You rolled OPEN ENDED HIGH! Keep rolling for {baseText}!";
} else if (_openEndedState == OpenEndedState.Low) {
fullText = $"You rolled OPEN ENDED LOW! Keep rolling for {baseText}!";
} else {
switch (_rollRequest.RollType) {
case RollType.D100: fullText = $"Roll D100 for {baseText}"; break;
case RollType.D100OpenEnded:
fullText = $"Roll Open Ended D100 for {baseText}";
break;
case RollType.D100OpenEndedHigh:
fullText = $"Roll Open Ended High D100 for {baseText}";
break;
case RollType.D100OpenEndedLow:
fullText = $"Roll Open Ended Low D100 for {baseText}";
break;
case RollType.Unspecified:
default: throw new ArgumentException("Invalid roll type");
}
}
if (_rollRequest.Odds is OddsView odds) {
int minRoll = 101 - odds.SuccessChance;
fullText += $" ({minRoll}+ to succeed)";
}
header.text = fullText;
if (_tensResult is int tr) {
tensResultLabel.text = tr.ToString();
} else {
tensResultLabel.text = "?";
}
if (_onesResult is int or) {
onesResultLabel.text = or.ToString();
} else {
onesResultLabel.text = "?";
}
runningTotalLabel.text = $"Running total: {_runningTotal}";
runningTotalLabel.gameObject.SetActive(_runningTotal != 0);
}
void RollCallback(string identifier, sbyte x, sbyte y, sbyte z) {
MainQueue.Q.Enqueue(() => {
if (gameObject.activeSelf) {
var d10Roll = DiceVectors.D10Value(x, y, z);
if (identifier == _tensDieInfo.identifier) {
if (_tensResult == null) { _tensResult = d10Roll; }
} else if (identifier == _onesDieInfo.identifier) {
if (_onesResult == null) { _onesResult = d10Roll; }
}
SetResultLabels();
CheckCompletion();
}
});
}
void HandleOneRoll(int newD100Result) {
if (_openEndedState == OpenEndedState.Low) {
_runningTotal -= newD100Result;
} else {
_runningTotal += newD100Result;
}
// Rolls in the middle always end the open ended state.
if (newD100Result > _d100OpenEndedLowThreshold &&
newD100Result < _d100OpenEndedHighThreshold) {
_openEndedState = OpenEndedState.RollComplete;
return;
}
// For a LOW roll, if we are currently in a None state, move to OpenEnded
// Low so we can roll again, but if we were already there, this ends the roll.
if (newD100Result <= _d100OpenEndedLowThreshold) {
if ((_rollRequest.RollType == RollType.D100OpenEnded ||
_rollRequest.RollType == RollType.D100OpenEndedLow) &&
_openEndedState == OpenEndedState.None) {
_openEndedState = OpenEndedState.Low;
} else {
_openEndedState = OpenEndedState.RollComplete;
}
return;
}
// At this point we know the current roll is high. If we were already in OpenEndedLow,
// stay there and keep subtracting, otherwise move into High.
if (_openEndedState == OpenEndedState.Low) {
_openEndedState = OpenEndedState.Low;
} else {
_openEndedState = OpenEndedState.High;
}
}
void CheckCompletion() {
if (_onesResult is int ones && _tensResult is int tens) {
// FIXME: open ended rolls
var currentResult = tens * 10 + ones;
HandleOneRoll(currentResult);
// TODO: flash the dice here for high rolls
if (_openEndedState == OpenEndedState.RollComplete) {
skipContinueButtonText.text = "Dismiss";
SetResultLabels();
StartCoroutine(AfterDelay(_displayTime, () => {
if (gameObject.activeSelf) {
gameObject.SetActive(false);
_rollResultCallback(_runningTotal);
}
}));
// Disconnect only after _disconnectionDelay time
_lastRollTime = DateTime.Now;
StartCoroutine(AfterDelay(_disconnectionDelay, () => {
if (DateTime.Now - _lastRollTime > _disconnectionDelay) {
_diceInterface.StopListening();
_tensDieInfo.connected = false;
_onesDieInfo.connected = false;
_diceInterface.Disconnect(_tensDieInfo.identifier);
_diceInterface.Disconnect(_onesDieInfo.identifier);
}
}));
} else {
// We keep rolling, so reset the dice and keep going.
StartCoroutine(AfterDelay(_openEndedResetTime, () => {
_tensResult = null;
_onesResult = null;
SetResultLabels();
}));
}
}
}
IEnumerator AfterDelay(TimeSpan timeSpan, Action action) {
yield return new WaitForSeconds(timeSpan.Seconds);
action();
}
void LogFromNative(string log) {
MainQueue.Q.Enqueue(() => {
Debug.Log(log);
File.AppendAllText(logLocation, log);
});
}
string NewLogLocation() {
return Path.Combine(
Application.persistentDataPath,
"eagle0",
"Resources",
"RollPanelLogs",
DateTime.Now.ToString("yyyy-MM-dd-HH-mm-ss") + ".log");
}
}
@@ -1,11 +0,0 @@
fileFormatVersion: 2
guid: db0669ab8b7684b2bb7eb30a0e66f5e8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,18 +0,0 @@
using System;
using UnityEngine;
namespace UnityGoDiceInterface {
public class UnityDieColors {
public static Color ColorFromDieColor(DieColor dieColor) {
switch (dieColor) {
case DieColor.DieColorBlack: return Color.black;
case DieColor.DieColorRed: return Color.red;
case DieColor.DieColorGreen: return Color.green;
case DieColor.DieColorBlue: return Color.blue;
case DieColor.DieColorYellow: return Color.yellow;
case DieColor.DieColorOrange: return new Color(1.0f, 0.5f, 0.0f);
default: throw new ArgumentOutOfRangeException(nameof(dieColor), dieColor, null);
}
}
}
}
@@ -1,3 +0,0 @@
fileFormatVersion: 2
guid: d26c35650c67416e86ab5a26906cfb5a
timeCreated: 1703173073
@@ -20,8 +20,6 @@ using Random = System.Random;
using VictoryCondition = Net.Eagle0.Common.VictoryCondition;
public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
public RollPanelController rollPanelController;
public GameObject yourArmiesArea;
public GameObject aiArmiesArea;
public Canvas shardokCanvas;
@@ -103,8 +101,7 @@ public class CustomBattleHandler : MonoBehaviour, IClientConnectionSubscriber {
players: players,
heroNameTextIds: _heroNames,
heroImages: _heroImages,
battalionNames: new Dictionary<int, string> { { 0, "no name" } },
rollFetcher: rollPanelController);
battalionNames: new Dictionary<int, string> { { 0, "no name" } });
shardokCanvas.gameObject.SetActive(true);
shardokCanvas.GetComponent<ShardokGameController>().SetUpGame(_shardokModel);
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using Eagle0.Tutorial;
using Net.Eagle0.Eagle.Api;
using Net.Eagle0.Eagle.Views;
using UnityEngine;
@@ -88,6 +89,9 @@ namespace eagle {
_model = model;
_availableCommand = ac;
SetUpUI();
// Trigger tutorial for this command type (fires every time command is selected)
TutorialManager.Instance?.TriggerRegistry?.OnCommandPanelShown(CommandType);
}
}
}
@@ -22,8 +22,6 @@ namespace eagle {
public class EagleGameController : MonoBehaviour {
public SoundManager soundManager;
public RollPanelController rollPanelController;
public TextMeshProUGUI roundStatusLabel;
public TextMeshProUGUI connectionStatusLabel;
private bool _connectionStatusUIInitialized = false;
@@ -248,11 +246,7 @@ namespace eagle {
int? playerId,
PersistentClientConnection persistentClientConnection,
string environmentName = null) {
ModelUpdater = new GameModelUpdater(
gameId,
playerId,
persistentClientConnection,
rollPanelController) {
ModelUpdater = new GameModelUpdater(gameId, playerId, persistentClientConnection) {
UpdateAction = ModelUpdated,
NoteRecipient = (title, text, llmId, pids, displayedHeroes) =>
notificationPanel.AddNote(title, text, llmId, pids, displayedHeroes)
@@ -284,11 +278,13 @@ namespace eagle {
MainQueue.Q.EnqueueForNextUpdate(
() => { _ = ModelUpdater.StartListeningForUpdates(); });
// Initialize tutorial system
// Initialize tutorial system (onboarding starts after first model update in SwapModel)
TutorialManager.Instance?.Initialize(this, null);
if (TutorialManager.Instance != null &&
!TutorialManager.Instance.State.OnboardingCompleted) {
TutorialManager.Instance.StartOnboarding();
// Register command panel for tutorial positioning
if (TutorialManager.Instance?.TargetRegistry != null && commandPanel != null) {
TutorialManager.Instance.TargetRegistry.RegisterTarget(
"CommandPanel",
commandPanel.GetComponent<RectTransform>());
}
#if UNITY_EDITOR
@@ -507,6 +503,12 @@ namespace eagle {
if (Model == null) { return; }
// Start onboarding after first model update (UI panels are now populated)
if (oldModel == null && TutorialManager.Instance != null &&
!TutorialManager.Instance.State.OnboardingCompleted) {
TutorialManager.Instance.StartOnboarding();
}
PrefetchHeadshots(Model);
SetMusic();
@@ -13,7 +13,6 @@ using Net.Eagle0.Eagle.Views;
using Net.Eagle0.Shardok.Common;
using TMPro;
using UnityEngine;
using UnityGoDiceInterface;
using Logger = common.Logger;
using Notification = eagle.Notifications.Notification;
@@ -58,8 +57,6 @@ namespace eagle {
public List<ProvinceId> ProvincesForFaction(FactionId factionId);
public List<ChronicleEntry> ChronicleEntries { get; }
RollFetcher RollFetcher { get; }
}
public class GameModelUpdater : IClientConnectionSubscriber, IGameStateProvider {
@@ -150,8 +147,6 @@ namespace eagle {
private readonly ConcurrentDictionary<string, int> _shardokResultCounts =
new ConcurrentDictionary<string, int>();
private readonly RollFetcher _rollFetcher;
// State synced with server
private struct ConcreteGameModel : IGameModel {
public PlayerId? PlayerId { get; set; }
@@ -220,8 +215,6 @@ namespace eagle {
var faction = MaybeDestroyedFaction(factionId);
textUpdater.SetFactionText(textComponent, faction, this, template, fallbackText);
}
public RollFetcher RollFetcher { get; set; }
}
private ConcreteGameModel _currentModel;
@@ -229,11 +222,9 @@ namespace eagle {
public GameModelUpdater(
GameId eagleGameId,
FactionId? factionId,
PersistentClientConnection pers,
RollFetcher rollFetcher) {
PersistentClientConnection pers) {
GameId = eagleGameId;
PersistentConnection = pers;
this._rollFetcher = rollFetcher;
_currentModel.LastPostedToken = -1;
_currentModel.PlayerId = factionId;
@@ -245,8 +236,6 @@ namespace eagle {
new ConcurrentDictionary<ShardokGameId, ShardokGameModel>();
_currentModel.BattalionTypes = new Dictionary<BattalionTypeId, BattalionType>();
_currentModel.RollFetcher = rollFetcher;
}
private ShardokGameModel MakeGameModel(ShardokGameId shardokGameId) {
@@ -294,8 +283,7 @@ namespace eagle {
kv => kv.Value.ImagePath),
battalionNames: _currentModel.BattalionNames.ToDictionary(
kv => kv.Key,
kv => kv.Value),
rollFetcher: _rollFetcher);
kv => kv.Value));
return shardokModel;
}
@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using common;
using Eagle0.Tutorial;
using Net.Eagle0.Eagle.Views;
using UnityEngine;
@@ -136,6 +137,25 @@ namespace eagle {
row.Hero = hero;
SetHeroRowSelections(row, hero);
});
// Register hero rows with tutorial system for highlighting
RegisterHeroRowsForTutorial();
}
private void RegisterHeroRowsForTutorial() {
var registry = TutorialManager.Instance?.TargetRegistry;
if (registry == null) return;
// Register first row as WarlordRow (index 0)
var warlordRow = heroesTable.GetRowRectTransform(0);
if (warlordRow != null) { registry.RegisterTarget("WarlordRow", warlordRow); }
// Register rows 1 and 2 as VassalRows
var vassalRow1 = heroesTable.GetRowRectTransform(1);
if (vassalRow1 != null) { registry.RegisterTarget("VassalRow1", vassalRow1); }
var vassalRow2 = heroesTable.GetRowRectTransform(2);
if (vassalRow2 != null) { registry.RegisterTarget("VassalRow2", vassalRow2); }
}
private void SetUpBattalionsTable() {
@@ -196,6 +196,20 @@ namespace eagle {
/// </summary>
private async Task<bool> StreamOneGameAsync(IClientConnectionSubscriber subscriber) {
var gameId = subscriber.GameId;
// Send JoinGameRequest first to ensure game is loaded on server.
// For already-started games, this returns GAME_FULL but still triggers
// game loading, which prevents "key not found" errors on subsequent commands.
var joinRequest = new UpdateStreamRequest {
JoinGameRequest = new JoinGameRequest { GameId = gameId }
};
await DoWithStreamingCall(async (sc) => {
await sc.RequestStream.WriteAsync(joinRequest);
_remoteEagleClientLogger.LogLine(
$"[SUBSCRIBE] Sent JoinGameRequest for game {gameId} to ensure game is loaded");
return true;
});
var streamGameRequest = new StreamGameRequest {
GameId = gameId,
UnfilteredResultCount = subscriber.LastUnfilteredResultCount
@@ -810,12 +824,19 @@ namespace eagle {
await Task.WhenAny(actionTask, timeoutTask).ConfigureAwait(false);
if (completedTask == timeoutTask) {
// WriteAsync timed out - connection is likely dead
// WriteAsync timed out - connection is dead
LogConnectionEvent(
"write_timeout",
$"WriteAsync timed out after {WriteAsyncTimeoutMs}ms");
_remoteEagleClientLogger.LogLine(
$"[WRITE_TIMEOUT] WriteAsync timed out after {WriteAsyncTimeoutMs}ms, connection may be dead");
$"[WRITE_TIMEOUT] WriteAsync timed out after {WriteAsyncTimeoutMs}ms, triggering reconnect");
// Dispose the dead connection and schedule reconnect
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
}
ScheduleReconnect("WriteAsyncTimeout");
return false;
}
@@ -823,15 +844,31 @@ namespace eagle {
timeoutCts.Cancel();
return await actionTask.ConfigureAwait(false);
} catch (RpcException e) {
if (e.StatusCode == StatusCode.Cancelled) {
// This is expected when the connection is closed.
return false;
} else {
throw;
_remoteEagleClientLogger.LogLine(
$"[WRITE_ERROR] RpcException: {e.StatusCode} - {e.Message}");
// Connection is broken - dispose and reconnect
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
}
if (e.StatusCode != StatusCode.Cancelled) {
// Cancelled is expected during normal shutdown, don't reconnect
ScheduleReconnect($"RpcException-{e.StatusCode}");
}
return false;
} catch (OperationCanceledException) {
// Timeout was triggered
return false;
} catch (Exception e) {
_remoteEagleClientLogger.LogLine(
$"[WRITE_ERROR] Exception: {e.GetType().Name} - {e.Message}");
// Unknown error - dispose and reconnect
lock (this) {
_streamingCall?.Dispose();
_streamingCall = null;
}
ScheduleReconnect($"WriteException-{e.GetType().Name}");
return false;
}
}
@@ -0,0 +1,13 @@
using UnityEngine;
/// <summary>
/// Initializes Sparkle auto-updater at app startup.
/// Uses RuntimeInitializeOnLoadMethod to ensure early initialization.
/// </summary>
public static class SparkleInitializer {
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
private static void Initialize() {
Debug.Log("SparkleInitializer: Initializing Sparkle at startup");
SparkleUpdater.Initialize();
}
}
@@ -1,11 +1,11 @@
fileFormatVersion: 2
guid: 680b7d1179eb34a0fa338286236db2e9
guid: 2b4c6d8e0f1a3b5c7d9e1f3a5b7c9d1e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
executionOrder: -100
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,120 @@
using System.Runtime.InteropServices;
using UnityEngine;
/// <summary>
/// C# wrapper for the native SparklePlugin on macOS.
/// Provides auto-update functionality via the Sparkle framework.
/// On non-macOS platforms, all methods are no-ops.
/// </summary>
public static class SparkleUpdater {
#if UNITY_STANDALONE_OSX && !UNITY_EDITOR
private const string PluginName = "SparklePlugin";
[DllImport(PluginName)]
private static extern void SparklePlugin_Initialize();
[DllImport(PluginName)]
private static extern void SparklePlugin_CheckForUpdates();
[DllImport(PluginName)]
private static extern void SparklePlugin_CheckForUpdatesInBackground();
[DllImport(PluginName)]
private static extern int SparklePlugin_IsCheckingForUpdates();
[DllImport(PluginName)]
private static extern int SparklePlugin_GetAutomaticallyChecksForUpdates();
[DllImport(PluginName)]
private static extern void SparklePlugin_SetAutomaticallyChecksForUpdates(int enabled);
private static bool _initialized = false;
/// <summary>
/// Initialize the Sparkle updater. Should be called once at app startup.
/// This starts automatic background update checks based on Info.plist settings.
/// </summary>
public static void Initialize() {
if (_initialized) {
Debug.Log("SparkleUpdater: Already initialized");
return;
}
Debug.Log("SparkleUpdater: Initializing Sparkle");
try {
SparklePlugin_Initialize();
_initialized = true;
Debug.Log("SparkleUpdater: Initialization complete");
} catch (System.Exception e) {
Debug.LogError($"SparkleUpdater: Failed to initialize: {e.Message}");
}
}
/// <summary>
/// Manually check for updates. Shows the update UI to the user.
/// </summary>
public static void CheckForUpdates() {
if (!_initialized) {
Debug.LogWarning("SparkleUpdater: Not initialized");
return;
}
SparklePlugin_CheckForUpdates();
}
/// <summary>
/// Check for updates silently in the background.
/// Only shows UI if an update is found.
/// </summary>
public static void CheckForUpdatesInBackground() {
if (!_initialized) {
Debug.LogWarning("SparkleUpdater: Not initialized");
return;
}
SparklePlugin_CheckForUpdatesInBackground();
}
/// <summary>
/// Returns true if an update check is currently in progress.
/// </summary>
public static bool IsCheckingForUpdates {
get {
if (!_initialized) return false;
return SparklePlugin_IsCheckingForUpdates() != 0;
}
}
/// <summary>
/// Gets or sets whether automatic update checks are enabled.
/// </summary>
public static bool AutomaticallyChecksForUpdates {
get {
if (!_initialized) return false;
return SparklePlugin_GetAutomaticallyChecksForUpdates() != 0;
}
set {
if (!_initialized) return;
SparklePlugin_SetAutomaticallyChecksForUpdates(value? 1: 0);
}
}
#else
// Stub implementations for non-macOS platforms and Editor
public static void Initialize() { Debug.Log("SparkleUpdater: Not available on this platform"); }
public static void CheckForUpdates() {
Debug.Log("SparkleUpdater: Not available on this platform");
}
public static void CheckForUpdatesInBackground() {
Debug.Log("SparkleUpdater: Not available on this platform");
}
public static bool IsCheckingForUpdates => false;
public static bool AutomaticallyChecksForUpdates {
get => false;
set {}
}
#endif
}
@@ -1,11 +1,11 @@
fileFormatVersion: 2
guid: 75ea5d911cfab4851831d1de4b61f559
guid: 8a3b5c7d9e1f2a3b4c5d6e7f8a9b0c1d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -1,3 +1,6 @@
System.Runtime.CompilerServices.Unsafe/**
!**/*.psd
**/*.dll
# Native plugins built at CI time
DarwinGodiceBundle.bundle/
macOS/SparklePlugin.bundle/
@@ -1,27 +0,0 @@
fileFormatVersion: 2
guid: 1fc634da07f5047c99dba5ce558b5886
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 0
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
Any:
second:
enabled: 1
settings: {}
- first:
Editor: Editor
second:
enabled: 0
settings:
DefaultValueInitialized: true
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 3c5d7e9f1a2b4c6d8e0f2a4b6c8d0e2f
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: b5b6db3c9b9e34b82ae9163a61276c8a
guid: 4d6e8f0a2b3c5d7e9f1a3b5c7d9e1f3a
PluginImporter:
externalObjects: {}
serializedVersion: 2
@@ -12,22 +12,34 @@ PluginImporter:
validateReferences: 1
platformData:
- first:
Any:
: Any
second:
enabled: 0
settings:
Exclude Editor: 1
Exclude Linux64: 1
Exclude OSXUniversal: 0
Exclude Win: 1
Exclude Win64: 1
- first:
Any:
second:
enabled: 0
settings: {}
- first:
Editor: Editor
second:
enabled: 1
enabled: 0
settings:
CPU: AnyCPU
DefaultValueInitialized: true
OS: AnyOS
- first:
Standalone: OSXUniversal
second:
enabled: 1
settings:
CPU: AnyCPU
userData:
assetBundleName:
assetBundleVariant:
userData:
assetBundleName:
assetBundleVariant:
@@ -23,10 +23,6 @@ public class SettingsPanelController : MonoBehaviour {
public Slider tileBorderWidthSlider;
public TMP_Text tileBorderWidthLabel;
public Toggle useGoDiceToggle;
public Button testGoDice;
public RollPanelController rollPanelController;
public Toggle tooltipHugsCursorToggle;
public HoveringTooltip hoveringTooltip;
@@ -42,7 +38,6 @@ public class SettingsPanelController : MonoBehaviour {
private const string SoundEffectsVolumeKey = "soundEffectsVolume";
private const string MusicVolumeKey = "musicVolume";
public const string UseGoDiceKey = "useGoDice";
private const string TooltipHugsCursorKey = "tooltipHugsCursor";
private const string AnimationToggleKey = "animationToggle";
@@ -62,7 +57,6 @@ public class SettingsPanelController : MonoBehaviour {
soundEffectsSlider.value = soundEffectsSource.volume;
musicSlider.value = musicSource.volume;
useGoDiceToggle.isOn = PlayerPrefs.GetInt(UseGoDiceKey, 0) == 1;
tooltipHugsCursorToggle.isOn = PlayerPrefs.GetInt(TooltipHugsCursorKey, 0) == 1;
hoveringTooltip.HugsCursor = tooltipHugsCursorToggle.isOn;
@@ -111,17 +105,6 @@ public class SettingsPanelController : MonoBehaviour {
PlayerPrefs.SetFloat(MusicVolumeKey, val);
}
public void OnUseGoDiceToggleChange(bool val) {
PlayerPrefs.SetInt(UseGoDiceKey, val ? 1 : 0);
testGoDice.interactable = val;
}
public void OnTestGoDiceClick() {
rollPanelController.GetRoll(
new RollRequest { RollType = RollType.D100OpenEnded },
(result) => { Console.Out.WriteLine($"Got ${result}"); });
}
public void OnCursorHugToggleChange(bool val) {
PlayerPrefs.SetInt(TooltipHugsCursorKey, val ? 1 : 0);
hoveringTooltip.HugsCursor = val;
@@ -6,7 +6,6 @@ using eagle0;
using UnityEngine;
using Net.Eagle0.Shardok.Api;
using Net.Eagle0.Shardok.Common;
using UnityGoDiceInterface;
using BattalionId = System.Int32;
using UnitId = System.Int32;
using PlayerId = System.Int32;
@@ -133,8 +132,6 @@ public class ShardokGameModel {
private const int StartingHistoryCapacity = 100;
private readonly RollFetcher _rollFetcher;
public ShardokGameModel(
EagleGameId eagleGameId,
ShardokGameId shardokGameId,
@@ -146,13 +143,11 @@ public class ShardokGameModel {
List<PlayerWithHostility> players,
Dictionary<HeroId, String> heroNameTextIds,
Dictionary<HeroId, String> heroImages,
Dictionary<BattalionId, String> battalionNames,
RollFetcher rollFetcher) {
Dictionary<BattalionId, String> battalionNames) {
EagleGameId = eagleGameId;
ShardokGameId = shardokGameId;
History = new List<ActionResultView>(StartingHistoryCapacity);
_persistentClientConnection = persistentClientConnection;
this._rollFetcher = rollFetcher;
PlayerId = playerId;
this.players = new List<PlayerWithHostility>(players);
@@ -350,25 +345,14 @@ public class ShardokGameModel {
var index = AvailableCommands.IndexOf(action);
AvailableCommands.Clear();
if (action.RollRequest != null) {
_rollFetcher.GetRoll(action.RollRequest, roll => {
_persistentClientConnection.PostShardokCommand(
EagleGameId,
ShardokGameId,
PlayerId,
History.Count(),
index,
roll);
});
} else {
_persistentClientConnection.PostShardokCommand(
EagleGameId,
ShardokGameId,
PlayerId,
History.Count(),
index,
null);
}
// Physical dice roll support removed - server generates random rolls
_persistentClientConnection.PostShardokCommand(
EagleGameId,
ShardokGameId,
PlayerId,
History.Count(),
index,
null);
}
private void PostGameSetupActions() {
@@ -16,6 +16,9 @@ namespace Eagle0.Tutorial {
var onboarding = CreateOnboardingSequence();
manager.OnboardingSequence = onboarding;
// Register command panel tutorials (shown after onboarding)
RegisterCommandPanelTutorials(registry);
// Register strategic contextual tutorials
RegisterStrategicTutorials(registry);
@@ -23,7 +26,7 @@ namespace Eagle0.Tutorial {
RegisterTacticalTutorials(registry);
}
// ========== ONBOARDING SEQUENCE (13 steps) ==========
// ========== ONBOARDING SEQUENCE ==========
private static TutorialSequence CreateOnboardingSequence() {
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
@@ -37,60 +40,146 @@ namespace Eagle0.Tutorial {
StepId = "welcome",
Title = "Welcome to Eagle0",
Description =
"Command your armies, recruit heroes, and conquer provinces in this turn-based strategy game.\n\nLet's walk through the basics!",
"You are a new ruler in a fractured realm. Build your power, recruit heroes, and expand your domain.\n\n" +
"Let's look at your first province!\n\n" +
"<size=80%><color=#888888>(You can skip this tutorial and restart it later from Settings.)</color></size>",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 2: Map overview - select a province
// Step 2: Province Info Panel - Stats Overview
new TutorialStep {
StepId = "select_province",
Title = "The Strategic Map",
StepId = "province_stats",
Title = "Province Statistics",
Description =
"This is your kingdom. Each colored region is a province.\n\nTap a province you control (shown in your color) to see what you can do there.",
DisplayMode = TutorialDisplayMode.Overlay,
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "province_selected",
AllowSkip = true
},
// Step 3: Province info panel
new TutorialStep {
StepId = "province_panel",
Title = "Province Information",
Description =
"This panel shows province details: its name, terrain, any armies present, and the commands available to you.\n\nCommands let you move troops, recruit heroes, and more.",
"This panel shows your province's vital statistics. <b>Hover over icons and numbers</b> for detailed tooltips.\n\n" +
"• <b>Agriculture</b> produces Food to feed your troops\n" +
"• <b>Economy</b> generates Gold for hiring, gifts, and equipment\n" +
"• <b>Infrastructure</b> improves troop armament and disaster resilience\n\n" +
"All three stats increase your storage capacity.",
DisplayMode = TutorialDisplayMode.Modal,
BlocksInteraction = false,
PanelAnchor = TutorialPanelAnchor.Right,
AdjacentTargetPath = "ProvinceInfoPanel",
TargetGameObjectPath = "ProvinceInfoPanel",
HighlightPulsing = true,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 4: Try March command
// Step 3: Province Info Panel - Support (CRITICAL)
new TutorialStep {
StepId = "try_march",
Title = "Issue a Command",
StepId = "province_support",
Title = "Support is Critical!",
Description =
"Try issuing a March command to move your army to an adjacent province.\n\nSelect a destination and confirm the order.",
DisplayMode = TutorialDisplayMode.Overlay,
// TODO: Add TargetGameObjectPath once we know the actual UI hierarchy
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "command_issued",
"<b>Support</b> measures how much your people trust you. This is your most important number right now!\n\n" +
"You need <b>40 Support by January</b> to collect taxes. Taxes provide both <b>Gold</b> and <b>Food</b>:\n" +
"• <b>Food</b> feeds your troops and lets you Give Alms\n" +
"• <b>Gold</b> pays for hiring, hero gifts, equipment, and more\n\n" +
"Use <b>Improve</b> and <b>Give Alms</b> to raise Support quickly.",
DisplayMode = TutorialDisplayMode.Modal,
BlocksInteraction = false,
PanelAnchor = TutorialPanelAnchor.Right,
AdjacentTargetPath = "ProvinceInfoPanel",
TargetGameObjectPath = "SupportField",
HighlightPulsing = true,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 5: Turn cycle explanation
// Step 4: Heroes Panel - Warlord
new TutorialStep {
StepId = "heroes_warlord",
Title = "Your Warlord",
Description =
"The <b>Resident Heroes</b> panel shows heroes in this province. The first hero is your <b>Warlord</b> - this is essentially <i>you</i>.\n\n" +
"<b>Hover over a hero</b> to see their backstory, stats, and abilities. Your Warlord has a special profession that grants unique powers.\n\n" +
"<color=#FF6666>Your Warlord is very important - protect them!</color>",
DisplayMode = TutorialDisplayMode.Modal,
BlocksInteraction = false,
PanelAnchor = TutorialPanelAnchor.Right,
AdjacentTargetPath = "HeroesPanel",
TargetGameObjectPath = "WarlordRow",
HighlightPulsing = true,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 5: Heroes Panel - Vassals
new TutorialStep {
StepId = "heroes_vassals",
Title = "Your Vassals",
Description =
"The other heroes in your service are your <b>Vassals</b>. They lead troops, govern provinces, and perform tasks for you.\n\n" +
"<b>Watch their Loyalty!</b> At the end of each year, vassal loyalty may drop. If it falls below <b>50</b>, they might leave your service - or worse.\n\n" +
"Keep vassals happy with gifts and feasts!",
DisplayMode = TutorialDisplayMode.Modal,
BlocksInteraction = false,
PanelAnchor = TutorialPanelAnchor.Right,
AdjacentTargetPath = "HeroesPanel",
TargetGameObjectPath = "VassalRow1",
AdditionalHighlightTargets = new[] { "VassalRow2" },
HighlightPulsing = true,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 6: Command Buttons - Focus on Improve/Alms
new TutorialStep {
StepId = "command_buttons",
Title = "Province Commands",
Description =
"The buttons below let you issue commands. <b>Feel free to click them</b> - nothing happens until you click <b>Commit</b>!\n\n" +
"For now, focus on:\n" +
"• <b>Improve</b> - Raises province stats AND Support\n" +
"• <b>Give Alms</b> - Quickly boosts Support (costs Food)\n\n" +
"Get to <b>40 Support before January</b> to collect taxes!",
DisplayMode = TutorialDisplayMode.Modal,
BlocksInteraction = false,
PanelAnchor = TutorialPanelAnchor.Top,
AdjacentTargetPath = "CommandButtonsPanel",
TargetGameObjectPath = "CommandButtonsPanel",
HighlightPulsing = true,
HighlightBoundsFromChildren = true,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 7: Turn cycle explanation
new TutorialStep {
StepId = "turn_cycle",
Title = "The Turn Cycle",
Description =
"Eagle0 uses simultaneous turns. All players give orders at the same time, then turns resolve together.\n\nWhen all players are ready, the server processes everyone's commands and shows the results.",
"Eagle0 uses <b>simultaneous turns</b>. All players give orders at the same time, then everything resolves together.\n\n" +
"Your turn ends when you've given an order in each of your provinces. Once all players are ready, the server processes commands and shows results.",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 6: Hidden wait for battle
// Step 8: Completion of strategic intro
new TutorialStep {
StepId = "strategic_complete",
Title = "You're Ready to Begin!",
Description =
"That's the basics of managing your province. Remember:\n\n" +
"• Get <b>Support to 40</b> before January for taxes (Gold + Food)\n" +
"• <b>Hover over everything</b> for tooltips\n" +
"• Protect your <b>Warlord</b> at all costs!\n" +
"• Keep your <b>Vassals</b> loyal with gifts\n\n" +
"When armies clash, you'll learn tactical combat. Good luck, ruler!",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = false,
// Mark onboarding complete here so command tutorials can appear
// Tactical tutorial continues in background when battle becomes available
MarksOnboardingComplete = true
},
// === TACTICAL TUTORIAL (triggered later when battle occurs) ===
// Step 9: Hidden wait for battle
new TutorialStep {
StepId = "wait_for_battle",
Title = "",
@@ -101,103 +190,413 @@ namespace Eagle0.Tutorial {
AllowSkip = true
},
// Step 7: Battle introduction
// Step 10: Battle introduction
new TutorialStep {
StepId = "battle_intro",
Title = "Battle Time!",
Description =
"When armies collide, you'll fight tactical battles on a hex grid.\n\nYou command individual units - infantry, cavalry, archers, and heroes with special abilities.",
"When armies collide, you fight tactical battles on a hex grid.\n\n" +
"You command individual units - infantry, cavalry, archers, and heroes with special abilities.",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 8: Battle button highlight
// Step 11: Battle button highlight
new TutorialStep {
StepId = "enter_battle",
Title = "Enter the Battle",
Description = "Tap the Battle button to enter tactical combat.",
Description = "Tap the <b>Battle</b> button to enter tactical combat.",
DisplayMode = TutorialDisplayMode.Overlay,
// TODO: Add TargetGameObjectPath once we know the actual UI hierarchy
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "battle_entered",
AllowSkip = true
},
// Step 9: Tactical overview
// Step 12: Tactical overview
new TutorialStep {
StepId = "tactical_overview",
Title = "Tactical Combat",
Description =
"Each unit has movement points and attack power. Position your troops wisely!\n\nUnits attack adjacent enemies. Flanking (attacking from multiple sides) deals bonus damage.",
"Each unit has movement points and attack power. Position your troops wisely!\n\n" +
"Units attack adjacent enemies. <b>Flanking</b> (attacking from multiple sides) deals bonus damage.",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
},
// Step 10: Move a unit
// Step 13: Move a unit
new TutorialStep {
StepId = "move_unit",
Title = "Move Your Units",
Description =
"Tap one of your units to select it, then tap a highlighted hex to move there.\n\nBlue hexes show where you can move.",
"Tap one of your units to select it, then tap a highlighted hex to move there.\n\n" +
"<b>Blue hexes</b> show where you can move.",
DisplayMode = TutorialDisplayMode.Overlay,
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "battle_action",
AllowSkip = true
},
// Step 11: Attack an enemy
// Step 14: Attack an enemy
new TutorialStep {
StepId = "attack_enemy",
Title = "Attack!",
Description =
"Move next to an enemy unit, then tap the enemy to attack.\n\nRed highlights show valid attack targets.",
Description = "Move next to an enemy unit, then tap the enemy to attack.\n\n" +
"<b>Red highlights</b> show valid attack targets.",
DisplayMode = TutorialDisplayMode.Overlay,
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "battle_action",
AllowSkip = true
},
// Step 12: End turn
new TutorialStep {
StepId = "end_turn",
Title = "End Your Turn",
Description =
"When you've moved all units or want to pass, tap End Turn.\n\nThe enemy will then take their turn.",
DisplayMode = TutorialDisplayMode.Overlay,
// TODO: Add TargetGameObjectPath once we know the actual UI hierarchy
CompletionType = TutorialCompletionType.GameEvent,
CompletionEventId = "turn_ended",
AllowSkip = true
},
// Step 13: Completion
// Step 15: Final completion
new TutorialStep {
StepId = "complete",
Title = "You're Ready!",
Title = "Tutorial Complete!",
Description =
"You now know the basics of Eagle0!\n\nExplore diplomacy, recruit powerful heroes, and conquer the realm. Good luck, commander!",
"You now know the basics of Eagle0!\n\n" +
"Explore diplomacy, recruit powerful heroes, and conquer the realm. May your reign be long and prosperous!",
DisplayMode = TutorialDisplayMode.Modal,
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = false // Don't allow skipping the final step
AllowSkip = false
}
};
return sequence;
}
// ========== COMMAND PANEL TUTORIALS ==========
// These appear the first time a command panel is shown, after onboarding completes.
private static void RegisterCommandPanelTutorials(TutorialTriggerRegistry registry) {
// Improve command - most important early game command
var improve = CreateCommandPanelTutorial(
"command_improve",
"Improve Province",
"Use <b>Improve</b> to develop your province and increase Support.\n\n" +
"• <b>Agriculture</b> - Increases food production\n" +
"• <b>Economy</b> - Increases gold income\n" +
"• <b>Infrastructure</b> - Improves armament and resilience\n" +
"• <b>Repair Devastation</b> - Restores damaged stats\n\n" +
"<b>Tip:</b> Select a hero using the dropdown, or click a hero in the <b>Resident Heroes</b> panel to the left!");
registry.RegisterTutorial(improve, "command_panel_ImproveCommand");
// Alms command
var alms = CreateCommandPanelTutorial(
"command_alms",
"Give Alms",
"Give Alms distributes food to the people, quickly raising <b>Support</b>.\n\n" +
"This is the fastest way to boost Support, but costs Food from your stores. Use it when you need Support urgently!");
registry.RegisterTutorial(alms, "command_panel_AlmsCommand");
// March command
var march = CreateCommandPanelTutorial(
"command_march",
"March",
"March moves your army to another province.\n\n" +
"• Select a <b>destination</b> province\n" +
"• Choose which <b>heroes and battalions</b> to take\n" +
"• Optionally bring <b>supplies</b> (food and gold)\n\n" +
"If you march into enemy territory, battle may ensue!");
registry.RegisterTutorial(march, "command_panel_MarchCommand");
// Defend command
var defend = CreateCommandPanelTutorial(
"command_defend",
"Defend",
"Defend prepares your forces to repel attackers.\n\n" +
"Choose a <b>rally point</b> where your troops will gather. Units defending have advantages in tactical combat.");
registry.RegisterTutorial(defend, "command_panel_DefendCommand");
// Rest command
var rest = CreateCommandPanelTutorial(
"command_rest",
"Rest",
"Rest restores your heroes' <b>Vigor</b>.\n\n" +
"Heroes need rest to recover from exertion. Use this when your heroes are tired and you don't need to take other actions.");
registry.RegisterTutorial(rest, "command_panel_RestCommand");
// Trade command
var trade = CreateCommandPanelTutorial(
"command_trade",
"Trade",
"Trade lets you exchange <b>Food</b> for <b>Gold</b> or vice versa.\n\n" +
"The market takes its cut - buy and sell prices differ. Useful when you have surplus of one resource and need the other.");
registry.RegisterTutorial(trade, "command_panel_TradeCommand");
// Feast command
var feast = CreateCommandPanelTutorial(
"command_feast",
"Hold Feast",
"Hold a Feast to boost hero <b>Loyalty</b> and <b>Vigor</b>.\n\n" +
"The cost depends on how many heroes are in the province. A feast keeps your vassals happy and well-rested!");
registry.RegisterTutorial(feast, "command_panel_FeastCommand");
// Hero Gift command
var heroGift = CreateCommandPanelTutorial(
"command_hero_gift",
"Give Gift",
"Give a gift to increase a hero's <b>Loyalty</b>.\n\n" +
"Select a hero and choose how much gold to spend. More expensive gifts have greater effect.");
registry.RegisterTutorial(heroGift, "command_panel_HeroGiftCommand");
// Train command
var train = CreateCommandPanelTutorial(
"command_train",
"Train",
"Train improves your troops' <b>combat effectiveness</b>.\n\n" +
"Select a hero to lead training. Better trainers produce better results!");
registry.RegisterTutorial(train, "command_panel_TrainCommand");
// Arm Troops command
var armTroops = CreateCommandPanelTutorial(
"command_arm_troops",
"Arm Troops",
"Arm Troops equips your battalions with better weapons and armor.\n\n" +
"Higher <b>Infrastructure</b> allows better equipment. Well-armed troops are more effective in battle.");
registry.RegisterTutorial(armTroops, "command_panel_ArmTroopsCommand");
// Organize Troops command
var organizeTroops = CreateCommandPanelTutorial(
"command_organize_troops",
"Organize Troops",
"Organize Troops lets you <b>hire new battalions</b> and restructure existing ones.\n\n" +
"Light Infantry and Longbowmen are always available. Other battalion types require minimum <b>Economy</b> or <b>Agriculture</b> levels. You can also combine, split, or reassign troops between battalions.");
registry.RegisterTutorial(organizeTroops, "command_panel_OrganizeTroopsCommand");
// Recruit Heroes command
var recruitHeroes = CreateCommandPanelTutorial(
"command_recruit_heroes",
"Recruit Heroes",
"Recruit a free hero to join your faction!\n\n" +
"Heroes lead armies, govern provinces, and have special abilities. Choose wisely - their stats and professions vary.");
registry.RegisterTutorial(recruitHeroes, "command_panel_RecruitHeroesCommand");
// Travel command
var travel = CreateCommandPanelTutorial(
"command_travel",
"Travel",
"Travel sends your hero to <b>town</b> within the province.\n\n" +
"While traveling, you can do as many actions as you want in a turn: trade, arm troops, divine or recruit heroes, and manage prisoners. Use <b>Return</b> when done.");
registry.RegisterTutorial(travel, "command_panel_TravelCommand");
// Return command
var returnCmd = CreateCommandPanelTutorial(
"command_return",
"Return to Camp",
"Return your province leader from town back to camp.\n\n" +
"This ends <b>Travel</b> mode and completes your turn. Use it when you're done with town activities.");
registry.RegisterTutorial(returnCmd, "command_panel_ReturnCommand");
// Diplomacy command
var diplomacy = CreateCommandPanelTutorial(
"command_diplomacy",
"Diplomacy",
"Send a diplomatic envoy to another faction.\n\n" +
"Propose truces to pause hostilities, form alliances for mutual defense, or invite factions to join your cause. Select an ambassador - sending your leader is risky!");
registry.RegisterTutorial(diplomacy, "command_panel_DiplomacyCommand");
// Ransom command (uses DiplomacyCommand type but separate selector)
// Note: RansomCommandSelector also uses DiplomacyCommand, triggered by same event
// Send Supplies command
var sendSupplies = CreateCommandPanelTutorial(
"command_send_supplies",
"Send Supplies",
"Send gold and food to another province.\n\n" +
"Useful for resupplying distant armies or supporting provinces under siege. Select a hero to escort the supplies.");
registry.RegisterTutorial(sendSupplies, "command_panel_SendSuppliesCommand");
// Recon command
var recon = CreateCommandPanelTutorial(
"command_recon",
"Recon",
"Send a hero to scout an enemy province.\n\n" +
"Gather intelligence on enemy forces, resources, and defenses. Your scout may encounter danger!");
registry.RegisterTutorial(recon, "command_panel_ReconCommand");
// Divine command
var divine = CreateCommandPanelTutorial(
"command_divine",
"Divine Heroes",
"Use divination to learn a hero's hidden potential.\n\n" +
"Costs gold but reveals the hero's true abilities and stats. Divine all available heroes at once for efficiency.");
registry.RegisterTutorial(divine, "command_panel_DivineCommand");
// Issue Orders command
var issueOrders = CreateCommandPanelTutorial(
"command_issue_orders",
"Issue Orders",
"Set standing orders for your provinces.\n\n" +
"Orders determine how provinces behave each turn - prioritize military, economy, or defense. You can also designate a Focus Province for special attention.");
registry.RegisterTutorial(issueOrders, "command_panel_IssueOrdersCommand");
// Control Weather command
var controlWeather = CreateCommandPanelTutorial(
"command_control_weather",
"Control Weather",
"Use magical power to control the weather.\n\n" +
"Create favorable conditions for your forces or harsh weather to hinder enemies. Requires a hero with weather magic.");
registry.RegisterTutorial(
controlWeather,
"command_panel_ControlWeatherAvailableCommand");
// Swear Brotherhood command
var swearBrotherhood = CreateCommandPanelTutorial(
"command_swear_brotherhood",
"Swear Brotherhood",
"Swear an oath of brotherhood with a hero.\n\n" +
"Creates a powerful bond that boosts loyalty and grants special bonuses. Choose your sworn brothers wisely - this bond is sacred!");
registry.RegisterTutorial(swearBrotherhood, "command_panel_SwearBrotherhoodCommand");
// Apprehend Outlaw command
var apprehendOutlaw = CreateCommandPanelTutorial(
"command_apprehend_outlaw",
"Apprehend Outlaw",
"Capture an outlaw hiding in your province.\n\n" +
"Send a hero and optionally a battalion to apprehend the fugitive. Outlaws may be former heroes from defeated factions.");
registry.RegisterTutorial(apprehendOutlaw, "command_panel_ApprehendOutlawCommand");
// Suppress Beasts command
var suppressBeasts = CreateCommandPanelTutorial(
"command_suppress_beasts",
"Suppress Beasts",
"Deal with dangerous creatures threatening your province.\n\n" +
"Send a hero with a battalion to suppress the beasts. Going without troops is risky - your hero could be killed!");
registry.RegisterTutorial(suppressBeasts, "command_panel_SuppressBeastsCommand");
// Exile Vassal command
var exileVassal = CreateCommandPanelTutorial(
"command_exile_vassal",
"Exile Vassal",
"Exile a hero from your faction.\n\n" +
"Sometimes a disloyal or troublesome vassal must be removed. The exiled hero becomes an outlaw and may seek revenge!");
registry.RegisterTutorial(exileVassal, "command_panel_ExileVassalCommand");
// Handle Captured Hero command
var handleCapturedHero = CreateCommandPanelTutorial(
"command_handle_captured_hero",
"Handle Captured Hero",
"Decide the fate of a captured enemy hero.\n\n" +
"You can recruit them to your cause, imprison them for ransom, exile them, execute them, or return them for diplomatic favor.");
registry.RegisterTutorial(
handleCapturedHero,
"command_panel_HandleCapturedHeroCommand");
// Manage Prisoners command
var managePrisoners = CreateCommandPanelTutorial(
"command_manage_prisoners",
"Manage Prisoners",
"Manage heroes you've imprisoned.\n\n" +
"Release them, move them between provinces, exile them, execute them, or return them to their faction for diplomatic benefit.");
registry.RegisterTutorial(managePrisoners, "command_panel_ManagePrisonerCommand");
// Decline Quest command
var declineQuest = CreateCommandPanelTutorial(
"command_decline_quest",
"Decline Quest",
"Decline a quest offered by a hero.\n\n" +
"Some heroes seek specific quests before joining. If you can't fulfill their request, you may decline - but they may leave.");
registry.RegisterTutorial(declineQuest, "command_panel_DeclineQuestCommand");
// Start Epidemic command
var startEpidemic = CreateCommandPanelTutorial(
"command_start_epidemic",
"Start Plague",
"Unleash a deadly plague upon an enemy province.\n\n" +
"A devastating but dishonorable tactic. Requires a hero with plague magic. The disease will spread and cause great suffering.");
registry.RegisterTutorial(startEpidemic, "command_panel_StartEpidemicCommand");
// Handle Riot - Crack Down command
var handleRiotCrackDown = CreateCommandPanelTutorial(
"command_handle_riot_crack_down",
"Crack Down",
"Use force to suppress the rioting populace.\n\n" +
"Send a hero with troops to restore order. Effective but may damage your reputation and reduce support.");
registry.RegisterTutorial(
handleRiotCrackDown,
"command_panel_HandleRiotCrackDownAvailableCommand");
// Handle Riot - Do Nothing command
var handleRiotDoNothing = CreateCommandPanelTutorial(
"command_handle_riot_do_nothing",
"Ignore Riot",
"Choose to ignore the riot and let it run its course.\n\n" +
"The unrest may subside on its own, or it may worsen. A risky choice that avoids immediate costs.");
registry.RegisterTutorial(
handleRiotDoNothing,
"command_panel_HandleRiotDoNothingAvailableCommand");
// Handle Riot - Give command
var handleRiotGive = CreateCommandPanelTutorial(
"command_handle_riot_give",
"Give to Populace",
"Appease the rioters with gifts of food or gold.\n\n" +
"A peaceful solution that costs resources but preserves your reputation. Choose how much to give.");
registry.RegisterTutorial(
handleRiotGive,
"command_panel_HandleRiotGiveAvailableCommand");
// Attack Decision command
var attackDecision = CreateCommandPanelTutorial(
"command_attack_decision",
"Attack Decision",
"Your army has encountered enemy forces! Choose your action.\n\n" +
"Advance to attack, demand tribute, request safe passage, or withdraw. Consider your strength before engaging.");
registry.RegisterTutorial(attackDecision, "command_panel_AttackDecisionCommand");
// Free For All Decision command
var freeForAllDecision = CreateCommandPanelTutorial(
"command_free_for_all_decision",
"Free-For-All Battle",
"Multiple armies have converged! A chaotic battle is imminent.\n\n" +
"You must decide whether to join the fray or attempt to withdraw from this dangerous situation.");
registry.RegisterTutorial(
freeForAllDecision,
"command_panel_FreeForAllDecisionCommand");
// Resolve Tribute command
var resolveTribute = CreateCommandPanelTutorial(
"command_resolve_tribute",
"Tribute Demanded",
"Another faction is demanding tribute from you.\n\n" +
"You can pay to avoid conflict, refuse and risk war, or imprison the messenger (a hostile act).");
registry.RegisterTutorial(resolveTribute, "command_panel_ResolveTributeCommand");
}
/// <summary>
/// Creates a command panel tutorial with onboarding as a prerequisite.
/// </summary>
private static TutorialSequence
CreateCommandPanelTutorial(string id, string title, string description) {
var sequence = ScriptableObject.CreateInstance<TutorialSequence>();
sequence.SequenceId = id;
sequence.DisplayName = title;
sequence.IsOnboarding = false;
// Require onboarding completion before showing command tutorials
sequence.RequiredCompletedTutorials = new List<string> { "onboarding" };
sequence.Steps = new List<TutorialStep> { new TutorialStep {
StepId = id + "_step",
Title = title,
Description = description,
DisplayMode = TutorialDisplayMode.Modal,
BlocksInteraction = false,
// Position above the command selector panel
PanelAnchor = TutorialPanelAnchor.Top,
AdjacentTargetPath = "CommandPanel",
CompletionType = TutorialCompletionType.ButtonClick,
AllowSkip = true
} };
return sequence;
}
// ========== STRATEGIC CONTEXTUAL TUTORIALS ==========
private static void RegisterStrategicTutorials(TutorialTriggerRegistry registry) {
// Diplomacy introduction
var diplomacy = CreateSingleStepTutorial(
"diplomacy_intro",
"Diplomacy",
"You can negotiate with other factions!\n\nOffer alliances, declare war, or propose tribute. Your diplomatic choices shape the realm.",
TutorialDisplayMode.Modal);
registry.RegisterTutorial(diplomacy, "diplomacy_available");
// Hero recruitment
var heroRecruitment = CreateSingleStepTutorial(
"hero_recruitment",
@@ -64,6 +64,37 @@ namespace Eagle0.Tutorial {
/// </summary>
public int StepCount => Steps?.Count ?? 0;
/// <summary>
/// Number of visible steps up to and including the first MarksOnboardingComplete step.
/// This gives an accurate count for the current "phase" of onboarding.
/// </summary>
public int VisibleStepCount {
get {
if (Steps == null) return 0;
int count = 0;
foreach (var step in Steps) {
if (step.DisplayMode != TutorialDisplayMode.None) count++;
// Stop counting after the step that marks onboarding complete
if (step.MarksOnboardingComplete) break;
}
return count;
}
}
/// <summary>
/// Gets the visible step index for a given actual step index.
/// Returns 0-based index among visible steps only.
/// </summary>
public int GetVisibleStepIndex(int actualIndex) {
if (Steps == null || actualIndex < 0) return 0;
int visibleIndex = 0;
for (int i = 0; i < actualIndex && i < Steps.Count; i++) {
if (Steps[i].DisplayMode != TutorialDisplayMode.None) visibleIndex++;
}
return visibleIndex;
}
/// <summary>
/// Checks if all prerequisites are met.
/// </summary>
@@ -22,6 +22,26 @@ namespace Eagle0.Tutorial {
None
}
/// <summary>
/// Where to position the tutorial panel on screen.
/// </summary>
public enum TutorialPanelAnchor {
/// <summary>Centered on screen (default modal behavior).</summary>
Center,
/// <summary>Panel anchored to left side of screen.</summary>
Left,
/// <summary>Panel anchored to right side of screen.</summary>
Right,
/// <summary>Panel anchored to top of screen.</summary>
Top,
/// <summary>Panel anchored to bottom of screen.</summary>
Bottom
}
/// <summary>
/// How the tutorial step is completed/advanced.
/// </summary>
@@ -66,16 +86,31 @@ namespace Eagle0.Tutorial {
[Tooltip("How this step should be displayed")]
public TutorialDisplayMode DisplayMode = TutorialDisplayMode.Modal;
[Tooltip("Whether this step blocks game interaction (false = user can interact with game)")]
public bool BlocksInteraction = true;
[Tooltip("Where to position the tutorial panel on screen")]
public TutorialPanelAnchor PanelAnchor = TutorialPanelAnchor.Center;
[Header("UI Targeting")]
[Tooltip("Path to GameObject to highlight (e.g., 'Canvas/CommandPanel/MarchButton')")]
public string TargetGameObjectPath;
[Tooltip("Additional targets to highlight together (combined bounding box)")]
public string[] AdditionalHighlightTargets;
[Tooltip("Path to GameObject to position the panel adjacent to (for non-blocking modals)")]
public string AdjacentTargetPath;
[Tooltip("Offset from target element for tooltip/arrow positioning")]
public Vector2 HighlightOffset;
[Tooltip("Whether the highlight should pulse")]
public bool HighlightPulsing = true;
[Tooltip("Compute highlight bounds from active children instead of target's RectTransform")]
public bool HighlightBoundsFromChildren;
[Header("Completion")]
[Tooltip("How this step is completed")]
public TutorialCompletionType CompletionType = TutorialCompletionType.ButtonClick;
@@ -93,6 +128,10 @@ namespace Eagle0.Tutorial {
[Tooltip("Jump to specific step ID instead of sequential (empty = next step)")]
public string NextStepOverride;
[Tooltip(
"Marks onboarding as complete when this step finishes (for mid-sequence completion)")]
public bool MarksOnboardingComplete;
[Header("Audio")]
[Tooltip("Optional narration audio")]
public AudioClip NarrationClip;
@@ -80,6 +80,36 @@ namespace Eagle0.Tutorial {
}
}
// ========== Command Panel Events ==========
// Track the last command type shown so we can re-trigger after onboarding
private AvailableCommand.SealedValueOneofCase? _lastCommandTypeShown;
/// <summary>
/// Called when a command panel is shown in the strategic view.
/// Triggers contextual tutorials for each command type.
/// </summary>
public void OnCommandPanelShown(AvailableCommand.SealedValueOneofCase commandType) {
_lastCommandTypeShown = commandType;
string eventId = $"command_panel_{commandType}";
OnGameEvent(eventId);
}
/// <summary>
/// Re-triggers the last command panel tutorial.
/// Called after onboarding completes to show tutorial for already-selected command.
/// </summary>
public void RetriggerLastCommandTutorial() {
if (_lastCommandTypeShown.HasValue) {
Debug.Log(
$"[Tutorial] RetriggerLastCommandTutorial: Triggering for {_lastCommandTypeShown.Value}");
string eventId = $"command_panel_{_lastCommandTypeShown.Value}";
OnGameEvent(eventId);
} else {
Debug.Log("[Tutorial] RetriggerLastCommandTutorial: No last command type recorded");
}
}
// ========== Strategic Layer Events ==========
/// <summary>
@@ -23,6 +23,9 @@ namespace Eagle0.Tutorial {
[Tooltip("Reference to the tutorial UI manager")]
public TutorialUIManager UIManager;
[Tooltip("Registry of UI targets for tutorials")]
public TutorialTargetRegistry TargetRegistry;
[Header("Debug")]
[Tooltip("Enable verbose logging")]
public bool DebugLogging;
@@ -147,15 +150,48 @@ namespace Eagle0.Tutorial {
/// </summary>
public void TriggerContextualTutorial(string tutorialId) {
if (!TutorialsEnabled || _state.AllTutorialsDisabled) return;
if (_state.HasCompletedTutorial(tutorialId)) return;
// Don't interrupt an active tutorial with a contextual one
// (onboarding can be interrupted by explicit StartSequence calls)
if (_activeSequence != null) {
Log($"TriggerContextualTutorial: Skipping '{tutorialId}' - another tutorial is active");
bool isAlreadyCompleted = _state.HasCompletedTutorial(tutorialId);
bool isCommandTutorial = tutorialId.StartsWith("command_");
bool activeIsCommandTutorial =
_activeSequence?.SequenceId.StartsWith("command_") ?? false;
// If switching to a completed command tutorial while another command tutorial
// is showing, just hide the current one (don't mark it complete)
if (isAlreadyCompleted && isCommandTutorial && activeIsCommandTutorial) {
Log($"TriggerContextualTutorial: '{tutorialId}' already completed, " +
$"hiding active command tutorial '{_activeSequence.SequenceId}'");
StopCurrentSequence(skipped: true);
return;
}
if (isAlreadyCompleted) return;
// Check if we should allow interruption
if (_activeSequence != null) {
var currentStep = CurrentStep;
bool isHiddenStep = currentStep?.DisplayMode == TutorialDisplayMode.None;
// Allow interruption if:
// 1. Current step is hidden (DisplayMode.None) - waiting for async events
// 2. Both tutorials are command tutorials - allow switching between commands
bool bothAreCommandTutorials = isCommandTutorial && activeIsCommandTutorial;
if (!isHiddenStep && !bothAreCommandTutorials) {
Log($"TriggerContextualTutorial: Skipping '{tutorialId}' - another tutorial is active");
return;
}
if (bothAreCommandTutorials && _activeSequence.SequenceId == tutorialId) {
// Same command tutorial already showing, don't restart
return;
}
Log($"TriggerContextualTutorial: Allowing '{tutorialId}' to interrupt " +
(isHiddenStep ? "hidden step"
: $"command tutorial '{_activeSequence.SequenceId}'"));
}
// Look up the tutorial sequence by ID
var sequence = _triggerRegistry?.GetSequenceById(tutorialId);
if (sequence != null) { StartSequence(sequence); }
@@ -168,6 +204,18 @@ namespace Eagle0.Tutorial {
if (_activeSequence == null) return;
var currentStep = CurrentStep;
// Check if this step marks onboarding as complete (mid-sequence)
bool shouldRetriggerCommandTutorial = false;
if (currentStep != null && currentStep.MarksOnboardingComplete &&
_activeSequence.IsOnboarding) {
Log("Marking onboarding complete (mid-sequence)");
_state.CompleteOnboarding();
_state.MarkTutorialCompleted(_activeSequence.SequenceId);
// Defer re-triggering until after we advance to the hidden step
shouldRetriggerCommandTutorial = true;
}
string nextStepId = currentStep?.NextStepOverride;
// Determine next step index
@@ -192,6 +240,11 @@ namespace Eagle0.Tutorial {
Log($"Advanced to step {_currentStepIndex}: '{CurrentStep?.StepId}'");
ShowCurrentStep();
// Now that we've advanced to the hidden step, re-trigger command tutorial
if (shouldRetriggerCommandTutorial) {
_triggerRegistry?.RetriggerLastCommandTutorial();
}
}
/// <summary>
@@ -297,12 +350,14 @@ namespace Eagle0.Tutorial {
// Handle different display modes
switch (step.DisplayMode) {
case TutorialDisplayMode.Modal:
// Use visible step index/count for better UX (excludes hidden steps)
int visibleIndex = _activeSequence.GetVisibleStepIndex(_currentStepIndex);
int visibleCount = _activeSequence.VisibleStepCount;
UIManager?.ShowModal(
step,
_currentStepIndex,
_activeSequence.StepCount,
visibleIndex,
visibleCount,
OnStepComplete,
OnStepSkip,
_activeSequence.IsOnboarding);
break;
@@ -343,8 +398,6 @@ namespace Eagle0.Tutorial {
private void OnStepComplete() { AdvanceStep(); }
private void OnStepSkip() { SkipCurrentStep(); }
private void CompleteCurrentSequence() {
if (_activeSequence == null) return;
@@ -0,0 +1,107 @@
using System.Collections.Generic;
using UnityEngine;
namespace Eagle0.Tutorial {
/// <summary>
/// Registry of UI elements that tutorials can reference.
/// Assign static targets via Inspector, register dynamic targets via code.
/// </summary>
public class TutorialTargetRegistry : MonoBehaviour {
[Header("Province Info Panel")]
[Tooltip("The main Province Info panel")]
public RectTransform ProvinceInfoPanel;
[Tooltip("The Support value display")]
public RectTransform SupportField;
[Tooltip("The Agriculture value display")]
public RectTransform AgricultureField;
[Tooltip("The Economy value display")]
public RectTransform EconomyField;
[Tooltip("The Infrastructure value display")]
public RectTransform InfrastructureField;
[Header("Heroes Panel")]
[Tooltip("The Resident Heroes panel")]
public RectTransform HeroesPanel;
[Header("Command Panel")]
[Tooltip("The command selector panel (parent of individual command selectors)")]
public RectTransform CommandPanel;
[Header("Command Buttons")]
[Tooltip("Container for command buttons")]
public RectTransform CommandButtonsPanel;
[Tooltip("The Commit button")]
public RectTransform CommitButton;
// Dynamic targets registered at runtime (e.g., command buttons from prefabs)
private Dictionary<string, RectTransform> _dynamicTargets =
new Dictionary<string, RectTransform>();
/// <summary>
/// Registers a target at runtime. Use for prefab-instantiated UI elements.
/// </summary>
public void RegisterTarget(string targetId, RectTransform target) {
if (string.IsNullOrEmpty(targetId) || target == null) return;
_dynamicTargets[targetId] = target;
}
/// <summary>
/// Unregisters a dynamically registered target.
/// </summary>
public void UnregisterTarget(string targetId) {
if (!string.IsNullOrEmpty(targetId)) { _dynamicTargets.Remove(targetId); }
}
/// <summary>
/// Gets a target by its string ID.
/// Checks static Inspector fields first, then dynamic registrations.
/// </summary>
public RectTransform GetTarget(string targetId) {
if (string.IsNullOrEmpty(targetId)) return null;
// Check static targets first
var staticTarget = targetId switch { "ProvinceInfoPanel" => ProvinceInfoPanel,
"SupportField" => SupportField,
"AgricultureField" => AgricultureField,
"EconomyField" => EconomyField,
"InfrastructureField" => InfrastructureField,
"HeroesPanel" => HeroesPanel,
"CommandPanel" => CommandPanel,
"CommandButtonsPanel" => CommandButtonsPanel,
"CommitButton" => CommitButton,
_ => null };
if (staticTarget != null) return staticTarget;
// Check dynamic targets
if (_dynamicTargets.TryGetValue(targetId, out var dynamicTarget)) {
return dynamicTarget;
}
return null;
}
/// <summary>
/// Gets a target, with fallback to GameObject.Find for unregistered paths.
/// </summary>
public Transform GetTargetOrFind(string targetId) {
var registered = GetTarget(targetId);
if (registered != null) return registered;
// Fallback to path-based lookup for unregistered targets
var found = GameObject.Find(targetId);
if (found != null) {
Debug.LogWarning(
$"TutorialTargetRegistry: '{targetId}' not registered, using GameObject.Find fallback");
return found.transform;
}
return null;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: be44269baefd349e99d5ed5f6004234b
@@ -85,7 +85,7 @@ namespace Eagle0.Tutorial {
RectTransform panelRect = panel.AddComponent<RectTransform>();
panelRect.anchorMin = new Vector2(0.5f, 0.5f);
panelRect.anchorMax = new Vector2(0.5f, 0.5f);
panelRect.sizeDelta = new Vector2(800, 550);
panelRect.sizeDelta = new Vector2(540, 500);
panelRect.anchoredPosition = Vector2.zero;
// Panel Background
@@ -100,8 +100,8 @@ namespace Eagle0.Tutorial {
// Add vertical layout group
VerticalLayoutGroup layout = panel.AddComponent<VerticalLayoutGroup>();
layout.padding = new RectOffset(40, 40, 30, 30);
layout.spacing = 20;
layout.padding = new RectOffset(20, 20, 15, 15);
layout.spacing = 10;
layout.childAlignment = TextAnchor.UpperCenter;
layout.childControlHeight = false;
layout.childControlWidth = true;
@@ -134,18 +134,18 @@ namespace Eagle0.Tutorial {
titleObj.transform.SetParent(parent, false);
RectTransform rect = titleObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(720, 60);
rect.sizeDelta = new Vector2(460, 36);
TextMeshProUGUI text = titleObj.AddComponent<TextMeshProUGUI>();
text.text = "Tutorial";
text.fontSize = 42;
text.fontSize = 28;
text.fontStyle = FontStyles.Bold;
text.color = TitleColor;
text.alignment = TextAlignmentOptions.Center;
if (font != null) text.font = font;
LayoutElement layoutElement = titleObj.AddComponent<LayoutElement>();
layoutElement.preferredHeight = 60;
layoutElement.preferredHeight = 36;
}
private static void CreateIcon(Transform parent) {
@@ -172,11 +172,11 @@ namespace Eagle0.Tutorial {
descObj.transform.SetParent(parent, false);
RectTransform rect = descObj.AddComponent<RectTransform>();
rect.sizeDelta = new Vector2(720, 200);
rect.sizeDelta = new Vector2(460, 180);
TextMeshProUGUI text = descObj.AddComponent<TextMeshProUGUI>();
text.text = "";
text.fontSize = 26;
text.fontSize = 18;
text.color = TextColor;
text.alignment = TextAlignmentOptions.TopLeft;
text.enableWordWrapping = true;
@@ -184,8 +184,8 @@ namespace Eagle0.Tutorial {
if (font != null) text.font = font;
LayoutElement layoutElement = descObj.AddComponent<LayoutElement>();
layoutElement.preferredHeight = 200;
layoutElement.minHeight = 100;
layoutElement.preferredHeight = 180;
layoutElement.minHeight = 80;
}
private static void CreateSpacer(Transform parent, float height) {
@@ -233,7 +233,7 @@ namespace Eagle0.Tutorial {
TextMeshProUGUI progressText = progressTextObj.AddComponent<TextMeshProUGUI>();
progressText.text = "Step 1 of 1";
progressText.fontSize = 20;
progressText.fontSize = 14;
progressText.color = TextColor;
progressText.alignment = TextAlignmentOptions.Center;
if (font != null) progressText.font = font;
@@ -308,10 +308,6 @@ namespace Eagle0.Tutorial {
panel.ContinueButton.onClick.RemoveAllListeners();
panel.ContinueButton.onClick.AddListener(panel.OnContinueClicked);
}
if (panel.SkipButton != null) {
panel.SkipButton.onClick.RemoveAllListeners();
panel.SkipButton.onClick.AddListener(panel.OnSkipClicked);
}
if (panel.SkipAllButton != null) {
panel.SkipAllButton.onClick.RemoveAllListeners();
panel.SkipAllButton.onClick.AddListener(panel.OnSkipAllClicked);
@@ -334,13 +330,10 @@ namespace Eagle0.Tutorial {
layout.childForceExpandWidth = false;
LayoutElement layoutElement = buttonRow.AddComponent<LayoutElement>();
layoutElement.preferredHeight = 60;
layoutElement.preferredHeight = 44;
// Skip Button (left)
CreateButton(buttonRow.transform, "SkipButton", "Skip", 150, 50, font);
// Skip All Button
CreateButton(buttonRow.transform, "SkipAllButton", "Skip All", 150, 50, font);
// Skip Tutorial Button (left) - only shown during onboarding
CreateButton(buttonRow.transform, "SkipAllButton", "Skip Tutorial", 140, 36, font);
// Spacer
GameObject spacer = new GameObject("ButtonSpacer");
@@ -350,7 +343,7 @@ namespace Eagle0.Tutorial {
spacerLayout.flexibleWidth = 1;
// Continue Button (right)
CreateButton(buttonRow.transform, "ContinueButton", "Continue", 180, 50, font);
CreateButton(buttonRow.transform, "ContinueButton", "Continue", 120, 36, font);
}
private static GameObject CreateButton(
@@ -398,7 +391,7 @@ namespace Eagle0.Tutorial {
TextMeshProUGUI buttonText = textObj.AddComponent<TextMeshProUGUI>();
buttonText.text = text;
buttonText.fontSize = 24;
buttonText.fontSize = 16;
buttonText.color = ButtonTextColor;
buttonText.alignment = TextAlignmentOptions.Center;
if (font != null) buttonText.font = font;
@@ -450,11 +443,6 @@ namespace Eagle0.Tutorial {
// Button Row
Transform buttonRow = containerTransform.Find("ButtonRow");
if (buttonRow != null) {
Transform skipTransform = buttonRow.Find("SkipButton");
if (skipTransform != null) {
panel.SkipButton = skipTransform.GetComponent<Button>();
}
Transform skipAllTransform = buttonRow.Find("SkipAllButton");
if (skipAllTransform != null) {
panel.SkipAllButton = skipAllTransform.GetComponent<Button>();
@@ -26,10 +26,7 @@ namespace Eagle0.Tutorial {
[Tooltip("Text on continue button")]
public TextMeshProUGUI ContinueButtonText;
[Tooltip("Skip this step button")]
public Button SkipButton;
[Tooltip("Skip all tutorials button (onboarding only)")]
[Tooltip("Skip tutorial button (onboarding only)")]
public Button SkipAllButton;
[Header("Progress")]
@@ -52,12 +49,10 @@ namespace Eagle0.Tutorial {
// Callbacks
private Action _onContinue;
private Action _onSkip;
private void Awake() {
// Set up button listeners
if (ContinueButton != null) { ContinueButton.onClick.AddListener(OnContinueClicked); }
if (SkipButton != null) { SkipButton.onClick.AddListener(OnSkipClicked); }
if (SkipAllButton != null) { SkipAllButton.onClick.AddListener(OnSkipAllClicked); }
// Start hidden
@@ -72,10 +67,8 @@ namespace Eagle0.Tutorial {
int currentIndex,
int totalSteps,
Action onContinue,
Action onSkip,
bool isOnboarding) {
_onContinue = onContinue;
_onSkip = onSkip;
// Set content
if (TitleText != null) { TitleText.text = step.Title ?? ""; }
@@ -96,8 +89,7 @@ namespace Eagle0.Tutorial {
ContinueButtonText.text = currentIndex >= totalSteps - 1 ? "Got it!" : "Continue";
}
// Show/hide skip buttons
if (SkipButton != null) { SkipButton.gameObject.SetActive(step.AllowSkip); }
// Show Skip Tutorial button only during onboarding (and when skipping is allowed)
if (SkipAllButton != null) {
SkipAllButton.gameObject.SetActive(isOnboarding && step.AllowSkip);
}
@@ -112,9 +104,19 @@ namespace Eagle0.Tutorial {
ProgressSlider.gameObject.SetActive(totalSteps > 1);
}
// Position panel - either adjacent to target or based on anchor setting
if (!string.IsNullOrEmpty(step.AdjacentTargetPath)) {
PositionAdjacentTo(step.AdjacentTargetPath, step.PanelAnchor);
} else {
PositionPanel(step.PanelAnchor);
}
// Show panel - ensure all parent containers are active first
ActivateParents();
if (ModalBlocker != null) { ModalBlocker.SetActive(true); }
// Only show modal blocker if step blocks interaction
if (ModalBlocker != null) { ModalBlocker.SetActive(step.BlocksInteraction); }
if (PanelContainer != null) { PanelContainer.SetActive(true); }
gameObject.SetActive(true);
@@ -122,6 +124,170 @@ namespace Eagle0.Tutorial {
if (PanelAnimator != null) { PanelAnimator.SetTrigger("Show"); }
}
/// <summary>
/// Positions the panel based on the anchor setting.
/// </summary>
private void PositionPanel(TutorialPanelAnchor anchor) {
if (PanelContainer == null) return;
RectTransform rect = PanelContainer.GetComponent<RectTransform>();
if (rect == null) return;
// Store original size
Vector2 size = rect.sizeDelta;
switch (anchor) {
case TutorialPanelAnchor.Center:
rect.anchorMin = new Vector2(0.5f, 0.5f);
rect.anchorMax = new Vector2(0.5f, 0.5f);
rect.pivot = new Vector2(0.5f, 0.5f);
rect.anchoredPosition = Vector2.zero;
break;
case TutorialPanelAnchor.Left:
rect.anchorMin = new Vector2(0f, 0.5f);
rect.anchorMax = new Vector2(0f, 0.5f);
rect.pivot = new Vector2(0f, 0.5f);
rect.anchoredPosition = new Vector2(20f, 0f); // 20px padding from edge
break;
case TutorialPanelAnchor.Right:
rect.anchorMin = new Vector2(1f, 0.5f);
rect.anchorMax = new Vector2(1f, 0.5f);
rect.pivot = new Vector2(1f, 0.5f);
rect.anchoredPosition = new Vector2(-20f, 0f); // 20px padding from edge
break;
case TutorialPanelAnchor.Top:
rect.anchorMin = new Vector2(0.5f, 1f);
rect.anchorMax = new Vector2(0.5f, 1f);
rect.pivot = new Vector2(0.5f, 1f);
rect.anchoredPosition = new Vector2(0f, -20f); // 20px padding from edge
break;
case TutorialPanelAnchor.Bottom:
rect.anchorMin = new Vector2(0.5f, 0f);
rect.anchorMax = new Vector2(0.5f, 0f);
rect.pivot = new Vector2(0.5f, 0f);
rect.anchoredPosition = new Vector2(0f, 20f); // 20px padding from edge
break;
}
// Restore size after changing anchors
rect.sizeDelta = size;
}
/// <summary>
/// Positions the panel adjacent to a target GameObject.
/// </summary>
private void PositionAdjacentTo(string targetId, TutorialPanelAnchor side) {
if (PanelContainer == null) return;
// Use registry to find target
Transform target = GetTarget(targetId);
if (target == null) {
Debug.LogWarning($"TutorialModalPanel: Could not find target '{targetId}'");
PositionPanel(side);
return;
}
RectTransform targetRect = target.GetComponent<RectTransform>();
if (targetRect == null) {
PositionPanel(side);
return;
}
RectTransform panelRect = PanelContainer.GetComponent<RectTransform>();
if (panelRect == null) return;
Vector2 size = panelRect.sizeDelta;
// Get target bounds in screen space
Vector3[] targetCorners = new Vector3[4];
targetRect.GetWorldCorners(targetCorners);
// Get canvas for coordinate conversion
Canvas canvas = GetComponentInParent<Canvas>();
RectTransform canvasRect = canvas?.GetComponent<RectTransform>();
if (canvasRect == null) {
PositionPanel(side);
return;
}
// Convert target corners to canvas space
for (int i = 0; i < 4; i++) {
RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvasRect,
RectTransformUtility.WorldToScreenPoint(
canvas.worldCamera,
targetCorners[i]),
canvas.worldCamera,
out Vector2 localPoint);
targetCorners[i] = localPoint;
}
// Calculate target bounds
float targetLeft = Mathf.Min(targetCorners[0].x, targetCorners[1].x);
float targetRight = Mathf.Max(targetCorners[2].x, targetCorners[3].x);
float targetBottom = Mathf.Min(targetCorners[0].y, targetCorners[3].y);
float targetTop = Mathf.Max(targetCorners[1].y, targetCorners[2].y);
float targetCenterX = (targetLeft + targetRight) / 2f;
float targetCenterY = (targetTop + targetBottom) / 2f;
// Use center anchor for the panel
panelRect.anchorMin = new Vector2(0.5f, 0.5f);
panelRect.anchorMax = new Vector2(0.5f, 0.5f);
panelRect.pivot = new Vector2(0.5f, 0.5f);
float padding = 20f;
Vector2 position;
// Position based on which side
switch (side) {
case TutorialPanelAnchor.Right:
// Position panel to the right of target
position = new Vector2(targetRight + size.x / 2f + padding, targetCenterY);
break;
case TutorialPanelAnchor.Left:
// Position panel to the left of target
position = new Vector2(targetLeft - size.x / 2f - padding, targetCenterY);
break;
case TutorialPanelAnchor.Top:
// Position panel above target
position = new Vector2(targetCenterX, targetTop + size.y / 2f + padding);
break;
case TutorialPanelAnchor.Bottom:
// Position panel below target
position = new Vector2(targetCenterX, targetBottom - size.y / 2f - padding);
break;
default:
// For Center, fall back to standard positioning
PositionPanel(side);
return;
}
// Clamp to screen bounds (with padding)
float canvasHalfWidth = canvasRect.rect.width / 2f;
float canvasHalfHeight = canvasRect.rect.height / 2f;
float screenPadding = 10f;
float minX = -canvasHalfWidth + size.x / 2f + screenPadding;
float maxX = canvasHalfWidth - size.x / 2f - screenPadding;
float minY = -canvasHalfHeight + size.y / 2f + screenPadding;
float maxY = canvasHalfHeight - size.y / 2f - screenPadding;
position.x = Mathf.Clamp(position.x, minX, maxX);
position.y = Mathf.Clamp(position.y, minY, maxY);
panelRect.anchoredPosition = position;
panelRect.sizeDelta = size;
}
/// <summary>
/// Activates all parent GameObjects to ensure this object can be active in hierarchy.
/// </summary>
@@ -144,7 +310,6 @@ namespace Eagle0.Tutorial {
gameObject.SetActive(false);
_onContinue = null;
_onSkip = null;
}
/// <summary>
@@ -158,22 +323,28 @@ namespace Eagle0.Tutorial {
}
/// <summary>
/// Called when Skip button is clicked.
/// Public to allow external setup of button listeners.
/// </summary>
public void OnSkipClicked() {
var callback = _onSkip;
Hide();
callback?.Invoke();
}
/// <summary>
/// Called when Skip All button is clicked.
/// Public to allow external setup of button listeners.
/// Called when Skip Tutorial button is clicked.
/// Skips all remaining tutorial steps. User can restart from Settings.
/// </summary>
public void OnSkipAllClicked() {
Hide();
TutorialManager.Instance?.SkipAllOnboarding();
}
/// <summary>
/// Gets a target Transform by ID, using the registry if available.
/// Falls back to GameObject.Find for unregistered targets.
/// </summary>
private Transform GetTarget(string targetId) {
if (string.IsNullOrEmpty(targetId)) return null;
// Try registry first
var registry = TutorialManager.Instance?.TargetRegistry;
if (registry != null) { return registry.GetTargetOrFind(targetId); }
// Fallback to GameObject.Find
var targetObj = GameObject.Find(targetId);
return targetObj?.transform;
}
}
}
@@ -112,11 +112,15 @@ namespace Eagle0.Tutorial {
/// <summary>
/// Shows only the highlight without tooltip (for emphasis).
/// </summary>
public void HighlightOnly(Transform target) {
public void HighlightOnly(Transform target, bool boundsFromChildren = false) {
_currentTarget = target;
if (target != null) {
PositionHighlight(target);
if (boundsFromChildren) {
PositionHighlightFromChildren(target);
} else {
PositionHighlight(target);
}
if (HighlightFrame != null) { HighlightFrame.gameObject.SetActive(true); }
}
@@ -130,6 +134,103 @@ namespace Eagle0.Tutorial {
_pulseCoroutine = StartCoroutine(PulseHighlight());
}
/// <summary>
/// Shows highlight around multiple targets with a combined bounding box.
/// </summary>
public void HighlightMultiple(Transform[] targets) {
if (targets == null || targets.Length == 0) {
ClearHighlight();
return;
}
// Use first target as "current" for tracking
_currentTarget = targets[0];
PositionHighlightMultiple(targets);
if (HighlightFrame != null) { HighlightFrame.gameObject.SetActive(true); }
// Hide tooltip elements
if (TooltipContainer != null) { TooltipContainer.gameObject.SetActive(false); }
if (BackgroundDimmer != null) { BackgroundDimmer.gameObject.SetActive(false); }
// Ensure all parent containers are active first
ActivateParents();
gameObject.SetActive(true);
_pulseCoroutine = StartCoroutine(PulseHighlight());
}
private void PositionHighlightMultiple(Transform[] targets) {
if (HighlightFrame == null || targets == null || targets.Length == 0) return;
Canvas canvas = GetComponentInParent<Canvas>();
RectTransform canvasRect = canvas?.GetComponent<RectTransform>();
if (canvasRect == null) {
HighlightFrame.gameObject.SetActive(false);
return;
}
Camera cam = canvas.worldCamera;
// Initialize bounds with extreme values
float minX = float.MaxValue, maxX = float.MinValue;
float minY = float.MaxValue, maxY = float.MinValue;
foreach (var target in targets) {
if (target == null) continue;
RectTransform targetRect = target as RectTransform;
if (targetRect == null) { targetRect = target.GetComponent<RectTransform>(); }
if (targetRect == null) continue;
// Get target bounds in world space
Vector3[] corners = new Vector3[4];
targetRect.GetWorldCorners(corners);
// Convert to canvas-local and expand bounds
for (int i = 0; i < 4; i++) {
Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(cam, corners[i]);
RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvasRect,
screenPoint,
cam,
out Vector2 localPoint);
minX = Mathf.Min(minX, localPoint.x);
maxX = Mathf.Max(maxX, localPoint.x);
minY = Mathf.Min(minY, localPoint.y);
maxY = Mathf.Max(maxY, localPoint.y);
}
}
if (minX == float.MaxValue) {
HighlightFrame.gameObject.SetActive(false);
return;
}
// Clamp bounds to stay within canvas (accounting for padding that will be added)
float canvasHalfWidth = canvasRect.rect.width / 2f;
float canvasHalfHeight = canvasRect.rect.height / 2f;
float margin = 5f + HighlightPadding;
minX = Mathf.Max(minX, -canvasHalfWidth + margin);
maxX = Mathf.Min(maxX, canvasHalfWidth - margin);
minY = Mathf.Max(minY, -canvasHalfHeight + margin);
maxY = Mathf.Min(maxY, canvasHalfHeight - margin);
// Position and size highlight to encompass all targets
Vector2 center = new Vector2((minX + maxX) / 2, (minY + maxY) / 2);
Vector2 size = new Vector2(
maxX - minX + HighlightPadding * 2,
maxY - minY + HighlightPadding * 2);
HighlightFrame.anchorMin = new Vector2(0.5f, 0.5f);
HighlightFrame.anchorMax = new Vector2(0.5f, 0.5f);
HighlightFrame.pivot = new Vector2(0.5f, 0.5f);
HighlightFrame.anchoredPosition = center;
HighlightFrame.sizeDelta = size;
HighlightFrame.gameObject.SetActive(true);
}
/// <summary>
/// Clears highlight without hiding overlay.
/// </summary>
@@ -171,35 +272,155 @@ namespace Eagle0.Tutorial {
if (targetRect == null) { targetRect = target.GetComponent<RectTransform>(); }
if (targetRect != null) {
// Get target bounds in screen space
// Get target bounds in world space
Vector3[] corners = new Vector3[4];
targetRect.GetWorldCorners(corners);
// Convert to canvas space
// Get canvas for coordinate conversion
Canvas canvas = GetComponentInParent<Canvas>();
if (canvas != null && canvas.renderMode != RenderMode.ScreenSpaceOverlay) {
Camera cam = canvas.worldCamera ?? Camera.main;
for (int i = 0; i < 4; i++) { corners[i] = cam.WorldToScreenPoint(corners[i]); }
RectTransform canvasRect = canvas?.GetComponent<RectTransform>();
if (canvasRect == null) {
HighlightFrame.gameObject.SetActive(false);
return;
}
// Calculate bounds
float minX = Mathf.Min(corners[0].x, corners[1].x, corners[2].x, corners[3].x);
float maxX = Mathf.Max(corners[0].x, corners[1].x, corners[2].x, corners[3].x);
float minY = Mathf.Min(corners[0].y, corners[1].y, corners[2].y, corners[3].y);
float maxY = Mathf.Max(corners[0].y, corners[1].y, corners[2].y, corners[3].y);
// Convert world corners to screen space, then to canvas-local space
Vector2[] localCorners = new Vector2[4];
Camera cam = canvas.worldCamera;
// Position and size highlight
for (int i = 0; i < 4; i++) {
Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(cam, corners[i]);
RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvasRect,
screenPoint,
cam,
out localCorners[i]);
}
// Calculate bounds in canvas-local space
float minX = Mathf.Min(
localCorners[0].x,
localCorners[1].x,
localCorners[2].x,
localCorners[3].x);
float maxX = Mathf.Max(
localCorners[0].x,
localCorners[1].x,
localCorners[2].x,
localCorners[3].x);
float minY = Mathf.Min(
localCorners[0].y,
localCorners[1].y,
localCorners[2].y,
localCorners[3].y);
float maxY = Mathf.Max(
localCorners[0].y,
localCorners[1].y,
localCorners[2].y,
localCorners[3].y);
// Clamp bounds to stay within canvas (accounting for padding that will be added)
float canvasHalfWidth = canvasRect.rect.width / 2f;
float canvasHalfHeight = canvasRect.rect.height / 2f;
float margin = 5f + HighlightPadding;
minX = Mathf.Max(minX, -canvasHalfWidth + margin);
maxX = Mathf.Min(maxX, canvasHalfWidth - margin);
minY = Mathf.Max(minY, -canvasHalfHeight + margin);
maxY = Mathf.Min(maxY, canvasHalfHeight - margin);
// Position and size highlight in canvas-local coordinates
Vector2 center = new Vector2((minX + maxX) / 2, (minY + maxY) / 2);
Vector2 size = new Vector2(
maxX - minX + HighlightPadding * 2,
maxY - minY + HighlightPadding * 2);
HighlightFrame.position = center;
// Set highlight position and size using anchored position (canvas-local)
HighlightFrame.anchorMin = new Vector2(0.5f, 0.5f);
HighlightFrame.anchorMax = new Vector2(0.5f, 0.5f);
HighlightFrame.pivot = new Vector2(0.5f, 0.5f);
HighlightFrame.anchoredPosition = center;
HighlightFrame.sizeDelta = size;
HighlightFrame.gameObject.SetActive(true);
}
}
/// <summary>
/// Positions highlight based on active children of the target rather than target's own
/// bounds. Useful for containers where the RectTransform is larger than the visible
/// content.
/// </summary>
private void PositionHighlightFromChildren(Transform target) {
if (HighlightFrame == null || target == null) return;
Canvas canvas = GetComponentInParent<Canvas>();
RectTransform canvasRect = canvas?.GetComponent<RectTransform>();
if (canvasRect == null) {
HighlightFrame.gameObject.SetActive(false);
return;
}
Camera cam = canvas.worldCamera;
// Collect bounds from all active children with RectTransforms
float minX = float.MaxValue, maxX = float.MinValue;
float minY = float.MaxValue, maxY = float.MinValue;
foreach (Transform child in target) {
if (!child.gameObject.activeInHierarchy) continue;
RectTransform childRect = child.GetComponent<RectTransform>();
if (childRect == null) continue;
Vector3[] corners = new Vector3[4];
childRect.GetWorldCorners(corners);
for (int i = 0; i < 4; i++) {
Vector2 screenPoint = RectTransformUtility.WorldToScreenPoint(cam, corners[i]);
RectTransformUtility.ScreenPointToLocalPointInRectangle(
canvasRect,
screenPoint,
cam,
out Vector2 localPoint);
minX = Mathf.Min(minX, localPoint.x);
maxX = Mathf.Max(maxX, localPoint.x);
minY = Mathf.Min(minY, localPoint.y);
maxY = Mathf.Max(maxY, localPoint.y);
}
}
if (minX == float.MaxValue) {
// No active children found, fall back to target bounds
PositionHighlight(target);
return;
}
// Clamp bounds to stay within canvas (accounting for padding)
float canvasHalfWidth = canvasRect.rect.width / 2f;
float canvasHalfHeight = canvasRect.rect.height / 2f;
float margin = 5f + HighlightPadding;
minX = Mathf.Max(minX, -canvasHalfWidth + margin);
maxX = Mathf.Min(maxX, canvasHalfWidth - margin);
minY = Mathf.Max(minY, -canvasHalfHeight + margin);
maxY = Mathf.Min(maxY, canvasHalfHeight - margin);
// Position and size highlight
Vector2 center = new Vector2((minX + maxX) / 2, (minY + maxY) / 2);
Vector2 size = new Vector2(
maxX - minX + HighlightPadding * 2,
maxY - minY + HighlightPadding * 2);
HighlightFrame.anchorMin = new Vector2(0.5f, 0.5f);
HighlightFrame.anchorMax = new Vector2(0.5f, 0.5f);
HighlightFrame.pivot = new Vector2(0.5f, 0.5f);
HighlightFrame.anchoredPosition = center;
HighlightFrame.sizeDelta = size;
HighlightFrame.gameObject.SetActive(true);
}
private void PositionTooltip(Transform target, Vector2 offset) {
if (TooltipContainer == null) return;
@@ -46,7 +46,6 @@ namespace Eagle0.Tutorial {
private bool _fallbackModalVisible;
private TutorialStep _fallbackStep;
private Action _fallbackOnContinue;
private Action _fallbackOnSkip;
private int _fallbackCurrentIndex;
private int _fallbackTotalSteps;
private bool _fallbackIsOnboarding;
@@ -106,16 +105,43 @@ namespace Eagle0.Tutorial {
private void OnGUI() {
if (!_fallbackModalVisible || _fallbackStep == null) return;
// Semi-transparent background
GUI.color = new Color(0, 0, 0, 0.7f);
GUI.DrawTexture(new Rect(0, 0, Screen.width, Screen.height), Texture2D.whiteTexture);
GUI.color = Color.white;
// Only draw background blocker if step blocks interaction
if (_fallbackStep.BlocksInteraction) {
GUI.color = new Color(0, 0, 0, 0.7f);
GUI.DrawTexture(
new Rect(0, 0, Screen.width, Screen.height),
Texture2D.whiteTexture);
GUI.color = Color.white;
}
// Modal window
float windowWidth = Mathf.Min(1200, Screen.width - 40);
float windowHeight = 600;
float windowX = (Screen.width - windowWidth) / 2;
float windowY = (Screen.height - windowHeight) / 2;
// Calculate window position based on anchor
float windowWidth = Mathf.Min(800, Screen.width - 40);
float windowHeight = 500;
float windowX, windowY;
switch (_fallbackStep.PanelAnchor) {
case TutorialPanelAnchor.Left:
windowX = 20;
windowY = (Screen.height - windowHeight) / 2;
break;
case TutorialPanelAnchor.Right:
windowX = Screen.width - windowWidth - 20;
windowY = (Screen.height - windowHeight) / 2;
break;
case TutorialPanelAnchor.Top:
windowX = (Screen.width - windowWidth) / 2;
windowY = 20;
break;
case TutorialPanelAnchor.Bottom:
windowX = (Screen.width - windowWidth) / 2;
windowY = Screen.height - windowHeight - 20;
break;
case TutorialPanelAnchor.Center:
default:
windowX = (Screen.width - windowWidth) / 2;
windowY = (Screen.height - windowHeight) / 2;
break;
}
GUIStyle windowStyle = new GUIStyle(GUI.skin.window);
windowStyle.fontSize = 48;
@@ -165,26 +191,15 @@ namespace Eagle0.Tutorial {
// Buttons
GUILayout.BeginHorizontal();
if (_fallbackStep.AllowSkip) {
// Skip Tutorial button (only for onboarding, when step allows skipping)
if (_fallbackIsOnboarding && _fallbackStep.AllowSkip) {
if (GUILayout.Button(
"Skip",
"Skip Tutorial",
buttonStyle,
GUILayout.Width(200),
GUILayout.Width(280),
GUILayout.Height(80))) {
var callback = _fallbackOnSkip;
HideFallbackModal();
callback?.Invoke();
}
if (_fallbackIsOnboarding) {
if (GUILayout.Button(
"Skip All",
buttonStyle,
GUILayout.Width(200),
GUILayout.Height(80))) {
HideFallbackModal();
TutorialManager.Instance?.SkipAllOnboarding();
}
TutorialManager.Instance?.SkipAllOnboarding();
}
}
@@ -211,13 +226,11 @@ namespace Eagle0.Tutorial {
int currentIndex,
int totalSteps,
Action onContinue,
Action onSkip,
bool isOnboarding) {
_fallbackStep = step;
_fallbackCurrentIndex = currentIndex;
_fallbackTotalSteps = totalSteps;
_fallbackOnContinue = onContinue;
_fallbackOnSkip = onSkip;
_fallbackIsOnboarding = isOnboarding;
_fallbackModalVisible = true;
}
@@ -226,7 +239,6 @@ namespace Eagle0.Tutorial {
_fallbackModalVisible = false;
_fallbackStep = null;
_fallbackOnContinue = null;
_fallbackOnSkip = null;
}
/// <summary>
@@ -237,16 +249,56 @@ namespace Eagle0.Tutorial {
int currentIndex,
int totalSteps,
Action onComplete,
Action onSkip,
bool isOnboarding) {
// Clear any existing highlight before showing new one
OverlayController?.ClearHighlight();
// Show highlight on target element(s) if specified (works alongside modal)
if (!string.IsNullOrEmpty(step.TargetGameObjectPath)) {
if (OverlayController == null) {
Debug.LogWarning(
"TutorialUIManager: OverlayController is null, cannot highlight");
} else {
// Collect all targets (primary + additional)
var targets = new List<Transform>();
var primaryTarget = GetTarget(step.TargetGameObjectPath);
if (primaryTarget != null) { targets.Add(primaryTarget); }
if (step.AdditionalHighlightTargets != null) {
foreach (var additionalId in step.AdditionalHighlightTargets) {
var additionalTarget = GetTarget(additionalId);
if (additionalTarget != null) { targets.Add(additionalTarget); }
}
}
if (targets.Count > 1) {
OverlayController.HighlightMultiple(targets.ToArray());
} else if (targets.Count == 1) {
OverlayController.HighlightOnly(
targets[0],
step.HighlightBoundsFromChildren);
} else {
Debug.LogWarning(
$"TutorialUIManager: Target '{step.TargetGameObjectPath}' not found");
}
}
}
if (ModalPanel != null) {
// Ensure canvas is active
if (TutorialCanvas != null && !TutorialCanvas.gameObject.activeSelf) {
TutorialCanvas.gameObject.SetActive(true);
}
ModalPanel.Show(step, currentIndex, totalSteps, onComplete, onSkip, isOnboarding);
// Wrap callback to clear highlight when modal is dismissed
Action wrappedCallback = () => {
OverlayController?.ClearHighlight();
onComplete?.Invoke();
};
ModalPanel.Show(step, currentIndex, totalSteps, wrappedCallback, isOnboarding);
} else if (UseFallbackUI) {
ShowFallbackModal(step, currentIndex, totalSteps, onComplete, onSkip, isOnboarding);
ShowFallbackModal(step, currentIndex, totalSteps, onComplete, isOnboarding);
} else {
Debug.LogWarning("TutorialUIManager: No ModalPanel assigned and fallback disabled");
onComplete?.Invoke();
@@ -263,16 +315,11 @@ namespace Eagle0.Tutorial {
return;
}
// Find target element
Transform target = null;
if (!string.IsNullOrEmpty(step.TargetGameObjectPath)) {
var targetObj = GameObject.Find(step.TargetGameObjectPath);
if (targetObj != null) {
target = targetObj.transform;
} else {
Debug.LogWarning(
$"TutorialUIManager: Could not find target '{step.TargetGameObjectPath}'");
}
// Find target element using registry
Transform target = GetTarget(step.TargetGameObjectPath);
if (!string.IsNullOrEmpty(step.TargetGameObjectPath) && target == null) {
Debug.LogWarning(
$"TutorialUIManager: Could not find target '{step.TargetGameObjectPath}'");
}
OverlayController.ShowOverlay(
@@ -305,13 +352,8 @@ namespace Eagle0.Tutorial {
// Don't show duplicate hints
if (_activeHints.ContainsKey(hintId)) return;
// Find target
Transform target = null;
if (!string.IsNullOrEmpty(targetPath)) {
var targetObj = GameObject.Find(targetPath);
if (targetObj != null) { target = targetObj.transform; }
}
// Find target using registry
Transform target = GetTarget(targetPath);
if (target == null) {
Debug.LogWarning($"TutorialUIManager: Could not find hint target '{targetPath}'");
return;
@@ -356,15 +398,10 @@ namespace Eagle0.Tutorial {
/// <summary>
/// Highlights a specific UI element (without showing tutorial content).
/// </summary>
public void HighlightElement(string gameObjectPath) {
public void HighlightElement(string targetId) {
if (OverlayController == null) return;
Transform target = null;
if (!string.IsNullOrEmpty(gameObjectPath)) {
var targetObj = GameObject.Find(gameObjectPath);
if (targetObj != null) { target = targetObj.transform; }
}
Transform target = GetTarget(targetId);
if (target != null) { OverlayController.HighlightOnly(target); }
}
@@ -372,5 +409,21 @@ namespace Eagle0.Tutorial {
/// Clears any active highlight.
/// </summary>
public void ClearHighlight() { OverlayController?.ClearHighlight(); }
/// <summary>
/// Gets a target Transform by ID, using the registry if available.
/// Falls back to GameObject.Find for unregistered targets.
/// </summary>
private Transform GetTarget(string targetId) {
if (string.IsNullOrEmpty(targetId)) return null;
// Try registry first
var registry = TutorialManager.Instance?.TargetRegistry;
if (registry != null) { return registry.GetTargetOrFind(targetId); }
// Fallback to GameObject.Find
var targetObj = GameObject.Find(targetId);
return targetObj?.transform;
}
}
}
@@ -223,6 +223,15 @@ namespace common {
return RowAt(index).GetComponent<T>();
}
/// <summary>
/// Gets the RectTransform of the row at the specified index.
/// Returns null if index is out of bounds.
/// </summary>
public RectTransform GetRowRectTransform(int index) {
if (index < 0 || index >= _rows.Count) return null;
return _rows[index].GetComponent<RectTransform>();
}
private void Awake() {
if (ShadeAlternateRows) {
_evenColor = rowPrefab.GetComponent<Image>().color;
@@ -4,6 +4,7 @@ go_library(
name = "authservice_lib",
srcs = [
"admin_handlers.go",
"apple_auth.go",
"handlers.go",
"invitation_handlers.go",
"invitations.go",
@@ -0,0 +1,284 @@
package main
import (
"crypto/ecdsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
)
// AppleTokenResponse represents the response from Apple's token endpoint
type AppleTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
RefreshToken string `json:"refresh_token"`
IDToken string `json:"id_token"`
}
// AppleIDTokenClaims represents the claims in Apple's id_token
type AppleIDTokenClaims struct {
jwt.RegisteredClaims
Email string `json:"email,omitempty"`
EmailVerified string `json:"email_verified,omitempty"`
IsPrivateEmail string `json:"is_private_email,omitempty"`
RealUserStatus int `json:"real_user_status,omitempty"`
}
// GenerateAppleClientSecret generates a JWT client_secret for Apple Sign-In
func GenerateAppleClientSecret() (string, error) {
teamID := os.Getenv("APPLE_TEAM_ID")
clientID := os.Getenv("APPLE_SIGNIN_CLIENT_ID")
keyID := os.Getenv("APPLE_SIGNIN_KEY_ID")
privateKeyPEM := os.Getenv("APPLE_SIGNIN_PRIVATE_KEY")
if teamID == "" || clientID == "" || keyID == "" || privateKeyPEM == "" {
return "", fmt.Errorf("Apple Sign-In not fully configured")
}
// Parse the private key
block, _ := pem.Decode([]byte(privateKeyPEM))
if block == nil {
// Try base64 decoding if not PEM format
keyBytes, err := base64.StdEncoding.DecodeString(privateKeyPEM)
if err != nil {
return "", fmt.Errorf("failed to decode private key: %v", err)
}
block = &pem.Block{Type: "PRIVATE KEY", Bytes: keyBytes}
}
privateKey, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return "", fmt.Errorf("failed to parse private key: %v", err)
}
ecdsaKey, ok := privateKey.(*ecdsa.PrivateKey)
if !ok {
return "", fmt.Errorf("private key is not ECDSA")
}
now := time.Now()
claims := jwt.RegisteredClaims{
Issuer: teamID,
Subject: clientID,
Audience: jwt.ClaimStrings{"https://appleid.apple.com"},
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(6 * 30 * 24 * time.Hour)), // ~6 months
}
token := jwt.NewWithClaims(jwt.SigningMethodES256, claims)
token.Header["kid"] = keyID
signedToken, err := token.SignedString(ecdsaKey)
if err != nil {
return "", fmt.Errorf("failed to sign token: %v", err)
}
return signedToken, nil
}
// ExchangeAppleCode exchanges an authorization code for tokens from Apple
func (s *OAuthService) ExchangeAppleCode(config OAuthProviderConfig, code string) (*AppleTokenResponse, error) {
clientSecret, err := GenerateAppleClientSecret()
if err != nil {
return nil, err
}
data := url.Values{}
data.Set("client_id", config.ClientID)
data.Set("client_secret", clientSecret)
data.Set("grant_type", "authorization_code")
data.Set("code", code)
data.Set("redirect_uri", s.callbackURL)
req, err := http.NewRequest("POST", config.TokenEndpoint, strings.NewReader(data.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("Apple token exchange failed: %d - %s", resp.StatusCode, string(body))
}
var tokenResp AppleTokenResponse
if err := json.Unmarshal(body, &tokenResp); err != nil {
return nil, err
}
return &tokenResp, nil
}
// ParseAppleIDToken extracts user info from Apple's id_token JWT
func ParseAppleIDToken(idToken string) (*ProviderUserInfo, error) {
// Split the JWT into parts
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("invalid id_token format")
}
// Decode the payload (middle part)
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return nil, fmt.Errorf("failed to decode id_token payload: %v", err)
}
var claims AppleIDTokenClaims
if err := json.Unmarshal(payload, &claims); err != nil {
return nil, fmt.Errorf("failed to parse id_token claims: %v", err)
}
// Apple user ID is in the 'sub' claim
sub := claims.Subject
// Use email prefix as username fallback since Apple doesn't provide username
username := claims.Email
if idx := strings.Index(username, "@"); idx > 0 {
username = username[:idx]
}
return &ProviderUserInfo{
ID: sub,
Email: claims.Email,
Username: username,
AvatarURL: "", // Apple doesn't provide avatar
}, nil
}
// HandleAppleCallback handles the Apple OAuth callback
// Note: Apple sends a POST request to the callback URL, not GET
func (s *OAuthService) HandleAppleCallback(w http.ResponseWriter, r *http.Request) {
// Apple POSTs the callback data
if r.Method != "POST" {
// Fallback to GET for the state parameter only
state := r.URL.Query().Get("state")
if state != "" {
// This might be an error redirect
errorParam := r.URL.Query().Get("error")
if errorParam != "" {
s.completeWithError(state, fmt.Sprintf("Apple OAuth error: %s", errorParam))
http.Error(w, "OAuth failed: "+errorParam, http.StatusBadRequest)
return
}
}
http.Error(w, "Apple Sign-In requires POST callback", http.StatusMethodNotAllowed)
return
}
// Parse form data
if err := r.ParseForm(); err != nil {
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
return
}
code := r.FormValue("code")
state := r.FormValue("state")
errorParam := r.FormValue("error")
if errorParam != "" {
s.completeWithError(state, fmt.Sprintf("Apple OAuth error: %s", errorParam))
http.Error(w, "OAuth failed: "+errorParam, http.StatusBadRequest)
return
}
if code == "" || state == "" {
http.Error(w, "Missing code or state", http.StatusBadRequest)
return
}
// Get pending OAuth info
stateData, ok := s.pendingOAuth.Load(state)
if !ok {
http.Error(w, "Invalid or expired state", http.StatusBadRequest)
return
}
oauthState := stateData.(OAuthState)
if time.Since(oauthState.CreatedAt) >= stateExpiration {
s.pendingOAuth.Delete(state)
s.completeWithError(state, "OAuth session expired")
http.Error(w, "OAuth session expired", http.StatusBadRequest)
return
}
// Exchange code for tokens (Apple requires JWT client_secret)
config := s.configs[oauthState.Provider]
tokenResp, err := s.ExchangeAppleCode(config, code)
if err != nil {
s.completeWithError(state, err.Error())
http.Error(w, "Token exchange failed", http.StatusInternalServerError)
return
}
// Parse user info from id_token
userInfo, err := ParseAppleIDToken(tokenResp.IDToken)
if err != nil {
s.completeWithError(state, err.Error())
http.Error(w, "Failed to parse user info", http.StatusInternalServerError)
return
}
// Apple may provide user info in the POST body on first auth
// This includes the user's name which isn't in the id_token
if userName := r.FormValue("user"); userName != "" {
var user struct {
Name struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
} `json:"name"`
}
if err := json.Unmarshal([]byte(userName), &user); err == nil {
if user.Name.FirstName != "" || user.Name.LastName != "" {
userInfo.Username = strings.TrimSpace(user.Name.FirstName + " " + user.Name.LastName)
}
}
}
// Mark as completed
s.pendingOAuth.Delete(state)
s.completedOAuth.Store(state, OAuthResult{
Success: true,
UserInfo: userInfo,
Provider: oauthState.Provider,
InvitationCode: oauthState.InvitationCode,
})
// Redirect or show close message
if oauthState.ReturnURL != "" {
http.Redirect(w, r, oauthState.ReturnURL, http.StatusFound)
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head><title>Login Successful</title></head>
<body>
<h1>Login Successful!</h1>
<p>You can close this window and return to the game.</p>
<script>window.close();</script>
</body>
</html>`)
}
@@ -146,6 +146,7 @@ func main() {
// Start HTTP server for OAuth callbacks and invitation pages
http.HandleFunc("/oauth/callback", oauthSvc.HandleCallback)
http.HandleFunc("/oauth/apple/callback", oauthSvc.HandleAppleCallback) // Apple uses POST
http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "OK")
@@ -180,5 +181,22 @@ func getOAuthConfigs() map[string]OAuthProviderConfig {
UserInfoEndpoint: "https://www.googleapis.com/oauth2/v2/userinfo",
Scopes: []string{"openid", "email", "profile"},
},
"github": {
ClientID: os.Getenv("GH_OAUTH_CLIENT_ID"),
ClientSecret: os.Getenv("GH_OAUTH_CLIENT_SECRET"),
AuthorizationEndpoint: "https://github.com/login/oauth/authorize",
TokenEndpoint: "https://github.com/login/oauth/access_token",
UserInfoEndpoint: "https://api.github.com/user",
Scopes: []string{"read:user", "user:email"},
},
"apple": {
ClientID: os.Getenv("APPLE_SIGNIN_CLIENT_ID"),
ClientSecret: "", // Generated dynamically from private key
AuthorizationEndpoint: "https://appleid.apple.com/auth/authorize",
TokenEndpoint: "https://appleid.apple.com/auth/token",
UserInfoEndpoint: "", // Not used - info comes from id_token
Scopes: []string{"name", "email"},
CallbackPath: "/oauth/apple/callback", // Apple uses POST callback
},
}
}
+35 -1
View File
@@ -23,6 +23,7 @@ type OAuthProviderConfig struct {
TokenEndpoint string
UserInfoEndpoint string
Scopes []string
CallbackPath string // Optional: custom callback path (e.g., "/oauth/apple/callback")
}
// OAuthState represents a pending OAuth session
@@ -111,13 +112,29 @@ func (s *OAuthService) GetAuthURL(provider string, returnURL string, invitationC
log.Printf("OAuth: created state=%s for provider=%s returnURL=%s hasInvitationCode=%v", state, provider, returnURL, invitationCode != "")
// Use custom callback path if configured, otherwise use default
callbackURL := s.callbackURL
if config.CallbackPath != "" {
// Extract base URL and append custom path
baseURL := s.callbackURL
if idx := strings.LastIndex(baseURL, "/oauth/callback"); idx > 0 {
baseURL = baseURL[:idx]
}
callbackURL = baseURL + config.CallbackPath
}
params := url.Values{}
params.Set("client_id", config.ClientID)
params.Set("redirect_uri", s.callbackURL)
params.Set("redirect_uri", callbackURL)
params.Set("response_type", "code")
params.Set("scope", strings.Join(config.Scopes, " "))
params.Set("state", state)
// Apple-specific parameters
if provider == "apple" {
params.Set("response_mode", "form_post") // Apple posts the callback
}
authURL = config.AuthorizationEndpoint + "?" + params.Encode()
return authURL, state, nil
}
@@ -349,6 +366,23 @@ func (s *OAuthService) parseUserInfo(provider string, data []byte) (*ProviderUse
AvatarURL: picture,
}, nil
case "github":
// GitHub returns id as a number
var id string
if idNum, ok := result["id"].(float64); ok {
id = fmt.Sprintf("%.0f", idNum)
}
login, _ := result["login"].(string)
email, _ := result["email"].(string)
avatarURL, _ := result["avatar_url"].(string)
return &ProviderUserInfo{
ID: id,
Username: login,
Email: email,
AvatarURL: avatarURL,
}, nil
default:
return nil, fmt.Errorf("unsupported provider: %s", provider)
}
@@ -0,0 +1,40 @@
load("@build_bazel_rules_apple//apple:macos.bzl", "macos_bundle")
# Native Sparkle plugin for Unity
# This plugin initializes Sparkle auto-updater at runtime
objc_library(
name = "sparkle_plugin_lib",
srcs = ["SparklePlugin.m"],
copts = [
"-fmodules",
"-fobjc-arc",
"-fvisibility=default", # Export symbols for Unity P/Invoke
],
sdk_frameworks = ["Foundation"],
deps = ["@sparkle//:Sparkle"],
)
macos_bundle(
name = "SparklePlugin",
bundle_id = "net.eagle0.SparklePlugin",
bundle_name = "SparklePlugin",
infoplists = ["Info.plist"],
linkopts = [
# Export symbols for Unity P/Invoke
"-exported_symbol",
"_SparklePlugin_Initialize",
"-exported_symbol",
"_SparklePlugin_CheckForUpdates",
"-exported_symbol",
"_SparklePlugin_CheckForUpdatesInBackground",
"-exported_symbol",
"_SparklePlugin_IsCheckingForUpdates",
"-exported_symbol",
"_SparklePlugin_GetAutomaticallyChecksForUpdates",
"-exported_symbol",
"_SparklePlugin_SetAutomaticallyChecksForUpdates",
],
minimum_os_version = "10.13",
visibility = ["//visibility:public"],
deps = [":sparkle_plugin_lib"],
)
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>SparklePlugin</string>
<key>CFBundleIdentifier</key>
<string>net.eagle0.SparklePlugin</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>SparklePlugin</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>
@@ -0,0 +1,86 @@
//
// SparklePlugin.m
// Native macOS plugin for Unity to initialize Sparkle auto-updates
//
// Copyright 2026 Dan Crosby
//
#import <Foundation/Foundation.h>
#import <Sparkle/Sparkle.h>
// The updater controller - retained for the lifetime of the app
static SPUStandardUpdaterController *updaterController = nil;
// Export macro for Unity P/Invoke
#define EXPORT __attribute__((visibility("default")))
// Called from Unity to initialize Sparkle
// This should be called once at app startup
EXPORT void SparklePlugin_Initialize(void) {
if (updaterController != nil) {
NSLog(@"SparklePlugin: Already initialized");
return;
}
NSLog(@"SparklePlugin: Initializing Sparkle auto-updater");
// Create the updater controller with automatic update checking enabled
// The feed URL and public key are read from Info.plist (SUFeedURL, SUPublicEDKey)
updaterController = [[SPUStandardUpdaterController alloc]
initWithStartingUpdater:YES
updaterDelegate:nil
userDriverDelegate:nil];
if (updaterController != nil) {
NSLog(@"SparklePlugin: Sparkle initialized successfully");
} else {
NSLog(@"SparklePlugin: Failed to initialize Sparkle");
}
}
// Manually trigger an update check (shows UI)
EXPORT void SparklePlugin_CheckForUpdates(void) {
if (updaterController == nil) {
NSLog(@"SparklePlugin: Not initialized, call Initialize first");
return;
}
NSLog(@"SparklePlugin: Checking for updates");
[updaterController checkForUpdates:nil];
}
// Check for updates silently in background (no UI unless update found)
EXPORT void SparklePlugin_CheckForUpdatesInBackground(void) {
if (updaterController == nil) {
NSLog(@"SparklePlugin: Not initialized, call Initialize first");
return;
}
NSLog(@"SparklePlugin: Checking for updates in background");
[[updaterController updater] checkForUpdatesInBackground];
}
// Returns 1 if an update check is in progress, 0 otherwise
EXPORT int SparklePlugin_IsCheckingForUpdates(void) {
if (updaterController == nil) {
return 0;
}
return [[updaterController updater] sessionInProgress] ? 1 : 0;
}
// Returns 1 if automatic update checks are enabled, 0 otherwise
EXPORT int SparklePlugin_GetAutomaticallyChecksForUpdates(void) {
if (updaterController == nil) {
return 0;
}
return [[updaterController updater] automaticallyChecksForUpdates] ? 1 : 0;
}
// Enable or disable automatic update checks
EXPORT void SparklePlugin_SetAutomaticallyChecksForUpdates(int enabled) {
if (updaterController == nil) {
NSLog(@"SparklePlugin: Not initialized");
return;
}
[[updaterController updater] setAutomaticallyChecksForUpdates:(enabled != 0)];
}
@@ -39,6 +39,8 @@ enum OAuthProvider {
OAUTH_PROVIDER_UNSPECIFIED = 0;
OAUTH_PROVIDER_DISCORD = 1;
OAUTH_PROVIDER_GOOGLE = 2;
OAUTH_PROVIDER_GITHUB = 3;
OAUTH_PROVIDER_APPLE = 4;
}
// Request to initiate OAuth flow
@@ -24,6 +24,7 @@ message IncompleteText {
.net.eagle0.eagle.internal.GeneratedTextRequest llm_request = 3;
int32 requested_after_history_count = 4;
int64 requested_at_millis = 5;
int64 last_update_at_millis = 6;
}
message UnrequestedText {
@@ -4,17 +4,16 @@ import scala.collection.Map
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.{FactionId, GameId, ProvinceId}
import net.eagle0.eagle.api.available_command.AvailableCommand as ProtoAvailableCommand
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands as ProtoOneProvinceAvailableCommands
import net.eagle0.eagle.api.selected_command.SelectedCommand
import net.eagle0.eagle.library.util.*
import net.eagle0.eagle.library.util.command_choice_helpers.{
AlmsCommandSelector,
AttackDecisionCommandChooser,
CommandChooser
}
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.*
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.Engine
import net.eagle0.eagle.model.proto_converters.command.available.OneProvinceAvailableCommandsConverter
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.OneProvinceAvailableCommands
import net.eagle0.eagle.model.state.game_state.GameState
case class AIClientWithSelectedCommand(
@@ -27,14 +26,12 @@ case class AIClient(
factionId: FactionId,
focusProvinceId: Option[ProvinceId] = None
) {
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.*
val logger = new AIClientLogger(gameId = gameId, factionId = factionId)
val roundsBetweenRecruitAttempts = 5
def handleError(
selectedCommand: Option[SelectedCommand],
selectedCommand: Option[CommandSelection],
error: Throwable
): Unit = {
println(s"Received an error with AI selected command $error")
@@ -50,13 +47,8 @@ case class AIClient(
if scalaCommandsMap.isEmpty then RandomState(AIClientWithSelectedCommand(this, None), functionalRandom)
else {
// Convert to proto for command choosers (temporary until command choosers are migrated to Scala types)
val protoCommandsMap = scalaCommandsMap.map {
case (pid, scalaCmds) =>
pid -> OneProvinceAvailableCommandsConverter.toProto(scalaCmds, engine.currentState, factionId)
}
chooseFrom(
protoCommandsMap,
scalaCommandsMap,
engine.currentState,
functionalRandom
)
@@ -65,7 +57,7 @@ case class AIClient(
private def chooseMidGameCommandFrom(
gameState: GameState,
oneProvinceAvailableCommands: ProtoOneProvinceAvailableCommands,
oneProvinceAvailableCommands: OneProvinceAvailableCommands,
functionalRandom: FunctionalRandom
): RandomState[AIClientWithSelectedCommand] =
MidGameAIClient.chosenMidGameCommand(
@@ -75,7 +67,7 @@ case class AIClient(
functionalRandom = functionalRandom
) match {
case RandomState(
CommandSelection(
cs @ CommandSelection(
actingFactionId,
actingProvinceId,
available,
@@ -91,7 +83,7 @@ case class AIClient(
s"$actingProvinceId (${gameState.provinces(actingProvinceId).name})"
)
logger.log(
s"${selected.asMessage.toProtoString}"
s"$selected"
)
logger.logLine(s" $reason")
logger.logLine("")
@@ -103,32 +95,23 @@ case class AIClient(
factionId = this.factionId,
focusProvinceId = focusProvinceId
),
Some(
CommandSelection(
actingFactionId,
actingProvinceId,
available,
selected,
reason
)
)
Some(cs)
),
fr
)
}
private def chooseFrom(
acs: Map[ProvinceId, ProtoOneProvinceAvailableCommands],
acs: Map[ProvinceId, OneProvinceAvailableCommands],
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[AIClientWithSelectedCommand] = {
val opac = acs.head._2
val oneProvinceAcs = acs.head._2.commands
val opac = acs.head._2
CommandChooser.choose(
actingFactionId = factionId,
gameState = gs,
availableCommands = oneProvinceAcs.toVector,
availableCommands = opac.commands,
rankedChoosers = Vector[CommandChooser](
(actingFactionId, gameState, availableCommands, functionalRandom) =>
handleCapturedHeroesSelectedCommand(
@@ -181,7 +164,7 @@ case class AIClient(
),
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
resolvePleaseRecruitMeSelectedCommand(
resolvePleaseRecruitMeSelected(
actingFactionId,
gameState,
availableCommands
@@ -191,7 +174,7 @@ case class AIClient(
(
fid: FactionId,
gameState: GameState,
acs: Vector[ProtoAvailableCommand],
commandsVector: Vector[AvailableCommand],
functionalRandom
) =>
RandomState(
@@ -199,7 +182,7 @@ case class AIClient(
.chosenAlmsForSupportWithMinimumCommandForProvince(
actingFactionId = fid,
gameState = gameState,
availableCommands = acs,
availableCommands = commandsVector,
minimumFood = 0,
provinceId = opac.provinceId
),
@@ -225,7 +208,7 @@ case class AIClient(
.chooseEarlyGameCommand(
factionId,
gs,
oneProvinceAcs.toVector,
opac.commands,
fr
) match {
case RandomState(cs, fr2) =>
+19 -21
View File
@@ -12,9 +12,6 @@ scala_library(
":early_game_ai_client",
":mid_game_ai_client",
":resolve_diplomacy_command_selector",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:eagle_scala_grpc",
"//src/main/protobuf/net/eagle0/eagle/internal:battalion_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:engine",
@@ -24,7 +21,8 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:alms_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:attack_decision_command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/command/available:one_province_available_commands_converter",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/shardok_interface:battle_resolution",
],
)
@@ -70,14 +68,16 @@ scala_library(
],
deps = [
":resolve_diplomacy_command_selector",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:invite_gold_cost",
"//src/main/scala/net/eagle0/eagle/library/settings:min_chance_for_a_i_invite",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:trust_for_diplomacy",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/common",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -93,7 +93,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
@@ -134,13 +133,13 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -153,14 +152,14 @@ scala_library(
],
deps = [
":ai_client_utils",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:battalion_utils",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:province_gold_surplus_calculator",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
)
@@ -173,7 +172,6 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
],
exports = [
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
@@ -184,9 +182,6 @@ scala_library(
":march_toward_province_command_chooser",
":move_leader_to_better_province_command_chooser",
":seek_more_leaders_command_chooser",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/common:more_option",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
@@ -210,6 +205,9 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/food_consumption",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/common",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
"//src/main/scala/net/eagle0/eagle/model/state/date",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
@@ -232,10 +230,10 @@ scala_library(
deps = [
":faction_leader_province_ranker",
":march_toward_province_command_chooser",
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/util:command_selection",
"//src/main/scala/net/eagle0/eagle/library/util:province_distances",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
@@ -251,8 +249,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:eagle_internal_exception",
@@ -263,8 +259,10 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util:eagle_require",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:available_command_selector",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:ransom_offer_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
"//src/main/scala/net/eagle0/eagle/model/state/diplomacy_offer/status",
"//src/main/scala/net/eagle0/eagle/model/state/faction",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
@@ -279,8 +277,6 @@ scala_library(
"//src/test/scala/net/eagle0/eagle/ai:__pkg__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/common:more_option",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/settings:loyalty_gain_from_feast",
@@ -293,6 +289,8 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:sworn_brother_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/hero:hero_utils",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/hero",
"//src/main/scala/net/eagle0/eagle/model/state/province",
@@ -1,7 +1,6 @@
package net.eagle0.eagle.ai
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.AvailableCommand
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers.{
chosenCommandWhileTraveling,
chosenDevelopCommand,
@@ -11,6 +10,7 @@ import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooser
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
@@ -1,12 +1,13 @@
package net.eagle0.eagle.ai
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand}
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.randomSelectionForType
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.MarchAvailable
import net.eagle0.eagle.model.state.command.selected.CombatUnit
import net.eagle0.eagle.model.state.command.selected.SelectedCommand.MarchSelected
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
@@ -19,7 +20,7 @@ object FixLeaderAloneCommandSelector {
): RandomState[Option[CommandSelection]] = {
val factions = gameState.factions.values.toVector
val provinces = gameState.provinces
randomSelectionForType[MarchAvailableCommand](
randomSelectionForType[MarchAvailable](
availableCommands,
functionalRandom
) {
@@ -40,7 +41,7 @@ object FixLeaderAloneCommandSelector {
.map { destination =>
fr.nextRandomElement(opmc.availableHeroIds)
.map { hid =>
CombatUnit(factionId = actingFactionId, heroId = hid)
CombatUnit(heroId = hid, battalionId = None)
}
.map { combatUnit =>
Some(
@@ -48,7 +49,7 @@ object FixLeaderAloneCommandSelector {
actingFactionId = actingFactionId,
actingProvinceId = ac.actingProvinceId,
available = ac,
selected = MarchSelectedCommand(
selected = MarchSelected(
gold = 0,
food = 0,
originProvince = opmc.originProvinceId,
@@ -1,18 +1,19 @@
package net.eagle0.eagle.ai
import net.eagle0.eagle.api.available_command.{AvailableCommand, DiplomacyAvailableCommand}
import net.eagle0.eagle.api.command.util.diplomacy_option.InvitationOption
import net.eagle0.eagle.api.selected_command.DiplomacySelectedCommand
import net.eagle0.eagle.library.settings.MinChanceForAIInvite
import net.eagle0.eagle.library.settings.{InviteGoldCost, MinChanceForAIInvite}
import net.eagle0.eagle.library.util.command_choice_helpers.{ProvinceGoldSurplusCalculator, TrustForDiplomacy}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.flatSelectionForType
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.{DiplomacyAvailable, DiplomacyOption}
import net.eagle0.eagle.model.state.command.common.DiplomacyOptionType
import net.eagle0.eagle.model.state.command.selected.SelectedCommand.DiplomacySelected
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.FactionId
object InvitationCommandSelector {
private case class InvitationOptionWithChance(
invitationOption: InvitationOption,
targetFactionId: FactionId,
acceptanceChance: Int
)
@@ -21,38 +22,44 @@ object InvitationCommandSelector {
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[DiplomacyAvailableCommand](acs = availableCommands) { diplomacyAvailableCommand =>
diplomacyAvailableCommand.options.collect { case io: InvitationOption => io }.filter { invitationOption =>
TrustForDiplomacy.meetsConditionsForInvitation(
actingFactionId = actingFactionId,
targetFactionId = invitationOption.targetFactionId,
gameState = gameState
)
}.filter { invitationOption =>
ProvinceGoldSurplusCalculator.provinceGoldSurplus(
gameState.provinces(diplomacyAvailableCommand.actingProvinceId)
) >= invitationOption.goldCost
}.map { invitationOption =>
InvitationOptionWithChance(
invitationOption = invitationOption,
acceptanceChance = ResolveDiplomacyCommandSelector.invitationAcceptanceChance(
actingFactionId,
invitationOption.targetFactionId,
gameState.factions.values.toVector,
gameState.provinces.values.toVector
flatSelectionForType[DiplomacyAvailable](acs = availableCommands) { diplomacyAvailable =>
// Find options that have Invitation as an option type
diplomacyAvailable.options
.filter(opt => opt.optionTypes.contains(DiplomacyOptionType.Invitation))
.filter { diplomacyOption =>
TrustForDiplomacy.meetsConditionsForInvitation(
actingFactionId = actingFactionId,
targetFactionId = diplomacyOption.targetFactionId,
gameState = gameState
)
)
}.maxByOption(_.acceptanceChance)
}
.filter { _ =>
ProvinceGoldSurplusCalculator.provinceGoldSurplus(
gameState.provinces(diplomacyAvailable.actingProvinceId)
) >= InviteGoldCost.intValue
}
.map { diplomacyOption =>
InvitationOptionWithChance(
targetFactionId = diplomacyOption.targetFactionId,
acceptanceChance = ResolveDiplomacyCommandSelector.invitationAcceptanceChance(
actingFactionId,
diplomacyOption.targetFactionId,
gameState.factions.values.toVector,
gameState.provinces.values.toVector
)
)
}
.maxByOption(_.acceptanceChance)
.filter(_.acceptanceChance >= MinChanceForAIInvite.intValue)
.map { iowc =>
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = diplomacyAvailableCommand.actingProvinceId,
available = diplomacyAvailableCommand,
selected = DiplomacySelectedCommand(
selectedOption = iowc.invitationOption,
targetFactionId = iowc.invitationOption.targetFactionId,
sentHeroId = diplomacyAvailableCommand.recommendedHeroId
actingProvinceId = diplomacyAvailable.actingProvinceId,
available = diplomacyAvailable,
selected = DiplomacySelected(
selectedOption = DiplomacyOptionType.Invitation,
targetFactionId = iowc.targetFactionId,
sentHeroId = diplomacyAvailable.recommendedHeroId
),
reason = "maybeInviteOtherFaction"
)
@@ -1,17 +1,17 @@
package net.eagle0.eagle.ai
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.{MarchAvailableCommand, MarchCommandFromOneProvince}
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.library.util.{BattalionUtils, CommandSelection, ProvinceDistances}
import net.eagle0.eagle.library.util.command_choice_helpers.ProvinceGoldSurplusCalculator.provinceGoldSurplus
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.model.state.command.available.AvailableCommand.{MarchAvailable, MarchCommandFromOneProvince}
import net.eagle0.eagle.model.state.command.selected.CombatUnit
import net.eagle0.eagle.model.state.command.selected.SelectedCommand.MarchSelected
import net.eagle0.eagle.model.state.game_state.GameState
object MarchTowardProvinceCommandChooser {
def marchTowardProvinceCommand(
ac: MarchAvailableCommand,
ac: MarchAvailable,
fid: FactionId,
opmc: MarchCommandFromOneProvince,
destinationProvinceId: ProvinceId,
@@ -49,7 +49,7 @@ object MarchTowardProvinceCommandChooser {
AIClientUtils.takenHeroIdsForMarchTowardFocus(
originProvince = originProvince,
destinationProvince = dest,
availableHeroIds = opmc.availableHeroIds.toVector,
availableHeroIds = opmc.availableHeroIds,
favorLeaders = favorLeaders,
factions = gs.factions.values.toVector,
heroes = gs.heroes
@@ -72,7 +72,7 @@ object MarchTowardProvinceCommandChooser {
)
.map {
case (hid, bid) =>
CombatUnit(factionId = fid, heroId = hid, battalionId = bid)
CombatUnit(heroId = hid, battalionId = bid)
}
val foodToTake = 2 * BattalionUtils.monthlyConsumedFood(
@@ -84,7 +84,7 @@ object MarchTowardProvinceCommandChooser {
actingFactionId = fid,
actingProvinceId = actingProvinceId,
available = ac,
selected = MarchSelectedCommand(
selected = MarchSelected(
marchingUnits = takenUnits, // Check the food cost here?
destinationProvinceId = dest.id,
originProvince = opmc.originProvinceId,
@@ -3,11 +3,6 @@ package net.eagle0.eagle.ai
import net.eagle0.common.{FunctionalRandom, MoreOption, RandomState}
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.ai.AIClientUtils
import net.eagle0.eagle.api.available_command.*
import net.eagle0.eagle.api.command.OneProvinceAvailableCommands
import net.eagle0.eagle.api.selected_command.{MarchSelectedCommand, ReconSelectedCommand}
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.common.improvement_type.ImprovementType
import net.eagle0.eagle.library.settings.{MinSupportForTaxes, MonthsReconConsideredRecent}
import net.eagle0.eagle.library.util.*
import net.eagle0.eagle.library.util.command_choice_helpers.{
@@ -30,6 +25,12 @@ import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.util.ProvinceDistances
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.*
import net.eagle0.eagle.model.state.command.available.OneProvinceAvailableCommands
import net.eagle0.eagle.model.state.command.common.ImprovementType
import net.eagle0.eagle.model.state.command.selected.CombatUnit
import net.eagle0.eagle.model.state.command.selected.SelectedCommand.{MarchSelected, ReconSelected}
import net.eagle0.eagle.model.state.date.Date.given
import net.eagle0.eagle.model.state.faction.FactionRelationship as FactionRelationshipC
import net.eagle0.eagle.model.state.game_state.GameState
@@ -66,7 +67,7 @@ object MidGameAIClient {
CommandChooser.choose(
actingFactionId = factionId,
gameState = gs,
availableCommands = opac.commands.toVector,
availableCommands = opac.commands,
rankedChoosers = Vector[CommandChooser](
(fid: FactionId, gs: GameState, acs: Vector[AvailableCommand], fr: FunctionalRandom) =>
chosenUniversalCommand(fid, gs, acs, fr),
@@ -129,14 +130,13 @@ object MidGameAIClient {
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[SendSuppliesAvailableCommand](availableCommands) {
case available @ SendSuppliesAvailableCommand(
flatSelectionForType[SendSuppliesAvailable](availableCommands) {
case available @ SendSuppliesAvailable(
goldAvailable,
foodAvailable,
availableDestinationProvinceIds,
actingProvinceId,
_ /* availableHeroIds */,
_ /* unknownFieldSet */
_ /* availableHeroIds */
) =>
val actingProvince = gameState.provinces(actingProvinceId)
val foodMonthsToHoldBack =
@@ -546,7 +546,7 @@ object MidGameAIClient {
.choose(
actingFactionId = actingFactionId,
gameState = gameState,
availableCommands = opac.commands.toVector,
availableCommands = opac.commands,
rankedChoosers = Vector[CommandChooser](
(actingFactionId, gameState, availableCommands, functionalRandom) =>
RandomState(
@@ -683,7 +683,7 @@ object MidGameAIClient {
acs: Vector[AvailableCommand],
factionId: FactionId
): Option[CommandSelection] =
flatSelectionForType[ImproveAvailableCommand](acs) { availableCommand =>
flatSelectionForType[ImproveAvailable](acs) { availableCommand =>
val province = gs.provinces(availableCommand.actingProvinceId)
val existingBattalions = province.battalionIds
.map(gs.battalions)
@@ -706,7 +706,7 @@ object MidGameAIClient {
actingFactionId = factionId,
actingProvinceId = province.id,
availableCommands = acs,
improvementType = ImprovementType.DEVASTATION
improvementType = ImprovementType.Devastation
)
.orElse {
val neededAgriculture = notAvailable
@@ -729,7 +729,7 @@ object MidGameAIClient {
actingFactionId = factionId,
actingProvinceId = province.id,
availableCommands = acs,
improvementType = ImprovementType.AGRICULTURE
improvementType = ImprovementType.Agriculture
)
else
ImproveCommandSelector
@@ -737,7 +737,7 @@ object MidGameAIClient {
actingFactionId = factionId,
actingProvinceId = province.id,
availableCommands = acs,
improvementType = ImprovementType.ECONOMY
improvementType = ImprovementType.Economy
)
end if
}
@@ -856,7 +856,7 @@ object MidGameAIClient {
.filter(p => p.rulingFactionHeroIds.size < p.heroCap)
val cs = for {
(marchCommand, _) <- acs.zipWithIndex.collectFirst {
case (ac: MarchAvailableCommand, idx) => (ac, idx)
case (ac: MarchAvailable, idx) => (ac, idx)
}
} yield {
// Find the march command origin provinces that contain extra heroes
@@ -924,14 +924,14 @@ object MidGameAIClient {
private def selectedReconCommand(
actingFactionId: FactionId,
gameState: GameState,
ac: ReconAvailableCommand,
ac: ReconAvailable,
targetProvinceId: ProvinceId
): CommandSelection =
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = ac.actingProvinceId,
available = ac,
selected = ReconSelectedCommand(
selected = ReconSelected(
actingHeroId = ac.availableHeroIds.maxBy(hid => gameState.heroes(hid).vigor),
targetProvinceId = targetProvinceId
),
@@ -943,7 +943,7 @@ object MidGameAIClient {
gameState: GameState,
acs: Vector[AvailableCommand]
): Option[CommandSelection] =
flatSelectionForType[ReconAvailableCommand](acs) { ac =>
flatSelectionForType[ReconAvailable](acs) { ac =>
val targetProvincesNeighboringMe = ac.availableTargetProvinces
.map(gameState.provinces)
.filter(_.rulingFactionId.exists(_ != actingFactionId))
@@ -1013,8 +1013,8 @@ object MidGameAIClient {
): Option[CommandSelection] = {
val faction = gameState.factions(actingFactionId)
val factionLeaders = faction.leaderIds
flatSelectionForType[MarchAvailableCommand](acs) { marchAvailableCommand =>
marchAvailableCommand.oneProvinceCommands.flatMap { opmc =>
flatSelectionForType[MarchAvailable](acs) { marchAvailable =>
marchAvailable.oneProvinceCommands.flatMap { opmc =>
val originProvince = gameState.provinces(opmc.originProvinceId)
val leadersInOriginProvince =
originProvince.rulingFactionHeroIds.intersect(factionLeaders)
@@ -1042,16 +1042,15 @@ object MidGameAIClient {
validDestinations.minByOption(_.rulingFactionHeroIds.size).map { destinationProvince =>
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = marchAvailableCommand.actingProvinceId,
available = marchAvailableCommand,
selected = MarchSelectedCommand(
actingProvinceId = marchAvailable.actingProvinceId,
available = marchAvailable,
selected = MarchSelected(
gold = 0,
food = 0,
originProvince = originProvince.id,
destinationProvinceId = destinationProvince.id,
marchingUnits = Vector(
CombatUnit(
factionId = actingFactionId,
heroId = leaderIdToMove,
battalionId = None
)
@@ -1,10 +1,11 @@
package net.eagle0.eagle.ai
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand, MarchCommandFromOneProvince}
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.util.ProvinceDistances
import net.eagle0.eagle.library.util.ProvinceDistances.ProvinceAndDistance
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.{MarchAvailable, MarchCommandFromOneProvince}
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.game_state.GameState
@@ -31,11 +32,11 @@ object MoveLeaderToBetterProvinceCommandChooser {
)
private def candidatesForMarchCommand(
marchCommand: MarchAvailableCommand,
marchCommand: MarchAvailable,
gs: GameState,
faction: FactionT
): Vector[MarchCommandFromOneProvince] =
marchCommand.oneProvinceCommands.toVector.filter { opmc =>
marchCommand.oneProvinceCommands.filter { opmc =>
val originProvince = gs.provinces(opmc.originProvinceId)
if opmc.availableHeroIds.intersect(faction.leaderIds).isEmpty then false
else if originProvince.rulingFactionHeroIds.length < 2 then false
@@ -57,7 +58,7 @@ object MoveLeaderToBetterProvinceCommandChooser {
ProvinceDistances
.closestProvinceThroughFriendliesOption(
desiredPid,
mcfop.availableDestinationProvinces.map(_.provinceId).toVector,
mcfop.availableDestinationProvinces.map(_.provinceId),
actingFactionId,
gs.provinces
)
@@ -78,7 +79,7 @@ object MoveLeaderToBetterProvinceCommandChooser {
): Option[CommandSelection] = {
val cs = for {
faction <- gs.factions.get(actingFactionId)
marchCommand <- acs.collectFirst { case ac: MarchAvailableCommand => ac }
marchCommand <- acs.collectFirst { case ac: MarchAvailable => ac }
} yield {
// Find the march command origin provinces that contain a faction leader
val candidatesWithDistances = candidateCommandsWithDistances(
@@ -1,42 +1,25 @@
package net.eagle0.eagle.ai
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.{
AvailableCommand,
ResolveAllianceOfferAvailableCommand,
ResolveBreakAllianceAvailableCommand,
ResolveInvitationAvailableCommand,
ResolveRansomOfferAvailableCommand,
ResolveTruceOfferAvailableCommand
}
import net.eagle0.eagle.api.selected_command.{
ResolveAllianceOfferSelectedCommand,
ResolveBreakAllianceSelectedCommand,
ResolveInvitationSelectedCommand,
ResolveRansomOfferSelectedCommand,
ResolveTruceOfferSelectedCommand,
SelectedCommand
}
import net.eagle0.eagle.common.diplomacy_offer.RansomOfferDetails
import net.eagle0.eagle.common.diplomacy_offer_status.DiplomacyOfferStatus.{
DIPLOMACY_OFFER_STATUS_ACCEPTED,
DIPLOMACY_OFFER_STATUS_IMPRISONED,
DIPLOMACY_OFFER_STATUS_REJECTED
}
import net.eagle0.eagle.library.settings.{
AiAllianceAcceptanceBaseChance,
AiTruceAcceptanceBaseChance,
MinimumPrestigeDifferenceToInvite
}
import net.eagle0.eagle.library.util.command_choice_helpers.{CommandChooser, RansomOfferHelpers}
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.{
randomSelectionForType,
selectionForType
}
import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooser
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.library.util.EagleRequire.internalRequire
import net.eagle0.eagle.library.EagleInternalException
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.*
import net.eagle0.eagle.model.state.command.selected.SelectedCommand
import net.eagle0.eagle.model.state.command.selected.SelectedCommand.*
import net.eagle0.eagle.model.state.diplomacy_offer.status.{Accepted, Imprisoned, Rejected, Status}
import net.eagle0.eagle.model.state.faction.FactionT
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
@@ -71,7 +54,7 @@ object ResolveDiplomacyCommandSelector {
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
randomSelectionForType[ResolveInvitationAvailableCommand](
randomSelectionForType[ResolveInvitationAvailable](
availableCommands,
functionalRandom
) {
@@ -96,7 +79,7 @@ object ResolveDiplomacyCommandSelector {
private def selectionForResolveInvitationCommand(
actingFactionId: FactionId,
ac: ResolveInvitationAvailableCommand,
ac: ResolveInvitationAvailable,
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
@@ -104,20 +87,20 @@ object ResolveDiplomacyCommandSelector {
val factions = gs.factions.values.toVector
val provinces = gs.provinces.values.toVector
val bestInvitation = ac.invitations
.maxBy(inv => invitationAcceptanceChance(inv.originatingFactionId, invitedFid, factions, provinces))
.maxBy(inv => invitationAcceptanceChance(inv.offeringFactionId, invitedFid, factions, provinces))
internalRequire(
bestInvitation.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
bestInvitation.eligibleStatuses.contains(Accepted),
"Cannot handle invitation without ACCEPTED option"
)
internalRequire(
bestInvitation.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_REJECTED),
bestInvitation.eligibleStatuses.contains(Rejected),
"Cannot handle invitation without REJECTED option"
)
val bestOriginatingFid = bestInvitation.originatingFactionId
val bestOriginatingFid = bestInvitation.offeringFactionId
ResolveInvitationSelectedCommand(
ResolveInvitationSelected(
originatingFactionId = bestOriginatingFid,
resolution =
if 100.0 * roll < invitationAcceptanceChance(
@@ -126,8 +109,8 @@ object ResolveDiplomacyCommandSelector {
factions,
provinces
)
then DIPLOMACY_OFFER_STATUS_ACCEPTED
else DIPLOMACY_OFFER_STATUS_REJECTED
then Accepted
else Rejected
)
}
@@ -150,7 +133,7 @@ object ResolveDiplomacyCommandSelector {
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
randomSelectionForType[ResolveTruceOfferAvailableCommand](
randomSelectionForType[ResolveTruceOfferAvailable](
availableCommands,
functionalRandom
) {
@@ -175,7 +158,7 @@ object ResolveDiplomacyCommandSelector {
private def selectionForResolveTruceOfferSelectedCommand(
actingFactionId: FactionId,
ac: ResolveTruceOfferAvailableCommand,
ac: ResolveTruceOfferAvailable,
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
@@ -183,20 +166,20 @@ object ResolveDiplomacyCommandSelector {
val factions = gs.factions.values.toVector
val provinces = gs.provinces.values.toVector
val bestOffer = ac.offers
.maxBy(inv => truceOfferAcceptanceChance(inv.originatingFactionId, actingFid, factions, provinces))
.maxBy(inv => truceOfferAcceptanceChance(inv.offeringFactionId, actingFid, factions, provinces))
internalRequire(
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
bestOffer.eligibleStatuses.contains(Accepted),
"Cannot handle truce offer without ACCEPTED option"
)
internalRequire(
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_REJECTED),
bestOffer.eligibleStatuses.contains(Rejected),
"Cannot handle truce offer without REJECTED option"
)
val bestOriginatingFid = bestOffer.originatingFactionId
val bestOriginatingFid = bestOffer.offeringFactionId
ResolveTruceOfferSelectedCommand(
ResolveTruceOfferSelected(
originatingFactionId = bestOriginatingFid,
resolution =
if 100.0 * roll < truceOfferAcceptanceChance(
@@ -205,8 +188,8 @@ object ResolveDiplomacyCommandSelector {
factions = factions,
provinces = provinces
)
then DIPLOMACY_OFFER_STATUS_ACCEPTED
else DIPLOMACY_OFFER_STATUS_REJECTED
then Accepted
else Rejected
)
}
@@ -271,7 +254,7 @@ object ResolveDiplomacyCommandSelector {
gameState: GameState,
availableCommands: Vector[AvailableCommand]
): Option[CommandSelection] =
selectionForType[ResolveRansomOfferAvailableCommand](availableCommands) { resolveRansomAc =>
selectionForType[ResolveRansomOfferAvailable](availableCommands) { resolveRansomAc =>
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = 0,
@@ -284,17 +267,18 @@ object ResolveDiplomacyCommandSelector {
}
private def selectionForResolveRansomOfferSelectedCommand(
ac: ResolveRansomOfferAvailableCommand
): SelectedCommand =
ac.offers.head.offerDetails match {
case rod: RansomOfferDetails =>
ResolveRansomOfferSelectedCommand(
offeringFactionId = ac.offers.head.originatingFactionId,
ransomOffer = Some(ac.offers.head),
resolution = RansomOfferHelpers.chosenResolution(rod)
)
case _ => throw new EagleInternalException("Not a ransom offer")
}
ac: ResolveRansomOfferAvailable
): SelectedCommand = {
val offer = ac.offers.head
// TODO: Proper scoring requires ransom details not currently in DiplomacyOfferInfo
// For now, accept all ransom offers
val resolution = if offer.eligibleStatuses.contains(Accepted) then Accepted else Rejected
ResolveRansomOfferSelected(
offeringFactionId = offer.offeringFactionId,
prisonerHeroId = offer.heroId.get,
resolution = resolution
)
}
private def resolveAllianceOfferSelectedCommand(
actingFactionId: FactionId,
@@ -302,7 +286,7 @@ object ResolveDiplomacyCommandSelector {
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
randomSelectionForType[ResolveAllianceOfferAvailableCommand](
randomSelectionForType[ResolveAllianceOfferAvailable](
availableCommands,
functionalRandom
) {
@@ -331,7 +315,7 @@ object ResolveDiplomacyCommandSelector {
availableCommands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
randomSelectionForType[ResolveBreakAllianceAvailableCommand](
randomSelectionForType[ResolveBreakAllianceAvailable](
availableCommands,
functionalRandom
) {
@@ -354,7 +338,7 @@ object ResolveDiplomacyCommandSelector {
private def selectionForResolveAllianceOfferSelectedCommand(
actingFactionId: FactionId,
ac: ResolveAllianceOfferAvailableCommand,
ac: ResolveAllianceOfferAvailable,
gs: GameState,
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { roll =>
@@ -362,20 +346,20 @@ object ResolveDiplomacyCommandSelector {
val factions = gs.factions.values.toVector
val provinces = gs.provinces.values.toVector
val bestOffer = ac.offers
.maxBy(inv => allianceOfferAcceptanceChance(inv.originatingFactionId, actingFid, factions, provinces))
.maxBy(inv => allianceOfferAcceptanceChance(inv.offeringFactionId, actingFid, factions, provinces))
internalRequire(
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED),
bestOffer.eligibleStatuses.contains(Accepted),
"Cannot handle alliance offer without ACCEPTED option"
)
internalRequire(
bestOffer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_REJECTED),
bestOffer.eligibleStatuses.contains(Rejected),
"Cannot handle alliance offer without REJECTED option"
)
val bestOriginatingFid = bestOffer.originatingFactionId
val bestOriginatingFid = bestOffer.offeringFactionId
ResolveAllianceOfferSelectedCommand(
ResolveAllianceOfferSelected(
originatingFactionId = bestOriginatingFid,
resolution =
if 100.0 * roll < allianceOfferAcceptanceChance(
@@ -384,26 +368,26 @@ object ResolveDiplomacyCommandSelector {
factions = factions,
provinces = provinces
)
then DIPLOMACY_OFFER_STATUS_ACCEPTED
else DIPLOMACY_OFFER_STATUS_REJECTED
then Accepted
else Rejected
)
}
// FIXME: always imprison ambassadors for now
// Note: doesn't use gameState or functionalRandom roll
private def selectionForResolveBreakAllianceSelectedCommand(
ac: ResolveBreakAllianceAvailableCommand,
ac: ResolveBreakAllianceAvailable,
functionalRandom: FunctionalRandom
): RandomState[SelectedCommand] = functionalRandom.nextDouble.map { _ =>
val offer = ac.offers.head
ResolveBreakAllianceSelectedCommand(
originatingFactionId = ac.offers.head.originatingFactionId,
ResolveBreakAllianceSelected(
originatingFactionId = offer.offeringFactionId,
resolution =
if offer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_IMPRISONED)
then DIPLOMACY_OFFER_STATUS_IMPRISONED
else if offer.eligibleStatuses.contains(DIPLOMACY_OFFER_STATUS_ACCEPTED)
then DIPLOMACY_OFFER_STATUS_ACCEPTED
if offer.eligibleStatuses.contains(Imprisoned)
then Imprisoned
else if offer.eligibleStatuses.contains(Accepted)
then Accepted
else
throw new EagleInternalException(
"Unable to handle a break alliance command without IMPRISONED or ACCEPTED options"
@@ -1,9 +1,6 @@
package net.eagle0.eagle.ai
import net.eagle0.common.MoreOption
import net.eagle0.eagle.api.available_command.{AvailableCommand, MarchAvailableCommand}
import net.eagle0.eagle.api.selected_command.MarchSelectedCommand
import net.eagle0.eagle.common.combat_unit.CombatUnit
import net.eagle0.eagle.library.settings.{LoyaltyGainFromFeast, MinimumLoyaltyForSwearBrotherhood}
import net.eagle0.eagle.library.util.{CommandSelection, ProvinceDistances}
import net.eagle0.eagle.library.util.command_choice_helpers.{
@@ -14,6 +11,10 @@ import net.eagle0.eagle.library.util.command_choice_helpers.{
}
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.hero.HeroUtils
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.MarchAvailable
import net.eagle0.eagle.model.state.command.selected.CombatUnit
import net.eagle0.eagle.model.state.command.selected.SelectedCommand.MarchSelected
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.hero.HeroT
import net.eagle0.eagle.model.state.province.ProvinceT
@@ -32,7 +33,7 @@ object SeekMoreLeadersCommandChooser {
case class Candidate(hero: HeroT, province: ProvinceT)
private def maybeMoveTowardDestination(
command: MarchAvailableCommand,
command: MarchAvailable,
hero: HeroT,
startingProvince: ProvinceT,
factionHeadProvince: ProvinceT,
@@ -62,10 +63,9 @@ object SeekMoreLeadersCommandChooser {
actingFactionId = factionHeadProvince.rulingFactionId.get,
actingProvinceId = command.actingProvinceId,
available = command,
selected = MarchSelectedCommand(
selected = MarchSelected(
marchingUnits = Vector(
CombatUnit(
factionId = factionHeadProvince.rulingFactionId.get,
heroId = hero.id,
battalionId = None
)
@@ -106,7 +106,7 @@ object SeekMoreLeadersCommandChooser {
.map(hero => Candidate(hero, province))
}
val marchACs = acs.collect { case ac: MarchAvailableCommand => ac }
val marchACs = acs.collect { case ac: MarchAvailable => ac }
val marchableCandidates = candidates.flatMap { c =>
marchACs.find { mac =>
@@ -35,6 +35,7 @@ scala_library(
":oauth_config",
"//src/main/protobuf/net/eagle0/eagle/api:auth_scala_grpc",
"//src/main/scala/net/eagle0/common:simple_timed_logger",
"@maven//:com_nimbusds_nimbus_jose_jwt",
"@maven//:org_json4s_json4s_ast_3",
"@maven//:org_json4s_json4s_core_3",
"@maven//:org_json4s_json4s_native_3",
@@ -31,6 +31,26 @@ object OAuthConfig {
scopes = Seq("openid", "email", "profile")
)
def github: OAuthProviderConfig = OAuthProviderConfig(
clientId = sys.env.getOrElse("GH_OAUTH_CLIENT_ID", ""),
clientSecret = sys.env.getOrElse("GH_OAUTH_CLIENT_SECRET", ""),
authorizationEndpoint = "https://github.com/login/oauth/authorize",
tokenEndpoint = "https://github.com/login/oauth/access_token",
userInfoEndpoint = "https://api.github.com/user",
scopes = Seq("read:user", "user:email")
)
// Apple Sign-In uses OIDC - user info comes from JWT id_token, not a userinfo endpoint.
// The client_secret is generated dynamically from a private key by the Go authservice.
def apple: OAuthProviderConfig = OAuthProviderConfig(
clientId = sys.env.getOrElse("APPLE_SIGNIN_CLIENT_ID", ""),
clientSecret = "", // Generated dynamically from private key
authorizationEndpoint = "https://appleid.apple.com/auth/authorize",
tokenEndpoint = "https://appleid.apple.com/auth/token",
userInfoEndpoint = "", // Not used - info is in id_token
scopes = Seq("name", "email")
)
/** Check if Discord OAuth is configured */
def isDiscordConfigured: Boolean =
discord.clientId.nonEmpty && discord.clientSecret.nonEmpty
@@ -38,4 +58,12 @@ object OAuthConfig {
/** Check if Google OAuth is configured */
def isGoogleConfigured: Boolean =
google.clientId.nonEmpty && google.clientSecret.nonEmpty
/** Check if GitHub OAuth is configured */
def isGitHubConfigured: Boolean =
github.clientId.nonEmpty && github.clientSecret.nonEmpty
/** Check if Apple Sign-In is configured */
def isAppleConfigured: Boolean =
apple.clientId.nonEmpty && sys.env.getOrElse("APPLE_SIGNIN_PRIVATE_KEY", "").nonEmpty
}
@@ -4,12 +4,18 @@ package net.eagle0.eagle.auth
import java.net.{URI, URLEncoder}
import java.net.http.{HttpClient, HttpRequest, HttpResponse}
import java.nio.charset.StandardCharsets
import java.time.Duration
import java.util.UUID
import java.security.interfaces.ECPrivateKey
import java.security.spec.PKCS8EncodedKeySpec
import java.security.KeyFactory
import java.time.{Duration, Instant}
import java.util.{Base64, Date, UUID}
import scala.collection.concurrent.TrieMap
import scala.util.Try
import com.nimbusds.jose.{JWSAlgorithm, JWSHeader}
import com.nimbusds.jose.crypto.ECDSASigner
import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT}
import net.eagle0.common.SimpleTimedLogger
import net.eagle0.eagle.api.auth.auth.OAuthProvider
import org.json4s.{DefaultFormats, JObject, JString}
@@ -137,25 +143,26 @@ class OAuthServiceImpl(serverBaseUrl: String) extends OAuthService {
case Some((provider, timestamp)) if System.currentTimeMillis() - timestamp < stateExpirationMs =>
val config = getConfig(provider)
// Exchange code for access token
val tokenResult = exchangeCodeForToken(config, code)
tokenResult match {
case Left(error) =>
// Apple uses a different flow - user info comes from id_token
val result = if provider == OAuthProvider.OAUTH_PROVIDER_APPLE then {
handleAppleCallback(config, code)
} else {
// Standard OAuth flow: exchange code for access token, then fetch user info
exchangeCodeForToken(provider, config, code).flatMap { accessToken =>
fetchUserInfo(provider, config, accessToken)
}
}
result match {
case Left(error) =>
completedOAuth.put(state, OAuthFailure(error))
Left(error)
case Right(accessToken) =>
// Fetch user info
fetchUserInfo(provider, config, accessToken) match {
case Left(error) =>
completedOAuth.put(state, OAuthFailure(error))
Left(error)
case Right(userInfo) =>
completedOAuth.put(state, OAuthSuccess(userInfo, provider))
SimpleTimedLogger.printLogger.logLine(
s"OAuth: handleCallback SUCCESS state=$state user=${userInfo.username} (completed now has ${completedOAuth.size} entries)"
)
Right(userInfo)
}
case Right(userInfo) =>
completedOAuth.put(state, OAuthSuccess(userInfo, provider))
SimpleTimedLogger.printLogger.logLine(
s"OAuth: handleCallback SUCCESS state=$state user=${userInfo.username} (completed now has ${completedOAuth.size} entries)"
)
Right(userInfo)
}
case Some(_) =>
@@ -189,10 +196,13 @@ class OAuthServiceImpl(serverBaseUrl: String) extends OAuthService {
provider match {
case OAuthProvider.OAUTH_PROVIDER_DISCORD => OAuthConfig.discord
case OAuthProvider.OAUTH_PROVIDER_GOOGLE => OAuthConfig.google
case OAuthProvider.OAUTH_PROVIDER_GITHUB => OAuthConfig.github
case OAuthProvider.OAUTH_PROVIDER_APPLE => OAuthConfig.apple
case _ => throw new IllegalArgumentException(s"Unsupported provider: $provider")
}
private def exchangeCodeForToken(
provider: OAuthProvider,
config: OAuthProviderConfig,
code: String
): Either[String, String] =
@@ -281,8 +291,142 @@ class OAuthServiceImpl(serverBaseUrl: String) extends OAuthService {
val avatarUrl = (data \ "picture").extractOpt[String].getOrElse("")
ProviderUserInfo(id, email, avatarUrl, name)
case OAuthProvider.OAUTH_PROVIDER_GITHUB =>
val id = (data \ "id").extract[Long].toString
val login = (data \ "login").extract[String]
val email = (data \ "email").extractOpt[String].getOrElse("")
val avatarUrl = (data \ "avatar_url").extractOpt[String].getOrElse("")
ProviderUserInfo(id, email, avatarUrl, login)
case OAuthProvider.OAUTH_PROVIDER_APPLE =>
// Apple user info comes from id_token JWT, not userinfo endpoint.
// This case is handled by parseAppleIdToken in handleCallback.
throw new IllegalArgumentException("Apple user info should be parsed from id_token")
case _ =>
throw new IllegalArgumentException(s"Unsupported provider: $provider")
}
}.toEither.left.map(e => s"Failed to parse user info: ${e.getMessage}")
// Apple Sign-In specific methods
/** Handle Apple OAuth callback - Apple uses a different flow with JWT client_secret and id_token */
private def handleAppleCallback(
config: OAuthProviderConfig,
code: String
): Either[String, ProviderUserInfo] =
for {
clientSecret <- generateAppleClientSecret()
tokenResponse <- exchangeAppleCode(config, code, clientSecret)
userInfo <- parseAppleIdToken(tokenResponse)
} yield userInfo
/** Generate Apple client_secret JWT signed with the private key */
private def generateAppleClientSecret(): Either[String, String] =
Try {
val teamId = sys.env.getOrElse("APPLE_TEAM_ID", "")
val clientId = sys.env.getOrElse("APPLE_SIGNIN_CLIENT_ID", "")
val keyId = sys.env.getOrElse("APPLE_SIGNIN_KEY_ID", "")
val privateKey = sys.env.getOrElse("APPLE_SIGNIN_PRIVATE_KEY", "")
if teamId.isEmpty || clientId.isEmpty || keyId.isEmpty || privateKey.isEmpty then {
return Left("Apple Sign-In not fully configured")
}
// Parse the private key (PEM format, EC P-256)
val keyLines = privateKey
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "")
val keyBytes = Base64.getDecoder.decode(keyLines)
val keySpec = new PKCS8EncodedKeySpec(keyBytes)
val keyFactory = KeyFactory.getInstance("EC")
val ecPrivateKey = keyFactory.generatePrivate(keySpec).asInstanceOf[ECPrivateKey]
val now = Instant.now()
val expiresAt = now.plusSeconds(15777000) // ~6 months (Apple max)
val header = new JWSHeader.Builder(JWSAlgorithm.ES256)
.keyID(keyId)
.build()
val claims = new JWTClaimsSet.Builder()
.issuer(teamId)
.subject(clientId)
.audience("https://appleid.apple.com")
.issueTime(Date.from(now))
.expirationTime(Date.from(expiresAt))
.build()
val signedJWT = new SignedJWT(header, claims)
signedJWT.sign(new ECDSASigner(ecPrivateKey))
Right(signedJWT.serialize())
}.toEither.left.map(e => s"Failed to generate Apple client_secret: ${e.getMessage}").flatten
/** Exchange Apple authorization code for tokens */
private def exchangeAppleCode(
config: OAuthProviderConfig,
code: String,
clientSecret: String
): Either[String, org.json4s.JValue] =
Try {
val formData = Map(
"client_id" -> config.clientId,
"client_secret" -> clientSecret,
"grant_type" -> "authorization_code",
"code" -> code,
"redirect_uri" -> callbackUrl
)
val formBody = formData.map {
case (k, v) =>
s"${URLEncoder.encode(k, StandardCharsets.UTF_8)}=${URLEncoder.encode(v, StandardCharsets.UTF_8)}"
}
.mkString("&")
val request = HttpRequest
.newBuilder()
.uri(URI.create(config.tokenEndpoint))
.timeout(Duration.ofSeconds(10))
.header("Content-Type", "application/x-www-form-urlencoded")
.header("Accept", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(formBody))
.build()
val response = httpClient.send(request, HttpResponse.BodyHandlers.ofString())
val body = response.body()
if response.statusCode() < 200 || response.statusCode() >= 300 then {
return Left(s"Apple token exchange failed: ${response.statusCode()} - $body")
}
Right(json.parse(body))
}.toEither.left.map(e => s"Apple token exchange error: ${e.getMessage}").flatten
/** Parse user info from Apple's id_token JWT */
private def parseAppleIdToken(tokenResponse: org.json4s.JValue): Either[String, ProviderUserInfo] =
Try {
val idToken = (tokenResponse \ "id_token").extract[String]
// Decode JWT payload (middle part) - Apple already verified it, so we just extract claims
val parts = idToken.split("\\.")
if parts.length != 3 then {
return Left("Invalid id_token format")
}
val payloadJson = new String(Base64.getUrlDecoder.decode(parts(1)), StandardCharsets.UTF_8)
val payload = json.parse(payloadJson)
// Apple user ID is in 'sub' claim
val sub = (payload \ "sub").extract[String]
val email = (payload \ "email").extractOpt[String].getOrElse("")
// Apple doesn't provide username/avatar in id_token.
// User's name is only available on first auth (in the POST body, not token).
// We use email prefix as fallback username.
val username = email.takeWhile(_ != '@')
Right(ProviderUserInfo(sub, email, "", username))
}.toEither.left.map(e => s"Failed to parse Apple id_token: ${e.getMessage}").flatten
}
@@ -22,10 +22,11 @@ case class IncompleteClientText(
partialText: String,
requestedAfterHistoryCount: Int,
llmRequest: GeneratedTextRequest,
requestedAtMillis: Long
requestedAtMillis: Long,
lastUpdateAtMillis: Long
) extends ClientText {
def append(newText: String): IncompleteClientText =
copy(partialText = partialText + newText)
copy(partialText = partialText + newText, lastUpdateAtMillis = System.currentTimeMillis())
override def text: String = partialText
@@ -63,6 +63,7 @@ case class ClientTextStoreImpl(
unrequestedTexts
.get(id)
.map { unrequested =>
val now = System.currentTimeMillis()
copy(
incompleteTexts = incompleteTexts + (id ->
IncompleteClientText(
@@ -70,7 +71,8 @@ case class ClientTextStoreImpl(
partialText = "",
llmRequest = unrequested.llmRequest,
requestedAfterHistoryCount = unrequested.requestedAfterHistoryCount,
requestedAtMillis = System.currentTimeMillis()
requestedAtMillis = now,
lastUpdateAtMillis = now
)),
unrequestedTexts = unrequestedTexts - id,
incompleteTextsAreSaved = false
@@ -240,7 +242,8 @@ object ClientTextStoreImpl {
partialText = ict.text,
llmRequest = Some(ict.llmRequest),
requestedAfterHistoryCount = ict.requestedAfterHistoryCount,
requestedAtMillis = ict.requestedAtMillis
requestedAtMillis = ict.requestedAtMillis,
lastUpdateAtMillis = ict.lastUpdateAtMillis
)
}.toVector,
unrequestedTexts = completeSaved.unrequestedTexts.map {
@@ -328,6 +331,7 @@ object ClientTextStoreImpl {
.map { icts =>
(
icts.incompleteTexts.map { it =>
val now = System.currentTimeMillis()
IncompleteClientText(
id = it.id,
partialText = it.partialText,
@@ -337,7 +341,10 @@ object ClientTextStoreImpl {
// (for backwards compatibility with old persisted data)
requestedAtMillis =
if it.requestedAtMillis > 0 then it.requestedAtMillis
else System.currentTimeMillis()
else now,
lastUpdateAtMillis =
if it.lastUpdateAtMillis > 0 then it.lastUpdateAtMillis
else now
)
}.toVector,
icts.unrequestedTexts.map { it =>
@@ -128,13 +128,13 @@ scala_library(
exports = [
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/common:action_result_type_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/internal:game_state_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library:game_history",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:action_with_resulting_state",
"//src/main/scala/net/eagle0/eagle/model/action_result:notification_trait",
"//src/main/scala/net/eagle0/eagle/model/action_result/generated_text_request/chronicle_event",
"//src/main/scala/net/eagle0/eagle/model/action_result/types",
"//src/main/scala/net/eagle0/eagle/model/proto_converters:notification_converter",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/date",
"//src/main/scala/net/eagle0/eagle/model/state/date",
@@ -460,7 +460,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/model/action_result/changed_province/concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/concrete:action_result_concrete",
"//src/main/scala/net/eagle0/eagle/model/action_result/types",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/command/available:available_command_converter",
"//src/main/scala/net/eagle0/eagle/model/state:round_phase",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
@@ -1304,7 +1303,6 @@ scala_library(
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/common:province_order_type_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/common:more_option",
@@ -1319,7 +1317,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers:command_chooser",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/command/available:available_command_converter",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
@@ -1342,7 +1339,6 @@ scala_library(
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/common:functional_random",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/library/actions/applier:action_result_applier",
@@ -1354,7 +1350,6 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/util/command_choice_helpers",
"//src/main/scala/net/eagle0/eagle/library/util/province",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/command/available:available_command_converter",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/game_state",
"//src/main/scala/net/eagle0/eagle/model/state/province",
@@ -1,9 +1,9 @@
package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.eagle.common.action_result_type.ActionResultType.FACTION_DESTROYED
import net.eagle0.eagle.library.actions.impl.common.ActionWithResultingState
import net.eagle0.eagle.library.GameHistory
import net.eagle0.eagle.model.action_result.generated_text_request.chronicle_event.*
import net.eagle0.eagle.model.action_result.types.ActionResultType
import net.eagle0.eagle.model.action_result.NotificationDetails
import net.eagle0.eagle.model.proto_converters.date.DateConverter
import net.eagle0.eagle.model.proto_converters.NotificationConverter
@@ -319,8 +319,8 @@ object ChronicleEventGenerator {
private def basicActionResultEntries(
awrs: ActionWithResultingState
): Vector[ChronicleEvent] =
awrs.actionResult.`type` match {
case FACTION_DESTROYED =>
ActionResultType.fromValue(awrs.actionResult.`type`.value) match {
case ActionResultType.FactionDestroyed =>
val date = DateConverter.fromProto(awrs.gameState.currentDate)
awrs.actionResult.removedFactionIds.map { factionId =>
FactionDestroyedChronicleEvent(
@@ -11,7 +11,6 @@ import net.eagle0.eagle.model.action_result.changed_province.concrete.ChangedPro
import net.eagle0.eagle.model.action_result.concrete.ActionResultC
import net.eagle0.eagle.model.action_result.types.ActionResultType.EndHandleRiotsPhase
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.command.available.AvailableCommandConverter
import net.eagle0.eagle.model.state.command.available.OneProvinceAvailableCommands
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
@@ -39,19 +38,15 @@ case class EndHandleRiotsPhaseAction(
commandsForProvince(province.id).map { opac =>
seq.withRandomActionResults { (gs, fr) =>
val actingFactionId = province.rulingFactionId.get
val protoCommands = opac.commands.map { cmd =>
AvailableCommandConverter.toProto(cmd, gs, actingFactionId)
}
CommandChoiceHelpers
.handleRiotSelectedCommand(
actingFactionId = actingFactionId,
gameState = gs,
availableCommands = protoCommands,
availableCommands = opac.commands,
functionalRandom = fr
)
.continue {
case (Some(cs), nextFr) =>
val scs = cs.toScala(opac.commands)
case (Some(scs), nextFr) =>
val cmd = commandFactory.makeTCommand(
actingFactionId = province.rulingFactionId.get,
gameState = gs,
@@ -66,7 +61,7 @@ case class EndHandleRiotsPhaseAction(
case TCommand.Sequential(action) =>
RandomState(action.results, nextFr)
}
case (None, nextFr) =>
case (None, nextFr) =>
RandomState(Vector.empty[ActionResultT], nextFr)
}
}
@@ -2,7 +2,6 @@ package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, MoreOption, RandomState}
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.{AvailableCommand as ProtoAvailableCommand, RestAvailableCommand}
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.command.TCommandFactory
import net.eagle0.eagle.library.actions.impl.common.{ProtolessRandomSequentialResultsAction, TCommand}
@@ -11,8 +10,8 @@ import net.eagle0.eagle.library.util.command_choice_helpers.CommandChooser
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.command.available.AvailableCommandConverter
import net.eagle0.eagle.model.state.command.available.OneProvinceAvailableCommands
import net.eagle0.eagle.model.state.command.available.{AvailableCommand, OneProvinceAvailableCommands}
import net.eagle0.eagle.model.state.command.available.AvailableCommand.RestAvailable
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceOrderType.*
import net.eagle0.eagle.model.state.province.ProvinceT
@@ -47,11 +46,10 @@ case class PerformVassalCommandsPhaseAction(
case (sequencer, province) =>
commandsForProvince(province.id).map { opac =>
sequencer.withRandomActionResults { (gs, fr) =>
chooseCommand(gs, province.id, fr).continue {
case (Some(cs), nextFr) =>
val scs = cs.toScala(opac.commands)
chooseCommand(gs, province.id, opac.commands, fr).continue {
case (Some(scs), nextFr) =>
val cmd = commandFactory.makeTCommand(
actingFactionId = cs.actingFactionId,
actingFactionId = scs.actingFactionId,
gameState = gs,
availableCommand = scs.available,
selectedCommand = scs.selected
@@ -64,7 +62,7 @@ case class PerformVassalCommandsPhaseAction(
case TCommand.Sequential(action) =>
RandomState(action.results, nextFr)
}
case (None, nextFr) =>
case (None, nextFr) =>
RandomState(Vector.empty[ActionResultT], nextFr)
}
}
@@ -76,7 +74,7 @@ case class PerformVassalCommandsPhaseAction(
private def maybeRestCommand(
actingFactionId: FactionId,
gs: GameState,
commandOptions: Vector[ProtoAvailableCommand],
commandOptions: Vector[AvailableCommand],
provinceId: ProvinceId,
reason: String
): Option[CommandSelection] = {
@@ -84,7 +82,7 @@ case class PerformVassalCommandsPhaseAction(
gs.provinces(provinceId).rulingFactionHeroIds.map(gs.heroes)
MoreOption.flatWhen(
commandOptions.collectFirst {
case ac: RestAvailableCommand =>
case ac: RestAvailable =>
ac
}.isDefined && shouldRest(heroes)
) {
@@ -95,7 +93,7 @@ case class PerformVassalCommandsPhaseAction(
private def selectedCommandFromOrders(
actingFactionId: FactionId,
gs: GameState,
commandOptions: Vector[ProtoAvailableCommand],
commandOptions: Vector[AvailableCommand],
functionalRandom: FunctionalRandom,
provinceId: ProvinceId
): RandomState[Option[CommandSelection]] =
@@ -145,45 +143,42 @@ case class PerformVassalCommandsPhaseAction(
def chooseCommand(
gs: GameState,
provinceId: ProvinceId,
commands: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
commandsForProvince(provinceId).map { oneProvinceAvailableCommands =>
val actingFactionId = gs.provinces(provinceId).rulingFactionId.get
val protoCommands = oneProvinceAvailableCommands.commands.map { cmd =>
AvailableCommandConverter.toProto(cmd, gs, actingFactionId)
}
CommandChooser.choose(
actingFactionId,
gs,
protoCommands,
Vector[CommandChooser](
resolveTributeSelectedCommand,
defendSelectedCommand,
handleRiotSelectedCommand,
(
fid: FactionId,
gsC: GameState,
acs: Vector[ProtoAvailableCommand],
fr: FunctionalRandom
) =>
RandomState(
maybeRestCommand(
fid,
gsC,
acs,
provinceId,
"chosen vassal command: rest"
),
fr
): RandomState[Option[CommandSelection]] = {
val actingFactionId = gs.provinces(provinceId).rulingFactionId.get
CommandChooser.choose(
actingFactionId,
gs,
commands,
Vector[CommandChooser](
resolveTributeSelectedCommand,
defendSelectedCommand,
handleRiotSelectedCommand,
(
fid: FactionId,
gsC: GameState,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) =>
RandomState(
maybeRestCommand(
fid,
gsC,
acs,
provinceId,
"chosen vassal command: rest"
),
(
fid: FactionId,
gsC: GameState,
acs: Vector[ProtoAvailableCommand],
fr: FunctionalRandom
) => selectedCommandFromOrders(fid, gsC, acs, fr, provinceId)
),
functionalRandom
)
}.get
fr
),
(
fid: FactionId,
gsC: GameState,
acs: Vector[AvailableCommand],
fr: FunctionalRandom
) => selectedCommandFromOrders(fid, gsC, acs, fr, provinceId)
),
functionalRandom
)
}
}
@@ -1,7 +1,6 @@
package net.eagle0.eagle.library.actions.impl.action
import net.eagle0.common.{FunctionalRandom, RandomState}
import net.eagle0.eagle.api.available_command.AvailableCommand as ProtoAvailableCommand
import net.eagle0.eagle.library.actions.applier.ActionResultApplier
import net.eagle0.eagle.library.actions.impl.command.TCommandFactory
import net.eagle0.eagle.library.actions.impl.common.{ProtolessRandomSequentialResultsAction, TCommand}
@@ -10,8 +9,7 @@ import net.eagle0.eagle.library.util.command_choice_helpers.CommandChoiceHelpers
import net.eagle0.eagle.library.util.province.ProvinceUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.action_result.ActionResultT
import net.eagle0.eagle.model.proto_converters.command.available.AvailableCommandConverter
import net.eagle0.eagle.model.state.command.available.OneProvinceAvailableCommands
import net.eagle0.eagle.model.state.command.available.{AvailableCommand, OneProvinceAvailableCommands}
import net.eagle0.eagle.model.state.game_state.GameState
import net.eagle0.eagle.model.state.province.ProvinceT
import net.eagle0.eagle.ProvinceId
@@ -43,15 +41,10 @@ case class PerformVassalDefenseDecisionsAction(
case (sequencer, province) =>
commandsForProvince(province.id).map { opac =>
sequencer.withRandomActionResults { (gs, fr) =>
val actingFactionId = province.rulingFactionId.get
val protoCommands = opac.commands.map { cmd =>
AvailableCommandConverter.toProto(cmd, gs, actingFactionId)
}
chooseCommand(gs, province.id, protoCommands, fr).continue {
case (Some(cs), nextFr) =>
val scs = cs.toScala(opac.commands)
chooseCommand(gs, province.id, opac.commands, fr).continue {
case (Some(scs), nextFr) =>
val cmd = commandFactory.makeTCommand(
actingFactionId = cs.actingFactionId,
actingFactionId = scs.actingFactionId,
gameState = gs,
availableCommand = scs.available,
selectedCommand = scs.selected
@@ -64,7 +57,7 @@ case class PerformVassalDefenseDecisionsAction(
case TCommand.Sequential(action) =>
RandomState(action.results, nextFr)
}
case (None, nextFr) =>
case (None, nextFr) =>
RandomState(Vector.empty[ActionResultT], nextFr)
}
}
@@ -76,7 +69,7 @@ case class PerformVassalDefenseDecisionsAction(
private def chooseCommand(
gs: GameState,
provinceId: ProvinceId,
commandOptions: Vector[ProtoAvailableCommand],
commandOptions: Vector[AvailableCommand],
functionalRandom: FunctionalRandom
): RandomState[Option[CommandSelection]] =
CommandChoiceHelpers.resolveTributeSelectedCommand(
@@ -80,6 +80,10 @@ scala_library(
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_sequential_results_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:protoless_simple_action",
"//src/main/scala/net/eagle0/eagle/library/actions/impl/common:t_command",
"//src/main/scala/net/eagle0/eagle/library/settings:alliance_gold_cost",
"//src/main/scala/net/eagle0/eagle/library/settings:break_alliance_gold_cost",
"//src/main/scala/net/eagle0/eagle/library/settings:invite_gold_cost",
"//src/main/scala/net/eagle0/eagle/library/settings:truce_gold_cost",
"//src/main/scala/net/eagle0/eagle/library/util/faction_utils",
"//src/main/scala/net/eagle0/eagle/library/util/quest_fulfillment:quest_fulfillment_utils",
"//src/main/scala/net/eagle0/eagle/model/action_result:action_result_trait",
@@ -13,6 +13,7 @@ import net.eagle0.eagle.library.actions.impl.common.{
ProtolessSimpleAction,
TCommand
}
import net.eagle0.eagle.library.settings.{AllianceGoldCost, BreakAllianceGoldCost, InviteGoldCost, TruceGoldCost}
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.quest_fulfillment.QuestFulfillmentChecker
import net.eagle0.eagle.library.EagleCommandException
@@ -120,7 +121,7 @@ class CommandFactory extends TCommandFactory {
armedBattalions = sc.armedBattalions.map { ab =>
ArmTroopsCommand.ArmedBattalion(
id = ab.battalionId,
newArmament = ab.battalionTypeId.toDouble
newArmament = ab.newArmament.toDouble
)
},
actingProvince = gameState.provinces(ac.actingProvinceId),
@@ -780,11 +781,14 @@ class CommandFactory extends TCommandFactory {
targetFactionId: FactionId
): DiplomacyOption =
dot match {
// Using goldCost = 0 since availability factory already ensures options are affordable
case DiplomacyOptionType.Alliance => DiplomacyOption.AllianceOption(targetFactionId, goldCost = 0)
case DiplomacyOptionType.Truce => DiplomacyOption.TruceOption(targetFactionId, goldCost = 0)
case DiplomacyOptionType.Invitation => DiplomacyOption.InvitationOption(targetFactionId, goldCost = 0)
case DiplomacyOptionType.BreakAlliance => DiplomacyOption.BreakAllianceOption(targetFactionId, goldCost = 0)
case DiplomacyOptionType.Alliance =>
DiplomacyOption.AllianceOption(targetFactionId, goldCost = AllianceGoldCost.intValue)
case DiplomacyOptionType.Truce =>
DiplomacyOption.TruceOption(targetFactionId, goldCost = TruceGoldCost.intValue)
case DiplomacyOptionType.Invitation =>
DiplomacyOption.InvitationOption(targetFactionId, goldCost = InviteGoldCost.intValue)
case DiplomacyOptionType.BreakAlliance =>
DiplomacyOption.BreakAllianceOption(targetFactionId, goldCost = BreakAllianceGoldCost.intValue)
case DiplomacyOptionType.Ransom(details) =>
DiplomacyOption.RansomOfferOption(
targetFactionId = targetFactionId,
@@ -101,10 +101,7 @@ scala_library(
"//src/test/scala/net/eagle0/eagle:__subpackages__",
],
deps = [
"//src/main/protobuf/net/eagle0/eagle/api:available_command_scala_proto",
"//src/main/protobuf/net/eagle0/eagle/api:selected_command_scala_proto",
"//src/main/scala/net/eagle0/eagle:eagle_pkg",
"//src/main/scala/net/eagle0/eagle/model/proto_converters/command/selected:selected_command_converter",
"//src/main/scala/net/eagle0/eagle/model/state/command/available",
"//src/main/scala/net/eagle0/eagle/model/state/command/selected",
],
@@ -1,51 +1,16 @@
package net.eagle0.eagle.library.util
import net.eagle0.eagle.{FactionId, ProvinceId}
import net.eagle0.eagle.api.available_command.AvailableCommand as ProtoAvailableCommand
import net.eagle0.eagle.api.selected_command.SelectedCommand as ProtoSelectedCommand
import net.eagle0.eagle.model.proto_converters.command.selected.SelectedCommandConverter
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.selected.SelectedCommand
// Proto-based command selection (legacy, for gradual migration)
case class CommandSelection(
actingFactionId: FactionId,
actingProvinceId: ProvinceId,
available: ProtoAvailableCommand,
selected: ProtoSelectedCommand,
reason: String
) {
def withReason(reason: String): CommandSelection =
this.copy(reason = reason)
/** Convert to ScalaCommandSelection by finding the matching Scala AvailableCommand */
def toScala(scalaAvailableCommands: Iterable[AvailableCommand]): ScalaCommandSelection = {
val scalaSelectedCommand = SelectedCommandConverter.fromProto(selected)
val scalaAvailableCommand = scalaAvailableCommands
.find(_.commandType == scalaSelectedCommand.commandType)
.getOrElse(
throw new IllegalStateException(
s"No matching Scala available command for selected command: ${scalaSelectedCommand.commandType}"
)
)
ScalaCommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = actingProvinceId,
available = scalaAvailableCommand,
selected = scalaSelectedCommand,
reason = reason
)
}
}
// Scala-based command selection
case class ScalaCommandSelection(
actingFactionId: FactionId,
actingProvinceId: ProvinceId,
available: AvailableCommand,
selected: SelectedCommand,
reason: String
) {
def withReason(reason: String): ScalaCommandSelection =
def withReason(reason: String): CommandSelection =
this.copy(reason = reason)
}
@@ -1,12 +1,13 @@
package net.eagle0.eagle.library.util.command_choice_helpers
import net.eagle0.eagle.{FactionId, HeroId}
import net.eagle0.eagle.api.available_command.{AvailableCommand, DiplomacyAvailableCommand}
import net.eagle0.eagle.api.command.util.diplomacy_option.{AllianceOption, DiplomacyOption}
import net.eagle0.eagle.api.selected_command.DiplomacySelectedCommand
import net.eagle0.eagle.library.util.command_choice_helpers.AvailableCommandSelector.ofType
import net.eagle0.eagle.library.util.faction_utils.FactionUtils
import net.eagle0.eagle.library.util.CommandSelection
import net.eagle0.eagle.model.state.command.available.AvailableCommand
import net.eagle0.eagle.model.state.command.available.AvailableCommand.{DiplomacyAvailable, DiplomacyOption}
import net.eagle0.eagle.model.state.command.common.DiplomacyOptionType
import net.eagle0.eagle.model.state.command.selected.SelectedCommand.DiplomacySelected
import net.eagle0.eagle.model.state.game_state.GameState
object AllianceOfferCommandSelector {
@@ -16,13 +17,13 @@ object AllianceOfferCommandSelector {
): Vector[HeroId] =
hids.filterNot(FactionUtils.isFactionLeader(_, gameState.factions.values.toVector))
private def allianceOptionWithTarget(
private def hasAllianceOptionWithTarget(
options: Vector[DiplomacyOption],
targetFactionId: FactionId
): Option[AllianceOption] =
options.collectFirst {
case x @ AllianceOption(tfid, _, _ /* unknownFieldSet */ ) if tfid == targetFactionId =>
x
): Boolean =
options.exists { opt =>
opt.targetFactionId == targetFactionId &&
opt.optionTypes.contains(DiplomacyOptionType.Alliance)
}
def chosenAllianceWithFactionCommand(
@@ -31,26 +32,18 @@ object AllianceOfferCommandSelector {
availableCommands: Vector[AvailableCommand],
targetFactionId: FactionId
): Option[CommandSelection] =
ofType[DiplomacyAvailableCommand](availableCommands).find { ac =>
allianceOptionWithTarget(
ac.options.toVector,
targetFactionId
).isDefined && usableHeroIds(
ac.availableHeroIds.toVector,
gameState
).nonEmpty
ofType[DiplomacyAvailable](availableCommands).find { ac =>
hasAllianceOptionWithTarget(ac.options, targetFactionId) &&
usableHeroIds(ac.availableHeroIds, gameState).nonEmpty
}.map { ac =>
CommandSelection(
actingFactionId = actingFactionId,
actingProvinceId = ac.actingProvinceId,
available = ac,
selected = DiplomacySelectedCommand(
selectedOption = allianceOptionWithTarget(
ac.options.toVector,
targetFactionId
).get,
selected = DiplomacySelected(
selectedOption = DiplomacyOptionType.Alliance,
targetFactionId = targetFactionId,
sentHeroId = usableHeroIds(ac.availableHeroIds.toVector, gameState).maxBy { hid =>
sentHeroId = usableHeroIds(ac.availableHeroIds, gameState).maxBy { hid =>
gameState.heroes(hid).vigor
}
),

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